지원 관리 확장 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-81 | 문서 계열·버전 + 회사 비종속 로컬 저장 + 이력서 프로필 확정 + 규칙 기반 지원 추천도 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-77에spring-boot-starter-security가 없습니다. 의존성 목록 전체에 security 계열이 0건입니다.- FE 클라이언트는 인증을 아예 전제하지 않습니다 —
web/src/api/client.ts:20-47의apiRequest()가credentials: 'omit'로 호출하고, 주석client.ts:19가 “인증 헤더 없음(NFR-7 — 로컬 Docker 전용)“이라고 명시합니다. - nginx는
/api/를app:8080으로 그대로 프록시합니다(web/nginx.conf:10-15). 인증·rate limit이 없습니다. docker-compose.yml:43-44가8080:8080,:54-55가3000: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·sort2개, 페이지네이션 파라미터가 없습니다. - 응답도 봉투가 없습니다 —
JobPostingListResponse(val jobPostings: List<JobPostingListItemResponse>)(application/posting/JobPostingListResponse.kt:3).totalCount·page·hasNext가 없습니다. - 조회는 SQL에
company_id = ?하나만 내리고 나머지는 메모리 필터입니다 —JobPostingRepositoryImpl.kt:71-75가findByCompanyId(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_results는keyword_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_DIGEST가dispatchSequence에 KST 일자 서수를 넣어 하루 1건을 강제하는 선례가 있습니다(IdempotencyKey.kt:37-38,NotificationDispatchDomainService.kt:91-103). - 알림 배치는 09:00 하루 1회입니다(
application.yml:46notification-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.kts에pdfbox·poi0건.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-23의ThreadPoolTaskSchedulerpoolSize=1. NFR-16(배치 순차 실행)이 이미 구조로 보장됩니다. - Layer 1 이벤트 선례가 있습니다 —
AsyncConfig.kt:29-40의jobPostingEvaluationTaskExecutor(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-IS | TO-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 쿠키 + BCrypt | 32바이트 난수 토큰을 발급해 쿠키에 담고, 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).
LoginAttemptState가username단위 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 | 신규 watchlist | posting은 “수집·변경·마감 판정” 책임이다. 여기에 사용자 개인 취향(우선순위·태그·제외 사유)을 넣으면 배치가 소유한 데이터와 사용자가 소유한 데이터가 한 애그리게이트에 섞인다. 라이프사이클도 다르다 — 공고는 배치가, 관리 상태는 사용자만 바꾼다. 참조는 dedupGroupId: Long 단방향 |
| 중복 그룹 (FR-74) | — | posting에 합류 | 그룹은 08:30 배치가 계산하는 posting 자기 사실이다. 별도 컨텍스트로 빼면 배치가 컨텍스트를 넘나든다 |
| 담당자 (FR-80) | application | application에 합류 | FR-80이 “담당자는 회사가 아니라 지원 건에 귀속한다”고 명시했다. 지원 건과 라이프사이클이 완전히 같고(지원이 사라지면 담당자도 의미 없음), 독립 조회 요구가 없다 |
| 대시보드 (FR-79) | application | 컨텍스트 없음 — application 레이어 조합 | 대시보드는 저장 데이터가 없는 순수 read model이다. 지원(칸반·장기 미변경)·면접(다가오는 면접)·공고/회사(카드 라벨)를 조합하므로 application/dashboard UseCase가 각 컨텍스트 DomainService를 호출해 조립한다 |
| 서류 (FR-81~85) | application | 신규 document | FR-85가 “하나의 서류 버전을 여러 지원 건에 재사용”을 요구한다 — 문서는 지원과 독립적으로 존재한다. 파일 저장이라는 별도 영속 매체를 소유한다. 단 “어느 지원에 무엇을 제출했는가”는 지원의 사실이므로 연결 테이블은 application이 소유하고 documentVersionId: Long만 참조한다 |
| 프로필·추천도 (FR-95~97) | matching | 신규 recommendation | matching은 “알림을 보낼 공고인가”(키워드·근무형태)를 판정한다. 추천도는 “지원할 만한가”(내 역량 대비 적합도)를 판정한다 — 입력(이력서 프로필)·출력(점수·등급)·기준 버전 축이 전부 다르다. FR-97이 “기존 match_criteria_revisions와는 별개 축”을 명시적으로 요구했다 |
| 이력서 프로필 (FR-95) | document | recommendation에 합류 | 프로필은 추천도 계산의 입력 기준이며 독립적 의미가 약하다. document에 두면 확정·기준 버전 증가가 두 컨텍스트에 걸쳐 조율돼야 한다 |
| 연락 이벤트 (FR-89~91) | application | 신규 contact | 연락 이벤트는 지원과 무관하게 먼저 도착한다(후보 매칭 실패 시 어느 지원에도 안 붙는다). 원본·파싱·후보·결정 이력을 독립 소유하고, 반영 여부와 무관하게 보존된다(FR-89 “중복 수신과 오탐을 추적”) |
| 소스 상태 6종 (FR-88) | company | company에 합류 | job_sources가 company 컨텍스트 소유다(기존 TDD 시스템 역할 경계). 상태는 그 테이블의 컬럼 확장이다. 전이 트리거(수집 결과)는 posting이 알지만, 전이 실행은 application 레이어가 조합한다 |
| 필드별 매칭 (FR-86) | matching | matching에 합류 | 매칭 규칙의 확장 그 자체다. EvaluationTarget에 이미 태그·본문이 흐르고 있다(EvaluationTarget.kt:10-15) |
| 알림 2종 (FR-93·94) | notification | notification에 합류 | enum 2개 확장 + 대상 산출 경로 추가. 기존 멱등 키 규칙을 그대로 쓴다 |
결과: 신규 컨텍스트 6개(auth·webhook·watchlist·document·recommendation·contact), 기존 확장 5개(posting·matching·application·notification·company).
방안 6 — 서버 토폴로지: 무엇을 어디서 돌릴 것인가
과제 특성별로 후보를 놓고 배치를 결정했습니다.
| 과제 | 특성 | 후보 | 결정 |
|---|---|---|---|
| REST 요청-응답 (FR-73 | 동기, 저부하(사용자 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 매칭)“을 요구합니다. 그런데 platform은 job_sources(company 컨텍스트) 소유입니다. posting이 그 테이블을 조인하면 no-crosscontext-raw-read 위반입니다.
해법: job_postings.platform 컬럼을 신설합니다. 이것은 비정규화가 아니라 “이 공고가 어느 플랫폼에서 왔는가”라는 posting 자기 사실입니다. 수집 시점에 어댑터가 이미 알고 있는 값이고(JobPlatform은 domain.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,844 | 2,446 | +602 |
| 3년 누적 행 수 | 38,900 | 51,600 | +12,700 |
| 크기 | 17MB | 22MB | +5MB |
| 증가율 | 기준 | +32.6% | |
| 미매칭 사유 설명 | 불가 | 가능 |
최초 추정이 빗나간 원인 — 구조적 상한을 놓쳤습니다.
- 본문을 가진 공고가 914건(13.8%)뿐이고 전량
GREENHOUSE입니다.WANTED3,224건·SARAMIN2,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 (필수 기술) | 45 | JD 자격 요건 섹션에서 추출한 기술 목록 × 프로필 기술 | 자격 요건 섹션 추출 실패 또는 추출 기술 0건 |
PREFERRED_SKILL (우대 기술·도메인) | 20 | JD 우대사항 섹션 기술 목록 × 프로필 기술 | 우대사항 섹션 추출 실패 또는 추출 기술 0건 |
CAREER_ROLE (경력·직무) | 20 | JD 요구 경력(개월)·직무 분류 × 프로필 경력·직무 선호 | 요구 경력 표기 없음 그리고 직무 분류 없음 |
WORK_CONDITION (근무 조건·선호) | 15 | 공고 근무형태 판정 결과(matching) × 프로필 근무 조건 선호 | 근무형태 확신도 UNKNOWN 또는 프로필 선호 미입력 |
항목 충족률 판정 (FR-96 확정 규칙의 구현 형태)
기술명이 프로필에 없음 → NONE (0%)
기술명 일치 && (최근 사용 3년 초과 || 사용 기간 < 요구 기간의 1/2) → PARTIAL (60%)
그 외 기술명 일치 → FULL (100%)
축 충족률 = 항목 충족률의 산술 평균. 경력·근무 조건 축은 항목이 1~2개인 단일 판정입니다.
적용 순서 (FR-96 ①~⑤ 그대로)
- 사용자 가중치 합계 100 검증 — 아니면
InvalidRecommendationWeightException(400) - 판단 불가 축 제외
- 남은 축 비중을 합계 100으로 정규화 — 필수 기술 축(45)이 판단 불가여도 예외 없이 정규화(PRD B-16~18)
총점 = Σ(정규화 비중 × 축 충족률), 반올림 후 0~100- 필수 기술 미충족이면 59점 상한 (해제되지 않은 경우에만)
“필수 기술 미충족”의 정의 — 사용자 확정 (2026-08-08, A-1)
확정 규칙: 필수 기술 항목 중 NONE(기술명 자체가 프로필에 없음)이 1건 이상이면 미충족으로 보고 59점 상한을 적용합니다. PARTIAL(60%, 오래된 기술)은 상한 사유가 아닙니다.
근거 세 가지입니다.
- PRD 문언과 일치 —
docs/PRD.md§214가 “하나라도 완전히 미충족”으로 규정합니다. “완전히”는NONE(0%)을 가리키고PARTIAL(60%)은 부분 충족이므로 문언상 상한 대상이 아닙니다. - 변별력 — 미채택 대안(“필수 축 충족률 100% 미만이면 상한”)을 택하면 기술 하나만 오래돼도 상한에 걸려 대부분의 공고가 59점에 수렴해 등급이 신호를 잃습니다.
- 의미 —
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는 지원 생성/철회가 관리 상태를 바꾸도록 요구합니다. application과 watchlist는 서로 다른 컨텍스트라 직접 참조가 금지됩니다(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를 직접 호출 | CreateApplicationUseCase가 ApplicationDomainService와 WatchStateDomainService를 모두 주입 | 미채택 — 지원 생성이 관리 상태 복구까지 알아야 하는 결합이 생긴다. 관심사(파생 처리) 분리가 이벤트의 정확한 용도이고, 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_states | AuthDomainService | PasswordHasher, SessionTokenGenerator(둘 다 domain interface) |
| webhook 컨텍스트 | HMAC 서명 검증·시간 오차·nonce 재사용 판정·수신 이력 | webhook_request_nonces, webhook_receipt_logs | WebhookVerificationDomainService | (없음) |
| watchlist 컨텍스트 (신규) | 관리 상태 3종·전이·이력·우선순위·목표일·태그·메모·제외 사유 | job_posting_watch_states, job_posting_watch_tags, job_posting_watch_state_histories | WatchStateDomainService, WatchStateQueryService | (없음 — 그룹은 dedupGroupId: Long로만 인지) |
| document 컨텍스트 (신규) | 문서 계열·버전·파일 저장 경로·새니타이즈·업로드 실패 이력(A-3) | application_document_series, application_document_versions, application_document_upload_failures, 파일시스템 | DocumentDomainService, DocumentUploadFailureQueryService | DocumentFileGateway |
| 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_overrides | RecommendationDomainService, ResumeProfileDomainService | ResumeTextExtractor(domain interface) |
| contact 컨텍스트 (신규) | 연락 이벤트 원본·파싱·후보·사용자 결정 이력 | recruitment_contact_events, ..._parses, ..._candidates, ..._decisions | ContactEventDomainService | (없음 — 지원 건은 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 | 기존 + ApplicationContactDomainService | DomainEventPublisher |
| 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 |
WebhookNonceCleanupScheduler | 24시간 경과 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·findAllGroupIdsBy는 BE-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에 nullableactualCount: 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로 수렴하던 것은 그대로 둡니다 — 그것은 실제로 클라이언트가 만들 수 없는 요청이라 분류가 필요 없습니다. -
시간은 전부
ZonedDateTimeISO-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 확장분)
| code | status | 발생 조건 |
|---|---|---|
UNAUTHENTICATED | 401 | 세션 쿠키 없음·만료·무효 |
INVALID_CREDENTIAL | 401 | 로그인 아이디/비밀번호 불일치 |
LOGIN_LOCKED | 429 | 연속 5회 실패로 잠금 (응답에 retryAfterSeconds 없음 — 메시지에 해제 시각 포함) |
WEBHOOK_SIGNATURE_INVALID | 401 | HMAC 검증 실패 (사유는 응답에 노출하지 않음 — 로그·이력에만) |
DEDUP_GROUP_NOT_FOUND | 404 | 존재하지 않는 중복 그룹 |
WATCH_STATE_INVALID_FIELD | 400 | EXCLUDED가 아닌데 제외 사유 입력, 태그 11개 이상 등 |
FEATURE_DISABLED | 409 | 피처 플래그 OFF로 기능이 아직 열리지 않음 (단계 1부터 필요 — watchlist.management) |
JOB_POSTING_SORT_NOT_APPLICABLE | 400 | 정렬·필터 파라미터 조합 불가 (sort=PRIORITY_DESC + watchedOnly=false) |
WATCH_STATE_FILTER_LIMIT_EXCEEDED | 400 | 관리 상태 기반 필터·정렬의 대상 그룹이 상한(1,000) 초과 |
JOB_POSTING_FILTER_NOT_SUPPORTED | 400 | 제공하지 않는 필터를 요청 (현재 matchedOnly=true — 계약에서 제거 확정). 조합 불가(JOB_POSTING_SORT_NOT_APPLICABLE)와 별도 코드 — FE 처리가 다르다 |
RECOMMENDATION_TARGET_LIMIT_EXCEEDED | 400 | 재평가 대상 공고가 상한(1,000) 초과 |
DOCUMENT_SERIES_NOT_FOUND | 404 | 문서 계열 없음 |
DOCUMENT_VERSION_NOT_FOUND | 404 | 문서 버전 없음 |
DOCUMENT_EXTENSION_NOT_ALLOWED | 400 | pdf·docx·md·hwp 외 |
DOCUMENT_SIZE_EXCEEDED | 400 | 20MB 초과 |
DOCUMENT_PATH_ESCAPE | 400 | 새니타이즈 후에도 저장 루트를 벗어나는 경로 |
DOCUMENT_ALREADY_SUBMITTED | 409 | 같은 지원 건에 같은 버전 중복 연결 |
RESUME_TEXT_EXTRACTION_FAILED | 200 (에러 아님) | 응답 필드로 표현 — profileDraft: null + extractionFailed: true (시나리오 13: 등록 자체는 성공) |
RESUME_PROFILE_NOT_CONFIRMED | 409 | 확정 프로필 없이 추천도 평가 요청 |
RECOMMENDATION_WEIGHT_INVALID | 400 | 축 가중치 합계 ≠ 100 또는 축 4종 미비 |
RECOMMENDATION_NOT_FOUND | 404 | 평가 결과 없음 |
WEBHOOK_RECEIVE_CONFLICT | 503 | provider_message_id 유니크 위반 경합 — 재시도하면 해소된다. 이 경로는 수신 이력을 남기지 못한다(트랜잭션 오염, 계약 2) |
CONTACT_EVENT_NOT_FOUND | 404 | 연락 이벤트 없음 |
CONTACT_EVENT_ALREADY_DECIDED | 409 | PENDING이 아닌 이벤트에 결정 요청 |
CONTACT_EVENT_TARGET_TERMINAL | 409 | 종료 상태 지원 건에 반영 시도 (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 인계)
web/src/api/client.ts:20-47의credentials: 'omit'을'same-origin'으로 변경해야 쿠키가 전송됩니다. 직접fetch를 쓰는 2곳(web/src/api/company/registration.ts:79-111,web/src/api/application/create.ts:81-113)도 동일하게 변경해야 합니다.- 401 수신 시 로그인 화면으로 유도 —
ApiError.code === 'UNAUTHENTICATED'로 판별합니다. - 앱 부팅 시
GET /api/auth/session1회로 인증 상태를 확인합니다(쿠키가 HttpOnly라 JS가 읽을 수 없음). - 기존 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 OFF | 409 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). 다음 규칙으로 해결합니다.
watchedOnly=true를 강제합니다. 미지정이면 서버가true로 강제 적용하고, 명시적으로false를 보내면 400JOB_POSTING_SORT_NOT_APPLICABLE입니다 — 관리 상태가 없는 공고는 우선순위 자체가 없어 정렬 대상이 아닙니다.- application 레이어가 watchlist DomainService에서 우선순위 정렬된 그룹 id 목록(
HIGH → NORMAL → LOW, 동순위는updated_at내림차순)을 받습니다. - 그 목록을 posting 검색에 필터(IN)와 정렬 기준(CASE 순서)으로 동시에 넘겨, 나머지 posting 필터(
companyId·platform·마감일·키워드)와LIMIT/OFFSET을 한 쿼리에서 적용합니다. 최종 tie-break는id오름차순입니다. - 그룹 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가 지정되면watchedOnly를true로 강제합니다(PRIORITY_DESC와 동일 규칙, 명시적false는JOB_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회를 하지 않게 합니다.
dedupGroupId가 null일 때의 처리 — 조회 시 생성하지 않습니다.
| 후보 | 판정 |
|---|---|
| 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는 진행 중 상태만 대상이며lastTransitedAt이staleThresholdDays이전인 건입니다(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) → IssuedSession | LoginCredentialGateway, PasswordHasher, SessionTokenGenerator, LoginSessionRepository, LoginAttemptStateRepository |
WebhookVerificationDomainService | HMAC·시간·nonce 검증 + 이력 | WebhookVerificationInput → Unit(실패 시 예외) | WebhookNonceRepository, WebhookReceiptLogRepository, WebhookSecretGateway |
JobPostingDeduplicationDomainService (확장) | 그룹 재계산 + 정체성 승계 + 대표 5단 tie-break | (Set<Long>) -> Map<Long, SourceType> → JobPostingDeduplicationResult | JobPostingRepository, JobPostingDedupGroupRepository, FeatureFlagGateway |
JobPostingSearchDomainService | 교차 회사 검색 창구 | JobPostingSearchCriteria → JobPostingSearchPage | JobPostingSearchRepository |
WatchStateDomainService | 관리 상태 upsert·전이·복구 | (dedupGroupId, WatchStateUpsertInput) → JobPostingWatchState | JobPostingWatchStateRepository |
DocumentDomainService | 계열·버전 채번·파일 저장 | DocumentUploadInput → ApplicationDocumentVersion | ApplicationDocumentSeriesRepository, ApplicationDocumentVersionRepository, DocumentFileGateway |
ResumeProfileDomainService | 초안 생성·수정·확정 | (documentVersionId, 추출 텍스트) → ResumeProfile | ResumeProfileRepository, RecommendationCriteriaRepository |
RecommendationDomainService | 축 가중치·기준 버전·평가 실행·상한 해제 | (RecommendationTarget, RecommendationPlan) → RecommendationItemOutcome | ResumeProfileRepository, RecommendationAxisWeightRepository, JobPostingRecommendationRepository, JobRequirementExtractor |
JobRequirementExtractor | JD → 요구사항 추출 (순수 함수) | (descriptionBody, tagValues) → JobRequirement | (없음) |
ContactEventDomainService | 수신 멱등·파싱·후보 확정·결정 기록 | ContactEventReceiveInput → ContactEvent | ContactEventRepository, ContactMessageParser, ContactMatchScorer |
ContactMessageParser | 본문 → 회사·공고·전형 키워드 추출 (순수 함수) | (subject, bodyText, senderAddress) → ContactEventParse | (없음) |
ContactMatchScorer | 후보 점수 산정 (순수 함수) | (parse, List<ContactMatchInput>) → List<ContactMatchCandidate> | (없음) |
ApplicationContactDomainService | 담당자 CRUD | ApplicationContactInput → JobApplicationContact | JobApplicationContactRepository |
JobSourceRegistryDomainService | 소스 상태 전이 판정·조회 | (jobSourceId, SourceCollectionOutcome) → Boolean | JobSourceRepository, JobSourceHealthRepository |
application 레이어 오케스트레이션 (크로스 컨텍스트 조합 지점)
| UseCase | 조합하는 컨텍스트 | 조합 이유 |
|---|---|---|
ListAllJobPostingsUseCase | posting + watchlist + matching + application + company + recommendation | 교차 목록 응답이 6개 컨텍스트의 데이터를 합친다. 각 컨텍스트를 id 집합 단위 배치 조회 1회씩으로 호출해 N+1을 막는다 |
GetDashboardUseCase | application + posting + company | 칸반 카드에 회사명·공고 제목이 필요 |
EvaluateInterestedPostingsUseCase | watchlist + posting + matching + recommendation | 재평가 대상(관심 그룹) → 대표 공고 → JD·근무형태 → 추천도 계산 |
ReceiveContactEventUseCase | webhook + contact | 서명 검증 후 수신 저장 |
AnalyzeContactEventUseCase (Layer 1 리스너 경유) | contact + application + posting + company | 후보 계산에 지원 건·회사·공고 요약이 필요 |
DecideContactEventUseCase | contact + application | 결정 기록 + 상태 전이·면접 등록 |
RestoreWatchStateOnApplicationUseCase (Layer 1 리스너 경유) | application + posting + watchlist | 지원 공고 → dedup 그룹 → 관리 상태 복구 |
BackfillFieldScopeMatchingUseCase | matching + posting | 재평가 결과 비교 → 신규 매칭 공고의 알림 억제 |
ApplyJobSourceRegistryOutcomeUseCase | posting + 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_failures에 STORAGE_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 + 로그 WARN | 404 DOCUMENT_VERSION_NOT_FOUND |
| 이력서 텍스트 추출 | 텍스트 레이어 없음(스캔 PDF)·hwp | 예외가 아니라 null 반환 → 초안은 빈 항목 + extractionFailed: true | 201 (등록 성공, 수동 입력 안내) |
| 추천도 평가 | 확정 프로필 없음 | 평가 자체를 실행하지 않음 | 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가지
-
실패를 영속화하는 경로는 예외 전파와 트랜잭션 경계를 함께 설계한다. “기록이 커밋되고 예외도 전파되어야 한다”면
noRollbackFor를 쓸지 격리 트랜잭션(REQUIRES_NEW)으로 뺄지를 티켓 수준에서 명시한다. 기록 코드를 넣는 것만으로 끝났다고 보지 않는다 — 그 기록이 커밋되는지가 완료 조건이다. -
JPA 제약 위반(유니크 등)은
noRollbackFor로 구제되지 않는다. 제약 위반은 flush 시점에 트랜잭션을 rollback-only로 마킹하므로, 예외를 잡아도 그 트랜잭션에서는 더 이상 쓸 수 없습니다. BE-47이no-business-flow-in-infra를 지키려 infra의REQUIRES_NEW를 제거하자 이 상태에 빠졌고 실 MySQL로 재현 확인됐습니다. 이 경로는 영속성 컨텍스트를 오염시키지 않는 수단이 필요합니다 — 별도 트랜잭션 진입점(application 레이어의REQUIRES_NEWUseCase) 또는 JDBC upsert. 같은 제약이 이미JobPostingEvaluationDomainService에도 기록돼 있습니다(JobPostingEvaluationDomainService.kt:16-24— “Hibernate는 flush 실패 이후 같은 영속성 컨텍스트로 이어지는 조회·저장을 거부한다”). -
계약을 주석·문서로만 남기지 않고 실 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
saveAndFlush→UnexpectedRollbackException→ REQUIRES_NEW 검토 → 재시도를 막는다는 이유로 기각 → JDBC 직접 삽입 채택.
이 계약이 다시 적용될 지점 — 3단계 FR-89~92(연락 수신)가 같은 구조를 반복합니다.
계약 1과 계약 2는 한 경로에서 충돌할 수 있습니다. 제약 위반으로 트랜잭션이 오염된 경로에서는 “실패를 기록한다”(계약 1) 자체가 불가능합니다 — 어떤 쓰기도 커밋되지 않기 때문입니다. 지점마다 계약 1을 지킬 수 있는지 먼저 판정하고, 지킬 수 없으면 대체 관측 수단을 정합니다.
| 지점 | 계약 1(실패 기록) | 수단 | 근거·대체 관측 |
|---|---|---|---|
| 서명·시각 오차 검증 실패 이력 (BE-71·47) | 가능 | noRollbackFor | DB 제약을 건드리지 않는 비즈니스 예외라 트랜잭션이 오염되지 않는다 |
| 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.gs의sendMessage_는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_recommendations에 job_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가 올린 항목이며, 인지하되 지금은 방어하지 않기로 확정합니다.
발생 시나리오
- 08:30 배치가 실행되기 직전, 사용자가 공고
P에 관리 상태를 저장한다 → 단독 그룹G1({P})생성 + 관리 상태가G1에 붙는다. - 같은 순간 배치가
P와Q를 한 클러스터로 계산한다. 배치가 그룹 정체성을 조회한 시점에G1이 아직 커밋 전이었다면 교집합을 못 찾고 새 그룹G2({P,Q})를 만든다. - 결과:
G1은 멤버 0인 고아가 되고, 사용자가 붙인 관리 상태는G1에 남아 화면(=G2기준)에서 분리된다.
방어하지 않는 근거
- 데이터 손실이 아닙니다.
G1행과 관리 상태·이력이 모두 보존되며(승계 실패 그룹은 삭제하지 않는 기존 규칙), 사용자는 화면에서 다시 관리 상태를 지정하면 됩니다. 복구 비용이 클릭 1회입니다. - 창이 극히 좁습니다. 08:30 배치의 그룹 조회~저장 구간 × 사용자가 그 순간 같은 공고를 저장할 확률입니다. 단일 사용자·1일 1회 배치라 실질 0에 수렴합니다.
- 방어 비용이 이득을 넘습니다. 막으려면 배치 구간 동안
PUT을 거부(사용자에게 “잠시 후 다시” 노출)하거나 그룹 테이블에 락을 잡아야 하는데, 둘 다 손실 없는 사고를 막으려고 정상 경로에 상시 비용을 추가합니다. - 관측만 남깁니다 — 배치가 고아 그룹(멤버 0이면서 관리 상태가 붙어 있는 그룹)을 만들면 그 개수를 WARN 로그로 남깁니다. 실제로 관측되면 그때 방어를 검토합니다.
멱등
| 대상 | 멱등 키 | 중복 시 동작 |
|---|---|---|
| 웹훅 수신 | provider_message_id (선조회 + 유니크 백스톱) | 기존 이벤트 반환, duplicated: true. 파싱·알림을 다시 하지 않는다 |
| nonce | nonce (유니크) | 재사용 → 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 × 지원 생성 | INTERESTED | FR-75 ① — 자동 복구 |
INTERESTED/PLANNED × 지원 생성 | 유지 | 변경하지 않는다 |
| 임의 상태 × 지원 철회·삭제 | 직전 상태(이력 기준) / 이력 없으면 INTERESTED | FR-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 × IGNORE | IGNORED | — |
PENDING × REASSIGN | APPLIED | 다른 지원 건을 지정해 반영 |
APPLIED/IGNORED × 임의 결정 | 유지 | 409 CONTACT_EVENT_ALREADY_DECIDED |
소스 레지스트리 상태 (JobSourceRegistryStatus, FR-88)
| 현재 상태 × 트리거 | 다음 상태 | 비고 |
|---|---|---|
| (등록) | DISCOVERED | 등록만 되고 아직 수집 검증 전 (seeded_at IS NULL) |
임의 × 어댑터 미보유(UnsupportedJobPlatformException) | UNSUPPORTED | SPA 채용 사이트 등 — PRD Non-Goals 유지 |
임의 × 수집 성공(fetched_count > 0) | ACTIVE | seeded_at 채움 |
ACTIVE × 수집 실패·0건 (연속 3일 미만) | TRANSIENT_FAILURE | job_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 인계)
| 단계 | 신규 테이블 | 기존 테이블 변경 |
|---|---|---|
| 1 | user_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) |
| 2 | application_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) | 없음 |
| 3 | job_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 기존 스택 그대로, 신규 프레임워크 도입 없음).
| 레벨 | 대상 | 타입 | 핵심 범위 |
|---|---|---|---|
| domain | LoginSession·LoginAttemptState·WebhookSignature·JobPostingDedupGroup·JobPostingWatchState·DocumentFileName·ApplicationDocumentVersion·AxisAssessment·RecommendationScore·ProfileSkill·ContactEvent·ContactMatchScorer·MatchCriteria.evaluateFields·JobRequirementExtractor·JobSourceRegistryStatus | 단위 (MockK) | 상태 전이·검증·계산 규칙. 판정 로직은 전부 이 레벨에서 커버한다 |
| application | LoginUseCase·UpsertWatchStateUseCase·ListAllJobPostingsUseCase·GetDashboardUseCase·UploadDocumentUseCase·ConfirmResumeProfileUseCase·EvaluateInterestedPostingsUseCase·ReceiveContactEventUseCase·DecideContactEventUseCase·BackfillFieldScopeMatchingUseCase | 단위 (DomainService 모킹) | 오케스트레이션 순서·크로스 컨텍스트 조합·트랜잭션 격리 호출 |
| infrastructure | LoginSessionRepositoryImpl·WebhookNonceRepositoryImpl·JobPostingDedupGroupRepositoryImpl·JobPostingSearchRepositoryImpl(QueryDSL)·JobPostingWatchStateRepositoryImpl·LocalDocumentFileGatewayImpl·PdfBoxResumeTextExtractor·ContactEventRepositoryImpl | 통합 (Testcontainers MySQL 1.20.3 — 기존 SharedMySqlContainer 재사용) | 유니크 제약 동작·QueryDSL 쿼리 정확성·cascade 저장·실제 파일 쓰기/삭제 |
| presentation | AuthApiController·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 |
| 7 | body 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) |
| 11 | MANUAL로 등록한 공고가 이후 수집 경로로 발견 | 같은 그룹으로 병합, 수동 등록 시 메모 유지 (FR-74 A-2) |
| 12 | 접근 제한 공고 1건 + 접근 가능 공고 1건이 같은 그룹 | 접근 가능 공고가 대표 (tie-break ①) |
| 13 | MANUAL 1건 + AGGREGATOR 1건이 같은 그룹 | MANUAL이 대표 (tie-break ② 동급 → ③④⑤로) |
| 14 | dedup 배치 2회 연속 실행 | 그룹 id·대표·멤버가 동일 (멱등) |
| 15 | EXCLUDED 공고에 지원 생성 | 관리 상태가 INTERESTED로 복구, 이력 1건 추가 (FR-75 ①) |
| 16 | 지원 철회 후 | 직전 관리 상태로 복귀, 이력 없으면 INTERESTED (FR-75 ②) |
| 17 | PLANNED 공고가 CLOSED로 전환 | 관리 상태 PLANNED 유지, 목록에서 사라지지 않음 (FR-75 ③) |
| 18 | INTERESTED 상태에서 제외 사유 입력 | 400 WATCH_STATE_INVALID_FIELD |
| 19 | 태그 11개 입력 | 400 |
| 20 | 교차 목록 size=101 | 400 BAD_REQUEST |
| 21 | 교차 목록 플랫폼 필터가 그룹 내 비대표 공고에만 일치 | 대표 공고가 결과에 포함된다 (OR 매칭, FR-78) |
| 22 | 교차 목록 정렬 시 동순위 다수 | 페이지 1·2 사이 항목 중복·누락 0건 (id tie-break) |
| 23 | 대시보드 진행 중 상태가 0건 | 해당 칸반 컬럼이 count=0, items=[]로 존재한다 |
| 24 | 대시보드 staleThresholdDays=14, 마지막 전이 13일 전 | 장기 미변경 목록에 미포함 (경계값) |
| 25 | 21MB 파일 업로드 | 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) |
| 34 | hwp 업로드 후 초안 생성 | 201 + extractionFailed: true |
| 35 | 확정 프로필 없이 재평가 호출 | 409 RESUME_PROFILE_NOT_CONFIRMED |
| 36 | 이미 확정된 프로필 재확정 | 기준 버전이 증가하지 않음 (멱등) |
| 37 | 축 가중치 합계 99 | 400 RECOMMENDATION_WEIGHT_INVALID |
| 38 | 필수 기술 축(45)이 판단 불가 | 예외 없이 정규화 — 나머지 3축 비중이 20:20:15 → 36:36:28 (FR-96 B-16~18) |
| 39 | JD 없는 공고 평가 | 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 (멱등) |
| 57 | dryRun=true 백필 | revision 미생성, 통계만 반환 |
| 58 | 3일 연속 수집 실패 소스 | 레지스트리 상태 TRANSIENT_FAILURE, 고장 알림 1건 |
| 59 | 403 응답 소스 | 레지스트리 상태 ACCESS_RESTRICTED |
| 60 | DISABLED 소스를 다시 켬 | 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 유지 + 알림은 발송 |
| 68 | 24시간 경과 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.required | 1 | 인증 강제 | 기존과 동일하게 무인증 통과 (FE 배포 전 전환 창) |
posting.dedup-group-identity | 1 | 그룹 영속화·정체성 승계 | 기존 dedup 동작(그룹 미영속) 유지 |
watchlist.management | 1 | 관리 상태 API | 409 FEATURE_DISABLED — “미분류”(200 + watchState: null)와 구분되어야 하므로 404를 쓰지 않는다 (C). 단 FR-75 자동 보정 쓰기는 플래그와 무관하게 계속된다 (아래) |
document.upload | 2 | 서류 업로드 | 업로드 API가 409 FEATURE_DISABLED |
recommendation.evaluation | 2 | 추천도 계산 | 평가가 실행되지 않고 결과가 null |
matching.field-weighted-scope | 3 | 필드별 가중치 매칭 | MatchCriteria.matches(title) 제목 단독 경로로 복귀 |
contact.inbound-webhook | 3 | 연락 이벤트 수신 | 웹훅이 202 대신 404 (엔드포인트 비활성) |
notification.job-posting-changed | 3 | 변경 공고 알림 | 대상 산출 0건 |
notification.contact-review-request | 3 | 연락 검토 요청 알림 | 대상 산출 0건 |
source.registry-status | 3 | 소스 상태 전이 기록 | 전이 미기록(컬럼은 유지) |
신규 플래그와 별개로 시드만 복구하는 기존 키 1개 (A-2)
| 플래그 키 | 단계 | 성격 | 이번 범위 |
|---|---|---|---|
posting.collection-dispatch-v2 | 1 (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.managementON) 이전에도 관리 상태 행이 생길 수 있으므로, 플래그 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-6 | posting.dedup-group-identity ON → 다음 08:30 배치가 그룹을 영속화. 첫 실행은 6,311 INSERT + 6,633 UPDATE 규모이므로 500그룹 단위로 커밋한다(B-3, 아래) | 배치 1회 성공, job_posting_dedup_groups 행 생성 확인 | 플래그 OFF (그룹 테이블은 남지만 아무도 안 읽음) |
| 1-7 | watchlist.management ON → 관리 상태 API 개방 | 관리 상태 1건 저장·조회 성공 | 플래그 OFF |
| 1-8 | FE 배포 — credentials: 'same-origin' 전환 + 로그인 화면 + 401 처리 | FE 빌드·배포 성공 | 이전 web 이미지 태그 |
| 1-9 | auth.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-4 | document.upload ON → 업로드 1회 왕복 | 파일이 지정 경로에 생성됨 | 플래그 OFF (업로드된 파일은 남지만 무해) |
| 2-5 | 축 가중치 기본값 4행 시드 (recommendation_axis_weights) — 정적 시드 4행, 운영 경합 없음이므로 Flyway DML 예외 허용 근거를 파일 주석에 남긴다 | 4행 존재 확인 | 역방향 DELETE |
| 2-6 | recommendation.evaluation ON → 프로필 확정 1회 + 관심 공고 재평가 | 점수·등급·축별 근거 산출 확인 | 플래그 OFF (평가 결과 행은 남지만 화면이 안 읽음) |
| 2-7 | FE 배포 | — | 이전 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-3 | source.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-5 | matching.field-weighted-scope 사전 점검 — POST /api/matching/field-scope-backfills with dryRun=true. 신규 매칭 건수·해제 건수를 확인해 오탐 규모를 사전 판단 | unmatchedNowCount = 0 (기존 매칭이 풀리지 않음) | 없음 (읽기 전용) |
| 3-6 | matching.field-weighted-scope ON | — | 플래그 OFF → 제목 단독 복귀 |
| 3-7 | 소급 재평가 백필 실행 (FR-87) — dryRun=false. 새 revision 생성 → 전 공고 재평가 → 신규 매칭 공고 알림 억제 | 처리 건수 = 대상 건수, 억제 건수 = 신규 매칭 건수 | 부분 롤백만 가능 — 플래그 OFF로 매칭 규칙은 즉시 복귀하나, notification_eligible=0으로 억제된 공고는 되돌리지 않는다. 이는 “알림 과다 발송” 방향이 아니라 “알림 누락” 방향이며, 그 공고들은 이미 목록에 노출되므로 사용자 손실이 없다 |
| 3-8 | notification.job-posting-changed ON | 다음 09:00 배치에서 변경 알림 1건 이하 | 플래그 OFF |
| 3-9 | 웹훅 시크릿 설정 — WEBHOOK_HMAC_SECRET 환경 변수 주입 후 컨테이너 재기동 | 기동 성공 | compose 되돌리기 |
| 3-10 | contact.inbound-webhook ON + Gmail Apps Script 배포 | 테스트 메일 1건 왕복 성공 | 플래그 OFF → 웹훅 404 → 스크립트가 라벨 유지하며 재시도 대기 |
| 3-11 | notification.contact-review-request ON | 검토 요청 알림 1건 수신 | 플래그 OFF |
| 3-12 | FE 배포 | — | 이전 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건의 platform이 job_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이고, 교차 목록 응답의 matchScore가 null로 내려갑니다(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 | [해소 — 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 | [철회 — 2026-08-08 게이트 ② 결정 ⓑ] 로그 WARN만으로 두려던 판단을 철회하고 PRD Operations 요구(“이력으로 저장하고 조회”)를 그대로 구현합니다. 경로 검증 실패·저장 루트 접근 거부는 보안 사건이라 사후 추적이 필요하다는 것이 결정 사유입니다. 2단계 범위, 티켓 BE-81 | 확정 완료 | |
| 5 | 연락 이벤트 본문 보존 기간 | NFR-5(무기한 보존)를 따라 삭제하지 않습니다. 본문은 최대 20000자 × 연간 수백 건이라 용량 문제가 없습니다 | 개인정보(메일 원문)를 무기한 보관하는 것이 부담되면 보존 기간 정책 추가 |
| 6 | 웹훅 시크릿 회전 절차 (PRD Open Questions) | 단일 시크릿·수동 교체로 둡니다. 교체 시 앱 환경 변수와 Apps Script 상수를 동시에 바꿔야 하므로 짧은 중단(1회 수신 실패) 이 발생하나, 스크립트가 라벨을 유지해 재시도하므로 유실은 없습니다 | 무중단 교체가 필요하면 “구·신 시크릿 2개를 동시 유효”로 확장(검증 로직 1곳만 변경) |
| 7 | posting.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) |
| 12 | posting.collection-dispatch-v2를 언제 ON 할 것인가 | 이번 범위에서는 켜지 않습니다. 켜는 순간 수집 경로가 통째로 v2로 전환되므로 BE-33~36의 완성도 검증이 선행돼야 합니다 | 별도 릴리즈로 판단 |
| 15 | matchedOnly 필터 미구현 | [해소 — 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) 재검토 |
| 8 | NotificationDispatch.isExpired() 미사용 (AS-IS 관찰) | 7일 만료 판정 로직이 도메인에 있으나 planDispatches 경로에 연결돼 있지 않습니다 | 이번 범위 밖. 신규 알림 2종도 만료 필터를 쓰지 않아 기존과 동작이 일관됩니다 |
| 9 | message_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에 플랫폼 집합을 비정규화 보관(배치가 갱신) |
| 11 | hwp 텍스트 추출 | 지원하지 않습니다 — 업로드·보관은 되지만 프로필 초안은 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-10 | matchedOnly 계약 제거 확정 (Open Questions #15 해소) — 후속 티켓으로 남기지 않고 계약에서 제거. 근거: ① PRD FR-78 필터 목록에 매칭 여부가 없음(계약 초안의 자체 추가분) ② FR-25 “매칭은 알림 조건일 뿐 저장 조건이 아니다”와 방향이 반대 — 저장 목적이 키워드 변경 후 과거 공고 재발견인데 목록에서 감추면 역행 ③ matching id 집합 + 상한을 watchlist 경로와 2중으로 조합해야 하는 비용이 이진 토글 하나의 이득을 초과 ④ matched·3단계 matchScore로 정렬·표시가 이미 가능하고 FR-86 취지에 더 부합. 단 matchedOnly=true의 400 거부 방어는 유지 — 조용한 무시가 FE 토글을 오작동시키고 에러 코드는 재사용 가치가 있음 |
| 2026-08-10 | BE-52 리뷰 반영 (계약 2건 확정) — ① matchedOnly는 미구현이므로 400 JOB_POSTING_FILTER_NOT_SUPPORTED로 거부한다고 확정(조용한 무시는 FE 토글이 필터 없는 전체 목록을 200으로 받아 오작동시킨다는 리뷰 p1 근거). 에러 코드 신설 + 400 분류를 3분류 → 4분류(미구현 필터 추가)로 확장 ② 교차 목록 에러 목록에 409 FEATURE_DISABLED 추가 — 플래그 OFF에서 관리 상태 파라미터를 명시한 경우에만 409이고, 미지정이면 관리 상태 필드만 null로 강등되어 200임을 명문화 |
| 2026-08-10 | BE-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-10 | BE-53 리뷰 반영 (SSOT 드리프트 2건) — ① revertOnApplicationRemoved 시그니처를 구현에 맞춰 histories: List<WatchStateHistory> 인자 포함으로 정정(엔티티가 전체 이력을 보유하지 않고 별도 조회하므로 구현이 옳다) ② watchlist.management OFF 구간에도 FR-75 자동 보정 쓰기는 계속된다를 Release Scenario에 명시 — 플래그의 목적은 사용자 노출 차단이지 데이터 드리프트 허용이 아니며, 이 판단으로 플래그 ON 시점의 데이터 정합 보정이 불필요해진다 |
| 2026-08-10 | FR-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-10 | wave 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-09 | dba 정합 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-08 | 2차 검수 패치 (계약 구멍 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_at→first_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_DESC의 watchedOnly 강제 + 정렬 그룹 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-1
B-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 | 티켓 | 너비 |
|---|---|---|
| 1 | BE-45 공통 계약 | 1 |
| 2 | BE-46 인증 / BE-47 웹훅 HMAC / BE-48 dedup 그룹·MANUAL·platform / BE-49 담당자 / BE-50 watchlist / BE-51 대시보드 | 6 |
| 3 | BE-52 교차 목록 / BE-53 지원↔관리 상태 이벤트 / BE-54 운영 조회·백필 | 3 |
| 4 | BE-55 E2E | 1 |
단계 2 — 서류·추천도 (FR-8185, 9596), 12건
| wave | 티켓 | 너비 |
|---|---|---|
| 1 | BE-56 공통 계약 | 1 |
| 2 | BE-57 문서 도메인·파일 게이트웨이 / BE-58 프로필·가중치 / BE-59 요구사항 추출 / BE-60 서류 연결 도메인 | 4 |
| 3 | BE-61 업로드 API / BE-62 텍스트 추출·프로필 API / BE-63 추천도 계산 / BE-64 서류 연결 API | 4 |
| 4 | BE-65 추천도 API·일괄 재평가 / BE-66 목록 추천도 노출 / BE-81 업로드 실패 이력·운영 조회 | 3 |
| 5 | BE-67 E2E | 1 |
단계 3 — 연동 (FR-86~94, 97), 12건
| wave | 티켓 | 너비 |
|---|---|---|
| 1 | BE-68 공통 계약 | 1 |
| 2 | BE-69 필드 가중치 매칭 / BE-70 소스 상태 6종 / BE-71 연락 도메인·웹훅(+첨부) / BE-72 가중치·상한 해제 / BE-73 변경 공고 알림 | 5 |
| 3 | BE-74 소급 백필·억제 / BE-75 파싱·후보 점수 / BE-76 검토 요청 알림 / BE-80 매칭 근거 API 노출 / BE-82 Gmail Apps Script | 5 |
| 4 | BE-77 검토 화면·반영 / BE-78 운영 가시성 | 2 |
| 5 | BE-79 E2E | 1 |
전체 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차 검수 후 | 게이트 ② 후 | |
|---|---|---|---|
| 단계 1 | 1, 6, 3, 1 | 1, 6, 3, 1 | 1, 6, 3, 1 (변동 없음) |
| 단계 2 | 1, 4, 4, 2, 1 | 1, 4, 4, 2, 1 | 1, 4, 4, **3**, 1 (BE-81) |
| 단계 3 | 1, 5, 3, 2, 1 | 1, 5, 4, 2, 1 | 1, 5, **5**, 2, 1 (BE-82) |
| 합계 | 35티켓 / 2.33 | 36티켓 / 2.40 | 38티켓 / 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-46 | domain/auth/**(신규), application/auth/**(신규), presentation/auth/**(신규), infrastructure/auth/**(신규) |
| BE-47 | domain/webhook/**(신규), application/webhook/**(신규), presentation/webhook/**(신규), infrastructure/webhook/**(신규) |
| BE-48 | domain/posting/{JobPosting, JobPostingRepository, JobPostingDeduplicationDomainService, ManualJobPostingDomainService, JobPostingDedupGroup*}, application/posting/RegisterManualJobPostingUseCase, infrastructure/posting/persistence/{JobPostingRepositoryImpl, JobPostingJpaEntity, DedupGroup*} |
| BE-49 | domain/application/{JobApplicationContact, JobApplicationContactRepository, ApplicationContactDomainService}(신규), presentation/application/ApplicationContactApiController(신규), application/application/*Contact*UseCase(신규) |
| BE-50 | domain/watchlist/**(신규), application/watchlist/**(신규), presentation/watchlist/WatchStateApiController(신규), infrastructure/watchlist/**(신규) |
| BE-51 | domain/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-52 | presentation/posting/JobPostingApiController(추가), application/posting/{ListAllJobPostingsUseCase, CrossCompanyPostingResponseMapper}(신규), domain/posting/{JobPostingSearchRepository, JobPostingSearchDomainService}(신규), infrastructure/posting/persistence/JobPostingSearchRepositoryImpl(신규) |
| BE-53 | domain/application/{Application, ApplicationDomainService, JobApplicationEvent}, presentation/watchlist/listener/**(신규), application/watchlist/Restore*·Revert*UseCase(신규), config/AsyncConfig(executor 추가) |
| BE-54 | presentation/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.kt | 3개 파일로 분리 — 같은 컨텍스트지만 티켓별 단독 소유 |
OperationApiController.kt | BE-54(단계 1 wave 3), BE-78(단계 3 wave 4) — 다른 단계·다른 wave |
JobPostingApiController.kt | BE-52(단계 1 wave 3)만 수정 |
JobPostingDetailResponse.kt / JobPostingResponseMapper.kt | BE-52(단계 1 — C-1 dedupGroupId·watchState), BE-80(단계 3 — C-2 매칭 근거). 단계가 달라 wave 교집합 없음 |
CrossCompanyPostingResponseMapper.kt | BE-52(단계 1 생성), BE-66(단계 2 추천도), BE-80(단계 3 matchScore). 전부 다른 단계 |
web/nginx.conf | BE-56(단계 2 wave 1) 단독 소유 — FE 티켓은 건드리지 않습니다(senior-fe 인계) |
application/document/UploadDocumentUseCase.kt | BE-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-10 | BE-45, BE-54(터널 상태) |
| FR-72 웹훅 HMAC ±5분/nonce 24h | P0 | 방안 2 | BE-45, BE-47 |
| FR-73 관리 상태 3종 | P0 | 방안 5, 상태 전이 표 + 공고 기준 저장 진입점(C-1) | BE-50, BE-52 |
| FR-74 그룹 귀속·MANUAL dedup_key | P0 | 방안 3·4 (그룹 정체성 승계, 후보/델타 필터 분리) | BE-48, BE-50 |
| FR-75 관리 상태 전이 규칙 | P0 | 방안 12 (Layer 1 이벤트), 상태 전이 표 | BE-50, BE-53 |
| FR-76 관리 상태 변경 이력 | P0 | 방안 5 | BE-50 |
| FR-77 우선순위·목표일·태그·메모·제외 사유 | P0 | API 계약 관리 상태 | 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종·20MB | P0 | document 시그니처 | BE-56, BE-57, BE-61 |
| FR-84 경로 새니타이즈·저장 루트 검증 | P0 | DocumentFileName 값 객체 + 게이트웨이 이중 방어 + 실패 이력 기록(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-7 | BE-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/10 | P1 | 방안 10 | BE-75 |
| FR-92 Gmail Apps Script | P1 | API 계약 + 동작하는 .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·9 | BE-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 24h | BE-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·#12 | BE-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-2 | BE-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 |