Post

[DevBid - JPA] 카테고리 트리 N+1 최적화

[DevBid - JPA] 카테고리 트리 N+1 최적화

개요

DevBid의 상품 등록 페이지에서 카테고리 트리를 로드할 때 N+1 문제가 발생했다. 루트 카테고리 수만큼 추가 쿼리가 실행되어 총 9개의 SQL이 발생했고, 이를 3개로 줄이는 과정을 다룬다.

  • 상품 등록 페이지 로딩 시 Category 트리 로딩 때문에 9개의 SQL 발생
  • 재귀 DTO 변환에서 category.getChildren() 접근 → LAZY 로 N+1

개선 결과:

  • 쿼리 수: 9개 → 3개 (66% 감소)
  • 적용 기술: @BatchSize + Fetch Join + 메모리 필터링

현재 쿼리 로그

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
2025-10-11 16:06:16.488 [INFO ] [http-nio-3000-exec-3] p6spy - 
2025-10-11 16:06:16.491 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 0 ms
SQL    :
SELECT c1_0.id,
    c1_0.level,
    c1_0.name,
    c1_0.parent_id 
FROM categories c1_0 
WHERE c1_0.level=1
────────────────────────────────────────────

2025-10-11 16:06:16.494 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 1 ms
SQL    :
SELECT c1_0.parent_id,
    c1_0.id,
    c1_0.level,
    c1_0.name 
FROM categories c1_0 
WHERE c1_0.parent_id=1
────────────────────────────────────────────

...

2025-10-11 16:06:16.502 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 0 ms
SQL    :
SELECT c1_0.parent_id,
    c1_0.id,
    c1_0.level,
    c1_0.name 
FROM categories c1_0 
WHERE c1_0.parent_id=5
────────────────────────────────────────────

2025-10-11 16:06:16.503 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 0 ms
SQL    :
SELECT c1_0.parent_id,
    c1_0.id,
    c1_0.level,
    c1_0.name 
FROM categories c1_0 
WHERE c1_0.parent_id=8
────────────────────────────────────────────

categoryDtoList: [CategoryDto{id=1, name='전자기기', children=[CategoryDto{id=3, name='컴퓨터', children=[CategoryDto{id=6, name='노트북', children=[]}, CategoryDto{id=7, name='데스크탑', children=[]}]}, CategoryDto{id=4, name='스마트폰', children=[]}]}, CategoryDto{id=2, name='의류', children=[CategoryDto{id=5, name='상의', children=[CategoryDto{id=8, name='티셔츠', children=[]}]}]}]

분석:

  • 첫 번째 쿼리: level=1인 루트 카테고리 조회
  • 이후 8개 쿼리: 각 parent_id별 children 조회 (N+1 발생)

원인분석

엔티티 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Table(name = "categories")
@Getter
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(name = "level")
    private int level;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> children = new ArrayList<>();
}

DTO 변환 과정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Getter
public class CategoryDto {
    private Long id;
    private String name;
    private List<CategoryDto> children;

    public CategoryDto(Long id, String name, List<CategoryDto> children) {
        this.id = id;
        this.name = name;
        this.children = children;
    }

    public static CategoryDto of(Category category) {
        List<Category> children = category.getChildren(); // ← 여기서 LAZY 로딩 발생!
        return new CategoryDto(
                category.getId(),
                category.getName(),
                children.stream()
                        .map(CategoryDto::of)   //자기자신 호출 (재귀)
                        .collect(Collectors.toList())
        );
    }

    @Override
    public String toString() {
        return "CategoryDto{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", children=" + children +
                '}';
    }
}

핵심 문제:

  • @OneToMany(fetch = LAZY)는 각 parent마다 children을 개별 조회
  • 재귀 DTO 변환에서 category.getChildren() 접근 시 추가 쿼리 발생
  • 트리 depth가 깊을수록 쿼리 수는 기하급수적 증가

시도한 해결 방법

parent Fetch Join

1
2
3
@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent WHERE c.level =
1")
List<Category> findByLevel(Long level);
  • 1 depth → 2 depth만 JOIN, 3 depth는 여전히 개별 쿼리

전체조회 + parent Fetch Join

1
2
@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent")
List<Category> findAllWithParent();
  • parent는 JOIN되지만 children은 여전히 LAZY 로딩

(해결) BatchSize + 전체 조회 + 메모리 필터링

1
2
3
@BatchSize(size = 100)  // children 조회 시 IN절로 일괄 조회
@OneToMany(mappedBy = "parent")
private List<Category> children = new ArrayList<>();
  • 배치 사이즈 추가
1
2
@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent")
List<Category> findAllWithParent();
  • parent 페치 조인
