이번 프로젝트를 진행하면서 처음으로 3depth 카테고리를 다뤄보게 되었다. 처음에는 각 계층별로 별도의 엔티티를 만들어 연관관계를 구성했다:
복사최상위 카테고리 > 하위 카테고리 > 최하위 카테고리 > 제품
‼️이 구조에서 발생한 문제점:
실제 성능 테스트 결과, 210개의 헬스장이 있는 카테고리 시스템에서 전체 카테고리 조회 시 약 2초가 소요되었고, 서버 리소스 사용량도 높게 나타났다. 무엇보다 콘솔에 찍힌 중복되는 쿼리가 너무 많았다
🔍해결 방안: 무한 자가 참조 카테고리
문제를 해결하기 위해 '무한 자가 참조 카테고리' 패턴을 도입
이 패턴은 하나의 엔티티가 자신을 참조하는 구조이다.
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를 만들어보자"
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));
}
}
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)
);
}
```