Post

[리팩토링] 카테고리 트리 N+1 최적화

[리팩토링] 카테고리 트리 N+1 최적화

현재 문제점

상품 등록 페이지 로딩 시 카테고리 트리 로딩 때문에 N+1 문제가 발생했다.

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

현재 로그

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=[]}]}]}]

  • perent_id별 children 조회가 반복

현재코드

컨트롤러

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/product/new")
public String newProduct(@AuthenticationPrincipal AuthUser authUser, Model model) {
    User user = userService.findById(authUser.getId());
    model.addAttribute("user", user);

    List<CategoryDto> categoryDtoList = categoryService.getCategoryTree(1L);
    System.out.println("categoryDtoList: " + categoryDtoList.toString());
    model.addAttribute("categoryDtoList", categoryDtoList);

    return "product/productRegister";
}

엔티티

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<>();
}

서비스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class CategoryApplicationService implements CategoryService {
    private final CategoryRepository categoryRepository;

    public CategoryApplicationService(CategoryRepository categoryRepository) {
        this.categoryRepository = categoryRepository;
    }

    @Override
    public List<CategoryDto> getCategoryTree(Long parentId) {
        List<Category> categories = categoryRepository.findByLevel(parentId);
        return categories.stream().map(CategoryDto::of).collect(Collectors.toList());
    }
}

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();
        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을 개별 조회 즉, 루트 카테고리 수만큼 추가 쿼리 실행

시도한 해결 방법

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)에 유용하지만, 컬렉션(1:N)에는 한계가 있다.
  • 일대다 관계 최적화에는 @BatchSize가 가장 실용적이다.
  • 계층형 구조(Category, 댓글, 조직도)는 전체 조회 후 메모리 트리 구성 패턴이 일반적이다.
자기참조 관계는 거의 항상 N:1 양방향으로 설계 (카테고리, 댓글, 대댓글, 조직도 등)

계층 구조에서는 결국 N+1 문제를 메모리 조립이나 BatchSize로 제어해야 한다. 이번 리팩터링으로 Fetch Join의 한계를 직접 체감했고, BatchSize가 단순하면서도 실무적으로 가장 안정적인 선택임을 확인했다.

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