0. 개요
쇼핑몰 프로젝트의 카테고리는 재귀적 계층 구조로 설계되어 있었다.
처음에는 백엔드에서 전체 카테고리 트리를 재귀적으로 내려주는 방식이었지만, 이 방식은 N+1 문제를 유발했고, 그로 인해 쿼리 수가 과도하게 늘어나고 응답 속도도 매우 느려지는 문제가 있었다.
당시에는 시간적인 여유가 없어서, 문제를 임시로 해결하기 위해 프론트 서버에서 아래와 같이 로직을 구성한 것 같다.
- 최상위 카테고리 목록을 먼저 /topCategories API로 조회.
- 그 중 자식이 있는 카테고리에 대해 /subCategories/{id} API를 반복 호출.
이 방식은 겉보기에 구조를 분리한 것처럼 보이지만, 결국 카테고리 개수만큼 API 요청이 발생하게 되면서 N+1 문제를 DB 쿼리에서 API 요청 수준으로 옮긴 것에 불과했다.
아래는 실제 프론트 코드 일부다.
public List<CategoryDTO> getTopCategories() {
List<CategoryDTO> topCategories = categoryClient.getTopCategories();
for (CategoryDTO category : topCategories) {
List<CategoryDTO> subCategories = categoryClient.getSubCategories(category.getCategoryId()); // 하위 카테고리 조회 API 호출
category.setSubCategories(subCategories);
}
return topCategories;
}
이처럼 구조적으로 불안정한 카테고리를 개선하기 위해 이번 리팩토링에서는 다음과 같은 방향으로 접근했다.
- 기존 테이블 구조를 유지한 채, 조회 방식과 연관 관계 설정을 통해 개선
- 단방향에서 양방향 관계로 확장하여 연관된 자식 카테고리까지 한 번에 조회할 수 있도록 개선
- 한 번의 쿼리로 전체 카테고리 조회
- 카테고리 계층 구조 자체에 대한 구조적 고민 → 클로저 테이블 도입
- 모든 조상-자식 관계를 별도 테이블에 저장함으로써 트리 탐색 비용 최소화
- Redis 캐싱 도입으로 성능 최적화
- Redis 캐시로 공통 헤더 메뉴 응답 가속
이번 글에서는 위와 같은 배경을 바탕으로 카테고리 구조 리팩토링 과정을 정리하고, N+1 문제를 어떻게 해결했는지, Redis를 통해 어떻게 성능을 최적화했는지 정리한 글이다.
1. 기존 구조의 문제점
1) 재귀 구조 (단방향 참조)
Category 엔티티는 자기 자신을 참조하는 parentCategory 필드를 통해 재귀적 계층 구조를 표현한다.
하지만 이를 단방향 관계로 하고 있어, 부모는 알지만 자식 목록(subCategories)은 알 수 없다.
이로 인해 전체 카테고리 트리를 표현하거나, 자식 노드를 탐색하려면 매번 별도의 DB 쿼리가 필요함.
2) N+1 문제 발생
- 프론트 서버에서 /topCategories로 상위 카테고리를 조회한 뒤, 각 카테고리에 대해 /subCategories/{id} API를 반복적으로 호출하여 자식 카테고리를 가져온다.
- 이 과정에서 카테고리 개수만큼 추가 API 요청이 발생 → 결국 N+1 문제로 이어진다.
- DB 단에서 벌어지는 N+1 문제를 API 레벨로 이전한 것뿐, 서버 부하 문제는 여전히 존재한다.
* 프론트
CategoryService
public List<CategoryDTO> getTopCategories() {
List<CategoryDTO> topCategories = categoryClient.getTopCategories();
for (CategoryDTO category : topCategories) {
List<CategoryDTO> subCategories = categoryClient.getSubCategories(category.getCategoryId()); // 하위 카테고리 조회 API 호출
category.setSubCategories(subCategories);
}
return topCategories;
}
* 백엔드
CategoryController

CategoryService

CategoryRepositoryCustomImpl

CategoryRepository

3) SQL 쿼리 분석을 통한 문제 확인
테스트는 다음과 같은 테이블 상태를 기반으로 한다
(1) 실제 동작 흐름과 쿼리 분석
1단계: /topCategories API 호출 시 실행되는 쿼리


