aster®

[로그인인증] 끊기지 않는 세션 (12)

마무리 · 2026-09

초록으로 채운 큰 열쇠 하나가 가로로 놓여 있고, 그 뒤에 같은 모양의 열쇠 두 개가 남색 윤곽선만으로 겹쳐 있다. 열쇠들 아래로 가는 선이 하나 지나간다.

이 장을 마치면

  • 모든 API 요청에 액세스 토큰을 싣고, 401 이 오면 refresh 뒤 한 번만 재시도하는 authFetch 를 만든다.
  • 탭 두 개가 같은 쿠키로 동시에 refresh 할 때 무슨 일이 생기는지 시간순으로 설명하고, navigator.locks 로 직렬화한다.
  • BroadcastChannel 로 한 탭의 로그인·로그아웃을 다른 탭에 알린다.
  • 세션 목록·세션 끊기·토큰 카운트다운이 있는 MePage 를 완성하고, 만료 → 자동 갱신을 눈으로 관찰한다.
  • 8·9장의 CSRF 실습을 FE 화면에서 다시 해 본다.

들어가며

11장이 끝난 지금, 앱은 로그인하고 로그아웃하고, 새로고침해도 로그인을 되살립니다. 그런데 아직 할 수 없는 일이 있습니다. 액세스 토큰이 필요한 API 를 부르는 것입니다. 11장의 api 클라이언트는 요청에 토큰을 싣지 않았고, 그래서 MePage 는 세션 상태에 들어 있는 사용자 정보만 그렸습니다.

이 장에서는 먼저 그 일을 합니다. 요청마다 Authorization: Bearer … 를 붙이고, 토큰이 만료돼 401 이 오면 조용히 refresh 한 뒤 원래 요청을 다시 보내는 장치(authFetch)를 api 에 끼웁니다. 사용자는 액세스 토큰이 10분마다 바뀐다는 것을 모른 채 앱을 씁니다. 장 제목의 "끊기지 않는" 이 이 뜻입니다.

그다음은 11장 끝에서 예고한 문제입니다. single-flight 는 한 탭 안의 refresh 를 하나로 모았습니다. 그런데 사용자는 탭을 여러 개 엽니다. 쿠키는 탭끼리 공유되고, 메모리는 공유되지 않습니다. 이 비대칭이 7장의 재사용 탐지와 만나면, 탭 두 개를 열어 둔 사용자가 아무 잘못 없이 "토큰이 다시 쓰였다" 는 경고와 함께 로그아웃될 수 있습니다. 머리말의 질문 "탭을 두 개 열어 두면 가끔 로그아웃되는 버그는 왜 생기나요?" 에 대한 답이 이 장에 있습니다.

마지막으로 로그아웃을 다른 탭에 알리고, MePage 를 완성하고, 8·9장의 CSRF 실습실을 FE 화면으로 옮겨 4부를 마무리합니다.

12.1 요청에 액세스 토큰 싣기 — withAuth 와 authFetch

openapi-fetch 의 fetch 자리

10장에서 createClient 에 baseUrl 과 credentials 만 넘겼습니다. openapi-fetch 는 여기에 fetch 옵션도 받습니다. 요청을 실제로 보낼 때 전역 fetch 대신 우리가 넘긴 함수를 부르라는 뜻입니다. 이 책이 쓰는 openapi-fetch 0.17 의 타입 정의에서 이 옵션의 모양은 다음과 같습니다.

fetch?: (input: Request) => Promise<Response>;

openapi-fetch 는 경로·쿼리·본문·헤더를 조립해 Request 객체 하나를 만들고, 그것을 이 함수에 넘깁니다. 우리 함수는 그 Request 를 받아 Response 를 돌려주기만 하면 됩니다. 그 사이에 무엇을 하든 자유입니다. 헤더를 더하든, 재시도하든. 다른 버전에서는 이 시그니처가 다를 수 있으니, 코드가 타입 검사를 통과하지 못하면 사용하는 버전의 공식 문서에서 custom fetch 항목을 확인하세요. (같은 일을 openapi-fetch 의 미들웨어 기능으로도 할 수 있지만, 이 책은 동작이 한눈에 보이는 fetch 옵션을 택했습니다.)

두 함수

