From 358f71af70250953551ba502d8d452368a974029 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 23 May 2026 02:29:29 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9C=84=EC=8A=A4=ED=82=A4=20looku?= =?UTF-8?q?p=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../asciidoc/api/admin-alcohols/alcohols.adoc | 27 +++ .../presentation/AdminAlcoholsController.kt | 19 +- .../AdminAlcoholsControllerDocsTest.kt | 96 +++++++++ .../constant/AlcoholLookupSource.java | 6 + .../domain/AlcoholLookupSnapshotStore.java | 11 ++ .../domain/AlcoholQueryRepository.java | 3 + .../dto/request/AlcoholLookupRequest.java | 30 +++ .../dto/response/AlcoholLookupItem.java | 37 ++++ .../CustomAlcoholQueryRepository.java | 3 + .../CustomAlcoholQueryRepositoryImpl.java | 31 +++ .../RedisAlcoholLookupSnapshotStore.java | 48 +++++ .../scheduled/AlcoholLookupSyncScheduler.java | 25 +++ .../service/AlcoholLookupService.java | 103 ++++++++++ .../InMemoryAlcoholLookupSnapshotStore.java | 20 ++ .../InMemoryAlcoholQueryRepository.java | 24 +++ .../service/AlcoholLookupServiceTest.java | 184 ++++++++++++++++++ .../docs/asciidoc/api/alcohols/lookup.adoc | 18 ++ .../src/docs/asciidoc/product-api.adoc | 3 + .../controller/AlcoholQueryController.java | 11 ++ .../src/main/resources/application.yml | 5 + .../InMemoryAlcoholQueryRepository.java | 23 +++ .../RestAlcoholQueryControllerTest.java | 101 +++++++++- plan/alcohol-lookup.md | 181 +++++++++++++++++ 23 files changed, 1007 insertions(+), 2 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholLookupSnapshotStore.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholLookupRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/scheduled/AlcoholLookupSyncScheduler.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholLookupSnapshotStore.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java create mode 100644 bottlenote-product-api/src/docs/asciidoc/api/alcohols/lookup.adoc create mode 100644 plan/alcohol-lookup.md 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..560d8fcb0 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,98 @@ 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("source", "REDIS") + .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("source").optional().description("조회 경로 (REDIS: 기본 snapshot 경로, DATABASE: k6 비교 검증용 DB 경로)"), + 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.source").type(JsonFieldType.STRING).description("조회 경로"), + 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/constant/AlcoholLookupSource.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java new file mode 100644 index 000000000..640938b6b --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java @@ -0,0 +1,6 @@ +package app.bottlenote.alcohols.constant; + +public enum AlcoholLookupSource { + REDIS, + DATABASE +} 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..dc576b709 --- /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.AlcoholLookupItem; +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..488d9aba7 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholLookupRequest.java @@ -0,0 +1,30 @@ +package app.bottlenote.alcohols.dto.request; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholLookupSource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Builder; + +public record AlcoholLookupRequest( + String keyword, + String category, + Long regionId, + Long distilleryId, + AlcoholLookupSource source, + @Min(0) Long cursor, + @Min(1) @Max(100) Long pageSize) { + @Builder + public AlcoholLookupRequest { + source = source != null ? source : AlcoholLookupSource.REDIS; + 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..07edf3dca --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupItem.java @@ -0,0 +1,37 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import java.util.Locale; +import java.util.stream.Stream; + +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) { + + public String searchText() { + return Stream.of( + korName, + engName, + korCategoryName, + engCategoryName, + categoryGroup != null ? categoryGroup.name() : null, + korRegion, + engRegion, + korDistillery, + engDistillery) + .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..0a02ca8be --- /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.AlcoholLookupItem; +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..784b62112 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java @@ -0,0 +1,103 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.constant.AlcoholLookupSource.DATABASE; + +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.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) { + List sourceItems = + request.source() == DATABASE ? findDatabaseItems() : findRedisItemsWithFallback(); + return filterAndSlice(sourceItems, request); + } + + @Transactional(readOnly = true) + public CursorResponse lookupFromDatabase(AlcoholLookupRequest request) { + return filterAndSlice(findDatabaseItems(), 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(); + } + + 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); + + // k6 비교 기준: Redis와 DB 경로 모두 동일한 JVM stream 필터링 비용을 사용하고 I/O source만 분리한다. + 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) + .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(AlcoholLookupItem item, List keywords) { + if (keywords.isEmpty()) { + return true; + } + String searchText = item.searchText(); + return keywords.stream().allMatch(searchText::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..8162196a9 --- /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.AlcoholLookupItem; +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..53a45f22c --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java @@ -0,0 +1,184 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.constant.AlcoholCategoryGroup.SINGLE_MALT; +import static app.bottlenote.alcohols.constant.AlcoholLookupSource.DATABASE; +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.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(createLookupItems(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( + lookupItem(1L, "맥캘란 12년", "Macallan 12", "스페이사이드", "Speyside", "맥캘란", "Macallan"), + lookupItem( + 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(AlcoholLookupItem::alcoholId) + .containsExactly(1L); + } + + @Test + @DisplayName("DATABASE source를 지정하면 DB 경로로 직접 조회한다") + void lookup_whenSourceIsDatabase_usesDatabaseItems() { + // given + snapshotStore.replaceAll(createLookupItems(1)); + alcoholQueryRepository.save(createAlcohol(2L)); + AlcoholLookupRequest request = + AlcoholLookupRequest.builder().source(DATABASE).keyword("macallan").pageSize(20L).build(); + + // when + CursorResponse response = alcoholLookupService.lookup(request); + + // then + assertThat(response.items()).extracting(AlcoholLookupItem::alcoholId).containsExactly(2L); + } + + private List createLookupItems(int size) { + return LongStream.rangeClosed(1, size) + .mapToObj( + id -> + lookupItem( + id, "맥캘란 " + id, "Macallan " + id, "스페이사이드", "Speyside", "맥캘란", "Macallan")) + .toList(); + } + + private AlcoholLookupItem lookupItem( + Long alcoholId, + String korName, + String engName, + String korRegion, + String engRegion, + String korDistillery, + String engDistillery) { + return 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..6649e8de1 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,104 @@ 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("source", "REDIS") + .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("source") + .optional() + .description("조회 경로 (REDIS: 기본 snapshot 경로, DATABASE: k6 비교 검증용 DB 경로)"), + 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.source").description("조회 경로"), + 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..b13809af4 --- /dev/null +++ b/plan/alcohol-lookup.md @@ -0,0 +1,181 @@ +# 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 기반 성능 검증 플랜과 측정은 다음 세션에서 별도로 진행한다. + +### 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 비교 검증을 위한 대체 경로로 준비한다. + +### Success Criteria + +- `GET /api/v1/alcohols/lookup`이 `keyword`, `category`, `regionId`, `distilleryId`, `cursor`, `pageSize` 조건을 받아 cursor 기반 목록을 반환한다. +- 응답 항목은 위스키 선택/세팅에 필요한 핵심 필드만 포함하고, 별점, 좋아요, 리뷰 수, pick 여부를 포함하지 않는다. +- Redis snapshot 기반 조회 경로와 DB 직접 조회 경로가 동일한 검색 조건과 응답 모델을 공유한다. +- Redis miss 또는 snapshot 부재 상황에서 정의된 fallback 동작이 있다. +- 최대 20,000건 기준으로 k6 검증을 수행할 수 있도록 조회 경로 선택 기준과 측정 포인트가 문서 또는 코드 주석으로 남아 있다. +- 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: k6 비교를 위해 Redis snapshot 경로와 DB fallback 경로를 구분할 수 있는 코드 주석 또는 분기 기준이 남는다. +- 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, `AlcoholLookupSource`, 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. From da041cb7ad284927b64afcf2b815f4ef088d7f94 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 23 May 2026 12:07:32 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20lookup=20DB=20source=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alcohols/AdminAlcoholsControllerDocsTest.kt | 3 --- .../alcohols/constant/AlcoholLookupSource.java | 6 ------ .../dto/request/AlcoholLookupRequest.java | 3 --- .../alcohols/service/AlcoholLookupService.java | 12 +----------- .../service/AlcoholLookupServiceTest.java | 17 ----------------- .../RestAlcoholQueryControllerTest.java | 5 ----- plan/alcohol-lookup.md | 14 ++++++++------ 7 files changed, 9 insertions(+), 51 deletions(-) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java 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 560d8fcb0..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 @@ -89,7 +89,6 @@ class AdminAlcoholsControllerDocsTest { .param("category", AlcoholCategoryGroup.SINGLE_MALT.name) .param("regionId", "1") .param("distilleryId", "10") - .param("source", "REDIS") .param("cursor", "0") .param("pageSize", "20") ) @@ -104,7 +103,6 @@ class AdminAlcoholsControllerDocsTest { parameterWithName("category").optional().description("카테고리 그룹 필터. ALL 또는 미전달 시 전체"), parameterWithName("regionId").optional().description("지역 ID 필터"), parameterWithName("distilleryId").optional().description("증류소 ID 필터"), - parameterWithName("source").optional().description("조회 경로 (REDIS: 기본 snapshot 경로, DATABASE: k6 비교 검증용 DB 경로)"), parameterWithName("cursor").optional().description("커서 위치 (기본값: 0)"), parameterWithName("pageSize").optional().description("페이지 크기 (기본값: 20, 최대: 100)") ), @@ -137,7 +135,6 @@ class AdminAlcoholsControllerDocsTest { 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.source").type(JsonFieldType.STRING).description("조회 경로"), fieldWithPath("meta.searchParameters.cursor").type(JsonFieldType.NUMBER).description("커서 위치"), fieldWithPath("meta.searchParameters.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java deleted file mode 100644 index 640938b6b..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/constant/AlcoholLookupSource.java +++ /dev/null @@ -1,6 +0,0 @@ -package app.bottlenote.alcohols.constant; - -public enum AlcoholLookupSource { - REDIS, - DATABASE -} 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 index 488d9aba7..f059974fb 100644 --- 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 @@ -1,7 +1,6 @@ package app.bottlenote.alcohols.dto.request; import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; -import app.bottlenote.alcohols.constant.AlcoholLookupSource; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.Builder; @@ -11,12 +10,10 @@ public record AlcoholLookupRequest( String category, Long regionId, Long distilleryId, - AlcoholLookupSource source, @Min(0) Long cursor, @Min(1) @Max(100) Long pageSize) { @Builder public AlcoholLookupRequest { - source = source != null ? source : AlcoholLookupSource.REDIS; cursor = cursor != null ? cursor : 0L; pageSize = pageSize != null ? pageSize : 20L; } 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 index 784b62112..4e91c2f18 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java @@ -1,7 +1,5 @@ package app.bottlenote.alcohols.service; -import static app.bottlenote.alcohols.constant.AlcoholLookupSource.DATABASE; - import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; @@ -25,14 +23,7 @@ public class AlcoholLookupService { @Transactional(readOnly = true) public CursorResponse lookup(AlcoholLookupRequest request) { - List sourceItems = - request.source() == DATABASE ? findDatabaseItems() : findRedisItemsWithFallback(); - return filterAndSlice(sourceItems, request); - } - - @Transactional(readOnly = true) - public CursorResponse lookupFromDatabase(AlcoholLookupRequest request) { - return filterAndSlice(findDatabaseItems(), request); + return filterAndSlice(findRedisItemsWithFallback(), request); } @Transactional(readOnly = true) @@ -66,7 +57,6 @@ private CursorResponse filterAndSlice( long cursor = Math.max(request.cursor(), 0L); long pageSize = Math.max(request.pageSize(), 1L); - // k6 비교 기준: Redis와 DB 경로 모두 동일한 JVM stream 필터링 비용을 사용하고 I/O source만 분리한다. List page = items.stream() .filter(item -> matchesKeywords(item, keywords)) 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 index 53a45f22c..1584e72fb 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java @@ -1,7 +1,6 @@ package app.bottlenote.alcohols.service; import static app.bottlenote.alcohols.constant.AlcoholCategoryGroup.SINGLE_MALT; -import static app.bottlenote.alcohols.constant.AlcoholLookupSource.DATABASE; import static org.assertj.core.api.Assertions.assertThat; import app.bottlenote.alcohols.domain.Alcohol; @@ -110,22 +109,6 @@ void syncSnapshot_savesDatabaseLookupItemsToSnapshotStore() { .containsExactly(1L); } - @Test - @DisplayName("DATABASE source를 지정하면 DB 경로로 직접 조회한다") - void lookup_whenSourceIsDatabase_usesDatabaseItems() { - // given - snapshotStore.replaceAll(createLookupItems(1)); - alcoholQueryRepository.save(createAlcohol(2L)); - AlcoholLookupRequest request = - AlcoholLookupRequest.builder().source(DATABASE).keyword("macallan").pageSize(20L).build(); - - // when - CursorResponse response = alcoholLookupService.lookup(request); - - // then - assertThat(response.items()).extracting(AlcoholLookupItem::alcoholId).containsExactly(2L); - } - private List createLookupItems(int size) { return LongStream.rangeClosed(1, size) .mapToObj( 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 6649e8de1..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 @@ -79,7 +79,6 @@ void docs_lookup() throws Exception { .param("category", "SINGLE_MALT") .param("regionId", "1") .param("distilleryId", "10") - .param("source", "REDIS") .param("cursor", "0") .param("pageSize", "20")) .andExpect(status().isOk()) @@ -95,9 +94,6 @@ void docs_lookup() throws Exception { .description("카테고리 그룹 필터. ALL 또는 미전달 시 전체"), parameterWithName("regionId").optional().description("지역 ID 필터"), parameterWithName("distilleryId").optional().description("증류소 ID 필터"), - parameterWithName("source") - .optional() - .description("조회 경로 (REDIS: 기본 snapshot 경로, DATABASE: k6 비교 검증용 DB 경로)"), parameterWithName("cursor").optional().description("커서 위치 (기본값: 0)"), parameterWithName("pageSize") .optional() @@ -132,7 +128,6 @@ void docs_lookup() throws Exception { fieldWithPath("meta.searchParameters.category").description("카테고리 그룹 필터"), fieldWithPath("meta.searchParameters.regionId").description("지역 ID 필터"), fieldWithPath("meta.searchParameters.distilleryId").description("증류소 ID 필터"), - fieldWithPath("meta.searchParameters.source").description("조회 경로"), fieldWithPath("meta.searchParameters.cursor").description("커서 위치"), fieldWithPath("meta.searchParameters.pageSize").description("페이지 크기")))); } diff --git a/plan/alcohol-lookup.md b/plan/alcohol-lookup.md index b13809af4..41e26724b 100644 --- a/plan/alcohol-lookup.md +++ b/plan/alcohol-lookup.md @@ -6,7 +6,7 @@ FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추 기존 `GET /api/v1/alcohols/search`는 일반 검색 화면까지 포함하는 무거운 조회 경로다. 신규 lookup API는 최대 20,000개 수준의 위스키 핵심 필드만 대상으로 하며, 별점, 좋아요, 리뷰 수, 사용자 pick 여부 같은 실시간 집계성 데이터는 제외한다. -기본 아키텍처는 DB를 원천 데이터로 두고, Redis에 lookup snapshot을 주기적으로 동기화한 뒤, 요청 시 BE에서 snapshot을 읽어 메모리 stream 필터링과 cursor pagination을 수행하는 방식이다. DB 직접 조회 방식도 비교 검증 가능한 대체 경로로 준비한다. 실제 k6 기반 성능 검증 플랜과 측정은 다음 세션에서 별도로 진행한다. +기본 아키텍처는 DB를 원천 데이터로 두고, Redis에 lookup snapshot을 주기적으로 동기화한 뒤, 요청 시 BE에서 snapshot을 읽어 메모리 stream 필터링과 cursor pagination을 수행하는 방식이다. DB 직접 조회 방식은 k6 비교 검증용 임시 경로로만 사용하고, 검증 후 외부 API 계약에서는 제거한다. ### Frontend Usage Findings @@ -30,15 +30,15 @@ FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추 - 페이지네이션은 cursor 기반을 기본으로 한다. - 최대 데이터 규모는 lookup 대상 20,000건으로 본다. - 일반 Redis만 사용 가능한 환경을 우선 가정하고, RediSearch는 이번 구현 범위에서 제외한다. -- DB 직접 조회 방식은 운영 기본 경로가 아니라 k6 비교 검증을 위한 대체 경로로 준비한다. +- DB 직접 조회 방식은 운영 기본 경로가 아니라 k6 비교 검증을 위한 임시 대체 경로로만 준비하고, 검증 후 외부 API 계약에서 제거한다. ### Success Criteria - `GET /api/v1/alcohols/lookup`이 `keyword`, `category`, `regionId`, `distilleryId`, `cursor`, `pageSize` 조건을 받아 cursor 기반 목록을 반환한다. - 응답 항목은 위스키 선택/세팅에 필요한 핵심 필드만 포함하고, 별점, 좋아요, 리뷰 수, pick 여부를 포함하지 않는다. -- Redis snapshot 기반 조회 경로와 DB 직접 조회 경로가 동일한 검색 조건과 응답 모델을 공유한다. +- Redis snapshot 기반 조회 경로가 기본 서빙 경로이며, Redis miss 또는 snapshot 부재 시 DB fallback을 수행한다. - Redis miss 또는 snapshot 부재 상황에서 정의된 fallback 동작이 있다. -- 최대 20,000건 기준으로 k6 검증을 수행할 수 있도록 조회 경로 선택 기준과 측정 포인트가 문서 또는 코드 주석으로 남아 있다. +- 최대 20,000건 기준으로 k6 검증을 수행하고, 외부 `source` 선택 경로는 검증 후 제거한다. - Product/Admin 양쪽에서 공통 `AlcoholLookupService`를 사용할 수 있다. - 기존 `/api/v1/alcohols/search`의 응답 계약과 동작은 변경하지 않는다. @@ -103,7 +103,7 @@ FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추 ### Task 3: Redis snapshot store와 fallback 경계 구현 - Acceptance: lookup snapshot을 읽고 쓰는 저장소 경계가 생긴다. - Acceptance: Redis snapshot miss/empty 시 DB fallback을 호출할 수 있는 결과 흐름이 정의된다. -- Acceptance: k6 비교를 위해 Redis snapshot 경로와 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 @@ -166,7 +166,7 @@ FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추 ## Progress Log -- 2026-05-23: Added lookup request/response contracts, `AlcoholLookupSource`, and cursor/pageSize defaults. +- 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. @@ -179,3 +179,5 @@ FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추 - 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. From d30198175c62b3b85216c0ba2c98f3af85828345 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 23 May 2026 13:05:01 +0900 Subject: [PATCH 3/3] =?UTF-8?q?perf:=20lookup=20snapshot=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=95=84=EB=93=9C=20=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/AlcoholLookupSnapshotStore.java | 6 +- .../dto/response/AlcoholLookupItem.java | 21 +---- .../response/AlcoholLookupSnapshotItem.java | 89 +++++++++++++++++++ .../RedisAlcoholLookupSnapshotStore.java | 8 +- .../service/AlcoholLookupService.java | 22 +++-- .../InMemoryAlcoholLookupSnapshotStore.java | 8 +- .../service/AlcoholLookupServiceTest.java | 47 +++++----- plan/alcohol-lookup.md | 2 + 8 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholLookupSnapshotItem.java 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 index dc576b709..a0234fcab 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholLookupSnapshotStore.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholLookupSnapshotStore.java @@ -1,11 +1,11 @@ package app.bottlenote.alcohols.domain; -import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; +import app.bottlenote.alcohols.dto.response.AlcoholLookupSnapshotItem; import java.util.List; public interface AlcoholLookupSnapshotStore { - List findAll(); + List findAll(); - void replaceAll(List items); + void replaceAll(List items); } 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 index 07edf3dca..49b027c00 100644 --- 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 @@ -1,8 +1,6 @@ package app.bottlenote.alcohols.dto.response; import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; -import java.util.Locale; -import java.util.stream.Stream; public record AlcoholLookupItem( Long alcoholId, @@ -17,21 +15,4 @@ public record AlcoholLookupItem( Long distilleryId, String korDistillery, String engDistillery, - String imageUrl) { - - public String searchText() { - return Stream.of( - korName, - engName, - korCategoryName, - engCategoryName, - categoryGroup != null ? categoryGroup.name() : null, - korRegion, - engRegion, - korDistillery, - engDistillery) - .filter(value -> value != null && !value.isBlank()) - .map(value -> value.toLowerCase(Locale.ROOT)) - .reduce("", (left, right) -> left + " " + right); - } -} + 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/RedisAlcoholLookupSnapshotStore.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java index 0a02ca8be..573c6b1aa 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/RedisAlcoholLookupSnapshotStore.java @@ -1,7 +1,7 @@ package app.bottlenote.alcohols.repository; import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore; -import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; +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; @@ -16,14 +16,14 @@ @RequiredArgsConstructor public class RedisAlcoholLookupSnapshotStore implements AlcoholLookupSnapshotStore { private static final String LOOKUP_SNAPSHOT_KEY = "alcohol:lookup:snapshot:v1"; - private static final TypeReference> LOOKUP_ITEMS_TYPE = + private static final TypeReference> LOOKUP_ITEMS_TYPE = new TypeReference<>() {}; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; @Override - public List findAll() { + public List findAll() { Object value = redisTemplate.opsForValue().get(LOOKUP_SNAPSHOT_KEY); if (value == null) { return List.of(); @@ -38,7 +38,7 @@ public List findAll() { } @Override - public void replaceAll(List items) { + public void replaceAll(List items) { try { redisTemplate.opsForValue().set(LOOKUP_SNAPSHOT_KEY, objectMapper.writeValueAsString(items)); } catch (JsonProcessingException e) { 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 index 4e91c2f18..3caff27a9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholLookupService.java @@ -5,6 +5,7 @@ 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; @@ -28,14 +29,14 @@ public CursorResponse lookup(AlcoholLookupRequest request) { @Transactional(readOnly = true) public int syncSnapshot() { - List items = findDatabaseItems(); + List items = findDatabaseItems(); snapshotStore.replaceAll(items); return items.size(); } - private List findRedisItemsWithFallback() { + private List findRedisItemsWithFallback() { try { - List items = snapshotStore.findAll(); + List items = snapshotStore.findAll(); if (!items.isEmpty()) { return items; } @@ -46,17 +47,20 @@ private List findRedisItemsWithFallback() { return findDatabaseItems(); } - private List findDatabaseItems() { - return alcoholQueryRepository.findAllLookupItems(); + private List findDatabaseItems() { + return alcoholQueryRepository.findAllLookupItems().stream() + .map(AlcoholLookupSnapshotItem::from) + .toList(); } private CursorResponse filterAndSlice( - List items, AlcoholLookupRequest request) { + 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)) @@ -69,6 +73,7 @@ private CursorResponse filterAndSlice( || request.distilleryId().equals(item.distilleryId())) .skip(cursor) .limit(pageSize + 1) + .map(AlcoholLookupSnapshotItem::toLookupItem) .toList(); return CursorResponse.of(page, cursor, Math.toIntExact(pageSize)); @@ -83,11 +88,10 @@ private List parseKeywords(String keyword) { .toList(); } - private boolean matchesKeywords(AlcoholLookupItem item, List keywords) { + private boolean matchesKeywords(AlcoholLookupSnapshotItem item, List keywords) { if (keywords.isEmpty()) { return true; } - String searchText = item.searchText(); - return keywords.stream().allMatch(searchText::contains); + 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 index 8162196a9..4d22ab677 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholLookupSnapshotStore.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholLookupSnapshotStore.java @@ -1,20 +1,20 @@ package app.bottlenote.alcohols.fixture; import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore; -import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; +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<>(); + private List snapshot = new ArrayList<>(); @Override - public List findAll() { + public List findAll() { return List.copyOf(snapshot); } @Override - public void replaceAll(List items) { + public void replaceAll(List items) { snapshot = new ArrayList<>(items); } } 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 index 1584e72fb..cc3208bd2 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholLookupServiceTest.java @@ -8,6 +8,7 @@ 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; @@ -37,7 +38,7 @@ void setUp() { @DisplayName("20,000건 lookup snapshot에서 검색 조건을 적용해 cursor 페이지를 반환한다") void lookup_whenSnapshotHas20000Items_returnsFilteredCursorPage() { // given - snapshotStore.replaceAll(createLookupItems(20_000)); + snapshotStore.replaceAll(createLookupSnapshotItems(20_000)); AlcoholLookupRequest request = AlcoholLookupRequest.builder() .keyword("macallan speyside") @@ -65,8 +66,9 @@ void lookup_whenMultipleKeywords_returnsItemsContainingEveryKeyword() { // given snapshotStore.replaceAll( List.of( - lookupItem(1L, "맥캘란 12년", "Macallan 12", "스페이사이드", "Speyside", "맥캘란", "Macallan"), - lookupItem( + lookupSnapshotItem( + 1L, "맥캘란 12년", "Macallan 12", "스페이사이드", "Speyside", "맥캘란", "Macallan"), + lookupSnapshotItem( 2L, "글렌피딕 12년", "Glenfiddich 12", "스페이사이드", "Speyside", "글렌피딕", "Glenfiddich"))); AlcoholLookupRequest request = AlcoholLookupRequest.builder().keyword("macallan speyside").pageSize(20L).build(); @@ -105,20 +107,22 @@ void syncSnapshot_savesDatabaseLookupItemsToSnapshotStore() { // then assertThat(syncedCount).isEqualTo(1); assertThat(snapshotStore.findAll()) - .extracting(AlcoholLookupItem::alcoholId) + .extracting(AlcoholLookupSnapshotItem::alcoholId) .containsExactly(1L); + assertThat(snapshotStore.findAll().get(0).normalizedSearchText()) + .contains("macallan", "speyside", "single_malt"); } - private List createLookupItems(int size) { + private List createLookupSnapshotItems(int size) { return LongStream.rangeClosed(1, size) .mapToObj( id -> - lookupItem( + lookupSnapshotItem( id, "맥캘란 " + id, "Macallan " + id, "스페이사이드", "Speyside", "맥캘란", "Macallan")) .toList(); } - private AlcoholLookupItem lookupItem( + private AlcoholLookupSnapshotItem lookupSnapshotItem( Long alcoholId, String korName, String engName, @@ -126,20 +130,21 @@ private AlcoholLookupItem lookupItem( String engRegion, String korDistillery, String engDistillery) { - return new AlcoholLookupItem( - alcoholId, - korName, - engName, - "싱글몰트", - "Single Malt", - SINGLE_MALT, - 1L, - korRegion, - engRegion, - 10L, - korDistillery, - engDistillery, - "https://example.com/alcohol.png"); + 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) { diff --git a/plan/alcohol-lookup.md b/plan/alcohol-lookup.md index 41e26724b..b2283ba02 100644 --- a/plan/alcohol-lookup.md +++ b/plan/alcohol-lookup.md @@ -181,3 +181,5 @@ FE 위스키 선택/세팅 컴포넌트에서 사용할 고속 조회 API를 추 - 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.