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..48d9ee8
--- /dev/null
+++ b/es/policy-index.json
@@ -0,0 +1,114 @@
+{
+ "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",
+ "fields": {
+ "text": {
+ "type": "text",
+ "analyzer": "ko_index",
+ "search_analyzer": "ko_search"
+ }
+ }
+ },
+ "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/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/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/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/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/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..7f44f26
--- /dev/null
+++ b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java
@@ -0,0 +1,189 @@
+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;
+
+/**
+ * 정책 검색 질의. 색인에서 조건에 맞는 정책 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 을 돌려준다(재색인 판단용). */
+ public long count() {
+ try {
+ return client.count(c -> c.index(properties.alias())).count();
+ } catch (Exception e) {
+ log.warn("색인 건수 조회 실패 — 0건으로 간주", e);
+ 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/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/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}
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
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