Light Blue Pointer
본문 바로가기
TIL(Develop)

N+1문제를 몰라서 면접 망하고 정리하는 N+1 문제 총정리

by Greedynamite 2026. 3. 4.

 

슬프게도 면접에서 혼자 N+1에 대해 헛소리를 해버린 관계로

기초를 다지고자 N+1 문제가 무엇이고 어떻게 해결할 수 있는지 정리해 보았다

내가 한 스프링 프로젝트에서는 JPA를 잘 안 사용했고, 복잡한 통계 쿼리가 많았어서 그냥 JPQL이나 QueryDsl로 SQL 짜듯이 다 되어있었다.

아래는 내 면접이 N+1로 망한 일화이다

JPA를 많이 사용하는 회사 같아서 써봤다고 했는데
N+1 문제에 대해서 언급할 것도 없어서
생각나는 JPA 튜닝은 아래 내용을 한 것뿐이라 아래 내용을 말했는데
다시 보니 N+1 문제가 아니고 메모리 문제 정도 될 것 같다 하하...

내가 하고 있던 프로젝트에서 존재 유무를 판별하는데 exists를 쓰지 않고 다 findBy를 쓰고 있었는데, 
실제로 찍어보니 entity를 로딩하고 영속화하는 불필요한 과정이 발생하는 것을 알 수 있었다.

// 기존 코드 (문제)
Optional<User> user = userRepository.findById(id);
if (user.isPresent()) { ... }
// → findById가 엔티티까지 다 가져옴

나의 해결 방안
// 개선 후
boolean exists = userRepository.existsById(id);
// EXISTS 서브쿼리로 존재 여부만 확인, 연관 엔티티 로딩 없음

- findById
    - 엔티티를 영속성 컨텍스트에 올리기 위해 `SELECT *`를 날림
    - 연관관계 설정에 따라 추가 쿼리(N+1)의 위험
- exists
    - DB 내부적으로 `limit 1`을 사용하여 존재 여부만 체크하고 끝
    - 메모리 점유 없음
    - 연관 객체 그래프를 탐색할 일도 없음

위의 사례는 N+1 문제와는 전혀 관련조차 없다는 것을 알게 되었다.

그럼 진짜 N+1은 무슨 문제이고 어떻게 해결할 수 있는지 알아보자~

N+1은 왜 발생할까?

N+1 문제의 본질은 객체 그래프관계형 DB라는 두 세계의 충돌이다.

1970년에 Codd가 관계형 모델을 만들었고

1990년대에 사람들이 객체지향을 만들었고

그 사이에서 개발자가 번역 작업을 하는 것을 ORM으로 자동화하려는데 서로 잘 맞지 않는 부분이 있어 부작용이 발생하는 것…

객체 그래프SQL이 서로 완벽하게 호환되지 않는 문제로 발생하게 된다.

다음 코드를 살펴보자.

List<Order> orders = orderRepository.findAll();

이것의 실제 의미는 다음과 같다.

SELECT o FROM Order o

여기에는 items를 가져오라는 말이 명시되어 있지 않아서 Hibernate은 기본적으로 Order만 가져온다.

그리고 이후 코드에서

order.getItems()

가 호출되는 순간 items를 가져오는 쿼리를 또 날린다.

이건 근본적인 문제라서 FetchType에 상관 없이 발생하게 된다.

JPA에서 연관된 엔티티를 Lazy Loading을 해도 N+1 문제는 발생하고

@OneToMany(fetch = FetchType.LAZY) // default
private List<Item> items;
List<Order> orders = orderRepository.findAll(); 
// 쿼리 1번: SELECT * FROM orders

for (Order order : orders) {
    order.getItems(); 
    // 여기서 접근하는 순간 추가 쿼리 발생
    // SELECT * FROM items WHERE order_id = 1
    // SELECT * FROM items WHERE order_id = 2
    // SELECT * FROM items WHERE order_id = 3
    // ... N번
}

Eager Loading으로 바꿔도 해결이 되지 않는다 (오히려 더 나빠짐)

처음에 모든 연관된 엔티티를 Eager하게 가져오게 되면 order.getItems() 접근시마다 쿼리가 N개 추가로 발생하는 현상이 없어지지 않을까? 하고 생각하기 쉽다.

@OneToMany(fetch = FetchType.EAGER) // 이렇게 하면?

