개요
DevBid의 상품 등록 페이지에서 카테고리 트리를 로드할 때 N+1 문제가 발생했다. 루트 카테고리 수만큼 추가 쿼리가 실행되어 총 9개의 SQL이 발생했고, 이를 3개로 줄이는 과정을 다룬다.
- 상품 등록 페이지 로딩 시 Category 트리 로딩 때문에 9개의 SQL 발생
- 재귀 DTO 변환에서
category.getChildren()접근 → LAZY 로 N+1
개선 결과:
- 쿼리 수: 9개 → 3개 (66% 감소)
- 적용 기술:
@BatchSize+ Fetch Join + 메모리 필터링
현재 쿼리 로그
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 발생)
원인분석
엔티티 구조
@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 변환 과정
@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
@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
@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent")
List<Category> findAllWithParent();
- parent는 JOIN되지만 children은 여전히
LAZY로딩
(해결) BatchSize + 전체 조회 + 메모리 필터링
@BatchSize(size = 100) // children 조회 시 IN절로 일괄 조회
@OneToMany(mappedBy = "parent")
private List<Category> children = new ArrayList<>();
- 배치 사이즈 추가
@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent")
List<Category> findAllWithParent();
- parent 페치 조인
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 기준으로 트리 재구성
결과 비교
쿼리 수 감소
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이 효과적
@Query("SELECT c FROM Category c JOIN FETCH c.parent")
- 1:N (일대다): Fetch Join으로 해결 어려움
- 컬렉션 Fetch Join은 페이징 불가
- 카테시안 곱 발생 가능
BatchSize의 실용성
@BatchSize(size = 100)
@OneToMany(mappedBy = "parent")
private List children;
장점:
- 설정 간단 (어노테이션 하나)
- IN절로 일괄 조회 (쿼리 수 대폭 감소)
- 메모리 효율적 (필요할 때만 로딩)
언제 사용?
- 계층형 구조 (카테고리, 댓글, 조직도)
- 1:N 관계에서 N+1 문제
- 컬렉션 조회가 빈번한 경우
계층형 데이터 처리 패턴
1. 전체 데이터 조회 (Fetch Join으로 parent 포함)
2. BatchSize로 children 일괄 로딩
3. 메모리에서 트리 구성 (stream filter)
자기 참조 관계 설계 원칙:
- N:1 양방향 관계로 설계
- 적용 사례: 카테고리, 댓글/대댓글, 조직도
결론
계층 구조에서는 N+1 문제를 완전히 피할 수 없다. 하지만 @BatchSize를 사용하면 개별 쿼리를 IN절 일괄 조회로 변환할 수 있다.
이번 리팩터링을 통해:
- Fetch Join의 한계 체감
- BatchSize의 실용성 확인
- 계층형 데이터 처리 패턴 학습
일대다 관계 최적화에는 @BatchSize가 가장 실무적이고 안정적인 선택이다.
···