2단계: 프론트는 각 상위 카테고리에 대해 /subCategories/{id} API를 반복 호출함
(1) /subCategories/15 요청 (국내도서 자식들 조회)


(2) /subCategories/18 요청 (해외도서 자식들 조회)


(2) 문제점
- 구조적인 문제 (카테고리 트리 조회 방식 자체의 비효율)
- N개의 상위 카테고리에 대해 N개의 쿼리가 발생하는 구조적 N+1 문제.
- 코드 작성 방식의 문제 (불필요한 부모 카테고리 조회)
- 자식 카테고리 조회엔 categoryId만 있으면 되는데, 굳이 부모 객체를 먼저 조회하면서 쿼리가 불필요하게 한 번 더 발생한다.
2. 기존 구조 기반의 N+1 문제 해결
- 양방향 연관 관계 설정
- Category 엔티티에 @OneToMany(mappedBy = "parentCategory")로 자식 컬럼 추가.
- 기존(단방향): 자식 → 부모만 가능했기 때문에 불필요한 쿼리 발생 -> 개선(양방향): 부모 → 자식에서 부모 데이터를 조회 가능.
(1) 첫 번째 시도 - API 단의 N+1 -> DB 단의 N+1
* 결과
N+1 문제는 해결되지 않았지만, 의미 있는 구조 개선.
- 기존에는 프론트에서 다수의 API 호출로 인해 N+1 문제가 발생했으나, 개선 후 백엔드 단에서 재귀적으로 한 번에 트리 구조를 구성하고 내려줌.
- 결과적으로 API 호출은 한 번으로 줄었고, "API 단의 N+1 → DB 단의 N+1" 문제로 전환됨.
Category 엔티티
- 양방향 관계로 자식 카테고리들 컬럼을 추가했다.

CategoryService
- 카테고리 트리를 재귀적으로 구성해서 프론트에 한 번에 전체 트리 구조를 내려주기 위한 구현.
- 최상위(루트)카테고리 조회 후, 최상위 카테고리부터 재귀적으로 자식 트리 구성한다.
- Entity가 아닌 DTO로 넘겨준다.

CategoryRepository

조회 결과
- 루트 카테고리들 조회
- parentCategory IS NULL 조건으로 루트 카테고리만 먼저 조회 (1개의 쿼리 발생)
- 루트 카테고리를 포함한 각 카테고리에 대해 자식 카테고리를 조회하는 쿼리가 재귀적으로 실행
- 카테고리가 N개 있다면 최대 N개의 쿼리 발생 가능
- 총 쿼리 수
- 루트 카테고리들 조회 1번 + 자식 개수만큼 -> 최대 N+1 쿼리 발생
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id is null
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id=?
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id=?
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id=?
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id=?
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id=?
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
where
c1_0.category_parent_id=?
2025-08-06T23:54:44.797+09:00 INFO 20786 --- [task-service] [nio-8500-exec-1] c.n.t.c.log.QueryCountLoggingFilter : STATUS_CODE: 200, METHOD: GET, URL: /task/categories/tree, TIME: 0.072초, QUERY_COUNT: 7
(2) 두 번째 시도 - N+1 문제 해결
* 결과
서비스 단에서 한 번에 모든 카테고리를 읽어와 서비스단에서 트리를 조립하는 방식으로 N+1 문제를 해결했다.
- 첫 번째 시도에서 재귀 단계마다 findByParentId()가 호출되어 루트 + 자식 수만큼 SELECT가 반복되는 전형적인 N+1 문제가 있었으나, 전체 카테고리를 한 번에 조회(SELECT 1회)한 뒤, 부모 ID를 기준으로 자식 목록을 매핑하고 재귀적으로 트리를 구성하는 방식으로 변경했다.
- 단 한번의 쿼리로 전체 트리를 조회할 수 있으나, 이 방식은 모든 카테고리를 한꺼번에 메모리에 올리기 때문에 데이터 규모가 크면 메모리 사용량이 증가하는 단점이 있다. 또한, 전체 데이터를 항상 조회하므로 특정 루트의 하위 트리만 필요할 때도 불필요한 데이터를 읽게 된다.
CategoryService
- 모든 카테고리 조회 (쿼리 1번)
- 부모 ID를 키로 하고, 그 부모를 가진 자식 카테고리 목록을 값으로 하는 Map을 만든다.
- parentCategory가 없는 카테고리를 골라 루트 목록을 만든다.
- 루트부터 시작해, Map에서 해당 루트의 자식 목록을 찾아 붙이고, 자식도 똑같이 재귀적으로 처리한다.
- DB 조회는 단 한 번만 수행되며, Entity가 아닌 DTO로 변환해 전체 트리 구조를 리턴한다.

