[JPA] 계층형 카테고리 구현하기(1)

심씨·2024년 9월 10일
0

JPA

목록 보기
3/3
post-thumbnail

JPA 계층형 카테고리 구현하기- 카테고리 조회

0. 테이블 설계

쇼핑몰에서 자주 볼 수 있는 카테고리는 계층형 구조를 가지고 있습니다. 예를 들어, "패션의류/잡화"라는 상위 카테고리 안에는 "여성패션", "남성패션" 등 여러 하위 카테고리가 포함됩니다. 그리고 "여성패션" 카테고리 안에는 "의류", "신발"과 같은 또 다른 하위 카테고리들이 포함되어 있습니다.

이러한 구조를 데이터베이스 테이블로 나타내려면, 각 카테고리가 자기 자신의 상위 카테고리를 참조하도록 설계해야 합니다. 예를 들어, "의류"라는 카테고리는 "여성패션" 카테고리를 상위 카테고리로 참조하는 방식입니다.

1. 도메인 설계

1.1 Category 엔티티

JPA에서 계층형 카테고리 구조를 객체지향적으로 표현하기 위해 카테고리 엔티티에 parentchildren 필드를 설정하여 자기 자신을 참조합니다. 여기서 parent 필드는 상위 카테고리를 나타내고, children 필드는 여러 하위 카테고리를 나타냅니다.

또한 하나의 상위 카테고리가 여러 하위 카테고리를 가질 수 있지만, 각 하위 카테고리는 하나의 상위 카테고리만을 가질 수 있으므로 children 필드는 OneToMany 관계로 매핑됩니다. 반대로, parent 필드는 ManyToOne 관계를 통해 상위 카테고리와 연결됩니다.

추가적으로, depth 필드를 통해 카테고리의 계층적 깊이를 관리할 수 있으며, 최상위 카테고리는 1, 그 아래 하위 카테고리는 2, 3 등의 값으로 계층을 나타낼 수 있습니다.


@Entity
@Table(name = "category")
public class Category {
    @Id
    @GeneratedValue
    @Column(name = "category_id")
    private Long id;

    private String name;

    private int depth;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent", fetch = LAZY)
    private List<Category> children = new ArrayList<>();

    protected Category() {
    }
}

여기서 의류, 가방, 신발 등 최상위 카테고리는 루트 노드로 자신보다 상위 카테고리가 존재하지 않으므로 parent_id는 null로 설정합니다. 그 외 나머지 카테고리는 자신의 상위 계층의 카테고리 아이디를 parent_id에 설정해 줍니다.

2. 카테고리 조회하기

2-1. Category 컨트롤러


@Controller
@AllArgsConstructor
public class ProductController {

    private final CategoryQueryService categoryQueryService;

    @GetMapping("/categories/{categoryId}")
    public String list(@PathVariable Long categoryId, Model model) {
        CategoryDto category = categoryQueryService.getCategory(categoryId);
        model.addAttribute("category", category);
        
        return "catalog/products";
    }
}

2-2. Category 서비스

컨트롤러에서 카테고리의 아이디를 전달받으면 아이디를 통해서 카테고리를 조회합니다. 카테고리 엔티티는 양방향 관계로 설정됐기 때문에 그대로 반환하게 되면 무한 참조로 N + 1 문제가 발생합니다.

따라서 parent 참조를 제외한 단방향 객체 CategoryDto를 생성하여 해당 데이터를 대신 반환받습니다.


@Service
@RequiredArgsConstructor
public class CategoryQueryService {

    private final CategoryRepository categoryRepository;

    public CategoryDto getCategory(Long categoryId) {
        Category category = categoryRepository.findById(categoryId)
                .orElseThrow(NoCategoryException::new);
                
        return new CategoryDto(category);
    }
}

2-3. Category DTO

서비스에서는 바로 아래 하위 계층의 카테고리 정보만 필요하기 때문에 무한으로 하위 카테고리를 조회하지 않고
1계층 아래 카테고리만 조회하여 값을 초기화합니다.


@Data
public class CategoryDto {
    private Long id;
    private String name;
    List<CategoryDto> children = new ArrayList<>();

    public CategoryDto() {
    }

    public CategoryDto(Category category) {
        this.id = category.getId();
        this.name = category.getName();
        setChildren(category.getChildren());
    }

    public CategoryDto(Long id, String name) {
        this.id = id;
        this.name = name;
    }

	// 하위 계층 자식 카테고리 조회 및 객체 생성 
    private void setChildren(List<Category> children) {
        children.forEach(category -> {
            CategoryDto child = new CategoryDto(category.getId(), category.getName());
            this.children.add(child);
        });
    }
}

3. 결과

타임리프의 each 기능을 통해 모든 하위 카테고리의 이름과 아이디를 바인딩 하였으며 아이디를 통해 다른 카테고리를 이동할 수 있도록 링크를 생성했습니다.


<div class="col-md-3">
    <p class="fw-bold ps-3" th:text="${category.name}"></p>
    <hr class="mt-0" style="border-top:2px solid black">
    <ul class="nav flex-column" th:each="child : ${category.children}">
        <li class="nav-item">
            <a class="nav-link link-dark"
               th:href="@{/categories/{categoryId}(categoryId=${child.id})}"
               th:text="${child.name}">name
            </a>
        </li>
    </ul>
</div>
        

이미지 출처
쿠팡

profile
개발 뿌샤!

0개의 댓글