[FE-23] 로그인 화면 · 세션 훅 · 인증 게이트

작업 내용 (설계 의도)

근거: 지원 관리 확장 FE 웹 설계 S-14 로그인 — 유일한 무인증 화면 · 401 처리 규약 · Query 규약(세션 staleTime: Infinity · 로그아웃 시 queryClient.clear()) · API 연동 > 1단계의 S-14·S-00 행 · 접근성의 로그인 항목, 지원 관리 확장 TDD 1단계 — 인증 (FR-70).

변경 사항

  • api/auth/에 로그인·로그아웃·세션 조회 3개 엔드포인트 함수를 추가하고, hooks/auth/useSession·useLogin·useLogout을 둡니다. 컴포넌트는 api/를 직접 import하지 않습니다.
  • 세션은 서버 상태입니다. 쿠키가 HttpOnly라 클라이언트가 만들 수 있는 진실이 없으므로 Zustand로 승격하지 않고 Query ['auth','session']로 둡니다. staleTime: Infinity — 401이 발생할 때만 무효화되면 충분하고 주기 재조회는 무의미한 트래픽입니다.
  • AuthGate.tsx를 FE-22의 스텁에서 구현으로 대체합니다. 판정 기준은 GET /api/auth/session의 3분기입니다(C-4 확정 — 초안의 “401이면 로그인” 2분기를 대체합니다).
세션 응답의미게이트 동작
200 { authRequired: false, authenticated: false }auth.required 플래그 OFF (배포 1-8 ~ 1-9 창)통과. 로그인 화면을 띄우지 않고 헤더의 로그아웃 버튼도 숨깁니다 — 끊을 세션이 없는데 버튼을 두면 눌러도 아무 일이 없습니다
200 { authRequired: true, authenticated: true, username, expiresAt }플래그 ON + 유효 세션통과. 로그아웃 버튼 노출
401 UNAUTHENTICATED플래그 ON + 세션 없음·만료<Navigate to="/login" state={{ from }} replace />. 보호 화면 콘텐츠를 절대 렌더하지 않습니다
  • 세션 로딩 중(응답 전) — 로그인 화면도 보호 화면도 렌더하지 않습니다. 빈 상태로 대기해 레이아웃 점프와 “로그인 화면이 깜빡였다 사라지는” 현상을 막습니다.

  • GET /api/auth/session은 인증 제외 경로가 아닙니다 — 플래그 ON + 무세션의 401이 곧 계약입니다. 따라서 이 요청의 401은 전역 401 핸들러를 타지 않고 useSession이 직접 소비해 위 3분기 중 하나로 판정합니다. 전역 핸들러가 잡으면 세션 쿼리가 자기 자신을 재귀 무효화합니다.

  • [필수 요구 · 2026-08-09 추가] useSession·useLoginmeta: { authExempt: true }를 반드시 부착합니다.

    • FE-21이 전역 401 제외를 경로 기반이 아니라 meta opt-in으로 구현했습니다(web/src/api/queryClient.ts). ApiError가 보유한 값은 status·code·message뿐이고 요청 경로를 갖지 않아(web/src/api/client.ts:2-12) 전역 핸들러가 경로로 제외 대상을 판별할 방법이 없습니다 — opt-in 방식 자체는 타당합니다.
    • 문제는 이 방식이 부착을 빠뜨리면 방어가 0이 된다는 것입니다. FE-21 쪽에는 누락을 막을 장치가 없으므로 이 티켓이 유일한 방어선입니다.
    • 누락 시 증상: 로그인 화면에서 useSession 401 → 전역 핸들러가 세션 쿼리 무효화 → 재조회 → 다시 401 → 무한 루프. useLoginINVALID_CREDENTIAL(401)도 같은 경로를 타 인라인 에러 대신 화면이 튕깁니다.
    • useLogout은 401을 사용자에게 의미 있게 만들 여지가 없고 실패해도 로컬 캐시를 비우고 /login으로 보내므로 부착 대상이 아닙니다 — 부착 대상은 useSession·useLogin 2개뿐입니다.
  • POST /api/auth/login은 플래그와 무관하게 항상 동작합니다 — 플래그 ON 전(배포 1-8 시점)에도 로그인 화면을 실제 자격 증명으로 검증할 수 있습니다.

  • 이 계약으로 배포 1-8(FE) ~ 1-9(플래그 ON) 창의 모순이 구조적으로 사라집니다 — “로그인 화면은 떠 있는데 API는 열려 있는” 상태가 발생하지 않습니다.

  • LoginPage.tsx를 스텁에서 구현으로 대체합니다. 아이디·비밀번호 2개 입력과 하단 단일 CTA만 둡니다 — 로고·마케팅 문구·회원가입 링크는 만들지 않습니다(단일 사용자 도구).

  • 오류는 종류별로 표현을 나눕니다. INVALID_CREDENTIAL(401)은 비밀번호 필드 아래 인라인(“아이디 또는 비밀번호가 맞지 않아요”), LOGIN_LOCKED(429)는 서버 메시지를 그대로 노출 + CTA disabled(잠금 해제 시각을 FE가 계산하지 않습니다), 네트워크 실패는 “서버에 연결하지 못했어요” + [다시 시도].

  • 로그인 성공 시 세션 쿼리를 무효화하고, AuthGatelocation.state.from으로 원래 가려던 경로로 복귀시킵니다. from이 없으면 /입니다.

  • 세션 만료 시각(expiresAt)은 화면에 노출하지 않습니다. 만료되면 다음 요청이 401을 주고 게이트가 로그인으로 보냅니다 — 카운트다운은 사용자가 할 수 있는 일이 없는 불안만 만듭니다.

  • components/layout/LogoutButton.tsx를 신규로 만들어 FE-22가 확보한 헤더 슬롯에 넣습니다. 노출 조건은 authRequired === true 입니다 — 플래그 OFF 구간에서는 렌더하지 않습니다. 로그아웃은 실패해도 queryClient.clear()/login으로 보냅니다 — 서버 세션이 이미 없는 경우가 정상 시나리오이고, 계약상 로그아웃은 멱등(204)입니다. clear()를 쓰는 이유는 이전 세션 데이터가 캐시에 남으면 안 되기 때문입니다(선택적 무효화로는 누락이 생깁니다).

  • 접근성: 아이디 autoComplete="username", 비밀번호 type="password" autoComplete="current-password", 에러는 aria-describedby로 필드에 연결 + aria-invalid, 폼은 <form onSubmit>으로 엔터 제출을 지원합니다.