하지만 문제는 JPA spec은 JOIN을 보장하지 않는다는 것이다.

Hibernate는 보통 이렇게 한다.

SELECT * FROM orders
SELECT * FROM items WHERE order_id=1
SELECT * FROM items WHERE order_id=2
...

EAGER이면 무조건 로딩, LAZY면 접근 시 로딩만 보장할 뿐

근본적으로는 하나의 쿼리로 다 가져오는 JOIN 로딩을 하는 것이 아니라서

N+1 문제는 FetchType에 관계없이 발생하고 EAGER이 더 위험하다…

Eager Loading이 Lazy Loading보다 더 위험한 이유

Lazy Loading는 실제로 order.getItems()를 호출하는 코드가 없으면 추가 쿼리가 나가지 않지만,

Eager Loading은 findAll() 하는 순간 무조건 연관 엔티티를 다 로딩한다.

  • 쓰지도 않을 데이터까지 항상 가져오면서 불필요한 쿼리가 더 많이 발생
  • 연관관계가 깊게 중첩되어 있는 경우 (Order → items → product → category) EAGER가 연쇄적으로 퍼져나가서 예상 못 한 쿼리가 한 번에 폭발적으로 나가는 문제 발생

해결 1. fetch join

fetch join은 JPA에게 객체 그래프를 SQL JOIN으로 변환하라고 시키는 과정이다.

@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();
SELECT o.*, i.* 
FROM orders o 
INNER JOIN items i ON i.order_id = o.id

DB 레벨에서 JOIN으로 한 번에 다 가져오니까 추가 쿼리가 나가지 않게 된다.

JPA가 결과를 받아서 Order 안에 items를 채워넣는다.

Hibernate는 내부적으로 ResultSet → Entity Graph 로 재구성하는 작업을 한다.

hydration (object materialization)

DB 결과가 다음과 같다면

Order1 ItemA
Order1 ItemB
Order1 ItemC

Hibernate가 다음과 같이 재구성한다.

Order1
  items=[A,B,C]

이것을 hydration 또는 object materialization 이라고 부른다.

fetch join의 한계

  • 페이징도 메모리에서 처리해서 위험하다
  • 컬렉션 fetch join을 두 개 이상 쓰면 MultipleBagFetchException이 발생

fetch join이 페이징에서 OOM 발생을 유발할 수 있는 이유

@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAll(Pageable pageable);

이걸 실행하면 Hibernate가 이런 경고를 날린다.

"HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"

문제는 DBrow 기준으로 생각하는데 Hibernate가 이걸 객체로 재조립할 수 없다는 것이다.

예를 들어서 다음과 같이 Order 하나당 4개씩의 item이 있다고 하자

Order1 items 4
Order2 items 4
Order3 items 4
  • DB가 하고싶은 것 (row 기준 limit 10)
limit 10
  • fetch join 결과 → Cartesian 곱이라 총 15 rows가 나오게 된다
Order1 items 1
Order1 items 2
Order1 items 3
Order1 items 4
Order2 items 1
Order2 items 2
Order2 items 3
Order2 items 4
Order3 items 1
Order3 items 2
Order3 items 3
Order3 items 4
  • 이 join 결과에서 DB 방식대로 limit 10을 하게 되면 다음과 같이 되는데
Order1 items 1
Order1 items 2
Order1 items 3
Order1 items 4
Order2 items 1
Order2 items 2
Order2 items 3
Order2 items 4
Order3 items 1
Order3 items 2
-------------- limit 10 이라 중간에서 잘림
Order3 items 3
Order3 items 4
  • Hibernate는 Order 3의 데이터가 완전하지 않기 때문에 이걸 객체로 재조립할 수 없다고 판단한다.
    • 10번째 Row가 Order 3의 두 번째 Item
    • Hibernate는 Order 3에 아이템이 더 있을 것 같은데 어디까지가 끝인지 알 수 없는 상태
    • 데이터의 무결성을 보장할 수 없어짐
  • Hibernate는 엔티티 그래프를 완전하게 구성해야 한다는 철학이 있어서 limit, offset을 무시하고 DB에서 전체 데이터를 다 가져와서(Full Scan) 메모리에 올려놓고 자르는 방식을 택함
  • 이때 전체 데이터의 사이즈가 크다면 OOM이 발생하게 된다!

컬렉션 fetch join 두 개 이상이면 왜 에러가 발생할까?