// web/src/auth/session.ts (이어서 — 파일 끝에 더한다)
function withAuth(request: Request): Request {
  if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`);
  return request;
}

/** openapi-fetch 의 fetch 자리에 끼운다. 401 이면 refresh 후 한 번만 재시도. */
export async function authFetch(request: Request): Promise<Response> {
  const retry = request.clone(); // 본문은 한 번만 읽힌다 — 보내기 전에 복제해 둔다
  const response = await fetch(withAuth(request));
  if (response.status !== 401) return response;
  if (!(await refreshSession())) return response;
  return fetch(withAuth(retry));
}

withAuth 는 요청에 지금 메모리에 있는 액세스 토큰을 붙입니다. 토큰이 없으면 아무것도 붙이지 않고 그대로 보냅니다. 그러면 서버가 401 을 돌려주고, 아래의 재시도 흐름이 refresh 를 시도합니다. 부팅 직후 조용한 갱신이 끝나기 전에 요청이 나가도 이 흐름으로 수습됩니다.

토큰을 요청이 나가는 순간에 읽는다는 점이 중요합니다. api 클라이언트를 만들 때 headers: { Authorization: … } 로 고정해 두면, 토큰이 바뀌어도 옛 값이 계속 실립니다. withAuth 는 매번 모듈 변수 accessToken 을 새로 읽습니다.

authFetch 의 흐름은 네 줄입니다.

  1. 보내기 전에 요청을 복제해 둔다.
  2. 토큰을 붙여 보낸다. 401 이 아니면 그대로 돌려준다.
  3. 401 이면 refresh 한다. 실패하면 원래의 401 응답을 그대로 돌려준다.
  4. refresh 가 성공했으면 복제본에 새 토큰을 붙여 한 번 더 보낸다.

왜 복제를 먼저 뜨는가

Request 의 본문은 스트림입니다. fetch 가 요청을 보내면서 본문 스트림을 끝까지 읽어 버리고, 한 번 읽힌 스트림은 되감을 수 없습니다. PATCH /users/me 처럼 본문이 있는 요청을 보낸 뒤 401 을 받고 같은 Request 객체로 다시 보내려 하면, 본문이 이미 소비됐다는 TypeError 가 납니다.

request.clone() 은 본문 스트림을 둘로 갈라 똑같은 요청을 하나 더 만듭니다. 이것은 보내기 전에만 할 수 있습니다. 보낸 뒤에 복제하려 하면 역시 "이미 사용된 본문" 오류가 납니다. 그래서 첫 줄에서, 401 이 올지 안 올지 모르는 상태에서 미리 복제해 둡니다. 대부분의 요청은 401 없이 끝나니 복제본은 그냥 버려집니다. 그 비용보다 재시도할 수 있다는 이점이 큽니다.

복제본에는 아직 Authorization 헤더가 없다는 점도 보세요. withAuth(request) 보다 앞에서 복제했으니까요. 재시도할 때 withAuth(retry) 가 refresh 로 받은 새 토큰을 붙입니다. 옛 토큰이 붙은 채로 재시도하는 실수가 구조적으로 생기지 않습니다.

왜 재시도는 한 번뿐인가

3번 줄을 다시 보세요. refresh 가 성공해 재시도한 요청이 또 401 을 받으면 어떻게 될까요? authFetch 는 그 응답을 그냥 돌려줍니다. 다시 refresh 하지 않습니다. 새로 받은 토큰으로도 401 이 난다면 문제는 토큰 만료가 아닙니다. 서버 설정이 바뀌었거나(예: JWT_ACCESS_SECRET 교체 직후 다른 인스턴스), 사용자가 지워졌거나 하는 일입니다. 그때 refresh 와 재시도를 반복하면 요청이 끝없이 이어집니다. "401 ACCESS_TOKEN_INVALID 는 refresh 후 1회만 재시도한다" 는 규칙이 이 줄입니다.

refresh 가 실패했을 때 원래의 401 응답을 돌려주는 것도 의도한 것입니다. 부르는 쪽(api.GET(...))은 평소처럼 error 로 ACCESS_TOKEN_INVALID 를 받습니다. 그리고 그 사이에 runRefresh 가 이미 dropSession('expired') 를 불러 상태를 anonymous 로 바꿨으므로, RequireAuth 가 로그인 화면으로 보냅니다. 화면 코드는 401 처리를 따로 하지 않아도 됩니다.

api 에만 끼운다

// web/src/api/client.ts
import createClient from 'openapi-fetch';
import { authFetch } from '../auth/session';
import type { components, paths } from './schema';

const baseUrl = import.meta.env.VITE_API_URL;

/** 앱 API 용. 401 이면 refresh 후 한 번 재시도한다. */
export const api = createClient<paths>({ baseUrl, credentials: 'include', fetch: authFetch });

/** /auth/* 용. 재시도 없음 — refresh 가 자기 자신을 부르는 순환을 막으려고 나눈다. */
export const authApi = createClient<paths>({ baseUrl, credentials: 'include' });

export type Schemas = components['schemas'];
export type TokenResponse = Schemas['TokenResponseDto'];
export type User = Schemas['UserDto'];
export type Session = Schemas['SessionDto'];
export type ErrorResponse = Schemas['ErrorResponseDto'];

10장에서 바꾸겠다고 한 곳은 두 줄입니다. authFetch import 와 api 의 fetch: authFetch. 주석도 이제 실제 동작에 맞게 고쳤습니다.

authApi 에는 끼우지 않습니다. 10장에서 "순환" 이라고만 했던 것을 이제 정확히 말할 수 있습니다. POST /auth/refresh 가 authFetch 를 거친다고 해 봅시다. refresh 가 401 을 받으면 authFetch 는 refreshSession() 을 부릅니다. 그런데 지금 진행 중인 refresh 가 바로 refreshSession() 이 만든 것이고, single-flight 는 진행 중인 그 promise 를 돌려줍니다. 자기 자신이 끝나기를 자기 자신이 기다리는 셈이라 영원히 끝나지 않습니다. 12.4절에서 잠금을 더하면 같은 이름의 잠금을 안에서 또 요청하는 꼴이 되어 역시 영원히 기다립니다. 그래서 쿠키로 인증하는 /auth/csrf·login·refresh·logout 은 authApi 로, Bearer 로 인증하는 나머지는 api 로 부릅니다.

⚠️ client.ts 는 session.ts 의 authFetch 를 import 하고, session.ts 는 client.ts 의 authApi(와 12.6절의 api)를 import 합니다. 두 모듈이 서로를 import 하는 순환 import 입니다. 이 코드가 동작하는 것은 두 가지 조건 덕분입니다. 첫째, authFetch 가 function 선언이라 모듈이 평가되기 전에도 이름이 준비돼 있습니다. 둘째, session.ts 는 api·authApi 를 함수 안에서만 쓰고, 모듈 최상위에서 바로 쓰지 않습니다. authFetch 를 export const authFetch = async (…) => … 처럼 화살표 함수 상수로 바꾸면, import 순서에 따라 Cannot access 'authFetch' before initialization 오류가 날 수 있습니다. 이 두 파일을 고칠 때는 이 조건을 지키세요.

12.2 동시에 401 세 개 — single-flight 복습

authFetch 가 생기자 11장의 single-flight 가 본래 할 일을 하게 됩니다. MePage 가 처음 그려질 때 API 를 세 곳에서 동시에 부른다고 해 봅시다. 액세스 토큰은 방금 만료됐습니다.

 화면 코드                 authFetch                 refreshSession()              서버
 ─────────                 ─────────                 ────────────────              ────
 api.GET('/auth/sessions') ──▶ fetch ─────────────────────────────────────────────▶ 401
 api.GET('/users/me')      ──▶ fetch ─────────────────────────────────────────────▶ 401
 api.PATCH('/users/me')    ──▶ fetch ─────────────────────────────────────────────▶ 401
                               ① 401 → refreshSession() ─▶ refreshing = P (새 요청)
                               ② 401 → refreshSession() ─▶ P 그대로
                               ③ 401 → refreshSession() ─▶ P 그대로
                                                          POST /auth/refresh ──────▶ 200 (R1→R2)
                               ◀─────────────────── P = true (세 곳 모두)
                               ① retry ────────────────────────────────────────────▶ 200
                               ② retry ────────────────────────────────────────────▶ 200
                               ③ retry ────────────────────────────────────────────▶ 200

401 은 세 번, refresh 는 한 번, 재시도는 세 번입니다. 11장에서 StrictMode 의 이중 effect 를 막으려고 넣은 refreshing ??= 가 여기서 세 요청을 한 refresh 로 모읍니다. single-flight 가 없었다면 refresh 가 세 번 나가, 11장에서 본 409 와 "운이 나쁘면 로그아웃" 이 세 겹으로 벌어졌을 것입니다.

12.3 탭이 두 개일 때 — 쿠키는 공유되고 메모리는 공유되지 않는다

이제 사용자가 같은 브라우저에서 앱을 탭 두 개로 열었다고 합시다. 두 탭에 무엇이 공유되고 무엇이 따로인지 정리해 봅니다.

탭 A탭 B공유되나
refresh_token 쿠키R1R1공유된다 (브라우저의 쿠키 저장소는 하나)
csrf_token 쿠키CC공유된다
accessToken 변수토큰 a토큰 b따로 (탭마다 모듈이 따로 뜬다)
csrfToken 변수값값따로
refreshing 변수promisepromise따로

마지막 줄이 문제입니다. single-flight 의 refreshing 은 탭마다 따로 있습니다. 탭 A 의 refresh 가 진행 중이어도 탭 B 는 그 사실을 모릅니다. 두 탭의 액세스 토큰이 거의 같은 시각에 만료되면(같은 시각에 로그인했거나, 한 탭의 로그인이 다른 탭에도 퍼지면 흔한 일입니다) 두 탭이 각자 refresh 를 보냅니다. 같은 쿠키 R1 로.

 탭 A                              서버·DB                                          탭 B
 ────                              ───────                                          ────
 POST /auth/refresh (쿠키 R1) ───▶ BEGIN; R1 행 FOR UPDATE
                                                            ◀─── POST /auth/refresh (쿠키 R1)
                                   BEGIN; R1 행 FOR UPDATE — 대기
                                   A: R2 INSERT, R1 → rotated, COMMIT
                                   B: 잠금 획득. R1 은 rotated, 10초 안 → 409
                                                            ───▶ 409 REFRESH_TOKEN_ROTATED
 ◀─── 200 + Set-Cookie R2                                        B: runRefresh(true)
                                                                 쿠키가 이미 R2 면 → 200 (R2→R3)
                                                                 아직 R1 이면   → 또 409 → 로그아웃

11장의 한 탭 안 그림과 똑같습니다. 차이는 이번에는 single-flight 로 막을 수 없다는 것뿐입니다. 이 상황에서 우리를 지켜 주는 것은 서버의 grace(10초) 와 FE 의 409 재시도 한 번뿐입니다. 그리고 11장에서 보았듯 둘 다 안전망이지 해결책이 아닙니다.

두 번째 경우가 특히 나쁩니다. 7장에서 공들여 만든 탈취 탐지가 정상 사용자를 공격자로 오인하는 것이고, 경보를 받는 운영자 입장에서도(14장) 진짜 탈취와 구분할 수 없는 소음이 됩니다.

해결의 방향은 분명합니다. single-flight 가 한 탭 안에서 한 일을 탭 사이에서도 해야 합니다. 한 탭이 refresh 하는 동안 다른 탭은 기다렸다가, 앞 탭이 끝난 뒤에 자기 차례를 가져야 합니다.

12.4 탭 사이의 잠금 — navigator.locks

브라우저에는 이 일을 위한 API 가 있습니다. Web Locks API 입니다. 같은 출처의 모든 탭(과 워커)이 공유하는 이름 붙은 잠금을 제공합니다.

// web/src/auth/session.ts (refreshSession 을 바꾼다)
/** single-flight + 탭 간 잠금. 성공하면 true. */
export function refreshSession(): Promise<boolean> {
  refreshing ??= navigator.locks
    .request('auth-refresh', () => runRefresh(false))
    .catch(() => {
      // API 에 닿지 못했다(서버 꺼짐·네트워크). 부팅 중이면 "세션 확인 중" 에 멈추지 않게 익명으로 둔다
      if (state.status === 'unknown') dropSession();
      return false;
    })
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}

11장의 코드에서 runRefresh(false) 를 navigator.locks.request('auth-refresh', () => runRefresh(false)) 로 감쌌을 뿐입니다. request 의 동작은 이렇습니다.

  1. 'auth-refresh' 라는 이름의 잠금을 요청합니다. 기본은 배타적 잠금이라, 같은 이름의 잠금은 한 번에 하나만 쥘 수 있습니다.
  2. 잠금을 얻으면 콜백(() => runRefresh(false))을 부릅니다. 다른 탭이 쥐고 있으면 풀릴 때까지 기다렸다가 부릅니다.
  3. 콜백이 돌려준 promise 가 끝나면 잠금을 풉니다.
  4. request 가 돌려주는 promise 는 콜백의 결과(여기서는 boolean)로 끝납니다. 그래서 refreshSession 의 반환 타입이 그대로 Promise<boolean> 입니다.

single-flight 는 그대로 남아 있습니다. 한 탭 안에서는 refreshing ??= 가 먼저 요청을 하나로 모으고, 그렇게 모인 하나가 탭 사이의 잠금 줄에 섭니다. 두 장치가 각자의 범위를 맡습니다.

잠금 안에서 두 번째 탭은 새 쿠키를 들고 간다

잠금으로 두 탭의 refresh 를 줄 세우면 왜 409 가 사라질까요? 핵심은 브라우저가 쿠키를 싣는 시점입니다. 브라우저는 refreshSession() 을 부른 순간이 아니라, fetch 가 요청을 실제로 보내는 순간의 쿠키 저장소에서 쿠키를 꺼내 싣습니다. 그리고 탭 B 의 fetch 는 잠금 콜백 안에 있으므로, 탭 A 가 잠금을 풀기 전에는 불리지 않습니다.

 탭 A                         잠금 'auth-refresh'     서버              탭 B                        쿠키 저장소 (탭 공유)
 ────                         ───────────────────     ────              ────                        ────────────────────
 refreshSession()                                                                                   refresh_token = R1
  locks.request ────────────▶ A 획득
  POST /refresh (R1) ───────────────────────────────▶ R1→R2
                                                                        refreshSession()
                                                                         locks.request ───▶ 대기
  ◀──── 200 + Set-Cookie R2 ────────────────────────                                                refresh_token = R2
  acceptTokens(), 콜백 끝 ──▶ A 반납 → B 획득
                                                                         POST /refresh (R2) ─────▶ R2→R3
                                                                         ◀──── 200 + Set-Cookie R3  refresh_token = R3
                                                                         콜백 끝 → B 반납

탭 A 의 콜백은 runRefresh 가 끝날 때 끝나고, runRefresh 는 응답을 받아 acceptTokens 까지 한 뒤에 끝납니다. fetch 의 promise 가 풀리는 시점에는 이미 응답 헤더가 처리돼 Set-Cookie 가 쿠키 저장소에 반영돼 있습니다. 그러니 탭 B 가 잠금을 얻어 요청을 만들 때 쿠키 저장소에는 R2 가 있습니다. B 는 방금 회전된 새 토큰으로 정상적인 회전을 합니다. 서버 입장에서 이것은 한 사용자가 refresh 를 두 번 연달아 한 것과 다르지 않습니다. 409 도, 재사용 탐지도 일어나지 않습니다.

탭 B 의 refresh 가 사라지지는 않는다는 점도 보세요. 탭 B 도 자기 메모리에 쓸 액세스 토큰이 필요하니, refresh 를 한 번 하기는 해야 합니다. 잠금이 하는 일은 refresh 를 없애는 것이 아니라 순서대로 세우는 것입니다.

잠금 안에는 runRefresh 전체가 들어갑니다. 409 재시도와 CSRF 재시도도 잠금을 쥔 채로 일어납니다. 한 탭이 재시도하는 도중에 다른 탭이 끼어들지 못합니다.

보안 컨텍스트에서만 된다

Web Locks API 는 보안 컨텍스트(secure context) 에서만 제공됩니다. HTTPS 로 연 페이지, 그리고 예외적으로 http://localhost 와 http://127.0.0.1 입니다. 그래서 실습 환경의 http://localhost:5173 에서는 동작합니다. 그런데 같은 개발 서버를 휴대폰에서 보려고 http://192.168.0.10:5173 같은 주소로 열면 보안 컨텍스트가 아니라 navigator.locks 가 undefined 이고, refreshSession 이 TypeError 로 실패합니다. (그 주소는 어차피 WEB_ORIGINS 에 없어 CORS 에서도 막힙니다.) 운영은 HTTPS 이니 문제가 없습니다.

💡 이 책은 잠금이 없는 환경을 위한 대체 경로를 두지 않았습니다. 현재 주요 브라우저는 모두 Web Locks 를 지원합니다. 오래된 브라우저를 지원해야 한다면 잠금 없이 runRefresh 를 부르는 분기를 두고, 서버의 grace 와 409 재시도에 기대게 됩니다. 서버의 grace 는 바로 이런 환경을 위한 안전망입니다.

💡 잠금을 쥔 탭이 닫히면 브라우저가 잠금을 풀어 줍니다. 한 탭이 멈춰서 다른 탭들이 영원히 기다리는 일은 탭을 닫는 것으로 끝납니다. 잠금의 범위는 출처(http://localhost:5173)와 브라우저 프로필입니다. 쿠키 저장소의 범위와 같으니, 잠금이 줄 세우는 대상과 쿠키를 나눠 쓰는 대상이 정확히 일치합니다. 시크릿 창은 쿠키도 잠금도 따로입니다.

12.5 다른 탭에 알리기 — BroadcastChannel

잠금으로 refresh 충돌은 막았습니다. 남은 문제는 로그아웃입니다. 탭 A 에서 로그아웃하면 서버는 family 를 폐기하고 쿠키를 지웁니다. 그런데 탭 B 의 메모리에는 액세스 토큰이 그대로 있습니다. 탭 B 는 그 토큰이 만료될 때까지(최대 10분) API 를 계속 부를 수 있고, 화면도 여전히 로그인 상태로 보입니다. 사용자는 로그아웃했다고 믿고 자리를 떴는데, 다른 탭에는 로그인된 화면이 떠 있는 것입니다.

같은 출처의 탭끼리 메시지를 주고받는 API 가 BroadcastChannel 입니다.

// web/src/auth/session.ts (변수 선언부에 한 줄 더한다)
const channel = new BroadcastChannel('auth');
// web/src/auth/session.ts (login·logout 에 한 줄씩, 그리고 수신부)
export async function login(email: string, password: string): Promise<ErrorResponse | null> {
  const { data, error } = await withCsrf((csrf) =>
    authApi.POST('/auth/login', { body: { email, password }, headers: { 'X-CSRF-Token': csrf } }),
  );
  if (!data) return error as ErrorResponse;
  acceptTokens(data);
  channel.postMessage({ type: 'login' });
  return null;
}

export async function logout(): Promise<void> {
  await withCsrf((csrf) => authApi.POST('/auth/logout', { headers: { 'X-CSRF-Token': csrf } }));
  dropSession('logout');
  channel.postMessage({ type: 'logout' });
}

// …

channel.onmessage = (event: MessageEvent<{ type: 'login' | 'logout' }>) => {
  if (event.data.type === 'logout') dropSession('logout');
  if (event.data.type === 'login') void refreshSession();
};

new BroadcastChannel('auth') 는 'auth' 라는 이름의 채널에 참여합니다. 같은 출처에서 같은 이름으로 참여한 모든 탭이 한 채널을 나눠 씁니다. postMessage 로 보낸 메시지는 보낸 자신을 뺀 다른 모든 참여자에게 갑니다. 그래서 로그아웃한 탭이 자기 메시지를 받아 dropSession 을 두 번 부르는 일은 없습니다.

logout 메시지를 받은 탭은 dropSession('logout') 만 합니다. 서버를 부르지 않습니다. family 폐기와 쿠키 삭제는 보낸 탭이 이미 했고, 쿠키 저장소는 공유되니 받은 탭의 쿠키도 이미 지워졌습니다. 받은 탭이 할 일은 메모리의 액세스 토큰을 버리고 상태를 바꾸는 것뿐입니다. 상태가 anonymous 가 되면 RequireAuth 가 로그인 화면으로 보내고, "로그아웃했습니다." 가 뜹니다.

login 메시지를 받은 탭은 refreshSession() 을 부릅니다. 로그인한 탭이 받은 액세스 토큰을 채널로 보내 주면 더 빠를 텐데, 이 책은 그렇게 하지 않습니다. 이유는 두 가지입니다. 첫째, 11장의 원칙 "액세스 토큰은 응답 본문에서 곧장 메모리로" 를 지키려는 것입니다. 토큰이 탭 사이 메시지로 떠돌면 그것을 받는 코드가 늘고, 토큰이 지나가는 길이 늘어납니다. 둘째, 쿠키는 이미 공유돼 있으니 각 탭은 refresh 한 번으로 자기 몫의 액세스 토큰을 정상 경로로 받을 수 있습니다. 탭이 여러 개면 refresh 가 여러 번 나가지만, 12.4절의 잠금이 그것들을 줄 세우니 충돌하지 않습니다.

메시지 본문은 { type: 'login' | 'logout' } 뿐입니다. 사용자 정보도 토큰도 싣지 않습니다. MessageEvent<…> 의 타입 인자는 우리가 주장하는 모양일 뿐 검증이 아닙니다. 같은 출처의 코드만 이 채널에 참여할 수 있으니 이 정도로 충분합니다.

⚠️ BroadcastChannel 은 한 브라우저 안의 탭끼리만 통합니다. 노트북의 Chrome 에서 로그아웃해도 휴대폰의 브라우저는 모릅니다. 다른 기기를 끊는 것은 다음 절의 logoutAll 과 서버의 몫입니다.

12.6 모든 기기에서 로그아웃 — logoutAll

// web/src/auth/session.ts (logout 아래에 더한다)
export async function logoutAll(): Promise<void> {
  await api.POST('/auth/logout-all');
  dropSession('logout');
  channel.postMessage({ type: 'logout' });
}

이 함수만 authApi 가 아니라 api 를 씁니다. POST /auth/logout-all 은 쿠키가 아니라 Bearer 토큰으로 인증하는 엔드포인트이기 때문입니다(7장, 10장의 보안 스킴 표). 그래서 X-CSRF-Token 헤더도 없습니다. 액세스 토큰이 만료돼 있으면 authFetch 가 refresh 한 뒤 재시도해 줍니다. 이 줄 때문에 session.ts 의 import 에 api 가 더해집니다.

서버는 이 사용자의 모든 family 를 logout_all 로 폐기하고, 이 브라우저의 refresh_token 쿠키를 지웁니다. 이 브라우저의 다른 탭에는 BroadcastChannel 로 알립니다. 다른 기기는 어떻게 될까요? 다른 기기의 리프레시 토큰은 DB 에서 폐기됐으니, 그 기기가 다음에 refresh 할 때 401 REFRESH_TOKEN_INVALID 를 받고 expired 로 로그아웃됩니다. 그러나 그때까지는 메모리의 액세스 토큰으로 API 를 부를 수 있습니다. 최대 10분입니다. 7장에서 이야기한 "폐기는 리프레시에만 즉시 반영된다" 는 시차입니다. 이것을 즉시 끊어야 하는 서비스라면 14장의 "액세스 토큰 즉시 폐기" 를 보세요.

12.7 session.ts 완성본

12.1절부터 12.6절까지 더한 것을 모은 최종 파일입니다. 부록 C 와 같습니다.

// web/src/auth/session.ts
import { api, authApi, type ErrorResponse, type TokenResponse, type User } from '../api/client';

export type AnonymousReason = 'logout' | 'expired' | 'reused';
export type SessionState =
  | { status: 'unknown' }
  | { status: 'authenticated'; user: User }
  | { status: 'anonymous'; reason?: AnonymousReason };

let state: SessionState = { status: 'unknown' };
let accessToken: string | null = null;
let csrfToken: string | null = null;
let refreshing: Promise<boolean> | null = null;
const listeners = new Set<() => void>();
const channel = new BroadcastChannel('auth');

export const getSession = () => state;
export const getAccessToken = () => accessToken;
export function subscribe(listener: () => void) {
  listeners.add(listener);
  return () => listeners.delete(listener);
}

function setState(next: SessionState) {
  state = next;
  listeners.forEach((listener) => listener());
}

function acceptTokens(body: TokenResponse) {
  accessToken = body.accessToken;
  setState({ status: 'authenticated', user: body.user });
}

function dropSession(reason?: AnonymousReason) {
  accessToken = null;
  setState({ status: 'anonymous', reason });
}

/** 닉네임 변경처럼 사용자 정보가 바뀌었을 때 화면 상태를 맞춘다. */
export function updateUser(user: User) {
  if (state.status === 'authenticated') setState({ status: 'authenticated', user });
}

export async function ensureCsrf(): Promise<string> {
  if (csrfToken) return csrfToken;
  const { data } = await authApi.GET('/auth/csrf');
  csrfToken = data!.csrfToken;
  return csrfToken;
}

/** single-flight + 탭 간 잠금. 성공하면 true. */
export function refreshSession(): Promise<boolean> {
  refreshing ??= navigator.locks
    .request('auth-refresh', () => runRefresh(false))
    .catch(() => {
      // API 에 닿지 못했다(서버 꺼짐·네트워크). 부팅 중이면 "세션 확인 중" 에 멈추지 않게 익명으로 둔다
      if (state.status === 'unknown') dropSession();
      return false;
    })
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}

/** CSRF 토큰이 쿠키와 어긋나면(쿠키 만료·서버 비밀 교체) 새로 받아 한 번만 다시 보낸다. */
async function withCsrf<T extends { error?: unknown }>(send: (csrf: string) => Promise<T>): Promise<T> {
  const first = await send(await ensureCsrf());
  if ((first.error as ErrorResponse | undefined)?.code !== 'CSRF_TOKEN_INVALID') return first;
  csrfToken = null;
  return send(await ensureCsrf());
}

async function runRefresh(retried: boolean): Promise<boolean> {
  const { data, error, response } = await withCsrf((csrf) =>
    authApi.POST('/auth/refresh', { headers: { 'X-CSRF-Token': csrf } }),
  );
  if (data) {
    acceptTokens(data);
    return true;
  }
  const code = (error as ErrorResponse | undefined)?.code;
  if (!retried && response.status === 409) return runRefresh(true);
  // 부팅 때 쿠키가 없던 것(처음 방문)과 로그인 중에 만료된 것을 구분한다
  const wasIn = state.status === 'authenticated';
  dropSession(code === 'REFRESH_TOKEN_REUSED' ? 'reused' : wasIn ? 'expired' : undefined);
  return false;
}

export async function login(email: string, password: string): Promise<ErrorResponse | null> {
  const { data, error } = await withCsrf((csrf) =>
    authApi.POST('/auth/login', { body: { email, password }, headers: { 'X-CSRF-Token': csrf } }),
  );
  if (!data) return error as ErrorResponse;
  acceptTokens(data);
  channel.postMessage({ type: 'login' });
  return null;
}

export async function logout(): Promise<void> {
  await withCsrf((csrf) => authApi.POST('/auth/logout', { headers: { 'X-CSRF-Token': csrf } }));
  dropSession('logout');
  channel.postMessage({ type: 'logout' });
}

export async function logoutAll(): Promise<void> {
  await api.POST('/auth/logout-all');
  dropSession('logout');
  channel.postMessage({ type: 'logout' });
}

channel.onmessage = (event: MessageEvent<{ type: 'login' | 'logout' }>) => {
  if (event.data.type === 'logout') dropSession('logout');
  if (event.data.type === 'login') void refreshSession();
};

function withAuth(request: Request): Request {
  if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`);
  return request;
}

