기록

[Kafka × Elasticsearch 기반 상품 검색 시스템] 4. 상품 검색 API 설계 본문

Web/Spring

[Kafka × Elasticsearch 기반 상품 검색 시스템] 4. 상품 검색 API 설계

zyin 2025. 12. 15. 10:00

검색 API의 목표는 사용자의 짧은 질의(q)를 받아 의미 있는 결과를 빠르고 안정적으로 반환하는 것이다. Query 서버는 Spring WebFlux 기반의 논블로킹 아키텍처 위에서 Elasticsearch와 통신하며, ReactiveElasticsearchOperations로 네이티브 쿼리를 유연하게 구성한다. 본 편에서는 컨트롤러→서비스→리포지토리 흐름, 검색 점수 모델 설계, 페이징·정렬·필터 확장, 에러·관측·성능 고려까지 단계적으로 정리한다.

1. 전체 흐름

 

요청은 ProductQueryController → ProductQueryService → ProductQueryRepository → Elasticsearch 순으로 진행된다. 컨트롤러는 파라미터를 검증해 서비스로 전달하고, 서비스는 리포지토리의 Flux 스트림을 받아 응답 DTO와 페이지 정보를 결합한다. 리포지토리는 NativeQuery를 작성하고 리액티브로 실행한다. 이 분리는 책임을 명확히 하며, 검색 로직(리포지토리)과 프레젠테이션 요구(서비스/컨트롤러)의 결합을 줄인다.

2. 컨트롤러: 엔드포인트와 파라미터

@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductQueryController {
    private final ProductQueryService productQueryService;
    @GetMapping
    public Mono<Page<ProductResponse>> search(
            @RequestParam(required = false) String q,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        return productQueryService.search(q, page, size);
    }
}

 

핵심은 단순함이다. q가 없으면 전체 조회, 있으면 전문 검색으로 분기한다. 페이지 사이즈는 서버 기본값을 두되(예: 10), 최대 허용치(예: 100)를 필터로 제한하는 것이 바람직하다.

3. 서비스: DTO 매핑과 Page 조합

@Service
@RequiredArgsConstructor
@Slf4j
public class ProductQueryService {
    private final ProductQueryRepository repo;
    public Mono<Page<ProductResponse>> search(String keyword, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return repo.search(keyword, pageable)
                .map(e -> ProductResponse.builder()
                        .id(e.getId()).name(e.getName()).description(e.getDescription())
                        .category(e.getCategory()).price(e.getPrice())
                        .updatedAt(Instant.ofEpochMilli(e.getUpdatedAt())
                                .atZone(ZoneId.systemDefault()).toLocalDateTime())
                        .build())
                .collectList()
                .zipWith(repo.count(keyword))
                .map(t -> new PageImpl<>(t.getT1(), pageable, t.getT2()))
                .doOnNext(p -> log.info("search done q='{}' total={}", keyword, p.getTotalElements()));
    }
}

 

Flux 결과를 .collectList()로 수집해 count()와 zip하여 PageImpl로 만든다. 이 방식은 클라이언트가 익숙한 Spring Page 모델을 그대로 활용하게 하며, 리액티브 파이프라인을 유지한다. updatedAt은 사용자 친화적으로 변환한다(밀리초→로컬 시간).

4. 리포지토리: NativeQuery와 리액티브 실행

@Repository
@RequiredArgsConstructor
@Slf4j
public class ProductQueryRepository {
    private final ReactiveElasticsearchOperations ops;
    public Flux<ProductEntity> search(String keyword, Pageable pageable) {
        Query q = (keyword == null || keyword.isBlank())
                ? NativeQuery.builder().withPageable(pageable).build()
                : NativeQuery.builder()
                    .withQuery(b -> b.multiMatch(m -> m
                        .fields("name", "description") // 필요시 name^3 가중치
                        .query(keyword)
                        .type(MultiMatchQueryType.BEST_FIELDS)))
                    .withPageable(pageable)
                    .build();
        return ops.search(q, ProductEntity.class).map(SearchHit::getContent)
                .doOnComplete(() -> log.info("es search keyword='{}'", keyword));
    }
    public Mono<Long> count(String keyword) {
        Query cq = (keyword == null || keyword.isBlank())
                ? NativeQuery.builder().build()
                : NativeQuery.builder().withQuery(b -> b.multiMatch(m -> m
                        .fields("name", "description").query(keyword))).build();
        return ops.count(cq, ProductEntity.class);
    }
}

 

multi_match로 필드 단위 검색을 단순화하고, count를 별도 호출로 분리해 비용을 줄이고 페이징 계산을 명확히 한다. 추후 필터와 정렬이 늘어도 빌더 체인을 확장할수 있다. 

 

5. 점수, 가중치, 필터, 확장 이해하기

