[트러블슈팅] JPA 연관관계 매핑 vs Repository 패턴 - 어떤 게 더 안전할까?
"JPA 고급 기능, 정말 써야 할까?" 시리즈 1편
들어가며
스프링 부트 강의 2주차를 들으며 JPA 연관관계 매핑을 배웠습니다. @OneToMany, @ManyToOne, @OneToOne, @ManyToMany... 다양한 어노테이션들과 함께 객체 간의 관계를 매핑하는 방법을 익혔습니다.
하지만 학습하면서 계속 드는 의문이 있었습니다.
"이렇게 서로 참조하면 의존성이 생기는 거 아닌가?"
실무에서도 정말 이런 식으로 연관관계를 사용할까? Repository 패턴으로 필요시 조회하는 게 더 안전하지 않을까? 이런 고민을 하게 되었습니다.
연관관계 매핑의 기본 개념
전형적인 연관관계 매핑
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private int amount;
@ManyToOne
private User user;
}
언뜻 보면 깔끔해 보입니다. User는 여러 Order를 가지고, Order는 하나의 User에 속하고, 객체지향적으로도 자연스러워 보입니다.
실제 사용하는 모습
// 사용자 조회
User user = userRepository.findById(1L);
// 해당 사용자의 주문들 조회
List<Order> orders = user.getOrders(); // 연관관계 활용
// 특정 주문의 사용자 정보 조회
Order order = orderRepository.findById(100L);
String userName = order.getUser().getName(); // 연관관계 활용
코드만 보면 직관적이고 사용하기 편해 보입니다.
연관관계 매핑의 문제점들
1. 순환 참조와 복잡한 의존성
User ↔ Order // 서로 알고 있음
- User가 변경되면 Order도 영향받을 수 있음
- Order가 변경되면 User도 영향받을 수 있음
- 객체 그래프가 복잡해짐
- JSON 직렬화 시 무한 루프 위험
2. 예측하기 어려운 쿼리 발생
User user = userRepository.findById(1L);
List<Order> orders = user.getOrders(); // 언제 쿼리가 나갈까?
지연로딩으로 설정했다면:
- user.getOrders() 호출 시점에 추가 쿼리 발생
- 몇 개의 Order가 조회될지 예측 어려움
- N+1 문제 발생 가능
즉시로딩으로 설정했다면:
- User 조회 시마다 항상 Order도 함께 조회
- 불필요한 데이터까지 로딩하여 성능 저하
3. 트랜잭션 경계 문제
@Transactional
public User getUser(Long id) {
return userRepository.findById(id); // Order는 지연로딩
}
// 컨트롤러에서
User user = userService.getUser(1L); // 트랜잭션 종료
List<Order> orders = user.getOrders(); // 💥 LazyInitializationException!
지연로딩의 함정에 빠지기 쉽습니다.
4. 테스트의 복잡성
@Test
public void testUser() {
User user = new User("김개발");
// Order 객체들도 함께 설정해야 제대로 테스트 가능
Order order1 = new Order(10000);
Order order2 = new Order(20000);
user.getOrders().add(order1);
user.getOrders().add(order2);
order1.setUser(user); // 양방향 관계 동기화
order2.setUser(user); // 양방향 관계 동기화
// 복잡한 설정...
}
Repository 패턴의 대안
단방향 관계만 유지
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private int amount;
@ManyToOne(fetch = FetchType.LAZY)
private User user; // Order → User 단방향만
}
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
// Order와의 연관관계 없음!
}
User 엔티티에는 Order 정보가 없고, 깔끔하고 단순합니다.
Repository로 필요시 조회
@Service
public class OrderService {
// 사용자의 주문 목록이 필요할 때
public List<Order> getUserOrders(Long userId) {
return orderRepository.findByUserId(userId); // 명시적 조회
}
// 페이징이 필요할 때
public Page<Order> getUserOrdersWithPaging(Long userId, Pageable pageable) {
return orderRepository.findByUserId(userId, pageable);
}
// 특정 조건의 주문만 필요할 때
public List<Order> getRecentUserOrders(Long userId) {
return orderRepository.findByUserIdAndCreatedAtAfter(
userId, LocalDateTime.now().minusDays(30)
);
}
}
필요한 만큼만, 원하는 방식으로 조회할 수 있습니다.
Repository 패턴의 장점
1. 명시적이고 예측 가능한 쿼리
// 언제 어떤 쿼리가 나갈지 명확함
List<Order> orders = orderRepository.findByUserId(userId);
// vs 연관관계에서는
user.getOrders(); // 언제 쿼리가 나갈지 모호함
2. 관심사의 분리
// Order 도메인은 User의 변경에 영향받지 않음
@Entity
public class Order {
private Long userId; // 단순한 식별자만 참조
// Order의 비즈니스 로직에만 집중 가능
public void cancel() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("취소할 수 없는 주문입니다");
}
this.status = OrderStatus.CANCELLED;
}
}
3. 제어 가능한 의존성
OrderService → OrderRepository // 명확한 단방향 의존성
OrderService → UserRepository // 필요시에만 의존
// vs 연관관계에서는
Order ↔ User // 양방향 의존성, 복잡함
의존성의 방향이 명확하고, 인터페이스를 통해 추상화되어 있어 테스트하기도 쉽다.
4. 유연한 조회 전략
// 다양한 조회 방법을 Repository에서 제공
orderRepository.findByUserId(userId);
orderRepository.findByUserIdWithPaging(userId, pageable);
orderRepository.findByUserIdAndStatus(userId, OrderStatus.COMPLETED);
orderRepository.findRecentOrdersByUserId(userId);
// 필요시 JOIN FETCH로 성능 최적화
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.userId = :userId")
List<Order> findByUserIdWithUser(@Param("userId") Long userId);
상황에 맞는 최적의 조회 전략을 선택할 수 있습니다.
실무에서의 선택 기준
ManyToOne은 괜찮다?
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private User user; // 이 정도는 자연스럽고 안전함
}
이유:
- 단방향 관계라 순환참조 위험 없음
- N+1 문제 해결 쉬움 (JOIN FETCH 활용)
- 직관적: "주문은 사용자를 알아야 한다"
OneToMany와 양방향은 신중하게
// 이런 관계는 정말 필요한지 고민해보자
@OneToMany(mappedBy = "user")
private List<Order> orders;
대안:
- Repository 패턴으로 필요시 조회
- DTO 조회로 성능 최적화
- 애그리거트 경계 재검토
ID 참조도 고려하기
@Entity
public class Order {
private Long userId; // 객체 참조 대신 ID만 저장
// 필요할 때만 Repository로 User 조회
}
마이크로서비스 환경에서는 이런 방식이 더 적합할 수 있습니다.
결론: 단순함을 선택하자
핵심 원칙
- ManyToOne 정도만으로도 대부분 상황 해결 가능
- Repository 패턴으로 명시적 조회가 더 안전
- 복잡한 연관관계보다는 단순한 구조
- 성능 최적화는 문제가 생겼을 때 해결
실무에서의 패턴
// 90% 상황에서 이 조합이면 충분
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private User user; // 필요한 참조만 단방향으로
}
// 역방향은 Repository로
public class OrderService {
public List<Order> getUserOrders(Long userId) {
return orderRepository.findByUserId(userId);
}
}
"할 수 있다"와 "해야 한다"는 다릅니다. JPA가 복잡한 연관관계를 지원한다고 해서 반드시 써야 하는 건 아니고,
단순하고 명확한 설계가 장기적으로 더 가치 있다고 생각합니다.
다음 편 예고
다음 편에서는 JPA 상속관계 매핑에 대해 다뤄보겠다. SINGLE_TABLE, JOINED, TABLE_PER_CLASS... 3가지 전략이 있지만, 실무에서는 언제 써야 할까? 컴포지션이 더 나은 선택이 아닐까?
시리즈 목차
- 연관관계 매핑 vs Repository 패턴 ← 현재 글
- 상속관계 매핑 3가지 전략 - 실무에서는 언제 쓸까?
- 지연로딩과 Proxy - LazyInitializationException 해결하기
- Cascade와 트랜잭션 전파 - 편의성 vs 안전성
"개발자도 다 찾아가면서 한다"는 말을 실감했습니다. 완벽한 설계를 찾기보다는, 상황에 맞는 적절한 선택을 할 수 있는 판단력을 기르는 것이 더 중요하다고 한 번 더 느꼈습니다.