diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index 8f3e859a..9ee29ec9 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -68,6 +68,22 @@ jobs:
- name: Run tests
run: ./gradlew clean test jacocoTestReport
+ # Testcontainers 테스트는 Docker 가 없으면 조용히 skip 되고 빌드는 초록불이 된다.
+ # 스키마 정합성 검증이 그렇게 빠지면 마이그레이션 누락을 아무도 못 잡는다.
+ - name: Assert schema validation actually ran
+ run: |
+ report=build/test-results/test/TEST-com.carecode.integration.FlywaySchemaValidationTest.xml
+ if [ ! -f "$report" ]; then
+ echo "::error::스키마 정합성 테스트 리포트가 없습니다."
+ exit 1
+ fi
+ if grep -q 'skipped="0"' "$report"; then
+ echo "스키마 정합성 테스트 실행 확인"
+ else
+ echo "::error::스키마 정합성 테스트가 skip 되었습니다. Docker 환경을 확인하세요."
+ exit 1
+ fi
+
- name: Publish test report
uses: mikepenz/action-junit-report@v5
if: always()
diff --git a/.gitignore b/.gitignore
index 61e02318..419f2ef5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,3 +62,6 @@ logs/
### Temporary files ###
*.tmp
*.temp
+
+# 별도 저장소로 관리되는 프론트엔드
+CareCode_FE/
diff --git a/README.md b/README.md
index 3cd7551f..08e8b4ec 100644
--- a/README.md
+++ b/README.md
@@ -473,9 +473,26 @@ docker-compose up carecode-mariadb carecode-redis -d
## API 문서
+### 설계 문서
+
+기능별 상세 문서와 아키텍처는 [`docs/`](docs/README.md) 에 있습니다.
+무엇을 만들었는지보다 **왜 그렇게 만들었는지**를 남기는 것을 목표로 합니다.
+
+| 문서 | 내용 |
+|------|------|
+| [시스템 개요](docs/architecture/system-overview.md) | 계층 구조, 요청·배치 흐름 |
+| [데이터 흐름](docs/architecture/data-flow.md) | 공공데이터 수집 → 알림까지의 파이프라인 |
+| [공공데이터 연동](docs/features/public-data-integration.md) | 4개 정부 API, 공급자 추상화 |
+| [지원금 지능화](docs/features/benefit-intelligence.md) | 추천·비교·놓친 지원금·금액 신뢰도 |
+| [시설 지능화](docs/features/facility-intelligence.md) | 정원 시계열·입소 예측·빈자리 알림 |
+| [알림과 리텐션](docs/features/notification-and-retention.md) | 알림 3종과 중복 방지 |
+| [기동 안정화](docs/quality/runtime-hardening.md) | 실기동에서 드러난 차단 8건 |
+| [회귀 방지](docs/quality/regression-safety.md) | 왜 CI 가 못 잡았는지 |
+
### Swagger UI
프로젝트는 **SpringDoc OpenAPI 3**를 사용하여 자동으로 API 문서를 생성합니다.
+**운영(prod) 프로파일에서는 차단됩니다.**
**접속 URL**: http://localhost/swagger-ui.html
diff --git a/build.gradle b/build.gradle
index d734674e..4c84bd7b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -58,8 +58,8 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.batch:spring-batch-test'
- testImplementation 'org.testcontainers:junit-jupiter:1.20.4'
- testImplementation 'org.testcontainers:mariadb:1.20.4'
+ testImplementation 'org.testcontainers:junit-jupiter:1.21.3'
+ testImplementation 'org.testcontainers:mariadb:1.21.3'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
// 소셜 로그인(OAuth2)
@@ -78,16 +78,45 @@ dependencies {
// Logging - JSON 형식 로깅 지원
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
- // AsciiDoctor for documentation generation
- implementation 'org.asciidoctor:asciidoctorj:2.5.7'
- implementation 'org.asciidoctor:asciidoctorj-pdf:2.3.4'
+ // API 문서는 springdoc-openapi 가 런타임에 생성한다(운영 프로파일에서는 비공개).
+ // asciidoctor 로 정적 산출물을 만들던 시절의 의존성이 남아 있었는데, .adoc 도 사용처도 없이
+ // 실행 jar 에 7.5MB 를 차지하고 취약점 스캔 표면만 늘리고 있었다.
+}
+
+// 소스에 한글 문자열 리터럴이 있다. 인코딩을 지정하지 않으면 Windows(CP949)에서 깨져 컴파일된다.
+// 공공데이터 응답의 한글 필드명("서비스ID" 등) 조회가 전부 실패하는 원인이었다.
+tasks.withType(JavaCompile).configureEach {
+ options.encoding = 'UTF-8'
+}
+
+tasks.withType(Test).configureEach {
+ systemProperty 'file.encoding', 'UTF-8'
+ jvmArgs '-Dfile.encoding=UTF-8'
}
tasks.named('test') {
- useJUnitPlatform()
+ useJUnitPlatform {
+ // 외부 API 를 호출하는 테스트는 기본 빌드에서 제외한다. 네트워크 상태로 빌드가 깨지면 안 된다.
+ // 실행: ./gradlew liveSyncCheck
+ excludeTags 'live'
+ }
finalizedBy(tasks.named('jacocoTestReport'))
}
+/** 공공데이터 실연동 점검. 키를 환경변수로 넘겨 수동 실행한다. */
+tasks.register('liveSyncCheck', Test) {
+ group = 'verification'
+ description = '실제 공공데이터 API 를 호출해 적재까지 확인한다'
+ testClassesDirs = sourceSets.test.output.classesDirs
+ classpath = sourceSets.test.runtimeClasspath
+ useJUnitPlatform {
+ includeTags 'live'
+ }
+ testLogging {
+ showStandardStreams = true
+ }
+}
+
jacoco {
toolVersion = "0.8.12"
}
diff --git a/docs/ERD.md b/docs/ERD.md
index 26b168c1..37266b15 100644
--- a/docs/ERD.md
+++ b/docs/ERD.md
@@ -887,9 +887,10 @@ erDiagram
| ID | BIGINT | PK, AUTO_INCREMENT | 고유 식별자 |
| USER_ID | BIGINT | FK, NOT NULL | 사용자 ID |
| NOTIFICATION_TYPE | ENUM | NOT NULL | 알림 유형 |
-| EMAIL_ENABLED | BOOLEAN | DEFAULT TRUE | 이메일 알림 활성화 |
+| EMAIL_ENABLED | BOOLEAN | DEFAULT FALSE | 이메일 알림 활성화 |
| PUSH_ENABLED | BOOLEAN | DEFAULT TRUE | 푸시 알림 활성화 |
| SMS_ENABLED | BOOLEAN | DEFAULT FALSE | SMS 알림 활성화 |
+| IN_APP_ENABLED | BOOLEAN | DEFAULT TRUE | 인앱 알림 활성화 |
| CREATED_AT | DATETIME | NOT NULL | 생성 시간 |
| UPDATED_AT | DATETIME | NULL | 수정 시간 |
@@ -1023,7 +1024,7 @@ TBL_USER (1) ----< (N) TBL_CHAT_SESSION
| Hospital - HospitalReview | 1:N | 한 병원은 여러 리뷰 받을 수 있음 |
| HealthRecord - Attachment | 1:N | 한 건강 기록은 여러 첨부파일 가능 |
| Policy - PolicyDocument | 1:N | 한 정책은 여러 문서를 가질 수 있음 |
-| User - NotificationSettings | 1:1 | 한 사용자는 하나의 알림 설정을 가짐 |
+| User - NotificationPreference | 1:N | 알림 유형별로 한 행씩 가짐 (UNIQUE: USER_ID + NOTIFICATION_TYPE) |
| ChatSession - ChatMessage | 1:N | 한 세션은 여러 메시지를 포함 |
---
diff --git "a/docs/OpenAPI\354\204\234\353\271\204\354\212\244\353\252\205\354\204\270\354\204\234_021_v1.0 (1).doc" "b/docs/OpenAPI\354\204\234\353\271\204\354\212\244\353\252\205\354\204\270\354\204\234_021_v1.0 (1).doc"
new file mode 100644
index 00000000..6a4dc2f6
Binary files /dev/null and "b/docs/OpenAPI\354\204\234\353\271\204\354\212\244\353\252\205\354\204\270\354\204\234_021_v1.0 (1).doc" differ
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..1642be4b
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,80 @@
+# CareCode(맘편한) 문서
+
+육아 지원금·보육시설·건강관리를 한 곳에서 다루는 백엔드 서비스입니다.
+이 디렉터리는 **무엇을 만들었는지**가 아니라 **왜 그렇게 만들었는지**를 남기는 것을 목표로 합니다.
+
+## 이 문서들의 전제
+
+이 서비스의 핵심 데이터는 전부 **정부 공공데이터**에서 옵니다. 그래서 대부분의 설계 판단은
+"우리가 무엇을 하고 싶은가" 보다 **"공공데이터가 무엇을 주지 않는가"** 에서 출발합니다.
+
+예를 들어 어린이집 정원 데이터는 시설 전체 수치만 주고 반별로는 주지 않습니다.
+그래서 빈자리 알림은 "어느 반에 자리가 났는지는 알 수 없다" 는 한계를 문구에 그대로 밝힙니다.
+정확한 척하는 것이 틀린 정보보다 위험하기 때문입니다.
+
+이런 판단의 근거를 각 문서에 함께 적었습니다.
+
+## 문서 지도
+
+### 아키텍처
+
+| 문서 | 내용 |
+|------|------|
+| [시스템 개요](architecture/system-overview.md) | 전체 구성, 계층 구조, 요청·배치 흐름 (Mermaid) |
+| [데이터 흐름](architecture/data-flow.md) | 공공데이터 수집 → 정제 → 알림까지의 파이프라인 (Mermaid) |
+| [carecode-architecture.drawio](architecture/carecode-architecture.drawio) | draw.io 편집용 아키텍처 원본 |
+
+### 기능
+
+| 문서 | 다루는 범위 | 관련 이슈 |
+|------|-------------|-----------|
+| [공공데이터 연동](features/public-data-integration.md) | 4개 정부 API 연동, 공급자 추상화, 전국 순회 동기화 | #61 #68 |
+| [지원금 지능화](features/benefit-intelligence.md) | 추천·지역 비교·놓친 지원금·실수령액 제보·중복 수급 배타 | #65 #67 #69 |
+| [시설 지능화](features/facility-intelligence.md) | 정원 시계열·입소 예측·인기도·대기 기록·빈자리 알림 | #65 #67 #69 #73 |
+| [알림과 리텐션](features/notification-and-retention.md) | 정책 변경·빈자리·마감 임박 알림, 딥링크, 클릭 전환 | #69 #73 #74 |
+| [지표 수집](features/analytics.md) | 행동 이벤트, 퍼널, 코호트 리텐션 | #68 |
+| [개인정보와 법적 문서](features/privacy-and-legal.md) | 동의 분리, 민감정보 차단, 처리방침·약관 | #68 #71 |
+| [운영](features/operations.md) | 운영 알림, 헬스체크, 스케줄러, 수동 실행 | #68 #70 |
+
+### 품질
+
+| 문서 | 내용 | 관련 이슈 |
+|------|------|-----------|
+| [기동 안정화](quality/runtime-hardening.md) | 실기동에서 드러난 차단 8건과 접근제어 결함 | #70 |
+| [회귀 방지](quality/regression-safety.md) | 왜 CI 가 못 잡았는지, 어떻게 막았는지 | #72 |
+
+### 레퍼런스
+
+| 문서 | 내용 |
+|------|------|
+| [데이터베이스 마이그레이션](reference/database-migrations.md) | 각 마이그레이션이 왜 필요했는지 |
+| [접근제어 매트릭스](reference/access-control-matrix.md) | 공개·인증·관리자 경로 전수 |
+
+### 기존 문서
+
+| 문서 | 내용 |
+|------|------|
+| [ERD.md](ERD.md) | 엔티티 관계도 |
+| [ISSUE_MANAGEMENT.md](ISSUE_MANAGEMENT.md) | 이슈·커밋 연결 규칙 |
+| [system-architecture.md](system-architecture.md) | 초기 아키텍처 문서 |
+| [ARCHITECTURE_IMPROVEMENTS.md](ARCHITECTURE_IMPROVEMENTS.md) | 초기 개선 기록 |
+
+## 기술 스택
+
+| 구분 | 사용 기술 |
+|------|-----------|
+| 런타임 | Java 17, Spring Boot 3.3.3 |
+| 데이터 | MariaDB 10.11, Redis 7, Flyway |
+| 빌드 | Gradle 8.14, JaCoCo |
+| 테스트 | JUnit 5, Mockito, AssertJ, Testcontainers, H2 |
+| 문서 | springdoc-openapi (운영에서는 비공개) |
+| 배포 | Docker, GitHub Actions, Blue/Green |
+
+## 개발 규칙
+
+- **커밋**: `TYPE : 한글 설명 (#이슈번호)` — 관심사별로 잘게 나눕니다.
+- **스키마**: 운영은 `ddl-auto=validate` 입니다. 엔티티를 바꾸면 마이그레이션도 반드시 씁니다.
+ 안 쓰면 [스키마 정합성 테스트](quality/regression-safety.md)가 기동 단계에서 깨뜨립니다.
+- **접근제어**: 경로를 추가하면 [접근제어 계약 테스트](reference/access-control-matrix.md)에도 넣습니다.
+ SecurityConfig 는 앞선 규칙이 뒤를 덮어서, 규칙만 보고는 실제로 열렸는지 알 수 없습니다.
+- **비밀값**: API 키·자격증명은 저장소에 넣지 않습니다. 환경변수로만 주입합니다.
diff --git a/docs/architecture/carecode-architecture.drawio b/docs/architecture/carecode-architecture.drawio
new file mode 100644
index 00000000..76436c8f
--- /dev/null
+++ b/docs/architecture/carecode-architecture.drawio
@@ -0,0 +1,278 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md
new file mode 100644
index 00000000..097c50dd
--- /dev/null
+++ b/docs/architecture/data-flow.md
@@ -0,0 +1,210 @@
+# 데이터 흐름
+
+이 서비스의 가치는 **정부가 흩어놓은 데이터를 한 사람 기준으로 다시 조립하는 것**에서 나옵니다.
+그 조립 과정을 단계별로 정리합니다.
+
+## 전체 파이프라인
+
+```mermaid
+flowchart LR
+ subgraph collect["1. 수집"]
+ direction TB
+ P1["보육통합정보 XML · HTTPS"]
+ P2["유치원알리미 JSON"]
+ P3["보조금24 JSON"]
+ P4["심평원 XML"]
+ end
+
+ subgraph normalize["2. 정제"]
+ direction TB
+ N1["시군구 순회 RegionCodeCatalog"]
+ N2["필드 매핑 Upsert 서비스"]
+ N3["좌표 보정 주소 → 위경도"]
+ N4["지급유형 판별 월정액·일시금·융자"]
+ end
+
+ subgraph enrich["3. 축적"]
+ direction TB
+ E1["정원 스냅샷 일자별 관측"]
+ E2["정책 변경 이력 금액·기한·연령"]
+ E3["실수령액 제보 사용자 입력"]
+ E4["대기 기록 사용자 입력"]
+ end
+
+ subgraph derive["4. 판단"]
+ direction TB
+ D1["입소 예측"]
+ D2["시설 인기도"]
+ D3["놓친 지원금"]
+ D4["지역별 비교"]
+ D5["빈자리 감지"]
+ D6["마감 임박 감지"]
+ end
+
+ subgraph act["5. 전달"]
+ direction TB
+ A1["알림 발송"]
+ A2["조회 API"]
+ A3["행동 이벤트"]
+ end
+
+ P1 & P2 --> N1 --> N2 --> N3
+ P3 --> N4
+ P4 --> N2
+
+ N2 --> E1
+ N4 --> E2
+ N2 --> E1
+
+ E1 --> D1 & D2 & D5
+ E2 --> D6
+ E3 --> D3 & D4
+ E4 --> D5
+
+ D1 & D2 & D3 & D4 --> A2
+ D5 & D6 --> A1
+ A1 & A2 --> A3
+```
+
+**3단계(축적)가 이 구조의 핵심**입니다. 정부 API 는 "지금 이 순간" 만 알려주고 과거를 주지 않습니다.
+매일 관측해서 쌓아야만 "자리가 늘었다", "금액이 바뀌었다" 를 말할 수 있습니다.
+그래서 스냅샷과 변경 이력은 기능이 아니라 **다른 모든 판단의 재료**입니다.
+
+## 공공데이터 수집 상세
+
+```mermaid
+sequenceDiagram
+ participant S as Scheduler
+ participant Sync as SyncService
+ participant T as PagedSyncTemplate
+ participant Cat as RegionCodeCatalog
+ participant Prov as Provider
+ participant Gov as 정부 API
+ participant Up as UpsertService
+ participant Snap as CapacitySnapshotRecorder
+ participant DB as MariaDB
+ participant Ops as OperationalAlerter
+
+ S->>Sync: sync()
+ Sync->>Cat: 시군구 코드 목록 (어린이집 202 / 유치원 212)
+ loop 시군구마다
+ Sync->>T: 페이지 순회
+ T->>Prov: 요청
+ Prov->>Gov: HTTP
+ alt 정상
+ Gov-->>Prov: 목록
+ Prov-->>T: 파싱 결과
+ T->>Up: upsert (코드 기준)
+ Up->>DB: 저장
+ Up->>Snap: 정원·현원 관측 기록
+ Snap->>DB: 스냅샷 (일자별 1행)
+ else 응답 코드가 한도 초과·키 만료
+ Gov-->>Prov: 오류 코드
+ Prov-->>Sync: ChildcareApiStatus
+ Sync->>Ops: 운영 알림
+ else 통신 실패
+ Sync->>Sync: 해당 시군구만 실패 기록
+ end
+ end
+ Sync-->>S: SyncResult (생성·갱신·실패)
+ alt 실패 있음
+ S->>Ops: 운영 알림
+ end
+```
+
+한 시군구가 실패해도 **나머지는 계속 돕니다.** 전국 데이터는 일부가 비어도 쓸 수 있지만,
+하나 때문에 전체가 멈추면 아무것도 못 씁니다.
+
+## 빈자리 알림 판단
+
+```mermaid
+flowchart TD
+ START([스케줄러 09:30]) --> W{대기자가 있는 시설이 있는가}
+ W -->|없음| END1([종료])
+ W -->|있음| H[최근 30일 스냅샷 조회]
+ H --> C{관측이 2회 이상인가}
+ C -->|아니오| SKIP1[증감을 알 수 없음 건너뜀]
+ C -->|예| CALC[직전 대비 빈자리 증감 계산]
+ CALC --> INC{증가분 ≥ 기준?}
+ INC -->|아니오| SKIP2[이미 있던 자리거나 오르내림 · 건너뜀]
+ INC -->|예| LOOP[대기자 순회]
+ LOOP --> DUP{최근에 알린 적 있는가}
+ DUP -->|예| SKIP3[간격 미달 · 건너뜀]
+ DUP -->|아니오| SEND[알림 저장 · 발송]
+ SEND --> MARK[대기 기록에 관측일 기록]
+ MARK --> LOOP
+
+ style SEND fill:#d4edda,stroke:#28a745
+ style SKIP1 fill:#f8f9fa,stroke:#adb5bd
+ style SKIP2 fill:#f8f9fa,stroke:#adb5bd
+ style SKIP3 fill:#f8f9fa,stroke:#adb5bd
+```
+
+**"빈자리가 있다" 가 아니라 "빈자리가 늘었다" 로 판단합니다.**
+계속 자리가 있는 시설은 사용자도 이미 알고 있어서, 매일 알리면 그냥 스팸이 됩니다.
+
+## 마감 임박 알림 판단
+
+```mermaid
+flowchart TD
+ START([스케줄러 10:00]) --> LOAD[활성 정책 조회]
+ LOAD --> DL{마감일이 있는가}
+ DL -->|없음| SKIP0[대상 아님]
+ DL -->|있음| LEAD{남은 일수가 D-7 또는 D-1 인가}
+ LEAD -->|아니오| SKIP1[해당 없음]
+ LEAD -->|예| HIST[오늘 이미 받은 사용자 조회]
+ HIST --> USER[활성 사용자 순회]
+ USER --> SENT{오늘 이미 받았는가}
+ SENT -->|예| SKIP2[중복 방지]
+ SENT -->|아니오| T1{자녀가 있는가}
+ T1 -->|없음| SKIP3[대상 아님]
+ T1 -->|있음| T2{지역이 맞는가}
+ T2 -->|아니오| SKIP4[다른 지역]
+ T2 -->|예| T3{자녀수 요건 충족하는가}
+ T3 -->|아니오| SKIP5[대상 아님]
+ T3 -->|예| T4{소득이 기준을 넘는가}
+ T4 -->|넘음| SKIP6[대상 아님]
+ T4 -->|미입력·이하| T5{연령이 맞는 아이가 있는가}
+ T5 -->|없음| SKIP7[대상 아님]
+ T5 -->|있음| REC[발송 이력 저장 유니크 제약]
+ REC --> SEND[알림 저장 · 발송]
+ SEND --> USER
+
+ style SEND fill:#d4edda,stroke:#28a745
+ style REC fill:#fff3cd,stroke:#ffc107
+```
+
+**소득 미입력은 탈락시키지 않습니다.** 놓친 사람의 손해가 잘못 받은 알림의 성가심보다 훨씬 크기
+때문입니다. 반대로 소득이 기준을 명확히 넘으면 보내지 않습니다.
+
+발송 이력을 **먼저** 저장하는 것도 의도적입니다. 이 서비스는 Blue/Green 배포라 인스턴스가 잠깐
+2대가 될 수 있고, 그러면 유니크 제약만이 중복을 막습니다.
+
+## 지원금 총액 계산
+
+```mermaid
+flowchart LR
+ P[정책 목록] --> ELIG{자격 판정}
+ ELIG -->|미충족| DROP[제외]
+ ELIG -->|충족| TYPE{지급 유형}
+ TYPE -->|월정액| M["금액 × min(지급개월, 남은개월)"]
+ TYPE -->|일시금| L[금액 그대로]
+ TYPE -->|융자·현물| X[총액에서 제외 별도 안내]
+ TYPE -->|금액 미상| U[unknownAmountCount 로 노출]
+ M & L --> EX{배타 그룹}
+ EX -->|같은 그룹| MAX[최댓값 하나만]
+ EX -->|무관| ADD[합산]
+ MAX & ADD --> TOTAL[예상 총액]
+
+ style X fill:#f8d7da,stroke:#dc3545
+ style U fill:#fff3cd,stroke:#ffc107
+```
+
+이 계산은 처음에 **2억 9,506만 원** 이라는 값을 냈습니다. 원인이 두 가지였습니다.
+
+1. 자격 요건(자녀수·소득)을 보지 않고 전부 더했습니다.
+2. **대상 연령 상한을 지급 기간으로 착각**했습니다. 아빠육아휴직보너스 250만 원을 60개월 곱하면
+ 1억 5천만 원이 됩니다.
+
+지급 기간 컬럼을 분리하고 자격 판정을 넣어 **8,056만 원**이 되었습니다.
+자세한 내용은 [지원금 지능화 문서](../features/benefit-intelligence.md)에 있습니다.
diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md
new file mode 100644
index 00000000..3496dcbd
--- /dev/null
+++ b/docs/architecture/system-overview.md
@@ -0,0 +1,169 @@
+# 시스템 개요
+
+## 한 장으로 보는 구성
+
+```mermaid
+graph TB
+ subgraph clients["클라이언트"]
+ APP["모바일 · 웹"]
+ end
+
+ subgraph edge["진입 계층"]
+ SEC["SecurityConfig JWT · OAuth2(카카오)"]
+ RATE["RateLimitInterceptor Redis 카운터"]
+ EXH["전역 예외 핸들러 404·403 을 5xx 로 새지 않게"]
+ end
+
+ subgraph app["애플리케이션"]
+ CTRL["Controller 20종"]
+ FACADE["Facade · Service"]
+ GUARD["ConsentGuard 민감정보 접근 차단"]
+ end
+
+ subgraph domains["도메인"]
+ POLICY["policy 지원금"]
+ FACILITY["careFacility 어린이집·유치원"]
+ HEALTHD["health 건강기록·병원"]
+ USERD["user 계정·자녀·동의"]
+ NOTI["notification"]
+ COMM["community"]
+ BOT["chatbot"]
+ end
+
+ subgraph batch["배치 · 수집"]
+ SCHED["PublicDataSyncScheduler"]
+ SYNC["Sync 서비스 PagedSyncTemplate"]
+ PROVIDER["PublicDataProvider 공급자 추상화"]
+ GEO["KakaoGeocoder 좌표 보정"]
+ end
+
+ subgraph store["저장소"]
+ DB[("MariaDB Flyway 마이그레이션")]
+ REDIS[("Redis 캐시·레이트리밋·토큰")]
+ FILES["파일 저장소"]
+ end
+
+ subgraph external["외부"]
+ GOV1["보육통합정보시스템 어린이집"]
+ GOV2["유치원알리미 유치원"]
+ GOV3["보조금24 정부지원 서비스"]
+ GOV4["심평원 소아청소년과"]
+ KAKAO["카카오 로컬 API"]
+ FCM["FCM · SMTP · SMS"]
+ SLACK["Slack Webhook"]
+ end
+
+ APP --> SEC --> RATE --> CTRL
+ CTRL --> EXH
+ CTRL --> FACADE --> GUARD
+ FACADE --> POLICY & FACILITY & HEALTHD & USERD & NOTI & COMM & BOT
+
+ SCHED --> SYNC --> PROVIDER
+ PROVIDER --> GOV1 & GOV2 & GOV3 & GOV4
+ SCHED --> GEO --> KAKAO
+
+ POLICY & FACILITY & HEALTHD & USERD & NOTI & COMM --> DB
+ SYNC --> DB
+ FACADE --> REDIS
+ NOTI --> FCM
+ SCHED --> SLACK
+ HEALTHD --> FILES
+
+ classDef ext fill:#fff4e6,stroke:#d9822b
+ classDef st fill:#e8f4fd,stroke:#2b6cb0
+ class GOV1,GOV2,GOV3,GOV4,KAKAO,FCM,SLACK ext
+ class DB,REDIS,FILES st
+```
+
+## 계층 구조
+
+| 계층 | 패키지 | 책임 |
+|------|--------|------|
+| 진입 | `core.security`, `core.RateLimitInterceptor`, `core.handler` | 인증·인가, 호출 제한, 예외의 상태코드 변환 |
+| 표현 | `domain.*.controller` | HTTP 계약. 비즈니스 판단은 하지 않음 |
+| 조합 | `domain.*.facade` | 여러 서비스를 묶어 화면 단위 응답을 만듦 |
+| 도메인 | `domain.*.service` | 판단과 계산. 대부분의 설계 결정이 여기 있음 |
+| 수집 | `core.client` | 공공데이터 공급자 추상화와 동기화 |
+| 지표 | `core.analytics` | 행동 이벤트 적재와 퍼널·리텐션 집계 |
+| 운영 | `core.ops`, `core.scheduler` | 알림, 주기 실행 |
+
+## 요청 처리 흐름
+
+```mermaid
+sequenceDiagram
+ participant C as 클라이언트
+ participant S as SecurityFilterChain
+ participant R as RateLimitInterceptor
+ participant Ctrl as Controller
+ participant G as ConsentGuard
+ participant Svc as Service
+ participant DB as MariaDB
+ participant E as EventLogger
+
+ C->>S: 요청 (+ JWT)
+ alt 공개 경로
+ S->>R: 통과
+ else 보호 경로
+ S->>S: 토큰 검증
+ S--xC: 401 (실패 시)
+ end
+ R->>R: Redis 카운터 확인
+ R--xC: 429 (초과 시)
+ R->>Ctrl: 진입
+ Ctrl->>G: 민감정보 접근이면 동의 확인
+ G--xC: 403 CONSENT_REQUIRED (미동의 시)
+ Ctrl->>Svc: 위임
+ Svc->>DB: 조회·저장
+ Svc-)E: 행동 이벤트 (비동기, 실패해도 응답에 영향 없음)
+ Svc-->>Ctrl: 결과
+ Ctrl-->>C: 200 (+ ETag)
+```
+
+`EventLogger` 는 **비동기이고 큐가 차면 버립니다**. 지표 수집이 사용자 응답을 느리게 하거나
+실패시키면 본말이 전도되기 때문입니다. 지표는 유실을 감수하고, 응답은 감수하지 않습니다.
+
+## 배치 실행 시각
+
+모두 `Asia/Seoul` 기준이며 프로퍼티로 덮어쓸 수 있습니다.
+
+```mermaid
+gantt
+ title 일일 배치 순서
+ dateFormat HH:mm
+ axisFormat %H:%M
+
+ section 수집
+ 어린이집 동기화 (월) :03:00, 30m
+ 정부지원 서비스 동기화 :03:30, 30m
+ 유치원 동기화 (월) :04:00, 30m
+ 병원 동기화 (화) :03:00, 30m
+
+ section 정제
+ 좌표 보정 :05:00, 30m
+
+ section 발송
+ 정책 변경 알림 :09:00, 30m
+ 빈자리 알림 :09:30, 30m
+ 마감 임박 알림 :10:00, 30m
+ 실수령액 제보 요청 (수) :10:00, 30m
+```
+
+순서에는 이유가 있습니다.
+
+- **수집이 먼저, 발송이 나중**입니다. 알림은 그날 들어온 데이터를 근거로 나가야 합니다.
+- **좌표 보정은 수집 뒤**입니다. 새로 들어온 시설이 보정 대상에 포함되어야 합니다.
+- **빈자리 알림은 시설 동기화 뒤**입니다. 새 정원이 반영되어야 그날 난 자리가 잡힙니다.
+- **어린이집과 유치원은 1시간 벌립니다.** 둘 다 전국 200여 개 시군구를 순회해서 오래 걸립니다.
+
+자세한 내용은 [운영 문서](../features/operations.md)를 보세요.
+
+## 저장소 사용 구분
+
+| 저장소 | 용도 | 없으면 |
+|--------|------|--------|
+| MariaDB | 모든 영속 데이터 | 기동 불가 |
+| Redis | 캐시, 레이트리밋, 리프레시 토큰 | **기동 불가** — `RateLimitingAspect` 가 `StringRedisTemplate` 을 요구 |
+| 파일 저장소 | 건강기록 첨부 | 첨부 기능만 실패 |
+
+Redis 가 필수라는 점은 로컬 개발에서 자주 걸립니다. 캐시는 `spring.cache.type=none` 으로 끌 수 있지만
+레이트리밋은 끌 수 없습니다. 이건 [기동 안정화 문서](../quality/runtime-hardening.md)에 기록해 두었습니다.
diff --git a/docs/features/analytics.md b/docs/features/analytics.md
new file mode 100644
index 00000000..232a2093
--- /dev/null
+++ b/docs/features/analytics.md
@@ -0,0 +1,116 @@
+# 지표 수집
+
+> 관련 이슈: #68 · 관련 마이그레이션: V8
+
+## 문제
+
+"사용자가 늘고 있다" 는 말은 아무것도 설명하지 못합니다.
+
+- 가입한 사람 중 몇 명이 **아이를 등록**했는가
+- 아이를 등록한 사람 중 몇 명이 **주소를 넣었는가**
+- 주소를 넣은 사람 중 몇 명이 **추천을 봤는가**
+- 추천을 본 사람 중 몇 명이 **신청 링크를 눌렀는가**
+
+이걸 모르면 어디를 고쳐야 할지 알 수 없습니다.
+개선은 짐작이 아니라 **어느 단계에서 사람이 빠져나가는지** 를 보고 결정해야 합니다.
+
+## 이벤트 수집
+
+```mermaid
+flowchart LR
+ SVC["서비스 코드"] -->|"log()"| EL["EventLogger @Async"]
+ EL --> Q["analyticsExecutor 큐"]
+ Q --> DB[("TBL_USER_EVENT")]
+ Q -.->|"큐가 차면"| DROP["버림 DiscardPolicy"]
+
+ style DROP fill:#f8d7da,stroke:#dc3545
+```
+
+**비동기이고, 큐가 차면 버립니다.**
+
+지표 수집이 사용자 응답을 느리게 하거나 실패시키면 본말이 전도됩니다.
+지표는 유실을 감수하고, 응답은 감수하지 않습니다.
+트래픽이 몰릴 때 지표 몇 건을 잃는 것은 서비스가 느려지는 것보다 훨씬 낫습니다.
+
+## 수집하는 이벤트
+
+| 분류 | 이벤트 |
+|------|--------|
+| 온보딩 | `SIGNED_UP`, `CHILD_REGISTERED`, `ADDRESS_REGISTERED`, `INCOME_REGISTERED` |
+| 지원금 | `MISSED_BENEFIT_VIEWED`, `BENEFIT_LINK_CLICKED`, `RECOMMENDATION_VIEWED`, `REGIONAL_COMPARISON_VIEWED` |
+| 시설 | `FACILITY_VIEWED`, `ADMISSION_FORECAST_VIEWED`, `FACILITY_POPULARITY_VIEWED`, `WAITLIST_REGISTERED` |
+| 알림 | `NOTIFICATION_SENT`, `NOTIFICATION_CLICKED` |
+| 참여 | `BENEFIT_AMOUNT_REPORTED`, `APP_OPENED`, `BOOKING_CREATED`, `CHATBOT_ASKED` |
+
+## 온보딩 퍼널
+
+```mermaid
+flowchart TD
+ A["SIGNED_UP 가입"] --> B["CHILD_REGISTERED 아이 등록"]
+ B --> C["ADDRESS_REGISTERED 주소 입력"]
+ C --> D["RECOMMENDATION_VIEWED 추천 조회"]
+ D --> E["BENEFIT_LINK_CLICKED 신청 링크 클릭"]
+
+ A -.->|이탈| X1[" "]
+ B -.->|이탈| X2[" "]
+ C -.->|이탈| X3[" "]
+ D -.->|이탈| X4[" "]
+
+ style E fill:#d4edda,stroke:#28a745
+```
+
+### 전환율 계산 방식
+
+각 단계의 전환율은 **직전 단계를 통과한 사용자만** 분모로 씁니다.
+
+전체 가입자를 분모로 쓰면 "주소 입력률 30%" 같은 숫자가 나오는데,
+이건 아이 등록에서 빠진 사람까지 포함한 값이라 **주소 입력 화면의 문제인지 아이 등록의 문제인지
+구분할 수 없습니다.**
+
+## 알림 전환 퍼널
+
+`NOTIFICATION_SENT` → `NOTIFICATION_CLICKED` 를 알림 종류별로 봅니다.
+
+이게 [알림 기능](notification-and-retention.md)의 효과를 판단하는 유일한 방법입니다.
+발송 수만 세면 "많이 보냈다" 는 것 외에 아무것도 알 수 없습니다.
+
+어떤 알림의 클릭률이 낮다면 그 알림은 **가치가 없거나 문구가 잘못된 것**이고,
+그건 발송을 줄여야 한다는 신호입니다.
+
+## 코호트 리텐션
+
+가입 주차별로 묶어 이후 재방문을 봅니다.
+
+응답 형태 (수치는 **설명용 예시**이며 실측값이 아닙니다):
+
+| 가입 코호트 | D1 | D7 | D30 |
+|-------------|----|----|-----|
+| 5주 전 가입 | 값 | 값 | 값 |
+| 2주 전 가입 | 값 | 값 | **null** |
+| 이번 주 가입 | 값 | **null** | **null** |
+
+**아직 오지 않은 시점은 0이 아니라 `null`** 입니다.
+
+가입 1주일 된 코호트의 D30 리텐션을 0%로 표시하면 "리텐션이 무너지고 있다" 는 착시가 생깁니다.
+측정할 수 없는 것과 0인 것은 다릅니다.
+
+## 관련 API
+
+| 메서드 | 경로 | 인증 |
+|--------|------|------|
+| GET | `/api/admin/analytics/funnel` | 관리자 |
+| GET | `/api/admin/analytics/events` | 관리자 |
+| GET | `/api/admin/analytics/notification-funnel` | 관리자 |
+| GET | `/api/admin/analytics/retention` | 관리자 |
+
+## 개인정보 관점
+
+이벤트에는 사용자 ID 와 대상 식별자만 남기고 **개인 식별 정보는 담지 않습니다.**
+회원 탈퇴 시 익명화 대상에 포함됩니다. 자세한 내용은 [개인정보 문서](privacy-and-legal.md)를 보세요.
+
+## 미해결
+
+| 항목 | 내용 |
+|------|------|
+| 검색어 로그 | 사용자가 무엇을 찾는지 = 다음에 무엇을 만들지의 근거인데, 아직 수집하지 않습니다 |
+| 이벤트 보존 기간 | 무한 적재 중입니다. 파티셔닝이나 아카이빙 정책이 필요합니다 |
diff --git a/docs/features/benefit-intelligence.md b/docs/features/benefit-intelligence.md
new file mode 100644
index 00000000..d7bf2c88
--- /dev/null
+++ b/docs/features/benefit-intelligence.md
@@ -0,0 +1,219 @@
+# 지원금 지능화
+
+> 관련 이슈: #65 #67 #69 · 관련 마이그레이션: V6, V7, V9, V11, V12, V14
+
+## 문제
+
+육아 지원금은 **중앙정부와 지자체가 따로 운영**합니다.
+받을 수 있는데 몰라서 못 받는 경우가 흔하고, 조건이 복잡해 본인이 대상인지 판단하기 어렵습니다.
+
+"목록을 보여준다" 는 검색만으로는 이 문제를 풀지 못합니다.
+**이 사람이 얼마를 받을 수 있는지**를 계산해야 합니다.
+
+## 기능 구성
+
+```mermaid
+mindmap
+ root((지원금 지능화))
+ 맞춤 추천
+ 자녀 월령
+ 거주지
+ 소득분위
+ 자녀수
+ 놓친 지원금
+ 지나온 월령 구간 역추적
+ 소급 신청 가능 여부
+ 마감까지 남은 개월
+ 지역 비교
+ 현재 거주지 총액
+ 다른 지역 총액
+ 차액과 근거
+ 금액 신뢰도
+ 수기 검증
+ 실수령액 제보
+ 3인 합의
+ 변경 감지
+ 금액·기한·연령
+ 지역별 알림
+```
+
+## 자격 판정
+
+세 가지를 봅니다. 판정 결과는 **세 갈래**입니다.
+
+| 결과 | 조건 | 처리 |
+|------|------|------|
+| `ELIGIBLE` | 요건을 모두 충족 | 총액에 포함 |
+| `NOT_ELIGIBLE` | 자녀수 미달 또는 소득 초과 | 제외 |
+| `UNKNOWN` | 소득을 입력하지 않음 | **제외하지 않고 보류로 표기** |
+
+소득 미입력을 탈락으로 처리하면 **받을 수 있었던 지원금이 통째로 사라집니다.**
+사용자는 자기가 왜 목록에서 그걸 못 봤는지도 모릅니다.
+
+## 수령액 계산 — 두 번의 큰 오류
+
+### 처음 결과: 2억 9,506만 원
+
+지역 비교 기능을 붙이고 실행했더니 한 지역의 예상 총액이 **2억 9,506만 원**으로 나왔습니다.
+명백히 틀린 값입니다.
+
+### 원인 1 — 자격 요건을 보지 않았다
+
+`minChildren`(최소 자녀수)과 `incomeThresholdPercent`(소득 기준)를 검사하지 않고
+지역에 있는 정책을 전부 더하고 있었습니다. 다자녀 전용 지원금이 외동 가정에도 합산됐습니다.
+
+### 원인 2 — 대상 연령을 지급 기간으로 착각했다
+
+이쪽이 더 컸습니다. `targetAgeMax`(대상 연령 상한, 개월)를 **지급 개월 수**로 쓰고 있었습니다.
+
+> 아빠육아휴직보너스: 월 250만 원 × `targetAgeMax` 60 = **1억 5천만 원**
+
+실제로는 최대 3개월만 지급됩니다. `max_payment_months` 컬럼을 분리(V7)하고
+지급 유형을 판별하도록 고쳤습니다.
+
+### 수정 후: 8,056만 원
+
+```mermaid
+flowchart TD
+ A["2억 9,506만 원 (초기값)"] --> B[자격 요건 검사 추가]
+ B --> C[지급 기간 컬럼 분리]
+ C --> D["8,056만 원 (수정 후)"]
+
+ style A fill:#f8d7da,stroke:#dc3545
+ style D fill:#d4edda,stroke:#28a745
+```
+
+## 지급 유형 판별
+
+`BenefitPaymentType` 이 정책 설명에서 지급 방식을 구분합니다.
+
+| 유형 | 계산 | 이유 |
+|------|------|------|
+| 월정액 | 금액 × min(지급개월, 남은 대상 개월) | 실제 받을 기간만큼만 |
+| 일시금 | 금액 그대로 | 한 번 받고 끝 |
+| 융자 | **총액에서 제외** | 갚아야 하는 돈이라 "받는 돈" 이 아님 |
+| 현물·바우처 | 총액에서 제외 | 현금 총액과 섞으면 오해를 부름 |
+| 미상 | `unknownAmountCount` 로 노출 | 버리면 존재 자체를 모름 |
+
+**융자를 지원금 총액에 넣으면 안 됩니다.** 연 1.5% 대출 3천만 원을 "받을 수 있는 돈" 이라고
+표시하면 그건 거짓말입니다.
+
+## 중복 수급 배타 그룹 (V14)
+
+같은 목적의 지원금은 **동시에 받을 수 없습니다.** 예를 들어 부모급여와 양육수당은 택일입니다.
+전부 더하면 실제로 받을 수 없는 금액이 나옵니다.
+
+`exclusion_group` 컬럼으로 묶고, 같은 그룹 안에서는 **가장 큰 금액 하나만** 총액에 넣습니다.
+
+```mermaid
+flowchart LR
+ subgraph g1["exclusion_group = 'INFANT_CASH'"]
+ A[부모급여 100만]
+ B[양육수당 10만]
+ end
+ subgraph g2["그룹 없음"]
+ C[첫만남이용권 200만]
+ end
+ A & B --> MAX["최댓값 100만"]
+ MAX --> SUM["총액 300만"]
+ C --> SUM
+
+ style MAX fill:#fff3cd,stroke:#ffc107
+```
+
+## 금액 신뢰도 — 두 가지 경로
+
+정책 금액은 자유 텍스트에서 추출하므로 틀릴 수 있습니다.
+**자동 파싱을 신뢰하지 않기로** 하고, 대신 두 가지를 만들었습니다.
+
+### 1. 수기 검증 (V9)
+
+관리자가 확인한 정책에 `verified_at`, `verified_by` 를 남깁니다.
+응답에 **지역별 검증 비율**을 함께 노출해서 사용자가 얼마나 믿을지 판단할 수 있게 합니다.
+
+### 2. 실수령액 제보와 합의 (V12)
+
+실제로 받은 사람에게 금액을 묻습니다.
+
+```mermaid
+flowchart TD
+ ASK[주 1회 제보 요청 알림] --> REP[사용자가 실수령액 입력]
+ REP --> N{같은 금액 제보가 3건 이상?}
+ N -->|아니오| PEND[참고값으로만 표기]
+ N -->|예| CONS[합의값으로 확정]
+ CONS --> SHOW[지원금 상세에 표기]
+
+ style CONS fill:#d4edda,stroke:#28a745
+ style PEND fill:#fff3cd,stroke:#ffc107
+```
+
+3인 합의를 기준으로 삼는 이유는, 한 사람의 오타나 착각이 전체 금액을 흔들면 안 되기 때문입니다.
+
+제보 요청은 **주 1회(수요일)** 만 보냅니다. 매일 물으면 소음이 됩니다.
+
+## 놓친 지원금
+
+아이가 **이미 지나온 월령 구간**을 훑어 대상이었던 지원금을 찾습니다.
+
+```mermaid
+flowchart LR
+ A[아이 생년월일] --> B[현재 월령 계산]
+ B --> C{targetAgeMax 를 지났는가}
+ C -->|아니오| SKIP[지금도 대상]
+ C -->|예| D[소급 가능 기간 확인]
+ D --> E{retroactive_months 안에 있는가}
+ E -->|예| CLAIM["claimable 아직 신청 가능"]
+ E -->|아니오| EXP["expired 기회를 놓침"]
+
+ style CLAIM fill:#d4edda,stroke:#28a745
+ style EXP fill:#f8d7da,stroke:#dc3545
+```
+
+놓친 것을 `claimable`(아직 받을 수 있음)과 `expired`(놓침)로 나눠서 보여줍니다.
+`expired` 를 굳이 보여주는 이유는, 둘째를 준비하는 부모에게는 그게 중요한 정보이기 때문입니다.
+
+> 이 기능은 사후 대응입니다. 놓치기 **전에** 막는 쪽이 낫다는 판단으로
+> [마감 임박 알림](notification-and-retention.md#신청-마감-임박-알림)을 추가했습니다.
+
+## 지역별 비교
+
+같은 조건에서 **다른 지역에 살면 얼마를 더 받는지** 계산합니다.
+이사를 고민하는 가정에게는 실질적인 판단 재료이고, 지자체 간 격차를 드러내는 데이터이기도 합니다.
+
+응답에는 총액만이 아니라 **차액의 근거가 되는 정책 목록**을 함께 담습니다.
+숫자만 주면 믿을 이유가 없습니다.
+
+## 정책 변경 감지 (V11)
+
+동기화할 때마다 이전 값과 비교해 변경을 기록합니다.
+
+| 변경 유형 | 감지 대상 |
+|-----------|-----------|
+| `CREATED` | 새 정책 등록 |
+| `AMOUNT_CHANGED` | 지원 금액 변경 |
+| `DEADLINE_CHANGED` | 신청 기한 변경 |
+| `AGE_RANGE_CHANGED` | 대상 연령 변경 |
+
+기록된 변경은 [해당 지역 사용자에게 알림](notification-and-retention.md)으로 나갑니다.
+
+## 관련 API
+
+| 메서드 | 경로 | 인증 |
+|--------|------|------|
+| GET | `/policies` | 공개 |
+| GET | `/policies/search` | 공개 |
+| GET | `/policies/categories` | 공개 |
+| GET | `/policies/statistics` | 공개 |
+| GET | `/policies/{id}` | 공개 |
+| GET | `/policies/recommendations` | 인증 |
+| GET | `/policies/missed-benefits` | 인증 |
+| GET | `/policies/regional-comparison` | 인증 |
+| POST | `/policies/{id}/amount-reports` | 인증 |
+| GET/POST/DELETE | `/policies/bookmarks` | 인증 |
+
+## 미해결
+
+| 항목 | 내용 |
+|------|------|
+| 상위 30개 지자체 금액 수기 검증 | 자동 파싱 값은 참고용입니다. 사람이 확인해야 신뢰할 수 있습니다 |
+| 배타 그룹 데이터 입력 | `exclusion_group` 은 구조만 있고 실제 그룹 지정은 수기 작업이 필요합니다 |
diff --git a/docs/features/facility-intelligence.md b/docs/features/facility-intelligence.md
new file mode 100644
index 00000000..cc55ef34
--- /dev/null
+++ b/docs/features/facility-intelligence.md
@@ -0,0 +1,193 @@
+# 시설 지능화
+
+> 관련 이슈: #65 #67 #69 #73 · 관련 마이그레이션: V5, V13, V16
+
+## 문제
+
+어린이집 대기는 부모가 겪는 가장 답답한 일 중 하나입니다.
+**언제 자리가 나는지 아무도 알려주지 않습니다.** 시설에 전화해도 "기다려 보세요" 가 전부입니다.
+
+정부 API 는 지금 이 순간의 정원과 현원을 줍니다. 그런데 그것만으로는
+"이 시설은 자리가 잘 나는 곳인가" 를 알 수 없습니다. **과거를 주지 않기 때문입니다.**
+
+## 해결의 출발점 — 관측을 쌓는다
+
+```mermaid
+flowchart LR
+ SYNC["동기화 (주 1회)"] --> SNAP["정원 스냅샷 일자별 1행"]
+ SNAP --> F1[입소 예측]
+ SNAP --> F2[시설 인기도]
+ SNAP --> F3[빈자리 감지]
+
+ style SNAP fill:#fff3cd,stroke:#ffc107
+```
+
+`FacilityCapacitySnapshot`(V5)은 기능이 아니라 **다른 세 기능의 재료**입니다.
+시설 행은 최신값만 갖고, 추이는 여기에 쌓입니다.
+
+하루에 여러 번 동기화해도 같은 날짜면 갱신만 하므로 일자별로 한 행만 남습니다.
+
+## 입소 가능 시점 예측
+
+관측 이력에서 자리가 났던 횟수를 세어 확률을 추정합니다.
+
+```mermaid
+flowchart TD
+ A[목표 시점 입력] --> B{관측이 있는가}
+ B -->|없음| N1["available=false '관측 이력이 아직 없습니다'"]
+ B -->|있음| C{관측 기간이 충분한가}
+ C -->|아니오| N2["available=false 기간 부족"]
+ C -->|예| D[자리 발생 빈도 계산]
+ D --> E[목표 시점까지 확률 추정]
+ E --> F["probability + 근거 문구"]
+
+ style N1 fill:#f8f9fa,stroke:#adb5bd
+ style N2 fill:#f8f9fa,stroke:#adb5bd
+ style F fill:#d4edda,stroke:#28a745
+```
+
+**근거가 부족하면 확률을 만들어내지 않습니다.** `available=false` 와 이유를 돌려줍니다.
+
+이건 테스트로 고정해 두었습니다 — "관측이 없으면 확률을 만들어내지 않는다".
+그럴듯한 숫자를 보여주는 쪽이 사용자 경험은 좋아 보이지만, 그 숫자를 믿고 다른 시설을 포기한
+부모에게는 피해입니다.
+
+## 시설 인기도
+
+충원율 **추이**로 판단합니다. 현재 충원율만 보면 정원이 작은 시설이 항상 높게 나옵니다.
+
+- 충원율이 계속 높게 유지 → 인기 있음
+- 자리가 나도 금방 채워짐 → 인기 있음
+- 정원 대비 대기 등록이 많음 → 인기 있음
+
+## 대기 기록 (V13)
+
+정원 관측은 "자리가 났는가" 만 알려줍니다. **대기 순번이 언제 도는지는 겪은 사람만 압니다.**
+그래서 사용자가 직접 기록하게 합니다.
+
+| 상태 | 의미 |
+|------|------|
+| `WAITING` | 대기 중 |
+| `ADMITTED` | 입소 |
+| `GAVE_UP` | 포기 |
+
+입소·포기 시점이 찍혀야 **대기 기간 데이터가 완성**됩니다.
+
+### 통계는 표본이 모여야 낸다
+
+```mermaid
+flowchart LR
+ A[입소 기록 수집] --> B{3건 이상인가}
+ B -->|아니오| C["available=false '입소 기록이 N건으로 부족합니다'"]
+ B -->|예| D[평균 · 중앙값 · 최대 대기일]
+ D --> E["근거 문구와 함께 응답"]
+
+ style C fill:#f8f9fa,stroke:#adb5bd
+ style E fill:#d4edda,stroke:#28a745
+```
+
+표본이 3건 미만이면 평균이 우연에 좌우됩니다. 그럴 때는 숫자 대신 이유를 돌려줍니다.
+
+응답에는 "입소한 N명의 실제 기록 기준입니다", "절반이 N개월 안에 입소했습니다" 처럼
+**근거를 문장으로** 함께 담습니다. 숫자만으로는 얼마나 믿을지 판단할 수 없습니다.
+
+## 빈자리 알림 (V16) — 끊겨 있던 루프
+
+여기가 이 도메인에서 가장 큰 구멍이었습니다.
+
+**정원 스냅샷은 자리가 났다는 사실을 알고 있었고 대기 명단도 있었는데, 둘을 잇는 코드가 없었습니다.**
+입소 예측까지 만들어 놓고 정작 그 예측이 맞았을 때 알려주지 않았습니다.
+사용자는 직접 들어와 확인해야만 알 수 있었습니다.
+
+### 판단 기준: "있다" 가 아니라 "늘었다"
+
+```mermaid
+flowchart TD
+ A[대기자가 있는 시설만 조회] --> B[최근 30일 스냅샷]
+ B --> C{관측 2회 이상}
+ C -->|아니오| S1[증감을 알 수 없음]
+ C -->|예| D[직전 대비 증감]
+ D --> E{증가분 ≥ 기준}
+ E -->|아니오| S2[이미 있던 자리]
+ E -->|예| F{시설이 활성인가}
+ F -->|아니오| S3[건너뜀]
+ F -->|예| G[대기자 순회]
+ G --> H{최근에 알렸는가}
+ H -->|예| S4[간격 미달]
+ H -->|아니오| I[알림 발송]
+ I --> J[관측일 기록]
+
+ style I fill:#d4edda,stroke:#28a745
+```
+
+빈자리가 **계속 있는** 시설은 사용자도 이미 압니다. 매일 알리면 그냥 스팸입니다.
+그래서 새로 늘어난 자리만 알립니다.
+
+### 빈자리 계산 — 두 가지 경로
+
+공공데이터는 빈자리를 직접 주기도 하고 정원·현원만 주기도 합니다.
+
+1. `availableSpots` 가 있으면 그대로 사용
+2. 없으면 `capacity - currentEnrollment`
+3. 둘 다 없으면 **판단하지 않음** (0으로 간주하지 않음)
+
+없는 값을 0으로 채우면 "자리가 났다" 는 잘못된 알림이 나갑니다.
+
+### 알리지 않는 것도 설계다
+
+| 상황 | 처리 | 이유 |
+|------|------|------|
+| 관측 1회 | 발송 안 함 | 늘었는지 줄었는지 알 수 없음 |
+| 빈자리 동일 | 발송 안 함 | 이미 알고 있음 |
+| 빈자리 감소 | 발송 안 함 | 알릴 내용이 아님 |
+| 최근 14일 내 발송 | 발송 안 함 | 같은 자리 반복 알림은 신뢰를 잃음 |
+| 대기자 없음 | 시설 조회조차 안 함 | 전국 시설을 다 뒤지면 대부분이 헛일 |
+
+### 알림 문구에 한계를 밝힌다
+
+공공데이터는 **시설 전체 정원만** 줍니다. 어느 반에 자리가 났는지는 알 수 없습니다.
+0세반이 찼는데 5세반에 자리가 난 것일 수도 있습니다.
+
+> "대기 등록해 두신 행복어린이집의 빈자리가 2자리 늘어 현재 3자리입니다. (2026-08-06 관측 기준)
+> **시설 전체 기준이라 해당 반에 자리가 있는지는 시설에 확인해 보세요.**"
+
+이 한 문장이 없으면 부모가 헛걸음합니다. 정확한 척하지 않는 것이 더 나은 제품입니다.
+
+### 실기동 검증
+
+| 시나리오 | 결과 |
+|----------|------|
+| 빈자리 0 → 3 변화, 대기자 1명 | **알림 1건 발송** |
+| 같은 조건 재실행 | **0건** (중복 방지 동작) |
+| 대기 기록 `VACANCY_NOTIFIED_AT` | 관측일 기록 확인 |
+
+## 관련 API
+
+| 메서드 | 경로 | 인증 |
+|--------|------|------|
+| GET | `/facilities` | 공개 |
+| GET | `/facilities/radius` | 공개 |
+| GET | `/facilities/popular` | 공개 |
+| GET | `/facilities/statistics` | 공개 |
+| GET | `/facilities/{facilityId}/admission-forecast` | 공개 |
+| GET | `/facilities/search` | 인증 |
+| POST | `/facilities/{facilityId}/waitlist` | 인증 |
+| GET | `/facilities/waitlist/me` | 인증 |
+| PATCH | `/facilities/waitlist/{waitlistId}` | 인증 |
+| GET | `/facilities/{facilityId}/waitlist/stats` | 공개 |
+| POST | `/api/admin/public-data/facilities/notify-vacancy` | 관리자 |
+
+## 설정
+
+| 프로퍼티 | 기본값 | 설명 |
+|----------|--------|------|
+| `app.facility-vacancy.min-interval-days` | 14 | 같은 사람에게 다시 알리기까지 최소 간격 |
+| `app.facility-vacancy.min-increase` | 1 | 이만큼 늘어야 알림 |
+| `app.scheduler.public-data.vacancy-cron` | `0 30 9 * * *` | 실행 시각 (시설 동기화 이후) |
+
+## 미해결
+
+| 항목 | 내용 |
+|------|------|
+| 반별 정원 | 공공데이터가 주지 않습니다. 시설 직접 입력이나 크라우드 제보가 필요합니다 |
+| 대기 순번 검증 | 사용자가 입력한 순번을 검증할 방법이 없습니다 |
diff --git a/docs/features/notification-and-retention.md b/docs/features/notification-and-retention.md
new file mode 100644
index 00000000..ebff27b2
--- /dev/null
+++ b/docs/features/notification-and-retention.md
@@ -0,0 +1,201 @@
+# 알림과 리텐션
+
+> 관련 이슈: #69 #73 #74 · 관련 마이그레이션: V11, V16, V17
+
+## 문제
+
+이 앱은 **"한 번 보고 끝"** 이 되기 쉽습니다.
+지원금을 한 번 조회하고 나면 다시 열 이유가 없습니다.
+
+알림은 다시 열 이유를 만드는 **유일한 경로**입니다. 그런데 알림이 성가시면 앱을 지웁니다.
+그래서 이 도메인의 설계는 대부분 **"언제 보내지 않을 것인가"** 에 대한 것입니다.
+
+## 알림 3종
+
+```mermaid
+flowchart LR
+ subgraph src["재료"]
+ S1[정책 변경 이력]
+ S2[정원 스냅샷]
+ S3[신청 마감일]
+ end
+
+ subgraph det["감지"]
+ D1["PolicyChangeNotifier 금액·기한·연령이 바뀜"]
+ D2["FacilityVacancyNotifier 빈자리가 늘었음"]
+ D3["PolicyDeadlineNotifier D-7 · D-1"]
+ end
+
+ subgraph guard["중복 방지"]
+ G1["markNotified()"]
+ G2["vacancyNotifiedAt + 최소 간격"]
+ G3["발송 이력 테이블 + 유니크 제약"]
+ end
+
+ S1 --> D1 --> G1
+ S2 --> D2 --> G2
+ S3 --> D3 --> G3
+ G1 & G2 & G3 --> SEND["NotificationDispatcher"]
+ SEND --> CH["EMAIL · PUSH · SMS"]
+```
+
+| 알림 | 계기 | 대상 | 실행 |
+|------|------|------|------|
+| 정책 변경 | 동기화에서 금액·기한·연령 변경 감지 | 해당 지역 사용자 | 매일 09:00 |
+| 빈자리 | 대기 시설의 빈자리 **증가** | 그 시설 대기자 | 매일 09:30 |
+| 마감 임박 | 신청 마감 D-7, D-1 | 조건이 맞는 사용자 | 매일 10:00 |
+| 제보 요청 | 지원금 수령 여부 확인 | 대상자 | 주 1회 (수 10:00) |
+
+## 신청 마감 임박 알림
+
+### 왜 필요했나
+
+`MissedBenefitService` 는 **이미 놓친 것을 사후에** 알려줍니다.
+놓치기 전에 막는 쪽이 훨씬 낫고, 사용자가 실제로 돈을 받게 되는 순간이 이 서비스의 유일한 증명입니다.
+
+`applicationEndDate` 컬럼도 있고 `DEADLINE_CHANGED` 변경 감지도 있었는데,
+정작 "내 조건에 맞는 지원금이 D-7" 을 알려주는 기능은 없었습니다.
+
+### 언제 보내는가
+
+남은 일수가 **정확히** D-7 또는 D-1 인 날에만 보냅니다. (`app.policy-deadline.lead-days`)
+
+- **D-7** — 서류를 준비할 시간을 줍니다.
+- **D-1** — 그날 스케줄러가 실패했거나 알림을 놓친 사람에게 마지막 기회입니다.
+
+### 대상 판단 — 넓게, 그러나 명확히 아닌 건 제외
+
+```mermaid
+flowchart TD
+ U[활성 사용자] --> C1{자녀가 있는가}
+ C1 -->|없음| X1[제외]
+ C1 -->|있음| C2{지역이 맞는가}
+ C2 -->|아니오| X2[제외]
+ C2 -->|예| C3{자녀수 요건}
+ C3 -->|미달| X3[제외]
+ C3 -->|충족| C4{소득}
+ C4 -->|기준 초과| X4[제외]
+ C4 -->|미입력| OK1["포함 (판단 보류)"]
+ C4 -->|기준 이하| C5{연령이 맞는 아이}
+ OK1 --> C5
+ C5 -->|없음| X5[제외]
+ C5 -->|있음| SEND[발송]
+
+ style OK1 fill:#fff3cd,stroke:#ffc107
+ style SEND fill:#d4edda,stroke:#28a745
+```
+
+마감 알림은 성격상 **조금 넓게 보내는 편이 낫습니다.**
+놓친 사람의 손해가 잘못 받은 알림의 성가심보다 훨씬 크기 때문입니다.
+그래서 소득 미입력은 배제하지 않습니다. 반대로 소득이 기준을 명확히 넘으면 보내지 않습니다.
+
+### 중복 방지 — Blue/Green 에서 드러난 결함
+
+처음 설계는 **"남은 일수가 D-7 인 날에만 보내니 하루 한 번"** 이었습니다.
+별도 이력 테이블 없이 중복을 막는 깔끔한 방법이라고 생각했습니다.
+
+그런데 이 서비스는 **Blue/Green 배포**입니다. 배포 중에는 인스턴스가 잠깐 2대가 되고,
+각 인스턴스의 스케줄러가 모두 돌면 **모든 알림이 두 번씩** 나갑니다.
+
+지원금 알림은 한 번 더 오는 순간 신뢰를 잃습니다. 그래서 발송 이력 테이블(V17)을 추가했습니다.
+
+```sql
+CONSTRAINT UK_POLICY_DEADLINE_NOTICE UNIQUE (POLICY_ID, USER_ID, NOTIFIED_ON)
+```
+
+발송 이력을 **먼저** 저장하고 알림을 보냅니다.
+존재 확인은 반복 실행을 막고, 유니크 제약은 동시 실행을 막습니다.
+
+### 실기동 검증
+
+| 시나리오 | 결과 |
+|----------|------|
+| D-7 정책 2건 (청주·제주), 사용자는 청주 거주 | 지역이 맞는 **1건만 발송** |
+| D-5 정책 | 대상에서 제외 |
+| 같은 조건 **15회 재실행** | 모두 **0건** |
+| 발송 이력 테이블 | 1행만 존재 |
+
+## 딥링크와 클릭 측정
+
+알림을 보내는 것과 **알림이 효과가 있는 것**은 다릅니다.
+발송 수만 세면 아무것도 알 수 없습니다.
+
+```mermaid
+sequenceDiagram
+ participant N as Notifier
+ participant E as EventLogger
+ participant U as 사용자
+ participant L as NotificationLinkController
+
+ N->>E: NOTIFICATION_SENT (알림ID, 종류)
+ N->>U: 알림 발송
+ U->>L: 딥링크 클릭
+ L->>L: 리다이렉트 대상 검증
+ L->>E: NOTIFICATION_CLICKED
+ L-->>U: 목적지로 이동
+```
+
+`NOTIFICATION_SENT` → `NOTIFICATION_CLICKED` 전환율이 **알림의 효과를 판단하는 유일한 지표**입니다.
+알림 종류별로 나눠 보면 어떤 알림이 실제로 가치 있는지 알 수 있습니다.
+
+### 오픈 리다이렉트 차단
+
+딥링크와 지원금 신청 링크는 외부 URL 로 이동합니다.
+검증 없이 리다이렉트하면 **우리 도메인을 경유한 피싱 통로**가 됩니다.
+허용 대상을 확인한 뒤에만 이동합니다.
+
+## 알림 폭주 방지
+
+정책 동기화 직후에는 수천 건의 변경이 한꺼번에 쌓일 수 있습니다.
+
+| 설정 | 기본값 | 목적 |
+|------|--------|------|
+| `app.policy-change.batch-size` | 200 | 한 번에 처리할 변경 수 |
+| `app.policy-change.max-per-user` | 3 | 한 사람에게 보낼 최대 알림 수 (넘으면 묶어서 한 건) |
+
+10건이 바뀌었다고 10개를 보내면 그날로 알림을 끕니다.
+
+## 실패해도 표시한다
+
+```java
+} finally {
+ // 실패해도 표시해 둔다. 재시도로 같은 알림이 반복되는 편이 더 나쁘다.
+ change.markNotified();
+}
+```
+
+발송이 실패했을 때 재시도하지 않는 것은 의도적입니다.
+누락 한 건보다 **같은 알림이 계속 오는 쪽**이 사용자에게 더 나쁩니다.
+
+## 발송 채널
+
+`NotificationDispatcher` 가 EMAIL·PUSH·SMS 를 등록합니다.
+FCM 자격증명이 없으면 **푸시만 비활성화**되고 나머지는 그대로 동작합니다.
+기동을 막지 않습니다.
+
+## 관련 API
+
+| 메서드 | 경로 | 인증 |
+|--------|------|------|
+| GET | `/notifications` | 인증 |
+| PATCH | `/notifications/{id}/read` | 인증 |
+| GET | `/notifications/link/{id}` | 인증 (딥링크) |
+| POST | `/api/admin/public-data/facilities/notify-vacancy` | 관리자 |
+| POST | `/api/admin/public-data/policies/notify-deadline` | 관리자 |
+
+관리자 수동 실행이 있는 이유는, 스케줄러가 하루 한 번만 돌아서
+발송이 안 나갔을 때 원인을 확인하려면 다음 날까지 기다려야 하기 때문입니다.
+확인한 시설 수까지 돌려주므로 **대기자가 없어서인지 자리가 안 나서인지** 구분됩니다.
+
+## 로그를 두 번 찍던 문제
+
+스케줄러와 서비스가 **같은 결과를 각각 로그**하고 있었습니다.
+
+```
+17:30:24 PolicyDeadlineNotifier | 마감 임박 알림 - 정책 2건, 알림 1건 발송
+17:30:24 PublicDataSyncScheduler | 마감 임박 알림 - 정책 2건, 알림 1건 발송
+```
+
+검증 중에 이걸 보고 **"두 번 실행되어 중복 발송됐다"** 고 잘못 판단했습니다.
+운영 중에 같은 오해를 하면 없는 장애를 쫓게 됩니다.
+서비스가 이미 남기므로 스케줄러 쪽 로그를 걷어냈습니다. (기존 4개 작업 모두 같은 문제였습니다)
diff --git a/docs/features/operations.md b/docs/features/operations.md
new file mode 100644
index 00000000..fa92d37d
--- /dev/null
+++ b/docs/features/operations.md
@@ -0,0 +1,212 @@
+# 운영
+
+> 관련 이슈: #68 #70
+
+## 원칙
+
+**사용자가 이미 실패를 겪은 뒤라면, 로그만 남겨서는 아무도 모릅니다.**
+
+이 도메인의 기능들은 전부 "문제가 생겼을 때 사람이 알게 하는 것" 에 대한 것입니다.
+동시에, **문제가 아닌 것으로 사람을 깨우지 않는 것** 도 똑같이 중요합니다.
+
+## 운영 알림
+
+`OperationalAlerter` 가 Slack 웹훅으로 보냅니다.
+
+```mermaid
+flowchart TD
+ E[알릴 사건 발생] --> W{웹훅이 설정됐는가}
+ W -->|없음| LOG[로그로만 남김]
+ W -->|있음| C{같은 키로 최근 30분 내 보낸 적 있는가}
+ C -->|예| SKIP[건너뜀]
+ C -->|아니오| SEND[Slack 발송]
+
+ style SKIP fill:#f8f9fa,stroke:#adb5bd
+ style SEND fill:#d4edda,stroke:#28a745
+```
+
+**키별 30분 쿨다운**이 있습니다. 같은 장애가 초당 수십 번 발생할 때
+알림이 폭주하면 사람이 채널을 음소거하고, 그러면 알림 자체가 무의미해집니다.
+
+웹훅이 없으면 기동을 막지 않고 로그로만 남깁니다. 로컬 개발에서 Slack 을 요구하면 안 됩니다.
+
+### 무엇을 알리는가
+
+| 사건 | 이유 |
+|------|------|
+| 동기화 미완료 | 데이터가 며칠씩 낡은 채로 서비스될 수 있음 |
+| 동기화 부분 실패 | 특정 지역 데이터가 비어 있을 수 있음 |
+| 공공데이터 한도 초과·키 만료 | 조용히 0건을 받으면 며칠 모르고 지나감 |
+| 처리되지 않은 예외 | 5xx 는 사용자가 이미 실패를 겪은 뒤 |
+
+### 무엇을 알리지 않는가
+
+이게 더 중요합니다. 초기에는 **없는 URL 요청과 권한 거부까지 운영 알림**으로 올라갔습니다.
+
+```
+[운영알림] 처리되지 않은 예외: NoResourceFoundException - No static resource hospitals.
+```
+
+없는 URL 은 잘못된 요청이지 장애가 아닙니다. 봇이 `/wp-admin` 을 긁고 가면 알림이 울립니다.
+그러면 **진짜 장애가 그 소음에 묻힙니다.**
+
+전역 예외 핸들러에 다음을 추가해 걸러냅니다.
+
+| 예외 | 응답 | 알림 |
+|------|------|------|
+| `NoResourceFoundException` | 404 | 안 보냄 |
+| `AuthorizationDeniedException` | 403 | 안 보냄 |
+| 그 외 미처리 예외 | 500 | 보냄 |
+
+## 헬스체크
+
+`/actuator/health` 는 로드밸런서와 컨테이너 오케스트레이터가 봅니다.
+여기가 DOWN 이면 **멀쩡한 인스턴스가 내려갑니다.**
+
+### 메일 헬스체크를 뺀 이유
+
+기본 설정에서는 SMTP 에 연결하지 못하면 헬스체크 전체가 DOWN 이 됩니다.
+
+```json
+{"status":"DOWN","components":{"mail":{"error":"AuthenticationFailedException ..."}}}
+```
+
+메일은 부가 기능입니다. **메일 서버 장애 하나로 조회·검색·알림이 전부 멈추면** 안 됩니다.
+
+```yaml
+management:
+ health:
+ mail:
+ enabled: false
+```
+
+메일 발송 실패는 알림 도메인에서 따로 잡습니다.
+
+### 노출 범위
+
+| 프로파일 | 노출 | Swagger |
+|----------|------|---------|
+| dev / docker | health, info, prometheus | 공개 |
+| prod | health, info, prometheus | **비공개** |
+
+운영에서 API 문서를 열어두면 공격 표면을 그대로 알려주는 셈입니다.
+
+## 스케줄러
+
+전부 `Asia/Seoul` 기준이며 프로퍼티로 덮어쓸 수 있습니다.
+
+| 작업 | 기본 cron | 프로퍼티 |
+|------|-----------|----------|
+| 어린이집 동기화 | `0 0 3 * * MON` | `app.scheduler.public-data.facility-cron` |
+| 정부지원 서비스 동기화 | `0 30 3 * * *` | `...benefit-cron` |
+| 병원 동기화 | `0 0 3 * * TUE` | `...hospital-cron` |
+| 유치원 동기화 | `0 0 4 * * MON` | `...kindergarten-cron` |
+| 좌표 보정 | `0 0 5 * * *` | `...geocoding-cron` |
+| 정책 변경 알림 | `0 0 9 * * *` | `...policy-change-cron` |
+| 빈자리 알림 | `0 30 9 * * *` | `...vacancy-cron` |
+| 마감 임박 알림 | `0 0 10 * * *` | `...policy-deadline-cron` |
+| 제보 요청 | `0 0 10 * * WED` | `...report-ask-cron` |
+
+순서의 근거는 [시스템 개요](../architecture/system-overview.md#배치-실행-시각)에 있습니다.
+
+### 로그는 서비스에서만 남긴다
+
+스케줄러와 서비스가 **같은 결과를 각각 로그**하던 시절이 있었습니다.
+
+```
+17:30:24 PolicyDeadlineNotifier | 마감 임박 알림 - 정책 2건, 알림 1건 발송
+17:30:24 PublicDataSyncScheduler | 마감 임박 알림 - 정책 2건, 알림 1건 발송
+```
+
+검증 중에 이걸 **"두 번 실행되어 중복 발송됐다"** 고 잘못 읽었습니다.
+운영 중에 같은 오해를 하면 없는 장애를 쫓게 됩니다. 스케줄러 쪽 로그를 걷어냈습니다.
+
+## 수동 실행
+
+스케줄러는 하루 한 번만 돕니다. 발송이 안 나갔을 때 원인을 확인하려면
+**다음 날까지 기다려야 합니다.** 그래서 관리자 수동 실행을 열어 두었습니다.
+
+| 경로 | 반환 |
+|------|------|
+| `POST /api/admin/public-data/facilities/sync` | 생성·갱신·실패 수 |
+| `POST /api/admin/public-data/kindergartens/sync` | 동일 |
+| `POST /api/admin/public-data/benefits/sync` | 동일 |
+| `POST /api/admin/public-data/hospitals/sync` | 동일 |
+| `POST /api/admin/public-data/facilities/geocode` | 보정·실패·남은 수 |
+| `POST /api/admin/public-data/facilities/notify-vacancy` | **확인한 시설 수**, 자리 발생 시설 수, 발송 수 |
+| `POST /api/admin/public-data/policies/notify-deadline` | 마감 임박 정책 수, 발송 수 |
+
+빈자리 알림이 **확인한 시설 수**까지 돌려주는 이유는,
+0건이 나왔을 때 **대기자가 없어서인지 자리가 안 나서인지** 구분하기 위해서입니다.
+
+## 로깅
+
+`logback-spring.xml` 에서 JSON 으로 남깁니다.
+
+### 요청 추적
+
+`TraceIdFilter` 가 모든 요청에 ID 하나를 붙입니다.
+
+```mermaid
+flowchart LR
+ REQ[요청] --> F{X-Request-Id 헤더가 있는가}
+ F -->|있음| S[정제 후 이어받기]
+ F -->|없음| G[새로 생성]
+ S & G --> M[MDC 에 저장]
+ M --> H[응답 헤더에 반환]
+ H --> B[오류 응답 본문에도 포함]
+ B --> C[요청 종료 시 MDC 비움]
+
+ style C fill:#fff3cd,stroke:#ffc107
+```
+
+| 판단 | 이유 |
+|------|------|
+| 보안 필터보다 **먼저** 실행 | 401·404 처럼 컨트롤러에 닿기 전에 끝나는 요청도 추적해야 함 |
+| 들어온 헤더를 **이어받음** | 로드밸런서·게이트웨이가 붙인 ID 와 같은 요청으로 묶임 |
+| 응답 **헤더 + 오류 본문** 양쪽 | 사용자는 오류 화면을 캡처해 보내는데 헤더는 캡처에 안 나옴 |
+| 외부 값 **정제** | 개행이 섞이면 로그 한 줄을 위조해 다른 요청인 것처럼 꾸밀 수 있음 |
+| 종료 시 **MDC 비움** | 톰캣은 스레드를 재사용해서, 안 비우면 다음 요청 로그에 남의 ID 가 붙음 |
+
+장애 조사는 사용자가 알려준 ID 하나로 시작합니다.
+
+```bash
+grep '"traceId":"notfound-77"' application.log
+```
+
+> 이전에는 `@LogExecutionTime` 안에서만 traceId 를 넣어서, 컨트롤러에 닿기 전에 끝난 요청은
+> 아무 값도 없었습니다. 실제로 500 원인을 찾을 때 타임스탬프로 로그를 뒤져야 했습니다.
+
+> Logback 의 기본값 문법은 `${VAR:-기본값}` 입니다.
+> Spring 문법인 `${VAR:기본값}` 을 쓰면 변수가 없을 때 `..._IS_UNDEFINED` 경로가 되어
+> **기동 자체가 실패합니다.** 자세한 내용은 [기동 안정화](../quality/runtime-hardening.md)에 있습니다.
+
+## 필수 의존성
+
+| 의존성 | 없으면 |
+|--------|--------|
+| MariaDB | 기동 불가 |
+| Redis | **기동 불가** — `RateLimitingAspect` 가 `StringRedisTemplate` 을 요구 |
+| SMTP | 메일만 실패 (헬스체크에는 영향 없음) |
+| FCM | 푸시만 비활성화 |
+| 카카오 지오코딩 키 | 좌표 보정만 건너뜀 |
+| Slack 웹훅 | 운영 알림이 로그로만 남음 |
+
+Redis 가 필수라는 점은 로컬 개발에서 자주 걸립니다.
+캐시는 `spring.cache.type=none` 으로 끌 수 있지만 레이트리밋은 끌 수 없습니다.
+
+## 배포
+
+GitHub Actions → Docker 이미지 → **Blue/Green**.
+
+Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다.
+배포 중에는 인스턴스가 잠깐 2대가 되고, 각 인스턴스의 스케줄러가 모두 돌면
+**중복 발송**이 생깁니다. 이 때문에 [마감 임박 알림](notification-and-retention.md#중복-방지--bluegreen-에서-드러난-결함)에
+유니크 제약 기반 발송 이력을 넣었습니다.
+
+## 미해결
+
+| 항목 | 내용 | 이슈 |
+|------|------|------|
+| 배포 후 스모크 테스트 | 배포가 성공해도 실제로 도는지 확인하지 않습니다 | #51 |
+| 스케줄러 단일 실행 보장 | 인스턴스별 중복 실행을 알림 쪽에서만 막고 있습니다. 분산 락이 근본 해결입니다 | — |
diff --git a/docs/features/privacy-and-legal.md b/docs/features/privacy-and-legal.md
new file mode 100644
index 00000000..15265ca7
--- /dev/null
+++ b/docs/features/privacy-and-legal.md
@@ -0,0 +1,133 @@
+# 개인정보와 법적 문서
+
+> 관련 이슈: #68 #71 · 관련 마이그레이션: V2, V15
+
+## 문제
+
+이 서비스는 **아이의 건강 정보**를 다룹니다. 진단명, 처방, 증상까지 저장합니다.
+이건 개인정보보호법상 **민감정보**이고, 일반 개인정보와 같은 동의로 처리할 수 없습니다.
+
+그런데 동의 기능(`UserConsent`, `ConsentGuard`)은 있는데
+**정작 동의 대상 문서가 없었습니다.** 무엇에 동의하는지 모른 채 동의를 받고 있었습니다.
+
+## 실제로 수집하는 항목
+
+문서는 추상적인 양식이 아니라 **엔티티에 실재하는 필드**를 근거로 작성했습니다.
+
+| 엔티티 | 항목 | 구분 |
+|--------|------|------|
+| `User` | 이메일, 이름, 전화번호, 주소, 위경도, 소득분위, 가구원수 | 일반 |
+| `Child` | 이름, 생년월일, 성별, 특수보육 필요 여부 | 일반 (아동) |
+| `HealthRecord` | 키, 몸무게, 체온, 혈압, 맥박, 접종명, **증상, 진단, 처방** | **민감정보** |
+
+소득분위와 주소는 지원금 자격 판정에, 위경도는 반경 검색에 필요합니다.
+필요 없는 항목은 받지 않는다는 원칙을 문서에 명시했습니다.
+
+## 동의 분리와 접근 차단
+
+```mermaid
+flowchart TD
+ REQ[건강 정보 접근 요청] --> G[ConsentGuard]
+ G --> C{민감정보 동의가 있는가}
+ C -->|없음| E["403 CONSENT_REQUIRED + 어떤 동의가 필요한지"]
+ C -->|있음| OK[접근 허용]
+
+ style E fill:#f8d7da,stroke:#dc3545
+ style OK fill:#d4edda,stroke:#28a745
+```
+
+거부할 때 **어떤 동의가 필요한지**를 응답에 담습니다.
+
+```json
+{
+ "error": "CONSENT_REQUIRED",
+ "consentType": "SENSITIVE_HEALTH",
+ "displayName": "건강정보 수집·이용",
+ "sensitive": true,
+ "message": "...",
+ "path": "..."
+}
+```
+
+그냥 403만 주면 클라이언트가 동의 화면을 띄울 수 없습니다.
+사용자는 왜 막혔는지 모른 채 화면만 보게 됩니다.
+
+## 법적 문서
+
+| 문서 | 경로 | 버전 |
+|------|------|------|
+| 개인정보 처리방침 | `src/main/resources/legal/privacy-policy-v1.0.md` | v1.0 |
+| 서비스 이용약관 | `src/main/resources/legal/terms-of-service-v1.0.md` | v1.0 |
+
+### 비로그인도 읽을 수 있어야 한다
+
+```
+.requestMatchers("/legal/**").permitAll()
+```
+
+**동의하기 전에 읽어야 하는 문서**입니다. 로그인해야 볼 수 있으면 순서가 뒤집힙니다.
+
+### 버전을 파일명에 담는다
+
+`LegalDocumentService` 가 `legal/privacy-policy-{version}.md` 를 읽습니다.
+동의 이력에 버전을 남기므로, 나중에 "이 사용자가 어떤 내용에 동의했는지" 를 되짚을 수 있습니다.
+
+> 처음에는 파일명을 `-v1.md`, 버전 상수를 `v1.0` 으로 두어 문서를 찾지 못했습니다.
+> 파일명과 버전 문자열이 **정확히 일치**해야 합니다.
+
+## 이용약관에 넣은 정확성 고지
+
+이 서비스는 **추정치와 예측을 제공합니다.** 잘못된 기대는 그대로 분쟁이 됩니다.
+그래서 약관 제6조에 명시했습니다.
+
+| 기능 | 고지 내용 |
+|------|-----------|
+| 지원금 금액 | 참고자료이며 추정치입니다. 실제 수령액은 다를 수 있습니다 |
+| 입소 예측 | 관측 기반 추정이며 입소를 보장하지 않습니다 |
+| 성장 정보 | 의학적 진단이 아닙니다 |
+| 빈자리 알림 | 시설 전체 기준이며 해당 반의 자리를 보장하지 않습니다 |
+
+## 정보주체 권리
+
+| 기능 | 경로 | 처리 |
+|------|------|------|
+| 내 데이터 열람 | `GET /users/privacy/export` | 저장된 개인정보 전체 반환 |
+| 동의 관리 | `GET/POST /users/privacy/consents` `GET .../consents/history` | 동의 항목별 조회·변경 |
+| 회원 탈퇴 | `DELETE /users/privacy/account` | 식별 정보 익명화 + 계정 비활성화 |
+
+### 탈퇴는 물리 삭제가 아니다
+
+식별 정보를 `deleted_{id}` 로 익명화하고 `deletedAt` 을 남깁니다.
+`CustomUserDetailsService` 가 `deletedAt` 조건을 포함해 조회하므로 **탈퇴 계정은 로그인되지 않습니다.**
+
+물리 삭제하지 않는 이유는, 커뮤니티 게시글이나 통계 표본처럼 다른 사용자의 데이터와
+얽힌 부분이 함께 사라지면 서비스가 깨지기 때문입니다.
+
+## 보관 기간
+
+| 항목 | 기간 | 근거 |
+|------|------|------|
+| 회원 정보 | 탈퇴 시까지 | — |
+| 동의 이력 | 5년 | 분쟁 대비 |
+| 건강 정보 | 탈퇴 시 | (파기 방식 확정 필요) |
+
+## 안전 조치
+
+- 비밀번호는 단방향 해시로 저장합니다.
+- 리프레시 토큰은 해시해서 저장하고 **HttpOnly 쿠키**로 발급합니다.
+- 건강 정보는 소유권을 서비스 계층에서 검증합니다. (IDOR 방지)
+- 개인정보는 로그·예외 메시지에 남기지 않습니다.
+
+## 미해결 — **법률 검토 전까지 시행할 수 없습니다**
+
+문서 안에 `[확인 필요]` 로 표시해 두었습니다. 제가 정할 수 없는 사실관계입니다.
+
+| 항목 | 왜 필요한가 |
+|------|-------------|
+| 자녀 건강정보 파기 방식 | 익명화로 충분한지 완전삭제가 필요한지 |
+| 클라우드 사업자 명시 | 처리 위탁 고지 의무 |
+| 메일 발송 사업자 명시 | 처리 위탁 고지 의무 |
+| 챗봇 Claude API 국외이전 | 국외 이전 동의를 별도로 받아야 하는지 |
+| 개인정보 보호책임자 연락처 | 법정 필수 기재 사항 |
+
+이 항목들이 채워지고 법률 검토를 받기 전에는 **v1.0 을 시행일 문서로 게시하면 안 됩니다.**
diff --git a/docs/features/public-data-integration.md b/docs/features/public-data-integration.md
new file mode 100644
index 00000000..0470d552
--- /dev/null
+++ b/docs/features/public-data-integration.md
@@ -0,0 +1,180 @@
+# 공공데이터 연동
+
+> 관련 이슈: #61 #68 · 관련 마이그레이션: V3, V10
+
+## 문제
+
+이 서비스가 다루는 어린이집·유치원·병원·지원금은 전부 정부가 공개합니다.
+그런데 **네 곳이 전부 다른 방식**입니다. 응답 형식도, 페이징 규칙도, 지역 코드 체계도 다릅니다.
+
+각각에 맞춰 코드를 쓰면 새 데이터를 붙일 때마다 처음부터 다시 만들어야 하고,
+한 곳이 장애를 내면 그게 어디서 온 문제인지 알기 어렵습니다.
+
+## 연동한 4개 소스
+
+| 소스 | 대상 | 형식 | 페이징 | 특이사항 |
+|------|------|------|--------|----------|
+| 보육통합정보시스템 | 어린이집 | XML | 없음 | **HTTPS 전용**, 시군구(`arcode`) 5자리, 지역당 50건 상한 |
+| 유치원알리미 | 유치원 | JSON | 없음 | 시군구(`sggCode`) 순회 |
+| 보조금24 (odcloud) | 정부지원 서비스 | JSON | `page`/`perPage` | 전국 단위 |
+| 심평원 | 소아청소년과 | XML | `pageNo`/`numOfRows` | 요양기호가 자연키 |
+
+### 실연동으로 확인한 수치
+
+`./gradlew liveSyncCheck` 로 실제 키를 넣고 확인한 결과입니다.
+
+| 소스 | 수집량 | 실패 |
+|------|--------|------|
+| 유치원 | 7,052곳 (212개 시군구) | 0건 |
+| 어린이집 | 8,331곳 (202개 중 200개 시군구) | 2건 |
+| 병원 | 200곳 수집 (전체 4,292곳) | 2건 |
+| 정부지원 서비스 | 60건 | 0건 |
+
+## 공급자 추상화
+
+```mermaid
+classDiagram
+ class PublicDataProvider {
+ <>
+ +fetch(SyncSpec) PublicDataResponse
+ }
+ class ChildcarePortalProvider {
+ XML · HTTPS 전용
+ }
+ class KindergartenInfoProvider {
+ JSON · 시군구 순회
+ }
+ class OdcloudProvider {
+ JSON · page/perPage
+ }
+ class DataGoKrProvider {
+ XML · 절대 URL 지원
+ }
+
+ PublicDataProvider <|.. ChildcarePortalProvider
+ PublicDataProvider <|.. KindergartenInfoProvider
+ PublicDataProvider <|.. OdcloudProvider
+ PublicDataProvider <|.. DataGoKrProvider
+
+ class PagedSyncTemplate {
+ 페이지 순회 · 실패 격리
+ }
+ class RegionCodeCatalog {
+ 시군구 코드 목록
+ }
+
+ PagedSyncTemplate --> PublicDataProvider
+ PagedSyncTemplate --> RegionCodeCatalog
+```
+
+`PagedSyncTemplate` 이 순회와 실패 격리를 맡고, 공급자는 **"한 번 요청해서 목록을 준다"** 만 책임집니다.
+새 데이터 소스를 붙일 때 작성할 코드가 공급자 하나로 줄어듭니다.
+
+## 각 소스에서 실제로 겪은 문제
+
+### 어린이집 — "연결 실패" 의 진짜 원인
+
+처음에는 문서에 적힌 `http://api.childcare.go.kr` 로 붙였는데 계속 연결이 되지 않았습니다.
+포트 80이 막혀 있었고 **HTTPS 로는 정상**이었습니다.
+
+또 `arcode` 에 시도 코드(2자리)를 넣으면 빈 결과가 옵니다. **5자리 시군구 코드**여야 합니다.
+
+### 어린이집 — 지역당 50건 상한
+
+실연동 결과를 검증하다 지역마다 정확히 50건에서 끊기는 것을 발견했습니다.
+페이징 파라미터가 명세에 없어서 더 가져올 방법이 없습니다.
+**개발키의 제한으로 보이며, 운영키 전환이 필요합니다.** (미해결 — 아래 참조)
+
+### 광주·전남이 두 API 모두에서 빈 결과
+
+시도 코드 29(광주)와 46(전남)은 어린이집·유치원 **양쪽 모두** 데이터를 주지 않습니다.
+우리 코드 문제가 아니라 정부 API 쪽 상태입니다.
+이 사실을 `src/main/resources/public-data/*.txt` 주석에 남겨 두었습니다.
+누군가 나중에 "왜 광주가 비었지" 를 다시 조사하지 않도록.
+
+### 병원 — 진료과목과 종별을 섞어 담고 있었다
+
+`clCdNm` 은 "상급종합", "종합병원" 같은 **요양기관 종별**인데 이걸 `type` 에 넣고 있었습니다.
+그래서 "소아과" 로 검색하면 아무것도 나오지 않았습니다.
+
+`type` 은 진료과목(소아청소년과), `grade` 는 종별로 분리했습니다. (V10)
+
+### 정책 — 지자체 지역 매핑
+
+보조금24 응답의 소관기관명은 "청주시청" 처럼 오지만 사용자 주소는 "충청북도 청주시 흥덕구" 입니다.
+그대로 비교하면 매칭되지 않아서, 기관명에서 지자체명을 추출해 정규화합니다.
+
+### 정책 — 금액 미상이 소실되던 문제
+
+지원금 설명은 자유 텍스트라 `"국공립 100,000원, 사립 280,000원"`, `"융자(연 1.5%)"` 처럼 옵니다.
+파싱에 실패한 정책을 그냥 버리면 **목록에서 사라져 사용자는 존재조차 모릅니다.**
+
+버리지 않고 금액을 `null` 로 두되, 응답에 `unknownAmountCount` 로 몇 건이 미상인지 노출합니다.
+정확한 척하는 것보다 모른다고 말하는 편이 낫습니다.
+
+## 안전 장치
+
+### XXE 차단
+
+외부 XML 을 파싱하므로 `XmlResponseParser` 에서 외부 엔티티 확장을 끕니다.
+정부 API 라고 신뢰할 이유가 없고, 중간자 공격이면 더욱 그렇습니다.
+
+### 응답 코드 해석
+
+보육통합정보시스템은 HTTP 200 으로 오면서 본문에 오류 코드를 담습니다.
+`ChildcareApiStatus` 로 한도 초과·키 만료를 구분해 **운영 알림**으로 올립니다.
+조용히 0건을 받아 "오늘은 데이터가 없네" 로 넘어가면 며칠씩 모르고 지나갑니다.
+
+### 비밀값
+
+API 키는 저장소에 넣지 않습니다. 환경변수로만 주입하고, 예외 메시지나 로그에도 남기지 않습니다.
+
+## 좌표 보정
+
+어린이집 API 는 **좌표를 주지 않습니다.** 반경 검색을 하려면 위경도가 필요합니다.
+
+```mermaid
+flowchart LR
+ A[좌표 없는 시설 조회] --> B{카카오 키가 설정됐는가}
+ B -->|없음| SKIP[건너뜀 · 로그만]
+ B -->|있음| C[주소 → 카카오 로컬 API]
+ C --> D{한반도 범위 안인가}
+ D -->|아니오| DROP[버림]
+ D -->|예| SAVE[좌표 저장]
+```
+
+카카오 응답은 `x` 가 경도, `y` 가 위도입니다. 바꿔 넣으면 전국 시설이 동해 한가운데로 갑니다.
+받은 좌표가 한반도 범위 안인지 검사하는 이유입니다.
+
+키가 없으면 **기동을 막지 않고 건너뜁니다.** 좌표 보정은 부가 기능이라
+로컬 개발자가 카카오 키 없이도 앱을 띄울 수 있어야 합니다.
+
+## 관련 API
+
+| 메서드 | 경로 | 설명 |
+|--------|------|------|
+| POST | `/api/admin/public-data/facilities/sync` | 전국 어린이집 동기화 |
+| POST | `/api/admin/public-data/kindergartens/sync` | 전국 유치원 동기화 |
+| POST | `/api/admin/public-data/benefits/sync` | 정부지원 서비스 동기화 |
+| POST | `/api/admin/public-data/hospitals/sync` | 소아청소년과 동기화 |
+| POST | `/api/admin/public-data/facilities/geocode` | 좌표 보정 |
+
+전부 `ROLE_ADMIN` 이 필요합니다.
+
+## 실연동 점검 방법
+
+```bash
+./gradlew liveSyncCheck \
+ -Dchildcare.key=... -Dkindergarten.key=... -Dodcloud.key=... -Dhpsvc.key=...
+```
+
+`@Tag("live")` 가 붙어 있어 일반 빌드에서는 제외됩니다.
+실제 정부 API 를 때리므로 CI 에서 매번 돌리면 한도를 소진합니다.
+
+## 미해결
+
+| 항목 | 내용 | 담당 |
+|------|------|------|
+| 어린이집 운영키 | 지역당 50건 상한 때문에 실제 수집량이 실제보다 적습니다 | 사용자 (키 신청) |
+| 병원 전량 수집 | 현재 2페이지에서 끊습니다. 전체 4,292곳을 받으려면 상한 해제 필요 | 설정 변경 |
+| 광주·전남 | 정부 API 가 비어 있습니다. 대체 소스 검토 필요 | 조사 필요 |
diff --git a/docs/quality/regression-safety.md b/docs/quality/regression-safety.md
new file mode 100644
index 00000000..f5ecbf79
--- /dev/null
+++ b/docs/quality/regression-safety.md
@@ -0,0 +1,187 @@
+# 회귀 방지
+
+> 관련 이슈: #72
+
+## 문제 — 왜 CI 가 못 잡았나
+
+[기동 안정화](runtime-hardening.md)에서 찾은 문제들은 전부 **CI 가 잡았어야 하는** 것들입니다.
+테이블이 없고, 접근제어가 뒤집혀 있었는데 빌드는 계속 초록불이었습니다.
+
+원인을 찾아보니 구조적이었습니다.
+
+### 원인 1 — 모든 통합 테스트가 create-drop
+
+```java
+"spring.flyway.enabled=false",
+"spring.jpa.hibernate.ddl-auto=create-drop",
+```
+
+통합 테스트 4개가 **전부** 이 설정이었습니다.
+Hibernate 가 엔티티로부터 스키마를 만들어 내니, **Flyway 마이그레이션이 아무리 어긋나도 통과합니다.**
+
+정책 북마크는 테이블 없이 API 와 리포지토리까지 있었고,
+조회수는 컬럼이 없어 저장된 적이 없었는데, 그 상태로 모든 테스트가 초록불이었습니다.
+
+**테스트가 검증한 것은 "엔티티끼리 앞뒤가 맞는가" 였지 "실제 스키마와 맞는가" 가 아니었습니다.**
+
+### 원인 2 — 접근제어를 실제로 호출해 본 적이 없다
+
+SecurityConfig 는 선언 순서에 따라 앞선 규칙이 뒤를 덮습니다.
+게다가 클래스 레벨 `@PreAuthorize` 가 URL 규칙을 다시 덮습니다.
+
+규칙 목록만 읽으면 "병원은 공개" 로 보이는데 실제로는 401 이었습니다.
+**읽어서는 알 수 없고, 호출해 봐야 압니다.**
+
+## 대응 1 — 스키마 정합성 테스트
+
+`FlywaySchemaValidationTest` 는 **운영과 같은 방식**으로 띄웁니다.
+
+```java
+"spring.flyway.enabled=true",
+"spring.jpa.hibernate.ddl-auto=validate",
+```
+
+```mermaid
+flowchart LR
+ A["Testcontainers MariaDB 10.11"] --> B["Flyway 전체 적용"]
+ B --> C["Hibernate validate"]
+ C -->|불일치| F["기동 실패 = 테스트 실패"]
+ C -->|일치| D["단언 검증"]
+
+ style F fill:#f8d7da,stroke:#dc3545
+```
+
+엔티티에 필드를 추가하고 마이그레이션을 안 쓰면 **기동 단계에서 깨집니다.**
+
+### MariaDB 컨테이너를 쓰는 이유
+
+H2 로는 이 검증이 성립하지 않습니다.
+
+- 마이그레이션에 MariaDB 전용 문법(FULLTEXT, COMMENT)이 있습니다.
+- Linux MariaDB 는 `lower_case_table_names=0` 이라 **테이블명 대소문자를 구분**합니다.
+ 이 조건이어야 대문자 마이그레이션 / 소문자 매핑 불일치가 여기서 잡힙니다.
+
+### 단언 항목
+
+| 검증 | 목적 |
+|------|------|
+| 마이그레이션 성공 15건 이상 | 전부 적용됐는가 |
+| 실패 0건 | 중간에 깨진 게 없는가 |
+| `TBL_POLICY_BOOKMARKS`, `TBL_NOTIFICATION_CHANNEL` 존재 | 뒤늦게 채운 것들의 회귀 감시 |
+
+가장 중요한 검증은 단언이 아니라 **컨텍스트가 뜬다는 사실 자체**입니다.
+`validate` 가 실패하면 단언에 도달하기 전에 죽습니다.
+
+## 대응 2 — 접근제어 계약 테스트
+
+`AccessControlContractTest` 는 규칙을 읽는 대신 **실제 응답 코드**를 확인합니다.
+
+```java
+@ParameterizedTest
+@ValueSource(strings = { "/actuator/health", "/legal/privacy-policy", "/policies",
+ "/facilities", "/health/hospitals", "/community/posts", ... })
+void publicPathsDoNotRequireLogin(String path) {
+ assertThat(status).isNotIn(401, 403);
+}
+```
+
+| 구분 | 단언 | 이유 |
+|------|------|------|
+| 공개 경로 | 401·403 이 **아님** | 데이터가 없어 404 일 수는 있어도 인증을 요구하면 안 됨 |
+| 보호 경로 | 정확히 **401** | 남의 개인정보가 걸린 경로는 뚫리면 그대로 사고 |
+
+응답 **내용**이 아니라 **인가**만 봅니다. 그래야 테스트가 기능 변경에 흔들리지 않습니다.
+
+### H2 를 쓰는 이유
+
+접근제어는 DB 방언과 무관합니다. H2 in-memory 로 돌면 **Docker 없이도** 실행되므로
+개발자가 로컬에서 항상 돌릴 수 있습니다.
+
+현재 **23개 케이스, skip 0, 전부 통과**합니다.
+
+## 대응 3 — 조용한 skip 방지
+
+Testcontainers 테스트는 `disabledWithoutDocker = true` 라
+**Docker 가 없으면 조용히 skip 되고 빌드는 초록불**이 됩니다.
+
+스키마 검증이 그렇게 빠지면 이 테스트를 만든 의미가 없습니다.
+CI 에 실행 여부 검사를 넣었습니다.
+
+```yaml
+- name: Assert schema validation actually ran
+ run: |
+ report=build/test-results/test/TEST-com.carecode.integration.FlywaySchemaValidationTest.xml
+ if [ ! -f "$report" ]; then
+ echo "::error::스키마 정합성 테스트 리포트가 없습니다."
+ exit 1
+ fi
+ if grep -q 'skipped="0"' "$report"; then
+ echo "스키마 정합성 테스트 실행 확인"
+ else
+ echo "::error::스키마 정합성 테스트가 skip 되었습니다. Docker 환경을 확인하세요."
+ exit 1
+ fi
+```
+
+CI 는 `ubuntu-latest` 라 Docker 가 있으므로 정상 실행됩니다.
+이 검사는 **환경이 바뀌어 조용히 빠지는 상황**을 막습니다.
+
+## 테스트 지형
+
+```mermaid
+flowchart TD
+ subgraph unit["단위 — Mockito"]
+ U1[알림 판단 로직]
+ U2[지원금 계산]
+ U3[예측·통계]
+ U4[소유권 검증]
+ end
+ subgraph slice["통합 — H2"]
+ S1[컨텍스트 로딩]
+ S2["접근제어 계약 23 케이스"]
+ S3[샘플 데이터 시나리오]
+ end
+ subgraph container["통합 — Testcontainers"]
+ C1["스키마 정합성 Flyway + validate"]
+ C2[MariaDB 통합]
+ end
+ subgraph live["실연동 — @Tag(live)"]
+ L1["공공데이터 실호출 일반 빌드 제외"]
+ end
+
+ unit --> slice --> container --> live
+
+ style C1 fill:#d4edda,stroke:#28a745
+ style S2 fill:#d4edda,stroke:#28a745
+```
+
+| 계층 | 도구 | Docker | CI |
+|------|------|--------|-----|
+| 단위 | JUnit 5 + Mockito | 불필요 | 항상 |
+| 통합(경량) | H2 in-memory | 불필요 | 항상 |
+| 통합(스키마) | Testcontainers MariaDB | **필요** | 항상 (실행 여부 검사) |
+| 실연동 | 실제 정부 API | 불필요 | **제외** (한도 소진) |
+
+실연동 테스트를 CI 에서 빼는 이유는, 매 커밋마다 정부 API 를 때리면
+**하루 호출 한도를 개발이 다 써버리기** 때문입니다. 필요할 때 수동으로 돕니다.
+
+```bash
+./gradlew liveSyncCheck -Dchildcare.key=... -Dkindergarten.key=...
+```
+
+## 새 기능을 추가할 때
+
+| 바꾼 것 | 해야 할 일 |
+|---------|-----------|
+| 엔티티에 필드·테이블 추가 | 마이그레이션도 작성 (안 하면 스키마 테스트가 깨뜨림) |
+| 컨트롤러에 경로 추가 | 접근제어 계약 테스트에 공개/보호 중 하나로 등록 |
+| 클래스 레벨 `@PreAuthorize` 가 있는 컨트롤러에 공개 API 추가 | 메서드에도 `@PreAuthorize("permitAll()")` 필요 |
+| 알림 발송 추가 | 중복 방지 수단 확보 (Blue/Green 에서 인스턴스가 2대가 됨) |
+
+## 알려진 한계
+
+| 항목 | 내용 |
+|------|------|
+| 로컬 Docker Desktop | 일부 환경에서 docker-java 가 Docker Desktop 29.x 에 붙지 못해 Testcontainers 테스트가 skip 됩니다. CI(ubuntu)에서는 정상입니다 |
+| 접근제어 테스트 범위 | 전 경로가 아니라 대표 경로만 담았습니다. 새 경로는 수동 등록이 필요합니다 |
+| 스케줄러 | 단위 테스트만 있고, 실제 cron 발화는 검증하지 않습니다 |
diff --git a/docs/quality/runtime-hardening.md b/docs/quality/runtime-hardening.md
new file mode 100644
index 00000000..427ae41c
--- /dev/null
+++ b/docs/quality/runtime-hardening.md
@@ -0,0 +1,194 @@
+# 기동 안정화
+
+> 관련 이슈: #70 · 관련 마이그레이션: V15
+
+## 발단
+
+기능을 여러 차례 추가하고 테스트도 전부 통과하던 시점에,
+**"실제로 실행은 되는지"** 를 확인해 보기로 했습니다.
+
+Docker 로 MariaDB 와 Redis 를 띄우고 `--spring.profiles.active=prod` 로 기동했습니다.
+
+결과: **이 애플리케이션은 한 번도 정상 기동한 적이 없었습니다.**
+
+여덟 개의 차단 요인이 **연쇄적으로** 나왔습니다. 하나를 고치면 다음 것이 나오는 식이었습니다.
+그리고 기동에 성공한 뒤에도 접근제어 결함 두 건이 더 나왔습니다.
+
+## 기동 차단 8건
+
+```mermaid
+flowchart TD
+ S([기동 시도]) --> B1["1. 로그 경로 _IS_UNDEFINED"]
+ B1 --> B2["2. ngram 파서 MariaDB 미지원"]
+ B2 --> B3["3. 테이블명 대소문자 전 엔티티 검증 실패"]
+ B3 --> B4["4. 누락 테이블 2개"]
+ B4 --> B5["5. 누락 컬럼 view_count"]
+ B5 --> B6["6. 죽은 매핑 HealthRecordType"]
+ B6 --> B7["7. 기동 러너 소문자 네이티브 SQL"]
+ B7 --> B8["8. 헬스체크 503 메일 실패"]
+ B8 --> OK([기동 성공])
+
+ style S fill:#f8d7da,stroke:#dc3545
+ style OK fill:#d4edda,stroke:#28a745
+```
+
+### 1. Logback 기본값 문법
+
+```xml
+
+${LOG_FILE:/var/log/carecode/application.log}
+
+
+${LOG_FILE:-/var/log/carecode/application.log}
+```
+
+Logback 은 `${VAR:-기본값}` 입니다. Spring 의 `${VAR:기본값}` 을 쓰면
+변수가 없을 때 경로가 `..._IS_UNDEFINED` 가 되어 **기동 자체가 실패**합니다.
+
+두 문법이 비슷해서 눈으로는 잘 안 보입니다.
+
+### 2. ngram 파서는 MySQL 전용
+
+```sql
+-- 실패: Function 'ngram' is not defined
+CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS) WITH PARSER ngram;
+```
+
+MariaDB 에는 `ngram` 파서가 없습니다. 마이그레이션 V4 가 실패하고 기동이 멈춥니다.
+
+파서를 제거했습니다. MariaDB 내장 토크나이저는 공백 단위라 "행복어린이집" 같은 붙은 말은
+부분 일치가 안 되지만, `FullTextSearchSupport` 가 짧은 키워드를 **LIKE 로 폴백**하므로
+검색 자체는 동작합니다.
+
+### 3. 테이블명 대소문자 불일치
+
+Spring Boot 기본 네이밍 전략(`CamelCaseToUnderscoresNamingStrategy`)은 테이블 이름을 **소문자로** 바꿉니다.
+그런데 마이그레이션은 `TBL_USER` 처럼 대문자로 만듭니다.
+
+Linux MariaDB 는 `lower_case_table_names=0` 이라 대소문자를 구분하므로 **전 엔티티가 검증 실패**합니다.
+(개발자 Windows 환경에서는 대소문자를 구분하지 않아 드러나지 않았습니다.)
+
+반대로 이름을 전부 그대로 쓰면 `@Column` 없이 선언된 필드가 camelCase 로 남아 또 어긋납니다.
+
+```java
+public class CareCodeNamingStrategy extends CamelCaseToUnderscoresNamingStrategy {
+ @Override
+ public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) {
+ return name; // @Table 로 선언한 이름은 손대지 않는다
+ }
+ // 컬럼은 기존처럼 snake_case 로 변환
+}
+```
+
+### 4~5. DDL 에 없던 테이블과 컬럼
+
+| 대상 | 상태 |
+|------|------|
+| `TBL_POLICY_BOOKMARKS` | **API 와 리포지토리까지 있는데 테이블이 없었습니다.** 한 번도 동작한 적이 없습니다 |
+| `TBL_NOTIFICATION_CHANNEL` | 엔티티만 있고 테이블 없음 |
+| `TBL_POLICIES.VIEW_COUNT` | 엔티티와 서비스는 쓰는데 컬럼이 없어 **조회수가 저장된 적이 없습니다** |
+
+정책 북마크는 특히 나쁩니다. 컨트롤러·서비스·리포지토리가 다 있으니 **코드만 보면 완성된 기능**입니다.
+호출하면 그때 터집니다.
+
+이게 [회귀 방지 문서](regression-safety.md)를 쓰게 된 직접적인 계기입니다.
+
+### 6. 참조 0건인 죽은 매핑
+
+`HealthRecord` 가 `HealthRecordType` 을 `@ManyToOne` 으로 물고 있었는데,
+**테이블이 존재한 적도 없고 코드에서 쓰는 곳도 없었습니다.** 매핑만 남아 검증을 막고 있었습니다.
+
+엔티티와 매핑을 함께 제거했습니다.
+
+### 7. 허구 데이터를 만들던 기동 러너
+
+`CareFacilityDataMigrationService` 가 `CommandLineRunner` 로 **매 기동마다** 실행되면서
+병원 데이터를 어린이집 테이블로 복사하고 있었습니다.
+
+```java
+.capacity(50) // 기본값
+.rating(4.5) // 기본 평점
+.facilityCode("CF" + System.currentTimeMillis() + (int)(Math.random() * 1000))
+```
+
+**정원 50, 평점 4.5는 지어낸 값**입니다. 초기 스캐폴딩 시절의 흔적인데,
+지금은 전국 어린이집·유치원 동기화가 실데이터를 채우므로 **데이터를 오염시키기만** 합니다.
+
+게다가 소문자 네이티브 SQL(`SELECT * FROM tbl_hospital`)을 써서 기동도 실패시켰습니다.
+
+삭제했습니다. 다른 초기화 러너들은 전부 `@Profile("dev")` 로 막혀 있어 prod 에서 도는 건 이것뿐이었습니다.
+
+### 8. 메일 헬스체크가 서비스 전체를 내린다
+
+SMTP 인증에 실패하면 `/actuator/health` 가 503 을 반환합니다.
+로드밸런서와 k8s 가 이걸 보고 **멀쩡한 인스턴스를 내려버립니다.**
+
+메일은 부가 기능이라 헬스체크에서 분리했습니다. 자세한 내용은 [운영 문서](../features/operations.md#헬스체크)에.
+
+## 기동 이후 발견한 접근제어 결함
+
+### 병원 조회가 전부 로그인 필수였다
+
+병원 API 의 실제 경로는 `/health/hospitals/**` 인데,
+SecurityConfig 의 공개 규칙은 **존재하지 않는 경로**에 걸려 있었습니다.
+
+```java
+.requestMatchers("/health/**").authenticated() // ← 이게 먼저 잡는다
+.requestMatchers("/hospitals").permitAll() // ← 이 경로는 없다
+.requestMatchers("/hospitals/search").permitAll() // ← 죽은 규칙
+```
+
+SecurityConfig 는 **앞선 규칙이 뒤를 덮습니다.** `/health/**` 가 전부 잡아버려서
+병원 목록·검색·리뷰 조회가 통째로 로그인 필수였습니다.
+
+규칙만 읽으면 "병원은 공개" 로 보입니다. **실제로 호출해 보기 전에는 알 수 없습니다.**
+
+### 클래스 레벨 @PreAuthorize 가 URL 규칙을 덮는다
+
+URL 규칙을 고쳤는데도 500 이 났습니다.
+
+```java
+@RestController
+@RequestMapping("/health")
+@PreAuthorize("isAuthenticated()") // ← 클래스 전체에 적용
+public class HealthController {
+```
+
+URL 규칙을 통과해도 메서드 진입 시 다시 막힙니다.
+공개해야 할 GET 7개에 `@PreAuthorize("permitAll()")` 를 붙여야 실제로 열립니다.
+
+**두 겹의 접근제어가 서로 다른 말을 하고 있었습니다.**
+
+### 404·403 이 500 으로 새며 운영 알림을 울렸다
+
+없는 URL 요청이 정적 리소스 핸들러까지 흘러가 `NoResourceFoundException` 이 되고,
+최후 예외 핸들러가 이걸 잡아 **500 + 운영 알림**으로 처리했습니다.
+
+`@PreAuthorize` 거부(`AuthorizationDeniedException`)도 마찬가지였습니다.
+
+둘 다 장애가 아닙니다. 이걸 알리면 진짜 장애가 소음에 묻힙니다.
+전용 핸들러를 추가해 각각 404·403 으로 응답하고 알림을 보내지 않게 했습니다.
+
+## 검증 결과
+
+prod 프로파일 + 실제 MariaDB·Redis 컨테이너.
+
+| 항목 | 결과 |
+|------|------|
+| 기동 시간 | 약 16~22초 |
+| 마이그레이션 | 17건 적용, 0건 실패 |
+| 공개 경로 | `/actuator/health`, `/legal/*`, `/policies*`, `/facilities*`, `/health/hospitals*`, `/community/*` → **200** |
+| 보호 경로 | `/policies/recommendations`, `/health/records/*`, `/notifications`, 관리자 경로 → **401** |
+| `./gradlew clean build` | 통과 |
+
+## 교훈
+
+이 작업에서 얻은 것은 개별 버그 수정이 아니라 **테스트가 무엇을 증명하지 못하는지에 대한 이해**입니다.
+
+- 모든 테스트가 초록불이어도 **애플리케이션은 기동조차 못할 수 있습니다.**
+- 컨트롤러·서비스·리포지토리가 다 있어도 **테이블이 없으면 기능은 존재하지 않습니다.**
+- 접근제어는 **규칙을 읽어서가 아니라 호출해 봐야** 알 수 있습니다.
+- 개발자 환경(Windows, 대소문자 무시)과 운영 환경(Linux, 대소문자 구분)의 차이가
+ **전 엔티티 검증 실패** 같은 큰 문제로 나타날 수 있습니다.
+
+이 이해를 코드로 옮긴 것이 [회귀 방지](regression-safety.md)입니다.
diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md
new file mode 100644
index 00000000..74816ffe
--- /dev/null
+++ b/docs/reference/access-control-matrix.md
@@ -0,0 +1,200 @@
+# 접근제어 매트릭스
+
+## 왜 이 문서가 필요한가
+
+SecurityConfig 는 **앞선 규칙이 뒤를 덮습니다.** 게다가 클래스 레벨 `@PreAuthorize` 가
+URL 규칙을 다시 덮습니다. 두 겹이 서로 다른 말을 할 수 있습니다.
+
+실제로 병원 조회는 공개 규칙이 선언돼 있는데도 **전부 로그인 필수**였습니다.
+규칙 목록만 읽어서는 알 수 없었고, 호출해 보고서야 드러났습니다.
+
+그래서 이 문서와 [접근제어 계약 테스트](../quality/regression-safety.md#대응-2--접근제어-계약-테스트)를
+함께 둡니다. 문서는 의도를, 테스트는 사실을 기록합니다.
+
+## 두 겹의 접근제어
+
+```mermaid
+flowchart TD
+ REQ[요청] --> URL{SecurityConfig URL 규칙}
+ URL -->|거부| E401[401]
+ URL -->|통과| PRE{메서드 @PreAuthorize}
+ PRE -->|거부| E403[403]
+ PRE -->|통과| CTRL[컨트롤러 진입]
+ CTRL --> CONSENT{ConsentGuard 민감정보}
+ CONSENT -->|미동의| E403C["403 CONSENT_REQUIRED"]
+ CONSENT -->|동의| OK[처리]
+
+ style E401 fill:#f8d7da,stroke:#dc3545
+ style E403 fill:#f8d7da,stroke:#dc3545
+ style E403C fill:#fff3cd,stroke:#ffc107
+ style OK fill:#d4edda,stroke:#28a745
+```
+
+**URL 규칙만 열어서는 부족합니다.** 클래스에 `@PreAuthorize("isAuthenticated()")` 가 붙어 있으면
+메서드에도 `@PreAuthorize("permitAll()")` 를 붙여야 실제로 열립니다.
+
+## 공개 경로 (로그인 불필요)
+
+### 시스템·문서
+
+| 경로 | 비고 |
+|------|------|
+| `/actuator/health`, `/actuator/info`, `/actuator/prometheus` | 로드밸런서·모니터링 |
+| `/legal/privacy-policy`, `/legal/terms`, `/legal/version` | **동의하기 전에 읽어야 하므로 공개** |
+| `/`, `/error`, `/favicon.ico` | — |
+| `/css/**`, `/js/**`, `/images/**`, `/static/**` | 정적 리소스 |
+
+### 인증 흐름
+
+| 경로 | 비고 |
+|------|------|
+| `/auth/login`, `/auth/register` | — |
+| `/auth/refresh` | 토큰 갱신 |
+| `/auth/kakao/login`, `/auth/kakao/login-url`, `/auth/kakao/complete-registration` | 카카오 |
+| `/oauth2/**` | — |
+| `/users/send-code`, `/users/verify-code`, `/users/verify` | 이메일 인증 |
+
+### 지원금
+
+| 경로 | 비고 |
+|------|------|
+| `/policies` | 목록 |
+| `/policies/search` | 검색 |
+| `/policies/categories` | 분류 |
+| `/policies/statistics` | 통계 |
+| `/policies/{id}` | 상세 |
+
+### 시설
+
+| 경로 | 비고 |
+|------|------|
+| `/facilities` | 목록 |
+| `/facilities/type/**`, `/facilities/location/**`, `/facilities/age` | 조건별 조회 |
+| `/facilities/operating-hours`, `/facilities/radius` | — |
+| `/facilities/popular`, `/facilities/new` | — |
+| `/facilities/statistics` | — |
+| `/facilities/{id}/view` | 조회수 증가 |
+| `/facilities/{id}/rating` (GET) | 평점 조회 |
+| `/api/public/care-facilities/**` | 공공데이터 조회 |
+
+### 병원
+
+**실제 경로는 `/health/hospitals/**` 입니다.** `/hospitals/**` 가 아닙니다.
+`/health/**` → `authenticated()` 보다 **먼저** 선언해야 합니다.
+
+| 경로 | 메서드 | 비고 |
+|------|--------|------|
+| `/health/hospitals` | GET | 목록 |
+| `/health/hospitals/{id}` | GET | 상세 |
+| `/health/hospitals/nearby` | GET | 반경 검색 |
+| `/health/hospitals/popular` | GET | 인기 |
+| `/health/hospitals/type/{type}` | GET | 진료과목별 |
+| `/health/hospitals/{id}/reviews` | GET | 리뷰 조회 |
+| `/health/hospitals/{id}/likes` | GET | 좋아요 수 |
+
+> 위 7개는 URL 규칙과 **메서드 `@PreAuthorize("permitAll()")` 둘 다** 필요합니다.
+> `HealthController` 에 클래스 레벨 `@PreAuthorize("isAuthenticated()")` 가 있기 때문입니다.
+
+### 커뮤니티 (GET 만)
+
+| 경로 |
+|------|
+| `/community/posts`, `/community/posts/{id}`, `/community/posts/{id}/comments` |
+| `/community/search`, `/community/search/all` |
+| `/community/popular`, `/community/popular/limit` |
+| `/community/latest`, `/community/latest/limit` |
+| `/community/tags`, `/community/tags/**` |
+
+## 인증 필요
+
+### 개인화 — 남의 정보가 걸린 경로
+
+| 경로 | 이유 |
+|------|------|
+| `/policies/recommendations` | 자녀·주소·소득 기반 |
+| `/policies/missed-benefits` | 동일 |
+| `/policies/regional-comparison` | 동일 |
+| `/policies/bookmarks`, `/policies/{id}/bookmarks` | 내 북마크 |
+| `POST /policies/{id}/amount-reports` | 제보자 식별 |
+
+### 건강 — 민감정보
+
+| 경로 | 이유 |
+|------|------|
+| `/health/**` (병원 공개 조회 제외) | 건강기록은 민감정보 |
+| `/health/records/**` | 소유권을 서비스 계층에서 다시 검증 |
+| `/health/hospitals/{id}/like-status` | **"내" 좋아요 여부라 공개 조회와 구분** |
+
+### 그 외
+
+| 경로 | 비고 |
+|------|------|
+| `/auth/user/**`, `/auth/logout` | — |
+| `/users/privacy/**` | 열람·동의·탈퇴 |
+| `/children/**` | 자녀 정보 |
+| `/notifications/**` | — |
+| `/facilities/search` | 개인화 검색 |
+| `/facilities/{id}/bookings/**` | 예약 |
+| `/facilities/waitlist/**`, `POST /facilities/{facilityId}/waitlist` | 대기 등록 |
+| `/community/comments/**` | 댓글 작성·수정 |
+| `POST /facilities/{id}/rating` | 평점 등록 |
+| `POST/DELETE /health/hospitals/{id}/like` | 좋아요 등록·해제 |
+| `/api/**` | **기본 정책** — 명시하지 않은 `/api` 경로는 인증 필요 |
+
+### 최종 규칙
+
+```java
+.anyRequest().authenticated()
+```
+
+명시하지 않은 모든 경로는 인증이 필요합니다.
+새 컨트롤러를 만들고 규칙을 빠뜨리면 **닫힌 채로 시작**합니다. 안전한 기본값입니다.
+
+## 관리자 전용
+
+`ROLE_ADMIN` 이 필요합니다.
+
+| 경로 | 용도 |
+|------|------|
+| `/api/admin/**` | 전체 |
+| `/api/admin/public-data/*/sync` | 수동 동기화 |
+| `/api/admin/public-data/facilities/geocode` | 좌표 보정 |
+| `/api/admin/public-data/facilities/notify-vacancy` | 빈자리 알림 실행 |
+| `/api/admin/public-data/policies/notify-deadline` | 마감 알림 실행 |
+| `/api/admin/analytics/**` | 퍼널·리텐션 |
+| `/api/admin/policy-verification/**` | 금액 수기 검증 |
+| `/api/admin/reports/**` | 신고 처리 |
+
+## 프로파일별 차이
+
+| 경로 | dev / docker | prod |
+|------|--------------|------|
+| `/swagger-ui/**`, `/v3/api-docs/**` | 공개 | **차단(401)** |
+| `/*.html` | 공개 | 차단 |
+| `/kakao-test.html`, `/kakao-debug.html` | 공개 | 차단 |
+
+운영에서 API 문서를 열어두면 공격 표면을 그대로 알려주는 셈입니다.
+
+## 오류 응답
+
+| 상황 | 상태 | 본문 |
+|------|------|------|
+| 미인증 | 401 | `{"error":"Unauthorized","message":"Authentication required"}` |
+| 권한 없음 | 403 | `{"code":"C003","message":"접근 권한이 없습니다"}` |
+| 동의 필요 | 403 | `{"error":"CONSENT_REQUIRED","consentType":"...","displayName":"..."}` |
+| 없는 경로 | 404 | `{"code":"C004","message":"요청하신 경로를 찾을 수 없습니다"}` |
+| 서버 오류 | 500 | `{"code":"C000","message":"서버 내부 오류가 발생했습니다"}` + 운영 알림 |
+
+모든 오류 응답에는 `traceId` 가 함께 담기고, 응답 헤더 `X-Request-Id` 로도 나갑니다.
+사용자가 알려준 ID 하나로 로그를 바로 찾을 수 있습니다.
+
+**404·403 은 운영 알림을 보내지 않습니다.** 장애가 아니기 때문입니다.
+초기에는 봇이 없는 URL 을 긁을 때마다 알림이 울렸고, 그러면 진짜 장애가 소음에 묻힙니다.
+
+## 경로를 추가할 때
+
+1. SecurityConfig 에 규칙을 넣습니다. **와일드카드보다 구체적인 경로를 먼저** 선언합니다.
+2. 클래스 레벨 `@PreAuthorize` 가 있는 컨트롤러라면 메서드에도 붙입니다.
+3. `AccessControlContractTest` 에 공개/보호 중 하나로 등록합니다.
+
+3번을 빠뜨리면 다음에 누가 규칙 순서를 바꿨을 때 아무도 모릅니다.
diff --git a/docs/reference/database-migrations.md b/docs/reference/database-migrations.md
new file mode 100644
index 00000000..482d0439
--- /dev/null
+++ b/docs/reference/database-migrations.md
@@ -0,0 +1,135 @@
+# 데이터베이스 마이그레이션
+
+운영은 `ddl-auto=validate` 입니다. **스키마는 Flyway 만 바꿉니다.**
+엔티티를 고치고 마이그레이션을 안 쓰면 [스키마 정합성 테스트](../quality/regression-safety.md)가
+기동 단계에서 깨뜨립니다.
+
+## 전체 목록
+
+| 버전 | 파일 | 무엇을 | 왜 |
+|------|------|--------|-----|
+| V1 | `baseline` | 기본 스키마 전체 | 초기 구축 |
+| V2 | `feature_tables` | 예방접종 일정, 동의, 신고, 차단 | 아이 건강·커뮤니티 모더레이션 |
+| V3 | `hospital_external_code` | 병원 외부 식별자(요양기호) | 심평원 데이터와 매칭할 자연키 |
+| V4 | `search_indexes` | 위치·전문 검색 인덱스 | 반경 검색이 전 행에 삼각함수를 돌리는 풀스캔이었음 |
+| V5 | `facility_capacity_snapshot` | 정원·현원 시계열 | 덮어쓰면 관측 이력이 사라져 예측 불가 |
+| V6 | `benefit_eligibility` | 소득·다자녀·소급 조건 | 연령·지역만으로는 수급 가능 여부를 못 가림 |
+| V7 | `benefit_payment_duration` | 지급 기간 상한 | **대상 연령을 지급 기간으로 착각해 총액이 폭증** |
+| V8 | `user_events` | 행동 이벤트 원본 | 전환율·리텐션을 사후 계산하려면 원본이 필요 |
+| V9 | `policy_verification` | 금액 검증 이력 | 틀린 금액을 확정치처럼 보이면 분쟁이 됨 |
+| V10 | `hospital_grade` | 요양기관 종별 분리 | `type` 에 종별이 들어가 "소아과" 검색이 안 됐음 |
+| V11 | `policy_change_log` | 정책 변경 이력 | 덮어쓰면 "무엇이 바뀌었는지" 가 사라져 알림 불가 |
+| V12 | `benefit_amount_report` | 실수령액 제보 | 공공데이터가 금액을 숫자로 주지 않음 |
+| V13 | `facility_waitlist` | 대기 기록 | **공공데이터에 존재하지 않는 데이터** — 사용자에게서만 얻음 |
+| V14 | `policy_exclusion_group` | 중복 수급 배타 그룹 | 부모급여와 양육수당을 합산해 총액이 부풀려짐 |
+| V15 | `missing_entity_tables` | 누락 테이블·컬럼 보충 | **엔티티는 있는데 DDL 에 없어 기동 실패** |
+| V16 | `waitlist_vacancy_notice` | 빈자리 알림 발송 이력 | 같은 자리를 반복 알리면 신뢰를 잃음 |
+| V17 | `policy_deadline_notice` | 마감 알림 발송 이력 | **Blue/Green 에서 인스턴스가 2대가 되면 중복 발송** |
+| V18 | `notification_email_default` | 이메일 알림 DDL 기본값 | 엔티티는 `false` 인데 DDL 이 `TRUE` 라 JPA 를 안 거치면 켜짐 |
+
+## 특히 기억할 것들
+
+### V7 — 총액이 3.6배 부풀려졌던 원인
+
+`targetAgeMin`/`targetAgeMax` 는 **"어떤 아이가 대상인가"** 이지 **"몇 개월 받는가"** 가 아닙니다.
+
+이걸 지급 기간으로 쓰는 바람에 아빠육아휴직보너스 250만 원 × 60개월 = **1억 5천만 원**이
+한 사람의 예상 수령액에 들어갔습니다.
+
+`max_payment_months` 를 분리해 2억 9,506만 원 → 8,056만 원이 되었습니다.
+자세한 내용은 [지원금 지능화](../features/benefit-intelligence.md#수령액-계산--두-번의-큰-오류)에.
+
+### V13 — 공공데이터에 없는 데이터
+
+대기 기록은 정부가 공개하지 않습니다. **사용자에게서만 얻을 수 있습니다.**
+
+정원 관측은 "자리가 났는가" 만 알려주고, "실제로 얼마나 기다렸는지" 는 겪은 사람만 압니다.
+이런 데이터가 쌓일수록 공공데이터만으로는 만들 수 없는 것을 할 수 있게 됩니다.
+
+### V15 — 있는 줄 알았던 테이블
+
+`TBL_POLICY_BOOKMARKS` 는 **컨트롤러·서비스·리포지토리가 다 있는데 테이블이 없었습니다.**
+코드만 보면 완성된 기능이라 아무도 의심하지 않았고, 모든 테스트가 통과했습니다.
+
+`TBL_POLICIES.VIEW_COUNT` 도 마찬가지로 코드는 쓰는데 컬럼이 없어 조회수가 저장된 적이 없습니다.
+
+이 마이그레이션이 [회귀 방지](../quality/regression-safety.md)를 만들게 된 직접적인 계기입니다.
+
+### V17 — 유니크 제약이 하는 일
+
+```sql
+CONSTRAINT UK_POLICY_DEADLINE_NOTICE UNIQUE (POLICY_ID, USER_ID, NOTIFIED_ON)
+```
+
+"남은 일수가 D-7 인 날에만 보낸다" 는 규칙은 **하루에 한 번 실행될 때만** 성립합니다.
+Blue/Green 배포로 인스턴스가 잠깐 2대가 되면 모든 알림이 두 번 나갑니다.
+
+존재 확인은 반복 실행을, 유니크 제약은 동시 실행을 막습니다.
+
+### V18 — 엔티티만 고치면 절반만 고친 것
+
+이메일 알림 기본값 버그를 엔티티에서 `false` 로 고쳤는데 DDL 은 `DEFAULT TRUE` 로 남아 있었습니다.
+
+JPA 는 값을 항상 명시해서 쓰기 때문에 **앱을 거치는 생성은 정상**입니다.
+그래서 테스트도 통과하고 실사용에서도 드러나지 않습니다.
+
+문제는 **시드 스크립트나 수기 SQL** 처럼 JPA 를 거치지 않는 삽입입니다.
+그 경로로는 여전히 요청한 적 없는 이메일 알림이 켜진 채 행이 만들어집니다.
+
+기본값을 바꾸는 엔티티 변경은 **DDL 기본값도 함께 봐야 합니다.**
+
+## 작성 규칙
+
+### MariaDB 문법만 사용
+
+MySQL 전용 문법은 실패합니다. 실제로 V4 의 `WITH PARSER ngram` 이 기동을 막았습니다.
+
+```sql
+-- 실패: Function 'ngram' is not defined
+CREATE FULLTEXT INDEX ... WITH PARSER ngram;
+```
+
+### 테이블명은 대문자
+
+Linux MariaDB 는 `lower_case_table_names=0` 이라 대소문자를 구분합니다.
+`CareCodeNamingStrategy` 가 `@Table` 이름을 그대로 쓰도록 하므로 **엔티티와 정확히 일치**해야 합니다.
+
+Windows 개발 환경에서는 대소문자를 구분하지 않아 이 문제가 드러나지 않습니다.
+스키마 정합성 테스트가 Linux 컨테이너를 쓰는 이유입니다.
+
+### 주석에 "왜" 를 남긴다
+
+```sql
+-- 지급 기간 상한. targetAgeMin/Max 는 "어떤 아이가 대상인가" 이지 "몇 개월 받는가" 가 아니다
+```
+
+무엇을 추가하는지는 SQL 이 말해 줍니다. 주석은 **왜 필요했는지**를 남깁니다.
+6개월 뒤에 이 컬럼을 지워도 되는지 판단할 사람에게 필요한 건 그 정보입니다.
+
+### 적용된 마이그레이션은 수정하지 않는다
+
+Flyway 체크섬이 어긋나 기동이 실패합니다. 새 버전을 추가하세요.
+
+## 검증
+
+```bash
+# 스키마 정합성 (Testcontainers MariaDB 필요)
+./gradlew test --tests "*FlywaySchemaValidationTest*"
+```
+
+로컬에서 직접 확인하려면:
+
+```bash
+docker run -d --name cc-db -e MARIADB_ROOT_PASSWORD=pw -e MARIADB_DATABASE=carecode \
+ -p 13306:3306 mariadb:10.11
+docker run -d --name cc-redis -p 16379:6379 redis:7-alpine
+
+java -jar build/libs/carecode-app.jar --spring.profiles.active=prod \
+ --spring.datasource.url='jdbc:mariadb://localhost:13306/carecode' \
+ --spring.datasource.username=root --spring.datasource.password=pw \
+ --spring.data.redis.host=localhost --spring.data.redis.port=16379 \
+ --spring.flyway.enabled=true
+```
+
+`Started CareCodeApplication` 이 나오면 마이그레이션과 엔티티가 일치하는 것입니다.
+`validate` 는 불일치가 있으면 그 전에 죽습니다.
diff --git a/src/main/java/com/carecode/core/RateLimitInterceptor.java b/src/main/java/com/carecode/core/RateLimitInterceptor.java
index 308b0097..d0c3d643 100644
--- a/src/main/java/com/carecode/core/RateLimitInterceptor.java
+++ b/src/main/java/com/carecode/core/RateLimitInterceptor.java
@@ -15,16 +15,7 @@
import java.time.Duration;
-/**
- * Rate Limiting 인터셉터
- *
- * - 인증된 사용자: userId 기반 분당 300회 (NAT/공유 IP 환경 대응)
- * - 미인증 요청: IP 기반 분당 120회
- * - 민감 공개 API(/auth/signup 등): IP 기반 분당 30회
- *
- * 학교 환경처럼 다수 사용자가 동일 공인 IP를 쓰는 경우
- * IP 기반 단일 제한은 오탐이 많아 인증 여부로 키를 분리합니다.
- */
+/** Rate Limiting 인터셉터 - 인증된 사용자: userId 기반 분당 300회 (NAT/공유 IP 환경 대응) - 미인증 요청: IP 기반 분당 120회 - 민감 */
@Component
@Slf4j
@RequiredArgsConstructor
@@ -88,9 +79,7 @@ private boolean checkLimit(String keyBody, int limit, HttpServletResponse respon
return true;
}
- /**
- * SecurityContext에서 인증된 사용자 ID 추출. 미인증이면 null.
- */
+ /** SecurityContext에서 인증된 사용자 ID 추출. 미인증이면 null. */
private String resolveUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
@@ -99,17 +88,12 @@ private String resolveUserId() {
return auth.getName();
}
- /**
- * 클라이언트 IP 추출.
- * 프록시 헤더 신뢰 여부는 {@link ClientIpResolver} 가 설정에 따라 판단한다.
- */
+ /** 클라이언트 IP 추출. 프록시 헤더 신뢰 여부는 ClientIpResolver 가 설정에 따라 판단한다. */
private String getClientIp(HttpServletRequest request) {
return clientIpResolver.resolve(request);
}
- /**
- * 공개 API 중 민감한 엔드포인트 (낮은 rate limit 적용)
- */
+ /** 공개 API 중 민감한 엔드포인트 (낮은 rate limit 적용) */
private boolean isPublicSensitiveEndpoint(String path) {
return path.startsWith("/api/v1/contact") ||
path.startsWith("/api/v1/auth/signup");
diff --git a/src/main/java/com/carecode/core/analytics/AnalyticsService.java b/src/main/java/com/carecode/core/analytics/AnalyticsService.java
new file mode 100644
index 00000000..6f8efeb4
--- /dev/null
+++ b/src/main/java/com/carecode/core/analytics/AnalyticsService.java
@@ -0,0 +1,117 @@
+package com.carecode.core.analytics;
+
+import com.carecode.core.analytics.dto.FunnelResponse;
+import com.carecode.core.analytics.dto.RetentionResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** 수집한 이벤트로 퍼널과 리텐션을 계산한다. */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AnalyticsService {
+
+ /** 온보딩부터 핵심 가치까지의 경로. 순서가 곧 퍼널이다. */
+ private static final List FUNNEL = List.of(
+ new StepDef(EventType.SIGNED_UP, "가입"),
+ new StepDef(EventType.CHILD_REGISTERED, "자녀 등록"),
+ new StepDef(EventType.MISSED_BENEFIT_VIEWED, "놓친 지원금 확인"),
+ new StepDef(EventType.BENEFIT_LINK_CLICKED, "신청 링크 클릭"));
+
+ /** 알림이 실제로 사람을 돌아오게 하는지. 리텐션의 핵심 지표다. */
+ private static final List NOTIFICATION_FUNNEL = List.of(
+ new StepDef(EventType.NOTIFICATION_SENT, "알림 발송"),
+ new StepDef(EventType.NOTIFICATION_CLICKED, "알림 클릭"),
+ new StepDef(EventType.BENEFIT_LINK_CLICKED, "신청 링크 클릭"));
+
+ private static final int MAX_COHORT_DAYS = 60;
+
+ private final UserEventRepository eventRepository;
+
+ private record StepDef(EventType type, String label) {
+ }
+
+ public FunnelResponse funnel(LocalDate from, LocalDate to) {
+ return buildFunnel(FUNNEL, from, to);
+ }
+
+ /** 알림 → 재방문 전환. 이 값이 낮으면 알림 내용이나 시점을 바꿔야 한다. */
+ public FunnelResponse notificationFunnel(LocalDate from, LocalDate to) {
+ return buildFunnel(NOTIFICATION_FUNNEL, from, to);
+ }
+
+ private FunnelResponse buildFunnel(List definition, LocalDate from, LocalDate to) {
+ List steps = new ArrayList<>();
+ long previous = 0;
+
+ for (int i = 0; i < definition.size(); i++) {
+ StepDef def = definition.get(i);
+ // 두 번째 단계부터는 앞 단계를 거친 사용자만 센다. 그래야 전환율이 의미를 갖는다.
+ long users = i == 0
+ ? eventRepository.countDistinctUsers(def.type(), from, to)
+ : eventRepository.countConverted(definition.get(i - 1).type(), def.type(), from, to);
+
+ steps.add(FunnelResponse.Step.builder()
+ .event(def.type().name())
+ .label(def.label())
+ .users(users)
+ .conversionRate(i == 0 ? null : percentage(users, previous))
+ .build());
+ previous = users;
+ }
+
+ return FunnelResponse.builder().from(from).to(to).steps(steps).build();
+ }
+
+ public RetentionResponse retention(LocalDate from, LocalDate to) {
+ LocalDate start = from.isBefore(to.minusDays(MAX_COHORT_DAYS)) ? to.minusDays(MAX_COHORT_DAYS) : from;
+ LocalDate today = LocalDate.now();
+ List cohorts = new ArrayList<>();
+
+ for (LocalDate date = start; !date.isAfter(to); date = date.plusDays(1)) {
+ List signedUp = eventRepository.findUserIdsSignedUpOn(date);
+ if (signedUp.isEmpty()) {
+ continue;
+ }
+ cohorts.add(RetentionResponse.Cohort.builder()
+ .signUpDate(date)
+ .signedUp(signedUp.size())
+ .day1(retentionAt(signedUp, date, 1, today))
+ .day7(retentionAt(signedUp, date, 7, today))
+ .day30(retentionAt(signedUp, date, 30, today))
+ .build());
+ }
+ return RetentionResponse.builder().cohorts(cohorts).build();
+ }
+
+ /** 아직 그날이 오지 않은 코호트는 0% 가 아니라 미집계다. 구분하지 않으면 지표가 왜곡된다. */
+ private Integer retentionAt(List userIds, LocalDate signUpDate, int offset, LocalDate today) {
+ LocalDate target = signUpDate.plusDays(offset);
+ if (target.isAfter(today)) {
+ return null;
+ }
+ return percentage(eventRepository.countActiveOn(userIds, target), userIds.size());
+ }
+
+ /** 이벤트 종류별 발생 건수. 대시보드 개요용. */
+ public Map eventCounts(LocalDate from, LocalDate to) {
+ Map counts = new LinkedHashMap<>();
+ for (Object[] row : eventRepository.countByType(from, to)) {
+ counts.put(((EventType) row[0]).name(), (Long) row[1]);
+ }
+ return counts;
+ }
+
+ private Integer percentage(long part, long whole) {
+ return whole == 0 ? 0 : (int) Math.round(100.0 * part / whole);
+ }
+}
diff --git a/src/main/java/com/carecode/core/analytics/EventLogger.java b/src/main/java/com/carecode/core/analytics/EventLogger.java
new file mode 100644
index 00000000..763589e2
--- /dev/null
+++ b/src/main/java/com/carecode/core/analytics/EventLogger.java
@@ -0,0 +1,53 @@
+package com.carecode.core.analytics;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+/** 행동 이벤트 기록. 지표 수집 실패가 기능을 막지 않도록 비동기로 처리하고 예외를 삼킨다. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class EventLogger {
+
+ private static final int MAX_METADATA = 500;
+
+ private final UserEventRepository eventRepository;
+
+ public void log(EventType type, Long userId) {
+ log(type, userId, null, null);
+ }
+
+ public void log(EventType type, Long userId, String targetId) {
+ log(type, userId, targetId, null);
+ }
+
+ /** 호출부의 트랜잭션과 분리한다. 이벤트 저장 실패가 본 작업을 롤백시키면 안 된다. */
+ @Async("analyticsExecutor")
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void log(EventType type, Long userId, String targetId, String metadata) {
+ try {
+ eventRepository.save(UserEvent.builder()
+ .userId(userId)
+ .eventType(type)
+ .targetId(truncate(targetId, 100))
+ .metadata(truncate(metadata, MAX_METADATA))
+ .occurredAt(LocalDateTime.now())
+ .build());
+ } catch (Exception e) {
+ log.warn("이벤트 기록 실패 - type={}, 사유={}", type, e.getMessage());
+ }
+ }
+
+ private String truncate(String value, int limit) {
+ if (value == null) {
+ return null;
+ }
+ return value.length() <= limit ? value : value.substring(0, limit);
+ }
+}
diff --git a/src/main/java/com/carecode/core/analytics/EventType.java b/src/main/java/com/carecode/core/analytics/EventType.java
new file mode 100644
index 00000000..f6975b3d
--- /dev/null
+++ b/src/main/java/com/carecode/core/analytics/EventType.java
@@ -0,0 +1,33 @@
+package com.carecode.core.analytics;
+
+/** 추적 대상 이벤트. 제품 판단에 쓰이는 것만 남긴다 — 다 찍으면 아무것도 안 보인다. */
+public enum EventType {
+
+ // 온보딩 퍼널
+ SIGNED_UP,
+ CHILD_REGISTERED,
+ ADDRESS_REGISTERED,
+ INCOME_REGISTERED,
+
+ // 핵심 가치 — 이 전환율이 서비스의 존재 이유를 증명한다
+ MISSED_BENEFIT_VIEWED,
+ BENEFIT_LINK_CLICKED,
+
+ // 탐색
+ RECOMMENDATION_VIEWED,
+ REGIONAL_COMPARISON_VIEWED,
+ FACILITY_VIEWED,
+ ADMISSION_FORECAST_VIEWED,
+ FACILITY_POPULARITY_VIEWED,
+
+ // 알림 효과 — 이 앱이 "한 번 보고 끝" 을 벗어났는지 판단하는 지표
+ NOTIFICATION_SENT,
+ NOTIFICATION_CLICKED,
+ BENEFIT_AMOUNT_REPORTED,
+ WAITLIST_REGISTERED,
+
+ // 유지
+ APP_OPENED,
+ BOOKING_CREATED,
+ CHATBOT_ASKED
+}
diff --git a/src/main/java/com/carecode/core/analytics/UserEvent.java b/src/main/java/com/carecode/core/analytics/UserEvent.java
new file mode 100644
index 00000000..11998321
--- /dev/null
+++ b/src/main/java/com/carecode/core/analytics/UserEvent.java
@@ -0,0 +1,55 @@
+package com.carecode.core.analytics;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/** 한 번 쓰면 고치지 않는 append-only 기록. 지표를 나중에 다시 계산할 수 있어야 한다. */
+@Entity
+@Table(name = "TBL_USER_EVENT")
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class UserEvent {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "ID")
+ private Long id;
+
+ /** 비로그인 이벤트는 null. */
+ @Column(name = "USER_ID")
+ private Long userId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "EVENT_TYPE", nullable = false, length = 60)
+ private EventType eventType;
+
+ @Column(name = "TARGET_ID", length = 100)
+ private String targetId;
+
+ @Column(name = "METADATA", length = 500)
+ private String metadata;
+
+ @Column(name = "OCCURRED_AT", nullable = false)
+ private LocalDateTime occurredAt;
+
+ /** 집계 쿼리가 인덱스를 타도록 날짜를 따로 저장한다. */
+ @Column(name = "OCCURRED_DATE", nullable = false)
+ private LocalDate occurredDate;
+
+ @PrePersist
+ protected void onCreate() {
+ if (occurredAt == null) {
+ occurredAt = LocalDateTime.now();
+ }
+ occurredDate = occurredAt.toLocalDate();
+ }
+}
diff --git a/src/main/java/com/carecode/core/analytics/UserEventRepository.java b/src/main/java/com/carecode/core/analytics/UserEventRepository.java
new file mode 100644
index 00000000..af9c74a3
--- /dev/null
+++ b/src/main/java/com/carecode/core/analytics/UserEventRepository.java
@@ -0,0 +1,46 @@
+package com.carecode.core.analytics;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Repository
+public interface UserEventRepository extends JpaRepository {
+
+ /** 퍼널 단계별 고유 사용자 수. 이벤트 발생 횟수가 아니라 사람 수를 센다. */
+ @Query("SELECT COUNT(DISTINCT e.userId) FROM UserEvent e "
+ + "WHERE e.eventType = :type AND e.occurredDate BETWEEN :from AND :to")
+ long countDistinctUsers(@Param("type") EventType type,
+ @Param("from") LocalDate from,
+ @Param("to") LocalDate to);
+
+ /** 앞 단계를 거친 사용자 중 뒤 단계까지 간 사람 수. */
+ @Query("SELECT COUNT(DISTINCT e2.userId) FROM UserEvent e2 "
+ + "WHERE e2.eventType = :next AND e2.occurredDate BETWEEN :from AND :to "
+ + "AND e2.userId IN (SELECT e1.userId FROM UserEvent e1 "
+ + "WHERE e1.eventType = :previous AND e1.occurredDate BETWEEN :from AND :to)")
+ long countConverted(@Param("previous") EventType previous,
+ @Param("next") EventType next,
+ @Param("from") LocalDate from,
+ @Param("to") LocalDate to);
+
+ /** 가입일이 기준일인 사용자들. 리텐션의 분모가 된다. */
+ @Query("SELECT DISTINCT e.userId FROM UserEvent e "
+ + "WHERE e.eventType = com.carecode.core.analytics.EventType.SIGNED_UP "
+ + "AND e.occurredDate = :date AND e.userId IS NOT NULL")
+ List findUserIdsSignedUpOn(@Param("date") LocalDate date);
+
+ /** 주어진 사용자들 중 특정 날짜에 활동한 사람 수. */
+ @Query("SELECT COUNT(DISTINCT e.userId) FROM UserEvent e "
+ + "WHERE e.userId IN :userIds AND e.occurredDate = :date")
+ long countActiveOn(@Param("userIds") List userIds, @Param("date") LocalDate date);
+
+ /** 이벤트 종류별 발생 건수. */
+ @Query("SELECT e.eventType, COUNT(e) FROM UserEvent e "
+ + "WHERE e.occurredDate BETWEEN :from AND :to GROUP BY e.eventType")
+ List