From 7749a39f023225d694d2ca80959a380c1594fcc5 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 13:04:00 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20categoryGroup=20=EA=B7=B8=EB=A3=B9=20Map?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/AdminAlcoholsController.kt | 6 +-- .../AdminAlcoholsControllerDocsTest.kt | 50 +++++++++++++++---- .../domain/AlcoholQueryRepository.java | 3 +- .../dto/response/CategoryPairItem.java | 3 ++ .../CustomAlcoholQueryRepository.java | 4 +- .../CustomAlcoholQueryRepositoryImpl.java | 20 ++++---- .../alcohols/service/AlcoholQueryService.java | 24 +++++++-- .../InMemoryAlcoholQueryRepository.java | 8 +-- .../InMemoryAlcoholQueryRepository.java | 8 +-- 9 files changed, 86 insertions(+), 40 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt index 8efa8ac68..19d22bc9f 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt @@ -26,11 +26,7 @@ class AdminAlcoholsController( ): ResponseEntity<*> = GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId)) @GetMapping("/categories/reference") - fun getCategoryReference(): ResponseEntity<*> { - val pairs = alcoholQueryService.findAllCategoryPairs() - val response = pairs.map { mapOf("korCategory" to it.left, "engCategory" to it.right) } - return GlobalResponse.ok(response) - } + fun getCategoryReference(): ResponseEntity<*> = GlobalResponse.ok(alcoholQueryService.findAllCategoryReferenceMap()) @PostMapping fun createAlcohol( diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index f9c593058..dfe76ceb2 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -4,6 +4,7 @@ import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest +import app.bottlenote.alcohols.dto.response.CategoryPairItem import app.bottlenote.alcohols.presentation.AdminAlcoholsController import app.bottlenote.alcohols.service.AdminAlcoholCommandService import app.bottlenote.alcohols.service.AlcoholQueryService @@ -11,7 +12,6 @@ import app.bottlenote.global.dto.response.AdminResultResponse import app.bottlenote.global.service.cursor.SortOrder import app.helper.alcohols.AlcoholsHelper import com.fasterxml.jackson.databind.ObjectMapper -import org.apache.commons.lang3.tuple.Pair import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -328,15 +328,29 @@ class AdminAlcoholsControllerDocsTest { @Test @DisplayName("카테고리 레퍼런스를 조회할 수 있다") fun getCategoryReference() { - // given - val categoryPairs = listOf( - Pair.of("싱글 몰트", "Single Malt"), - Pair.of("블렌디드", "Blended"), - Pair.of("버번", "Bourbon") + // given — categoryGroup을 키로 갖는 grouped Map. enum 선언 순서로 LinkedHashMap/EnumMap에 채움 + val grouped = linkedMapOf( + AlcoholCategoryGroup.SINGLE_MALT to listOf( + CategoryPairItem("싱글 몰트", "Single Malt") + ), + AlcoholCategoryGroup.BLEND to listOf( + CategoryPairItem("블렌디드", "Blend") + ), + AlcoholCategoryGroup.BLENDED_MALT to listOf( + CategoryPairItem("블렌디드 몰트", "Blended Malt") + ), + AlcoholCategoryGroup.BOURBON to listOf( + CategoryPairItem("버번", "Bourbon") + ), + AlcoholCategoryGroup.RYE to emptyList(), + AlcoholCategoryGroup.OTHER to listOf( + CategoryPairItem("테네시", "Tennessee"), + CategoryPairItem("콘", "Corn") + ) ) - given(alcoholQueryService.findAllCategoryPairs()) - .willReturn(categoryPairs) + given(alcoholQueryService.findAllCategoryReferenceMap()) + .willReturn(grouped) // when & then assertThat( @@ -351,9 +365,23 @@ class AdminAlcoholsControllerDocsTest { responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.ARRAY).description("카테고리 페어 목록"), - fieldWithPath("data[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리"), - fieldWithPath("data[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("categoryGroup별 카테고리 목록 (SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)"), + fieldWithPath("data.SINGLE_MALT").type(JsonFieldType.ARRAY).description("싱글 몰트 그룹 카테고리 목록"), + fieldWithPath("data.SINGLE_MALT[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.SINGLE_MALT[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.BLEND").type(JsonFieldType.ARRAY).description("블렌디드 그룹 카테고리 목록"), + fieldWithPath("data.BLEND[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.BLEND[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.BLENDED_MALT").type(JsonFieldType.ARRAY).description("블렌디드 몰트 그룹 카테고리 목록"), + fieldWithPath("data.BLENDED_MALT[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.BLENDED_MALT[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.BOURBON").type(JsonFieldType.ARRAY).description("버번 그룹 카테고리 목록"), + fieldWithPath("data.BOURBON[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.BOURBON[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.RYE").type(JsonFieldType.ARRAY).description("라이 그룹 카테고리 목록 (데이터 없으면 빈 배열)"), + fieldWithPath("data.OTHER").type(JsonFieldType.ARRAY).description("기타 그룹 카테고리 목록"), + fieldWithPath("data.OTHER[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.OTHER[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index c2f64614f..35c46c192 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -15,7 +15,6 @@ import app.bottlenote.global.service.cursor.PageResponse; import java.util.List; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; /** 알코올 조회 질의에 관한 애그리거트를 정의합니다. */ @@ -37,7 +36,7 @@ public interface AlcoholQueryRepository { List findAllCategories(AlcoholType type); - List> findAllCategoryPairs(); + List findAllCategoryItems(); Boolean existsByAlcoholId(Long alcoholId); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java new file mode 100644 index 000000000..53273ad06 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java @@ -0,0 +1,3 @@ +package app.bottlenote.alcohols.dto.response; + +public record CategoryPairItem(String korCategory, String engCategory) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java index f573d83eb..8aaea82d0 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java @@ -6,18 +6,18 @@ import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; +import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; public interface CustomAlcoholQueryRepository { - List> findAllCategoryPairs(); + List findAllCategoryItems(); PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java index 0e2430d1c..cf9f1ba7d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java @@ -16,6 +16,7 @@ import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; +import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.global.service.cursor.CursorPageable; import app.bottlenote.global.service.cursor.CursorResponse; @@ -30,7 +31,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.AllArgsConstructor; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -40,18 +40,20 @@ public class CustomAlcoholQueryRepositoryImpl implements CustomAlcoholQueryRepos private final JPAQueryFactory queryFactory; private final AlcoholQuerySupporter supporter; - /** 모든 카테고리 페어(한글, 영문) 조회 */ + /** 모든 카테고리(한글, 영문, 그룹) 조회 — 카테고리 레퍼런스 응답용 */ @Override - public List> findAllCategoryPairs() { + public List findAllCategoryItems() { return queryFactory - .select(alcohol.korCategory, alcohol.engCategory) + .select( + Projections.constructor( + CategoryItem.class, + alcohol.korCategory, + alcohol.engCategory, + alcohol.categoryGroup)) .from(alcohol) - .groupBy(alcohol.korCategory, alcohol.engCategory) + .groupBy(alcohol.korCategory, alcohol.engCategory, alcohol.categoryGroup) .orderBy(alcohol.korCategory.asc()) - .fetch() - .stream() - .map(tuple -> Pair.of(tuple.get(alcohol.korCategory), tuple.get(alcohol.engCategory))) - .toList(); + .fetch(); } /** queryDSL 알코올 검색 */ diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java index 0fbc2b56c..dc9f50058 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java @@ -1,5 +1,6 @@ package app.bottlenote.alcohols.service; +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; import app.bottlenote.alcohols.constant.SearchSortType; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; @@ -13,6 +14,8 @@ import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailResponse; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; +import app.bottlenote.alcohols.dto.response.CategoryItem; +import app.bottlenote.alcohols.dto.response.CategoryPairItem; import app.bottlenote.alcohols.dto.response.ExploreStandardResponse; import app.bottlenote.alcohols.dto.response.FriendsDetailResponse; import app.bottlenote.alcohols.exception.AlcoholException; @@ -25,11 +28,13 @@ import app.bottlenote.review.facade.ReviewFacade; import app.bottlenote.user.facade.FollowFacade; import app.bottlenote.user.facade.payload.FriendItem; +import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -118,9 +123,22 @@ public GlobalResponse searchAdminAlcohols(AdminAlcoholSearchRequest request) { return GlobalResponse.fromPage(alcoholQueryRepository.searchAdminAlcohols(request)); } + /** 카테고리 레퍼런스를 categoryGroup 기준으로 묶어 반환한다. enum 선언 순서로 키가 고정되며, 데이터가 없는 그룹도 빈 리스트로 포함된다. */ @Transactional(readOnly = true) - public List> findAllCategoryPairs() { - return alcoholQueryRepository.findAllCategoryPairs(); + public Map> findAllCategoryReferenceMap() { + Map> grouped = + new EnumMap<>(AlcoholCategoryGroup.class); + for (AlcoholCategoryGroup group : AlcoholCategoryGroup.values()) { + grouped.put(group, new ArrayList<>()); + } + + for (CategoryItem item : alcoholQueryRepository.findAllCategoryItems()) { + AlcoholCategoryGroup group = item.categoryGroup(); + if (group == null) continue; + grouped.get(group).add(new CategoryPairItem(item.korCategory(), item.engCategory())); + } + + return grouped; } @Transactional(readOnly = true) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 7e1f0bafb..5f8b96aa1 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -19,7 +19,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.test.util.ReflectionTestUtils; @@ -75,11 +75,11 @@ public List findAllCategories(AlcoholType type) { } @Override - public List> findAllCategoryPairs() { + public List findAllCategoryItems() { return alcohols.values().stream() - .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .map(a -> new CategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup())) .distinct() - .toList(); + .collect(Collectors.toList()); } @Override diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index be8431fd5..f8cdbf7d5 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -18,7 +18,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; public class InMemoryAlcoholQueryRepository implements AlcoholQueryRepository { @@ -66,11 +66,11 @@ public List findAllCategories(AlcoholType type) { } @Override - public List> findAllCategoryPairs() { + public List findAllCategoryItems() { return alcohols.values().stream() - .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .map(a -> new CategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup())) .distinct() - .toList(); + .collect(Collectors.toList()); } @Override From 79f3d97d984f0193900f365b5be069f03738c1f3 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 13:04:04 +0900 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=20grouped=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20plan=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan/admin-category-reference-grouped.md | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 plan/admin-category-reference-grouped.md diff --git a/plan/admin-category-reference-grouped.md b/plan/admin-category-reference-grouped.md new file mode 100644 index 000000000..c6b1fe2dd --- /dev/null +++ b/plan/admin-category-reference-grouped.md @@ -0,0 +1,201 @@ +# Plan: 어드민 카테고리 레퍼런스 응답 구조 grouped Map 변경 (issue #221) + +## Overview + +어드민 위스키 등록/수정(`POST/PUT /admin/api/v1/alcohols`)이 필수로 요구하는 `categoryGroup`(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)을 카테고리 레퍼런스 API에서 함께 내려주도록 응답 구조를 변경한다. + +현재 `GET /admin/api/v1/alcohols/categories/reference` 응답은 `[{korCategory, engCategory}, ...]` 형태이며, 프론트엔드에서 `korCategory ↔ categoryGroup` 매핑을 하드코딩하고 있어 카테고리 추가/변경 시 동기화 누락 문제가 발생한다 (#220 위스키 등록 오류 원인). + +응답 구조는 1안(flat array) / 2안(grouped map) 중 **2안**으로 결정되었다. `korCategory`/`engCategory`가 서버 입장에서 자유 입력이라 같은 그룹에 여러 표기가 들어올 수 있으므로, `categoryGroup` 1:N 관계를 자료구조로 명확히 표현한다. + +### 결정된 응답 형식 + +```json +{ + "SINGLE_MALT": [ + { "korCategory": "싱글 몰트", "engCategory": "Single Malt" } + ], + "BLEND": [ + { "korCategory": "블렌디드", "engCategory": "Blend" } + ], + "BLENDED_MALT": [ + { "korCategory": "블렌디드 몰트", "engCategory": "Blended Malt" } + ], + "BOURBON": [ + { "korCategory": "버번", "engCategory": "Bourbon" } + ], + "RYE": [], + "OTHER": [ + { "korCategory": "테네시", "engCategory": "Tennessee" }, + { "korCategory": "콘", "engCategory": "Corn" } + ] +} +``` + +### Assumptions + +1. **응답 타입**: `Map>` 형태로 변경한다 (CategoryPairItem은 `{korCategory, engCategory}` record). +2. **빈 그룹 포함**: 데이터가 0건인 그룹도 응답에 빈 배열 `[]`로 포함하여 6개 enum 키 모두 항상 응답에 존재한다. +3. **키 순서 보장**: 응답 키 순서는 `AlcoholCategoryGroup` enum 선언 순서(SINGLE_MALT → BLEND → BLENDED_MALT → BOURBON → RYE → OTHER)를 따른다. `LinkedHashMap` + enum 순회로 구현한다. +4. **Breaking Change 허용**: 기존 List 응답 클라이언트는 프론트엔드 어드민 페이지 단일이며, 이슈 본문에 따라 백엔드 변경 후 프론트도 동시에 마이그레이션되므로 응답 형식 변경(breaking change)을 허용한다. v2 신규 엔드포인트를 만들지 않는다. +5. **type 파라미터 없음**: 기존 엔드포인트는 type 필터 없이 전체 카테고리를 반환한다. 본 변경에서도 이를 유지한다. +6. **데이터 소스**: 기존 QueryDSL 메서드(`findAllCategoryPairs`)를 확장하여 `alcohol.categoryGroup` 컬럼을 함께 select한다. 별도 마스터 테이블이나 schema 변경은 없다. +7. **DTO 신규 정의**: 응답 내부 항목은 기존 `CategoryItem(korCategory, engCategory, categoryGroup)`이 아닌, `categoryGroup`이 빠진 새 record(`CategoryPairItem` 또는 유사 명칭)를 정의한다 — 그룹핑 후 항목 안에 `categoryGroup`을 또 넣을 필요가 없기 때문. +8. **캐싱**: 본 엔드포인트는 현재 캐싱되지 않는다(어드민 전용). 캐시 정책 변경 없음. +9. **인증/인가**: 기존 admin-api 보안 설정(`/admin/api/v1` context-path, 어드민 RBAC)을 유지한다. + +→ 위 가정 9가지 확인 완료 (사용자 승인). + +### 결정 사항 (2026-05-06 확정) + +| # | 결정 | 적용 | +|---|------|------| +| 1 | 기존 `findAllCategoryPairs()` 시그니처 변경 허용 | 신규 메서드 추가 X. 다만 영향받는 테스트(`AdminAlcoholsControllerDocsTest`, `InMemoryAlcoholQueryRepository` 2곳)를 빠짐없이 동기화한다. | +| 2 | grouping 로직 위치 | **Service 레이어**(`AlcoholQueryService`)에서 `Map>` 형태로 반환. 컨트롤러는 단순 위임. | +| 3 | 응답 래퍼 클래스 | 도입하지 않음. 컨트롤러에서 `Map`을 `GlobalResponse.ok()`로 그대로 감싸 반환. **최종 JSON 응답 key가 enum 선언 순서로 정확히 직렬화되는 것만 보장**한다. | + +### Success Criteria + +1. `GET /admin/api/v1/alcohols/categories/reference` 응답이 `Map>` 형태의 JSON 객체로 반환된다. +2. 응답에 6개 키(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)가 enum 선언 순서대로 항상 포함된다. +3. DB에 데이터가 0건인 그룹은 빈 배열 `[]`로 응답된다 (키 자체는 누락되지 않음). +4. 응답 항목은 `korCategory` 오름차순으로 정렬된다 (그룹 내 순서 안정성). +5. `JpaAlcoholQueryRepository.findAllCategories(AlcoholType)`(상품 API용)는 기존 동작과 시그니처를 유지한다. +6. `AdminAlcoholsControllerDocsTest`가 새 응답 구조 기준으로 통과한다. +7. `InMemoryAlcoholQueryRepository`(admin/product 양쪽) Fake 구현이 새 메서드 시그니처에 맞춰 동기화된다. +8. RestDocs 문서가 새 응답 구조를 반영한다. +9. `./gradlew :bottlenote-admin-api:test`와 mono 단위 테스트가 모두 통과한다. + +### Impact Scope + +#### 모듈 +- **bottlenote-admin-api** (Kotlin, presentation) +- **bottlenote-mono** (Java, domain/service/repository/dto) +- **bottlenote-product-api** (테스트 픽스처 영향만, 컨트롤러 변경 없음) + +#### 변경 파일 + +**bottlenote-mono (main)** +- `dto/response/CategoryItem.java` — 유지 (product-api `findAllCategories(type)`에서 계속 사용) +- `dto/response/CategoryPairItem.java` (신규) — `{korCategory, engCategory}` record +- `dto/response/CategoryReferenceResponse.java` (신규, 선택) — Map 응답 래퍼 또는 typealias +- `domain/AlcoholQueryRepository.java` — `findAllCategoryPairs()` 시그니처 변경 또는 신규 메서드 추가 +- `repository/CustomAlcoholQueryRepository.java` — 동일 +- `repository/CustomAlcoholQueryRepositoryImpl.java` — QueryDSL select에 `categoryGroup` 추가, 반환 타입 변경 +- `service/AlcoholQueryService.java` — `findAllCategoryPairs()` 반환 타입 변경, grouping 로직 추가 (또는 컨트롤러 위임) + +**bottlenote-admin-api (main)** +- `presentation/AdminAlcoholsController.kt` — `getCategoryReference()` 응답 변환 로직 변경 (`mapOf` 제거, Map 직접 반환) + +**bottlenote-mono (test)** +- `fixture/InMemoryAlcoholQueryRepository.java` — 시그니처 동기화 + +**bottlenote-product-api (test)** +- `fixture/InMemoryAlcoholQueryRepository.java` — 시그니처 동기화 + +**bottlenote-admin-api (test)** +- `app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt` — 응답 구조 변경, RestDocs descriptor 갱신 + +#### 비변경 영역 +- 엔티티 / 스키마 / Liquibase 마이그레이션: 변경 없음 (`alcohol.category_group` 컬럼 이미 존재) +- 도메인 이벤트: 영향 없음 +- 캐시: 영향 없음 (해당 엔드포인트 미캐싱) +- 보안 설정: 영향 없음 + +#### 테스트 종류 +- 단위 테스트: `CustomAlcoholQueryRepositoryImpl` grouping 로직 (필요 시) +- 통합 테스트: `AdminAlcoholsControllerDocsTest`에서 응답 구조 검증 + RestDocs +- 아키텍처 규칙 테스트: 영향 없음 + +--- + +### 외부 영향 (참고) +- 프론트엔드 어드민(`alcohol.api.ts`)의 `GROUP_TO_CATEGORY`, `CATEGORY_TO_GROUP_MAP` 하드코딩 제거 후속 작업 예정 (별도 PR/이슈) +- #220 위스키 등록 오류는 프론트가 기존 하드코딩으로 우선 수정한 상태이며, 본 변경 후 단일 소스화로 재발 방지 + +--- + +## Tasks + +### Task 1: 응답 항목 DTO 추가 +- **목적**: 그룹 내부 항목용 record 정의 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java` (신규) +- **내용**: `public record CategoryPairItem(String korCategory, String engCategory) {}` +- **검증**: 컴파일 통과 + +### Task 2: 도메인/QueryDSL 레포지토리 시그니처 변경 +- **목적**: 레포지토리는 grouping 책임 없이 `categoryGroup` 포함한 raw 데이터만 반환 +- **결정**: 메서드명 `findAllCategoryPairs` → `findAllCategoryItems`로 의도 명확화. 반환 타입 `List>` → `List` (기존 `CategoryItem(korCategory, engCategory, categoryGroup)` 재사용 — type 파라미터 없는 전체 조회 용도) +- **파일**: + - `bottlenote-mono/.../domain/AlcoholQueryRepository.java` — 시그니처 변경 + - `bottlenote-mono/.../repository/CustomAlcoholQueryRepository.java` — 시그니처 변경 + - `bottlenote-mono/.../repository/CustomAlcoholQueryRepositoryImpl.java` — QueryDSL select에 `alcohol.categoryGroup` 추가, `Projections.constructor(CategoryItem.class, ...)` 또는 tuple → CategoryItem 매핑, group by에 categoryGroup 추가, order by `korCategory.asc()` 유지 +- **검증**: 단위 테스트로 grouping 입력 데이터 형태 확인 가능 + +### Task 3: Service 계층 grouping 로직 추가 +- **목적**: enum 선언 순서 + 빈 그룹 `[]` 보장하는 `LinkedHashMap` 구성 +- **파일**: `bottlenote-mono/.../service/AlcoholQueryService.java` +- **메서드 변경**: `findAllCategoryPairs()` → `findAllCategoryReferenceMap()` (또는 적절한 명칭). 반환 타입 `Map>` +- **구현**: + 1. `repository.findAllCategoryItems()` 호출 + 2. `LinkedHashMap>` 생성 + 3. `AlcoholCategoryGroup.values()` 순회하며 빈 `ArrayList` 초기화 (키 순서 + 빈 그룹 `[]` 보장) + 4. 조회 결과를 `categoryGroup` 기준으로 해당 리스트에 `new CategoryPairItem(korCategory, engCategory)` 추가 + 5. 그룹 내 `korCategory` 오름차순 정렬 (DB order by가 이미 보장하지만 방어적으로) +- **검증**: 단위 테스트 (Fake 레포지토리로 grouping 결과 검증) + +### Task 4: Admin 컨트롤러 응답 변경 +- **파일**: `bottlenote-admin-api/.../presentation/AdminAlcoholsController.kt` +- **변경**: + - `getCategoryReference()` 내부의 `mapOf` 변환 제거 + - `alcoholQueryService.findAllCategoryReferenceMap()` 결과를 `GlobalResponse.ok()`에 그대로 전달 +- **검증**: Jackson 직렬화 시 `LinkedHashMap` 순서 유지 확인 (enum 선언 순) + +### Task 5: 테스트 Fake 구현 동기화 +- **목적**: 시그니처 변경된 메서드를 InMemory 구현체에 반영 +- **파일**: + - `bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java` + - `bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java` +- **구현**: 기존 `findAllCategoryPairs` 메서드를 새 시그니처로 교체. `alcohols.values().stream().map(a -> new CategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup())).distinct().toList()` + +### Task 6: AdminAlcoholsControllerDocsTest 갱신 +- **파일**: `bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt` +- **변경**: + - `given(alcoholQueryService.findAllCategoryReferenceMap())` stub을 `LinkedHashMap` 형태로 구성 (모든 enum 키 포함, 일부는 빈 리스트) + - RestDocs `responseFields` 갱신: `data[].korCategory` 형태 → `data.SINGLE_MALT[].korCategory` / `data.BLEND[].korCategory` 형태로 각 enum 키 6개에 대해 documenting (또는 동적 패턴) + - 응답 키 순서 검증 추가 가능 시 추가 +- **검증**: `./gradlew :bottlenote-admin-api:asciidoctor` 통과 + +### Task 7: 검증 (/verify full) +- **명령**: + - `./gradlew :bottlenote-mono:test` (단위/통합) + - `./gradlew :bottlenote-admin-api:test` + - `./gradlew :bottlenote-product-api:test` (Fake 동기화 영향 확인) + - `./gradlew :bottlenote-admin-api:asciidoctor` +- **성공 기준**: 모든 테스트 통과, RestDocs 빌드 성공 + +## Implementation Order +Task 1 → Task 2 → Task 3 → Task 5 (Fake 먼저 컴파일 깨짐 방지) → Task 4 → Task 6 → Task 7 + +> 주의: Task 2에서 시그니처를 바꾸면 Task 3, 4, 5가 모두 컴파일 에러 상태가 됨. Task 5(Fake 동기화)를 먼저 끝내고 Task 4(컨트롤러)로 넘어가야 admin-api 모듈 컴파일이 복구됨. + +## Progress Log + +### 2026-05-06 +- Task 1 완료: `CategoryPairItem` record 신규 (`bottlenote-mono/.../dto/response/CategoryPairItem.java`) +- Task 2 완료: 레포지토리 시그니처 변경 + - `AlcoholQueryRepository`, `CustomAlcoholQueryRepository`: `findAllCategoryPairs(): List>` → `findAllCategoryItems(): List` + - `CustomAlcoholQueryRepositoryImpl`: QueryDSL select에 `alcohol.categoryGroup` 추가, `Projections.constructor(CategoryItem.class, ...)`로 매핑 +- Task 3 완료: `AlcoholQueryService.findAllCategoryReferenceMap()` 추가 + - `EnumMap>`로 enum 선언 순서 + 빈 그룹 `[]` 보장 +- Task 5 완료: Fake 동기화 (`bottlenote-mono/.../fixture/InMemoryAlcoholQueryRepository.java`, `bottlenote-product-api/.../fixture/InMemoryAlcoholQueryRepository.java`) +- Task 4 완료: `AdminAlcoholsController.kt`의 `getCategoryReference()`를 단순 위임으로 변경 (`mapOf` 제거) +- Task 6 완료: `AdminAlcoholsControllerDocsTest`의 stub과 RestDocs `responseFields` 갱신 +- Task 7 완료: 검증 + - `:bottlenote-mono:compileJava`, `:bottlenote-admin-api:compileKotlin` BUILD SUCCESSFUL + - `:bottlenote-admin-api:test` 전체 통과 (카테고리 레퍼런스 테스트 포함) + - `:bottlenote-mono:unit_test`, `:bottlenote-product-api:unit_test` BUILD SUCCESSFUL + - 실제 RestDocs 응답 결과(`build/generated-snippets/admin/alcohols/category-reference/response-body.adoc`)가 의도한 grouped 구조와 정확히 일치 (enum 선언 순서, `RYE: []` 포함) + - `:bottlenote-admin-api:asciidoctor` BUILD SUCCESSFUL + - `:bottlenote-mono:integration_test` BUILD SUCCESSFUL + - `:bottlenote-product-api:integration_test` BUILD SUCCESSFUL (4m 6s) From 7832bcc02661fb12361af5978446c55eeefb21fc Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 13:07:52 +0900 Subject: [PATCH 3/9] =?UTF-8?q?docs:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20http=20=EC=83=98=ED=94=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\355\215\274\353\237\260\354\212\244.http" | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" new file mode 100644 index 000000000..754e5020e --- /dev/null +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" @@ -0,0 +1,21 @@ +### 카테고리 레퍼런스 조회 (categoryGroup별 grouped Map) +# 응답은 AlcoholCategoryGroup enum(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)을 +# key로 갖고, 각 그룹에 속한 {korCategory, engCategory} 리스트를 value로 갖는다. +# 데이터가 없는 그룹도 빈 배열([])로 응답에 포함된다. +# +# 위스키 등록/수정(POST/PUT /alcohols) 시 사용해야 하는 categoryGroup 값을 +# 이 응답에서 단일 소스로 참조한다. +# +# 예시 응답: +# { +# "data": { +# "SINGLE_MALT": [{ "korCategory": "싱글 몰트", "engCategory": "Single Malt" }], +# "BLEND": [{ "korCategory": "블렌디드", "engCategory": "Blend" }], +# "BLENDED_MALT":[{ "korCategory": "블렌디드 몰트", "engCategory": "Blended Malt" }], +# "BOURBON": [{ "korCategory": "버번", "engCategory": "Bourbon" }], +# "RYE": [], +# "OTHER": [{ "korCategory": "테네시", "engCategory": "Tennessee" }] +# } +# } +GET {{host}}/alcohols/categories/reference +Authorization: Bearer {{accessToken}} From 850e6815a2f5af6ce3ad4ce417cfe2dcfaadd546 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 13:24:55 +0900 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=98=ED=94=8C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=A0=88=ED=8D=BC=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20=EB=AC=B8=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0\210\355\215\274\353\237\260\354\212\244.http" | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" index 754e5020e..13cc5f30d 100644 --- "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" @@ -1,3 +1,17 @@ +### 로그인 (accessToken 발급 — 카테고리 레퍼런스 조회 전에 먼저 실행) +POST {{host}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); + client.global.set("refreshToken", response.body.data.refreshToken); +%} + ### 카테고리 레퍼런스 조회 (categoryGroup별 grouped Map) # 응답은 AlcoholCategoryGroup enum(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)을 # key로 갖고, 각 그룹에 속한 {korCategory, engCategory} 리스트를 value로 갖는다. From 2a5a5c1e438f504ab43123e2aee198492cd78a0b Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 13:48:00 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20grouped=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/alcohols/AdminAlcoholsIntegrationTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index 69a775054..a678a6f1f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -256,11 +256,11 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { @Test @DisplayName("동일 한글 카테고리에 다른 영문 카테고리가 별도로 조회된다") fun differentEngCategoriesAreSeparate() { - // given - 같은 한글 카테고리, 다른 영문 카테고리 + // given - 같은 한글 카테고리, 다른 영문 카테고리 (둘 다 SINGLE_MALT 그룹) alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "-") - // when & then - 2개의 다른 페어가 반환됨 + // when & then - 그룹된 응답에서 SINGLE_MALT 그룹에 2개의 다른 페어가 반환됨 assertThat( mockMvcTester .get() @@ -268,7 +268,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() - .extractingPath("$.data.length()") + .extractingPath("$.data.SINGLE_MALT.length()") .isEqualTo(2) } } From f650a82bbd68d517aa23f7c766502bc9b3483b0e Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 15:31:01 +0900 Subject: [PATCH 6/9] feat: enable response compression in api services Added response compression settings to the application.yml files for both the product and admin APIs. This improves performance by reducing the size of responses for specified MIME types when the response size exceeds 1024 bytes. Configuration includes enabling compression and specifying supported MIME types. --- bottlenote-admin-api/src/main/resources/application.yml | 4 ++++ bottlenote-product-api/src/main/resources/application.yml | 4 ++++ git.environment-variables | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bottlenote-admin-api/src/main/resources/application.yml b/bottlenote-admin-api/src/main/resources/application.yml index 07ecf73a4..91a07ec1e 100644 --- a/bottlenote-admin-api/src/main/resources/application.yml +++ b/bottlenote-admin-api/src/main/resources/application.yml @@ -13,6 +13,10 @@ server: context-path: /admin/api/v1 encoding: charset: UTF-8 + compression: + enabled: true + mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css + min-response-size: 1024 tomcat: threads: max: 15 diff --git a/bottlenote-product-api/src/main/resources/application.yml b/bottlenote-product-api/src/main/resources/application.yml index 9f6246e9b..f878262c4 100644 --- a/bottlenote-product-api/src/main/resources/application.yml +++ b/bottlenote-product-api/src/main/resources/application.yml @@ -14,6 +14,10 @@ server: servlet: encoding: charset: UTF-8 + compression: + enabled: true + mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css + min-response-size: 1024 tomcat: threads: max: 15 diff --git a/git.environment-variables b/git.environment-variables index 57cd5c5bb..bceaae315 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 57cd5c5bbeb473be1c2b1036e6ea718a2f10227b +Subproject commit bceaae315300104f60aa9b3b6d11cb5c6e03787a From bf6145d32377d4fd24295b4cd8c386dc68ca4d77 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 16:16:44 +0900 Subject: [PATCH 7/9] chore: version update --- bottlenote-admin-api/VERSION | 2 +- bottlenote-product-api/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 9084fa2f7..524cb5524 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 9084fa2f7..524cb5524 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 From 2257a4283fe7e4161b51b7042637fc379032ee39 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 16:16:57 +0900 Subject: [PATCH 8/9] feat: Add default sort order field to Region and Distillery Introduce a `sortOrder` field with a default value of 9999 in the `Region` and `Distillery` entities to facilitate custom ordering. This change ensures a consistent sorting mechanism while maintaining backward compatibility. --- .../app/bottlenote/alcohols/domain/Distillery.java | 10 ++++++++-- .../java/app/bottlenote/alcohols/domain/Region.java | 5 +++++ git.environment-variables | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java index 9743db4df..2ef479865 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java @@ -8,8 +8,6 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import java.util.ArrayList; -import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,6 +15,9 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; +import java.util.ArrayList; +import java.util.List; + @Getter @Builder @AllArgsConstructor @@ -43,6 +44,11 @@ public class Distillery extends BaseEntity { @Column(name = "logo_img_url") private String logoImgPath; + @Builder.Default + @Comment("정렬 순서 (미설정: 9999)") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 9999; + @Builder.Default @OneToMany(mappedBy = "distillery") private List alcohol = new ArrayList<>(); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java index 537e3258e..fee6da819 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java @@ -45,6 +45,11 @@ public class Region extends BaseEntity { @Column(name = "description", nullable = true) private String description; + @Builder.Default + @Comment("정렬 순서 (미설정: 9999)") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 9999; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") @Comment("상위 지역") diff --git a/git.environment-variables b/git.environment-variables index bceaae315..a1d2ce555 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit bceaae315300104f60aa9b3b6d11cb5c6e03787a +Subproject commit a1d2ce555d7aa84ed0a08fe92a73f6e67f5a9a14 From 8b6da61cdad662da9dc3fcd9cfdae64fbdeedb27 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 6 May 2026 16:36:31 +0900 Subject: [PATCH 9/9] feat: Expose `sortOrder` in Region and Distillery API responses Added the `sortOrder` field to the API responses of `Region` (`GET /api/v1/regions`, `GET /regions`) and `Distillery` (`GET /distilleries`) with a default value of 9999. This change ensures backward compatibility while enabling clients to access custom sorting information. --- .../AdminDistilleryControllerDocsTest.kt | 1 + .../alcohols/AdminRegionControllerDocsTest.kt | 1 + .../app/helper/alcohols/AlcoholsHelper.kt | 12 ++- .../AdminReferenceDataIntegrationTest.kt | 42 +++++++++-- .../dto/response/AdminDistilleryItem.java | 3 +- .../dto/response/AdminRegionItem.java | 3 +- .../alcohols/dto/response/RegionsItem.java | 1 + .../repository/JpaDistilleryRepository.java | 2 +- .../repository/JpaRegionQueryRepository.java | 9 ++- .../alcohols/service/RegionServiceTest.java | 19 +++-- .../alcohols/RestReferenceControllerTest.java | 39 +++++----- plan/distillery-region-sort-order-response.md | 75 +++++++++++++++++++ 12 files changed, 163 insertions(+), 44 deletions(-) create mode 100644 plan/distillery-region-sort-order-response.md diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt index 410ee27a1..359df4d04 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt @@ -76,6 +76,7 @@ class AdminDistilleryControllerDocsTest { fieldWithPath("data[].logoImgUrl").type(JsonFieldType.STRING).description("로고 이미지 URL"), fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (미설정: 9999)"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt index 337ced4fa..17ec747ff 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -78,6 +78,7 @@ class AdminRegionControllerDocsTest { fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), fieldWithPath("data[].parentId").type(JsonFieldType.NUMBER).description("상위 지역 ID").optional(), + fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (미설정: 9999)"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 9f85a2c4e..e7d47ceae 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -2,12 +2,8 @@ package app.helper.alcohols import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.constant.AlcoholType -import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse +import app.bottlenote.alcohols.dto.response.* import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse.TastingTagInfo -import app.bottlenote.alcohols.dto.response.AdminAlcoholItem -import app.bottlenote.alcohols.dto.response.AdminDistilleryItem -import app.bottlenote.alcohols.dto.response.AdminRegionItem -import app.bottlenote.alcohols.dto.response.TastingTagNodeItem import app.bottlenote.global.data.response.GlobalResponse import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime @@ -151,7 +147,8 @@ object AlcoholsHelper { "지역 설명 $i", LocalDateTime.of(2024, 1, i, 0, 0), LocalDateTime.of(2024, 6, i, 0, 0), - null + null, + 9999 ) } @@ -162,7 +159,8 @@ object AlcoholsHelper { listOf("Glenfiddich", "Macallan", "Yamazaki")[i - 1], "https://example.com/logo$i.png", LocalDateTime.of(2024, 1, i, 0, 0), - LocalDateTime.of(2024, 6, i, 0, 0) + LocalDateTime.of(2024, 6, i, 0, 0), + 9999 ) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt index 0f568561e..0853ac063 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt @@ -3,11 +3,7 @@ package app.integration.alcohols import app.IntegrationTestSupport import app.bottlenote.alcohols.fixture.AlcoholTestFactory import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.* import org.springframework.beans.factory.annotation.Autowired @Tag("admin_integration") @@ -113,6 +109,24 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { mockMvcTester.get().uri("/regions") ).hasStatus4xxClientError() } + + @Test + @DisplayName("지역 응답에 sortOrder 필드가 포함되며 미설정 시 9999가 반환된다") + fun getRegionsResponseIncludesSortOrderDefault() { + // given - 시드는 sort_order 미지정으로 INSERT, DB default 9999 적용 + alcoholTestFactory.persistAlcohols(1) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/regions?page=0&size=10") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data[0].sortOrder") + .isEqualTo(9999) + } } @Nested @@ -160,5 +174,23 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { mockMvcTester.get().uri("/distilleries") ).hasStatus4xxClientError() } + + @Test + @DisplayName("증류소 응답에 sortOrder 필드가 포함되며 미설정 시 9999가 반환된다") + fun getDistilleriesResponseIncludesSortOrderDefault() { + // given - 시드는 sort_order 미지정으로 INSERT, DB default 9999 적용 + alcoholTestFactory.persistAlcohols(1) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/distilleries?page=0&size=20") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data[0].sortOrder") + .isEqualTo(9999) + } } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java index 8e1c5503c..8257273a4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java @@ -8,4 +8,5 @@ public record AdminDistilleryItem( String engName, String logoImgUrl, LocalDateTime createdAt, - LocalDateTime modifiedAt) {} + LocalDateTime modifiedAt, + Integer sortOrder) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java index f310ac599..3cc2dda8b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java @@ -10,4 +10,5 @@ public record AdminRegionItem( String description, LocalDateTime createdAt, LocalDateTime modifiedAt, - Long parentId) {} + Long parentId, + Integer sortOrder) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java index 613c778f3..02ca304d1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java @@ -11,4 +11,5 @@ public class RegionsItem { private final String engName; private final String description; private final Long parentId; + private final Integer sortOrder; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java index 16f4d35eb..ae3b9a6e1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java @@ -18,7 +18,7 @@ public interface JpaDistilleryRepository @Query( """ select new app.bottlenote.alcohols.dto.response.AdminDistilleryItem( - d.id, d.korName, d.engName, d.logoImgPath, d.createAt, d.lastModifyAt + d.id, d.korName, d.engName, d.logoImgPath, d.createAt, d.lastModifyAt, d.sortOrder ) from distillery d where (:keyword is null or :keyword = '' diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java index ef4c3d300..bd96716fe 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java @@ -5,21 +5,22 @@ import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; -import java.util.Collection; -import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; +import java.util.Collection; +import java.util.List; + @JpaRepositoryImpl public interface JpaRegionQueryRepository extends RegionRepository, CrudRepository { @Override @Query( """ - select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description, r.parent.id) + select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description, r.parent.id, r.sortOrder) from region r order by r.id asc """) List findAllRegionsResponse(); @@ -28,7 +29,7 @@ public interface JpaRegionQueryRepository extends RegionRepository, CrudReposito @Query( """ select new app.bottlenote.alcohols.dto.response.AdminRegionItem( - r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt, r.parent.id + r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt, r.parent.id, r.sortOrder ) from region r where (:keyword is null or :keyword = '' diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java index 67c8aff38..b651c90a6 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java @@ -1,10 +1,7 @@ package app.bottlenote.alcohols.service; -import static org.mockito.Mockito.when; - import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.alcohols.repository.JpaRegionQueryRepository; -import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -14,6 +11,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; + +import static org.mockito.Mockito.when; + @Tag("unit") @DisplayName("[unit] [service] RegionService") @ExtendWith(MockitoExtension.class) @@ -29,16 +30,17 @@ void testFindAll() { // given List response = List.of( - RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L), + RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L, 9999), RegionsItem.of( 2L, "스코틀랜드/하이랜드", "Scotland/Highlands", "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지", - 19L), - RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L), - RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null), - RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null)); + 19L, + 9999), + RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L, 9999), + RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null, 9999), + RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null, 9999)); // When when(regionQueryRepository.findAllRegionsResponse()).thenReturn(response); @@ -47,5 +49,6 @@ void testFindAll() { List regions = regionService.findAllRegion(); Assertions.assertEquals(response.size(), regions.size()); Assertions.assertEquals(response.get(0).getRegionId(), regions.get(0).getRegionId()); + Assertions.assertEquals(9999, regions.get(0).getSortOrder()); } } diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java index d37ca0bd7..590150dde 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java @@ -1,5 +1,18 @@ package app.docs.alcohols; +import app.bottlenote.alcohols.controller.AlcoholReferenceController; +import app.bottlenote.alcohols.dto.response.CategoryItem; +import app.bottlenote.alcohols.dto.response.RegionsItem; +import app.bottlenote.alcohols.fixture.AlcoholQueryFixture; +import app.bottlenote.alcohols.service.AlcoholReferenceService; +import app.docs.AbstractRestDocs; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.List; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -12,18 +25,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import app.bottlenote.alcohols.controller.AlcoholReferenceController; -import app.bottlenote.alcohols.dto.response.CategoryItem; -import app.bottlenote.alcohols.dto.response.RegionsItem; -import app.bottlenote.alcohols.fixture.AlcoholQueryFixture; -import app.bottlenote.alcohols.service.AlcoholReferenceService; -import app.docs.AbstractRestDocs; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - @DisplayName("알코올 참조 컨트롤러 RestDocs용 테스트") class RestReferenceControllerTest extends AbstractRestDocs { @@ -41,16 +42,17 @@ void docs_1() throws Exception { // given List response = List.of( - RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L), + RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L, 9999), RegionsItem.of( 2L, "스코틀랜드/하이랜드", "Scotland/Highlands", "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지", - 19L), - RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L), - RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null), - RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null)); + 19L, + 9999), + RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L, 9999), + RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null, 9999), + RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null, 9999)); // when when(referenceService.findAllRegion()).thenReturn(response); @@ -72,6 +74,9 @@ void docs_1() throws Exception { .type(JsonFieldType.NUMBER) .description("상위 지역 ID") .optional(), + fieldWithPath("data[].sortOrder") + .type(JsonFieldType.NUMBER) + .description("정렬 순서 (미설정: 9999)"), fieldWithPath("errors") .type(JsonFieldType.ARRAY) .description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"), diff --git a/plan/distillery-region-sort-order-response.md b/plan/distillery-region-sort-order-response.md new file mode 100644 index 000000000..300ccbf79 --- /dev/null +++ b/plan/distillery-region-sort-order-response.md @@ -0,0 +1,75 @@ +# Plan: Distillery/Region 응답에 sortOrder 노출 + +## Overview + +이미 DB 컬럼 및 엔티티 필드까지 추가가 완료된 `Distillery.sortOrder`, `Region.sortOrder` 값을 +**조회 API 응답**에 노출시킨다. 본 작업은 **응답값 확장만** 다루며, +어드민의 sortOrder 수정 기능은 별도 PR로 분리한다. + +### 선행 완료 사항 (참고) +- Liquibase changeset `hgkim:20260506-1`, `hgkim:20260506-2` 적용 완료 (DEV/PROD) +- `Distillery.sortOrder: Integer` (default 9999, NOT NULL) 추가 완료 +- `Region.sortOrder: Integer` (default 9999, NOT NULL) 추가 완료 + +### Assumptions + +1. **응답 노출 대상은 "distillery/region 자체 조회 엔드포인트"에 한정한다.** + - product-api: `GET /api/v1/regions` (`RegionsItem`) + - admin-api: `GET /distilleries` (`AdminDistilleryItem`) + - admin-api: `GET /regions` (`AdminRegionItem`) + - **product-api의 `Alcohol` 조회 응답 내 중첩된 distillery/region 정보(korName/engName만 노출 중)에는 추가하지 않는다.** + -> 별도 요구가 있을 때 확장 +2. product-api에는 distillery 자체 조회 엔드포인트가 존재하지 않으므로 distillery는 admin-api에서만 노출된다. +3. `RegionsItem`은 product-api 응답에 한 번만 사용되므로 필드 추가 시 다른 호출처 영향이 없다 (확인 완료). +4. 캐시 키 `local_cache_alcohol_region_information`(`AlcoholReferenceService.findAllRegion`)은 + 응답 구조가 바뀌므로 **무효화/구버전 직렬화 충돌 방지 필요** + -> 로컬(Caffeine) 캐시이므로 애플리케이션 재시작 시 자동 갱신 가정. 별도 조치 불필요. +5. 정렬 순서를 응답에 추가할 뿐이며, **DB 정렬(ORDER BY sort_order) 적용은 본 PR 범위 외**. + -> 정렬 적용은 후속 PR (또는 별도 의사결정) 대상. +6. 필드명은 응답에서 **`sortOrder`** (camelCase, JSON 직렬화 동일). + +-> 위 가정들 중 의도와 어긋나는 항목이 있으면 알려주세요. + 특히 **5번(정렬을 적용까지 할지 vs 노출만 할지)** 확인이 필요합니다. + +### Success Criteria + +- [ ] `GET /api/v1/regions` 응답 각 항목에 `sortOrder: number` 필드가 포함된다 (default 9999). +- [ ] 어드민 `GET /distilleries` 응답 각 항목에 `sortOrder: number` 필드가 포함된다. +- [ ] 어드민 `GET /regions` 응답 각 항목에 `sortOrder: number` 필드가 포함된다. +- [ ] 기존 응답 필드는 모두 유지된다 (BC 보장). +- [ ] 단위 테스트 / RestDocs 통합 테스트가 신규 필드를 검증한다. +- [ ] `./gradlew compileJava :bottlenote-admin-api:compileKotlin` 통과. +- [ ] `./gradlew test`(기본 태그) 통과. + +### Impact Scope + +**모듈** +- `bottlenote-mono` (DTO + Repository JPQL 수정) +- `bottlenote-product-api` (RestDocs 테스트 수정) +- `bottlenote-admin-api` (RestDocs 테스트 수정) + +**파일 (수정 예정)** +| 파일 | 변경 내용 | +|------|----------| +| `mono/.../alcohols/dto/response/RegionsItem.java` | `sortOrder` 필드 추가 | +| `mono/.../alcohols/dto/response/AdminRegionItem.java` | `sortOrder` 필드 추가 | +| `mono/.../alcohols/dto/response/AdminDistilleryItem.java` | `sortOrder` 필드 추가 | +| `mono/.../alcohols/repository/JpaRegionQueryRepository.java` | `findAllRegionsResponse` JPQL select 절에 `r.sortOrder` 추가 / `findAllRegions` 도 동일 | +| `mono/.../alcohols/repository/JpaDistilleryRepository.java` | `findAllDistilleries` JPQL select 절에 `d.sortOrder` 추가 | +| `product-api/.../alcohols/controller/AlcoholReferenceControllerTest.java` (또는 RestDocs 테스트) | 신규 필드 문서화 + 검증 | +| `admin-api/.../alcohols/presentation/AdminDistilleryControllerTest.kt` | 신규 필드 문서화 + 검증 | +| `admin-api/.../alcohols/presentation/AdminRegionControllerTest.kt` | 신규 필드 문서화 + 검증 | + +**도메인 / 이벤트 / 캐시** +- 도메인: `alcohols` +- 이벤트: 영향 없음 +- 캐시: `local_cache_alcohol_region_information` (Caffeine 로컬 캐시) — 앱 재기동 후 자동 무효화 + +**테스트 종류** +- 통합 테스트(RestDocs) 보강: 응답 필드 문서화 + 값 검증 +- 단위 테스트: DTO 생성자/필드 추가에 따른 기존 테스트가 깨지지 않는지 확인 + +**고려하지 않는 것 (Out of Scope)** +- 어드민의 sortOrder **수정 엔드포인트** (별도 PR) +- 응답을 sort_order **기준으로 ORDER BY 적용**하는 정렬 동작 (Assumption #5 참조) +- product-api의 Alcohol 조회 응답에 중첩된 distillery/region 정보로 sortOrder 전파 (Assumption #1 참조)