조회 결과
- 쿼리 1번으로 전체 트리 조회.
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
2025-08-10T15:31:55.935+09:00 INFO 38820 --- [task-service] [nio-8500-exec-1] c.n.t.c.log.QueryCountLoggingFilter : STATUS_CODE: 200, METHOD: GET, URL: /task/categories/tree2, TIME: 0.062초, QUERY_COUNT: 1
[
{
"id": 15,
"name": "국내도서",
"status": false,
"children": [
{
"id": 16,
"name": "여행",
"status": false,
"children": [
{
"id": 17,
"name": "유럽여행",
"status": false,
"children": []
}
]
}
]
},
{
"id": 18,
"name": "해외도서",
"status": false,
"children": [
{
"id": 19,
"name": "역사",
"status": false,
"children": [
{
"id": 20,
"name": "르네상스",
"status": false,
"children": []
}
]
}
]
}
]
3. 구조 리팩토링: 클로저 테이블
리팩토링을 통해 전체 카테고리 조회를 하나의 쿼리로 가능하게 했다. 그러나 전체 데이터를 항상 조회하므로 특정 루트의 하위 트리만 필요할 때도 불필요한 데이터를 읽게 된다. 그래서 클로저 테이블을 도입했다.
클로저 테이블(Closure Table)은 카테고리처럼 계층 구조를 다룰 때, 모든 “조상→자손” 경로를 미리 저장해 두는 방법이다.
아래 ERD처럼 기존 카테고리 테이블은 그대로 두고, 보조 테이블 category_closure에 3가지 정보를 저장한다.
- ancestor_id : 조상 카테고리 ID
- descendant_id : 자손 카테고리 ID
- depth : 조상에서 자손까지의 단계 수(자기 자신은 0, 직계는 1, 손자는 2 …)
이를 통해 루트 기준 서브트리를 단일 쿼리로 조회해 N+1을 제거하고, 불필요한 전체 로드를 피할 수 있다.
* 코드
CategoryClosure

CategoryClosureId - 복합키

