[BE-45] 단계 1 공통 계약 — 에러 코드·예외·의존성·설정·터널 컨테이너·PageResponse

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “API 계약 / 공통 규약”, “신규 에러 코드”, “Release Scenario 1단계”

변경 사항

이 티켓은 단계 1의 유일한 병목입니다. 후행 6개 티켓이 전부 참조하는 공통 산출물(예외 클래스·에러 코드 매핑·공통 응답 타입·빌드 의존성·설정·compose)을 한 wave에 한 번에 확정해, 후행 티켓이 트리형으로 동시에 열리게 합니다. 연관된 병목을 쪼개면 wave가 직렬 사슬이 되고 GlobalExceptionHandler.kt·build.gradle.kts·application.yml 세 파일에서 머지 충돌이 납니다.

  1. 신규 예외 클래스 — 각 도메인 패키지에 정의합니다. 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은 “이미 정의됨”만 참조합니다.
  2. 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.ymlrecruitment.auth.*(세션 만료 24h·실패 임계 5회·잠금 15분·쿠키 Secure 여부), recruitment.webhook.*(HMAC 시크릿·허용 오차 300초·nonce 보존 24h), recruitment.tunnel.public-hostname, nonce 정리 cron(0 20 3 * * *)을 추가합니다. 6. docker composecloudflared 사이드카 서비스(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이 401 INVALID_CREDENTIAL 본문으로 매핑된다
  • LoginLockedException이 429 LOGIN_LOCKED로 매핑되고 메시지에 해제 시각이 포함된다
  • WebhookVerificationFailedException이 401 WEBHOOK_SIGNATURE_INVALID로 매핑되고 실패 사유가 응답 본문에 노출되지 않는다
  • InvalidWatchStateFieldException이 400 WATCH_STATE_INVALID_FIELD로 매핑된다
  • FeatureDisabledException이 409 FEATURE_DISABLED로 매핑된다 (404가 아님 — 미분류와 구분)
  • JobPostingSortNotApplicableException이 400 JOB_POSTING_SORT_NOT_APPLICABLE로 매핑되고 actualCount·limit이 null이다
  • WatchStateFilterLimitExceededException이 400 WATCH_STATE_FILTER_LIMIT_EXCEEDED로 매핑되고 actualCount·limit이 채워진다
  • RecommendationTargetLimitExceededException이 400 RECOMMENDATION_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 등 신규 설정이 바인딩된다