[FE-21] 쿠키 인증 전환 · 401 정규화 · apiUpload · ApiError 부가 필드 확장

작업 내용 (설계 의도)

근거: 지원 관리 확장 FE 웹 설계 401 처리 규약 · ApiError 확장 — 부가 필드 actualCount·limit (A 확정) · 400 에러 3분류 · Query 규약 > 무효화 매핑 · S-14 로그인 — 유일한 무인증 화면, 지원 관리 확장 TDD 1단계 — 인증 (FR-70)의 FE 구현 요구 1~3항.

ApiError 부가 필드 확장(A 확정)이 이 티켓 범위에 추가되어 사이즈가 S → M이 됐습니다.

변경 사항

  • api/client.ts:24credentials: 'omit''same-origin'으로 바꿉니다. 세션 쿠키가 HttpOnly라 JS가 읽을 수 없으므로, 이 한 줄이 없으면 1단계 인증 자체가 성립하지 않습니다. /api는 nginx 동일 오리진 프록시라 include가 아니라 same-origin으로 충분합니다.
  • 같은 줄의 주석에 남아 있는 “NFR-7 로컬 Docker 전용” 근거를 FR-70(단일 사용자 최소 인증) 근거로 갱신합니다. 근거가 낡은 채로 남으면 다음 작업자가 “인증이 없는 프로젝트”로 오해해 되돌립니다.
  • apiRequest를 우회해 직접 fetch를 쓰는 2곳도 동일하게 전환합니다 — api/company/registration.ts:83, api/application/create.ts:85. 이 둘은 4xx 응답의 부가 필드(409 중복 회사 안내 등)를 보존하려고 직접 fetch를 쓰고 있으므로, 부가 필드 보존 구조는 그대로 두고 credentials만 바꿉니다. 구조를 apiRequest로 합치는 리팩토링은 이 티켓 범위가 아닙니다.
  • multipart 전용 apiUploadclient.ts에 추가합니다. 본문이 FormData일 때는 Content-Type 헤더를 설정하지 않습니다 — 브라우저가 boundary를 포함한 헤더를 직접 붙여야 하므로, application/json을 강제하면 업로드가 깨집니다. 에러 정규화·credentials 정책은 apiRequest와 동일하게 공유합니다. 2단계 서류 업로드(FE-33)가 소비할 진입점을 1단계에 미리 두는 이유는 client.ts단계 1 wave 1 단독 수정 파일이라 이후 단계에서 다시 열면 Single Writer 규칙이 깨지기 때문입니다.
  • 401 전역 처리를 api/queryClient.tsQueryCache·MutationCache onError 콜백에 넣습니다. 훅마다 401을 처리하지 않습니다 — 신규 훅이 늘어날 때마다 처리를 빠뜨릴 수 있습니다.
  • 판별 기준은 ApiError.code === 'UNAUTHENTICATED' 입니다. HTTP status 401만 보면 로그인 실패(INVALID_CREDENTIAL)와 세션 만료가 구분되지 않아, 로그인 시도 실패가 곧바로 게이트를 발동시킵니다.
  • 401 감지 시 queryClient.setQueryData(['auth','session'], null)로 세션 캐시를 비웁니다. 이 값을 보고 AuthGate(FE-23)가 /login으로 보냅니다 — 클라이언트 라우팅을 queryClient.ts에서 직접 수행하지 않습니다(순환 의존·테스트 불가).
  • ApiError 클래스에 부가 필드 2개를 추가합니다(A 확정). api/client.ts:36-43apiRequest는 지금 에러 바디에서 code·message만 정규화하고 나머지를 버립니다 — 기존 existingCompanyId가 그래서 registration.ts·create.ts에서 직접 fetch로 우회한 원인입니다. 이번에는 우회하지 않고 클래스 자체를 확장해 같은 문제가 세 번째로 반복되지 않게 합니다.
    • ApiErrorreadonly actualCount: number | null · readonly limit: number | null을 추가하고, apiRequest·apiUpload가 응답 바디에서 타입 가드로 좁혀 채웁니다. 검증 없는 타입 단언(as)을 쓰지 않습니다(no-loose-assertion) — 외부 응답은 unknown에서 시작해 typeof value === 'number'로 좁힌 값만 대입하고, 그 외에는 null입니다.
    • 생성자 파라미터는 기본값 null을 가진 선택 인자로 추가합니다. ApiError를 상속한 CompanyNameDuplicateError(api/company/registration.ts:20-28ApplicationAlreadyExistsError(api/application/create.ts:14-22)가 super(409, code, message) 3인자로 호출하므로, 필수 인자로 추가하면 두 서브클래스가 컴파일 실패합니다. 필드가 추가만 되므로 두 서브클래스의 기존 동작은 영향받지 않습니다.
    • 두 필드는 *_LIMIT_EXCEEDED 계열(WATCH_STATE_FILTER_LIMIT_EXCEEDED·RECOMMENDATION_TARGET_LIMIT_EXCEEDED)에서만 채워지고 나머지 코드에서는 null 입니다. 소비 측은 code로 먼저 분기한 뒤 값을 읽습니다 — 코드 확인 없이 값부터 읽으면 null 분기가 화면 문구로 새어 나옵니다.
    • 이 확장은 이 티켓이 단독으로 소유합니다(api/client.ts는 단계 1 wave 1 단독 수정 파일). FE-24·FE-39를 포함한 다른 티켓은 ApiError를 읽기만 하고 클래스를 수정하지 않습니다(Single Writer per File).
  • 전역 401 핸들러의 제외 경로는 2개입니다(C-4).
    • POST /api/auth/login — 로그인 시도 실패는 게이트 대상이 아닙니다. useLogin이 자기 에러를 인라인으로 처리합니다.
    • GET /api/auth/session/api/auth/session은 인증 제외 경로가 아니며, 플래그 ON + 무세션의 401 UNAUTHENTICATED가 곧 계약입니다. 이 401까지 전역 핸들러가 잡아 세션 캐시를 다시 무효화하면 세션 쿼리가 자기 자신을 재귀 무효화하는 무한 루프가 남습니다. 세션 401은 useSession(FE-23)이 직접 소비해 게이트 3분기 중 하나로 판정합니다.

롤백: credentials'omit'으로, queryClient onError의 401 분기를 제거하면 인증 도입 이전 동작으로 즉시 되돌아갑니다(BE auth.required 플래그 OFF와 짝).

의존

  • BE-46 (인증 세션·쿠키 발급) — 쿠키 전송·401 코드가 실제로 검증되려면 BE 인증이 필요합니다. MSW 목만으로 이 티켓의 테스트는 완결됩니다.
  • 선행 FE 티켓 없음 (단계 1 wave 1). FE-20이 만드는 타입에 의존하지 않습니다 — 클라이언트 계층만 건드립니다.

다이어그램

처리 흐름

sequenceDiagram
    participant Hook as 임의의 Query/Mutation
    participant Client as apiRequest / apiUpload
    participant Server as /api
    participant Cache as queryClient onError
    Hook->>Client: 요청 (credentials same-origin)
    Client->>Server: 쿠키 동봉
    Server-->>Client: 401 UNAUTHENTICATED
    Client-->>Hook: ApiError(code · actualCount · limit)
    Hook->>Cache: onError 전파
    Cache->>Cache: setQueryData(auth session, null)

컴포넌트 의존

flowchart LR
    Client[api/client.ts] --> Request[apiRequest]
    Client --> Upload[apiUpload]
    Request --> Error[ApiError 정규화]
    Upload --> Error
    Error --> Extra["actualCount · limit 타입 가드 채움"]
    Extra --> Consumer["FE-24 · FE-39 읽기 전용"]
    Reg[api/company/registration.ts] --> Cred[credentials same-origin]
    Create[api/application/create.ts] --> Cred
    Request --> Cred
    Error --> QC[api/queryClient.ts onError]
    QC --> Session[auth session 캐시 null]
    Session --> Gate[AuthGate FE-23]

테스트 케이스

  • apiRequest가 요청에 credentials: 'same-origin'을 실어 보낸다.
  • registerCompany가 직접 fetch 호출에 credentials: 'same-origin'을 실어 보낸다.
  • createApplication이 직접 fetch 호출에 credentials: 'same-origin'을 실어 보낸다.
  • registerCompany가 409 응답의 중복 회사 부가 필드를 기존과 동일하게 보존해 반환한다.
  • apiUploadFormData 본문에 Content-Type 헤더를 설정하지 않고 브라우저 boundary에 맡긴다.
  • apiUpload가 4xx 응답을 apiRequest와 동일한 ApiError(status·code·message)로 정규화한다.
  • 401 UNAUTHENTICATED 응답을 받으면 ['auth','session'] 캐시가 null로 설정된다.
  • 401 INVALID_CREDENTIAL 응답을 받으면 세션 캐시가 비워지지 않는다.
  • /api/auth/login 요청이 401로 실패해도 세션 캐시가 비워지지 않는다.
  • GET /api/auth/session 요청이 401 UNAUTHENTICATED로 실패해도 전역 401 핸들러가 세션 캐시를 무효화하지 않는다.
  • 세션 조회 401이 전역 401 핸들러를 타지 않아 세션 쿼리의 재귀 무효화(무한 루프)가 발생하지 않는다.
  • mutation이 401 UNAUTHENTICATED로 실패해도 query와 동일하게 세션 캐시가 비워진다.
  • 네트워크 실패(fetch reject)는 UNAUTHENTICATED로 오인되지 않고 네트워크 에러로 정규화된다.
  • 400 WATCH_STATE_FILTER_LIMIT_EXCEEDED 응답에서 ApiError.actualCount·ApiError.limit이 응답 바디 값(1842·1000)으로 채워진다.
  • 400 RECOMMENDATION_TARGET_LIMIT_EXCEEDED 응답에서도 두 필드가 동일하게 채워진다.
  • *_LIMIT_EXCEEDED가 아닌 코드(VALIDATION_FAILED·JOB_POSTING_SORT_NOT_APPLICABLE·UNAUTHENTICATED)의 응답에서는 actualCount·limit이 둘 다 null이다.
  • 부가 필드가 문자열·누락 등 숫자가 아닌 값으로 오면 타입 가드가 걸러 null이 되고, 검증 없는 타입 단언(as)이 코드에 0건이다.
  • apiUploadapiRequest와 동일하게 actualCount·limit을 채운다.
  • CompanyNameDuplicateError·ApplicationAlreadyExistsError가 3인자 super 호출 그대로 컴파일되고 기존 부가 필드 보존 동작이 유지된다.