1
2
3
4
5
6
7
8
9
10
11
public List<CategoryDto> getCategoryTree() {
      List<Category> allCategories = categoryRepository.findAllWithParent();

      List<Category> rootCategories = allCategories.stream()
              .filter(c -> c.getParent() == null)  // parent_id IS NULL
              .toList();

      return rootCategories.stream()
              .map(CategoryDto::of)
              .collect(Collectors.toList());
  }
  • parent는 Fetch Join으로 묶고, children은 BatchSize로 IN절 조회
  • 메모리에서 parent_id 기준으로 트리 재구성

결과 비교

쿼리 수 감소

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2025-10-11 16:37:59.487 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 0 ms
SQL    :
SELECT id,
    created_at,
    email,
    nickname,
    password,
    phone,
    status,
    updated_at,
    username 
FROM users 
WHERE id=10000001
────────────────────────────────────────────

2025-10-11 16:37:59.488 [INFO ] [http-nio-3000-exec-3] p6spy - 
2025-10-11 16:37:59.491 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 0 ms
SQL    :
SELECT c1_0.id,
    c1_0.level,
    c1_0.name,
    p1_0.id,
    p1_0.level,
    p1_0.name,
    p1_0.parent_id 
FROM categories c1_0 
LEFT JOIN categories p1_0 
  ON p1_0.id=c1_0.parent_id
────────────────────────────────────────────

2025-10-11 16:37:59.494 [INFO ] [http-nio-3000-exec-3] p6spy - 
── SQL DEBUG ───────────────────────────────
Method : ProductController.newProduct()
Time   : 0 ms
SQL    :
SELECT c1_0.parent_id,
    c1_0.id,
    c1_0.level,
    c1_0.name 
FROM categories c1_0 
WHERE c1_0.parent_id in (1,2,3,4,5,6,7,8,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL)
────────────────────────────────────────────

categoryDtoList: [CategoryDto{id=1, name='전자기기', children=[CategoryDto{id=3, name='컴퓨터', children=[CategoryDto{id=6, name='노트북', children=[]}, CategoryDto{id=7, name='데스크탑', children=[]}]}, CategoryDto{id=4, name='스마트폰', children=[]}]}, CategoryDto{id=2, name='의류', children=[CategoryDto{id=5, name='상의', children=[CategoryDto{id=8, name='티셔츠', children=[]}]}]}]

  • User 조회 SELECT * FROM users WHERE id=10000001
  • 전체 카테고리 + parent JOIN SELECT c1_0., p1_0. FROM categories c1_0 LEFT JOIN categories p1_0 ON p1_0.id=c1_0.parent_id
  • BatchSize로 children 일괄 조회 SELECT c1_0.* FROM categories c1_0 WHERE c1_0.parent_id IN (1,2,3,4,5,6,7,8,…)
9개 → 3개 (66% 감소)

핵심 학습 내용

Fetch Join의 한계

  • N:1 (다대일): Fetch Join이 효과적
    1
    
    @Query("SELECT c FROM Category c JOIN FETCH c.parent")
    
  • 1:N (일대다): Fetch Join으로 해결 어려움
    • 컬렉션 Fetch Join은 페이징 불가
    • 카테시안 곱 발생 가능

BatchSize의 실용성

1
2
3
@BatchSize(size = 100)
@OneToMany(mappedBy = "parent")
private List children;

장점:

  • 설정 간단 (어노테이션 하나)
  • IN절로 일괄 조회 (쿼리 수 대폭 감소)
  • 메모리 효율적 (필요할 때만 로딩)

언제 사용?

  • 계층형 구조 (카테고리, 댓글, 조직도)
  • 1:N 관계에서 N+1 문제
  • 컬렉션 조회가 빈번한 경우

계층형 데이터 처리 패턴

1
2
3
1. 전체 데이터 조회 (Fetch Join으로 parent 포함)
2. BatchSize로 children 일괄 로딩
3. 메모리에서 트리 구성 (stream filter)

자기 참조 관계 설계 원칙:

  • N:1 양방향 관계로 설계
  • 적용 사례: 카테고리, 댓글/대댓글, 조직도

결론

계층 구조에서는 N+1 문제를 완전히 피할 수 없다. 하지만 @BatchSize를 사용하면 개별 쿼리를 IN절 일괄 조회로 변환할 수 있다.

이번 리팩터링을 통해:

  • Fetch Join의 한계 체감
  • BatchSize의 실용성 확인
  • 계층형 데이터 처리 패턴 학습

일대다 관계 최적화에는 @BatchSize가 가장 실무적이고 안정적인 선택이다.

This post is licensed under CC BY 4.0 by the author.