/** openapi-fetch 의 fetch 자리에 끼운다. 401 이면 refresh 후 한 번만 재시도. */
export async function authFetch(request: Request): Promise<Response> {
  const retry = request.clone(); // 본문은 한 번만 읽힌다 — 보내기 전에 복제해 둔다
  const response = await fetch(withAuth(request));
  if (response.status !== 401) return response;
  if (!(await refreshSession())) return response;
  return fetch(withAuth(retry));
}

11장 버전과 달라진 곳을 다시 세면 다섯 군데입니다. import 에 api, 변수 선언부에 channel, refreshSession 의 잠금, login·logout 의 postMessage 와 새 함수 logoutAll, 그리고 파일 끝의 수신부·withAuth·authFetch. hooks.ts 와 RequireAuth.tsx 는 11장 그대로입니다.

12.8 MePage 완성 — 세션 목록, 끊기, 카운트다운

이제 api 가 토큰을 실으니, 11장에서 미뤄 둔 기능을 붙입니다. 먼저 액세스 토큰의 만료 시각을 읽는 작은 도구입니다.

// web/src/auth/jwt.ts
/** 화면 표시용으로 JWT 본문만 읽는다. 검증은 하지 않는다 — 검증은 서버의 일이다. */
export function readJwtPayload(token: string): { sub: string; sid: string; exp: number; iat: number } {
  const payload = token.split('.')[1];
  const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
  return JSON.parse(json);
}

