타깃 공고 알림 및 지원 히스토리 TDD

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-prd.md 근거 사전 조사: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-채용소스-조사-브리프.md (실제 HTTP 호출로 검증됨)

1인 사용자가 지정한 회사의 채용 공고를 매일 자동 수집해 디스코드로 알리고, 지원 이력을 상태 전이·면접 회차 단위로 추적하는 개인용 도구입니다. 대상 레포 /Users/biuea/recruitment-applicationREADME.md.claude/private-project 마커만 있는 빈 레포이므로, 이 문서는 모듈 구조·패키지 레이아웃·스키마 베이스라인부터 설계합니다.

이 문서의 범위는 PRD Milestone 1단계 = P0 요구사항 41건 전부입니다. P1(FR-3·8·35 일부·39·40·49)·P2(FR-28·31·32③·51·52)는 확장 지점만 표시하고 상세 설계하지 않습니다.

Overview

항목결정
무엇을회사·소스 등록 → 일 1회 수집 → 델타 기반 마감 감지 → 직무 매칭·근무형태 추출 → 디스코드 알림 → 지원 상태 추적
사람이 채용 페이지를 주기 방문하는 방식은 누락이 발생하고, 지원 이력이 흩어져 진행 상황을 잃습니다
어떻게Kotlin/Spring Boot 단일 프로세스 + @Scheduled 4종 배치, MySQL 8.0 + Flyway, Hexagonal + Rich Domain Model, 컨텍스트 간 결합은 Spring ApplicationEvent(Layer 1)
규모 전제소스 30개 미만, 공고 수천 건, 1일 1회 배치 (NFR-1) — 브로커·별도 워커·캐시 계층 미도입
핵심 위험① 파서 고장이 정상 공고를 무더기 CLOSED로 뒤집는 사고 ② 알림 중복·누락 ③ EUC-KR 문자 깨짐 — 세 가지 모두 설계 단계에서 방어 장치를 확정합니다

Terminology

용어정의
소스(JobSource)회사 하나의 채용 데이터 출처. (플랫폼 타입, slug) 조합. 한 회사가 복수 소스를 가집니다 (Company : JobSource = 1:N)
플랫폼(JobPlatform)어댑터 구현 단위. GREENHOUSE / WOOWAHAN / INCRUIT 3종 (P0)
어댑터(PlatformAdapter)한 플랫폼의 목록 조회·후보 탐색·정규화를 담당하는 infrastructure 구현체
수집 회차(CollectionRun)소스 1개 × 실행일 1일의 수집 실행 단위. 성공/실패·건수·오류를 기록
델타 비교이번 회차 수집 결과와 DB의 OPEN 공고 집합을 비교해 신규·변경·미발견을 판정하는 절차
소스 가드수집 실패 또는 0건 회차에서 마감 판정 자체를 건너뛰는 규칙 (FR-15). 1회차부터 즉시 발동
소스 고장3일 연속 비정상(실패 또는 0건)이 누적된 상태 (FR-19). 가드와 발동 주기가 다릅니다
비정상 회차수집 실패 또는 fetched=0인 회차. 가드·고장 판정의 공통 입력
변경 시그니처(ChangeSignature)공고 변경 감지 근거. 소스별로 UpdatedAt / SourceVersion / BodyHash 중 하나 (sealed 타입)
마감일 정규화마감일 필드 부재(Greenhouse)·상시채용 센티널(배민 9999-12-31)을 어댑터가 null로 통일하는 처리 (FR-13)
상시채용deadlineAt == null인 공고. 마감일 경과 판정 대상에서 제외
시딩(Seeding)소스 등록 후 최초 수집. 저장은 하되 알림 대상에서 제외 (FR-10)
확신도(Confidence)근무형태 판정 근거 강도. CONFIRMED(구조화 필드) / LIKELY(JD 본문) / INFERRED(P2) / UNKNOWN
발송 회차(dispatchSequence)같은 대상·같은 알림 종류의 N번째 발송. 멱등 키의 구성 요소 (FR-38)
멱등 키{대상종류}:{대상ID}:{알림종류}:{발송회차}. 발송 성공 시에만 채워지는 컬럼

Define Problem

AS-IS

대상 레포에 코드가 없습니다. 실제 확인 결과:

/Users/biuea/recruitment-application
├── .claude/private-project   (마커)
├── .git                      (커밋 1건: "레포 초기화 및 개인 프로젝트 마커 추가")
└── README.md                 (190 bytes)

빌드 스크립트·소스 디렉토리·마이그레이션·컨테이너 정의가 전부 부재합니다. 따라서 AS-IS 문제는 코드 구조가 아니라 운영 방식입니다.

현재 방식문제
채용 페이지 수동 방문확인 주기가 사람 손에 달려 누락 발생
상시채용 공고”이미 봤다”는 착각으로 재확인 주기를 놓침
지원 이력 스프레드시트회사별 기록이 흩어지고 면접 회차·단계 전이가 유실
회사마다 다른 사이트 구조확인 난이도가 회사마다 달라 일관된 추적이 불가능

소스 3종의 구조 차이는 사전 조사에서 실제 HTTP 호출로 검증됐습니다.

소스안정 ID변경 감지 수단마감일근무형태 구조화 필드인코딩
Greenhouse (boards-api.greenhouse.io/v1/boards/{slug}/jobs)idupdated_at필드 없음metadata[] 보유 (①+② 가능)UTF-8
배민형 (career.woowahan.com/w1/recruits)recruitSeqrecruitVersionrecruitEndDate (상시 = 9999-12-31)없음 (②만)UTF-8
인크루트 (recruit.incruit.com/{slug}/)공고 URL의 job id상세 본문 비교목록에 노출 (2026.07.16 00:00 ~ 2026.07.30 17:00)없음 (②만)EUC-KR 계열

TO-BE

  • 단일 Spring Boot 프로세스가 6개 스케줄(00:00 회사 종속형 수집 / 00:10 애그리게이터 수집 / 00:30 마감일 경과 / 08:30 크로스 소스 중복 판정 / 08:50 매칭 보정 / 09:00 알림 발송)을 수행합니다.
  • 소스 능력 차이는 공통 인터페이스로 뭉개지 않고 SourceCapabilities 값 객체 + ChangeSignature sealed 타입으로 표현합니다.
  • 마감일 정규화는 어댑터 책임입니다. 도메인은 deadlineAt == null만 보고 상시채용을 판정합니다.
  • 마감 판정은 수집 회차 안의 델타 비교로 수행하되, 비정상 회차는 판정 자체를 건너뜁니다.
  • 컨텍스트 간 결합은 Spring ApplicationEvent(Layer 1)로 끊고, 리스너 실패는 08:50 보정 스윕이 흡수합니다.

Architecture Benchmarking

동일 과제(다중 소스 채용 공고 수집 → 신규/변경/마감 판정 → 알림)를 푼 사례를 조사했습니다.

제품/사례해결 방식참고할 패턴미참고 사유
LinkedIn Job Ingestion System (engineering blog)Orchestrator가 피드 유형별 Mining Node로 라우팅. Mining Task는 START/JOB/END 상태 머신으로 피드 1회 수집에 트랜잭션 경계를 부여하고, mining task watcher가 종료 시 “최근 미발견 공고는 즉시 제거하지 않고 유예(suspend), 임계 초과 시에만 제거”하는 lifecycle 정책을 적용. 소스별 우선순위 큐 + 서킷 브레이커로 연쇄 실패 차단미발견 유예 정책 → 연속 미발견 2회 임계(FR-14)의 근거로 채택 ② 피드 단위 트랜잭션 경계CollectionRun(소스×일자)을 수집·판정의 단일 경계로 채택 ③ 소스 단위 격리 → 한 소스 실패가 배치 전체를 중단시키지 않도록 소스별 트랜잭션 분리 채택Orchestrator·Mining Node 분산 토폴로지, 우선순위 큐, 분산 캐시 상태 추적은 소스 30개 미만 규모에 과합니다 (NFR-8). 단일 프로세스 순차 처리로 대체
Olostep — “Job Scraping: Build a Pipeline, Not a Bot” (blog)6단계 파이프라인(discovery → extraction → normalization → dedup → freshness scoring → delivery). 소스를 Tier 1(ATS 공개 API) / Tier 2(애그리게이터) / Tier 3(잡보드)로 계층화해 추출 방식을 다르게 적용. first_seen/last_seen 타임스탬프로 ghost job(마감됐는데 살아있는 공고, 주요 플랫폼에서 18~22%) 판별. “파서가 예외 없이 null을 반환하는 silent failure·schema drift를 능동 감지”할 것을 강조소스 티어별 추출 전략 → 조사 브리프의 어댑터 5단계 절차와 동일 방향, 플랫폼 단위 어댑터 설계에 채택 ② first_seen/last_seenfirstSeenAt/lastSeenAt 컬럼으로 채택 ③ silent failure 감지 → “예외 없이 0건 반환”도 비정상 회차로 취급(FR-15·19)하는 근거로 채택프록시 로테이션·anti-bot 우회·벡터 스토어 delivery는 대상 소스 3종이 모두 공개 JSON/서버 렌더링이라 불필요합니다 (PRD Non-Goals: 헤드리스 브라우저 미도입)
Feashliaa/job-board-aggregator (GitHub)7개 ATS(Greenhouse·Lever·Ashby·BambooHR·iCIMS·Paylocity·Workday)를 플랫폼별 스크레이퍼 + 플랫폼별 동시성 상한(Workday 50 / Greenhouse·Lever 30 / BambooHR 10 / Ashby·Paylocity 5)으로 병렬 수집. 일 1회 GitHub Actions로 갱신, merge_data.py가 dedup + 30일 초과 공고 prune. 플랫폼별 수집량 이상치를 감지해 이슈를 자동 생성플랫폼 단위 어댑터 + 어댑터별 요청 정책 → 인크루트만 요청 간 지연 1초·상세 조회 상한을 두는 설계로 채택 ② 수집량 이상치 감지 → 소스 고장 감지(3일 연속 0건)로 채택”30일 경과 공고 prune”은 물리 삭제이며 NFR-5(무기한 보존)·FR-18(소프트 삭제)과 정면 충돌합니다. 미채택
changedetection.io (GitHub)변경 감지 전용 도구. processor 플러그인 구조로 감지 방식(전체 텍스트 해시 / XPath·CSS 선택자 부분 / JSON 경로)을 교체. 스냅샷 보관 후 diff 제공변경 근거를 소스별로 교체 가능한 전략으로 두는 구조ChangeSignature sealed 타입(UpdatedAt / SourceVersion / BodyHash)으로 채택. 인크루트는 본문 해시 비교스냅샷 전문 보관·시각적 diff·워치 스케줄 UI는 이번 범위 밖입니다. 본문 해시만 보관하고 원문 스냅샷은 최신 1건만 유지

Possible Solutions

방안 1 — 서버 토폴로지: 실행 형태를 어떻게 나눌 것인가

후보를 넷 놓고 비교했습니다. “모든 것을 API 서버 하나에” 라는 기본값을 검증 없이 채택하지 않았습니다.

방안설명왜 채택 / 미채택
A. API 서버 단일 프로세스 + 인프로세스 @ScheduledSpring Boot 하나가 REST API와 6개 스케줄 배치를 함께 수행. 컨테이너 1개 + MySQL 1개채택. 워크로드가 1일 1회이고 사용자는 1명입니다. 애그리게이터 편입 후에도 전체 배치가 09:00 이전 완료(NFR-2 재조정, 방안 11)되며, 실행 주체를 나눌 이유(독립 스케일·장애 격리·상이한 배포 주기)가 하나도 성립하지 않습니다. PRD Non-Goals의 “별도 워커 프로세스·브로커 미도입”과도 정합
B. API 서버 + 배치 워커 프로세스 분리수집·발송을 별도 컨테이너로 분리, DB만 공유미채택. 분리 이득(배치가 API 응답에 주는 영향 차단)이 사용자 1명 환경에서 0에 가깝고, 컨테이너 2개 운영·중복 실행 방지(분산 락) 비용만 늘어납니다
C. 스케줄러 서버 + 브로커 + 컨슈머 워커수집 작업을 큐로 팬아웃미채택. PRD Non-Goals 명시(브로커 미도입). 소스 30개를 순차 처리해도 10분 이내라 병렬화 자체가 불필요
D. 소켓/SSE 서버 추가수집 진행을 실시간 푸시미채택. 알림 채널이 디스코드 웹훅이고, 조회는 요청-응답으로 충분합니다. 실시간 양방향 요구 없음

A 채택 시 확장 여지: 스케줄러 클래스는 UseCase 한 줄 호출만 담습니다(presentation/**/scheduler/). 나중에 배치를 분리해야 하면 같은 UseCase를 다른 진입점에서 호출하면 되고, 도메인·애플리케이션 코드는 무변경입니다.

방안 2 — 소스 어댑터 추상화: 능력 차이를 어떻게 표현할 것인가

방안설명왜 채택 / 미채택
a. 공통 인터페이스 1개 (fetch(): List<JobPosting>)모든 소스가 같은 계약을 구현, 없는 정보는 null미채택. “마감일 null”이 필드 부재(Greenhouse) 인지 파싱 실패인지 구분이 사라지고, 변경 감지 근거가 소스마다 다른 사실이 코드에서 소실됩니다. 호출부가 if (platform == INCRUIT) 분기를 갖게 되는 전형적 경로
b. 소스별 UseCase 3벌플랫폼마다 수집 흐름을 따로 작성미채택. 델타 비교·가드·시딩·재오픈 로직이 3벌 복제됩니다. 사고 위험 1순위 로직을 복제하는 건 최악의 선택
c. 공통 수집 흐름 + 능력 선언 값 객체 + sealed 변경 시그니처어댑터는 SourceCapabilities(마감일 제공 여부·구조화 필드 보유·상세 조회 필요 여부·요청 정책)를 선언하고, 변경 근거는 ChangeSignature sealed(UpdatedAt / SourceVersion / BodyHash)로 반환. 마감일 정규화·인코딩·상세 조회 지연은 전부 어댑터 내부에서 끝내고, 도메인에는 정규화 완료된 RawJobPosting만 넘김채택. 수집 흐름(델타·가드·판정)은 1벌로 유지하면서 소스 차이는 타입으로 드러납니다. when(signature) 전수 분기를 컴파일러가 강제해 새 플랫폼 추가 시 누락이 컴파일 에러로 잡힙니다

방안 3 — 마감 판정: 어디서 무엇을 근거로 판정하는가

방안설명왜 채택 / 미채택
a. 미발견 즉시 CLOSED이번 수집에 없으면 바로 마감미채택. 소스 일시 장애·페이지네이션 누락 한 번에 그 회사 공고 전체가 뒤집힙니다
b. 연속 미발견 2회 + 소스 가드 + 마감일 경과 별도 판정① 비정상 회차(실패·0건)는 판정 skip ② 정상 회차에서 미발견이면 카운터 증가, 2회 연속이면 CLOSED ③ 마감일이 지난 공고는 DB만으로 판정하는 경량 배치가 별도 처리채택. LinkedIn의 “미발견 유예 → 임계 초과 시 제거” 정책과 동일 구조입니다. ③은 수집 성공 여부와 무관하게 동작해 수집이 며칠 깨져도 마감일 기반 판정은 계속 정확합니다
c. 소스가 제공하는 마감 플래그만 신뢰소스의 상태 필드 사용미채택. Greenhouse는 마감일 필드 자체가 없고, 인크루트는 목록에서 사라지는 것이 유일한 신호입니다. 3종 공통으로 성립하지 않습니다

방안 4 — 알림 멱등: 반복 발송과 어떻게 공존시키는가