Hibernate 구현상 제약이 있기 때문이다.

@Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.coupons")
List<Order> findAll();
// → MultipleBagFetchException 발생(Hibernate)

Bag이 뭘까?

  • 인덱스/유니크 제약 없는 중복 허용 컬렉션
  • DB에 순서 정보가 없으면(인덱스가 없으면) + 중복을 허용하면 그냥 막 담는 bag(봉투)이랑 마찬가지라는 뜻
    • Plastic bag…

List는 Bag이다

Hibernate에서 기본 List는

  • 인덱스 없음
  • 정렬 컬럼 없음
  • 중복 허용

DB에 순서 정보도 없고 중복도 허용해서 그냥 bag(봉투)임

Hibernate Collection Type

타입 특징
Bag 순서 없음, 중복 허용 (기본 List)
Indexed List 순서 있음, 중복 허용 (@OrderColumn)
Set 순서 없음, 중복 불허
Map 키-값 구조

bag 컬렉션을 2개 이상 fetch join 하면 결과가 카르테시안 곱으로 원본 데이터보다 늘어나게 됨

  • Order가 items 3개, coupons 2개 → JOIN 결과가 3×2=6행
    • 상황
      • Order 1개
      • items = [A, B, C] (3개)
      • coupons = [X, Y] (2개)
    • 쿼리
    SELECT o.*, i.*, c.*
    FROM orders o
    JOIN items i ON i.order_id= o.id
    JOIN coupons c ON c.order_id= o.id
    • DB는 객체 개념을 몰라서 그냥 조인한다.
      Order ID Item (A, B, C) Coupon (X, Y) 문제
      1 Item A Coupon X  
      1 Item A Coupon Y Item A 중복 발생!
      1 Item B Coupon X Item B 중복 발생!
      1 Item B Coupon Y  
      1 Item C Coupon X  
      1 Item C Coupon Y  
  • Hibernate 입장에서 생각하기
    • Hibernate는 이걸 읽어서 다음과 같이 만들어야 한다
    • Order{ items= [A,B,C] coupons= [X,Y] }
    • 그런데 실제 DB 결과
      • A가 2번 등장
      • B가 2번 등장
      • C가 2번 등장
      • X가 3번 등장
      • Y가 3번 등장
      items = [A, A, B, B, C, C]
      coupons = [X, Y, X, Y, X, Y]
      
    • 이 상태에서 Hibernate의 고민
      • 중복 제거를 해야 하나?
      • 제거하면 순서는?
      • List는 중복 허용인데 중복을 제거해도 되는건가?
      • 이 중복이 진짜 DB 데이터 중복인지, 조인 때문에 생긴 가짜 중복인지 어떻게 구분하지?

Hibernate는 이걸 중복 제거하고 재구성하면서도 bag의 의미(중복 허용, 순서 등)를 보존하기가 애매해서 MultipleBagFetchException 예외로 막아버린 것이다.

Indexed List는 Bag이 아님 → MultipleBagFetchException 발생 X

@OrderColumn이 붙으면 그때는 indexed list라서 MultipleBagFetchException이 발생하지 않는다.

→ Hibernate가 중복 제거하면서 순서가 안 헷갈리기 때문이다

→ 근데 List 안에 중복인지는 어떻게 판별할까?

  • @OrderColumn을 사용하면 DB 테이블에 item_idx 같은 순서 저장용 컬럼이 추가됨
    • Order 1 에 Item A(index 0), Item B(index 1)가 있고, Coupon X(index 0), Coupon Y(index 1)가 있다고 가정
    • 두 컬렉션을 동시에 조인
      Order_ID Item_Name Item_Idx Coupon_Name Coupon_Idx
      1 Item A 0 Coupon X 0
      1 Item A 0 Coupon Y 1
      1 Item B 1 Coupon X 0
      1 Item B 1 Coupon Y 1
    • Hibernate의 판별 로직
      • 결과를 읽으면서 Item_Idx를 확인
      • 0번 인덱스에 Item A가 있으면 items[0]에 A 넣기
      • 다음 줄을 보니 또 Item_Idx가 0이고 Item A
      • 이미 items[0]은 A로 채웠으니 이건 조인 때문에 생긴 가짜 중복이라 판단하고 무시
      • 그다음 줄에 Item_Idx가 1이고 Item B
      • items[1]에 B 넣기
      Index라는 Key가 생겨서 똑같은 데이터가 여러 번 나타나도 이 위치에는 이 데이터가 하나만 있어야 한다는 것을 알 수 있기 때문에 중복을 걸러낼 수 있어진다.

