타깃 공고 알림 및 지원 히스토리 TDD
Background
근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-prd.md
근거 사전 조사: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-채용소스-조사-브리프.md (실제 HTTP 호출로 검증됨)
1인 사용자가 지정한 회사의 채용 공고를 매일 자동 수집해 디스코드로 알리고, 지원 이력을 상태 전이·면접 회차 단위로 추적하는 개인용 도구입니다. 대상 레포 /Users/biuea/recruitment-application은 README.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) | id | updated_at | 필드 없음 | metadata[] 보유 (①+② 가능) | UTF-8 |
배민형 (career.woowahan.com/w1/recruits) | recruitSeq | recruitVersion | recruitEndDate (상시 = 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값 객체 +ChangeSignaturesealed 타입으로 표현합니다. - 마감일 정규화는 어댑터 책임입니다. 도메인은
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_seen → firstSeenAt/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 서버 단일 프로세스 + 인프로세스 @Scheduled | Spring 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 내부에서 수행하고, 429는 Retry-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건) | 채택. companyOrigin은 registrationType(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) |
JobPostingCollectionScheduler | 00:00 회사 종속형 수집 트리거 | 없음 (진입점) | @Scheduled(cron="0 0 0 * * *", zone="Asia/Seoul") | CollectJobPostingsUseCase |
AggregatorCollectionScheduler | 00:10 애그리게이터 수집 트리거 (회사 자동 등록 포함) | 없음 | @Scheduled(cron="0 10 0 * * *") | CollectAggregatorPostingsUseCase |
JobPostingDeadlineSweepScheduler | 00:30 마감일 경과 판정 트리거 | 없음 | @Scheduled(cron="0 30 0 * * *") | CloseDeadlinePassedJobPostingsUseCase |
JobPostingDeduplicationScheduler | 08:30 크로스 소스 중복 판정·대표 선정 트리거 | 없음 | @Scheduled(cron="0 30 8 * * *") | DeduplicateJobPostingsUseCase |
JobPostingEvaluationSweepScheduler | 08:50 미평가 공고 보정 트리거 | 없음 | @Scheduled(cron="0 50 8 * * *") | EvaluatePendingJobPostingsUseCase |
NotificationDispatchScheduler | 09:00 알림 발송 트리거 (개별 + 일일 요약) | 없음 | @Scheduled(cron="0 0 9 * * *") | DispatchPendingNotificationsUseCase |
| company 컨텍스트 | 회사·소스 등록, 소스 탐색, 애그리게이터 소스 등록·발견 회사 자동 등록·승격 | companies(+company_origin), job_sources(+source_type·검색조건) | CompanyRepository, JobSourceRepository | JobSourceGateway(probe) |
| posting 컨텍스트 | 수집(2유형)·델타·마감 판정·소스 건강도, 수동 공고, 본문·태그 보관, 크로스 소스 중복 판정·대표 선정 | job_postings(+dedup_key·representative_id), job_posting_descriptions, job_posting_source_tags, job_posting_collection_runs, job_source_health | JobPostingRepository, JobPostingDetailRepository, CollectionRunRepository, SourceHealthRepository, JobSourceGateway | DomainEventPublisher |
| 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_arrangements | MatchCriteriaRepository, JobPostingMatchResultRepository | (없음 — 공고는 이벤트/ID로만 인지, 평가 입력은 application이 조합해 주입) |
| application 컨텍스트 | 지원 생성·상태 전이·면접 회차·히스토리 | job_applications, job_application_status_histories, job_application_interviews | ApplicationRepository, InterviewRepository | (없음) |
| notification 컨텍스트 | 발송 대상 산출·멱등·발송·실패 이력, 관심/발견 회사 분기(개별 vs 일일 요약) | notification_dispatches | NotificationDispatchRepository, DiscordWebhookGateway | 디스코드 웹훅 |
JobSourceGatewayImpl (infra) | 플랫폼 어댑터 레지스트리 | 없음 | JobSourceGateway 구현 | List<SourcePlatformAdapter> |
SourceRequestExecutor (infra 공통) | FR-69 규약 강제(UA·요청 지연·페이지 상한·허용 경로) — 모든 어댑터가 이것을 통해서만 외부 호출 | 없음 | (어댑터 내부 협력자) | RestClient |
GreenhouseJobSourceAdapter | Greenhouse 목록·후보 탐색·정규화 | 없음 | SourcePlatformAdapter | SourceRequestExecutor |
WoowahanJobSourceAdapter | 배민형 내부 API 수집·정규화 | 없음 | SourcePlatformAdapter | SourceRequestExecutor |
IncruitJobSourceAdapter | 인크루트 HTML 파싱(EUC-KR)·상세 전건 조회 | 없음 | SourcePlatformAdapter | SourceRequestExecutor (Jsoup) |
SaraminAggregatorAdapter | 사람인 HTML 검색 조건 수집·마감일 정규화(~MM/DD·문자열) | 없음 | SourcePlatformAdapter | SourceRequestExecutor (Jsoup) |
JumpitAggregatorAdapter | 점핏 JSON 수집·필드 해시·상세로 회사 식별 | 없음 | SourcePlatformAdapter | SourceRequestExecutor |
WantedAggregatorAdapter | 원티드 JSON 수집 (미검증 필드 실호출 확정) | 없음 | SourcePlatformAdapter | SourceRequestExecutor |
RememberAggregatorAdapter | 리멤버 JSON POST 수집·min_updated_at 증분·per≤50 | 없음 | SourcePlatformAdapter | SourceRequestExecutor |
JobkoreaAggregatorAdapter | 잡코리아 JSON-LD·본문 해시·허용 경로 화이트리스트만 | 없음 | SourcePlatformAdapter | SourceRequestExecutor (Jsoup) |
SurfitAggregatorAdapter | 서핏 JSON POST·필드 해시 (speciality 포맷 실호출 확정) | 없음 | SourcePlatformAdapter | SourceRequestExecutor |
DiscordWebhookGatewayImpl | 웹훅 발송 + 지수 백오프 3회 | 없음 | DiscordWebhookGateway 구현 | DiscordWebhookClient |
FeatureFlagGatewayImpl | 런타임 플래그 조회 | feature_flags | FeatureFlagGateway 구현 | 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 + jobPostingTitle | FR-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으로 매핑하므로 넣지 않습니다. -
applicationId를applied: boolean대신 ID로 내리는 이유: FE가 지원 상세로 바로 이동할 수 있고,null이 곧 “미지원”이라 FR-42의 표현과 1:1 대응합니다. -
workArrangement는 대표 1건만 내립니다(정렬 기준과 동일한work_arrangement_sort_rank산출 근거). 근거 스니펫과 복수 근거는 상세 응답에만 포함합니다 — 목록 응답을 무겁게 만들지 않기 위함입니다. -
목록은 매칭·확신도로 필터링하지 않습니다(FR-25·33).
matched=false도 전부 내려가며, 접힘·정렬은 FE의 표현 책임입니다.
정렬 규칙: sort=WORK_ARRANGEMENT는 work_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 전용, 외부 미노출).
클래스 역할 정의
도메인 모델
| 클래스명 | 컨텍스트 | 역할 | 핵심 책임 |
|---|---|---|---|
Company | company | 관심/발견 회사 | createWatched(name, registrationType) / createDiscovered(name)(애그리게이터 자동 등록) / promoteToWatched() / isAutoCollectable() / isWatched() |
CompanyOrigin | company | 회사 출처 축 | WATCHED(사용자 직접) / DISCOVERED(애그리게이터 자동) — registrationType과 직교 |
JobSource | company | 회사의 데이터 출처 | markSeeded() / isSeeded() / disable()(disabledAt 기록) / isActive(). 애그리게이터 소스는 searchCriteria 보유·companyId 없음 |
JobPosting | posting | 공고 (수집·수동 공통) | createCollected(raw, companyName, seeded) / createManual(cmd) / refreshFrom(raw): JobPostingChange / markMissed() / markFound() / closeByDeadline() / reopen() / isDeltaTarget() / isPermanentPosting() / computeDedupKey(): String(정규화 회사명+제목, 쿼리 없음) / linkTo(representativeId) / markRepresentative() / isRepresentative() |
JobPostingStatus | posting | 공고 상태 | OPEN/CLOSED + canTransitTo() |
JobPostingOrigin | posting | 수집 출처 | COLLECTED/MANUAL — 델타 모수 판별 |
JobPostingDedupKey | posting | 중복 판정 키 값 객체 | of(companyName, title) — 정규화(소문자·공백/기호 제거·NFKC) 후 회사|제목. 마감일 30일 초과 차이는 별개 공고로 분리(보조 근거) |
JobPostingDescription | posting | 공고 본문(JD) — 공고당 1건, 최신 스냅샷만 | of(body) / charLength() (급감 시 파서 고장 의심 지표) |
JobPostingSourceTag | posting | 소스 구조화 태그 1건 (Greenhouse metadata[]) | of(rawValue) — 원문 + 정규화 값 보유 |
JobPostingCollectionRun | posting | 소스×일자 수집 회차 | succeed(counts) / fail(reason) / isAbnormal() (실패 또는 fetched=0) |
JobSourceHealth | posting | 소스 건강도 | recordNormal() / recordAbnormal(): Boolean(고장 진입 여부) / isBroken() / currentFailureEpisode() |
MatchCriteria | matching | 매칭 기준 집합 (그룹·제외어·근무형태 키워드 + revision) | matches(title): KeywordMatchOutcome |
JobKeywordGroup | matching | 동의어 그룹 | containsAnySynonymIn(normalizedTitle) |
NormalizedKeyword | matching | 정규화된 키워드 값 객체 | of(raw) — 소문자·공백/하이픈 제거·NFKC |
JobPostingMatchResult | matching | 공고별 평가 결과 | evaluate(...) / isNotifiable() / topConfidence() / sortRank() (1=CONFIRMED, 2=LIKELY, 99=그 외) / matchedGroupIds() |
EvaluationTarget | matching | 평가 입력 값 객체 (제목·태그·본문) | application이 posting DomainService 조회 결과를 매핑해 주입 — matching은 posting을 모른다 |
WorkArrangementEvidence | matching | 근무형태 근거 1건 | keyword·evidenceStage·confidence·snippet |
WorkArrangementConfidence | matching | 확신도 | CONFIRMED/LIKELY/INFERRED/UNKNOWN + isSortable() (FR-30) |
Application | application | 지원 기록 | create(cmd): Pair<Application, ApplicationStatusHistory> (생성 이력 (null → APPLIED) 1건을 함께 반환) / transitTo(next, memo): ApplicationStatusHistory / isTerminal() / allowedNextStatuses() |
ApplicationStatus | application | 지원 상태 8종 | canTransitTo(next) / isTerminal() / allowedNextStatuses(): Set<ApplicationStatus> — 전이 규칙 캡슐화. API 응답의 allowedNextStatuses가 이 값을 그대로 노출해 규칙 SSOT를 BE에 유지합니다 |
ApplicationStatusHistory | application | 전이 이력 | of(previous, next, memo) — previous는 생성 이력에서만 null |
Interview | application | 면접 회차 | create(round, label, scheduledAt) / recordResult(result) / reschedule(at) |
NotificationDispatch | notification | 발송 시도 1건 | attempt(target) / markDelivered(): 멱등키 부여 / markRejected(reason) / isExpired() |
NotificationType | notification | 알림 종류 | NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST(발견 회사 매칭 신규 묶음) (P0) / DEADLINE_D1, PERMANENT_REMINDER (P1 확장 지점) |
서비스 클래스
| 클래스명 | 역할 | 입력 → 출력 | 의존 |
|---|---|---|---|
JobPostingCollectionDomainService | 소스 1개 회차의 수집·델타·가드·건강도 갱신 (2유형 공통) | CollectionDescriptor → CollectionRunResult | JobSourceGateway, 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 | 수동 공고 등록 | RegisterManualJobPostingCommand → JobPosting | JobPostingRepository, CompanyRepository(ID 검증은 company 컨텍스트 조회 UseCase 경유) |
JobPostingQueryService | 회사별 공고·정렬 조회 read model | 조회 조건 → 뷰 모델 | 조회 전용 Repository |
MatchCriteriaDomainService | 매칭 기준 CRUD + revision 증가 | 키워드 명령 → MatchCriteria | MatchCriteriaRepository |
JobPostingEvaluationDomainService | 직무 매칭 + 근무형태 추출 평가 | (jobPostingId, title, tags, body) → JobPostingMatchResult | MatchCriteriaRepository, JobPostingMatchResultRepository, JobKeywordMatcher, WorkArrangementDetector |
JobKeywordMatcher | 정규화 후 동의어/제외어 판정 (순수) | (title, criteria) → KeywordMatchOutcome | 없음 |
WorkArrangementDetector | ①구조화 필드 + ②JD 본문 근거 추출, 부정어 무효화 (순수) | (tags, body, keywords) → List<WorkArrangementEvidence> | 없음 |
CompanyRegistrationDomainService | 회사·소스 등록(후보 0건 시 MANUAL_ONLY), 애그리게이터 소스 등록, 발견 회사 승격 | RegisterCompanyCommand / RegisterAggregatorSourceCommand / promote(companyId) → Company/JobSource | CompanyRepository, JobSourceRepository |
JobSourceDiscoveryDomainService | slug 프로빙·URL 직접 입력 후보 탐색 (회사 종속형만) | (companyName, siteUrl?) → List<JobSourceCandidate> | JobSourceGateway |
ApplicationDomainService | 지원 생성·상태 전이·히스토리 적재 | 명령 → Application | ApplicationRepository |
InterviewDomainService | 면접 회차 추가·결과 기록 | 명령 → Interview | InterviewRepository |
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=true | GET career.woowahan.com/w1/recruits?page=N&size=100 | GET recruit.incruit.com/{slug}/ + 공고별 상세 |
sourceJobId | id | recruitSeq | 공고 URL의 job id (/job/2507310011) |
changeSignature | UpdatedAt(updated_at) | SourceVersion(recruitVersion) | BodyHash(sha256(정규화 본문)) |
| 마감일 정규화 (FR-13) | 필드 없음 → 항상 null | recruitEndDate가 9999-* 또는 2999-* 센티널이면 null, 그 외 파싱 | 목록 2026.07.16 00:00 ~ 2026.07.30 17:00의 종료 시각 파싱, 파싱 실패 시 null |
| 구조화 태그 | metadata[] → structuredTags (①근거, CONFIRMED) | emptyList() (②만) | emptyList() (②만) |
| 본문 확보 | content 필드 (목록 응답에 포함) | 상세 엔드포인트 존재 여부 미검증 → 목록의 recruitName+요약으로 시작, 상세 확인되면 확장 | 상세 페이지 전건 조회 |
| 인코딩 | UTF-8 | UTF-8 | EUC-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, 무인증) |
sourceJobId | rec_idx | id |
changeSignature | UpdatedAt(수정일 YY/MM/DD) | FieldHash(sha256(title+deadline+company)) — 변경 감지 필드 없음(FR-68) |
| 마감일 정규화 (FR-67) | ~MM/DD 연도 없음 → 연도 추론 / 채용시·상시채용 문자열 → null | alwaysOpen=true → null(closedAt 무시) / 그 외 closedAt ISO 파싱 |
| 회사 식별 | 목록에 회사명 존재 → sourceCompanyName | 목록 companyName 존재 → sourceCompanyName (companyIdentifierInDetailOnly=false). |
| 규약 (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 필요) |
sourceJobId | id | id | data-gno / JSON-LD identifier | id |
changeSignature | FieldHash(기본) — 실호출로 조정 | FieldHash (응답에 건별 updated_at 없음) | BodyHash(상세 본문) — 등록일만 제공 | FieldHash (변경 감지 없음) |
| 마감일 정규화 (FR-67) | 실호출로 확정 | ends_at + explicit_due(false+null=상시 → null) | JSON-LD validThrough ISO / 상시채용 문자열·validThrough≈+1년 → null | is_closed 불리언 + close=“상시채용” → null / ads_end_at |
| 증분 수집 | — | min_updated_at 증분 필터(supportsIncrementalSince=true) — 전량 재수집 대신 마지막 성공 수집 시각 이후만. 단 건별 updated_at 부재로 변경 판정은 여전히 FieldHash | — | — |
| 규약 (FR-69) | robots가 WAF 403이라 판단 불가 → 보수적: 1일 1회·요청 지연·명시 UA | per 최대 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중 방어:
SourceCompliancePolicy.allowedPathPrefixes화이트리스트 — 잡코리아는["/recruit/joblist", "/Recruit/GI_Read"]만.SourceRequestExecutor가 이 prefix에 없는 경로 호출을 던져서 차단하므로 키워드 검색 경로를 어댑터가 실수로 때릴 수 없습니다.- 1일 1회 —
unique(job_source_id, run_date)가 DB 레벨에서 이미 강제. 어댑터가 하루에 여러 번 수집을 시도해도 두 번째 회차 INSERT가 거부됩니다. - 페이지 상한·요청 지연·UA —
SourceRequestExecutor가 정책값으로 강제(리멤버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_runs의 unique(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 / 429 | DiscordWebhookGatewayImpl 내부에서 지수 백오프 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회 연속) | CLOSED | closedReason=NOT_FOUND_TWICE |
OPEN × 마감일 경과 (경량 배치) | CLOSED | closedReason=DEADLINE_PASSED. 상시채용(deadlineAt=null)은 대상 제외 (FR-17) |
OPEN/CLOSED × 비정상 회차(실패·0건) | 변화 없음 | 소스 가드 — 판정 자체를 수행하지 않음 (FR-15) |
CLOSED × 재발견 | OPEN | reopenedAt 기록, 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_DECLINED는 OFFERED 이후만 |
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_postings | unique(job_source_id, source_job_id) | 공고 식별 (FR-11) |
job_posting_collection_runs | unique(job_source_id, run_date) | 하루 1회 실행 보장 |
job_source_health | unique(job_source_id) | 소스당 1행 |
job_posting_descriptions | unique(job_posting_id) | 본문 최신 1건만 유지 |
job_posting_source_tags | unique(job_posting_id, tag_value) | 태그 중복 방지 |
job_posting_match_results | unique(job_posting_id) | 공고당 평가 결과 1건 |
job_posting_matched_keyword_groups | unique(job_posting_match_result_id, job_keyword_group_id) | 매칭 그룹 N:M 해소 |
job_posting_work_arrangements | unique(job_posting_id, work_arrangement_keyword_id) | 근거 중복 방지 |
job_applications | unique(job_posting_id) | 공고당 지원 1건 (“미지원 = 미존재”) |
job_application_interviews | unique(job_application_id, round_number) | 회차 중복 방지 |
notification_dispatches | unique(idempotency_key) (nullable) | 성공 기준 멱등 (FR-38·62 일일 요약 포함) |
job_sources | unique(platform, source_slug) (회사 종속형) / 애그리게이터는 unique(platform, search_category_code, search_keyword) | 소스 중복 등록 방지 |
job_postings | idx(dedup_key) (조회) | 크로스 소스 그룹핑 (FR-63) |
companies | unique(name) | 회사 중복 (자동 등록 멱등의 근거) |
DBA 파급 요약 (design-db 갱신 요청 — 애그리게이터 편입)
| 테이블 | 변경 | 근거 |
|---|---|---|
companies | company_origin VARCHAR(20) NOT NULL 추가 (WATCHED/DISCOVERED) | FR-61. registration_type과 직교 |
job_sources | source_type VARCHAR(20) NOT NULL 추가 / company_id nullable로 변경(애그리게이터는 NULL) / search_category_code VARCHAR(100) NULL / search_keyword VARCHAR(200) NULL 추가 / source_slug nullable | FR-60. 유니크 제약이 유형별로 갈림 |
job_postings | representative_id BIGINT NULL(자기참조) / dedup_key VARCHAR(255) NULL + idx(dedup_key) 추가. company_id는 자동 등록 회사로 채워짐 | FR-63·64 |
notification_dispatches | notification_type·target_type 코드값에 DAILY_DIGEST 추가 | FR-62 |
| 코드값 사전 | platform에 SARAMIN·JUMPIT·WANTED·REMEMBER·JOBKOREA·SURFIT(P0 승격, 워크넷·잡알리오만 P1 예약), change_signal_kind에 FIELD_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-convention이 applications 단독 명사를 금지 예시로 직접 지목합니다. 도메인 클래스는 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_date는 DATE + KST 업무 일자), 전 컬럼 COMMENT 필수. 그 외 전 컬럼은 UTC 저장(hibernate.jdbc.time_zone=UTC).
Testing Plan
프레임워크는 Kotest(BehaviorSpec/DescribeSpec) 전 레이어, RED → GREEN → REFACTOR 순서를 강제합니다.
| 레벨 | 대상 | 도구 | 핵심 시나리오 |
|---|---|---|---|
| domain | JobPosting, JobSourceHealth, ApplicationStatus, JobKeywordMatcher, WorkArrangementDetector, NotificationDispatch | Kotest + MockK | 상태 전이 허용/거부 전수, 미발견 카운터, 재오픈, 부정어 무효화, 멱등 키 생성 |
| application | 각 UseCase | Kotest + MockK (DomainService 모킹) | 오케스트레이션 순서, 트랜잭션 경계, 플래그 OFF 시 no-op |
| infrastructure | 어댑터 9종(회사 3 + 애그리게이터 6), SourceRequestExecutor, RepositoryImpl, DiscordWebhookGatewayImpl | Kotest + Testcontainers(MySQL) + MockWebServer + 고정 fixture | 실제 응답 fixture 파싱, EUC-KR 디코딩, 사람인 연도없는 마감일 추론, 점핏 alwaysOpen·필드해시, 리멤버 per≤50·증분, 잡코리아 허용 경로 화이트리스트 강제(금지 경로 호출 시 예외), 규약 정책 강제(UA·지연·페이지 상한), 백오프 재시도, 유니크 제약 |
| presentation | ApiController, Scheduler(6종), EventListener | Kotest + MockMvc + Testcontainers | 요청/응답 계약, 스케줄 진입점이 UseCase를 1회 호출 |
| scenario | E2E | Kotest + Testcontainers | 회사 등록 → 시딩 → 2회차 수집 → 매칭 → 09:00 발송 → 지원 → 상태 전이 → 히스토리 조회 / 애그리게이터 수집 → 회사 자동 등록 → 크로스 소스 중복 → 대표 선정 → 일일 요약 |
반드시 커버할 실패 경로
| # | 시나리오 | 기대 |
|---|---|---|
| 1 | 소스 수집이 예외로 실패 | 그 소스의 어떤 공고도 CLOSED되지 않고 미발견 카운터가 증가하지 않는다 |
| 2 | 소스가 0건 반환 | 1과 동일하게 판정 skip + 비정상 일수 증가 |
| 3 | 정상 회차 미발견 1회 → 2회 | 1회차 OPEN 유지, 2회차 CLOSED |
| 4 | 3일 연속 비정상 | 소스 고장 알림 1건 발송, 복구 후 재고장 시 회차 증가로 재알림 |
| 5 | 수동 등록 공고가 있는 상태로 수집 | 수동 공고는 미발견 카운터가 증가하지 않는다 |
| 6 | 웹훅 3회 실패 후 다음 배치 | 같은 대상이 재발송되고, 성공 시에만 멱등 키가 부여된다 |
| 7 | 같은 대상 연속 2회 발송 시도 | 두 번째는 skip되어 중복 발송 0건 |
| 8 | EUC-KR 인크루트 fixture 파싱 | 한글 깨짐 0건 (한국어 제목 정확 일치) |
| 9 | Greenhouse 마감일 없음 / 배민 9999-12-31 | 둘 다 deadlineAt == null로 정규화되어 상시채용으로 취급 |
| 10 | ”재택근무 불가” 본문 | 근무형태 근거로 채택되지 않고 UNKNOWN |
| 11 | 종료 상태에서 전이 시도 | 도메인 예외, 이력 미생성 |
| 12 | OFFERED → WITHDRAWN | 거부 (FR-44) |
| 13 | 시딩 회차 공고 | 알림 대상에서 제외 |
| 14 | CLOSED 공고 재발견 | 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) |
| 30 | dedup 플래그 OFF | 그룹핑 안 하고 전부 대표 취급 (무중단 롤백) |
Observability
별도 모니터링 스택(Prometheus·Grafana)은 도입하지 않습니다 — 1인용 로컬 Docker 환경(NFR-7)에서 운영 비용이 가치를 넘습니다. 대신 DB 테이블 자체를 관측 대상으로 삼고 조회 API로 노출합니다.
| 관측 대상 | 수단 | 확인 방법 |
|---|---|---|
| 수집 성공률 (Success Metrics: 30일 롤링 95%) | job_posting_collection_runs | GET /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_dispatches | GET /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 — 무중단 배포
빈 레포의 최초 배포이므로 “기존 트래픽 무중단” 요건은 완화되지만, 위험한 기능을 마지막에 켜는 단계적 활성화와 모든 단계의 롤백 지점은 그대로 적용합니다.
배포 순서 (스키마 먼저 → 코드)
| 단계 | 내용 | 전환 조건 | 롤백 |
|---|---|---|---|
| 0 | MySQL 컨테이너 기동 → 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 기록(소프트 비활성화) |
| 3 | 2회차 이상 정상 수집 확인 (델타 비교가 실제로 동작하는지) | 소스별 SUCCESS 2회 + 미발견 카운터가 의도대로 증감 | 단계 4로 진행하지 않음 |
| 4 | notification.discord-dispatch=true (플래그 UPDATE 1건, 재기동 없음) | 09:00 배치에서 신규 공고 알림이 디스코드에 도착 | 플래그 false — 즉시 발송 중단 |
| 5 | posting.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 변경 감지 시 즉시 |
| 7 | posting.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 | 배민 상세 조회 엔드포인트 존재 여부 (미검증) | 목록 필드만으로 시작. 본문 미확보 시 근무형태는 ②근거 부재 → UNKNOWN | BE-08에서 확인. 존재하면 requiresDetailFetch=true로 능력 선언만 바꾸면 됩니다 |
| 5 | FR-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. JobPostingQueryService에 companyId를 nullable로 받는 오버로드만 추가하면 되어 확장 비용이 낮습니다 |
| 8 | collection-runs의 guardApplied 필드 (FE 요청 #9) | 부분 수용. abnormal만 내리고 guardApplied는 두지 않습니다 — 소스 가드는 “비정상 회차이면 마감 판정을 건너뛴다”는 규칙이라 두 값이 항상 동일합니다. 같은 값을 두 필드로 내리면 한쪽 계산만 바뀌었을 때 모순이 생깁니다. FE는 abnormal=true일 때 “마감 판정 제외됨” 문구를 표시합니다 | 가드 조건이 비정상 회차 외 요인으로 확장되면 그때 분리 |
| 9 | P0 애그리게이터를 사람인·점핏 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-22 | DB 설계(design-db) 정합 반영 7건 — ① 테이블 명명 job_applications·job_application_status_histories·job_application_interviews(도메인 클래스명은 유지) ② job_sources.disabled_at ③ job_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-22 | FE 설계(design-fe-web) 계약 요청 9건 판정 — 수용 7건(매칭 기준 조회 신설, 제외어·근무형태 DELETE 신설, 공고 목록 아이템 필드 확정, 지원 목록 필드 확정, /api/companies의 brokenSourceCount, re-evaluations의 force, 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-22 | FE 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 설계 무변경 |