Repository navigation
feat: 정책 검색을 Elasticsearch로 전환 — 색인 구축과 검색 이관 (#230) - #232
Merged
Merged
Conversation
LIKE 기반 검색의 한계를 실측한 뒤(docs/2026-09-05-검색-기준선-측정.md) 검색엔진을 도입하기 위한 기반을 만든다. 색인·검색 코드는 후속 커밋에서 붙인다. - es/Dockerfile: 공식 이미지에 없는 analysis-nori(한국어 형태소 분석기)를 설치한다. compose 인라인이 아닌 별도 Dockerfile인 이유는 배포 환경(k8s/ECS)이 레지스트리 이미지를 요구하고, 린트·스캔 도구도 Dockerfile을 전제하기 때문이다. 버전 8.18.8은 Spring Boot 3.5.16이 관리하는 클라이언트 버전에 맞춘 것이다. - es/config/userdict_ko.txt: nori 기본 사전에 없는 도메인 단어 사전. 실제 정책 제목 2,691건과 지역명 265개를 분석해 뽑았다. decompound_mode만으로는 mecab-ko-dic에 등록되지 않은 합성어(청년도약계좌 등)가 엉뚱하게 쪼개져 검색되지 않는다. - es/policy-index.json: 색인 매핑. 색인용(ko_index)과 검색용(ko_search) 분석기를 나눠, 동의어를 검색 시점에만 적용한다 — 동의어를 고쳐도 재색인 없이 _reload_search_analyzers 로 끝난다. dynamic: strict 는 필드 오타를 조용한 누락이 아니라 색인 거부로 드러낸다. - docker-compose.yml: backend의 depends_on에 넣지 않는다. ES가 없어도 앱은 뜨고 기존 LIKE 검색으로 동작해야 한다(선택 의존). - application.yml: connection 1s / socket 3s. 기본값(30s)을 기다리면 폴백이 있어도 사용자는 이미 떠난 뒤다. youthpick.search.enabled 는 안전 기본값 off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MySQL을 원본으로 두고 ES는 검색용 사본으로 채운다. 검색 질의 전환은 후속 작업이며, 이 커밋 시점에도 사용자 검색은 기존 LIKE 경로 그대로다. 색인 경로는 둘이고 꼬리는 공유한다. - 초기 적재: PolicyReindexer 가 앱 기동 후 인덱스가 비어 있을 때만 전량 색인한다. 매핑을 바꿔 새 인덱스로 alias를 옮기면 그쪽이 비어 있으므로 자동으로 다시 채워진다. - 변경 반영: PolicyChangedEvent 를 AFTER_COMMIT 으로 받아 갱신한다. 이벤트를 낀 이유는 결합 때문이다. 배치가 색인기를 직접 부르게 했더니 @DataJpaTest 슬라이스가 ElasticsearchClient 빈을 요구해 테스트 14개가 컨텍스트 로딩에서 깨졌다. 테스트를 목으로 덮는 대신 이벤트로 끊자 sync 패키지가 ES를 모르게 되고 해당 테스트는 한 줄도 고치지 않게 됐다. - 커밋 전에 색인하면 롤백 시 MySQL에 없는 문서가 ES에만 남는다. 반대로 트랜잭션 밖에서 발행하면 AFTER_COMMIT 리스너가 아예 호출되지 않는다. 그래서 발행은 트랜잭션 안(writeChunk), 처리는 커밋 후다. - 색인 실패는 예외로 던지지 않고 건수로 돌려준다. ES는 언제든 다시 만들 수 있는 사본이지만 수집한 원본은 다시 받아올 수 없다. - bulk 는 일부 실패해도 HTTP 200이라 항목별로 error를 확인해야 누락을 알 수 있다. - ElasticsearchJsonConfig: 기본 변환기가 LocalDate를 몰라 초기 적재 2,691건이 전량 거부됐다. 웹 응답용 ObjectMapper와 분리해, API 포맷 변경이 색인 포맷을 흔들지 않게 한다. - PolicyUpsertWriter.writeOne 이 boolean 대신 저장된 엔티티를 돌려준다. 색인에 필요한 id를 얻기 위해서이고, 신규 판정은 호출부가 existing 으로 한다. 검증: 로컬 2,691건 색인 성공(실패 0건, 7.7s), 테스트 544건 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ES 는 조건에 맞는 정책 id 와 총건수만 돌려주고, 카드 내용은 기존대로 MySQL 에서 읽는다. ES 에 카드 내용까지 담으면 색인이 조금만 늦어도 사용자에게 옛 제목이 보이고, 응답 조립 로직이 두 벌이 된다. id 만 받으면 화면 값은 항상 원본 기준이고 폴백 경로와 조립 코드를 그대로 공유한다. findCards 의 필터 8개를 그대로 옮겼고 건수가 일치하는 것을 확인했다. 전체 1203, 카테고리=주거 139, 지역=서울특별시 195, 취업상태=0013003 1092, 나이=25 1151 — 다섯 조건 모두 MySQL 과 동일하다. - sidoNames 에 text 하위 필드를 추가했다. keyword 타입은 정확 일치라 "서울"로 "서울특별시"가 걸리지 않아, 지역 행으로만 그 지역인 정책이 키워드 검색에서 빠졌다(#199 회귀). 매핑 변경이라 policy_v2 를 만들고 alias 를 옮겼으며, 인덱스가 비어 있으면 자동 재색인되는 기존 장치가 그대로 재색인 트리거가 됐다. - MySQL 이 "case when ... then 1 else 0" 정렬로 표현하던 후순위 규칙을 should 절 가산점으로 뒤집었다. should 는 걸러내지 않으므로 결과 집합은 그대로고 순서만 바뀐다. - minimum_should_match "2<70%": 기본값(OR)은 '청년' 같은 흔한 한 단어만 맞아도 전부 걸려 필터 구실을 못 한다. - track_total_hits 를 켠다. 기본값은 10,000건에서 세기를 멈춰 총 페이지 수가 틀어진다. - _source 를 받지 않는다. id 만 쓰므로 문서 본문 전송이 통째로 사라진다. - 폴백 검증: ES 컨테이너를 내린 상태에서 검색이 정상 응답했고(862건) 경고 로그가 남았다. 다시 올리자 관련도 순서로 복귀했다. 검증: 테스트 544건 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
무엇을
정책 목록 검색을 MySQL LIKE 에서 Elasticsearch 로 옮긴다. MySQL 이 원본이고 ES 는 검색용 사본이며, ES 가 없거나 죽어도 서비스는 기존 LIKE 경로로 계속 동작한다.
왜
docs/2026-09-05-검색-기준선-측정.md에 실측한 대로, LIKE 검색은 단어를 잘라내지 못해 다음이 전부 0건이었다.서울 청년 취업→ 0건 (각 단어는 수백 건씩 존재)청년도약계좌→ 형태소가 엉뚱하게 쪼개져 미매칭어떻게
색인 — 진입점 둘, 꼬리 하나
PolicyReindexer가 기동 후 인덱스가 비었을 때만 전량 색인PolicyChangedEvent를AFTER_COMMIT으로 받아 갱신배치·관리자 4개 경로(수집/누락숨김/수정/삭제)가 모두 이벤트를 발행한다. 이벤트로 끊은 이유는 결합 때문이다 — 배치가 색인기를 직접 부르게 했더니
@DataJpaTest슬라이스가ElasticsearchClient빈을 요구해 테스트 14개가 컨텍스트 로딩에서 깨졌다. 목으로 덮는 대신 이벤트를 끼우자sync패키지가 ES 를 모르게 되고 해당 테스트는 한 줄도 고치지 않게 됐다.검색 — ES 는 id 와 총건수만 돌려준다
카드 내용까지 ES 에 담으면 색인이 조금만 늦어도 사용자에게 옛 제목이 보이고 응답 조립 로직이 두 벌이 된다. id 만 받으면 화면 값은 항상 원본 기준이고, 폴백 경로와 조립 코드를 그대로 공유한다.
검증
findCards의 필터 8개를 옮긴 뒤 건수를 MySQL 과 대조했다.검색 품질
청년취업ON청년 월세 지원청년 면접 정장 지원폴백 —
docker stop youthpick-es상태에서 검색이 정상 응답(862건)하고 경고 로그가 남았다. 컨테이너를 다시 올리자 관련도 순서로 복귀했다.초기 적재 2,691건 / 실패 0건 / 7.7초. 테스트 544건 통과.
남은 작업 (#230 에 이어서)
es/config/synonym_ko.txt를 실데이터 기반으로 채우기Refs #230
🤖 Generated with Claude Code