[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:24의credentials: '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 전용
apiUpload를client.ts에 추가합니다. 본문이FormData일 때는Content-Type헤더를 설정하지 않습니다 — 브라우저가 boundary를 포함한 헤더를 직접 붙여야 하므로,application/json을 강제하면 업로드가 깨집니다. 에러 정규화·credentials정책은apiRequest와 동일하게 공유합니다. 2단계 서류 업로드(FE-33)가 소비할 진입점을 1단계에 미리 두는 이유는client.ts가 단계 1 wave 1 단독 수정 파일이라 이후 단계에서 다시 열면 Single Writer 규칙이 깨지기 때문입니다. - 401 전역 처리를
api/queryClient.ts의QueryCache·MutationCacheonError콜백에 넣습니다. 훅마다 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-43의apiRequest는 지금 에러 바디에서code·message만 정규화하고 나머지를 버립니다 — 기존existingCompanyId가 그래서registration.ts·create.ts에서 직접fetch로 우회한 원인입니다. 이번에는 우회하지 않고 클래스 자체를 확장해 같은 문제가 세 번째로 반복되지 않게 합니다.ApiError에readonly 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-28)·ApplicationAlreadyExistsError(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 응답의 중복 회사 부가 필드를 기존과 동일하게 보존해 반환한다.apiUpload가FormData본문에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건이다. apiUpload도apiRequest와 동일하게actualCount·limit을 채운다.CompanyNameDuplicateError·ApplicationAlreadyExistsError가 3인자super호출 그대로 컴파일되고 기존 부가 필드 보존 동작이 유지된다.