9osari.log
← 목록으로

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

개요

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

개선 결과:

현재 쿼리 로그

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

분석:


원인분석

엔티티 구조

@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 +
                '}';
    }
}

핵심 문제:


시도한 해결 방법

parent Fetch Join

@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent WHERE c.level =
1")
List<Category> findByLevel(Long level);

전체조회 + parent Fetch Join

@Query("SELECT c FROM Category c LEFT JOIN FETCH c.parent")
List<Category> findAllWithParent();

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

결과 비교

쿼리 수 감소

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=[]}]}]}]
9개 → 3개 (66% 감소)

핵심 학습 내용

Fetch Join의 한계

  @Query("SELECT c FROM Category c JOIN FETCH c.parent")

BatchSize의 실용성

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

장점:

언제 사용?

계층형 데이터 처리 패턴

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

자기 참조 관계 설계 원칙:


결론

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

이번 리팩터링을 통해:

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

···
← PREV [DevBid - HTTP] Form 환경에서 RESTful 설계 NEXT → [DevBid - AWS] S3 파일업로드