CategoryClosureService
@Service
@RequiredArgsConstructor
@Transactional
public class CategoryClosureService {
private final CategoryRepository categoryRepository; // 기본 엔티티용
private final CategoryClosureRepository closureRepository; // 클로저 전용(조회/경로 갱신)
// 새 카테고리 등록: parentId 있으면 그 밑에 붙임
public CategoryResponseDto create(String name, Integer parentId) {
Category c = new Category();
c.setName(name);
if (parentId != null) {
// 프록시만 들고옴(여기선 쿼리 안 나감)
c.setParentCategory(categoryRepository.getReferenceById(parentId));
}
categoryRepository.save(c); // INSERT 후 ID 확보
// 클로저 경로 세팅
closureRepository.insertSelf(c.getCategoryId()); // (자기자신,자기자신,0)
if (parentId != null) {
// 부모 체인 전체를 새 노드 조상으로 연결
closureRepository.linkToParentChain(c.getCategoryId(), parentId);
}
return toDto(c);
}
// 노드 이동: nodeId를 newParentId 밑으로
public void move(int nodeId, int newParentId) {
// 부모 포인터만 먼저 교체
Category n = categoryRepository.getReferenceById(nodeId);
n.setParentCategory(categoryRepository.getReferenceById(newParentId));
// 클로저 경로는 싹 갈아끼움: 기존 경로 지우고 -> 새 부모 체인 기준으로 다시 생성
closureRepository.unlinkOldAncestors(nodeId);
closureRepository.linkToNewParentChain(nodeId, newParentId);
// TODO: 사이클 방지 체크(자기 자손 밑으로 못 가게)
// if (closureRepository.findSubtree(nodeId).stream().anyMatch(c -> c.getCategoryId() == newParentId)) throw ...
}
// 서브트리 통째 삭제
public void deleteSubtree(int nodeId) {
// 클로저로 서브트리 한 방 조회(루트 포함)
List<Category> subtree = closureRepository.findSubtree(nodeId);
// 자식부터 지우자(부모 FK 위반 방지)
for (int i = subtree.size() - 1; i >= 0; i--) {
categoryRepository.delete(subtree.get(i));
}
// FK ON DELETE CASCADE면 클로저 테이블은 자동 정리됨
}
// 루트 기준 트리 조회 -> 트리 DTO로 반환
@Transactional(readOnly = true)
public CategoryResponseDto readTree(int rootId) {
// 클로저 덕분에 서브트리 SELECT 1번
List<Category> flat = closureRepository.findSubtree(rootId);
// parentId -> children 맵
Map<Integer, List<Category>> byParent = flat.stream()
.filter(x -> x.getParentCategory() != null) // 루트 제외
.collect(Collectors.groupingBy(x -> x.getParentCategory().getCategoryId()));
// 루트 찾고
Category root = flat.stream()
.filter(x -> x.getCategoryId() == rootId)
.findFirst()
.orElseThrow();
// 재귀로 붙여서 트리 DTO 만든다
return toDtoTree(root, byParent);
}
// --- DTO 매핑 ---
private CategoryResponseDto toDto(Category c) {
return new CategoryResponseDto(
c.getCategoryId(),
c.getName(),
c.isStatus(),
List.of()
);
}
private CategoryResponseDto toDtoTree(Category c, Map<Integer, List<Category>> byParent) {
List<Category> children = byParent.getOrDefault(c.getCategoryId(), List.of());
return new CategoryResponseDto(
c.getCategoryId(),
c.getName(),
c.isStatus(),
children.stream()
.map(ch -> toDtoTree(ch, byParent))
.collect(Collectors.toList())
);
}
}
CategoryClosureRepository
public interface CategoryClosureRepository extends JpaRepository<CategoryClosure, CategoryClosureId> {
// 서브트리 조회 (루트 포함)
@Query("""
select d from Category d
join CategoryClosure cc on cc.descendantId = d.categoryId
where cc.ancestorId = :rootId
order by cc.depth asc, d.name asc
""")
List<Category> findSubtree(@Param("rootId") int rootId);
// 조상 경로 조회 (루트 -> 대상, 대상 자신 포함)
@Query("""
select a from Category a
join CategoryClosure cc on cc.ancestorId = a.categoryId
where cc.descendantId = :nodeId
order by cc.depth asc
""")
List<Category> findAncestors(@Param("nodeId") int nodeId);
// 새 노드: 자기 자신 경로(depth=0)
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
insert into category_closure(ancestor_id, descendant_id, depth)
values (:n, :n, 0)
""", nativeQuery = true)
void insertSelf(@Param("n") int newId);
// 새 노드: 부모 체인의 모든 조상을 연결
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
insert into category_closure(ancestor_id, descendant_id, depth)
select a.ancestor_id, :n, a.depth + 1
from category_closure a
where a.descendant_id = :p
""", nativeQuery = true)
void linkToParentChain(@Param("n") int newId, @Param("p") int parentId);
// 이동: 기존 조상 경로 제거 (자기 자신(depth=0) 보존)
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
delete cc
from category_closure cc
join (
select descendant_id
from category_closure
where ancestor_id = :n
) s on cc.descendant_id = s.descendant_id
join (
select ancestor_id
from category_closure
where descendant_id = :n
and ancestor_id <> :n -- 자기자신 제외
) a on cc.ancestor_id = a.ancestor_id
""", nativeQuery = true)
void unlinkOldAncestors(@Param("n") int nodeId);
// 이동: 새 부모 체인 × 서브트리 연결 (CTE 제거)
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
insert into category_closure(ancestor_id, descendant_id, depth)
select pa.ancestor_id, s.descendant_id, pa.depth + s.depth + 1
from category_closure pa,
(select descendant_id, depth
from category_closure
where ancestor_id = :n) as s
where pa.descendant_id = :np
""", nativeQuery = true)
void linkToNewParentChain(@Param("n") int nodeId, @Param("np") int newParentId);
}
조회 결과
- 쿼리 한 번으로 부분 카테고리 조회.
* 국내 도서 조회.
Hibernate:
select
c1_0.category_id,
c1_0.name,
c1_0.category_parent_id,
c1_0.status
from
categories c1_0
join
category_closure cc1_0
on cc1_0.descendant_id=c1_0.category_id
where
cc1_0.ancestor_id=?
order by
cc1_0.depth,
c1_0.name
2025-08-12T17:02:13.100+09:00 INFO 54957 --- [task-service] [nio-8500-exec-1] c.n.t.c.log.QueryCountLoggingFilter : STATUS_CODE: 200, METHOD: GET, URL: /task/categories/closure/tree/15, TIME: 0.072초, QUERY_COUNT: 1
{
"id": 15,
"name": "국내도서",
"status": false,
"children": [
{
"id": 16,
"name": "여행",
"status": false,
"children": [
{
"id": 17,
"name": "유럽여행",
"status": false,
"children": []
}
]
}
]
}
4. Redis를 활용한 카테고리 캐싱
헤더 메뉴(카테고리)는 모든 요청에 포함되는데, 이를 매번 DB에서 읽어오는 것은 부담이 된다. 그래서 조회가 잦은 카테고리를 캐싱을 적용하여 응답 속도를 더 개선했다.
카테고리 Redis 캐싱
(1) 의존성 추가
- redis와 cache 의존성 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.4.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>3.4.4</version>
</dependency>
(2) Redis 설정

