Skip to content

feat : B1 캘린더용 정책상품 회차·온통청년 청년정책 수집 - #19

Draft
yuyeol3 wants to merge 7 commits into
mainfrom
yuyeol3/feat/youth-policy-calendar
Draft

yuyeol3 wants to merge 7 commits into
mainfrom
yuyeol3/feat/youth-policy-calendar

Conversation

@yuyeol3

@yuyeol3 yuyeol3 commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

변경점 👍

B1 캘린더 데이터를 수집합니다. 스키마(V10)와 조회 API는 ApptiveDev/Fin-BE#43에 있습니다.

계층 원천 저장
1계층: 정책상품 회차 manual-product-rounds.json(사람이 관리) policy_product_round
2계층: 온통청년 정책 온통청년 getPlcy API youth_policy, youth_policy_round

1. 1계층: 정책상품 회차 (policyround)

  • 온통청년과 자동으로 연결하지 않고 파일로 관리합니다.
    • 온통청년은 정책번호가 해마다 바뀌고, 공고보다 늦게 올리거나 아예 올리지 않습니다(2026년 16종 중 시작 전 등록 4종).
    • PRD "운영자가 승인한 회차만 노출"과 같습니다.
  • 2026 데이터: 14종 17회차, 회차마다 근거 링크
    • 경기 청년 노동자 통장(2026 모집 미정), 대구 청년희망적금(2026 공고 없음)은 rounds 비우고 note에 사유
  • 동기화(PolicyProductRoundSyncService)
    • 상품은 source=ONTONG + product_code로 찾음
    • 파일 안 순서(round_no)로 맞추고, 날짜가 바뀌면 schedule_changed_at 기록, 파일에서 빠진 회차는 삭제
    • 아직 수집 안 된 상품은 건너뛰고 경고 로그
    • 파일 형식 오류(시작일 누락, 순서 역전)는 step 실패
  • manual-products.json: POLICY014 이름을 세종청년희망적금으로 변경(2026 사업명)

2. 2계층: 온통청년 정책 (youthpolicy)

  • 범위: PRD 개요의 "자산형성 저축·공제·신용회복"(2026-11 기준 3,843건 중 90건)
    • 정책 이름 키워드로 고르고 온통청년 분류는 보지 않습니다. 분류로 거르면 내일채움공제(재직자), 학자금 신용회복(교육비지원)이 빠집니다.
    • 주거(청약통장, 전세·임차 대출이자 등)와 세금 공제는 제외
    • 상수: YouthPolicyParser.SCOPE_NAME, EXCLUDED_NAME
  • 파싱: 신청기간 구분(PERIOD/ALWAYS/CLOSED), 여러 회차(\N 구분), 나이 0 → NULL, 읽을 수 없는 회차는 경고 후 버림
  • 동기화: 정책 번호로 upsert, 날짜 바뀐 회차만 변경 기록, 사라진 정책은 비활성화
  • 1계층과 같은 사업은 저장하지 않음 (PolicyProductMatcher)
    • PRD상 2계층은 "Y-Fin 상품 DB에 없는 정책"인데, 범위 90여 건 중 절반 넘게 정책상품 16종을 시군·연도별로 다시 등록한 정책이었습니다(청년내일저축계좌만 21건).
    • 판정: 정책상품명을 띄어쓰기로 나눈 단어가 모두 정책명(공백·기호 제거)에 들어 있으면 같은 사업
      • 예: 자산형성지원사업(청년내일저축계좌), 청년근로자 사랑채움사업(← 청년 사랑채움사업), 영암군 청년 희망 디딤돌 통장사업
    • 측정(2026-11-04 데이터, 직접 단 정답 51건): 50건 잡음, 오탐 0. 은행 상품용 ProductNameSimilarity는 괄호 안을 지워 48건이었고, 임계값 여유도 0.03이었습니다.
    • 놓치는 경우: 지역이 이름에 없고 기관에만 있는 정책(2025 대구 청년희망적금)
    • 뺀 정책은 상품코드·기관·회차를 로그로 남깁니다. 시군 별도 모집(영암·영광 희망디딤돌, 경주 사랑채움)처럼 1계층과 회차가 다른 경우가 있어 manual-product-rounds.json 갱신 때 참고용입니다.