롤백: BE auth.required 플래그를 끄면 세션 응답이 authRequired: false로 바뀌고 게이트가 자동으로 통과 모드가 되므로 1-9 롤백 시 FE 재배포가 필요 없습니다. 게이트 자체를 걷어내야 할 때만 AuthGate를 통과 스텁으로 되돌립니다.

의존

  • FE-22 — AuthGate.tsx·LoginPage.tsx 스텁과 /login 라우트, 헤더 로그아웃 슬롯을 선행 생성합니다.
  • FE-21 — 쿠키 전송(credentials: 'same-origin')과 401 → UNAUTHENTICATED 정규화가 없으면 게이트가 동작하지 않습니다.
  • FE-20 — AuthSessionResponse 타입, ['auth','session'] queryKey, 인증 MSW 목.
  • BE-46 — 세션 발급·잠금 정책. 통합 검증은 BE-46 완료 후 수행합니다.

다이어그램

처리 흐름

sequenceDiagram
    participant User as 사용자
    participant Gate as AuthGate
    participant Session as useSession
    participant Login as LoginPage
    Gate->>Session: GET /api/auth/session
    Session-->>Gate: 401 UNAUTHENTICATED
    Gate->>Login: /login 이동 (state.from 보존)
    User->>Login: 아이디·비밀번호 제출
    Login->>Session: 로그인 성공 → 세션 무효화
    Session-->>Gate: 200 username
    Gate-->>User: 원래 경로로 복귀

컴포넌트 의존

flowchart LR
    Gate[AuthGate] --> UseSession[useSession]
    UseSession --> ApiSession[api/auth/session]
    Login[LoginPage] --> UseLogin[useLogin]
    UseLogin --> ApiLogin[api/auth/login]
    Logout[LogoutButton] --> UseLogout[useLogout]
    UseLogout --> ApiLogout[api/auth/logout]
    UseLogout --> Clear[queryClient.clear]
    Gate --> Protected[보호 라우트]
    Login --> Field[TextField · Button]

테스트 케이스

  • 세션 조회가 401을 반환하면 로그인 화면이 렌더되고 보호 화면 콘텐츠는 렌더되지 않는다.
  • 세션 조회 응답 전에는 로그인 화면도 보호 화면도 렌더되지 않는다.
  • 세션 조회가 200 authRequired:false를 반환하면 게이트를 통과해 보호 화면이 렌더되고 로그인 화면이 렌더되지 않는다.
  • 세션 조회가 200 authRequired:false를 반환하면 헤더의 로그아웃 버튼이 렌더되지 않는다.
  • 세션 조회가 200 authRequired:true, authenticated:true를 반환하면 보호 화면이 렌더되고 로그아웃 버튼이 노출된다.
  • 세션 조회 401이 전역 401 핸들러를 타지 않고 useSession이 직접 소비해, 세션 쿼리 무효화가 재귀 호출되지 않는다.
  • useSession 쿼리에 meta: { authExempt: true }가 부착돼 있다.
  • useLogin 뮤테이션에 meta: { authExempt: true }가 부착돼 있다.
  • 로그인 화면에서 세션 조회가 401을 반복해도 무한 재조회 루프가 발생하지 않는다.
  • useLoginINVALID_CREDENTIAL(401)을 받으면 전역 핸들러로 튕기지 않고 비밀번호 필드 아래 인라인 에러로 표시된다.
  • 플래그 OFF 구간(authRequired:false)에서도 /login으로 직접 진입하면 로그인 화면이 렌더되고 실제 자격 증명으로 로그인할 수 있다.
  • 올바른 자격 증명으로 로그인하면 세션 쿼리가 무효화되고 원래 가려던 경로로 복귀한다.
  • from state 없이 로그인하면 /로 이동한다.
  • INVALID_CREDENTIAL 응답 시 “아이디 또는 비밀번호가 맞지 않아요”가 비밀번호 필드 아래 인라인으로 보이고 화면 전체 ErrorState는 보이지 않는다.
  • 로그인 5회 실패로 429가 오면 서버 메시지가 그대로 보이고 CTA가 비활성된다.
  • 보호 화면에서 API가 401을 반환하면 로그인 화면으로 이동하고, 로그인 성공 시 원래 경로로 복귀한다.
  • 로그아웃 요청이 실패해도 캐시가 비워지고 로그인 화면으로 이동한다.
  • 로그아웃 성공 시 queryClient.clear()가 실행돼 이전 세션의 목록 캐시가 남지 않는다.
  • 로그인 제출 중에는 CTA가 “로그인 중…”과 함께 비활성된다.
  • 세션 응답의 expiresAt이 화면 어디에도 노출되지 않는다.
  • 로그인 실패 문구가 aria-describedby로 비밀번호 필드에 연결되고 aria-invalid가 설정된다.
  • 로그인 화면과 로그아웃 버튼을 다크 모드로 렌더하면 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.