확장 안전 공고 수집 배치 TDD
Background
근거 PRD: 20260722-타깃-공고-알림-및-지원-히스토리-prd.md의 FR-9, FR-12, FR-14~15, FR-19, FR-60, FR-66, FR-69, NFR-2, Operations. 애그리게이터 검색 하나가 수천 건까지 커질 수 있는데 현재 구현은 활성 소스를 전부 순차 순회하고, 외부 HTTP 호출과 DB 저장을 소스당 하나의 REQUIRES_NEW 트랜잭션으로 묶는다. 원티드의 links.next 및 사람인의 총 건수 기반 반복에는 총 페이지 상한도 없다.
Overview
메시지 브로커·별도 워커 프로세스 없이 MySQL을 내구성 있는 작업 큐로 사용한다. 자정의 단발 수집은 매분 동작하는 dispatcher가 만드는 일일 소스 수집 사이클로 바뀐다. 사이클은 짧은 lease 작업(slice)으로 나뉘며, slice 하나는 설정된 페이지·공고·상세조회·시간 예산을 넘지 않는다. HTTP는 어떤 DB 트랜잭션에도 포함하지 않으며, 결과 저장·cursor 전진·lease 해제만 짧은 트랜잭션으로 처리한다.
기본 운영값(환경변수로 재정의 가능): tick당 claim 10개, slice당 목록 5페이지/500공고/상세 50건/5분, 일일 사이클당 목록 50페이지/5,000공고/상세 300건, lease 10분. 일일 총 예산을 소진하면 BUDGET_EXHAUSTED로 끝내며 마감 델타를 절대 실행하지 않는다. 다음 날 최신순 첫 페이지부터 다시 시작한다. 이는 FR-69의 소스별 1일 1회 정책과 최신 공고 우선성을 유지하면서 무한 호출을 막는 보수적 선택이다.
Terminology
| 용어 | 정의 |
|---|---|
| 일일 사이클 | KST 일자·jobSourceId에 유일한 논리 수집 단위. 자정 creation 시 NOT_STARTED로 내구 저장 |
| slice | 하나의 worker lease가 처리하는 제한된 목록/상세 요청 묶음 |
| cursor | 다음 slice가 이어받을 플랫폼별 페이지 또는 cursor 값 |
| 완전 수집 | nextCursor=null까지 도달한 사이클 |
| safe close predicate | cycleStatus=SUCCESS && isComplete=true && fetchedCount>0; 이 조건일 때만 미발견·마감 델타 허용 |
| budget exhausted | 안전 상한 때문에 완전 수집 전에 종료한 정상 보호 상태 |
| lease | 특정 인스턴스가 짧은 시간 동안 작업을 소유했다는 DB 기반 권한 |
Define Problem
AS-IS
CollectJobPostingsUseCase#execute와CollectAggregatorPostingsUseCase#execute가 모든 활성 소스를 메모리 목록으로 읽어 순차 실행한다.- 두 UseCase의
perSourceTransaction안에서JobPostingCollectionDomainService#collect/AggregatorCollectionDomainService#fetch가JobSourceGateway#collect를 호출한다. 즉 네트워크 대기와 DB 트랜잭션이 같은 경계다. WantedAggregatorAdapter#collectAllPages는 next URL이 없어질 때까지,SaraminAggregatorAdapter#collect는 총 건수가 찰 때까지 반복한다. 점핏만maxPageCount가 있다.JobPostingCollectionRun은SUCCESS/FAILED결과만 표현하고(job_source_id, run_date)유니크로 당일 재실행을 막는다. 진행 중 cursor·lease·부분 카운터가 없다.SourceRequestExecutor#awaitRequestDelay는 프로세스 메모리의 host별 마지막 호출만 보므로 다중 인스턴스에서 host 간격을 보장하지 않는다.OperationApiController#listCollectionRuns는 종료 이력만 조회하며 slice/예산/지연/재개 상태를 노출하지 않는다.
TO-BE
일일 사이클 행을 먼저 원자적으로 만들고, worker가 FOR UPDATE SKIP LOCKED 또는 조건부 UPDATE로 한 행만 lease한다. 한 slice가 HTTP로 얻은 페이지는 종료 후 별도 저장 트랜잭션에서 반영한다. 완료 전에는 seen sourceJobId를 기록하고, 마지막 slice에서만 미발견 델타를 수행한다. 외부 HTTP 오류·lease 만료는 같은 KST 날짜에 재claim하지 않고 즉시 terminal FAILED로 기록한다. stale worker는 lease token 불일치로 결과를 저장할 수 없다.
Architecture Benchmarking
| 제품/사례 | 해결 방식 | 참고할 패턴 | 미참고 사유 |
|---|---|---|---|
| Spring Batch chunk processing | 읽은 item 묶음을 짧은 transaction boundary로 commit | HTTP와 저장을 분리하고 page/slice 단위 commit | 이 앱은 브로커·별도 Batch 메타 스키마를 PRD가 배제하므로 프레임워크 도입 대신 기존 MySQL 스키마에 최소 상태를 둔다 |
| ShedLock | 외부 저장소에 scheduler lock을 두어 다중 인스턴스의 중복 scheduled task를 조정 | lease 만료·외부 저장소 조정 원칙 | 전역 잠금 하나만으로는 소스별 재개·부분 실패·공정 분배를 표현 못 하므로 source cycle 행 자체를 lease한다 |
| Temporal durable execution | workflow 상태·재시도·task queue를 내구 저장 | 실패 후 정확한 상태 재개와 activity 멱등성 | 단일 사용자/1일 배치에 Temporal 운영은 과도하므로 같은 핵심을 관계형 큐로 제한 구현한다 |
| Sidekiq job format | job ID, scheduled time, retry 정보로 비동기 작업을 추적 | attempt·scheduled/next eligible·오류를 관측 가능한 작업 상태로 보존 | Redis/별도 worker는 PRD Non-Goal이고 FIFO queue를 새로 운영하지 않는다 |
Possible Solutions
| 방안 | 설명 | 채택 여부 | 미채택 사유 |
|---|---|---|---|
| 현재 단일 스케줄러에 상한만 추가 | 모든 소스를 한 요청에서 처리하되 어댑터 반복만 제한 | 미채택 | 장기 트랜잭션·중단 후 재개 불가·다중 인스턴스 중복을 해결하지 못한다 |
| Spring Batch + 별도 JobRepository | chunk/restart를 프레임워크가 제공 | 미채택 | 현재 규모에는 스키마·운영 모델이 중복되고 PRD의 별도 배치 인프라 미도입 원칙에 비해 무겁다 |
| Kafka/Redis/Temporal worker | 내구 큐와 분산 worker를 제공 | 미채택 | 처리량 요구가 낮고 PRD Non-Goal이다; 나중에 처리 시간/소스 수가 기본 예산을 반복 초과할 때 전환한다 |
| MySQL source-cycle work queue + lease | 기존 MySQL에 일일 상태·cursor·seen set·host throttle을 보관하고 API 서버 scheduler가 slice 수행 | 채택 | 현재 스택만으로 재개·상한·다중 인스턴스 안전성을 만족하며 운영 복잡도가 가장 낮다 |
Detail Design
Bounded context / topology decision
새 도메인은 만들지 않고 기존 posting 컨텍스트에 합류한다. 수집 사이클은 JobPosting, JobSourceHealth, JobPostingCollectionRun과 동일한 수명·데이터 소유자이며, company 자동 등록은 계속 application 레이어가 조합한다. API 서버와 scheduler/worker는 지금은 한 Spring Boot 프로세스의 역할만 분리한다. 별도 worker 프로세스는 처리량·격리 요구가 생길 때만 채택한다.
시스템 역할 경계
| 단위 | 역할 | 소유 데이터/책임 | 노출 인터페이스 | 의존 |
|---|---|---|---|---|
CollectionCycleDomainService | cycle 상태 전이·budget·lease token 보호 | cycle, seen ID, 최종화 판정 | createDailyCycles, prepare, claim, applySlice, recoverExpired | posting repositories |
CollectionSlaSnapshotDomainService | 09:00 SLA snapshot | cutoff 시점 source eligibility와 cycle 상태를 immutable snapshot으로 보존 | captureAtNine, markMissedSnapshot | cycle/snapshot repositories |
CollectionDispatchUseCase | 회사/애그리게이터 source를 slice 단위로 조합 | lease 수명, company 매핑 호출 | executeTick(now, workerId) | posting/company domain services |
JobSourceGateway | 플랫폼 slice를 외부에서 읽음 | HTTP 결과만, DB 변경 없음 | collectSlice(descriptor): SourceCollectionSliceOutcome | adapter |
HostRequestThrottleGateway | host 간 최소 간격을 DB로 예약 | next_allowed_at | reserve(host, delay, now): notBefore | persistence |
| collector adapter | platform cursor/페이지 해석·상한 준수 | HTTP 호출 및 raw mapping | platform-local client | SourceRequestExecutor |
OperationApiController | 운영 읽기만 제공 | 상태를 사용자 DTO로 조합 | GET 기존 API의 additive fields | application |
| scheduler | tick 시작만 담당 | 없음 | run() | dispatch use case |
Interface signatures
interface JobPostingCollectionCycleRepository {
fun createIfAbsent(jobSourceId: Long, runDate: LocalDate, sourceType: SourceType): JobPostingCollectionCycle
fun save(cycle: JobPostingCollectionCycle): JobPostingCollectionCycle
fun claimReady(activeJobSourceIds: List<Long>, maxItems: Int, workerId: String, leaseUntil: ZonedDateTime, now: ZonedDateTime): List<JobPostingCollectionCycle>
fun renew(cycleId: Long, leaseToken: UUID, leaseUntil: ZonedDateTime): Boolean
fun applySlice(cycleId: Long, leaseToken: UUID, result: AppliedCollectionSlice): Boolean
fun fail(cycleId: Long, leaseToken: UUID?, reason: CollectionFailureReason, now: ZonedDateTime): Boolean
fun exhaustBudget(cycleId: Long, leaseToken: UUID, reason: CollectionBudgetLimit, now: ZonedDateTime): Boolean
fun recoverExpired(now: ZonedDateTime): Int
}
interface CollectionSlaCutoffGate {
fun <T> withDailyWriteGate(runDate: LocalDate, action: () -> T): T
}
interface JobSourceGateway {
fun collectSlice(descriptor: CollectionSliceDescriptor): SourceCollectionSliceOutcome
}
interface HostRequestThrottleGateway {
fun reserve(host: String, minimumDelayMillis: Long, now: ZonedDateTime): ZonedDateTime
}CollectionSliceDescriptor에는 jobSourceId, platform/sourceType 검색 조건, cursor, 이미 소비한 일일 budget, slice budget, known signatures를 둔다. SourceCollectionSliceOutcome.Fetched는 postings, nextCursor, listPageCount, detailRequestCount, detailFailureCount, isComplete를 가진다. adapter는 slice budget 전에 더 이상 요청하지 않으며 next cursor를 그대로 반환한다. AppliedCollectionSlice는 누적 counts와 seen IDs를 포함한다. domain은 named predicate isSafeToApplyMissedDelta() (SUCCESS && isComplete && fetchedCount > 0)가 참일 때만 final/missed/close delta를 실행한다.
클래스 역할 정의
| 클래스명 | 역할 | 핵심 책임 |
|---|---|---|
JobPostingCollectionCycle | posting aggregate | claim, renewLease, recordSlice, complete, exhaustBudget, recoverExpired 상태 보호 |
CollectionBudget | value object | 0/음수 설정 거부, slice·daily 상한 비교 |
CollectionCycleDomainService | domain service | source cycle claim, slice 저장, isSafeToApplyMissedDelta()일 때만 applyMissedPostings 호출 |
CollectionCycleStateWriteDomainService | domain service | active-source claim, lease fencing, terminal FAILED/BUDGET persistence를 daily SLA gate 안에서 수행 |
CollectionDispatchUseCase | application service | source snapshot→claim→HTTP(무트랜잭션)→짧은 persist transaction 오케스트레이션 |
CollectionSliceProcessor | application collaborator | company-bound/aggregator 각각의 source context 값을 descriptor로 변환; aggregator의 회사 resolve는 페이지 결과 직후 수행 |
PersistentHostRequestThrottle | infrastructure adapter | DB 원자 예약 후 notBefore 반환; sleep은 executor가 트랜잭션 밖에서 수행 |
State transition
| 현재 상태 × 이벤트 | 다음 상태 | 거부/처리 |
|---|---|---|
| NOT_STARTED × dispatcher prepare | PENDING | active·지원되는 source의 아직 시작하지 않은 cycle만 ready queue에 넣음; 외부 HTTP 없음 |
| NOT_STARTED/PENDING × source disabled | 그대로 | dispatcher claim 대상에서 제외; 외부 HTTP 없이 남은 당일 cycle은 상태를 바꾸지 않음 |
| NOT_STARTED × descriptor/configuration failure | FAILED | 외부 HTTP 없이 terminal failure 기록 |
| NOT_STARTED × 09:00 SLA snapshot | NOT_STARTED | immutable snapshot에 그대로 기록; 이후 dispatcher가 계속 처리 가능 |
| PENDING × claim | RUNNING | due이고 lease 없음일 때만 token 발급 |
| RUNNING × slice persisted, next 있음 | PENDING | cursor·누적 계수 저장, next_eligible_at=now |
| RUNNING × complete, fetchedCount > 0 | SUCCESS | isSafeToApplyMissedDelta()로 seen 집합 final delta 후 종료 |
| RUNNING × complete, fetchedCount = 0 | SUCCESS | abnormal·closeGuarded; final/missed/close delta 금지 |
| RUNNING × daily budget 초과 | BUDGET_EXHAUSTED | cursor 보존, final delta/health abnormal/auto-close 금지 |
| RUNNING × HTTP failure (403/429/5xx/timeout/policy 포함) | FAILED | 같은 KST 날짜에 외부 HTTP 재시도 금지; 다음 KST 날짜의 새 cycle만 가능 |
| RUNNING × lease 만료 | FAILED | recovery tick가 token을 폐기하고 terminal 실패 기록; stale 결과는 거부 |
| SUCCESS/FAILED/BUDGET_EXHAUSTED × claim | 그대로 | 같은 KST 날짜에 재실행하지 않음 |
실패 경로·동시성·멱등
- HTTP/DB 분리: claim·회사 매핑·결과 반영은 각각 짧은
REQUIRES_NEW;collectSlice와 throttle 대기는 transaction 밖이다. HTTP 성공 뒤 DB 저장 전 프로세스가 죽으면 lease recovery가FAILED로 terminalize하며 같은 KST 날짜에 동일 slice를 재요청하지 않는다. 이는 외부 호출의 exactly-once 보장 대신 FR-69의 하루 1회·요청 절제 규약을 우선하는 명시적 손실-보수 정책이다. - 멱등:
job_source_id + run_date가 cycle 식별자이며, seen table은(cycle_id, source_job_id)유니크다. 자정 cycle creation과 09:00 snapshot 전 보충 creation은 같은 unique key로 멱등이다.JobPosting의 기존(job_source_id, source_job_id)와 domain refresh가 중복 slice 저장을 흡수한다. 결과 반영은(cycleId, leaseToken)조건부 update여야 한다. - 동시성:
claimReady는 MySQL 8FOR UPDATE SKIP LOCKED와 lease token으로 구현하고, 결과 반영은 expired/stale token을 0 row update로 거부한다. DB가 다중 인스턴스의 진실원천이다; in-memory scheduler pool은 보조일 뿐이다. - rate control: host permit 행을 짧게 잠가
next_allowed_at=max(now, next_allowed_at)+delay로 갱신한다. 반환된 시각까지 transaction 밖에서 대기한다. 소스의 기존 UA/host/path/page-size whitelist는 유지한다. - 비활성화와 부분 실패: dispatcher는 claim 직전과 throttle 대기 직후 source가 여전히 active인지 재확인한다. 비활성화된
NOT_STARTED/PENDINGcycle은 상태를 유지한 채 claim하지 않고,RUNNINGcycle은 이미 발행한 HTTP만 결과 반영할 수 있으나 다음 slice HTTP는 발행하지 않는다. 즉 disable 후 새 외부 호출은 없고, 같은 날 다시 active가 되면 기존 일일 cycle만 재개할 수 있어 FR-69의 source당 하나의 일일 cycle을 유지한다. detail 일부 실패는 현행처럼 카운트하고 slice/cycle은 진행한다. 목록 HTTP 실패(403/429/5xx/timeout/policy 포함)와 lease 만료는 즉시FAILED이며 해당 KST 날짜에 외부 HTTP를 다시 호출하지 않는다. 0건은 complete된 cycle에서만 abnormal으로 처리한다. budget exhausted는 운영 경고 대상이나 source broken 3일 카운터에는 넣지 않는다. - 마감 안전: named safe predicate
cycleStatus=SUCCESS && isComplete=true && fetchedCount>0일 때만 missed/close를 반영한다. complete-zeroSUCCESS는 abnormal·closeGuarded이며 final delta를 실행하지 않는다.FAILED,BUDGET_EXHAUSTED, 실행 중, lease 회수 중에도 소스 가드와 동일하게 마감 판정이 없다.
Component Diagram
flowchart LR Scheduler --> DispatchUseCase DispatchUseCase --> CycleService CycleService --> CycleRepository DispatchUseCase --> JobSourceGateway JobSourceGateway --> Adapter Adapter --> RequestExecutor RequestExecutor --> HostThrottle DispatchUseCase --> CompanyService CycleService --> PostingRepository CycleService --> RunRepository OperationAPI --> CycleQuery
Sequence Diagram
sequenceDiagram participant S as Scheduler participant D as DispatchUseCase participant C as CycleService participant G as Gateway/Adapter participant H as Host throttle S->>D: executeTick(workerId) D->>C: create + claim slice (short tx) C-->>D: cycle + lease token D->>G: collectSlice(descriptor) (no DB tx) G->>H: reserve host interval (short tx) H-->>G: notBefore G-->>D: postings + next cursor D->>C: apply slice (short tx) C-->>D: pending/success/budget exhausted
ERD
erDiagram JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_CYCLES : collects JOB_POSTING_COLLECTION_CYCLES ||--o{ JOB_POSTING_COLLECTION_SEEN_POSTINGS : observes JOB_POSTING_COLLECTION_CYCLES ||--|| JOB_POSTING_COLLECTION_RUNS : exposes_history JOB_POSTINGS ||--o{ JOB_POSTING_COLLECTION_SEEN_POSTINGS : seen_as EXTERNAL_REQUEST_HOST_LIMITS ||--o{ JOB_SOURCES : throttles_host 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 int list_page_count int fetched_count }
Schema needs for DBA
- Add
job_posting_collection_cyclesas the SSOT for(job_source_id, run_date):NOT_STARTED/PENDING/RUNNING/SUCCESS/FAILED/BUDGET_EXHAUSTED, cursor, lease token/until, next eligible time, slice attempt count, list-page/detail-request counts, completion flag, and budget-exhausted reason. Its(job_source_id, run_date)unique key is the sole daily execution identity. - Keep existing
job_posting_collection_runsas a terminal compatibility projection only. It receives one immutable terminal row after the cycle terminal transition; it never owns or stores cursor/lease/claim state, and no new collection decision reads it. The operation API derivesabnormal/closeGuardedfrom the cycle SSOT, not this projection. Existing historical rows remain readable. - Add
job_posting_collection_seen_postings(cycle_id, source_job_id, created_at)unique(cycle_id, source_job_id)for safe-close-predicate-only missed detection. Terminal transition (SUCCESSafter final delta only when safe predicate is true,SUCCESScomplete-zero without delta,FAILED,BUDGET_EXHAUSTED) must delete its seen rows in the same short transaction. A 10:15 KST residual cleanup runs in 1,000-row chunks for terminal cycles older than 48 hours, exposesseenRowsOlderThan48Hours, and alerts when it is non-zero; cleanup never touches RUNNING rows. - Add
external_request_host_limits(host PK, next_allowed_at, updated_at)for cross-instance host rate coordination. It is state, not an audit log. - Add immutable
job_posting_collection_sla_snapshotsheader (snapshot_dateunique,scheduled_at,capture_status=PENDING/CAPTURING/CAPTURED/MISSED/NOT_APPLICABLE,captured_at,miss_reason) andjob_posting_collection_sla_snapshot_items(snapshot_id,job_source_id, captured cycle status/counts/abnormal/close_guarded; unique(snapshot_id, job_source_id)). The day header is created at 00:00 and is the durable 09:00 cutoff gate: every cycle state-write transaction briefly locks it; at 09:00 capture locks it for the full cycle-lock/item-insert transaction. Therefore every cycle state transition serializes either before or after the snapshot, making CAPTURED items the truthful DB-serialization-time state at the 09:00 KST cutoff. These rows, not current cycles, are the SLA truth source. - Add indexes for ready claim on cycles
(cycle_status, next_eligible_at, lease_until, run_date)and per-source history(job_source_id, run_date). DBA must produce expand-only Flyway DDL and no inline backfill DML.
API Contract
No write endpoint is introduced. GET /api/operations/collection-runs remains URL and query compatible. Each item gains nullable/additive fields:
{
"cycleStatus": "NOT_STARTED|PENDING|RUNNING|SUCCESS|FAILED|BUDGET_EXHAUSTED",
"isComplete": true,
"listPageCount": 12,
"detailRequestCount": 100,
"attemptCount": 1,
"nextEligibleAt": "2026-07-30T00:22:00+09:00",
"leaseUntil": null,
"budgetExhausted": false,
"continuationPending": false,
"abnormal": false,
"closeGuarded": false
}runStatus stays for old clients (SUCCESS/FAILED; in-progress states are represented by cycleStatus). abnormal=true only for FAILED or complete SUCCESS with fetchedCount=0; BUDGET_EXHAUSTED is not abnormal. closeGuarded=true for NOT_STARTED, PENDING, RUNNING, FAILED, BUDGET_EXHAUSTED, and complete-zero SUCCESS; it is false only for complete non-zero SUCCESS. FE must render the server-supplied closeGuarded as “마감 판정 제외” and must not infer it from abnormal or reimplement the status mapping.
NOT_STARTED is a persisted cycle state, created for each eligible source at 00:00 KST and idempotently filled for an eligible source missing a cycle immediately before the 09:00 snapshot. It is not a query-time invented status. Disabled sources are excluded from claim and snapshot eligibility; they do not create a CANCELLED state or mutate an existing cycle solely because they were disabled.
The current-cycle summary is intentionally separate from the persistent 09:00 KST SLA snapshot. The 00:00-created daily snapshot header is a durable cutoff gate: every cycle state-write transaction locks it briefly, and the 09:00 capture holds it while it locks eligible cycles, idempotently creates any missing NOT_STARTED cycles, and writes immutable item rows. Thus a state update is serialized either before or after capture; CAPTURED values truthfully represent the DB-serialization-time 09:00 KST cutoff, not a later current query. Eligibility is active company-bound sources when posting.collection-dispatch-v2 is ON plus active aggregators only when that flag and aggregator.collection are ON, evaluated in the snapshot transaction in Asia/Seoul. The header unique date key makes multi-instance capture idempotent.
The API returns slaSnapshot with this full contract: { snapshotDate: LocalDate, scheduledAt: ZonedDateTime, captureStatus: "PENDING"|"CAPTURED"|"MISSED"|"NOT_APPLICABLE"|"UNAVAILABLE", capturedAt: ZonedDateTime|null, missReason: String|null, lateBeforeNotificationCount: Int|null, items: [{ jobSourceId: Long, sourceLabel: String, cycleStatus: "NOT_STARTED"|"PENDING"|"RUNNING"|"SUCCESS"|"FAILED"|"BUDGET_EXHAUSTED", isComplete: Boolean, fetchedCount: Int, abnormal: Boolean, closeGuarded: Boolean }]|null }. DB-internal CAPTURING is never exposed; API continues to return client-safe PENDING until its atomic capture commits. For CAPTURED, lateBeforeNotificationCount is the snapshot-item count whose captured status is NOT_STARTED, PENDING, RUNNING, FAILED, or BUDGET_EXHAUSTED; items are those immutable per-source statuses, never current cycles. Before 09:00 API returns PENDING, count 0, and absent items. For MISSED, NOT_APPLICABLE, or UNAVAILABLE, the count and items are null, never 0 or a late current-state calculation. A recovery tick after 09:00 must write MISSED only, with no post-cutoff reconstruction or CATCH_UP mode, if capture did not occur at the cutoff; this is required for truthful reporting. Before the first v2 capture and for pre-deployment dates, API returns NOT_APPLICABLE/UNAVAILABLE, with no backfill.
Testing Plan
| Level | Coverage |
|---|---|
| domain | budget boundaries, NOT_STARTED lifecycle, state matrix, stale token rejection, safe-close predicate including complete-zero rejection, idempotent seen IDs |
| application | source cap/fair claim, no transaction during gateway call, same-day HTTP failure terminalization, company resolution page path |
| infrastructure | MySQL concurrent claim, expired lease recovery, host throttle across two repository instances, adapter cursor/page caps |
| presentation | additive operation contract, 09:00 snapshot-before-notification ordering, and scheduler only triggers a tick |
| scenario | two app instances claim one source once; crash after HTTP leaves terminal FAILED until next KST day without replay; 09:00 snapshot remains immutable after a 09:01 completion; 2,594-result source becomes bounded slices and does not false-close |
Release Scenario — 무중단 배포
- Expand: DBA adds
job_posting_collection_cycles, seen/throttle/SLA snapshot tables and indexes. Existingjob_posting_collection_runsremains unchanged and old schedulers still write its validSUCCESS/FAILEDterminal history. Rollback: new tables/indexes can remain; old binary ignores them. - Dark deploy: deploy code with
posting.collection-dispatch-v2=false; legacy schedulers continue. New operation fields are nullable. Rollback: flag remains OFF. - Seed/activate: set v2 ON, first tick creates current-day
NOT_STARTEDcycles only for sources that have no legacy run. Disable legacy two collection schedulers before v2 scheduler ON to avoid dual outbound collection. At 09:00 run the durable SLA snapshot transaction before notification dispatch. Rollback: turn v2 OFF; let leased work expire, then re-enable legacy only on the next KST day (same-day one-per-source constraint prevents unsafe replay). - Observe: verify no active lease past 10 minutes, no
BUDGET_EXHAUSTEDon company-bound sources, host interval metric meets policy,seenRowsOlderThan48Hours=0, and aCAPTURED09:00 snapshot exists before notification dispatch;MISSEDis an explicit operational incident, never silently replaced by later current status. - Contract: after 30 days of stable operation, remove legacy scheduler code in a separate contract release; do not drop history columns/tables in this feature.
Data migration plan
No backfill is needed. New cycle state begins on deployment day. Existing terminal run history remains immutable; Flyway performs DDL only.
Ticket DAG and Single Writer Check
| Wave | 티켓 | 너비 | 같은 wave 수정 경로 교집합 |
|---|---|---|---|
| 0 | DBA cycle/seen/throttle/SLA migration, BE-33 | 2 | 없음 — DBA는 migration, BE-33은 domain/posting/** 계약 |
| 1 | BE-35, BE-36, BE-37, BE-38, BE-39, BE-42, BE-44 | 7 | 없음 — executor/throttle, 플랫폼별 adapter 패키지 4개, company-bound adapter 패키지, cleanup worker, cycle persistence/state-write 패키지로 분리 |
| 2 | BE-43 | 1 | BE-44의 CollectionSlaCutoffGate domain port 구현을 제공 |
| 3 | BE-34, BE-40 | 2 | 없음 — dispatcher가 state-write/gate contract를 소비, operation mapper가 snapshot query를 소비 |
| 4 | BE-41 | 1 | 의도된 단일 통합 — application.yml, scheduler, 공통 wiring |
BE-34는 새 dispatcher 경로만, BE-39는 회사 종속형 adapter/domain delta 경로만 변경한다. BE-36~38은 각각 서로 다른 aggregator adapter 디렉터리만 수정한다. 공통 JobSourceGateway/config/scheduler 파일 수정은 BE-33 또는 BE-41로 고정해 같은 wave 충돌을 피한다.
Requirements Coverage
| PRD/검토 요구 | 설계 요소 | 구현 티켓 |
|---|---|---|
| FR-9/FR-60 활성 소스 수집 | tick claim 상한·source cycle queue | BE-33, BE-34, BE-41 |
| FR-12/FR-14/FR-15 마감 안전 | safe-close predicate (SUCCESS && complete && fetched>0)·BUDGET_EXHAUSTED/complete-zero guard | BE-33, BE-39 |
| FR-19 소스 고장 | complete-zero/FAILED만 health abnormal, partial detail failure 격리 | BE-33, BE-34 |
| FR-66 전체 저장 | page slice별 idempotent 저장 | BE-33, BE-34, BE-36~39 |
| FR-69 rate/UA/page 규약 | DB host throttle + adapter page/detail budgets | BE-35, BE-36~39, BE-41 |
| NFR-2 09:00 이전 운영 가시성 | exact KST late predicate·run summary·closeGuarded | BE-40, BE-41 |
| NFR-2 09:00 SLA 진실성 | immutable snapshot, cutoff missed 처리, NOT_STARTED 보충 생성 | BE-33, BE-40, BE-43 |
| BE-34 영속 state-write 선행 계약 | active claim·lease fencing·terminal persistence·SLA gate port | BE-44 |
| 수집 seen 데이터 보존 상한 | terminal delete + 48시간 residual cleanup/metric | BE-33, BE-42 |
| 다중 인스턴스 미래 대응 | MySQL claim/lease token/expiry recovery | BE-33, BE-34, BE-35 |
| Operations 이력 조회 | additive API contract | BE-40 |
Open Questions
- Product owner must set production daily budget per platform after the first week of measured page/detail/request counts; the defaults above are safety caps, not a claim of platform quota.
- If a source is repeatedly
BUDGET_EXHAUSTED, choose either narrower registered search conditions or a future cursor-across-days coverage cycle; that latter design needs a separate product decision because it changes freshness/completeness semantics.
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-30 | 최초 작성 — 대량 수집 상한, durable slice queue, lease recovery, DB host throttle, 운영 계약 |