[BE-10] 수집 오케스트레이션 — 델타 비교 · 소스 가드 · 소스 고장 판정
작업 내용 (설계 의도)
근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “수집 실패 경로”, “상태 전이 표 / JobPosting”, “Sequence Diagram 자정 수집”
변경 사항
이 프로젝트에서 사고 위험이 가장 높은 티켓입니다. 매일 자정 등록된 모든 소스를 수집하고, 같은 실행 안에서 델타 비교로 신규·변경·미발견을 판정합니다(FR-9, FR-14).
핵심 설계 의도 — 방어 장치가 본체입니다:
- 소스 가드(FR-15) — 회차가 실패했거나 0건이면 그 소스의 마감 판정 자체를 건너뜁니다. 미발견 카운터를 증가시키지도 않습니다. 이 가드가 없으면 파서가 한 번 깨질 때 그 회사 공고 전체가 CLOSED로 뒤집힙니다. 가드는 1회차부터 즉시 발동합니다.
- 연속 미발견 2회 → CLOSED(FR-14) — 정상 회차에서만 카운터가 움직입니다. 1회차는 유예입니다.
- 델타 모수 제한 —
origin = COLLECTED이고accessRestricted = false인 공고만 비교 대상입니다. 수동 등록 공고(FR-20)가 모수에 들어가면 소스에서 매번 미발견으로 잡혀 즉시 CLOSED됩니다. 도메인의isDeltaTarget()으로 판정하고, 조회 단계에서도origin조건을 걸어 이중으로 방어합니다. - 재오픈(FR-16) — CLOSED 공고가 다시 발견되면 OPEN으로 복귀하되 신규 공고 알림은 발생시키지 않습니다(기존 레코드의 상태 전이이므로 자연히 신규가 아닙니다).
- 시딩(FR-10) — 소스의 최초 수집은 저장만 하고
notificationEligible=false로 표시해 알림 대상에서 영구 제외합니다. 이후JobSource.markSeeded(). - 소스 고장 판정(FR-19) — 3일 연속 비정상(실패 또는 0건)이면
JobSourceHealth가 고장으로 진입합니다. 가드(1회차)와 고장 알림(3일 연속)은 트리거가 다릅니다 — 두 판정을 분리해 구현합니다. 알림 발송 자체는 BE-16이 담당하고, 이 티켓은 상태만 남깁니다. - 소스 단위 트랜잭션 격리 — 소스 A의 예외가 소스 B의 수집을 중단시키지 않습니다. 소스별로 try/catch + 독립 트랜잭션.
- 중복 실행 방지 —
job_posting_collection_runs의unique(job_source_id, run_date). 같은 날 재실행은 DuplicateKey로 skip합니다(NFR-6: 실패해도 재시도 없이 다음 날). - 피처 플래그 —
posting.auto-close가 false면 수집·저장은 수행하되 미발견 판정 자체(연속 미발견 카운터 증가 + CLOSED 전환)를 통째로 동결합니다. 마감 오판정이 관측될 때 재기동 없이 즉시 멈추는 수단입니다. 카운터만 증가시키고 전환만 막는 대신 판정 전체를 멈추는 이유: ①markMissed()가 카운터 증가와 전환을 한 캡슐화 메서드로 묶어 분리 API가 없고, ② 만약 OFF 기간 중 카운터를 계속 올렸다면 플래그를 다시 켜는 순간 밀린 마감이 대량으로 한꺼번에 CLOSED되는 스파이크가 터져(=플래그를 끈 목적과 정반대) 더 위험합니다. 동결 방식은 오판정으로 잘못 CLOSED하지 않는 안전 방향이며, OFF→ON 후에는 정상 회차 2회 미발견 사이클이 재개돼 마감이 유계 지연(최대 1~2회차) 내에 반영됩니다 — 영구 OPEN으로 남지 않습니다. - 이벤트 발행 — 신규·변경 공고에 대해
JobPostingEvent.Discovered/Changed를 Layer 1(Spring ApplicationEvent)로 발행합니다. Kafka를 쓰지 않는 근거는 TDD “방안 5”에 있습니다. - 본문·구조화 태그 영속화 — 어댑터가 반환한
descriptionBody·structuredTags를JobPostingDetailRepository로 저장합니다. 이 저장이 없으면 FR-25(키워드 변경 후 재매칭)와 FR-27②(JD 본문 근거)가 성립하지 않습니다 — 재평가 시점에 소스를 다시 호출할 수 없기 때문입니다. 본문은 최신 1건 덮어쓰기, 태그는 전량 교체입니다. 본문이null(상세 조회 실패·미지원 소스)이면 저장을 건너뛰고 기존 본문을 유지합니다 — 실패로 기존 본문을 지우면 그 공고의 ②근거가 사라집니다. run_date는 KST 업무 일자로 계산해 기록합니다. UTC 날짜로 넣으면 KST 09:00 이전 실행분이 전날로 기록돼 하루 1회 보장이 깨집니다.- dedupKey 계산 — 공고 저장 시 회사명(소스가 속한
Company.name)을JobPosting.createCollected(raw, companyName, seeded)에 넘겨 엔티티가dedup_key를 계산·보관합니다. 이 티켓은 크로스 소스 그룹핑을 하지 않습니다 — 그룹핑·대표 선정은 08:30 dedup 배치(BE-24)가 전담합니다. 여기서는 키를 심어 두기만 합니다.
이 티켓은 회사 종속형 수집만 담당합니다. 애그리게이터 수집·회사 자동 등록은 BE-25가 별도 UseCase·스케줄러(00:10)로 처리하며, 두 흐름은 같은 도메인 서비스·리포지토리를 공유하되 파일이 분리됩니다. 외부 호출은 BE-02의 SourceRequestExecutor(규약 강제)를 경유합니다.
범위: CollectJobPostingsUseCase(@Transactional 위치), JobPostingCollectionDomainService, JobPostingCollectionScheduler(cron 0 0 0 * * *, zone Asia/Seoul — UseCase 1줄 호출).
롤백: 피처 플래그 posting.auto-close=false로 마감 판정 즉시 중단. 오판정된 공고는 소프트 삭제이므로 상태 컬럼 복구로 되돌립니다.
의존
- BE-02 (도메인·SPI 계약), BE-06 (활성 소스 조회)
다이어그램
처리 흐름
sequenceDiagram participant S as CollectionScheduler participant U as CollectJobPostingsUseCase participant D as CollectionDomainService participant G as JobSourceGateway participant R as JobPostingRepository participant H as JobSourceHealthRepository S->>U: execute() loop 활성 소스별 (독립 트랜잭션) U->>D: collect(descriptor) D->>G: collect(descriptor) G-->>D: Fetched / Failed alt 비정상 회차 (Failed 또는 0건) D->>H: recordAbnormal() — 3일 연속이면 고장 진입 D->>D: 소스 가드 — 델타 판정 전면 skip else 정상 회차 D->>H: recordNormal() D->>R: findAllCollectedIn(sourceId) D->>D: 신규 · 변경 · 미발견 분류 (수동 공고 제외) D->>R: saveAll(신규 · 갱신 · 카운터 · CLOSED 전환) end D->>R: 회차 이력 저장 end
클래스 의존
flowchart LR subgraph Presentation["presentation/posting"] Sched[JobPostingCollectionScheduler] end subgraph Application["application/posting"] UC[CollectJobPostingsUseCase] end subgraph Domain["domain/posting"] DS[JobPostingCollectionDomainService] Posting[JobPosting] Health[JobSourceHealth] Run[JobPostingCollectionRun] GW[JobSourceGateway] Pub[DomainEventPublisher] Flag[FeatureFlagGateway] end Sched --> UC UC --> DS DS --> Posting DS --> Health DS --> Run DS --> GW DS --> Pub DS --> Flag
테스트 케이스
- 정상 회차에서 신규 공고가 저장되고
Discovered이벤트가 발행된다 - 시딩(최초 수집) 회차의 공고는
notificationEligible=false로 저장되고 이벤트는 발행되되 알림 대상이 아니다 - 두 번째 수집부터 신규 공고는
notificationEligible=true로 저장된다 - 수집이 예외로 실패하면 그 소스의 어떤 공고도 CLOSED되지 않고 미발견 카운터가 증가하지 않는다 (소스 가드)
- 수집이 0건을 반환하면 예외가 없어도 비정상 회차로 분류되어 델타 판정을 건너뛴다
- 정상 회차에서 미발견 1회차는 OPEN을 유지하고 카운터가 1이 된다
- 미발견 2회 연속이면 CLOSED로 전이한다
- CLOSED 공고가 재발견되면 OPEN으로 복귀하고 신규 공고 알림 대상이 되지 않는다
- 수동 등록(
origin=MANUAL) 공고는 델타 모수에 포함되지 않아 미발견 카운터가 증가하지 않는다 accessRestricted=true공고는 마감 판정 대상에서 제외된다- 3일 연속 비정상이면
JobSourceHealth가 고장 상태로 전이한다 - 2일 연속 비정상에서는 고장 상태로 전이하지 않는다
- 소스 A가 예외를 던져도 소스 B의 수집은 정상 완료된다
- 같은 날 두 번째 실행은 유니크 제약으로 skip되고 데이터가 중복 반영되지 않는다
posting.auto-close=false면 수집·저장은 되지만 CLOSED 전환이 발생하지 않는다- 변경 시그니처가 달라진 공고는 갱신되고
Changed이벤트가 발행된다 - 시그니처가 동일한 공고는
lastSeenAt만 갱신되고 이벤트가 발행되지 않는다 - 신규 공고 저장 시 본문과 구조화 태그가 함께 영속화된다
- 재수집으로 본문이 바뀌면 최신 1건으로 덮어써지고 태그는 전량 교체된다
- 본문이 null인 공고(상세 조회 실패)는 기존 본문을 지우지 않고 유지한다
run_date가 KST 업무 일자로 기록되어 KST 자정 직후 실행이 당일로 남는다- 신규 공고 저장 시 회사명 기반
dedup_key가 함께 채워진다