지원 관리 확장 TDD (FR-70~97)

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-prd.md (FR-70~97, 2026-08-08 편입분)

기존 설계 문서(이 문서가 이어붙는 대상):

  • 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — FR-1~69 설계 (도메인 모델·계약의 SSOT)
  • 20260722-공고알림앱-design-db.md — 테이블 20개 정의
  • 20260730-확장-안전-공고수집-배치-tdd.md — 수집 v2(durable queue) 확장

FR-169로 만든 앱은 “공고를 놓치지 않고 수집·알림한다”까지를 해결했습니다. 이번 범위 FR-7097은 그 뒤의 문제를 다룹니다 — 수집된 공고를 사용자가 어떻게 관리하고, 지원한 뒤 무엇으로 추적하는가. 구체적으로 ① 앱을 외부에 노출하기 위한 인증·웹훅 신뢰 기반, ② 공고에 사용자 관리 상태를 붙이는 관심 목록, ③ 지원 서류 버전과 지원 추천도, ④ 메일 연락 이벤트를 지원 상태에 반영하는 확인형 흐름입니다.

이 문서는 기존 TDD를 대체하지 않습니다. 기존 도메인 모델(JobPosting·ApplicationStatus·NotificationDispatch·MatchCriteria)의 계약을 그대로 전제하고, 확장이 필요한 지점만 명시적으로 기술합니다.

Overview

무엇을 — FR-70~97 (28건)을 3단계 독립 배포 단위로 나눠 설계합니다.

단계FR핵심 산출
1 — 기반FR-70~80세션 인증 + HMAC 웹훅 검증 기반 + dedup 그룹 영속화 + 관심 목록(watchlist) 컨텍스트 + 교차 회사 목록 API + 대시보드 + 담당자
2 — 서류·추천도FR-8185, 9596문서 계열·버전 + 회사 비종속 로컬 저장 + 이력서 프로필 확정 + 규칙 기반 지원 추천도 4축
3 — 연동FR-86~94, 97필드별 가중치 매칭 + 소급 재평가 + 소스 상태 6종 + 연락 이벤트 확인형 반영 + 알림 2종 + 가중치 수정·상한 해제

— 세 단계는 순방향 의존만 가집니다. 2단계는 1단계의 관리 상태(재평가 대상 한정)를, 3단계는 1단계의 HMAC 검증과 2단계의 추천도 모델을 씁니다. 역방향 의존이 없어 각 단계가 독립 배포 단위로 성립합니다.

어떻게 — 서버 토폴로지는 기존 단일 API 서버 프로세스를 유지합니다(NFR-8). 신규 프로세스·메시지 브로커·캐시 계층을 도입하지 않고, 외부 노출만 사이드카 컨테이너(Cloudflare Tunnel)로 처리합니다. 신규 바운디드 컨텍스트는 5개(auth·webhook·watchlist·document·recommendation·contact 중 판단 결과 확정분)를 추가하고, 기존 컨텍스트(posting·matching·application·notification·company)는 확장만 합니다.

Terminology

용어정의
관리 상태 (watch status)사용자가 공고에 붙이는 INTERESTED·PLANNED·EXCLUDED 3종. 지원 완료는 상태값이 아니라 Application 존재로 파생 (FR-73)
중복 그룹 (dedup group)dedup_key + 마감일 30일 클러스터로 묶인 공고 집합. 관리 상태의 귀속 단위 (FR-74)
그룹 정체성 (group identity)매 배치 재계산되는 그룹을 이전 회차 그룹과 이어붙이는 규칙. 멤버 교집합 최대 그룹을 재사용한다
문서 계열 (document series)같은 이력서/자소서의 서로 다른 버전을 묶는 단위. 버전 번호는 계열별 연속 증가 (FR-81)
현재 프로필 (confirmed profile)사용자가 검토·확정한 이력서 프로필. 확정 전 초안은 추천도 계산에 쓰이지 않는다 (FR-95)
지원 추천도 (recommendation)4축 규칙 기반 0~100점 + 등급. 외부 LLM 호출 없음 (FR-96)
평가 기준 버전 (recommendation criteria revision)프로필 확정·축 가중치 변경으로 증가하는 버전. match_criteria_revisions(키워드 매칭)와 별개 축 (FR-97)
판단 불가 (not judgeable)그 축의 입력이 없어 점수를 낼 수 없는 상태. 남은 축 비중으로 정규화한다 (FR-96 ②③)
연락 이벤트 (contact event)사용자 메일함/문자에서 전달된 채용 관련 수신 이벤트 1건 (FR-89)
확인형 반영 (confirmed application)연락 이벤트가 상태를 자동 변경하지 않고, 사용자가 반영을 선택해야만 전이하는 흐름 (FR-90)
필드별 가중치 매칭제목·구조화 태그·JD 본문 각각에 가중치를 두고 합산이 임계치 이상일 때 매칭 성립 (FR-86)
소스 레지스트리 상태소스의 6종 운영 상태(DISCOVERED·ACTIVE·TRANSIENT_FAILURE·ACCESS_RESTRICTED·UNSUPPORTED·DISABLED) (FR-88)

Define Problem

AS-IS

실제 코드를 읽고 기술합니다. 인용은 파일:라인 형식입니다.

A. 인증이 없다 — 전 API 무인증 공개

  • build.gradle.kts:32-77spring-boot-starter-security없습니다. 의존성 목록 전체에 security 계열이 0건입니다.
  • FE 클라이언트는 인증을 아예 전제하지 않습니다 — web/src/api/client.ts:20-47apiRequest()credentials: 'omit'로 호출하고, 주석 client.ts:19가 “인증 헤더 없음(NFR-7 — 로컬 Docker 전용)“이라고 명시합니다.
  • nginx는 /api/app:8080으로 그대로 프록시합니다(web/nginx.conf:10-15). 인증·rate limit이 없습니다.
  • docker-compose.yml:43-448080:8080, :54-553000:80을 호스트에 공개합니다. 외부 노출 수단은 없습니다.

→ FR-71이 요구하는 터널 노출을 그대로 켜면 모든 API가 인터넷에 무인증 공개됩니다.

B. 관리 상태를 붙일 대상이 없다

  • job_postings에 사용자 관리 상태 컬럼이 없습니다(V202607220000__baseline_schema_and_feature_flags.sql:69-102, 컬럼 24개 전량이 수집·마감·중복 판정용).
  • 중복 그룹은 영속화되지 않습니다. JobPostingDeduplicationDomainService.deduplicate()가 매 실행 findAllWithDedupKey() 전량을 다시 그룹핑하고(JobPostingDeduplicationDomainService.kt:37-44) 대표만 representative_id 자기참조로 남깁니다(:50-65). 그룹 자체는 메모리에만 존재합니다.
  • 마감일 클러스터는 splitByDeadlineGap()이 매 실행 계산합니다(:90-107, DEADLINE_GAP_DAYS=30, :111). 멤버가 늘면 클러스터 경계가 이동하므로 자연 키를 만들 수 없습니다.

→ 관리 상태를 어디에 붙일지(개별 공고 / 대표 / 그룹)와, 그룹을 어떻게 안정적으로 식별할지가 이번 설계의 첫 번째 난제입니다.

C. MANUAL 공고는 dedup 후보가 아니다

  • JobPosting.createManual()dedupKey = null로 생성합니다(JobPosting.kt:279).
  • dedup 후보 조회는 findAllWithDedupKey().filter { it.isDeltaTarget() }이고(JobPostingDeduplicationDomainService.kt:37), isDeltaTarget()origin == COLLECTED && !accessRestricted입니다(JobPosting.kt:153).
  • 즉 MANUAL 공고는 dedupKey가 없어서 1차로 걸리고, origin 필터로 2차로도 걸립니다.
  • 대표 선정 tie-break는 4단입니다(:71-75): sourceType → status → firstSeenAt → PK. sourcePriority()requireNotNull(jobSourceId)를 강제하므로(:79) jobSourceId=null인 MANUAL이 들어오면 예외가 납니다.

→ FR-74가 MANUAL에도 dedup_key를 요구하므로, 후보 선정 필터와 tie-break를 함께 고쳐야 합니다.

D. 교차 회사 공고 목록이 없고, 지금 구조로는 만들 수 없다

  • 공고 목록 API는 회사 단위뿐입니다 — GET /api/companies/{companyId}/job-postings (JobPostingApiController.kt:35-41). 파라미터는 postingStatus·sort 2개, 페이지네이션 파라미터가 없습니다.
  • 응답도 봉투가 없습니다 — JobPostingListResponse(val jobPostings: List<JobPostingListItemResponse>) (application/posting/JobPostingListResponse.kt:3). totalCount·page·hasNext가 없습니다.
  • 조회는 SQL에 company_id = ? 하나만 내리고 나머지는 메모리 필터입니다 — JobPostingRepositoryImpl.kt:71-75findByCompanyId(companyId)로 전량(비대표·본문·태그 eager 포함)을 로드한 뒤 .filter { it.representativeId == null }·상태 필터를 적용합니다.
  • 정렬도 매퍼에서 메모리 정렬입니다(JobPostingResponseMapper.kt:121-128) — 페이지네이션과 결합 불가능합니다.
  • QueryDSL은 build.gradle.kts:52-56에 의존성·kapt가 선언돼 있으나 src/main/kotlin 전역 사용처가 0건입니다.
  • 목록 경로에 N+1이 있습니다 — 항목마다 findAlternateGroupOf()(JobPostingResponseMapper.kt:76), companyDomainService.findJobSourceBy()(:86)를 호출하고, 매칭 결과 “배치” 조회는 실제로는 id 루프입니다(JobPostingMatchResultRepositoryImpl.kt:56-57).

→ FR-78(수천 건, P95 500ms, NFR-12)을 현 구조 위에 얹으면 성립하지 않습니다.

