
기존에 구현한 카테고리 로직에서는 N+1 문제, 재귀적 구조의 한계, 조회 효율성 저하 같은 문제점들이 있었다.
그래서 이번 글에서는 카테고리 리팩토링 과정을 담아보도록 하겠다.
1. 기존 구조의 문제점
1.1 인접 리스트 모델(Adjacency List Model)
기존 구조는 인접 리스트 모델로, Category
엔티티 내에 자기 자신을 참조하는 CategoryParent
, CategoryChildren
필드를 통해 재귀적으로 자기 자신을 참조했다.
1.2 N+1 문제 발생
readAllCategory()
메서드에서 부모 카테고리를 조회한 뒤, 각 카테고리의 자식들을 DTO로 변환하는 과정에서 지연 로딩으로 인해 매번 추가 쿼리가 발생했다.
여기서는 @BatchSize
설정으로도 최적화할 수 없다.
그 이유는 Hibernate는 동일한 필드의 프록시 객체들이 비슷한 시점에 여러 개 접근될 때만 배치 쿼리를 IN(...)
형식으로 보낸다.
하지만 재귀적으로 자식 카테고리를 순차적으로 탐색하기 때문에 Hibernate는 프록시들을 묶을 수 없고 쿼리를 하나씩 날리게 된다.
쉽게 말하면 루트 노드가 실행한 getCategoryChildren()
가 부르는 필드와 그 필드에서 또 호출하는 getCategoryChildren()
의 필드는 다른 프록시라서 @BatchSize
로 묶이지 않는다.
아래의 예시와 함께 살펴보자.
A
├── A1
│ ├── A11
│ └── A12
├── A2
│ ├── A21
│ └── A22
B
├── B1
└── B2
C
├── C1
└── C2
- A,B,C는
findAllByCategoryParentIsNull()
로 한 번에 조회 - A1, A2는 A의
categoryChildren
프록시 객체에서 조회되므로 한 번 - B1, B2는 B의 프록시에서, C1, C2는 C의 프록시에서 각각 한 번씩 조회
- A11와 A12, A21와 A22도 마찬가지로 각각 한 번씩 조회
이처럼 프록시 객체가 부모마다 다르기 때문에 Hibernate는 IN
쿼리를 만들 수 없고, 결국 부모 1개당 자식 쿼리 1개씩 총 6번의 쿼리가 발생한다.
그래서 N+1문제를 해결할 수 없다.
1.3 트리 경로 조회 어려움
IT > Backend > SpringBoot
처럼 조상 경로를 표현하려면 깊이를 모르는 상태에서 재귀 CTE를 사용하거나 JPA에서 반복 쿼리를 실행해야 했다.
즉, 경로 조회 성능도 좋지 않았고 구현도 번거로웠다.
2. 구조 리팩토링: 클로저 테이블 + 인접 리스트 혼합 모델
계층 구조 테이블을 구성하는 방식에는 여러 가지가 존재한다.
각 방식의 비교 내용은 계층 구조 테이블 설계 방식 비교 글에 정리해두었다.
여기에 실제 적용한 것은 클로저테이블과 인접리스트를 혼합한 모델이다.
2.1 새로운 테이블 구조
- 인접 리스트 테이블 : 기존처럼
parent_id
를 유지함. 트리 구성 편의성을 위해 유지 - 클로저 테이블 : 모든 조상-자손 관계를 미리 저장하여 트리 경로 조회를 쉽게 조회할 수 있게 함
- 정렬 필드: 정렬 순서 보장을 위해 추가
parent_id를 제거할 수도 있지만, 유지하면 전체 트리 구성 시 JOIN 없이도 간편하게 구성 가능하다.
2.2 계층 구조 모델 선정 기준
전체 카테고리를 한 번에 가져와서 애플리케이션에서 트리 구조로 만드는 작업이라면 사실 어떤 계층 구조 모델을 사용해도 가능하다.
하지만 경로 조회는 클로저 테이블이 가장 적합하기 때문에 기존 구조에 클로저 테이블을 섞은 혼합 모델로 구현하게 되었다.
각 계층 구조 방식의 비교는 계층 구조 테이블 설계 방식 비교 글에서 확인해보길 바란다.
3. 조회 방식 리팩토링
3.1 트리 구성 로직 : CategoryReader
트리형태로 변경해주는 중요한 클래스는 CategoryReader
에 구현했기 때문에 이 코드로만 설명을 진행해보겠다.
3.2 주요 메서드 설명
- readTree : 지정된 루트를 기준으로 전체 카테고리를 트리 형태로 조회하는 메서드
- readAncestors : 특정 카테고리의 모든 조상 카테고리 경로 조회 (ex.
IT > BackEnd > SpringBoot
) - createNodeMap : 루트부터 순서대로, 갚은 깊이에서는 우선순위 순으로 조회한 카테고리를 Map에 저장한다. (트리를 구성할 때 시간복잡도를 줄여주는 핵심)
- createCategoryTree : 위 Map과 parentId를 통해 실제 계층 구조 트리 생성
3.3 카테고리 트리 구성 방식 요약
- DB에서 카테고리 테이블 기반 전체 카테고리 조회
- 각 Category 객체를
CategoryNode
로 변환하여 Map 구성 - 부모 ID를 기준으로 자식 노드를 붙이며 트리 완성
if (parentId == null) {
categoryNodes.add(current); // 루트 노드
} else {
CategoryNode parent = nodeMap.get(parentId);
parent.appendChild(current); // 자식 노드 연결
}
IT > BackEnd > SpringBoot
구조로 조회하는건 SpringBoot의 id를 descendant 로 두고 조건을 걸어서 구하면 된다. 간단한 작업이므로 생략하겠다.
원래는 루트 노드(조회 편의를 위한 가짜 노드)가 하나이기 때문에 List로 안하고 CategoryNode로 반환해도 된다.
하지만 진짜 루트 노드들로 바뀐다면 여러개가 생길 수도 있기 때문에 이렇게 구현하였다.
3. 결과 및 정리
- 재귀 구조의 N+1 문제 > 쿼리 1번으로 전체 트리 조회
- 깊이에 관계없이 경로 조회 가능
카테고리는 조회가 잦은 데이터이기 때문에 이후에는 캐싱을 적용하여 응답 속도를 더 개선할 수 있을 것 같다.
'프로젝트 > 온라인 서점 프로젝트 + 리팩토링' 카테고리의 다른 글
Java 11에서 17, Maven에서 Gradle, Spring Boot 2.7에서 3.4까지 마이그레이션 (0) | 2025.05.14 |
---|---|
이미지 업로드와 DB 저장에 대한 고민과 해결과정 (0) | 2025.04.11 |
요청 DTO에서 ConstraintValidator로 Enum 검증하기 (0) | 2025.04.06 |
주소 도메인 테이블 구조 리팩토링으로 성능·동시성 문제 해결 (0) | 2025.03.28 |
Object Storage 업로드 방식과 이미지 조회 방식 변경 (0) | 2025.03.13 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!