2026 ๋ณด๊ฑด์๋ฃ๋น ๋ฐ์ดํฐยทAI ํ์ฉ ์ฐฝ์ ๊ฒฝ์ง๋ํ ์ถํ์ Java 21 + Spring Boot 4.0.6 + PostgreSQL 14+ + Redis 7+
์๋ ๊ฐ ๋ถ๋ชจ๋์ ์ฝยท๊ธฐ์ ์งํยท์๋ ๋ฅด๊ธฐ๋ฅผ ์ ๋ ฅํ๋ฉด (1) DUR + NB 2๊ฒน ์ฝ๋ฌผ ์์ ๊ฒ์ฆ, (2) ์์ฝ ์ฌ์ง ์๋ณ, (3) 8์ง๋ณ ร 4๋จ๊ณ = 32 ๋ฃฐ ๊ธฐ๋ฐ ๋ ์จ ์ฐ๊ณ ์ฃผ์ ํธ์, (4) ์๊ธ์ค์ฉ QR ์นด๋๋ฅผ ํตํฉ ์ ๊ณตํ๋ค.
์ ์ฒด ๋ฐฑ์๋(Spring Boot + PostgreSQL + Redis)๋ฅผ ํ ์ค๋ก ๋์ธ ์ ์๊ฒ ์ปจํ ์ด๋ํ๋์ด ์์ต๋๋ค. ์ด๋ค OSยทํ๊ฒฝ์ด๋ ๋์ผํ๊ฒ ์๋ํฉ๋๋ค.
cp .env.example .env.env๋ฅผ ์ด์ด์ ๋ฐ๋์ ์ฑ์์ผ ํ ๊ฐ (ํ์ฅ์๊ฒ ๋ฐ๊ธฐ):
POSTGRES_PASSWORDโ ๋์ปค PG ๋น๋ฐ๋ฒํธ (์๋ฌด๊ฑฐ๋ ์ ํด๋ OK)APP_ENCRYPTION_KEYโ 32๋ฐ์ดํธ base64 (ํ ๊ณตํต๊ฐ)MFDS_API_KEY/HIRA_API_KEY/KMA_API_KEYโ ๊ณต๊ณต๋ฐ์ดํฐํฌํธ ์ธ์ฆํค (ํ ๊ณตํต)GEMINI_API_KEYโ Google AI Studio ํค (ํ ๊ณตํต)
๋ณด์:
.env๋.gitignore๋ก ์ ์ธ๋์ด git์ ์ ์ฌ๋ผ๊ฐ๋๋ค. ํ ๋น๋ฐ๋ฒํธ ๋งค๋์ ์์ ๋ฐ์ผ์ธ์.
docker compose up -d --build์ฒซ ๋น๋๋ 5~10๋ถ (Java ๋น๋ + ์ด๋ฏธ์ง ๋ค์ด๋ก๋). ๋ ๋ฒ์งธ๋ถํฐ 30์ด.
docker compose ps # 3๊ฐ ๋ชจ๋ (healthy)
docker compose logs -f backend # "Started BackendApplication in N seconds"- API ๋ฌธ์: http://localhost:8080/swagger-ui.html
- ๋ฐ๋ชจ ์๋(๋ถ๋ชจ 2๋ช + ์ฝ 4์ข + ์ด๋จธ๋ ๋ฉํธํฌ๋ฅด๋ฏผ)๋ ์๋์ผ๋ก Flyway๊ฐ ์ ์ฌํฉ๋๋ค โ ํ๋ก ํธ์๋ BLOCK ์์ฐ ์ฆ์ ๊ฐ๋ฅ.
๊ธฐ๋ณธ ๋์ปค ์ ์ ์๋ DUR ๋ณ์ฉ๊ธ๊ธฐ ๋ฐ์ดํฐ๊ฐ ๋น์ด์์ต๋๋ค. BLOCK ๋ชจ๋ฌ์ด ์ง์ง๋ก ๋์ํ๊ฒ ํ๋ ค๋ฉด:
- ํ์ฅ์๊ฒ DUR CSV 5์ข ๊ตฌ๊ธ๋๋ผ์ด๋ธ ๋งํฌ ๋ฐ๊ธฐ
- ์์ถ ํ๊ณ
backend/dur-data/ํด๋์ 5๊ฐ CSV ๊ทธ๋๋ก ๋ฐฐ์น .env์ ์ถ๊ฐ:DUR_DATA_DIR=./dur-data APP_ETL_DUR_ENABLED=true
docker compose down && docker compose up -dโ ์ฒซ ๋ถํ ์ 1๋ถ ETL ์๋ ์งํ (docker compose logs -f backend)
ETL์ idempotent(์ค๋ณต ๋ฌด์)๋ผ ์ดํ ๋งค ๋ถํ ์ ๊ทธ๋๋ก ๋ฌ๋ ์์ ํฉ๋๋ค.
docker compose ps # ์ปจํ
์ด๋ ์ํ
docker compose logs -f backend # ๋ฐฑ์๋ ๋ก๊ทธ ๋ผ์ด๋ธ
docker compose stop # ์์ ์ค์ง (๋ฐ์ดํฐ ์ ์ง)
docker compose start # ๋ค์ ์์
docker compose down # ์ปจํ
์ด๋ ์ ๊ฑฐ (๋ณผ๋ฅจ ๋ฐ์ดํฐ๋ ๋จ์)
docker compose down -v # ์์ ์ญ์ (DB๊น์ง ๋ ์๊ฐ)
docker compose up -d --build # ์ฝ๋ ๋ณ๊ฒฝ ํ ์ฌ๋น๋| ์ฆ์ | ํด๊ฒฐ |
|---|---|
| ํฌํธ 8080ยท15432ยท16379 ์ถฉ๋ | .env์ BACKEND_PORT / POSTGRES_PORT / REDIS_PORT ๋ณ๊ฒฝ |
POSTGRES_PASSWORD must be set ์๋ฌ |
.env ์ ๋ง๋ค์์ โ cp .env.example .env ํ ๋น๋ฐ๋ฒํธ ์์ฑ |
ํธ์คํธ์์ localhost:8080 ์๋ต ์์ (Windows) |
IPv6 localhost ํด์ ์ด์ โ ํ๋ก ํธ์๋ .env์ API_BASE_URL์ 127.0.0.1:8080๋ก |
| ๋ฐ์ดํฐ ์ด๊ธฐํํ๊ณ ์ถ์ | docker compose down -v && docker compose up -d --build (DB๊น์ง ๋ฆฌ์
) |
- PostgreSQL 16+, Redis 7+ ํธ์คํธ์ ์ค์น
src/main/resources/application-local.yml์์ฑ (DB ๋น๋ฒยทAPI ํค ์ง์ ์ ๋ ฅ โ.gitignore๋ก git ์ ์ธ๋จ)./gradlew bootRun --args='--spring.profiles.active=local'
Track A = ๋ํ / Track B = ์์ข / ๐ค = ๊ณต๋ ์์
[๐ค Slice 00 ์ธํ๋ผ] โ ๋์ด ๊ฐ์ด ์
์
ํ๊ณ ๊ฐ๋ผ์ง
โ
โโโโโโโโโโ[A] 01 ์ฝ ๋ง์คํฐ โโโ
โ โ
โโโโโโโโโโ[A] 02 DUR โโโโโโโโโค
โ โผ
โโโโโโโโโโ[A] 03 NB ์ถ์ถ โโ [๐ค SafetyJudge ํตํฉ]
โ โ
โโโโโโโโโโ[B] 04 ๋ถ๋ชจ/์ฝ์ฅ โ
โ โ ๋ฐ๋ชจ ํต์ฌ, 04 Controller๋ B / SafetyJudge๋ A
โ โ
โโโโโโโโโโ[B] 05 ์ง๋ณ/๊ธ๊ธฐ โโโค
โ โ
โโโโโโโโโโ[A] 06 ๋ ์จ ๋ฃฐ์
โโโ
โ โ
โ [๐ค Slice 07 ETL+ํธ์] โ ๋ฐ์ดํฐ=A / ๋ถ๋ชจ์ปจํ
์คํธยทํธ์=B
โ
โโโโโโโโโโ[B] 08 ์๊ธ์นด๋
| # | ์ฌ๋ผ์ด์ค | ๋ด๋น | ๋น๊ณ |
|---|---|---|---|
| 00 | ์ธํ๋ผ + ๊ณตํต ์ ํธ | ๐ค ๊ณต๋ | DDL/Flyway/๊ณตํต ํด๋์ค๋ ๋์ด ํฉ์ํด์ ์ค๊ณ |
| 01 | ์ฝ ์๋ณ + ์์ฝ ์ฌ์ง (์์ฝ์ฒ API) | A ๋ํ | ์ธ๋ถ API ํด๋ผ์ด์ธํธ ๋ผ์ธ |
| 02 | DUR ๋ณ์ฉ๊ธ๊ธฐ (837kํ CSV ์ ์ฌ + ์ ํ๋งค์นญ) | A ๋ํ | ์ธ๋ฑ์ค ํ๋ยทEXPLAIN ๊ฒ์ฆ |
| 03 | NB AI ์ถ์ถ + ํ๊ฐ ๊ฒ์ฆ (LLM) | A ๋ํ | Gemini ํด๋ผ์ด์ธํธยทํ๋กฌํํธ |
| 04 | ๋ถ๋ชจ/์ฝ์ฅ/์๋ ๋ฅด๊ธฐ/๋๋ฐ์ด์คํ ํฐ (โ ๋ฐ๋ชจ ํต์ฌ) | B ์์ข + ๐ค ํตํฉ | ControllerยทService๋ B / SafetyJudge ํธ์ถ ์ง์ ์ A์ ํฉ์ |
| 05 | ๊ธฐ์ ์งํ + ํ์๋ถ๋ฅ ๊ธ๊ธฐ | B ์์ข | HIRA 12904ยทKCD ๋งคํ |
| 06 | ๋ ์จ ๋ฃฐ์ + ๊ฒฉ์์ขํ + ๋ฃฉ์ | A ๋ํ | seed JSONยทxlsx ์ ์ฌ |
| 07 | ๊ธฐ์์ฒญ ETL + ๋งค์ผ ํธ์ (Redis ๋ฝ) | ๐ค ๊ณต๋ | ๊ธฐ์์ฒญ ํธ์ถยท์์ฒด ํ์ =A / ๋ถ๋ชจ์ปจํ ์คํธยทํธ์๋ฐ์ก=B |
| 08 | ์๊ธ์นด๋ + HIRA ๋ณ์/์ฝ๊ตญ | B ์์ข | QRยทRate Limitยท๊ณต๊ฐ ์๋ํฌ์ธํธ |
| Week | A ๋ํ | B ์์ข | ๐ค ๊ณต๋ |
|---|---|---|---|
| 1 | Slice 01 (์์ฝ์ฒ ํด๋ผ์ด์ธํธ + drugs_master) | Slice 04 ๋๋ฉ์ธ ์ค์บํด๋ (PatientProfile/Allergy/DeviceToken ์ํฐํฐ+CRUD, SafetyJudge๋ mock) | Slice 00: docker-compose, Flyway V001~V003, ๊ณตํต ์ ํธ 7์ข , ํ๊ฒฝ๋ณ์ |
| 2 | Slice 02 (DUR 5 CSV ์ ์ฌ + DurEngineService) | Slice 04 Medication CRUD (mock SafetyJudge๋ก ์ฐ์ ๋์) | API ์๋ต ๊ตฌ์กฐ ํฉ์ ์ ๊ฒ (HTTP 409 BLOCK ๋ฑ) |
| 3 | Slice 03 (NB ์ถ์ถ + ํ๊ฐ ๊ฒ์ฆ + SafetyJudge ์์ฑ) | Slice 05 (HIRA 12904 + patient_conditions ์ ๊ทํ) | ๐ค SafetyJudge ํตํฉ โ A๊ฐ ๋ง๋ SafetyJudgeService.judgeFull()์ B์ MedicationController๊ฐ ํธ์ถํ๋๋ก ์์ด์ด๋ง. HTTP 409 ์๋ต e2e ๊ฒ์ฆ โ
|
| 4 | Slice 06 (weather_rules 32๋ฃฐยท๊ฒฉ์์ขํ ์ ์ฌ + lookupRules) | Slice 08 (HIRA 11999/12101/12100 + EmergencyCard + /em/{token} ๊ณต๊ฐ ์๋ํฌ์ธํธ) |
โ |
| 5 | Slice 07 ETL ๋ถ๋ถ (KMA ํธ์ถยท์์ฒด ํ์ ยทobservations ์ ์ฌ) | Slice 07 ํธ์ ๋ถ๋ถ (parent_conditions ์กฐํยทdevice_tokens ๋ณตํธํยทํธ์ ๋ฐ์ก mock) | ๐ค Slice 07 ํตํฉ โ A์ ETL ๊ฒฐ๊ณผ๋ฅผ B์ ํธ์ ์ก์ด ์๋น. Redisson ๋ฝ 2๋จ ๊ฐ์ด ๊ฒ์ฆ. ๋ฐ๋ชจ ์๋๋ฆฌ์ค e2e ๋ฆฌํ์ค |
- Slice 00 ์ธํ๋ผ โ Flyway ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ๋ช
๋ช
๊ท์น, 4-schema, ๊ณตํต ์ํฐํฐ(
BaseTimeEntity), ์์ธ ๊ณ์ธต(SafetyBlockException,ApiException), ์๋ต ํฌ๋งท(ApiResponse<T>,BlockErrorResponse) SafetyJudgeService์ธํฐํ์ด์ค โ Week 1์ ์๊ทธ๋์ฒ๋ง ๋จผ์ ๋ฐ์๋๊ณ ์์ชฝ์ด ๋์ ๊ฐ๋ฐ (Verdict judgeFull(PatientContext, List<Drug> current, Drug newDrug))- API ์๋ต ๊ตฌ์กฐ โ HTTP 409 BLOCK / 201 ALLOWยทWARN ๊ตฌ์กฐ (
HANDOFF_COMMON.mdground truth) - Slice 07 ํธ๋์คํ โ
WeatherEtlService.runDaily()๊ฒฐ๊ณผ โWeatherPushService.dispatch()์ ๋ ฅ DTO ํฉ์ - ๋ฐ๋ชจ ์๋๋ฆฌ์ค e2e โ ์ฝ ์ถ๊ฐ BLOCK โ ์๊ธ์นด๋ โ ํญ์ผ ์๋ฎฌ (Week 5 ๊ฐ์ด ๋๋ ค๋ด)
์ถ๊ฐ ์์ ๋ถ๋ด ๋ํ ์ผ์ ยง11 MVP TODO ์ฐธ๊ณ .
# 1) ์ธํ๋ผ
docker compose up -d # PostgreSQL 14 + Redis 7
# 2) DB ์คํค๋ง (Flyway)
./gradlew flywayMigrate
# 3) ํ๊ฒฝ๋ณ์ (application-local.yaml ๋๋ ํ๊ฒฝ๋ณ์)
MFDS_API_KEY=...
HIRA_API_KEY=...
KMA_API_KEY=...
GEMINI_API_KEY=...
DATABASE_URL=postgresql://localhost:5432/mom_med
REDIS_URL=redis://localhost:6379
APP_ENCRYPTION_KEY=... # pgcrypto ์ปฌ๋ผ ์ํธํ ๋ง์คํฐํค
# 4) ์คํ
./gradlew bootRunํ์ API ํค 4๊ฐ: ์์ฝ์ฒ / HIRA / ๊ธฐ์์ฒญ (์ ๋ถ data.go.kr ํ์ฉ์ ์ฒญ) / Google Gemini (Google AI Studio).
| ์์ญ | ์ ํ | ๋น๊ณ |
|---|---|---|
| JDK | Java 21 (toolchain.languageVersion=21) |
Virtual Threads, Pattern Matching, Records ์ ๊ทน ํ์ฉ ๊ถ์ฅ |
| ํ๋ ์์ํฌ | Spring Boot 4.0.6 (Spring Framework 7 ๊ธฐ๋ฐ) | Boot 4์์ spring-boot-starter-web โ **spring-boot-starter-webmvc**๋ก ๋ถ๋ฆฌ๋จ (์ด๋ฏธ build.gradle ๋ฐ์) |
| ๋น๋ | Gradle | build.gradle |
| DB | PostgreSQL 14+ | 4-schema ๋ถ๋ฆฌ, pg_trgmยทpgcryptoยทbtree_ginยทpg_stat_statements ํ์ฅ |
| ์บ์ยท๋ฝ | Redis 7+ | LLM ์บ์ / Slice 07 ๋ถ์ฐ ๋ฝ 2๋จ |
| ๋ง์ด๊ทธ๋ ์ด์ | Flyway | src/main/resources/db/migration/V###__*.sql |
| DTOยท๋งคํ | Lombok + MapStruct (or Java 21 records) | records ๊ถ์ฅ (response DTO) |
| API ๋ฌธ์ | springdoc-openapi 3.0.2 | ์ด๋ฏธ ์ถ๊ฐ๋จ, /swagger-ui.html |
| LLM | Gemini 2.5 Flash Lite | utils/llm_client Spring WebClient ๋ํผ |
| ๊ฒ์ฆ | spring-boot-starter-validation | ์ด๋ฏธ ์ถ๊ฐ๋จ |
| HTTP ํด๋ผ์ด์ธํธ | RestClient (Spring 6.1+) ๋๋ WebClient | Boot 4์์ RestClient๊ฐ ๊ถ์ฅ |
- โ
spring-boot-starter-webโspring-boot-starter-webmvc(build.gradle ์ด๋ฏธ ์ ์ฉ) - โ
ํ
์คํธ๋
spring-boot-starter-webmvc-test์ฌ์ฉ (build.gradle ์ด๋ฏธ ์ ์ฉ) - Spring Framework 7 ๊ธฐ๋ฐ โ JEP 442 ๋ฑ ์ต์ Java ๊ธฐ๋ฅ ํธํ
- AOT ์ปดํ์ผ ๊ธฐ๋ณธ ๊ฐํ (๋ค์ดํฐ๋ธ ์ด๋ฏธ์ง ์นํ)
- Jakarta EE 11 / Servlet 6.1
- Java 17 ์ด์ ํ์ (์ฐ๋ฆฌ๋ Java 21)
dependencies {
// ๋ฐ์ดํฐ
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
// ๋ถ์ฐ ๋ฝ (Slice 07)
implementation 'org.redisson:redisson-spring-boot-starter:3.34.1'
// Rate Limit (Slice 08 โ /em/{token})
implementation 'com.bucket4j:bucket4j-core:8.10.1'
// ์ธ๋ถ API ํธ์ถ (RestClient๋ starter-webmvc ํฌํจ, ์ถ๊ฐ ์ค์ ๋ง)
// ๋๋ WebFlux ํด๋ผ์ด์ธํธ๋ง:
// implementation 'org.springframework.boot:spring-boot-starter-webflux'
// ๋งคํ
implementation 'org.mapstruct:mapstruct:1.6.3'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
// ํ
์คํธ
testImplementation 'org.testcontainers:postgresql:1.20.4'
testImplementation 'org.testcontainers:junit-jupiter:1.20.4'
}partial UNIQUE ์ธ๋ฑ์ค๊ฐ PostgreSQL ํนํ ๊ธฐ๋ฅ์ด๋ผ H2๋ก ํ ์คํธํ๋ฉด ์ ๋จ โ Testcontainers PostgreSQL ํ์.
- records๋ก ์๋ต/์์ฒญ DTO:
public record AddMedicationRequest(String itemSeq, String source, String notes) {} - pattern matching switch:
Verdict.decision์ ๋ฐ๋ผ ์๋ต ๋ถ๊ธฐ - virtual threads: Slice 07 ETL์์ ๊ฒฉ์๋ณ KMA ํธ์ถ ๋ณ๋ ฌ โ
Executors.newVirtualThreadPerTaskExecutor() - sealed interface:
Evidence๊ณ์ธต (DurEvidence / NbEvidence / AllergyEvidence)
| ๋ณด๊ณ ์ถ์ ๊ฒ | ํ์ผ |
|---|---|
| ์ ์ฒด ๋น์ , ์ฌ์ฉ์ ์คํ ๋ฆฌ, 4-schema ์ฒญ์ฌ์ง | PRD_์๋ง์ฝ_v6.md |
| ๊ฐ๋ฐ ์ฌ๋ผ์ด์ค๋ณ ์์ธ ๋ช ์ธ (DDL ยท API ยท ์์ฌ์ฝ๋) | issues_v2/00 ~ 08-*.md |
| ์ฌ๋ผ์ด์ค ์์กด์ฑ ๊ทธ๋ํ ยท ๊ถ์ฅ ์์ ์์ | issues_v2/README.md |
| ํ๋ก ํธ์์ ์๋ต๊ตฌ์กฐ ํฉ์ (HTTP 409 BLOCK ๋ฑ) | HANDOFF_COMMON.md |
| ํ๋ก ํธ์๋๊ฐ ๊ธฐ๋ํ๋ ์๋ต JSON Zod ์คํค๋ง | api-types/*.ts |
| ์๊ฐํ ๋ค์ด์ด๊ทธ๋จ (๋ธ๋ผ์ฐ์ ๋ก ์ด์ด๋ณด๊ธฐ) | ์๋ง์ฝ_API_ํ๋ฆ๋_v8.html |
| DUR/์์ฝ์ฒ OpenAPI ํธ์ถ ๊ฐ์ด๋ | data/OpenAPIํ์ฉ๊ฐ์ด๋_*.pdf |
| ๊ธฐ์์ฒญ ๋จ๊ธฐ์๋ณด API ๊ฐ์ด๋ + ๊ฒฉ์์ขํ xlsx | ๊ธฐ์์ฒญ41_๋จ๊ธฐ์๋ณด_*/ |
| ๋ ์จ ๋ฃฐ์ 32๊ฑด + ํ๊ฐ ๊ฒ์ฆ ground truth | seed/ |
| LLM NB ์ถ์ถ ํ๊ท ํ ์คํธ fixture | nb_amlodipine.*, nb_warfarin.* |
์ถฉ๋ ์ ground truth ์ฐ์ ์์: API ์๋ต ๊ตฌ์กฐ =
api-types/*.tsโ DB DDL =issues_v2/*.mdโ ๊ธฐ๋ฅ ๋ฒ์ =PRD_์๋ง์ฝ_v6.mdโ ์ํ ํฉ์ =HANDOFF_COMMON.md
mom_med database
โโโ ref ์ธ๋ถ ๋ง์คํฐ (์์ฝ์ฒยทHIRAยท๊ธฐ์์ฒญ ์ ์ฌ) โ SELECT๋ง, ๋ฐฑ์
์ ๊ฒ
โโโ app ์ฌ์ฉ์ ๋ฐ์ดํฐ (PII, RLS, PITR ๋ฐฑ์
ํ์) โ ์ด์์ ํต์ฌ
โโโ derived LLM ๊ฐ๊ณต ์บ์ (์ฌํธ์ถ๋ก ๋ณต๊ตฌ ๊ฐ๋ฅ)
โโโ logs ์๊ณ์ด (์๋ณ RANGE ํํฐ์
, append-only)
ํ์ PostgreSQL ํ์ฅ: pg_trgm, pgcrypto, btree_gin, pg_stat_statements
- RLS (Row-Level Security): ๋งค ์์ฒญ๋ง๋ค
SET LOCAL app.current_parent_id = '...'์ฃผ์ - ์ปฌ๋ผ ์ํธํ:
device_tokens.token_encrypted = pgp_sym_encrypt(token, :key) - ๊ณต๊ฐ ์๋ํฌ์ธํธ
GET /em/{token}: ์ธ์ฆ ์์ โ ๋ฐ๋์ Rate Limit + 90์ผ ๋ง๋ฃ + revoke ์ง์
์ฝ ์ถ๊ฐ ์ BLOCK (HTTP 409):
{
"error": "block",
"verdict": {
"decision": "BLOCK",
"evidences": [
{ "source":"DUR", "severity":"high", "message":"...", "citation":"...", "citation_source":"..." }
]
}
}์ฝ ์ถ๊ฐ ์ ALLOW / WARN (HTTP 201):
{
"medication_id": "uuid",
"item_seq": "200610660",
"safety_check": { "decision":"ALLOW"|"WARN", "evidences": [...] }
}์๊ธ์นด๋ QR URL: https://app.์๋ง์ฝ.com/em/{token} (โ /c/{token} ์๋)
| ๋๋ฉ์ธ | ๊ฐ | ์ฌ์ฉ์ฒ |
|---|---|---|
| ์ฝ๋ฌผ ์์ ํ์ | high / medium / low (์๋ฌธ) |
Slice 02ยท03ยท04 |
| ๋ ์จ ์๋ฆผ | ๊ด์ฌ / ์ฃผ์ / ๊ฒฝ๊ณ / ์ํ (ํ๊ธ) |
Slice 06ยท07 |
| ์๋ ๋ฅด๊ธฐ | severe / moderate / mild |
Slice 04 |
| ์์ ํ์ ๊ฒฐ๊ณผ | BLOCK / WARN / INFO / ALLOW |
Slice 04 ์๋ต |
| ํธ์ ์ก์ ์ํ | sent / failed / simulated / skipped_fatigue / skipped_review |
Slice 07 |
public static boolean isAnticoagulant(String atcCode) {
return atcCode != null && atcCode.startsWith("B01A");
}00 ์ธํ๋ผ (4-schema, LLM ํด๋ผ์ด์ธํธ, ํ๊ฐ ๊ฒ์ฆ๊ธฐ) ๐ค ๊ณต๋
โ
01 ์ฝ ๋ง์คํฐ + ์์ฝ ์ด๋ฏธ์ง (์์ฝ์ฒ 15095677ยท15057639) A ๋ํ
โ
02 DUR ๋ณ์ฉ๊ธ๊ธฐ (HIRA 11983, 837kํ, ์ ํ๋งค์นญ โ
) A ๋ํ
โ
03 NB ์ถ์ถ (LLM 2๊ฒน ์์ ๋ง, ํ๊ฐ ๊ฒ์ฆ) A ๋ํ
โ
04 ๋ถ๋ชจ ์ฝ์ฅ + ์์ ํ์ ํตํฉ (โ
๊ฐ์ฅ ํฐ ์ฌ๋ผ์ด์ค) B ์์ข
+ ๐ค ํตํฉ
โ
05 ์ง๋ณยทํ์๋ถ๋ฅ ๊ธ๊ธฐ (KCD ๋งคํ) B ์์ข
โ
06 ๋ ์จ ๋ฃฐ์
32๊ฐ + ๊ฒฉ์์ขํ + ๋ฃฉ์
A ๋ํ
โ
07 ๋งค์ผ ETL + ์์ฒด ํ์ + ํธ์ (Redis ๋ฝ 2๋จ) ๐ค ๊ณต๋
โ
08 ์๊ธ์นด๋ + HIRA 11999/12101/12100 B ์์ข
๊ฐ ์ฌ๋ผ์ด์ค ๋ฌธ์๊ฐ ground truth. ์๋๋ ์ธ๋ฑ์ค + ํต์ฌ ๋ฐ์ท.
๋ด๋น ๋ถ๋ด:
- ๋ํ:
DrugNameNormalizer(15 ์ผ ๋ชฉ๋ก),HallucinationVerifier(3๋จ๊ณ ๋งค์นญ),GeminiClient(RestClient/WebClient) - ์์ข
:
BaseTimeEntity,GlobalExceptionHandler,ApiResponse<T>/BlockErrorResponse,RedisCacheService,PgCryptoService - ๊ณต๋: Flyway ๋ง์ด๊ทธ๋ ์ด์
(V001 extensions / V002 schemas / V003 audit trigger), docker-compose, application.yaml, ํ๊ฒฝ๋ณ์ 7๊ฐ,
SafetyJudgeService์ธํฐํ์ด์ค ์๊ทธ๋์ฒ ๋ฐ์๋๊ธฐ
Flyway ๋ง์ด๊ทธ๋ ์ด์ 3์ข :
-- V001__extensions.sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS btree_gin;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- V002__schemas.sql
CREATE SCHEMA IF NOT EXISTS ref;
CREATE SCHEMA IF NOT EXISTS app;
CREATE SCHEMA IF NOT EXISTS derived;
CREATE SCHEMA IF NOT EXISTS logs;
ALTER DATABASE mom_med SET search_path TO app, ref, derived, logs, public;
-- V003__audit_trigger.sql
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;์๋ฝ ๊ธฐ์ค: docker compose up โ PG+Redis ์ ์, Flyway ํ \dn์ 4 schema, DrugNameNormalizer ๋จ์ํ
์คํธ 6 ์ผ์ด์ค, GeminiClient.call("ping") ์ฑ๊ณต
Slice 01 โ ์ฝ ์๋ณ + ์์ฝ ์ฌ์ง [A ๋ํ] [โ issues_v2/01-drug-identity-pill-visual-v2.md]
์ธ๋ถ API 3๊ฐ (์์ฝ์ฒ):
| API | sno | ์ฉ๋ |
|---|---|---|
| ์์ฝํ ์ ํ ํ๊ฐ์ ๋ณด [๋ชฉ๋ก] | 15095677 | item_name โ ITEM_SEQ ๋งค์นญ |
| ์์ฝํ ์ ํ ํ๊ฐ์ ๋ณด [์์ธ] | 15095677 | ITEM_SEQ โ ์ฑ๋ถ/ATC/NB_DOC_DATA |
| ์์ฝํ ๋ฑ์์๋ณ ์ ๋ณด | 15057639 | ITEM_NAME โ ์์ฝ ์ฌ์งยท๊ฐ์ธยท์ยท๋ชจ์ |
ํ
์ด๋ธ: ref.drugs_master (PK item_seq), ref.pill_visuals (PK item_seq FK)
ํต์ฌ ์ปฌ๋ผ: main_ingr_norm โ DrugNameNormalizer(main_ingr_en) โ Slice 02 DUR ์กฐ์ธ ํค
API: GET /v1/drugs/identify?name={user_input} โ 200/300(๋๋ช
์ดํ)/404
์๋ฝ ๊ธฐ์ค: ํ์ด๋ ๋500 โ item_seq=202106092, main_ingr_norm="acetaminophen", 2ํ ํธ์ถ ์ ์ธ๋ถ API 0ํ
Slice 02 โ DUR 1์ฐจ ๋ณ์ฉ๊ธ๊ธฐ (์ ํ๋งค์นญ) [A ๋ํ] [โ issues_v2/02-dur-coadministration-check-v2.md]
์ธ๋ถ API 0๊ฑด. CSV 5๊ฐ ์ ์ฌ:
| ํ ์ด๋ธ | ํ์ |
|---|---|
ref.dur_combo_contraindications |
837,837 (231MB, cp949) |
ref.dur_elderly_caution |
572 |
ref.dur_elderly_nsaid_caution |
1,083 |
ref.dur_age_contraindication |
2,905 (Slice 05์์ ์ ์ฌ) |
ref.dur_pregnancy_contraindication |
19,015 (Slice 05์์ ์ ์ฌ) |
โ ํต์ฌ: LIKE '%X%' ์ ๋ ๊ธ์ง. ingredient_norm = ? ์ ํ ๋งค์นญ + idx_dur_combo_pair(a_norm, b_norm) ์ธ๋ฑ์ค
SafetyJudge MVP (๋ํ์ด ์ธํฐํ์ด์ค ๊ตฌํ):
- ๋ณ์ฉ๊ธ๊ธฐ ๋งค์นญ โ BLOCK / 65์ธโ + ๋ ธ์ธ์ฃผ์ โ WARN / ๊ทธ ์ธ โ ALLOW
- Dedupe: (์ฑ๋ถA, ์ฑ๋ถB) ์ง ๊ธฐ์ค
์๋ฝ ๊ธฐ์ค: amlodipine+itraconazole โ BLOCK, amlodipine+simvastatin โ ALLOW (Slice 03์์ ํ์), ์๋ต < 50ms, EXPLAIN ์ธ๋ฑ์ค hit
Slice 03 โ NB AI ์ถ์ถ + 2๊ฒน ์์ ๋ง [A ๋ํ] [โ issues_v2/03-nb-extraction-2layer-safety-v2.md]
์ธ๋ถ API: Gemini 2.5 Flash Lite (RestClient/WebClient)
ํ
์ด๋ธ: derived.nb_extractions, derived.nb_interactions (โ
entry_type CHECK ('drug_drug' | 'patient_class') ์ฒ์๋ถํฐ ์ ์)
risk_level enum ๋งคํ โ ํตํฉ ํ์ :
| ๋ณธ๋ฌธ ํํ | enum | ํ์ |
|---|---|---|
| "ํฌ์ฌํ์ง ๋ง ๊ฒ", "๊ธ๊ธฐ" | ๋์ํฌ์ฌํผํด์ผํจ |
BLOCK |
| "๊ถ์ฅํ์ง ์๋๋ค" | ๊ถ์ฅํ์ง์์ |
WARN |
| "์ฃผ์๊น๊ฒ ๊ด์ฐฐ" | ์ฃผ์ |
INFO |
| "์ํฅ์ด ์์๋ค" | ์ ๋ณด๋ง |
(UI ๋ ธ์ถ ์ ํจ) |
์ฝ๋ฌผ๊ตฐ โ ATC prefix ๋งคํ (NB๊ฐ "CYP3A4 ์ ํด์ " ๊ฐ์ ๊ตฐ๋ช ๋ฐํ ์):
Map.of("CYP3A4 ์ ํด์ ", List.of("J02AC","J01FA09"),
"๋น์คํ
๋ก์ด๋์ฑ ์์ผ์ ", List.of("M01A"),
"ํญ์๊ณ ์ ", List.of("B01A"))HallucinationVerifier: 3๋จ๊ณ (exact โ normalized โ fuzzy 40์). ์คํจ ์ 1ํ ์ฌ์๋, ๊ทธ๋๋ ์คํจํ๋ฉด verified=false, ์ฌ์ฉ์ ๋
ธ์ถ ๊ธ์ง.
SafetyJudgeService.judgeFull() ์์ฑ (โ
์์ข
์ 04์ ํตํฉ ์ง์ ):
- DUR (Slice 02) + 2. ์ ์ฝ NB โ ๊ธฐ์กด ์ฝ + 3. ๊ธฐ์กด ์ฝ NB โ ์ ์ฝ
์๋ฝ ๊ธฐ์ค: ๋ ธ๋ฐ์คํฌ 39 entry / ์ํ๋ฆฐ 46 entry / ํ๊ฐ 0๊ฑด, ๋ ธ๋ฐ์คํฌ+์ฌ๋ฐ์คํํด โ NB๋ก WARN ํ์
Slice 04 โ ๋ถ๋ชจ + ์ฝ์ฅ + ์๋ ๋ฅด๊ธฐ + ๋๋ฐ์ด์ค ํ ํฐ โ
[B ์์ข
+ ๐ค ํตํฉ] [โ issues_v2/04-parent-medication-management-v2.md]
๊ฐ์ฅ ํฐ ์ฌ๋ผ์ด์ค. ๊ณต๋ชจ์ ๋ฐ๋ชจ์ ํต์ฌ.
ํ
์ด๋ธ 4์ข
(app.*):
| ํ ์ด๋ธ | PK | ํต์ฌ |
|---|---|---|
app.patient_profiles |
parent_id UUID |
birthdate, sex, nx/ny, is_pregnant |
app.patient_medications |
id BIGSERIAL |
soft delete, partial UNIQUE(parent_id, item_seq) WHERE deleted_at IS NULL |
app.patient_allergies |
id BIGSERIAL |
allergen_norm (์ฝ๋ฌผ๋ง ์ ๊ทํ) |
app.device_tokens |
id BIGSERIAL |
token_encrypted BYTEA (pgp_sym_encrypt) |
logs.safety_check_log (์๋ณ ํํฐ์
) โ ๋ชจ๋ ์ฝ ์ถ๊ฐ ์๋ ๊ธฐ๋ก (BLOCK๋ ํฌํจ)
๐ค ํตํฉ ์ง์ (Week 3) โ ์์ข โ ๋ํ:
- ์์ข
์ด
MedicationController.addMedication()์ ๋ง๋ฆ - ๋ํ์ด ๋ง๋
SafetyJudgeService.judgeFull(parentCtx, currentDrugs, newDrug)์ ํธ์ถ - Week 1์ ์์ข
์ด mock ๊ตฌํ์ฒด๋ก ์ฐ์ ๋์ (
return Verdict.ALLOW;) - Week 3์ ์ค์ ๊ตฌํ์ฒด swap โ e2e BLOCK ์๋ต ๊ฒ์ฆ
ํต์ฌ API โ :
POST /v1/parents/{parentId}/medications
๋ด๋ถ:
1. ref.drugs_master์์ item_seq ์กฐํ (์์ผ๋ฉด Slice 01 ํธ์ถ)
2. SafetyJudgeService.judgeFull(parentCtx, currentDrugs, newDrug) โ ๋ํ ์ฝ๋
3. logs.safety_check_log INSERT (BLOCK๋ ๊ธฐ๋ก) โ @Transactional(REQUIRES_NEW)
4. BLOCK โ SafetyBlockException ๋ฐ์ โ HTTP 409
WARN/ALLOW โ INSERT + HTTP 201
ํธ๋์ญ์
์ฃผ์: log ๊ธฐ๋ก์ @Transactional(propagation=REQUIRES_NEW) ๋๋ ApplicationEvent๋ก ๋ถ๋ฆฌํด BLOCK rollback ์์๋ log๋ ๋จ๊ธฐ๊ธฐ.
์๋ฝ ๊ธฐ์ค: HTTP 409 BLOCK ์๋ต, partial UNIQUE ์๋ฐ, ํ ํฐ ํ๋ฌธ DB ๋ฏธ์ ์ฅ (๋จ์ํ ์คํธ), consent_data_share=false โ 403
Slice 05 โ ๊ธฐ์ ์งํ + ํ์๋ถ๋ฅ ๊ธ๊ธฐ [B ์์ข
] [โ issues_v2/05-disease-patient-class-contraindication-v2.md]
์ธ๋ถ API: HIRA 12904 (์ง๋ณ์ ๋ณด์๋น์ค) โ XML ์๋ต. KCD ๊ฒ์ฆ์ฉ.
ํ ์ด๋ธ:
ref.disease_master(PK sick_cd) โ 47,798ํ (HIRA 11984 CSV)app.patient_conditionsโ ์ ๊ทํ (JSONB ์ต์ ๊ฑฐ๋ถ), partial UNIQUE(parent_id, disease_code) WHERE deleted_at IS NULL
KCD ๋งคํ ์ฌ์ (LLM ํ๋กฌํํธ ํ์ฅ์ผ๋ก patient_class entry ์ถ์ถ):
| NB ๋ณธ๋ฌธ ํํ | KCD |
|---|---|
| "๊ฐ์ฅ์ ํ์" | K70-K77 |
| "์ ์ฅ(์ฝฉํฅ)์ฅ์ ํ์" | N17-N19 |
| "์ฌ์ฅ๊ธฐ๋ฅ์ ํ" | I50, I20-I25 |
| "์ํ์ฑ๊ถค์" | K25-K27 |
| "ํ์ก ์ด์" | D60-D64, D70-D77 |
| "๋น๋จ ํ์" | E10-E14 |
๐ค ํฉ์ ํ์: ๋ํ์ Slice 03 LLM ํ๋กฌํํธ์ entry_type='patient_class' ์ถ์ถ ํญ๋ชฉ ์ถ๊ฐ (์์ข
์ด ํ๋กฌํํธ PR ํจ๊ป ๊ฒํ )
SafetyJudge ํ์ฅ (3์ฐจ ์์ ๋ง):
checkPatientClassContraindication/checkAgeContraindication/checkPregnancyContraindication/checkAllergy
์๋ฝ ๊ธฐ์ค: ๋ถ๋ชจ K25 + NSAID โ WARN, ๋ถ๋ชจ ํ๋์ค๋ฆฐ ์๋ ๋ฅด๊ธฐ + amoxicillin โ BLOCK, ๋ถ๋ชจ ์์ + ์๋ถ๊ธ๊ธฐ โ BLOCK
Slice 06 โ ๋ ์จ ๋ฃฐ์
+ ๊ฒฉ์ ์ขํ + ๋ฃฉ์
[A ๋ํ] [โ issues_v2/06-weather-ruleset-lookup-v2.md]
์ธ๋ถ API 0๊ฑด. JSON/xlsx ์๋ ์ ์ฌ.
ํ ์ด๋ธ:
ref.weather_rulesโ 32ํ (seed/weather_rules_v0.2.json), severity CHECK IN ('๊ด์ฌ','์ฃผ์','๊ฒฝ๊ณ ','์ํ')ref.region_gridโ 26K+ (xlsx)
updateParentGrid(parentId) โ 1์์ ์ยท๊ตฌยท๋ ์ ํ๋งค์นญ, 2์์ ์ยท๊ตฌ fallback (๋๋๋ณตํฉ์ง์ญ ๊ฒฉ์ ์ ํ๋)
WeatherAdvisory DTO: rule_id ํ์ ํฌํจ (Slice 07 dedup์ฉ โ v6 ๋ธ๋ก์ปค 9๋ฒ)
๐ค ํธ๋์คํ: lookupRules(diseaseCodes, alerts) ์๊ทธ๋์ฒ๋ฅผ Week 4์ ๋ฏธ๋ฆฌ ํฉ์ โ Week 5์ ์์ข
์ด ํธ์ถ
์๋ฝ ๊ธฐ์ค: ๋๊ตฌ ์ค๊ตฌ โ nx=89,ny=90, lookupRules(["I10","E11"], ["ํญ์ผ๊ฒฝ๋ณด"]) โ 2 advisory + rule_id
์ด ์ฌ๋ผ์ด์ค๋ AยทB ๋ ๋ค ์๋๋ ํตํฉ ์ฌ๋ผ์ด์ค.
A ๋ํ ๋ด๋น (๋ฐ์ดํฐ ๋ผ์ธ):
KmaForecastClientโgetVilageFcstํธ์ถ- TMX/TMN ์ถ์ถ + ์์ฒด ์๊ณ๊ฐ ํ์ (TMXโฅ35 โ ํญ์ผ๊ฒฝ๋ณด ๋ฑ)
logs.weather_observations_daily์ ์ฌ@Scheduled(cron="0 30 5 * * *", zone="Asia/Seoul")์ก โ Java 21 Virtual Threads๋ก ๊ฒฉ์๋ณ ๋ณ๋ ฌ ํธ์ถ- ์ก ๋ฝ: Redisson
RLock(weather_etl:job:{date}, TTL 30๋ถ)
B ์์ข ๋ด๋น (ํธ์ ๋ผ์ธ):
- ํ์ฑ ๋ถ๋ชจ ์กฐํ (
consent_data_share=true, nx/ny ์์) app.patient_conditionsJOIN์ผ๋ก disease_codeslookupRules()ํธ์ถ (โ ๋ํ์ Slice 06)- ์ฐ์ ์์ ์ ์ฑ (severity ์ํ ์ฐ์ 1ํธ์/์ผ)
- ์๋ ํผ๋ก ๋ฐฉ์ง +
requires_reviewskip device_tokens๋ณตํธํ (pgp_sym_decrypt) โ ํธ์ ๋ฐ์ก (mock)logs.advisory_push_log์ ์ฌ +delivery_status5์ข ๋ถ๊ธฐ- ๋ถ๋ชจ ๋ฝ: Redisson
RLock(weather_etl:parent:{pid}:{date}, TTL 5๋ถ)
๐ค ํฉ์ ํฌ์ธํธ:
WeatherEtlService.runDaily()๊ฒฐ๊ณผ DTO (๊ด์ธก + alerts ๋ฆฌ์คํธ)WeatherPushService.dispatch(observation, parents)์๊ทธ๋์ฒ- Redisson ์ค์ ยทํค ๋ค์์คํ์ด์ค
- ํตํฉ e2e: TMX=36 mock โ ํญ์ผ๊ฒฝ๋ณด โ I10 ๋ถ๋ชจ์ ํธ์ 1๊ฑด
์์ฒด ์๊ณ๊ฐ:
| ์กฐ๊ฑด | derived_alerts |
|---|---|
| TMX โฅ 35โ | ํญ์ผ๊ฒฝ๋ณด |
| 33 โค TMX < 35 | ํญ์ผ์ฃผ์๋ณด |
| TMN โค -15โ | ํํ๊ฒฝ๋ณด |
| -15 < TMN โค -12 | ํํ์ฃผ์๋ณด |
์๋ฝ ๊ธฐ์ค: TMX=36 mock โ ํญ์ผ๊ฒฝ๋ณด+ํธ์ 1๊ฑด, ๊ฐ์ (parent,rule,date) 2๋ฒ์งธ โ skipped_fatigue, I10(์ํ)+E11(์ฃผ์) โ I10 1ํธ์๋ง, ETL 2๋ฒ ํธ๋ฆฌ๊ฑฐ โ skipped_duplicate_run
์ธ๋ถ API 3๊ฐ (HIRA):
| API | sno | ์ฉ๋ |
|---|---|---|
| ๋ณ์์ ๋ณด์๋น์ค | 11999 | yadmNm/ykiho |
| ์๋ฃ๊ธฐ๊ด๋ณ์์ธ์ ๋ณด | 12101 | ์๊ธ์ค ์ด์ |
| ์ฝ๊ตญ์ ๋ณด์๋น์ค | 12100 | ๋จ๊ณจ ์ฝ๊ตญ |
ํ
์ด๋ธ: app.parent_hospitals, app.hospital_emergency_info, app.parent_pharmacies, app.emergency_cards (PK parent_id, UNIQUE qr_token)
snapshot ์ฐ์ ์์:
- ์๋ ๋ฅด๊ธฐ (severity DESC) โ TOP
- ํญ์๊ณ ์
atc_code LIKE 'B01A%'โ 2์์ - ๊ธฐ์ ์งํ (Slice 05 JOIN)
- ์ ์ฒด ์ฝ์ฅ (ํญ์๊ณ ์ ์ต์๋จ)
- ๋จ๊ณจ ๋ณ์ยท์ฝ๊ตญ
QR ํ ํฐ: Java SecureRandom 48๋ฐ์ดํธ โ Base64URL โ 64์
access_count ์์์ ์ฆ๊ฐ:
UPDATE app.emergency_cards SET access_count = access_count + 1, last_accessed_at = NOW()
WHERE qr_token = :t;Rate Limit (Bucket4j + Redis):
- IP๋น ๋ถ๋น 10ํ / ํ ํฐ๋น ์๊ฐ๋น 60ํ / ํ ํฐ๋น ์ผ์ผ 200ํ (์ด๊ณผ ์ ์๋ revoke)
ํต์ฌ API:
POST /v1/parents/{parentId}/emergency-card/regenerate โ 200 { qr_token, qr_url:"/em/{token}", valid_until }
POST /v1/parents/{parentId}/emergency-card/rotate โ 200 (new token, old revoked)
POST /v1/parents/{parentId}/emergency-card/revoke โ 204
GET /em/{token} โ 200 snapshot / 410 Gone / 429 Too Many
์๋ฝ ๊ธฐ์ค: ํญ์๊ณ ์ ์ ๋ ฌ, access_count ์์ ์ฆ๊ฐ (๋์์ฑ ๋ถํ ํ ์คํธ), ๋ง๋ฃ ํ 410, revoke ํ ์ฆ์ 410, Rate limit โ 429 + ์๋ revoke
mamokey.mom_med.backend
โโโ BackendApplication.java
โ
โโโ global/ ๐ค ๊ณต๋
โ โโโ config/ SecurityConfig, JpaConfig, RedisConfig, OpenApiConfig, RestClientConfig
โ โโโ exception/ GlobalExceptionHandler, SafetyBlockException, ApiException
โ โโโ response/ ApiResponse, ErrorResponse, BlockErrorResponse (record)
โ โโโ util/ DrugNameNormalizer[A], HallucinationVerifier[A], KcdRangeExpander[B]
โ โโโ audit/ BaseTimeEntity
โ โโโ ratelimit/ Bucket4j ์ค์ (Slice 08)
โ
โโโ infra/
โ โโโ cache/ RedisCacheService [B ๋๋ ๊ณต๋]
โ โโโ crypto/ PgCryptoService (pgp_sym_encrypt/decrypt) [B]
โ โโโ llm/ GeminiClient (RestClient ๋๋ WebClient) [A]
โ โโโ lock/ RedissonLockService (Slice 07) [๊ณต๋]
โ โโโ scheduler/ WeatherEtlScheduler (@Scheduled) [A ํธ๋ฆฌ๊ฑฐ]
โ
โโโ external/
โ โโโ mfds/ MfdsClient (์์ฝ์ฒ 3 API) [A]
โ โโโ hira/ HiraDurClient ยท HiraHospitalClient ยท HiraPharmacyClient ยท HiraDiseaseClient [HiraDur=A / HospitalยทPharmacyยทDisease=B]
โ โโโ kma/ KmaForecastClient [A]
โ
โโโ domain/
โโโ drug/ Slice 01 โ Drug, PillVisual, DrugController [A]
โโโ dur/ Slice 02 โ DurCombo*, DurEngineService [A]
โโโ nb/ Slice 03 โ NbExtraction, NbInteraction [A]
โโโ safety/ Slice 02+03 โ SafetyJudgeService, Verdict, Evidence (sealed) [A ๊ตฌํ / B ํธ์ถ]
โโโ parent/ Slice 04 โ PatientProfile, ParentController [B]
โโโ medication/ Slice 04 โ PatientMedication, MedicationController, SafetyBlockException [B]
โโโ allergy/ Slice 04 โ PatientAllergy [B]
โโโ device/ Slice 04 โ DeviceToken (pgcrypto) [B]
โโโ condition/ Slice 05 โ PatientCondition, DiseaseMaster [B]
โโโ weather/ Slice 06+07 โ WeatherRule, RegionGrid, WeatherAdvisorService [A] / WeatherEtlService [A] / WeatherPushService [B]
โโโ emergency/ Slice 08 โ EmergencyCard, ParentHospital, ParentPharmacy, PublicEmController [B]
โโโ log/ SafetyCheckLog, AdvisoryPushLog, ApiCallLog (logs.*) [๊ฐ์ ์๊ธฐ ์ฌ๋ผ์ด์ค ์ฑ
์]
| Method | URL | ์ฌ๋ผ์ด์ค | ๋ด๋น | ์ธ์ฆ |
|---|---|---|---|---|
| GET | /v1/drugs/identify?name= |
01 | A | โ |
| GET | /v1/drugs/{itemSeq}/contraindications |
03 | A | โ |
| POST | /v1/parents |
04 | B | โ |
| GET / PATCH / DELETE | /v1/parents/{id} |
04 | B | โ |
| GET | /v1/parents/{id}/medications |
04 | B | โ |
| POST | /v1/parents/{id}/medications โ
|
04 | B (+๐ค SafetyJudge) | โ |
| DELETE | /v1/parents/{id}/medications/{medId} |
04 | B | โ |
| POST / GET / DELETE | /v1/parents/{id}/allergies |
04 | B | โ |
| POST / DELETE | /v1/parents/{id}/device-tokens |
04 | B | โ |
| POST / GET / DELETE | /v1/parents/{id}/conditions[/{id}] |
05 | B | โ |
| POST | /v1/safety/check |
02/03/05 | A | โ |
| GET | /v1/parents/{id}/weather-advisory |
06/07 | B (Controller) โ A (๋ฃฉ์ ) | โ |
| POST | /v1/parents/{id}/hospitals |
08 | B | โ |
| POST | /v1/parents/{id}/pharmacies |
08 | B | โ |
| POST | /v1/parents/{id}/emergency-card/regenerate |
08 | B | โ |
| POST | /v1/parents/{id}/emergency-card/rotate |
08 | B | โ |
| POST | /v1/parents/{id}/emergency-card/revoke |
08 | B | โ |
| GET | /em/{token} |
08 | B | โ Rate Limit |
| POST | /v1/admin/weather/etl-run |
07 | A | ๊ด๋ฆฌ์ |
| GET | /v1/admin/weather/recent-pushes?days= |
07 | B | ๊ด๋ฆฌ์ |
| GET | /v1/admin/weather-rules?status=needs_review |
06 | A | ๊ด๋ฆฌ์ |
| POST | /v1/admin/weather-rules/{id}/approve |
06 | A | ๊ด๋ฆฌ์ |
| ์ฌ๋ผ์ด์ค | ๋ฐ์ดํฐ | ์์น | ํฌ๊ธฐ | ๋ด๋น |
|---|---|---|---|---|
| 02 | DUR 5๊ฐ CSV | data/๊ฑด๊ฐ๋ณดํ์ฌ์ฌํ๊ฐ์_์์ฝํ์์ ์ฌ์ฉ์๋น์ค(DUR)*/ |
240MB (๋ณ์ฉ๊ธ๊ธฐ 231MB ๋ณ๋ ๋ค์ด๋ก๋) | A |
| 01ยท02 | ATC ๋งคํ | data/๊ฑด๊ฐ๋ณดํ์ฌ์ฌํ๊ฐ์_ATC์ฝ๋ ๋งคํ ๋ชฉ๋ก_20250630.csv |
2.4MB | A |
| 05 | ์ง๋ณ๋ง์คํฐ | HIRA 11984 ๋ณ๋ ๋ค์ด๋ก๋ | โ | B |
| 06 | ๋ ์จ ๋ฃฐ์ 32๊ฐ | seed/weather_rules_v0.2.json |
76KB | A |
| 06 | ๊ฒฉ์ ์ขํ 26K | ๊ธฐ์์ฒญ41_๋จ๊ธฐ์๋ณด_*/๊ฒฉ์_์๊ฒฝ๋(2510).xlsx |
680KB | A |
| 03 ํ๊ท | NB ์ถ์ถ ๊ณจ๋ | nb_amlodipine.*, nb_warfarin.* |
66KB | A |
231MB CSV ์ถ์ฒ: HIRA ๊ณต๊ณต๋ฐ์ดํฐํฌํธ sno 11983 (data.go.kr/data/15095677)
- ์นด์นด์ค OAuth ยท SMS ๋ณธ์ธ์ธ์ฆ โ "์ธ์ฆยท์ธ์ PRD"
- ์นด์นด์คํก ์๋ฆผํก / FCMยทAPNs ์ค์ ๋ฐ์ก โ "ํธ์ ์ธํ๋ผ PRD"
- ์ฒ๋ฐฉ์ OCR โ "OCR PRD"
- ์๋ฃ์ง ๊ฒ์ ์ด๋๋ฏผ โ "๊ด๋ฆฌ์ PRD"
MVP์์ ์๋
์ธ์
์ X-Parent-Owner ํค๋ ๋๋ mock JWT๋ก ๋์ฒด. (์์ข
์ด ์ธ์ฆ mock ์ธํฐ์
ํฐ ์ค์น)
- DUR 837kํ LIKE ๊ธ์ง โ
ingredient_norm = ?์ ํ๋งค์นญ (A ๋ํ) - partial UNIQUE๋ PostgreSQL ํนํ โ Testcontainers PostgreSQL ํ์ (H2 โ)
- ์๋ณ RANGE ํํฐ์
์๋ ์์ฑ ์ก โ
pg_partmanor Spring@Scheduled๋ก ๋ค์๋ฌ ๋ฏธ๋ฆฌ ์์ฑ (๊ณต๋, 04ยท07 ์ฌ์ฉ) - Spring Boot 4.x:
spring-boot-starter-webโwebmvc(์ด๋ฏธ ์ ์ฉ), Java 17 ๋ฏธ๋ง ํธํ ๋ถ๊ฐ โ Java 21 ์ฌ์ฉ
- ์ฝ ์ถ๊ฐ:
safety_check_log INSERT๋@Transactional(REQUIRES_NEW)๋๋ ApplicationEvent๋ก BLOCK rollback๊ณผ ๋ถ๋ฆฌ (B ์์ข ) device_tokensํญ์pgp_sym_encrypt/decrypt๋ค์ดํฐ๋ธ ์ฟผ๋ฆฌ. JPA AttributeConverter๋ก๋ ๊ตฌํ ๊ฐ๋ฅ (B ์์ข )/em/{token}Bucket4j + Redis Rate Limit +Cache-Control: no-store(B ์์ข )- RLS๋ ์ธ์ฆ ์์คํ
๋ถ์ ํ ์ธํฐ์
ํฐ์์
SET LOCAL app.current_parent_id์๋ ์ฃผ์ (๊ณต๋)
source_quote๊ฐ NB ์๋ฌธ์ byte-for-byte ์กด์ฌํ์ง ์์ผ๋ฉด ์ฌ์ฉ์ ๋ ธ์ถ ๊ธ์งverified=false๋ฐ์ดํฐ๋ SELECT์์ ์ ์ธ- ํ๊ฐ ๊ฒ์ฆ ๋จ์ํ ์คํธ ํ์ (Slice 00 ์๋ฝ๊ธฐ์ค)
- build.gradle ์์กด์ฑ ์ถ๊ฐ (data-jpa, data-redis, flyway, redisson, bucket4j, mapstruct, testcontainers)
-
docker-compose.ymlโ PostgreSQL 14 + Redis 7 - Flyway:
V001__extensions.sql,V002__schemas.sql,V003__audit_trigger.sql -
application.yaml+ ํ๊ฒฝ๋ณ์ 7๊ฐ +application-local.yaml -
BaseTimeEntity(@MappedSuperclass, created_at/updated_at) -
GlobalExceptionHandler+ApiResponse<T> record+ErrorResponse record+BlockErrorResponse record -
SafetyBlockException,ApiException -
SafetyJudgeService์ธํฐํ์ด์ค ์๊ทธ๋์ฒ ํฉ์ ํ ๋ฐ์๋๊ธฐ (Week 3 ํตํฉ ๋๋น) - CI (GitHub Actions: build, test, Flyway dry-run)
-
DrugNameNormalizer(15 ์ผ ๋ชฉ๋ก) + 6 ์ผ์ด์ค ๋จ์ํ ์คํธ -
HallucinationVerifier(3๋จ๊ณ ๋งค์นญ) + ๋จ์ํ ์คํธ -
GeminiClient(RestClient) + ping ํตํฉํ ์คํธ - Slice 01 ์์:
MfdsClient๊ณจ๊ฒฉ +Drug์ํฐํฐ
-
RedisCacheService(read-through, fallback) -
PgCryptoService(๋ค์ดํฐ๋ธ ์ฟผ๋ฆฌ) - Slice 04 ์์:
PatientProfile,PatientMedication,PatientAllergy,DeviceToken์ํฐํฐ + Repository -
ParentController,MedicationController์ค์บํด๋ (SafetyJudge mock์ผ๋ก ์ฐ์ ๋์)
- DUR 5 CSV ์ ์ฌ (Spring Batch Job ๋๋
CommandLineRunner) โ cp949 ์ธ์ฝ๋ฉ -
ref.dur_*5 ํ ์ด๋ธ + ingredient_norm ์ธ๋ฑ์ค ๋ง์ด๊ทธ๋ ์ด์ - ATC ๋งคํ CSV ์ ์ฌ
-
DurEngineService์ ํ๋งค์นญ (ingredient_norm = ?) -
SafetyJudgeServiceMVP ๊ตฌํ (DUR + ๋ ธ์ธ์ฃผ์๋ง, NB๋ ๋ค์ ์ฃผ) - EXPLAIN์ผ๋ก ์ธ๋ฑ์ค hit ๊ฒ์ฆ
- amlodipine+itraconazole BLOCK ํตํฉํ ์คํธ
-
ParentControllerCRUD ์์ฑ (POST/GET/PATCH/DELETE) -
MedicationController.addMedicationโ mock SafetyJudge๋ก ๋์ -
AllergyControllerCRUD -
DeviceTokenController+ pgcrypto ์ํธํ ๋จ์ํ ์คํธ -
logs.safety_check_log์๋ณ ํํฐ์ + ์ฒซ ํํฐ์ ๋ง์ด๊ทธ๋ ์ด์ - partial UNIQUE ์๋ฐ ํตํฉํ ์คํธ (Testcontainers)
- soft delete ํตํฉํ ์คํธ
- HTTP 409 BLOCK ์๋ต ๊ตฌ์กฐ ์ ๊ฒ (
api-types/safety.ts์ ์ ํฉ)
- NB ์ถ์ถ LLM ํ์ดํ๋ผ์ธ (
NbExtractionService.extract(itemSeq)) -
derived.nb_extractions,derived.nb_interactionsํ ์ด๋ธ (entry_type ์ฒ์๋ถํฐ ํฌํจ) - HallucinationVerifier ํตํฉ (์คํจ ์ 1ํ ์ฌ์๋,
verified=false์ ์ธ) - ์ฝ๋ฌผ๊ตฐ โ ATC prefix ๋งคํ ์ฌ์
-
SafetyJudgeService.judgeFull()์์ฑ (DUR + NB ์๋ฐฉํฅ) - ํ๊ท: amlodipine 39 entry / warfarin 46 entry / ํ๊ฐ 0
-
HiraDiseaseClient(HIRA 12904 XML) -
ref.disease_masterCSV ์ ์ฌ (47,798ํ) -
ConditionController+app.patient_conditions์ ๊ทํ - KCD ๋งคํ ์ฌ์ +
KcdRangeExpander์ ํธ (K70-K77โ set ํ์ฅ)
- A์
SafetyJudgeService์ค๊ตฌํ โ B์MedicationController๊ฐ ํธ์ถํ๋๋ก swap - e2e: ๋ ธ๋ฐ์คํฌ+์ฌ๋ฐ์คํํด ์ถ๊ฐ โ 409 ์๋ต ๊ฒ์ฆ
- e2e: ๋ถ๋ชจ K25 + NSAID ์ถ๊ฐ โ WARN ์๋ต ๊ฒ์ฆ
- e2e: ๋ถ๋ชจ ํ๋์ค๋ฆฐ ์๋ ๋ฅด๊ธฐ + amoxicillin โ 409 BLOCK ๊ฒ์ฆ
- AยทB ํ์ด PR review
-
weather_rules_v0.2.json32 ๋ฃฐ ์ ์ฌ (ref.weather_rules) - ๊ฒฉ์์ขํ xlsx 26K ์ ์ฌ (
ref.region_grid) โ Apache POI -
WeatherAdvisorService.lookupRules(diseaseCodes, alerts)(rule_id ํฌํจ) -
WeatherAdvisorService.updateParentGrid(parentId)(๋ ์ฐ์ ๋งค์นญ) - ์๋ฃ์ง ๊ฒ์ ์ด๋๋ฏผ API (
needs_reviewํ, 17๊ฑด)
-
HiraHospitalClient/HiraPharmacyClient(XML) -
parent_hospitals,hospital_emergency_info,parent_pharmacies,emergency_cardsํ ์ด๋ธ -
EmergencyCardService.regenerate()โ ํญ์๊ณ ์ ์ ๋ ฌ, snapshot JSON ์์ฑ -
PublicEmController.getByToken()โGET /em/{token}+ Bucket4j Rate Limit + Redis ์นด์ดํฐ - ํ ํฐ ํ์ ยทrevokeยท๋ง๋ฃ ์๋๋ฆฌ์ค
-
access_count์์ ์ฆ๊ฐ ๋์์ฑ ํ ์คํธ (JMeter or ๊ฐ๋ฒผ์ด ๋ถํ ํ ์คํธ)
-
lookupRules์๊ทธ๋์ฒ ํ์ (Week 5 ETL ํตํฉ ๋๋น)
-
KmaForecastClient.getVilageFcst(nx, ny, baseDate, baseTime) - TMX/TMN ์ถ์ถ + ์์ฒด ์๊ณ๊ฐ ํ์ ๋ก์ง
-
logs.weather_observations_dailyํํฐ์ ์ ์ฌ -
@Scheduled(cron="0 30 5 * * *", zone="Asia/Seoul")์ก โ Java 21 Virtual Threads๋ก ๊ฒฉ์ ๋ณ๋ ฌ ํธ์ถ - Redisson ์ก ๋ฝ (
weather_etl:job:{date})
- ํ์ฑ ๋ถ๋ชจ ์กฐํ + ๊ฒฉ์ ๊ทธ๋ฃนํ
-
app.patient_conditionsJOIN โ A์lookupRules()ํธ์ถ - ์ฐ์ ์์ ์ ์ฑ (severity ์ํ ์ฐ์ 1ํธ์/์ผ)
- ์๋ ํผ๋ก ๋ฐฉ์ง +
requires_reviewskip โdelivery_status5์ข ๋ถ๊ธฐ -
device_tokens๋ณตํธํ โ ํธ์ ๋ฐ์ก (mock) -
logs.advisory_push_logํํฐ์ ์ ์ฌ - Redisson ๋ถ๋ชจ ๋ฝ (
weather_etl:parent:{pid}:{date})
- ETL โ ํธ์ e2e: TMX=36 mock โ ํญ์ผ๊ฒฝ๋ณด โ I10 ๋ถ๋ชจ ํธ์ 1๊ฑด
- ์๋ ํผ๋ก: ๊ฐ์ (parent,rule,date) 2๋ฒ์งธ ํธ์ถ โ
skipped_fatigue - ์ฐ์ ์์: I10(์ํ)+E11(์ฃผ์) โ I10๋ง
- ์ค๋ณต์คํ: ETL 2ํ ํธ๋ฆฌ๊ฑฐ โ 2๋ฒ์งธ
skipped_duplicate_run - ๊ณต๋ชจ์ ๋ฐ๋ชจ ์๋๋ฆฌ์ค e2e ๋ฆฌํ์ค (HANDOFF_COMMON.md ยง13):
- ๋ถ๋ชจ ๋ฑ๋ก โ ์ฝ 4๊ฐ ์ถ๊ฐ (warfarin ํฌํจ)
- ์์คํผ๋ฆฐ ์ถ๊ฐ โ 409 BLOCK ๋ชจ๋ฌ
- ์๊ธ์นด๋ QR โ
/em/{token}์ธ๋ถ ์กฐํ โ critical_drugs์ warfarin - ํญ์ผ๊ฒฝ๋ณด ์๋ฎฌ โ I10 ๋งค์นญ advisory
- ํ๋ก ํธ์ ํตํฉ (
EXPO_PUBLIC_USE_MOCK=false)
- RLS ํ์ฑํ + ์ธ์ฆ ์ธํฐ์ ํฐ
- ์นด์นด์ค OAuth (๋ณ๋ PRD)
- FCM/APNs ์ค์ ๋ฐ์ก
- ์ฒ๋ฐฉ์ OCR ํตํฉ
- ์๋ฃ์ง ๊ฒ์ ์ด๋๋ฏผ
- Prometheus + Grafana ๋์๋ณด๋
- pg_partman ์๋ ํํฐ์
- Phase 2 ๋ฃฐ์ (์น๋งค, ๋ฏธ์ธ๋จผ์ง, ํฉ์ฌ)
- ๊ฐ ์ฌ๋ผ์ด์ค "์๋ฝ ๊ธฐ์ค" โ + ํ๊ท ํ ์คํธ ๊ทธ๋ฆฐ + PR ๋จธ์ง
- ๋จ์ ํ ์คํธ ๊ทธ๋ฆฐ
- Smoke Test ํต๊ณผ (๊ฐ ์ฌ๋ผ์ด์ค ๋ฌธ์ ํ๋จ
curl์๋๋ฆฌ์ค) - ๋ค๋ฅธ ๊ฐ๋ฐ์๊ฐ README๋ง ๋ณด๊ณ
docker compose upโ ํ๊ฒฝ ๊ธฐ๋ ๊ฐ๋ฅ
๊ตฌํ ์์ยทDDLยทAPI ๊ณ์ฝ์ issues_v2/*.md๊ฐ ground truth. ๋ณ๊ฒฝ ํ์ํ๋ฉด ์ฌ๋ผ์ด์ค ๋ฌธ์ ๋จผ์ ์์ , ์ฝ๋๋ ๊ทธ ๋ค์.
| ๋ฐฑ์๋ ์ฌ๋ผ์ด์ค ์๋ฃ ์ | ํ๋ก ํธ๊ฐ mock โ ์ค API ๊ต์ฒด | ์ฑ ์ |
|---|---|---|
| 04 (์ฝ์ฅ + ์์ ํ์ ) | F2 (โ ๋ฐ๋ชจ ํต์ฌ) | B ์์ข |
| 05 (์ง๋ณ๊ธ๊ธฐ) | F3 | B ์์ข |
| 06ยท07 (๋ ์จ) | F5 | ๊ณต๋ |
| 08 (์๊ธ์นด๋) | F4 | B ์์ข |
| 01 (์ฝ ๋ง์คํฐ) | F2ยทF4ยทF6 ์์ฝ ์ฌ์ง | A ๋ํ |
๊ฐ ์ฌ๋ผ์ด์ค PR ๋จธ์ง ์ ํ๋ก ํธ ๊ฐ๋ฐ์์๊ฒ Slack/Teams ์๋ฆผ โ ๋ด๋น์๊ฐ ์ง์ .
| ์ํฉ | ์ฐธ์กฐ |
|---|---|
| DDL ๋ชจ๋ฅด๊ฒ ์ | ํด๋น ์ฌ๋ผ์ด์ค์ "DB ์ ์ฌ" ์น์ |
| API ์๋ต ๊ตฌ์กฐ | ํด๋น ์ฌ๋ผ์ด์ค์ "API ๊ณ์ฝ" + api-types/*.ts |
| ๋ธ๋ก์ปค 12๊ฐ ๊ฒฐ์ | PRD_์๋ง์ฝ_v6.md ยง๋ธ๋ก์ปค 12๊ฐ ๋งคํ |
| ํ๋ก ํธ์ ์ถฉ๋ | HANDOFF_COMMON.md |
| ์ ์ฒด ํ๋ฆ ์๊ฐํ | ์๋ง์ฝ_API_ํ๋ฆ๋_v8.html (๋ธ๋ผ์ฐ์ ) |
| Spring Boot 4.x ๋ง์ด๊ทธ๋ ์ด์ ๊ฐ์ด๋ | https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Release-Notes |
| ์ง๊ฟ์ด ๋งํ | ํ์ด ํ๋ก๊ทธ๋๋ฐ / ํ์ด PR review |