3. 배치

  • Source.CALENDAR → calendarSyncFlow: syncPolicyProductRoundStep → syncYouthPolicyStep
    • 상품 수집과 따로 돌릴 수 있게 분리(PRD: 하루 1회 이상 갱신)
  • ALL에도 마지막 step들로 포함(정부상품 정규화 뒤라야 회차를 붙일 상품이 있음)
  • 범위 안 정책이 0건이면 syncYouthPolicyStep 실패(전체 비활성화 방지)

버그 해결 💊

  • OntongYouthClient: 온통청년이 가끔 HTML 오류 페이지나 400·403·500을 돌려줌 → 페이지마다 3번까지 재시도
    • 실제로 여러 번 받아 보니 39페이지 중 0~2번씩 발생, 모두 재시도로 회복
    • 로그와 예외에는 예외 종류만 남김(I/O 오류 메시지에 API 키가 든 URL이 들어감)

테스트 💻

  • ./gradlew test 420개 통과
  • 추가·변경한 테스트
    • YouthPolicyParserTest 11: 회차 분리, 상시·마감, 읽을 수 없는 날짜, 나이 0, 범위 키워드, 오탐 이름, 주거 제외, 분류 무관 선택
    • PolicyProductRoundSyncServiceTest 4(Testcontainers): 저장, 날짜 변경 표시, 빠진 회차·상품 삭제, 미수집 상품 건너뜀
    • YouthPolicySyncServiceTest 4(Testcontainers): 저장, 변경 표시, 삭제·비활성화, 재활성화
    • ManualProductRoundsFileTest: 회차 파일 형식과 상품 코드가 manual-products.json과 일치
    • PolicyProductMatcherTest 15: 실제 온통청년 정책명으로 같은 사업 10개는 맞는 상품, 다른 정책 5개는 매칭 없음
    • SyncYouthPolicyTaskletTest 4(정책상품과 같은 사업 제외 포함), OntongYouthClientTest 3, AllSyncFlowTest(새 step 반영)
  • 변이 검사: 날짜 변경 감지, 빠진 상품 회차 삭제를 빼면 동기화 테스트 실패 확인
  • 실제 API: 빈 Postgres(Fin-BE#43 V10) → ONTONG → CALENDAR 순서로 실행(LLM 끔)
    • 1회차: 정책상품 회차 17개, 온통청년 90건 저장(저축 55, 공제 18, 신용회복 17)
    • 2회차: 회차 변경 0건, 비활성화 0건

비고 ✏

  • 서브모듈: fin-be가 머지 전 Fin-BE 캘린더 브랜치 커밋(a877ba8)을 가리킵니다. 그래서 draft입니다.
  • 머지 순서: Fin-BE#41(V9) → Fin-BE#43(V10) → 서브모듈 포인터를 main 커밋으로 갱신 → 이 PR
  • 소스 이름 변경: 처음 올린 YOUTH_POLICY를 CALENDAR로 바꿨습니다. 아직 이 이름을 쓰는 워크플로는 없습니다.
  • 운영 반영: 머지 후 다음 ALL 수집부터 캘린더 데이터가 채워집니다. 매일 실행 워크플로(COLLECTOR_SOURCE=CALENDAR)는 별도 PR로 올립니다.
  • 1계층 갱신 방법: 새 공고가 나오면 manual-product-rounds.json에 회차를 추가하는 PR을 올리면 됩니다. 온통청년 새 회차 후보 알림은 아직 없습니다.

궁금한 점 있으시면 편하게 질문 주세요!

@yuyeol3 yuyeol3 changed the title feat : B1 캘린더용 온통청년 청년정책 수집 feat : B1 캘린더용 정책상품 회차·온통청년 청년정책 수집 Nov 1, 2026
2계층은 Y-Fin 상품 DB에 없는 정책만 보여준다(B1 PRD). 온통청년에는 정책상품 16종을
시군·연도별로 다시 등록한 정책이 많아(2026-11 범위 92건 중 51건) 같은 사업이 두 계층에 뜬다.

정책상품명을 띄어쓰기로 나눈 단어가 모두 정책명(공백·기호 제거)에 들어 있으면 같은 사업으로 본다.
51건 중 50건을 잡고 오탐이 없었다. 은행 상품용 ProductNameSimilarity는 괄호 안을 지워
"자산형성지원사업(청년내일저축계좌)" 같은 이름을 놓쳐 48건이었다.
시군 별도 모집처럼 회차가 1계층과 다를 수 있어 뺀 정책은 회차와 함께 로그로 남긴다.
zwo-n pushed a commit to ApptiveDev/Fin-BE that referenced this pull request Nov 9, 2026
# 변경점 👍

## 개요

B1 정부상품 캘린더 기능을 위한 일정 및 요약 조회 API를 구현했습니다.

기획서(PRD)에서 정의한 두 데이터 계층을 통합 조회하되, 응답의 `tier` 필드로 구분합니다. 데이터 수집 및 적재는 별도
수집기([Fin-API #19](https://github.com/ApptiveDev/Fin-API/pull/19))에서
담당합니다.

| `tier` | 데이터 계층 | 저장 테이블 | 상세 페이지 |
|---|---|---|---|
| `POLICY_PRODUCT` | 1계층: 정부 정책상품 16종 | `policy_product_round` | Y4 상품
상세 (`productId`) |
| `YOUTH_POLICY` | 2계층: 온통청년 금융 지원 정책 | `youth_policy`,
`youth_policy_round` | 온통청년 상세 (`externalUrl`) |

## 1. 데이터 모델 추가 (Flyway V10)

캘린더 데이터 관리를 위해 다음 테이블을 추가했습니다.

- `youth_policy`: 온통청년 정책 기본 정보
- `youth_policy_round`: 온통청년 정책별 신청 회차
- `policy_product_round`: 기존 정부 정책상품(`product`)의 신청 회차 및 근거 URL

**1계층 정책상품의 신청 회차는 별도로 관리합니다.**

온통청년 정책번호는 연도 및 회차에 따라 변경될 수 있으며, 공고 등록이 지연되거나 누락되는 사례도 있어 기존 정부상품과
안정적으로 연결하기 어렵습니다.

이에 따라 사람이 확인한 신청 회차를 별도로 관리하도록 구성했습니다.

## 2. 캘린더 조회 API 구현

두 API 모두 비로그인 상태에서 호출할 수 있습니다.

| API | 기능 |
|---|---|
| `GET /calendar/events?from=&to=` | 지정 기간 내 신청 시작·마감 일정 조회 |
| `GET /calendar/summary` | 오늘 기준 신청 가능·연중 접수·상시 신청 정책 조회 |

### 기간별 일정 조회

- `from`, `to`를 통해 조회 기간을 지정하며, 시작일과 종료일을 모두 포함합니다.
- 신청 시작일 또는 마감일이 지정 기간에 포함되는 일정을 반환합니다.
- 월간 캘린더의 앞뒤 날짜, 주간 스트립, 리스트 뷰에서 동일한 API를 활용할 수 있도록 날짜 범위 기반으로 설계했습니다.
- 일정은 날짜순으로 정렬하며, 같은 날짜에서는 **마감 일정 우선, 동일 유형에서는 1계층 우선**으로 정렬합니다.

### 오늘 기준 요약 조회

`GET /calendar/summary`는 조회 월과 관계없이 오늘 기준 정보를 반환합니다.

- `openNow`: 현재 신청 가능한 일반 회차. 마감일이 가까운 순
- `yearRound`: 현재 접수 중인 연중 접수 회차
- `always`: 신청 기간이 별도로 정해지지 않은 상시 신청 정책 (2계층)

### 응답 및 필터링 정책

- 상품 유형(`type`), 데이터 계층(`tier`), 자격 판정(`eligibility`) 필터는 프론트엔드에서 처리합니다.
필터별 건수 표시를 고려한 설계입니다.
- `roundId`는 데이터 계층별로 독립적으로 부여되므로 `(tier, roundId)` 조합으로 식별해야 합니다.
- `eligibility`는 **로그인 및 약관 동의를 완료한 사용자**에게만 제공하며, 그 외에는 `null`을 반환합니다.
- `asOf`에는 온통청년 데이터의 마지막 수집 시각을 제공하여 데이터 기준일 표시에 활용할 수 있도록 했습니다.

**오류 처리**

| 조건 | HTTP 상태 | 오류 코드 |
|---|---|---|
| `from > to` | 400 | `CA001` |
| 날짜 형식 오류 또는 필수 파라미터 누락 | 400 | `C003` |

### 응답 예시 (실제 수집 데이터)

**1계층: 정부 정책상품**

```json
{
  "tier": "POLICY_PRODUCT",
  "productId": 14,
  "policyId": null,
  "roundId": 15,
  "name": "세종청년희망적금",
  "institution": "세종특별자치시",
  "summary": null,
  "scheduleKind": "PERIOD",
  "applyStartDate": "2026-05-06",
  "applyEndDate": "2026-05-15",
  "minAge": 19,
  "maxAge": 39,
  "regions": ["REGION_SEJONG"],
  "externalUrl": null,
  "scheduleChanged": false,
  "eligibility": null
}
```

※ 1계층의 `summary`는 LLM을 통한 요약 결과입니다. 해당 테스트 환경에서는 LLM을 비활성화한 상태로 데이터를
수집하여 `null`로 표시됩니다.

**2계층: 온통청년 금융 지원 정책**

```json
{
  "tier": "YOUTH_POLICY",
  "productId": null,
  "policyId": 15,
  "roundId": 12,
  "name": "학자금대출 신용유의자 신용회복 지원사업",
  "institution": "전남광주통합특별시",
  "summary": "한국장학재단 학자금대출 분할상환약정 초입금 지원\n총 약정금액(채무액)의 5%(100만원 이내)",
  "scheduleKind": "PERIOD",
  "applyStartDate": "2026-03-16",
  "applyEndDate": "2026-11-30",
  "minAge": null,
  "maxAge": null,
  "regions": ["REGION_GWANGJU", "REGION_JEONNAM"],
  "externalUrl": "https://www.youthcenter.go.kr/youthPolicy/ythPlcyTotalSearch/ythPlcyDetail/20261016005400213958",
  "scheduleChanged": false,
  "eligibility": null
}
```

## 3. 일정 및 자격 판정 규칙

### 일정 유형 판정 (`CalendarSchedule`)

PRD의 일정 분류 기준을 두 데이터 계층에 공통 적용했습니다.

- **연중 접수**: 신청 기간이 양 끝을 포함해 180일 이상이고, 종료일이 12월 31일인 경우
  - 마감 일정은 표시하지 않습니다.
  - 신청 시작 전이라면 시작 일정만 표시합니다.
- **일반 접수**: 연중 접수에 해당하지 않는 경우
  - 신청 시작일과 마감일을 각각 표시합니다.
  - 시작일과 마감일이 같으면 마감 일정만 표시합니다.

### 자격 판정 (`PolicyEligibility`)

사용자의 **나이와 거주지역**을 기준으로 정책 신청 가능 여부를 판정합니다.

- 프로필 정보가 부족해 판정할 수 없는 경우 `NEEDS_INFO`
- 하나 이상의 조건을 충족하지 못한 경우 `INELIGIBLE` 우선
- 조건을 모두 충족한 경우 `ELIGIBLE`

**계층별 판정 방식**

- 1계층: 상품 옵션별 나이 조건 중 가장 넓은 범위를 사용하고, `REGION_*` 키워드로 지역 제한을 확인합니다.
- 2계층: 온통청년 `zipCd`의 앞 두 자리(시도 코드)를 사용합니다.
  - 2026년 행정구역 개편 코드 반영: 광주·전남 `12`, 강원 `51`, 전북 `52`
  - 모든 시도가 대상인 경우 전국으로 처리합니다.

1계층은 현재 나이와 거주지역만 판정하며, PRD의 Y3-2 전체 자격 조건은 아직 반영하지 않았습니다.

### 일정 변경 감지

수집 과정에서 기존 회차의 신청 시작일 또는 종료일이 변경되면 변경 시각을 기록합니다.

변경 후 7일 동안 `scheduleChanged = true`를 반환하여 프론트엔드에서 일정 변경 여부를 표시할 수 있도록
했습니다.

## 4. 서비스 구조 및 성능 개선

**주요 구성 요소**

| 구성 요소 | 역할 |
|---|---|
| `CalendarService` | 일정·요약 조회, 정렬 및 사용자별 응답 처리 |
| `CalendarRoundMapper` | 엔티티를 캘린더 카드 DTO로 변환 |
| `CalendarSchedule` | 일정 유형 판정 및 시작·마감 이벤트 생성 |
| `PolicyEligibility` | 나이·거주지역 기반 자격 판정 |
| `ProductConditions` | 1계층 상품의 나이·지역·제공기관 정보 추출 |

**N+1 문제 개선**

1계층 상품의 신청 회차를 조회할 때 상품, 가입 옵션 및 제공기관을 함께 조회하도록 구성했습니다.

Hibernate 통계 기반으로 상품·정책 데이터 2건, 10건, 30건을 각각 측정한 결과, 기존에는 상품 수에 따라 쿼리가
증가했으나 개선 후에는 조회 건수와 관계없이 일정한 쿼리 수를 유지했습니다.

- `GET /calendar/events`: 6개
- `GET /calendar/summary`: 7개

## 5. 기타 변경 사항

- 쿼리 파라미터 타입 변환 오류(`MethodArgumentTypeMismatchException`)의 응답을 기존 500에서
400으로 수정
- `docs/implementation/calendar.md` 신규 작성
- `plan-vs-implementation.md`에 기획과 구현의 차이 기록
- `data-model.md`에 V10 테이블 구조 반영
- README의 API 목록 및 기능 구현 현황 갱신

---

# 테스트 💻

## 1. 자동화 테스트

```bash
./gradlew test integrationTest
```

**총 408개 테스트 통과**, 이 중 캘린더 관련 테스트는 26개입니다.

| 테스트 | 건수 | 검증 내용 |
|---|---:|---|
| `CalendarScheduleTest` | 9 | PRD 일정 예시, 180일 경계값, 연중 접수 시작 일정 |
| `PolicyEligibilityTest` | 9 | 나이 경계값, 지역 판정, 정보 부족, 미충족 우선순위, 개편 시도 코드
|
| `CalendarApiIntegrationTest` | 8 | 일정 정렬, 기간 조회, 비활성·마감 제외, 요약 분류, 계층별
자격 판정, 가입 불가 상품 제외, 일정 변경 감지, 잘못된 날짜 범위 처리 |

추가로 핵심 조건을 의도적으로 제거하는 변이 검사를 수행했습니다.

- 1계층 우선 정렬 제거
- 가입 불가능한 상품 제외 조건 제거

두 경우 모두 관련 통합 테스트가 실패하는 것을 확인했습니다.

## 2. 실제 데이터 연동 검증

빈 PostgreSQL 데이터베이스에 현재 브랜치 서버를 실행하고, [Fin-API
#19](ApptiveDev/Fin-API#19) 수집기를 통해 실제 데이터를 적재하여
검증했습니다.

**수집 결과**

- 정부 정책상품 신청 회차: 17개
- 온통청년 정책: 90건
- 2026년 캘린더 일정: 총 100건
  - 1계층: 34건
  - 2계층: 66건
- 현재 신청 가능 정책: 6건
- 상시 신청 정책: 14건
- 동일 데이터 재수집 시 변경된 회차: 0건

※ 신청 가능 건수는 검증 당시인 2026년 11월 2일을 기준으로 합니다.

---

# 스크린샷 🖼

로컬 DB 복사본에 Flyway V10을 적용한 뒤, Fin-API #19 수집기로 데이터를 적재하고 Swagger에서 API를
호출했습니다.

비로그인 상태로 검증하여 `eligibility`는 `null`이며, 1계층 `summary`는 LLM을 비활성화한 수집
환경이므로 비어 있습니다.

### 1. 기간별 일정 조회

`GET /calendar/events?from=2026-05-06&to=2026-05-08`

총 4건의 일정이 반환됩니다.

- 세종청년희망적금(1계층) 신청 시작
- 희망디딤돌(1·2계층) 신청 마감
- 함안정착 청년통장(1계층) 신청 시작

같은 날짜에서는 **마감 우선, 같은 일정 유형에서는 1계층 우선** 정렬이 적용되는 것을 확인했습니다.


![calendar-events](https://github.com/user-attachments/assets/27760830-ea13-4ac4-b05d-c3e817816abb)

### 2. 오늘 기준 요약 조회

`GET /calendar/summary`

2026년 11월 2일 기준 다음 결과를 확인했습니다.

- `openNow`: 6건
- `yearRound`: 0건
- `always`: 14건


![calendar-summary](https://github.com/user-attachments/assets/00ec1bfe-1faa-4beb-b772-67a593e8fcf9)

### 3. 잘못된 날짜 범위 요청

`from > to`인 경우 HTTP 400 및 `CA001` 오류 코드가 반환되는 것을 확인했습니다.


![calendar-events-invalid-range](https://github.com/user-attachments/assets/8dba6818-b82a-48b4-95a7-7ebe32520500)

---

# 비고 ✏

## 머지 순서 및 마이그레이션 유의사항

다음 순서로 머지해야 합니다.

1. 현재 PR: Flyway V10
2. Fin-API 서브모듈 포인터 갱신
3. Fin-API #19: 캘린더 데이터 수집

`policy_product_round` 테이블을 V10 마이그레이션에 추가했습니다. 해당 마이그레이션을 이전에 실행한 로컬
데이터베이스에서는 체크섬 불일치가 발생할 수 있으므로 **기존 로컬 DB를 재생성해야 합니다.**

---

궁금한 점이나 추가로 확인할 부분이 있다면 편하게 말씀해 주세요!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant