확장 안전 공고수집 배치 DB 설계

Background

  • 입력 PRD: 20260722-타깃-공고-알림-및-지원-히스토리-prd.md
  • 입력 BE TDD: 20260730-확장-안전-공고수집-배치-tdd.md
  • 대상 구현 기준: /Users/biuea/recruitment-applicationV202607220000__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_statusSUCCESS|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 IDMySQL 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_sourcescycle 상태 쓰기와 cutoff를 직렬화해 NFR-2의 정확한 09:00 결과만 불변으로 보존한다. cutoff를 놓치면 추정하지 않고 MISSED를 기록한다.
기존 종료 이력MySQL job_posting_collection_runs기존 Operations API와 30일 성공률 집계의 읽기 계약을 계속 제공한다.

전부 MySQL이다. MongoDB는 대량 비정형 원문·독립 TTL 로그 같은 채택 근거가 없고, 이 데이터는 관계형 유일 제약과 lease 정합성이 핵심이므로 채택하지 않는다.

Terminology

용어정의
cycleKST run_datejob_source_id로 유일한 하루의 논리 수집 작업
slice한 worker가 lease 동안 수행하는 제한된 목록/상세 요청 묶음
terminal cycleSUCCESS, FAILED, BUDGET_EXHAUSTED 중 하나로 끝난 cycle
completecursor가 소진돼 전체 목록을 끝까지 읽은 SUCCESS; 이 경우에만 미발견/마감 델타를 실행
seen set해당 cycle에서 실제 발견한 source_job_id 집합
lease tokenclaim한 worker만 결과를 반영하도록 하는 UUID 값

Define Problem

AS-IS

  • CollectJobPostingsUseCase#executeCollectAggregatorPostingsUseCase#execute가 활성 소스를 모두 메모리로 읽어 순차 처리한다. 두 클래스의 perSourceTransaction은 HTTP 호출까지 포함한다.
  • JobPostingCollectionDomainService#collectAggregatorCollectionDomainService#fetch(job_source_id, run_date)가 존재하면 건너뛴다. 재개 cursor, lease, 부분 누적 수치는 없다.
  • 기준 마이그레이션의 job_posting_collection_runsrun_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 readsSKIP LOCKED로 대기 중인 lock 행을 건너뛰며 worker가 작업을 가져감짧은 claim transaction과 행 단위 leaseordered 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 단위로 짧게 commitHTTP 결과를 slice 단위로 저장하고 long DB transaction을 피함별도 Batch JobRepository는 PRD NFR-8의 현재 규모에 과하다.

Possible Solutions

방안설명결정미채택 사유
기존 run 행 확장run 행에 진행 상태·cursor·lease를 추가미채택기존 API의 `runStatus=SUCCESS
Spring Batch JobRepositoryframework restart metadata를 추가미채택PRD NFR-8의 별도 배치 인프라 미도입 원칙에 비해 메타 스키마·운영 모델이 과하다.
Redis/Kafka/Temporal 작업 큐별도 durable worker 운영미채택현재 1일 1회·개인 규모에서 운영 복잡도가 더 크다.
신규 MySQL cycle queue + 기존 run projectioncycle이 진행 SSOT, run은 종료 이력 호환 projection채택기존 스택만으로 cursor 재개, slice lease, 다중 인스턴스 안전성, 기존 조회 계약을 함께 만족한다.

Detail Design

역할 및 정합성 경계

테이블소유 책임다른 테이블과의 정합성
job_posting_collection_cyclesclaim, lease, cursor, 일일/slice 예산, terminal 판정job_sources.id를 논리 참조한다. FK는 프로젝트 규약상 만들지 않는다. 목록 실패/lease expiry는 같은 날 재호출하지 않고 FAILED terminal 처리한다.
job_posting_collection_seen_postingscycle가 발견한 외부 공고 식별자 멱등 집합cycle 결과 반영 transaction에서 upsert한다. job_postings를 FK로 참조하지 않는다. 아직 저장되지 않은 raw posting도 seen이어야 한다.
external_request_host_limitshost별 다음 허용 시각host URL의 lower-case ASCII hostname만 저장한다. port/path/query를 포함하지 않는다.
job_posting_collection_sla_snapshotsKST 날짜별 09:00 SLA cutoff gate/상태 SSOT00:00에 PENDING으로 만들고 모든 cycle 상태 쓰기와 09:00 capture가 이 행을 직렬화한다.
job_posting_collection_sla_snapshot_sourcesSLA 시점의 eligible source별 결과와 late 근거snapshot과 source의 논리 관계를 보존한다. source가 뒤에 비활성화되어도 과거 SLA는 변하지 않는다.
job_posting_collection_runs기존 Operations 및 health용 terminal projectioncycle terminal 전이는 run 1행 생성/갱신과 같은 짧은 transaction에서 처리한다.

상태 전이

현재 상태이벤트다음 상태DB 보호 조건
NOT_STARTEDdispatcher preparePENDINGactive·지원 source만 ready queue에 넣는다. 외부 HTTP 없음
PENDINGready claimRUNNINGnext_eligible_at <= now이며 조건부 update 또는 FOR UPDATE SKIP LOCKED로 lease token을 발급
RUNNINGslice 저장, 다음 cursor 존재PENDINGid + 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 재시도 없음
RUNNINGlease 만료 recoveryFAILEDlease_until < now를 조건으로 token 무효화·실패 terminal 처리. stale worker 저장은 0 row update로 거부하며 다음 KST 날짜만 새 cycle 가능
SUCCESS/FAILED/BUDGET_EXHAUSTEDclaim거부같은 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 / 근거
idBIGINT / NOT NULLPK, auto incrementPK
job_source_idBIGINT / NOT NULLunique key의 선두job_sources.id 논리 참조; source당 KST 일자 1 cycle
run_dateDATE / NOT NULLuk_job_posting_collection_cycles_source_date(job_source_id, run_date)KST 업무 일자. 기존 규약의 DATE 예외를 동일 적용
source_typeVARCHAR(20) / NOT NULLCOMPANY_BOUND/AGGREGATOR snapshot. source가 나중에 비활성화되어도 cycle 해석 가능
cycle_statusVARCHAR(30) / NOT NULLdefault NOT_STARTEDNOT_STARTED/PENDING/RUNNING/SUCCESS/FAILED/BUDGET_EXHAUSTED; ENUM 금지
continuation_cursorVARCHAR(2000) / NULL다음 slice의 플랫폼 페이지/cursor. URL 전체를 허용하되 인덱싱하지 않음
is_completeTINYINT(1) / NOT NULLdefault 0전체 목록 완주 여부; SUCCESS라도 0이면 final delta 금지
lease_tokenCHAR(36) / NULLUUID 문자열. claim마다 새 값이며 결과 반영의 fencing token
lease_untilDATETIME(6) / NULLlease 만료 시 recovery 가능
next_eligible_atDATETIME(6) / NOT NULLdispatcher prepare 뒤 또는 성공한 slice가 다음 cursor를 남긴 PENDING 상태의 다음 claim 가능 시각; 실패 재시도에는 쓰지 않음
attempt_countINT / NOT NULLdefault 0같은 일일 cycle에서 성공적으로 claim한 slice 횟수(운영 진단용). 목록 실패 재시도 횟수가 아님
list_page_countINT / NOT NULLdefault 0누적 목록 페이지 요청 수; 일일 50-page 상한 검증
fetched_countINT / NOT NULLdefault 0누적 목록 공고 수
new_countINT / NOT NULLdefault 0누적 신규 저장 공고 수
changed_countINT / NOT NULLdefault 0누적 변경 공고 수
missed_countINT / NOT NULLdefault 0complete final delta에서만 기록
closed_countINT / NOT NULLdefault 0complete final delta에서만 기록
detail_request_countINT / NOT NULLdefault 0누적 상세 요청 수; 일일 300건 상한 검증
detail_failure_countINT / NOT NULLdefault 0누적 상세 실패 수
detail_skipped_countINT / NOT NULLdefault 0slice/daily 상한으로 상세를 생략한 수
budget_exhausted_reasonVARCHAR(100) / NULLLIST_PAGE_LIMIT/POSTING_LIMIT/DETAIL_LIMIT/SLICE_TIME_LIMIT 등. terminal이 BUDGET_EXHAUSTED일 때만 값
failure_reasonVARCHAR(200) / NULLterminal FAILED의 짧은 원인; 기존 run 상한과 맞춤
failure_cause_summaryVARCHAR(1000) / NULL예외 메시지 요약; 기존 run 상한과 맞춤
started_atDATETIME(6) / NOT NULLcycle 최초 생성 시각 UTC
finished_atDATETIME(6) / NULLterminal 시각 UTC
created_atDATETIME(6) / NOT NULL생성 시각 UTC
updated_atDATETIME(6) / NOT NULL상태/lease/cursor 갱신 시각 UTC

애플리케이션 불변식: terminal 상태면 finished_at IS NOT NULLlease_token/lease_until IS NULL; SUCCESSis_complete=1; BUDGET_EXHAUSTEDis_complete=0이다. MySQL 8 CHECK은 baseline에 사용하지 않았고 JPA/운영 호환성을 위해 DDL CHECK로 강제하지 않는다. 상태 전이 서비스와 조건부 UPDATE가 이를 보장한다.

job_posting_collection_seen_postings (신규)

컬럼타입 / NULL제약·기본값COMMENT / 근거
idBIGINT / NOT NULLPK, auto increment프로젝트 PK 명명 규약
job_posting_collection_cycle_idBIGINT / NOT NULLunique key 선두cycle 논리 참조; FK 없음
source_job_idVARCHAR(200) / NOT NULLunique key 후행소스가 제공한 안정 식별자. 기존 job_postings.source_job_id 상한과 동일
created_atDATETIME(6) / NOT NULLslice 저장 시각 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 / 근거
idBIGINT / NOT NULLPK, auto increment프로젝트 PK 명명 규약
hostVARCHAR(255) / NOT NULLuk_external_request_host_limits_host(host)lowercase hostname. 국제 도메인은 URL parser가 ASCII/punycode로 정규화
next_allowed_atDATETIME(6) / NOT NULL다음 외부 요청을 시작할 수 있는 UTC 시각
created_atDATETIME(6) / NOT NULL생성 시각 UTC
updated_atDATETIME(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 / 근거
idBIGINT / NOT NULLPK, auto incrementPK
snapshot_dateDATE / NOT NULLuk_job_posting_collection_sla_snapshots_dateSLA 대상 KST 업무 일자. 날짜당 하나의 immutable cutoff gate
scheduled_atDATETIME(6) / NOT NULL해당 snapshot_date의 정확한 09:00:00 KST를 UTC로 환산한 SLA deadline
capture_statusVARCHAR(20) / NOT NULLdefault PENDINGPENDING/CAPTURING/CAPTURED/MISSED/NOT_APPLICABLE; ENUM 금지
captured_atDATETIME(6) / NULLcapture transaction이 09:00 cutoff를 통과해 item을 확정한 시각 UTC. MISSED이면 NULL
miss_reasonVARCHAR(200) / NULLMISSED일 때 cutoff를 놓친 운영 원인. CAPTURED이면 NULL
created_atDATETIME(6) / NOT NULL생성 시각 UTC
updated_atDATETIME(6) / NOT NULLcapture status의 단 한 번 terminal 확정 시각 UTC

job_posting_collection_sla_snapshot_sources (신규)

컬럼타입 / NULL제약·기본값COMMENT / 근거
idBIGINT / NOT NULLPK, auto incrementPK
job_posting_collection_sla_snapshot_idBIGINT / NOT NULLsnapshot 유일키 선두header 논리 참조; FK 없음
job_source_idBIGINT / NOT NULLsnapshot 유일키 후행snapshot 당시 eligible source의 논리 참조
source_typeVARCHAR(20) / NOT NULLCOMPANY_BOUND/AGGREGATOR snapshot 값
captured_cycle_statusVARCHAR(30) / NOT NULLcutoff gate와 직렬화된 정확한 09:00 cycle 상태: NOT_STARTED/PENDING/RUNNING/SUCCESS/FAILED/BUDGET_EXHAUSTED
captured_fetched_countINT / NOT NULLcutoff 시점 누적 수집 건수
captured_is_completeTINYINT(1) / NOT NULLcutoff 시점 전체 목록 완주 여부
captured_abnormalTINYINT(1) / NOT NULL서버가 계산한 cutoff 시점 비정상 여부
captured_close_guardedTINYINT(1) / NOT NULL서버가 계산한 cutoff 시점 마감 판정 제외 여부
created_atDATETIME(6) / NOT NULLsnapshot 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 생성 규칙

  1. 00:00 KST dispatcher는 v2가 ON인 eligible source마다 NOT_STARTED cycle을 create-if-absent 하고, 같은 날짜 SLA header를 PENDING cutoff gate로 create-if-absent 한다. cycle 상태를 쓰는 모든 transaction은 이 PENDING gate row를 짧게 잠가 상태 쓰기와 cutoff를 직렬화한다.
  2. 정확한 09:00 KST capture transaction은 header를 FOR UPDATE로 잠근 뒤 CAPTURING으로 바꾸고, v2 ON인 active company-bound source와 v2+aggregator.collection ON인 active aggregator source를 재평가한다. 누락 cycle은 NOT_STARTED로 보충하고, eligible cycle 행을 잠근 다음 immutable item을 삽입한다. header를 CAPTUREDcaptured_at으로 terminalize해 commit한다. concurrent instance는 date unique/gate lock 때문에 이미 CAPTURED인 header를 읽고 종료한다.
  3. item의 상태·counts·abnormal·closeGuarded는 capture transaction과 cycle state-write가 같은 gate를 잠그므로 DB 직렬화상 정확히 cutoff 전 또는 cutoff 후 하나로만 관측된다. item은 이후 cycle 상태 변화로 절대 갱신하지 않는다.
  4. 09:00 cutoff capture가 수행되지 않은 채 recovery tick가 header PENDING을 발견하면, 현재 cycle/source/flag를 읽거나 item/count를 계산하지 않고 header를 MISSEDmiss_reason으로 단 한 번 terminalize한다. 00:00 header 생성 자체가 누락돼 09:00 이후 header가 없으면 recovery는 source/cycle을 읽지 않고 MISSED header를 직접 create한다. MISSEDcaptured_at은 NULL, item은 0행, API count는 NULL이다. 이 정책은 later state에서 과거 09:00을 추정하지 않는다.
  5. 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 > 0run_status=SUCCESS projectionnormal, final delta 허용
complete SUCCESS, fetched = 0run_status=SUCCESS, 0건 projectionabnormal, final delta 금지
FAILEDrun_status=FAILED projectionabnormal, final delta 금지
BUDGET_EXHAUSTEDprojection하지 않음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쿼리 패턴사용할 인덱스순서 근거
Q1NOT_STARTED/PENDING AND next_eligible_at <= now를 KST 당일 우선으로 최대 10개 claimidx_job_posting_collection_cycles_ready(cycle_status, next_eligible_at, run_date, id)상태는 낮은 카디널리티지만 claim 가능 두 상태로 먼저 좁히고, 시간 range 후 당일 공정 정렬/PK tie-breaker.
Q2RUNNING AND lease_until < now lease-expiry failure terminalizationidx_job_posting_collection_cycles_expired_lease(cycle_status, lease_until, id)상태 equality 뒤 lease expiry range. 조건부 FAILED 전이로 token을 무효화하며 same-day HTTP re-claim을 하지 않는다.
Q3source/date 한 cycle 생성 또는 조회uk_job_posting_collection_cycles_source_date(job_source_id, run_date)source 식별이 가장 선택적이고 일별 1행을 DB에서 보장한다.
Q4특정 source 최근 cycle/Operations source filteridx_job_posting_collection_cycles_source_date(job_source_id, run_date, id)Q3 유일키가 left prefix로 충족하므로 별도 index를 만들지 않는다.
Q5cycle 결과 반영 중 seen ID 멱등 insert 및 final seen 읽기/삭제uk_job_posting_collection_seen_postings_cycle_source_job(cycle_id, source_job_id)cycle equality가 선두이며, source ID 유일성이 재실행을 흡수한다.
Q6host permit row 조회·잠금·upsertuk_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를 그대로 사용한다.
Q810: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을 직렬화한다.
Q10CAPTURED SLA source별 late drill-down 및 countuk_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_snapshots1행/일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_runsterminal 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가 남지 않는다. FAILEDBUDGET_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의 seenRowsOlderThan48Hourscreated_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 integrationbaseline 뒤 새 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 integrationSUCCESS, FAILED, BUDGET_EXHAUSTED terminal transition이 seen DELETE와 같은 transaction으로 commit/rollback됨을 검증
repository integration10: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 scenario09:00 capture는 누락 eligible cycle을 NOT_STARTED로 생성하고 그 상태/count/closeGuarded를 immutable item으로 남기지만 adapter HTTP는 호출하지 않음을 검증
application scenario09:00 cutoff를 놓친 recovery는 MISSED header와 miss reason만 기록하고 current cycle/source/flag를 읽어 item이나 late count를 만들지 않음을 검증
operation contract09:00 전 count=0/items absent, CAPTURED는 immutable item late count, MISSED/NOT_APPLICABLE/UNAVAILABLE은 count/items 모두 NULL인 slaSnapshot을 additive로 노출함을 검증
operation contractseenRowsOlderThan48Hours가 terminal orphan seen만 세며 1/1,000 경고 기준을 만족함을 검증
repository integration두 인스턴스가 같은 host permit을 예약할 때 반환 notBefore가 최소 delay만큼 단조 증가함을 검증
application scenarioHTTP 성공 뒤 저장 전 crash를 재현하면 lease recovery가 cycle을 FAILED terminal/run projection으로 기록하고, 같은 KST 날짜에는 adapter HTTP 호출이 0회이며 다음 KST 날짜의 새 cycle만 수집할 수 있음을 검증
application scenarioBUDGET_EXHAUSTED, FAILED, complete-zero가 final delta/false-close를 일으키지 않고 health 규칙이 TDD 상태표와 일치함을 검증
operation contract기존 run history와 v2 cycle을 조회할 때 runStatus 기존 의미와 additive cycleStatus/budget 필드가 함께 노출됨을 검증

Release Scenario — 무중단 배포

Expand

  1. 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를 단다.
  2. index를 ALTER로 추가하는 경우에는 ALGORITHM=INPLACE, LOCK=NONE을 명시한다. 새 테이블의 CREATE TABLE 인라인 index에는 해당 절이 필요 없다.
  3. 새 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 및 전환

  1. 새 binary를 flag OFF로 배포한다. cycle repository/Operations v2 DTO는 읽을 수 있지만 scheduler는 cycle을 만들지 않는다. 롤백은 이전 binary로 재기동해도 새 테이블이 고립돼 안전하다.
  2. 다음 KST 일자의 legacy scheduler 실행 전에 두 legacy collection scheduler를 disable하고, v2 scheduler만 enable한다. 모든 인스턴스에서 설정이 반영됐음을 확인한 뒤 posting.collection-dispatch-v2=ON으로 바꾼다.
  3. activation 당일에 legacy job_posting_collection_runs 행이 이미 있으면 v2는 해당 (job_source_id, run_date) cycle을 만들지 않는다. 같은 날 dual outbound 수집을 하지 않으며, 안전하게 다음 KST 일자부터 v2가 전면 동작한다.
  4. 첫 주에는 lease 만료 건수, stale-token 0-row 저장 횟수, host 예약 간격, complete success율, BUDGET_EXHAUSTED source 수, seenRowsOlderThan48Hours, SLA captureStatuscapturedAt을 매일 확인한다. 09:00 전에 CAPTURED가 없으면 notification dispatch 전에 MISSED를 명시적으로 확정해야 하며, MISSED는 운영 incident다.

Rollback

시점조치데이터 안전성
migration 직후 / dark deploy이전 binary로 rollbackadditive 새 테이블은 구 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에게 넘길 단계

  1. V20260730HHMM__add_collection_cycle_queue_tables.sql에 신규 cycle/seen/SLA snapshot/SLA snapshot source/host-limit 테이블과 위 명명·컬럼·유일키·인덱스를 작성한다. DDL 전문은 이 설계 문서에 넣지 않는다.
  2. 모든 comment, DATETIME(6), TINYINT(1), VARCHAR 상태값, FK 없음, JSON 없음, id PK를 검증한다.
  3. MySQL 8에서 baseline 전체 다음에 migration을 적용해 검증한다. concurrent claim, seen unique duplicate, host unique duplicate의 테스트 fixture를 준비한다.
  4. 생성 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를 PENDING cutoff 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

  1. BUDGET_EXHAUSTED가 7일 연속인 source를 자동 비활성화할지는 제품 결정이 필요하다. 현재는 운영 경고와 검색 조건 축소만 한다.
  2. 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-30PM NEEDS_REVISION 반영 — terminal 즉시 seen 삭제, 10:15 KST 1,000행/48시간 orphan cleanup, seenRowsOlderThan48Hours, 확정 late predicate 반영
2026-07-30PM 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-30PM blocking revision 반영 — post-09:00 재구성 제거, MISSED null count/items semantics 및 불필요한 disable terminal state 미채택 확정