E. 매칭은 제목 단독이다

  • MatchCriteria.matches(title)가 제목만 정규화 비교합니다(MatchCriteria.kt:15-31). 주석 :14가 “제목 단독을 매칭 대상으로 판정한다(TDD Open Questions #2)“로 근거를 명시합니다.
  • JobKeywordMatcher.evaluate(title, criteria)도 제목만 받습니다(JobKeywordMatcher.kt:15-16).
  • 평가 실행부는 본문·태그를 갖고 있으나 근무형태 추출에만 씁니다 — JobPostingEvaluationDomainService.evaluateOne()jobKeywordMatcher.evaluate(target.title, ...)(:81)와 workArrangementDetector.detect(structuredTags, descriptionBody, ...)(:82-86)로 입력을 나눠 씁니다. EvaluationTarget에는 이미 structuredTags·descriptionBody가 있습니다(EvaluationTarget.kt:10-15).
  • 기존 TDD가 본문 매칭을 기각한 사유: “본문 매칭은 ‘백엔드와 협업’ 같은 문장에서 대량 오탐이 발생합니다”(20260722-...-tdd.md:1149).
  • 매칭 결과 저장에 점수·근거 필드가 없습니다job_posting_match_resultskeyword_matched TINYINT(1)·excluded_keyword_id만 가집니다(baseline...sql:227-228).

→ 입력(태그·본문)은 이미 흐르고 있고, 판정 규칙과 근거 저장만 없습니다.

F. 알림·소스 상태의 확장 지점

  • NotificationType은 3종입니다 — NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST (domain/notification/NotificationType.kt:7-11). TargetType도 3종 — JOB_POSTING, JOB_SOURCE, DAILY_DIGEST (TargetType.kt:6-10).
  • 멱등 키는 "${targetType.name}:$targetId:${notificationType.name}:$dispatchSequence"입니다(IdempotencyKey.kt:31). DAILY_DIGESTdispatchSequence에 KST 일자 서수를 넣어 하루 1건을 강제하는 선례가 있습니다(IdempotencyKey.kt:37-38, NotificationDispatchDomainService.kt:91-103).
  • 알림 배치는 09:00 하루 1회입니다(application.yml:46 notification-dispatch-cron: "0 0 9 * * *", NotificationDispatchScheduler.kt:23).
  • 소스 상태는 2축 + 별도 테이블입니다 — job_sources.seeded_at·disabled_at(baseline...sql:59-60)와 job_source_health(:152-164, consecutive_abnormal_days·broken_since·failure_episode). 6종 상태를 담을 컬럼이 없습니다.
  • 접근 제한은 공고 단위 플래그로만 존재합니다 — job_postings.access_restricted(:91). 소스 단위 상태가 아닙니다.

G. 지원·서류·연락의 부재

  • 지원 테이블 3종만 있습니다 — job_applications(:263-275), job_application_status_histories(:278-288), job_application_interviews(:291-303). 담당자·서류·연락 이벤트 테이블이 없습니다.
  • 면접 회차는 UNIQUE KEY uk_job_application_interviews_round (job_application_id, round_number)로 중복을 막습니다(:302) — FR-90의 “최대 회차 + 1” 규칙이 이 제약과 대응합니다.
  • 상태 전이 제약은 ApplicationStatus.TRANSITIONS가 SSOT이고 종료 4종은 emptySet()입니다(ApplicationStatus.kt:35-44).
  • 파일 처리 라이브러리가 없습니다 — build.gradle.ktspdfbox·poi 0건. jsoup:1.18.1만 있습니다(:59).
  • 파일 저장 경로 설정이 없습니다 — application.yml 전체(77줄)에 경로 프로퍼티가 없고, docker-compose.yml에 앱 볼륨 마운트가 없습니다(mysql 볼륨만, :13-14).

H. 아키텍처 제약 (설계가 지켜야 할 것)

  • LayeredArchitectureTest.kt:25-62가 4개 규칙을 강제합니다: domain↛infrastructure, domain↛(application·presentation), application↛infrastructure, 도메인 컨텍스트 간 상호 참조 금지(domain.common만 예외, :52-62).
  • 배치는 단일 스레드 직렬입니다 — SchedulingConfig.kt:15-23ThreadPoolTaskScheduler poolSize=1. NFR-16(배치 순차 실행)이 이미 구조로 보장됩니다.
  • Layer 1 이벤트 선례가 있습니다 — AsyncConfig.kt:29-40jobPostingEvaluationTaskExecutor(core 2 / max 4 / queue 50)와 presentation/matching/JobPostingEventListener. Kafka는 도입돼 있지 않습니다.
  • 피처 플래그는 DB 조회 방식입니다 — FeatureFlagGatewayImpl.kt:12-19가 매 호출 findByFlagKey(), 미정의 키는 false. 시드 4행 전부 enabled=0(baseline...sql:343-347).
  • 에러 계약은 ApiErrorResponse(code, message, existingCompanyId?) 단일 형태입니다(GlobalExceptionHandler.kt:34-39). 핸들러가 없는 예외는 catch-all 500으로 떨어집니다(:187-193).

TO-BE

AS-ISTO-BE
인증없음세션 쿠키 기반 단일 사용자 인증. 웹훅 경로만 HMAC로 별도 인증
외부 노출없음Cloudflare Tunnel 사이드카. 앱 코드 무변경
중복 그룹메모리 계산, 미영속job_posting_dedup_groups 영속화 + 멤버 교집합 기반 그룹 정체성 유지
MANUAL 공고dedup 제외dedup_key 부여 + 후보 편입 + tie-break에서 COMPANY_BOUND 동급
관리 상태없음watchlist 컨텍스트. 그룹 귀속 + 이력 + 우선순위·목표일·태그·메모
공고 목록회사 단위, 메모리 필터·정렬, 페이지 없음교차 회사 목록 신설. QueryDSL로 SQL 필터·정렬·페이지
매칭제목 단독제목 60 / 태그 35 / 본문 20, 임계치 50. 필드별 근거 저장
서류없음계열·버전 + 로컬 파일 저장(새니타이즈) + 지원 건 연결
추천도없음4축 규칙 기반 + 판단 불가 정규화 + 59점 상한·해제
연락없음웹훅 수신 → 후보 계산 → Discord 검토 요청 → 사용자 확인 반영
소스 상태2축 + health 테이블6종 레지스트리 상태 + 마지막 전이 시각

Architecture Benchmarking

동일 과제를 푼 제품·사례를 조사해 이번 설계에 반영/미반영을 판단했습니다.

제품/사례해결 방식참고할 패턴미참고 사유
Stripe Webhooks (docs, replay prevention)Stripe-Signature: t=<ts>,v1=<hmac> 헤더. 서명 대상은 "{timestamp}.{rawBody}". 기본 허용 오차 5분(300초). 타임스탬프가 서명 대상에 포함돼 변조 불가서명 포맷·서명 대상 정규화·5분 오차를 그대로 채택(FR-72와 수치 일치). raw body에 대해 HMAC을 계산하고 JSON 파싱 전에 검증하는 순서도 채택서명 시크릿 회전(다중 유효 시크릿) 미채택 — 단일 사용자·단일 발신자라 회전 절차를 수동으로 처리(PRD Open Question)
SendGrid Inbound Parse / Event Webhook (guide, idempotency)중복 전달을 정상 시나리오로 보고 수신 측이 이벤트 ID로 멱등 처리. 2xx를 빠르게 반환하고 처리는 비동기providerMessageId 유니크 제약으로 멱등(FR-92)을 채택. 2xx 선반환 후 파싱·후보 계산은 Layer 1 이벤트로 분리하는 구조 채택Redis TTL 기반 dedup 스토어 미채택 — 별도 캐시 계층 도입 금지(NFR-8). MySQL 유니크 제약으로 충분(1일 수십 건)
Elasticsearch multi_match 필드 부스팅 (multi-match, boosting)title^3 형태로 필드별 부스트, best_fields + tie_breaker로 “한 필드에서 강하게 뜬 문서”를 우선. 최종 점수 = 최고 필드 점수 + tie_breaker × 나머지 합필드별 가중 합산 + 임계치 구조를 채택(FR-86). 제목 가중치를 단독으로 임계치를 넘게 설정해 “제목에서 뜬 공고는 무조건 매칭”을 보존검색 엔진 자체는 미도입 — 인덱스 운영·동기화 비용이 이 규모(수천 건, 1일 1회 평가)에 과함. 동의어 그룹은 이미 DB에 있고 매칭은 순수 함수라 인메모리 계산으로 충분
Teal HQ / Jobscan (Teal match, 비교)이력서-JD 대조로 0~100 점수 + 하드/소프트 스킬 항목별 충족 여부를 분리 표시. Jobscan은 키워드 일치만, Teal은 구조·성과·키워드 3축”점수 + 축별 항목 근거를 함께 노출” 패턴 채택(FR-96). 점수만 주면 사용자가 신뢰·행동을 못 하므로 축별 충족률과 부족 항목을 함께 저장·응답이력서 문구 최적화·실시간 재계산 미채택 — 본 도구는 이력서 개선이 아니라 지원 의사결정 지원이 목적(PRD Benchmarking에 명시). LLM 기반 의미 유사도 미채택(B-23 규칙 기반 확정)
Miniflux / Linkding 계열 셀프호스트 단일 사용자 앱 (session vs token, JWT 세션 반대론)단일 도메인 셀프호스트 앱은 서버 세션 + HttpOnly 쿠키가 표준. 로그아웃이 서버 측 삭제 한 번, 토큰 블랙리스트 불필요서버 세션 + HttpOnly·SameSite 쿠키 채택. FE가 nginx로 동일 오리진(/api)이라 쿠키가 자연스럽고, XSS로 토큰이 새는 경로가 없음JWT·리프레시 토큰 회전 미채택 — 무상태 확장이 필요 없고(단일 인스턴스), 즉시 무효화를 위해 블랙리스트를 다시 만들어야 해 오히려 복잡
파일 버전 관리 (문서 저장소 일반형) (versioning 설계)바이너리는 스토리지, DB에는 버전 행(계열 id·버전 번호·스토리지 키·메타)만. 버전마다 새 행, 기존 행 불변계열(series) + 버전 행 + 경로 메타만 DB 보관 채택(FR-81·82). 파일은 한 벌만 두고 지원 건 연결은 DB 관계로 표현오브젝트 스토리지·콘텐츠 주소 지정(해시 경로) 미채택 — 로컬 Docker 볼륨 한 대이고 사용자가 파인더로 직접 열람하는 것이 요구(FR-82의 사람이 읽는 경로 규칙)

Possible Solutions

방안 1 — 인증 방식: 무엇으로 단일 사용자를 인증할 것인가 (FR-70)

방안설명판정
A. 자체 세션 토큰 + HttpOnly 쿠키 + BCrypt32바이트 난수 토큰을 발급해 쿠키에 담고, DB에는 토큰의 SHA-256 해시만 저장한다. 자격 증명(아이디·BCrypt 해시)은 환경 변수로 주입한다. 검증은 OncePerRequestFilter 하나가 담당하고, 만료·연장·실패 잠금 판정은 LoginSession·LoginAttemptState 엔티티에 캡슐화한다채택
B. Spring Security 전체 스택spring-boot-starter-security + SecurityFilterChain으로 formLogin/세션을 구성미채택 — 사용자 1명·권한 1종이라 Spring Security의 가치(권한 모델·다중 인증 방식·메서드 보안)가 발현되지 않는다. 대신 CSRF·CORS·기본 필터체인 설정이 따라오고, 잠금·세션 만료 같은 우리 정책이 SecurityConfig로 흩어져 Rich Domain 규칙과 이원화된다. BCrypt만 필요하므로 spring-security-crypto 단일 라이브러리로 대체한다
C. JWT + Authorization 헤더무상태 토큰을 FE가 보관미채택 — 즉시 로그아웃·강제 만료를 위해 블랙리스트 테이블을 다시 만들게 되어 세션보다 복잡해진다. FE가 토큰을 localStorage에 두면 XSS 노출 경로가 생기고, 쿠키에 두면 결국 A와 같아진다. 인스턴스가 1개라 무상태의 이점이 없다
D. nginx Basic Auth프록시 단에서 차단미채택 — 로그아웃·실패 잠금(NFR-17)·세션 만료를 표현할 수 없고, 웹훅 경로만 예외 처리하는 규칙이 앱 밖으로 새어 나가 앱과 프록시에 이중 관리된다

채택안 세부

  • 자격 증명은 DB에 사용자 테이블을 두지 않고 환경 변수 2개(AUTH_USERNAME, AUTH_PASSWORD_BCRYPT)로 주입합니다. 사용자가 1명 고정이므로 사용자 테이블은 순수 오버헤드입니다(NFR-17 “서버 비밀 설정 보관”과 정합).
  • 세션 토큰 원문은 어디에도 저장하지 않습니다 — DB에는 SHA-256 해시만 둡니다. DB 유출 시 세션 탈취를 막습니다.
  • 쿠키 속성: HttpOnly, SameSite=Lax, Path=/, Secure(설정값 — 터널 HTTPS 전제). CSRF 토큰은 도입하지 않습니다 — 상태 변경 API가 전부 non-GET이고 SameSite=Lax는 크로스사이트 non-GET 요청에 쿠키를 싣지 않으므로 방어가 성립합니다(근거를 코드 주석에 남깁니다).
  • 세션 만료 24시간(NFR-17), 슬라이딩 연장은 하지 않습니다(만료 시각 고정 — 판정이 단순하고 무기한 연장 사고가 없습니다).
  • 실패 잠금: 연속 5회 실패 시 15분 잠금(NFR-17). LoginAttemptStateusername 단위 1행으로 카운터·잠금 해제 시각을 보유합니다.

방안 2 — 웹훅 신뢰: 검증을 어디에 둘 것인가 (FR-72)

방안설명판정
A. Controller가 raw body(String)를 받아 UseCase → DomainService가 검증컨트롤러 시그니처를 @RequestBody rawBody: String으로 두면 원문 바이트가 그대로 도메인까지 전달된다. 서명 대상 "{timestamp}.{rawBody}" 계산이 재직렬화 없이 정확해진다. 검증 규칙(오차 ±5분·nonce 재사용·서명 비교)은 WebhookVerificationDomainService에 캡슐화한다채택
B. 서블릿 Filter에서 검증인증 필터와 같은 층에서 처리미채택 — body를 필터에서 읽으면 ContentCachingRequestWrapper로 스트림을 되감아야 하고, 검증 실패 이력 저장(Operations 요구)이 필터에서 Repository를 호출하는 레이어 위반을 부른다. 서명 검증은 기술이 아니라 도메인 규칙(허용 오차·nonce 보존 기간이 요구사항 수치)이므로 domain에 있어야 한다
C. nginx에서 검증프록시 단 차단미채택 — Lua/njs 모듈이 필요하고 검증 실패 이력·nonce 저장을 프록시가 할 수 없다

정규화 규칙 확정

  • 헤더: X-Recruitment-Signature: t={epochSeconds},v1={hex(hmacSha256(secret, "{t}.{rawBody}"))}, X-Recruitment-Nonce: {UUID}
  • 서명 대상은 UTF-8 raw body 문자열 그대로입니다. 공백·키 순서를 정규화하지 않습니다(발신 측과 수신 측의 직렬화 차이를 원천 차단).
  • 비교는 상수 시간 비교(MessageDigest.isEqual)를 씁니다 — 타이밍 공격 방어.
  • nonce는 webhook_request_nonces에 유니크 저장하고 24시간 후 정리 스케줄러가 삭제합니다. nonce 삽입이 곧 재사용 판정입니다(유니크 위반 = 재사용).
  • 검증 실패는 본문 파싱 전에 401로 끊고 webhook_receipt_logs에 사유(SIGNATURE_MISMATCH·TIMESTAMP_OUT_OF_RANGE·NONCE_REUSED)를 남깁니다.

방안 3 — 관리 상태의 귀속 단위: 그룹을 어떻게 안정적으로 식별할 것인가 (FR-74, 가장 중요)

FR-74는 관리 상태를 dedup_key + 마감일 그룹에 귀속하라고 확정했습니다. 그런데 그 그룹은 08:30 배치가 매 실행 재계산하는 메모리 산출물입니다(AS-IS B). 안정적 식별자를 만드는 방법이 이번 설계의 핵심 난제입니다.

방안설명판정
A. 그룹 엔티티 영속화 + 멤버 교집합 기반 정체성 승계job_posting_dedup_groups 테이블을 신설하고 job_postings.dedup_group_id로 멤버십을 기록한다. 배치는 매 실행 그룹을 재계산하되, 계산된 새 클러스터마다 같은 dedup_key의 기존 그룹 중 멤버 교집합이 가장 큰 그룹을 재사용한다. 교집합이 0이면 새 그룹을 만든다채택
B. 대표 공고 id에 귀속관리 상태를 대표 공고에 붙이고, 대표가 바뀌면 승계 로직으로 옮긴다미채택 — PRD B-2가 지적한 문제 그대로다. deduplicate()가 매 실행 대표를 재선정하므로(JobPostingDeduplicationDomainService.kt:50-65) 승계 로직이 배치 안에 상주하게 되고, 승계 누락 시 사용자의 제외 결정이 조용히 사라진다
C. 자연 키 (dedup_key, 마감일 버킷)마감일을 30일 버킷으로 나눠 키를 만든다미채택 — splitByDeadlineGap()은 고정 버킷이 아니라 연쇄 클러스터링이다(:90-107: 정렬 후 인접 간격이 30일 이상일 때만 분리). 멤버가 추가되면 클러스터 경계가 이동하므로 같은 공고가 어제와 다른 버킷에 속할 수 있다. 자연 키가 성립하지 않는다
D. 개별 공고에 저장 후 배치가 그룹 내 전파상태를 공고마다 복제하고 동기화미채택 — 같은 그룹의 두 공고가 서로 다른 상태를 가질 수 있는 모순 상태가 생기고, 어느 쪽이 진실인지 판정하는 규칙을 또 만들어야 한다

채택안이 FR-74의 두 요구를 동시에 만족하는 방식

FR-74 요구채택안의 동작
재공고(마감일 30일 이상 차이)는 이전 회차 관리 상태를 승계하지 않는다 (A-1)새 회차 공고들은 이전 그룹 멤버와 교집합이 0이다 → 새 그룹 생성 → 관리 상태 없음(사용자가 새로 판단)
같은 그룹에 다른 플랫폼 공고가 뒤늦게 합류해도 관리 상태가 유지된다 (B-2)새 클러스터가 기존 멤버를 포함하므로 교집합 > 0 → 기존 그룹 재사용 → 관리 상태 유지
MANUAL 공고가 수집 경로로 재발견되면 자연 병합된다 (A-2)MANUAL에도 같은 규칙으로 dedup_key를 부여하므로 같은 클러스터에 들어가고, MANUAL이 이미 그룹 멤버라 교집합 > 0 → 사용자가 수동 등록 시 남긴 메모·상태 유지

교집합이 동률일 때: 그룹 id 오름차순으로 결정합니다(결정성 보장). 한 기존 그룹이 두 새 클러스터에 동시에 매칭되면(마감일이 벌어져 그룹이 쪼개진 경우) 교집합이 큰 쪽이 정체성을 가져가고 나머지는 새 그룹이 됩니다.

방안 4 — MANUAL 공고의 dedup 후보 편입 (FR-74 A-2, 검수 미결 #2)

결론: dedup 후보 선정 필터와 델타 판정 필터를 분리합니다.

현재 isDeltaTarget()은 두 조건을 하나로 묶고 있습니다(JobPosting.kt:153).

isDeltaTarget() = origin == COLLECTED && !accessRestricted

두 조건의 목적이 다릅니다.

조건목적dedup에 필요한가
origin == COLLECTED”이 공고가 수집 회차 응답에 등장할 수 있는가” — 미발견 카운터(markMissed())의 전제. MANUAL은 어떤 소스에도 안 나타나므로 미발견 판정 모수에서 빼야 한다아니오 — MANUAL도 중복 그룹을 형성할 수 있다(FR-74가 요구)
!accessRestricted”자동 판정 대상인가”아니오 — PRD B-25가 tie-break에 “접근 가능 > 접근 제한” 축을 추가하라고 확정했다. 이 축이 의미를 가지려면 접근 제한 공고도 그룹에 포함돼야 한다

따라서:

  • isDeltaTarget()이름·의미 그대로 유지하고 수집 델타 경로(BE-10 계열)에서만 씁니다.
  • dedup 후보는 findAllWithDedupKey() 전량으로 바꿉니다 — isDeltaTarget() 필터를 제거합니다. dedup_key가 있다는 것 자체가 후보 자격입니다.
  • JobPosting.createManual()dedupKey를 계산하도록 바꾸고, 회사명은 호출부가 전달합니다. RegisterManualJobPostingUseCase가 이미 companyDomainService.getBy(command.companyId)를 호출하고 있으므로(RegisterManualJobPostingUseCase.kt:22) 회사명을 얻는 추가 조회가 없습니다.
  • 기존 MANUAL 공고의 dedup_key는 백필이 필요합니다 — Release Scenario의 데이터 마이그레이션 5단계 대상입니다.

tie-break 5단 확정 (PRD B-25 + MANUAL 편입)

순위
접근 가능 여부접근 가능 0 / 접근 제한 1
출처 우선순위COMPANY_BOUND 0 / MANUAL 0(동급) / AGGREGATOR 1
공개 상태OPEN 0 / CLOSED 1
최초 발견 시각이른 순
PK오름차순

②에서 jobSourceId가 null인 MANUAL이 들어오므로 sourcePriority()requireNotNull(jobSourceId)(JobPostingDeduplicationDomainService.kt:79)를 제거하고 origin을 먼저 분기합니다.

방안 5 — 컨텍스트 경계: 새 도메인을 만들 것인가, 기존에 합류시킬 것인가

새 기능마다 새 도메인을 만들지 않는다는 원칙으로, 신규 기능군마다 기존 합류 가능성을 먼저 검토했습니다.

기능군기존 합류 후보결정근거
인증·세션 (FR-70)없음신규 auth어느 기존 컨텍스트와도 데이터·라이프사이클이 겹치지 않는다. 전 API를 가로지르는 횡단 관심사라 어느 한 컨텍스트에 종속시키면 그 컨텍스트가 앱 전체를 아는 셈이 된다
웹훅 검증·nonce (FR-72)contact신규 webhook검증 기반은 1단계, 연락 이벤트는 3단계로 배포 단위가 다르다. 또 FR-89가 channel 필드로 SMS 확장을 예고하므로, 검증 기반이 특정 수신 도메인에 묶이면 안 된다
관리 상태 (FR-73~78)posting신규 watchlistposting은 “수집·변경·마감 판정” 책임이다. 여기에 사용자 개인 취향(우선순위·태그·제외 사유)을 넣으면 배치가 소유한 데이터와 사용자가 소유한 데이터가 한 애그리게이트에 섞인다. 라이프사이클도 다르다 — 공고는 배치가, 관리 상태는 사용자만 바꾼다. 참조는 dedupGroupId: Long 단방향
중복 그룹 (FR-74)posting에 합류그룹은 08:30 배치가 계산하는 posting 자기 사실이다. 별도 컨텍스트로 빼면 배치가 컨텍스트를 넘나든다
담당자 (FR-80)applicationapplication에 합류FR-80이 “담당자는 회사가 아니라 지원 건에 귀속한다”고 명시했다. 지원 건과 라이프사이클이 완전히 같고(지원이 사라지면 담당자도 의미 없음), 독립 조회 요구가 없다
대시보드 (FR-79)application컨텍스트 없음 — application 레이어 조합대시보드는 저장 데이터가 없는 순수 read model이다. 지원(칸반·장기 미변경)·면접(다가오는 면접)·공고/회사(카드 라벨)를 조합하므로 application/dashboard UseCase가 각 컨텍스트 DomainService를 호출해 조립한다
서류 (FR-81~85)application신규 documentFR-85가 “하나의 서류 버전을 여러 지원 건에 재사용”을 요구한다 — 문서는 지원과 독립적으로 존재한다. 파일 저장이라는 별도 영속 매체를 소유한다. 단 “어느 지원에 무엇을 제출했는가”는 지원의 사실이므로 연결 테이블은 application이 소유하고 documentVersionId: Long만 참조한다
프로필·추천도 (FR-95~97)matching신규 recommendationmatching은 “알림을 보낼 공고인가”(키워드·근무형태)를 판정한다. 추천도는 “지원할 만한가”(내 역량 대비 적합도)를 판정한다 — 입력(이력서 프로필)·출력(점수·등급)·기준 버전 축이 전부 다르다. FR-97이 “기존 match_criteria_revisions와는 별개 축”을 명시적으로 요구했다
이력서 프로필 (FR-95)documentrecommendation에 합류프로필은 추천도 계산의 입력 기준이며 독립적 의미가 약하다. document에 두면 확정·기준 버전 증가가 두 컨텍스트에 걸쳐 조율돼야 한다
연락 이벤트 (FR-89~91)application신규 contact연락 이벤트는 지원과 무관하게 먼저 도착한다(후보 매칭 실패 시 어느 지원에도 안 붙는다). 원본·파싱·후보·결정 이력을 독립 소유하고, 반영 여부와 무관하게 보존된다(FR-89 “중복 수신과 오탐을 추적”)
소스 상태 6종 (FR-88)companycompany에 합류job_sources가 company 컨텍스트 소유다(기존 TDD 시스템 역할 경계). 상태는 그 테이블의 컬럼 확장이다. 전이 트리거(수집 결과)는 posting이 알지만, 전이 실행은 application 레이어가 조합한다
필드별 매칭 (FR-86)matchingmatching에 합류매칭 규칙의 확장 그 자체다. EvaluationTarget에 이미 태그·본문이 흐르고 있다(EvaluationTarget.kt:10-15)
알림 2종 (FR-93·94)notificationnotification에 합류enum 2개 확장 + 대상 산출 경로 추가. 기존 멱등 키 규칙을 그대로 쓴다

결과: 신규 컨텍스트 6개(auth·webhook·watchlist·document·recommendation·contact), 기존 확장 5개(posting·matching·application·notification·company).

방안 6 — 서버 토폴로지: 무엇을 어디서 돌릴 것인가

과제 특성별로 후보를 놓고 배치를 결정했습니다.

과제특성후보결정
REST 요청-응답 (FR-7380, 90, 9597)동기, 저부하(사용자 1명)API 서버 / BFF 분리기존 API 서버
웹훅 수신 (FR-89)외부 인바운드, 5분마다 최대 수 건API 서버 / 전용 수신 서버기존 API 서버 — 초당 0.003건 규모에 전용 서버는 과잉
파일 업로드 (FR-82)최대 20MB, 하루 수 건API 서버 / 별도 스토리지 서비스기존 API 서버 + Docker 볼륨
주기 실행 (수집·dedup·알림·nonce 정리)1일 1~2회인프로세스 스케줄러 / 워커 프로세스 / 외부 크론기존 인프로세스 스케줄러SchedulingConfig.kt:15-23 poolSize=1이 이미 직렬 실행을 보장(NFR-16)
소급 재평가 백필 (FR-87)1회성, 수천~수만 건Spring Batch / 기존 페이지 순회 배치 / 수동 SQL기존 페이지 순회 배치 패턴 재사용 (아래 상세)
추천도 일괄 재평가 (FR-97)관심 공고 200건, 5분 이내(NFR-13)동기 API / 비동기 잡동기 API — 200건 × 규칙 계산은 초 단위. 잡 상태 추적 테이블이 불필요
실시간 양방향없음WebSocket/SSE 서버미도입 — 알림은 Discord 웹훅이 담당하고, 화면 갱신은 사용자 조작 시점 재조회로 충분
외부 인터넷 노출 (FR-71)인바운드 HTTPS포트 포워딩 / 리버스 프록시 + 인증서 / 터널Cloudflare Tunnel 사이드카 컨테이너 — 공유기 포트 개방·고정 IP·인증서 갱신이 전부 불필요하고 앱 코드가 무변경

Spring Batch 미채택 근거 (FR-87 백필)

private-db-schema-convention의 데이터 마이그레이션 5단계는 “배치 백필은 Spring Batch 청크”를 규정합니다. 그 취지는 ① Flyway 인라인 백필 DML 금지 ② 청크 단위 커밋으로 락 범위 축소 ③ 멱등 재실행입니다. 이번 백필은 세 취지를 기존 자산으로 이미 충족합니다.

  • EvaluateJobPostingsUseCase가 500건 페이지 순회(EvaluateJobPostingsUseCase.kt:83-88, PAGE_SIZE = 500)를 하고, 대상 1건마다 PROPAGATION_REQUIRES_NEW 트랜잭션으로 격리합니다(:48-50, :109-118). 락 범위가 행 단위입니다.
  • 멱등은 criteria_revision 비교가 보장합니다 — 이미 새 revision으로 평가된 공고는 alreadyEvaluatedIds()로 skip합니다(JobPostingEvaluationDomainService.kt:97-98). 중단 후 재실행이 안전합니다.
  • Spring Batch를 도입하면 메타 테이블 6개(BATCH_JOB_INSTANCE 등)와 잡 정의 계층이 추가되는데, 이 프로젝트의 백필은 일생에 1회입니다. 지금 규모에 과합니다.

이 예외 판단을 private-db-schema-convention “Flyway 인라인 백필 DML 금지”의 취지에는 어긋나지 않게 유지합니다 — Flyway는 DDL만, 값 채우기는 애플리케이션 배치가 수행합니다.

방안 7 — 교차 회사 목록 조회: 어떻게 P95 500ms를 만들 것인가 (FR-78, NFR-12)

방안설명판정
A. QueryDSL 도입 + SQL 레벨 필터·정렬·페이지이미 선언된 QueryDSL 의존성(build.gradle.kts:52-56, 사용처 0건)을 활성화해 JobPostingSearchRepositoryImpl을 만든다. 필터·정렬·페이징을 전부 SQL로 내리고, 응답 조립에 필요한 부속 데이터(관리 상태·매칭 결과·지원·추천도)는 id 집합 단위 배치 조회 1회씩으로 가져온다채택
B. 기존 파생 쿼리 + 메모리 필터 확장findAllBy(companyId, ...) 패턴을 전 회사로 확장미채택 — 수천 건 전량을 @EntityGraph로 본문·태그까지 eager 로드한 뒤 메모리 필터하는 현재 구조(JobPostingRepositoryImpl.kt:71-75)를 전 회사로 확장하면 힙과 지연이 선형 증가한다. 페이지네이션과 정렬을 결합할 수도 없다
C. 검색 엔진(Elasticsearch/Meilisearch) 도입색인 후 조회미채택 — 별도 프로세스·색인 동기화가 NFR-8(브로커·캐시·워커 미도입)의 취지에 정면으로 어긋난다. 수천 건 규모에서 MySQL 인덱스로 충분하다
D. 조회 전용 비정규화 테이블(read model)목록에 필요한 필드를 한 테이블에 모아 배치가 갱신미채택 — 갱신 지연·정합 문제를 새로 만든다. 지금 규모에서 조인 3개가 문제가 되지 않는다

플랫폼 OR 매칭의 크로스 컨텍스트 문제와 해법

FR-78은 “플랫폼 필터는 중복 그룹 내 원본 중 하나라도 일치하면 포함(OR 매칭)“을 요구합니다. 그런데 platformjob_sources(company 컨텍스트) 소유입니다. posting이 그 테이블을 조인하면 no-crosscontext-raw-read 위반입니다.

해법: job_postings.platform 컬럼을 신설합니다. 이것은 비정규화가 아니라 “이 공고가 어느 플랫폼에서 왔는가”라는 posting 자기 사실입니다. 수집 시점에 어댑터가 이미 알고 있는 값이고(JobPlatformdomain.common 소속이라 교차 참조가 아닙니다 — JobPlatform.kt), MANUAL은 NULL입니다. 이 컬럼으로 OR 매칭이 posting 소유 테이블 안에서 성립합니다.

-- 개념 쿼리 (실제 구현은 QueryDSL)
대표 공고 = job_postings WHERE representative_id IS NULL
플랫폼 OR = EXISTS (SELECT 1 FROM job_postings m
                    WHERE m.dedup_group_id = p.dedup_group_id AND m.platform IN (:platforms))

기존 MANUAL·수집 공고의 platform 백필이 필요합니다(데이터 마이그레이션 5단계 대상).

방안 8 — 필드별 가중치 매칭: 오탐을 어떻게 막는가 (FR-86, 가장 중요)

기존 TDD가 본문 매칭을 기각한 사유는 구체적이었습니다 — “‘백엔드와 협업’ 같은 문장에서 대량 오탐”(20260722-...-tdd.md:1149). FR-86은 이 결정을 번복하되 “오탐률이 제목 단독 대비 증가하지 않을 것”을 성공 기준으로 걸었습니다. 따라서 오탐을 막는 규칙이 설계의 본체입니다.

1차 방어선 — 가중치 설계로 본문 단독 매칭을 구조적으로 불가능하게 만든다

필드가중치근거
제목 (title)60강한 신호. 소스가 직무를 명시적으로 선언한 자리
구조화 태그 (job_posting_source_tags)35중간 신호. 소스가 분류한 값이라 신뢰도는 높으나 분류 입도가 넓을 수 있음(예: “개발”)
JD 본문 (job_posting_descriptions)20약한 신호. 문맥에 따라 의미가 뒤집힘
매칭 성립 임계치50

PRD FR-86의 “직무 분류”는 구조화 태그(35)에 포함됩니다 — 직무 분류 전용 컬럼이 스키마에 없고, 어댑터가 소스의 직무 분류 값을 job_posting_source_tags로 평탄화해 저장하기 때문입니다(V202607220000__baseline_schema_and_feature_flags.sql:118-126 — “어댑터가 평탄화한 태그 문자열”). 따라서 PRD의 4필드(제목·JD 본문·구조화 태그·직무 분류)와 이 표의 3필드는 저장 구조상 동일한 범위이며, 직무 분류 전용 필드를 신설하지 않습니다(신설하면 어댑터 9종을 모두 고쳐야 하는데 지금 얻는 것이 없습니다).

이 수치의 의미를 조합별로 검증합니다.

조합합산매칭기존 대비
제목만60기존 동작 100% 보존 — 제목에서 뜨던 공고는 전부 그대로 매칭
태그만35신규 — 분류만 넓게 걸린 공고는 매칭 안 됨
본문만20기존 기각 사유 해소 — “백엔드와 협업”은 어떤 경우에도 단독 매칭 불가
태그 + 본문55신규 획득 — 제목이 모호해도(예: “서버 엔지니어 채용”) 두 신호가 겹치면 인정
제목 + 무엇이든80~115기존과 동일

본문은 단독으로 매칭을 성립시킬 수 없습니다. 이것이 오탐 억제의 구조적 보장입니다.

2차 방어선 — 본문 신호 자체의 문맥 규칙

규칙내용채택
(a) 부정·협업 문맥 배제키워드 출현 위치 앞 12자 / 뒤 20자 윈도우에 배제 표현이 있으면 그 출현을 무효화한다. 사전: 협업·유관·함께 일할·함께 일하는·소통·대상이 아·제외·우대하지채택WorkArrangementDetector.findValidSnippet()이 이미 같은 구조(윈도우 ±·사전 대조)로 부정어를 처리하고 있어(WorkArrangementDetector.kt:78-96, NEGATION_WINDOW_BEFORE=12·AFTER=20) 검증된 패턴을 재사용한다
(b) 출현 빈도 하한본문 신호는 서로 다른 문장에서 2회 이상 유효 출현해야 인정한다. 1회 스침은 무시채택 — “백엔드와 협업”류는 보통 1회 스친다. 실제 모집 직무면 담당 업무·자격 요건에 반복 등장한다
(c) 섹션 한정JD를 섹션으로 쪼개 “담당 업무·자격 요건”에서만 인정하고 “조직 소개·우대사항”은 제외미채택 — 섹션 헤더 포맷이 소스 9종마다 제각각이라 헤더 인식 실패 시 오히려 정상 매칭을 통째로 잃는다. (a)+(b)로 충분하고, 섹션 파싱은 FR-96의 요구사항 추출에서 별도로 다룬다(거기서는 실패해도 “판단 불가”로 안전하게 떨어진다)

제외 키워드의 적용 범위

제목 + 구조화 태그에만 적용하고 본문에는 적용하지 않습니다. 본문에 “인턴”이 한 번 스쳤다고 정규직 공고가 탈락하는 것은 매칭 누락(더 나쁜 실패)입니다. 제외는 매칭보다 강한 판정이므로 신뢰도 높은 필드에만 겁니다.

가중치를 사용자 설정으로 열 것인가

미채택. FR-86은 사용자 수정을 요구하지 않았고(FR-97의 가중치 수정은 추천도 축 가중치입니다), 열면 스키마·API·FE가 따라옵니다. 도메인 상수(MatchFieldWeight enum)로 두고, 변경 시 match_criteria_revisions에 1행을 넣어 소급 재평가를 트리거합니다.

근거 저장 — match_score > 0 전부 (A-2, 게이트 ② 결정 ⓑ)

job_posting_match_field_evidences 테이블에 (매칭 결과 × 키워드 그룹 × 필드) 단위로 가중치·발췌·출현 횟수를 남깁니다. job_posting_match_results에는 match_score(합산 점수)와 match_threshold(판정 당시 임계치)를 추가해 결과의 재현성을 확보합니다.

저장 조건 (구현 계약 — 추측 금지)

job_posting_match_field_evidences에는 weightPoints > 0인 (키워드 그룹 × 필드) 조합을 전부 저장한다. 즉 공고의 match_score > 0이면 매칭 성립 여부와 무관하게 근거를 남긴다.

  • 저장 대상: 그 필드에서 그 그룹이 유효 신호로 인정된 경우(본문은 방안 8(b) “서로 다른 문장 2회 이상” 통과분만).
  • 미저장 대상: match_score = 0(어느 필드에서도 걸리지 않음) 공고 — 남길 근거 자체가 없다.
  • 매칭 성립분만 저장하지 않는다. 그러면 미매칭 공고가 matchScore: 35, matchThreshold: 50, matchFieldEvidences: []로 내려가 “왜 임계치에 못 미쳤는지”를 설명할 수 없다. 확대하면 “제목 0 + 태그 35 = 50 미달”을 화면이 그대로 보여준다.

확대 비용 — dba 실측값 (2026-08-09 정정)

최초 설계의 “6.7배 / 30만 행” 추정은 틀렸습니다. dba가 로컬 MySQL에서 (공고 × 키워드 그룹 × 필드) 신호를 전수 계산한 결과로 교체합니다.

매칭 성립분만 (미채택)match_score > 0 (채택)차이
현재 행 수1,8442,446+602
3년 누적 행 수38,90051,600+12,700
크기17MB22MB+5MB
증가율기준+32.6%
미매칭 사유 설명불가가능

최초 추정이 빗나간 원인 — 구조적 상한을 놓쳤습니다.

  • 본문을 가진 공고가 914건(13.8%)뿐이고 전량 GREENHOUSE 입니다. WANTED 3,224건·SARAMIN 2,265건 등 애그리게이터 5종은 본문이 0건이라 DESCRIPTION_BODY 근거가 원천적으로 생기지 않습니다.
  • 근거 행의 71%가 TITLE 이고, 그건 이미 매칭 성립분에 포함돼 있습니다. 확대로 새로 생기는 행은 태그 단독 3 + 본문 단독 599 뿐입니다.
  • 그 599조차 방안 8(b)(“서로 다른 문장 2회 이상”) 적용 상한이며, 근사 측정상 43~71%만 생존합니다.

최초 추정은 “공고 1건당 (활성 그룹 수 × 3필드)”라는 이론적 상한을 그대로 곱한 것이었는데, 필드 3개 중 2개(태그·본문)가 대부분의 공고에서 비어 있다는 사실을 반영하지 않았습니다. 행 수를 좌우하는 실제 변수는 활성 키워드 그룹 수가 아니라 본문·태그 보유 공고 비율입니다.

성능 영향 판정 (NFR) — 실측 기준 정정

대상영향판정
일일 평가 경로 (08:50 sweep)+41행/일NFR-2(09:00 이전 완료) 영향 없음
FR-87 소급 재평가 백필 (1회성)전량 재평가 시 공고당 평균 +0.09행(6,633건 기준 총 +602행). 락 범위 불변(500건 페이지 + 대상별 REQUIRES_NEW)수용 — 최초 판정 “자식 쓰기 최대 6.7배”는 과대 추정이었고, 실측상 소요 시간 증가도 무시할 수준
NFR-13 (추천도 200건 5분)무관 (matching과 별개 경로)영향 없음

매칭/미매칭의 구분 — matched가 SSOT입니다

matchScore >= matchThreshold 비교만으로는 판정할 수 없습니다. 제외 키워드가 매칭을 이기기 때문입니다(FR-23) — 점수가 60이어도 제목·태그에 제외 키워드가 있으면 matched=false입니다. 따라서 응답에 다음을 함께 둡니다.

필드역할
matched: boolean판정 SSOT. FE는 이 값으로만 매칭 여부를 판단한다
matchScore / matchThreshold”얼마나 모자랐는가”의 설명용 수치
excludedByKeyword: { keywordId, keyword } | null미매칭 사유가 제외 키워드인지 점수 미달인지 구분 (신규)

미매칭 사유는 FE가 이렇게 분기합니다 — excludedByKeyword != null → “제외 키워드 ‘인턴’에 걸림” / null → “점수 35 / 임계 50 미달”.

방안 9 — 지원 추천도: 규칙을 어떻게 도메인으로 표현하는가 (FR-96)

PRD가 적용 순서 5단계·“일부 충족 60%” 기준·59점 상한을 확정했습니다. 설계 과제는 이를 타입으로 드러내는 것입니다.

방안설명판정
A. 축(axis)을 sealed 타입으로, 충족률을 값 객체로RecommendationAxis enum(4종) + AxisAssessment 값 객체(judgeable·fulfillmentRate·items)로 표현한다. 판단 불가를 judgeable=false로 1급 표현하고, 정규화·상한은 RecommendationScore 값 객체가 캡슐화한다채택
B. 점수 계산을 DomainService 함수로만절차형 계산 후 숫자만 저장미채택 — “판단 불가”와 “0점”이 구분되지 않아 정규화 규칙(FR-96 ②③)을 표현할 수 없다. Teal/Jobscan 벤치마킹의 “축별 근거 노출”도 불가능해진다
C. 외부 LLM 호출의미 유사도 기반미채택 — PRD B-23이 규칙 기반으로 확정했고, NFR-13(200건 5분)·NFR-10(개인정보 외부 전송 최소화)과 정합하지 않는다

축 정의와 기본 가중치

기본 가중치입력판단 불가 조건
REQUIRED_SKILL (필수 기술)45JD 자격 요건 섹션에서 추출한 기술 목록 × 프로필 기술자격 요건 섹션 추출 실패 또는 추출 기술 0건
PREFERRED_SKILL (우대 기술·도메인)20JD 우대사항 섹션 기술 목록 × 프로필 기술우대사항 섹션 추출 실패 또는 추출 기술 0건
CAREER_ROLE (경력·직무)20JD 요구 경력(개월)·직무 분류 × 프로필 경력·직무 선호요구 경력 표기 없음 그리고 직무 분류 없음
WORK_CONDITION (근무 조건·선호)15공고 근무형태 판정 결과(matching) × 프로필 근무 조건 선호근무형태 확신도 UNKNOWN 또는 프로필 선호 미입력

항목 충족률 판정 (FR-96 확정 규칙의 구현 형태)

기술명이 프로필에 없음                                   → NONE   (0%)
기술명 일치 && (최근 사용 3년 초과 || 사용 기간 < 요구 기간의 1/2) → PARTIAL (60%)
그 외 기술명 일치                                       → FULL   (100%)

축 충족률 = 항목 충족률의 산술 평균. 경력·근무 조건 축은 항목이 1~2개인 단일 판정입니다.

적용 순서 (FR-96 ①~⑤ 그대로)

  1. 사용자 가중치 합계 100 검증 — 아니면 InvalidRecommendationWeightException(400)
  2. 판단 불가 축 제외
  3. 남은 축 비중을 합계 100으로 정규화 — 필수 기술 축(45)이 판단 불가여도 예외 없이 정규화(PRD B-16~18)
  4. 총점 = Σ(정규화 비중 × 축 충족률), 반올림 후 0~100
  5. 필수 기술 미충족이면 59점 상한 (해제되지 않은 경우에만)

“필수 기술 미충족”의 정의 — 사용자 확정 (2026-08-08, A-1)

확정 규칙: 필수 기술 항목 중 NONE(기술명 자체가 프로필에 없음)이 1건 이상이면 미충족으로 보고 59점 상한을 적용합니다. PARTIAL(60%, 오래된 기술)은 상한 사유가 아닙니다.

근거 세 가지입니다.

  1. PRD 문언과 일치docs/PRD.md §214가 “하나라도 완전히 미충족”으로 규정합니다. “완전히”는 NONE(0%)을 가리키고 PARTIAL(60%)은 부분 충족이므로 문언상 상한 대상이 아닙니다.
  2. 변별력 — 미채택 대안(“필수 축 충족률 100% 미만이면 상한”)을 택하면 기술 하나만 오래돼도 상한에 걸려 대부분의 공고가 59점에 수렴해 등급이 신호를 잃습니다.
  3. 의미PARTIAL은 “가지고 있으나 오래됐다”는 뜻이라 지원 자체를 말릴 사유가 아닙니다. 반면 요구 기술을 아예 갖고 있지 않은데 상위 등급(65점 이상 추천)을 주면 사용자를 오도합니다.

구현은 AxisAssessment.hasUnmetRequiredItem()(= NONE 항목 1건 이상) 한 곳에 캡슐화하고, 판정 기준을 RecommendationScore.CAP_TRIGGER 상수로 분리해 기준이 바뀌면 1곳만 고치게 합니다.

JD 없는 공고의 처리 (검수 미결 #5) — 평가 제외로 확정

폐기된 FR-57의 “JD 없으면 평가 제외”가 FR-95~97에 승계되지 않아, 3축이 판단 불가가 되고 근무 조건 축 단독 100%로 왜곡되는 문제가 있습니다.

확정 규칙: REQUIRED_SKILL·PREFERRED_SKILL·CAREER_ROLE 3축이 모두 판단 불가이면 점수를 산출하지 않고 NOT_EVALUABLE(사유 JD_UNAVAILABLE)로 기록합니다.

“결과 없음”이 아니라 사유를 가진 결과 행으로 남기는 이유는 Operations 요구(“추천도 재평가 실패 건수와 사유 집계”, PRD Operations)를 만족하고, 화면이 “왜 점수가 없는지”를 설명할 수 있어야 하기 때문입니다.

추천도의 귀속 대상 — 대표 공고

후보판정
대표 공고 id 귀속채택 — 평가 입력(JD 본문·태그)이 공고의 것이라 근거 추적이 명확하다. 대표가 교체되면 새 대표를 재평가 대상에 넣는다
중복 그룹 귀속미채택 — 그룹에는 JD가 없다. 대표가 바뀌면 결과의 근거(어느 공고의 JD로 계산했는가)가 흐려진다

59점 상한 해제는 결과 행과 분리job_posting_recommendation_cap_overrides에 영속화합니다(FR-97 “해제 여부를 영속화해 재평가 시에도 유지”). 결과 행은 재평가 때 덮어써지므로 해제 플래그를 같은 행에 두면 날아갑니다.

방안 10 — 연락 이벤트: 무엇을 신뢰하고 무엇을 사용자에게 맡기는가 (FR-89~91)

방안설명판정
A. 수신 즉시 2xx → Layer 1 이벤트로 파싱·후보 계산·알림웹훅은 서명 검증 + 원본 저장 + 2xx만 하고, 파싱·후보 계산·Discord 알림은 @TransactionalEventListener(AFTER_COMMIT)로 분리한다채택
B. 수신 요청 안에서 전부 동기 처리파싱·후보·알림까지 한 트랜잭션미채택 — Discord 발송이 최대 3회 재시도로 수 초를 소모하는데(DiscordWebhookGatewayImpl.kt:18-40), Apps Script가 그 사이 타임아웃되면 앱은 저장했는데 스크립트는 실패로 보고 inbox 라벨을 유지해 재전송한다. 멱등이 있어 데이터는 안전하지만 알림이 중복된다
C. Kafka 토픽으로 분리내구성 있는 비동기미채택 — 브로커 미도입 원칙(NFR-8). 유실 위험은 Apps Script의 재시도 구조(2xx 확인 후에만 라벨 이동, FR-92)가 이미 흡수한다

Layer 판단 근거private-be-architecture-rule의 판단 기준표를 적용하면: 구독자가 발행자와 같은 앱·같은 배포 단위이고(✅ Layer 1), 프로세스가 죽어도 유실되면 안 되는 데이터는 원본 이벤트이며 그것은 이미 수신 트랜잭션에서 커밋됩니다. 파싱·후보 계산은 원본만 있으면 언제든 재계산 가능하므로 내구성이 필요 없습니다. Layer 1(Spring ApplicationEvent) 확정입니다.

후보 매칭 점수 (FR-91 확정 수치)

배점판정 기준
회사 일치40추출 회사명 정규화 == 지원 건 공고의 회사명 정규화
공고/직무 제목 일치30추출 공고명 정규화가 공고 제목 정규화에 포함되거나 그 역
담당자 일치20발신 이메일 주소 == 지원 건 담당자 이메일 (FR-80 전제)
진행 중 · 최근 지원10상태가 종료 4종이 아니고 applied_at이 180일 이내

신뢰도: 80점 이상 높음 / 55~79 보통 / 54점 이하 낮음. 최고점이 55 미만이거나 동점 최고 후보가 2건 이상이면 자동 선택하지 않습니다(FR-91, B-15 — “회사만 일치(40점)“가 수동 선택으로 떨어지는 것은 의도된 동작).

상태 자동 변경 금지 — 어떤 신뢰도에서도 상태를 바꾸지 않습니다. 사용자가 검토 화면에서 반영을 선택해야만 전이합니다(PRD Non-Goals “수신 연락 자동 상태 변경 미지원”).

방안 11 — 변경 공고 알림의 24시간 제한 (FR-93)

방안설명판정
A. 멱등 키의 dispatchSequence에 KST 일자 서수를 넣는다JOB_POSTING:{id}:JOB_POSTING_CHANGED:{kstDayOrdinal(now)}채택
B. 마지막 발송 시각으로 롤링 24시간 판정발송 이력을 조회해 24시간 경과 여부를 계산미채택 — 기존 멱등 키 규칙(FR-38·IdempotencyKey.kt:31)을 벗어나고, 판정마다 조회가 필요하다

채택 근거: DAILY_DIGEST가 이미 같은 패턴을 씁니다(IdempotencyKey.kt:37-38, NotificationDispatchDomainService.kt:91-103). 그리고 알림 배치가 09:00 하루 1회이므로(application.yml:46) 일자 서수와 롤링 24시간이 실질적으로 동일합니다 — 배치가 하루 한 번만 도는데 같은 공고에 두 번 발송될 창이 없습니다. 명시적 멱등 키는 수동 트리거·재실행 시의 중복을 방어합니다.

방안 12 — 지원 ↔ 관리 상태 연동 (FR-75)

FR-75는 지원 생성/철회가 관리 상태를 바꾸도록 요구합니다. applicationwatchlist는 서로 다른 컨텍스트라 직접 참조가 금지됩니다(LayeredArchitectureTest.kt:52-62).

방안설명판정
A. Layer 1 ApplicationEvent + presentation 리스너application 도메인이 JobApplicationEvent(sealed: Created·Withdrawn·Deleted)를 적재 → DomainEventPublisher로 발행 → presentation/watchlist/listener@Async @TransactionalEventListener(AFTER_COMMIT)로 수신 → watchlist UseCase 경유채택
B. application 레이어 UseCase가 두 DomainService를 직접 호출CreateApplicationUseCaseApplicationDomainServiceWatchStateDomainService를 모두 주입미채택 — 지원 생성이 관리 상태 복구까지 알아야 하는 결합이 생긴다. 관심사(파생 처리) 분리가 이벤트의 정확한 용도이고, JobPostingEventListener 선례가 이미 있다
C. Kafka미채택 — 같은 앱·같은 프로세스. 브로커 미도입(NFR-8)

실패 경로: 리스너가 실패하면 관리 상태가 EXCLUDED로 남습니다. @Retryable 3회 후 최종 실패는 ERROR 로그로 남기고, 사용자가 화면에서 수동 변경할 수 있어 복구 경로가 있습니다. 파생 실패가 지원 생성(원 트랜잭션)을 롤백하지 않습니다.

방안 13 — 단순함 우선: 도입하지 않는 것

항목미채택 사유
Kafka·RabbitMQ도메인 간 결합을 끊을 필요가 없다 — 전부 같은 앱·같은 배포 단위다. NFR-8 명시 금지
Redis (세션·캐시·nonce·분산 락)세션·nonce는 MySQL 테이블 + 유니크 제약으로 충분(1일 수십 건). 단일 인스턴스라 분산 락 대상이 없고, 배치는 poolSize=1로 직렬이다(SchedulingConfig.kt:15-23)
별도 워커 프로세스최대 부하가 “200건 규칙 계산”이다. 프로세스 분리 비용이 이득을 넘는다
Spring Batch방안 6 참조 — 일생 1회 백필에 메타 테이블 6개는 과하다
검색 엔진방안 7 참조
MongoDB이력서 프로필·연락 이벤트가 반정형이라 후보였으나, 관계·정합이 중요하다(프로필 확정 ↔ 기준 버전 ↔ 평가 결과, 연락 이벤트 ↔ 지원 건). private-mongodb-convention의 채택 근거 3종 어디에도 해당하지 않는다. MySQL 단일 유지
API 버저닝 (/api/v2)이번 변경에 기존 엔드포인트의 파괴적 변경이 없다. 전부 신규 엔드포인트이거나 응답 필드 추가(하위 호환)다
사용자 테이블사용자 1명 고정. 환경 변수로 충분(방안 1)

Detail Design

패키지 레이아웃 (신규분만)

com.biuea.recruitment
├── presentation/
│   ├── auth/          AuthApiController.kt, AuthenticationFilter.kt, AuthFilterConfig.kt
│   ├── webhook/       ContactEventWebhookApiController.kt, scheduler/WebhookNonceCleanupScheduler.kt
│   ├── watchlist/     WatchStateApiController.kt, listener/JobApplicationLifecycleListener.kt
│   ├── dashboard/     DashboardApiController.kt
│   ├── document/      DocumentApiController.kt
│   ├── recommendation/ RecommendationApiController.kt, RecommendationWeightApiController.kt
│   ├── contact/       ContactEventApiController.kt, listener/ContactEventReceivedListener.kt
│   └── application/   ApplicationContactApiController.kt (신규 파일만 추가)
├── application/
│   ├── auth/ webhook/ watchlist/ dashboard/ document/ recommendation/ contact/
│   └── posting/  ListAllJobPostingsUseCase.kt (신규 파일 추가)
├── domain/
│   ├── auth/          LoginSession.kt, LoginAttemptState.kt, LoginCredential.kt, PasswordHasher.kt,
│   │                  SessionTokenGenerator.kt, AuthDomainService.kt, *Repository.kt
│   ├── webhook/       WebhookSignature.kt, WebhookNonce.kt, WebhookReceiptLog.kt,
│   │                  WebhookVerificationDomainService.kt, *Repository.kt
│   ├── watchlist/     JobPostingWatchState.kt, WatchStatus.kt, WatchPriority.kt, WatchTag.kt,
│   │                  WatchStateHistory.kt, WatchStateDomainService.kt, *Repository.kt
│   ├── document/      ApplicationDocumentSeries.kt, ApplicationDocumentVersion.kt, DocumentType.kt,
│   │                  DocumentFileName.kt, DocumentFileGateway.kt, DocumentDomainService.kt
│   ├── recommendation/ ResumeProfile.kt, ProfileSkill.kt, RecommendationAxis.kt, AxisAssessment.kt,
│   │                  RecommendationScore.kt, RecommendationGrade.kt, JobRequirement.kt,
│   │                  JobRequirementExtractor.kt, RecommendationDomainService.kt,
│   │                  ResumeTextExtractor.kt (interface), *Repository.kt
│   ├── contact/       ContactEvent.kt, ContactChannel.kt, ContactEventParse.kt,
│   │                  ContactMatchCandidate.kt, ContactConfidence.kt, ContactDecision.kt,
│   │                  ContactEventDomainService.kt, *Repository.kt
│   ├── posting/       JobPostingDedupGroup.kt (신규), JobPosting.kt·JobPostingDeduplicationDomainService.kt (확장)
│   ├── matching/      MatchField.kt, MatchFieldWeight.kt, MatchFieldEvidence.kt (신규),
│   │                  MatchCriteria.kt·JobKeywordMatcher.kt (확장)
│   ├── application/   JobApplicationContact.kt, JobApplicationEvent.kt, SubmittedDocument.kt (신규)
│   ├── company/       JobSourceRegistryStatus.kt (신규), JobSource.kt (확장)
│   └── notification/  NotificationType/TargetType (enum 확장), ChangedJobPostingTarget.kt,
│                      ContactReviewTarget.kt (신규)
└── infrastructure/
    ├── auth/ webhook/ watchlist/ document/ recommendation/ contact/  (persistence + gateway 구현)
    ├── document/local/LocalDocumentFileGatewayImpl.kt
    ├── recommendation/extractor/{PdfBoxResumeTextExtractor, DocxResumeTextExtractor, MarkdownResumeTextExtractor}.kt
    └── posting/persistence/JobPostingSearchRepositoryImpl.kt  (QueryDSL — 교차 회사 목록)

시스템 역할 경계 (의무)

단위역할소유 데이터/책임노출 인터페이스의존
API 서버 (단일 프로세스, 기존)REST 요청-응답 + 인프로세스 스케줄 + 웹훅 수신 + 파일 업로드전체HTTP /api/**MySQL, Discord 웹훅, 채용 소스 11종, 로컬 파일시스템(볼륨)
cloudflared 사이드카 (신규 컨테이너)외부 인터넷 → web:80 터널링. 앱 코드 무변경없음공개 HTTPS 호스트명Cloudflare, web 컨테이너
문서 볼륨 (신규)지원 서류 원본 파일 보관{DOCUMENT_ROOT}/**컨테이너 내 마운트 경로호스트 /Users/biuea/Desktop/dpdpdndn/private/이직/지원서류
AuthenticationFilter (presentation)인증 필요 경로 판정 + 세션 검증 + 401 응답. 인증 통과 시 요청 속성에 세션 식별자 부착없음서블릿 필터 (order 1)ValidateSessionUseCase
auth 컨텍스트자격 증명 검증·세션 발급/만료·실패 잠금user_login_sessions, login_attempt_statesAuthDomainServicePasswordHasher, SessionTokenGenerator(둘 다 domain interface)
webhook 컨텍스트HMAC 서명 검증·시간 오차·nonce 재사용 판정·수신 이력webhook_request_nonces, webhook_receipt_logsWebhookVerificationDomainService(없음)
watchlist 컨텍스트 (신규)관리 상태 3종·전이·이력·우선순위·목표일·태그·메모·제외 사유job_posting_watch_states, job_posting_watch_tags, job_posting_watch_state_historiesWatchStateDomainService, WatchStateQueryService(없음 — 그룹은 dedupGroupId: Long로만 인지)
document 컨텍스트 (신규)문서 계열·버전·파일 저장 경로·새니타이즈·업로드 실패 이력(A-3)application_document_series, application_document_versions, application_document_upload_failures, 파일시스템DocumentDomainService, DocumentUploadFailureQueryServiceDocumentFileGateway
recommendation 컨텍스트 (신규)이력서 프로필(초안·확정)·축 가중치·기준 버전·추천도 계산·상한 해제resume_profiles, resume_profile_skills, resume_profile_experiences, resume_profile_work_preferences, recommendation_criteria_revisions, recommendation_axis_weights, job_posting_recommendations, job_posting_recommendation_axes, job_posting_recommendation_axis_items, job_posting_recommendation_cap_overridesRecommendationDomainService, ResumeProfileDomainServiceResumeTextExtractor(domain interface)
contact 컨텍스트 (신규)연락 이벤트 원본·파싱·후보·사용자 결정 이력recruitment_contact_events, ..._parses, ..._candidates, ..._decisionsContactEventDomainService(없음 — 지원 건은 jobApplicationId: Long로만 인지)
posting 컨텍스트 (확장)기존 + 중복 그룹 영속화·그룹 정체성 승계·플랫폼 컬럼·교차 회사 검색기존 + job_posting_dedup_groups, job_postings.dedup_group_id·platform기존 + JobPostingDedupGroupRepository, JobPostingSearchRepository기존
matching 컨텍스트 (확장)기존 + 필드별 가중 합산·근거 저장기존 + job_posting_match_field_evidences, job_posting_match_results.match_score·match_threshold기존기존
application 컨텍스트 (확장)기존 + 담당자·제출 서류 연결·라이프사이클 이벤트 발행기존 + job_application_contacts, job_application_submitted_documents기존 + ApplicationContactDomainServiceDomainEventPublisher
company 컨텍스트 (확장)기존 + 소스 레지스트리 상태 6종기존 + job_sources.registry_status·registry_status_changed_at기존 + JobSourceRegistryDomainService기존
notification 컨텍스트 (확장)기존 + 변경 공고·연락 검토 요청 알림 2종기존기존기존
LocalDocumentFileGatewayImpl (infra)경로 새니타이즈·저장 루트 검증·파일 쓰기/읽기/삭제없음DocumentFileGateway 구현java.nio.file
JobPostingSearchRepositoryImpl (infra)QueryDSL 교차 회사 검색 — SQL 레벨 필터·정렬·페이지없음JobPostingSearchRepository 구현JPAQueryFactory
WebhookNonceCleanupScheduler24시간 경과 nonce 삭제없음@Scheduled(cron="0 20 3 * * *")CleanupWebhookNoncesUseCase

노출 경계 규칙 (신규분): Path·InputStream·PDDocument(PDFBox)·XWPFDocument(POI)·HttpServletRequest·Cookie 타입은 infrastructure/presentation 내부에만 존재하고 domain·application에 노출되지 않습니다. DocumentFileGateway바이트 배열과 도메인 값 객체만 주고받습니다.

인터페이스 시그니처 (구현자 간 해석 차이 제거)

auth 컨텍스트

// domain/auth/PasswordHasher.kt — BCrypt 구현은 infrastructure
interface PasswordHasher {
    fun matches(rawPassword: String, hashedPassword: String): Boolean
}
 
// domain/auth/SessionTokenGenerator.kt — 난수 생성·해시는 infrastructure
interface SessionTokenGenerator {
    /** 32바이트 난수를 base64url로 인코딩한 원문 토큰을 만든다. */
    fun generate(): String
    /** 저장·조회용 SHA-256 해시(hex 64자). 원문은 저장하지 않는다. */
    fun hash(rawToken: String): String
}
 
// domain/auth/LoginSessionRepository.kt
interface LoginSessionRepository {
    fun save(session: LoginSession): LoginSession
    fun findBy(tokenHash: String): LoginSession?
    fun deleteBy(tokenHash: String)
    fun deleteAllExpiredBefore(baseTime: ZonedDateTime): Int
}
 
// domain/auth/LoginAttemptStateRepository.kt
interface LoginAttemptStateRepository {
    fun findBy(username: String): LoginAttemptState?
    fun save(state: LoginAttemptState): LoginAttemptState
}
 
// domain/auth/LoginCredentialGateway.kt — 환경 변수 주입값을 domain에 노출하지 않기 위한 경계
interface LoginCredentialGateway {
    fun currentCredential(): LoginCredential   // LoginCredential(username, passwordHash)
}
 
// domain/auth/AuthDomainService.kt
@Service
class AuthDomainService(...) {
    /** 잠금 확인 → 자격 검증 → 실패 카운트 갱신 또는 세션 발급. 실패 시 예외를 던진다. */
    fun login(username: String, rawPassword: String): IssuedSession   // IssuedSession(rawToken, expiresAt)
    /** 만료·미존재면 null. 유효하면 lastAccessedAt을 갱신한다. */
    fun validate(rawToken: String): LoginSession?
    fun logout(rawToken: String)
    fun purgeExpired(): Int
}

webhook 컨텍스트

// domain/webhook/WebhookVerificationDomainService.kt
@Service
class WebhookVerificationDomainService(...) {
    /**
     * 서명·시간 오차·nonce 재사용을 순서대로 검증한다. 실패 시 사유를 담은
     * [WebhookVerificationFailedException]을 던지고, 성공·실패 모두 수신 이력을 남긴다.
     * signedPayload = "{timestamp}.{rawBody}", 비교는 상수 시간.
     */
    fun verify(command: WebhookVerificationInput)
}
 
// domain/webhook/WebhookVerificationInput.kt
data class WebhookVerificationInput(
    val endpoint: String,
    val rawBody: String,
    val signatureHeader: String,   // "t=...,v1=..."
    val nonce: String,
)
 
// domain/webhook/WebhookVerificationFailureReason.kt
enum class WebhookVerificationFailureReason {
    MALFORMED_HEADER, SIGNATURE_MISMATCH, TIMESTAMP_OUT_OF_RANGE, NONCE_REUSED
}
 
// domain/webhook/WebhookNonceRepository.kt
interface WebhookNonceRepository {
    /** 이미 존재하면 false(재사용). 유니크 제약 위반도 false로 흡수한다. */
    fun putIfAbsent(nonce: String, receivedAt: ZonedDateTime): Boolean
    fun deleteAllReceivedBefore(baseTime: ZonedDateTime): Int
}
 
// domain/webhook/WebhookReceiptLogRepository.kt
interface WebhookReceiptLogRepository {
    fun save(log: WebhookReceiptLog): WebhookReceiptLog
    fun findAllReceivedFrom(from: ZonedDateTime): List<WebhookReceiptLog>
}

posting 컨텍스트 (확장)

// domain/posting/JobPostingDedupGroup.kt — 그룹 정체성의 소유자
class JobPostingDedupGroup private constructor(...) {
    val currentId: Long?
    val dedupKey: String
    fun memberIds(): List<Long>
    /** 새 회차 클러스터로 멤버를 교체한다. 대표·마감일 범위를 함께 갱신한다. */
    fun replaceMembers(memberIds: List<Long>, representativeId: Long,
                       earliestDeadlineAt: ZonedDateTime?, latestDeadlineAt: ZonedDateTime?)
    /** 새 클러스터와의 멤버 교집합 크기 — 그룹 정체성 승계 판정의 기준. */
    fun intersectionSizeWith(candidateMemberIds: Set<Long>): Int
    companion object {
        fun create(dedupKey: String, memberIds: List<Long>, representativeId: Long,
                   earliestDeadlineAt: ZonedDateTime?, latestDeadlineAt: ZonedDateTime?): JobPostingDedupGroup
        fun reconstitute(...): JobPostingDedupGroup
    }
}
 
// domain/posting/JobPostingDedupGroupRepository.kt
interface JobPostingDedupGroupRepository {
    fun saveAll(groups: List<JobPostingDedupGroup>): List<JobPostingDedupGroup>
    fun findAllBy(dedupKeys: Collection<String>): List<JobPostingDedupGroup>
    fun findBy(dedupGroupId: Long): JobPostingDedupGroup?
    /** 이번 회차에 어느 클러스터에도 승계되지 않은 그룹 — 멤버 0으로 남겨 관리 상태는 보존한다. */
    fun deleteAllOrphanedIn(dedupKeys: Collection<String>, survivingGroupIds: Collection<Long>): Int
}
 
// domain/posting/JobPosting.kt — 확장 시그니처
class JobPosting {
    var dedupGroupId: Long?   private set          // 신규
    var platform: JobPlatform? private set          // 신규 (MANUAL은 null)
    /** dedup 배치가 그룹 멤버십을 확정할 때 호출한다. */
    fun assignDedupGroup(dedupGroupId: Long)
    /** 소급 재평가로 새로 매칭된 공고의 알림을 영구 억제한다 (FR-87). */
    fun suppressNotification()
    /** dedup 후보 자격 — dedupKey 보유 여부만 본다(isDeltaTarget과 목적이 다르다). */
    fun isDedupCandidate(): Boolean = dedupKey != null
    /** tie-break ①: 접근 가능 0 / 접근 제한 1 */
    fun accessPriority(): Int
    /** tie-break ②: COMPANY_BOUND·MANUAL 0 / AGGREGATOR 1 */
    fun sourcePriority(sourceTypeById: Map<Long, SourceType>): Int
    // createManual 시그니처 확장 — companyName을 받아 dedupKey를 계산한다 (FR-74 A-2)
    companion object {
        fun createManual(companyId: Long, companyName: String, title: String,
                         postingUrl: String, deadlineAt: ZonedDateTime?): JobPosting
    }
}
 
// domain/posting/JobPostingSearchRepository.kt — 교차 회사 목록 (QueryDSL 구현)
interface JobPostingSearchRepository {
    fun search(criteria: JobPostingSearchCriteria): JobPostingSearchPage
}
 
// domain/posting/JobPostingSearchCriteria.kt
data class JobPostingSearchCriteria(
    val watchStatuses: Set<String>,          // watchlist가 넘긴 문자열 코드 (컨텍스트 교차 회피)
    val watchedGroupIds: Set<Long>?,         // null이면 관리 상태 필터 미적용
    val platforms: Set<JobPlatform>,
    val postingStatus: JobPostingStatus?,
    val deadlineFrom: ZonedDateTime?,
    val deadlineTo: ZonedDateTime?,
    val titleKeyword: String?,               // 정규화 후 LIKE
    val sort: JobPostingSearchSort,          // DEADLINE_ASC | DISCOVERED_DESC | TITLE_ASC
    val page: Int,                           // 0-based
    val size: Int,                           // 1..100
)
 
// domain/posting/JobPostingSearchPage.kt
data class JobPostingSearchPage(val items: List<JobPosting>, val totalCount: Long)
 
// domain/posting/JobPostingRepository.kt — 신규 메서드
interface JobPostingRepository {
    /** FR-93 변경 공고 알림 후보 — 대표 && changed_at >= baseTime. */
    fun findAllChangedSince(baseTime: ZonedDateTime): List<JobPosting>
    /** 데이터 마이그레이션 백필 — platform·dedup_key가 비어 있는 공고 페이지 조회. */
    fun findAllBackfillTargets(pageNumber: Int, pageSize: Int): List<JobPosting>
}

watchlist 컨텍스트

// domain/watchlist/WatchStatus.kt
enum class WatchStatus {
    INTERESTED, PLANNED, EXCLUDED;
    fun canTransitTo(next: WatchStatus): Boolean   // 3종 상호 전이 전부 허용 (아래 상태 전이 표)
}
 
// domain/watchlist/WatchPriority.kt
enum class WatchPriority { HIGH, NORMAL, LOW }
 
// domain/watchlist/JobPostingWatchState.kt — 애그리게이트 루트 (태그를 자식으로 소유)
class JobPostingWatchState private constructor(...) {
    val currentId: Long?
    val dedupGroupId: Long
    val currentStatus: WatchStatus
    fun tags(): List<WatchTag>
    /** 상태 전이 + 이력 적재. 같은 상태로의 전이는 no-op(이력 미적재). */
    fun transitTo(next: WatchStatus, reason: String?): Boolean
    /**
     * FR-75 ① 지원 생성 시 EXCLUDED를 INTERESTED로 되돌린다. 이미 EXCLUDED가 아니면 no-op.
     * **구현 티켓: BE-53** (BE-50 아님 — 아래 "FR-75 복구 메서드의 소유 티켓" 참고).
     */
    fun restoreOnApplied(): Boolean
    /**
     * FR-75 ② 지원 철회 시 직전 상태로 복귀. 이력이 없으면 INTERESTED. **구현 티켓: BE-53**.
     * **이력을 인자로 받습니다** — 엔티티는 전체 이력을 보유하지 않고(태그만 자식으로 소유),
     * 이력은 `findWithHistoriesByDedupGroupId`로 별도 조회하기 때문입니다. DomainService가
     * 조회해 넘깁니다.
     */
    fun revertOnApplicationRemoved(histories: List<WatchStateHistory>): Boolean
    fun updatePriority(priority: WatchPriority)
    fun updateTargetApplyDate(targetApplyDate: LocalDate?)
    fun updateMemo(memo: String?)
    fun updateExclusionReason(reason: String?)   // EXCLUDED가 아니면 예외
    fun replaceTags(tagNames: List<String>)      // 중복 제거·최대 10개·각 30자
    fun pullHistories(): List<WatchStateHistory>
    companion object {
        fun create(dedupGroupId: Long, status: WatchStatus): JobPostingWatchState
        fun reconstitute(...): JobPostingWatchState
    }
}
 
// domain/watchlist/JobPostingWatchStateRepository.kt
interface JobPostingWatchStateRepository {
    fun save(state: JobPostingWatchState): JobPostingWatchState
    fun findBy(dedupGroupId: Long): JobPostingWatchState?
    fun findAllBy(dedupGroupIds: Collection<Long>): Map<Long, JobPostingWatchState>
    fun findAllGroupIdsBy(statuses: Set<WatchStatus>): Set<Long>
    fun findAllHistoriesBy(dedupGroupId: Long): List<WatchStateHistory>
}
 
// domain/watchlist/WatchStateDomainService.kt
@Service
class WatchStateDomainService(...) {
    /** 없으면 만들고 있으면 전이한다(upsert). 이력을 함께 적재한다. */
    fun upsert(dedupGroupId: Long, command: WatchStateUpsertInput): JobPostingWatchState
    fun getBy(dedupGroupId: Long): JobPostingWatchState        // 없으면 WatchStateNotFoundException
    fun findBy(dedupGroupId: Long): JobPostingWatchState?
    fun restoreOnApplied(dedupGroupId: Long)          // 구현 티켓: BE-53
    fun revertOnApplicationRemoved(dedupGroupId: Long) // 구현 티켓: BE-53
    fun findGroupIdsBy(statuses: Set<WatchStatus>): Set<Long>  // 소비자: BE-52(교차 목록 필터)·BE-65(관심 공고 재평가)
    /**
     * 배치 조회 — 교차 목록 응답 조립(watchStatus·priority·tags 결합)과 `PRIORITY_DESC`·태그 필터
     * 해석이 공유하는 진입점. **최초 설계에서 누락됐다가 BE-52 구현 중 추가됐다** — Repository에는
     * `findAllBy(dedupGroupIds)`가 이미 선언돼 있었는데(위 Repository 계약) DomainService 노출만
     * 빠져 있었다. UseCase가 Repository를 직접 주입하는 대안은 `no-repo-in-usecase` 위반이라
     * 이 노출이 유일한 경로다.
     *
     * ⚠️ **피처 플래그 게이트 주의** — 이 메서드는 `ensureFeatureEnabled()`를 건다. 그래서 호출부가
     * 관리 상태를 요청하지 않았으면 **이 조회 자체를 건너뛰어야** 한다. 건너뛰지 않으면
     * `watchlist.management` OFF 구간에 **교차 목록 API 전체가 409로 죽는다** — 교차 목록은
     * `watchStatus` 미지정 시 관리 상태와 무관하게 동작해야 하고(API 계약), Release 1-7(플래그 ON)
     * 이전에도 열려 있어야 한다. BE-52 구현은 두 지점(필터 해석의 조기 반환 + 응답 조립의 예외 강등)에서
     * 이 조건을 만족한다.
     *
     * ⚠️ **게이트 가드 테스트 공백 (후속 13)** — 이 메서드와 형제 메서드 `findGroupIdsBy` 양쪽 모두
     * "플래그 OFF면 던진다"를 고정하는 테스트가 **없다.** 뮤턴트로 `ensureFeatureEnabled()`를 제거하면
     * 전 테스트가 통과하는데(BE-52 병합 정합 리뷰에서 실증), 실제로는 **플래그 OFF에서 목록 응답이
     * `watchStatus`·`priority`·`tags`를 노출해 C-1 계약을 위반**한다. 반대 방향(BE-53 복구 2종 =
     * 게이트 없음)은 strict mock + `verify(exactly = 0)`으로 강하게 고정돼 있어 **비대칭**이다.
     * 두 메서드를 함께 덮어야 하므로 **watchlist 정리 티켓에서 처리**한다 — 도메인 테스트의 기존
     * `given("피처 플래그가 OFF일 때")` 블록에 각각 `shouldThrow<FeatureDisabledException>`를 한 줄씩
     * 추가하면 두 뮤턴트가 모두 죽는다.
     */
    fun findAllBy(dedupGroupIds: Collection<Long>): Map<Long, JobPostingWatchState>
}
FR-75 복구 메서드의 소유 티켓 — BE-53 (BE-50 아님)

위 시그니처 4개(restoreOnApplied·revertOnApplicationRemoved 엔티티/서비스 각 2개)는 선언 위치는 watchlist 컨텍스트이지만 구현 티켓은 BE-53입니다. BE-50의 범위는 FR-73·74·76·77이고, FR-75는 “방안 12 지원 ↔ 관리 상태 연동”에 따라 Layer 1 이벤트 리스너 경유로 BE-53이 소유합니다.

  • BE-50이 이 메서드들을 선구현했다가 프로덕션 호출부 0건(리뷰 p2)으로 제거했습니다. BE-53은 이미 존재한다고 전제하지 말고 자기 범위로 재도입합니다.
  • 재도입 시 Testcontainers 실 DB 통합 테스트가 필수입니다. 이 경로는 상태 전이 + 이력 적재 + 태그 컬렉션이 한 애그리게이트에서 함께 flush되어 태그 유니크 제약(uk_job_posting_watch_tags_state_name)을 건드립니다. BE-50에서 MockK 단위 테스트만으로 통과했다가 실 DB 호출에서 DataIntegrityViolationException으로 죽은 사례가 있습니다 — Mock은 flush를 일으키지 않아 이 결함을 재현하지 못합니다. 계약 2(“JPA 제약 위반은 noRollbackFor로 구제되지 않는다”)와 같은 계열입니다.
  • 반면 findGroupIdsBy·findAllGroupIdsByBE-52(교차 목록 관리 상태 필터)·BE-65(관심 공고 일괄 재평가) 에 실제 소비자가 있어 BE-50 산출물로 유지됩니다.

document 컨텍스트

// domain/document/DocumentType.kt
enum class DocumentType(val directoryName: String) {
    RESUME("이력서"), COVER_LETTER("자소서"), PORTFOLIO("포트폴리오"), ETC("기타")
}
 
// domain/document/DocumentFileName.kt — 새니타이즈를 값 객체가 캡슐화한다 (FR-84)
class DocumentFileName private constructor(val value: String) {
    companion object {
        /** NFKC 정규화 → 경로 구분자(/ \)·상위 참조(..)·제어문자 제거 → 공백 정리 → 길이 상한 120. */
        fun ofOrThrow(rawValue: String): DocumentFileName
    }
}
 
// domain/document/DocumentFileGateway.kt — 로컬 파일시스템은 외부 시스템으로 취급
interface DocumentFileGateway {
    /**
     * 저장 루트 하위 상대 경로에 바이트를 쓴다. 정규화 후 루트를 벗어나면
     * [DocumentPathEscapeException]을 던진다. 같은 경로가 있으면 덮어쓰지 않고 예외.
     */
    fun store(relativePath: String, content: ByteArray): StoredDocumentFile
    fun read(relativePath: String): ByteArray
    fun delete(relativePath: String)
    fun exists(relativePath: String): Boolean
}
 
// domain/document/StoredDocumentFile.kt
data class StoredDocumentFile(val relativePath: String, val sizeBytes: Long, val storedAt: ZonedDateTime)
 
// domain/document/DocumentUploadFailureReason.kt — A-3 (게이트 ② 결정 ⓑ)
enum class DocumentUploadFailureReason {
    EXTENSION_NOT_ALLOWED,   // 허용 확장자 4종 외
    SIZE_EXCEEDED,           // 20MB 초과
    PATH_VALIDATION_FAILED,  // 새니타이즈 단계에서 거부 (보안)
    STORAGE_ROOT_ESCAPE,     // 정규화 후 저장 루트 이탈 (보안)
    // 아래 두 값의 판별 기준은 **파일이 남았다 지워졌는가**다.
    STORAGE_IO_FAILED,           // 파일 쓰기 자체가 실패 — **파일 미생성**, 보상 불필요
    METADATA_PERSISTENCE_FAILED, // 파일은 생성됐으나 메타데이터 행 저장 실패 — **파일 생성 후 보상 삭제** (C)
    OTHER,
}
 
// domain/document/DocumentUploadFailure.kt — 실패 1건 (추가 전용, 수정하지 않는다)
class DocumentUploadFailure private constructor(...) {
    val currentId: Long?
    val occurredAt: ZonedDateTime
    companion object {
        /**
         * documentType·seriesId는 검증 단계에 따라 확보 못 할 수 있어 nullable이다
         * (확장자 검증은 유형 파싱 전에도 실패한다).
         */
        fun of(
            documentType: DocumentType?,
            applicationDocumentSeriesId: Long?,
            originalFileName: String,
            failureReason: DocumentUploadFailureReason,
            detailMessage: String?,
        ): DocumentUploadFailure
        fun reconstitute(...): DocumentUploadFailure
    }
}
 
// domain/document/DocumentUploadFailureRepository.kt
interface DocumentUploadFailureRepository {
    fun save(failure: DocumentUploadFailure): DocumentUploadFailure
    fun findAllBy(
        failureReason: DocumentUploadFailureReason?,
        from: ZonedDateTime,
        page: Int,
        size: Int,
    ): List<DocumentUploadFailure>
    fun countBy(failureReason: DocumentUploadFailureReason?, from: ZonedDateTime): Long
}
 
// domain/document/DocumentDomainService.kt
@Service
class DocumentDomainService(...) {
    /**
     * 계열이 없으면 만들고, 다음 버전 번호를 계열별로 채번해 저장한다.
     * 확장자 4종(pdf·docx·md·hwp)·20MB 검증은 [ApplicationDocumentVersion.create]가 수행한다.
     */
    fun upload(command: DocumentUploadInput): ApplicationDocumentVersion
    fun listSeries(documentType: DocumentType?): List<ApplicationDocumentSeries>
    fun getVersionBy(versionId: Long): ApplicationDocumentVersion
    fun readContentOf(versionId: Long): ByteArray
}

recommendation 컨텍스트

// domain/recommendation/RecommendationAxis.kt
enum class RecommendationAxis(val defaultWeight: Int) {
    REQUIRED_SKILL(45), PREFERRED_SKILL(20), CAREER_ROLE(20), WORK_CONDITION(15)
}
 
// domain/recommendation/RecommendationGrade.kt
enum class RecommendationGrade {
    STRONGLY_RECOMMENDED, RECOMMENDED, REVIEW, NOT_RECOMMENDED;
    companion object { fun of(score: Int): RecommendationGrade }   // 80+/65-79/45-64/0-44
}
 
// domain/recommendation/AxisItemFulfillment.kt
enum class AxisItemFulfillment(val rate: Int) { FULL(100), PARTIAL(60), NONE(0) }
 
// domain/recommendation/AxisAssessment.kt — 판단 불가를 1급으로 표현
class AxisAssessment private constructor(
    val axis: RecommendationAxis,
    val judgeable: Boolean,
    private val items: List<AxisItemAssessment>,
) {
    /** 판단 불가면 접근 시 예외 — 호출부가 judgeable을 먼저 물어야 한다. */
    val fulfillmentRate: Int get() = ...
    fun items(): List<AxisItemAssessment>
    fun hasUnmetRequiredItem(): Boolean   // NONE 항목 1건 이상
    companion object {
        fun judged(axis: RecommendationAxis, items: List<AxisItemAssessment>): AxisAssessment
        fun notJudgeable(axis: RecommendationAxis): AxisAssessment
    }
}
 
// domain/recommendation/AxisItemAssessment.kt
data class AxisItemAssessment(
    val itemName: String,
    val fulfillment: AxisItemFulfillment,
    val reason: String,          // "최근 사용 4년 경과" 등 — 화면 근거 표시용
)
 
// domain/recommendation/RecommendationScore.kt — 적용 순서 5단계를 캡슐화
class RecommendationScore private constructor(
    val totalScore: Int,
    val grade: RecommendationGrade,
    val capApplied: Boolean,
    private val assessments: List<AxisAssessment>,
    private val normalizedWeights: Map<RecommendationAxis, Int>,
) {
    fun assessments(): List<AxisAssessment>
    fun normalizedWeightOf(axis: RecommendationAxis): Int
    companion object {
        /** ①가중치 합계 100 검증 ②판단 불가 제외 ③정규화 ④가중 합산 ⑤필수 미충족 59점 상한. */
        fun of(assessments: List<AxisAssessment>, weights: Map<RecommendationAxis, Int>,
               capReleased: Boolean): RecommendationScore
        const val CAP_SCORE = 59
    }
}
 
// domain/recommendation/JobRequirement.kt — JD에서 추출한 공고 요구사항
data class JobRequirement(
    val requiredSkills: List<String>,
    val preferredSkills: List<String>,
    val requiredExperienceMonths: Int?,
    val jobCategoryKeywords: List<String>,
) {
    fun isEmpty(): Boolean   // 3축 판단 불가 판정 = JD_UNAVAILABLE
}
 
// domain/recommendation/JobRequirementExtractor.kt — 순수 함수 (섹션 헤더 사전 기반)
@Service
class JobRequirementExtractor {
    fun extract(descriptionBody: String?, structuredTagValues: List<String>): JobRequirement
}
 
// domain/recommendation/ResumeTextExtractor.kt — 구현은 infrastructure(PDFBox/POI/plain)
interface ResumeTextExtractor {
    fun supports(fileExtension: String): Boolean
    /** 텍스트 레이어가 없으면 null을 반환한다(예외 아님 — FR-95·시나리오 13). */
    fun extract(content: ByteArray): String?
}
 
// domain/recommendation/ResumeProfile.kt — 초안·확정 상태를 가진 애그리게이트
class ResumeProfile private constructor(...) {
    val currentId: Long?
    val sourceDocumentVersionId: Long?
    val isConfirmed: Boolean
    fun skills(): List<ProfileSkill>
    fun experiences(): List<ProfileExperience>
    fun workPreferences(): List<ProfileWorkPreference>
    fun replaceSkills(skills: List<ProfileSkill>)
    fun confirm(): Boolean          // 이미 확정이면 false(멱등)
    companion object {
        fun draft(sourceDocumentVersionId: Long?, extractedText: String?): ResumeProfile
        fun reconstitute(...): ResumeProfile
    }
}
 
// domain/recommendation/ProfileSkill.kt
data class ProfileSkill(
    val skillName: String,
    val normalizedSkillName: String,
    val usageMonths: Int?,
    val lastUsedYearMonth: YearMonth?,
    val evidence: String?,
)
 
// domain/recommendation/RecommendationDomainService.kt
@Service
class RecommendationDomainService(...) {
    fun currentRevision(): Long
    /** 축 가중치 합계 100 검증 후 저장 + 기준 버전 증가 (FR-97). */
    fun updateWeights(weights: Map<RecommendationAxis, Int>): Long
    /** 대상 1건 평가. JD 3축 판단 불가면 NOT_EVALUABLE(JD_UNAVAILABLE)로 저장한다. */
    fun evaluateOne(target: RecommendationTarget, plan: RecommendationPlan): RecommendationItemOutcome
    fun planEvaluation(force: Boolean): RecommendationPlan     // 확정 프로필 + 가중치 + revision
    fun releaseCap(jobPostingId: Long, reason: String)
    fun findBy(jobPostingId: Long): JobPostingRecommendation?
    fun findAllBy(jobPostingIds: Collection<Long>): Map<Long, JobPostingRecommendation>
}
 
// domain/recommendation/RecommendationTarget.kt — application 레이어가 조합해 주입
data class RecommendationTarget(
    val jobPostingId: Long,
    val title: String,
    val descriptionBody: String?,
    val structuredTagValues: List<String>,
    val workArrangementLabels: List<String>,      // matching이 판정한 근무형태 라벨
    val workArrangementJudgeable: Boolean,        // 확신도 CONFIRMED/LIKELY면 true
)

contact 컨텍스트

// domain/contact/ContactChannel.kt
enum class ContactChannel { EMAIL, SMS }
 
// domain/contact/ContactReviewStatus.kt
enum class ContactReviewStatus { PENDING, APPLIED, IGNORED }
 
// domain/contact/ContactDecisionType.kt
enum class ContactDecisionType { APPLY, IGNORE, REASSIGN }
 
// domain/contact/ContactConfidence.kt
enum class ContactConfidence {
    HIGH, MEDIUM, LOW;
    companion object { fun of(score: Int): ContactConfidence }   // 80+/55-79/54-
}
 
// domain/contact/ContactEventAttachment.kt — 첨부 메타데이터 값 객체 (B-1)
// 원본 바이너리는 받지 않는다 (PRD §156 "첨부파일 원본은 자동 전송하지 않는다").
class ContactEventAttachment private constructor(
    val fileName: String,        // 새니타이즈된 표시용 파일명 (경로 구분자·상위 참조 제거)
    val mimeType: String,
    val sizeBytes: Long,
) {
    companion object {
        const val MAX_COUNT_PER_EVENT = 20
        /** 파일명 상한 255자·MIME 100자 초과, sizeBytes 음수는 거부한다. */
        fun of(fileName: String, mimeType: String, sizeBytes: Long): ContactEventAttachment
    }
}
 
// domain/contact/ContactEvent.kt — 애그리게이트 루트 (첨부·파싱·후보·결정을 자식으로 소유)
class ContactEvent private constructor(...) {
    val currentId: Long?
    val providerMessageId: String
    val reviewStatus: ContactReviewStatus
    fun attachments(): List<ContactEventAttachment>      // B-1
    fun parse(): ContactEventParse?
    fun candidates(): List<ContactMatchCandidate>
    fun applyParse(parse: ContactEventParse, candidates: List<ContactMatchCandidate>)
    /** 사용자 결정을 기록하고 reviewStatus를 확정한다. 이미 PENDING이 아니면 예외. */
    fun decide(decision: ContactDecision)
    /** 최고점 후보가 55점 이상이고 동점이 없을 때만 반환. 그 외 null(수동 선택 요구). */
    fun autoSelectableCandidate(): ContactMatchCandidate?
    fun pullDomainEvents(): List<DomainEvent>
    companion object {
        fun receive(channel: ContactChannel, providerMessageId: String, threadId: String?,
                    senderAddress: String, senderName: String?, subject: String?,
                    bodyText: String, receivedAt: ZonedDateTime,
                    attachments: List<ContactEventAttachment>): ContactEvent   // B-1
        fun reconstitute(...): ContactEvent
    }
}
 
// domain/contact/ContactMatchInput.kt — application이 조합해 넘기는 지원 건 요약 (교차 참조 회피)
data class ContactMatchInput(
    val jobApplicationId: Long,
    val companyName: String,
    val jobPostingTitle: String,
    val contactEmailAddresses: Set<String>,
    val inProgress: Boolean,
    val appliedAt: ZonedDateTime,
)
 
// domain/contact/ContactMatchScorer.kt — 순수 함수 (FR-91 배점)
@Service
class ContactMatchScorer {
    fun score(parse: ContactEventParse, inputs: List<ContactMatchInput>): List<ContactMatchCandidate>
    companion object {
        const val COMPANY_MATCH_POINTS = 40
        const val TITLE_MATCH_POINTS = 30
        const val CONTACT_MATCH_POINTS = 20
        const val RECENCY_POINTS = 10
        const val RECENCY_DAYS = 180L
        const val AUTO_SELECT_THRESHOLD = 55
    }
}
 
// domain/contact/ContactEventRepository.kt
interface ContactEventRepository {
    fun save(event: ContactEvent): ContactEvent
    /** providerMessageId 유니크 — 이미 있으면 기존 이벤트를 반환한다(멱등). */
    fun findBy(providerMessageId: String): ContactEvent?
    fun findBy(contactEventId: Long): ContactEvent?
    fun findAllBy(reviewStatus: ContactReviewStatus?, from: ZonedDateTime, page: Int, size: Int): List<ContactEvent>
    fun countBy(reviewStatus: ContactReviewStatus?, from: ZonedDateTime): Long
}

company·matching·notification 확장

// domain/company/JobSourceRegistryStatus.kt
enum class JobSourceRegistryStatus {
    DISCOVERED, ACTIVE, TRANSIENT_FAILURE, ACCESS_RESTRICTED, UNSUPPORTED, DISABLED;
    fun canTransitTo(next: JobSourceRegistryStatus): Boolean
}
 
// domain/company/JobSourceRegistryDomainService.kt
@Service
class JobSourceRegistryDomainService(...) {
    /** 수집 회차 결과를 받아 전이를 판정·기록한다. 전이가 없으면 false. */
    fun applyCollectionOutcome(jobSourceId: Long, outcome: SourceCollectionOutcome): Boolean
    fun findAllStatuses(): List<JobSourceRegistrySnapshot>
    fun recalculateAll(): Int   // 데이터 마이그레이션 초기값 산출 (소스 30개 미만)
}
 
// domain/matching/MatchField.kt
enum class MatchField(val weightPoints: Int) {
    TITLE(60), SOURCE_TAG(35), DESCRIPTION_BODY(20)
}
 
// domain/matching/MatchFieldEvidence.kt
data class MatchFieldEvidence(
    val jobKeywordGroupId: Long,
    val matchField: MatchField,
    val weightPoints: Int,
    val snippet: String,
    val occurrenceCount: Int,
)
 
// domain/matching/MatchCriteria.kt — 확장 시그니처 (기존 matches(title)는 유지·위임)
class MatchCriteria {
    /** 제목 단독 판정 — 기존 호출부 호환용. 내부적으로 evaluateFields(title, [], null)에 위임한다. */
    fun matches(title: String): KeywordMatchOutcome
    /**
     * 필드별 가중 합산 판정 (FR-86). 본문 신호는 부정·협업 문맥 배제 + 서로 다른 문장 2회 이상일 때만
     * 인정한다. 제외 키워드는 제목·구조화 태그에만 적용한다.
     */
    fun evaluateFields(title: String, structuredTags: List<StructuredTag>,
                       descriptionBody: String?): FieldWeightedMatchOutcome
    companion object { const val MATCH_THRESHOLD = 50 }
}
 
// domain/matching/FieldWeightedMatchOutcome.kt
data class FieldWeightedMatchOutcome(
    val matched: Boolean,
    val matchScore: Int,
    val matchThreshold: Int,
    val matchedGroupIds: List<Long>,
    val excludedByKeywordId: Long?,
    val fieldEvidences: List<MatchFieldEvidence>,
)
 
// domain/notification/NotificationType.kt — 확장
enum class NotificationType {
    NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST,
    JOB_POSTING_CHANGED,        // FR-93
    CONTACT_REVIEW_REQUEST,     // FR-94
}
 
// domain/notification/TargetType.kt — 확장
enum class TargetType { JOB_POSTING, JOB_SOURCE, DAILY_DIGEST, CONTACT_EVENT }
 
// domain/notification/ChangedJobPostingTarget.kt
data class ChangedJobPostingTarget(
    val jobPostingId: Long, val title: String, val companyName: String,
    val postingUrl: String, val changedAt: ZonedDateTime, val deadlineAt: ZonedDateTime?,
) {
    fun toMessageContent(): String
    /** 24시간 1회 제한 — dispatchSequence에 KST 일자 서수를 쓴다 (FR-93). */
    fun dispatchSequence(): Int = IdempotencyKey.kstDayOrdinal(changedAt)
}
 
// domain/notification/ContactReviewTarget.kt
data class ContactReviewTarget(
    val contactEventId: Long, val senderName: String?, val subject: String?,
    val topCandidateSummary: String?, val reviewUrl: String,
) {
    fun toMessageContent(): String
}

API 계약 (senior-fe 소비 대상 — 시그니처 수준 확정)

공통 규약

  • 기존 계약을 그대로 따릅니다 — 성공 응답은 봉투 없이 DTO 직접 반환, 에러는 ApiErrorResponse(code, message) 단일 형태(GlobalExceptionHandler.kt:34-39).

  • 에러 응답 확장 필드 (A — FE-24·FE-39 블로커 해소): ApiErrorResponse에 nullable actualCount: Long?·limit: Long? 2개를 추가합니다. 기존 existingCompanyId(GlobalExceptionHandler.kt:104-109)가 이미 만든 “필요한 코드에만 값을 싣는 nullable 부가 필드” 선례를 그대로 따릅니다. *_LIMIT_EXCEEDED 계열에서만 채워지고 나머지는 null 입니다.

    400 {
      "code": "WATCH_STATE_FILTER_LIMIT_EXCEEDED",
      "message": "관리 상태 대상이 1,842건으로 상한 1,000건을 초과했습니다. 필터를 좁혀 주세요",
      "actualCount": 1842,
      "limit": 1000
    }
    
  • 400 에러 3분류 — FE가 “사용자가 고칠 수 있는 것”과 “클라이언트 버그”를 구분할 수 있어야 합니다.

    분류코드FE 처리
    범위 초과 (사용자가 필터를 좁히면 해결)WATCH_STATE_FILTER_LIMIT_EXCEEDED, RECOMMENDATION_TARGET_LIMIT_EXCEEDEDactualCount·limit으로 “N건 → 1,000건 이하로 좁혀 주세요” 안내를 조립해 표시
    파라미터 조합 불가 (클라이언트가 고칠 것)JOB_POSTING_SORT_NOT_APPLICABLE사용자 안내가 아니라 개발 오류. 일반 오류 토스트 + 로깅
    미구현 필터 (서버가 아직 제공하지 않음)JOB_POSTING_FILTER_NOT_SUPPORTED”이 필터는 아직 지원되지 않습니다” 안내. 해당 토글을 비활성 처리하고 사용자에게 재시도를 유도하지 않는다
    일반 검증 실패VALIDATION_FAILED(빈 검증), BAD_REQUEST(형식·필수 누락)기존 처리 유지

    size 범위 초과·enum 형식 오류처럼 기존에 BAD_REQUEST로 수렴하던 것은 그대로 둡니다 — 그것은 실제로 클라이언트가 만들 수 없는 요청이라 분류가 필요 없습니다.

  • 시간은 전부 ZonedDateTime ISO-8601 문자열(JacksonConfig.kt:15-19 — 타임스탬프 직렬화 비활성).

  • 날짜만 필요한 필드(지원 목표일)는 LocalDate (yyyy-MM-dd).

  • 인증: /api/auth/login/api/webhooks/**를 제외한 모든 /api/**가 세션 쿠키를 요구합니다. 미인증은 401 UNAUTHENTICATED.

  • 페이지네이션 봉투(신규 공통 타입): PageResponse<T> { items: T[], page: number, size: number, totalCount: number, hasNext: boolean }. 기존 엔드포인트에는 적용하지 않습니다(하위 호환).

신규 에러 코드 (GlobalExceptionHandler 확장분)

codestatus발생 조건
UNAUTHENTICATED401세션 쿠키 없음·만료·무효
INVALID_CREDENTIAL401로그인 아이디/비밀번호 불일치
LOGIN_LOCKED429연속 5회 실패로 잠금 (응답에 retryAfterSeconds 없음 — 메시지에 해제 시각 포함)
WEBHOOK_SIGNATURE_INVALID401HMAC 검증 실패 (사유는 응답에 노출하지 않음 — 로그·이력에만)
DEDUP_GROUP_NOT_FOUND404존재하지 않는 중복 그룹
WATCH_STATE_INVALID_FIELD400EXCLUDED가 아닌데 제외 사유 입력, 태그 11개 이상 등
FEATURE_DISABLED409피처 플래그 OFF로 기능이 아직 열리지 않음 (단계 1부터 필요watchlist.management)
JOB_POSTING_SORT_NOT_APPLICABLE400정렬·필터 파라미터 조합 불가 (sort=PRIORITY_DESC + watchedOnly=false)
WATCH_STATE_FILTER_LIMIT_EXCEEDED400관리 상태 기반 필터·정렬의 대상 그룹이 상한(1,000) 초과
JOB_POSTING_FILTER_NOT_SUPPORTED400제공하지 않는 필터를 요청 (현재 matchedOnly=true — 계약에서 제거 확정). 조합 불가(JOB_POSTING_SORT_NOT_APPLICABLE)와 별도 코드 — FE 처리가 다르다
RECOMMENDATION_TARGET_LIMIT_EXCEEDED400재평가 대상 공고가 상한(1,000) 초과
DOCUMENT_SERIES_NOT_FOUND404문서 계열 없음
DOCUMENT_VERSION_NOT_FOUND404문서 버전 없음
DOCUMENT_EXTENSION_NOT_ALLOWED400pdf·docx·md·hwp
DOCUMENT_SIZE_EXCEEDED40020MB 초과
DOCUMENT_PATH_ESCAPE400새니타이즈 후에도 저장 루트를 벗어나는 경로
DOCUMENT_ALREADY_SUBMITTED409같은 지원 건에 같은 버전 중복 연결
RESUME_TEXT_EXTRACTION_FAILED200 (에러 아님)응답 필드로 표현profileDraft: null + extractionFailed: true (시나리오 13: 등록 자체는 성공)
RESUME_PROFILE_NOT_CONFIRMED409확정 프로필 없이 추천도 평가 요청
RECOMMENDATION_WEIGHT_INVALID400축 가중치 합계 ≠ 100 또는 축 4종 미비
RECOMMENDATION_NOT_FOUND404평가 결과 없음
WEBHOOK_RECEIVE_CONFLICT503provider_message_id 유니크 위반 경합 — 재시도하면 해소된다. 이 경로는 수신 이력을 남기지 못한다(트랜잭션 오염, 계약 2)
CONTACT_EVENT_NOT_FOUND404연락 이벤트 없음
CONTACT_EVENT_ALREADY_DECIDED409PENDING이 아닌 이벤트에 결정 요청
CONTACT_EVENT_TARGET_TERMINAL409종료 상태 지원 건에 반영 시도 (FR-90·시나리오 15)

1단계 — 인증 (FR-70)

POST /api/auth/login
  Request  { username: string, password: string }
  Response 200 { username: string, expiresAt: string }
           + Set-Cookie: RECRUITMENT_SESSION=<opaque>; HttpOnly; Path=/; SameSite=Lax[; Secure]
  Error    401 INVALID_CREDENTIAL / 429 LOGIN_LOCKED

POST /api/auth/logout
  Response 204 + Set-Cookie: RECRUITMENT_SESSION=; Max-Age=0
  (세션이 이미 없어도 204 — 멱등)

GET  /api/auth/session
  Response 200 { authRequired: boolean, authenticated: boolean,
                 username: string | null, expiresAt: string | null }
  Error    401 UNAUTHENTICATED   (auth.required=ON 이고 세션이 없거나 만료된 경우에만)
auth.required 플래그 OFF 구간의 세션 계약 (C-4 — 배포 1-8 ~ 1-9 창의 모순 제거)

Release Scenario에서 FE 배포(1-8)가 인증 강제(1-9)보다 먼저입니다. 그 사이 구간에서 “로그인 화면은 떠 있는데 API는 열려 있는” 모순이 생기지 않도록, GET /api/auth/session플래그 상태 자체를 응답합니다.

auth.required세션 쿠키응답
OFF유무 무관200 { authRequired: false, authenticated: false, username: null, expiresAt: null }
ON없음·만료·무효401 UNAUTHENTICATED (+ Set-Cookie: Max-Age=0)
ON유효200 { authRequired: true, authenticated: true, username, expiresAt }
  • FE 규칙: authRequired === false이면 인증 게이트를 통과시키고 로그인 화면을 띄우지 않습니다. 401이면 로그인 화면으로 보냅니다.
  • /api/auth/session은 인증 제외 경로가 아닙니다 — 플래그 ON + 세션 없음일 때 필터가 401을 내는 것이 곧 계약이기 때문입니다. 플래그 OFF면 필터가 그대로 통과시켜 컨트롤러가 authRequired: false를 반환합니다.
  • POST /api/auth/login은 플래그와 무관하게 항상 동작합니다 — 플래그 ON 전에 FE가 로그인 경로를 실제로 검증할 수 있어야 합니다.
  • 이 계약 덕분에 1-8과 1-9 사이 어느 시점에 스냅샷을 찍어도 FE·BE 상태가 일치하며, 1-9 롤백(플래그 OFF) 시 FE 재배포 없이 즉시 원복됩니다.

FE 구현 요구(senior-fe 인계)

  1. web/src/api/client.ts:20-47credentials: 'omit''same-origin'으로 변경해야 쿠키가 전송됩니다. 직접 fetch를 쓰는 2곳(web/src/api/company/registration.ts:79-111, web/src/api/application/create.ts:81-113)도 동일하게 변경해야 합니다.
  2. 401 수신 시 로그인 화면으로 유도 — ApiError.code === 'UNAUTHENTICATED'로 판별합니다.
  3. 앱 부팅 시 GET /api/auth/session 1회로 인증 상태를 확인합니다(쿠키가 HttpOnly라 JS가 읽을 수 없음).
  4. 기존 11개 라우트를 인증 게이트 뒤로 옮깁니다(web/src/routes.tsx:18-56).

1단계 — 관리 상태 (FR-73~77)

PUT  /api/dedup-groups/{dedupGroupId}/watch-state
  Request  {
    status: 'INTERESTED' | 'PLANNED' | 'EXCLUDED',
    priority?: 'HIGH' | 'NORMAL' | 'LOW',        // 미지정 시 기존 유지, 신규면 NORMAL
    targetApplyDate?: string | null,             // yyyy-MM-dd
    memo?: string | null,                        // 최대 1000자
    tags?: string[] | null,                      // 최대 10개, 각 30자. 전량 교체
    exclusionReason?: string | null              // status=EXCLUDED일 때만 허용, 최대 200자
  }
  Response 200 WatchStateResponse
  Error    404 DEDUP_GROUP_NOT_FOUND / 400 WATCH_STATE_INVALID_FIELD

GET  /api/dedup-groups/{dedupGroupId}/watch-state
  Response 200 { dedupGroupId: number, watchState: WatchStateResponse | null }
                 // watchState=null 이 곧 "미분류" — 404가 아니다 (C)
  Error    409 FEATURE_DISABLED   (watchlist.management 플래그 OFF)
           404 DEDUP_GROUP_NOT_FOUND

DELETE /api/dedup-groups/{dedupGroupId}/watch-state
  Response 204   (관리 상태 해제 — 이력은 보존. 이미 없어도 204, 멱등)

GET  /api/dedup-groups/{dedupGroupId}/watch-state/histories
  Response 200 { histories: WatchStateHistoryResponse[] }

WatchStateResponse {
  dedupGroupId: number,
  status: 'INTERESTED' | 'PLANNED' | 'EXCLUDED',
  priority: 'HIGH' | 'NORMAL' | 'LOW',
  targetApplyDate: string | null,
  memo: string | null,
  tags: string[],
  exclusionReason: string | null,
  hasApplication: boolean,          // FR-73 — 지원 완료는 파생 표시
  updatedAt: string
}

WatchStateHistoryResponse {
  previousStatus: 'INTERESTED' | 'PLANNED' | 'EXCLUDED' | null,
  nextStatus: 'INTERESTED' | 'PLANNED' | 'EXCLUDED',
  reason: string | null,
  changedAt: string
}
“미분류”와 “기능 준비 중”의 구분 (C)

배포 1단계는 플래그를 순차로 켜므로(1-7 watchlist.management ON) 두 상태가 실제로 공존하는 구간이 있습니다. 같은 404로 내려오면 FE가 “기능 준비 중”과 “아직 분류 안 함”을 다르게 표시할 수 없습니다.

상황응답FE 표시
미분류 — 기능은 켜졌고 이 그룹에 관리 상태만 없음200 { dedupGroupId, watchState: null }관리 상태 선택 UI를 활성 상태로 노출 (관심/지원 예정/제외 버튼)
기능 준비 중watchlist.management OFF409 FEATURE_DISABLED관리 상태 섹션 자체를 숨김(또는 “준비 중” 안내)
그룹 자체가 없음404 DEDUP_GROUP_NOT_FOUND오류 처리
  • 이 결정으로 WATCH_STATE_NOT_FOUND 코드는 쓰이는 곳이 없어져 제거했습니다 — DELETE는 없어도 204(멱등), 이력 조회는 없으면 빈 배열입니다. 죽은 코드를 계약에 남기지 않습니다.
  • FEATURE_DISABLED단계 1부터 필요하므로 FeatureDisabledException 정의·핸들러를 BE-45(단계 1 공통 계약) 로 옮겼습니다. 최초 분해에서 BE-56(단계 2)에 둔 것은 순서 오류였습니다 — BE-56은 “이미 정의됨”만 참조합니다.
  • 공고 상세(C-1)의 watchState도 같은 규칙입니다 — 미분류면 null, 플래그 OFF면 상세 응답에서 필드를 아예 생략하지 않고 null로 두되 별도 조회가 409를 냅니다.

1단계 — 교차 회사 공고 목록 (FR-78, NFR-12)

GET /api/job-postings
  Query
    watchStatus     : string[]  (repeatable, INTERESTED|PLANNED|EXCLUDED). 미지정 = 관리 상태 필터 없음
    watchedOnly     : boolean   (default false). true면 관리 상태가 있는 그룹만
    companyId       : number[]  (repeatable). 미지정 = 전 회사 (C-3 추가)
    tag             : string[]  (repeatable). 하나라도 일치하는 그룹 (OR). watchedOnly를 함의 (D 추가)
    platform        : string[]  (repeatable, JobPlatform). 그룹 내 원본 하나라도 일치하면 포함(OR)
    postingStatus   : 'OPEN' | 'CLOSED'
    deadlineFrom    : string    (ISO-8601)
    deadlineTo      : string
    keyword         : string    (제목 부분 일치, 정규화 후 비교)
    sort            : 'DISCOVERED_DESC' | 'DEADLINE_ASC' | 'TITLE_ASC' | 'PRIORITY_DESC'
                                (default 'DISCOVERED_DESC')
    page            : number    (0-based, default 0)
    size            : number    (default 20, max 100)
  Response 200 PageResponse<CrossCompanyJobPostingItem>
  Error    400 BAD_REQUEST                        (enum 형식 오류·size 범위 초과)
           400 JOB_POSTING_SORT_NOT_APPLICABLE   (PRIORITY_DESC·tag + watchedOnly=false)
           400 WATCH_STATE_FILTER_LIMIT_EXCEEDED (+actualCount/limit — 대상 그룹 1,000 초과)
           400 JOB_POSTING_FILTER_NOT_SUPPORTED  (matchedOnly=true — 계약에서 제거된 파라미터)
           409 FEATURE_DISABLED                  (watchlist.management OFF + 관리 상태 파라미터를
                                                  **명시**한 경우에만. 파라미터 미지정이면 관리 상태
                                                  필드만 null로 강등되고 200이다 — Release 1-7 이전에도
                                                  교차 목록은 열려 있어야 한다)

CrossCompanyJobPostingItem {
  jobPostingId: number,
  dedupGroupId: number | null,
  companyId: number,
  companyName: string,
  companyOrigin: 'WATCHED' | 'DISCOVERED',
  title: string,
  postingUrl: string,
  platform: string | null,                  // MANUAL은 null
  groupPlatforms: string[],                 // 그룹 내 전체 플랫폼(중복 제거)
  postingStatus: 'OPEN' | 'CLOSED',
  deadlineAt: string | null,
  firstSeenAt: string,
  changedAt: string | null,
  accessRestricted: boolean,
  postingOrigin: 'AUTO' | 'MANUAL',
  matched: boolean,
  matchScore: number | null,                // 3단계 배포 이후에만 채워짐
  workArrangement: { keyword: string, confidence: string } | null,
  watchStatus: 'INTERESTED' | 'PLANNED' | 'EXCLUDED' | null,
  watchPriority: 'HIGH' | 'NORMAL' | 'LOW' | null,
  targetApplyDate: string | null,
  tags: string[],
  applicationId: number | null,
  applicationStatus: string | null,
  recommendation: {                         // 2단계 배포 이후에만 non-null
    totalScore: number | null,
    grade: 'STRONGLY_RECOMMENDED' | 'RECOMMENDED' | 'REVIEW' | 'NOT_RECOMMENDED' | null,
    capApplied: boolean,
    capReleased: boolean,
    notEvaluableReason: 'JD_UNAVAILABLE' | 'PROFILE_NOT_CONFIRMED' | null
  } | null
}
  • 모든 정렬은 마지막에 id를 붙여 페이지 간 안정성을 보장합니다.
  • companyId 필터(C-3)job_postings.company_id가 posting 자기 컬럼이고 idx_job_postings_company_status_seen(baseline...sql:97)이 이미 존재해 추가 인덱스 없이 성립합니다. (PRD 본문 FR-78의 필터 목록에는 회사가 빠져 있으나, FR-50 “공고와 지원 이력은 회사 단위로 분류해 조회할 수 있다”와 docs/PRD.md의 회사 필터 요구를 근거로 추가합니다.)
미채택 파라미터 — matchedOnly (계약에서 제거 확정, 2026-08-10)

최초 API 계약 초안에 matchedOnly(매칭 성립 공고만)를 넣었으나 계약에서 제거합니다. 구현을 미룬 것이 아니라 넣지 않기로 결정한 것입니다.

기각 근거내용
PRD 근거 부재FR-78이 열거한 필터는 “관리 상태·플랫폼·마감일·키워드”입니다. 매칭 여부는 없습니다. 이 파라미터는 PRD 요구가 아니라 제가 계약 초안에 추가한 것이었습니다
FR-25 원칙에 역행”매칭에 실패한 공고도 저장한다 — 매칭은 알림 발송 조건일 뿐 저장 조건이 아니다”. 목록에서 미매칭 공고를 감추는 필터는 이 원칙과 방향이 반대입니다. 저장의 목적이 “키워드를 바꾼 뒤 과거 공고를 다시 찾는 것”(FR-25)이기 때문입니다
비용이 이득을 초과matching이 “매칭 성립 id 전량 + 상한”을 새로 노출해야 하고, watchlist 그룹 id 경로와 함께 id 집합 2개 + 상한 2개를 동시에 조합해야 합니다. 얻는 것은 이진 토글 하나입니다
더 나은 대체 수단이 이미 계약에 있음응답에 matched: boolean이 있고 3단계부터 matchScore가 채워집니다. 이진 필터보다 정렬·표시가 FR-86의 취지(“어느 필드에서 얼마나 매칭됐는지”)에 부합합니다

그러나 matchedOnly=true 요청은 400 JOB_POSTING_FILTER_NOT_SUPPORTED로 계속 거부합니다 — 계약에서 뺐다고 방어를 걷어내지 않습니다. 이유: ① 계약 초안을 본 FE가 보낼 수 있고, 조용히 무시되면 필터 없는 전체 목록이 200으로 돌아가 토글이 오작동합니다 ② 이 에러 코드는 미래의 다른 미채택·미구현 필터에 재사용됩니다. 이미 구현·검증된 방어를 걷어내는 비용이 남겨두는 비용보다 큽니다.

정렬 성능 노트 (B-2 — dba 실측 반영)
정렬인덱스 활용비고
DISCOVERED_DESC (기본값)first_seen_at B-tree로 조기 종료 가능기본 정렬을 이것으로 고정합니다
DEADLINE_ASC조기 종료 불가deadline_at이 실측 71.4% NULL(4,738/6,633)이고, 마감일 없는 공고를 뒤로 보내려면 ORDER BY deadline_at IS NULL, deadline_at 표현식 정렬이 필요해 B-tree가 순서를 주지 못합니다. 필터로 후보를 좁힌 뒤 정렬하는 경로로만 사용합니다
TITLE_ASC조기 종료 불가제목 인덱스를 만들지 않습니다(쓰기 비용 대비 사용 빈도 낮음)
PRIORITY_DESC아래 별도 규칙

정렬 인덱스는 deadline_at이 아니라 first_seen_at 기준으로 만듭니다.

sort=PRIORITY_DESC의 페이지네이션 규칙 (C-6 — dba 제안 채택)

정렬 키(watch_priority)가 다른 컨텍스트 테이블(job_posting_watch_states)에 있어 posting이 조인할 수 없습니다(no-crosscontext-raw-read). 다음 규칙으로 해결합니다.

  1. watchedOnly=true를 강제합니다. 미지정이면 서버가 true로 강제 적용하고, 명시적으로 false를 보내면 400 JOB_POSTING_SORT_NOT_APPLICABLE 입니다 — 관리 상태가 없는 공고는 우선순위 자체가 없어 정렬 대상이 아닙니다.
  2. application 레이어가 watchlist DomainService에서 우선순위 정렬된 그룹 id 목록(HIGH → NORMAL → LOW, 동순위는 updated_at 내림차순)을 받습니다.
  3. 그 목록을 posting 검색에 필터(IN)와 정렬 기준(CASE 순서)으로 동시에 넘겨, 나머지 posting 필터(companyId·platform·마감일·키워드)와 LIMIT/OFFSET한 쿼리에서 적용합니다. 최종 tie-break는 id 오름차순입니다.
  4. 그룹 id 목록 상한 1,000건 — 초과하면 400 WATCH_STATE_FILTER_LIMIT_EXCEEDED(+actualCount·limit)로 거부하고 필터를 좁히도록 안내합니다. 단일 사용자가 관심·지원 예정으로 큐레이션하는 규모(NFR-1: 관심 회사 10~20곳)에서 현실적으로 도달하지 않는 값이며, 무한히 커지는 IN 절을 구조적으로 막습니다.

이 방식은 정렬 순서 계산을 소유 컨텍스트(watchlist)에 두고 posting은 “주어진 순서대로 정렬”만 하므로 컨텍스트 경계를 지키면서 페이지네이션 정합성을 보장합니다.

태그 필터 (D — 채택)

판단: 채택합니다. FR-77이 개인 태그 등록을 요구하고 FE가 이미 태그를 표시하는데 거를 수 없으면 기능이 반쪽입니다.

  • 채택 근거 — 한계 비용이 거의 0입니다. 태그(job_posting_watch_tags)도 우선순위와 똑같이 watchlist 컨텍스트 소유라 조인이 금지되는데, 바로 위 PRIORITY_DESC가 만든 “watchlist가 그룹 id 목록을 해석해 posting에 넘긴다”는 기계를 그대로 재사용하면 됩니다. 새 구조·새 인덱스·새 상한이 필요 없습니다.
  • (PRD 본문 FR-78의 필터 목록에는 태그가 없습니다. FR-77의 태그 등록 요구 + FE 표시 요구를 근거로 추가하며, companyId(C-3)와 같은 성격의 보강입니다.)
    tag             : string[]  (repeatable). 지정한 태그를 **하나라도** 가진 그룹 (OR 매칭)
  • 태그 필터는 watched 범위를 함의합니다 — 태그는 관리 상태가 있어야만 존재하므로, tag가 지정되면 watchedOnlytrue로 강제합니다(PRIORITY_DESC와 동일 규칙, 명시적 falseJOB_POSTING_SORT_NOT_APPLICABLE).
  • 같은 1,000건 상한을 공유합니다 — 태그 필터로 해석된 그룹 id가 1,000을 넘으면 WATCH_STATE_FILTER_LIMIT_EXCEEDED입니다.
  • 태그 값은 저장 시와 동일하게 정규화(trim·대소문자 보존)해 비교합니다.
  • recommendation·matchScore단계별로 점진 노출됩니다. 1단계 배포 시점에는 각각 null을 내려 FE가 하위 호환으로 동작합니다.

1단계 — 공고 상세 응답 확장 · 공고 기준 관리 상태 저장 (C-1 — FE 블로커 해소)

문제: 관리 상태는 dedupGroupId로만 저장할 수 있는데, 기존 공고 상세 응답(JobPostingDetailResponse.kt:14-20)에 그 값이 없습니다. 디스코드 딥링크·직접 URL로 상세 화면에 진입하면 FE가 그룹 id를 알 수 없어 관리 상태 섹션이 동작하지 않습니다.

기존 응답에 필드 2개를 추가합니다(하위 호환 — 추가만).

GET /api/job-postings/{jobPostingId}   (기존 엔드포인트 — 실제 코드 경로. 최초 문서의 `/api/companies/{companyId}/job-postings/{jobPostingId}` 표기는 오류였다)
  Response 200 JobPostingDetailResponse
    ... 기존 필드 전부 유지 ...
  + dedupGroupId : number | null      // 그룹 미형성(08:30 배치 전)이면 null
  + watchState   : WatchStateResponse | null   // 관리 상태 미등록이면 null
  + matchFieldEvidences : MatchFieldEvidenceResponse[]   // 3단계 전에는 빈 배열 (C-2, 아래)
  + matchScore     : number | null    // 3단계 전에는 null
  + matchThreshold : number | null    // 3단계 전에는 null

watchState를 함께 내려 상세 화면이 왕복 2회를 하지 않게 합니다.

dedupGroupIdnull일 때의 처리 — 조회 시 생성하지 않습니다.

후보판정
GET 응답 시점에 그룹을 만든다미채택 — 조회가 쓰기를 하면 GET의 멱등·캐시 가정이 깨지고, 08:30 배치가 아직 보지 못한 공고에 그룹을 선점해 그룹 정체성이 흔들립니다
null을 내리고 FE가 관리 상태 섹션을 숨긴다미채택 (단독으로는) — 신규 수집 공고는 다음 08:30 배치까지 최대 24시간 동안 관심 표시가 불가능해 FR-73의 사용 흐름이 끊깁니다
쓰기 시점에 그룹을 보장하는 별도 엔드포인트를 둔다채택
PUT /api/job-postings/{jobPostingId}/watch-state
  Request  (기존 WatchState upsert 본문과 동일)
  Response 200 WatchStateResponse   (dedupGroupId 포함 — FE가 이후 그룹 기준 API로 전환)
  Error    409 POSTING_DEDUP_KEY_ABSENT  (dedup_key가 없는 공고 — 백필 전 잔존분)
           404 JOB_POSTING_NOT_FOUND
  • 이 엔드포인트는 쓰기 요청이므로 그룹 생성이 정당합니다. 대상 공고에 그룹이 없으면 그 공고의 dedup_key단독(singleton) 그룹을 만든 뒤 관리 상태를 저장합니다.
  • 이렇게 만든 단독 그룹은 다음 08:30 배치의 그룹 정체성 승계 규칙(방안 3)에 그대로 올라탑니다 — 배치가 계산한 클러스터가 이 공고를 포함하므로 멤버 교집합이 1 이상이 되어 기존 그룹이 재사용되고 관리 상태가 보존됩니다. 별도 이관 로직이 필요 없습니다.
  • dedup_key가 없는 공고(1단계 백필 이전에 생성된 MANUAL 잔존분)는 409로 거부합니다. 백필(1-4) 완료 후에는 발생하지 않습니다.

3단계 — 매칭 근거 노출 (C-2 — FR-86 “어느 필드에서 일치했는지” 요구)

job_posting_match_field_evidences에 저장한 근거를 응답으로 노출합니다. 공고 상세와 교차 목록 양쪽에 적용합니다.

MatchFieldEvidenceResponse {
  matchField: 'TITLE' | 'SOURCE_TAG' | 'DESCRIPTION_BODY',
  jobKeywordGroupId: number,
  jobKeywordGroupName: string,      // 화면이 그룹 id를 라벨로 바꾸지 않아도 되게 함께 내린다
  weightPoints: number,             // 이 필드가 기여한 점수 (60 / 35 / 20)
  occurrenceCount: number,          // 유효 출현 횟수 (본문 신호는 2 이상이어야 인정)
  snippet: string                   // 근거 발췌 (최대 200자)
}
  • 공고 상세: matchFieldEvidences[] + matchScore + matchThreshold + matched + excludedByKeyword. 판정 재현성을 위해 점수와 판정 당시 임계치를 함께 내립니다 — 임계치가 바뀌어도 과거 판정을 설명할 수 있습니다.
  • 미매칭 공고에서도 matchFieldEvidences가 채워집니다 (A-2 결정 ⓑ) — match_score > 0이면 저장하므로, “제목 0 + 태그 35 = 50 미달”을 근거 배열로 설명합니다. 배열이 비는 경우는 어느 필드에서도 전혀 걸리지 않은 공고(matchScore: 0)뿐입니다.
  • 교차 목록: matchScore·matched만 채웁니다(항목 수 × 근거 수 만큼 응답이 커지므로 근거 배열은 상세에서만 노출).
  • 매칭 여부는 matched로 판단합니다matchScore >= matchThreshold 비교는 제외 키워드 케이스를 놓칩니다(위 “매칭/미매칭의 구분” 참조).
excludedByKeyword: { keywordId: number, keyword: string } | null   // 미매칭 사유가 제외 키워드일 때만
  • 단계별 점진 노출 — 3단계 배포 전에는 matchFieldEvidences: [], matchScore: null, matchThreshold: null, excludedByKeyword: null입니다. FE는 matchScore === null로 “미배포”를, matchScore === 0으로 “근거 없음”을 구분합니다.

1단계 — 대시보드 (FR-79)

GET /api/dashboard
  Query  upcomingInterviewDays : number (default 14, 1..90)
         staleThresholdDays    : number (default 14, 1..90)
  Response 200 DashboardResponse

DashboardResponse {
  statusBoard: {
    status: string,               // ApplicationStatus
    count: number,
    items: DashboardApplicationCard[]     // 상태별 최대 20건, 최근 전이 순
  }[],
  upcomingInterviews: {
    jobApplicationId: number,
    companyName: string,
    jobPostingTitle: string,
    roundNumber: number,
    roundLabel: string,
    scheduledAt: string,
    daysUntil: number
  }[],
  staleApplications: {
    jobApplicationId: number,
    companyName: string,
    jobPostingTitle: string,
    applicationStatus: string,
    lastTransitedAt: string,
    daysSinceLastChange: number
  }[]
}

DashboardApplicationCard {
  jobApplicationId: number,
  jobPostingId: number,
  companyName: string,
  jobPostingTitle: string,
  applicationStatus: string,
  appliedAt: string,
  lastTransitedAt: string,
  deadlineAt: string | null,
  allowedNextStatuses: string[]
}
  • statusBoard진행 중 4종(APPLIED·DOCUMENT_SCREENING·INTERVIEWING·OFFERED)을 항상 포함하고(0건이어도 빈 배열), 종료 4종은 count가 0보다 클 때만 포함합니다 — 칸반 컬럼이 사라졌다 생겼다 하지 않게 합니다.
  • staleApplications는 진행 중 상태만 대상이며 lastTransitedAtstaleThresholdDays 이전인 건입니다(FR-79 ③).

1단계 — 담당자 (FR-80)

POST   /api/applications/{applicationId}/contacts
  Request  { name: string, organization?: string|null, emailAddress?: string|null,
             phoneNumber?: string|null, memo?: string|null }
  Response 201 ApplicationContactResponse
  Error    404 APPLICATION_NOT_FOUND / 400 VALIDATION_FAILED

GET    /api/applications/{applicationId}/contacts
  Response 200 { contacts: ApplicationContactResponse[] }

PUT    /api/applications/{applicationId}/contacts/{contactId}
  Request  (POST와 동일)
  Response 200 ApplicationContactResponse

DELETE /api/applications/{applicationId}/contacts/{contactId}
  Response 204

ApplicationContactResponse {
  contactId: number, jobApplicationId: number, name: string,
  organization: string | null, emailAddress: string | null,
  phoneNumber: string | null, memo: string | null,
  createdAt: string, updatedAt: string
}
  • emailAddress는 FR-91의 담당자 일치 20점 축 판정 키입니다. 소문자 정규화해 저장합니다.

1단계 — 운영 조회 확장

GET /api/operations/webhook-receipts
  Query  days: number (default 7, 1..30), result: 'OK'|'SIGNATURE_MISMATCH'|'TIMESTAMP_OUT_OF_RANGE'|'NONCE_REUSED'
         page, size
  Response 200 PageResponse<WebhookReceiptResponse>

WebhookReceiptResponse {
  receiptId: number, endpoint: string,
  verificationResult: string, providerMessageId: string | null,
  receivedAt: string
}

GET /api/operations/tunnel-status
  Response 200 { reachable: boolean, checkedAt: string, publicHostname: string | null }
  • tunnel-status는 앱이 자신의 공개 호스트명으로 GET /api/health-probe를 1회 호출해 왕복 가능 여부를 확인합니다(타임아웃 3초). 호스트명이 설정되지 않았으면 reachable=false, publicHostname=null.

2단계 — 지원 서류 (FR-81~85)

POST /api/documents
  Content-Type: multipart/form-data
  Parts
    file        : binary (필수, 최대 20MB, 확장자 pdf|docx|md|hwp)
    documentType: 'RESUME'|'COVER_LETTER'|'PORTFOLIO'|'ETC' (필수)
    seriesId    : number (선택 — 기존 계열에 새 버전 추가)
    seriesTitle : string (선택 — seriesId 없을 때 필수, 신규 계열 제목, 최대 100자)
  Response 201 DocumentVersionResponse
  Error    400 DOCUMENT_EXTENSION_NOT_ALLOWED / DOCUMENT_SIZE_EXCEEDED / DOCUMENT_PATH_ESCAPE
           404 DOCUMENT_SERIES_NOT_FOUND

GET  /api/documents/series
  Query  documentType?: string
  Response 200 { series: DocumentSeriesResponse[] }

GET  /api/documents/series/{seriesId}
  Response 200 DocumentSeriesDetailResponse

GET  /api/documents/versions/{versionId}/content
  Response 200 (application/octet-stream, Content-Disposition: attachment)
  Error    404 DOCUMENT_VERSION_NOT_FOUND

DocumentSeriesResponse {
  seriesId: number, documentType: string, seriesTitle: string,
  latestVersionNumber: number, versionCount: number,
  createdAt: string, updatedAt: string
}

DocumentSeriesDetailResponse {
  seriesId: number, documentType: string, seriesTitle: string,
  versions: DocumentVersionResponse[]
}

DocumentVersionResponse {
  versionId: number, seriesId: number, versionNumber: number,
  originalFileName: string, fileExtension: string, fileSizeBytes: number,
  storedRelativePath: string,          // 저장 루트 기준 상대 경로 (사용자가 파인더로 찾을 수 있게)
  uploadedAt: string,
  isCurrentProfileSource: boolean      // 2단계 FR-95 — 현재 프로필의 원본인지
}

저장 경로 규칙 (FR-82): {DOCUMENT_ROOT}/{문서유형}/{YYYY-MM-DD}_{문서제목}_v{버전}_{원본파일명} 예: {root}/이력서/2026-08-08_백엔드_이력서_v3_resume.pdf

2단계 — 서류 업로드 실패 이력 조회 (A-3 — PRD Operations 요구)

GET /api/operations/document-upload-failures
  Query  reasonCode?: 'EXTENSION_NOT_ALLOWED'|'SIZE_EXCEEDED'|'PATH_VALIDATION_FAILED'
                     |'STORAGE_ROOT_ESCAPE'|'STORAGE_IO_FAILED'|'OTHER'
         days?: number (default 30, 1..365)
         page, size
  Response 200 PageResponse<DocumentUploadFailureResponse>   // 최신순(occurredAt DESC, id DESC)

DocumentUploadFailureResponse {
  failureId: number,
  occurredAt: string,
  documentType: 'RESUME'|'COVER_LETTER'|'PORTFOLIO'|'ETC' | null,  // 유형 파싱 전 실패면 null
  seriesId: number | null,
  seriesTitle: string | null,
  originalFileName: string,
  reasonCode: string,
  detailMessage: string | null
}
  • 보존 정책: 사유별 2단 분리 (E — dba 확정, 최초 “일괄 무기한”에서 보강)

    대상정책
    보안 사유 2종 (PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE)무기한 — 삭제 금지. 침해 시도 추적 근거이므로 임계와 무관하게 보존
    나머지 5종테이블이 5만 행 임계 도달 시 400일 초과분 정리

    최초 설계의 “일괄 무기한”과 방향은 같고(기본 보존), 임계 기반 정리만 추가됐습니다. 발생 규모(월 수 건)상 임계 도달은 현실적으로 오지 않으므로 정리 배치를 선제 구현하지 않고, 임계 도달이 관측되면 그때 만듭니다. 조회는 days 필터로 범위를 좁힙니다.

  • seriesTitle은 document 컨텍스트 내부 조인이 아니라 seriesId 집합 배치 조회 1회로 채웁니다(N+1 금지).

  • 보안 사유 2종(PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE)은 반드시 기록합니다 — 사후 추적이 이 기능을 채택한 결정 사유입니다.

사유 코드 7종 — METADATA_PERSISTENCE_FAILED 분리 확정 (C)

최초 6종은 “파일 쓰기 성공 후 DB 실패 → 보상 삭제” 경로를 담을 자리가 없어 OTHER로 묻혔습니다. dba 지적대로 보상 삭제가 실제로 돌았는지 사후 구분이 불가능해지므로 별도 값으로 분리합니다. STORAGE_IO_FAILED가 이를 커버하지 못하는 이유는 두 사건의 파일 잔존 여부가 반대이기 때문입니다.

코드시점파일 상태보상 삭제
EXTENSION_NOT_ALLOWED검증미생성불필요
SIZE_EXCEEDED검증미생성불필요
PATH_VALIDATION_FAILED새니타이즈 (보안)미생성불필요
STORAGE_ROOT_ESCAPE루트 검증 (보안)미생성불필요
STORAGE_IO_FAILED파일 쓰기미생성 (쓰기 자체 실패)불필요
METADATA_PERSISTENCE_FAILED메타데이터 행 저장생성됐다가 삭제됨실행됨
OTHER

판별 기준 (구현자 필독)STORAGE_IO_FAILED = 파일 미생성(쓰기 자체 실패) / METADATA_PERSISTENCE_FAILED = 파일 생성 후 보상 삭제(쓰기는 성공, 메타데이터 행 저장 실패). 둘의 차이는 파일이 남았다 지워졌는가입니다.

명명 확정 근거 (중재 결과) — 후보 2개를 모두 기각하고 METADATA_PERSISTENCE_FAILED로 확정했습니다. STORAGE_WRITE_FAILED(dba 제안)는 “쓰기가 성공한 뒤 실패”인데 이름이 반대로 읽혀 STORAGE_IO_FAILED와 혼동됩니다. PERSISTENCE_FAILED(be 제안)는 “무엇의” 영속화인지가 빠져 no-over-abstract-name에 걸립니다 — 파일도 영속화이고 DB 행도 영속화입니다. 채택안은 PRD FR-4의 도메인 어휘(“DB에는 파일 바이너리가 아닌 경로와 메타데이터를 저장한다”)를 그대로 쓰며, 실패한 대상이 정확히 그 메타데이터 행입니다. 파일(STORAGE_*)과 메타데이터(METADATA_*)의 대비가 이름만으로 드러납니다.

2단계 — 지원 건 ↔ 서류 연결 (FR-85)

POST   /api/applications/{applicationId}/documents
  Request  { documentVersionId: number, submittedAt?: string }
  Response 201 SubmittedDocumentResponse
  Error    409 DOCUMENT_ALREADY_SUBMITTED / 404 APPLICATION_NOT_FOUND, DOCUMENT_VERSION_NOT_FOUND

GET    /api/applications/{applicationId}/documents
  Response 200 { documents: SubmittedDocumentResponse[] }

DELETE /api/applications/{applicationId}/documents/{submittedDocumentId}
  Response 204

SubmittedDocumentResponse {
  submittedDocumentId: number, jobApplicationId: number,
  documentVersionId: number, seriesId: number, seriesTitle: string,
  documentType: string, versionNumber: number, originalFileName: string,
  submittedAt: string
}
  • 제출 시점의 버전을 고정합니다 — 이후 계열에 새 버전이 생겨도 이 연결은 바뀌지 않습니다(FR-85).

2단계 — 이력서 프로필 (FR-95)

POST /api/resume-profiles/drafts
  Request  { documentVersionId: number }
  Response 201 ResumeProfileResponse
  Error    404 DOCUMENT_VERSION_NOT_FOUND

PUT  /api/resume-profiles/{profileId}
  Request  {
    skills: { skillName: string, usageMonths?: number|null,
              lastUsedYearMonth?: string|null, evidence?: string|null }[],
    experiences: { totalMonths: number, jobCategory: string, description?: string|null }[],
    workPreferences: { preferenceKeyword: string, required: boolean }[]
  }
  Response 200 ResumeProfileResponse
  Error    409 (이미 확정된 프로필 수정 시도)

POST /api/resume-profiles/{profileId}/confirmations
  Response 200 { profileId: number, criteriaRevision: number, confirmedAt: string,
                 reevaluatedCount: number, elapsedMillis: number }
  (확정 즉시 관심 공고 재평가를 동기 실행 — FR-95·NFR-13)

GET  /api/resume-profiles/current
  Response 200 ResumeProfileResponse
  Error    404 (확정 프로필 없음)

ResumeProfileResponse {
  profileId: number,
  sourceDocumentVersionId: number | null,
  confirmed: boolean,
  confirmedAt: string | null,
  extractionFailed: boolean,          // true면 텍스트 추출 실패 — 수동 입력 안내 (시나리오 13)
  skills: { skillName: string, usageMonths: number|null,
            lastUsedYearMonth: string|null, evidence: string|null }[],
  experiences: { totalMonths: number, jobCategory: string, description: string|null }[],
  workPreferences: { preferenceKeyword: string, required: boolean }[],
  updatedAt: string
}
  • POST /drafts텍스트 추출 실패해도 201을 반환하고 extractionFailed: true + 빈 항목 배열을 내립니다(시나리오 13 — “이력서 등록 자체는 저장된다”).
  • hwp는 텍스트 추출을 지원하지 않습니다 — 업로드·보관은 되지만 extractionFailed: true입니다.

2단계 — 지원 추천도 (FR-96)

GET  /api/job-postings/{jobPostingId}/recommendation
  Response 200 RecommendationResponse
  Error    404 RECOMMENDATION_NOT_FOUND

POST /api/recommendations/re-evaluations
  Request  { force?: boolean }        // default false — 현재 기준 버전으로 이미 평가된 건은 skip
  Response 200 { evaluatedCount: number, skippedCount: number,
                 notEvaluableCount: number, criteriaRevision: number,
                 elapsedMillis: number,        // 실행 소요 시간(밀리초) — C-5 실측 근거
                 failures: { jobPostingId: number, reason: string }[] }
  Error    409 RESUME_PROFILE_NOT_CONFIRMED
           409 FEATURE_DISABLED
           400 RECOMMENDATION_TARGET_LIMIT_EXCEEDED  (+actualCount/limit — 대상 1,000 초과)
  (대상 = 관리 상태 INTERESTED 또는 PLANNED인 그룹의 대표 공고 — FR-97)

RecommendationResponse {
  jobPostingId: number,
  criteriaRevision: number,
  totalScore: number | null,
  grade: 'STRONGLY_RECOMMENDED'|'RECOMMENDED'|'REVIEW'|'NOT_RECOMMENDED' | null,
  capApplied: boolean,
  capReleased: boolean,
  capReason: string | null,            // 상한이 걸린 이유 (해제해도 계속 노출 — FR-97)
  notEvaluableReason: 'JD_UNAVAILABLE' | 'PROFILE_NOT_CONFIRMED' | null,
  evaluatedAt: string,
  axes: {
    axis: 'REQUIRED_SKILL'|'PREFERRED_SKILL'|'CAREER_ROLE'|'WORK_CONDITION',
    judgeable: boolean,
    configuredWeight: number,          // 사용자 설정 가중치
    normalizedWeight: number,          // 판단 불가 축 제외 후 정규화된 비중
    fulfillmentRate: number | null,    // judgeable=false면 null
    items: {
      itemName: string,
      fulfillment: 'FULL'|'PARTIAL'|'NONE',
      reason: string
    }[]
  }[]
}
동기 재평가와 프록시 타임아웃 (C-5 — 확정: nginx 타임아웃 상향, 비동기 잡 미채택)

문제: NFR-13이 관심 공고 200건 재평가에 5분을 허용하는데, web/nginx.conf:10-15/api/ location에 proxy_read_timeout이 없어 nginx 기본값 60초가 적용됩니다. 동기 API로 두면 2단계에서 502가 납니다. 같은 노출이 POST /api/resume-profiles/{id}/confirmations(확정 시 동기 재평가)에도 있습니다.

방안설명판정
A. nginx 프록시 타임아웃 상향/api/ location에 proxy_read_timeout 600s · proxy_send_timeout 600s · proxy_connect_timeout 5s를 명시한다채택
B. 비동기 잡 큐 + 진행 조회재평가를 잡으로 등록하고 FE가 폴링한다미채택 — 잡 상태 테이블·폴링 엔드포인트·FE 폴링 로직이 따라온다. 실측 부하가 그 비용을 정당화하지 못한다: 200건 × 규칙 기반 계산(외부 호출 0회)은 초 단위이고, NFR-13의 5분은 상한이지 기대값이 아니다. NFR-8(브로커·워커 미도입)의 취지와도 어긋난다
C. 재평가를 배치로만 실행사용자 트리거를 없애고 야간 배치로미채택 — FR-95가 “확정 즉시 재평가”를 요구한다(시나리오 12-4)

채택안의 범위와 리스크

  • 변경 파일은 web/nginx.conf 한 개이고 BE-56(단계 2 공통 계약)이 단독 소유합니다 — FE 티켓은 이 파일을 건드리지 않아야 합니다(Single Writer, senior-fe 인계 사항).
  • /api/ 전체에 적용되므로 행이 걸린 요청이 nginx worker를 최대 10분 점유할 수 있습니다. 단일 사용자 환경이라 워커 고갈 위험이 없어 경로별 분리를 하지 않습니다(location 중복 정의로 프록시 헤더를 두 벌 관리하는 비용이 더 큽니다).
  • 서버 측 방어를 함께 둡니다 — 재평가 대상이 1,000건을 넘으면 400으로 거부하고 관리 상태 필터를 좁히도록 안내합니다. 상한 없이 열어두면 타임아웃을 아무리 늘려도 언젠가 넘습니다.
  • 응답 스키마에 elapsedMillis: number(밀리초) 를 포함해 실측을 남깁니다(POST /api/recommendations/re-evaluations·POST /api/resume-profiles/{id}/confirmations 양쪽). 3회 연속 60초를 넘기면 방안 B 재검토를 Open Questions 13으로 올립니다.
  • 상한 초과는 400 RECOMMENDATION_TARGET_LIMIT_EXCEEDED(+actualCount·limit)로 내려 FE가 좁히기 안내를 만들 수 있게 합니다.

3단계 — 가중치 수정·상한 해제 (FR-97)

GET  /api/recommendations/weights
  Response 200 { criteriaRevision: number,
                 weights: { axis: string, weight: number }[] }

PUT  /api/recommendations/weights
  Request  { weights: { axis: string, weight: number }[] }   // 4축 전부, 합계 100
  Response 200 { criteriaRevision: number, weights: { axis: string, weight: number }[] }
  Error    400 RECOMMENDATION_WEIGHT_INVALID

POST   /api/job-postings/{jobPostingId}/recommendation/cap-release
  Request  { reason?: string }
  Response 200 RecommendationResponse    // 재계산된 결과 (상한 해제 반영)

DELETE /api/job-postings/{jobPostingId}/recommendation/cap-release
  Response 200 RecommendationResponse    // 상한 재적용

3단계 — 연락 이벤트 웹훅 (FR-89·92)

POST /api/webhooks/contact-events
  Headers
    X-Recruitment-Signature : t={epochSeconds},v1={hex hmac-sha256}
    X-Recruitment-Nonce     : {UUID}
    Content-Type            : application/json
  Request  {
    channel: 'EMAIL' | 'SMS',
    providerMessageId: string,          // Gmail message id — 멱등 키
    threadId?: string | null,
    senderAddress: string,
    senderName?: string | null,
    subject?: string | null,
    bodyText: string,                   // 최대 20000자 (초과분은 발신 측이 절단)
    receivedAt: string,                 // ISO-8601
    attachments?: { fileName: string, mimeType: string, sizeBytes: number }[]
  }
  Response 202 { contactEventId: number, duplicated: boolean }
  Error    401 WEBHOOK_SIGNATURE_INVALID / 400 VALIDATION_FAILED

(인증 쿠키 불필요 — HMAC이 유일한 인증. 같은 providerMessageId 재수신 시 202 + duplicated:true)

Gmail Apps Script 계약 — 동작하는 스크립트를 산출물에 포함합니다 (A-1, 게이트 ② 결정 ⓐ)

PRD FR-92 문언(“Google Apps Script로 제공한다”)을 그대로 충족시키기 위해, 기존 “스크립트 자체는 범위 밖” 판단을 철회합니다. 3단계 완료 판정(“Gmail 웹훅 1건 수신 → 검토 화면 반영 왕복 성공”)이 사용자가 직접 .gs를 작성하는 것에 의존하면 자급자족하지 못하기 때문입니다.

산출물위치
스크립트 본문설계 초안 공고알림앱/gmail-forwarder/Code.gs → 구현 티켓이 레포 scripts/gmail-forwarder/Code.gs 로 배치
설치 절차 문서설계 초안 공고알림앱/gmail-forwarder/README.md → 레포 scripts/gmail-forwarder/README.md
담당 티켓BE-82 (3단계, JS라 Kotlin 티켓과 분리)

스크립트가 충족하는 계약은 아래 표와 1:1로 일치합니다(서명 대상 정규화 문자열·헤더 이름 포함). 어긋나면 서명 검증이 깨지므로 BE-82의 완료 기준을 “로컬 웹훅에 실제 1건 전송 성공 + 라벨 왕복 확인”으로 잡습니다.

항목규약
트리거시간 기반 5분 주기
조회 대상라벨 recruitment-app/inbox가 붙은 스레드만. 자동 키워드 검색 금지
전송 필드위 Request 스키마. 첨부는 메타데이터만, 원본 바이너리 미전송
서명hmacSha256(SHARED_SECRET, "{t}.{body}"), t는 전송 시각 epoch 초
nonce요청마다 새 UUID
성공 처리앱이 2xx를 반환한 뒤에만 recruitment-app/processed 라벨 부착 + inbox 라벨 제거
실패 처리2xx가 아니면 라벨을 그대로 두어 다음 트리거에서 재시도
재시도 안전성앱이 providerMessageId 유니크로 멱등 처리 — 중복 전송은 정상 시나리오

3단계 — 연락 검토 화면 (FR-90)

GET  /api/contact-events
  Query  reviewStatus?: 'PENDING'|'APPLIED'|'IGNORED', days?: number (default 30),
         page, size
  Response 200 PageResponse<ContactEventListItem>

GET  /api/contact-events/{contactEventId}
  Response 200 ContactEventDetailResponse
  Error    404 CONTACT_EVENT_NOT_FOUND

POST /api/contact-events/{contactEventId}/decisions
  Request  {
    decision: 'APPLY' | 'IGNORE' | 'REASSIGN',
    jobApplicationId?: number,          // APPLY·REASSIGN 시 필수
    targetStatus?: 'DOCUMENT_SCREENING'|'INTERVIEWING'|'OFFERED'|'REJECTED',
    interview?: {                       // 지정 시 면접 회차 등록
      roundLabel: string,
      scheduledAt?: string | null,
      memo?: string | null
    } | null,
    memo?: string | null
  }
  Response 200 ContactEventDetailResponse
  Error    409 CONTACT_EVENT_ALREADY_DECIDED
           409 CONTACT_EVENT_TARGET_TERMINAL
           409 TRANSITION_NOT_ALLOWED     (기존 코드 재사용 — canTransitTo 위반)
           404 APPLICATION_NOT_FOUND

ContactEventListItem {
  contactEventId: number, channel: string, senderName: string | null,
  senderAddress: string, subject: string | null, receivedAt: string,
  reviewStatus: 'PENDING'|'APPLIED'|'IGNORED',
  topCandidateConfidence: 'HIGH'|'MEDIUM'|'LOW' | null,
  topCandidateCompanyName: string | null
}

ContactEventDetailResponse {
  contactEventId: number,
  channel: string,
  senderName: string | null,
  senderAddress: string,
  subject: string | null,
  bodyText: string,                     // 원문 (최대 20000자)
  receivedAt: string,
  reviewStatus: 'PENDING'|'APPLIED'|'IGNORED',
  attachments: { fileName: string, mimeType: string, sizeBytes: number }[],
  parse: {
    extractedCompanyName: string | null,
    extractedPostingTitle: string | null,
    extractedStageKeyword: string | null,
    suggestedStatus: string | null,     // DOCUMENT_SCREENING|INTERVIEWING|OFFERED|REJECTED 중 하나
    suggestedInterviewAt: string | null,
    suggestedRoundLabel: string | null  // 추출 전형명, 없으면 "{N}차 면접"
  } | null,
  candidates: {
    jobApplicationId: number,
    companyName: string,
    jobPostingTitle: string,
    applicationStatus: string,
    confidenceScore: number,
    confidenceLevel: 'HIGH'|'MEDIUM'|'LOW',
    companyMatched: boolean,
    titleMatched: boolean,
    contactMatched: boolean,
    recentlyApplied: boolean,
    terminal: boolean,                  // true면 반영 불가 (시나리오 15)
    allowedNextStatuses: string[],      // canTransitTo 결과 — 화면이 선택지를 제한
    nextInterviewRoundNumber: number    // 기존 최대 회차 + 1
  }[],
  autoSelectedJobApplicationId: number | null,   // 55점 미만·동점이면 null (수동 선택 요구)
  decision: {
    decisionType: string,
    selectedJobApplicationId: number | null,
    appliedStatus: string | null,
    createdInterviewId: number | null,
    memo: string | null,
    decidedAt: string
  } | null
}

3단계 — 소스 상태 레지스트리 (FR-88)

GET /api/operations/source-registry
  Response 200 { sources: SourceRegistryResponse[] }

SourceRegistryResponse {
  jobSourceId: number, platform: string, sourceType: 'COMPANY_BOUND'|'AGGREGATOR',
  companyId: number | null, companyName: string | null,
  sourceSlug: string | null, searchCategoryCode: string | null,
  registryStatus: 'DISCOVERED'|'ACTIVE'|'TRANSIENT_FAILURE'|'ACCESS_RESTRICTED'|'UNSUPPORTED'|'DISABLED',
  registryStatusChangedAt: string | null,
  consecutiveAbnormalDays: number,
  lastNormalAt: string | null,
  lastCollectedAt: string | null
}

3단계 — 소급 재평가 백필 (FR-87)

POST /api/matching/field-scope-backfills
  Request  { dryRun?: boolean }        // default false
  Response 200 {
    criteriaRevision: number,
    processedCount: number,
    newlyMatchedCount: number,
    notificationSuppressedCount: number,
    unmatchedNowCount: number          // 기존 매칭이 새 기준에서 풀린 건 (없어야 정상)
  }
  • dryRun=true면 revision을 만들지 않고 재평가 결과만 계산해 통계를 반환합니다 — 오탐률을 사전 점검하는 수단입니다.
  • 실행은 동기이며 500건 페이지 순회입니다. 수천 건 기준 수 초~수십 초입니다.

클래스 역할 정의

도메인 모델 (Rich Domain — 비즈니스 판단을 Entity/값 객체에 캡슐화)

클래스명역할핵심 책임
LoginSession세션 1건만료 판정(isExpired())·마지막 접근 갱신·토큰 해시 보유. 토큰 원문을 갖지 않는다
LoginAttemptState사용자별 로그인 시도 상태연속 실패 카운트·잠금 진입(5회)·잠금 해제 시각(15분) 판정을 스스로 결정
LoginCredential자격 증명 값 객체아이디 일치 + 비밀번호 해시 검증 위임 (PasswordHasher는 인자로 받는다 — Entity가 빈을 주입받지 않는다)
WebhookSignature서명 헤더 값 객체t=..,v1=.. 파싱·시간 오차(±300초) 판정·상수 시간 비교
WebhookReceiptLog수신 이력 1건검증 결과·사유·수신 시각 보유
JobPostingDedupGroup중복 그룹 애그리게이트멤버 교체·교집합 크기 계산(정체성 승계 판정)·마감일 범위 보유
JobPosting (확장)기존 + 그룹 멤버십·플랫폼assignDedupGroup()·suppressNotification()·isDedupCandidate()·accessPriority(). MANUAL도 dedupKey를 갖는다
JobPostingWatchState관리 상태 애그리게이트 (태그 자식 소유)상태 전이 + 이력 적재·지원 생성/철회 시 복구 규칙·태그 상한(10개)·제외 사유 유효성(EXCLUDED에서만)
WatchStateHistory상태 변경 이력 1건변경 전/후·시각·사유 보유 (추가 전용)
ApplicationDocumentSeries문서 계열다음 버전 번호 채번(nextVersionNumber())·유형·제목 보유
ApplicationDocumentVersion문서 버전확장자 4종·20MB 검증·저장 상대 경로 조립(FR-82 규칙)을 스스로 수행
DocumentFileName파일명 값 객체경로 새니타이즈(FR-84) — NFKC·구분자·상위 참조·제어문자 제거를 생성 시점에 강제
DocumentUploadFailure업로드 실패 이력 1건 (A-3)실패 시각·유형·원본 파일명·사유 코드·상세 메시지 보유. 추가 전용(수정하지 않는다). 경로 검증 실패·저장 루트 이탈은 보안 사건이라 반드시 남긴다
ResumeProfile이력서 프로필 애그리게이트초안/확정 상태·항목 교체·confirm() 멱등. 확정 전에는 추천도 계산에 쓰이지 않음을 isConfirmed로 표현
ProfileSkill프로필 기술 값 객체정규화 기술명·사용 개월·마지막 사용 연월 보유. 충족률 판정(fulfillmentAgainst(요구))을 자신이 수행
AxisAssessment축 평가 결과 값 객체판단 불가를 1급으로 표현(judgeable)·항목별 충족률 평균·필수 미충족 판정(hasUnmetRequiredItem())
RecommendationScore점수 산출 값 객체FR-96 적용 순서 5단계 전체를 캡슐화 — 합계 검증·판단 불가 제외·정규화·가중 합산·59점 상한
JobRequirement공고 요구사항 값 객체JD에서 추출된 필수/우대 기술·요구 경력·직무 분류. isEmpty()JD_UNAVAILABLE 판정 근거
ContactEvent연락 이벤트 애그리게이트 (첨부·파싱·후보·결정 자식 소유)수신 멱등·후보 자동 선택 가능 여부(autoSelectableCandidate())·결정 확정(PENDING에서만)
ContactEventAttachment첨부 메타데이터 값 객체 (B-1)파일명 새니타이즈·상한(이벤트당 20건·파일명 255자) 검증. 원본 바이너리를 갖지 않는다
ContactMatchCandidate후보 1건 값 객체축별 일치 여부·합산 점수·신뢰도 등급 보유
MatchCriteria (확장)기존 + 필드별 가중 판정evaluateFields() — 필드별 가중치 합산·본문 문맥 규칙·제외어 범위 제한
JobSource (확장)기존 + 레지스트리 상태6종 상태 전이 판정(canTransitTo)·전이 시각 기록

서비스 클래스

클래스명역할입력 → 출력의존
AuthDomainService로그인·세션 검증·로그아웃(username, rawPassword) → IssuedSessionLoginCredentialGateway, PasswordHasher, SessionTokenGenerator, LoginSessionRepository, LoginAttemptStateRepository
WebhookVerificationDomainServiceHMAC·시간·nonce 검증 + 이력WebhookVerificationInput → Unit(실패 시 예외)WebhookNonceRepository, WebhookReceiptLogRepository, WebhookSecretGateway
JobPostingDeduplicationDomainService (확장)그룹 재계산 + 정체성 승계 + 대표 5단 tie-break(Set<Long>) -> Map<Long, SourceType>JobPostingDeduplicationResultJobPostingRepository, JobPostingDedupGroupRepository, FeatureFlagGateway
JobPostingSearchDomainService교차 회사 검색 창구JobPostingSearchCriteriaJobPostingSearchPageJobPostingSearchRepository
WatchStateDomainService관리 상태 upsert·전이·복구(dedupGroupId, WatchStateUpsertInput)JobPostingWatchStateJobPostingWatchStateRepository
DocumentDomainService계열·버전 채번·파일 저장DocumentUploadInputApplicationDocumentVersionApplicationDocumentSeriesRepository, ApplicationDocumentVersionRepository, DocumentFileGateway
ResumeProfileDomainService초안 생성·수정·확정(documentVersionId, 추출 텍스트)ResumeProfileResumeProfileRepository, RecommendationCriteriaRepository
RecommendationDomainService축 가중치·기준 버전·평가 실행·상한 해제(RecommendationTarget, RecommendationPlan)RecommendationItemOutcomeResumeProfileRepository, RecommendationAxisWeightRepository, JobPostingRecommendationRepository, JobRequirementExtractor
JobRequirementExtractorJD → 요구사항 추출 (순수 함수)(descriptionBody, tagValues)JobRequirement(없음)
ContactEventDomainService수신 멱등·파싱·후보 확정·결정 기록ContactEventReceiveInputContactEventContactEventRepository, ContactMessageParser, ContactMatchScorer
ContactMessageParser본문 → 회사·공고·전형 키워드 추출 (순수 함수)(subject, bodyText, senderAddress)ContactEventParse(없음)
ContactMatchScorer후보 점수 산정 (순수 함수)(parse, List<ContactMatchInput>)List<ContactMatchCandidate>(없음)
ApplicationContactDomainService담당자 CRUDApplicationContactInputJobApplicationContactJobApplicationContactRepository
JobSourceRegistryDomainService소스 상태 전이 판정·조회(jobSourceId, SourceCollectionOutcome) → BooleanJobSourceRepository, JobSourceHealthRepository

application 레이어 오케스트레이션 (크로스 컨텍스트 조합 지점)

UseCase조합하는 컨텍스트조합 이유
ListAllJobPostingsUseCaseposting + watchlist + matching + application + company + recommendation교차 목록 응답이 6개 컨텍스트의 데이터를 합친다. 각 컨텍스트를 id 집합 단위 배치 조회 1회씩으로 호출해 N+1을 막는다
GetDashboardUseCaseapplication + posting + company칸반 카드에 회사명·공고 제목이 필요
EvaluateInterestedPostingsUseCasewatchlist + posting + matching + recommendation재평가 대상(관심 그룹) → 대표 공고 → JD·근무형태 → 추천도 계산
ReceiveContactEventUseCasewebhook + contact서명 검증 후 수신 저장
AnalyzeContactEventUseCase (Layer 1 리스너 경유)contact + application + posting + company후보 계산에 지원 건·회사·공고 요약이 필요
DecideContactEventUseCasecontact + application결정 기록 + 상태 전이·면접 등록
RestoreWatchStateOnApplicationUseCase (Layer 1 리스너 경유)application + posting + watchlist지원 공고 → dedup 그룹 → 관리 상태 복구
BackfillFieldScopeMatchingUseCasematching + posting재평가 결과 비교 → 신규 매칭 공고의 알림 억제
ApplyJobSourceRegistryOutcomeUseCaseposting + company수집 회차 결과 → 소스 상태 전이

실패 경로·동시성·멱등

실패 경로 (해피 패스만 있는 설계는 미완성)

경로실패 조건처리사용자 노출
로그인비밀번호 불일치실패 카운트 +1, 5회 도달 시 15분 잠금401 INVALID_CREDENTIAL / 429 LOGIN_LOCKED
로그인AUTH_PASSWORD_BCRYPT 미설정앱 기동 시 fail-fast — 인증 강제 플래그가 ON인데 자격 증명이 없으면 기동 거부기동 실패 로그
세션 검증만료·미존재·해시 불일치401 반환, 쿠키 삭제 지시(Max-Age=0)401 UNAUTHENTICATED
웹훅 수신서명 불일치 / 시간 오차 초과 / nonce 재사용본문 파싱 전 401. 사유를 webhook_receipt_logs에 기록401 WEBHOOK_SIGNATURE_INVALID (사유는 응답에 노출하지 않음 — 공격자에게 힌트를 주지 않는다)
웹훅 수신같은 providerMessageId 재수신202 + duplicated: true — 정상 시나리오로 처리202
연락 파싱 (Layer 1)파싱 실패·후보 0건이벤트는 PENDING으로 남기고 파싱 결과를 null로 저장. Discord 알림은 보낸다(사용자가 원문을 보고 판단)검토 화면에 “추출 실패 — 직접 선택”
연락 반영대상이 종료 상태전이 거부, terminal: true로 표시409 CONTACT_EVENT_TARGET_TERMINAL (시나리오 15)
연락 반영canTransitTo 위반기존 예외 재사용409 TRANSITION_NOT_ALLOWED
연락 반영이미 결정된 이벤트재결정 거부409 CONTACT_EVENT_ALREADY_DECIDED
문서 업로드확장자·용량 위반파일 쓰기 전에 검증 — 부분 저장 없음400
문서 업로드새니타이즈 후에도 루트 이탈저장 거부 + application_document_upload_failuresSTORAGE_ROOT_ESCAPE로 기록(보안 사건)400 DOCUMENT_PATH_ESCAPE
문서 업로드 (실패 이력 기록 자체)원 트랜잭션이 롤백되면 이력도 함께 사라짐별도 트랜잭션(REQUIRES_NEW)으로 기록한 뒤 원 예외를 그대로 재전파한다. UseCase가 도메인 예외를 잡아 기록 UseCase를 호출하고 rethrow하는 구조로, 기존 collectSafely·dispatchSafely 패턴과 동일하다원래 400/500 그대로
문서 업로드파일 쓰기 성공 후 DB 저장 실패파일을 삭제하고 예외 전파 (보상 트랜잭션). 트랜잭션은 DB만 롤백되므로 고아 파일을 남기지 않는다. 실패 이력에 METADATA_PERSISTENCE_FAILED로 기록해 보상 삭제가 실제로 돌았는지 사후 구분한다 (C)500
문서 업로드파일 쓰기 자체 실패 (디스크 full·권한)파일이 생성되지 않아 보상이 불필요. 실패 이력에 STORAGE_IO_FAILED로 기록500
문서 다운로드파일이 사라짐(사용자가 파인더에서 삭제)DB 행은 있으나 파일 없음 → 404 + 로그 WARN404 DOCUMENT_VERSION_NOT_FOUND
이력서 텍스트 추출텍스트 레이어 없음(스캔 PDF)·hwp예외가 아니라 null 반환 → 초안은 빈 항목 + extractionFailed: true201 (등록 성공, 수동 입력 안내)
추천도 평가확정 프로필 없음평가 자체를 실행하지 않음409 RESUME_PROFILE_NOT_CONFIRMED
추천도 평가JD 없음(3축 판단 불가)NOT_EVALUABLE(JD_UNAVAILABLE) 행 저장 — 실패 집계에 잡힌다결과에 notEvaluableReason
추천도 일괄 재평가대상 1건 실패대상별 REQUIRES_NEW 트랜잭션으로 격리, 실패는 failures[]에 담아 계속 진행200 + failures[]
dedup 배치그룹 정체성 매칭 결과가 모호(교집합 동률)그룹 id 오름차순으로 결정 — 결정성 보장(배치 로그)
dedup 배치피처 플래그 OFF그룹핑을 하지 않고 즉시 반환(기존 동작 유지)(배치 로그)
소급 재평가 백필중단·재실행criteria_revision 비교로 이미 처리된 공고는 skip — 멱등200 + 처리 건수
알림 발송기존과 동일지수 백오프 3회 → FAILED 행 + idempotency_key=NULL로 다음 배치 재시도운영 조회
실패를 영속화하는 경로의 트랜잭션 설계 (구현 계약 — wave 2 리뷰에서 3회 반복 검출)

“실패를 기록하고, 실패를 알린다” 구조는 기본 롤백 규칙과 정면으로 충돌합니다. 기록은 커밋돼야 하고 알림(예외)은 전파돼야 하는데, 예외가 전파되면 같은 트랜잭션의 기록이 함께 롤백됩니다. 이 결함이 wave 2에서 서로 독립적으로 세 번 나왔으므로 설계 계약으로 못박습니다.

경로증상결과
로그인 실패 카운트 (BE-46)@Transactional 안에서 카운트를 저장한 뒤 InvalidCredentialException을 던져 저장이 롤백5회 잠금(NFR-17)이 영원히 미발동
웹훅 검증 실패 이력 (BE-47)이력을 저장한 뒤 예외를 던져 이력 유실. 성공 경로는 무사실패 이력만 선택적으로 사라짐 — 있는 것보다 나쁨
서류 업로드 실패 이력 (BE-81)설계 단계에서 REQUIRES_NEW로 선제 대응유일하게 처음부터 정상

계약 3가지

  1. 실패를 영속화하는 경로는 예외 전파와 트랜잭션 경계를 함께 설계한다. “기록이 커밋되고 예외도 전파되어야 한다”면 noRollbackFor를 쓸지 격리 트랜잭션(REQUIRES_NEW)으로 뺄지를 티켓 수준에서 명시한다. 기록 코드를 넣는 것만으로 끝났다고 보지 않는다 — 그 기록이 커밋되는지가 완료 조건이다.

  2. JPA 제약 위반(유니크 등)은 noRollbackFor로 구제되지 않는다. 제약 위반은 flush 시점에 트랜잭션을 rollback-only로 마킹하므로, 예외를 잡아도 그 트랜잭션에서는 더 이상 쓸 수 없습니다. BE-47이 no-business-flow-in-infra를 지키려 infra의 REQUIRES_NEW를 제거하자 이 상태에 빠졌고 실 MySQL로 재현 확인됐습니다. 이 경로는 영속성 컨텍스트를 오염시키지 않는 수단이 필요합니다 — 별도 트랜잭션 진입점(application 레이어의 REQUIRES_NEW UseCase) 또는 JDBC upsert. 같은 제약이 이미 JobPostingEvaluationDomainService에도 기록돼 있습니다(JobPostingEvaluationDomainService.kt:16-24 — “Hibernate는 flush 실패 이후 같은 영속성 컨텍스트로 이어지는 조회·저장을 거부한다”).

  3. 계약을 주석·문서로만 남기지 않고 실 DB 통합 테스트로 고정한다. “예외를 던졌는데 기록이 남아 있는가”는 Mock으로 검증되지 않습니다 — 롤백은 트랜잭션 매니저의 동작이라 Testcontainers MySQL에서 커밋 경계를 실제로 넘겨야 드러납니다. BE-47이 이 방식으로 고정했고 리뷰어가 뮤턴트로 실제 가드임을 확인했습니다.

계약 2의 해법 — “그럼 어떻게 하는가” (BE-47에서 검증됨)

계약 2는 “noRollbackFor로는 안 된다”까지만 말합니다. 검증된 해법은 두 가지이고, 선택 기준은 “삽입 자체가 판정인가” 입니다.

해법쓰는 경우원리검증
① JPA 경로를 벗어나 오염을 원천 차단 (JDBC 직접 INSERT)삽입 자체가 판정인 경우 — nonce 재사용, 멱등 키 충돌Hibernate Session을 거치지 않아 flush 시점 rollback-only 마킹이 발생하지 않는다. MySQL/InnoDB는 문장 단위 유니크 위반이 트랜잭션 전체를 무효화하지 않으므로 후속 쓰기가 같은 트랜잭션에서 성공한다. JDBC INSERT는 주변 트랜잭션에 참여한다(스스로 격리하지 않음)BE-47 WebhookNonceRepositoryImpl.putIfAbsent — 실 MySQL로 고정
② REQUIRES_NEW 격리실패 이력이 원 트랜잭션과 무관하게 남아야 하는 경우 — 업로드 실패 이력, 로그인 실패 카운트별도 트랜잭션에서 커밋되므로 원 트랜잭션이 롤백돼도 기록이 살아남는다BE-81 설계, 기존 EvaluateJobPostingsUseCase 패턴

①의 자리에 ②를 쓰면 안 됩니다. 삽입이 곧 판정인데 별도 트랜잭션에서 먼저 커밋되면, 외부 트랜잭션이 다른 사유로 롤백된 뒤의 정상 재시도가 “이미 사용됨”으로 거부됩니다. 이력 유실 결함과 같은 계열이 반대 방향으로 재발합니다. BE-47이 실제로 이 지점을 지나며 확인했습니다 — JPA saveAndFlushUnexpectedRollbackException → REQUIRES_NEW 검토 → 재시도를 막는다는 이유로 기각 → JDBC 직접 삽입 채택.

이 계약이 다시 적용될 지점 — 3단계 FR-89~92(연락 수신)가 같은 구조를 반복합니다.

계약 1과 계약 2는 한 경로에서 충돌할 수 있습니다. 제약 위반으로 트랜잭션이 오염된 경로에서는 “실패를 기록한다”(계약 1) 자체가 불가능합니다 — 어떤 쓰기도 커밋되지 않기 때문입니다. 지점마다 계약 1을 지킬 수 있는지 먼저 판정하고, 지킬 수 없으면 대체 관측 수단을 정합니다.

지점계약 1(실패 기록)수단근거·대체 관측
서명·시각 오차 검증 실패 이력 (BE-71·47)가능noRollbackForDB 제약을 건드리지 않는 비즈니스 예외라 트랜잭션이 오염되지 않는다
nonce 재사용 이력 (BE-71·47)가능noRollbackFor + JDBC 직접 INSERT(해법 ①)유니크 위반이 Hibernate Session을 거치지 않아 오염이 없다
provider_message_id 유니크 충돌 (BE-71)불가능JPA 애그리게이트의 제약 위반으로 트랜잭션이 rollback-only가 되어 수신 이력 저장도 함께 실패한다. 누락이 아니라 불가능이다. 대체 관측: 503 응답 + 앱 로그, 그리고 재시도가 성공하면 그때 정상 경로로 이력이 남는다 (아래 상세)
업로드 실패 기록 (BE-81)가능REQUIRES_NEW(해법 ②)원 트랜잭션과 무관하게 남아야 하는 기록
로그인 실패 카운트 (BE-46)가능noRollbackFor제약 위반이 아니라 비즈니스 예외(InvalidCredentialException)라 오염이 없다
추천도 평가 유니크 충돌 (BE-63)가능(응답으로)대상별 REQUIRES_NEW격리 목적이 배치 계속 진행이라 ②가 맞다. 실패는 이력 테이블이 아니라 응답 failures[]로 관측

판별 규칙: noRollbackFor비즈니스 예외에만 듣습니다. JPA 제약 위반에는 듣지 않습니다(flush 시점 rollback-only 마킹). 그 경로에서 계약 1이 필요하면 해법 ①(JPA 경로 이탈) 또는 ②(격리)로 오염 자체를 피해야 하고, 둘 다 못 쓰면 계약 1을 포기하고 대체 관측을 정합니다.

provider_message_id 멱등의 확정 방식 (BE-71)ContactEvent는 첨부를 자식으로 갖는 JPA 애그리게이트라 해법 ①(JDBC 직접 INSERT)이 비현실적이고, 삽입이 곧 판정이라 해법 ②(REQUIRES_NEW)는 금지 대상입니다. 따라서 선조회 후 삽입 + 유니크 제약을 최종 백스톱으로 둡니다.

  • 정상 경로: findBy(providerMessageId)로 먼저 조회해 존재하면 제약을 건드리지 않고 202 + duplicated: true를 반환합니다. Apps Script의 재전송(비2xx 후 라벨 유지 → 재시도)이 이 경로로 흡수됩니다.
  • 백스톱: 그래도 유니크 위반이 발생하면 그 요청은 실패시키고(2xx 아님) Apps Script 재시도에 맡깁니다 — 다음 시도의 선조회가 기존 행을 찾아 정상 응답합니다. 자기 치유되므로 오염된 트랜잭션에서 복구를 시도하지 않습니다.
  • “check-then-act 금지” 원칙과 어긋나 보이지만, 그 원칙의 취지는 “제약을 유일한 방어선으로 삼지 말라” 가 아니라 “제약 없이 조회만으로 판정하지 말라” 입니다. 여기서는 제약이 백스톱으로 살아 있습니다. 게다가 발신 측이 LockService 스크립트 락으로 직렬 전송하고 사용자가 1명이라 실제 경합 창이 사실상 없습니다.

유니크 위반 경합 경로에서는 수신 이력을 남기지 않습니다 — 누락이 아니라 불가능입니다.

항목확정
이력 저장하지 않는다. 제약 위반 시점에 트랜잭션이 rollback-only로 마킹돼 webhook_receipt_logs 저장도 함께 실패한다(계약 2). 구현자는 이 분기에 이력 저장을 넣지 말 것 — 넣으면 UnexpectedRollbackException으로 새어 나가고, 그 원인을 처음부터 다시 추적하게 된다
응답 상태503 WEBHOOK_RECEIVE_CONFLICT (아래 근거)
대체 관측503 응답 + 앱 로그(WARN). 재시도의 선조회가 성공하면 그때 정상 경로로 이력이 남는다 — 관측이 사라지는 게 아니라 한 회차 지연된다
도달 빈도정상 경로에서는 도달하지 않는다. 발신 측 LockService 직렬 전송 + 단일 사용자라 경합 창이 사실상 없다. 이 분기에 과한 방어를 넣지 말 것

503을 택한 근거 — 자기 치유가 성립하려면 발신 측이 재시도해야 합니다.

  • 4xx 부적절 — 클라이언트가 고칠 것이 없습니다. 4xx는 “요청을 고쳐 다시 보내라”는 뜻이라 의미가 어긋납니다.
  • 500(catch-all)보다 503 — 이 경로는 수신 이력을 남길 수 없어 응답 코드가 유일한 신호입니다. 진짜 버그(500)와 구분돼야 운영자가 오탐하지 않습니다. 503은 “일시적이니 재시도하면 된다”를 표현합니다.
  • 발신 측 동작 확인Code.gssendMessage_2xx가 아니면 실패로 보고 inbox 라벨을 유지해 다음 회차에 재시도합니다. 503도 재시도됩니다.
  • 예외 매핑 위치 — 이 경로의 DataIntegrityViolationException·UnexpectedRollbackException웹훅 컨트롤러 범위의 @ExceptionHandler 로 매핑합니다. UnexpectedRollbackException을 전역 핸들러에 넓게 매핑하면 다른 엔드포인트의 진짜 버그를 503으로 가립니다.

동시성

단일 사용자 전제(NFR-16)라 다중 사용자 쓰기 충돌은 없습니다. 실제 경합은 배치 × 사용자 조작뿐이며, 배치가 poolSize=1로 직렬 실행되므로(SchedulingConfig.kt:15-23) 배치 간 경합도 없습니다.

경합 지점전략근거
08:30 dedup 배치 × 사용자 관리 상태 변경락 없음 — 서로 다른 테이블을 쓴다. 배치는 job_postings·job_posting_dedup_groups, 사용자는 job_posting_watch_states그룹이 재계산돼도 관리 상태 행은 dedup_group_id로 계속 연결된다. 그룹이 사라지는 경우만 문제인데, 승계되지 않은 그룹은 삭제하지 않고 멤버 0으로 남긴다(관리 상태 이력 보존)
08:30 dedup 배치 × PUT /api/job-postings/{id}/watch-state (E-1 — C-1 대안의 부작용)방어하지 않는다 (아래 근거)두 경로가 job_posting_dedup_groups·job_postings 같은 두 테이블을 쓴다
08:50 평가 배치 × 재평가 API기존 그대로 — uk_job_posting_match_results_posting 유니크 + REQUIRES_NEW 격리 + DataIntegrityViolationException 흡수EvaluateJobPostingsUseCase.kt:109-118 패턴 재사용
추천도 재평가 × 프로필 확정job_posting_recommendationsjob_posting_id 유니크 + 같은 패턴 격리위와 동일
웹훅 동시 수신(같은 메시지 2회)선조회 후 삽입 + 유니크 제약 백스톱 — 정상 경로는 findBy(providerMessageId)로 제약을 건드리지 않고 duplicated: true 반환ContactEvent가 JPA 애그리게이트라 제약 위반을 같은 트랜잭션에서 흡수할 수 없다(계약 2). 위반이 나면 요청을 실패시키고 Apps Script 재시도의 선조회가 자기 치유한다
nonce 동시 사용nonce 유니크 제약 — 삽입 성공이 곧 최초 사용 판정위와 동일
로그인 실패 카운트단일 사용자·순차 요청이라 경합 없음. 이론적 경합 시 카운트가 1 적게 올라가는 정도로 안전 방향낙관적 락 미도입(과잉)
지원 상태 전이 × 연락 반영기존 job_applications.version 낙관적 락 그대로ObjectOptimisticLockingFailureException → 409 CONFLICTING_UPDATE (GlobalExceptionHandler.kt:123-127)
문서 계열 버전 번호 채번UNIQUE (series_id, version_number) + 충돌 시 1회 재시도동시 업로드가 실질적으로 없으나 제약으로 방어

분산 락(Redis) 미도입 근거: 인스턴스가 1개이고 배치가 단일 스레드 직렬입니다. 락으로 막을 대상이 존재하지 않습니다.

신규 경합: 단독 그룹 생성 × dedup 배치 (E-1 — 인지하고 방어하지 않음)

C-1에서 채택한 PUT /api/job-postings/{jobPostingId}/watch-state(그룹이 없으면 단독 그룹 생성)가 08:30 배치와 같은 두 테이블을 쓰면서 생긴 경합입니다. senior-dba가 올린 항목이며, 인지하되 지금은 방어하지 않기로 확정합니다.

발생 시나리오

  1. 08:30 배치가 실행되기 직전, 사용자가 공고 P에 관리 상태를 저장한다 → 단독 그룹 G1({P}) 생성 + 관리 상태가 G1에 붙는다.
  2. 같은 순간 배치가 PQ를 한 클러스터로 계산한다. 배치가 그룹 정체성을 조회한 시점에 G1이 아직 커밋 전이었다면 교집합을 못 찾고 새 그룹 G2({P,Q}) 를 만든다.
  3. 결과: G1은 멤버 0인 고아가 되고, 사용자가 붙인 관리 상태는 G1에 남아 화면(=G2 기준)에서 분리된다.

방어하지 않는 근거

  • 데이터 손실이 아닙니다. G1 행과 관리 상태·이력이 모두 보존되며(승계 실패 그룹은 삭제하지 않는 기존 규칙), 사용자는 화면에서 다시 관리 상태를 지정하면 됩니다. 복구 비용이 클릭 1회입니다.
  • 창이 극히 좁습니다. 08:30 배치의 그룹 조회~저장 구간 × 사용자가 그 순간 같은 공고를 저장할 확률입니다. 단일 사용자·1일 1회 배치라 실질 0에 수렴합니다.
  • 방어 비용이 이득을 넘습니다. 막으려면 배치 구간 동안 PUT을 거부(사용자에게 “잠시 후 다시” 노출)하거나 그룹 테이블에 락을 잡아야 하는데, 둘 다 손실 없는 사고를 막으려고 정상 경로에 상시 비용을 추가합니다.
  • 관측만 남깁니다 — 배치가 고아 그룹(멤버 0이면서 관리 상태가 붙어 있는 그룹)을 만들면 그 개수를 WARN 로그로 남깁니다. 실제로 관측되면 그때 방어를 검토합니다.

멱등

대상멱등 키중복 시 동작
웹훅 수신provider_message_id (선조회 + 유니크 백스톱)기존 이벤트 반환, duplicated: true. 파싱·알림을 다시 하지 않는다
noncenonce (유니크)재사용 → 401
변경 공고 알림JOB_POSTING:{id}:JOB_POSTING_CHANGED:{kstDayOrdinal}같은 날 재발송 skip
연락 검토 요청 알림CONTACT_EVENT:{id}:CONTACT_REVIEW_REQUEST:1재발송 skip
추천도 평가(job_posting_id, criteria_revision) 비교같은 기준 버전으로 이미 평가된 건은 skip (force=true면 재계산)
소급 재평가 백필criteria_revision 비교재실행 안전
dedup 배치전량 재그룹핑여러 번 실행해도 같은 결과 — 그룹 정체성 승계 규칙이 결정적
관리 상태 전이같은 상태로의 전이no-op, 이력 미적재
프로필 확정confirmed_at 존재 여부이미 확정이면 기준 버전을 올리지 않는다
로그아웃세션 존재 여부없어도 204
지원 서류 연결UNIQUE (job_application_id, document_version_id)409

상태 전이 표

관리 상태 (WatchStatus, FR-73·75)

현재 상태 × 이벤트다음 상태거부 사유
(없음) × 사용자 저장요청 상태
INTERESTED × 사용자 변경PLANNED / EXCLUDED
PLANNED × 사용자 변경INTERESTED / EXCLUDED
EXCLUDED × 사용자 변경INTERESTED / PLANNED
임의 상태 × 같은 상태로 변경변화 없음거부는 아니나 이력을 남기지 않는다(no-op)
EXCLUDED × 지원 생성INTERESTEDFR-75 ① — 자동 복구
INTERESTED/PLANNED × 지원 생성유지변경하지 않는다
임의 상태 × 지원 철회·삭제직전 상태(이력 기준) / 이력 없으면 INTERESTEDFR-75 ②
PLANNED × 공고 마감(CLOSED)유지FR-75 ③ — 관리 상태는 유지하고 공개 상태만 CLOSED. 관리 목록에서 사라지지 않는다
임의 상태 × 제외 사유 입력EXCLUDED가 아니면 400 WATCH_STATE_INVALID_FIELD

연락 이벤트 검토 (ContactReviewStatus, FR-90)

현재 상태 × 이벤트다음 상태거부 사유
(수신)PENDING
PENDING × APPLY(유효 대상·허용 전이)APPLIED
PENDING × APPLY(대상 종료 상태)유지409 CONTACT_EVENT_TARGET_TERMINAL — 종료 지원에는 상태 변경도 면접 추가도 불가
PENDING × APPLY(전이 규칙 위반)유지409 TRANSITION_NOT_ALLOWED
PENDING × IGNOREIGNORED
PENDING × REASSIGNAPPLIED다른 지원 건을 지정해 반영
APPLIED/IGNORED × 임의 결정유지409 CONTACT_EVENT_ALREADY_DECIDED

소스 레지스트리 상태 (JobSourceRegistryStatus, FR-88)

현재 상태 × 트리거다음 상태비고
(등록)DISCOVERED등록만 되고 아직 수집 검증 전 (seeded_at IS NULL)
임의 × 어댑터 미보유(UnsupportedJobPlatformException)UNSUPPORTEDSPA 채용 사이트 등 — PRD Non-Goals 유지
임의 × 수집 성공(fetched_count > 0)ACTIVEseeded_at 채움
ACTIVE × 수집 실패·0건 (연속 3일 미만)TRANSIENT_FAILUREjob_source_health.consecutive_abnormal_days가 근거
TRANSIENT_FAILURE × 수집 성공ACTIVE복구
임의 × 401·403 응답 또는 robots 차단(DisallowedRequestPathException)ACCESS_RESTRICTED수집 대상에서 제외
ACCESS_RESTRICTED × 수집 성공ACTIVE규약·차단 해제 시 자동 복귀
임의 × 사용자 비활성화(disabled_at 설정)DISABLED수동 전이
DISABLED × 사용자 활성화(disabled_at 해제)DISCOVERED재검증부터 시작 — 바로 ACTIVE로 보내지 않는다

TRANSIENT_FAILURE가 3일 연속이면 기존 소스 고장 알림(FR-19)이 발송됩니다. 레지스트리 상태는 TRANSIENT_FAILURE를 유지합니다 — 별도 BROKEN 상태를 두지 않는 이유는 PRD가 6종을 확정했고, 고장 여부는 job_source_health.broken_since가 이미 표현하기 때문입니다.

지원 상태 (기존 — 변경 없음)

ApplicationStatus.TRANSITIONS(ApplicationStatus.kt:35-44)가 SSOT입니다. 연락 반영(FR-90)도 이 표를 그대로 통과해야 합니다. 종료 4종은 emptySet()이므로 어떤 반영도 거부됩니다.

Component Diagram — 1단계 (인증·관리 상태)

flowchart LR
    subgraph Edge["외부 경계"]
        Tunnel[cloudflared]
        Nginx[nginx web]
    end
    subgraph Presentation["presentation"]
        Filter[AuthenticationFilter]
        AuthApi[AuthApiController]
        WatchApi[WatchStateApiController]
        SearchApi[JobPostingApiController]
    end
    subgraph Application["application"]
        LoginUC[LoginUseCase]
        WatchUC[UpsertWatchStateUseCase]
        ListUC[ListAllJobPostingsUseCase]
    end
    subgraph Domain["domain"]
        AuthDS[AuthDomainService]
        WatchDS[WatchStateDomainService]
        SearchDS[JobPostingSearchDomainService]
    end
    Tunnel --> Nginx
    Nginx --> Filter
    Filter --> AuthApi
    Filter --> WatchApi
    Filter --> SearchApi
    AuthApi --> LoginUC
    WatchApi --> WatchUC
    SearchApi --> ListUC
    LoginUC --> AuthDS
    WatchUC --> WatchDS
    ListUC --> SearchDS

Component Diagram — 2단계 (서류·추천도)

flowchart LR
    subgraph Presentation["presentation"]
        DocApi[DocumentApiController]
        RecApi[RecommendationApiController]
    end
    subgraph Application["application"]
        UploadUC[UploadDocumentUseCase]
        ConfirmUC[ConfirmResumeProfileUseCase]
        EvalUC[EvaluateInterestedPostingsUseCase]
    end
    subgraph Domain["domain"]
        DocDS[DocumentDomainService]
        ProfileDS[ResumeProfileDomainService]
        RecDS[RecommendationDomainService]
        Extractor[JobRequirementExtractor]
        Score[RecommendationScore]
    end
    subgraph Infra["infrastructure"]
        FileGw[LocalDocumentFileGatewayImpl]
        TextEx[PdfBox/Docx TextExtractor]
    end
    DocApi --> UploadUC
    RecApi --> ConfirmUC
    RecApi --> EvalUC
    UploadUC --> DocDS
    ConfirmUC --> ProfileDS
    EvalUC --> RecDS
    DocDS --> FileGw
    ProfileDS --> TextEx
    RecDS --> Extractor
    RecDS --> Score

Component Diagram — 3단계 (연락 연동)

flowchart LR
    subgraph External["외부"]
        Gmail[Gmail Apps Script]
        Discord[Discord Webhook]
    end
    subgraph Presentation["presentation"]
        Hook[ContactEventWebhookApiController]
        Listener[ContactEventReceivedListener]
        ReviewApi[ContactEventApiController]
    end
    subgraph Application["application"]
        ReceiveUC[ReceiveContactEventUseCase]
        AnalyzeUC[AnalyzeContactEventUseCase]
        DecideUC[DecideContactEventUseCase]
    end
    subgraph Domain["domain"]
        Verify[WebhookVerificationDomainService]
        ContactDS[ContactEventDomainService]
        Scorer[ContactMatchScorer]
        NotiDS[NotificationDispatchDomainService]
    end
    Gmail --> Hook
    Hook --> ReceiveUC
    ReceiveUC --> Verify
    ReceiveUC --> ContactDS
    ContactDS --> Listener
    Listener --> AnalyzeUC
    AnalyzeUC --> Scorer
    AnalyzeUC --> NotiDS
    NotiDS --> Discord
    ReviewApi --> DecideUC
    DecideUC --> ContactDS

Sequence Diagram — 로그인과 인증 요청

sequenceDiagram
    participant FE as web(SPA)
    participant F as AuthenticationFilter
    participant C as AuthApiController
    participant U as LoginUseCase
    participant D as AuthDomainService
    participant R as LoginSessionRepository
    FE->>F: POST /api/auth/login
    F->>C: 인증 제외 경로 — 통과
    C->>U: execute(LoginCommand)
    U->>D: login(username, rawPassword)
    D->>D: 잠금 확인 → 자격 검증
    alt 검증 실패
        D-->>U: InvalidCredentialException (실패 카운트 +1)
    else 검증 성공
        D->>R: save(LoginSession(tokenHash, expiresAt))
        D-->>U: IssuedSession(rawToken, expiresAt)
    end
    U-->>C: LoginResult
    C-->>FE: 200 + Set-Cookie(HttpOnly)
    FE->>F: GET /api/job-postings (쿠키 동봉)
    F->>D: validate(rawToken)
    D-->>F: LoginSession (만료면 null)
    F->>F: null이면 401 UNAUTHENTICATED

Sequence Diagram — 08:30 dedup 배치의 그룹 정체성 승계

sequenceDiagram
    participant S as DeduplicationScheduler
    participant U as DeduplicateJobPostingsUseCase
    participant D as DeduplicationDomainService
    participant PR as JobPostingRepository
    participant GR as DedupGroupRepository
    S->>U: execute()
    U->>D: deduplicate(sourceTypesOf)
    D->>PR: findAllWithDedupKey() (MANUAL·접근제한 포함)
    D->>D: dedupKey 그룹핑 → splitByDeadlineGap
    D->>GR: findAllBy(dedupKeys) 기존 그룹 조회
    loop 새 클러스터마다
        D->>D: 기존 그룹과 멤버 교집합 계산
        alt 교집합 최대 > 0
            D->>D: 그룹 정체성 승계 (관리 상태 유지)
        else 교집합 0
            D->>D: 새 그룹 생성 (관리 상태 미승계)
        end
        D->>D: 대표 선정 5단 tie-break
    end
    D->>GR: saveAll(groups)
    D->>PR: saveAll(dedupGroupId 배정된 공고)
    D-->>U: 대표·중복 건수

Sequence Diagram — 연락 이벤트 수신부터 반영까지

sequenceDiagram
    participant G as Gmail Apps Script
    participant H as WebhookApiController
    participant U as ReceiveContactEventUseCase
    participant V as WebhookVerificationDomainService
    participant C as ContactEventDomainService
    participant L as ContactEventReceivedListener
    participant N as NotificationDispatchDomainService
    G->>H: POST /api/webhooks/contact-events (서명+nonce)
    H->>U: execute(rawBody, signatureHeader, nonce)
    U->>V: verify(rawBody, signature, nonce)
    alt 검증 실패
        V-->>U: 예외 (수신 이력은 별도 트랜잭션으로 커밋)
        U-->>H: 예외 전파
        H-->>G: 401
    else 검증 성공
        U->>C: receive(providerMessageId, ...)
        C-->>U: ContactEvent (중복이면 기존 반환)
        U-->>H: ReceiveContactEventResult
        H-->>G: 202 (라벨 이동 트리거)
    end
    C->>L: AFTER_COMMIT ContactEventReceived
    L->>C: 파싱 + 후보 점수 계산
    L->>N: 연락 검토 요청 알림 발송

Sequence Diagram — 연락 검토 반영 (사용자 확인 후)

sequenceDiagram
    participant User as 사용자(검토 화면)
    participant H as ContactEventApiController
    participant U as DecideContactEventUseCase
    participant C as ContactEventDomainService
    participant A as ApplicationDomainService
    participant I as InterviewDomainService
    User->>H: POST /contact-events/{id}/decisions (APPLY)
    H->>U: execute(command)
    U->>C: decide(eventId, decision)
    U->>A: transit(applicationId, targetStatus)
    opt 면접 지정
        U->>I: add(applicationId, 최대회차+1, label, scheduledAt)
    end
    U-->>H: ContactEventDetailResponse

Sequence Diagram — 프로필 확정과 관심 공고 재평가

sequenceDiagram
    participant FE as web(SPA)
    participant C as RecommendationApiController
    participant U as ConfirmResumeProfileUseCase
    participant P as ResumeProfileDomainService
    participant E as EvaluateInterestedPostingsUseCase
    participant W as WatchStateDomainService
    participant R as RecommendationDomainService
    FE->>C: POST /api/resume-profiles/{id}/confirmations
    C->>U: execute(profileId)
    U->>P: confirm(profileId)
    P-->>U: 새 criteriaRevision
    U->>E: reevaluate(force=false)
    E->>W: findGroupIdsBy([INTERESTED, PLANNED])
    E->>E: 그룹 → 대표 공고 → JD·태그·근무형태 조합
    loop 대상마다 (REQUIRES_NEW)
        E->>R: evaluateOne(target, plan)
        R->>R: 요구사항 추출 → 축 평가 → 정규화 → 상한
    end
    E-->>C: evaluatedCount / notEvaluableCount / failures
    C-->>FE: 200

ERD

컬럼 상세·인덱스·용량 추정은 private-senior-dba의 design-db 갱신 몫이고, DDL 전문은 Flyway 마이그레이션에 둡니다. 여기서는 요약 수준만 기술합니다.

ERD (1) — 1단계: 인증·웹훅·중복 그룹·관리 상태·담당자

erDiagram
    JOB_POSTING_DEDUP_GROUPS ||--o{ JOB_POSTINGS : groups
    JOB_POSTING_DEDUP_GROUPS ||--o| JOB_POSTING_WATCH_STATES : has
    JOB_POSTING_WATCH_STATES ||--o{ JOB_POSTING_WATCH_TAGS : has
    JOB_POSTING_WATCH_STATES ||--o{ JOB_POSTING_WATCH_STATE_HISTORIES : records
    JOB_APPLICATIONS ||--o{ JOB_APPLICATION_CONTACTS : has
    JOB_POSTING_DEDUP_GROUPS {
        bigint id PK
        varchar dedup_key
        bigint representative_job_posting_id
        datetime earliest_deadline_at
        datetime latest_deadline_at
        int member_count
    }
    JOB_POSTINGS {
        bigint id PK
        bigint dedup_group_id
        varchar platform
        varchar dedup_key
        varchar posting_origin
    }
    JOB_POSTING_WATCH_STATES {
        bigint id PK
        bigint dedup_group_id UK
        varchar watch_status
        varchar watch_priority
        date target_apply_date
        varchar memo
        varchar exclusion_reason
    }
    JOB_POSTING_WATCH_TAGS {
        bigint id PK
        bigint job_posting_watch_state_id
        varchar tag_name
    }
    JOB_POSTING_WATCH_STATE_HISTORIES {
        bigint id PK
        bigint job_posting_watch_state_id
        varchar previous_watch_status
        varchar next_watch_status
        datetime changed_at
    }
    JOB_APPLICATION_CONTACTS {
        bigint id PK
        bigint job_application_id
        varchar contact_name
        varchar contact_email_address
    }
    USER_LOGIN_SESSIONS {
        bigint id PK
        varchar session_token_hash UK
        datetime expires_at
    }
    LOGIN_ATTEMPT_STATES {
        bigint id PK
        varchar login_username UK
        int consecutive_failure_count
        datetime locked_until
    }
    WEBHOOK_REQUEST_NONCES {
        bigint id PK
        varchar request_nonce UK
        datetime received_at
    }
    WEBHOOK_RECEIPT_LOGS {
        bigint id PK
        varchar webhook_endpoint
        varchar verification_result
        datetime received_at
    }

ERD (2) — 2단계: 서류·프로필·추천도

erDiagram
    APPLICATION_DOCUMENT_SERIES ||--o{ APPLICATION_DOCUMENT_VERSIONS : versions
    APPLICATION_DOCUMENT_VERSIONS ||--o{ JOB_APPLICATION_SUBMITTED_DOCUMENTS : submitted
    APPLICATION_DOCUMENT_VERSIONS ||--o| RESUME_PROFILES : source
    RESUME_PROFILES ||--o{ RESUME_PROFILE_SKILLS : has
    RESUME_PROFILES ||--o{ RESUME_PROFILE_EXPERIENCES : has
    RESUME_PROFILES ||--o{ RESUME_PROFILE_WORK_PREFERENCES : has
    JOB_POSTING_RECOMMENDATIONS ||--o{ JOB_POSTING_RECOMMENDATION_AXES : axes
    JOB_POSTING_RECOMMENDATION_AXES ||--o{ JOB_POSTING_RECOMMENDATION_AXIS_ITEMS : items
    APPLICATION_DOCUMENT_SERIES {
        bigint id PK
        varchar document_type
        varchar series_title
    }
    APPLICATION_DOCUMENT_UPLOAD_FAILURES {
        bigint id PK
        varchar document_type
        bigint application_document_series_id
        varchar original_file_name
        varchar failure_reason
        datetime occurred_at
    }
    APPLICATION_DOCUMENT_VERSIONS {
        bigint id PK
        bigint application_document_series_id
        int version_number
        varchar stored_relative_path
        varchar original_file_name
        bigint file_size_bytes
    }
    JOB_APPLICATION_SUBMITTED_DOCUMENTS {
        bigint id PK
        bigint job_application_id
        bigint application_document_version_id
        datetime submitted_at
    }
    RESUME_PROFILES {
        bigint id PK
        bigint source_document_version_id
        tinyint extraction_failed
        datetime confirmed_at
    }
    RESUME_PROFILE_SKILLS {
        bigint id PK
        bigint resume_profile_id
        varchar skill_name
        varchar normalized_skill_name
        int usage_months
    }
    RESUME_PROFILE_EXPERIENCES {
        bigint id PK
        bigint resume_profile_id
        int total_months
        varchar job_category
    }
    RESUME_PROFILE_WORK_PREFERENCES {
        bigint id PK
        bigint resume_profile_id
        varchar preference_keyword
        tinyint preference_required
    }
    RECOMMENDATION_CRITERIA_REVISIONS {
        bigint id PK
        varchar change_target
        varchar change_reason
    }
    RECOMMENDATION_AXIS_WEIGHTS {
        bigint id PK
        varchar recommendation_axis UK
        int weight_points
    }
    JOB_POSTING_RECOMMENDATIONS {
        bigint id PK
        bigint job_posting_id UK
        bigint criteria_revision
        int total_score
        varchar recommendation_grade
        tinyint cap_applied
        varchar not_evaluable_reason
    }
    JOB_POSTING_RECOMMENDATION_AXES {
        bigint id PK
        bigint job_posting_recommendation_id
        varchar recommendation_axis
        tinyint axis_judgeable
        int normalized_weight
        int fulfillment_rate
    }
    JOB_POSTING_RECOMMENDATION_AXIS_ITEMS {
        bigint id PK
        bigint job_posting_recommendation_axis_id
        varchar item_name
        varchar item_fulfillment
    }
    JOB_POSTING_RECOMMENDATION_CAP_OVERRIDES {
        bigint id PK
        bigint job_posting_id UK
        varchar release_reason
        datetime released_at
    }

ERD (3) — 3단계: 매칭 근거·연락 이벤트

erDiagram
    JOB_POSTING_MATCH_RESULTS ||--o{ JOB_POSTING_MATCH_FIELD_EVIDENCES : evidences
    RECRUITMENT_CONTACT_EVENTS ||--o| RECRUITMENT_CONTACT_EVENT_PARSES : parsed
    RECRUITMENT_CONTACT_EVENTS ||--o{ RECRUITMENT_CONTACT_EVENT_CANDIDATES : candidates
    RECRUITMENT_CONTACT_EVENTS ||--o| RECRUITMENT_CONTACT_EVENT_DECISIONS : decided
    RECRUITMENT_CONTACT_EVENTS ||--o{ RECRUITMENT_CONTACT_EVENT_ATTACHMENTS : attachments
    JOB_POSTING_MATCH_RESULTS {
        bigint id PK
        bigint job_posting_id UK
        int match_score
        int match_threshold
        tinyint keyword_matched
    }
    JOB_POSTING_MATCH_FIELD_EVIDENCES {
        bigint id PK
        bigint job_posting_match_result_id
        bigint job_keyword_group_id
        varchar match_field
        int weight_points
        int occurrence_count
        varchar evidence_snippet
    }
    RECRUITMENT_CONTACT_EVENTS {
        bigint id PK
        varchar contact_channel
        varchar provider_message_id UK
        varchar sender_address
        text body_text
        varchar review_status
        datetime received_at
    }
    RECRUITMENT_CONTACT_EVENT_PARSES {
        bigint id PK
        bigint recruitment_contact_event_id UK
        varchar extracted_company_name
        varchar extracted_posting_title
        varchar suggested_application_status
        datetime suggested_interview_at
    }
    RECRUITMENT_CONTACT_EVENT_CANDIDATES {
        bigint id PK
        bigint recruitment_contact_event_id
        bigint job_application_id
        int confidence_score
        varchar confidence_level
        tinyint company_matched
        tinyint title_matched
        tinyint contact_matched
        tinyint recently_applied
    }
    RECRUITMENT_CONTACT_EVENT_DECISIONS {
        bigint id PK
        bigint recruitment_contact_event_id UK
        varchar decision_type
        bigint selected_job_application_id
        varchar applied_application_status
        datetime decided_at
    }
    RECRUITMENT_CONTACT_EVENT_ATTACHMENTS {
        bigint id PK
        bigint recruitment_contact_event_id
        varchar attachment_file_name
        varchar attachment_mime_type
        bigint attachment_size_bytes
    }
    JOB_SOURCES {
        bigint id PK
        varchar registry_status
        datetime registry_status_changed_at
    }

스키마 변경 요약 (senior-dba 인계)

단계신규 테이블기존 테이블 변경
1user_login_sessions, login_attempt_states, webhook_request_nonces, webhook_receipt_logs, job_posting_dedup_groups, job_posting_watch_states, job_posting_watch_tags, job_posting_watch_state_histories, job_application_contacts (9)job_postings +dedup_group_id(nullable) +platform(nullable) / feature_flags 시드 4행(신규 3 + posting.collection-dispatch-v2 복구 1, 아래 A-2)
2application_document_series, application_document_versions, job_application_submitted_documents, resume_profiles, resume_profile_skills, resume_profile_experiences, resume_profile_work_preferences, recommendation_criteria_revisions, recommendation_axis_weights, job_posting_recommendations, job_posting_recommendation_axes, job_posting_recommendation_axis_items, job_posting_recommendation_cap_overrides, application_document_upload_failures(A-3) (14)없음
3job_posting_match_field_evidences, recruitment_contact_events, recruitment_contact_event_parses, recruitment_contact_event_candidates, recruitment_contact_event_decisions, recruitment_contact_event_attachments(B-1) (6)job_posting_match_results +match_score +match_threshold / job_sources +registry_status +registry_status_changed_at / notification_dispatches COMMENT 갱신(코드값 4종 추가)

신규 테이블 총 29개 (1단계 9 / 2단계 14 / 3단계 6).

feature_flags 시드 — posting.collection-dispatch-v2 복구 (A-2, DB-02 요구사항)

DB-02의 feature_flags 시드에 ('posting.collection-dispatch-v2', 0, ...) 1행을 추가합니다. 신규 3행(auth.required·posting.dedup-group-identity·watchlist.management)과 함께 총 4행입니다.

  • 현상: 이 키가 JobPostingCollectionScheduler.kt:22-28·AggregatorCollectionScheduler·CollectionDispatchDomainService.kt:148 등에서 쓰이는데 baseline 시드는 3행뿐이라(baseline...sql:343-347) 이 키의 행이 없습니다.
  • 영향 방향 — 플래그는 legacy 경로의 kill switch입니다. isEnabled가 true면 legacy를 건너뛰고 return하므로, 미정의 키의 안전 기본값 false(FeatureFlagGatewayImpl.kt:18) 덕에 수집 자체는 legacy 경로로 정상 동작합니다. 실제 손실은 BE-33~36(수집 사이클 큐·디스패처 리스 복구·호스트 스로틀)이 한 번도 실행되지 않는다는 점이고, 행이 없어 운영 UPDATE로 켤 수도 없습니다(INSERT가 필요).
  • 조치는 스위치 복구까지만 — 값은 반드시 0(OFF)입니다. 켜는 순간 수집 경로가 통째로 v2로 바뀌므로 활성화는 이번 범위 밖의 별도 판단으로 남깁니다.
  • 정적 시드 4행·운영 쓰기 경합 없음이므로 Flyway DML 허용 예외에 해당합니다. 이 근거를 마이그레이션 파일 주석에 남깁니다.

주요 인덱스 요구 (쿼리 패턴 근거)

쿼리필요 인덱스
교차 회사 목록: 대표 + 상태 + 최초 발견 순 정렬 (FR-78, 기본 정렬)job_postings (representative_id, posting_status, first_seen_at, id)deadline_at이 아니라 first_seen_at (B-2: deadline_at 실측 71.4% NULL이라 표현식 정렬이 되어 B-tree가 순서를 주지 못함)
교차 회사 목록: 회사 필터 (C-3)job_postings (company_id, posting_status, first_seen_at) (기존 idx_job_postings_company_status_seen 재사용)
교차 회사 목록: 플랫폼 OR 매칭 서브쿼리job_postings (dedup_group_id, platform)
연락 이벤트 첨부 조회 (B-1)recruitment_contact_event_attachments (recruitment_contact_event_id)
업로드 실패 이력 최신순·사유 필터 (A-3)application_document_upload_failures (occurred_at, id) + (failure_reason, occurred_at)
매칭 근거 조회 (A-2 확대)job_posting_match_field_evidences (job_posting_match_result_id). dba 실측: 3년 38,900 → 51,600행(+32.6%), 17MB → 22MB. 본문 보유 공고가 13.8%(전량 GREENHOUSE)라 확대분이 작다
dedup 그룹 정체성 조회job_posting_dedup_groups (dedup_key)
관리 상태 필터job_posting_watch_states (watch_status) + UNIQUE (dedup_group_id)
세션 검증(요청마다)UNIQUE (session_token_hash)
nonce 재사용 판정UNIQUE (request_nonce) + (received_at) (정리 배치)
웹훅 멱등UNIQUE (provider_message_id)
문서 버전 채번UNIQUE (application_document_series_id, version_number)
추천도 조회·재평가UNIQUE (job_posting_id) . (criteria_revision) 인덱스는 미생성 확정(E-3, dba 결정) — 재평가 구동 축이 관리 상태(watchlist가 준 그룹 id 집합)이고 그 뒤 공고 단위 조회는 유니크가 커버하므로, revision 단독 조회 경로가 존재하지 않는다
대시보드 장기 미변경job_application_status_histories (job_application_id, transited_at) (기존 존재)

MySQL 단일 유지 — MongoDB 미채택 근거는 방안 13에 기술했습니다.

Testing Plan

TDD(테스트 우선)의 입력입니다. 모든 신규 테스트는 Kotest(BehaviorSpec/DescribeSpec)로 작성합니다 — JUnit 금지, MockK 사용(build.gradle.kts:62-71 기존 스택 그대로, 신규 프레임워크 도입 없음).

레벨대상타입핵심 범위
domainLoginSession·LoginAttemptState·WebhookSignature·JobPostingDedupGroup·JobPostingWatchState·DocumentFileName·ApplicationDocumentVersion·AxisAssessment·RecommendationScore·ProfileSkill·ContactEvent·ContactMatchScorer·MatchCriteria.evaluateFields·JobRequirementExtractor·JobSourceRegistryStatus단위 (MockK)상태 전이·검증·계산 규칙. 판정 로직은 전부 이 레벨에서 커버한다
applicationLoginUseCase·UpsertWatchStateUseCase·ListAllJobPostingsUseCase·GetDashboardUseCase·UploadDocumentUseCase·ConfirmResumeProfileUseCase·EvaluateInterestedPostingsUseCase·ReceiveContactEventUseCase·DecideContactEventUseCase·BackfillFieldScopeMatchingUseCase단위 (DomainService 모킹)오케스트레이션 순서·크로스 컨텍스트 조합·트랜잭션 격리 호출
infrastructureLoginSessionRepositoryImpl·WebhookNonceRepositoryImpl·JobPostingDedupGroupRepositoryImpl·JobPostingSearchRepositoryImpl(QueryDSL)·JobPostingWatchStateRepositoryImpl·LocalDocumentFileGatewayImpl·PdfBoxResumeTextExtractor·ContactEventRepositoryImpl통합 (Testcontainers MySQL 1.20.3 — 기존 SharedMySqlContainer 재사용)유니크 제약 동작·QueryDSL 쿼리 정확성·cascade 저장·실제 파일 쓰기/삭제
presentationAuthApiController·AuthenticationFilter·WatchStateApiController·JobPostingApiController(신규 엔드포인트)·DashboardApiController·DocumentApiController(멀티파트)·RecommendationApiController·ContactEventWebhookApiController·ContactEventApiController·JobApplicationLifecycleListener·ContactEventReceivedListener통합 (MockMvc + Testcontainers)HTTP 계약(상태 코드·에러 코드·응답 필드)·필터 통과/차단·리스너 AFTER_COMMIT 동작
scenario단계별 E2E시나리오 통합아래 표

반드시 커버할 실패 경로 (구현 티켓의 테스트 케이스 입력)

#시나리오기대
1쿠키 없이 /api/job-postings 호출401 UNAUTHENTICATED
2로그인 5회 연속 실패 후 6번째 시도429 LOGIN_LOCKED, 15분 후 해제
3만료된 세션 쿠키로 호출401 + Set-Cookie: Max-Age=0
4/api/webhooks/contact-events를 쿠키 없이 호출 (서명 유효)202 — 웹훅은 인증 제외 경로
5서명 타임스탬프가 6분 전401, 이력에 TIMESTAMP_OUT_OF_RANGE
6같은 nonce 재사용401, 이력에 NONCE_REUSED
7body 1바이트 변조 후 원 서명 전송401, 이력에 SIGNATURE_MISMATCH
8같은 providerMessageId 2회 수신둘 다 202, 두 번째는 duplicated: true, 이벤트 1건·알림 1건
9마감일이 40일 벌어진 재공고가 수집됨새 dedup 그룹 생성, 이전 회차 EXCLUDED승계되지 않음 (FR-74 A-1)
10같은 그룹에 애그리게이터 공고가 뒤늦게 합류그룹 정체성 유지, 관리 상태·메모 보존 (FR-74 B-2)
11MANUAL로 등록한 공고가 이후 수집 경로로 발견같은 그룹으로 병합, 수동 등록 시 메모 유지 (FR-74 A-2)
12접근 제한 공고 1건 + 접근 가능 공고 1건이 같은 그룹접근 가능 공고가 대표 (tie-break ①)
13MANUAL 1건 + AGGREGATOR 1건이 같은 그룹MANUAL이 대표 (tie-break ② 동급 → ③④⑤로)
14dedup 배치 2회 연속 실행그룹 id·대표·멤버가 동일 (멱등)
15EXCLUDED 공고에 지원 생성관리 상태가 INTERESTED로 복구, 이력 1건 추가 (FR-75 ①)
16지원 철회 후직전 관리 상태로 복귀, 이력 없으면 INTERESTED (FR-75 ②)
17PLANNED 공고가 CLOSED로 전환관리 상태 PLANNED 유지, 목록에서 사라지지 않음 (FR-75 ③)
18INTERESTED 상태에서 제외 사유 입력400 WATCH_STATE_INVALID_FIELD
19태그 11개 입력400
20교차 목록 size=101400 BAD_REQUEST
21교차 목록 플랫폼 필터가 그룹 내 비대표 공고에만 일치대표 공고가 결과에 포함된다 (OR 매칭, FR-78)
22교차 목록 정렬 시 동순위 다수페이지 1·2 사이 항목 중복·누락 0건 (id tie-break)
23대시보드 진행 중 상태가 0건해당 칸반 컬럼이 count=0, items=[]존재한다
24대시보드 staleThresholdDays=14, 마지막 전이 13일 전장기 미변경 목록에 미포함 (경계값)
2521MB 파일 업로드400 DOCUMENT_SIZE_EXCEEDED, 파일 미생성
26확장자 exe 업로드400 DOCUMENT_EXTENSION_NOT_ALLOWED
27문서 제목에 ../../etc 입력새니타이즈 후 저장 루트 하위 경로로만 저장 (FR-84)
28파일명에 / 포함치환 후 저장, 원본 파일명은 DB에 그대로 보존
29파일 쓰기 성공 후 DB 저장 실패파일이 삭제됨 (고아 파일 0건)
30같은 계열에 3번째 버전 업로드version_number=3, 기존 v1·v2 유지
31지원 건에 같은 버전 2회 연결409 DOCUMENT_ALREADY_SUBMITTED
32계열에 새 버전이 생겨도 기존 지원 연결v2를 가리키던 연결이 v3으로 바뀌지 않음 (FR-85)
33스캔 PDF(텍스트 레이어 없음) 업로드 후 초안 생성201 + extractionFailed: true + 빈 항목, 이력서 등록은 유지 (시나리오 13)
34hwp 업로드 후 초안 생성201 + extractionFailed: true
35확정 프로필 없이 재평가 호출409 RESUME_PROFILE_NOT_CONFIRMED
36이미 확정된 프로필 재확정기준 버전이 증가하지 않음 (멱등)
37축 가중치 합계 99400 RECOMMENDATION_WEIGHT_INVALID
38필수 기술 축(45)이 판단 불가예외 없이 정규화 — 나머지 3축 비중이 20:20:15 → 36:36:28 (FR-96 B-16~18)
39JD 없는 공고 평가NOT_EVALUABLE(JD_UNAVAILABLE), 점수 null, 실패 집계에 포함 (검수 미결 #5)
40필수 기술 1건이 NONE총점이 59점을 넘지 않음 (상한)
41필수 기술이 전부 PARTIAL상한 미적용 (60%는 미충족이 아님)
42상한 해제 후 재평가상한 해제가 유지되고 원 상한 사유가 계속 노출됨 (FR-97)
43기술명 일치 + 최근 사용 4년 전PARTIAL(60%)
44기술명 일치 + 사용 기간이 요구의 40%PARTIAL(60%)
45기술명 일치 + 최근 사용 1년 + 기간 충분FULL(100%)
46관심 공고 200건 일괄 재평가5분 이내 완료 (NFR-13)
47재평가 중 1건 실패나머지가 계속 처리되고 failures[]에 1건
48제목에만 키워드 존재매칭 성립(60 ≥ 50) — 기존 동작 보존
49본문에만 키워드 2회 존재매칭 불성립(20 < 50) — 기존 기각 사유 해소
50태그 + 본문에 키워드매칭 성립(55 ≥ 50)
51본문에 “백엔드와 협업” 1회만본문 신호 무효 (협업 문맥 + 1회 출현)
52본문 2개 문장에 유효 출현, 태그 없음20점만 — 불성립
53제목에 제외 키워드미매칭 (제외가 매칭을 이긴다)
54본문에만 제외 키워드제외 미적용 — 매칭 유지 (본문에 제외어를 적용하지 않는다)
55소급 재평가로 새로 매칭된 공고notification_eligible=0으로 억제, 알림 0건 (FR-87)
56소급 재평가 백필 재실행이미 새 revision으로 평가된 건 skip (멱등)
57dryRun=true 백필revision 미생성, 통계만 반환
583일 연속 수집 실패 소스레지스트리 상태 TRANSIENT_FAILURE, 고장 알림 1건
59403 응답 소스레지스트리 상태 ACCESS_RESTRICTED
60DISABLED 소스를 다시 켬DISCOVERED로 복귀 (바로 ACTIVE 아님)
61같은 공고가 하루에 2회 변경 감지변경 알림 1건 (KST 일자 서수 멱등, FR-93)
62종료된 지원 건에 연락 반영 시도409 CONTACT_EVENT_TARGET_TERMINAL (시나리오 15)
63최고 후보 점수 40(회사만 일치)autoSelectedJobApplicationId: null — 수동 선택 요구 (시나리오 16)
64동점 최고 후보 2건autoSelectedJobApplicationId: null
65연락 반영으로 면접 등록회차 = 기존 최대 + 1, 레이블 기본값이 추출 전형명 (FR-90)
66이미 결정된 이벤트 재결정409 CONTACT_EVENT_ALREADY_DECIDED
67파싱 실패(후보 0건) 이벤트PENDING 유지 + 알림은 발송
6824시간 경과 nonce정리 배치가 삭제, 같은 nonce 재사용 가능

시나리오 E2E (단계별 완료 판정 기준과 1:1)

단계시나리오
1로그인 → 공고 수집 → dedup 그룹 생성 → 관리 상태 INTERESTED 저장 → 교차 목록에서 필터 조회 → 지원 생성 → 관리 상태 자동 복구 확인 → 대시보드에 카드 노출 → 담당자 등록 → 로그아웃 후 401
2이력서 업로드 → 저장 경로 생성 확인 → 프로필 초안 → 항목 수정 → 확정 → 관심 공고 1건 이상 추천도(점수·등급·축별 근거) 산출 → 지원 건에 서류 연결 → 새 버전 업로드 후 기존 연결 불변 확인
3서명된 Gmail 웹훅 1건 수신 → Discord 검토 요청 알림 → 검토 화면 후보 확인 → 반영 → 지원 상태 전이 + 면접 등록 → 소급 재평가 백필 실행 → 신규 매칭 공고에 알림 미발송 확인 → 축 가중치 수정 후 재평가 반영 확인

Observability

항목지표·로그확인 수단
인증 실패로그인 실패 횟수·잠금 진입 시각login_attempt_states 조회 (운영 API 미신설 — 단일 사용자라 로그로 충분)
웹훅 수신수신 건수·검증 실패 건수·사유별 분포GET /api/operations/webhook-receipts (PRD Operations 요구)
터널 연결왕복 가능 여부·마지막 확인 시각GET /api/operations/tunnel-status (PRD Operations 요구)
소스 상태6종 상태별 소스 수·마지막 전이 시각GET /api/operations/source-registry (PRD Operations 요구)
추천도 재평가성공·판단 불가·실패 건수와 사유POST /api/recommendations/re-evaluations 응답의 failures[] (PRD Operations 요구)
서류 업로드 실패확장자·용량 위반 건수, 경로 이탈 거부 건수애플리케이션 로그 WARN + 운영 조회는 미신설 (Open Question — 아래)
dedup 그룹 정체성신규 그룹 수·승계 그룹 수·고아 그룹 수배치 완료 INFO 로그
소급 재평가처리·신규 매칭·억제 건수백필 API 응답 + INFO 로그
매칭 오탐률 (PRD Success Metrics)매칭 성립 공고 중 EXCLUDED 전환 비율job_posting_match_results × job_posting_watch_states 집계 쿼리 (화면 미신설, 수동 쿼리)

Release Scenario — 무중단 배포 (의무)

3단계 각각이 독립 배포 단위이며, 각 단계 안에서도 스키마 → 코드 → 플래그 순으로 쪼갭니다. 배포 대상은 로컬 Docker(dev → QA PASS → prod)입니다.

신규 피처 플래그 (전부 기본 OFF로 시드)

플래그 키단계목적OFF 시 동작
auth.required1인증 강제기존과 동일하게 무인증 통과 (FE 배포 전 전환 창)
posting.dedup-group-identity1그룹 영속화·정체성 승계기존 dedup 동작(그룹 미영속) 유지
watchlist.management1관리 상태 API409 FEATURE_DISABLED — “미분류”(200 + watchState: null)와 구분되어야 하므로 404를 쓰지 않는다 (C). 단 FR-75 자동 보정 쓰기는 플래그와 무관하게 계속된다 (아래)
document.upload2서류 업로드업로드 API가 409 FEATURE_DISABLED
recommendation.evaluation2추천도 계산평가가 실행되지 않고 결과가 null
matching.field-weighted-scope3필드별 가중치 매칭MatchCriteria.matches(title) 제목 단독 경로로 복귀
contact.inbound-webhook3연락 이벤트 수신웹훅이 202 대신 404 (엔드포인트 비활성)
notification.job-posting-changed3변경 공고 알림대상 산출 0건
notification.contact-review-request3연락 검토 요청 알림대상 산출 0건
source.registry-status3소스 상태 전이 기록전이 미기록(컬럼은 유지)

신규 플래그와 별개로 시드만 복구하는 기존 키 1개 (A-2)

플래그 키단계성격이번 범위
posting.collection-dispatch-v21 (DB-02 시드)legacy 수집 경로의 kill switch — ON이면 legacy를 건너뛰고 v2 큐 경로로 전환행만 0(OFF)으로 복구. 활성화는 이번 범위 밖 (BE-33~36 완성도 검증 선행 필요)

watchlist.management OFF 구간에도 FR-75 자동 보정은 수행합니다 (BE-53 구현 판단 승인 — 리뷰 p4 반영)

WatchStateDomainService.restoreOnApplied·revertOnApplicationRemoved의도적으로 피처 플래그를 우회합니다. 이유는 데이터 드리프트 방지입니다 — 플래그가 OFF인 동안 사용자가 지원을 생성·철회하면, 그 사건에 대응하는 관리 상태 보정이 누락된 채 남습니다. 나중에 플래그를 켜면 “지원했는데 여전히 EXCLUDED”인 모순 상태가 화면에 드러납니다.

  • 사용자 진입 경로(API)는 플래그로 막히고, 시스템 자동 보정(Layer 1 리스너 경유)은 계속됩니다. 플래그의 목적이 “사용자에게 기능을 아직 열지 않는 것”이지 “데이터를 어긋나게 두는 것”이 아니기 때문입니다.
  • 이 우회는 쓰기만 해당합니다 — 조회 API는 그대로 409입니다.
  • 릴리즈 1-7(watchlist.management ON) 이전에도 관리 상태 행이 생길 수 있으므로, 플래그 ON 시점에 기존 데이터 정합을 별도로 맞출 필요가 없습니다. 이것이 이 판단의 이득입니다.

플래그 조회는 FeatureFlagGateway(domain interface) 경유이고, @ConditionalOnProperty·@Profile은 쓰지 않습니다(no-conditional-on-property). 플래그 OFF 상태에서도 빈은 항상 등록되고 분기만 달라집니다.

1단계 배포 순서

순서내용전환 조건롤백
1-1스키마만 배포 — 신규 9테이블 + job_postings.dedup_group_id·platform nullable 추가 + 플래그 3행 시드(OFF)Flyway 성공역방향 DDL(신규 테이블 DROP, 컬럼 DROP). 아무도 읽지 않으므로 안전
1-2코드 배포 (플래그 전부 OFF) — 인증 필터·watchlist·교차 목록·대시보드·담당자 전부 포함. 인증 필터는 플래그 OFF면 통과dev 기동 성공, 기존 API 회귀 통과이전 이미지 태그로 compose 재기동
1-3듀얼라이트 시작 — 수집·수동 등록이 platform·dedup_key(MANUAL 포함)를 신규 경로에 함께 기록신규 공고에 platform이 채워짐 확인코드 되돌리기 (신규 컬럼은 아직 아무도 읽지 않음)
1-4배치 백필POST /api/operations/backfills/posting-platform 로 기존 공고의 platform(실측 대상 6,633건)·MANUAL dedup_key(실측 대상 0건, B-2)를 500건 페이지 순회로 채운다. 페이지마다 커밋, NULL인 행만 대상이라 재실행 멱등백필 완료백필은 되돌릴 필요 없음(신규 컬럼)
1-5데이터 검증SELECT COUNT(*) FROM job_postings WHERE platform IS NULL AND posting_origin='COLLECTED' = 0, ... WHERE dedup_key IS NULL = 0 확인. 결과를 QA 리포트에 첨부두 쿼리 모두 0건
1-6posting.dedup-group-identity ON → 다음 08:30 배치가 그룹을 영속화. 첫 실행은 6,311 INSERT + 6,633 UPDATE 규모이므로 500그룹 단위로 커밋한다(B-3, 아래)배치 1회 성공, job_posting_dedup_groups 행 생성 확인플래그 OFF (그룹 테이블은 남지만 아무도 안 읽음)
1-7watchlist.management ON → 관리 상태 API 개방관리 상태 1건 저장·조회 성공플래그 OFF
1-8FE 배포credentials: 'same-origin' 전환 + 로그인 화면 + 401 처리FE 빌드·배포 성공이전 web 이미지 태그
1-9auth.required ON → 전 API 인증 강제로그인 후 기존 화면 전부 동작 확인플래그 OFF (즉시 무인증 복귀 — 터널이 아직 꺼져 있으므로 안전)
1-10터널 활성화 — cloudflared 컨테이너 기동GET /api/operations/tunnel-status reachable=true, 외부에서 401 확인컨테이너 중지

핵심 순서 제약: 1-9(인증 ON)가 1-10(터널 ON)보다 반드시 먼저입니다. 순서가 뒤집히면 무인증 상태로 인터넷에 노출됩니다. 이 제약을 /private-release 체크리스트에 명시합니다.

1-6 dedup 그룹 첫 실행의 락 관리 (B-3 — dba 실측 반영)

posting.dedup-group-identity를 켠 뒤의 첫 08:30 배치는 백필이 아니라 정상 배치 경로지만, 규모가 백필급입니다 — 실측 기준 job_posting_dedup_groups 6,311 INSERT + job_postings 6,633 UPDATE입니다.

  • 현재 deduplicate()는 그룹 전체를 계산한 뒤 jobPostingRepository.saveAll(changed)한 번 호출합니다(JobPostingDeduplicationDomainService.kt:67). 단일 트랜잭션이면 job_postings 거의 전 행에 락이 걸려, 그 사이 사용자 조회·수동 등록이 대기합니다.
  • 요구사항: 500그룹 단위로 커밋합니다. 그룹 단위로 끊는 이유는 한 그룹의 대표·비대표가 항상 같은 트랜잭션에 들어가야 “대표가 둘”인 중간 상태가 보이지 않기 때문입니다(공고 단위로 끊으면 그 불변식이 깨집니다).
  • 청크 경계에서 중단돼도 재실행이 안전합니다 — 전량 재그룹핑이 멱등이고, 이미 처리된 그룹은 같은 결과로 다시 쓰입니다.
  • 두 번째 실행부터는 변경분만 쓰이므로 규모가 급감합니다. 청크 로직은 첫 실행 이후에도 그대로 둡니다(조건 분기 없음).
posting.collection-dispatch-v2 시드 복구의 활성화 제약 (A-2)

1-1에서 시드하는 posting.collection-dispatch-v2행만 복구하고 값은 0(OFF)로 둡니다. 이 플래그를 ON으로 바꾸는 것은 이번 릴리즈 범위가 아닙니다 — 켜는 순간 legacy 수집 경로가 통째로 건너뛰어지고(JobPostingCollectionScheduler.kt:22-28) v2 큐 경로로 전환되므로, BE-33~36(수집 사이클 큐·디스패처 리스 복구·호스트 스로틀)의 완성도 검증이 선행돼야 합니다. 검증 없이 ON 하면 수집이 통째로 멈출 수 있습니다. 이 플래그의 활성화 여부는 별도 판단·별도 릴리즈로 남깁니다.

2단계 배포 순서

순서내용전환 조건롤백
2-1스키마만 배포 — 신규 13테이블 + 플래그 2행 시드(OFF)Flyway 성공역방향 DDL
2-2볼륨 마운트 + nginx 타임아웃docker-compose.yml에 문서 루트 볼륨 + DOCUMENT_ROOT 환경 변수, web/nginx.conf/api/ location에 proxy_read_timeout 600s·proxy_send_timeout 600s·proxy_connect_timeout 5s 추가(C-5). web·app 컨테이너 재기동컨테이너 내 마운트 경로 쓰기 가능 + 60초 초과 요청이 502로 끊기지 않음 확인compose·nginx.conf 되돌리기
2-3코드 배포 (플래그 OFF) — 서류·프로필·추천도 전량기동 성공, 기존 회귀 통과이전 이미지
2-4document.upload ON → 업로드 1회 왕복파일이 지정 경로에 생성됨플래그 OFF (업로드된 파일은 남지만 무해)
2-5축 가중치 기본값 4행 시드 (recommendation_axis_weights) — 정적 시드 4행, 운영 경합 없음이므로 Flyway DML 예외 허용 근거를 파일 주석에 남긴다4행 존재 확인역방향 DELETE
2-6recommendation.evaluation ON → 프로필 확정 1회 + 관심 공고 재평가점수·등급·축별 근거 산출 확인플래그 OFF (평가 결과 행은 남지만 화면이 안 읽음)
2-7FE 배포이전 web 이미지

데이터 마이그레이션 없음 — 2단계 신규 테이블은 전부 신규 데이터만 받습니다. 백필 대상이 없습니다.

3단계 배포 순서

순서내용전환 조건롤백
3-1스키마만 배포 — 신규 5테이블 + job_posting_match_results.match_score·match_threshold(nullable) + job_sources.registry_status·registry_status_changed_at(nullable) + 플래그 5행 시드(OFF)Flyway 성공역방향 DDL
3-2코드 배포 (플래그 OFF) — 매칭 확장·연락·알림 2종·소스 상태 전량기존 매칭 회귀(제목 단독) 통과이전 이미지
3-3source.registry-status ON → 다음 수집 회차부터 전이 기록상태 전이 로그 확인플래그 OFF
3-4소스 레지스트리 초기값 산출POST /api/operations/backfills/source-registry 1회 실행(실측 대상 10행이라 단일 트랜잭션 가능, B-2)registry_status IS NULL = 0건컬럼을 다시 NULL로 UPDATE (10행)
3-5matching.field-weighted-scope 사전 점검POST /api/matching/field-scope-backfills with dryRun=true. 신규 매칭 건수·해제 건수를 확인해 오탐 규모를 사전 판단unmatchedNowCount = 0 (기존 매칭이 풀리지 않음)없음 (읽기 전용)
3-6matching.field-weighted-scope ON플래그 OFF → 제목 단독 복귀
3-7소급 재평가 백필 실행 (FR-87) — dryRun=false. 새 revision 생성 → 전 공고 재평가 → 신규 매칭 공고 알림 억제처리 건수 = 대상 건수, 억제 건수 = 신규 매칭 건수부분 롤백만 가능 — 플래그 OFF로 매칭 규칙은 즉시 복귀하나, notification_eligible=0으로 억제된 공고는 되돌리지 않는다. 이는 “알림 과다 발송” 방향이 아니라 “알림 누락” 방향이며, 그 공고들은 이미 목록에 노출되므로 사용자 손실이 없다
3-8notification.job-posting-changed ON다음 09:00 배치에서 변경 알림 1건 이하플래그 OFF
3-9웹훅 시크릿 설정WEBHOOK_HMAC_SECRET 환경 변수 주입 후 컨테이너 재기동기동 성공compose 되돌리기
3-10contact.inbound-webhook ON + Gmail Apps Script 배포테스트 메일 1건 왕복 성공플래그 OFF → 웹훅 404 → 스크립트가 라벨 유지하며 재시도 대기
3-11notification.contact-review-request ON검토 요청 알림 1건 수신플래그 OFF
3-12FE 배포이전 web 이미지

데이터 마이그레이션 계획 (백필 수반 — 1단계·3단계)

Flyway 인라인 백필 DML을 금지합니다. Flyway는 DDL 전용이고, 값 채우기는 애플리케이션 배치가 수행합니다.

1단계 — job_postings.platform · MANUAL dedup_key

단계설계
1. 듀얼라이트platform·dedup_group_id nullable 추가(1-1). 배포된 코드가 신규 수집·수동 등록 시 신규 컬럼에 함께 기록(1-3). JobPosting.createCollected()는 어댑터가 이미 아는 JobPlatform을, createManual()은 회사명으로 계산한 dedupKey를 채운다
2. 배치 백필POST /api/operations/backfills/posting-platform. 500건 페이지 순회 + 페이지마다 커밋(기존 EvaluateJobPostingsUseCase.kt:83-88 패턴 재사용). 대상은 platform IS NULL AND posting_origin='COLLECTED'(실측 6,633건) 또는 dedup_key IS NULL AND posting_origin='MANUAL'(실측 0건 — dba 실측상 posting_origin='MANUAL' 행이 현재 존재하지 않는다). MANUAL 분기 로직은 0건이어도 유지한다(B-2) — 백필 시점 이후 배포 전에 수동 등록이 발생할 수 있고, 재실행 안전망이 있어야 한다. 이미 채워진 행은 대상에서 빠지므로 재실행 멱등. rate limit 불필요(로컬 MySQL, 외부 호출 없음)
3. 데이터 검증platform IS NULL AND posting_origin='COLLECTED' = 0건 ② dedup_key IS NULL = 0건 ③ 표본 20건의 platformjob_sources.platform과 일치. 세 쿼리 결과를 QA 리포트에 첨부
4. 기능 배포검증 통과 후 posting.dedup-group-identity ON(1-6) → dedup 배치가 신규 컬럼을 읽어 그룹을 만든다. watchlist.management ON(1-7) → 교차 목록이 platform으로 필터한다
5. 배포 후 검증교차 목록에서 플랫폼 필터 결과 건수가 예상과 일치, 관리 상태 저장·조회 왕복 성공. 이상 시 플래그 OFF

NOT NULL 제약 강화는 하지 않습니다 — MANUAL 공고의 platform은 영구히 NULL이 정상이고, dedup_group_id도 dedup 배치 실행 전에는 NULL입니다.

3단계 — job_sources.registry_status

단계설계
1. 듀얼라이트registry_status·registry_status_changed_at nullable 추가(3-1). 코드 배포 후 source.registry-status ON(3-3)부터 신규 수집 회차가 전이를 기록
2. 배치 백필POST /api/operations/backfills/source-registry 1회. 실측 대상 10행(dba 실측, B-2 — 기존 추정 “30행 미만”보다 작다)이라 페이지 순회·청크가 불필요하다. 기존 seeded_at·disabled_at·job_source_health·어댑터 보유 여부로 6종 초기값을 계산한다. registry_status IS NULL인 행만 대상이라 재실행 멱등
3. 데이터 검증registry_status IS NULL = 0건. 상태 분포를 GET /api/operations/source-registry로 육안 확인(30건 전수)
4. 기능 배포운영 조회 API 개방
5. 배포 후 검증다음 수집 회차 후 상태가 기대대로 전이되는지 확인(성공 소스 → ACTIVE)

3단계 — job_posting_match_results.match_score

백필하지 않습니다. 소급 재평가(3-7)가 전 공고를 다시 평가하면서 자연히 채웁니다. 재평가 전에는 NULL이고, 교차 목록 응답의 matchScorenull로 내려갑니다(FE가 하위 호환 처리).

플래그 제거 시점

플래그성격제거 시점
auth.required임시 릴리즈 토글1단계 prod QA PASS + 터널 활성화 1주 후 제거. 분기 자체를 삭제하고 인증을 무조건 강제한다 — 플래그가 남아 있으면 실수로 OFF할 위험이 존재한다
posting.dedup-group-identity영구 운영 스위치유지 — 그룹 오판정 시 즉시 중단해야 한다
watchlist.management·document.upload·recommendation.evaluation·contact.inbound-webhook임시 릴리즈 토글각 단계 QA PASS 2주 후 제거
matching.field-weighted-scope영구 운영 스위치유지 — 오탐 급증 시 제목 단독으로 즉시 복귀해야 한다(FR-86의 성공 기준이 오탐률이다)
notification.job-posting-changed·notification.contact-review-request영구 운영 스위치유지 — 알림 폭주 시 종류별로 끌 수 있어야 한다
source.registry-status임시 릴리즈 토글3단계 QA PASS 2주 후 제거

Open Questions

#항목현재 처리확정 필요 시점
1”필수 기술 미충족”의 정의 (FR-96 ⑤)[해소 — 2026-08-08 사용자 확정 A-1] NONE(기술명 자체 부재) 1건 이상일 때만 상한. PARTIAL은 상한 사유가 아닙니다. 근거는 docs/PRD.md §214 “하나라도 완전히 미충족” 문언과 변별력입니다. 상세는 “방안 9 → 필수 기술 미충족의 정의” 참조확정 완료
2매칭 필드 가중치·임계치 수치 (PRD Open Questions)제목 60 / 태그 35 / 본문 20, 임계치 50으로 확정했습니다. 근거는 방안 8 — ① 제목 단독이 임계치를 넘어 기존 동작 100% 보존 ② 본문 단독이 임계치에 못 미쳐 기존 기각 사유 해소 ③ 태그+본문 조합만 신규 획득3-5의 dryRun 결과(신규 매칭 건수)를 보고 배포 직전 조정 가능
3관리 상태가 붙은 그룹이 다음 배치에서 승계되지 않은 경우그룹 행을 삭제하지 않고 member_count=0으로 남깁니다 — 관리 상태·이력이 보존되고, 같은 공고가 나중에 다시 나타나면 교집합으로 복귀합니다고아 그룹이 누적되면(연 단위) 정리 정책이 필요합니다. 현 규모(연간 수백 그룹)에서는 방치
4서류 업로드 실패 운영 조회 API[철회 — 2026-08-08 게이트 ② 결정 ⓑ] 로그 WARN만으로 두려던 판단을 철회하고 PRD Operations 요구(“이력으로 저장하고 조회”)를 그대로 구현합니다. 경로 검증 실패·저장 루트 접근 거부는 보안 사건이라 사후 추적이 필요하다는 것이 결정 사유입니다. 2단계 범위, 티켓 BE-81확정 완료
5연락 이벤트 본문 보존 기간NFR-5(무기한 보존)를 따라 삭제하지 않습니다. 본문은 최대 20000자 × 연간 수백 건이라 용량 문제가 없습니다개인정보(메일 원문)를 무기한 보관하는 것이 부담되면 보존 기간 정책 추가
6웹훅 시크릿 회전 절차 (PRD Open Questions)단일 시크릿·수동 교체로 둡니다. 교체 시 앱 환경 변수와 Apps Script 상수를 동시에 바꿔야 하므로 짧은 중단(1회 수신 실패) 이 발생하나, 스크립트가 라벨을 유지해 재시도하므로 유실은 없습니다무중단 교체가 필요하면 “구·신 시크릿 2개를 동시 유효”로 확장(검증 로직 1곳만 변경)
7posting.collection-dispatch-v2 플래그 미시드[해소 — 2026-08-08 A-2. 최초 기술의 영향 방향이 틀렸으므로 정정합니다] 이 플래그는 v2 활성화 스위치가 아니라 legacy 경로의 kill switch입니다 — isEnabled가 true면 legacy를 건너뛰고 return합니다(JobPostingCollectionScheduler.kt:22-28). 따라서 시드가 없어 false인 현재 수집은 legacy 경로로 정상 동작하며, 실제 손실은 BE-33~36(수집 사이클 큐·리스 복구·호스트 스로틀)이 실행되지 않는 것과 행이 없어 운영 UPDATE로 켤 수 없다는 것입니다. 조치: DB-02 시드에 값 0으로 1행 추가(스위치만 복구, 활성화는 별도 판단)이번 범위에 포함 (DB-02)
12posting.collection-dispatch-v2를 언제 ON 할 것인가이번 범위에서는 켜지 않습니다. 켜는 순간 수집 경로가 통째로 v2로 전환되므로 BE-33~36의 완성도 검증이 선행돼야 합니다별도 릴리즈로 판단
15matchedOnly 필터 미구현[해소 — 2026-08-10 설계 결정: 계약에서 제거] 구현을 미루는 것이 아니라 넣지 않기로 확정했습니다. 근거 4가지(PRD FR-78 필터 목록에 없음 / FR-25 “매칭 실패 공고도 저장” 원칙에 역행 / id 집합·상한을 2중으로 조합해야 하는 비용 / matched·matchScore로 이미 대체 가능). 상세는 “미채택 파라미터 — matchedOnly” 절. true 요청의 400 거부 방어는 유지합니다확정 완료
14미매칭 공고의 근거 노출 범위[해소 — 2026-08-08 게이트 ② 결정 ⓑ] 근거 저장 범위를 match_score > 0 전부로 확대 확정. 비용은 dba 실측으로 정정 — 3년 38,900 → 51,600행(+32.6%, 17MB → 22MB). 최초 추정 “6.7배/30만 행”은 본문 보유 공고 13.8% 사실을 놓친 과대 추정이었다. 상세는 “방안 8 → 근거 저장” 참조확정 완료
13동기 재평가 실측 소요 (C-5)nginx proxy_read_timeout을 600초로 올려 동기 API를 유지하고, 대상 1,000건 상한 + elapsedMillis 응답으로 실측을 남깁니다3회 연속 60초를 넘기면 비동기 잡 큐(방안 B) 재검토
8NotificationDispatch.isExpired() 미사용 (AS-IS 관찰)7일 만료 판정 로직이 도메인에 있으나 planDispatches 경로에 연결돼 있지 않습니다이번 범위 밖. 신규 알림 2종도 만료 필터를 쓰지 않아 기존과 동작이 일관됩니다
9message_summary VARCHAR(300) 초과 위험 (AS-IS 관찰)DAILY_DIGEST 본문이 대상 수에 비례해 길어지는데 절단 코드가 없습니다. 신규 알림 2종은 요약을 300자 이내로 조립하도록 설계했으나, 기존 digest 경로는 그대로입니다이번 범위 밖. 발견 회사가 늘면 별도 티켓
10교차 목록의 groupPlatforms 조회 비용그룹당 플랫폼 집합을 위해 페이지 항목의 dedup_group_id 집합으로 배치 조회 1회를 추가합니다(N+1 아님). 20건 페이지 기준 쿼리 1회실측 후 P95 500ms를 못 맞추면 job_posting_dedup_groups에 플랫폼 집합을 비정규화 보관(배치가 갱신)
11hwp 텍스트 추출지원하지 않습니다 — 업로드·보관은 되지만 프로필 초안은 extractionFailed: true로 수동 입력을 안내합니다. 자바 진영에 신뢰할 만한 HWP 파서가 없습니다필요해지면 외부 변환 도구 연동(범위 밖)

Document History

날짜변경 내용
2026-08-10플래그 게이트 가드 공백 기록 (후속 13)WatchStateDomainService.findAllBy·findGroupIdsBy 양쪽에 “플래그 OFF면 던진다”를 고정하는 테스트가 없어 ensureFeatureEnabled() 제거 뮤턴트가 생존한다(BE-52 병합 정합 리뷰 실증). 제거되면 플래그 OFF에서 목록이 watchStatus·priority·tags를 노출해 C-1 계약 위반. BE-53 복구 2종의 “게이트 없음”은 strict mock + verify(exactly = 0)으로 강하게 고정돼 있어 비대칭. 두 메서드를 함께 덮어야 하므로 watchlist 정리 티켓으로 이관
2026-08-10matchedOnly 계약 제거 확정 (Open Questions #15 해소) — 후속 티켓으로 남기지 않고 계약에서 제거. 근거: ① PRD FR-78 필터 목록에 매칭 여부가 없음(계약 초안의 자체 추가분) ② FR-25 “매칭은 알림 조건일 뿐 저장 조건이 아니다”와 방향이 반대 — 저장 목적이 키워드 변경 후 과거 공고 재발견인데 목록에서 감추면 역행 ③ matching id 집합 + 상한을 watchlist 경로와 2중으로 조합해야 하는 비용이 이진 토글 하나의 이득을 초과 ④ matched·3단계 matchScore로 정렬·표시가 이미 가능하고 FR-86 취지에 더 부합. matchedOnly=true의 400 거부 방어는 유지 — 조용한 무시가 FE 토글을 오작동시키고 에러 코드는 재사용 가치가 있음
2026-08-10BE-52 리뷰 반영 (계약 2건 확정) — ① matchedOnly미구현이므로 400 JOB_POSTING_FILTER_NOT_SUPPORTED로 거부한다고 확정(조용한 무시는 FE 토글이 필터 없는 전체 목록을 200으로 받아 오작동시킨다는 리뷰 p1 근거). 에러 코드 신설 + 400 분류를 3분류 → 4분류(미구현 필터 추가)로 확장 ② 교차 목록 에러 목록에 409 FEATURE_DISABLED 추가 — 플래그 OFF에서 관리 상태 파라미터를 명시한 경우에만 409이고, 미지정이면 관리 상태 필드만 null로 강등되어 200임을 명문화
2026-08-10BE-52 구현 중 발견된 설계 갭 4건 정정 — ① WatchStateDomainService.findAllBy(dedupGroupIds) 시그니처 누락 보완(Repository에는 있었으나 DomainService 노출 누락. ensureFeatureEnabled()를 걸면 교차 목록 전체가 409로 죽는다는 경고 병기) ② 공고 상세 경로를 실제 코드(/api/job-postings/{jobPostingId})에 맞춰 정정 — 최초 문서의 /api/companies/... 표기는 오류 ③ matchedOnly 미구현 명시 + Open Questions #15 신설(matching id 집합 경로 부재) ④ BE-52 티켓의 PRIORITY_DESC 문면에서 “미등록” 제거 — C-6의 watchedOnly 강제와 상충
2026-08-10BE-53 리뷰 반영 (SSOT 드리프트 2건) — ① revertOnApplicationRemoved 시그니처를 구현에 맞춰 histories: List<WatchStateHistory> 인자 포함으로 정정(엔티티가 전체 이력을 보유하지 않고 별도 조회하므로 구현이 옳다) ② watchlist.management OFF 구간에도 FR-75 자동 보정 쓰기는 계속된다를 Release Scenario에 명시 — 플래그의 목적은 사용자 노출 차단이지 데이터 드리프트 허용이 아니며, 이 판단으로 플래그 ON 시점의 데이터 정합 보정이 불필요해진다
2026-08-10FR-75 복구 메서드 소유 티켓 명시 (BE-50 → BE-53 전제 변경) — BE-50이 restoreOnApplied·revertOnApplicationRemoved를 프로덕션 호출부 0건(리뷰 p2)으로 제거했고, 그 코드가 실 DB에서 태그 유니크 제약 위반으로 죽어 있던 것도 함께 드러났다(MockK 단위 테스트만으로 통과). TDD 시그니처 선언은 유지하되 구현 티켓이 BE-53임을 명시하고, 재도입 시 Testcontainers 실 DB 통합 테스트 필수를 계약 2와 같은 계열로 기록. findGroupIdsBy는 BE-52·BE-65에 실 소비자가 있어 유지됨을 확인
2026-08-10wave 2 리뷰 반영 (설계 문서 오류 1건 + 보강 1건) — A 시퀀스 다이어그램 정정: BE-71과 TDD 모두 Controller → WebhookVerificationDomainService 직접 호출로 그려져 방안 2 채택안(Controller → UseCase → DomainService)·레이어 규칙과 반대였다. 두 다이어그램을 Controller → UseCase → DomainService로 고치고 BE-71 본문에 레이어 경로를 명문화. TDD의 연락 검토 반영 흐름은 수신 흐름과 섞여 있어 별도 시퀀스로 분리 / B “실패 경로·동시성·멱등”에 실패를 영속화하는 경로의 트랜잭션 설계 절 신설 — wave 2에서 같은 결함이 3회 반복 검출(로그인 실패 카운트 롤백·웹훅 검증 이력 유실·서류 업로드 이력은 선제 대응). 계약 3가지(예외 전파와 트랜잭션 경계를 함께 설계 / JPA 제약 위반은 noRollbackFor로 구제되지 않음 — flush 시점 rollback-only 마킹, 실 MySQL 재현 확인 / 실 DB 통합 테스트로 고정)와 3단계 재적용 지점 4곳 명시
2026-08-09사유 코드 명명 중재 확정 — 업로드 실패 사유 7종 중 메타데이터 저장 실패 값을 METADATA_PERSISTENCE_FAILED 로 확정. 후보 2개 기각(dba 제안 STORAGE_WRITE_FAILED는 “쓰기 성공 후 실패”인데 이름이 반대로 읽혀 STORAGE_IO_FAILED와 혼동 / be 제안 PERSISTENCE_FAILED는 대상이 빠져 no-over-abstract-name 위반). 채택 근거는 PRD FR-4의 “경로와 메타데이터” 도메인 어휘이며 STORAGE_*METADATA_* 대비가 이름만으로 드러난다. 판별 기준(파일 미생성 vs 파일 생성 후 보상 삭제)을 TDD·BE-81에 명시. enum 값 이름 변경뿐이라 컬럼·wave·Single Writer 변동 없음
2026-08-09dba 정합 5건 반영 — A 근거 확대 비용을 dba 실측으로 정정(6.7배/30만 행 → +32.6%/51,600행, 원인: 본문 보유 공고 13.8%·전량 GREENHOUSE) 및 NFR 판정 정정(일일 +41행, 전량 재평가 공고당 +0.09행, 락 불변) / B job_posting_match_field_evidences 저장 조건을 match_score > 0으로 본문·티켓에 명문화 / C 메타데이터 저장 실패 분리 채택(사유 6 → 7종, 파일 잔존 여부가 반대라 병합 불가) / D .gs 0건 회차 POST 미발생 검증 + 명시적 가드·주석 고정 / E 테이블명 application_document_upload_failures 통일·보존 2단 분리(보안 2종 무기한, 나머지 5만 행 임계 400일)
2026-08-08게이트 ② 승인 반영 (범위 확대 3건) — A-1 Gmail Apps Script “범위 밖” 철회, 동작하는 Code.gs + 설치 절차 문서 산출(BE-82 신설) / A-2 매칭 근거 저장을 match_score > 0 전부로 확대(3년 4.5만 → 약 30만 행), matched를 판정 SSOT로 명시하고 excludedByKeyword 신설(제외 키워드 vs 점수 미달 구분), Open Questions #14 해소 / A-3 서류 업로드 실패 이력 테이블·운영 조회 API 추가(BE-81 신설), 사유 6종·보안 사유 2종 필수 기록·REQUIRES_NEW 기록·보존 무기한, Open Questions 4 철회. 티켓 36 → 38건, wave 분포 `1,6,3,1
2026-08-082차 검수 패치 (계약 구멍 4건 + 인지 항목 3건) — A 400 에러 3분류(JOB_POSTING_SORT_NOT_APPLICABLE·WATCH_STATE_FILTER_LIMIT_EXCEEDED·RECOMMENDATION_TARGET_LIMIT_EXCEEDED) + actualCount·limit 응답 필드 / B elapsedMillis 스키마 반영 / C 미분류(200+watchState:null) vs 플래그 OFF(409 FEATURE_DISABLED) 구분, WATCH_STATE_NOT_FOUND 제거, FeatureDisabledException을 BE-45로 이동 / D 태그 필터 채택(C-6 기계 재사용) / E-1 단독 그룹×배치 경합 방어하지 않는 근거 기록 / E-2 미매칭 근거 범위를 Open Questions 14로 이관(미결정) / E-3 (criteria_revision) 인덱스 미생성 확정
2026-08-08검수 반영 패치 (senior-fe·senior-dba 지적 5건 + 사용자 확정 2건) — A-1 59점 상한 발동 조건 확정(NONE 1건 이상, PRD §214 근거) / A-2 posting.collection-dispatch-v2 시드 복구를 DB-02 범위로 편입(영향 방향 정정: legacy kill switch) / B-1 recruitment_contact_event_attachments 신설 + ContactEvent 애그리게이트·receive() 시그니처 반영 / B-2 정렬 인덱스를 deadline_atfirst_seen_at으로 정정(실측 71.4% NULL)·백필 대상 실측 반영(MANUAL 0건·소스 10건) / B-3 dedup 첫 실행 500그룹 청크 커밋 요구 / C-1 공고 상세에 dedupGroupId·watchState 추가 + 공고 기준 관리 상태 저장 엔드포인트 신설 / C-2 매칭 근거(matchFieldEvidences·matchScore·matchThreshold) 노출 계약 확정 + BE-80 신설 / C-3 교차 목록 companyId 필터 추가 / C-4 auth.required OFF 구간의 GET /api/auth/session 계약 확정(authRequired 필드) / C-5 nginx proxy_read_timeout 600초 상향 채택(비동기 잡 미채택) / C-6 PRIORITY_DESCwatchedOnly 강제 + 정렬 그룹 id 전달(상한 1,000)
2026-08-08최초 작성 — PRD FR-70~97(28건) 대상 설계. 3단계 독립 배포 단위로 분할. 인증 방식(자체 세션+쿠키)·웹훅 HMAC 정규화·중복 그룹 정체성 승계(멤버 교집합)·MANUAL dedup 편입(후보/델타 필터 분리)·교차 목록 QueryDSL 도입·필드별 가중치(60/35/20, 임계치 50)·추천도 4축 규칙·연락 확인형 반영의 8개 축 방안 비교와 채택 근거 확정. 신규 컨텍스트 6개·신규 테이블 28개·API 계약 시그니처 수준 확정. 검수 미결 2건(MANUAL dedup 후보 편입, JD 없는 공고 추천도) 설계 확정

부록 A — 티켓 DAG · wave 분포

티켓 38건(BE-45 ~ BE-82). 3단계 각각이 독립 배포 단위이므로 wave도 단계별로 닫습니다.

2026-08-08 검수 패치 반영 — C-2(매칭 근거 노출)로 BE-80을 신설해 단계 3 wave 3에 배치했습니다. 나머지 지적(A-1·A-2·B-1B-3·C-1·C-3C-6)은 기존 티켓 본문 수정으로 흡수했습니다.

2026-08-08 게이트 ② 승인 반영 — 범위 확대 3건으로 BE-81(서류 업로드 실패 이력, 단계 2 wave 4)·BE-82(Gmail Apps Script, 단계 3 wave 3)를 신설했습니다. 근거 저장 확대(A-2)는 기존 티켓(BE-69·74·80) 본문 수정으로 흡수했습니다.

단계 1 — 기반 (FR-70~80), 11건

wave티켓너비
1BE-45 공통 계약1
2BE-46 인증 / BE-47 웹훅 HMAC / BE-48 dedup 그룹·MANUAL·platform / BE-49 담당자 / BE-50 watchlist / BE-51 대시보드6
3BE-52 교차 목록 / BE-53 지원↔관리 상태 이벤트 / BE-54 운영 조회·백필3
4BE-55 E2E1

단계 2 — 서류·추천도 (FR-8185, 9596), 12건

wave티켓너비
1BE-56 공통 계약1
2BE-57 문서 도메인·파일 게이트웨이 / BE-58 프로필·가중치 / BE-59 요구사항 추출 / BE-60 서류 연결 도메인4
3BE-61 업로드 API / BE-62 텍스트 추출·프로필 API / BE-63 추천도 계산 / BE-64 서류 연결 API4
4BE-65 추천도 API·일괄 재평가 / BE-66 목록 추천도 노출 / BE-81 업로드 실패 이력·운영 조회3
5BE-67 E2E1

단계 3 — 연동 (FR-86~94, 97), 12건

wave티켓너비
1BE-68 공통 계약1
2BE-69 필드 가중치 매칭 / BE-70 소스 상태 6종 / BE-71 연락 도메인·웹훅(+첨부) / BE-72 가중치·상한 해제 / BE-73 변경 공고 알림5
3BE-74 소급 백필·억제 / BE-75 파싱·후보 점수 / BE-76 검토 요청 알림 / BE-80 매칭 근거 API 노출 / BE-82 Gmail Apps Script5
4BE-77 검토 화면·반영 / BE-78 운영 가시성2
5BE-79 E2E1

전체 wave 너비 분포 (게이트 ② 반영 후 재계산): 1, 6, 3, 1 | 1, 4, 4, 3, 1 | 1, 5, 5, 2, 1 평균 2.53(38티켓 / 15wave), 최대 6. 모든 wave가 1~2인 직선형 DAG가 아니므로 분해 게이트를 통과합니다. 각 단계의 wave 1(공통 계약)이 유일한 병목이고, wave 2가 트리형으로 최대 6갈래로 벌어집니다.

최초1차 검수 후게이트 ② 후
단계 11, 6, 3, 11, 6, 3, 11, 6, 3, 1 (변동 없음)
단계 21, 4, 4, 2, 11, 4, 4, 2, 11, 4, 4, **3**, 1 (BE-81)
단계 31, 5, 3, 2, 11, 5, 4, 2, 11, 5, **5**, 2, 1 (BE-82)
합계35티켓 / 2.3336티켓 / 2.4038티켓 / 2.53

범위가 넓어졌는데도 직렬 구간이 늘지 않고 wave 너비만 넓어졌습니다 — 두 신규 티켓이 각각 기존 wave의 빈 폭에 들어갔기 때문입니다.

병목을 하나로 묶은 근거

각 단계의 wave 1은 연관된 병목을 단일 티켓으로 합친 것입니다. GlobalExceptionHandler.kt·build.gradle.kts·application.yml·docker-compose.yml은 후행 티켓이 전부 참조하는 파일이라, 쪼개면 ① wave가 1→1→1 직렬 사슬이 되고 ② 같은 파일 머지 충돌이 납니다. 특히 예외 클래스와 그 핸들러는 분리 불가입니다 — 예외가 없으면 핸들러가 컴파일되지 않고, 핸들러가 없으면 후행 티켓의 계약 테스트가 전부 catch-all 500으로 깨집니다.

부록 B — Single Writer per File 검증

같은 wave의 두 티켓이 같은 파일을 수정하는지 검증했습니다. 교집합 0건입니다.

단계 1 wave 2 (6티켓)

티켓수정 파일 집합
BE-46domain/auth/**(신규), application/auth/**(신규), presentation/auth/**(신규), infrastructure/auth/**(신규)
BE-47domain/webhook/**(신규), application/webhook/**(신규), presentation/webhook/**(신규), infrastructure/webhook/**(신규)
BE-48domain/posting/{JobPosting, JobPostingRepository, JobPostingDeduplicationDomainService, ManualJobPostingDomainService, JobPostingDedupGroup*}, application/posting/RegisterManualJobPostingUseCase, infrastructure/posting/persistence/{JobPostingRepositoryImpl, JobPostingJpaEntity, DedupGroup*}
BE-49domain/application/{JobApplicationContact, JobApplicationContactRepository, ApplicationContactDomainService}(신규), presentation/application/ApplicationContactApiController(신규), application/application/*Contact*UseCase(신규)
BE-50domain/watchlist/**(신규), application/watchlist/**(신규), presentation/watchlist/WatchStateApiController(신규), infrastructure/watchlist/**(신규)
BE-51domain/application/DashboardQueryDomainService(신규 파일 — 기존 ApplicationQueryDomainService 미수정), application/dashboard/**(신규), presentation/dashboard/**(신규)
  • BE-49와 BE-51이 둘 다 domain/application 패키지에 들어가지만 파일이 다릅니다. BE-51은 기존 ApplicationQueryDomainService.kt를 건드리지 않고 신규 DashboardQueryDomainService.kt를 만듭니다 — 이 제약을 티켓 본문에 명시했습니다.
  • BE-46이 AuthenticationFilter의 인증 제외 경로에 /api/webhooks/**미리 등록해 BE-47이 그 파일을 건드리지 않습니다.

단계 1 wave 3 (3티켓)

티켓수정 파일 집합
BE-52presentation/posting/JobPostingApiController(추가), application/posting/{ListAllJobPostingsUseCase, CrossCompanyPostingResponseMapper}(신규), domain/posting/{JobPostingSearchRepository, JobPostingSearchDomainService}(신규), infrastructure/posting/persistence/JobPostingSearchRepositoryImpl(신규)
BE-53domain/application/{Application, ApplicationDomainService, JobApplicationEvent}, presentation/watchlist/listener/**(신규), application/watchlist/Restore*·Revert*UseCase(신규), config/AsyncConfig(executor 추가)
BE-54presentation/operation/OperationApiController(추가), application/operation/**(신규), presentation/HealthProbeApiController(신규)

단계 2 wave 2·3, 단계 3 wave 2·3

  • 단계 2 wave 2: BE-57(domain/document)·BE-58(domain/recommendation 프로필)·BE-59(domain/recommendation 추출기)·BE-60(domain/application 신규 파일) — 교집합 0. BE-58·59는 같은 패키지지만 파일이 다릅니다.
  • 단계 2 wave 3: BE-61(presentation/document)·BE-62(presentation/recommendation/ResumeProfileApiController)·BE-63(domain/recommendation 계산)·BE-64(presentation/application/SubmittedDocumentApiController) — 교집합 0.
  • 단계 2 wave 4: BE-65(application/recommendation + presentation/recommendation/RecommendationApiController)·BE-66(application/posting 매퍼)·BE-81(domain/document/DocumentUploadFailure* 신규 + application/document/{Record,List}*UseCase 신규 + application/document/UploadDocumentUseCase(BE-61이 wave 3에서 생성, 여기서 훅 1곳 추가) + presentation/operation/OperationApiController) — 교집합 0. UploadDocumentUseCase.kt는 BE-61이 wave 3, BE-81이 wave 4로 wave가 달라 충돌하지 않습니다. OperationApiController.kt는 단계 1(BE-54)·단계 3(BE-78)이 만지지만 단계가 달라 교집합이 없습니다.
  • 단계 3 wave 3: BE-74·BE-75·BE-76·BE-80에 BE-82가 더해지며, BE-82는 scripts/gmail-forwarder/**(JS)만 건드려 Kotlin 소스와 교집합이 원천적으로 0입니다.
  • 단계 3 wave 2: BE-69(domain/matching)·BE-70(domain/company)·BE-71(domain/contact)·BE-72(presentation/recommendation/RecommendationWeightApiController)·BE-73(domain/notification + application/notification) — 교집합 0.
  • 단계 3 wave 3: BE-74(application/matching + presentation/matching)·BE-75(application/contact + presentation/contact/listener)·BE-76(application/notification/SendContactReview*BE-80(application/posting/{JobPostingDetailResponse, JobPostingResponseMapper, CrossCompanyPostingResponseMapper}) — 교집합 0. BE-73과 BE-76이 둘 다 application/notification이지만 다른 wave이고 파일이 다릅니다. BE-80이 만지는 application/posting 파일들은 BE-52(단계 1)·BE-66(단계 2)이 만졌던 파일이지만 단계가 달라 wave가 겹치지 않습니다.

공통 파일 와이어업 배치

파일배치
GlobalExceptionHandler.kt각 단계 wave 1 단독 (BE-45·56·68)
build.gradle.kts / application.yml / docker-compose*.yml각 단계 wave 1 단독
RecommendationApiController.kt vs RecommendationWeightApiController.kt vs ResumeProfileApiController.kt3개 파일로 분리 — 같은 컨텍스트지만 티켓별 단독 소유
OperationApiController.ktBE-54(단계 1 wave 3), BE-78(단계 3 wave 4) — 다른 단계·다른 wave
JobPostingApiController.ktBE-52(단계 1 wave 3)만 수정
JobPostingDetailResponse.kt / JobPostingResponseMapper.ktBE-52(단계 1 — C-1 dedupGroupId·watchState), BE-80(단계 3 — C-2 매칭 근거). 단계가 달라 wave 교집합 없음
CrossCompanyPostingResponseMapper.ktBE-52(단계 1 생성), BE-66(단계 2 추천도), BE-80(단계 3 matchScore). 전부 다른 단계
web/nginx.confBE-56(단계 2 wave 1) 단독 소유 — FE 티켓은 건드리지 않습니다(senior-fe 인계)
application/document/UploadDocumentUseCase.ktBE-61(단계 2 wave 3 생성) → BE-81(단계 2 wave 4에서 실패 기록 훅 추가). 같은 단계지만 wave가 달라 안전
scripts/gmail-forwarder/**BE-82 단독 — JS라 Kotlin 티켓과 교집합 없음
E2E 시나리오 파일각 단계 마지막 wave 단독 (BE-55·67·79), 기존 시나리오 파일 미수정

부록 C — 요구사항 커버리지 (PRD P0/P1 → 설계 → 티켓)

FR우선순위설계 근거티켓
FR-70 단일 사용자 최소 인증P0방안 1 (자체 세션+쿠키), API 계약 인증 + 플래그 OFF 구간 세션 계약(C-4)BE-45, BE-46
FR-71 터널링 외부 노출P0방안 6 (cloudflared 사이드카), Release 1-10BE-45, BE-54(터널 상태)
FR-72 웹훅 HMAC ±5분/nonce 24hP0방안 2BE-45, BE-47
FR-73 관리 상태 3종P0방안 5, 상태 전이 표 + 공고 기준 저장 진입점(C-1)BE-50, BE-52
FR-74 그룹 귀속·MANUAL dedup_keyP0방안 3·4 (그룹 정체성 승계, 후보/델타 필터 분리)BE-48, BE-50
FR-75 관리 상태 전이 규칙P0방안 12 (Layer 1 이벤트), 상태 전이 표BE-50, BE-53
FR-76 관리 상태 변경 이력P0방안 5BE-50
FR-77 우선순위·목표일·태그·메모·제외 사유P0API 계약 관리 상태BE-50
FR-78 교차 회사 목록·필터·정렬P0방안 7 (QueryDSL·platform 컬럼) + companyId 필터(C-3)·PRIORITY_DESC 규칙(C-6)·정렬 인덱스 정정(B-2)BE-48, BE-52, BE-66, BE-80
FR-79 대시보드 3요소P0방안 5, API 계약 대시보드BE-51
FR-80 담당자 관리P0방안 5 (application 합류)BE-49
FR-81 문서 유형 4종·계열·버전P0방안 5, document 시그니처BE-57
FR-82 회사 비종속 로컬 저장 경로P0방안 5, Release 2-2 볼륨BE-56, BE-57
FR-83 확장자 4종·20MBP0document 시그니처BE-56, BE-57, BE-61
FR-84 경로 새니타이즈·저장 루트 검증P0DocumentFileName 값 객체 + 게이트웨이 이중 방어 + 실패 이력 기록(A-3)BE-56, BE-57, BE-81
FR-85 지원 건 연결·버전 고정P0방안 5 (연결은 application 소유)BE-60, BE-64
FR-86 필드별 가중치 매칭P1방안 8 (60/35/20, 임계 50 + 문맥 규칙 2종) + 매칭 근거 노출 계약(C-2)BE-69, BE-80
FR-87 전체 소급 재평가·알림 없음P1방안 6 (Spring Batch 미채택), Release 3-5·3-7BE-74
FR-88 소스 상태 6종P1방안 5, 상태 전이 표BE-70, BE-78
FR-89 연락 웹훅·후보·검토 요청P1방안 10 + 첨부 메타 저장소(B-1)BE-71, BE-75
FR-90 검토 화면·확인형 반영P1방안 10, 상태 전이 표BE-77
FR-91 후보 매칭 점수 40/30/20/10P1방안 10BE-75
FR-92 Gmail Apps ScriptP1API 계약 + 동작하는 .gs 산출(A-1)BE-71, BE-82
FR-93 변경 공고 알림 24h 1회P1방안 11 (KST 일자 서수)BE-68, BE-73
FR-94 연락 검토 요청 알림P1방안 11, enum 확장BE-68, BE-76
FR-95 이력서→프로필 초안→확정P0방안 5·9BE-58, BE-62
FR-96 추천도 4축·정규화·59점 상한P0방안 9 (판단 불가 1급 표현, JD 없으면 평가 제외)BE-58, BE-59, BE-63, BE-65
FR-97 상한 해제·가중치 수정P1방안 9, API 계약BE-72
NFR설계 대응
NFR-8 브로커·캐시·워커 미도입방안 13 — 전부 미채택 사유 명시
NFR-12 교차 목록 P95 500ms방안 7 QueryDSL + 배치 조회 (BE-52)
NFR-13 관심 공고 200건 5분방안 6 동기 API + 규칙 기반 (BE-65)
NFR-14 20MB·확장자 4종BE-56·57·61
NFR-15 HMAC ±5분·nonce 24hBE-47
NFR-16 배치 순차 실행SchedulingConfig poolSize=1 (기존 구조로 이미 충족)
NFR-17 세션 24h·5회 잠금·비밀 환경 변수방안 1 (BE-46)

| NFR-13 관심 공고 200건 5분 (재확인) | 동기 API 유지 + nginx proxy_read_timeout 600s(C-5) + 대상 1,000건 상한 | BE-56, BE-65 |

누락 0건. FR-70~97 전 28건이 설계 요소와 티켓에 1:1 이상으로 매핑됩니다.

검수 패치 항목 → 반영 위치 (2026-08-08)

항목TDD 반영 위치티켓
A-1 59점 상한 발동 조건 확정방안 9 “필수 기술 미충족의 정의” (Open Questions 1에서 해소)BE-63 (기존 본문과 일치 — 변경 없음)
A-2 collection-dispatch-v2 시드 복구스키마 변경 요약 “feature_flags 시드”, Release 1-1·플래그 표, Open Questions #7·#12BE-45 (+DB-02 요구)
B-1 연락 첨부 저장소contact 인터페이스 시그니처, 도메인 모델 표, ERD(3), 스키마 요약(3단계 6개)BE-71, BE-77, BE-79
B-2 정렬 인덱스·백필 실측정렬 성능 노트, 인덱스 요구 표, Release 1-4·3-4, 데이터 마이그레이션 계획BE-52, BE-54, BE-70, BE-78
B-3 dedup 첫 실행 청크Release “1-6 dedup 그룹 첫 실행의 락 관리”BE-48
C-1 상세 dedupGroupId·공고 기준 저장”1단계 — 공고 상세 응답 확장 · 공고 기준 관리 상태 저장”BE-52, BE-55
C-2 매칭 근거 노출”3단계 — 매칭 근거 노출”BE-80 (신설), BE-79
C-3 companyId 필터교차 목록 쿼리 파라미터 + 인덱스 표BE-52, BE-55
C-4 플래그 OFF 세션 계약”auth.required 플래그 OFF 구간의 세션 계약”BE-46, BE-55
C-5 재평가 타임아웃”동기 재평가와 프록시 타임아웃”, Release 2-2BE-56, BE-65
C-6 PRIORITY_DESC 페이지네이션”sort=PRIORITY_DESC의 페이지네이션 규칙”BE-52

2차 검수 패치 (2026-08-08 — 계약 구멍 4건 + 인지 항목 3건)

항목처리TDD 반영 위치티켓
A 400 에러 코드 세분화코드 3종 신설 + actualCount·limit 응답 필드”공통 규약 → 에러 응답 확장 필드 / 400 에러 3분류”, 에러 코드 표BE-45, BE-52, BE-65
B elapsedMillis 스키마 누락재평가·프로필 확정 응답 스키마에 추가(밀리초)추천도 재평가 / 이력서 프로필 API 블록BE-62, BE-65
C 플래그 OFF vs 미분류미분류 = 200 + watchState: null, 플래그 OFF = 409 FEATURE_DISABLED. WATCH_STATE_NOT_FOUND 제거. FeatureDisabledException 정의를 BE-56 → BE-45로 이동(단계 1부터 필요 — 최초 분해의 순서 오류)“미분류와 기능 준비 중의 구분”, 플래그 표BE-45, BE-50, BE-52, BE-56
D 태그 필터채택 — C-6의 그룹 id 전달 기계를 재사용해 한계 비용이 거의 0. watchedOnly 함의 + 1,000건 상한 공유”태그 필터 (D — 채택)“BE-52
E-1 단독 그룹 × 배치 경합dba 판단에 동의 — 방어하지 않음. 손실 없음(행 보존·재설정 1클릭), 창 극소, 방어 비용이 이득 초과. 고아 그룹 WARN 로그만 남김”신규 경합: 단독 그룹 생성 × dedup 배치”BE-48, BE-52
E-2 미매칭 근거 범위결정하지 않음 — 두 선택지를 Open Questions 14에 정리, 사용자 게이트 ② 판단. (당시 제시한 비용 “4.5만 vs 30만 행”은 2026-08-09 dba 실측으로 38,900 vs 51,600행으로 정정됨)Open Questions #14(판단 후 배정)
E-3 (criteria_revision) 인덱스미생성 확정으로 표기해 dba 설계와 일치인덱스 요구 표

게이트 ② 승인 반영 (2026-08-08 — 범위 확대 3건)

항목결정TDD 반영 위치티켓
A-1 Gmail Apps Scriptⓐ 동작하는 .gs를 산출물에 포함 — “범위 밖” 철회. 초안 공고알림앱/gmail-forwarder/{Code.gs, README.md} → 레포 scripts/gmail-forwarder/”Gmail Apps Script 계약” 절 재작성BE-82 (신설), BE-71
A-2 미매칭 근거match_score > 0 전부 저장 — dba 실측 3년 38,900 → 51,600행(+32.6%, 최초 추정 6.7배는 정정). matched가 판정 SSOT이고 excludedByKeyword 신설로 미매칭 사유 구분”근거 저장”, “매칭/미매칭의 구분”, 매칭 근거 노출 계약, 인덱스 표, Open Questions #14 해소BE-69, BE-74, BE-80, BE-79
[dba 정합 5건 · 2026-08-09] A 근거 확대 비용 정정(실측 +32.6%) / B 저장 조건 명시(match_score > 0) / C 메타데이터 저장 실패 분리(7종) / D .gs 0건 POST 생략 검증·가드 추가 / E 테이블명 application_document_upload_failures·보존 2단 분리반영 완료근거 저장 절, 사유 코드 7종 표, 보존 정책 표, 인덱스 표BE-69, BE-74, BE-81, BE-82
A-3 업로드 실패 이력ⓑ 테이블·조회 API 추가 — 로그 WARN 미채택 철회. 사유 6종, 보안 사유 2종 필수 기록, REQUIRES_NEW 기록, 보존 무기한document 컨텍스트 시그니처·역할 경계·도메인 모델·실패 경로·API 계약·ERD(2)·스키마 요약(2단계 14개), Open Questions 4 철회BE-81 (신설), BE-67