해결 1. List → Set으로 바꾸기

하지만 순서가 중요한 컬렉션이면 위험.

// 1. List → Set으로 바꾸기
@OneToMany
private Set<Item> items; // Set은 중복 허용 안 하니까 JPA가 처리 가능

Set

  • 중복 허용 안 함
  • equals,hashCode 기준으로 dedupe(de-duplicate) 가능

그래서 위 6행을 읽어도 Hibernate가 이건 의미적으로 안전하다고 판단 가능해서

Set<Item>= {A,B,C}
Set<Coupon>= {X,Y}

으로 안정적으로 만들 수 있기 때문에 에러를 발생시키지 않는다.

해결 2. fetch join 하나만 쓰고 나머지는 @BatchSize로

// 2. fetch join 하나만 쓰고 나머지는 @BatchSize로
@BatchSize(size = 100)
@OneToMany
private List<Coupon> coupons;

→ 아래에서 추가로 설명

해결 3: DTO로 플랫하게 받고 애플리케이션에서 그룹핑

SELECT o.id, i.id, c.id

데이터를 받아 Map으로 묶어서 Hibernate 제약을 우회하는 방식.

→ 아래에서 추가로 설명

fetch join은 INNER JOIN 뿐만 아니라 LEFT OUTER JOIN도 옵션으로 가능하다

// INNER JOIN (기본 fetch join)
// items 없는 Order는 결과에서 사라짐
"SELECT o FROM Order o JOIN FETCH o.items"

// LEFT OUTER JOIN fetch join
// items 없어도 Order는 나옴
"SELECT o FROM Order o LEFT JOIN FETCH o.items"

해결 2. @EntityGraph

@EntityGraph(attributePaths = {"items"})
List<Order> findAll();

JPQL대신 Annotation으로 하는 fetch join으로, fetch join과 동작 방식이 완전히 같다.

내부적으로 LEFT OUTER JOIN으로 한 번에 가져오게 되어있다.

이유: fetch join은 개발자가 직접 JOIN FETCH, LEFT JOIN FETCH 중 무엇을 할지 골라서 설정하지만 EntityGraph는 자동 생성이기 때문에 데이터 유실을 방지하기 위해 LEFT OUTER JOIN을 기본으로 하도록 되어있다.

@EntityGraph의 한계 = fetch join의 한계

  • 페이징도 메모리에서 처리해서 위험하다
  • 컬렉션 fetch join을 두 개 이상 쓰면 MultipleBagFetchException이 발생

@EntityGraph의 기본값이 LEFT OUTER JOIN인 이유

fetch join(INNER JOIN)은 연관 엔티티가 없으면 부모도 조회되지 않아서 데이터 유실이 발생한다.

EntityGraph는 연관 엔티티가 없어도 부모는 나올 수 있도록 안전하게 LEFT OUTER JOIN을 기본값으로 쓴다.

  • fetch join (INNER JOIN): items 없는 Order는 결과에서 사라짐
SELECT o., i. FROM orders o INNER JOIN items i ON i.order_id = o.id
  • EntityGraph (LEFT OUTER): items 없어도 Order는 나옴
SELECT o., i. FROM orders o LEFT OUTER JOIN items i ON i.order_id = o.id

이름이 EntityGraph인 이유

엔티티들의 연관관계를 그래프로 표현해서 어디까지 로딩할지 경로를 지정하는 개념이라고 함.

@EntityGraph(attributePaths = {"items", "items.product"})
// Order → items → product 까지 한 번에 로딩하는 그래프를 그리는 것의 개념

해결 3. @BatchSize

@BatchSize(size = 100)
@OneToMany
private List<Item> items;

Lazy Loading 자체는 유지하지만 쿼리를 묶어서 날리는 방식이다.

- 기존: N번 개별 쿼리

SELECT  FROM items 
WHERE order_id = 1
SELECT  FROM items 
WHERE order_id = 2...

- BatchSize 적용 후: IN절로 묶어서 몇 번으로 줄임

SELECT  FROM items WHERE order_id IN (1, 2, 3, ... 100)

orders가 1000개라면 쿼리가 10번으로 줄게 된다.