2장에서 JWT 를 손으로 해부했던 것을 코드로 옮긴 것입니다. 헤더.본문.서명 중 가운데를 떼어, base64url 을 일반 base64 로 바꾸고(-→+, _→/), atob 으로 풀어 JSON 으로 읽습니다. base64url 은 끝의 = 패딩을 생략하는데, 브라우저의 atob 은 패딩이 없어도 받아 줍니다. 본문의 값이 모두 ASCII(uuid, 숫자)라 atob 의 결과를 그대로 JSON.parse 해도 됩니다. 한글 같은 문자가 클레임에 들어 있다면 UTF-8 해석을 따로 해야 합니다.

FE 는 이 토큰을 검증하지 않습니다. 할 수도 없습니다. 검증에 필요한 JWT_ACCESS_SECRET 은 서버에만 있고, 있어서도 안 됩니다. 이 함수는 표시용입니다. 여기서 읽은 exp 로 "몇 초 남았다" 를 보여 줄 뿐, 권한 판단을 하지 않습니다. 누군가 개발자 도구에서 토큰을 고쳐 exp 를 늘려도 화면의 숫자만 바뀌고, 서버는 서명이 맞지 않는 토큰을 401 로 거절합니다.

MePage 의 최종 모습입니다.

// web/src/pages/MePage.tsx
import { useEffect, useState, type FormEvent } from 'react';
import { api, type Session } from '../api/client';
import { useSession } from '../auth/hooks';
import { readJwtPayload } from '../auth/jwt';
import { getAccessToken, logout, logoutAll, updateUser } from '../auth/session';

