무한 자가 참조 카테고리를 활용한 다중 계층 카테고리 시스템 구현

SUUUI·2025년 2월 22일
0

이번 프로젝트를 진행하면서 처음으로 3depth 카테고리를 다뤄보게 되었다. 처음에는 각 계층별로 별도의 엔티티를 만들어 연관관계를 구성했다:
복사최상위 카테고리 > 하위 카테고리 > 최하위 카테고리 > 제품

‼️이 구조에서 발생한 문제점:

  • 과도한 조인 쿼리: 3개의 테이블을 조인해서 정보를 가져오기 위해 복잡한 쿼리가 필요
  • 성능 저하: 카테고리 조회 시 N+1 문제가 발생하여 쿼리 수가 급증
  • 확장성 제한: 새로운 카테고리 계층을 추가하려면 스키마 변경 필요
  • 유지보수 어려움: 각 계층별로 거의 동일한 코드 반복

실제 성능 테스트 결과, 210개의 헬스장이 있는 카테고리 시스템에서 전체 카테고리 조회 시 약 2초가 소요되었고, 서버 리소스 사용량도 높게 나타났다. 무엇보다 콘솔에 찍힌 중복되는 쿼리가 너무 많았다

🔍해결 방안: 무한 자가 참조 카테고리
문제를 해결하기 위해 '무한 자가 참조 카테고리' 패턴을 도입
이 패턴은 하나의 엔티티가 자신을 참조하는 구조이다.

Category 엔티티

package com.example.category_study.entity;

import com.example.category_study.entity.enums.LastType;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;

import java.util.ArrayList;
import java.util.List;

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
@Table(name = "category")
@ToString(exclude = "children")

//엔티티의 자가 참조(Self-Referential) 관계
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "logo")
    private String logo;

    //부모 참조
    //각각의 자식 카테고리는 하나의 부모를 가짐
    //여러개의 부모 카테고리 들도 또 하나의 부모 카테고리를 가질 수 있음
    @ManyToOne(fetch = FetchType.LAZY)

    //각 카테고리는 자신을 참조하는 parent_id 필드를 가짐
    //이 parent_id는 같은 테이블의 id를 참조
    @JoinColumn(name="parent_id")
    private Category parent;

    //자식은 부모를 참조 하는 리스트
    //하나의 카테고리는 여러 자식 카테고리를 만들 수 있음
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    @Builder.Default
    private List<Category> children = new ArrayList<>();

    //엔티티 증식의 끝을 나태니기 위한 enums 값
    //Y : 이에 해당하는 카테고리는 자식 카테고리가 없음
    @Enumerated(EnumType.STRING)
    @ColumnDefault("'N'") // 자식 카테고리 존재
    private LastType lastType;



    // 카테고리 브릿지
    //최하위 카테고리와 product 를 잇는 중간 매핑 테이블
    @OneToMany(mappedBy = "category")
    @Builder.Default
    private List<CategoryBridge> categoryBridgeList = new ArrayList<>();

}

최상위 부모 카테고리를 참조하는 자식 카테고리 , 그 자식 카테고리를 참조하는 자자식 카테고리 자자식 카테고리를 참조하는 자자자식 카테고리를 끝없이 만들 수 있다

"이제 카테고리 데이터를 넣고 하나씩 조회해보는 api를 만들어보자"

  • Depth 1 (Level 1): 최상위 카테고리
  • Depth 2 (Level 2): 중간 카테고리
  • Depth 3 (Level 3): 하위 카테고리
package com.example.category_study.controller;

import com.example.category_study.dto.CategoryParentDTO;
import com.example.category_study.dto.CategoryResponseDTO;
import com.example.category_study.dto.ProductDTO;
import com.example.category_study.service.CategoryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Slf4j
@RestController
@RequestMapping("/api/category")
@RequiredArgsConstructor
public class CategoryController {
    private final CategoryService categoryService;


