[BE-45] 단계 1 공통 계약 — 에러 코드·예외·의존성·설정·터널 컨테이너·PageResponse
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “API 계약 / 공통 규약”, “신규 에러 코드”, “Release Scenario 1단계”
변경 사항
이 티켓은 단계 1의 유일한 병목입니다. 후행 6개 티켓이 전부 참조하는 공통 산출물(예외 클래스·에러 코드 매핑·공통 응답 타입·빌드 의존성·설정·compose)을 한 wave에 한 번에 확정해, 후행 티켓이 트리형으로 동시에 열리게 합니다. 연관된 병목을 쪼개면 wave가 직렬 사슬이 되고 GlobalExceptionHandler.kt·build.gradle.kts·application.yml 세 파일에서 머지 충돌이 납니다.
-
신규 예외 클래스 — 각 도메인 패키지에 정의합니다.
InvalidCredentialException·LoginLockedException(domain/auth),WebhookVerificationFailedException(domain/webhook),DedupGroupNotFoundException·JobPostingSortNotApplicableException(domain/posting),InvalidWatchStateFieldException·WatchStateFilterLimitExceededException(domain/watchlist),FeatureDisabledException(domain/common). 예외만 정의하고 던지는 쪽은 후행 티켓이 맡습니다.WatchStateNotFoundException은 만들지 않습니다 — 미분류를 404가 아니라200 + watchState: null로 표현하기로 확정했기 때문입니다(아래 3-1). 죽은 코드를 계약에 남기지 않습니다.FeatureDisabledException은 단계 1부터 필요합니다(watchlist.management). 최초 분해에서 단계 2 공통 계약(BE-56)에 둔 것은 순서 오류였고, 이 티켓으로 옮깁니다. BE-56은 “이미 정의됨”만 참조합니다.
-
GlobalExceptionHandler확장 — 위 예외를 401·429·409·404·400으로 매핑합니다. 핸들러가 없으면 catch-all 500으로 떨어져(GlobalExceptionHandler.kt:187-193) 후행 티켓의 계약 테스트가 전부 깨지므로, 예외 정의와 핸들러는 반드시 같은 티켓입니다. 2-1. 400 에러를 3분류로 나눕니다 — 기존처럼 전부BAD_REQUEST로 수렴하면 FE가 “사용자가 필터를 좁히면 해결되는 것”과 “클라이언트 버그”를 구분할 수 없습니다(FE-24·FE-39 착수 블로커).분류 코드 status 범위 초과 (사용자가 좁히면 해결) WATCH_STATE_FILTER_LIMIT_EXCEEDED/RECOMMENDATION_TARGET_LIMIT_EXCEEDED400 파라미터 조합 불가 (클라이언트 버그) JOB_POSTING_SORT_NOT_APPLICABLE400 일반 검증 실패 VALIDATION_FAILED/BAD_REQUEST(기존 유지)400
2-2. ApiErrorResponse에 nullable actualCount: Long?·limit: Long? 2개를 추가합니다. 기존 existingCompanyId(GlobalExceptionHandler.kt:104-109)가 만든 “필요한 코드에만 값을 싣는 nullable 부가 필드” 선례를 그대로 따릅니다. *_LIMIT_EXCEEDED 계열에서만 채우고 나머지는 null 입니다 — FE가 “1,842건 → 1,000건 이하로 좁혀 주세요”를 조립할 수 있어야 합니다.
3-1. FEATURE_DISABLED(409) 매핑 — 피처 플래그 OFF를 404가 아니라 409로 내립니다. 404로 내리면 “기능 준비 중”과 “해당 그룹에 관리 상태가 아직 없음(미분류)“이 구분되지 않습니다. 미분류는 200 + watchState: null입니다.
3. PageResponse<T> 공통 타입 — application/common/PageResponse.kt. items·page·size·totalCount·hasNext. 기존 엔드포인트에는 적용하지 않습니다(하위 호환).
4. 빌드 의존성 — spring-security-crypto(BCrypt 전용, 필터체인 없음)만 추가합니다. Spring Security 전체 스택은 미채택(TDD 방안 1).
5. 설정 — application.yml에 recruitment.auth.*(세션 만료 24h·실패 임계 5회·잠금 15분·쿠키 Secure 여부), recruitment.webhook.*(HMAC 시크릿·허용 오차 300초·nonce 보존 24h), recruitment.tunnel.public-hostname, nonce 정리 cron(0 20 3 * * *)을 추가합니다.
6. docker compose — cloudflared 사이드카 서비스(dev·prod 양쪽)와 인증·웹훅 환경 변수를 추가합니다. 터널은 이 시점에 기동하지 않습니다 — Release Scenario 1-10에서 인증 ON 이후에만 켭니다.
7. 피처 플래그 시드 4행 — 신규 3행(auth.required·posting.dedup-group-identity·watchlist.management)과 기존 키 복구 1행(posting.collection-dispatch-v2), 전부 enabled=0. 정적 시드 4행이므로 Flyway DML 예외 근거(대상 4행, 운영 경합 없음)를 파일 주석에 남깁니다.
8. posting.collection-dispatch-v2 시드 복구 (A-2) — 이 키는 4개 클래스에서 쓰이는데 baseline 시드가 3행뿐이라(baseline...sql:343-347) 행 자체가 없습니다. v2 활성화 스위치가 아니라 legacy 경로의 kill switch입니다 — isEnabled가 true면 legacy를 건너뛰고 return하므로(JobPostingCollectionScheduler.kt:22-28), 미정의 키의 안전 기본값 false(FeatureFlagGatewayImpl.kt:18) 덕에 수집은 legacy로 정상 동작합니다. 실제 손실은 BE-33~36(수집 사이클 큐·리스 복구·호스트 스로틀)이 실행되지 않는 것이고, 행이 없어 운영 UPDATE로 켤 수도 없습니다.
- 값은 반드시
0(OFF) — 켜는 순간 수집 경로가 통째로 v2로 바뀌므로 스위치만 복구하고 활성화는 별도 판단으로 남깁니다.
롤백: 마이그레이션 실패 시 역방향 DDL로 플래그 3행 DELETE. compose 변경은 이전 파일로 되돌립니다. 터널 컨테이너는 기동하지 않으므로 이 티켓만으로는 외부 노출이 발생하지 않습니다.
의존
- DB-02 (단계 1 스키마 마이그레이션 — senior-dba 몫)
다이어그램
처리 흐름
sequenceDiagram participant C as Controller participant D as DomainService participant H as GlobalExceptionHandler participant FE as web(SPA) C->>D: execute(command) D-->>C: WatchStateFilterLimitExceededException C->>H: 예외 전파 H->>H: 예외 → (status, code, actualCount, limit) 매핑 H-->>FE: 400 { code, message, actualCount, limit }
클래스 의존
flowchart LR subgraph Config["config"] Handler[GlobalExceptionHandler] Error[ApiErrorResponse] end subgraph Application["application/common"] Page[PageResponse] end subgraph Domain["domain 신규 예외"] Auth[InvalidCredentialException] Lock[LoginLockedException] Hook[WebhookVerificationFailedException] Group[DedupGroupNotFoundException] Sort[JobPostingSortNotApplicableException] Limit[WatchStateFilterLimitExceededException] Disabled[FeatureDisabledException] end Handler --> Error Handler --> Auth Handler --> Lock Handler --> Hook Handler --> Group Handler --> Sort Handler --> Limit Handler --> Disabled
테스트 케이스
InvalidCredentialException이 401INVALID_CREDENTIAL본문으로 매핑된다LoginLockedException이 429LOGIN_LOCKED로 매핑되고 메시지에 해제 시각이 포함된다WebhookVerificationFailedException이 401WEBHOOK_SIGNATURE_INVALID로 매핑되고 실패 사유가 응답 본문에 노출되지 않는다InvalidWatchStateFieldException이 400WATCH_STATE_INVALID_FIELD로 매핑된다FeatureDisabledException이 409FEATURE_DISABLED로 매핑된다 (404가 아님 — 미분류와 구분)JobPostingSortNotApplicableException이 400JOB_POSTING_SORT_NOT_APPLICABLE로 매핑되고actualCount·limit이 null이다WatchStateFilterLimitExceededException이 400WATCH_STATE_FILTER_LIMIT_EXCEEDED로 매핑되고actualCount·limit이 채워진다RecommendationTargetLimitExceededException이 400RECOMMENDATION_TARGET_LIMIT_EXCEEDED로 매핑되고actualCount·limit이 채워진다*_LIMIT_EXCEEDED가 아닌 에러 응답에서actualCount·limit이 직렬화되지 않거나 null이다WatchStateNotFoundException클래스가 존재하지 않는다 (미분류는 404가 아니라 200으로 표현)- 기존 예외(
CompanyNotFoundException등) 매핑이 변경되지 않는다 (회귀) PageResponse.of(items, page, size, totalCount)가hasNext를(page+1)*size < totalCount로 계산한다totalCount=0이면hasNext=false,items가 빈 배열이다 (경계값)- 마지막 페이지에서
hasNext=false다 (경계값) - 피처 플래그 4행이
enabled=0으로 시드되고FeatureFlagGateway.isEnabled()가 false를 반환한다 posting.collection-dispatch-v2행이 존재하고 값이 0이다 (운영 UPDATE로 켤 수 있는 상태)posting.collection-dispatch-v2시드 이후에도 legacy 수집 스케줄러가 그대로 실행된다 (회귀 — 값이 0이므로 동작 불변)- 기동 시
recruitment.auth.session-ttl-hours등 신규 설정이 바인딩된다