
이 장을 마치면
- 모든 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 의 흐름은 네 줄입니다.
- 보내기 전에 요청을 복제해 둔다.
- 토큰을 붙여 보낸다. 401 이 아니면 그대로 돌려준다.
- 401 이면 refresh 한다. 실패하면 원래의 401 응답을 그대로 돌려준다.
- 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 쿠키 | R1 | R1 | 공유된다 (브라우저의 쿠키 저장소는 하나) |
csrf_token 쿠키 | C | C | 공유된다 |
accessToken 변수 | 토큰 a | 토큰 b | 따로 (탭마다 모듈이 따로 뜬다) |
csrfToken 변수 | 값 | 값 | 따로 |
refreshing 변수 | promise | promise | 따로 |
마지막 줄이 문제입니다. 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장에서 보았듯 둘 다 안전망이지 해결책이 아닙니다.
- 탭 B 의 409 가 탭 A 의 200 보다 먼저 도착하면, B 의 재시도가 아직 R1 을 들고 나가 또 409 를 받습니다. B 는 로그아웃됩니다.
- 탭 B 가 백그라운드에서 멈춰 있다가(브라우저는 보이지 않는 탭의 타이머와 작업을 늦추기도 합니다) 10초 넘게 지나 R1 을 보내면, grace 밖이라 서버는 재사용 탐지로 판단하고 family 전체를 끊습니다. 두 탭 모두 로그아웃되고, 사용자는 "다른 곳에서 이 세션의 토큰이 다시 쓰였습니다" 라는 경고를 봅니다.
두 번째 경우가 특히 나쁩니다. 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 의 동작은 이렇습니다.
'auth-refresh'라는 이름의 잠금을 요청합니다. 기본은 배타적 잠금이라, 같은 이름의 잠금은 한 번에 하나만 쥘 수 있습니다.- 잠금을 얻으면 콜백(
() => runRefresh(false))을 부릅니다. 다른 탭이 쥐고 있으면 풀릴 때까지 기다렸다가 부릅니다. - 콜백이 돌려준 promise 가 끝나면 잠금을 풉니다.
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 탭을 엽니다.
-
"액세스 토큰 남은 시간" 이 15 에서 1초씩 줄어듭니다. 0 을 지나면 음수가 됩니다. -3, -4, … 이 앱은 만료되기 전에 미리 갱신하지 않습니다. 만료된 토큰을 메모리에 쥔 채로 있다가, 다음 요청이 401 을 받을 때 갱신합니다. 음수는 "만료된 토큰을 아직 들고 있다" 는 뜻입니다.
-
음수가 된 상태에서 "닉네임 변경" 을 누릅니다. 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()덕분입니다. -
카운트다운이 다시 15 근처로 돌아갑니다. 제목의 닉네임도 바뀌었습니다.
-
세션 목록을 보려면 F5 를 누릅니다.
이 기기줄의 "마지막 사용" 이 방금 시각으로 바뀌었습니다. 7장의 세션 목록 쿼리가 family 에서 가장 최근에 만들어진 행의 시각을 쓰기 때문에, 회전할 때마다 이 값이 갱신됩니다.
ACCESS_TOKEN_TTL_SEC 는 다음 절 실습에서도 15 를 그대로 씁니다. 12.10·12.11 절까지 끝낸 뒤 600 으로 되돌리세요.
12.10 실습 — 탭 두 개 (S21)
13장에서 돌릴 검증 시나리오 중 S21 입니다. "탭 두 개가 거의 동시에 만료된 토큰으로 API 를 부르면, 둘 다 성공하고 재사용 탐지가 일어나지 않아야 한다" 는 것입니다.
기본 확인
- 로그인된 상태에서
내 정보탭을 하나 더 엽니다(탭 A, 탭 B). 같은 브라우저이니 두 탭은 같은 쿠키, 즉 같은 세션(family) 을 씁니다. 세션 목록에 "이 기기" 가 한 줄만 있는 것을 확인하세요. - 두 탭의 카운트다운이 모두 음수가 될 때까지 기다립니다.
- 탭 A 에서 "닉네임 변경" 을 누르고, 곧바로 탭 B 로 옮겨 "닉네임 변경" 을 누릅니다.
- 두 탭 모두 닉네임이 바뀌고 카운트다운이 되살아납니다. 어느 탭도 로그인 화면으로 가지 않습니다.
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() 을 부릅니다.
- 탭 세 개(A, B, C)를 엽니다. 개발자 도구의 Application 탭에서
localhost의 쿠키를 모두 지운 뒤 세 탭을 새로 고칩니다. 세 탭 모두 안내 문구 없는 로그인 화면입니다. - 탭 B 와 C 에서 개발자 도구의 Network 탭을 각각 열어 둡니다.
- 탭 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)
- 로그인된 상태에서
내 정보탭을 두 개 엽니다(탭 A, 탭 B). - 탭 A 에서 "로그아웃" 을 누릅니다.
- 탭 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을 확인합니다.
- 타입 검사 —
web/에서pnpm exec tsc -b가 오류 없이 끝납니다. - 자동 갱신 — 카운트다운이 음수일 때 "닉네임 변경" 을 누르면 Network 에
PATCH 401→refresh 200→PATCH 200이 보이고, 두PATCH의Authorization값이 다릅니다. - 동시 401 — 카운트다운이 음수인 상태에서
가입링크로 나갔다가내 정보링크로 다시 들어옵니다.sessions요청(StrictMode 로 두 번)이 401 을 받아도refresh는 한 번입니다. - 탭 두 개 (S21) — 12.10절의 "겹치게 만들기" 로 탭 B·C 가 모두 로그인되고, DB 에
reuse_detected행이 없습니다. - 로그아웃 동기화 (S22) — 12.11절의 절차로 탭 A 의 로그아웃 뒤 탭 B 가 즉시 로그인 화면이 되고, 탭 A 의
logout이 204 입니다. - 세션 목록 — 시크릿 창에서 따로 로그인한 뒤 일반 창의
내 정보를 새로 고치면 세션이 두 줄이고👉 이 기기가 정확히 하나입니다. 다른 줄의 "끊기" 를 누르면 목록에서 사라지고, 시크릿 창에서 F5 를 누르면refresh가 401 이 되어 로그인 화면으로 갑니다. - 실습실 —
/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 으로 되돌리지 않았습니다.
정리
authFetch를api의fetch자리에 끼워, 요청이 나가는 순간의 액세스 토큰을 싣고 401 이면 refresh 뒤 한 번만 재시도합니다. 본문은 한 번만 읽히므로 보내기 전에clone()합니다.- 쿠키로 인증하는
/auth/*요청은 재시도 장치가 없는authApi로 부릅니다. 그렇지 않으면 refresh 가 자기 자신을 기다립니다. - single-flight 는 한 탭 안의 동시 401 을 refresh 하나로 모읍니다. 탭 사이에서는 쿠키는 공유되고 메모리는 공유되지 않으므로 single-flight 가 닿지 않습니다.
navigator.locks.request('auth-refresh', …)로 탭 사이의 refresh 를 줄 세웁니다. 브라우저는 요청을 보내는 순간의 쿠키를 싣기 때문에, 잠금을 기다린 탭은 앞 탭이 받은 새 쿠키로 정상 회전합니다. Web Locks 는 보안 컨텍스트에서만 되고,localhost는 보안 컨텍스트입니다.BroadcastChannel로 로그아웃을 알리면 다른 탭이 메모리의 토큰을 즉시 버립니다. 로그인을 알리면 다른 탭이 각자 refresh 로 자기 토큰을 받습니다. 토큰 자체는 채널에 싣지 않습니다.logoutAll은 Bearer 로 인증하므로api를 씁니다. 다른 기기의 액세스 토큰은 만료될 때까지 남습니다.- FE 는 JWT 를 읽기만 하고 검증하지 않습니다.
exp는 화면 표시에만 씁니다.
연습문제
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 는 "누르면 즉시 화면이 로그아웃된다" 는 단순함을 택했고, 이 연습은 그 선택의 비용을 보여 줍니다. 실무에서는 응답을 확인하는 쪽을 권합니다.