방안설명왜 채택 / 미채택
a. (공고ID + 알림종류) 유니크가장 단순미채택. P1의 상시채용 7일 반복 리마인드가 2회차부터 영구 차단됩니다. 지금 스키마를 이렇게 잡으면 P1에서 유니크 제약 변경(파괴적 마이그레이션)이 필요합니다
b. (대상+종류+회차) 유니크를 모든 시도에 부여시도 시점에 레코드 생성미채택. 발송 실패 레코드가 키를 점유해 재발송이 영구 차단됩니다. 시나리오 7-3(“다음 정상 발송 시점에 재발송”)이 성립하지 않습니다
c. (대상종류+대상ID+종류+회차) 문자열을 nullable 유니크 컬럼에 두고, 발송 성공 시에만 채움시도 레코드는 항상 남기되 idempotency_key는 성공 시에만 값을 넣습니다. MySQL 유니크 인덱스는 NULL 중복을 허용하므로, 실패 레코드는 여러 건 공존하고 성공 레코드는 대상당 1건만 존재합니다채택. 세 요구를 동시에 만족합니다 — ① 중복 발송 0건(FR-38) ② 실패 이력 보존·조회(Operations) ③ 재발송 가능(시나리오 7-3). 회차가 키에 있으므로 P1 반복 리마인드도 스키마 변경 없이 동작

멱등 기준 확정: 성공 기준입니다. 발송 시도 레코드는 남기되 멱등 키는 디스코드 웹훅이 2xx를 반환한 시점에만 부여합니다. 근거: 시나리오 7-3이 “3회 재시도 모두 실패 후 다음 정상 발송 시점에 재발송”을 요구하므로, 실패가 키를 점유하면 요구를 만족할 수 없습니다. 반대급부로 “발송은 됐는데 응답 수신 전 프로세스가 죽는” 경우 중복 발송이 1회 가능합니다 — 사용자 1명·디스코드 메시지 중복이라는 피해가 무시 가능한 수준이라 수용합니다.

방안 5 — 컨텍스트 간 결합: 이벤트 레이어 판단

private-be-architecture-rule의 레이어 판단 기준을 적용했습니다.

판단 질문결론
구독자가 발행자와 같은 앱·같은 배포 단위인가예 (단일 Spring Boot 프로세스)Layer 1
구독자가 별도 서비스/배포 단위인가아니오Layer 2 불필요
프로세스가 죽어도 이벤트 유실이 치명적인가아니오 — 유실되면 08:50 보정 스윕이 재평가합니다. 발송 시각까지 9시간 여유Layer 2 불필요
재처리·다중 소비자·순서 보장이 필요한가아니오 (구독자 1개, 순서 무관, 재처리는 스윕이 담당)Layer 2 불필요

결론: Layer 1(Spring ApplicationEvent) 채택, Kafka 미도입. PRD Non-Goals(브로커 미도입)와 정합하며, “판단이 애매하면 Layer 1로 시작하고 필요해지면 승격” 원칙에도 부합합니다.

방안 6 — 기타 기술 선택 (처리량·지연·운영 복잡도 기준)

후보 기술검토 대상판정
Spring Batch (청크·재시작)수집 배치미채택. 소스 30개·공고 수천 건은 단일 트랜잭션 청크가 불필요한 규모이고, JobRepository 메타 테이블 9개가 스키마를 오염시킵니다. 재시작 요구도 없습니다(NFR-6: 실패 시 재시도 없이 다음 날)
멀티 스레드 병렬 수집 (CompletableFuture)소스별 병렬미채택(1단계). 순차 처리로 10분 이내 달성 가능합니다(소스 30개 × 평균 5초 = 2.5분, 인크루트 상세 조회 200건 × 1초 = 3.3분). 병렬은 대상 사이트 부하·디버깅 난이도만 올립니다. 소스가 100개를 넘으면 어댑터별 상한을 둔 병렬로 승격
트랜잭션 버퍼 / 배치 insert공고 저장부분 채택. 소스 1개 회차 = 트랜잭션 1개로 묶고 saveAll을 사용합니다. 소스 간에는 트랜잭션을 분리해 부분 실패를 격리합니다
Redis 캐시조회·중복 방지미채택. PRD Non-Goals(별도 캐시 계층 미도입). 멱등은 DB 유니크 제약으로 충분합니다
Resilience4j 서킷 브레이커소스 호출미채택. 호출이 1일 1회라 회로를 열 트래픽이 없습니다. 소스 가드(FR-15)가 동일 목적을 더 단순하게 달성합니다
Spring Retry (@Retryable)디스코드 웹훅채택. 지수 백오프 3회(FR-41)를 DiscordWebhookGatewayImpl 내부에서 수행하고, 429Retry-After 헤더를 존중합니다
DB 기반 피처 플래그위험 기능 토글채택. feature_flags 테이블 + FeatureFlagGateway. @ConditionalOnProperty는 금지 패턴(no-conditional-on-property)이며, 재기동 없이 OFF해야 하는 요구(무중단 롤백)와 맞지 않습니다
낙관적 잠금(@Version)JobPosting·Application채택. 배치와 사용자 API가 같은 레코드를 동시 수정할 수 있는 유일한 지점입니다. 비관적 락은 경합이 사실상 없어 과합니다

방안 7 — 도메인 바운디드 컨텍스트 경계

“새 기능마다 새 도메인을 만들지 않는다”는 원칙에 따라, 먼저 기존/인접 도메인에 합류 가능한지 판단했습니다.

후보 컨텍스트합류 가능성 검토결정근거
company (Company, JobSource)posting에 합류 가능?분리라이프사이클이 독립적입니다 — 회사·소스는 사용자가 등록 시점에 1회 쓰고 이후 변경이 거의 없는 반면, 공고는 매일 쓰입니다. 소스 탐색(discovery)은 수집과 실행 주기가 명시적으로 분리됩니다(FR-6)
posting (JobPosting, CollectionRun, SourceHealth)독립수집·마감 판정의 데이터 소유자
matching (키워드 기준, 평가 결과)posting에 합류 가능?분리① 기준(키워드 그룹·제외어·근무형태 키워드)은 공고와 무관하게 사용자가 언제든 변경하며, 변경 시 저장된 공고 전체를 재평가합니다(FR-25) — 독립 변경 주기 ② 평가 결과 테이블을 matching이 소유하면 posting은 매칭 개념을 몰라도 됩니다 ③ 합류시켰다면 posting 도메인이 수집·마감·매칭·근무형태 4책임을 갖게 되어 사고 위험 1순위 로직과 뒤섞입니다
application (Application, StatusHistory, Interview)posting에 합류 가능?분리”미지원 = Application 미존재”(FR-42)라는 요구 자체가 공고와 지원의 라이프사이클 분리를 전제합니다. 공고가 CLOSED돼도 지원 이력은 무기한 보존됩니다(NFR-5)
notification (NotificationDispatch)각 컨텍스트가 자기 알림을 보내면?분리알림 종류가 공고(신규)·소스(고장) 양쪽에서 발생하고, 멱등 키·재발송·실패 이력이라는 알림 고유 관심사가 존재합니다. 각 컨텍스트에 흩어두면 멱등 규칙이 복제됩니다
크롤러/어댑터별도 컨텍스트인가?컨텍스트 아님외부 시스템 호출 어댑터이므로 posting 컨텍스트의 Gateway 구현(infrastructure)입니다. 도메인 개념이 없습니다
통계(FR-52)P2, 미설계지금 만들면 사용할 데이터가 없습니다

미채택 안(단일 도메인 통합)의 사유: 5개 컨텍스트를 job 하나로 합치면 패키지 교차 참조 문제는 사라지지만, 매칭 기준 변경이 수집 코드와 같은 파일 집합을 건드리게 되어 티켓 병렬화가 불가능해지고(Single Writer per File 위반), 사고 위험이 높은 마감 판정 로직에 무관한 변경이 섞입니다.

컨텍스트 간 규칙: 도메인 패키지 교차 참조 금지. 참조는 ID(Long)만. 교차 로직은 ① Layer 1 이벤트(posting → matching) 또는 ② application 레이어 조합으로 처리합니다 — UseCase가 소유 컨텍스트 DomainService를 조회해 도메인 객체로 받고, application 매퍼가 소비 컨텍스트의 입력 값 객체로 변환합니다. 소비 컨텍스트 infrastructure에서 다른 컨텍스트 테이블을 로우 쿼리로 직접 읽는 read model은 금지입니다(no-crosscontext-raw-read — import만 없앨 뿐 스키마 결합이 남아 규칙을 우회). 평가(matching←posting)·알림 대상 산출(notification←posting)이 모두 이 application 조합으로 처리됩니다.

방안 8 — 애그리게이터 소스: 어댑터 추상화를 어떻게 확장하는가 (FR-60)

애그리게이터(사람인·점핏 등)는 회사에 종속되지 않는 전역 소스로, “회사 slug”가 아니라 “검색 조건(직무 카테고리·키워드)“으로 수집합니다(조사 브리프 “애그리게이터 소스 조사”). 기존 SPI가 sourceSlug를 강제하던 것이 문제입니다.

방안설명왜 채택 / 미채택
a. 애그리게이터용 SPI를 완전히 별도로AggregatorAdapter 인터페이스를 신설, 수집 흐름도 별도미채택. 델타·시딩·마감·본문 저장·이벤트 발행 로직이 두 벌로 복제됩니다. 사고 위험이 높은 수집 코어를 복제하는 최악의 선택
b. CollectionDescriptor sealed로 수집 파라미터만 일반화, SPI·수집 코어는 단일어댑터 SPI는 하나(SourcePlatformAdapter)이고 descriptor가 CompanyBound/Aggregator로 갈립니다. 델타·시딩·마감·이벤트는 두 유형이 공유하고, “회사 slug vs 검색 조건” 차이만 sealed로 흡수채택. 수집 코어 1벌 유지 + 새 유형은 타입으로 표현. 애그리게이터의 회사 식별은 RawJobPosting.sourceCompanyName으로 넘어오고, 회사 종속형은 null(회사가 소스로 이미 확정). P1 소스 6종도 같은 SPI로 어댑터 파일만 추가

P0 애그리게이터는 사람인·점핏 2종으로 한정합니다. 근거: 조사 브리프가 두 소스만 **“규약 청정 + 실측 완료”**로 분류했습니다(사람인 robots 미차단·Crawl-delay 없음, 점핏 /positions 미차단). 원티드(robots가 WAF 403이라 판단 불가)·리멤버(약관 제13조가 스크래퍼 명시 금지)·잡코리아(robots가 AI 크롤러 Disallow: /)는 규약 회색지대라 P1로 미룹니다. 사용자가 회색지대 포함을 승인했으나, P0는 규약 청정 소스로 시작하는 것이 안전하다는 판단이며, 게이트 ②에서 사용자가 회색지대 소스를 P0에 포함하도록 뒤집을 수 있습니다. 워크넷·서핏·잡알리오는 추가 조사 중이라 어댑터 SPI가 수용만 하고 구현 티켓을 만들지 않습니다.

방안 9 — 크로스 소스 중복 제거: 알림 2회를 어떻게 막는가 (FR-63·64·65, 가장 중요)

같은 공고가 당근 Greenhouse(회사 직접)와 원티드/사람인(애그리게이터)에 동시 존재하면, 현재 식별 키 (job_source_id, source_job_id)로는 별개 저장되어 알림이 2회 갑니다.

방안설명왜 채택 / 미채택
a. 수집 시점 인라인 중복 판정저장 직전에 기존 공고와 대조해 병합미채택. 회사 직접 소스와 애그리게이터의 수집 시각·순서가 달라(00:00 vs 00:10), 애그리게이터가 먼저 저장되면 그 시점엔 회사 직접본이 없어 대표를 잘못 뽑습니다. 매 저장마다 크로스 소스 조회가 붙어 수집 코어가 무거워집니다
b. 중복 그룹 엔티티(DuplicateGroup) + 그룹 참조별도 그룹 테이블미채택(과설계). 그룹은 사실상 “대표 1 + 종속 N”이라 자기참조로 충분합니다. 그룹 테이블은 대표 재선정 시 그룹·멤버 두 곳을 갱신해야 해 정합 지점만 늘립니다
c. dedupKey(정규화 회사명+제목) + 자기참조 representativeId + 별도 dedup 배치공고 저장 시 엔티티가 dedupKey쿼리 없이 계산해 컬럼에 보관. 모든 수집 완료 후 dedup 배치(08:30)가 같은 dedupKey를 그룹핑해 대표를 선정(회사 직접 > 애그리게이터, 동률이면 먼저 발견된 것)하고, 비대표는 representativeId 설정 + notification_eligible=0. 마감일은 보조 근거(같은 dedupKey인데 마감일이 30일 이상 차이나면 다른 공고로 분리)채택. ① dedupKey 계산이 순수 함수라 수집 코어 무변경 ② dedup을 배치로 분리해 수집 순서·시각에 무관하게 하루치 전체를 보고 대표를 뽑습니다 ③ 대표 재선정(애그리게이터가 대표였다가 회사 직접본이 나중에 등장)이 자기참조 재지정 한 번으로 끝납니다 ④ 알림은 대표만(FR-65) — 비대표는 dedup 배치가 notification_eligible=0으로 눌러 자연히 1회만 발송

대표 선정 우선순위(FR-64): 회사 직접(COMPANY_BOUND) > 애그리게이터(AGGREGATOR). 매칭·마감 판정·지원 연결은 대표 기준이고, 비대표는 “같은 공고의 다른 출처”로 조회 시 연결됩니다. 자동 판정만 지원하며 사용자 수동 정정은 P0 미지원(FR-65).

방안 10 — 알림 소음 제어: 관심 회사 vs 발견된 회사 (FR-61·62)

애그리게이터로 매칭 공고가 하루 수백 건이 되면 개별 알림은 소음이 됩니다.

방안설명왜 채택 / 미채택
a. 모든 신규 공고 개별 알림기존 그대로미채택. 발견된 회사 공고가 하루 수백 건이면 디스코드가 알림 폭탄이 됩니다(FR-62)
b. 애그리게이터 공고는 알림 안 함저장만미채택. “발견”의 가치(새 회사 인지)가 사라집니다(시나리오 9)
c. 회사에 companyOrigin(WATCHED/DISCOVERED) 축 + 발견 회사는 일일 요약 1건사용자 직접 등록 = WATCHED(개별 알림, 기존 FR-10), 애그리게이터 자동 등록 = DISCOVERED(그날 매칭 신규를 묶어 DAILY_DIGEST 1건)채택. companyOriginregistrationType(AUTO/MANUAL_ONLY)과 직교하는 별도 축입니다 — 전자는 “출처가 사용자냐 발견이냐”, 후자는 “자동 수집 가능한가”. 발견 회사는 관심 회사로 승격(promote) 가능(시나리오 9.5). 자동 등록은 아래 “발견 회사 자동 등록 흐름” 참조

방안 11 — 볼륨·배치 시간 예산 (FR-66, NFR-2 재조정)

NFR-2가 “10분 이내”에서 **“알림 발송 시각(09:00 KST) 이전 완료”**로 바뀌었습니다. 애그리게이터는 검색 조건 하나당 수백~수천 건(사람인 2,594건)입니다.

  • 단일 프로세스·브로커 없음 전제 유지(PRD Non-Goals). 순차 처리 + 소스당 1일 1회.
  • 시간 예산: 수집 시작 00:00~00:10, 발송 09:00 → 약 9시간. 사람인 2,594건 = 26페이지(page_count 100) × 요청 지연 1s ≈ 30초. 최악의 보수적 지연으로 소스당 30분 잡아도(NFR-2) P0 애그리게이터 6종 × 30분 = 3시간 < 9시간. 순차로 충분하며, 리멤버는 min_updated_at 증분으로 재수집량이 더 작습니다.
  • FR-66: 애그리게이터도 매칭 실패 공고까지 전부 저장(수집 단계 필터링 없음). 저장 볼륨은 늘지만 델타·목록 hot path는 좁은 행만 읽으므로(본문 분리) 조회 성능 영향은 없습니다.