export function MePage() {
  const session = useSession();
  const [sessions, setSessions] = useState<Session[]>([]);
  const [secondsLeft, setSecondsLeft] = useState<number | null>(null);

  async function loadSessions() {
    const { data } = await api.GET('/auth/sessions');
    if (data) setSessions(data);
  }

  useEffect(() => {
    void loadSessions();
  }, []);

  // 교육용: 액세스 토큰이 몇 초 남았는지 보여 준다
  useEffect(() => {
    const timer = setInterval(() => {
      const token = getAccessToken();
      setSecondsLeft(token ? readJwtPayload(token).exp - Math.floor(Date.now() / 1000) : null);
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  if (session.status !== 'authenticated') return null;

  async function onRename(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const nickname = String(new FormData(event.currentTarget).get('nickname'));
    const { data } = await api.PATCH('/users/me', { body: { nickname } });
    if (data) updateUser(data);
  }

  async function onDelete(sessionId: string) {
    await api.DELETE('/auth/sessions/{sessionId}', { params: { path: { sessionId } } });
    await loadSessions();
  }

  return (
    <section style={{ display: 'grid', gap: 16 }}>
      <h1>{session.user.nickname} 님</h1>
      <p>
        {session.user.email} · 액세스 토큰 남은 시간: {secondsLeft ?? '-'}초
        <br />
        <small>0 이 된 뒤에 아무 API 나 부르면 refresh 가 한 번 일어나는 것을 네트워크 탭에서 볼 수 있습니다.</small>
      </p>

      <form onSubmit={onRename} style={{ display: 'flex', gap: 8 }}>
        <input name="nickname" defaultValue={session.user.nickname} minLength={2} maxLength={20} />
        <button>닉네임 변경</button>
      </form>

      <div>
        <h2>로그인된 세션</h2>
        <ul>
          {sessions.map((s) => (
            <li key={s.sessionId}>
              {s.current ? '👉 이 기기 · ' : ''}
              {s.userAgent ?? '알 수 없음'} · 마지막 사용 {new Date(s.lastUsedAt).toLocaleString()}
              {!s.current && <button onClick={() => onDelete(s.sessionId)}>끊기</button>}
            </li>
          ))}
        </ul>
      </div>

      <div style={{ display: 'flex', gap: 8 }}>
        <button onClick={() => void logout()}>로그아웃</button>
        <button onClick={() => void logoutAll()}>모든 기기에서 로그아웃</button>
      </div>
    </section>
  );
}

11장의 최소 버전에 더해진 것을 하나씩 봅니다.

훅은 모두 이른 return 앞에 있습니다. useState 두 개와 useEffect 두 개가 if (session.status !== 'authenticated') return null; 보다 위에 있습니다. onRename·onDelete 는 훅이 아니라 평범한 함수라 아래에 있어도 됩니다.

세션 목록. 마운트할 때 GET /auth/sessions 를 한 번 부릅니다. StrictMode 에서는 effect 가 두 번 돌아 이 요청이 두 번 나갑니다. 읽기 전용 GET 이라 두 번 나가도 결과가 같으니 11장의 refresh 와 달리 문제가 되지 않습니다. 이것이 "effect 는 두 번 돌아도 안전해야 한다" 는 StrictMode 의 요구를 만족하는 모습입니다. 액세스 토큰이 만료돼 두 요청이 모두 401 을 받아도 12.2절의 single-flight 가 refresh 를 하나로 모읍니다.

s.current 는 7장에서 서버가 액세스 토큰의 sid 와 family id 를 비교해 채운 값입니다. 현재 세션에는 "끊기" 버튼을 두지 않습니다. 현재 세션을 끊고 싶으면 로그아웃하면 되고, 끊기로 현재 세션을 지우면 쿠키는 남은 채 서버에서만 폐기돼 다음 refresh 에서야 알게 되는 어색한 상태가 됩니다. s.userAgent ?? '알 수 없음' 은 10장에서 본 nullable: true 가 FE 에 요구한 null 처리입니다.

세션 끊기. api.DELETE('/auth/sessions/{sessionId}', { params: { path: { sessionId } } }) 에서 경로 문자열은 계약에 적힌 모양 그대로 중괄호를 씁니다. openapi-fetch 가 params.path 의 값으로 채웁니다. path 에 sessionId 를 빠뜨리면 컴파일 오류입니다. 끊은 뒤 목록을 다시 읽습니다. 7장에서 만든 대로 남의 세션 id 를 넣으면 404 SESSION_NOT_FOUND 가 오지만, 이 화면은 자기 목록에서만 고르니 그럴 일이 없습니다.

닉네임 변경. api.PATCH('/users/me', { body: { nickname } }) 의 응답 본문은 갱신된 UserDto 입니다. 그것을 updateUser 로 세션 상태에 반영하면 제목의 닉네임이 바로 바뀝니다. 11장에서 updateUser 를 만들어 둔 이유가 이것입니다. 이 버튼이 12.9절 실습에서 "아무 API" 역할을 합니다.

카운트다운. 1초마다 getAccessToken() 으로 지금의 토큰을 읽어 exp - 현재 시각 을 계산합니다. 토큰을 effect 밖에서 한 번 읽어 두지 않고 매번 새로 읽는 것은, refresh 로 토큰이 바뀌면 다음 틱에 곧바로 새 만료 시각이 반영되게 하려는 것입니다. 정리 함수(clearInterval)가 있으니 StrictMode 에서 effect 가 두 번 돌아도 타이머는 하나만 남습니다. 표시되는 숫자는 브라우저 시계 기준이라 서버 시계와 몇 초 어긋날 수 있습니다. 표시용이니 괜찮습니다.

모든 기기에서 로그아웃. 12.6절의 logoutAll 을 부릅니다.

12.9 실습 — 만료와 자동 갱신을 눈으로 보기

액세스 토큰의 수명은 10분이라, 만료되는 것을 기다려 보기에는 깁니다. 실습 동안만 15초로 줄입니다.

# api/.env (실습 동안만 — 끝나면 600 으로 되돌린다)
ACCESS_TOKEN_TTL_SEC=15

API 를 다시 띄우고, FE 에서 다시 로그인하거나 F5 로 새 토큰을 받습니다. 내 정보 화면을 연 채로 Network 탭을 엽니다.

  1. "액세스 토큰 남은 시간" 이 15 에서 1초씩 줄어듭니다. 0 을 지나면 음수가 됩니다. -3, -4, … 이 앱은 만료되기 전에 미리 갱신하지 않습니다. 만료된 토큰을 메모리에 쥔 채로 있다가, 다음 요청이 401 을 받을 때 갱신합니다. 음수는 "만료된 토큰을 아직 들고 있다" 는 뜻입니다.

  2. 음수가 된 상태에서 "닉네임 변경" 을 누릅니다. Network 탭에 이런 순서가 보입니다(예시).

    이름        메서드   상태
    me          OPTIONS  204      ← PATCH·Authorization 때문에 사전 요청
    me          PATCH    401      ← 만료된 토큰
    refresh     OPTIONS  204
    refresh     POST     200      ← authFetch → refreshSession
    me          PATCH    200      ← 복제본에 새 토큰을 붙여 재시도
    

    csrf 요청은 없습니다. CSRF 토큰은 부팅 때 받아 메모리에 있습니다. 두 PATCH 요청의 상세를 열어 요청 헤더의 Authorization 값을 비교하면 서로 다른 토큰입니다. 두 번째 요청의 Payload 에는 첫 번째와 같은 {"nickname":"…"} 이 들어 있습니다. request.clone() 덕분입니다.

  3. 카운트다운이 다시 15 근처로 돌아갑니다. 제목의 닉네임도 바뀌었습니다.

  4. 세션 목록을 보려면 F5 를 누릅니다. 이 기기 줄의 "마지막 사용" 이 방금 시각으로 바뀌었습니다. 7장의 세션 목록 쿼리가 family 에서 가장 최근에 만들어진 행의 시각을 쓰기 때문에, 회전할 때마다 이 값이 갱신됩니다.

ACCESS_TOKEN_TTL_SEC 는 다음 절 실습에서도 15 를 그대로 씁니다. 12.10·12.11 절까지 끝낸 뒤 600 으로 되돌리세요.

12.10 실습 — 탭 두 개 (S21)

13장에서 돌릴 검증 시나리오 중 S21 입니다. "탭 두 개가 거의 동시에 만료된 토큰으로 API 를 부르면, 둘 다 성공하고 재사용 탐지가 일어나지 않아야 한다" 는 것입니다.

기본 확인

  1. 로그인된 상태에서 내 정보 탭을 하나 더 엽니다(탭 A, 탭 B). 같은 브라우저이니 두 탭은 같은 쿠키, 즉 같은 세션(family) 을 씁니다. 세션 목록에 "이 기기" 가 한 줄만 있는 것을 확인하세요.
  2. 두 탭의 카운트다운이 모두 음수가 될 때까지 기다립니다.
  3. 탭 A 에서 "닉네임 변경" 을 누르고, 곧바로 탭 B 로 옮겨 "닉네임 변경" 을 누릅니다.
  4. 두 탭 모두 닉네임이 바뀌고 카운트다운이 되살아납니다. 어느 탭도 로그인 화면으로 가지 않습니다.

DB 에서 이 family 의 행들을 봅니다.

$ cd ~/Workspace/auth-lab/api
$ pnpm db:psql
SELECT family_id, left(token_hash, 8) AS hash, revoke_reason, replaced_by IS NOT NULL AS replaced,
       expires_at, session_expires_at
  FROM refresh_tokens ORDER BY created_at;

예시 출력(마지막 몇 줄):

 family_id | hash     | revoke_reason | replaced | …
-----------+----------+---------------+----------+---
 3f1c…     | 9a0b1c2d | rotated       | t        |
 3f1c…     | 4e5f6a7b | rotated       | t        |
 3f1c…     | c8d9e0f1 |               | f        |

같은 family 에서 rotated 가 사슬처럼 이어지고, 살아 있는 행(revoke_reason 이 비어 있는 행)이 하나입니다. reuse_detected 는 없어야 합니다.

솔직히 말하면, 3번처럼 손으로 탭을 옮겨 가며 누르면 두 refresh 가 실제로 겹치는 일은 드뭅니다. refresh 한 번은 수십 밀리초면 끝나는데 사람이 탭을 옮기는 데는 그보다 오래 걸리니까요. 잠금이 정말로 일하는 모습을 보려면 두 탭이 같은 순간에 refresh 하게 만들어야 합니다.

잠금이 일하는 것을 보기 — 겹치게 만들기

12.5절의 BroadcastChannel 을 이용하면 됩니다. 한 탭에서 로그인하면 다른 모든 탭이 같은 순간에 메시지를 받아 refreshSession() 을 부릅니다.

  1. 탭 세 개(A, B, C)를 엽니다. 개발자 도구의 Application 탭에서 localhost 의 쿠키를 모두 지운 뒤 세 탭을 새로 고칩니다. 세 탭 모두 안내 문구 없는 로그인 화면입니다.
  2. 탭 B 와 C 에서 개발자 도구의 Network 탭을 각각 열어 둡니다.
  3. 탭 A 에서 로그인합니다.

탭 B 와 C 가 거의 동시에 login 메시지를 받고, 둘 다 refresh 를 시도합니다. 잠금이 있으니 둘은 순서대로 처리되고, 두 탭 모두 내 정보 화면으로 넘어갑니다. 중요한 것은 두 탭 모두 200 으로 끝나고, 409 가 없다는 것입니다. DB 에는 로그인으로 생긴 행 하나와 회전 두 번이 사슬로 남습니다.

이번에는 잠금을 잠시 빼고 같은 실험을 해 봅니다. refreshSession 을 11장 버전으로 되돌립니다.

// web/src/auth/session.ts (실험용 — 확인 뒤 되돌린다)
export function refreshSession(): Promise<boolean> {
  refreshing ??= runRefresh(false)
    .catch(() => {
      // API 에 닿지 못했다(서버 꺼짐·네트워크). 부팅 중이면 "세션 확인 중" 에 멈추지 않게 익명으로 둔다
      if (state.status === 'unknown') dropSession();
      return false;
    })
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}

1~3을 되풀이하면(쿠키를 지우고 세 탭을 새로 고치는 것부터), 결과가 매번 조금씩 다르게 나옵니다. Network 탭에 409 가 섞여 보이고, 타이밍이 나쁘면 두 탭 중 하나가 로그인 화면에 남습니다(예시).

[탭 B]                                   [탭 C]
refresh   POST   200                     refresh   POST   409    ← B 가 먼저 회전시켰다
                                         refresh   POST   409    ← B 의 새 쿠키가 아직 안 왔다
                                                                    → 재시도도 실패 → 로그인 화면에 남는다

두 탭이 같은 리프레시 쿠키 R 을 들고 거의 동시에 서버에 닿았습니다. 먼저 도착한 B 가 R 을 회전시키고, 늦게 온 C 는 grace 안이라 409 를 받습니다. C 는 한 번 재시도하지만, 그 재시도가 B 의 응답(새 쿠키)이 브라우저에 도착하기 전에 나가면 다시 옛 쿠키 R 을 싣고 가서 또 409 를 받습니다. 재시도는 한 번뿐이니 C 는 포기합니다. 서버의 grace 는 안전망일 뿐 "반드시 성공" 을 약속하지 않습니다. 뿌리는 하나입니다. 쿠키는 하나인데 그 쿠키를 다루는 탭은 여럿이고, 탭끼리 순서를 맞추지 않았다.

잠금을 넣으면 runRefresh 전체가 잠금 안에서 돕니다. B 가 refresh 를 끝내고 새 쿠키가 저장된 뒤에야 C 가 요청을 보내므로, C 는 처음부터 새 쿠키를 싣고 갑니다. 확인했으면 refreshSession 을 잠금 버전으로 되돌립니다.

💡 CSRF 토큰은 탭마다 따로 받는데 왜 어긋나지 않을까요? 12.3절의 표처럼 csrf_token 쿠키는 탭끼리 공유되고 csrfToken 변수는 탭마다 따로입니다. 서버가 GET /auth/csrf 마다 새 값을 만든다면 가장 나중에 받은 탭만 쿠키와 짝이 맞게 됩니다. 그래서 9장의 issueCsrf 는 이미 유효한 쿠키가 있으면 그 값을 그대로 돌려줍니다. 모든 탭이 같은 값을 받으니 짝이 어긋나지 않습니다. 그래도 쿠키가 만료되는 등으로 어긋나면, withCsrf 가 토큰을 새로 받아 한 번 더 보냅니다. login·logout·refresh 가 모두 withCsrf 를 거치는 이유입니다.

12.11 실습 — 로그아웃 동기화 (S22)

  1. 로그인된 상태에서 내 정보 탭을 두 개 엽니다(탭 A, 탭 B).
  2. 탭 A 에서 "로그아웃" 을 누릅니다.
  3. 탭 B 로 옮깁니다. 이미 로그인 화면이고, "로그아웃했습니다." 가 보여야 합니다. 탭 B 에서 아무것도 누르지 않았는데도 그렇습니다.

탭 A 의 Network 탭에서 logout 이 204 인지 반드시 확인하세요. 204 라면 서버가 family 를 폐기하고 쿠키를 지운 것입니다. 이어서 탭 B 에서 F5 를 누르면 refresh 가 401 이고 로그인 화면이 그대로입니다. 쿠키가 없으니까요.

logout 이 204 가 아니라면(API 가 꺼져 있었다든지) 주의하세요. logout 함수는 서버 응답과 상관없이 dropSession 과 postMessage 를 부르므로 화면은 두 탭 모두 로그아웃된 것처럼 보이지만 서버의 세션은 살아 있을 수 있습니다. 이 동작의 위험과 고치는 방법은 연습문제 5에서 다룹니다.

"모든 기기에서 로그아웃" 도 같은 방법으로 확인합니다. 탭 A 에서 누르면 탭 B 도 로그인 화면으로 갑니다. 이 버튼은 Bearer 로 인증하므로 CSRF 토큰이 필요 없습니다. 다른 기기에서의 효과를 보려면 시크릿 창에서 따로 로그인해 두었다가(시크릿 창은 쿠키가 따로라 다른 기기처럼 동작합니다), 누른 뒤 시크릿 창에서 카운트다운이 음수가 되도록 기다렸다가 "닉네임 변경" 을 눌러 보세요. refresh 가 401 이 되어 "세션이 만료됐습니다." 와 함께 로그인 화면으로 갑니다.

실습이 끝나면 api/.env 의 ACCESS_TOKEN_TTL_SEC 를 600 으로 되돌리고 API 를 다시 띄웁니다.

12.12 CSRF 실습실을 FE 에서 — LabPage

마지막으로 8·9장에서 curl 과 공격 페이지로 했던 CSRF 실습을 앱 화면에서 다시 해 봅니다. 실습 엔드포인트(/lab/*)는 8장에서 만든 일부러 취약한 구성입니다. 액세스 토큰을 쿠키(lab_session)로 받고, 그 쿠키만으로 닉네임을 바꿉니다.

// web/src/pages/LabPage.tsx
import { useEffect, useState, type FormEvent } from 'react';
import { ensureCsrf } from '../auth/session';

const API = import.meta.env.VITE_API_URL;
type LabSettings = { sameSite: string; checkOrigin: boolean; checkToken: boolean };

export function LabPage() {
  const [settings, setSettings] = useState<LabSettings | null>(null);
  const [nickname, setNickname] = useState<string>('(로그인 전)');
  const [log, setLog] = useState<string[]>([]);
  const write = (line: string) => setLog((prev) => [`${new Date().toLocaleTimeString()} ${line}`, ...prev]);

  async function loadMe() {
    const res = await fetch(`${API}/lab/me`, { credentials: 'include' });
    setNickname(res.ok ? (await res.json()).nickname : `(쿠키 없음 — ${res.status})`);
  }

  useEffect(() => {
    void fetch(`${API}/lab/settings`).then(async (res) => setSettings(await res.json()));
    void loadMe();
  }, []);

  async function onLogin(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    const res = await fetch(`${API}/lab/login`, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: form.get('email'), password: form.get('password') }),
    });
    write(`실습 로그인 → ${res.status}`);
    await loadMe();
  }

  async function onLegitRename() {
    const res = await fetch(`${API}/lab/nickname`, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': await ensureCsrf() },
      body: JSON.stringify({ nickname: '정상변경' }),
    });
    write(`정상 요청(이 페이지에서) → ${res.status}`);
    await loadMe();
  }

  return (
    <section style={{ display: 'grid', gap: 16 }}>
      <h1>CSRF 실습실</h1>
      <p>
        현재 설정: SameSite=<b>{settings?.sameSite}</b> · Origin 검사 <b>{String(settings?.checkOrigin)}</b> ·
        토큰 검사 <b>{String(settings?.checkToken)}</b>
        <br />
        <small>설정은 api/.env 의 LAB_* 값을 바꾸고 API 를 다시 띄워 바꿉니다.</small>
      </p>
      <form onSubmit={onLogin} style={{ display: 'flex', gap: 8 }}>
        <input name="email" type="email" placeholder="이메일" required />
        <input name="password" type="password" placeholder="비밀번호" required />
        <button>실습 쿠키로 로그인</button>
      </form>
      <p>
        지금 닉네임: <b>{nickname}</b> <button onClick={() => void loadMe()}>다시 읽기</button>
      </p>
      <button onClick={() => void onLegitRename()}>이 페이지에서 정상적으로 닉네임 바꾸기</button>
      <ol>
        <li>위에서 실습 쿠키로 로그인합니다.</li>
        <li>
          같은 브라우저 새 탭에서 <code>http://localhost:5174</code> 와 <code>http://127.0.0.1:5174</code> 를 차례로 엽니다.
        </li>
        <li>이 탭으로 돌아와 "다시 읽기" 를 눌러 닉네임이 "털렸음" 으로 바뀌었는지 봅니다.</li>
      </ol>
      <pre>{log.join('\n')}</pre>
    </section>
  );
}

이 화면은 openapi-fetch 가 아니라 평범한 fetch 를 씁니다. /lab/* 도 계약 파일에 들어 있지만, 응답 DTO 를 따로 만들지 않은 교육용 엔드포인트라 타입의 이점이 크지 않고, 무엇보다 요청이 어떻게 생겼는지를 코드에서 한눈에 보이게 하려는 것입니다. credentials: 'include' 가 있어야 lab_session 쿠키가 오가는 것, 정상 요청에는 X-CSRF-Token 헤더가 붙는다는 것이 그대로 드러납니다. 정상 요청이 ensureCsrf 를 쓰므로, 이 화면은 본 앱과 같은 CSRF 토큰을 씁니다.

App.tsx 에 링크와 라우트를 더하면 부록 C 의 최종 모습이 됩니다.

// web/src/App.tsx
import { Link, Route, Routes } from 'react-router';
import { useBootSession } from './auth/hooks';
import { RequireAuth } from './auth/RequireAuth';
import { LabPage } from './pages/LabPage';
import { LoginPage } from './pages/LoginPage';
import { MePage } from './pages/MePage';
import { SignupPage } from './pages/SignupPage';

export function App() {
  useBootSession();
  return (
    <main style={{ maxWidth: 640, margin: '40px auto', fontFamily: 'system-ui', padding: '0 16px' }}>
      <nav style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
        <Link to="/">내 정보</Link>
        <Link to="/login">로그인</Link>
        <Link to="/signup">가입</Link>
        <Link to="/lab">CSRF 실습실</Link>
      </nav>
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route path="/signup" element={<SignupPage />} />
        <Route path="/lab" element={<LabPage />} />
        <Route element={<RequireAuth />}>
          <Route path="/" element={<MePage />} />
        </Route>
      </Routes>
    </main>
  );
}

/lab 은 RequireAuth 밖에 둡니다. 실습실은 본 앱의 로그인과 상관없이 자기 쿠키(lab_session)로 로그인하기 때문입니다.

8·9장 실습 다시 하기

api/.env 의 LAB_* 값을 바꿔 가며 API 를 다시 띄우고, http://localhost:5173/lab 에서 다음을 해 봅니다. 공격 페이지는 8장처럼 evil/ 에서 python3 -m http.server 5174 로 띄웁니다.

설정localhost:5174 공격127.0.0.1:5174 공격"정상적으로 바꾸기"
SameSite=none, Origin·토큰 검사 끔성공 ("털렸음")성공200
SameSite=strict성공 (같은 사이트)실패 (쿠키가 안 실림)200
+ LAB_CHECK_ORIGIN=true실패 403실패200
+ LAB_CHECK_TOKEN=true실패 403실패200

표의 모든 칸이 9장에서 curl 과 브라우저로 확인한 것과 같습니다. 달라진 것은 오른쪽 열입니다. 우리 앱의 정상 요청은 어떤 설정에서도 통과합니다. 같은 사이트에서 오고(SameSite), 허용된 출처에서 오고(Origin 검사), 서버가 서명한 CSRF 토큰을 헤더에 싣기(토큰 검사) 때문입니다. 세 겹의 방어가 공격만 막고 정상 사용자는 막지 않는다는 것을 한 화면에서 확인할 수 있습니다.

두 번째 줄의 왼쪽 칸 "성공(같은 사이트)" 을 다시 새겨 두세요. localhost:5174 는 localhost:5173 과 같은 사이트라 SameSite=Strict 로는 막히지 않습니다. 운영에서는 같은 등록 도메인 아래의 다른 서브도메인이 이 자리에 섭니다. SameSite 가 단독 방어가 될 수 없는 이유입니다.

확인하기

ACCESS_TOKEN_TTL_SEC=15 로 둔 채 1~5를 확인하고, 끝나면 600 으로 되돌린 뒤 6·7을 확인합니다.

  1. 타입 검사 — web/ 에서 pnpm exec tsc -b 가 오류 없이 끝납니다.
  2. 자동 갱신 — 카운트다운이 음수일 때 "닉네임 변경" 을 누르면 Network 에 PATCH 401 → refresh 200 → PATCH 200 이 보이고, 두 PATCH 의 Authorization 값이 다릅니다.
  3. 동시 401 — 카운트다운이 음수인 상태에서 가입 링크로 나갔다가 내 정보 링크로 다시 들어옵니다. sessions 요청(StrictMode 로 두 번)이 401 을 받아도 refresh 는 한 번입니다.
  4. 탭 두 개 (S21) — 12.10절의 "겹치게 만들기" 로 탭 B·C 가 모두 로그인되고, DB 에 reuse_detected 행이 없습니다.
  5. 로그아웃 동기화 (S22) — 12.11절의 절차로 탭 A 의 로그아웃 뒤 탭 B 가 즉시 로그인 화면이 되고, 탭 A 의 logout 이 204 입니다.
  6. 세션 목록 — 시크릿 창에서 따로 로그인한 뒤 일반 창의 내 정보 를 새로 고치면 세션이 두 줄이고 👉 이 기기 가 정확히 하나입니다. 다른 줄의 "끊기" 를 누르면 목록에서 사라지고, 시크릿 창에서 F5 를 누르면 refresh 가 401 이 되어 로그인 화면으로 갑니다.
  7. 실습실 — /lab 에서 12.12절 표의 첫 줄과 마지막 줄을 재현합니다.

자주 하는 실수

재시도한 PATCH 가 TypeError: … body is already used 로 실패한다 request.clone() 을 fetch 뒤에 부르고 있습니다. 본문 스트림은 보내는 순간 소비됩니다. 복제는 반드시 보내기 전에 합니다.

401 뒤에 refresh 와 재시도가 끝없이 반복된다 재시도한 요청을 다시 authFetch 로 보내고 있거나, 재시도 결과가 401 일 때 또 refresh 하도록 고쳤습니다. 재시도는 전역 fetch 로 한 번만 합니다.

refresh 요청이 영원히 끝나지 않는다 (Pending 으로 남는다) /auth/refresh 를 authFetch 가 끼워진 api 로 부르고 있습니다. refresh 가 401 을 받으면 자기 자신을 기다리게 됩니다. /auth/csrf·login·refresh·logout 은 authApi 로 부릅니다.

Cannot access 'authFetch' before initialization authFetch 를 화살표 함수 상수로 바꿨습니다. client.ts 와 session.ts 는 서로를 import 하므로, authFetch 는 function 선언으로 둡니다.

TypeError: Cannot read properties of undefined (reading 'request') navigator.locks 가 없습니다. 보안 컨텍스트가 아닌 주소(예: http://192.168.0.10:5173)로 열었습니다. http://localhost:5173 으로 엽니다.

탭 두 개를 열어 두면 가끔 "다른 곳에서 이 세션의 토큰이 다시 쓰였습니다" 가 뜬다 refreshSession 에 탭 간 잠금이 없거나, 잠금 밖에서 refresh 를 부르는 경로가 있습니다. refresh 는 반드시 refreshSession() 을 거치게 하고, runRefresh 를 직접 부르지 않습니다.

로그인할 때 "로그인하지 못했습니다" 가 뜬다 Network 탭에서 login 의 응답 코드를 봅니다. 403 CSRF_ORIGIN_REJECTED 면 FE 를 WEB_ORIGINS 에 없는 주소(예: 127.0.0.1)로 연 것입니다. 403 CSRF_TOKEN_INVALID 가 두 번 연속이면 withCsrf 의 재시도도 실패한 것이니 API 의 CSRF_SECRET 이나 cookie-parser 설정을 확인합니다(부록 B).

카운트다운이 음수인데 아무 일도 일어나지 않는다 정상입니다. 이 앱은 만료 전에 미리 갱신하지 않고, 다음 API 호출이 401 을 받을 때 갱신합니다. 버튼을 눌러 API 를 부르세요.

실습이 끝난 뒤에도 10분이 아니라 15초마다 refresh 가 일어난다 api/.env 의 ACCESS_TOKEN_TTL_SEC 를 600 으로 되돌리지 않았습니다.

정리

연습문제

1. authFetch 의 첫 줄과 둘째 줄의 순서를 바꿔, 먼저 fetch(withAuth(request)) 를 보내고 401 일 때만 request.clone() 을 하도록 고치면 어떤 일이 생길까요? GET 요청과 PATCH 요청으로 나눠 답해 보세요.

해답

PATCH 처럼 본문이 있는 요청은 fetch 가 본문 스트림을 소비했으므로 그 뒤의 clone() 이 "본문이 이미 사용됐다" 는 TypeError 를 던집니다. 401 을 받은 PATCH 는 재시도는커녕 예외로 끝납니다. GET 은 본문이 없으니 겉보기에는 동작할 수 있지만, 이번에는 withAuth(request) 가 옛 토큰을 이미 헤더에 붙여 둔 객체를 복제하게 됩니다. 재시도에서 withAuth(retry) 가 새 토큰으로 덮어쓰니 결과는 맞지만, 두 경우의 동작이 달라지는 코드는 읽고 고치기 어렵습니다. 보내기 전에, 헤더를 붙이기 전에 복제하는 지금의 순서가 두 경우 모두에서 같은 결과를 냅니다.

2. 동료가 "탭끼리 refresh 가 부딪히는 게 문제라면, 로그인한 탭이 받은 액세스 토큰을 BroadcastChannel 로 다른 탭에 나눠 주면 refresh 자체가 필요 없지 않나?" 라고 묻습니다. 이 방법이 탭 간 잠금을 대신할 수 없는 이유를 설명해 보세요.

해답

토큰을 나눠 주는 방법은 "로그인 직후" 한 순간만 해결합니다. 탭들이 각자 쓰다가 만료된 뒤 동시에 401 을 받는 상황은 그대로 남습니다. 그때 각 탭은 여전히 refresh 를 부르고, 같은 쿠키로 동시에 부르면 12.3절의 충돌이 일어납니다. 이것을 막으려면 결국 "한 탭이 refresh 하는 동안 다른 탭은 기다린다" 는 조정이 필요하고, 그것이 잠금입니다. 게다가 토큰이 탭 사이 메시지로 떠돌면 액세스 토큰이 지나가는 길이 늘어납니다. 이 책은 각 탭이 자기 토큰을 refresh 응답 본문에서만 받게 하고, 잠금으로 순서를 맞추는 쪽을 택했습니다.

3. 12.4절의 잠금 다이어그램에서 탭 B 가 R1 이 아니라 R2 를 들고 가는 것은 "브라우저가 요청을 보내는 순간의 쿠키를 싣는다" 는 사실 덕분입니다. 그렇다면 탭 B 의 refreshSession 을 아래처럼 고치면 무엇이 달라질까요?

refreshing ??= (async () => {
  const csrf = await ensureCsrf();
  return navigator.locks.request('auth-refresh', () => runRefresh(false));
})().finally(() => { refreshing = null; });
해답

리프레시 쿠키에 관해서는 달라지지 않습니다. fetch 는 여전히 잠금 콜백 안의 runRefresh 에서 불리므로, 요청을 보내는 순간 쿠키 저장소에 있는 R2 가 실립니다. 쿠키는 우리 코드가 미리 "읽어 두는" 값이 아니기 때문입니다. 다만 ensureCsrf 가 잠금 밖에서 불리므로, 처음 방문한 여러 탭이 동시에 GET /auth/csrf 를 보내면 쿠키가 아직 없어 서로 다른 토큰이 발급되고 쿠키를 덮어쓰는 경쟁이 생길 수 있습니다. withCsrf 의 재시도가 이것을 수습하긴 하지만, 쿠키를 건드리는 요청은 잠금 안에 두는 편이 단순하고 안전합니다.

4. "모든 기기에서 로그아웃" 을 눌렀는데 휴대폰의 앱은 최대 10분 동안 계속 API 를 부를 수 있습니다. 이 시차를 줄이는 방법 두 가지와 각각의 비용을 말해 보세요.

해답

첫째, 액세스 토큰 수명(ACCESS_TOKEN_TTL_SEC)을 줄입니다. 5분으로 줄이면 시차도 5분이 됩니다. 비용은 refresh 가 그만큼 자주 일어나 DB 쓰기(회전)와 요청이 늘어나는 것입니다. 둘째, 14장에서 다루듯 JwtAuthGuard 에서 토큰의 sid(family id)가 아직 살아 있는지 DB 를 조회합니다. 그러면 폐기가 즉시 반영되지만, 모든 보호된 요청마다 쿼리가 하나씩 늘어납니다. JWT 를 쓰는 이점("서버가 상태를 조회하지 않고 검증한다")을 일부 내려놓는 선택이므로, 계정 정지처럼 즉시 끊어야 하는 요구가 있을 때만 넣습니다.

5. 12.11절에서 본 것처럼 logout 은 서버 응답과 상관없이 화면을 로그아웃 상태로 바꾸고 다른 탭에도 알립니다. 이 동작이 위험한 경우는 언제이고, 어떻게 고칠 수 있을까요?

해답

서버가 logout 을 처리하지 못했을 때(API 가 꺼져 있음, 네트워크 끊김, 설정 오류로 403) 위험합니다. 사용자는 로그아웃했다고 믿지만 서버의 family 와 브라우저의 리프레시 쿠키는 살아 있어서, 누구든 그 브라우저에서 F5 한 번으로 다시 로그인된 화면을 볼 수 있습니다. 공용 컴퓨터라면 심각한 문제입니다.

고치는 방법은 응답을 확인하는 것입니다.

export async function logout(): Promise<boolean> {
  const { response } = await withCsrf((csrf) => authApi.POST('/auth/logout', { headers: { 'X-CSRF-Token': csrf } }));
  if (response.status !== 204) return false; // 화면은 그대로 두고 "로그아웃하지 못했습니다. 다시 시도하세요" 를 띄운다
  dropSession('logout');
  channel.postMessage({ type: 'logout' });
  return true;
}

네트워크 오류로 fetch 자체가 reject 되는 경우도 try/catch 로 잡아 false 를 돌려줍니다. 부록 C 는 "누르면 즉시 화면이 로그아웃된다" 는 단순함을 택했고, 이 연습은 그 선택의 비용을 보여 줍니다. 실무에서는 응답을 확인하는 쪽을 권합니다.