[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.