계층형 카테고리 설계하기
by eelseungmin들어가며
현재 프로젝트에는 음식점 데이터가 존재하고 이를 각각 제공하는 메뉴에 따라 특정 카테고리로 분류하는 작업이 필요하다. 예를 들면 다음과 같은 방식이다.
각 카테고리가 계층적인 구조를 가질 필요가 있다.
고민한 방법들
1. 테이블 구조 변경
처음으로 고려한 방법이다.
Enum은 건드리지 않으면서 음식점 카테고리 엔티티에 부모 엔티티를 나타내는 하나의 외래키 필드만 추가하면 되었기 때문에 해당 방법을 사용했었다.
위와 같이 빨간색으로 표시된 하나의 필드만 추가하면 되었기 때문에 변경할 부분이 많지 않았고, 아직 운영 DB에 보존해야 할 데이터가 없기 때문에 구조를 바꾸는 것 또한 간단했다.
사실 이 방법을 사용했을 때의 문제는 카테고리의 depth가 깊어질수록 쿼리가 계속해서 추가적으로 실행되어야 한다는 점이다. 구체적으로는 재귀쿼리를 이용해 모든 하위 카테고리를 구하거나, select문을 여러 번 실행해서 계층 구조를 완성시켜야 한다.
검색을 위해 카테고리를 조회거나 검색 결과에 카테고리를 덧붙이기 위해서 매번 상기한 DB I/O가 필요하다는 점이 바람직하지 않게 느껴졌다.
그렇다면 코드에 계층에 관련된 직관성을 부여하면서도, DB I/O를 줄일 수 있는 방법은 무엇일까?
캐시를 사용해서 DB I/O를 줄일 수도 있겠지만 이 방법은 더 이상 다른 방법이 없을 때 최후의 수단으로 사용하고 싶었다.
내가 떠올린 방법은 아래에서 확인할 수 있다.
2. Enum 수정
https://techblog.woowahan.com/2527/
우아한형제들 기술블로그에 올라온 글을 보고 Enum 자체에 계층 구조를 부여하면 되겠구나 하는 생각이 들었다.
Enum도 인자값으로 계산식을 가지거나, 다른 Enum을 가지도록 할 수 있다는 점을 간과하고 있었던 것 같다.
기존 Enum
@AllArgsConstructor
public enum FoodType {
// 한식
KOREAN_FOOD("한식"),
KOREAN_SEAFOOD("해물,생선"),
CONGEE("죽"),
JOKBAL("족발, 보쌈"),
HOT_POT("찌개,전골"),
MEAT("육류,고기"),
SUNDAE("순대"),
STREET_FOOD("분식"),
NOODLE("면"),
LUNCH_BOX("도시락"),
KOREAN_CHICKEN("닭요리"),
INTESTINE("곱창,막창"),
TEPPAN_YAKI("철판요리"),
// 일식, 중식
JAPANESE_FOOD("일식"),
CHINESE_FOOD("중식"),
// 양식
WESTERN_FOOD("양식"),
HAMBURGER("햄버거"),
WESTERN_SEAFOOD("해산물"),
PIZZA("피자"),
FRENCH_FOOD("프랑스음식"),
FAST_FOOD("패스트푸드"),
FAMILY_RESTAURANT("패밀리레스토랑"),
CHICKEN("치킨"),
ITALIAN_FOOD("이탈리안"),
SPANISH_FOOD("스페인"),
SALAD("샐러드"),
LATIN("멕시칸,브라질"),
// 기타
ASIAN_FOOD("아시아음식"),
BAR("술집"),
BUFFET("뷔페"),
DESSERT("디저트");
private final String title;
}
개선한 Enum
package modu.menu.food.domain;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.List;
@AllArgsConstructor
public enum FoodType {
// 한식
KOREAN_FOOD("한식", null),
KOREAN_SEAFOOD("해물,생선", KOREAN_FOOD),
CONGEE("죽", KOREAN_FOOD),
JOKBAL("족발, 보쌈", KOREAN_FOOD),
HOT_POT("찌개,전골", KOREAN_FOOD),
MEAT("육류,고기", KOREAN_FOOD),
SUNDAE("순대", KOREAN_FOOD),
STREET_FOOD("분식", KOREAN_FOOD),
NOODLE("면", KOREAN_FOOD),
LUNCH_BOX("도시락", KOREAN_FOOD),
KOREAN_CHICKEN("닭요리", KOREAN_FOOD),
INTESTINE("곱창,막창", KOREAN_FOOD),
TEPPAN_YAKI("철판요리", KOREAN_FOOD),
// 일식, 중식
JAPANESE_FOOD("일식", null),
CHINESE_FOOD("중식", null),
// 양식
WESTERN_FOOD("양식", null),
HAMBURGER("햄버거", WESTERN_FOOD),
WESTERN_SEAFOOD("해산물", WESTERN_FOOD),
PIZZA("피자", WESTERN_FOOD),
FRENCH_FOOD("프랑스음식", WESTERN_FOOD),
FAST_FOOD("패스트푸드", WESTERN_FOOD),
FAMILY_RESTAURANT("패밀리레스토랑", WESTERN_FOOD),
CHICKEN("치킨", WESTERN_FOOD),
ITALIAN_FOOD("이탈리안", WESTERN_FOOD),
SPANISH_FOOD("스페인", WESTERN_FOOD),
SALAD("샐러드", WESTERN_FOOD),
LATIN("멕시칸,브라질", WESTERN_FOOD),
// 기타
ASIAN_FOOD("아시아음식", null),
BAR("술집", null),
BUFFET("뷔페", null),
DESSERT("디저트", null);
private final String title;
@Getter
private final FoodType parent;
@JsonValue
public String getTitle() {
return title;
}
// 최상위 Enum 목록을 반환하는 메서드
public static List<FoodType> getAncestor() {
return Arrays.stream(FoodType.values())
.filter(foodType -> foodType.getParent() == null)
.toList();
}
}
위처럼 같은 Enum 타입의 parent 변수를 추가해서 Enum만으로도 계층 구조를 표현할 수 있도록 개선하였다.
이렇게 함으로써 얻은 이점은 다음과 같다.
1. 단순 검색용 카테고리 조회를 목적으로 DB I/O를 거치거나 캐싱을 적용할 필요가 없게 되었다.
2. DB 테이블 구조를 변경하지 않고 기존과 똑같이 가져갈 수 있게 되었다.
참고로 카테고리를 조회하는 코드는 다음과 같다.
package modu.menu.place.service.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import modu.menu.food.domain.FoodType;
import java.util.Arrays;
import java.util.List;
@Schema(description = "음식점 카테고리 DTO")
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class FoodTypeServiceResponse {
@Schema(description = "음식점 카테고리 key")
private String key;
@Schema(description = "음식점 카테고리 value")
private String value;
@Schema(description = "하위 카테고리 목록")
private List<FoodTypeServiceResponse> children;
// 최상위 카테고리부터 시작하여 FoodType의 전체 계층 구조를 반환하는 메서드
public static List<FoodTypeServiceResponse> getFoodTypeHierarchy() {
return FoodType.getAncestor().stream()
.map(FoodTypeServiceResponse::toFoodTypeServiceResponse)
.toList();
}
private static FoodTypeServiceResponse toFoodTypeServiceResponse(FoodType foodType) {
return FoodTypeServiceResponse.builder()
.key(foodType.name())
.value(foodType.getTitle())
.children(Arrays.stream(FoodType.values())
.filter(f -> f.getParent() == foodType)
.map(FoodTypeServiceResponse::toFoodTypeServiceResponse)
.toList())
.build();
}
}
DTO 내부에 static 메서드를 만들어서 재귀 방식으로 계층을 형성하도록 하였다. 카카오 API를 통해 가져온 데이터도 이미 계층이 2개에 불과하고 DB를 사용하지 않기 때문에 이러한 코드가 서버에 부담을 줄 일은 없다고 생각했다.
실제 데이터도 깔끔하게 조회된다.
'Side > 모두의 회식' 카테고리의 다른 글
[Spring] Graceful Shutdown (0) | 2024.06.17 |
---|---|
[Java] Stream distinct()로 객체 타입 리스트의 중복 제거하기 (0) | 2024.05.26 |
AWS SNS, Chatbot을 통해 Slack 알림 받기 (0) | 2024.04.21 |
Slack으로 Logback 에러 로그 수집하기 (0) | 2024.04.14 |
[Spring] MDC를 사용해 요청 별로 구분되는 로그 남기기 (0) | 2024.03.25 |
블로그의 정보
eel.log
eelseungmin