확장 안전 공고수집 배치 DB 설계
Background
- 입력 PRD:
20260722-타깃-공고-알림-및-지원-히스토리-prd.md - 입력 BE TDD:
20260730-확장-안전-공고수집-배치-tdd.md - 대상 구현 기준:
/Users/biuea/recruitment-application의V202607220000__baseline_schema_and_feature_flags.sql
PRD의 FR-9, FR-12, FR-14, FR-15, FR-19, FR-60, FR-66, FR-69와 NFR-2를 만족하려면, 한 스케줄러 호출에서 활성 소스를 전량 수집하는 현재 구조를 내구성 있는 slice 작업으로 바꿔야 한다. 특히 애그리게이터는 검색 조건 하나에 수천 건이 될 수 있으므로, 중단 후 재개·일일 예산·다중 인스턴스 중복 방지·완전 수집일 때만 마감 델타 적용을 DB에서 보장한다.
Overview
기존 job_posting_collection_runs는 이미 완료된 일별 수집 이력이며 run_status가 SUCCESS|FAILED 두 상태뿐이다. 진행 중 cursor와 lease를 그 테이블에 억지로 넣으면 구 클라이언트의 runStatus/abnormal 의미가 일시적으로 거짓이 된다. 따라서 job_posting_collection_cycles가 모든 v2 진행 상태의 유일한 SSOT이고, job_posting_collection_runs는 terminal 결과만 담는 호환 projection으로 역할을 분리한다.
| 데이터 단위 | 저장소 | 선택 근거 |
|---|---|---|
| 일일 source cycle·slice lease·누적 예산 | MySQL job_posting_collection_cycles | 유일성, 조건부 claim, 원자 상태 전이와 기존 Spring/JPA/MySQL 8 스택이 필요하다. |
| cycle 내 발견한 source job ID | MySQL job_posting_collection_seen_postings | (cycle_id, source_job_id) 멱등성 및 complete-only 미발견 판정에 트랜잭션 정합이 필요하다. |
| host 다음 요청 가능 시각 | MySQL external_request_host_limits | 여러 앱 인스턴스가 동일 host 간격을 공유하도록 짧은 row lock/원자 갱신이 필요하다. |
| 09:00 KST SLA cutoff gate와 source별 판정 | MySQL job_posting_collection_sla_snapshots, job_posting_collection_sla_snapshot_sources | cycle 상태 쓰기와 cutoff를 직렬화해 NFR-2의 정확한 09:00 결과만 불변으로 보존한다. cutoff를 놓치면 추정하지 않고 MISSED를 기록한다. |
| 기존 종료 이력 | MySQL job_posting_collection_runs | 기존 Operations API와 30일 성공률 집계의 읽기 계약을 계속 제공한다. |
전부 MySQL이다. MongoDB는 대량 비정형 원문·독립 TTL 로그 같은 채택 근거가 없고, 이 데이터는 관계형 유일 제약과 lease 정합성이 핵심이므로 채택하지 않는다.
Terminology
| 용어 | 정의 |
|---|---|
| cycle | KST run_date와 job_source_id로 유일한 하루의 논리 수집 작업 |
| slice | 한 worker가 lease 동안 수행하는 제한된 목록/상세 요청 묶음 |
| terminal cycle | SUCCESS, FAILED, BUDGET_EXHAUSTED 중 하나로 끝난 cycle |
| complete | cursor가 소진돼 전체 목록을 끝까지 읽은 SUCCESS; 이 경우에만 미발견/마감 델타를 실행 |
| seen set | 해당 cycle에서 실제 발견한 source_job_id 집합 |
| lease token | claim한 worker만 결과를 반영하도록 하는 UUID 값 |
Define Problem
AS-IS
CollectJobPostingsUseCase#execute와CollectAggregatorPostingsUseCase#execute가 활성 소스를 모두 메모리로 읽어 순차 처리한다. 두 클래스의perSourceTransaction은 HTTP 호출까지 포함한다.JobPostingCollectionDomainService#collect와AggregatorCollectionDomainService#fetch는(job_source_id, run_date)가 존재하면 건너뛴다. 재개 cursor, lease, 부분 누적 수치는 없다.- 기준 마이그레이션의
job_posting_collection_runs는run_status, 수집 수치, 실패 원인과 종료 시각만 보관한다. 유일키uk_job_posting_collection_runs_source_date(job_source_id, run_date)가 이미 일별 완료 회차를 보호한다. SourceRequestExecutor#awaitRequestDelay의 host 마지막 호출 시각은 프로세스 메모리이므로 다중 인스턴스에서는 FR-69의 host 간격을 보장하지 못한다.
TO-BE
새 job_posting_collection_cycles를 source-cycle queue의 SSOT로 둔다. cycle은 slice마다 cursor/누적 수치/lease를 짧은 트랜잭션으로 갱신하고, worker는 lease token 조건을 통과할 때만 결과를 반영한다. terminal이 된 cycle의 요약만 기존 run 이력에 1회 투영한다. SUCCESS이면서 is_complete=1일 때만 seen set을 기준으로 마감 델타를 실행한다.
Architecture Benchmarking
| 제품/사례 | 해결 방식 | 참고할 패턴 | 미참고 사유 |
|---|---|---|---|
| MySQL 8.0 locking reads | SKIP LOCKED로 대기 중인 lock 행을 건너뛰며 worker가 작업을 가져감 | 짧은 claim transaction과 행 단위 lease | ordered queue 전체를 long transaction으로 잡는 방식은 HTTP 대기와 결합되므로 쓰지 않는다. |
| ShedLock | 외부 저장소의 만료 lock으로 여러 scheduler 인스턴스를 조정 | lease에는 만료와 crash 회수 경로가 있어야 함 | 전역 scheduler lock 하나는 source별 cursor·예산·공정성을 표현하지 못해 cycle 행 lease로 확장한다. 이 앱은 NFR-6에 따라 회수 시 HTTP 재획득 대신 FAILED terminal 처리한다. |
| Spring Batch chunk processing | 읽기/처리를 chunk 단위로 짧게 commit | HTTP 결과를 slice 단위로 저장하고 long DB transaction을 피함 | 별도 Batch JobRepository는 PRD NFR-8의 현재 규모에 과하다. |
Possible Solutions
| 방안 | 설명 | 결정 | 미채택 사유 |
|---|---|---|---|
| 기존 run 행 확장 | run 행에 진행 상태·cursor·lease를 추가 | 미채택 | 기존 API의 `runStatus=SUCCESS |
| Spring Batch JobRepository | framework restart metadata를 추가 | 미채택 | PRD NFR-8의 별도 배치 인프라 미도입 원칙에 비해 메타 스키마·운영 모델이 과하다. |
| Redis/Kafka/Temporal 작업 큐 | 별도 durable worker 운영 | 미채택 | 현재 1일 1회·개인 규모에서 운영 복잡도가 더 크다. |
| 신규 MySQL cycle queue + 기존 run projection | cycle이 진행 SSOT, run은 종료 이력 호환 projection | 채택 | 기존 스택만으로 cursor 재개, slice lease, 다중 인스턴스 안전성, 기존 조회 계약을 함께 만족한다. |
Detail Design
역할 및 정합성 경계
| 테이블 | 소유 책임 | 다른 테이블과의 정합성 |
|---|---|---|
job_posting_collection_cycles | claim, lease, cursor, 일일/slice 예산, terminal 판정 | job_sources.id를 논리 참조한다. FK는 프로젝트 규약상 만들지 않는다. 목록 실패/lease expiry는 같은 날 재호출하지 않고 FAILED terminal 처리한다. |
job_posting_collection_seen_postings | cycle가 발견한 외부 공고 식별자 멱등 집합 | cycle 결과 반영 transaction에서 upsert한다. job_postings를 FK로 참조하지 않는다. 아직 저장되지 않은 raw posting도 seen이어야 한다. |
external_request_host_limits | host별 다음 허용 시각 | host URL의 lower-case ASCII hostname만 저장한다. port/path/query를 포함하지 않는다. |
job_posting_collection_sla_snapshots | KST 날짜별 09:00 SLA cutoff gate/상태 SSOT | 00:00에 PENDING으로 만들고 모든 cycle 상태 쓰기와 09:00 capture가 이 행을 직렬화한다. |
job_posting_collection_sla_snapshot_sources | SLA 시점의 eligible source별 결과와 late 근거 | snapshot과 source의 논리 관계를 보존한다. source가 뒤에 비활성화되어도 과거 SLA는 변하지 않는다. |
job_posting_collection_runs | 기존 Operations 및 health용 terminal projection | cycle terminal 전이는 run 1행 생성/갱신과 같은 짧은 transaction에서 처리한다. |
상태 전이
| 현재 상태 | 이벤트 | 다음 상태 | DB 보호 조건 |
|---|---|---|---|
NOT_STARTED | dispatcher prepare | PENDING | active·지원 source만 ready queue에 넣는다. 외부 HTTP 없음 |
PENDING | ready claim | RUNNING | next_eligible_at <= now이며 조건부 update 또는 FOR UPDATE SKIP LOCKED로 lease token을 발급 |
RUNNING | slice 저장, 다음 cursor 존재 | PENDING | id + lease_token + lease_until > now 일치 시에만 cursor/수치를 갱신하고 lease를 NULL로 해제 |
RUNNING | 목록 완주 | SUCCESS | 같은 token 조건. is_complete=1 후 seen set으로 final delta·run projection·seen 즉시 삭제를 같은 transaction에서 반영 |
RUNNING | 일일 budget 도달 | BUDGET_EXHAUSTED | 같은 token 조건. is_complete=0, 마감/health abnormal/run의 SUCCESS projection을 만들지 않음 |
RUNNING | 목록 오류 | FAILED | 실패 원인 기록·terminal run projection·seen 삭제. NFR-6에 따라 같은 날 HTTP 재시도 없음 |
RUNNING | lease 만료 recovery | FAILED | lease_until < now를 조건으로 token 무효화·실패 terminal 처리. stale worker 저장은 0 row update로 거부하며 다음 KST 날짜만 새 cycle 가능 |
SUCCESS/FAILED/BUDGET_EXHAUSTED | claim | 거부 | 같은 KST 날짜에 재실행하지 않음 |
FAILED만 health abnormal 입력이다. SUCCESS이면서 fetched_count=0도 현행과 동일하게 abnormal이다. BUDGET_EXHAUSTED는 안전 상한에 의한 정상 보호 상태로 health abnormal과 마감 델타 양쪽에서 제외한다.
ERD
erDiagram JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_CYCLES : has JOB_POSTING_COLLECTION_CYCLES ||--o{ JOB_POSTING_COLLECTION_SEEN_POSTINGS : records JOB_POSTING_COLLECTION_SLA_SNAPSHOTS ||--o{ JOB_POSTING_COLLECTION_SLA_SNAPSHOT_SOURCES : captures JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_SLA_SNAPSHOT_SOURCES : eligible_at JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_RUNS : projects JOB_POSTING_COLLECTION_CYCLES { bigint id PK bigint job_source_id date run_date varchar cycle_status varchar continuation_cursor varchar lease_token datetime lease_until } JOB_POSTING_COLLECTION_SEEN_POSTINGS { bigint id PK bigint job_posting_collection_cycle_id varchar source_job_id datetime created_at } EXTERNAL_REQUEST_HOST_LIMITS { bigint id PK varchar host datetime next_allowed_at } JOB_POSTING_COLLECTION_SLA_SNAPSHOTS { bigint id PK date snapshot_date datetime scheduled_at datetime captured_at varchar capture_status } JOB_POSTING_COLLECTION_SLA_SNAPSHOT_SOURCES { bigint id PK bigint job_posting_collection_sla_snapshot_id bigint job_source_id varchar captured_cycle_status tinyint captured_close_guarded }
화살표는 도메인 참조를 뜻하며 물리 FK는 아니다. 이는 프로젝트 DB 컨벤션의 FK 금지 규칙에 따른다.
테이블 정의
job_posting_collection_cycles (신규)
| 컬럼 | 타입 / NULL | 제약·기본값 | COMMENT / 근거 |
|---|---|---|---|
id | BIGINT / NOT NULL | PK, auto increment | PK |
job_source_id | BIGINT / NOT NULL | unique key의 선두 | job_sources.id 논리 참조; source당 KST 일자 1 cycle |
run_date | DATE / NOT NULL | uk_job_posting_collection_cycles_source_date(job_source_id, run_date) | KST 업무 일자. 기존 규약의 DATE 예외를 동일 적용 |
source_type | VARCHAR(20) / NOT NULL | COMPANY_BOUND/AGGREGATOR snapshot. source가 나중에 비활성화되어도 cycle 해석 가능 | |
cycle_status | VARCHAR(30) / NOT NULL | default NOT_STARTED | NOT_STARTED/PENDING/RUNNING/SUCCESS/FAILED/BUDGET_EXHAUSTED; ENUM 금지 |
continuation_cursor | VARCHAR(2000) / NULL | 다음 slice의 플랫폼 페이지/cursor. URL 전체를 허용하되 인덱싱하지 않음 | |
is_complete | TINYINT(1) / NOT NULL | default 0 | 전체 목록 완주 여부; SUCCESS라도 0이면 final delta 금지 |
lease_token | CHAR(36) / NULL | UUID 문자열. claim마다 새 값이며 결과 반영의 fencing token | |
lease_until | DATETIME(6) / NULL | lease 만료 시 recovery 가능 | |
next_eligible_at | DATETIME(6) / NOT NULL | dispatcher prepare 뒤 또는 성공한 slice가 다음 cursor를 남긴 PENDING 상태의 다음 claim 가능 시각; 실패 재시도에는 쓰지 않음 | |
attempt_count | INT / NOT NULL | default 0 | 같은 일일 cycle에서 성공적으로 claim한 slice 횟수(운영 진단용). 목록 실패 재시도 횟수가 아님 |
list_page_count | INT / NOT NULL | default 0 | 누적 목록 페이지 요청 수; 일일 50-page 상한 검증 |
fetched_count | INT / NOT NULL | default 0 | 누적 목록 공고 수 |
new_count | INT / NOT NULL | default 0 | 누적 신규 저장 공고 수 |
changed_count | INT / NOT NULL | default 0 | 누적 변경 공고 수 |
missed_count | INT / NOT NULL | default 0 | complete final delta에서만 기록 |
closed_count | INT / NOT NULL | default 0 | complete final delta에서만 기록 |
detail_request_count | INT / NOT NULL | default 0 | 누적 상세 요청 수; 일일 300건 상한 검증 |
detail_failure_count | INT / NOT NULL | default 0 | 누적 상세 실패 수 |
detail_skipped_count | INT / NOT NULL | default 0 | slice/daily 상한으로 상세를 생략한 수 |
budget_exhausted_reason | VARCHAR(100) / NULL | LIST_PAGE_LIMIT/POSTING_LIMIT/DETAIL_LIMIT/SLICE_TIME_LIMIT 등. terminal이 BUDGET_EXHAUSTED일 때만 값 | |
failure_reason | VARCHAR(200) / NULL | terminal FAILED의 짧은 원인; 기존 run 상한과 맞춤 | |
failure_cause_summary | VARCHAR(1000) / NULL | 예외 메시지 요약; 기존 run 상한과 맞춤 | |
started_at | DATETIME(6) / NOT NULL | cycle 최초 생성 시각 UTC | |
finished_at | DATETIME(6) / NULL | terminal 시각 UTC | |
created_at | DATETIME(6) / NOT NULL | 생성 시각 UTC | |
updated_at | DATETIME(6) / NOT NULL | 상태/lease/cursor 갱신 시각 UTC |
애플리케이션 불변식: terminal 상태면 finished_at IS NOT NULL 및 lease_token/lease_until IS NULL; SUCCESS면 is_complete=1; BUDGET_EXHAUSTED면 is_complete=0이다. MySQL 8 CHECK은 baseline에 사용하지 않았고 JPA/운영 호환성을 위해 DDL CHECK로 강제하지 않는다. 상태 전이 서비스와 조건부 UPDATE가 이를 보장한다.
job_posting_collection_seen_postings (신규)
| 컬럼 | 타입 / NULL | 제약·기본값 | COMMENT / 근거 |
|---|---|---|---|
id | BIGINT / NOT NULL | PK, auto increment | 프로젝트 PK 명명 규약 |
job_posting_collection_cycle_id | BIGINT / NOT NULL | unique key 선두 | cycle 논리 참조; FK 없음 |
source_job_id | VARCHAR(200) / NOT NULL | unique key 후행 | 소스가 제공한 안정 식별자. 기존 job_postings.source_job_id 상한과 동일 |
created_at | DATETIME(6) / NOT NULL | slice 저장 시각 UTC |
유일키 uk_job_posting_collection_seen_postings_cycle_source_job(job_posting_collection_cycle_id, source_job_id)가 정상 slice 경계의 중복 raw ID와 결과 반영 재시도를 흡수한다. HTTP 성공 뒤 DB 반영 전에 worker가 죽어도 source HTTP를 같은 날 재호출하지 않는다. lease recovery는 cycle을 FAILED terminal로 만들며 다음 KST 일자의 새 cycle만 수집할 수 있다. 이 유일키의 left prefix가 cycle별 final delta 및 즉시 삭제를 충족한다. idx_job_posting_collection_seen_postings_created_cycle(created_at, job_posting_collection_cycle_id, id)는 48시간 초과 orphan cleanup/metric 전용이다. 평소 slice write에 보조 index 비용이 생기지만, 오래된 행의 full scan과 cleanup table 추가를 피하는 근거가 된다.
external_request_host_limits (신규)
| 컬럼 | 타입 / NULL | 제약·기본값 | COMMENT / 근거 |
|---|---|---|---|
id | BIGINT / NOT NULL | PK, auto increment | 프로젝트 PK 명명 규약 |
host | VARCHAR(255) / NOT NULL | uk_external_request_host_limits_host(host) | lowercase hostname. 국제 도메인은 URL parser가 ASCII/punycode로 정규화 |
next_allowed_at | DATETIME(6) / NOT NULL | 다음 외부 요청을 시작할 수 있는 UTC 시각 | |
created_at | DATETIME(6) / NOT NULL | 생성 시각 UTC | |
updated_at | DATETIME(6) / NOT NULL | 마지막 예약 갱신 시각 UTC |
host가 PK가 아닌 이유는 전 테이블 PK를 id로 통일하는 규약 때문이다. host 유일키가 논리 PK 역할을 한다. 예약은 INSERT 경쟁을 흡수한 뒤 해당 행을 잠그고 next_allowed_at = GREATEST(now, next_allowed_at) + delay로 갱신한다. 반환된 GREATEST(now, 기존 next_allowed_at)까지 대기하는 것은 DB transaction 밖에서 한다.
job_posting_collection_sla_snapshots (신규)
| 컬럼 | 타입 / NULL | 제약·기본값 | COMMENT / 근거 |
|---|---|---|---|
id | BIGINT / NOT NULL | PK, auto increment | PK |
snapshot_date | DATE / NOT NULL | uk_job_posting_collection_sla_snapshots_date | SLA 대상 KST 업무 일자. 날짜당 하나의 immutable cutoff gate |
scheduled_at | DATETIME(6) / NOT NULL | 해당 snapshot_date의 정확한 09:00:00 KST를 UTC로 환산한 SLA deadline | |
capture_status | VARCHAR(20) / NOT NULL | default PENDING | PENDING/CAPTURING/CAPTURED/MISSED/NOT_APPLICABLE; ENUM 금지 |
captured_at | DATETIME(6) / NULL | capture transaction이 09:00 cutoff를 통과해 item을 확정한 시각 UTC. MISSED이면 NULL | |
miss_reason | VARCHAR(200) / NULL | MISSED일 때 cutoff를 놓친 운영 원인. CAPTURED이면 NULL | |
created_at | DATETIME(6) / NOT NULL | 생성 시각 UTC | |
updated_at | DATETIME(6) / NOT NULL | capture status의 단 한 번 terminal 확정 시각 UTC |
job_posting_collection_sla_snapshot_sources (신규)
| 컬럼 | 타입 / NULL | 제약·기본값 | COMMENT / 근거 |
|---|---|---|---|
id | BIGINT / NOT NULL | PK, auto increment | PK |
job_posting_collection_sla_snapshot_id | BIGINT / NOT NULL | snapshot 유일키 선두 | header 논리 참조; FK 없음 |
job_source_id | BIGINT / NOT NULL | snapshot 유일키 후행 | snapshot 당시 eligible source의 논리 참조 |
source_type | VARCHAR(20) / NOT NULL | COMPANY_BOUND/AGGREGATOR snapshot 값 | |
captured_cycle_status | VARCHAR(30) / NOT NULL | cutoff gate와 직렬화된 정확한 09:00 cycle 상태: NOT_STARTED/PENDING/RUNNING/SUCCESS/FAILED/BUDGET_EXHAUSTED | |
captured_fetched_count | INT / NOT NULL | cutoff 시점 누적 수집 건수 | |
captured_is_complete | TINYINT(1) / NOT NULL | cutoff 시점 전체 목록 완주 여부 | |
captured_abnormal | TINYINT(1) / NOT NULL | 서버가 계산한 cutoff 시점 비정상 여부 | |
captured_close_guarded | TINYINT(1) / NOT NULL | 서버가 계산한 cutoff 시점 마감 판정 제외 여부 | |
created_at | DATETIME(6) / NOT NULL | snapshot detail 생성 UTC |
유일키 uk_job_posting_collection_sla_snapshot_sources_snapshot_source(job_posting_collection_sla_snapshot_id, job_source_id)가 동일 날짜 snapshot의 source 중복을 막는다. 인덱스 idx_job_posting_collection_sla_snapshot_sources_snapshot_status(job_posting_collection_sla_snapshot_id, captured_cycle_status, job_source_id)는 late source drill-down과 status count의 대상 쿼리 근거다. MISSED/NOT_APPLICABLE header에는 item을 절대 만들지 않는다.
09:00 KST SLA snapshot 생성 규칙
- 00:00 KST dispatcher는 v2가 ON인 eligible source마다
NOT_STARTEDcycle을 create-if-absent 하고, 같은 날짜 SLA header를PENDINGcutoff gate로 create-if-absent 한다. cycle 상태를 쓰는 모든 transaction은 이 PENDING gate row를 짧게 잠가 상태 쓰기와 cutoff를 직렬화한다. - 정확한 09:00 KST capture transaction은 header를
FOR UPDATE로 잠근 뒤CAPTURING으로 바꾸고, v2 ON인 active company-bound source와 v2+aggregator.collectionON인 active aggregator source를 재평가한다. 누락 cycle은NOT_STARTED로 보충하고, eligible cycle 행을 잠근 다음 immutable item을 삽입한다. header를CAPTURED와captured_at으로 terminalize해 commit한다. concurrent instance는 date unique/gate lock 때문에 이미CAPTURED인 header를 읽고 종료한다. - item의 상태·counts·abnormal·closeGuarded는 capture transaction과 cycle state-write가 같은 gate를 잠그므로 DB 직렬화상 정확히 cutoff 전 또는 cutoff 후 하나로만 관측된다. item은 이후 cycle 상태 변화로 절대 갱신하지 않는다.
- 09:00 cutoff capture가 수행되지 않은 채 recovery tick가 header
PENDING을 발견하면, 현재 cycle/source/flag를 읽거나 item/count를 계산하지 않고 header를MISSED와miss_reason으로 단 한 번 terminalize한다. 00:00 header 생성 자체가 누락돼 09:00 이후 header가 없으면 recovery는 source/cycle을 읽지 않고MISSEDheader를 직접 create한다.MISSED의captured_at은 NULL, item은 0행, API count는 NULL이다. 이 정책은 later state에서 과거 09:00을 추정하지 않는다. - v2 OFF 날짜는 header를
NOT_APPLICABLE로 terminalize한다. v2 도입 전 날짜는 header를 만들거나 backfill하지 않으며 API가UNAVAILABLE로 표현한다.
기존 테이블의 호환 projection
job_posting_collection_runs에는 새 상태/lease/cursor 컬럼을 추가하지 않는다. 새 cycle의 terminal 결과 중 다음만 1회 기록한다.
| cycle 결과 | job_posting_collection_runs 처리 | health / final delta |
|---|---|---|
complete SUCCESS, fetched > 0 | run_status=SUCCESS projection | normal, final delta 허용 |
complete SUCCESS, fetched = 0 | run_status=SUCCESS, 0건 projection | abnormal, final delta 금지 |
FAILED | run_status=FAILED projection | abnormal, final delta 금지 |
BUDGET_EXHAUSTED | projection하지 않음 | abnormal 아님, final delta 금지 |
BUDGET_EXHAUSTED도 Operations v2에서는 cycle 테이블에서 보여야 한다. 따라서 BE-40의 조회는 v2 activation 뒤 cycles를 기준으로 하고, 이전 날짜의 run history를 union/read-compatible하게 보여야 한다. job_posting_collection_runs를 30일 성공률의 유일한 모수로 쓰지 말고, v2에서는 cycle 상태 기준으로 계산한다. 이 설계가 TDD의 additive cycleStatus 계약을 충족하며 구 run API의 성공/실패 의미를 보존한다.
쿼리 패턴 → 인덱스 매핑
| ID | 쿼리 패턴 | 사용할 인덱스 | 순서 근거 |
|---|---|---|---|
| Q1 | NOT_STARTED/PENDING AND next_eligible_at <= now를 KST 당일 우선으로 최대 10개 claim | idx_job_posting_collection_cycles_ready(cycle_status, next_eligible_at, run_date, id) | 상태는 낮은 카디널리티지만 claim 가능 두 상태로 먼저 좁히고, 시간 range 후 당일 공정 정렬/PK tie-breaker. |
| Q2 | RUNNING AND lease_until < now lease-expiry failure terminalization | idx_job_posting_collection_cycles_expired_lease(cycle_status, lease_until, id) | 상태 equality 뒤 lease expiry range. 조건부 FAILED 전이로 token을 무효화하며 same-day HTTP re-claim을 하지 않는다. |
| Q3 | source/date 한 cycle 생성 또는 조회 | uk_job_posting_collection_cycles_source_date(job_source_id, run_date) | source 식별이 가장 선택적이고 일별 1행을 DB에서 보장한다. |
| Q4 | 특정 source 최근 cycle/Operations source filter | idx_job_posting_collection_cycles_source_date(job_source_id, run_date, id) | Q3 유일키가 left prefix로 충족하므로 별도 index를 만들지 않는다. |
| Q5 | cycle 결과 반영 중 seen ID 멱등 insert 및 final seen 읽기/삭제 | uk_job_posting_collection_seen_postings_cycle_source_job(cycle_id, source_job_id) | cycle equality가 선두이며, source ID 유일성이 재실행을 흡수한다. |
| Q6 | host permit row 조회·잠금·upsert | uk_external_request_host_limits_host(host) | host equality로 정확히 한 행에만 경합시킨다. |
| Q7 | 기존 terminal 이력 조회/legacy projection 유일성 | 기존 uk_job_posting_collection_runs_source_date(job_source_id, run_date) 및 idx_job_posting_collection_runs_run_date(run_date) | baseline index를 그대로 사용한다. |
| Q8 | 10:15 KST orphan seen cleanup/metric: 48시간 초과 seen 중 terminal parent만 최대 1,000행 선택 | idx_job_posting_collection_seen_postings_created_cycle(created_at, job_posting_collection_cycle_id, id) + idx_job_posting_collection_cycles_terminal_finished(cycle_status, finished_at, id) | seen 생성 시각 range로 먼저 한정하고 parent terminal을 확인한다. RUNNING cycle은 predicate에 절대 포함하지 않는다. |
| Q9 | 오늘 SLA header cutoff gate 생성 및 Operations snapshot 조회 | uk_job_posting_collection_sla_snapshots_date(snapshot_date) | KST 날짜 equality로 1행 gate를 보장하며 다중 인스턴스 capture/MISSED terminalization을 직렬화한다. |
| Q10 | CAPTURED SLA source별 late drill-down 및 count | uk_job_posting_collection_sla_snapshot_sources_snapshot_source(job_posting_collection_sla_snapshot_id, job_source_id) + idx_job_posting_collection_sla_snapshot_sources_snapshot_status(job_posting_collection_sla_snapshot_id, captured_cycle_status, job_source_id) | snapshot equality가 선두다. source 점 조회는 유일키, late 상태 group/count는 전용 index를 사용한다. |
Q1/Q2의 실제 claim은 짧은 transaction에서 SELECT ... FOR UPDATE SKIP LOCKED 후 해당 행의 token/lease를 갱신하거나, 위 조건을 포함한 conditional UPDATE로 구현한다. 결과 반영은 반드시 WHERE id=? AND lease_token=? AND lease_until > UTC_TIMESTAMP(6) AND cycle_status='RUNNING' 조건을 써 stale worker를 fencing한다.
lateBeforeNotificationCount와 source items는 live cycle count가 아니라 durable SLA snapshot에서만 나온다. 09:00 KST 전에는 count=0, items는 없다. CAPTURED면 count는 immutable item 중 captured_cycle_status IN ('NOT_STARTED','PENDING','RUNNING','FAILED','BUDGET_EXHAUSTED')의 수이고 items는 이 immutable rows다. MISSED, NOT_APPLICABLE, UNAVAILABLE이면 count와 items는 모두 NULL이며 0이나 later current-state로 대체하지 않는다. 응답은 slaSnapshot { snapshotDate, scheduledAt, captureStatus, capturedAt, missReason, lateBeforeNotificationCount, items }를 additive로 포함한다.
Component Diagram
flowchart LR Scheduler --> DispatchUseCase DispatchUseCase --> CycleRepository DispatchUseCase --> HostThrottleRepository DispatchUseCase --> Adapter Adapter --> ExternalSite DispatchUseCase --> PostingRepository CycleRepository --> MySQL[(MySQL)] HostThrottleRepository --> MySQL PostingRepository --> MySQL
Sequence Diagram
sequenceDiagram participant S as Scheduler participant D as Dispatch use case participant C as Cycle repository participant H as Host throttle participant A as Adapter S->>D: executeTick(workerId) D->>C: claim ready cycle (short tx) C-->>D: cycle + lease token D->>H: reserve(host, delay) (short tx) H-->>D: notBefore D->>A: collect slice outside transaction A-->>D: postings + next cursor D->>C: apply slice(token) (short tx)
용량과 보존 정책
가정은 PRD의 10~20개 회사에 애그리게이터를 포함해 최대 활성 source 30개, 일일 source당 최대 5,000 공고, tick claim 10개, slice 목록 5페이지/500공고, 일일 상세 300건이다.
| 테이블 | 최대 증가/동시 상한 | 1년 추정 | 보존 |
|---|---|---|---|
job_posting_collection_cycles | 최대 30행/일 | 약 10,950행 | NFR-5/운영 이력 목적상 무기한 보존. 10년이어도 약 109,500행으로 작다. |
job_posting_collection_seen_postings | 정상 상태는 아직 terminal이 아닌 당일 cycle의 최대 30 × 5,000 = 150,000행 | 장기 누적하지 않음 | terminal 전이 transaction에서 즉시 삭제한다. DB 장애 등으로 48시간을 넘긴 잔여 행은 10:15 KST cleanup이 최대 1,000행씩 삭제한다. |
job_posting_collection_sla_snapshots | 1행/일 | 365행/년 | NFR-2 SLA audit 기준으로 무기한 보존. |
job_posting_collection_sla_snapshot_sources | 최대 30행/일 | 약 10,950행/년 | source별 09:00 late 근거로 무기한 보존. 10년 약 109,500행이다. |
external_request_host_limits | 활성 host 수 이하(대략 50행) | 거의 불변 | host state이므로 무기한 보존. 비활성 host 정리는 별도 제품 결정 전 수행하지 않는다. |
기존 job_posting_collection_runs | terminal projection 최대 30행/일 | 약 10,950행 | 기존 NFR-5와 최소 30일 Operations 요구에 맞춰 무기한 보존. |
seen set의 삭제는 PRD가 말하는 공고·지원 이력 삭제가 아니라 final delta가 끝난 뒤 가치가 사라지는 작업 제어 상태의 정리다. terminal 전이는 final delta/run projection과 seen 즉시 DELETE를 같은 짧은 transaction으로 commit한다. 실패 시 전부 rollback되어 다음 정상 전이가 다시 처리하므로 partial delete가 남지 않는다. FAILED와 BUDGET_EXHAUSTED도 final delta 없이 terminal 처리에서 즉시 삭제한다.
즉시 삭제가 DB 장애로 누락된 경우만 10:15 KST maintenance가 created_at < now - 48 hours이고 parent cycle이 terminal인 seen 행을 최대 1,000행 삭제한다. RUNNING, NOT_STARTED, PENDING cycle은 48시간이 지나도 maintenance 대상이 아니며, lease recovery가 cycle 상태를 terminal로 바꾼 뒤에만 다음 cleanup 대상이 된다. 이 maintenance는 DB DELETE만 수행해 NFR-6의 same-day source HTTP 재시도를 만들지 않는다.
Operations summary의 seenRowsOlderThan48Hours는 created_at < now-48h이며 terminal parent cycle을 가진 seen 행의 COUNT(*)다. 1 이상은 warning, 1,000 이상은 critical(다음 10:15 cleanup으로도 하루에 다 비울 수 없으므로 운영 점검 필요)로 한다. 이 count와 cleanup selection은 Q8의 seen created_at index를 먼저 사용하고 parent terminal을 확인한다. 정상적인 당일 in-progress seen은 metric에서 제외한다.
Testing Plan
| 레벨 | 검증 범위 |
|---|---|
| migration integration | baseline 뒤 새 migration을 MySQL 8에 적용하고 각 테이블/column comment, unique key, index 존재를 검증 |
| repository integration | 두 DB connection이 같은 ready cycle을 claim할 때 1개만 lease됨, 만료 lease는 FAILED terminal 처리되고 stale token update가 0행임을 검증 |
| repository integration | 같은 (cycle_id, source_job_id)를 두 번 저장해도 1행이며 final seen 조회가 정확함을 검증 |
| repository integration | SUCCESS, FAILED, BUDGET_EXHAUSTED terminal transition이 seen DELETE와 같은 transaction으로 commit/rollback됨을 검증 |
| repository integration | 10:15 KST maintenance가 terminal+48시간 초과 seen만 최대 1,000행 지우며 RUNNING/PENDING/NOT_STARTED cycle seen을 보존함을 검증 |
| repository integration | 두 instance가 같은 09:00 capture를 시작해도 snapshot_date header 1행·source item당 1행만 commit되고, cycle state write가 gate lock 전/후 하나로만 직렬화됨을 검증 |
| application scenario | 09:00 capture는 누락 eligible cycle을 NOT_STARTED로 생성하고 그 상태/count/closeGuarded를 immutable item으로 남기지만 adapter HTTP는 호출하지 않음을 검증 |
| application scenario | 09:00 cutoff를 놓친 recovery는 MISSED header와 miss reason만 기록하고 current cycle/source/flag를 읽어 item이나 late count를 만들지 않음을 검증 |
| operation contract | 09:00 전 count=0/items absent, CAPTURED는 immutable item late count, MISSED/NOT_APPLICABLE/UNAVAILABLE은 count/items 모두 NULL인 slaSnapshot을 additive로 노출함을 검증 |
| operation contract | seenRowsOlderThan48Hours가 terminal orphan seen만 세며 1/1,000 경고 기준을 만족함을 검증 |
| repository integration | 두 인스턴스가 같은 host permit을 예약할 때 반환 notBefore가 최소 delay만큼 단조 증가함을 검증 |
| application scenario | HTTP 성공 뒤 저장 전 crash를 재현하면 lease recovery가 cycle을 FAILED terminal/run projection으로 기록하고, 같은 KST 날짜에는 adapter HTTP 호출이 0회이며 다음 KST 날짜의 새 cycle만 수집할 수 있음을 검증 |
| application scenario | BUDGET_EXHAUSTED, FAILED, complete-zero가 final delta/false-close를 일으키지 않고 health 규칙이 TDD 상태표와 일치함을 검증 |
| operation contract | 기존 run history와 v2 cycle을 조회할 때 runStatus 기존 의미와 additive cycleStatus/budget 필드가 함께 노출됨을 검증 |
Release Scenario — 무중단 배포
Expand
- MySQL implementer는 새 cycle/seen/SLA snapshot/SLA snapshot source/host-limit 테이블과 Q1~Q10 인덱스만 추가하는 expand-only Flyway migration을 작성한다. FK, ENUM, JSON, BOOLEAN, 인라인 대량 DML은 금지한다. 모든 시각은
DATETIME(6)이고 모든 테이블·컬럼 COMMENT를 단다. - index를 ALTER로 추가하는 경우에는
ALGORITHM=INPLACE, LOCK=NONE을 명시한다. 새 테이블의 CREATE TABLE 인라인 index에는 해당 절이 필요 없다. - 새 feature flag
posting.collection-dispatch-v2가 필요하면 수백 행 이하의 정적 seed 예외로 별도 migration에 삽입할 수 있다. 기본값은 OFF다. 기존 legacy 스케줄러는 계속 동작하며 새 테이블을 읽거나 쓰지 않는다.
락 영향: 새 테이블 CREATE와 신규 index만 수행하므로 기존 job_postings, job_sources, job_posting_collection_runs에 table rebuild/row backfill 락이 없다. 기존 run 테이블 변경도 없으므로 실행 중 legacy 수집과의 schema lock 위험을 최소화한다.
Dark deploy 및 전환
- 새 binary를 flag OFF로 배포한다. cycle repository/Operations v2 DTO는 읽을 수 있지만 scheduler는 cycle을 만들지 않는다. 롤백은 이전 binary로 재기동해도 새 테이블이 고립돼 안전하다.
- 다음 KST 일자의 legacy scheduler 실행 전에 두 legacy collection scheduler를 disable하고, v2 scheduler만 enable한다. 모든 인스턴스에서 설정이 반영됐음을 확인한 뒤
posting.collection-dispatch-v2=ON으로 바꾼다. - activation 당일에 legacy
job_posting_collection_runs행이 이미 있으면 v2는 해당(job_source_id, run_date)cycle을 만들지 않는다. 같은 날 dual outbound 수집을 하지 않으며, 안전하게 다음 KST 일자부터 v2가 전면 동작한다. - 첫 주에는 lease 만료 건수, stale-token 0-row 저장 횟수, host 예약 간격, complete success율,
BUDGET_EXHAUSTEDsource 수,seenRowsOlderThan48Hours, SLAcaptureStatus와capturedAt을 매일 확인한다. 09:00 전에CAPTURED가 없으면 notification dispatch 전에MISSED를 명시적으로 확정해야 하며, MISSED는 운영 incident다.
Rollback
| 시점 | 조치 | 데이터 안전성 |
|---|---|---|
| migration 직후 / dark deploy | 이전 binary로 rollback | additive 새 테이블은 구 binary가 무시한다. 역방향 DROP은 운영 데이터가 생긴 뒤 실행하지 않는다. |
| v2 활성화 후 lease 진행 중 | flag OFF → v2 scheduler 중지 → lease expiry 대기 | stale worker는 token mismatch로 결과를 저장할 수 없다. 그 KST 일자에 legacy scheduler를 다시 켜지 않는다. |
| 다음 KST 일자 | legacy scheduler를 재활성화하거나 수정된 v2 binary를 배포 | source당 하루 한 번 제약을 보존한다. 이전날 incomplete v2 cycle은 final delta를 하지 않았으므로 false-close가 없다. |
| contract 전 | v2 code만 rollback 가능 | cycle/seen/host 데이터는 남겨 조사에 사용한다. 기존 run projection은 이미 terminal 사실만 담는다. |
Data migration plan
기존 cycle/seen 데이터를 새 구조로 백필하지 않는다. 새 queue는 activation 날짜부터만 생성하고 기존 job_posting_collection_runs는 immutable history로 남긴다. 따라서 Flyway에는 DDL과 (필요 시) 소규모 feature-flag static seed만 포함되며 대량 UPDATE/INSERT ... SELECT는 없다.
이 기능에 5단계 듀얼라이트 백필은 해당 없다. 단, terminal 시 기존 run projection을 함께 쓰는 것은 새 historical table로 이관하는 데이터 백필이 아니라 새 cycle의 완료 사실을 기존 호환 이력에 기록하는 정상 write path다.
구현자 handoff
MySQL implementer에게 넘길 단계
V20260730HHMM__add_collection_cycle_queue_tables.sql에 신규 cycle/seen/SLA snapshot/SLA snapshot source/host-limit 테이블과 위 명명·컬럼·유일키·인덱스를 작성한다. DDL 전문은 이 설계 문서에 넣지 않는다.- 모든 comment,
DATETIME(6),TINYINT(1), VARCHAR 상태값, FK 없음, JSON 없음,idPK를 검증한다. - MySQL 8에서 baseline 전체 다음에 migration을 적용해 검증한다. concurrent claim, seen unique duplicate, host unique duplicate의 테스트 fixture를 준비한다.
- 생성 migration의 rollback 주석에는 신규 테이블 역순 drop이 activation 전/데이터 0건일 때만 가능하다고 명시한다. activation 이후 rollback은 drop이 아니라 code/flag rollback이다.
BE implementer에게 넘길 필수 계약
createDailyCycles는(job_source_id, run_date)unique 충돌을 정상적인 “이미 생성됨”으로 처리한다.- HTTP 호출과 sleep은 DB transaction 밖에서 수행한다. claim, host reserve, slice persist/final delta는 각각 독립적인 짧은 transaction이다.
- slice persist는 lease token 조건부 update가 1행 갱신됐을 때만 job postings/seen/counters를 commit한다. 0행이면 stale worker이며 재시도 저장하지 않는다.
- 목록 오류 또는 HTTP 성공 뒤 DB 반영 전 crash로 lease가 만료되면 recovery는 해당 cycle을 FAILED terminal로 만든다. 같은 KST 날짜에 adapter를 다시 호출하지 않으며, 다음 KST 날짜의 새 cycle만 허용한다(NFR-6).
BUDGET_EXHAUSTED,FAILED, incomplete cycle에서는applyMissedPostings, auto-close, health normal/abnormal 갱신을 하지 않는다.FAILED와 complete-zero만 현행 health abnormal 규칙에 전달한다.- terminal transition에서는 final delta/run projection 뒤 seen set을 같은 transaction에서 즉시 삭제한다. 10:15 KST maintenance는 terminal + 48시간 초과 seen만 1,000행씩 DB-only 삭제하며, 외부 source HTTP를 호출하거나 같은 날 cycle을 재claim하지 않는다.
- 00:00에는 날짜 unique SLA header를
PENDINGcutoff gate로, eligible source cycle을NOT_STARTED로 생성한다. 모든 cycle state write는 gate를 잠그며 09:00 capture는 gate+cycle을 잠가 immutable item을CAPTURED로 확정한다. cutoff를 놓친 recovery는 PENDING header를, header 자체가 없으면 새 header를 item/count 재구성 없이MISSED로만 확정한다. - Operations v2는 cycle을 SSOT로 보여 주며 기존 runs는 legacy 날짜 history로 읽는다.
runStatus값의 의미를 유지하기 위해 진행 cycle을job_posting_collection_runs에 조기 투영하지 않는다.
Open Questions
BUDGET_EXHAUSTED가 7일 연속인 source를 자동 비활성화할지는 제품 결정이 필요하다. 현재는 운영 경고와 검색 조건 축소만 한다.- host 간격은 현재 기본 1초이나 플랫폼별 정책이 확정되면
host + platform단위가 필요한지 재검토한다. 현 요구는 host별 최소 간격이므로 host만 키로 둔다.
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-30 | 최초 작성 — durable source-cycle queue, slice lease/recovery, seen set, cross-instance host rate throttle 설계 |
| 2026-07-30 | PM NEEDS_REVISION 반영 — terminal 즉시 seen 삭제, 10:15 KST 1,000행/48시간 orphan cleanup, seenRowsOlderThan48Hours, 확정 late predicate 반영 |
| 2026-07-30 | PM audit 반영 — crash/lease recovery를 same-day HTTP replay 대신 FAILED terminal 처리로 변경하고 NFR-6 검증 시나리오 명시 |
| 2026-07-30 | 사용자 승인 반영 — KST 09:00 durable SLA cutoff gate/source snapshot 및 immutable late count/query 계약 추가 |
| 2026-07-30 | PM blocking revision 반영 — post-09:00 재구성 제거, MISSED null count/items semantics 및 불필요한 disable terminal state 미채택 확정 |