Spring/이론

[트러블슈팅] JPA 연관관계 매핑 vs Repository 패턴 - 어떤 게 더 안전할까?

JuNo_12 2025. 6. 4. 20:41

"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 조회
}

마이크로서비스 환경에서는 이런 방식이 더 적합할 수 있습니다.


결론: 단순함을 선택하자

핵심 원칙

  1. ManyToOne 정도만으로도 대부분 상황 해결 가능
  2. Repository 패턴으로 명시적 조회가 더 안전
  3. 복잡한 연관관계보다는 단순한 구조
  4. 성능 최적화는 문제가 생겼을 때 해결

 

실무에서의 패턴

// 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가지 전략이 있지만, 실무에서는 언제 써야 할까? 컴포지션이 더 나은 선택이 아닐까?

시리즈 목차

  1. 연관관계 매핑 vs Repository 패턴 ← 현재 글
  2. 상속관계 매핑 3가지 전략 - 실무에서는 언제 쓸까?
  3. 지연로딩과 Proxy - LazyInitializationException 해결하기
  4. Cascade와 트랜잭션 전파 - 편의성 vs 안전성

"개발자도 다 찾아가면서 한다"는 말을 실감했습니다. 완벽한 설계를 찾기보다는, 상황에 맞는 적절한 선택을 할 수 있는 판단력을 기르는 것이 더 중요하다고 한 번 더 느꼈습니다.