From 8e62295fe6ff79930ddc1378c8d421f8ee0b665e Mon Sep 17 00:00:00 2001 From: myh Date: Sun, 6 Sep 2026 03:09:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=EC=A0=95=EC=B1=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9A=A9=20Elasticsearch=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20(#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build.gradle | 1 + docker-compose.yml | 20 +- ...0\354\204\240-\354\270\241\354\240\225.md" | 115 +++++ ...5-\352\260\200\354\235\264\353\223\234.md" | 487 ++++++++++++++++++ es/Dockerfile | 7 + es/config/synonym_ko.txt | 18 + es/config/userdict_ko.txt | 114 ++++ es/helper/analyze.py | 73 +++ es/helper/create_index.py | 104 ++++ es/helper/dict_candidates.py | 83 +++ es/policy-index.json | 105 ++++ src/main/resources/application-local.yml | 6 + src/main/resources/application.yml | 14 + 13 files changed, 1146 insertions(+), 1 deletion(-) create mode 100644 "docs/2026-09-05-\352\262\200\354\203\211-\352\270\260\354\244\200\354\204\240-\354\270\241\354\240\225.md" create mode 100644 "docs/ES-\354\247\201\354\240\221-\352\265\254\354\266\225-\352\260\200\354\235\264\353\223\234.md" create mode 100644 es/Dockerfile create mode 100644 es/config/synonym_ko.txt create mode 100644 es/config/userdict_ko.txt create mode 100644 es/helper/analyze.py create mode 100644 es/helper/create_index.py create mode 100644 es/helper/dict_candidates.py create mode 100644 es/policy-index.json diff --git a/build.gradle b/build.gradle index 399ab4b..3ce451f 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'io.minio:minio:9.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' implementation 'org.flywaydb:flyway-core' diff --git a/docker-compose.yml b/docker-compose.yml index 07e23c8..2f96c3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,24 @@ services: timeout: 5s retries: 10 start_period: 10s - + elasticsearch: + build: ./es + container_name: youthpick-es + environment: + - discovery.type=single-node # 단일 노드 (클러스터 구성 안 함) + - xpack.security.enabled=false # 로컬이라 인증 끔 + - ES_JAVA_OPTS=-Xms512m -Xmx512m # 안 주면 시스템 메모리의 절반을 먹는다 + ports: + - "${ES_PORT:-9200}:9200" + volumes: + - youthpick-es-data:/usr/share/elasticsearch/data + - ./es/config:/usr/share/elasticsearch/config/analysis + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s backend: build: context: . @@ -103,3 +120,4 @@ volumes: youthpick-mysql-data: youthpick-redis-data: youthpick-minio-data: + youthpick-es-data: diff --git "a/docs/2026-09-05-\352\262\200\354\203\211-\352\270\260\354\244\200\354\204\240-\354\270\241\354\240\225.md" "b/docs/2026-09-05-\352\262\200\354\203\211-\352\270\260\354\244\200\354\204\240-\354\270\241\354\240\225.md" new file mode 100644 index 0000000..cbea62f --- /dev/null +++ "b/docs/2026-09-05-\352\262\200\354\203\211-\352\270\260\354\244\200\354\204\240-\354\270\241\354\240\225.md" @@ -0,0 +1,115 @@ +# 검색 기준선(before) 측정 — LIKE 검색의 실패 유형 + +> 2026-09-05 측정. ES 도입 전 현행 `LIKE '%키워드%'` 검색의 상태를 기록한다. +> 대상: `policies` 2,691건 (VISIBLE, deleted_at IS NULL) — **실데이터, 합성 아님** +> 측정 조건: `PolicyRepository.findCards`의 keyword 조건과 동일한 5필드 LIKE +> (title / keywords / description / support_content / organization_name) + +## 0. 실사용 검색 로그 (참고) + +`search_histories` 53건, 2026-07-25 14:20~15:16 (약 1시간) — **데모 촬영 중 기록**이라 표본으로 쓸 수 없다. + +| 검색어 | 횟수 | 결과 | +|---|---|---| +| 서울 | 29 | 56~198 | +| 주거 | 8 | 17 | +| 강원 | 7 | 18 | +| 서산 | 5 | 33 | +| k-디지털 | 2 | **1** | +| k | 1 | **60** | + +- `result_count = 0`인 검색은 **0건** — 데모라 잘 나오는 검색어만 입력했기 때문. +- 다만 `k-디지털 → 1건`은 0건이 아니어서 "성공"으로 기록됐지만 **사용자 입장에서는 실패**다. + → 현행 0건율 지표가 실패를 과소평가한다. +- 검색어 7종 중 4종이 지역명(서울·강원·서산) — 지역 검색 비중이 높다는 실사용 근거. + +## 1. 실패 유형과 실측치 + +### 유형 A — 동의어를 모른다 + +| 검색어 | 결과 | +|---|---| +| 일자리 | 380 | +| 취업 | 662 | +| 채용 | 166 | +| 구직 | 222 | + +**"일자리"로 검색하면 "취업" 정책 488건을 놓친다** (전체 2,691건의 **18.1%**). +"구직" 정책은 158건을 놓친다. 주거 계열도 동일 — "주거" 검색 시 월세 8건 · 주택 25건 누락. + +### 유형 B — 띄어쓰기가 다르면 못 찾는다 + +| 검색어 | 결과 | +|---|---| +| 청년 일자리 (공백 O) | 49 | +| 청년일자리 (공백 X) | 70 | + +두 결과 집합이 서로 다르다. 사용자가 띄어 쓰면 붙여 쓴 정책을, 붙여 쓰면 띄어 쓴 정책을 놓친다. + +### 유형 C — 어순이 바뀌면 무너진다 + +| 검색어 | 결과 | +|---|---| +| 지원사업 | 503 | +| 사업 지원 | 24 | +| 청년 월세 지원 | 7 | +| 월세 지원 청년 | **0** | +| 일자리 청년 | 1 | + +**단어를 재배열했을 뿐인데 7건 → 0건.** LIKE는 문자열이 그 순서 그대로 붙어 있어야 매칭된다. + +### 유형 D — 자연스러운 문장으로 검색하면 0건 + +| 검색어 | 결과 | +|---|---| +| **서울 청년 취업** | **0** | +| **일자리를 찾고 있어요** | **0** | + +가장 심각한 케이스. `서울`·`청년`·`취업`은 각각 수백 건씩 있는 단어인데, **세 단어를 함께 입력하면 0건**이다. +사용자가 검색창에 자연어를 넣는 순간 검색이 사실상 동작하지 않는다. + +### 유형 E — 표기 변형·복합어 + +| 검색어 | 결과 | +|---|---| +| k-디지털 | 3 | +| **K디지털** (하이픈 제거) | **0** | +| 디지털 | 52 | +| 청년도약계좌 | 1 | + +실사용 로그에 남은 `k-디지털`은 하이픈을 빼는 순간 0건이 된다. + +## 2. 요약 + +| 유형 | 대표 케이스 | before | +|---|---|---| +| A 동의어 | 일자리 → 취업 정책 누락 | **488건(18.1%) 누락** | +| B 띄어쓰기 | 청년 일자리 / 청년일자리 | 49 vs 70 (불일치) | +| C 어순 | 청년 월세 지원 → 월세 지원 청년 | 7 → **0** | +| D 자연어 | 서울 청년 취업 | **0** | +| E 표기변형 | k-디지털 → K디지털 | 3 → **0** | + +**공통 원인:** `LIKE '%키워드%'`는 형태소·동의어·어순 개념이 없는 **문자열 부분일치**다. +쿼리 튜닝으로는 해결되지 않으며, 색인 자료구조를 바꿔야 한다. + +또한 이 5개 유형 어디에도 **관련도 순위**가 없다 — 현재 정렬은 `id DESC`(최신순) 고정이라, +제목 정확일치와 본문 구석 일치가 동급이다. (로그의 `k → 60건`이 그 예) + +## 3. 재측정 방법 (after 비교용) + +동일 조건에서 같은 검색어를 재실행한다. + +```bash +# scratchpad/baseline.sh 와 동일한 5필드 LIKE +docker exec youthpick-mysql mysql -uroot -proot --default-character-set=utf8mb4 -N -B youthpick -e " + SELECT COUNT(*) FROM policies p + WHERE p.visibility='VISIBLE' AND p.admin_hidden=0 AND p.deleted_at IS NULL + AND (p.title LIKE '%검색어%' OR p.keywords LIKE '%검색어%' OR p.description LIKE '%검색어%' + OR p.support_content LIKE '%검색어%' OR p.organization_name LIKE '%검색어%');" +``` + +측정 검색어 26종: +`일자리 취업 채용 구직 일거리 주거 주택 월세 임대 창업 사업 지원금 보조금 수당` +`"청년 일자리" 청년일자리 "일자리 청년" 지원사업 "사업 지원" k-디지털 K디지털 디지털` +`청년도약계좌 도약계좌 일자리를 취업지원` +`"일자리를 찾고 있어요" "청년 월세 지원" "월세 지원 청년" "서울 청년 취업" "청년 취업 서울"` diff --git "a/docs/ES-\354\247\201\354\240\221-\352\265\254\354\266\225-\352\260\200\354\235\264\353\223\234.md" "b/docs/ES-\354\247\201\354\240\221-\352\265\254\354\266\225-\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 0000000..613f685 --- /dev/null +++ "b/docs/ES-\354\247\201\354\240\221-\352\265\254\354\266\225-\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,487 @@ +# Elasticsearch + nori + Spring 연결 — 직접 해보기 + +> 목표: 빈 상태에서 시작해 **ES 컨테이너 기동 → nori 설치 확인 → Spring 앱과 연결**까지. +> 각 단계에 **확인 방법**이 있다. 확인이 통과해야 다음으로 넘어간다. + +## 전체 순서 + +``` +1. ES 컨테이너 띄우기 (nori 없이) — 먼저 맨몸으로 뜨는지 본다 +2. nori 설치 — Dockerfile 로 굽는다 +3. nori 동작 확인 — 한국어가 실제로 쪼개지는지 +4. 사전 파일 자리 만들기 — 나중에 채울 빈 파일 +5. Spring 의존성 추가 +6. Spring 설정 (yml) +7. 연결 확인 — 앱이 ES를 잡는지 +``` + +--- + +# 1. ES 컨테이너 띄우기 (nori 없이) + +먼저 **플러그인 없이** 순정 ES가 뜨는지 확인한다. 문제가 생기면 원인이 ES인지 nori인지 가려야 하기 때문. + +## 1-1. `docker-compose.yml` 에 서비스 추가 + +기존 `services:` 아래, `backend:` 앞에 넣는다. + +```yaml + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.18.8 + container_name: youthpick-es + environment: + - discovery.type=single-node # 단일 노드 (클러스터 구성 안 함) + - xpack.security.enabled=false # 로컬이라 인증 끔 + - ES_JAVA_OPTS=-Xms512m -Xmx512m # JVM 힙. 안 주면 시스템 메모리의 절반을 먹는다 + ports: + - "${ES_PORT:-9200}:9200" + volumes: + - youthpick-es-data:/usr/share/elasticsearch/data + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s +``` + +파일 맨 아래 `volumes:` 블록에 한 줄 추가한다. + +```yaml +volumes: + youthpick-mysql-data: + youthpick-redis-data: + youthpick-minio-data: + youthpick-es-data: # ← 추가 +``` + +> **`backend` 의 `depends_on` 에는 넣지 않는다.** ES가 죽어도 앱은 떠야 하고, +> 검색은 기존 LIKE 경로로 폴백해야 하기 때문(뒤 단계에서 구현). + +## 1-2. 띄우기 + +```bash +cd D:\develop\project\ClaudeCode\test\Sesac\team-first-project\youth-pick +docker compose up -d elasticsearch +``` + +## ✅ 확인 1 + +```bash +docker compose ps elasticsearch +``` +`STATUS` 가 `Up ... (healthy)` 가 될 때까지 기다린다 (40초~1분). + +```bash +curl http://localhost:9200 +``` + +이렇게 나오면 성공: +```json +{ + "name" : "...", + "cluster_name" : "docker-cluster", + "version" : { "number" : "8.18.8", ... }, + "tagline" : "You Know, for Search" +} +``` + +
+안 되면 + +- `docker compose logs elasticsearch` 로 로그 확인 +- `max virtual memory areas vm.max_map_count [65530] is too low` → Docker Desktop 재시작 +- 메모리 부족 → Docker Desktop 설정에서 메모리를 4GB 이상으로 +
+ +--- + +# 2. nori 설치 + +## 2-1. 왜 Dockerfile 인가 + +`docker exec` 로 컨테이너에 들어가서 설치해도 동작하지만, **컨테이너를 다시 만들면 사라진다.** +플러그인은 `/usr/share/elasticsearch/plugins/` 에 설치되는데 여기는 볼륨이 아니다. +데이터 볼륨은 남으므로 "데이터는 있는데 분석기가 없는" 상태가 되어 원인 찾기가 어려워진다. + +이미지에 구워두면 몇 번을 다시 만들어도 항상 있고, 팀원이 clone 해서 `docker compose up` 해도 똑같이 동작한다. + +## 2-2. `es/Dockerfile` 생성 + +프로젝트 루트에 `es/` 폴더를 만들고 그 안에 `Dockerfile`: + +```dockerfile +# 버전은 Spring Boot 가 관리하는 클라이언트 버전과 맞춘다. +# Spring Boot 3.5.16 → spring-data-elasticsearch 5.5.13 → elasticsearch-java 8.18.8 +FROM docker.elastic.co/elasticsearch/elasticsearch:8.18.8 + +RUN bin/elasticsearch-plugin install --batch analysis-nori +``` + +> `--batch` 는 설치 중 물어보는 확인 프롬프트를 자동 승인한다. 없으면 빌드가 멈춘다. +> +> **버전을 클라이언트와 맞추는 이유:** Java 클라이언트와 ES 서버의 minor 버전이 어긋나면 +> 미묘한 호환성 문제가 난다. 클라이언트 버전은 이렇게 확인한다: +> ```bash +> ./gradlew dependencies --configuration compileClasspath | grep elasticsearch-java +> ``` +> (5단계에서 의존성을 추가한 뒤에 확인 가능) + +## 2-3. compose 를 image → build 로 변경 + +```yaml + elasticsearch: + build: ./es # ← image: ... 를 이걸로 교체 + container_name: youthpick-es + ... +``` + +## 2-4. 다시 빌드해서 띄우기 + +```bash +docker compose up -d --build elasticsearch +``` + +`--build` 를 빼면 기존 이미지를 그대로 쓴다. 처음 빌드는 몇 분 걸린다. + +## ✅ 확인 2 + +```bash +docker exec youthpick-es bin/elasticsearch-plugin list +``` + +``` +analysis-nori +``` + +한 줄 나오면 성공. + +--- + +# 3. nori 동작 확인 + +설치됐다고 끝이 아니다. **실제로 한국어를 쪼개는지** 봐야 한다. + +## 3-1. 문제: Windows 셸이 한글을 깨뜨린다 + +Git Bash / PowerShell 에서 curl 로 한글을 인라인으로 보내면 CP949 로 인코딩돼 ES 가 400 을 뱉는다. + +```bash +curl -XGET "localhost:9200/_analyze" -H 'Content-Type: application/json' \ + -d '{"tokenizer":"nori_tokenizer","text":"청년 일자리"}' +``` +```json +{"error":{"reason":"Invalid UTF-8 start byte 0xb3", ...}, "status":400} +``` + +## 3-2. 해결: UTF-8 로 보내는 헬퍼 + +`es/analyze.py` 를 만든다. + +```python +# -*- coding: utf-8 -*- +"""ES _analyze 호출 헬퍼. Windows 셸의 한글 인코딩 문제를 우회한다.""" +import json +import sys +import urllib.request + +ES = "http://localhost:9200" + + +def analyze(text, tokenizer="nori_tokenizer"): + body = {"tokenizer": tokenizer, "text": text} + req = urllib.request.Request( + f"{ES}/_analyze", + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as res: + return json.loads(res.read().decode("utf-8")) + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + text = " ".join(sys.argv[1:]) + tokens = [t["token"] for t in analyze(text)["tokens"]] + print(f"{text}\n -> {' · '.join(tokens)}") +``` + +## ✅ 확인 3 + +```bash +python es/analyze.py "청년 일자리를 지원하는 정책" +``` + +``` +청년 일자리를 지원하는 정책 + -> 청년 · 일 · 자리 · 를 · 지원 · 하 · 는 · 정책 +``` + +**조사 `를`, `하`, `는` 이 분리되면 nori 가 동작하는 것이다.** + +> `일자리` 가 `일 · 자리` 로 쪼개진 것에 주목. `decompound_mode` 기본값이 `discard` 라 +> 복합어를 쪼갠 뒤 원본을 버리기 때문이다. 다음 단계(인덱스 매핑)에서 `mixed` 로 바꾼다. +> 지금은 "nori 가 동작한다"만 확인하면 된다. + +--- + +# 4. 사전 파일 자리 만들기 + +인덱스 매핑에서 사용자 사전·동의어 사전을 참조할 예정이라 **파일이 없으면 ES 가 에러를 낸다.** +빈 파일을 미리 만들어 둔다. + +## 4-1. 파일 2개 + +`es/config/userdict_ko.txt` +``` +# nori 사용자 사전 — 쪼개지면 안 되는 복합어를 한 줄에 하나씩. +# 이 파일을 고치면 재색인이 필요하다(색인 시점에 적용되므로). +``` + +`es/config/synonym_ko.txt` +``` +# 동의어 사전 (Solr 형식) — 검색 시점에만 적용된다. +# 등가형: 일자리, 취업, 채용, 구직 +# 지정형: 알바, 아르바이트 => 아르바이트 +``` + +## 4-2. compose 에 마운트 추가 + +```yaml + volumes: + - youthpick-es-data:/usr/share/elasticsearch/data + - ./es/config:/usr/share/elasticsearch/config/analysis # ← 추가 +``` + +> ES 는 보안상 `config/` 하위 경로만 사전 파일로 읽는다. 다른 경로에 두면 거부된다. + +```bash +docker compose up -d elasticsearch +``` + +## ✅ 확인 4 + +```bash +docker exec youthpick-es ls config/analysis +``` +``` +synonym_ko.txt +userdict_ko.txt +``` + +--- + +# 5. Spring 의존성 추가 + +## 5-1. `build.gradle` + +`dependencies { }` 안에 한 줄: + +```gradle +implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' +``` + +버전은 안 쓴다 — Spring Boot 플러그인이 관리한다. + +> **`@Document` / `ElasticsearchRepository` 는 쓰지 않을 것이다.** +> 이 스타터를 쓰는 이유는 `ElasticsearchClient`(공식 Java 클라이언트) 자동구성을 얻기 위해서다. +> 우리 검색은 필터 8개 + 조건부 정렬 + 패싯 집계라 Repository 추상화로 표현되지 않는다. + +## ✅ 확인 5 + +```bash +./gradlew compileJava +./gradlew dependencies --configuration compileClasspath | grep elasticsearch-java +``` +``` +co.elastic.clients:elasticsearch-java:8.18.8 +``` + +**여기 나온 버전이 `es/Dockerfile` 의 ES 버전과 같아야 한다.** 다르면 Dockerfile 을 맞춰 수정하고 +`docker compose up -d --build elasticsearch` 로 다시 빌드한다. + +--- + +# 6. Spring 설정 + +이 프로젝트의 yml 규칙을 먼저 이해하고 넣어야 한다. + +| 종류 | 어디에 | 기존 예시 | +|---|---|---| +| 환경마다 다른 **주소** | `application-local.yml` | `spring.data.redis.host` | +| 어디서든 같은 **동작 규칙** | `application.yml` | `spring.http.client.read-timeout` | +| **안전 기본값 off** | `application.yml` | `spring.flyway.enabled: false` | +| 그걸 **켜기** | `application-local.yml` | `spring.flyway.enabled: true` | + +## 6-1. `application.yml` — 동작 규칙 + 안전 기본값 + +`spring:` 아래에 추가: +```yaml + elasticsearch: + # 접속 주소(uris)는 환경마다 달라 각 프로필이 정한다(redis와 같은 방식). + # 여기엔 환경과 무관한 동작 규칙만 둔다 — ES가 느려질 때 기본값(30s)을 기다리면 + # 폴백이 있어도 사용자는 이미 떠난 뒤다. 짧게 끊고 LIKE 경로로 넘긴다. + connection-timeout: ${ES_CONNECT_TIMEOUT:1s} + socket-timeout: ${ES_SOCKET_TIMEOUT:3s} +``` + +`youthpick:` 아래에 추가: +```yaml + search: + # 기본 off(안전 기본값) — sync.scheduler와 같은 방식. + # ES 없이도 앱이 뜨고 LIKE로 검색된다. + enabled: ${SEARCH_ES_ENABLED:false} + # 코드가 보는 이름. 실제 인덱스는 policy_v1, policy_v2… 이고 이 alias가 가리킨다. + alias: ${SEARCH_ES_ALIAS:policy} +``` + +## 6-2. `application-local.yml` — 주소 + 켜기 + +`spring:` 아래: +```yaml + elasticsearch: + uris: ${ES_URIS:http://localhost:9200} +``` + +`youthpick:` 아래: +```yaml + search: + enabled: ${SEARCH_ES_ENABLED:true} # 로컬은 compose로 ES를 띄우므로 켠다 +``` + +## 6-3. 설정 바인딩 클래스 + +`youthpick.search.*` 를 코드에서 읽으려면 클래스가 필요하다. +이 프로젝트는 `@ConfigurationPropertiesScan` 이 켜져 있고(`YouthpickApplication`), +기존 `SiteProperties` 처럼 **record 하나**로 끝낸다. + +`src/main/java/com/bop/youthpick/policy/search/PolicySearchProperties.java` + +```java +package com.bop.youthpick.policy.search; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 정책 검색(Elasticsearch) 설정. + * + *

{@code enabled}가 false면 ES를 아예 호출하지 않고 기존 LIKE 검색만 쓴다 — ES가 없는 + * 환경(테스트·CI)에서도 앱이 그대로 뜨게 하기 위함이며, {@code youthpick.sync.scheduler.enabled}와 + * 같은 "안전 기본값 off" 방식이다. + * + *

{@code alias}는 코드가 보는 인덱스 이름이다. 실제 인덱스는 {@code policy_v1}, + * {@code policy_v2}… 이고 이 alias가 그중 하나를 가리킨다. 분석기 설정은 색인 시점에 + * 적용되므로 매핑을 바꾸면 재색인이 필수인데, alias가 있으면 새 인덱스를 채운 뒤 alias만 + * 옮겨 검색 중단 없이 전환할 수 있다. + */ +@ConfigurationProperties(prefix = "youthpick.search") +public record PolicySearchProperties(boolean enabled, String alias) {} +``` + +`@ConfigurationPropertiesScan` 덕분에 **등록 코드가 따로 필요 없다.** 만들면 바로 주입된다. + +--- + +# 7. 연결 확인 + +앱이 실제로 ES 를 잡는지 본다. + +## 7-1. 확인용 임시 컴포넌트 + +`src/main/java/com/bop/youthpick/policy/search/EsConnectionCheck.java` +(확인이 끝나면 지울 파일) + +```java +package com.bop.youthpick.policy.search; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.ApplicationArguments; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EsConnectionCheck implements ApplicationRunner { + + private final ElasticsearchClient client; + private final PolicySearchProperties properties; + + @Override + public void run(ApplicationArguments args) { + if (!properties.enabled()) { + log.info("[ES] 검색 비활성(youthpick.search.enabled=false) — 연결 확인 생략"); + return; + } + try { + var info = client.info(); + log.info("[ES] 연결 성공 — version={} cluster={}", + info.version().number(), info.clusterName()); + } catch (Exception e) { + // ES가 없어도 앱은 떠야 한다. 로그만 남기고 넘어간다. + log.warn("[ES] 연결 실패 — 검색은 LIKE 경로로 동작한다: {}", e.getMessage()); + } + } +} +``` + +## 7-2. 실행 + +```bash +docker compose up -d mysql redis minio elasticsearch +./gradlew bootRun +``` + +## ✅ 확인 7 + +로그에 이렇게 뜨면 성공: +``` +[ES] 연결 성공 — version=8.18.8 cluster=docker-cluster +``` + +**일부러 실패도 시켜본다** (폴백이 동작할 조건을 확인): +```bash +docker compose stop elasticsearch +./gradlew bootRun +``` +``` +[ES] 연결 실패 — 검색은 LIKE 경로로 동작한다: ... +``` +**여기서 앱이 죽지 않고 뜨는 게 중요하다.** 죽으면 `depends_on` 이나 설정을 잘못 넣은 것이다. + +확인이 끝나면 `EsConnectionCheck.java` 는 지운다. + +--- + +# 완료 후 상태 + +``` +youth-pick/ +├── es/ +│ ├── Dockerfile ES 8.18.8 + analysis-nori +│ ├── analyze.py _analyze 헬퍼 +│ └── config/ +│ ├── userdict_ko.txt (빈 파일) +│ └── synonym_ko.txt (빈 파일) +├── docker-compose.yml elasticsearch 서비스 + 볼륨 +├── build.gradle spring-boot-starter-data-elasticsearch +└── src/main/ + ├── resources/ + │ ├── application.yml 타임아웃 + search.enabled=false + alias + │ └── application-local.yml uris + search.enabled=true + └── java/com/bop/youthpick/policy/search/ + └── PolicySearchProperties.java +``` + +## 다음 단계 + +``` +▶ 인덱스 매핑 JSON 작성 ← nori decompound_mode, 동의어, 필드 가중치가 들어가는 곳 + 인덱스 생성 코드 + 정책 → ES 문서 변환 + 색인 코드 (bulk) + 검색 코드 + PolicyService 폴백 +``` diff --git a/es/Dockerfile b/es/Dockerfile new file mode 100644 index 0000000..594f44f --- /dev/null +++ b/es/Dockerfile @@ -0,0 +1,7 @@ +# 버전은 Spring Boot 가 관리하는 클라이언트 버전과 맞춘다. +# Spring Boot 3.5.16 → spring-data-elasticsearch 5.5.13 → elasticsearch-java 8.18.8 +FROM docker.elastic.co/elasticsearch/elasticsearch:8.18.8 + +# 한국어 형태소 분석기. 공식 이미지에는 없어서 직접 설치한다. +# --batch : 설치 중 뜨는 확인 프롬프트를 자동 승인 (없으면 빌드가 멈춘다) +RUN bin/elasticsearch-plugin install --batch analysis-nori diff --git a/es/config/synonym_ko.txt b/es/config/synonym_ko.txt new file mode 100644 index 0000000..8b6fdd1 --- /dev/null +++ b/es/config/synonym_ko.txt @@ -0,0 +1,18 @@ +# 동의어 사전 (Solr 형식) +# +# 형식 두 가지 +# 일자리, 취업, 채용, 구직 등가형 — 서로 모두 동의어 +# 알바, 아르바이트 => 아르바이트 지정형 — 왼쪽을 오른쪽으로 치환 +# +# 이 사전은 search_analyzer(검색 시점)에만 적용된다. +# 색인 시점에 넣으면 사전을 고칠 때마다 전체 재색인이 필요하기 때문이다. +# 고친 뒤 재색인 없이 아래 한 줄로 반영한다: +# curl -XPOST "localhost:9200/policy/_reload_search_analyzers" +# +# 주의: nori 가 먼저 형태소로 쪼갠 뒤에 이 규칙이 걸린다. 그래서 여기 적는 단어는 +# nori 가 실제로 만들어내는 토큰이어야 한다(예: "일자리" 는 userdict 에 등록돼 있어야 +# 하나의 토큰이 된다). +# +# 아직 비어 있다. 기준선 측정(docs/2026-09-05-검색-기준선-측정.md)에서 +# "일자리 → 취업 정책 488건 누락" 이 확인됐으므로, 인덱스를 만들고 실제 +# 검색 결과를 본 뒤에 데이터 근거로 채운다. diff --git a/es/config/userdict_ko.txt b/es/config/userdict_ko.txt new file mode 100644 index 0000000..ccbbcab --- /dev/null +++ b/es/config/userdict_ko.txt @@ -0,0 +1,114 @@ +# nori 사용자 사전 +# +# 형식 두 가지 +# 단어 → 하나의 명사로 인식만 시킨다 +# 단어 조각1 조각2 → 복합어로 등록하고 분해 방식을 지정한다 +# (조각을 이어붙이면 원본과 정확히 같아야 한다) +# +# decompound_mode: mixed 와 함께 쓰면 원본 + 조각이 모두 색인된다. +# 청년농업인 청년 농업인 → [청년농업인, 청년, 농업인] +# +# 이 파일을 고치면 재색인이 필요하다(색인 시점에 적용되므로). +# +# 후보 추출: es/helper/dict_candidates.py 로 정책 제목 2,691건을 분석해 +# "3조각 이상 + 한 글자 토큰" 인 단어를 뽑았다(등장 3회 이상, 88개 중 선별). + +# ─── 사람 · 직군 ─────────────────────────────── +# nori 가 "농업이-ㄴ"(농업이다의 관형형)으로 오인해 [농업, 이, ᆫ] 이 되던 것들 +농업인 +어업인 +예술인 +근로자 +창업자 +소상공인 +청년농업인 청년 농업인 +청년어업인 청년 어업인 +청년예술인 청년 예술인 +청년근로자 청년 근로자 +청년창업자 청년 창업자 +청년창업농 청년 창업 농 +무역전문가 무역 전문가 + +# ─── 일자리 ─────────────────────────────────── +# "일자리" 가 [일, 자리] 로 쪼개지던 것 +일자리 +청년일자리 청년 일자리 +일자리사업 일자리 사업 +대학일자리플러스센터 대학 일자리 플러스 센터 +일경험드림 일 경험 드림 +고용이음 고용 이음 + +# ─── 금융 · 주거 ────────────────────────────── +# "이자" 가 "대출이-자" 로 오인되던 것 등 +이자 +보증금 +학자금 +정착금 +장려금 +축하금 +수수료 +교통비 +이차보전 +대출이자 대출 이자 +학자금대출 학자금 대출 +전세보증금 전세 보증금 +임차보증금 임차 보증금 +주택임차보증금 주택 임차 보증금 +전세보증금반환보증 전세 보증금 반환 보증 +자립정착금 자립 정착금 +결혼장려금 결혼 장려금 +출산장려금 출산 장려금 +결혼축하금 결혼 축하금 +중개수수료 중개 수수료 +대중교통비 대중 교통비 +청년월세 청년 월세 +청년도약계좌 청년 도약 계좌 + +# ─── 정책 · 기관명 ──────────────────────────── +국민취업지원제도 국민 취업 지원 제도 +청년도전지원사업 청년 도전 지원 사업 +청년사업단 청년 사업단 +청년사회서비스사업단 청년 사회 서비스 사업단 +청년정책위원회 청년 정책 위원회 +청년정책조정위원회 청년 정책 조정 위원회 +청년정책협의체 청년 정책 협의체 +청년공동체 청년 공동체 +사회적경제기업 사회적 경제 기업 +지역주도형 지역 주도형 +콘텐츠코리아랩 콘텐츠 코리아 랩 +인큐베이팅 +청소년쉼터 청소년 쉼터 +고립은둔청년 고립 은둔 청년 +1인가구 + +# ─── 행정구역 ───────────────────────────────── +# regions 테이블 265개를 전수 분석한 결과. +# "종로구 → 종로 · 구" 처럼 [지역명 + 행정단위] 로 쪼개지는 것은 정상이라 등록하지 않는다 +# ("종로" 로도 "종로구" 로도 검색된다). +# +# (1) 지역명 자체가 사라져 검색이 불가능한 것 — 반드시 등록 +# 안동시 → 안 · 동시 ("안" 부사 + "동시" 명사로 오인, "안동" 토큰이 없음) +안동시 안동 시 +이천시 이천 시 +속초시 속초 시 +사천시 사천 시 +사하구 사하 구 +# +# (2) "OO이-구"(이다의 활용)로 오인돼 무의미한 "이" 토큰이 끼는 것 +# 검색 자체는 되지만 색인에 노이즈가 쌓인다 +영종구 영종 구 +연수구 연수 구 +미추홀구 미추홀 구 +서해구 서해 구 +검단구 검단 구 +동안구 동안 구 +만세구 만세 구 +효행구 효행 구 +동탄구 동탄 구 +서원구 서원 구 +청원구 청원 구 +# +# (3) 기타 오분석 +병점구 병점 구 +전북특별자치도 전북 특별자치도 +강원특별자치도 강원 특별자치도 diff --git a/es/helper/analyze.py b/es/helper/analyze.py new file mode 100644 index 0000000..fb435b4 --- /dev/null +++ b/es/helper/analyze.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""ES _analyze 호출 헬퍼 (개발용 도구 — 서비스 코드 아님). + +PowerShell 이 curl.exe 에 인자를 넘길 때 따옴표를 재해석해 공백에서 잘리고, +한글은 CP949 로 깨진다. 여기서 JSON 을 직접 만들어 UTF-8 로 보내 둘 다 우회한다. + +사용: + python es/helper/analyze.py "청년 일자리를 지원하는 정책" + python es/helper/analyze.py --decompound mixed "청년도약계좌 가입 지원" + python es/helper/analyze.py --tokenizer standard "청년도약계좌" + python es/helper/analyze.py --analyzer ko_search --index policy "청년 일자리" + python es/helper/analyze.py --compare "청년도약계좌 가입 지원" +""" +import argparse +import json +import sys +import urllib.request + +ES = "http://localhost:9200" + + +def analyze(text, tokenizer=None, analyzer=None, index=None, decompound=None): + """텍스트를 토큰 리스트로 변환한다.""" + body = {"text": text} + if analyzer: + body["analyzer"] = analyzer + elif decompound: + # tokenizer 를 문자열이 아니라 객체로 주면 옵션을 붙일 수 있다 + body["tokenizer"] = {"type": "nori_tokenizer", "decompound_mode": decompound} + else: + body["tokenizer"] = tokenizer or "nori_tokenizer" + + url = f"{ES}/{index}/_analyze" if index else f"{ES}/_analyze" + req = urllib.request.Request( + url, + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as res: + result = json.loads(res.read().decode("utf-8")) + return [t["token"] for t in result["tokens"]] + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("text", nargs="+") + p.add_argument("--tokenizer") + p.add_argument("--analyzer") + p.add_argument("--index") + p.add_argument("--decompound", choices=["none", "discard", "mixed"]) + p.add_argument( + "--compare", + action="store_true", + help="standard 와 decompound 3종을 한 번에 비교", + ) + args = p.parse_args() + text = " ".join(args.text) + + if args.compare: + print(text) + print(f" {'standard':<10} -> {' · '.join(analyze(text, tokenizer='standard'))}") + for mode in ("none", "discard", "mixed"): + tokens = analyze(text, decompound=mode) + print(f" {mode:<10} -> {' · '.join(tokens)} ({len(tokens)}개)") + return + + tokens = analyze(text, args.tokenizer, args.analyzer, args.index, args.decompound) + print(f"{text}\n -> {' · '.join(tokens)}") + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + main() diff --git a/es/helper/create_index.py b/es/helper/create_index.py new file mode 100644 index 0000000..a26e915 --- /dev/null +++ b/es/helper/create_index.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +"""정책 검색 인덱스 생성 (개발용 도구 — 서비스 코드 아님). + +es/policy-index.json 을 읽어 새 인덱스를 만들고 alias 를 붙인다. + +alias 를 쓰는 이유: analyzer 설정은 색인 시점에 굳어서 매핑을 바꾸면 재색인이 필수다. +코드는 alias(policy)만 보게 하고 실제 인덱스(policy_v1, policy_v2...)를 갈아끼우면 +검색 중단 없이 전환할 수 있다. + +사용: + python es/helper/create_index.py # policy_v1 생성 + alias 부착 + python es/helper/create_index.py --version 2 # policy_v2 생성 (alias 는 안 옮김) + python es/helper/create_index.py --switch 2 # alias 를 policy_v2 로 이동 + python es/helper/create_index.py --status # 현재 인덱스/alias 확인 + python es/helper/create_index.py --delete 1 # policy_v1 삭제 +""" +import argparse +import json +import sys +import urllib.error +import urllib.request + +ES = "http://localhost:9200" +ALIAS = "policy" +MAPPING_FILE = "es/policy-index.json" + + +def call(method, path, body=None): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request( + f"{ES}{path}", data=data, method=method, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as res: + text = res.read().decode("utf-8") + return json.loads(text) if text else {} + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8") + print(f"[{e.code}] {method} {path}", file=sys.stderr) + try: + print(json.dumps(json.loads(detail), ensure_ascii=False, indent=2), file=sys.stderr) + except ValueError: + print(detail, file=sys.stderr) + sys.exit(1) + + +def status(): + print("=== 인덱스 ===") + for line in call("GET", "/_cat/indices?format=json&v"): + print(f" {line['index']:<16} docs={line['docs.count']:<8} size={line['store.size']}") + print("=== alias ===") + aliases = call("GET", "/_cat/aliases?format=json") + if not aliases: + print(" (없음)") + for a in aliases: + print(f" {a['alias']} -> {a['index']}") + + +def create(version): + index = f"{ALIAS}_v{version}" + with open(MAPPING_FILE, encoding="utf-8") as f: + mapping = json.load(f) + + call("PUT", f"/{index}", mapping) + print(f"인덱스 생성: {index}") + + # alias 가 아직 없을 때만 자동 부착 — 이미 있으면 --switch 로 명시적으로 옮긴다 + if not call("GET", "/_cat/aliases?format=json"): + call("POST", "/_aliases", {"actions": [{"add": {"index": index, "alias": ALIAS}}]}) + print(f"alias 부착: {ALIAS} -> {index}") + + +def switch(version): + index = f"{ALIAS}_v{version}" + actions = [{"remove": {"index": "*", "alias": ALIAS}}, {"add": {"index": index, "alias": ALIAS}}] + call("POST", "/_aliases", {"actions": actions}) # 원자적 — 중간에 alias 가 비는 순간이 없다 + print(f"alias 이동: {ALIAS} -> {index}") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--version", type=int, default=1) + p.add_argument("--switch", type=int) + p.add_argument("--delete", type=int) + p.add_argument("--status", action="store_true") + args = p.parse_args() + + if args.status: + status() + elif args.switch: + switch(args.switch) + status() + elif args.delete: + call("DELETE", f"/{ALIAS}_v{args.delete}") + print(f"삭제: {ALIAS}_v{args.delete}") + else: + create(args.version) + status() + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + main() diff --git a/es/helper/dict_candidates.py b/es/helper/dict_candidates.py new file mode 100644 index 0000000..87f5ccb --- /dev/null +++ b/es/helper/dict_candidates.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""nori 사용자 사전 후보 추출 (개발용 도구 — 서비스 코드 아님). + +정책 제목/키워드를 공백 단위 단어로 쪼갠 뒤 각 단어를 nori 로 분석해, +**nori 가 제대로 인식하지 못한 단어**를 찾는다. + +판정 기준: 한 단어가 3조각 이상으로 쪼개지면서 **한 글자 토큰**을 만들어내면 +nori 사전에 없는 단어일 가능성이 높다. + 청년도약계좌 -> 청년 · 도 · 약 · 계좌 (4조각, 한 글자 2개) → 후보 + +사용: + python es/helper/dict_candidates.py titles.txt + python es/helper/dict_candidates.py titles.txt --min-count 3 +""" +import argparse +import json +import re +import sys +import urllib.request +from collections import Counter + +ES = "http://localhost:9200" +HANGUL = re.compile(r"[가-힣]") +STRIP = "()[]{}<>,.·:;\"'/-~!?★☆「」『』…" + + +def tokenize(word): + body = {"tokenizer": "nori_tokenizer", "text": word} + req = urllib.request.Request( + f"{ES}/_analyze", + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as res: + return [t["token"] for t in json.loads(res.read().decode("utf-8"))["tokens"]] + + +def words_of(lines): + """공백으로 쪼갠 뒤 구두점을 털어낸 한글 단어들.""" + counter = Counter() + for line in lines: + for raw in line.split(): + word = raw.strip(STRIP) + # 3글자 미만은 쪼개질 여지가 적고, 한글이 없으면 nori 대상이 아니다 + if len(word) >= 3 and HANGUL.search(word): + counter[word] += 1 + return counter + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("file", help="한 줄에 제목 하나") + p.add_argument("--min-count", type=int, default=2, help="이 횟수 이상 등장한 단어만") + args = p.parse_args() + + with open(args.file, encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + + counter = words_of(lines) + targets = [(w, c) for w, c in counter.items() if c >= args.min_count] + print(f"제목 {len(lines)}건 / 고유 단어 {len(counter)}개 / 분석 대상 {len(targets)}개\n") + + broken = [] + for i, (word, count) in enumerate(targets, 1): + if i % 200 == 0: + print(f" ... {i}/{len(targets)}", file=sys.stderr) + tokens = tokenize(word) + singles = [t for t in tokens if len(t) == 1 and HANGUL.match(t)] + # 3조각 이상 + 한 글자 토큰 존재 = nori 가 모르는 단어 + if len(tokens) >= 3 and singles: + broken.append((count, len(singles), word, tokens)) + + broken.sort(key=lambda x: (-x[0], -x[1])) + print(f"사전 후보 {len(broken)}개 (등장 많은 순)\n") + print(f"{'횟수':>4} {'단어':<20} 분해 결과") + print("-" * 78) + for count, _, word, tokens in broken: + print(f"{count:>4} {word:<20} {' · '.join(tokens)}") + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + main() diff --git a/es/policy-index.json b/es/policy-index.json new file mode 100644 index 0000000..dacc35f --- /dev/null +++ b/es/policy-index.json @@ -0,0 +1,105 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "analysis": { + "tokenizer": { + "nori_user": { + "type": "nori_tokenizer", + "decompound_mode": "mixed", + "user_dictionary": "analysis/userdict_ko.txt" + } + }, + "filter": { + "ko_pos": { + "type": "nori_part_of_speech", + "stoptags": [ + "E", + "IC", + "J", + "MAJ", + "MM", + "SP", + "SSC", + "SSO", + "SC", + "SE", + "XPN", + "XSA", + "XSN", + "XSV", + "UNA", + "NA", + "VSV", + "VCP", + "VCN" + ] + }, + "ko_synonym": { + "type": "synonym_graph", + "synonyms_path": "analysis/synonym_ko.txt", + "updateable": true + } + }, + "analyzer": { + "ko_index": { + "type": "custom", + "tokenizer": "nori_user", + "filter": ["ko_pos", "nori_readingform", "lowercase"] + }, + "ko_search": { + "type": "custom", + "tokenizer": "nori_user", + "filter": ["ko_pos", "nori_readingform", "lowercase", "ko_synonym"] + } + } + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "policyId": { "type": "long" }, + + "title": { + "type": "text", + "analyzer": "ko_index", + "search_analyzer": "ko_search" + }, + "keywords": { + "type": "text", + "analyzer": "ko_index", + "search_analyzer": "ko_search" + }, + "description": { + "type": "text", + "analyzer": "ko_index", + "search_analyzer": "ko_search" + }, + "supportContent": { + "type": "text", + "analyzer": "ko_index", + "search_analyzer": "ko_search" + }, + "organizationName": { + "type": "text", + "analyzer": "ko_index", + "search_analyzer": "ko_search" + }, + + "category": { "type": "keyword" }, + "sidoNames": { "type": "keyword" }, + "jobCodes": { "type": "keyword" }, + "visibility": { "type": "keyword" }, + + "minAge": { "type": "integer" }, + "maxAge": { "type": "integer" }, + + "applicationEndDate": { "type": "date" }, + "businessPeriodEnd": { "type": "date" }, + "firstRegisteredAt": { "type": "date" }, + + "nationwide": { "type": "boolean" }, + "adminHidden": { "type": "boolean" } + } + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ee634da..eaef298 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -18,8 +18,14 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} + elasticsearch: + # 로컬 compose의 elasticsearch 서비스. 타임아웃 등 동작 규칙은 application.yml에 있다. + uris: ${ES_URIS:http://localhost:9200} youthpick: + search: + enabled: ${SEARCH_ES_ENABLED:true} # 로컬은 compose로 ES를 띄우므로 켠다 (기본 off) + reindex-on-empty: ${SEARCH_ES_REINDEX_ON_EMPTY:true} # 인덱스가 비어 있으면 채운다 (기본 off) minio: # 로컬 compose의 기본값과 짝이다. 배포 환경은 application.yml의 환경변수 필수 설정을 사용한다. endpoint: ${MINIO_ENDPOINT:http://localhost:9000} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 311bedf..8243445 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,10 +29,24 @@ spring: max-file-size: ${FILE_MAX_SIZE:10MB} # multipart boundary/header 오버헤드를 고려해 실제 파일 제한보다 여유를 둔다. max-request-size: ${FILE_MAX_REQUEST_SIZE:11MB} + elasticsearch: + # 접속 주소(uris)는 환경마다 달라 각 프로필이 정한다(redis와 같은 방식). + # 여기엔 환경과 무관한 동작 규칙만 둔다 — ES가 느려질 때 기본값(30s)을 기다리면 + # 폴백이 있어도 사용자는 이미 떠난 뒤다. 짧게 끊고 LIKE 경로로 넘긴다(PolicyService). + connection-timeout: ${ES_CONNECT_TIMEOUT:1s} + socket-timeout: ${ES_SOCKET_TIMEOUT:3s} youthpick: cors: allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://localhost:5174} + search: + # 기본 off(안전 기본값) — sync.scheduler와 같은 방식. ES 없이도 앱이 뜨고 LIKE로 검색된다. + enabled: ${SEARCH_ES_ENABLED:false} + # 코드가 보는 이름. 실제 인덱스는 policy_v1 이고 이 alias가 가리킨다. + alias: ${SEARCH_ES_ALIAS:policy} + # 인덱스가 비어 있을 때만 앱 시작 시 전체 색인 1회. sync.startup-on-empty와 같은 방식. + # 매핑을 바꾸면 새 인덱스(policy_v2)가 비어 있으므로 자동으로 다시 채워진다. + reindex-on-empty: ${SEARCH_ES_REINDEX_ON_EMPTY:false} site: # sitemap.xml에 절대 URL()을 만들 때 쓰는 프론트엔드 배포 도메인. frontend-url: ${FRONTEND_URL:https://youthpick.samchon.cloud} From b152f9db963afeae0766fb9603ee132ac1bddef8 Mon Sep 17 00:00:00 2001 From: myh Date: Sun, 6 Sep 2026 03:09:39 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=EC=9D=84=20Elast?= =?UTF-8?q?icsearch=EC=97=90=20=EC=83=89=EC=9D=B8=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=B6=84=EC=9D=84=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../policy/service/AdminPolicyService.java | 7 ++ .../config/ElasticsearchJsonConfig.java | 36 +++++++ .../search/config/PolicySearchProperties.java | 17 ++++ .../search/dto/PolicyChangedEvent.java | 30 ++++++ .../youthpick/search/dto/PolicyDocument.java | 90 +++++++++++++++++ .../service/PolicyDocumentAssembler.java | 56 +++++++++++ .../search/service/PolicyReindexer.java | 77 +++++++++++++++ .../search/service/PolicySearchIndexer.java | 97 +++++++++++++++++++ .../search/service/PolicySearchService.java | 27 ++++++ .../service/PolicySearchSyncListener.java | 55 +++++++++++ .../sync/service/PolicyUpsertWriter.java | 52 +++++++--- .../service/AdminPolicyServiceTest.java | 8 +- 12 files changed, 537 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/search/config/ElasticsearchJsonConfig.java create mode 100644 src/main/java/com/bop/youthpick/search/config/PolicySearchProperties.java create mode 100644 src/main/java/com/bop/youthpick/search/dto/PolicyChangedEvent.java create mode 100644 src/main/java/com/bop/youthpick/search/dto/PolicyDocument.java create mode 100644 src/main/java/com/bop/youthpick/search/service/PolicyDocumentAssembler.java create mode 100644 src/main/java/com/bop/youthpick/search/service/PolicyReindexer.java create mode 100644 src/main/java/com/bop/youthpick/search/service/PolicySearchIndexer.java create mode 100644 src/main/java/com/bop/youthpick/search/service/PolicySearchService.java create mode 100644 src/main/java/com/bop/youthpick/search/service/PolicySearchSyncListener.java diff --git a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java index 23b021b..80175e6 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java @@ -12,11 +12,13 @@ import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.search.dto.PolicyChangedEvent; import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -29,6 +31,8 @@ public class AdminPolicyService { private final PolicyRepository policyRepository; private final PolicyRegionRepository policyRegionRepository; private final RegionRepository regionRepository; + // 커밋된 뒤 검색 색인을 갱신하기 위한 이벤트 발행(PolicySearchSyncListener 가 받는다). + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public Page search( @@ -71,6 +75,7 @@ public AdminPolicyResponse update(Long policyId, AdminPolicyUpdateRequest reques regions.stream().map(region -> PolicyRegion.create(policy, region)).toList(); policyRegionRepository.saveAll(policyRegions); + eventPublisher.publishEvent(PolicyChangedEvent.updated(policyId)); return AdminPolicyResponse.from(policy, request.regionCodes()); } @@ -82,6 +87,7 @@ public AdminPolicyResponse updateVisibility(Long policyId, String visibilityStat } else { policy.unhideByAdmin(); } + eventPublisher.publishEvent(PolicyChangedEvent.updated(policyId)); return AdminPolicyResponse.from(policy, regionCodesOf(policyId)); } @@ -89,6 +95,7 @@ public AdminPolicyResponse updateVisibility(Long policyId, String visibilityStat public AdminPolicyResponse softDelete(Long policyId) { Policy policy = findPolicy(policyId); policy.softDelete(); + eventPublisher.publishEvent(PolicyChangedEvent.removed(policyId)); return AdminPolicyResponse.from(policy, regionCodesOf(policyId)); } diff --git a/src/main/java/com/bop/youthpick/search/config/ElasticsearchJsonConfig.java b/src/main/java/com/bop/youthpick/search/config/ElasticsearchJsonConfig.java new file mode 100644 index 0000000..12baa31 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/config/ElasticsearchJsonConfig.java @@ -0,0 +1,36 @@ +package com.bop.youthpick.search.config; + +import co.elastic.clients.json.JsonpMapper; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Elasticsearch 클라이언트가 쓰는 JSON 변환기. + * + *

기본 변환기는 {@code LocalDate}/{@code LocalDateTime}을 모른다("Java 8 date/time type not supported"). + * 웹 응답용 ObjectMapper 를 재사용하지 않고 따로 만드는 이유는, API 응답 포맷을 바꾸는 설정이 색인 문서 포맷까지 흔들면 + * 안 되기 때문이다 — 둘은 다른 계약이다. + * + *

Spring Boot 의 기본 {@code JsonpMapper} 는 {@code @ConditionalOnMissingBean} 이라 이 빈이 우선한다. + */ +@Configuration +public class ElasticsearchJsonConfig { + + @Bean + public JsonpMapper elasticsearchJsonpMapper() { + return new JacksonJsonpMapper( + JsonMapper.builder() + .addModule(new JavaTimeModule()) + // 날짜를 epoch 숫자가 아니라 "2026-09-06" 문자열로 보낸다. + // 매핑의 date 타입이 기본으로 이해하는 형식이고, 색인을 눈으로 확인하기도 쉽다. + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + // null 필드는 아예 보내지 않는다. 색인 크기가 줄고 "값 없음"이 명확해진다. + .serializationInclusion(JsonInclude.Include.NON_NULL) + .build()); + } +} diff --git a/src/main/java/com/bop/youthpick/search/config/PolicySearchProperties.java b/src/main/java/com/bop/youthpick/search/config/PolicySearchProperties.java new file mode 100644 index 0000000..356a400 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/config/PolicySearchProperties.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.search.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 정책 검색(Elasticsearch) 설정. {@code YouthpickApplication}의 {@code @ConfigurationPropertiesScan}이 + * 자동 등록하므로 별도 등록 코드가 필요 없다. + * + *

{@code enabled}가 false면 ES를 아예 호출하지 않고 기존 LIKE 검색만 쓴다 — ES가 없는 환경(테스트·CI)에서도 + * 앱이 그대로 뜨게 하기 위함이며, {@code youthpick.sync.scheduler.enabled}와 같은 "안전 기본값 off" 방식이다. + * + *

{@code alias}는 코드가 보는 인덱스 이름이다. 실제 인덱스는 policy_v1, policy_v2… 이고 이 alias가 그중 하나를 + * 가리킨다. 분석기 설정은 색인 시점에 굳으므로 매핑을 바꾸면 재색인이 필수인데, alias가 있으면 새 인덱스를 채운 뒤 + * alias만 옮겨 검색 중단 없이 전환할 수 있다. + */ +@ConfigurationProperties(prefix = "youthpick.search") +public record PolicySearchProperties(boolean enabled, String alias) {} diff --git a/src/main/java/com/bop/youthpick/search/dto/PolicyChangedEvent.java b/src/main/java/com/bop/youthpick/search/dto/PolicyChangedEvent.java new file mode 100644 index 0000000..8125563 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/dto/PolicyChangedEvent.java @@ -0,0 +1,30 @@ +package com.bop.youthpick.search.dto; + +import java.util.List; + +/** + * 정책이 바뀌었음을 알리는 이벤트. 검색 색인을 갱신하는 데 쓴다. + * + *

발행하는 쪽(배치·관리자)은 트랜잭션 안에서 던지고, 받는 쪽({@code PolicySearchSyncListener})은 커밋된 뒤에 + * 처리한다. 커밋 전에 색인하면 이후 롤백됐을 때 MySQL에는 없는 내용이 ES에만 남는다. + * + *

이벤트로 한 겹 끊어둔 덕분에 발행하는 쪽은 Elasticsearch 를 전혀 모른다 — 배치는 저장만 알면 되고, ES 가 없는 환경에서도 + * 그대로 동작한다. + * + * @param policyIds 바뀐 정책들. 배치는 청크 단위로 묶어 던져 색인 요청이 건수만큼 늘지 않게 한다. + * @param removed 삭제(색인에서 제거)면 true, 신규·수정이면 false + */ +public record PolicyChangedEvent(List policyIds, boolean removed) { + + public static PolicyChangedEvent updated(Long policyId) { + return new PolicyChangedEvent(List.of(policyId), false); + } + + public static PolicyChangedEvent updated(List policyIds) { + return new PolicyChangedEvent(List.copyOf(policyIds), false); + } + + public static PolicyChangedEvent removed(Long policyId) { + return new PolicyChangedEvent(List.of(policyId), true); + } +} diff --git a/src/main/java/com/bop/youthpick/search/dto/PolicyDocument.java b/src/main/java/com/bop/youthpick/search/dto/PolicyDocument.java new file mode 100644 index 0000000..748624f --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/dto/PolicyDocument.java @@ -0,0 +1,90 @@ +package com.bop.youthpick.search.dto; + +import com.bop.youthpick.policy.entity.Policy; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +/** + * Elasticsearch 색인 문서. 필드 구성은 {@code es/policy-index.json} 매핑과 1:1로 맞춘다 + * (매핑이 {@code dynamic: "strict"}라 여기에만 있는 필드를 보내면 색인이 400으로 거부된다). + * + *

{@link Policy}를 통째로 보내지 않는 이유: ES는 저장소가 아니라 검색 인덱스로만 쓴다. 검색 결과의 카드 내용은 + * 기존대로 MySQL에서 읽으므로, ES에는 "어떤 정책이 걸리고 어떤 순서인가"를 판정할 필드만 넣는다. + * rawPayload(LONGTEXT)·제출서류·심사방법 등은 색인만 키우고 검색에 쓰이지 않는다. + * + *

{@code sidoNames}는 policy_regions 조인 대신 배열로 비정규화한 것이다 — ES에는 JOIN이 없다. 대신 기존 + * {@code findCards}의 지역 EXISTS 서브쿼리가 {@code terms} 필터 한 줄로 줄어든다. + */ +public record PolicyDocument( + Long policyId, + + // ── 검색 대상 (text, 한국어 분석기로 쪼개서 색인) ── + String title, + String keywords, + String description, + String supportContent, + String organizationName, + + // ── 필터·집계 대상 (keyword, 쪼개지 않고 정확 일치) ── + String category, + List sidoNames, + List jobCodes, + String visibility, + + // ── 나이 겹침 판정 ── + Integer minAge, + Integer maxAge, + + // ── 마감 조건 ── + LocalDate applicationEndDate, + LocalDate businessPeriodEnd, + LocalDate firstRegisteredAt, + + // ── 정렬·제외 ── + boolean nationwide, + boolean adminHidden) { + + /** 색인 문서 id. MySQL PK를 그대로 쓴다 — 같은 정책을 다시 색인하면 새로 쌓이지 않고 덮어써진다. */ + public String documentId() { + return String.valueOf(policyId); + } + + public static PolicyDocument from(Policy policy, List sidoNames) { + return new PolicyDocument( + policy.getId(), + policy.getTitle(), + policy.getKeywords(), + policy.getDescription(), + policy.getSupportContent(), + policy.getOrganizationName(), + policy.getCategory(), + sidoNames, + splitCodes(policy.getJobCodes()), + policy.getVisibility().name(), + policy.getMinAge(), + policy.getMaxAge(), + policy.getApplicationEndDate(), + policy.getBusinessPeriodEnd(), + policy.getFirstRegisteredAt() == null + ? null + : policy.getFirstRegisteredAt().toLocalDate(), + policy.isNationwide(), + policy.isAdminHidden()); + } + + /** + * DB에 콤마로 이어 붙여 저장된 취업상태 코드를 배열로 편다. MySQL에서는 + * {@code concat(',', jobCodes, ',') like '%,코드,%'}로 부분일치를 흉내 냈는데, ES에서는 배열 필드에 + * {@code terms} 필터를 걸면 되므로 문자열 조작이 사라진다. + */ + private static List splitCodes(String commaSeparated) { + if (commaSeparated == null || commaSeparated.isBlank()) { + return List.of(); + } + return Arrays.stream(commaSeparated.split(",")) + .map(String::trim) + .filter(code -> !code.isEmpty()) + .toList(); + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicyDocumentAssembler.java b/src/main/java/com/bop/youthpick/search/service/PolicyDocumentAssembler.java new file mode 100644 index 0000000..0fe5497 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/service/PolicyDocumentAssembler.java @@ -0,0 +1,56 @@ +package com.bop.youthpick.search.service; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.search.dto.PolicyDocument; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 정책 엔티티를 색인 문서로 조립한다. 지역명은 {@code policy_regions} 조인이 필요해 엔티티만으로는 만들 수 없어서, 이 조회를 한곳에 + * 모았다 — 전체 재색인({@link PolicyReindexer})과 변경 반영({@link PolicySearchSyncListener})이 같은 규칙을 쓰게 하기 + * 위함이다. + */ +@Service +@RequiredArgsConstructor +public class PolicyDocumentAssembler { + + private final PolicyRegionRepository policyRegionRepository; + + /** + * 정책들을 색인 문서로 바꾼다. 지역명을 정책마다 조회하면 N+1이 되므로 목록 전체의 지역을 fetch join 한 번으로 모아 정책 id별로 + * 묶는다 — {@code PolicyService.getCards}의 라벨 조립과 같은 방식이다. + * + *

삭제된 정책({@code deletedAt})은 검색에 나오면 안 되므로 애초에 문서를 만들지 않는다. + */ + public List assemble(List policies) { + if (policies.isEmpty()) { + return List.of(); + } + List policyIds = policies.stream().map(Policy::getId).toList(); + Map> sidoNamesByPolicyId = + policyRegionRepository.findWithRegionByPolicyIdIn(policyIds).stream() + .collect( + Collectors.groupingBy( + pr -> pr.getPolicy().getId(), + Collectors.mapping( + (PolicyRegion pr) -> pr.getRegion().getSidoName(), + Collectors.toList()))); + + return policies.stream() + .filter(policy -> policy.getDeletedAt() == null) + .map(policy -> PolicyDocument.from(policy, sidoNamesOf(sidoNamesByPolicyId, policy))) + .toList(); + } + + private List sidoNamesOf(Map> byPolicyId, Policy policy) { + return byPolicyId.getOrDefault(policy.getId(), List.of()).stream() + .distinct() + .sorted() + .toList(); + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicyReindexer.java b/src/main/java/com/bop/youthpick/search/service/PolicyReindexer.java new file mode 100644 index 0000000..56e7bd1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/service/PolicyReindexer.java @@ -0,0 +1,77 @@ +package com.bop.youthpick.search.service; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.search.dto.PolicyDocument; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * MySQL의 전체 정책을 Elasticsearch에 처음부터 다시 색인한다. + * + *

배치({@code PolicyUpsertWriter})의 증분 색인만으로는 이미 저장돼 있던 정책이 영영 들어가지 않는다. 초기 1회 적재와 매핑 변경 + * 후 재색인이 이 클래스의 역할이다. + * + *

인덱스가 비어 있을 때만 동작하므로 켜 둔 채로 재기동해도 안전하다 — {@code youthpick.sync.startup-on-empty}와 같은 + * 방식. alias를 새 인덱스(policy_v2)로 옮기면 그쪽이 비어 있으니 자동으로 다시 채워진다. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "youthpick.search.reindex-on-empty", havingValue = "true") +@RequiredArgsConstructor +public class PolicyReindexer implements ApplicationRunner { + + /** 한 번에 MySQL에서 읽어올 정책 수. 색인 벌크(500)와 맞춰 페이지 하나가 벌크 하나가 되게 한다. */ + private static final int PAGE_SIZE = 500; + + private final PolicyRepository policyRepository; + private final PolicyDocumentAssembler assembler; + private final PolicySearchIndexer indexer; + private final PolicySearchService searchService; + + @Override + public void run(ApplicationArguments args) { + long indexed = searchService.count(); + if (indexed > 0) { + log.info("색인 {}건 존재 — 전체 재색인 생략", indexed); + return; + } + reindexAll(); + } + + /** 전체 정책을 페이지 단위로 읽어 색인한다. 실패해도 예외를 던지지 않고 건수만 로그로 남긴다. */ + @Transactional(readOnly = true) + public void reindexAll() { + long started = System.currentTimeMillis(); + int total = 0; + int failed = 0; + + for (int page = 0; ; page++) { + List policies = + policyRepository + .findAll(PageRequest.of(page, PAGE_SIZE, Sort.by("id"))) + .getContent(); + if (policies.isEmpty()) { + break; + } + List documents = assembler.assemble(policies); + failed += indexer.index(documents); + total += documents.size(); + log.info("색인 진행 {}건", total); + } + + log.info( + "전체 재색인 완료 — {}건, 실패 {}건, {}ms", + total, + failed, + System.currentTimeMillis() - started); + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicySearchIndexer.java b/src/main/java/com/bop/youthpick/search/service/PolicySearchIndexer.java new file mode 100644 index 0000000..3194899 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/service/PolicySearchIndexer.java @@ -0,0 +1,97 @@ +package com.bop.youthpick.search.service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.BulkRequest; +import co.elastic.clients.elasticsearch.core.BulkResponse; +import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem; +import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.dto.PolicyDocument; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 정책 문서를 Elasticsearch 에 색인한다. + * + *

색인 실패는 예외로 던지지 않고 로그 + 실패 건수로 돌려준다. 색인은 MySQL 저장이 끝난 뒤에 하는 부가 작업이라, + * 여기서 터지면 배치 전체가 롤백되거나 동기화가 중단된다. ES 가 잠깐 죽어도 정책 수집은 계속돼야 하고, 빠진 문서는 + * 다음 전체 재색인 때 메워진다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicySearchIndexer { + + /** + * 한 번에 보낼 문서 수. 배치의 {@code PolicyUpsertWriter.CHUNK_SIZE}(100)보다 크게 잡은 이유는 ES 색인이 DB 트랜잭션과 + * 달리 락을 잡지 않아서다. 너무 크면 요청 본문이 커져 ES 쪽 메모리를 밀어내므로, 문서 크기를 감안해 500 으로 둔다. + */ + private static final int BULK_SIZE = 500; + + private final ElasticsearchClient client; + private final PolicySearchProperties properties; + + /** + * 문서들을 색인하고 실패 건수를 돌려준다. + * + * @return 색인하지 못한 문서 수 (0 이면 전부 성공) + */ + public int index(List documents) { + if (!properties.enabled() || documents.isEmpty()) { + return 0; + } + + int failed = 0; + for (int from = 0; from < documents.size(); from += BULK_SIZE) { + int to = Math.min(from + BULK_SIZE, documents.size()); + failed += indexChunk(documents.subList(from, to)); + } + return failed; + } + + /** + * 문서 1건을 색인에서 지운다. 관리자가 정책을 삭제(soft delete)했을 때 검색 결과에 계속 노출되는 것을 막는다. + * + *

이미 없는 문서를 지우려 해도 ES는 404를 예외로 올리지 않고 result=not_found 로 응답하므로 따로 처리하지 않는다. + */ + public void delete(Long policyId) { + if (!properties.enabled()) { + return; + } + try { + client.delete(d -> d.index(properties.alias()).id(String.valueOf(policyId))); + } catch (Exception e) { + log.warn("정책 색인 삭제 실패 id={}", policyId, e); + } + } + + private int indexChunk(List chunk) { + BulkRequest.Builder request = new BulkRequest.Builder(); + for (PolicyDocument document : chunk) { + // index = "있으면 덮어쓰고 없으면 만든다". 문서 id 를 정책 PK 로 고정했으므로 재색인해도 중복되지 않는다. + request.operations(op -> op.index(idx -> idx.index(properties.alias()) + .id(document.documentId()) + .document(document))); + } + + try { + BulkResponse response = client.bulk(request.build()); + if (!response.errors()) { + return 0; + } + // bulk 는 일부만 실패해도 HTTP 200 이다. 항목별로 뒤져야 실패를 알 수 있다. + int failed = 0; + for (BulkResponseItem item : response.items()) { + if (item.error() != null) { + failed++; + log.warn("정책 색인 실패 id={} reason={}", item.id(), item.error().reason()); + } + } + return failed; + } catch (Exception e) { + log.error("정책 색인 요청 실패 size={}", chunk.size(), e); + return chunk.size(); + } + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java new file mode 100644 index 0000000..003d15a --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.search.service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import com.bop.youthpick.search.config.PolicySearchProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** 정책 검색. 지금은 색인 건수 조회만 있고, 실제 검색 질의는 7단계에서 채운다. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicySearchService { + + private final ElasticsearchClient client; + private final PolicySearchProperties properties; + + /** 현재 색인된 문서 수. 인덱스가 아직 없거나 ES가 죽어 있으면 0을 돌려준다(재색인 판단용). */ + public long count() { + try { + return client.count(c -> c.index(properties.alias())).count(); + } catch (Exception e) { + log.warn("색인 건수 조회 실패 — 0건으로 간주", e); + return 0; + } + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicySearchSyncListener.java b/src/main/java/com/bop/youthpick/search/service/PolicySearchSyncListener.java new file mode 100644 index 0000000..b491258 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/service/PolicySearchSyncListener.java @@ -0,0 +1,55 @@ +package com.bop.youthpick.search.service; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.search.dto.PolicyChangedEvent; +import com.bop.youthpick.search.dto.PolicyDocument; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 정책 변경을 검색 색인에 반영한다. 색인이 MySQL 을 따라가게 만드는 유일한 지점이다. + * + *

{@link TransactionPhase#AFTER_COMMIT} 으로 받는 이유: 커밋 전에 색인하면 이후 롤백됐을 때 MySQL 에는 없는 내용이 + * ES 에만 남는다. MySQL 이 원본이고 ES 는 사본이므로 원본이 확정된 뒤에만 사본을 고친다. + * + *

여기서 나는 예외는 전부 삼킨다. 트랜잭션은 이미 끝나서 되돌릴 수 없고, 어긋난 문서는 다음 전체 재색인 때 메워진다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicySearchSyncListener { + + private final PolicyRepository policyRepository; + private final PolicyDocumentAssembler assembler; + private final PolicySearchIndexer indexer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(PolicyChangedEvent event) { + try { + if (event.removed()) { + event.policyIds().forEach(indexer::delete); + return; + } + List policies = policyRepository.findAllById(event.policyIds()); + List documents = assembler.assemble(policies); + indexer.index(documents); + // 삭제됐거나 사라진 정책은 문서가 만들어지지 않는다 — 색인에 남아 있으면 지운다. + deleteMissing(event.policyIds(), documents); + } catch (Exception e) { + log.warn("정책 색인 반영 실패 {}건 — 다음 전체 재색인 때 메워진다", event.policyIds().size(), e); + } + } + + private void deleteMissing(List requestedIds, List documents) { + Set indexed = + documents.stream().map(PolicyDocument::policyId).collect(Collectors.toSet()); + requestedIds.stream().filter(id -> !indexed.contains(id)).forEach(indexer::delete); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java index 9ff435e..3974f79 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java @@ -5,8 +5,10 @@ import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.search.dto.PolicyChangedEvent; import com.bop.youthpick.sync.dto.PolicyUpsertItem; import com.bop.youthpick.sync.dto.PolicyWriteResult; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -14,6 +16,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; @@ -28,6 +31,8 @@ public class PolicyUpsertWriter { private final PolicyRepository policyRepository; private final PolicyRegionRepository policyRegionRepository; private final TransactionTemplate transactionTemplate; + // 커밋된 뒤 검색 색인을 갱신하기 위한 이벤트 발행(PolicySearchSyncListener 가 받는다). + private final ApplicationEventPublisher eventPublisher; public PolicyWriteResult upsertAll(List items) { int newCount = 0; @@ -87,13 +92,16 @@ private ChunkResult retryOneByOne(List chunk) { .stream() .findFirst() .orElse(null); - return writeOne( - freshItem, - existing, - regionCodesByPolicyId( - existing == null - ? List.of() - : List.of(existing))); + Policy saved = + writeOne( + freshItem, + existing, + regionCodesByPolicyId( + existing == null + ? List.of() + : List.of(existing))); + publishChanged(List.of(saved.getId())); + return existing == null; }); if (isNew) { newCount++; @@ -118,14 +126,17 @@ private ChunkResult writeChunk(List chunk) { Map> regionCodesByPolicyId = regionCodesByPolicyId(existingByNo.values()); int newCount = 0; int updatedCount = 0; + List savedIds = new ArrayList<>(); for (PolicyUpsertItem item : chunk) { - if (writeOne( - item, existingByNo.get(item.policy().getPolicyNo()), regionCodesByPolicyId)) { + Policy existing = existingByNo.get(item.policy().getPolicyNo()); + savedIds.add(writeOne(item, existing, regionCodesByPolicyId).getId()); + if (existing == null) { newCount++; } else { updatedCount++; } } + publishChanged(savedIds); return new ChunkResult(newCount, updatedCount, 0); } @@ -147,12 +158,11 @@ private Map> regionCodesByPolicyId(Collection existing Collectors.toSet()))); } - /** 1건 저장. 신규면 true, 변경이면 false. */ - private boolean writeOne( + /** 1건 저장. 색인 갱신에 쓰도록 저장된 엔티티를 돌려준다(신규 여부는 호출부가 existing 으로 판단). */ + private Policy writeOne( PolicyUpsertItem item, Policy existing, Map> regionCodesByPolicyId) { Policy target; - boolean isNew = existing == null; - if (isNew) { + if (existing == null) { target = policyRepository.save(item.policy()); saveRegions(target, item.regions()); } else { @@ -174,7 +184,7 @@ private boolean writeOne( } // 지역 행과 같은 트랜잭션에서 갱신한다 — 따로 두면 둘이 어긋나 정렬이 조용히 틀린다(#120). target.applyRegionCoverage(item.nationwide()); - return isNew; + return target; } private void saveRegions(Policy policy, List regions) { @@ -183,6 +193,18 @@ private void saveRegions(Policy policy, List regions) { } } + /** + * 저장된 정책들의 색인 갱신을 요청한다. 트랜잭션 안에서 발행해야 리스너가 커밋 직후에 받는다 — 커밋 전에 색인하면 롤백 시 ES 에만 + * 남고, 커밋 밖에서 발행하면 AFTER_COMMIT 리스너가 아예 호출되지 않는다. + * + *

건별이 아니라 청크 단위로 묶어 던져, 색인 요청이 정책 건수만큼 늘어나지 않게 한다. + */ + private void publishChanged(List policyIds) { + if (!policyIds.isEmpty()) { + eventPublisher.publishEvent(PolicyChangedEvent.updated(policyIds)); + } + } + private record ChunkResult(int newCount, int updatedCount, int errorCount) {} /** 이번 수집에서 사라진 정책의 누락 횟수를 올린다(3회 도달 시 HIDDEN — Policy.markMissing). DB에 없는 번호는 무시. */ @@ -197,6 +219,8 @@ public int hideMissing(List missingPolicyNos) { status -> { List found = policyRepository.findByPolicyNoIn(chunk); found.forEach(Policy::markMissing); + // 누락 3회로 HIDDEN이 되면 검색에서 빠져야 하므로 색인도 갱신한다. + publishChanged(found.stream().map(Policy::getId).toList()); return found.size(); }); } diff --git a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java index a6afa56..fa71b85 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -38,6 +39,7 @@ class AdminPolicyServiceTest { @Mock private PolicyRegionRepository policyRegionRepository; @Mock private RegionRepository regionRepository; + @Mock private ApplicationEventPublisher eventPublisher; private AdminPolicyService adminPolicyService; @@ -59,7 +61,11 @@ class AdminPolicyServiceTest { void setUp() { adminPolicyService = - new AdminPolicyService(policyRepository, policyRegionRepository, regionRepository); + new AdminPolicyService( + policyRepository, + policyRegionRepository, + regionRepository, + eventPublisher); } @Test From 2682dfd2ad7a49e4290c20d0768b2d3f4dff545d Mon Sep 17 00:00:00 2001 From: myh Date: Sun, 6 Sep 2026 03:30:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B2=80=EC=83=89=EC=9D=84=20Elasticsearch?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98=EA=B3=A0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=8F=B4=EB=B0=B1=ED=95=9C=EB=8B=A4=20(#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- es/policy-index.json | 11 +- .../policy/service/PolicyService.java | 104 +++++++++-- .../search/dto/PolicySearchQuery.java | 29 +++ .../search/dto/PolicySearchResult.java | 19 ++ .../search/service/PolicySearchService.java | 166 +++++++++++++++++- .../policy/service/PolicyServiceTest.java | 8 +- 6 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/search/dto/PolicySearchQuery.java create mode 100644 src/main/java/com/bop/youthpick/search/dto/PolicySearchResult.java diff --git a/es/policy-index.json b/es/policy-index.json index dacc35f..48d9ee8 100644 --- a/es/policy-index.json +++ b/es/policy-index.json @@ -87,7 +87,16 @@ }, "category": { "type": "keyword" }, - "sidoNames": { "type": "keyword" }, + "sidoNames": { + "type": "keyword", + "fields": { + "text": { + "type": "text", + "analyzer": "ko_index", + "search_analyzer": "ko_search" + } + } + }, "jobCodes": { "type": "keyword" }, "visibility": { "type": "keyword" }, diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 94bcefc..cd487be 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -10,14 +10,20 @@ import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.dto.PolicySearchQuery; +import com.bop.youthpick.search.dto.PolicySearchResult; +import com.bop.youthpick.search.service.PolicySearchService; import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.lang.Nullable; @@ -35,6 +41,8 @@ public class PolicyService { private final PolicyRegionRepository policyRegionRepository; private final PolicyRecentViewService policyRecentViewService; private final SearchLogService searchLogService; + private final PolicySearchService policySearchService; + private final PolicySearchProperties searchProperties; private static final String NATIONWIDE_REGION = "전국"; @@ -55,6 +63,74 @@ public Page getCards( @Nullable Integer ageMax, @Nullable String jobCode, Pageable pageable) { + if (searchProperties.enabled()) { + try { + return searchViaElasticsearch( + category, keyword, region, ageMin, ageMax, jobCode, pageable); + } catch (Exception e) { + // 검색 품질은 떨어지지만 아무것도 못 보여주는 것보다는 낫다. 조용히 넘기지 않고 로그를 남긴다 — + // 폴백이 계속 도는 상황을 아무도 모르는 것이 진짜 사고다. + log.warn("ES 검색 실패 — MySQL 경로로 폴백합니다", e); + } + } + return searchViaMysql(category, keyword, region, ageMin, ageMax, jobCode, pageable); + } + + /** + * ES 로 걸리는 정책 id 를 받고, 카드 내용은 MySQL 에서 읽는다. 색인이 조금 늦어도 화면에 보이는 값은 항상 원본 기준이고, 응답 조립 코드도 + * 폴백 경로와 그대로 공유된다. + * + *

ES 가 돌려준 순서(관련도)가 곧 응답 순서다. {@code findAllById} 는 순서를 보장하지 않으므로 id 순서에 맞춰 다시 세운다. + */ + private Page searchViaElasticsearch( + @Nullable String category, + @Nullable String keyword, + @Nullable String region, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + @Nullable String jobCode, + Pageable pageable) + throws Exception { + String searchKeyword = trimToNull(keyword); + String regionFilter = trimToNull(region); + PolicySearchResult result = + policySearchService.search( + new PolicySearchQuery( + searchKeyword, + trimToNull(category), + NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter, + ageMin, + ageMax, + trimToNull(jobCode), + LocalDate.now(), + pageable.getPageNumber(), + pageable.getPageSize())); + + if (searchKeyword != null) { + searchLogService.record(searchKeyword, (int) result.total()); + } + + Map policiesById = + policyRepository + .findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + result.policyIds(), PolicyVisibility.VISIBLE) + .stream() + .collect(Collectors.toMap(Policy::getId, policy -> policy)); + // 색인과 원본이 어긋난 사이(삭제 직후 등)에는 id 가 비어 있을 수 있다 — 그 건만 조용히 빠진다. + List policies = + result.policyIds().stream().map(policiesById::get).filter(Objects::nonNull).toList(); + + return new PageImpl<>(toCards(policies), pageable, result.total()); + } + + private Page searchViaMysql( + @Nullable String category, + @Nullable String keyword, + @Nullable String region, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + @Nullable String jobCode, + Pageable pageable) { String categoryFilter = trimToNull(category); String keywordPattern = toLikePattern(keyword); String regionFilter = trimToNull(region); @@ -79,7 +155,12 @@ public Page getCards( searchLogService.record(trimToNull(keyword), (int) page.getTotalElements()); } - List policyIds = page.getContent().stream().map(Policy::getId).toList(); + return new PageImpl<>(toCards(page.getContent()), pageable, page.getTotalElements()); + } + + /** 정책들을 카드 응답으로 조립한다. 지역 라벨은 페이지 단위 배치 조회(fetch join)로 N+1 을 피한다. */ + private List toCards(List policies) { + List policyIds = policies.stream().map(Policy::getId).toList(); Map> sidoNamesByPolicyId = policyIds.isEmpty() ? Map.of() @@ -90,15 +171,18 @@ public Page getCards( Collectors.mapping( pr -> pr.getRegion().getSidoName(), Collectors.toList()))); - - return page.map( - policy -> - PolicyCardResponse.from( - policy, - sidoNamesByPolicyId.getOrDefault(policy.getId(), List.of()).stream() - .distinct() - .sorted() - .toList())); + return policies.stream() + .map( + policy -> + PolicyCardResponse.from( + policy, + sidoNamesByPolicyId + .getOrDefault(policy.getId(), List.of()) + .stream() + .distinct() + .sorted() + .toList())) + .toList(); } @Nullable diff --git a/src/main/java/com/bop/youthpick/search/dto/PolicySearchQuery.java b/src/main/java/com/bop/youthpick/search/dto/PolicySearchQuery.java new file mode 100644 index 0000000..ad34880 --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/dto/PolicySearchQuery.java @@ -0,0 +1,29 @@ +package com.bop.youthpick.search.dto; + +import java.time.LocalDate; +import org.springframework.lang.Nullable; + +/** + * 정책 검색 조건. {@code PolicyService.getCards} 의 파라미터를 그대로 옮긴 것이며, 기존 LIKE 경로와 같은 결과 집합을 내는 것이 + * 목표다(정렬은 관련도 점수가 추가로 개입한다). + * + * @param keyword 검색어. null 이면 조건 없이 필터만 적용한다 + * @param category 표준 5분류 정확 일치 + * @param sidoName 시도명 정확 일치. '전국' 선택은 호출부에서 null 로 바꿔 넘긴다 + * @param ageMin 요청 나이 구간 하한 (정책 자격 구간과 겹치는지 판정) + * @param ageMax 요청 나이 구간 상한 + * @param jobCode 온통청년 취업상태 코드. 해당 코드 정책과 '제한없음' 정책을 함께 통과시킨다 + * @param today 마감 판정 기준일. 호출 시점을 넘겨 테스트에서 고정할 수 있게 한다 + * @param page 0-based 페이지 번호 + * @param size 페이지 크기 + */ +public record PolicySearchQuery( + @Nullable String keyword, + @Nullable String category, + @Nullable String sidoName, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + @Nullable String jobCode, + LocalDate today, + int page, + int size) {} diff --git a/src/main/java/com/bop/youthpick/search/dto/PolicySearchResult.java b/src/main/java/com/bop/youthpick/search/dto/PolicySearchResult.java new file mode 100644 index 0000000..ccbdb3d --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/dto/PolicySearchResult.java @@ -0,0 +1,19 @@ +package com.bop.youthpick.search.dto; + +import java.util.List; + +/** + * 검색 결과. 정책 id 와 총건수만 돌려준다. + * + *

카드 내용을 ES 에서 함께 내리지 않는 이유: 그렇게 하면 ES 가 사실상 원본 행세를 하게 되어, 색인이 조금만 늦어도 사용자에게 옛 제목이 보인다. + * id 만 받고 내용은 MySQL 에서 읽으면 화면에 보이는 값은 항상 원본 기준이고, 폴백 경로와 응답 조립 코드도 그대로 공유된다. + * + * @param policyIds 관련도 순으로 정렬된 정책 id + * @param total 페이징용 전체 건수 + */ +public record PolicySearchResult(List policyIds, long total) { + + public static PolicySearchResult empty() { + return new PolicySearchResult(List.of(), 0); + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java index 003d15a..7f44f26 100644 --- a/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java +++ b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java @@ -1,21 +1,48 @@ package com.bop.youthpick.search.service; import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.FieldValue; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.dto.PolicySearchQuery; +import com.bop.youthpick.search.dto.PolicySearchResult; +import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -/** 정책 검색. 지금은 색인 건수 조회만 있고, 실제 검색 질의는 7단계에서 채운다. */ +/** + * 정책 검색 질의. 색인에서 조건에 맞는 정책 id만 뽑아 온다. + * + *

여기서 나는 예외는 삼키지 않고 그대로 올린다 — 폴백 여부는 호출부({@code PolicyService})가 판단하고, 이 클래스는 검색만 책임진다. + */ @Slf4j @Service @RequiredArgsConstructor public class PolicySearchService { + /** 검색어가 걸릴 필드와 가중치. 제목 일치를 본문 일치보다 크게 본다 — LIKE 에는 없던 개념이다. */ + private static final List SEARCH_FIELDS = + List.of( + "title^3", + "keywords^2", + "organizationName^2", + "description", + "supportContent", + // 지역명으로도 검색되게 한다(#199). keyword 필드는 정확 일치라 형태소 분석된 하위 필드를 쓴다. + "sidoNames.text"); + private final ElasticsearchClient client; private final PolicySearchProperties properties; - /** 현재 색인된 문서 수. 인덱스가 아직 없거나 ES가 죽어 있으면 0을 돌려준다(재색인 판단용). */ + /** 현재 색인된 문서 수. 인덱스가 아직 없거나 ES 가 죽어 있으면 0 을 돌려준다(재색인 판단용). */ public long count() { try { return client.count(c -> c.index(properties.alias())).count(); @@ -24,4 +51,139 @@ public long count() { return 0; } } + + public PolicySearchResult search(PolicySearchQuery query) throws Exception { + BoolQuery bool = bool(query); + SearchResponse response = + client.search( + request -> + request.index(properties.alias()) + .query(q -> q.bool(bool)) + // 카드 내용은 MySQL 에서 읽으므로 _source 는 받지 않는다(전송량 절감). + .source(s -> s.fetch(false)) + .from(query.page() * query.size()) + .size(query.size()) + // 기본값은 10,000건에서 집계를 멈춘다 — 페이징에 쓸 정확한 총건수가 필요하다. + .trackTotalHits(t -> t.enabled(true)) + .sort(s -> s.score(sc -> sc.order(SortOrder.Desc))) + // 점수가 같으면(검색어 없는 목록 조회 등) 최신순으로 안정 정렬한다. + .sort( + s -> + s.field( + f -> + f.field("policyId") + .order( + SortOrder + .Desc))), + Void.class); + + List policyIds = + response.hits().hits().stream().map(hit -> Long.valueOf(hit.id())).toList(); + long total = response.hits().total() == null ? 0 : response.hits().total().value(); + return new PolicySearchResult(policyIds, total); + } + + /** + * 질의 조립. filter 절은 점수에 영향을 주지 않고 걸러내기만 하고, should 절은 걸러내지 않고 점수만 올린다 — MySQL 이 + * {@code case when ... then 1 else 0} 정렬로 "후순위"를 표현했던 것을 ES 에서는 "가산점"으로 뒤집어 표현한다. + */ + private BoolQuery bool(PolicySearchQuery query) { + BoolQuery.Builder bool = new BoolQuery.Builder(); + + // ── 노출 조건 ── 삭제된 정책은 애초에 색인되지 않으므로 조건이 없다. + bool.filter(f -> f.term(t -> t.field("visibility").value(PolicyVisibility.VISIBLE.name()))); + bool.filter(f -> f.term(t -> t.field("adminHidden").value(false))); + + // ── 마감 조건 ── 신청 마감일이 없으면(상시) 통과, 있으면 오늘 이후여야 한다. + bool.filter(anyMatch(missing("applicationEndDate"), onOrAfter("applicationEndDate", query.today()))); + // 신청 마감일이 아예 없는 정책은 사업기간 종료일로 한 번 더 거른다(#123). + bool.filter( + anyMatch( + exists("applicationEndDate"), + missing("businessPeriodEnd"), + onOrAfter("businessPeriodEnd", query.today()))); + + if (query.category() != null) { + bool.filter(f -> f.term(t -> t.field("category").value(query.category()))); + } + if (query.sidoName() != null) { + // MySQL 의 EXISTS 서브쿼리가 배열 필드 term 하나로 줄었다. + bool.filter(f -> f.term(t -> t.field("sidoNames").value(query.sidoName()))); + // 전국 정책은 모든 시도에 걸려 있어 지역 검색에 항상 잡힌다. 지역 특화 정책을 위로 올린다. + bool.should(s -> s.term(t -> t.field("nationwide").value(false))); + } + // ── 나이 ── 요청 구간과 정책 자격 구간의 겹침. 값이 없거나 0이면 '제한 없음'이라 통과시킨다. + if (query.ageMin() != null) { + bool.filter( + anyMatch( + missing("maxAge"), + isZero("maxAge"), + atLeast("maxAge", query.ageMin()))); + } + if (query.ageMax() != null) { + bool.filter( + anyMatch(missing("minAge"), isZero("minAge"), atMost("minAge", query.ageMax()))); + } + if (query.jobCode() != null) { + bool.filter( + anyMatch( + missing("jobCodes"), + anyOf( + "jobCodes", + List.of(query.jobCode(), Policy.JOB_CODE_UNRESTRICTED)))); + // '제한없음'으로 걸린 정책보다 해당 코드를 실제로 가진 정책을 위로 올린다. + bool.should(s -> s.term(t -> t.field("jobCodes").value(query.jobCode()))); + } + + if (query.keyword() != null) { + bool.must( + m -> + m.multiMatch( + mm -> + mm.query(query.keyword()) + .fields(SEARCH_FIELDS) + // best_fields: 한 필드에 몰려 맞은 문서를 여러 필드에 흩어져 + // 맞은 문서보다 높게 본다. + .type(TextQueryType.BestFields) + // 단어가 3개 이상이면 70% 이상 맞아야 한다. 기본값(OR)은 + // '청년'처럼 흔한 한 단어만 맞아도 전부 걸려 필터 구실을 못 한다. + .minimumShouldMatch("2<70%"))); + } + return bool.build(); + } + + /** 여러 조건 중 하나만 맞으면 통과하는 filter 절(SQL 의 OR). */ + private static Query anyMatch(Query... alternatives) { + return Query.of( + q -> q.bool(b -> b.should(List.of(alternatives)).minimumShouldMatch("1"))); + } + + private static Query missing(String field) { + return Query.of(q -> q.bool(b -> b.mustNot(mn -> mn.exists(e -> e.field(field))))); + } + + private static Query exists(String field) { + return Query.of(q -> q.exists(e -> e.field(field))); + } + + private static Query isZero(String field) { + return Query.of(q -> q.term(t -> t.field(field).value(0))); + } + + private static Query onOrAfter(String field, LocalDate date) { + return Query.of(q -> q.range(r -> r.date(d -> d.field(field).gte(date.toString())))); + } + + private static Query atLeast(String field, int value) { + return Query.of(q -> q.range(r -> r.number(n -> n.field(field).gte((double) value)))); + } + + private static Query atMost(String field, int value) { + return Query.of(q -> q.range(r -> r.number(n -> n.field(field).lte((double) value)))); + } + + private static Query anyOf(String field, List values) { + List fieldValues = values.stream().map(FieldValue::of).toList(); + return Query.of(q -> q.terms(t -> t.field(field).terms(tv -> tv.value(fieldValues)))); + } } diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 4f7cba4..6dd3485 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -32,6 +32,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.service.PolicySearchService; import org.springframework.beans.BeanUtils; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.data.domain.Page; @@ -48,6 +50,7 @@ class PolicyServiceTest { @Mock private PolicyRegionRepository policyRegionRepository; @Mock private PolicyRecentViewService policyRecentViewService; @Mock private SearchLogService searchLogService; + @Mock private PolicySearchService policySearchService; private PolicyService policyService; @@ -58,7 +61,10 @@ void setUp() { policyRepository, policyRegionRepository, policyRecentViewService, - searchLogService); + searchLogService, + policySearchService, + // 이 테스트는 기존 MySQL 경로를 검증한다 — 검색 전환은 enabled=false 로 끈다. + new PolicySearchProperties(false, "policy")); } @Test