    //최상위 카테고리 조회
    @GetMapping
    public ResponseEntity<List<CategoryResponseDTO>> getParentCategories() {
        return ResponseEntity.ok(categoryService.getParentCategories());
    }

    //메인 카테고리 별 서브 카테고리 list
    @GetMapping("/{mainCategoryId}/sub/list")
    public ResponseEntity <List<CategoryResponseDTO>>getSubCategory(@PathVariable Long mainCategoryId) {
        return ResponseEntity.ok(categoryService.getSubCategoryList(mainCategoryId));
    }

    //서브 카테고리 별 child 카테고리 list
    @GetMapping("/{subCategoryId}/child/list")
    public ResponseEntity<List<CategoryResponseDTO>> getChildCategory(@PathVariable Long subCategoryId) {
        return ResponseEntity.ok(categoryService.getChildCategoryList(subCategoryId));
    }

    // 카테고리 아이디(sub , child) 로 이에 해당하는 productList 가져오기
    @GetMapping("/{categoryId}/product/list")
    public ResponseEntity<List<ProductDTO>> getProductByCategoryId(@PathVariable Long categoryId) {
        return ResponseEntity.ok(categoryService.getProductCategoryId(categoryId));
    }

    //부모 카테고리를 클릭하면 해당 자식 카테고리들 까지 나오게 !
    @GetMapping("/{categoryId}")
    public ResponseEntity<List<CategoryParentDTO>> getCategories(@PathVariable Long categoryId) {
        return ResponseEntity.ok(categoryService.getAllCategories(categoryId));
    }

}

Query dsl 을 사용하여 카테고리 별 조회

package com.example.category_study.repository.querydsl;

import com.example.category_study.entity.Category;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;

import java.util.List;

import static com.example.category_study.entity.QCategory.category;

@RequiredArgsConstructor
public class CategoryRepositoryImpl implements CategoryRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    //서브카테고리로 미니 카테고리 조회
    @Override
    public List<Category> findChildCategories(Long subCategoryId) {
        return queryFactory
                .selectFrom(category)
                .where(category.parent.id.eq(subCategoryId))
                .fetch();
    }

    @Override
    //메인카테고리 아이디로 나머지 카테고리 한번에 조회
    public List<Category> findByCategoryId(Long categoryId) {
        return queryFactory
                .selectFrom(category)
                .where(category.parent.id.eq(categoryId))
                .fetch();
    }

    //메인카테고리 아이디로 서브카테고리 조회
    @Override
    public List<Category> findSubCategories(Long mainCategoryId) {
        return queryFactory
                .selectFrom(category)
                .where(category.parent.id.eq(mainCategoryId))
                .fetch();
    }

    //메인 카테고리만 조회
    @Override
    public List<Category> findMainCategories() {
        return queryFactory
                .selectFrom(category)
                .where(category.parent.id.isNull())
                .fetch();
    }


}
 //카테고리 로 조건에 맞는 해당 프로덕트 들을 조회
    @Override
    public List<Product> findByProductCategoryId(Long categoryId) {

        return queryFactory
                .selectFrom(product)
                .distinct()
                .join(product.categoryBridges, categoryBridge)
                .join(categoryBridge.category, category)
                .where(eqChildCategoryId(categoryId).or
                        (eqSubCategoryId(categoryId)))
                .fetch();
        //파라미터로 받은 카테고리 아이디가 child 카테고리 일때 (최하위 카테고리 아이디로 product 조회)
    }
    
    private BooleanExpression eqChildCategoryId(Long childCategoryId) {
        if (childCategoryId == null) {
            return null;
        }
        return category.parent.isNotNull().and(
                category.id.eq(childCategoryId)).and(
                category.lastType.eq(Y));
    }


    private BooleanExpression eqSubCategoryId(Long subCategoryId) {
        if (subCategoryId == null) {
            return null;
        }
        return category.parent.isNotNull().and(
                category.parent.id.eq(subCategoryId)
        );

    }

    
    
    ```
profile
간단한 개발 기록

0개의 댓글