order 개수 / size 번 만큼의 쿼리만 추가된다.

완전히 1번만 할 수 있게 되는 해결책은 아니지만 실용적인 해결책이다.

@BatchSize의 장점

  • fetch join과 달리 페이징이랑 같이 써도 안전함. (큰 장점!)
    • 쿼리 횟수 1 + 1 또는 데이터 양에 따라 1 + N/BatchSize로 고정
    • 성능이 매우 안정적
  • 1:N 관계에서도 페이징이 완벽하게 유지됨
    • 루트 엔티티인 Order만 먼저 페이징해서 가져오기 때문
    • fetch join은 1:N에서 JOIN한 결과가 row 기준으로 불어나서 limit을 order 기준으로 걸 수 없어서 Hibernate가 전체를 메모리로 끌어올려서 자름 = 1:N 관계의 페이징을 DB단에서 컨트롤 불가능

BatchSize 페이징 내부 동작

  • Spring
// 페이징은 Order만 대상으로 
Page<Order> orders = orderRepository.findAll(pageable);
  • 실제 동작하는 쿼리
-- 쿼리 1단계(1번): Order만 페이징 (DB에서 정확하게 잘림) 
SELECT * FROM orders LIMIT 10 OFFSET 0 

// items는 BatchSize가 알아서 IN절로 묶어서 가져옴 
-- 쿼리 2단계(1번): 위에서 가져온 10개의 id로 IN절 
SELECT * FROM items WHERE order_id IN (1,2,3,...10)

Order 페이징 1번 + items BatchSize 1번 = 총 2번의 쿼리가 발생

JOIN을 전혀 안 하기 때문에 1단계에서 DB가 깔끔하게 Order 10개만 반환하고,

row가 불어날 일이 없으니 페이징이 정확하게 동작하게 된다.

BatchSize 설정 방법

  • 방법 A: 엔티티에 직접 설정
    • 특정 컬렉션이나 클래스에만 적용하고 싶을 때 사용
    @Entity
    public class Order {
        @BatchSize(size = 100) // 이 설정이 있어야 IN절이 나간다!
        @OneToMany(mappedBy = "order")
        private List<OrderItem> items = new ArrayList<>();
    }
    
  • 방법 B: 글로벌 설정 (권장)
    • 프로젝트 전체에 기본적으로 적용하는 방식
    • application.yml에 설정하여 모든 일대다, 다대다 관계에 일괄 적용
    spring:
      jpa:
        properties:
          hibernate:
            default_batch_fetch_size: 100
    

BatchSize Paging의 JPA 내부 동작

  1. Proxy 객체 생성
    • orderRepository.findAll(pageable)을 호출
    • Order 리스트만 가져옴
    • items는 프록시 상태로 둠
  2. 지연 로딩 트리거
    • 루프를 돌거나 JSON으로 변환하는 과정에서 데이터에 접근하는 순간 지연 로딩이 발생
      • order.getItems().size() 
  3. Persistence Context 확인
    • Hibernate가 현재 영속성 컨텍스트에 로드된 다른 Order 객체들도 함께 살펴본다.
  4. IN 절 생성
    • BatchSize 설정이 되어 있다면
    • 지금 메모리에 Order가 10개 있는데 items도 다 필요할 것 같다고 판단하여
    • IN (1, 2, ..., 10) 쿼리를 날린다.

4. DTO Projection (DTO에 매핑하여 직접 조회)

DTO로 flat하게 받고 애플리케이션에서 그룹핑하여(Map으로 묶어서) Hibernate 제약을 우회하는 방식.

