프로젝트/LinkBook

계층형 Category 구현 (Enum으로의 전환)

걸어가는 신사 2022. 8. 19. 18:32

1. 문제 발생

(1) 서비스 이해

  • 프로젝트에서 Tag를 2가지 형태로 나누어서 표현하였습니다.
    • 게임 RootTag에는 FPS, RPG, TPS 등의 여러 Tag들이 존재합니다.

  • 서비스에서 생성되는 폴더에 여러 Tag들을 붙일 수 있습니다.

(2) 기존 구현 방식

  • 데이터베이스 Table을 사용해서 Tag와 RootTag 들의 값들을 관리하였습니다.

Tag

RootTag

(3) Tag 조회 성능 문제

  1. 프론트에게 태그 리스트를 보내 주기 위해 항상 테이블 조회 쿼리가 나가야만 했습니다.
  2. 서비스 로직 상 해당 Tag가 들어왔을 때 Tag가 어떤 RootTag에 속하는 지 찾기 위해서는 DB에 조회 쿼리를 날려야만 했습니다.
  3. 태그 이름으로 검색 Tag, RootTag 테이블의 조인이 필요하였습니다.
@Query(value = "SELECT DISTINCT f FROM Folder f " +
    "JOIN FETCH f.user JOIN FETCH f.folderTags ft " +
    "WHERE f.isPrivate = false AND f.id IN " +
    "(SELECT subf.id FROM Folder subf JOIN subf.folderTags subft JOIN subft.tag subt JOIN subt.rootTag subrt " +
    "ON subrt.name = :rootTag)",
    countQuery = "SELECT count(f) FROM Folder f")
Page<Folder> findByRootTag(RootTagCategory rootTag, Pageable pageable);

@Query(value = "SELECT DISTINCT f FROM Folder f " +
    "JOIN FETCH f.user JOIN FETCH f.folderTags ft " +
    "WHERE f.isPrivate = false AND f.id IN " +
    "(SELECT subf.id FROM Folder subf JOIN subf.folderTags subft JOIN subft.tag subt " +
    "ON subt.name = :tag)",
    countQuery = "SELECT count(f) FROM Folder f")
Page<Folder> findByTag(TagCategory tag, Pageable pageable);

 

2. 문제 해결

  • Tag 데이터를 Enum으로 전환하였습니다.
    • RootTag, Tag의 관계를 명확히 표현하며 각 타입은 본인이 수행해야할 기능과 책임만 가질 수 있게 만들 수 있습니다.
  • DB 조회 쿼리를 날려서 해당 값이 존재하는지 검증하는 것이 아니라 Enum 클래스에서 직접 필터링을 거친다면 어플리케이션의 성능이 향상 될 것이라고 생각하였습니다.

RootTagCategory

@Getter
@AllArgsConstructor
public enum RootTagCategory {
    ALL("전체 카테고리", new TagCategory[0]),
    DAILY("일상", new TagCategory[]{
        TagCategory.HOBBY,
        TagCategory.LIFEHACK
    }),
    ANIMAL("동물", new TagCategory[]{
        TagCategory.DOG,
        TagCategory.CAT,
        TagCategory.REPTILE,
        TagCategory.INSECT
    }),
    GAME("게임", new TagCategory[]{
        TagCategory.FPS,
        TagCategory.RPG,
        TagCategory.TPS,
        TagCategory.SPORTS_GAME,
        TagCategory.RACING_GAME,
        TagCategory.SIMULATION_GAME,
        TagCategory.RHYTHM_GAME
    }),
    MOVIE("영화", new TagCategory[]{
        TagCategory.ROMANCE_MOVIE,
        TagCategory.ACTION_MOVIE,
        TagCategory.CRIME_MOVIE,
        TagCategory.SF_MOVIE,
        TagCategory.COMEDY_MOVIE,
        TagCategory.THRILLER_MOVIE,
        TagCategory.HORROR_MOVIE,
        TagCategory.SPORTS_MOVIE,
        TagCategory.FANTASY_MOVIE
    }),
    MUSIC("음악", new TagCategory[]{
        TagCategory.POP,
        TagCategory.JPOP,
        TagCategory.BALLADE,
        TagCategory.RAP,
        TagCategory.BAND,
        TagCategory.ACOUSTIC,
        TagCategory.JAZZ
    }),
    HUMOR("유머", new TagCategory[]{
        TagCategory.BLACK_COMEDY,
        TagCategory.DAD_JOKE,
        TagCategory.SATIRE
    }),
    HEALTH("헬스", new TagCategory[]{
        TagCategory.WORKOUT,
        TagCategory.PILATES,
        TagCategory.POWER_LIFTING,
        TagCategory.CROSSFIT
    }),
    BEAUTY("뷰티", new TagCategory[]{
        TagCategory.MAKEUP,
        TagCategory.CLOTHES
    }),
    SPORTS("스포츠", new TagCategory[]{
        TagCategory.SOCCER,
        TagCategory.BASKETBALL,
        TagCategory.BASEBALL,
        TagCategory.BILLIARDS,
        TagCategory.BOWLING,
        TagCategory.VOLLEYBALL
    }),
    DEVELOP("개발", new TagCategory[]{
        TagCategory.FRONTEND,
        TagCategory.BACKEND,
        TagCategory.INFRA,
        TagCategory.DEVOPS,
        TagCategory.DATA_VISUALIZATION,
        TagCategory.AI
    }),
    TRAVEL("여행", new TagCategory[]{
        TagCategory.DOMESTIC_TRAVEL,
        TagCategory.OVERSEAS_TRAVEL,
        TagCategory.BACKPACKING
    }),
    FOOD("음식", new TagCategory[]{
        TagCategory.KOREAN_FOOD,
        TagCategory.JAPANESE_FOOD,
        TagCategory.CHINESE_FOOD,
        TagCategory.VIETNAMESE_FOOD,
        TagCategory.WESTERN_FOOD,
        TagCategory.DESSERT
    });

