From 03f2ab3d879aa18b18497d2b6e5e194151e5acf2 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 5 Sep 2026 15:51:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ES=20=EC=9E=A5=EC=95=A0=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=BD=20=EA=B2=80=EC=83=89=20MySQL=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrugSearchQueryService.search의 ES 조회를 DataAccessException 기준으로 try-catch해 DrugInfoQueryService.searchByKeyword(MySQL LIKE)로 위임한다. CallCareException(빈 키워드 등 검증 실패)은 이 계층과 무관해 그대로 전파된다. 로컬에서 ES를 존재하지 않는 포트로 돌려 실제 예외 타입을 확인함 — DataAccessResourceFailureException(DataAccessException 하위)으로 확인, catch 타입 설계가 실측과 일치한다. 단위 테스트 4건: ES 정상 시 폴백 미사용 / ES 예외 시 폴백 / 빈 키워드는 폴백 없이 즉시 예외 / trim된 키워드가 폴백에도 그대로 전달. Co-Authored-By: Claude Sonnet 5 --- .../service/query/DrugSearchQueryService.java | 25 ++++-- .../query/DrugSearchQueryServiceTest.java | 90 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryServiceTest.java diff --git a/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java b/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java index 6643627..a1532f4 100644 --- a/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java +++ b/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java @@ -8,12 +8,15 @@ import com.piuda.callcare.global.exception.CallCareException; import com.piuda.callcare.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +@Slf4j @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -23,15 +26,27 @@ public class DrugSearchQueryService { private final DrugInfoQueryService drugInfoQueryService; private final DrugInfoConverter drugInfoConverter; - // Elasticsearch 약품명 검색 (자동완성·오타·중간 단어·초성 통합, 관련도 순, 최대 20건) + /** + * Elasticsearch 약품명 검색 (자동완성·오타·중간 단어·초성 통합, 관련도 순, 최대 20건). + *

+ * ES 연결 실패·타임아웃 등 저장소 접근 예외({@link DataAccessException})가 나면 + * MySQL {@code LIKE} 검색(폴백)으로 자동 전환한다. 파라미터 검증 실패({@link CallCareException})는 + * 이 catch 대상이 아니므로 그대로 400으로 전파된다. + */ public List search(String keyword) { if (keyword == null || keyword.isBlank()) { throw new CallCareException(ErrorCode.INVALID_PARAMETER); } - return drugSearchRepository.searchByItemName(keyword.trim(), PageRequest.of(0, 20)) - .stream() - .map(drugInfoConverter::toSearchResponse) - .toList(); + String trimmed = keyword.trim(); + try { + return drugSearchRepository.searchByItemName(trimmed, PageRequest.of(0, 20)) + .stream() + .map(drugInfoConverter::toSearchResponse) + .toList(); + } catch (DataAccessException e) { + log.warn("ES 약품 검색 실패 - MySQL 폴백으로 전환. keyword={}", trimmed, e); + return drugInfoQueryService.searchByKeyword(trimmed); + } } // 선택한 약의 자동 입력 데이터 반환 (drugName, drugType, memo 자동 생성) diff --git a/src/test/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryServiceTest.java b/src/test/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryServiceTest.java new file mode 100644 index 0000000..d4fa93e --- /dev/null +++ b/src/test/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryServiceTest.java @@ -0,0 +1,90 @@ +package com.piuda.callcare.domain.druginfo.service.query; + +import com.piuda.callcare.domain.druginfo.converter.DrugInfoConverter; +import com.piuda.callcare.domain.druginfo.document.DrugDocument; +import com.piuda.callcare.domain.druginfo.dto.response.DrugSearchResponse; +import com.piuda.callcare.domain.druginfo.repository.DrugSearchRepository; +import com.piuda.callcare.global.exception.CallCareException; +import com.piuda.callcare.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DrugSearchQueryService 단위 테스트 — ES 장애 시 MySQL 자동 폴백") +class DrugSearchQueryServiceTest { + + @InjectMocks + private DrugSearchQueryService drugSearchQueryService; + + @Mock private DrugSearchRepository drugSearchRepository; + @Mock private DrugInfoQueryService drugInfoQueryService; + @Mock private DrugInfoConverter drugInfoConverter; + + @Test + @DisplayName("정상 케이스: ES가 정상이면 MySQL 폴백을 타지 않는다") + void search_uses_es_when_healthy() { + DrugDocument document = DrugDocument.builder().itemSeq("1").itemName("타이레놀").build(); + DrugSearchResponse response = new DrugSearchResponse("1", "타이레놀", null, null, null, null); + given(drugSearchRepository.searchByItemName(anyString(), any())).willReturn(List.of(document)); + given(drugInfoConverter.toSearchResponse(document)).willReturn(response); + + List result = drugSearchQueryService.search("타이레놀"); + + assertThat(result).containsExactly(response); + then(drugInfoQueryService).should(never()).searchByKeyword(anyString()); + } + + @Test + @DisplayName("장애 케이스: ES 접근 예외가 나면 MySQL LIKE 검색으로 폴백한다") + void search_falls_back_to_mysql_when_es_fails() { + DrugSearchResponse fallbackResponse = new DrugSearchResponse("1", "타이레놀", null, null, null, null); + given(drugSearchRepository.searchByItemName(anyString(), any())) + .willThrow(new DataAccessResourceFailureException("ES 연결 실패")); + given(drugInfoQueryService.searchByKeyword("타이레놀")).willReturn(List.of(fallbackResponse)); + + List result = drugSearchQueryService.search("타이레놀"); + + assertThat(result).containsExactly(fallbackResponse); + then(drugInfoQueryService).should().searchByKeyword("타이레놀"); + } + + @Test + @DisplayName("예외 케이스: 빈 키워드는 폴백 없이 즉시 INVALID_PARAMETER") + void search_throws_on_blank_keyword_without_fallback() { + assertThatThrownBy(() -> drugSearchQueryService.search(" ")) + .isInstanceOf(CallCareException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PARAMETER); + + then(drugSearchRepository).should(never()).searchByItemName(anyString(), any()); + then(drugInfoQueryService).should(never()).searchByKeyword(anyString()); + } + + @Test + @DisplayName("경계: 검색어 앞뒤 공백은 잘라서 폴백 서비스에도 그대로 전달된다") + void search_trims_keyword_before_fallback() { + given(drugSearchRepository.searchByItemName(anyString(), any())) + .willThrow(new DataAccessResourceFailureException("ES 연결 실패")); + given(drugInfoQueryService.searchByKeyword("타이레놀")).willReturn(List.of()); + + drugSearchQueryService.search(" 타이레놀 "); + + then(drugSearchRepository).should().searchByItemName("타이레놀", PageRequest.of(0, 20)); + then(drugInfoQueryService).should().searchByKeyword("타이레놀"); + } +}