검색 API의 품질은 “어떤 쿼리를 어떻게 구성하느냐”에 달려 있다. Elasticsearch는 단순한 문자열 비교가 아니라, 각 문서에 점수(score)를 계산해 가장 연관성이 높은 결과를 상단에 올린다. 따라서 검색 쿼리를 설계할 때는 “어떤 필드에 더 높은 가중치를 줄지”, “정렬을 어떤 기준으로 할지”, “보조 기능(하이라이트, 동의어, 퍼지 등)을 얼마나 적용할지”를 의식적으로 정해야 한다.

1) multi_match 유형 선택

상품 검색에서는 하나의 키워드가 여러 필드(name, description)에서 동시에 일치할 수 있다. 이때 multi_match 쿼리를 사용하면 두 필드를 한 번에 검색할 수 있다. 가장 일반적인 모드는 BEST_FIELDS로, 여러 필드 중 일치도가 가장 높은 필드의 점수를 사용한다.
예를 들어 “무선 마우스”라는 키워드를 검색할 때, 이름에 완전히 들어맞는 상품은 점수가 높고, 설명에만 일부 포함된 상품은 점수가 낮게 나온다.
만약 상품 이름의 중요도를 높이고 싶다면 아래처럼 필드별 가중치를 줄 수 있다.

"fields": ["name^3", "description"]

이 경우 이름에 포함된 문서는 설명에 포함된 문서보다 3배 높은 점수를 받는다.
문장 그대로의 일치를 중시하고 싶다면 type: phrase를 선택한다. "무선 마우스"처럼 붙어 있는 문장형 검색의 정확도가 높아지고, 단어 간 허용 거리(slop)를 지정해 "무선 블루투스 마우스" 같은 유사 문장도 함께 잡을 수 있다.

2) 필터와 정렬

검색 결과에 카테고리나 가격 조건을 추가할 때는 필터(filter) 를 사용해야 한다. 필터는 단순 조건 필터링이기 때문에 문서 점수 계산에 영향을 주지 않는다. 즉, “전자기기” 카테고리만 보고 싶으면 스코어는 그대로 두고 필터만 거는 것이 가장 효율적이다.
정렬은 기본적으로 _score DESC(높은 점수 우선)로 되어 있지만, 특정 화면에서는 가격순·최신순 정렬이 필요할 수 있다.
이때는 다음과 같이 명시적으로 sort 절을 추가한다.

"sort": [{ "price": "asc" }]

단, 정렬 기준을 바꾸면 _score 기반 랭킹이 무시될 수 있으므로, “가장 관련성 높은 결과”와 “정렬 중심의 결과” 중 어느 쪽이 우선인지 명확히 정의해야 한다.

3) 하이라이트(Highlight)

사용자에게 검색 결과를 보여줄 때, 일치한 단어를 강조하면 가독성이 크게 향상된다. Elasticsearch는 highlight 기능으로 이를 지원한다.
예를 들어 "무선"을 검색했다면, 결과 설명 중 "무선 <em>마우스</em>"처럼 강조 표시가 포함된 필드를 반환할 수 있다.
하이라이트 필드를 DTO에 따로 두거나, 프런트엔드에서 안전하게 HTML 렌더링하도록 정책을 정하면 된다. 이는 UX 개선 효과가 크다.

4) 동의어와 형태소 확장

한국어는 복합명사, 띄어쓰기, 영어 병기 등의 이유로 검색어 변형이 자주 발생한다.
이를 해결하기 위해 Nori 분석기를 사용하면 “무선마우스”를 “무선” + “마우스”로 자동 분리해 인덱싱한다. 덕분에 “무선 마우스”처럼 띄어 쓴 검색어도 정확히 매칭된다.
제품명이 영어로도 많이 표기된다면 synonym 필터를 설정해 “마우스 ↔ mouse”처럼 동의어 관계를 지정할 수 있다.
또한 오탈자 허용을 위해 fuzziness: "AUTO"를 적용하면, 한두 글자가 틀린 검색어도 유연하게 매칭된다(예: “무선 마오스” → “무선 마우스”). 다만 퍼지 매칭을 과도하게 허용하면 원치 않는 결과가 늘어나므로, 검색어 길이에 따라 선택적으로 적용하는 것이 좋다.
예를 들어, 3글자 미만의 짧은 검색어에는 퍼지 매칭을 끄고, 긴 검색어(예: “블루투스헤드폰”)에만 적용하는 식이다.

6. 예시 호출과 응답

curl "http://localhost:8081/products?q=마우스&page=0&size=5"

응답(요약):

{
  "content": [{
    "id": 1001,
    "name": "무선 마우스",
    "description": "2.4GHz 블루투스 마우스",
    "category": "전자기기",
    "price": 24900.0,
    "updatedAt": "2025-11-08T13:10:24"
  }],
  "pageable": {"pageNumber":0,"pageSize":5},
  "totalElements": 1, "totalPages": 1
}
Comments