QueryDSL 예시

  1. Flat한 구조의 DTO 정의
    @Data
    @AllArgsConstructor
    public class OrderFlatDto {
        private Long orderId;
        private String orderName;
        private Long itemId;
        private String itemName;
        private Long couponId;
        private String couponName;
    }
    
  2. 우선 DB에서 넘어오는 6행(3×2)의 데이터를 담을 임시 DTO 정의
  3. 쿼리 실행 및 그룹핑
    public List<OrderResponseDto> findAllByGrouping() {
        // 1. DB에서 조인된 결과(Flat)를 그대로 가져옴
        List<OrderFlatDto> flatDtos = queryFactory
            .select(new QOrderFlatDto(
                order.id, order.name, item.id, item.name, coupon.id, coupon.name))
            .from(order)
            .leftJoin(order.items, item)
            .leftJoin(order.coupons, coupon)
            .fetch();
    
        // 2. 메모리(Map)에서 그룹핑 및 중복 제거
        return flatDtos.stream()
            .collect(Collectors.groupingBy(
                // OrderId를 기준으로 묶음
                o -> new OrderResponseDto(o.getOrderId(), o.getOrderName()),
                Collectors.collectingAndThen(Collectors.toList(), list -> {
                    // 한 Order 안에 매칭된 Item과 Coupon들을 중복 없이 추출
                    Set<ItemDto> items = list.stream()
                        .filter(o -> o.getItemId() != null)
                        .map(o -> new ItemDto(o.getItemId(), o.getItemName()))
                        .collect(Collectors.toSet());
    
                    Set<CouponDto> coupons = list.stream()
                        .filter(o -> o.getCouponId() != null)
                        .map(o -> new CouponDto(o.getCouponId(), o.getCouponName()))
                        .collect(Collectors.toSet());
    
                    return new ResultContainer(items, coupons);
                })
            )).entrySet().stream()
            .map(e -> {
                OrderResponseDto dto = e.getKey();
                dto.setItems(new ArrayList<>(e.getValue().getItems()));
                dto.setCoupons(new ArrayList<>(e.getValue().getCoupons()));
                return dto;
            })
            .collect(Collectors.toList());
    }
    
  4. transform과 groupBy 기능을 사용하여 메모리에서 객체 그래프를 재조립

JPQL 예시