Detail Design

패키지 레이아웃

com.biuea.recruitment
├── presentation/{company,posting,matching,application,notification,operation}/
│   ├── **ApiController.kt      (REST 진입점)
│   ├── scheduler/**Scheduler.kt (배치 진입점 — UseCase 1줄 호출)
│   └── listener/**EventListener.kt (Layer 1 구독)
├── application/{company,posting,matching,application,notification,operation}/
│   ├── **UseCase.kt / **Command.kt / **Response.kt
├── domain/{company,posting,matching,application,notification,common}/
│   ├── 엔티티(POJO) / **Repository.kt / **Gateway.kt / DomainEventPublisher.kt / **DomainService.kt
└── infrastructure/{company,posting,matching,application,notification,common}/
    ├── persistence/**JpaEntity.kt, **JpaRepository.kt, **RepositoryImpl.kt
    ├── posting/collector/common/  (SourceRequestExecutor — 규약 정책 강제 공통 기반)
    ├── posting/collector/{greenhouse,woowahan,incruit}/       (회사 종속형 어댑터)
    ├── posting/collector/aggregator/{saramin,jumpit,wanted,remember,jobkorea,surfit}/  (애그리게이터 어댑터 — P0 6종)
    └── notification/discord/DiscordWebhookGatewayImpl.kt

시스템 역할 경계

단위역할소유 데이터/책임노출 인터페이스의존
API 서버 (단일 프로세스)REST 요청-응답 + 6개 인프로세스 스케줄전체HTTP /api/**MySQL, 디스코드 웹훅, 채용 소스 9종(회사 3 + 애그리게이터 6)
JobPostingCollectionScheduler00:00 회사 종속형 수집 트리거없음 (진입점)@Scheduled(cron="0 0 0 * * *", zone="Asia/Seoul")CollectJobPostingsUseCase
AggregatorCollectionScheduler00:10 애그리게이터 수집 트리거 (회사 자동 등록 포함)없음@Scheduled(cron="0 10 0 * * *")CollectAggregatorPostingsUseCase
JobPostingDeadlineSweepScheduler00:30 마감일 경과 판정 트리거없음@Scheduled(cron="0 30 0 * * *")CloseDeadlinePassedJobPostingsUseCase
JobPostingDeduplicationScheduler08:30 크로스 소스 중복 판정·대표 선정 트리거없음@Scheduled(cron="0 30 8 * * *")DeduplicateJobPostingsUseCase
JobPostingEvaluationSweepScheduler08:50 미평가 공고 보정 트리거없음@Scheduled(cron="0 50 8 * * *")EvaluatePendingJobPostingsUseCase
NotificationDispatchScheduler09:00 알림 발송 트리거 (개별 + 일일 요약)없음@Scheduled(cron="0 0 9 * * *")DispatchPendingNotificationsUseCase
company 컨텍스트회사·소스 등록, 소스 탐색, 애그리게이터 소스 등록·발견 회사 자동 등록·승격companies(+company_origin), job_sources(+source_type·검색조건)CompanyRepository, JobSourceRepositoryJobSourceGateway(probe)
posting 컨텍스트수집(2유형)·델타·마감 판정·소스 건강도, 수동 공고, 본문·태그 보관, 크로스 소스 중복 판정·대표 선정job_postings(+dedup_key·representative_id), job_posting_descriptions, job_posting_source_tags, job_posting_collection_runs, job_source_healthJobPostingRepository, JobPostingDetailRepository, CollectionRunRepository, SourceHealthRepository, JobSourceGatewayDomainEventPublisher
matching 컨텍스트매칭 기준 소유, 직무 매칭·근무형태 추출 평가job_keyword_groups, job_keyword_synonyms, job_exclusion_keywords, work_arrangement_keywords, match_criteria_revisions, job_posting_match_results, job_posting_matched_keyword_groups, job_posting_work_arrangementsMatchCriteriaRepository, JobPostingMatchResultRepository(없음 — 공고는 이벤트/ID로만 인지, 평가 입력은 application이 조합해 주입)
application 컨텍스트지원 생성·상태 전이·면접 회차·히스토리job_applications, job_application_status_histories, job_application_interviewsApplicationRepository, InterviewRepository(없음)
notification 컨텍스트발송 대상 산출·멱등·발송·실패 이력, 관심/발견 회사 분기(개별 vs 일일 요약)notification_dispatchesNotificationDispatchRepository, DiscordWebhookGateway디스코드 웹훅
JobSourceGatewayImpl (infra)플랫폼 어댑터 레지스트리없음JobSourceGateway 구현List<SourcePlatformAdapter>
SourceRequestExecutor (infra 공통)FR-69 규약 강제(UA·요청 지연·페이지 상한·허용 경로) — 모든 어댑터가 이것을 통해서만 외부 호출없음(어댑터 내부 협력자)RestClient
GreenhouseJobSourceAdapterGreenhouse 목록·후보 탐색·정규화없음SourcePlatformAdapterSourceRequestExecutor
WoowahanJobSourceAdapter배민형 내부 API 수집·정규화없음SourcePlatformAdapterSourceRequestExecutor
IncruitJobSourceAdapter인크루트 HTML 파싱(EUC-KR)·상세 전건 조회없음SourcePlatformAdapterSourceRequestExecutor (Jsoup)
SaraminAggregatorAdapter사람인 HTML 검색 조건 수집·마감일 정규화(~MM/DD·문자열)없음SourcePlatformAdapterSourceRequestExecutor (Jsoup)
JumpitAggregatorAdapter점핏 JSON 수집·필드 해시·상세로 회사 식별없음SourcePlatformAdapterSourceRequestExecutor
WantedAggregatorAdapter원티드 JSON 수집 (미검증 필드 실호출 확정)없음SourcePlatformAdapterSourceRequestExecutor
RememberAggregatorAdapter리멤버 JSON POST 수집·min_updated_at 증분·per≤50없음SourcePlatformAdapterSourceRequestExecutor
JobkoreaAggregatorAdapter잡코리아 JSON-LD·본문 해시·허용 경로 화이트리스트만없음SourcePlatformAdapterSourceRequestExecutor (Jsoup)
SurfitAggregatorAdapter서핏 JSON POST·필드 해시 (speciality 포맷 실호출 확정)없음SourcePlatformAdapterSourceRequestExecutor
DiscordWebhookGatewayImpl웹훅 발송 + 지수 백오프 3회없음DiscordWebhookGateway 구현DiscordWebhookClient
FeatureFlagGatewayImpl런타임 플래그 조회feature_flagsFeatureFlagGateway 구현MySQL

노출 경계 규칙: RestClient·Jsoup·Document 타입은 어댑터 내부에만 존재하고 domain·application에 노출되지 않습니다. 어댑터는 정규화 완료된 도메인 값 객체(RawJobPosting) 만 반환합니다.

인터페이스 시그니처 (구현자 간 해석 차이 제거 — 티켓 병렬 실행의 전제)

소스 어댑터 계약 (핵심)

소스는 **회사 종속형(회사 slug로 수집)**과 애그리게이터형(검색 조건으로 수집) 2종이 공존합니다(FR-60). SPI는 “회사 slug”를 하드코딩하지 않고 CollectionDescriptor sealed로 수집 파라미터를 일반화합니다.

// domain/posting/JobPlatform.kt — P0: 회사 종속 3종 + 애그리게이터 6종. P1 확장값은 enum 추가만.
enum class JobPlatform(val sourceType: SourceType) {
    GREENHOUSE(COMPANY_BOUND), WOOWAHAN(COMPANY_BOUND), INCRUIT(COMPANY_BOUND),
    SARAMIN(AGGREGATOR), JUMPIT(AGGREGATOR),                          // 규약 청정
    WANTED(AGGREGATOR), REMEMBER(AGGREGATOR), JOBKOREA(AGGREGATOR), SURFIT(AGGREGATOR),  // 규약 회색지대 (사용자 승인 P0)
    // P1 확장 지점 (추가 조사 중): WORKNET, ALIO
}
enum class SourceType { COMPANY_BOUND, AGGREGATOR }
 
// domain/posting/ChangeSignature.kt — 소스별 변경 감지 근거를 타입으로 구분
sealed interface ChangeSignature {
    val kind: ChangeSignalKind
    val storedValue: String                       // 영속화 형태 (kind + storedValue 로 복원)
 
    data class UpdatedAt(val updatedAt: ZonedDateTime) : ChangeSignature   // Greenhouse, 사람인(수정일)
    data class SourceVersion(val version: Long) : ChangeSignature          // 배민
    data class BodyHash(val sha256: String) : ChangeSignature              // 인크루트(상세 본문)
    data class FieldHash(val sha256: String) : ChangeSignature             // 점핏(변경 필드 없음 → 주요 필드 해시, FR-68)
 
    companion object {
        fun reconstitute(kind: ChangeSignalKind, storedValue: String): ChangeSignature
    }
}
enum class ChangeSignalKind { UPDATED_AT, SOURCE_VERSION, DETAIL_BODY_HASH, FIELD_HASH }
 
// domain/posting/SourceCapabilities.kt — 소스가 "무엇을 할 수 있는지" + "무엇을 지켜야 하는지" 선언
data class SourceCapabilities(
    val platform: JobPlatform,
    val sourceType: SourceType,
    val changeSignalKind: ChangeSignalKind,
    val providesDeadline: Boolean,
    val providesStructuredWorkArrangement: Boolean,
    val companyIdentifierInDetailOnly: Boolean,   // 잡코리아=true (회사가 목록에 없고 상세에만 존재, FR-68). 점핏은 false 로 정정 — 2026-08-03 실측
    val requiresDetailFetch: Boolean,
    val supportsIncrementalSince: Boolean,        // 리멤버=true (min_updated_at 증분 필터). 전량 재수집 대신 증분 쿼리
    val compliance: SourceCompliancePolicy,       // FR-69 — 개별 어댑터가 위반할 수 없게 정책으로 강제
)
 
// domain/posting/SourceCompliancePolicy.kt — FR-69 규약을 값으로 고정
data class SourceCompliancePolicy(
    val userAgent: String,                        // 식별 가능한 UA (FR-69-5)
    val requestDelayMillis: Long,                 // 요청 간 지연 (FR-69-4). 인크루트/사람인=1000
    val pageSizeCap: Int,                         // 플랫폼 명시 상한 (FR-69-3). 사람인=100, 점핏=200, 리멤버=50(P1)
    val detailRequestLimitPerRun: Int,            // 회차당 상세 조회 상한. 인크루트=200
    val allowedPathPrefixes: List<String>,        // robots 허용 경로만 (FR-69-1)
    // 1일 1회(FR-69-2)는 unique(job_source_id, run_date)가 DB 레벨에서 강제하므로 정책 밖
)
 
// domain/posting/RawJobPosting.kt — 어댑터가 정규화를 끝낸 결과물
data class RawJobPosting(
    val sourceJobId: String,
    val title: String,
    val postingUrl: String,
    val deadlineAt: ZonedDateTime?,               // FR-13·67: 필드 부재·센티널·문자열·연도없음·불리언을 어댑터가 null/정규화
    val changeSignature: ChangeSignature,
    val structuredTags: List<String>,
    val descriptionBody: String?,
    val accessRestricted: Boolean,
    val sourceCompanyName: String?,               // 애그리게이터가 채움. 회사 종속형은 null(회사가 소스로 확정됨)
    val sourceCompanyIdentifier: String?,         // 애그리게이터의 소스측 회사 식별자(있으면). FR-68
)
 
// domain/posting/SourceCollectionOutcome.kt
sealed interface SourceCollectionOutcome {
    data class Fetched(
        val postings: List<RawJobPosting>,
        val detailFailureCount: Int,
    ) : SourceCollectionOutcome
    data class Failed(val reason: String, val causeSummary: String?) : SourceCollectionOutcome
}
 
// domain/posting/CollectionDescriptor.kt — 수집 파라미터 일반화 (회사 slug 하드코딩 제거)
sealed interface CollectionDescriptor {
    val jobSourceId: Long
    val platform: JobPlatform
    val knownSignatures: Map<String, ChangeSignature>   // sourceJobId → 저장된 시그니처
 
    data class CompanyBound(                             // 회사 종속형: slug로 수집
        override val jobSourceId: Long,
        override val platform: JobPlatform,
        override val knownSignatures: Map<String, ChangeSignature>,
        val sourceSlug: String,
        val baseUrl: String,
    ) : CollectionDescriptor
 
    data class Aggregator(                               // 애그리게이터형: 검색 조건으로 수집
        override val jobSourceId: Long,
        override val platform: JobPlatform,
        override val knownSignatures: Map<String, ChangeSignature>,
        val searchCategoryCode: String,                 // 플랫폼 직무 카테고리 코드 (사람인 cat_kewd, 점핏 jobCategory)
        val searchKeyword: String,                      // 미지정이면 빈 문자열 "" (유니크 성립, DBA 규칙 1-b)
        val lastSuccessfulCollectionAt: ZonedDateTime?, // 증분 수집 기준. supportsIncrementalSince=true인 소스(리멤버 min_updated_at)만 사용
    ) : CollectionDescriptor
}
 
// domain/posting/JobSourceGateway.kt — 외부 시스템 호출 계약
interface JobSourceGateway {
    fun collect(descriptor: CollectionDescriptor): SourceCollectionOutcome
    fun probe(companyName: String, siteUrl: String?): List<JobSourceCandidate>  // 회사 종속형 후보 탐색만
    fun capabilitiesOf(platform: JobPlatform): SourceCapabilities
    fun categoriesOf(platform: JobPlatform): List<AggregatorCategory>           // 애그리게이터 등록 화면 Select
}
 
data class JobSourceCandidate(
    val platform: JobPlatform,
    val sourceSlug: String,
    val baseUrl: String,
    val samplePostings: List<RawJobPosting>,      // 미리보기 3건 (FR-2)
)
 
// domain/posting/AggregatorCategory.kt — 애그리게이터가 지원하는 직무 카테고리 (코드↔라벨)
data class AggregatorCategory(val code: String, val label: String)  // 사람인 (84,"백엔드"), 점핏 (1,"서버/백엔드") 등
 
// infrastructure/posting/collector/SourcePlatformAdapter.kt — 플랫폼 SPI (일반화된 단일 계약)
interface SourcePlatformAdapter {
    val capabilities: SourceCapabilities
    fun supports(platform: JobPlatform): Boolean
    fun fetchList(descriptor: CollectionDescriptor): SourceCollectionOutcome   // 두 유형을 sealed로 받아 when 분기
    fun probe(companyName: String, siteUrl: String?): List<JobSourceCandidate> // 애그리게이터는 emptyList() 반환
    fun supportedCategories(): List<AggregatorCategory>                        // 회사 종속형은 emptyList(). FE 등록 화면 Select 소스
}

GET /api/aggregator-sources/categories?platform=JobSourceGateway를 통해 어댑터의 supportedCategories()를 그대로 노출합니다 — FE가 코드를 상수화하지 않고 어댑터 지원 목록을 받으므로 드리프트(어댑터 미지원 코드로 등록되는 사고)가 원천 차단됩니다(FE 요청 #12). 등록 API(POST /api/aggregator-sources)는 searchCategoryCode가 어댑터 지원 목록에 있는지 검증한 뒤 저장합니다.

JobSourceGatewayImpl(private val adapters: List<SourcePlatformAdapter>)adapters.first { it.supports(platform) }로 위임합니다. when(platform) 분기를 두지 않아 새 플랫폼(P1 원티드·리멤버·잡코리아·워크넷·서핏·잡알리오)이 같은 SPI로 어댑터 파일만 추가되면 기존 파일 수정이 없습니다(OCP). 어댑터는 fetchList 내부에서 when(descriptor)CompanyBound/Aggregator를 처리하되, 자신이 지원하지 않는 유형이 오면 명시적 예외를 던집니다(Greenhouse 어댑터에 Aggregator descriptor는 오지 않음 — supports가 이미 걸러냄).

Repository 계약

// domain/posting/JobPostingRepository.kt
interface JobPostingRepository {
    fun save(jobPosting: JobPosting): JobPosting
    fun saveAll(jobPostings: List<JobPosting>): List<JobPosting>
    fun findBy(id: Long): JobPosting?
    fun findBy(jobSourceId: Long, sourceJobId: String): JobPosting?
    fun findAllCollectedIn(jobSourceId: Long): List<JobPosting>          // 델타 모수 (origin=COLLECTED 한정)
    fun findAllOpenWithDeadlineBefore(baseTime: ZonedDateTime): List<JobPosting>
    fun findAllBy(companyId: Long, postingStatus: JobPostingStatus?): List<JobPosting>
    fun findAllEvaluable(limit: Int): List<Long>  // 평가 가능 공고 ID (OPEN·origin=COLLECTED·access_restricted 제외). criteria_revision(matching 소유)은 넣지 않는다 — pending 계산은 application이 matching 기평가 집합과 diff
}
 
// domain/posting/JobPostingDetailRepository.kt — 본문(1:1)·구조화 태그(1:N) 영속화
// 본문은 hot path(델타·목록)에서 읽지 않도록 job_postings 와 분리된 테이블에 보관한다 (design-db 판단 3)
interface JobPostingDetailRepository {
    fun saveDescription(jobPostingId: Long, description: JobPostingDescription)
    fun replaceSourceTags(jobPostingId: Long, tags: List<JobPostingSourceTag>)   // 재수집 시 전량 교체
    fun findDescriptionBy(jobPostingId: Long): JobPostingDescription?
    fun findSourceTagsBy(jobPostingId: Long): List<JobPostingSourceTag>
}
 
// domain/posting/JobPostingCollectionRunRepository.kt
interface JobPostingCollectionRunRepository {
    fun save(run: JobPostingCollectionRun): JobPostingCollectionRun
    fun existsBy(jobSourceId: Long, runDate: LocalDate): Boolean
    fun findAllBy(jobSourceId: Long?, from: LocalDate): List<JobPostingCollectionRun>
}
 
// domain/posting/JobSourceHealthRepository.kt
interface JobSourceHealthRepository {
    fun findBy(jobSourceId: Long): JobSourceHealth?
    fun save(health: JobSourceHealth): JobSourceHealth
    fun findAllBroken(): List<JobSourceHealth>
}
 
// domain/matching/MatchCriteriaRepository.kt
interface MatchCriteriaRepository {
    fun loadCurrent(): MatchCriteria        // 활성(deleted_at IS NULL) 그룹·제외어·근무형태 키워드 + revision 을 한 덩어리로
    fun bumpRevision(changeTarget: MatchCriteriaChangeTarget, changeReason: String): Long
    fun softDeleteKeywordGroup(groupId: Long)
    fun softDeleteExclusionKeyword(keywordId: Long)
    fun softDeleteWorkArrangementKeyword(keywordId: Long)
}
 
// domain/matching/JobPostingMatchResultRepository.kt
interface JobPostingMatchResultRepository {
    fun save(result: JobPostingMatchResult): JobPostingMatchResult   // 매칭 그룹·근무형태 근거 행을 함께 재작성(파생 캐시)
    fun findBy(jobPostingId: Long): JobPostingMatchResult?
}
 
// domain/matching/EvaluationTarget.kt — matching 도메인의 평가 입력 값 객체 (순수, posting 무지)
// posting 테이블을 직접 읽지 않는다. application(EvaluateJobPostingsUseCase)이 posting DomainService
// 조회 결과를 EvaluationTargetMapper로 변환해 matching 도메인 서비스에 넘긴다 (크로스 컨텍스트 조합은 application 책임).
// matching 도메인 서비스 시그니처엔 posting 타입이 없다: evaluate(criteriaRevision, targets: List<EvaluationTarget>).
data class EvaluationTarget(
    val jobPostingId: Long,
    val title: String,
    val structuredTags: List<StructuredTag>,  // rawValue·normalizedValue (태그↔정규화, 본문↔원문 대칭)
    val descriptionBody: String?,             // 없으면 null → ②근거 부재
)
 
// domain/notification/NotificationDispatchRepository.kt
interface NotificationDispatchRepository {
    fun findSucceededBy(idempotencyKey: String): NotificationDispatch?
    fun save(dispatch: NotificationDispatch): NotificationDispatch
    fun findAllFailedFrom(from: ZonedDateTime): List<NotificationDispatch>
}
 
// 알림 대상 산출도 application 조합이다 (read model 금지, no-crosscontext-raw-read).
// DispatchPendingNotificationsUseCase(application)가 posting DomainService로 신규 공고·고장 소스를
// 조회하고, 매퍼로 NotificationMessage를 구성한 뒤 notification DomainService에 발송을 위임한다.
// 멱등(idempotency_key)은 notification 소유 NotificationDispatchRepository로 확인한다.
// notification 도메인은 posting 타입을 참조하지 않는다.
 
// domain/notification/DiscordWebhookGateway.kt
interface DiscordWebhookGateway {
    fun send(message: NotificationMessage): WebhookDeliveryResult   // 내부에서 지수 백오프 3회
}
sealed interface WebhookDeliveryResult {
    data object Delivered : WebhookDeliveryResult
    data class Rejected(val statusCode: Int, val reason: String, val attemptCount: Int) : WebhookDeliveryResult
}
 
// domain/common/FeatureFlagGateway.kt
interface FeatureFlagGateway {
    fun isEnabled(flagKey: String): Boolean
}
 
// domain/common/DomainEventPublisher.kt
interface DomainEventPublisher {
    fun publishAll(events: List<DomainEvent>)
}

API 계약 (P0)

메서드경로요청응답FR
POST/api/companies/source-discoveries{companyName, siteUrl?}{candidates:[{platform, sourceSlug, baseUrl, samplePostings[3]}]}FR-1,2
POST/api/companies{name, sources:[{platform, sourceSlug, baseUrl}]} (빈 배열 → MANUAL_ONLY){companyId, registrationType, companyOrigin, sourceIds[]}FR-4,5
GET/api/companies?companyOrigin=WATCHED|DISCOVERED&page=&size={companies:[{id, name, registrationType, companyOrigin, sourceCount, brokenSourceCount}], totalCount, page}FR-50,61, Operations
POST/api/companies/{id}/promotion{companyId, companyOrigin} (DISCOVERED → WATCHED 승격)FR-61, 시나리오 9.5
GET/api/aggregator-sources/categories?platform=SARAMIN|JUMPIT{categories:[{code, label}]} — 어댑터가 지원하는 카테고리 (FE 상수화 대신 API 노출로 드리프트 차단)FR-60 (FE 요청 #12)
POST/api/aggregator-sources{platform(SARAMIN|JUMPIT), searchCategoryCode, searchKeyword?}{jobSourceId, platform}FR-60
GET/api/aggregator-sources{sources:[{jobSourceId, platform, searchCategoryCode, searchCategoryLabel, searchKeyword, disabled, brokenStatus}]} (활성·비활성 함께, 비활성은 disabled=true)FR-60 (FE 요청 #10)
DELETE/api/aggregator-sources/{id}{jobSourceId, disabledAt}소프트 삭제(disabled_at 기록, 수집 이력·기존 공고 보존)FR-60 (FE 요청 #10)
GET/api/companies/{companyId}/job-postings?postingStatus=&sort=WORK_ARRANGEMENT|DEADLINE|DISCOVERED{jobPostings:[ 아래 "공고 목록 아이템" ]}FR-50,30,33,34
POST/api/job-postings/manual-registrations{companyId, title, postingUrl, deadlineAt?}{jobPostingId}FR-20
GET/api/job-postings/{id}공고 목록 아이템 + workArrangements[](근거 스니펫 포함) + application(없으면 null) + descriptionBody + alternateSources[] (아래 shape)FR-29,34,42,64
GET/api/matching/criteria{criteriaRevision, keywordGroups:[{id, displayName, synonyms:[...]}], exclusionKeywords:[{id, keyword}], workArrangementKeywords:[{id, keyword}]}FR-22,23,26
POST/api/matching/keyword-groups{displayName, synonyms:[...]}{groupId, criteriaRevision}FR-22
DELETE/api/matching/keyword-groups/{id}{criteriaRevision} (소프트 삭제)FR-22
POST/api/matching/exclusion-keywords{keyword}{id, criteriaRevision}FR-23
DELETE/api/matching/exclusion-keywords/{id}{criteriaRevision} (소프트 삭제)FR-23
POST/api/matching/work-arrangement-keywords{keyword}{id, criteriaRevision}FR-26
DELETE/api/matching/work-arrangement-keywords/{id}{criteriaRevision} (소프트 삭제)FR-26
POST/api/matching/re-evaluations{force?: boolean} (기본 false){evaluatedCount, skippedCount, criteriaRevision}FR-25
POST/api/applications{jobPostingId, appliedAt, memo?}{applicationId, status}FR-42,21
POST/api/applications/{id}/status-transitions{nextStatus, memo?}{status, rejectedAtStage?, historyId, allowedNextStatuses[]}FR-43,44,45,47
GET/api/applications/{id}지원 + statusHistories[] + interviews[] + allowedNextStatuses[] + companyName + jobPostingTitleFR-47,43
GET/api/applications?companyId={applications:[{applicationId, jobPostingId, companyName, jobPostingTitle, currentStatus, rejectedAtStage, appliedAt, lastTransitedAt}]}FR-50,42
POST/api/applications/{id}/interviews{roundNumber, label, scheduledAt?}{interviewId}FR-46
PATCH/api/applications/{id}/interviews/{interviewId}{scheduledAt?, interviewResult?, memo?}{interviewId}FR-46
GET/api/operations/collection-runs?jobSourceId=&days=30{successRate, runs:[{jobSourceId, sourceLabel, runDate, runStatus, fetchedCount, newCount, closedCount, errorMessage, abnormal}]}Operations
GET/api/operations/notification-dispatches?dispatchStatus=FAILED&days=30{dispatches:[{targetType, targetId, notificationType, dispatchSequence, dispatchStatus, attemptCount, lastError, messageSummary, attemptedAt}]}Operations, 시나리오 7

공고 목록 아이템 (FE 계약 요청 #4 수용 — 목록만으로 화면이 성립해야 합니다)

{ jobPostingId, companyId, companyName, title, postingUrl, postingStatus, closedReason,
  deadlineAt,            // null 이면 상시채용
  matched,               // 직무 키워드 매칭 여부 (알림 조건, FR-25)
  workArrangement,       // { keyword, confidence } | null — 대표 1건, UNKNOWN 이면 null (FR-34)
  applicationId,         // null 이면 미지원 (FR-42 — "미지원"은 상태값이 아님)
  sourceType,            // COMPANY_BOUND | AGGREGATOR
  alternateSourceCount,  // 중복으로 묶인 다른 출처 수 (0이면 단독). 대표 공고만 목록에 노출 (FR-64)
  accessRestricted, postingOrigin, firstSeenAt }
  • 목록은 대표 공고만 반환합니다 — 비대표(중복본)는 representative_id IS NOT NULL이라 목록에서 제외되고, 상세의 alternateSources[]로만 노출됩니다(FR-64). 이 때문에 회사별 목록 쿼리에 representative_id IS NULL 조건이 들어갑니다.

alternateSources[] 아이템 shape (FE 요청 #11 확정) — 공고 상세 “N곳에 게재” 박스 렌더용:

alternateSources: [ { jobSourceId, platform, postingUrl, isRepresentative } ]
  • 대표 자신을 포함한 그룹 전체를 반환합니다(대표 1건은 isRepresentative=true, 나머지 비대표는 false). FE가 “대표는 강조, 나머지는 링크”로 렌더할 수 있게 대표도 목록에 넣습니다. 단독 공고면 자기 자신 1건만(길이 1). sourceLabel은 FE가 platform으로 매핑하므로 넣지 않습니다.

  • applicationIdapplied: boolean 대신 ID로 내리는 이유: FE가 지원 상세로 바로 이동할 수 있고, null이 곧 “미지원”이라 FR-42의 표현과 1:1 대응합니다.

  • workArrangement대표 1건만 내립니다(정렬 기준과 동일한 work_arrangement_sort_rank 산출 근거). 근거 스니펫과 복수 근거는 상세 응답에만 포함합니다 — 목록 응답을 무겁게 만들지 않기 위함입니다.

  • 목록은 매칭·확신도로 필터링하지 않습니다(FR-25·33). matched=false도 전부 내려가며, 접힘·정렬은 FE의 표현 책임입니다.

정렬 규칙: sort=WORK_ARRANGEMENTwork_arrangement_sort_rank(1=CONFIRMED, 2=LIKELY, 99=그 외) → firstSeenAt DESC 순입니다. 문자열 확신도로 정렬하면 알파벳 순서(CONFIRMED < INFERRED < LIKELY)가 도메인 순서와 어긋나므로 정렬은 rank 정수 컬럼으로만 수행합니다(design-db 판단 4).

DTO 흐름은 Request(presentation) → Command(application) → Entity(domain) → Response(application)를 따릅니다. 인증은 두지 않습니다(NFR-7 — 로컬 Docker 전용, 외부 미노출).

클래스 역할 정의

도메인 모델

클래스명컨텍스트역할핵심 책임
Companycompany관심/발견 회사createWatched(name, registrationType) / createDiscovered(name)(애그리게이터 자동 등록) / promoteToWatched() / isAutoCollectable() / isWatched()
CompanyOrigincompany회사 출처 축WATCHED(사용자 직접) / DISCOVERED(애그리게이터 자동) — registrationType과 직교
JobSourcecompany회사의 데이터 출처markSeeded() / isSeeded() / disable()(disabledAt 기록) / isActive(). 애그리게이터 소스는 searchCriteria 보유·companyId 없음
JobPostingposting공고 (수집·수동 공통)createCollected(raw, companyName, seeded) / createManual(cmd) / refreshFrom(raw): JobPostingChange / markMissed() / markFound() / closeByDeadline() / reopen() / isDeltaTarget() / isPermanentPosting() / computeDedupKey(): String(정규화 회사명+제목, 쿼리 없음) / linkTo(representativeId) / markRepresentative() / isRepresentative()
JobPostingStatusposting공고 상태OPEN/CLOSED + canTransitTo()
JobPostingOriginposting수집 출처COLLECTED/MANUAL — 델타 모수 판별
JobPostingDedupKeyposting중복 판정 키 값 객체of(companyName, title) — 정규화(소문자·공백/기호 제거·NFKC) 후 회사|제목. 마감일 30일 초과 차이는 별개 공고로 분리(보조 근거)
JobPostingDescriptionposting공고 본문(JD) — 공고당 1건, 최신 스냅샷만of(body) / charLength() (급감 시 파서 고장 의심 지표)
JobPostingSourceTagposting소스 구조화 태그 1건 (Greenhouse metadata[])of(rawValue) — 원문 + 정규화 값 보유
JobPostingCollectionRunposting소스×일자 수집 회차succeed(counts) / fail(reason) / isAbnormal() (실패 또는 fetched=0)
JobSourceHealthposting소스 건강도recordNormal() / recordAbnormal(): Boolean(고장 진입 여부) / isBroken() / currentFailureEpisode()
MatchCriteriamatching매칭 기준 집합 (그룹·제외어·근무형태 키워드 + revision)matches(title): KeywordMatchOutcome
JobKeywordGroupmatching동의어 그룹containsAnySynonymIn(normalizedTitle)
NormalizedKeywordmatching정규화된 키워드 값 객체of(raw) — 소문자·공백/하이픈 제거·NFKC
JobPostingMatchResultmatching공고별 평가 결과evaluate(...) / isNotifiable() / topConfidence() / sortRank() (1=CONFIRMED, 2=LIKELY, 99=그 외) / matchedGroupIds()
EvaluationTargetmatching평가 입력 값 객체 (제목·태그·본문)application이 posting DomainService 조회 결과를 매핑해 주입 — matching은 posting을 모른다
WorkArrangementEvidencematching근무형태 근거 1건keyword·evidenceStage·confidence·snippet
WorkArrangementConfidencematching확신도CONFIRMED/LIKELY/INFERRED/UNKNOWN + isSortable() (FR-30)
Applicationapplication지원 기록create(cmd): Pair<Application, ApplicationStatusHistory> (생성 이력 (null → APPLIED) 1건을 함께 반환) / transitTo(next, memo): ApplicationStatusHistory / isTerminal() / allowedNextStatuses()
ApplicationStatusapplication지원 상태 8종canTransitTo(next) / isTerminal() / allowedNextStatuses(): Set<ApplicationStatus> — 전이 규칙 캡슐화. API 응답의 allowedNextStatuses가 이 값을 그대로 노출해 규칙 SSOT를 BE에 유지합니다
ApplicationStatusHistoryapplication전이 이력of(previous, next, memo)previous는 생성 이력에서만 null
Interviewapplication면접 회차create(round, label, scheduledAt) / recordResult(result) / reschedule(at)
NotificationDispatchnotification발송 시도 1건attempt(target) / markDelivered(): 멱등키 부여 / markRejected(reason) / isExpired()
NotificationTypenotification알림 종류NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST(발견 회사 매칭 신규 묶음) (P0) / DEADLINE_D1, PERMANENT_REMINDER (P1 확장 지점)

서비스 클래스

클래스명역할입력 → 출력의존
JobPostingCollectionDomainService소스 1개 회차의 수집·델타·가드·건강도 갱신 (2유형 공통)CollectionDescriptorCollectionRunResultJobSourceGateway, JobPostingRepository, JobPostingDetailRepository, JobPostingCollectionRunRepository, JobSourceHealthRepository, DomainEventPublisher
AggregatorCollectionDomainService애그리게이터 회차 수집·회사 자동 등록 연동·델타CollectionDescriptor.Aggregator + Map<companyName, companyId>CollectionRunResult위와 동일 (회사 ID는 UseCase가 주입)
DiscoveredCompanyDomainService애그리게이터 공고의 회사 자동 등록(미존재 시 DISCOVERED 생성)Set<companyName>Map<companyName, companyId>CompanyRepository
JobPostingDeduplicationDomainService같은 dedupKey 그룹핑 → 대표 선정(직접>애그리게이터) → 비대표 링크·알림 억제ZonedDateTime → 대표/중복 건수JobPostingRepository, FeatureFlagGateway
JobPostingDeadlineDomainService마감일 경과 공고 CLOSED 전환 (DB만 사용)ZonedDateTime → 전환 건수JobPostingRepository, FeatureFlagGateway
ManualJobPostingDomainService수동 공고 등록RegisterManualJobPostingCommandJobPostingJobPostingRepository, CompanyRepository(ID 검증은 company 컨텍스트 조회 UseCase 경유)
JobPostingQueryService회사별 공고·정렬 조회 read model조회 조건 → 뷰 모델조회 전용 Repository
MatchCriteriaDomainService매칭 기준 CRUD + revision 증가키워드 명령 → MatchCriteriaMatchCriteriaRepository
JobPostingEvaluationDomainService직무 매칭 + 근무형태 추출 평가(jobPostingId, title, tags, body)JobPostingMatchResultMatchCriteriaRepository, JobPostingMatchResultRepository, JobKeywordMatcher, WorkArrangementDetector
JobKeywordMatcher정규화 후 동의어/제외어 판정 (순수)(title, criteria)KeywordMatchOutcome없음
WorkArrangementDetector①구조화 필드 + ②JD 본문 근거 추출, 부정어 무효화 (순수)(tags, body, keywords)List<WorkArrangementEvidence>없음
CompanyRegistrationDomainService회사·소스 등록(후보 0건 시 MANUAL_ONLY), 애그리게이터 소스 등록, 발견 회사 승격RegisterCompanyCommand / RegisterAggregatorSourceCommand / promote(companyId)Company/JobSourceCompanyRepository, JobSourceRepository
JobSourceDiscoveryDomainServiceslug 프로빙·URL 직접 입력 후보 탐색 (회사 종속형만)(companyName, siteUrl?)List<JobSourceCandidate>JobSourceGateway
ApplicationDomainService지원 생성·상태 전이·히스토리 적재명령 → ApplicationApplicationRepository
InterviewDomainService면접 회차 추가·결과 기록명령 → InterviewInterviewRepository
NotificationDispatchDomainService멱등 확인 → 발송 → 결과 기록. 관심 회사=개별 / 발견 회사=일일 요약 1건 분기. 대상 산출은 application(UseCase)이 posting 조회로 조합해 주입List<NotificationMessage> → 발송 요약NotificationDispatchRepository, DiscordWebhookGateway, FeatureFlagGateway

UseCase는 위 DomainService만 호출하며 execute() 10줄 이내, @Transactional은 UseCase에 선언합니다.

발견 회사 자동 등록 흐름 (FR-61): 애그리게이터 수집은 회사가 미지정이므로, CollectAggregatorPostingsUseCase가 상위 오케스트레이션으로 두 도메인 서비스를 순차 호출합니다 — ① AggregatorCollectionDomainService가 어댑터로 raw 공고를 받아 sourceCompanyName 집합을 추출 ② DiscoveredCompanyDomainService.resolveOrCreate(names)(company 컨텍스트)가 미존재 회사를 DISCOVERED로 생성하고 Map<name, companyId> 반환 ③ 그 매핑을 넘겨 공고를 저장. 회사 자동 등록이 동기 선행(companyId가 있어야 공고 저장 가능)이라 이벤트가 아닌 상위 오케스트레이션으로 처리합니다. posting 도메인은 company 패키지를 import하지 않고 companyId(Long)만 받습니다. 잡코리아처럼 회사가 상세에만 있는 소스는 어댑터가 상세 조회로 sourceCompanyName을 채웁니다(FR-68). 점핏은 목록 응답에 companyName이 있어 이 경로를 타지 않습니다(2026-08-03 정정).

소스별 어댑터 설계 (조사 브리프 근거)

항목Greenhouse배민형(Woowahan)인크루트
엔드포인트GET boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=trueGET career.woowahan.com/w1/recruits?page=N&size=100GET recruit.incruit.com/{slug}/ + 공고별 상세
sourceJobIdidrecruitSeq공고 URL의 job id (/job/2507310011)
changeSignatureUpdatedAt(updated_at)SourceVersion(recruitVersion)BodyHash(sha256(정규화 본문))
마감일 정규화 (FR-13)필드 없음 → 항상 nullrecruitEndDate9999-* 또는 2999-* 센티널이면 null, 그 외 파싱목록 2026.07.16 00:00 ~ 2026.07.30 17:00의 종료 시각 파싱, 파싱 실패 시 null
구조화 태그metadata[]structuredTags (①근거, CONFIRMED)emptyList() (②만)emptyList() (②만)
본문 확보content 필드 (목록 응답에 포함)상세 엔드포인트 존재 여부 미검증 → 목록의 recruitName+요약으로 시작, 상세 확인되면 확장상세 페이지 전건 조회
인코딩UTF-8UTF-8EUC-KR 명시 지정Content-Type charset을 신뢰하지 않고 Jsoup.parse(stream, "EUC-KR", url) 고정
요청 정책목록 1회페이지네이션 전량 (pageNumber base는 첫 응답으로 판별)목록 1회 + 상세 N회, 요청 간 1,000ms 지연, 회차당 상세 상한 200건, connect 5s / read 10s
접근 제한 감지해당 없음해당 없음상세 응답이 로그인 페이지로 리다이렉트/로그인 폼 마커 포함 → accessRestricted=true
사전 확인robots.txt·API 정책 확인 결과를 어댑터 KDoc에 기록 (NFR-9)동일동일

마감일 정규화 책임 소재 확정: 정규화는 어댑터가 수행합니다. 도메인은 deadlineAt: ZonedDateTime?만 받고 null == 상시채용으로 판정합니다. 센티널 값(9999-12-31)이나 “필드 없음” 개념이 도메인에 새어 들어오면, 상시채용 판정이 소스별 분기로 오염되고 새 플랫폼 추가 시 도메인이 수정됩니다.

애그리게이터 어댑터 설계 — 규약 청정 2종 (조사 브리프 “전체 플랫폼 조사 결과” 근거)

항목사람인 (SARAMIN)점핏 (JUMPIT)
수집 파라미터검색 조건 searchCategoryCode(cat_kewd) + 선택 키워드searchCategoryCode(jobCategory)
엔드포인트GET saramin.co.kr/zf_user/jobs/list/job-category?cat_kewd={code}&page=N&page_count=100 (HTML, UTF-8)GET jumpit-api.saramin.co.kr/api/positions?page=N&size=200&jobCategory={code} (JSON, 무인증)
sourceJobIdrec_idxid
changeSignatureUpdatedAt(수정일 YY/MM/DD)FieldHash(sha256(title+deadline+company)) — 변경 감지 필드 없음(FR-68)
마감일 정규화 (FR-67)~MM/DD 연도 없음 → 연도 추론 / 채용시·상시채용 문자열 → nullalwaysOpen=truenull(closedAt 무시) / 그 외 closedAt ISO 파싱
회사 식별목록에 회사명 존재 → sourceCompanyName목록 companyName 존재 → sourceCompanyName (companyIdentifierInDetailOnly=false). 상세 조회로 식별 은 오설계였고 상세 엔드포인트는 401 이라 2026-08-03 제거
규약 (FR-69)robots 미차단·Crawl-delay 없음. UA 헤더 필수(기본 UA는 307). page_count 100/positions 미차단(GPTBot만 차단). size≤200

애그리게이터 어댑터 설계 — 규약 회색지대 4종 (사용자 승인 P0 편입)

사용자가 약관·robots 리스크를 인지하고 승인했습니다. 규약 준수를 SourceCompliancePolicy + SourceRequestExecutor로 구조적으로 강제(우회 불가)하는 것이 이 4종의 최우선입니다.

항목원티드 (WANTED)리멤버 (REMEMBER)잡코리아 (JOBKOREA)서핏 (SURFIT)
엔드포인트GET wanted.co.kr/api/v4/jobs, /api/chaos/navigation/v1/results (JSON, 무인증)POST career-api.rememberapp.co.kr/job_postings/search body {search,page,per} (JSON, 무인증)목록 jobkorea.co.kr/recruit/joblist?menucode=duty&dutyCtgr=, 상세 /Recruit/GI_Read/{gno} (JSON-LD)POST api.surfit.io/v1/jobs/all body {page} (JSON, Origin/Referer 필요)
sourceJobIdididdata-gno / JSON-LD identifierid
changeSignatureFieldHash(기본) — 실호출로 조정FieldHash (응답에 건별 updated_at 없음)BodyHash(상세 본문) — 등록일만 제공FieldHash (변경 감지 없음)
마감일 정규화 (FR-67)실호출로 확정ends_at + explicit_due(false+null=상시 → null)JSON-LD validThrough ISO / 상시채용 문자열·validThrough≈+1년nullis_closed 불리언 + close=“상시채용” → null / ads_end_at
증분 수집min_updated_at 증분 필터(supportsIncrementalSince=true) — 전량 재수집 대신 마지막 성공 수집 시각 이후만. 단 건별 updated_at 부재로 변경 판정은 여전히 FieldHash
규약 (FR-69)robots가 WAF 403이라 판단 불가 → 보수적: 1일 1회·요청 지연·명시 UAper 최대 50(초과 시 400), robots Allow: /job/, 약관이 스크래퍼 금지 → 1일 1회·재배포 금지robots가 AI 크롤러(ClaudeBot·anthropic-ai) Disallow: / → 허용 경로 화이트리스트 /recruit/joblist·/Recruit/GI_Read만, 키워드 검색 경로 금지, 목록 1페이지 + 개별 상세로 제한, UA는 일반 식별자robots /jobs 미차단, 약관 크롤링 금지 취지 → 1일 1회·재배포 금지
미검증(실호출 확정)마감일·변경 감지 필드·페이지네이션search body 필터 포맷허용 경로 페이지네이션 실질 범위speciality 필터 값 포맷

규약 강제 3중 방어:

  1. SourceCompliancePolicy.allowedPathPrefixes 화이트리스트 — 잡코리아는 ["/recruit/joblist", "/Recruit/GI_Read"]만. SourceRequestExecutor가 이 prefix에 없는 경로 호출을 던져서 차단하므로 키워드 검색 경로를 어댑터가 실수로 때릴 수 없습니다.
  2. 1일 1회unique(job_source_id, run_date)가 DB 레벨에서 이미 강제. 어댑터가 하루에 여러 번 수집을 시도해도 두 번째 회차 INSERT가 거부됩니다.
  3. 페이지 상한·요청 지연·UASourceRequestExecutor가 정책값으로 강제(리멤버 per≤50 초과 요청 자체를 실행 전 거부). 어댑터는 RestClient를 직접 만들 수 없고 Executor만 주입받습니다.

미검증 항목은 각 구현 티켓의 첫 단계에서 실호출로 확정하고, 확정 전까지 FieldHash·보수적 정책을 기본값으로 둡니다(브리프 방침). 사전 확인 결과(robots·약관·요청 정책)를 각 어댑터 KDoc에 기록합니다(NFR-9·NFR-11).

실패 경로·동시성·멱등

해피 패스만 있는 설계는 미완성입니다. 아래가 이 설계의 1급 시민입니다.

수집 실패 경로

상황처리근거
HTTP 타임아웃·5xx·파싱 예외회차 FAILED 기록, 재시도 없음, 다음 날 배치로 이월NFR-6
목록은 성공했으나 0건 반환회차 SUCCESS(fetched=0)이지만 비정상 회차로 분류silent failure는 예외를 던지지 않습니다 (Olostep 벤치마킹)
비정상 회차 (실패 또는 0건)그 소스의 델타 비교·미발견 카운터 증가·CLOSED 전환을 전부 건너뜁니다 (소스 가드, 1회차부터 즉시)FR-15 — 사고 위험 1순위
3일 연속 비정상JobSourceHealth.brokenSince 설정 + failureEpisode 확정 → 소스 고장 알림 대상FR-19 — 가드와 발동 주기가 다릅니다
소스 A 실패 / B 성공소스 단위로 트랜잭션 분리. 한 소스 예외가 배치 전체를 중단시키지 않습니다LinkedIn mining task 경계 벤치마킹
인크루트 상세 조회 부분 실패목록 기반 정보는 반영하고, 실패 건은 changeSignature 미갱신(다음 회차 재시도). 회차는 SUCCESS + detailFailureCount 기록. 미발견 판정에는 영향 없음(목록에 있었으므로 lastSeenAt 갱신)부분 실패가 마감 판정을 오염시키지 않게 분리
상세 조회 상한(200건) 초과초과분은 목록 정보만 갱신하고 경고 로그 + 회차에 detailSkippedCount 기록대상 사이트 부하 관리 (Operations)
로그인 리다이렉트 감지accessRestricted=true 저장, 자동 매칭·마감 판정 대상에서 제외. 목록 조회 시 “접근 제한” 배지시나리오 6
진짜로 공고가 0건인 소스3일 후 고장 오탐 알림 1건 발생 + 그 소스 공고가 CLOSED되지 않고 OPEN 유지안전 측 실패(false-open)를 의도적으로 선택합니다. 오탐 알림 1건 vs 정상 공고 무더기 마감 — 후자가 비교 불가하게 치명적입니다. 마감일이 있는 공고는 마감일 경과 배치가 계속 정확히 처리합니다
같은 날 배치 중복 실행job_posting_collection_runsunique(job_source_id, run_date)가 두 번째 실행을 차단(DuplicateKey → skip)NFR-6(“실패해도 재시도 없이 다음 배치”)과 정합

알림 실패 경로·멱등

상황처리
멱등 키 구성{targetType}:{targetId}:{notificationType}:{dispatchSequence} — 예: JOB_POSTING:1024:NEW_JOB_POSTING:1, JOB_SOURCE:7:SOURCE_FAILURE:2
멱등 판정 기준발송 성공 기준. idempotency_key 컬럼은 nullable이며 웹훅 2xx 수신 시에만 채웁니다. 유니크 인덱스는 NULL 중복을 허용하므로 실패 레코드는 여러 건 공존하고 성공 레코드는 1건만 존재합니다
5xx / 429DiscordWebhookGatewayImpl 내부에서 지수 백오프 3회(1s → 2s → 4s, 429는 Retry-After 우선). 3회 모두 실패 시 Rejected 반환
3회 실패 후dispatch_status=FAILED + last_error 기록. idempotency_key는 NULL 유지 → 다음 09:00 배치가 같은 대상을 다시 모수로 잡아 재발송 (시나리오 7-3)
무한 재발송 방지신규 공고 알림은 firstSeenAt 기준 7일 경과 시 대상에서 제외(만료). 소스 고장 알림은 brokenSince 기준 7일
발송 회차 증가신규 공고·소스 고장은 회차 고정 1. 소스 고장은 복구 후 재고장 시 failureEpisode가 1 증가해 새 키가 되므로 재알림됩니다
중복 키 예외DuplicateKeyException은 “이미 발송됨”의 정상 시나리오로 흡수하고 로그만 남깁니다
마감 알림발송하지 않습니다 (FR-36 — 의도적 제외)
재오픈기존 레코드 상태 전이이므로 신규 알림 대상이 아닙니다 (FR-16)
시딩notificationEligible=false로 저장되어 영구히 신규 알림 대상에서 제외됩니다 (FR-10)
관심 회사 신규 공고WATCHED 회사의 대표 공고 매칭 신규 → 개별 NEW_JOB_POSTING (기존, FR-62)
발견 회사 신규 공고DISCOVERED 회사의 대표 공고 매칭 신규 → 그날치를 묶어 DAILY_DIGEST 1건 (FR-62). 멱등 키 DAILY_DIGEST:0:DAILY_DIGEST:{KST일자ordinal} — 하루 1건 강제. 요약 본문에 회사·공고 목록
크로스 소스 중복dedup 배치가 비대표를 notificationEligible=0으로 눌러 대표만 알림 대상. 대표가 이미 발송됐고 다른 소스에서 같은 공고가 뒤늦게 발견돼도 그 비대표는 알림 대상이 아님 (FR-65)
대표 재선정 후 알림애그리게이터본이 먼저 대표로 알림 발송된 뒤 회사 직접본이 등장해 대표가 바뀌어도 추가 알림 없음 — dedupKey 단위로 “이미 이 공고 알림이 나갔는지”를 판정(대표 공고 기준 발송 이력 존재 시 skip). Success Metrics “크로스 소스 중복 알림 0건”의 근거

컨텍스트 간 이벤트 실패

  • JobPostingEvent.Discovered / Changed(Layer 1)를 matching 리스너가 @TransactionalEventListener(AFTER_COMMIT)로 구독해 즉시 평가합니다.
  • 리스너 실패는 원 수집 트랜잭션을 롤백하지 않습니다. 08:50 보정 스윕(EvaluatePendingJobPostingsUseCase)이 match_result 미존재 OR criteria_revision < 현재인 공고를 재평가해 흡수합니다. 즉 이벤트는 즉시성 목적이고, 기능의 정확성은 스윕이 보장합니다.
  • 같은 UseCase를 재매칭 API(FR-25)가 재사용합니다 — 평가 경로가 하나뿐이라 결과가 갈라지지 않습니다.

동시성

지점전략근거
배치 간 겹침TaskScheduler 풀 크기 1 → 6개 스케줄이 직렬 실행. 시각도 00:00 / 00:10 / 00:30 / 08:30 / 08:50 / 09:00으로 분리애그리게이터가 길어져 다음 배치 시각을 넘겨도 풀 크기 1이 순서를 보장(선행 미완이면 후행 대기). dedup(08:30)은 수집 완료 뒤 실행됨이 보장됩니다
배치 vs 사용자 API의 동일 공고 수정JobPosting에 낙관적 잠금(@Version). 충돌 시 배치는 그 공고만 skip + 경고 로그경합 확률이 극히 낮아 비관적 락은 과합니다
지원 상태 이중 전이Application@Version + ApplicationStatus.canTransitTo() 도메인 가드종료 상태에서의 추가 전이를 도메인이 거부
배치 중복 기동unique(job_source_id, run_date)분산 락(ShedLock) 불필요 — 단일 프로세스

멱등한 재실행

작업재실행 안전성
수집같은 날 재실행은 유니크 제약이 차단. 다른 날 재실행은 (jobSourceId, sourceJobId) upsert이므로 안전
매칭 평가criteria_revision 비교로 이미 평가된 건은 skip. 강제 재평가는 revision을 올려 수행
마감일 경과 판정이미 CLOSED면 no-op
크로스 소스 dedup매번 dedupKey 전체를 재그룹핑·대표 재선정하므로 재실행 안전. 비대표 링크는 upsert 성격
회사 자동 등록resolveOrCreate가 회사명 유니크로 기존 회사를 반환(중복 생성 없음). 재실행 안전
알림 발송성공 레코드 존재 시 skip. 일일 요약은 일자 멱등 키로 하루 1건

상태 전이 표

JobPosting (공고)

현재 상태 × 이벤트다음 상태거부/무시 사유
OPEN × 정상 회차에서 발견OPEN— (lastSeenAt 갱신, consecutiveMissCount=0)
OPEN × 정상 회차에서 미발견 (1회차)OPEN유예 — consecutiveMissCount=1
OPEN × 정상 회차에서 미발견 (2회 연속)CLOSEDclosedReason=NOT_FOUND_TWICE
OPEN × 마감일 경과 (경량 배치)CLOSEDclosedReason=DEADLINE_PASSED. 상시채용(deadlineAt=null)은 대상 제외 (FR-17)
OPEN/CLOSED × 비정상 회차(실패·0건)변화 없음소스 가드 — 판정 자체를 수행하지 않음 (FR-15)
CLOSED × 재발견OPENreopenedAt 기록, consecutiveMissCount=0, 신규 알림 없음 (FR-16)
CLOSED × 미발견CLOSED카운터 증가 없음 (no-op)
MANUAL origin × 수집 델타변화 없음델타 모수에서 제외 — 소스가 없어 매번 미발견으로 잡히면 즉시 CLOSED되는 사고 방지
accessRestricted=true × 수집 델타변화 없음자동 판정 대상 제외 (시나리오 6)
어떤 상태 × 물리 삭제 요청거부소프트 삭제만 허용 (FR-18, NFR-5)
CLOSED × 마감일 경과CLOSED이미 종료 (no-op)

크로스 소스 대표 선정 (dedup 배치, FR-64)

대표 선정 우선순위 (3단 tie-break): ① sourceType (COMPANY_BOUND > AGGREGATOR, FR-64) → ② 공고 상태 (OPEN > CLOSED) → ③ 가장 이른 firstSeenAt.

같은 dedupKey 그룹 상황 × 이벤트결과사유
그룹 내 공고 1건그 공고가 대표 (representativeId=NULL)단독이므로 자기 자신이 대표
회사 직접 1 + 애그리게이터 N회사 직접본이 대표, 애그리게이터 N건은 representativeId=대표id + notificationEligible=0우선순위 ① 직접 > 애그리게이터 (FR-64)
애그리게이터만 M건 (전부 OPEN)가장 이른 firstSeenAt 1건이 대표③ tie-break
OPEN 1 + CLOSED N (같은 sourceType)OPEN이 대표, CLOSED는 비대표② OPEN > CLOSED — 사용자에게 유효한 지원 경로(OPEN)를 대표로
그룹 전체가 CLOSED가장 이른 firstSeenAt CLOSED 1건이 대표CLOSED도 그룹에 남깁니다(아래 정책)
애그리게이터가 대표였는데 회사 직접본 등장대표를 회사 직접본으로 재선정, 기존 대표 강등자기참조 재지정 1회
마감일이 30일 이상 차이다른 공고로 분리(같은 dedupKey여도 별개 대표)회사·제목이 같아도 채용 회차가 다른 경우 보조 판정 (FR-63)
dedup 플래그 OFF그룹핑 안 함, 전부 대표 취급무중단 롤백 — 오판정 시 즉시 중단

CLOSED 공고의 dedup 포함 정책 (DBA Open Q 판단): dedup 대상에 CLOSED 공고도 포함합니다 — 크로스 소스로 묶인 공고 중 일부가 마감돼도 그룹에서 빼면 상세의 alternateSources[] 히스토리가 깨지고(어느 출처에 있었는지 소실), 재오픈(FR-16) 시 그룹을 다시 찾을 수 없습니다. 다만 대표 선정은 OPEN을 우선(②)해, 그룹에 살아있는(OPEN) 공고가 있으면 그것이 대표가 되어 사용자가 유효한 지원 경로를 봅니다. 그룹 전체가 CLOSED면 그중 1건이 대표로 남아 히스토리·지원 이력 참조(NFR-5)를 보존합니다. NFR-5(무기한 보존)·FR-18(소프트 삭제)과 정합합니다.

Application (지원)

현재 상태 × 전이 요청결과거부 사유
APPLIED × DOCUMENT_SCREENING/REJECTED/WITHDRAWN허용
APPLIED × INTERVIEWING/OFFERED/ACCEPTED/OFFER_DECLINED거부단계 건너뛰기 불가 / OFFER_DECLINEDOFFERED 이후만
DOCUMENT_SCREENING × INTERVIEWING/REJECTED/WITHDRAWN허용
INTERVIEWING × OFFERED/REJECTED/WITHDRAWN허용
OFFERED × ACCEPTED/OFFER_DECLINED허용
OFFERED × WITHDRAWN거부처우협의 단계의 포기는 OFFER_DECLINED로 표현 (FR-44)
OFFERED × REJECTED거부처우협의 진입 후 불합격 전이는 정의되지 않음 (FR-43 표)
ACCEPTED/REJECTED/WITHDRAWN/OFFER_DECLINED × 모든 전이거부종료 상태 (FR-43)
임의 상태 × 같은 상태로 전이거부무의미한 이력 생성 방지
* × REJECTED (허용 단계에서)허용 + rejectedAtStage=이전 상태 기록FR-43

모든 허용 전이는 job_application_status_histories(previousStatus, nextStatus, transitedAt, memo)로 적재됩니다 (FR-47).

생성 이력 규칙 (확정): 지원 기록 생성 시 (previousStatus=null → nextStatus=APPLIED) 이력 1건을 함께 적재합니다. 이렇게 해야 히스토리가 지원 시작 시점부터 완결되고, FE 타임라인이 “지원함” 노드를 별도 분기 없이 이력만으로 렌더링할 수 있습니다. previous_status가 nullable인 이유가 이 규칙이며, null은 생성 이력에서만 나타납니다. 따라서 이력 건수는 1(생성) + 전이 횟수입니다 — 예: APPLIED → DOCUMENT_SCREENING → INTERVIEWING → OFFERED → ACCEPTED 전체 경로는 이력 5건(생성 1 + 전이 4)입니다.

근무형태 확신도 판정

근거부정어 문맥결과 확신도
① 구조화 필드/태그에 근무형태 키워드없음CONFIRMED
② JD 본문에 근무형태 키워드없음LIKELY
①/② 키워드 발견있음 → 그 근거를 무효화해당 근거 채택 안 함
유효 근거 0건UNKNOWN (“근무형태 정보 없음” 표기, FR-34)
③ 같은 회사 다른 공고INFERRED (P2 — 미구현)

부정어 판정: 키워드 출현 위치 기준 앞 12자 / 뒤 20자 윈도우 안에 부정 표현 사전(불가, 불가능, 미지원, 없음, 아님, 않습니다, 종료, 제외, 전면 출근, 상시 출근, 출근 전환, 사무실 출근)이 있으면 그 근거를 무효화합니다. 사전은 상수로 관리하고 확장 시 테스트를 함께 추가합니다.

정렬 규칙(FR-30): CONFIRMED·LIKELY만 정렬 기준으로 사용하고, INFERRED·UNKNOWN은 정렬에서 제외해 뒤로 밀립니다. 어떤 확신도에서도 목록에서 제외하지 않습니다 (FR-33 — 필터가 아닙니다).

Component Diagram

flowchart LR
    subgraph Presentation["presentation"]
        Api[ApiController 6종]
        Sched[Scheduler 4종]
        Listener[JobPostingEventListener]
    end
    subgraph Application["application"]
        Collect[CollectJobPostingsUseCase]
        Evaluate[EvaluateJobPostingsUseCase]
        Dispatch[DispatchNotificationsUseCase]
        Others[company·application UseCase]
    end
    subgraph Domain["domain"]
        Posting[posting]
        Matching[matching]
        Notification[notification]
        AppCtx[application·company]
    end
    subgraph Infra["infrastructure"]
        Registry[JobSourceGatewayImpl]
        Adapters[어댑터 9종]
        Discord[DiscordWebhookGatewayImpl]
        Repos[RepositoryImpl 群]
    end
    Api --> Others
    Sched --> Collect
    Sched --> Evaluate
    Sched --> Dispatch
    Collect --> Posting
    Evaluate --> Matching
    Dispatch --> Notification
    Others --> AppCtx
    Posting -.->|ApplicationEvent| Listener
    Listener --> Evaluate
    Registry -.->|implements| Posting
    Registry --> Adapters
    Discord -.->|implements| Notification
    Repos -.->|implements| AppCtx

Sequence Diagram — 자정 수집 (소스 1개)

sequenceDiagram
    participant S as CollectionScheduler
    participant U as CollectJobPostingsUseCase
    participant D as CollectionDomainService
    participant G as JobSourceGateway
    participant R as JobPostingRepository
    participant P as DomainEventPublisher
    S->>U: execute()
    U->>D: collect(descriptor)
    D->>G: collect(descriptor)
    G-->>D: Fetched(postings) 또는 Failed
    alt 비정상 회차 (Failed 또는 0건)
        D->>D: 소스 가드 — 델타 판정 skip
        D->>R: 회차 기록 + health.recordAbnormal()
    else 정상 회차
        D->>R: findAllCollectedIn(sourceId)
        D->>D: 델타 비교 (신규·변경·미발견)
        D->>R: saveAll(신규·갱신·미발견 카운터)
        D->>P: publishAll(Discovered/Changed)
    end
    D-->>U: CollectionRunResult

Sequence Diagram — 00:10 애그리게이터 수집 + 회사 자동 등록

sequenceDiagram
    participant S as AggregatorScheduler
    participant U as CollectAggregatorPostingsUseCase
    participant A as AggregatorCollectionDomainService
    participant C as DiscoveredCompanyDomainService
    participant G as JobSourceGateway
    participant R as JobPostingRepository
    S->>U: execute()
    U->>A: fetch(descriptor)
    A->>G: collect(Aggregator descriptor)
    G-->>A: Fetched(raw + sourceCompanyName)
    A-->>U: raw 공고 + 회사명 집합
    U->>C: resolveOrCreate(회사명 집합)
    C-->>U: Map<회사명, companyId> (미존재는 DISCOVERED 생성)
    U->>A: persist(raw, companyIdByName)
    A->>R: saveAll(dedupKey 계산 · 델타 · 이벤트)

Sequence Diagram — 08:30 크로스 소스 중복 판정

flowchart LR
    A[DedupScheduler] --> B[DeduplicateJobPostingsUseCase]
    B --> C[DeduplicationDomainService]
    C --> D[dedupKey 로 그룹핑]
    D --> E[대표 선정: 직접 > 애그리게이터]
    E --> F[비대표: representativeId 설정]
    F --> G[비대표: notificationEligible=0]
    E --> H[대표: representativeId=NULL]

Sequence Diagram — 09:00 알림 발송 (개별 + 일일 요약)

sequenceDiagram
    participant S as NotificationScheduler
    participant U as DispatchNotificationsUseCase
    participant P as PostingDomainService
    participant D as NotificationDomainService
    participant N as NotificationDispatchRepository
    participant W as DiscordWebhookGateway
    S->>U: execute()
    U->>P: 신규 공고(WATCHED 개별 / DISCOVERED 요약) · 고장 소스 조회
    P-->>U: posting 도메인 객체 목록 (posting 자기 테이블)
    U->>U: 매퍼로 NotificationMessage 구성 (application 조합)
    U->>D: dispatch(messages)
    loop 관심 회사 대표 공고
        D->>N: findSucceededBy(key)
        D->>W: 개별 NEW_JOB_POSTING (백오프 3회)
        D->>N: save(성공 시 멱등키)
    end
    opt 발견 회사 매칭 신규 존재
        D->>N: findSucceededBy(DAILY_DIGEST 일자키)
        D->>W: DAILY_DIGEST 1건 (묶음)
        D->>N: save(성공 시 멱등키)
    end

ERD

컬럼 상세·인덱스·DDL의 SSOT는 DB 설계 문서 /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-공고알림앱-design-db.md이며, BE-01이 그 문서를 그대로 입력으로 받아 V1 베이스라인 마이그레이션을 작성합니다. 애그리게이터 편입으로 DBA가 반영할 스키마 변경은 아래 “DBA 파급 요약”에 정리했습니다. 아래는 요약 ERD입니다.

erDiagram
    COMPANIES ||--o{ JOB_SOURCES : has
    COMPANIES ||--o{ JOB_POSTINGS : owns
    JOB_SOURCES ||--o{ JOB_POSTINGS : collects
    JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_RUNS : records
    JOB_SOURCES ||--|| JOB_SOURCE_HEALTH : tracks
    JOB_POSTINGS ||--o| JOB_POSTING_DESCRIPTIONS : details
    JOB_POSTINGS ||--o{ JOB_POSTING_SOURCE_TAGS : tagged
    JOB_POSTINGS ||--o| JOB_POSTING_MATCH_RESULTS : evaluated
    JOB_POSTINGS ||--o{ JOB_POSTINGS : represents
    JOB_KEYWORD_GROUPS ||--o{ JOB_KEYWORD_SYNONYMS : contains
    COMPANIES {
        bigint id PK
        varchar name "UNIQUE"
        varchar registration_type "AUTO / MANUAL_ONLY"
        varchar company_origin "WATCHED / DISCOVERED (신규)"
    }
    JOB_SOURCES {
        bigint id PK
        bigint company_id "애그리게이터는 NULL (신규 nullable)"
        varchar platform "GREENHOUSE..SARAMIN,JUMPIT"
        varchar source_type "COMPANY_BOUND / AGGREGATOR (신규)"
        varchar source_slug "회사 종속형만"
        varchar search_category_code "애그리게이터만 (신규)"
        varchar search_keyword "애그리게이터 선택 (신규)"
        datetime disabled_at
    }
    JOB_POSTINGS {
        bigint id PK
        bigint company_id
        bigint job_source_id "NULL = 수동 등록"
        bigint representative_id "NULL=대표, 값=중복본→대표 (신규 자기참조)"
        varchar dedup_key "정규화 회사명+제목 (신규, 인덱스)"
        varchar posting_origin "COLLECTED / MANUAL"
        varchar source_job_id "소스별 고유 ID"
        varchar title
        datetime deadline_at "NULL = 상시채용"
        varchar posting_status "OPEN / CLOSED"
        varchar change_signal_kind "..FIELD_HASH(신규)"
        varchar change_signal_value
        int consecutive_miss_count
        tinyint notification_eligible "시딩·비대표=0"
        tinyint access_restricted
    }
    JOB_SOURCE_HEALTH {
        bigint id PK
        bigint job_source_id
        int consecutive_abnormal_days
        datetime broken_since "NULL = 정상"
        int failure_episode "재고장 시 증가"
    }
    NOTIFICATION_DISPATCHES {
        bigint id PK
        varchar target_type "JOB_POSTING / JOB_SOURCE"
        bigint target_id
        varchar notification_type
        int dispatch_sequence
        varchar idempotency_key "성공 시에만 채움 · UNIQUE"
        varchar dispatch_status
        int attempt_count
    }

주요 제약:

테이블제약목적
job_postingsunique(job_source_id, source_job_id)공고 식별 (FR-11)
job_posting_collection_runsunique(job_source_id, run_date)하루 1회 실행 보장
job_source_healthunique(job_source_id)소스당 1행
job_posting_descriptionsunique(job_posting_id)본문 최신 1건만 유지
job_posting_source_tagsunique(job_posting_id, tag_value)태그 중복 방지
job_posting_match_resultsunique(job_posting_id)공고당 평가 결과 1건
job_posting_matched_keyword_groupsunique(job_posting_match_result_id, job_keyword_group_id)매칭 그룹 N:M 해소
job_posting_work_arrangementsunique(job_posting_id, work_arrangement_keyword_id)근거 중복 방지
job_applicationsunique(job_posting_id)공고당 지원 1건 (“미지원 = 미존재”)
job_application_interviewsunique(job_application_id, round_number)회차 중복 방지
notification_dispatchesunique(idempotency_key) (nullable)성공 기준 멱등 (FR-38·62 일일 요약 포함)
job_sourcesunique(platform, source_slug) (회사 종속형) / 애그리게이터는 unique(platform, search_category_code, search_keyword)소스 중복 등록 방지
job_postingsidx(dedup_key) (조회)크로스 소스 그룹핑 (FR-63)
companiesunique(name)회사 중복 (자동 등록 멱등의 근거)

DBA 파급 요약 (design-db 갱신 요청 — 애그리게이터 편입)

테이블변경근거
companiescompany_origin VARCHAR(20) NOT NULL 추가 (WATCHED/DISCOVERED)FR-61. registration_type과 직교
job_sourcessource_type VARCHAR(20) NOT NULL 추가 / company_id nullable로 변경(애그리게이터는 NULL) / search_category_code VARCHAR(100) NULL / search_keyword VARCHAR(200) NULL 추가 / source_slug nullableFR-60. 유니크 제약이 유형별로 갈림
job_postingsrepresentative_id BIGINT NULL(자기참조) / dedup_key VARCHAR(255) NULL + idx(dedup_key) 추가. company_id는 자동 등록 회사로 채워짐FR-63·64
notification_dispatchesnotification_type·target_type 코드값에 DAILY_DIGEST 추가FR-62
코드값 사전platformSARAMIN·JUMPIT·WANTED·REMEMBER·JOBKOREA·SURFIT(P0 승격, 워크넷·잡알리오만 P1 예약), change_signal_kindFIELD_HASH, company_origin·source_type 신규회색지대 4종 P0 편입
신규 테이블없음 — 중복 그룹은 자기참조 컬럼으로 해결(그룹 테이블 미채택, 방안 9)정합 지점 최소화
배치 성능dedup_key 인덱스로 그룹핑 조회. 애그리게이터 볼륨(사람인 2,594건 등) 반영해 job_postings 3년 규모 상향(수만 → 수십만 행) 추정 갱신 필요FR-66

본문·태그 저장 (FR-25·FR-27② 성립 조건): 재평가 시점에 소스를 다시 호출하는 것은 불가능하므로(공고가 이미 내려갔을 수 있고 1일 1회 요청 정책과 충돌), 어댑터가 가져온 본문·구조화 태그를 수집 시점에 DB에 남겨야 합니다. 본문은 용량의 73%를 차지하는 cold 데이터라 job_posting_descriptions로 1:1 분리해 델타·목록 hot path가 읽지 않게 하고, 태그는 JSON 컬럼 금지 규칙에 따라 job_posting_source_tags로 정규화합니다.

테이블 명명 (도메인 클래스명과 다름): job_applications / job_application_status_histories / job_application_interviews. private-db-schema-conventionapplications 단독 명사를 금지 예시로 직접 지목합니다. 도메인 클래스는 Application / ApplicationStatusHistory / Interview를 그대로 유지하고 JPA 매핑에서만 테이블명을 지정합니다 — 컨텍스트(domain/application) 안에서는 클래스명이 자명하기 때문입니다.

파생 캐시 2종: job_posting_work_arrangements, job_posting_matched_keyword_groups는 이력이 아니라 현재 revision에 대한 계산 결과이므로, 재평가 시 WHERE job_posting_id = ? 범위의 삭제 후 재삽입을 허용합니다. 소프트 삭제 전용 원칙의 유일한 예외입니다.

스키마 컨벤션: FK 제약 없음(일반 컬럼), ENUM 금지(VARCHAR), BOOLEAN 금지(TINYINT(1)), 날짜 DATETIME(6)(예외: job_posting_collection_runs.run_dateDATE + KST 업무 일자), 전 컬럼 COMMENT 필수. 그 외 전 컬럼은 UTC 저장(hibernate.jdbc.time_zone=UTC).

Testing Plan

프레임워크는 Kotest(BehaviorSpec/DescribeSpec) 전 레이어, RED → GREEN → REFACTOR 순서를 강제합니다.

레벨대상도구핵심 시나리오
domainJobPosting, JobSourceHealth, ApplicationStatus, JobKeywordMatcher, WorkArrangementDetector, NotificationDispatchKotest + MockK상태 전이 허용/거부 전수, 미발견 카운터, 재오픈, 부정어 무효화, 멱등 키 생성
application각 UseCaseKotest + MockK (DomainService 모킹)오케스트레이션 순서, 트랜잭션 경계, 플래그 OFF 시 no-op
infrastructure어댑터 9종(회사 3 + 애그리게이터 6), SourceRequestExecutor, RepositoryImpl, DiscordWebhookGatewayImplKotest + Testcontainers(MySQL) + MockWebServer + 고정 fixture실제 응답 fixture 파싱, EUC-KR 디코딩, 사람인 연도없는 마감일 추론, 점핏 alwaysOpen·필드해시, 리멤버 per≤50·증분, 잡코리아 허용 경로 화이트리스트 강제(금지 경로 호출 시 예외), 규약 정책 강제(UA·지연·페이지 상한), 백오프 재시도, 유니크 제약
presentationApiController, Scheduler(6종), EventListenerKotest + MockMvc + Testcontainers요청/응답 계약, 스케줄 진입점이 UseCase를 1회 호출
scenarioE2EKotest + Testcontainers회사 등록 → 시딩 → 2회차 수집 → 매칭 → 09:00 발송 → 지원 → 상태 전이 → 히스토리 조회 / 애그리게이터 수집 → 회사 자동 등록 → 크로스 소스 중복 → 대표 선정 → 일일 요약

반드시 커버할 실패 경로

#시나리오기대
1소스 수집이 예외로 실패그 소스의 어떤 공고도 CLOSED되지 않고 미발견 카운터가 증가하지 않는다
2소스가 0건 반환1과 동일하게 판정 skip + 비정상 일수 증가
3정상 회차 미발견 1회 → 2회1회차 OPEN 유지, 2회차 CLOSED
43일 연속 비정상소스 고장 알림 1건 발송, 복구 후 재고장 시 회차 증가로 재알림
5수동 등록 공고가 있는 상태로 수집수동 공고는 미발견 카운터가 증가하지 않는다
6웹훅 3회 실패 후 다음 배치같은 대상이 재발송되고, 성공 시에만 멱등 키가 부여된다
7같은 대상 연속 2회 발송 시도두 번째는 skip되어 중복 발송 0건
8EUC-KR 인크루트 fixture 파싱한글 깨짐 0건 (한국어 제목 정확 일치)
9Greenhouse 마감일 없음 / 배민 9999-12-31둘 다 deadlineAt == null로 정규화되어 상시채용으로 취급
10”재택근무 불가” 본문근무형태 근거로 채택되지 않고 UNKNOWN
11종료 상태에서 전이 시도도메인 예외, 이력 미생성
12OFFEREDWITHDRAWN거부 (FR-44)
13시딩 회차 공고알림 대상에서 제외
14CLOSED 공고 재발견OPEN 복귀 + 신규 알림 미발송
15상세 조회 일부 실패회차는 SUCCESS, 실패 건은 시그니처 미갱신, 마감 판정 영향 없음
16매칭 리스너 실패 후 08:50 스윕미평가 공고가 평가되어 발송 대상에 포함
17수집 후 소스를 호출하지 않고 재평가저장된 본문·태그만으로 평가가 완주한다 (FR-25 성립 조건)
18본문 미확보(상세 조회 실패) 공고의 평가②근거 부재로 UNKNOWN이 되고 예외가 발생하지 않는다
19지원 생성 직후 히스토리 조회(null → APPLIED) 생성 이력 1건이 존재한다
20사람인 ~08/15(연도 없음) 파싱오늘이 08/16이면 내년으로 추론, 08/14면 올해로 추론
21점핏 alwaysOpen=true인데 closedAt 존재deadlineAt=null(상시채용) — closedAt을 마감 판정에 쓰지 않는다
22점핏 목록에 회사 없음 (전제 오류 — 목록에 companyName 존재)목록의 companyName으로 sourceCompanyName을 채우고 상세 조회를 하지 않는다. FR-68 상세 조회 경로는 잡코리아에만 적용 (2026-08-03 정정)
23애그리게이터 공고의 회사가 미등록DISCOVERED 회사로 자동 생성되고 companyId가 채워진다 (FR-61)
24같은 회사·제목이 Greenhouse·사람인에 동시 존재dedup 배치가 Greenhouse를 대표로, 사람인을 비대표로 링크 (FR-64)
25애그리게이터본이 대표로 알림 발송 후 회사 직접본 등장대표 재선정되지만 추가 알림 없음 (FR-65)
26발견 회사 매칭 신규 3건개별 3건이 아니라 DAILY_DIGEST 1건으로 발송 (FR-62)
27관심 회사 매칭 신규개별 NEW_JOB_POSTING 발송 (발견 회사와 구분)
28같은 날 일일 요약 2회 실행일자 멱등 키로 두 번째는 skip
29어댑터가 규약 정책 우회 시도(지연 0·페이지 상한 초과)SourceRequestExecutor가 강제해 우회 불가 (FR-69)
30dedup 플래그 OFF그룹핑 안 하고 전부 대표 취급 (무중단 롤백)

Observability

별도 모니터링 스택(Prometheus·Grafana)은 도입하지 않습니다 — 1인용 로컬 Docker 환경(NFR-7)에서 운영 비용이 가치를 넘습니다. 대신 DB 테이블 자체를 관측 대상으로 삼고 조회 API로 노출합니다.

관측 대상수단확인 방법
수집 성공률 (Success Metrics: 30일 롤링 95%)job_posting_collection_runsGET /api/operations/collection-runs?days=30
소스별 수집 건수 추이·이상치같은 테이블의 fetched_count위 API 응답에 일자별 건수 포함
소스 고장 상태job_source_health.broken_since회사·소스 조회 응답에 배지. 원티드류(robots WAF 403) 소스는 정책 변경 시 고장 감지로 포착해 즉시 중단(Operations)
크로스 소스 중복 알림 (Success Metrics: 0건)대표 공고 기준 발송 이력동일 대표 공고에 신규 알림 2회 이상 발송 여부 점검
알림 발송 실패 (Success Metrics: 실패율 2% 이하)notification_dispatchesGET /api/operations/notification-dispatches?dispatchStatus=FAILED알림에 의존하지 않는 확인 수단 (시나리오 7)
마감 전환 건수job_postings.closed_at + closed_reason회사별 공고 조회
배치 실행 로그구조화 로그 (MDC: jobSourceId, runDate, platform)docker compose logs
이력 보존수집 이력 최소 30일(Operations), 그 외 전 데이터 무기한(NFR-5)정리 배치를 만들지 않습니다 — 수집 이력이 연 10,950행(30소스 × 365일)이라 무기한 보존해도 3년 33,000행입니다. 아카이빙 임계는 100만 행 또는 조회 응답 1초 초과 시점이며, 그때의 방법은 “run_date < now-2y 구간을 job_posting_collection_runs_archive로 이관 후 원본 삭제”입니다. 임계 도달 전에는 만들지 않습니다

Release Scenario — 무중단 배포

빈 레포의 최초 배포이므로 “기존 트래픽 무중단” 요건은 완화되지만, 위험한 기능을 마지막에 켜는 단계적 활성화모든 단계의 롤백 지점은 그대로 적용합니다.

배포 순서 (스키마 먼저 → 코드)

단계내용전환 조건롤백
0MySQL 컨테이너 기동 → Flyway V1 베이스라인 적용 (전 테이블 DDL)flyway_schema_history에 V1 SUCCESS데이터가 없으므로 스키마 드롭 후 재적용
1앱 배포. feature_flags 시드: posting.auto-close=false, notification.discord-dispatch=false, posting.cross-source-dedup=false, aggregator.collection=false헬스체크 200컨테이너 중지
2회사 4곳(당근·배민·우리은행·수협은행) 등록 → 첫 수집 = 시딩collection_runs가 소스별 SUCCESS, 공고가 notification_eligible=0으로 적재회사 삭제 없이 job_sources.disabled_at 기록(소프트 비활성화)
32회차 이상 정상 수집 확인 (델타 비교가 실제로 동작하는지)소스별 SUCCESS 2회 + 미발견 카운터가 의도대로 증감단계 4로 진행하지 않음
4notification.discord-dispatch=true (플래그 UPDATE 1건, 재기동 없음)09:00 배치에서 신규 공고 알림이 디스코드에 도착플래그 false — 즉시 발송 중단
5posting.auto-close=true — 위험 기능을 뒤에최소 3일간 수집이 안정적으로 SUCCESS플래그 false — 마감 판정 즉시 중단. 오판정된 공고는 소프트 삭제라 posting_status를 OPEN으로 되돌리면 복구
6a규약 청정 애그리게이터 등록(사람인·점핏) → 첫 수집 = 시딩. aggregator.collection=true애그리게이터 collection_runs SUCCESS, DISCOVERED 회사 자동 등록, 공고 notification_eligible=0으로 적재플래그 false — 애그리게이터 수집 즉시 중단. 발견 회사·공고는 보존
6b규약 회색지대 애그리게이터 등록(원티드·리멤버·잡코리아·서핏) → 시딩. 소스별로 순차 검증소스별 SUCCESS + 금지 경로 미호출·per≤50 등 규약 준수 로그 확인소스별 job_sources.disabled_at 기록(소프트 비활성화)으로 개별 중단. 약관·robots 변경 감지 시 즉시
7posting.cross-source-dedup=true — 가장 복잡한 기능을 마지막에dedup 배치가 같은 dedupKey를 대표/비대표로 정확히 나누는지 샘플 검증플래그 false — 그룹핑 중단, 전부 대표 취급(중복 알림 가능하나 데이터 손상 없음). representative_id를 NULL로 되돌리면 복구
8이후 어댑터 추가(P1 워크넷·잡알리오 등)추가 조사 완료 후새 어댑터는 새 JobPlatform 값 + 새 파일이라 기존 소스에 영향 없음. 회색지대 소스는 소스 고장 감지로 정책 변경 포착 시 즉시 중단

롤백 원칙

  • 코드 롤백 = prod compose의 이미지 태그를 직전 릴리즈 태그({YYYYMMDD}-{NN})로 되돌려 up -d.
  • 기능 롤백 = 피처 플래그 UPDATE(재기동 불필요). @ConditionalOnProperty를 쓰지 않는 이유가 여기 있습니다 — 빈 등록을 설정으로 가르면 롤백에 재기동이 필요합니다.
  • 데이터 롤백 = 물리 삭제가 없으므로(FR-18, NFR-5) 상태 컬럼 되돌리기로 복구 가능.

데이터 마이그레이션 계획

해당 없음 — 신규 스키마이고 기존 데이터가 0건이라 백필 대상이 없습니다. 향후 컬럼 추가 시에는 expand-contract(nullable 추가 → 듀얼라이트 → 배치 백필 → 검증 → 전환)를 적용하며, Flyway 인라인 백필 DML은 금지입니다.

플래그 제거 시점

posting.auto-close·notification.discord-dispatch·posting.cross-source-dedup·aggregator.collection전부 영구 운영 스위치로 유지합니다. 소스 파서가 깨졌거나 dedup·애그리게이터 규약 문제가 발생했을 때 해당 기능만 즉시 멈춰야 하는 요구가 지속적이기 때문입니다(임시 릴리즈 토글이 아닙니다). 이 판단을 feature_flags.description에 기록합니다. 애그리게이터 규약 회색지대 소스(P1 리멤버·잡코리아)는 소스별로 aggregator.collection.{platform} 형태의 세분 플래그를 P1에서 추가해 소스 단위로 끌 수 있게 확장합니다.

Open Questions

#항목현재 처리확정 필요 시점
1시나리오 6-3 “로그인 필요 공고 발견” 알림FR-35의 알림 4종에 없어 P0 범위 밖으로 두고, 공고 목록의 “접근 제한” 배지로 대체했습니다사용자가 배지만으로 부족하다고 판단하면 P1에서 알림 종류 추가 (NotificationType enum 확장만으로 가능)
2직무 키워드 매칭 대상 범위제목(title) 단독으로 확정했습니다. 본문 매칭은 “백엔드와 협업” 같은 문장에서 대량 오탐이 발생합니다누락이 관찰되면 P1에서 “본문 포함” 옵션 추가
3배민 API 페이지네이션 base (미검증)어댑터가 첫 응답의 pageNumber로 base를 판별해 전량 수집하고, 총 건수와 수집 건수가 불일치하면 회차를 FAILED 처리구현 티켓(BE-08)에서 실호출로 확정
4배민 상세 조회 엔드포인트 존재 여부 (미검증)목록 필드만으로 시작. 본문 미확보 시 근무형태는 ②근거 부재 → UNKNOWNBE-08에서 확인. 존재하면 requiresDetailFetch=true로 능력 선언만 바꾸면 됩니다
5FR-12의 “마감일 연장은 알림 대상”P0에는 마감 관련 알림 종류가 없습니다(FR-35: D-1은 P1). P0에서는 변경 감지 = 필드 갱신 + changedAt 기록까지 수행하고, 마감일 연장이 알림에 반영되는 것은 D-1 알림이 도입되는 P1부터입니다P1 D-1 알림 설계 시
6인크루트 상세 조회 상한 200건소스당 등록 공고가 200건을 넘으면 초과분은 목록 정보만 갱신됩니다. 현재 확인된 규모(수협·우리은행)에서는 여유가 큽니다상한 초과 로그가 관찰되면 조정
7전 회사 통합 공고 조회 GET /api/job-postings (FE 요청 #3)P0 미채택. FR-50이 “회사 단위 분류 조회”를 P0으로 규정하고, FE도 회사 우선 내비게이션으로 완주 가능(non-blocking)하다고 확인했습니다. 통합 목록은 페이지네이션·전역 정렬이라는 새 요구를 파생시키는데 P0에 근거가 없습니다회사가 20곳을 넘거나 사용자가 “전체 보기”를 실제로 요구하면 P1. JobPostingQueryServicecompanyId를 nullable로 받는 오버로드만 추가하면 되어 확장 비용이 낮습니다
8collection-runsguardApplied 필드 (FE 요청 #9)부분 수용. abnormal만 내리고 guardApplied는 두지 않습니다 — 소스 가드는 “비정상 회차이면 마감 판정을 건너뛴다”는 규칙이라 두 값이 항상 동일합니다. 같은 값을 두 필드로 내리면 한쪽 계산만 바뀌었을 때 모순이 생깁니다. FE는 abnormal=true일 때 “마감 판정 제외됨” 문구를 표시합니다가드 조건이 비정상 회차 외 요인으로 확장되면 그때 분리
9P0 애그리게이터를 사람인·점핏 2종으로 한정규약 청정 소스로 시작(방안 8). 원티드·리멤버·잡코리아는 규약 회색지대라 P1. SPI는 이들을 수용하도록 일반화만 함게이트 ②에서 사용자가 회색지대 소스를 P0에 포함하도록 뒤집을 수 있습니다. 그 경우 어댑터 구현 티켓만 추가되고 SPI·dedup·자동등록은 무변경
10크로스 소스 dedup 문자열 유사도 알고리즘 (FR-63)정규화 완전 일치 + 마감일 30일 이내로 P0 확정 — 회사명·제목을 정규화(소문자·공백/기호 제거·NFKC)해 완전 일치할 때만 동일 공고로 봅니다. 편집 거리·토큰 유사도 같은 근사 매칭은 오판정(다른 공고를 묶어 알림 누락) 위험이 커 P0에서 배제정규화 완전 일치로 놓치는 중복이 관찰되면 P1에서 토큰 자카드 유사도 임계 도입 검토
11워크넷·서핏·잡알리오 (추가 조사 중)어댑터 SPI가 수용하도록 일반화만 하고 구현 티켓 미생성조사 완료 후 P1에서 어댑터 티켓 추가 (같은 SPI)
12사람인 공개 API(oapi, access-key 승인제) 전환P0는 HTML 파싱으로 시작. 발급받으면 JSON으로 전환이 더 안전(브리프)access-key 발급 후 P1에서 클라이언트만 교체(어댑터 계약 무변경)

Document History

날짜변경 내용
2026-07-22최초 작성 — PRD P0 41건 대상 설계. 서버 토폴로지·어댑터 추상화·마감 판정·알림 멱등·컨텍스트 경계 5개 축의 방안 비교와 채택 근거 확정
2026-07-22DB 설계(design-db) 정합 반영 7건 — ① 테이블 명명 job_applications·job_application_status_histories·job_application_interviews(도메인 클래스명은 유지) ② job_sources.disabled_atjob_posting_descriptions·job_posting_source_tags 신설(FR-25·FR-27② 성립 조건) + JobPostingDetailRepository·EvaluationTargetRepository 계약 추가 ④ job_posting_matched_keyword_groups 신설 ⑤ 지원 생성 시 (null → APPLIED) 이력 1건 적재 규칙 확정 ⑥ 매칭 키워드 소프트 삭제 ⑦ 수집 이력 아카이빙 임계·방법 명시
2026-07-22크로스 컨텍스트 조합 원칙 정정 — 소비 컨텍스트 소유 read model(EvaluationTargetRepository·NotificationTargetRepository) 폐기. 다른 컨텍스트 데이터는 application UseCase가 소유 컨텍스트 DomainService로 조회 → 매퍼 → 소비 컨텍스트 주입으로 조합. matching infra의 posting 테이블 로우 쿼리(JdbcTemplate) read model은 no-crosscontext-raw-read로 금지. posting repo의 findAllEvaluationPending(matching criteria_revision 참조)을 findAllEvaluable로 대체, pending 계산은 application이 담당
2026-07-22FE 설계(design-fe-web) 계약 요청 9건 판정 — 수용 7건(매칭 기준 조회 신설, 제외어·근무형태 DELETE 신설, 공고 목록 아이템 필드 확정, 지원 목록 필드 확정, /api/companiesbrokenSourceCount, re-evaluationsforce, allowedNextStatuses) / 부분 수용 1건(abnormal만, guardApplied 미채택) / 미채택 1건(전 회사 통합 공고 조회 — P1 보류)
2026-07-22규약 회색지대 애그리게이터 4종 P0 편입 (사용자 승인) — 원티드·리멤버·잡코리아·서핏 어댑터를 P0로 추가(BE-26·27·28·29). JobPlatform에 4종 추가(전부 AGGREGATOR). SourceCapabilities.supportsIncrementalSince(리멤버 min_updated_at 증분) + CollectionDescriptor.Aggregator.lastSuccessfulCollectionAt 추가. 규약 강제 3중 방어 명시(허용 경로 화이트리스트로 잡코리아 금지 경로 구조적 차단 / 1일 1회 = DB 유니크 / per≤50·지연·UA = Executor). 미검증 항목(원티드 필드·서핏 speciality)은 구현 티켓 첫 단계 실호출 확정. BE-25(수집)·BE-24(dedup)는 애그리게이터 일반을 다뤄 4종 자동 포함. 스키마 변경은 platform 코드값 4종 승격뿐(SPI 일반화 덕)
2026-07-22FE 2차 계약 3건 + DBA 보강 1건 — ① 애그리게이터 소스 목록/삭제 API 신설(GET·DELETE /api/aggregator-sources, 소프트 삭제 disabled_at) ② alternateSources[] shape 확정({jobSourceId, platform, postingUrl, isRepresentative}, 대표 포함 그룹 전체) ③ 직무 카테고리 코드↔라벨 API 노출(GET /api/aggregator-sources/categories, supportedCategories() SPI 추가로 FE 상수 드리프트 차단) ④ 애그리게이터 search_keyword빈 문자열 '' 저장(유니크 성립 — idempotency_key의 NULL 규칙과 반대) ⑤ dedup CLOSED 포함 정책 확정(CLOSED도 그룹 유지, 대표 선정 tie-break에 OPEN>CLOSED 추가)
2026-07-22애그리게이터 수집 P0 편입 (FR-60~69) — ① 어댑터 SPI 일반화(CollectionDescriptor sealed로 회사 종속형/애그리게이터형 2종, SourceType, SourceCompliancePolicy로 FR-69 규약 강제, ChangeSignature.FieldHash, RawJobPosting.sourceCompanyName) ② 크로스 소스 중복 도메인(방안 9 — dedupKey + 자기참조 representativeId + 08:30 dedup 배치, 대표 선정 직접>애그리게이터) ③ 관심/발견 회사 축(companyOrigin WATCHED/DISCOVERED) + 발견 회사 자동 등록 + 승격 ④ 알림 분기(관심=개별 / 발견=DAILY_DIGEST 1건) ⑤ 마감일 정규화·변경 감지 확장(사람인 연도없는 ~MM/DD·문자열, 점핏 alwaysOpen·필드해시·상세로 회사 식별) ⑥ NFR-2 재조정(09:00 이전 완료, 시간 예산 점검) ⑦ P0 어댑터는 사람인·점핏 2종 한정(규약 청정), 나머지 6종은 P1 확장 지점 ⑧ 스케줄러 4→6종, 신규 API 4개, DBA 파급 요약 정리. 기존 FR-1~59 설계 무변경