(3) CacheConfig 설정
- @EnableCaching: 캐싱 활성화
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 헤더는 자주 안 바뀌니 10분
.disableCachingNullValues()
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())); // JSON 저장
return RedisCacheManager.builder(cf).cacheDefaults(config).build();
}
}
(4) 네이티브 쿼리 작성
// 헤더에 들어갈 카테고리용: 루트 + 루트의 직계만
@Query(value = """
select
r.category_id as root_id,
r.name as root_name,
r.status as root_status,
c.category_id as child_id,
c.name as child_name,
c.status as child_status
from categories r
left join category_closure cc
on cc.ancestor_id = r.category_id
and cc.depth = 1
left join categories c
on c.category_id = cc.descendant_id
where r.category_parent_id is null
order by r.name asc, c.name asc
""", nativeQuery = true)
List<Object[]> findHeaderCategories();
(5) CategoryClosureService
- @Cacheable/@CachePut/@CacheEvict -> 저장/갱신/삭제
- 조회 로직 -> @Cacheable, 삭제 로직 -> @CacheEvict 적용.
@Cacheable(cacheNames = "headerCategories", key = "'v1'", sync = true)
@Transactional(readOnly = true)
public List<CategoryResponseDto> getHeaderCategories() {
List<Object[]> rows = closureRepository.findHeaderCategories();
// rootId -> rootDto
Map<Integer, CategoryResponseDto> roots = new LinkedHashMap<>();
for (Object[] r : rows) {
Integer rootId = (Integer) r[0];
String rootName = (String) r[1];
Boolean rootStatus = (Boolean) r[2]; // status가 tinyint면 Boolean으로 매핑됨
Integer childId = (Integer) r[3]; // 자식 없으면 null
String childName = (String) r[4];
Boolean childStatus= (Boolean) r[5];
// 루트 DTO 없으면 만들기
CategoryResponseDto rootDto = roots.get(rootId);
if (rootDto == null) {
rootDto = new CategoryResponseDto(rootId, rootName, rootStatus != null && rootStatus, new ArrayList<>());
roots.put(rootId, rootDto);
}
// 자식 있으면 붙이기 (직계만)
if (childId != null) {
CategoryResponseDto childDto =
new CategoryResponseDto(childId, childName, childStatus != null && childStatus, new ArrayList<>());
rootDto.getChildren().add(childDto); // 필드명이 children/subCategories 중 뭐든 맞춰서 사용
}
}
return new ArrayList<>(roots.values());
}
(6) Redis 확인