@Query("SELECT new com.example.OrderDto(o.id, u.name) 
        FROM Order o JOIN o.user u")
List<OrderDto> findAllDto();

연관 엔티티 객체 자체를 안 만들고, 필요한 필드만 뽑아서 DTO로 바로 만들어버린다.

엔티티를 안 쓰니까 Lazy Loading 자체가 발생할 일이 없어진다.

장점

  • 딱 1번의 쿼리
    • BatchSize(1+1)보다도 쿼리 횟수 면에서는 더 이득
  • 메모리 효율성, GC 부하 감소
    • 영속성 컨텍스트에 엔티티를 보관하지 않음(1차 캐시 생략)
    • Dirty Checking을 위한 스냅샷 데이터도 만들지 않음
  • 빠른 응답 속도
    • GC 부하가 감소했기 때문
  • 네트워크 대역폭과 DB I/O 소모 최소화
    • select *가 아니라, DTO에 정의된 필요한 컬럼만 조회
  • 빠른 속도
    • ORM의 느린 오버헤드를 다 건너뛰어서 빠름
    • DTO Projection: ResultSet → DTO 생성자 호출 → 결과 반환
    • Entity 조회: ResultSet → Entity 인스턴스화 → Hydration (필드 매핑) → 영속성 컨텍스트(1차 캐시) 등록 → 스냅샷 생성(Dirty Checking용) → Proxy/Collection Wrapper 처리 → 결과 반환

    단계 설명 DTO Projection 시
    Hydration DB의 Row 데이터를 객체의 필드에 채워넣는 작업. 이 과정에서 타입 변환 및 매핑 로직이 수행됨. 최소화. 필요한 필드만 직접 매핑.
    Dirty Tracking 변경 감지를 위해 조회 시점의 상태를 별도 공간(스냅샷)에 복사해 두는 작업. 생략. 변경 감지 대상이 아님.
    Persistence Context 1차 캐시에 객체를 등록하고 식별자(ID)로 관리하는 과정. 생략. 컨텍스트가 관리하지 않음.
    Proxy Generation 연관된 엔티티를 지연 로딩(Lazy Loading)하기 위해 가짜 객체를 생성하는 비용. 생략. 순수 Java 객체(POJO)로
    Collection Wrapper 자식 엔티티들을 관리하기 위해 Java 컬렉션을 Hibernate 전용 컬렉션으로 감싸는 작업. 생략.
  • 제약 우회(MultipleBagFetchException)
    • 로직으로 직접 중복을 걸러내니까 MultipleBagFetchException을 걱정할 필요가 전혀 없음

단점

  1. 애플리케이션 부하
  2. 데이터가 너무 많으면 Java 메모리 내에서 Stream 돌리고 Map으로 묶는 과정이 CPU와 메모리를 많이 차지한다.
  3. 페이징 불가
  4. 여전히 DB에서는 데이터가 조인으로 불어난 상태이므로, DB 레벨의 페이징(Limit/Offset)이 불가능하다
  5. 코드 복잡도
    • 코드가 많아진다
    • 엔티티 변경 시 DTO도 같이 수정해야 한다
    • 유지보수 난이도가 급격히 상승…

의문: DTO Projection을 하면 JPA 쓰는 의미가 있을까?

DTO Projection을 하면 JPA를 사용하는 대부분의 의미를 잃지만,

JPA의 장점 중 일부는 그대로 가져가게 된다

JPA 쓰는 이유

  • 객체지향적으로 DB 다루기
  • 연관관계 편하게 탐색
  • 같은 트랜잭션 안에서 엔티티 변경이 일어나는 부분 dirty checking 작동으로 감지 자동화

JPQL이나 Querydsl을 이용한 DTO 조인은 이 장점들을 다 포기하고 JPA 안 쓰는 것과 다름이 없다!

하지만 다른 데에서는 JPA를 쓰고 해당되는 경우에는 안 쓰려고 그냥 JPA의 탈로 쓴다고 한다.

  • 조회 성능이 중요한 API → DTO 직접 조회(대규모 조회 API)
  • 비즈니스 로직에서 상태 변경이 필요한 경우 → 엔티티로 조회

남은 JPA의 장점

  • 조회 전용 성능 최적화
    • 읽기 전용 API에서는 Dirty Checking이 필요 없으니 오버헤드를 줄이는 것이 더 이득
  • 생 JDBC를 쓰면 긴 문자열로 쿼리를 짜야 함
  • 컴파일 타임에 타입 체크가 가능(Type Safe)
    • 생 JDBC를 쓰면 rs.getString(1) 처럼 인덱스나 문자열에 의존하지만, Querydsl 등을 사용하면 컴파일 타임에 오류를 잡을 수 있다.
  • 가독성이 좋음
  • 인프라 통합
    • 프로젝트 전체가 JPA/Querydsl 설정 위에서 돌아가는데, 특정 쿼리만 굳이 JDBC Template등 다른 것을 사용하여 설정을 파편화 할 필요가 없다

권장 사항

  1. 최우선: ToOne 관계는 fetch join / ToMany 관계는 BatchSize (가장 깔끔하고 페이징도 안전)
  2. 차선: 컬렉션 하나만 fetch join 하고 나머지는 BatchSize
  3. 특수 상황: 페이징이 필요 없고, 쿼리 한 번으로 모든 데이터를 가져와야만 하는 극한의 조회 성능이 필요할 때만 DTO Grouping 사용

실무 팁: 상황별 해결책 판단 기준

Case 1: ToOne , 페이징 없음

→ fetch join

Case 2: ToMany

→ @BatchSize

Case 3: 페이징 필요

→ @BatchSize

Case 4: 컬렉션 fetch join 두 개 이상 필요

→ @BatchSize

Case 5: 조회 성능 극한으로 뽑아야 함

페이징이 필요 없고, 쿼리 한 번으로 모든 데이터를 가져와야만 하는 극한의 조회 성능이 필요할 때

→ DTO 직접 조회

BatchSize가 실무에서 가장 많이 쓰이는 이유

SELECT o FROM Order o
JOIN FETCH o.user
hibernate.default_batch_fetch_size=100

이렇게 하면

  • orders 조회 1번
  • items 조회 1번

총 2번만 쿼리를 날리게 된다.

대부분의 서비스에서 다음을 모두 달성 가능한 균형잡힌 솔루션이라 널리 쓰이는 것 같다.

  • 성능
  • 페이징
  • 복잡도

많은 회사에서 글로벌 설정을 두어 관리한다고 한다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

이거 하나만 넣어도 80%의 N+1이 사라지게 된다!

Hibernate 내부에서 proxy batch loading을 자동으로 수행하기 때문이다.

Fun Fact: N+1 문제는 JPA만의 문제가 아니다.

과연 JPA에만 이런 이슈가 있을까..해서 더 찾아봤는데

GraphQL, Prisma, Sequelize, Django ORM도 전부 같은 문제를 겪는다고 한다!

그래서 GraphQL 세계에서는 아예 DataLoader 라는 라이브러리가 등장했다고 한다.

BatchSize와 같은 철학으로 batch loading을 하는 라이브러리라고 한다…

동일한 문제라 해결책도 비슷했다는 것을 알 수 있다