    private String viewName;

    private TagCategory[] tags;

    public static RootTagCategory findByTag(TagCategory tagCategory) {
        return Arrays.stream(RootTagCategory.values())
            .filter(rootTagCategory -> rootTagCategory.hasRootTag(tagCategory))
            .findAny()
            .orElseThrow(() -> new NoDataException());
    }

    public boolean hasRootTag(TagCategory target) {
        return Arrays.stream(tags)
            .anyMatch(tagCategory -> tagCategory.equals(target));
    }

    @JsonCreator
    public static RootTagCategory from(String root) {
        for (RootTagCategory tag : RootTagCategory.values()) {
            if (tag.getViewName().equals(root)) {
                return tag;
            }
        }
        return null;
    }

}
  • RootTagCategory는 TagCategory 배열을 필드로 가지고 있습니다.
    • 스포츠 RootTagCategory는 축구, 농구 TagCategory를 가지고 있습니다.
  • findByTag 메서드
    • TagCategory를 입력받아 해당 RootTag을 찾을 수 있는 메서드들을 만들어 주었습니다.

TagCategory

@Getter
@AllArgsConstructor
public enum TagCategory {
    // 일상
HOBBY("취미"),
LIFEHACK("꿀팁"),

    // 동물
DOG("개"),
CAT("고양이"),
REPTILE("파충류"),
INSECT("곤충"),

    // 게임
FPS("FPS"),
RPG("RPG"),
TPS("TPS"),
SPORTS_GAME("스포츠 게임"),
RACING_GAME("레이싱 게임"),
SIMULATION_GAME("시뮬레이션 게임"),
RHYTHM_GAME("리듬 게임"),

    // 영화
ROMANCE_MOVIE("로맨스"),
ACTION_MOVIE("액션"),
CRIME_MOVIE("범죄"),
SF_MOVIE("SF"),
COMEDY_MOVIE("코미디"),
THRILLER_MOVIE("스릴러"),
HORROR_MOVIE("공포"),
SPORTS_MOVIE("스포츠"),
FANTASY_MOVIE("판타지"),

    // 음악
POP("pop"),
JPOP("jpop"),
BALLADE("발라드"),
RAP("랩"),
BAND("밴드"),
ACOUSTIC("어쿠스틱"),
JAZZ("재즈"),

    // 유머
BLACK_COMEDY("블랙 코미디"),
DAD_JOKE("아재 개그"),
SATIRE("풍자"),

    // 헬스
WORKOUT("헬스"),
PILATES("필라테스"),
POWER_LIFTING("파워 리프팅"),
CROSSFIT("크로스핏"),

    // 뷰티
MAKEUP("화장"),
CLOTHES("옷"),

    // 스포츠
SOCCER("축구"),
BASKETBALL("농구"),
BASEBALL("야구"),
BILLIARDS("당구"),
BOWLING("볼링"),
VOLLEYBALL("배구"),

    // 개발
FRONTEND("프론트엔드"),
BACKEND("백엔드"),
INFRA("인프라"),
DEVOPS("데브옵스"),
DATA_VISUALIZATION("데이터 시각화"),
AI("인공지능"),

    // 여행
DOMESTIC_TRAVEL("국내 여행"),
OVERSEAS_TRAVEL("해외 여행"),
BACKPACKING("배낭 여행"),

    // 음식
KOREAN_FOOD("한식"),
CHINESE_FOOD("중식"),
JAPANESE_FOOD("일식"),
VIETNAMESE_FOOD("베트남"),
WESTERN_FOOD("양식"),
DESSERT("디저트");

    private String viewName;

    @JsonCreator
    public static TagCategory from(String sub) {
        for (TagCategory tag : TagCategory.values()) {
            if (tag.getViewName().equals(sub)) {
                return tag;
            }
        }
        return null;
    }

}
  • TagCategory는 존재하는 모든 Tag들을 가지고 있습니다.
  • Enum Class를 계층화하여 카테고리를 분리하였습니다.

 

DB에서 객체로 전환

  • Tag와 RootTag의 조회를 이제 DB 조회 쿼리가 아닌 Enum Class에서 조회하게 되었습니다.
    • 이는 성능 향상에 도움이 되었습니다.

 

3. 결론

  • RootTag, Tag 테이블을 별도로 두고 이를 조회하여 사용하는 것 보다 가독성 측면에서 향상되었다고 생각합니다.
  • DB 조회 쿼리에서 객체 조회로의 전환으로 인해 성능 향상에 도움이 되었습니다.

 

Reference

https://techblog.woowahan.com/2527/

반응형