JPA를 사용하면서 연관관계 매핑, 특히 성능 문제와 마주하는 것은 모든 개발자의 숙명과 같다. N+1 문제부터 시작해 Fetch Join, Batch Size 등 다양한 해결책이 있지만, 각 전략의 동작 원리와 한계를 명확히 이해하지 못하면 또 다른 문제에 부딪히기 쉽다. 이 글은 JPA 연관관계 설계를 하면서 겪었던 여러 오해와 그 해소 과정을 정리한 것이다.
1. 관계 모델링의 기초: 왜 다대일(N:1) 관계인가?
가장 기본적인 선수(Player)와 팀(Team)의 관계부터 시작한다. 이 관계를 모델링할 때 흔히 일대다(1:N)인지 다대일(N:1)인지 혼동하곤 한다.
명확한 기준은 '하나의 엔티티가 상대 엔티티를 오직 하나만 가질 수 있는가'이다.
- 하나의 선수는 오직 하나의 팀에만 소속된다.
- 하나의 팀은 여러 선수를 가질 수 있다.
이 명제를 통해 이 관계는 선수(N)와 팀(1)의 다대일 관계임이 명확해진다.
그렇다면 외래 키(FK)는 어디에 두어야 하는가? 정답은 항상 '다(N)' 쪽인 선수 테이블이다. 만약 '일(1)' 쪽인 팀 테이블에 FK를 둔다면, 한 팀에 속한 여러 선수를 표현하기 위해 player_id_1, player_id_2... 와 같이 컬럼을 무한정 늘려야 하는, 확장성이 전혀 없는 잘못된 설계가 된다. '다' 쪽인 선수 테이블에 team_id FK를 두어야만 데이터 중복 없이 깔끔한 관계를 표현할 수 있다.
2. 단방향 vs 양방향: 무엇을 선택해야 하는가?
JPA에서 관계를 매핑할 때는 단방향과 양방향 중 선택해야 한다. 선택의 기준은 오직 비즈니스 로직상 반대 방향으로의 객체 그래프 탐색이 필요한가이다.
2.1. 단방향 관계로 충분한 경우
게시글(Post)과 댓글(Comment) 관계가 대표적이다. 댓글 객체에서는 자신이 어떤 게시글에 속하는지 알아야 하므로 comment.getPost()는 필요하다. 하지만 게시글 객체에서 댓글 목록을 직접 들고 있을 필요는 없다. 댓글 목록이 필요하다면 CommentRepository를 통해 조회하면 그만이다.
2.2. 양방향 관계가 유용한 경우
팀(Team)과 선수(Player) 관계는 양방향이 유용할 수 있다. 팀 객체에서 소속 선수 명단을 바로 team.getPlayers()로 가져와서 비즈니스 로직을 처리하는 것이 객체지향적이고 편리할 수 있다. 모든 관계는 단방향으로 시작하고, 반대 방향 탐색이 꼭 필요한 명확한 근거가 있을 때만 양방향을 추가하는 것이 좋다.
3. 내가 헷갈렸던 지점 1: 댓글 조회와 양방향의 함정
나는 여기서 첫 번째 오해를 했다. "사용자 흐름상 게시글을 볼 때 댓글도 함께 보니, 게시글과 댓글은 양방향으로 매핑해야 하지 않는가?" 라는 생각이었다.
이는 UI/UX 관점에서는 타당하지만, 데이터 관점에서는 위험한 생각이다. 팀-선수 관계와 달리, 게시글-댓글 관계는 댓글의 수가 잠재적으로 수만, 수십만 개에 이를 수 있다. 만약 게시글 객체에 댓글 목록을 양방향으로 매핑하면, 게시글 조회 시 수만 개의 댓글이 메모리에 로드되는 심각한 성능 문제를 야기할 수 있다.
따라서 댓글처럼 개수가 예측 불가능하게 많은 컬렉션은 양방향으로 매핑하지 않는 것이 원칙이다.
4. N+1 문제와 두 번째 오해: Fetch Join과 Paging
컬렉션을 조회할 때 발생하는 N+1 문제는 JPA 성능의 주적이다. 이를 해결하는 가장 유명한 방법이 Fetch Join이다. Fetch Join은 연관된 엔티티를 한 번의 JOIN 쿼리로 모두 가져와 N+1 문제를 해결한다.
나는 여기서 두 번째 오해를 했다. "Fetch Join으로 N+1 문제를 해결하고, Paging을 적용해 성능까지 잡으면 완벽하지 않은가?"
이는 불가능하다. Fetch Join은 컬렉션과 조인하면서 DB 결과 행의 수를 '뻥튀기' 시킨다. 이 상태에서 DB에 LIMIT을 적용하면, 우리가 원하는 엔티티 기준이 아닌 뻥튀기된 행 기준으로 잘려버려 데이터가 유실된다. 결국 컬렉션에 대한 Fetch Join과 페이징은 함께 사용할 수 없다.
5. 가장 현실적인 해결책: Batch Size
Fetch Join과 페이징의 딜레마를 해결하는 가장 현실적인 방법은 @BatchSize 어노테이션(또는 글로벌 설정)을 사용하는 것이다. 이 전략은 FetchType.LAZY를 유지하면서 N+1 문제를 해결한다.
동작 방식은 다음과 같다.
- 먼저 Post 엔티티를 페이징으로 조회한다 (쿼리 1회).
- 이후 post.getComments()처럼 LAZY 로딩이 동작할 때, JPA는 한 건씩 조회하는 대신 ID를 모아 IN 절 쿼리를 통해 Batch Size만큼의 Post에 대한 Comment를 한 번에 가져온다 (쿼리 1회 추가).
이로써 단 2번의 쿼리로 N+1 문제와 페이징 문제를 모두 해결하는 것처럼 보인다. 여기서 나는 마지막 오해를 했다. "Batch Size를 100으로 설정하면, 댓글이 1,000개여도 100개만 가져오는 것 아닌가?"
이는 batch_size의 역할을 완전히 잘못 이해한 것이다. batch_size는 가져오는 데이터의 개수(LIMIT)를 제한하는 기능이 아니다. batch_size는 쿼리의 횟수를 줄여주는 최적화 기능일 뿐이다. 즉, **최대 100개의 '게시글'**에 대한 댓글 조회를 한 번의 쿼리로 처리해준다는 의미이지, 하나의 게시글에서 '댓글'을 100개만 가져온다는 의미가 절대 아니다.
따라서 batch_size를 사용하더라도 특정 게시글에 댓글이 1,000개 있다면, 그 1,000개의 댓글은 모두 메모리에 로드된다.
6. fetch join에 대한 오해
컬렉션(@OneToMany)에 대한 fetch join은 JPA 성능 튜닝에서 가장 자주 언급되는 주제 중 하나이다. 하지만 그만큼 오해도 많다. fetch join은 무조건 피해야 할 기술이 아니라, 명확한 장단점을 이해하고 적재적소에 사용해야 하는 강력한 도구인 것이다.
6.1. fetch join이 유리한 경우 👍
fetch join은 페이징과 관련 없는 단일 엔티티를 조회하면서, 그에 속한 컬렉션을 모두 가져와야 할 때 가장 이상적인 해결책이다.
이것이 빛을 발하는 대표적인 상황은 특정 주문(Order) 1건과 그에 속한 모든 주문 상품(OrderItems) 목록을 함께 조회해야 하는 경우이다.
- 페이징이 필요 없다: 여러 주문 목록이 아닌, 특정 ID를 가진 단 하나의 주문만 조회하는 것이다.
- 컬렉션의 크기가 예측 가능하다: 하나의 주문에 담기는 상품의 수는 수십 개 수준으로, 수만 개를 넘을 수 있는 댓글과는 다르다.
- 데이터가 함께 필요하다: 주문 총액을 계산하는 등, 주문과 주문 상품은 하나의 비즈니스 로직을 위해 함께 사용될 가능성이 높다.
이러한 상황에서는 아래와 같이 fetch join을 사용하여 N+1 문제없이 단 한 번의 쿼리로 연관된 모든 데이터를 완벽하게 조회할 수 있다.
// OrderRepository.java
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.id = :orderId")
Optional<Order> findByIdWithOrderItems(@Param("orderId") Long orderId);
이것이 fetch join의 가장 교과서적이고 올바른 사용법이다.
6.2. fetch join이 불리한 경우 👎
반면, 아래 상황에서는 fetch join이 거의 항상 문제를 일으키므로 사용하지 않아야 한다.
- 부모 엔티티를 페이징 처리할 때 fetch join은 데이터베이스 결과 행의 수를 '뻥튀기' 시키기 때문에, DB 레벨의 페이징(LIMIT, OFFSET)이 불가능해진다. 이는 결국 모든 데이터를 메모리에 올린 후 페이징하는 심각한 성능 문제를 야기한다. 게시글 목록(List<Post>)을 조회하는 경우가 대표적인 예이다.
- 하나의 엔티티에 둘 이상의 컬렉션을 fetch join할 때 하나의 게시글(Post)에 댓글(List<Comment>)과 태그(List<Tag>)가 모두 있을 때, 두 컬렉션을 동시에 fetch join하면 **카테시안 곱(Cartesian Product)**이 발생한다. 댓글이 10개, 태그가 5개라면 DB 결과는 10 * 5 = 50개의 행이 되어 데이터가 과도하게 중복 생성된다.
- 컬렉션의 데이터가 예측 불가능하게 클 때 페이징이 없는 단건 조회라 할지라도, 조인하려는 컬렉션의 크기가 매우 클 수 있다면(예: 인기 게시글의 댓글 10만 개), fetch join을 사용하는 순간 메모리 초과(OutOfMemoryError)로 애플리케이션이 중단될 수 있다.
6.3. 결론: Fetch Join 사용 전 체크리스트
컬렉션에 fetch join을 사용하기 전 아래 질문에 답해보아야 한다.
- 페이징을 사용하고 있는가? → 아니오
- 조인하려는 컬렉션의 크기가 매우 클 수 있는가? → 아니오
- 둘 이상의 컬렉션을 동시에 조인해야 하는가? → 아니오
위 세 가지 질문에 모두 '아니오'라고 답할 수 있을 때, fetch join은 N+1 문제를 해결하는 훌륭하고 직관적인 선택이 될 수 있다. 그 외의 경우에는 batch_size를 사용하거나 쿼리를 분리하는 것이 훨씬 안전하고 현명한 방법이다.
'Spring > JPA' 카테고리의 다른 글
| [Practice-6] 단뱡향/양방향 선택 기준 (⭐⭐⭐) (0) | 2025.12.25 |
|---|---|
| [Practice-5] 일대다/다대일 관계 정의 (0) | 2025.12.16 |
| [Practice-4] Spring Data: 무한 깊이 조회 패턴 (댓글/대댓글, 카테고리) (0) | 2025.12.16 |
| [Practice-3] Spring Data: Enum 고도화 (0) | 2025.12.16 |
| [Practice-2] 연관관계 편의 메서드 (1) | 2025.08.31 |