diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc index c702686de..c27cf276a 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc @@ -26,6 +26,33 @@ include::{snippets}/admin/alcohols/search/http-response.adoc[] ''' +=== 술(Alcohol) lookup 조회 === + +- 관리자 선택 컴포넌트에서 사용할 핵심 필드 기반의 고속 조회 API입니다. +- 별점, 좋아요, 리뷰 수, 사용자 pick 여부 같은 집계성 데이터는 포함하지 않습니다. + +[source] +---- +GET /admin/api/v1/alcohols/lookup +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/lookup/query-parameters.adoc[] +include::{snippets}/admin/alcohols/lookup/curl-request.adoc[] +include::{snippets}/admin/alcohols/lookup/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/lookup/response-fields.adoc[] +include::{snippets}/admin/alcohols/lookup/http-response.adoc[] + +''' + === 술(Alcohol) 단건 상세 조회 === - 관리자용 술 단건 상세 조회 API입니다. 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 19d22bc9f..6a94a585c 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 @@ -2,9 +2,12 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest +import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest import app.bottlenote.alcohols.service.AdminAlcoholCommandService +import app.bottlenote.alcohols.service.AlcoholLookupService import app.bottlenote.alcohols.service.AlcoholQueryService import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.service.meta.MetaService.createMetaInfo import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -13,8 +16,22 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/alcohols") class AdminAlcoholsController( private val alcoholQueryService: AlcoholQueryService, - private val adminAlcoholCommandService: AdminAlcoholCommandService + private val adminAlcoholCommandService: AdminAlcoholCommandService, + private val alcoholLookupService: AlcoholLookupService ) { + @GetMapping("/lookup") + fun getAlcoholLookups( + @ModelAttribute @Valid request: AlcoholLookupRequest + ): ResponseEntity<*> { + val response = alcoholLookupService.lookup(request) + return GlobalResponse.ok( + response.items(), + createMetaInfo() + .add("searchParameters", request) + .add("pageable", response.pageable()) + ) + } + @GetMapping fun searchAlcohols( @ModelAttribute request: AdminAlcoholSearchRequest 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 31d1f0338..199ced508 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,11 +4,15 @@ 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.request.AlcoholLookupRequest +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem import app.bottlenote.alcohols.dto.response.CategoryPairItem import app.bottlenote.alcohols.presentation.AdminAlcoholsController import app.bottlenote.alcohols.service.AdminAlcoholCommandService +import app.bottlenote.alcohols.service.AlcoholLookupService import app.bottlenote.alcohols.service.AlcoholQueryService import app.bottlenote.global.dto.response.AdminResultResponse +import app.bottlenote.global.service.cursor.CursorResponse import app.bottlenote.global.service.cursor.SortOrder import app.helper.alcohols.AlcoholsHelper import com.fasterxml.jackson.databind.ObjectMapper @@ -53,6 +57,95 @@ class AdminAlcoholsControllerDocsTest { @MockitoBean private lateinit var adminAlcoholCommandService: AdminAlcoholCommandService + @MockitoBean + private lateinit var alcoholLookupService: AlcoholLookupService + + @Test + @DisplayName("관리자용 술 lookup 목록을 조회할 수 있다") + fun lookupAlcohols() { + // given + val item = AlcoholLookupItem( + 1L, + "맥캘란 12년", + "Macallan 12", + "싱글몰트", + "Single Malt", + AlcoholCategoryGroup.SINGLE_MALT, + 1L, + "스페이사이드", + "Speyside", + 10L, + "맥캘란", + "Macallan", + "https://example.com/alcohol.png" + ) + given(alcoholLookupService.lookup(any(AlcoholLookupRequest::class.java))) + .willReturn(CursorResponse.of(listOf(item), 0L, 20)) + + // when & then + assertThat( + mvc.get().uri("/v1/alcohols/lookup") + .param("keyword", "macallan") + .param("category", AlcoholCategoryGroup.SINGLE_MALT.name) + .param("regionId", "1") + .param("distilleryId", "10") + .param("cursor", "0") + .param("pageSize", "20") + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/lookup", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").optional().description("검색어 (이름, 카테고리, 지역, 증류소 대상)"), + parameterWithName("category").optional().description("카테고리 그룹 필터. ALL 또는 미전달 시 전체"), + parameterWithName("regionId").optional().description("지역 ID 필터"), + parameterWithName("distilleryId").optional().description("증류소 ID 필터"), + parameterWithName("cursor").optional().description("커서 위치 (기본값: 0)"), + parameterWithName("pageSize").optional().description("페이지 크기 (기본값: 20, 최대: 100)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("술 lookup 목록"), + fieldWithPath("data[].alcoholId").type(JsonFieldType.NUMBER).description("술 ID"), + fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("술 한글 이름"), + fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("술 영문 이름"), + fieldWithPath("data[].korCategoryName").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("data[].engCategoryName").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("data[].categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹"), + fieldWithPath("data[].regionId").type(JsonFieldType.NUMBER).description("지역 ID"), + fieldWithPath("data[].korRegion").type(JsonFieldType.STRING).description("지역 한글명"), + fieldWithPath("data[].engRegion").type(JsonFieldType.STRING).description("지역 영문명"), + fieldWithPath("data[].distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"), + fieldWithPath("data[].korDistillery").type(JsonFieldType.STRING).description("증류소 한글명"), + fieldWithPath("data[].engDistillery").type(JsonFieldType.STRING).description("증류소 영문명"), + fieldWithPath("data[].imageUrl").type(JsonFieldType.STRING).description("술 이미지 URL"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.pageable").type(JsonFieldType.OBJECT).description("페이징 정보"), + fieldWithPath("meta.pageable.currentCursor").type(JsonFieldType.NUMBER).description("조회 시 기준 커서"), + fieldWithPath("meta.pageable.cursor").type(JsonFieldType.NUMBER).description("다음 페이지 커서"), + fieldWithPath("meta.pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.pageable.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.searchParameters").type(JsonFieldType.OBJECT).description("검색 조건"), + fieldWithPath("meta.searchParameters.keyword").type(JsonFieldType.STRING).description("검색어"), + fieldWithPath("meta.searchParameters.category").type(JsonFieldType.STRING).description("카테고리 그룹 필터"), + fieldWithPath("meta.searchParameters.regionId").type(JsonFieldType.NUMBER).description("지역 ID 필터"), + fieldWithPath("meta.searchParameters.distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID 필터"), + fieldWithPath("meta.searchParameters.cursor").type(JsonFieldType.NUMBER).description("커서 위치"), + fieldWithPath("meta.searchParameters.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } + @Test @DisplayName("관리자용 술 목록을 조회할 수 있다") fun searchAdminAlcohols() { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholLookupSnapshotStore.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholLookupSnapshotStore.java new file mode 100644 index 000000000..a0234fcab --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholLookupSnapshotStore.java @@ -0,0 +1,11 @@ +package app.bottlenote.alcohols.domain; + +import app.bottlenote.alcohols.dto.response.AlcoholLookupSnapshotItem; +import java.util.List; + +public interface AlcoholLookupSnapshotStore { + + List findAll(); + + void replaceAll(List items); +} 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 dfd768055..d52c0a837 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 @@ -8,6 +8,7 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; @@ -38,6 +39,8 @@ public interface AlcoholQueryRepository { List findAllCategoryItems(); + List findAllLookupItems(); + Boolean existsByAlcoholId(Long alcoholId); Boolean existsByDistilleryId(Long distilleryId); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholLookupRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholLookupRequest.java new file mode 100644 index 000000000..f059974fb --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholLookupRequest.java @@ -0,0 +1,27 @@ +package app.bottlenote.alcohols.dto.request; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Builder; + +public record AlcoholLookupRequest( + String keyword, + String category, + Long regionId, + Long distilleryId, + @Min(0) Long cursor, + @Min(1) @Max(100) Long pageSize) { + @Builder + public AlcoholLookupRequest { + cursor = cursor != null ? cursor : 0L; + pageSize = pageSize != null ? pageSize : 20L; + } + + public AlcoholCategoryGroup categoryGroup() { + if (category == null || category.isBlank() || "ALL".equalsIgnoreCase(category)) { + return null; + } + return AlcoholCategoryGroup.fromCategory(category); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupItem.java new file mode 100644 index 000000000..49b027c00 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupItem.java @@ -0,0 +1,18 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; + +public record AlcoholLookupItem( + Long alcoholId, + String korName, + String engName, + String korCategoryName, + String engCategoryName, + AlcoholCategoryGroup categoryGroup, + Long regionId, + String korRegion, + String engRegion, + Long distilleryId, + String korDistillery, + String engDistillery, + String imageUrl) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupSnapshotItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupSnapshotItem.java new file mode 100644 index 000000000..69a8b93c7 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupSnapshotItem.java @@ -0,0 +1,89 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import java.util.Locale; +import java.util.stream.Stream; + +public record AlcoholLookupSnapshotItem( + Long alcoholId, + String korName, + String engName, + String korCategoryName, + String engCategoryName, + AlcoholCategoryGroup categoryGroup, + Long regionId, + String korRegion, + String engRegion, + Long distilleryId, + String korDistillery, + String engDistillery, + String imageUrl, + String normalizedSearchText) { + + public static AlcoholLookupSnapshotItem from(AlcoholLookupItem item) { + return new AlcoholLookupSnapshotItem( + item.alcoholId(), + item.korName(), + item.engName(), + item.korCategoryName(), + item.engCategoryName(), + item.categoryGroup(), + item.regionId(), + item.korRegion(), + item.engRegion(), + item.distilleryId(), + item.korDistillery(), + item.engDistillery(), + item.imageUrl(), + normalize( + item.korName(), + item.engName(), + item.korCategoryName(), + item.engCategoryName(), + item.categoryGroup() != null ? item.categoryGroup().name() : null, + item.korRegion(), + item.engRegion(), + item.korDistillery(), + item.engDistillery())); + } + + public AlcoholLookupItem toLookupItem() { + return new AlcoholLookupItem( + alcoholId, + korName, + engName, + korCategoryName, + engCategoryName, + categoryGroup, + regionId, + korRegion, + engRegion, + distilleryId, + korDistillery, + engDistillery, + imageUrl); + } + + public String normalizedSearchText() { + if (normalizedSearchText != null && !normalizedSearchText.isBlank()) { + return normalizedSearchText; + } + return normalize( + korName, + engName, + korCategoryName, + engCategoryName, + categoryGroup != null ? categoryGroup.name() : null, + korRegion, + engRegion, + korDistillery, + engDistillery); + } + + private static String normalize(String... values) { + return Stream.of(values) + .filter(value -> value != null && !value.isBlank()) + .map(value -> value.toLowerCase(Locale.ROOT)) + .reduce("", (left, right) -> left + " " + right); + } +} 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 8aaea82d0..703e4167e 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 @@ -5,6 +5,7 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; @@ -19,6 +20,8 @@ public interface CustomAlcoholQueryRepository { List findAllCategoryItems(); + List findAllLookupItems(); + PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto); AlcoholDetailItem findAlcoholDetailById(Long alcoholId, Long userId); 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 cf9f1ba7d..d2f952ecf 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 @@ -1,5 +1,6 @@ package app.bottlenote.alcohols.repository; +import static app.bottlenote.alcohols.constant.AlcoholType.WHISKY; import static app.bottlenote.alcohols.domain.QAlcohol.alcohol; import static app.bottlenote.alcohols.domain.QDistillery.distillery; import static app.bottlenote.alcohols.domain.QRegion.region; @@ -14,6 +15,7 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CategoryItem; @@ -56,6 +58,35 @@ public List findAllCategoryItems() { .fetch(); } + @Override + public List findAllLookupItems() { + return queryFactory + .select( + Projections.constructor( + AlcoholLookupItem.class, + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.korCategory, + alcohol.engCategory, + alcohol.categoryGroup, + region.id, + region.korName, + region.engName, + distillery.id, + distillery.korName, + distillery.engName, + alcohol.imageUrl)) + .from(alcohol) + .leftJoin(region) + .on(alcohol.region.id.eq(region.id)) + .leftJoin(distillery) + .on(alcohol.distillery.id.eq(distillery.id)) + .where(alcohol.type.eq(WHISKY), alcohol.deletedAt.isNull()) + .orderBy(alcohol.id.asc()) + .fetch(); + } + /** queryDSL 알코올 검색 */ @Override public PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java new file mode 100644 index 000000000..573c6b1aa --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java @@ -0,0 +1,48 @@ +package app.bottlenote.alcohols.repository; + +import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore; +import app.bottlenote.alcohols.dto.response.AlcoholLookupSnapshotItem; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class RedisAlcoholLookupSnapshotStore implements AlcoholLookupSnapshotStore { + private static final String LOOKUP_SNAPSHOT_KEY = "alcohol:lookup:snapshot:v1"; + private static final TypeReference> LOOKUP_ITEMS_TYPE = + new TypeReference<>() {}; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public List findAll() { + Object value = redisTemplate.opsForValue().get(LOOKUP_SNAPSHOT_KEY); + if (value == null) { + return List.of(); + } + + try { + return objectMapper.readValue(value.toString(), LOOKUP_ITEMS_TYPE); + } catch (JsonProcessingException e) { + log.warn("Alcohol lookup snapshot 역직렬화 실패. Redis snapshot을 비어 있는 것으로 처리합니다.", e); + return List.of(); + } + } + + @Override + public void replaceAll(List items) { + try { + redisTemplate.opsForValue().set(LOOKUP_SNAPSHOT_KEY, objectMapper.writeValueAsString(items)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Alcohol lookup snapshot 직렬화에 실패했습니다.", e); + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/scheduled/AlcoholLookupSyncScheduler.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/scheduled/AlcoholLookupSyncScheduler.java new file mode 100644 index 000000000..0fadb3dbf --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/scheduled/AlcoholLookupSyncScheduler.java @@ -0,0 +1,25 @@ +package app.bottlenote.alcohols.scheduled; + +import app.bottlenote.alcohols.service.AlcoholLookupService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "schedules.alcohol.lookup.sync", + name = "enable", + havingValue = "true") +public class AlcoholLookupSyncScheduler { + private final AlcoholLookupService alcoholLookupService; + + @Scheduled(cron = "${schedules.alcohol.lookup.sync.cron:0 */5 * * * *}") + public void syncLookupSnapshot() { + int syncedCount = alcoholLookupService.syncSnapshot(); + log.info("Alcohol lookup snapshot 동기화 완료: {}건", syncedCount); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java new file mode 100644 index 000000000..3caff27a9 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java @@ -0,0 +1,97 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupSnapshotItem; +import app.bottlenote.global.service.cursor.CursorResponse; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AlcoholLookupService { + private final AlcoholQueryRepository alcoholQueryRepository; + private final AlcoholLookupSnapshotStore snapshotStore; + + @Transactional(readOnly = true) + public CursorResponse lookup(AlcoholLookupRequest request) { + return filterAndSlice(findRedisItemsWithFallback(), request); + } + + @Transactional(readOnly = true) + public int syncSnapshot() { + List items = findDatabaseItems(); + snapshotStore.replaceAll(items); + return items.size(); + } + + private List findRedisItemsWithFallback() { + try { + List items = snapshotStore.findAll(); + if (!items.isEmpty()) { + return items; + } + log.info("Alcohol lookup Redis snapshot이 비어 있어 DB fallback 경로를 사용합니다."); + } catch (Exception e) { + log.warn("Alcohol lookup Redis snapshot 조회 실패. DB fallback 경로를 사용합니다.", e); + } + return findDatabaseItems(); + } + + private List findDatabaseItems() { + return alcoholQueryRepository.findAllLookupItems().stream() + .map(AlcoholLookupSnapshotItem::from) + .toList(); + } + + private CursorResponse filterAndSlice( + List items, AlcoholLookupRequest request) { + AlcoholCategoryGroup categoryGroup = request.categoryGroup(); + List keywords = parseKeywords(request.keyword()); + long cursor = Math.max(request.cursor(), 0L); + long pageSize = Math.max(request.pageSize(), 1L); + + // 성능 검증용 구조: 정규화 필드는 snapshot에 저장하되, 후속 작업에서 DTO 패키지 경계를 정리한다. + List page = + items.stream() + .filter(item -> matchesKeywords(item, keywords)) + .filter(item -> categoryGroup == null || categoryGroup == item.categoryGroup()) + .filter( + item -> request.regionId() == null || request.regionId().equals(item.regionId())) + .filter( + item -> + request.distilleryId() == null + || request.distilleryId().equals(item.distilleryId())) + .skip(cursor) + .limit(pageSize + 1) + .map(AlcoholLookupSnapshotItem::toLookupItem) + .toList(); + + return CursorResponse.of(page, cursor, Math.toIntExact(pageSize)); + } + + private List parseKeywords(String keyword) { + if (keyword == null || keyword.isBlank()) { + return List.of(); + } + return Arrays.stream(keyword.trim().toLowerCase(Locale.ROOT).split("\\s+")) + .filter(value -> !value.isBlank()) + .toList(); + } + + private boolean matchesKeywords(AlcoholLookupSnapshotItem item, List keywords) { + if (keywords.isEmpty()) { + return true; + } + return keywords.stream().allMatch(item.normalizedSearchText()::contains); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholLookupSnapshotStore.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholLookupSnapshotStore.java new file mode 100644 index 000000000..4d22ab677 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholLookupSnapshotStore.java @@ -0,0 +1,20 @@ +package app.bottlenote.alcohols.fixture; + +import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore; +import app.bottlenote.alcohols.dto.response.AlcoholLookupSnapshotItem; +import java.util.ArrayList; +import java.util.List; + +public class InMemoryAlcoholLookupSnapshotStore implements AlcoholLookupSnapshotStore { + private List snapshot = new ArrayList<>(); + + @Override + public List findAll() { + return List.copyOf(snapshot); + } + + @Override + public void replaceAll(List items) { + snapshot = new ArrayList<>(items); + } +} 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 94f8e1e0f..d891f4b1e 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 @@ -8,6 +8,7 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; @@ -82,6 +83,29 @@ public List findAllCategoryItems() { .collect(Collectors.toList()); } + @Override + public List findAllLookupItems() { + return alcohols.values().stream() + .filter(alcohol -> alcohol.getDeletedAt() == null) + .map( + alcohol -> + new AlcoholLookupItem( + alcohol.getId(), + alcohol.getKorName(), + alcohol.getEngName(), + alcohol.getKorCategory(), + alcohol.getEngCategory(), + alcohol.getCategoryGroup(), + alcohol.getRegion() != null ? alcohol.getRegion().getId() : null, + alcohol.getRegion() != null ? alcohol.getRegion().getKorName() : null, + alcohol.getRegion() != null ? alcohol.getRegion().getEngName() : null, + alcohol.getDistillery() != null ? alcohol.getDistillery().getId() : null, + alcohol.getDistillery() != null ? alcohol.getDistillery().getKorName() : null, + alcohol.getDistillery() != null ? alcohol.getDistillery().getEngName() : null, + alcohol.getImageUrl())) + .toList(); + } + @Override public Boolean existsByAlcoholId(Long alcoholId) { return alcohols.containsKey(alcoholId); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java new file mode 100644 index 000000000..cc3208bd2 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java @@ -0,0 +1,172 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.constant.AlcoholCategoryGroup.SINGLE_MALT; +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupSnapshotItem; +import app.bottlenote.alcohols.fixture.InMemoryAlcoholLookupSnapshotStore; +import app.bottlenote.alcohols.fixture.InMemoryAlcoholQueryRepository; +import app.bottlenote.global.service.cursor.CursorResponse; +import java.util.List; +import java.util.stream.LongStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +@Tag("unit") +@DisplayName("AlcoholLookupService 단위 테스트") +class AlcoholLookupServiceTest { + private InMemoryAlcoholQueryRepository alcoholQueryRepository; + private InMemoryAlcoholLookupSnapshotStore snapshotStore; + private AlcoholLookupService alcoholLookupService; + + @BeforeEach + void setUp() { + alcoholQueryRepository = new InMemoryAlcoholQueryRepository(); + snapshotStore = new InMemoryAlcoholLookupSnapshotStore(); + alcoholLookupService = new AlcoholLookupService(alcoholQueryRepository, snapshotStore); + } + + @Test + @DisplayName("20,000건 lookup snapshot에서 검색 조건을 적용해 cursor 페이지를 반환한다") + void lookup_whenSnapshotHas20000Items_returnsFilteredCursorPage() { + // given + snapshotStore.replaceAll(createLookupSnapshotItems(20_000)); + AlcoholLookupRequest request = + AlcoholLookupRequest.builder() + .keyword("macallan speyside") + .category("SINGLE_MALT") + .regionId(1L) + .distilleryId(10L) + .cursor(20L) + .pageSize(20L) + .build(); + + // when + CursorResponse response = alcoholLookupService.lookup(request); + + // then + assertThat(response.items()).hasSize(20); + assertThat(response.items().get(0).alcoholId()).isEqualTo(21L); + assertThat(response.pageable().getCurrentCursor()).isEqualTo(20L); + assertThat(response.pageable().getCursor()).isEqualTo(40L); + assertThat(response.pageable().getHasNext()).isTrue(); + } + + @Test + @DisplayName("다중 키워드는 모든 단어가 포함된 술만 반환한다") + void lookup_whenMultipleKeywords_returnsItemsContainingEveryKeyword() { + // given + snapshotStore.replaceAll( + List.of( + lookupSnapshotItem( + 1L, "맥캘란 12년", "Macallan 12", "스페이사이드", "Speyside", "맥캘란", "Macallan"), + lookupSnapshotItem( + 2L, "글렌피딕 12년", "Glenfiddich 12", "스페이사이드", "Speyside", "글렌피딕", "Glenfiddich"))); + AlcoholLookupRequest request = + AlcoholLookupRequest.builder().keyword("macallan speyside").pageSize(20L).build(); + + // when + CursorResponse response = alcoholLookupService.lookup(request); + + // then + assertThat(response.items()).extracting(AlcoholLookupItem::alcoholId).containsExactly(1L); + } + + @Test + @DisplayName("Redis snapshot이 비어 있으면 DB fallback으로 조회한다") + void lookup_whenSnapshotIsEmpty_usesDatabaseFallback() { + // given + alcoholQueryRepository.save(createAlcohol(1L)); + AlcoholLookupRequest request = + AlcoholLookupRequest.builder().keyword("macallan").pageSize(20L).build(); + + // when + CursorResponse response = alcoholLookupService.lookup(request); + + // then + assertThat(response.items()).extracting(AlcoholLookupItem::alcoholId).containsExactly(1L); + } + + @Test + @DisplayName("DB 원천 데이터를 Redis snapshot으로 동기화한다") + void syncSnapshot_savesDatabaseLookupItemsToSnapshotStore() { + // given + alcoholQueryRepository.save(createAlcohol(1L)); + + // when + int syncedCount = alcoholLookupService.syncSnapshot(); + + // then + assertThat(syncedCount).isEqualTo(1); + assertThat(snapshotStore.findAll()) + .extracting(AlcoholLookupSnapshotItem::alcoholId) + .containsExactly(1L); + assertThat(snapshotStore.findAll().get(0).normalizedSearchText()) + .contains("macallan", "speyside", "single_malt"); + } + + private List createLookupSnapshotItems(int size) { + return LongStream.rangeClosed(1, size) + .mapToObj( + id -> + lookupSnapshotItem( + id, "맥캘란 " + id, "Macallan " + id, "스페이사이드", "Speyside", "맥캘란", "Macallan")) + .toList(); + } + + private AlcoholLookupSnapshotItem lookupSnapshotItem( + Long alcoholId, + String korName, + String engName, + String korRegion, + String engRegion, + String korDistillery, + String engDistillery) { + return AlcoholLookupSnapshotItem.from( + new AlcoholLookupItem( + alcoholId, + korName, + engName, + "싱글몰트", + "Single Malt", + SINGLE_MALT, + 1L, + korRegion, + engRegion, + 10L, + korDistillery, + engDistillery, + "https://example.com/alcohol.png")); + } + + private Alcohol createAlcohol(Long alcoholId) { + Region region = Region.builder().korName("스페이사이드").engName("Speyside").build(); + ReflectionTestUtils.setField(region, "id", 1L); + + Distillery distillery = Distillery.builder().korName("맥캘란").engName("Macallan").build(); + ReflectionTestUtils.setField(distillery, "id", 10L); + + Alcohol alcohol = + Alcohol.builder() + .korName("맥캘란 " + alcoholId) + .engName("Macallan " + alcoholId) + .korCategory("싱글몰트") + .engCategory("Single Malt") + .categoryGroup(SINGLE_MALT) + .type(app.bottlenote.alcohols.constant.AlcoholType.WHISKY) + .region(region) + .distillery(distillery) + .imageUrl("https://example.com/alcohol.png") + .build(); + ReflectionTestUtils.setField(alcohol, "id", alcoholId); + return alcohol; + } +} diff --git a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/lookup.adoc b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/lookup.adoc new file mode 100644 index 000000000..e976a7d91 --- /dev/null +++ b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/lookup.adoc @@ -0,0 +1,18 @@ +=== 술(위스키) lookup 조회 === + +FE 선택 컴포넌트에서 사용할 핵심 필드 기반의 고속 조회 API입니다. + +별점, 좋아요, 리뷰 수, 사용자 pick 여부 같은 집계성 데이터는 포함하지 않습니다. + +[discrete] +==== 요청 파라미터 ==== + +include::{snippets}/alcohols/lookup/httpie-request.adoc[] +include::{snippets}/alcohols/lookup/query-parameters.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/alcohols/lookup/response-fields.adoc[] +include::{snippets}/alcohols/lookup/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc index db4722f19..f309ff498 100644 --- a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc @@ -79,6 +79,9 @@ include::api/review/explore.standard.adoc[] == 술 (alcohol) 관련 API +include::api/alcohols/lookup.adoc[] + +''' include::api/alcohols/search.adoc[] ''' diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholQueryController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholQueryController.java index 87c93c8e6..970684705 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholQueryController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholQueryController.java @@ -3,8 +3,10 @@ import static app.bottlenote.global.security.SecurityContextUtil.getUserIdByContext; import static app.bottlenote.global.service.meta.MetaService.createMetaInfo; +import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest; import app.bottlenote.alcohols.dto.request.AlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; +import app.bottlenote.alcohols.service.AlcoholLookupService; import app.bottlenote.alcohols.service.AlcoholQueryService; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.service.cursor.PageResponse; @@ -25,6 +27,15 @@ public class AlcoholQueryController { private final AlcoholQueryService alcoholQueryService; + private final AlcoholLookupService alcoholLookupService; + + @GetMapping("/lookup") + public ResponseEntity getAlcoholLookups(@ModelAttribute @Valid AlcoholLookupRequest request) { + var response = alcoholLookupService.lookup(request); + return GlobalResponse.ok( + response.items(), + createMetaInfo().add("searchParameters", request).add("pageable", response.pageable())); + } @GetMapping("/search") public ResponseEntity searchAlcohols(@ModelAttribute @Valid AlcoholSearchRequest request) { diff --git a/bottlenote-product-api/src/main/resources/application.yml b/bottlenote-product-api/src/main/resources/application.yml index 1418c0ded..078b3a7aa 100644 --- a/bottlenote-product-api/src/main/resources/application.yml +++ b/bottlenote-product-api/src/main/resources/application.yml @@ -76,6 +76,11 @@ logging: root: info schedules: + alcohol: + lookup: + sync: + enable: true + cron: "0 */5 * * * *" history: view: sync: 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 99fede448..f12d02698 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 @@ -8,6 +8,7 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; @@ -73,6 +74,28 @@ public List findAllCategoryItems() { .collect(Collectors.toList()); } + @Override + public List findAllLookupItems() { + return alcohols.values().stream() + .map( + alcohol -> + new AlcoholLookupItem( + alcohol.getId(), + alcohol.getKorName(), + alcohol.getEngName(), + alcohol.getKorCategory(), + alcohol.getEngCategory(), + alcohol.getCategoryGroup(), + alcohol.getRegion() != null ? alcohol.getRegion().getId() : null, + alcohol.getRegion() != null ? alcohol.getRegion().getKorName() : null, + alcohol.getRegion() != null ? alcohol.getRegion().getEngName() : null, + alcohol.getDistillery() != null ? alcohol.getDistillery().getId() : null, + alcohol.getDistillery() != null ? alcohol.getDistillery().getKorName() : null, + alcohol.getDistillery() != null ? alcohol.getDistillery().getEngName() : null, + alcohol.getImageUrl())) + .toList(); + } + @Override public Boolean existsByAlcoholId(Long alcoholId) { return null; diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java index 553c42723..9bc86ebde 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java @@ -14,14 +14,20 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; import app.bottlenote.alcohols.controller.AlcoholQueryController; +import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest; import app.bottlenote.alcohols.dto.request.AlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AlcoholDetailResponse; +import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.fixture.AlcoholQueryFixture; +import app.bottlenote.alcohols.service.AlcoholLookupService; import app.bottlenote.alcohols.service.AlcoholQueryService; +import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; import app.docs.AbstractRestDocs; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -31,11 +37,99 @@ class RestAlcoholQueryControllerTest extends AbstractRestDocs { private final AlcoholQueryService alcoholQueryService = mock(AlcoholQueryService.class); + private final AlcoholLookupService alcoholLookupService = mock(AlcoholLookupService.class); private final AlcoholQueryFixture fixture = new AlcoholQueryFixture(); @Override protected Object initController() { - return new AlcoholQueryController(alcoholQueryService); + return new AlcoholQueryController(alcoholQueryService, alcoholLookupService); + } + + @DisplayName("술 lookup 목록을 조회할 수 있다.") + @Test + void docs_lookup() throws Exception { + // given + CursorResponse response = + CursorResponse.of( + List.of( + new AlcoholLookupItem( + 1L, + "맥캘란 12년", + "Macallan 12", + "싱글몰트", + "Single Malt", + AlcoholCategoryGroup.SINGLE_MALT, + 1L, + "스페이사이드", + "Speyside", + 10L, + "맥캘란", + "Macallan", + "https://example.com/alcohol.png")), + 0L, + 20); + + when(alcoholLookupService.lookup(any(AlcoholLookupRequest.class))).thenReturn(response); + + // when & then + mockMvc + .perform( + get("/api/v1/alcohols/lookup") + .param("keyword", "macallan") + .param("category", "SINGLE_MALT") + .param("regionId", "1") + .param("distilleryId", "10") + .param("cursor", "0") + .param("pageSize", "20")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/lookup", + queryParameters( + parameterWithName("keyword") + .optional() + .description("검색어 (이름, 카테고리, 지역, 증류소 대상)"), + parameterWithName("category") + .optional() + .description("카테고리 그룹 필터. ALL 또는 미전달 시 전체"), + parameterWithName("regionId").optional().description("지역 ID 필터"), + parameterWithName("distilleryId").optional().description("증류소 ID 필터"), + parameterWithName("cursor").optional().description("커서 위치 (기본값: 0)"), + parameterWithName("pageSize") + .optional() + .description("페이지 크기 (기본값: 20, 최대: 100)")), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data[].alcoholId").description("술 ID"), + fieldWithPath("data[].korName").description("술 한글 이름"), + fieldWithPath("data[].engName").description("술 영문 이름"), + fieldWithPath("data[].korCategoryName").description("술 한글 카테고리 이름"), + fieldWithPath("data[].engCategoryName").description("술 영문 카테고리 이름"), + fieldWithPath("data[].categoryGroup").description("카테고리 그룹"), + fieldWithPath("data[].regionId").description("지역 ID"), + fieldWithPath("data[].korRegion").description("지역 한글 이름"), + fieldWithPath("data[].engRegion").description("지역 영문 이름"), + fieldWithPath("data[].distilleryId").description("증류소 ID"), + fieldWithPath("data[].korDistillery").description("증류소 한글 이름"), + fieldWithPath("data[].engDistillery").description("증류소 영문 이름"), + fieldWithPath("data[].imageUrl").description("술 이미지 URL"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored(), + fieldWithPath("meta.pageable").description("페이징 정보"), + fieldWithPath("meta.pageable.currentCursor").description("조회 시 기준 커서"), + fieldWithPath("meta.pageable.cursor").description("다음 페이지 커서"), + fieldWithPath("meta.pageable.pageSize").description("조회된 페이지 사이즈"), + fieldWithPath("meta.pageable.hasNext").description("다음 페이지 존재 여부"), + fieldWithPath("meta.searchParameters.keyword").description("검색어"), + fieldWithPath("meta.searchParameters.category").description("카테고리 그룹 필터"), + fieldWithPath("meta.searchParameters.regionId").description("지역 ID 필터"), + fieldWithPath("meta.searchParameters.distilleryId").description("증류소 ID 필터"), + fieldWithPath("meta.searchParameters.cursor").description("커서 위치"), + fieldWithPath("meta.searchParameters.pageSize").description("페이지 크기")))); } @DisplayName("술 리스트를 조회할 수 있다.") diff --git a/plan/alcohol-lookup.md b/plan/alcohol-lookup.md new file mode 100644 index 000000000..b2283ba02 --- /dev/null +++ b/plan/alcohol-lookup.md @@ -0,0 +1,185 @@ +# Plan: Alcohol Lookup + +## Overview + +FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추가한다. + +기존 `GET /api/v1/alcohols/search`는 일반 검색 화면까지 포함하는 무거운 조회 경로다. 신규 lookup API는 최대 20,000개 수준의 위스키 핵심 필드만 대상으로 하며, 별점, 좋아요, 리뷰 수, 사용자 pick 여부 같은 실시간 집계성 데이터는 제외한다. + +기본 아키텍처는 DB를 원천 데이터로 두고, Redis에 lookup snapshot을 주기적으로 동기화한 뒤, 요청 시 BE에서 snapshot을 읽어 메모리 stream 필터링과 cursor pagination을 수행하는 방식이다. DB 직접 조회 방식은 k6 비교 검증용 임시 경로로만 사용하고, 검증 후 외부 API 계약에서는 제거한다. + +### Frontend Usage Findings + +- Product FE 리뷰 작성 선택 컴포넌트는 `keyword`, `category`, `cursor`, `pageSize=20` 조건으로 술을 선택한다. +- Product FE 일반 검색은 `keyword`, `category`, `regionId`, `sortType`, `sortOrder`, `cursor`, `pageSize=10` 조건을 사용한다. +- Product FE 탐색 API는 `keywords[]`, `regionIds[]`, `category`, `sortType`, `sortOrder`, `cursor`, `size` 구조를 사용한다. +- Admin 위스키 선택 컴포넌트는 `keyword`, `size=10`을 사용하며 300ms debounce가 있다. +- Admin 위스키 목록 검색은 `keyword`, `category`, `page`, `size`, `includeDeleted` 조건을 사용한다. + +### Assumptions + +- Endpoint는 product 기존 alcohol 컨트롤러에 `GET /api/v1/alcohols/lookup`으로 추가한다. +- Admin도 동일 공통 서비스인 `AlcoholLookupService`를 사용하되, 컨트롤러는 기존 admin alcohol 컨트롤러에 필요한 엔드포인트를 추가하는 방식으로 확장한다. +- 서비스 레이어 이름은 `AlcoholLookupService`로 고정한다. +- Redis는 원천 저장소가 아니라 읽기 전용 lookup snapshot 저장소로 사용한다. +- Redis snapshot은 약간의 동기화 지연을 허용한다. +- Redis에 저장하는 필드는 위스키 선택에 필요한 핵심 필드로 제한한다. +- 핵심 필드는 `alcoholId`, `korName`, `engName`, `korCategory`, `engCategory`, `regionId`, `korRegion`, `engRegion`, `distilleryId`, `korDistillery`, `engDistillery`, `imageUrl`, `searchText`를 우선 후보로 한다. +- 별점, 좋아요, 리뷰 수, pick 여부, 개인화 필드는 lookup 응답에서 제외한다. +- 검색 대상은 `keyword`, `category`, `regionId`, `distilleryId`를 우선 지원한다. +- 페이지네이션은 cursor 기반을 기본으로 한다. +- 최대 데이터 규모는 lookup 대상 20,000건으로 본다. +- 일반 Redis만 사용 가능한 환경을 우선 가정하고, RediSearch는 이번 구현 범위에서 제외한다. +- DB 직접 조회 방식은 운영 기본 경로가 아니라 k6 비교 검증을 위한 임시 대체 경로로만 준비하고, 검증 후 외부 API 계약에서 제거한다. + +### Success Criteria + +- `GET /api/v1/alcohols/lookup`이 `keyword`, `category`, `regionId`, `distilleryId`, `cursor`, `pageSize` 조건을 받아 cursor 기반 목록을 반환한다. +- 응답 항목은 위스키 선택/세팅에 필요한 핵심 필드만 포함하고, 별점, 좋아요, 리뷰 수, pick 여부를 포함하지 않는다. +- Redis snapshot 기반 조회 경로가 기본 서빙 경로이며, Redis miss 또는 snapshot 부재 시 DB fallback을 수행한다. +- Redis miss 또는 snapshot 부재 상황에서 정의된 fallback 동작이 있다. +- 최대 20,000건 기준으로 k6 검증을 수행하고, 외부 `source` 선택 경로는 검증 후 제거한다. +- Product/Admin 양쪽에서 공통 `AlcoholLookupService`를 사용할 수 있다. +- 기존 `/api/v1/alcohols/search`의 응답 계약과 동작은 변경하지 않는다. + +### Impact Scope + +- `bottlenote-mono` + - `app.bottlenote.alcohols.service`: `AlcoholLookupService` 추가 + - `app.bottlenote.alcohols.dto.request`: lookup request DTO 추가 + - `app.bottlenote.alcohols.dto.response`: lookup item/response DTO 추가 + - `app.bottlenote.alcohols.domain` 또는 `repository`: DB lookup 조회 계약 추가 + - `app.bottlenote.alcohols.repository`: DB lookup QueryDSL 구현 추가 + - Redis snapshot 저장소 구현 위치 검토 +- `bottlenote-product-api` + - 기존 `AlcoholQueryController`에 `/lookup` endpoint 추가 + - REST Docs 테스트와 asciidoc include 추가 검토 +- `bottlenote-admin-api` + - 기존 `AdminAlcoholsController`에 admin lookup endpoint 추가 여부 검토 + - Admin Docs 테스트 추가 검토 +- Redis + - lookup snapshot key 설계 필요 + - snapshot 갱신 주기와 원자적 교체 방식 필요 + - Redis 3 replica 환경에서 read 경로와 consistency 기대치 명시 필요 +- Batch/Scheduler + - 5분 주기 동기화가 product/admin API 모듈 내부 scheduler로 충분한지, batch 모듈로 분리할지 결정 필요 +- Tests + - `AlcoholLookupService` 단위 테스트 + - Redis 저장소 fake 또는 TestContainers 기반 통합 테스트 + - Product lookup API 통합/문서 테스트 + - Admin lookup API가 추가될 경우 admin 통합/문서 테스트 +- Performance Verification + - 이번 define 단계에서는 k6 스크립트를 작성하지 않는다. + - 다음 세션에서 Redis snapshot 조회와 DB 직접 조회를 같은 조건으로 비교할 수 있도록 경로와 조건을 명확히 유지한다. + +### Open Questions + +- Admin lookup endpoint도 기존 `AdminAlcoholsController`에 추가한다. +- Redis snapshot 갱신은 공통 `AlcoholLookupService`에 동기화 메서드를 두고, scheduler binding 위치는 구현 중 현재 배포 구조를 확인해 결정한다. 중복 실행 방지를 위해 property guard를 둔다. +- cursor 기준은 Redis snapshot 결과 순서의 다음 index cursor를 기본으로 하고, DB fallback도 같은 cursor 의미를 유지한다. +- `keyword` 검색은 `searchText contains` 기반으로 시작하되, 다중 단어 입력은 공백 분리 후 AND 조건으로 처리한다. +- category `ALL` 또는 null은 category 필터 없음으로 처리한다. + +## Tasks + +### Task 1: Lookup API 계약과 DTO 정의 +- Acceptance: Product/Admin이 공유할 lookup request/response DTO가 `keyword`, `category`, `regionId`, `distilleryId`, `cursor`, `pageSize`를 표현한다. +- Acceptance: 응답 item은 핵심 필드만 포함하고 rating/review/pick 계열 필드를 포함하지 않는다. +- Acceptance: 기본값은 cursor `0`, pageSize는 product 선택 컴포넌트 기준 `20`을 우선한다. +- Verification: `./gradlew :bottlenote-mono:compileJava` +- Files: `bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/*`, `bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/*`, 필요 시 `dto/dsl/*` +- Size: S +- Status: [x] done + +### Task 2: DB lookup fallback 조회 경로 구현 +- Acceptance: `AlcoholQueryRepository` 계열에 lookup 전용 조회 계약이 추가된다. +- Acceptance: QueryDSL projection은 alcohol, region, distillery, category 핵심 필드만 조회한다. +- Acceptance: `keyword`, `category`, `regionId`, `distilleryId`, cursor/pageSize 조건이 DB 경로에서 동작한다. +- Verification: `./gradlew :bottlenote-mono:compileJava` +- Files: `AlcoholQueryRepository.java`, `CustomAlcoholQueryRepository.java`, `CustomAlcoholQueryRepositoryImpl.java`, `AlcoholQuerySupporter.java`, 관련 테스트 fake +- Size: M +- Status: [x] done + +### Task 3: Redis snapshot store와 fallback 경계 구현 +- Acceptance: lookup snapshot을 읽고 쓰는 저장소 경계가 생긴다. +- Acceptance: Redis snapshot miss/empty 시 DB fallback을 호출할 수 있는 결과 흐름이 정의된다. +- Acceptance: Redis snapshot miss/empty 시 DB fallback을 호출하되, 외부 API에서 DB source 선택은 노출하지 않는다. +- Verification: `./gradlew :bottlenote-mono:compileJava` +- Files: `bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/*` 또는 `global/redis/*`, 관련 fake/test fixture +- Size: M +- Status: [x] done + +### Task 4: AlcoholLookupService 서빙/동기화 유스케이스 구현 +- Acceptance: `AlcoholLookupService`가 Redis snapshot 기반 조회를 기본 경로로 제공한다. +- Acceptance: 같은 서비스가 DB 원천 데이터를 Redis snapshot으로 동기화하는 메서드를 제공한다. +- Acceptance: 20,000건 snapshot을 대상으로 stream 필터링, 다중 keyword AND, cursor/pageSize slicing을 수행한다. +- Verification: `./gradlew unit_test --tests '*AlcoholLookupServiceTest*'` +- Files: `bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java`, `bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/*` +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-4 +- [x] `./gradlew :bottlenote-mono:compileJava` +- [x] `./gradlew unit_test --tests '*AlcoholLookupServiceTest*'` +- [x] Repository interface 변경 시 InMemory/Fake 구현체가 함께 갱신됐는지 확인 + +### Task 5: Product/Admin 기존 컨트롤러에 lookup endpoint 연결 +- Acceptance: Product 기존 `AlcoholQueryController`에 `GET /api/v1/alcohols/lookup`이 추가된다. +- Acceptance: Admin 기존 `AdminAlcoholsController`에 admin lookup endpoint가 추가된다. +- Acceptance: 두 컨트롤러는 공통 `AlcoholLookupService`를 호출하고 별도 비즈니스 로직을 갖지 않는다. +- Verification: `./gradlew :bottlenote-product-api:compileJava :bottlenote-admin-api:compileKotlin` +- Files: `AlcoholQueryController.java`, `AdminAlcoholsController.kt` +- Size: S +- Status: [x] done + +### Task 6: Lookup 동기화 scheduler binding 추가 +- Acceptance: 5분 주기 snapshot 동기화가 property guard와 함께 등록된다. +- Acceptance: scheduler는 `AlcoholLookupService`의 동기화 메서드만 호출한다. +- Acceptance: product/admin/batch 중 실제 binding 위치 선택 이유가 코드 주석 또는 plan progress log에 남는다. +- Verification: `./gradlew :bottlenote-mono:compileJava` 또는 binding 모듈 compile task +- Files: scheduler binding 위치에 따라 `bottlenote-mono`, `bottlenote-product-api`, 또는 `bottlenote-batch`의 schedule config +- Size: S +- Status: [x] done + +### Task 7: Product/Admin 문서와 API 테스트 추가 +- Acceptance: Product lookup RestDocs 테스트가 query parameters, response fields, cursor meta를 문서화한다. +- Acceptance: Admin lookup Docs 테스트가 admin endpoint 계약을 문서화한다. +- Acceptance: asciidoc include가 필요한 경우 product/admin 문서 인덱스에 반영된다. +- Verification: `./gradlew :bottlenote-product-api:asciidoctor :bottlenote-admin-api:test` +- Files: product RestDocs test/adoc, admin Docs test/adoc, helper fixture +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 5-7 +- [x] `./gradlew :bottlenote-product-api:compileJava :bottlenote-admin-api:compileKotlin` +- [x] `./gradlew :bottlenote-product-api:asciidoctor :bottlenote-admin-api:test` +- [x] Product/Admin controller가 기존 controller를 확장하고 신규 controller를 만들지 않았는지 확인 + +### Task 8: 통합 검증과 k6 참조 포인트 정리 +- Acceptance: Redis snapshot 경로와 DB fallback 경로가 동일 조건에서 동일 응답 모델을 반환하는 통합 검증이 있다. +- Acceptance: 다음 세션 k6 플랜에서 사용할 비교 조건이 plan progress log 또는 코드 주석에 남는다. +- Acceptance: `verify full`에 필요한 명령 시퀀스가 통과한다. +- Verification: `./gradlew check_rule_test unit_test integration_test admin_integration_test :bottlenote-admin-api:test asciidoctor` +- Files: product/admin integration tests, 필요 시 plan progress log +- Size: M +- Status: [x] done + +## Progress Log + +- 2026-05-23: Added lookup request/response contracts and cursor/pageSize defaults. +- 2026-05-23: Added DB source projection through `AlcoholQueryRepository.findAllLookupItems()` for k6 Redis-vs-DB comparison. +- 2026-05-23: Added Redis snapshot store using key `alcohol:lookup:snapshot:v1`. +- 2026-05-23: Added `AlcoholLookupService` with Redis default path, DB fallback/source path, 20,000-item stream filtering scenario, multi-keyword AND, and snapshot sync. +- 2026-05-23: Added Product `/api/v1/alcohols/lookup` and Admin `/admin/api/v1/alcohols/lookup` to existing controllers. +- 2026-05-23: Added product-side 5-minute scheduler binding guarded by `schedules.alcohol.lookup.sync.enable`. +- 2026-05-23: Added Product/Admin RestDocs coverage and asciidoc includes for lookup API. +- 2026-05-23: Verification passed: `./gradlew :bottlenote-mono:compileJava :bottlenote-product-api:compileJava :bottlenote-admin-api:compileKotlin`. +- 2026-05-23: Verification passed: `./gradlew :bottlenote-mono:test --tests '*AlcoholLookupServiceTest*'` with 5 service scenarios. +- 2026-05-23: Verification passed: `./gradlew :bottlenote-product-api:asciidoctor :bottlenote-admin-api:test`. +- 2026-05-23: Verification passed: `./gradlew check_rule_test unit_test integration_test admin_integration_test :bottlenote-admin-api:test asciidoctor` in 9m 34s. +- 2026-05-23: Runtime smoke passed with product API `dev` profile, local Redis `localhost:16379`, and development DB. Redis snapshot sync stored 3,288 lookup items and default REDIS lookup returned HTTP 200. +- 2026-05-23: Completed clean PR repair. PR branch `codex/alcohol-lookup` now contains only lookup commit on top of `origin/main`, and GitHub CI passed. +- 2026-05-23: Local k6 comparison completed with temporary `/tmp` scripts only. DB load p95/p99 was 2128ms/2685ms, Redis load p95/p99 was 163ms/234ms, both with 0% failure. Redis stress at 50 VU exposed the local runtime limit with 8.3% failure, so spike was skipped. +- 2026-05-23: Removed public `source=DATABASE` selection from lookup request/docs/tests after comparison. Redis miss fallback and snapshot sync DB projection remain. +- 2026-05-23: Added experimental Redis snapshot normalized search text for local performance validation. API response shape remains unchanged; snapshot DTO/package boundary must be cleaned up in a follow-up. +- 2026-05-23: Local k6 normalized snapshot validation completed with temporary `/tmp` scripts only. Redis snapshot payload size was 2,243,998 bytes. Normalized no-sync load p95/p99 was 118ms/129ms with 0% failure. Normalized stress processed 20,166 requests with 1.98% failure and p99 timeout, so spike remains skipped.