5. 리팩토링 결과
* 준비: 더미 데이터 & 시간 측정
(1) 더미 데이터
- CALL seed_categories(10, 7, 7);
- 총 570개: 10/7/7 (루트 개수 / 루트당 자식 수 / 자식당 손자 수)

DELIMITER $$
DROP PROCEDURE IF EXISTS seed_categories$$
CREATE PROCEDURE seed_categories(
IN p_roots INT, -- 루트 개수
IN p_children_per_root INT, -- 루트당 자식 수
IN p_grand_per_child INT -- 자식당 손자 수
)
BEGIN
DECLARE r INT DEFAULT 1;
DECLARE c INT;
DECLARE g INT;
DECLARE v_root_id INT;
DECLARE v_child_id INT;
WHILE r <= p_roots DO
CALL add_category(CONCAT('Root ', r), NULL);
SET v_root_id = LAST_INSERT_ID();
SET c = 1;
WHILE c <= p_children_per_root DO
CALL add_category(CONCAT('Root ', r, ' - Child ', c), v_root_id);
SET v_child_id = LAST_INSERT_ID();
SET g = 1;
WHILE g <= p_grand_per_child DO
CALL add_category(CONCAT('Root ', r, ' - Child ', c, ' - Grandchild ', g), v_child_id);
-- 손자 id는 필요 없으니 LAST_INSERT_ID() 저장 생략
SET g = g + 1;
END WHILE;
SET c = c + 1;
END WHILE;
SET r = r + 1;
END WHILE;
END$$
DELIMITER ;
(2) 시간 측정
- AOP @TimeTrace로 uri, accept, timeMs 로그를 남긴다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimeTrace {
}
@Log4j2
@Component
@Aspect
public class TimeTraceAspect {
@Pointcut("@annotation(com.onebook.frontapi.aop.TimeTrace)")
private void timeTracePointcut() {
}
@Around("timeTracePointcut()")
public Object traceTime(ProceedingJoinPoint joinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
try {
stopWatch.start();
return joinPoint.proceed(); // 실제 타겟 호출
} finally {
stopWatch.stop();
String uri = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getRequestURI();
log.info("{} uri={} Total time = {}s",
joinPoint.getSignature().toShortString(),
uri,
stopWatch.getTotalTimeSeconds());
}
}
}
(1) 기존 방식 (N+1)
헤더에는 루트 카테고리와 각 루트의 직계 자식만 노출한다.
루트 카테고리 개수가 N=10개라면 총 1 + (N × 2) = 21쿼리.
즉, 페이지 로드 한 번에 루트 1쿼리 + 루트별 2쿼리가 반복되는 구조다.
API 실행 시간은 평균 0.0536s 다.
1. 루트 목록 조회 -> 1 쿼리
SELECT category_id, name, category_parent_id, status
FROM categories
WHERE category_parent_id IS NULL;
2. 각 루트의 직계 자식 조회 (루트마다 2쿼리)
- 자식 목록
- 루트 상세(부모 조인 포함 1건)
SELECT category_id, name, category_parent_id, status
FROM categories
WHERE category_parent_id = ?;
SELECT c.category_id, c.name, pc.category_id, pc.name, pc.category_parent_id, pc.status, c.status
FROM categories c
LEFT JOIN categories pc ON pc.category_id = c.category_parent_id
WHERE c.category_id = ?;
3. API 실행 시간
- 평균 0.0536s (5회 측정)
025-08-14T16:35:54.604+09:00 INFO 71062 --- [front-service] [nio-8100-exec-1] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.079048667s
2025-08-14T16:36:42.744+09:00 INFO 71062 --- [front-service] [nio-8100-exec-1] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.083739125s
2025-08-14T16:37:02.581+09:00 INFO 71062 --- [front-service] [nio-8100-exec-5] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.05020075s
2025-08-14T16:37:51.664+09:00 INFO 71062 --- [front-service] [nio-8100-exec-4] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.027883458s
2025-08-14T16:38:09.198+09:00 INFO 71062 --- [front-service] [nio-8100-exec-5] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.035098333s
2025-08-14T16:38:24.052+09:00 INFO 71062 --- [front-service] [nio-8100-exec-8] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.043667375s
(2) 클로저 테이블 + Redis 캐싱
마찬가지로 헤더에는 루트 카테고리와 각 루트의 직계 자식만 노출한다.
쿼리 1번으로 헤더에 들어갈 카테고리를 가져온다.
이후 요청은 Redis에 캐싱된 카테고리 데이터를 사용하므로 쿼리가 발생하지 않는다.
API 실행 시간은 캐싱 후 평균 0.0263s 다.
1. 헤더에 들어갈 카테고리 조회 -> 쿼리 1번
Hibernate:
select
r.category_id as root_id,
r.name as root_name,
r.status as root_status,
c.category_id as child_id,
c.name as child_name,
c.status as child_status
from
categories r
left join
category_closure cc
on cc.ancestor_id = r.category_id
and cc.depth = 1
left join
categories c
on c.category_id = cc.descendant_id
where
r.category_parent_id is null
order by
r.name asc,
c.name asc
2025-08-15T12:53:08.032+09:00 INFO 76506 --- [task-service] [nio-8500-exec-1] c.n.t.c.log.QueryCountLoggingFilter : STATUS_CODE: 200, METHOD: GET, URL: /task/categories/header, TIME: 0.089초, QUERY_COUNT: 1
2. 이후 요청은 Redis에 캐싱된 카테고리 데이터 사용 -> 쿼리 0번
2025-08-15T12:53:08.160+09:00 INFO 76506 --- [task-service] [nio-8500-exec-9] c.n.t.c.log.QueryCountLoggingFilter : STATUS_CODE: 200, METHOD: GET, URL: /task/categories/header, TIME: 0.002초, QUERY_COUNT: 0
3. API 실행 시간
- 캐싱 전: 0.1509s
2025-08-15T12:58:00.315+09:00 INFO 76648 --- [front-service] [nio-8100-exec-1] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.150993208s
- 캐싱 후: 평균 0.0263s (5회 측정)
2025-08-15T12:58:30.129+09:00 INFO 76648 --- [front-service] [nio-8100-exec-3] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.037764917s
2025-08-15T12:59:02.733+09:00 INFO 76648 --- [front-service] [nio-8100-exec-5] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.040314292s
2025-08-15T13:00:57.152+09:00 INFO 76648 --- [front-service] [io-8100-exec-10] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.006964708s
2025-08-15T13:01:50.015+09:00 INFO 76648 --- [front-service] [nio-8100-exec-7] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.028782916s
2025-08-15T13:02:43.297+09:00 INFO 76648 --- [front-service] [nio-8100-exec-2] c.onebook.frontapi.aop.TimeTraceAspect : CategoryService.getTopCategories() uri=/ Total time = 0.018001541s
(3) 결과
- 카테고리 조회 시 N+1 문제 -> 쿼리 1번으로 조회
- Redis 캐싱을 활용하여 최초 1회만 DB 쿼리 발생, 이후엔 DB 쿼리 0회(캐시에서 바로 반환)
- 평균 응답시간 약 50.9% 개선
- 리팩토링 전: 0.0536s
- 리팩토링 후(캐싱 적용): 0.0263s
참고
'프로젝트 > 쇼핑몰 프로젝트' 카테고리의 다른 글
[쇼핑몰 프로젝트 - 리팩토링] 생일 쿠폰 배치 성능 ~% 개선 과정 (0) | 2025.08.21 |
---|---|
[쇼핑몰 프로젝트 - 리팩토링] 상품 목록 조회 최적화: N+1 해결 & 인덱스로 31% 개선 (0) | 2025.08.20 |
[쇼핑몰 프로젝트] FeignClient 에러 처리: ErrorDecoder (0) | 2025.08.02 |
[쇼핑몰 프로젝트] Spring 예외 처리: ErrorCode 기반 공통 처리로 단순화하기 (3) | 2025.08.01 |
[쇼핑몰 프로젝트] Spring Cloud Gateway와 OpenFeign을 같이 사용하면 발생하는 문제 (3) | 2025.08.01 |