aster®

[로그인인증] 문제 해결 (B)

마무리 · 2026-09

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

이 부록은 책을 따라가다 막혔을 때 증상에서 출발해 원인을 찾도록 정리한 것입니다. 각 항목은 같은 순서로 적었습니다.

먼저 아래 표에서 증상을 찾고 해당 절로 가세요. 표에 없는 증상이라도 B.3(쿠키)과 B.4(CORS)를 먼저 읽어 보면 대개 실마리가 나옵니다. 인증 관련 문제의 대부분은 "쿠키가 저장되지 않았다", "쿠키가 요청에 실리지 않았다", "브라우저가 응답을 막았다" 셋 중 하나이기 때문입니다.

증상으로 찾기

증상절
ECONNREFUSED 127.0.0.1:55432, DB 에 연결되지 않음B.1.1
pnpm install 중 argon2 에서 에러B.1.2
Nest 가 뜨지 않고 Node 버전 관련 에러B.1.3
cookieParser is not a function, helmet is not a functionB.1.4
req.cookies 가 undefined, refresh 가 늘 401B.2.1
응답에 Set-Cookie 가 있는데 브라우저에 쿠키가 없다B.3.1
쿠키는 저장돼 있는데 요청에 실리지 않는다B.3.2
로그아웃했는데 쿠키가 남아 있다B.3.3
콘솔에 … has been blocked by CORS policyB.4
POST /auth/refresh 가 계속 401B.5.1
409 REFRESH_TOKEN_ROTATED 가 반복된다B.5.2
개발 중에만 REFRESH_TOKEN_REUSED 로 로그아웃된다B.5.3
액세스 토큰이 발급 직후부터 만료됐다고 나온다B.5.4
403 CSRF_ORIGIN_REJECTED (특히 Swagger 에서)B.6.1
403 CSRF_TOKEN_INVALIDB.6.2
8·9장 공격 실험 결과가 책과 다르다B.6.3
Swagger UI(/docs)가 빈 화면이거나 일부만 보인다B.7.1
import.meta.env.VITE_API_URL 이 undefinedB.8.1
FE 타입이 API 변경을 반영하지 않는다B.8.2
429 TOO_MANY_REQUESTS 로 로그인이 안 된다B.8.3

B.1 설치와 실행

B.1.1 DB 연결이 거부된다

증상. API 를 띄우면 첫 요청에서 Error: connect ECONNREFUSED 127.0.0.1:55432(또는 ::1:55432)가 나옵니다. pnpm db:migrate 가 service "db" is not running 으로 실패하기도 합니다.

원인.

확인.

$ docker compose ps
$ docker compose logs db | tail -20
$ lsof -iTCP:55432 -sTCP:LISTEN
$ grep DATABASE_URL api/.env

docker compose ps 에 db 가 running 이고 포트에 0.0.0.0:55432->5432/tcp 가 보여야 합니다.

해결. 저장소 루트에서 docker compose up -d 로 띄웁니다. 비밀번호를 바꿨다면 볼륨을 지우고 다시 만들어야 합니다. 볼륨 삭제는 DB 의 모든 데이터가 사라지는 작업이니, 실습 데이터라는 것을 확인한 뒤에 docker compose down -v → docker compose up -d → pnpm db:migrate 순서로 합니다(3장).

B.1.2 argon2 설치가 실패한다

증상. pnpm add argon2 나 pnpm install 중에 node-gyp, gyp ERR!, prebuild, make 같은 단어가 들어간 에러가 나거나, 설치는 됐는데 실행할 때 Cannot find module '…/argon2.node' 류의 에러가 납니다.

원인. argon2 는 C 로 된 네이티브 모듈입니다. 보통은 운영체제·CPU·Node 버전에 맞는 미리 빌드된 바이너리가 패키지에 들어 있어 그대로 쓰지만, 맞는 바이너리가 없으면 설치 중에 직접 컴파일을 시도하고, 이때 C/C++ 빌드 도구와 Python 이 필요합니다. 아주 새로운 Node 버전, 흔하지 않은 CPU 아키텍처, Alpine 같은 musl 기반 리눅스에서 이런 일이 생기기 쉽습니다. 또 pnpm 은 버전에 따라 의존성의 설치 스크립트를 기본으로 실행하지 않고 승인을 요구하기도 하므로, 빌드 단계가 조용히 건너뛰어졌을 수도 있습니다. (정확한 동작은 argon2·pnpm 버전마다 다를 수 있습니다. 설치 로그의 경고를 먼저 읽으세요.)

확인.

$ node -v && node -p "process.platform + ' ' + process.arch"
$ pnpm why argon2
$ node -e "require('argon2').hash('x').then(h => console.log(h.slice(0, 30)))"

마지막 명령이 $argon2id$v=19$m=… 로 시작하는 문자열을 찍으면 정상입니다(api/ 안에서 실행).

해결.

B.1.3 Node 버전이 맞지 않는다

증상. pnpm start:dev 에서 SyntaxError, ERR_REQUIRE_ESM, Unsupported engine 경고, 또는 Nest CLI 가 버전 요구를 알리는 메시지를 내고 멈춥니다.

원인. NestJS 12 로 만든 앱을 실행하려면 Node 20.19 이상 또는 22.12 이상이 필요하고, 프로젝트를 만들고 코드를 생성하는 Nest CLI 는 22.22.3 이상, 24.15 이상, 또는 26 이상을 요구합니다(NestJS 공식 마이그레이션 가이드, 이 책이 쓴 버전 기준). 머리말의 준비물 표가 CLI 기준을 적은 이유입니다. Nest CLI(@nestjs/cli)나 다른 개발 도구는 이보다 높은 버전을 요구할 수 있습니다. 정확한 값은 설치된 패키지의 package.json 에 있는 engines 필드가 기준입니다.

확인.

$ node -v
$ node -p "require('@nestjs/core/package.json').engines"
$ node -p "require('@nestjs/cli/package.json').engines"

(api/ 안에서 실행합니다. engines 가 undefined 면 그 패키지는 요구 버전을 적지 않은 것입니다.)

해결. 표시된 요구 범위에 맞는 Node 로 바꿉니다. 여러 버전을 오가야 한다면 버전 관리 도구(nvm, fnm, Volta 등)를 쓰고, api/.nvmrc 같은 파일에 버전을 적어 두면 팀 전체가 같은 버전을 씁니다.

B.1.4 cookieParser is not a function

증상. API 가 뜨자마자 TypeError: (0 , cookie_parser_1.default) is not a function 이나 helmet_1.default is not a function 으로 죽습니다.

원인. cookie-parser 와 helmet(의 CommonJS 빌드)은 module.exports = 함수 모양으로 내보냅니다. main.ts 의 import cookieParser from 'cookie-parser'; 같은 default import 가 이것을 제대로 받으려면 TypeScript 의 esModuleInterop 이 켜져 있어야 합니다. 꺼져 있으면 TypeScript 는 require('cookie-parser').default 를 부르는 코드로 컴파일하고, 그 값은 undefined 입니다. allowSyntheticDefaultImports 만 켜져 있으면 타입 검사는 통과하고 실행할 때만 터지므로 더 헷갈립니다. (최근 TypeScript 는 이 옵션들의 기본값과 허용 범위를 바꾸는 중이라, 스캐폴드가 만든 tsconfig.json 에 따라 증상이 없을 수도 있습니다.)

확인.

$ grep -n -E 'esModuleInterop|allowSyntheticDefaultImports|"module"' api/tsconfig.json
$ grep -n -A2 'cookie_parser' api/dist/main.js

두 번째 명령에서 .default)( 로 부르고 있다면 이 원인입니다.

해결. api/tsconfig.json 의 compilerOptions 에 "esModuleInterop": true 를 넣고 다시 빌드합니다. 부록 C 의 코드는 이 설정을 전제로 합니다. 설정을 바꿀 수 없는 사정이라면 import * as cookieParser from 'cookie-parser'; 처럼 namespace import 로 바꾸는 방법도 있지만, 이 형태로 함수를 부르는 것은 ES 모듈 규칙상 올바르지 않아 도구에 따라 경고가 나옵니다.


B.2 요청 처리

B.2.1 req.cookies 가 undefined 다

증상. 브라우저 개발자 도구에서는 요청에 Cookie: refresh_token=… 이 분명히 실려 있는데, POST /auth/refresh 가 401 REFRESH_TOKEN_INVALID 입니다. 핸들러에서 console.log(req.cookies) 를 찍으면 undefined 입니다.

원인. Express 는 Cookie 헤더를 스스로 파싱하지 않습니다. cookie-parser 미들웨어가 헤더를 읽어 req.cookies 객체를 만들어 줍니다. main.ts 에서 app.use(cookieParser()) 를 빠뜨렸거나, 파일은 고쳤는데 서버가 다시 뜨지 않았습니다.

확인.

$ grep -n 'cookieParser' api/src/main.ts
$ curl -s -i -H 'Origin: http://localhost:5173' -H 'Cookie: refresh_token=dummy' \
    -X POST http://localhost:4000/auth/refresh | head -1

(9장 이후라면 두 번째 요청은 CSRF 검사 때문에 403 이 먼저 나옵니다. 그 경우 CsrfGuard 의 토큰 검사도 req.cookies 가 없어서 항상 실패하므로, 정상 준비 함수(9장)를 써도 CSRF_TOKEN_INVALID 가 계속 난다면 이 원인을 의심합니다.)

해결. main.ts 에 app.use(cookieParser()); 를 넣습니다. 쿠키를 읽는 모든 코드(가드·핸들러)보다 먼저 등록되기만 하면 되므로 helmet() 바로 다음이 자연스럽습니다(3장).


B.3 쿠키

쿠키 문제는 먼저 어느 단계에서 문제가 생겼는지를 가르면 빨리 풀립니다.

  1. 응답에 Set-Cookie 가 있는가? → 개발자 도구 Network → 해당 요청 → Headers 의 응답 헤더
  2. 브라우저가 그 쿠키를 받아들였는가? → 같은 요청의 Cookies 하위 탭(거부된 쿠키에는 노란 경고 아이콘과 이유가 붙습니다), 그리고 Application → Cookies
  3. 다음 요청에 쿠키가 실렸는가? → 그 요청의 Cookies 하위 탭에서 "필터링된 쿠키 표시" 를 켜면 빠진 쿠키와 이유가 보입니다

(위치와 문구는 Chrome 버전에 따라 조금씩 다릅니다.)

B.3.1 Set-Cookie 는 왔는데 쿠키가 저장되지 않는다

증상. 로그인 응답 헤더에 Set-Cookie: refresh_token=… 이 있는데 Application 탭의 쿠키 목록에 없습니다. 새로고침하면 로그인이 풀립니다.

원인.

확인. Network 탭 → 로그인 요청 → Cookies 하위 탭의 "응답 쿠키" 에 경고 아이콘이 있는지, 마우스를 올려 이유를 읽습니다. curl 로는 속성을 확인할 수 있습니다.

$ curl -s -i http://localhost:4000/auth/csrf | grep -i '^set-cookie'

해결. FE·API·Swagger 를 모두 localhost 로 엽니다(부록 C 의 vite.config.ts 가 host: 'localhost' 인 이유). 사내 IP 나 다른 호스트 이름으로 시험해야 한다면 그 환경은 HTTPS 로 띄우거나, 로컬 실험에 한해 COOKIE_SECURE=false 로 둡니다. COOKIE_SECURE=false 는 운영에서 절대 쓰지 않습니다(14장).

B.3.2 쿠키가 요청에 실리지 않는다

증상. Application 탭에는 refresh_token 이 있는데, POST /auth/refresh 요청의 헤더에 Cookie 가 없습니다. 결과는 401 REFRESH_TOKEN_INVALID.

원인.

확인.

$ grep -rn "credentials" web/src
$ grep VITE_API_URL web/.env

브라우저 주소창의 호스트와 VITE_API_URL 의 호스트가 같은 문자열(localhost)인지 눈으로 확인합니다.

해결. 요청에 credentials: 'include' 를 주고, FE 와 API 를 같은 호스트 이름으로 엽니다(11장).

B.3.3 로그아웃했는데 쿠키가 지워지지 않는다

증상. POST /auth/logout 이 204 를 줬는데 Application 탭에 refresh_token 이 그대로 있습니다. 새로고침하면 refresh 가 401 을 받고서야 로그아웃 상태가 됩니다.

원인. 브라우저는 쿠키를 이름 + Domain + Path 로 구분합니다. 쿠키를 지우는 응답(Set-Cookie: refresh_token=; Expires=Thu, 01 Jan 1970 …)의 Path 가 설정할 때와 다르면 브라우저는 다른 쿠키로 보고 원래 쿠키를 지우지 않습니다. res.clearCookie(name) 을 옵션 없이 부르면 Path=/ 로 지우려 하므로 Path=/auth 쿠키는 남습니다. 일부 브라우저는 Secure·SameSite 가 다른 삭제 요청도 무시할 수 있습니다.

확인.

$ curl -s -i -H 'Origin: http://localhost:5173' -H "X-CSRF-Token: $T" -b "$JAR" \
    -X POST http://localhost:4000/auth/logout | grep -i '^set-cookie'

(9장의 준비 함수로 $JAR·$T 를 만든 뒤 실행합니다.) 출력의 Path=/auth 가 있는지, HttpOnly; Secure; SameSite=Strict 가 설정 때와 같은지 봅니다.

해결. 부록 C 의 cookies.ts 처럼 설정과 삭제가 같은 옵션 함수(refreshOptions(config))를 쓰게 합니다. 옵션을 한곳에서 만들면 한쪽만 고치는 실수가 사라집니다(6장).


B.4 CORS 에러

증상. FE 콘솔에 다음과 같은 메시지가 나오고, 요청이 실패합니다.

Access to fetch at 'http://localhost:4000/auth/login' from origin 'http://localhost:5173'
has been blocked by CORS policy: …

원인. 메시지의 뒷부분이 원인을 알려 줍니다. 흔한 것은 다음 넷입니다.

메시지의 뒷부분원인
No 'Access-Control-Allow-Origin' header is presentFE 의 출처가 WEB_ORIGINS 에 없다. 끝의 /, 127.0.0.1 과 localhost, 포트 번호 차이를 확인
Request header field x-csrf-token is not allowedallowedHeaders 에 그 헤더가 없다
The value of the 'Access-Control-Allow-Origin' header … must not be the wildcard '*' when the request's credentials mode is 'include'origin: '*' 와 credentials: 'include' 를 함께 썼다. 쿠키를 쓰는 요청에는 구체적인 출처가 필요하다
… 'Access-Control-Allow-Credentials' header … is ''서버의 credentials: true 가 빠졌다

CORS 에러처럼 보이지만 실제로는 서버가 500 을 냈거나 서버가 꺼져 있는 경우도 있습니다. 에러 응답에 CORS 헤더가 붙지 않는 구성이면 브라우저는 CORS 에러로 보고합니다.

확인. preflight 를 curl 로 흉내 냅니다.

$ curl -s -i -X OPTIONS http://localhost:4000/auth/login \
    -H 'Origin: http://localhost:5173' \
    -H 'Access-Control-Request-Method: POST' \
    -H 'Access-Control-Request-Headers: content-type,x-csrf-token' | grep -i '^access-control'

예상 결과(예시):

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization,X-CSRF-Token

Origin 을 http://evil.test 로 바꾸면 Access-Control-Allow-Origin 이 없어야 정상입니다(13장 S18).

해결. .env 의 WEB_ORIGINS 를 브라우저 주소창의 출처와 글자 하나까지 같게 적고, main.ts 의 enableCors 가 부록 C 와 같은지 확인합니다. origin: true 나 '*' 로 바꿔 에러를 없애지 않습니다. 9장에서 본 것처럼 CSRF 토큰 방어가 통째로 무너집니다.


B.5 토큰과 refresh

B.5.1 refresh 가 계속 401 이다

증상. 로그인은 되는데 POST /auth/refresh 가 매번 401 입니다. 새로고침하면 로그인이 풀립니다.

원인. 응답의 code 로 먼저 가릅니다.

확인.

$ pnpm --dir api db:psql
auth_lab=# SELECT family_id, left(token_hash, 8) AS hash, revoke_reason, expires_at, session_expires_at
auth_lab-#   FROM refresh_tokens ORDER BY created_at DESC LIMIT 5;

가장 최근 행의 revoke_reason 이 비어 있고 expires_at 이 미래여야 refresh 가 가능합니다. 브라우저 쪽에서는 refresh 요청의 Cookies 하위 탭에 refresh_token 이 실렸는지 봅니다.

해결. 원인에 맞게 고칩니다. 쿠키가 실리지 않는 문제가 가장 흔하므로 B.3.2 를 먼저 확인하세요.

B.5.2 409 가 반복된다

증상. 409 REFRESH_TOKEN_ROTATED 를 받고 FE 가 한 번 더 시도했는데 또 409 입니다.

원인. 409 는 "방금(grace 10초 안에) 다른 요청이 이 토큰을 회전시켰으니, 그 요청이 내려 준 새 쿠키로 다시 해라" 는 뜻입니다. 재시도에서도 409 가 나온다면 새 쿠키가 브라우저에 저장되지 않아 옛 쿠키를 다시 보낸 것입니다. 앞선 refresh 응답의 Set-Cookie 가 거부됐거나(B.3.1), 그 응답이 도착하기 전에 재시도가 나갔거나(탭 간 잠금이 없음), FE 가 아닌 곳(curl 스크립트 등)에서 쿠키 저장소를 갱신하지 않고 옛 값을 계속 보내는 경우입니다.

확인. Network 탭에서 연속된 refresh 요청들의 Cookie 값 앞부분을 비교합니다. 같은 값이 두 번 이상 나가고 있다면 이 원인입니다. curl 이라면 -c "$JAR" 로 응답 쿠키를 저장소에 다시 쓰고 있는지 봅니다.

해결. refresh 는 탭 간 잠금(navigator.locks) 안에서 single-flight 로 부르고(12장), 쿠키가 저장되지 않는 원인(B.3.1)을 없앱니다. curl 에서는 모든 요청에 -c "$JAR" -b "$JAR" 를 함께 줍니다.

B.5.3 StrictMode 에서 REFRESH_TOKEN_REUSED

증상. 개발 서버에서만, 새로고침 직후 가끔 "다른 곳에서 이 세션의 토큰이 다시 쓰였습니다" 가 뜨며 로그아웃됩니다. 운영 빌드에서는 재현되지 않습니다.

원인. React 개발 모드의 StrictMode 는 effect 를 두 번 실행합니다. 부팅 refresh 를 single-flight 없이 부르면 두 번째 요청이 옛 쿠키를 들고 나가고, grace 안이면 409, 밖이면 재사용 탐지가 걸립니다. 네트워크가 느리거나 디버거로 멈춘 사이 10초가 지나면 후자가 됩니다. Vite 의 HMR 로 모듈이 다시 실행되며 refreshing 변수가 초기화되는 경우도 비슷한 결과를 낳을 수 있습니다.

확인. Network 탭에서 새로고침 직후 refresh 요청이 두 개 나가는지, 두 요청의 Cookie 값이 같은지 봅니다.

해결. useBootSession 은 refreshSession() 을 부르고, refreshSession() 은 진행 중인 promise 를 재사용해야 합니다(부록 C session.ts 의 refreshing ??=, 12장). StrictMode 를 끄는 것은 해결이 아닙니다. 두 탭이 동시에 refresh 하는 실제 상황도 같은 문제를 일으키기 때문입니다.

B.5.4 JWT 의 exp 가 이상하다 — 시계 차이

증상. 방금 받은 액세스 토큰으로 요청했는데 401 ACCESS_TOKEN_INVALID 가 나오거나, MePage 의 "남은 시간" 이 600초가 아니라 음수나 수천 초로 표시됩니다.

원인. JWT 의 iat·exp 는 발급한 서버의 시계로 적힙니다. 검증하는 쪽(같은 API 라면 문제없음)이나 화면에 남은 시간을 계산하는 쪽(FE 는 사용자 컴퓨터의 시계)이 다른 시계를 쓰면 차이가 생깁니다. 가상 머신·컨테이너가 절전 후 시계를 맞추지 못했거나, 사용자 컴퓨터의 시계가 틀린 경우입니다. 이 책의 검증 옵션에는 시계 오차 허용(clockTolerance)을 두지 않았습니다.

확인.

$ date -u
$ docker compose exec db date -u
$ node -e "console.log(new Date().toISOString())"

그리고 토큰의 본문을 풀어 iat 를 지금 시각(date +%s)과 비교합니다. 몇 초 이상 차이 나면 시계 문제입니다.

해결. 운영체제의 시간 동기화(NTP)를 켭니다. FE 의 남은 시간 표시는 교육용이라 사용자 시계에 기대지만, 실제 만료 판단은 서버가 하고 FE 는 401 을 받으면 refresh 하므로 동작에는 지장이 없습니다. 서버가 여러 대라면 서버 간 시계 차이도 확인합니다.


B.6 CSRF

B.6.1 403 CSRF_ORIGIN_REJECTED

증상. login·refresh·logout 이 403 과 "code": "CSRF_ORIGIN_REJECTED" 를 줍니다.

원인. 요청의 Origin(없으면 Referer 의 출처)이 허용 목록(csrfAllowedOrigins)에 없습니다.

확인.

$ grep -E '^(WEB_ORIGINS|API_ORIGIN|SWAGGER_ENABLED)=' api/.env
$ curl -s -I http://localhost:4000/docs | grep -i referrer-policy

브라우저에서는 실패한 요청의 Headers → Request Headers → Origin 값을 직접 봅니다. 그 값이 허용 목록의 어느 원소와도 글자 하나까지 같지 않으면 거부됩니다.

해결. 설정 값을 주소창의 출처와 맞춥니다. 원인을 가리기 어려우면 9장의 curl 준비 함수로 같은 요청을 보내 서버 설정부터 확인합니다. 어느 경우든 허용 목록에 'null' 을 넣어서는 안 됩니다(9장).

B.6.2 403 CSRF_TOKEN_INVALID

증상. Origin 은 맞는데 403 과 "code": "CSRF_TOKEN_INVALID" 가 나옵니다.

원인. X-CSRF-Token 헤더와 csrf_token 쿠키가 한 쌍이 아닙니다.

확인.

$ curl --version | head -1
$ grep csrf_token "$JAR"
$ curl -s -b "$JAR" -H 'Origin: http://localhost:5173' -H "X-CSRF-Token: $T" \
    -X POST http://localhost:4000/auth/logout -o /dev/null -w '%{http_code}\n'

저장소 파일에 csrf_token 줄이 있고, 마지막 명령이 204 면 짝이 맞는 것입니다. grep 결과의 값과 echo "$T" 가 같은지도 봅니다.

해결. 새 저장소에서 T=$(csrf) 부터 다시 합니다. Swagger 에서는 GET /auth/csrf 를 다시 실행하고 Authorize 값을 새 값으로 교체합니다. FE 는 이 에러를 받으면 토큰을 버리고 다시 발급받아 한 번 재시도합니다(12장, 부록 C 의 runRefresh).

B.6.3 8·9장 공격 실험 결과가 책과 다르다

증상. 방어를 켰는데 공격이 성공하거나, 방어가 없는데 공격이 실패합니다.

원인과 확인.

증상원인확인
설정을 바꿨는데 이전 결과 그대로API 를 다시 띄우지 않았거나, POST /lab/login 을 다시 하지 않아 옛 속성의 쿠키가 남음curl -s localhost:4000/lab/settings, Application 탭의 lab_session SameSite 열
none 인데 127.0.0.1:5174 공격이 401서드파티 쿠키 차단(시크릿 창은 기본 차단)요청의 Cookies 하위 탭 → 필터링된 쿠키와 그 이유
모든 실습 요청이 404 LAB_DISABLEDLAB_MODE 가 true 가 아님grep LAB_ api/.env
닉네임 변경이 400 VALIDATION_FAILED본문이 파싱되지 않음(text/plain), 또는 닉네임이 2~20자 밖요청의 Content-Type 과 본문
공격 탭에 아무것도 안 보임정상. 응답은 숨긴 iframe 으로 가고, helmet 의 frame 관련 헤더가 표시를 막음Network 탭에서 nickname 요청의 상태 코드
localhost:5174 가 디렉터리 목록을 보여 줌evil/ 밖에서 python3 -m http.server 를 실행함서버를 띄운 터미널의 현재 폴더
lab_session 이 저장되지 않음Swagger 를 localhost 가 아닌 주소로 엶주소창

해결. 9장 9.1 의 실험 순서를 처음부터 다시 따라갑니다. 한 번에 한 설정만 바꿉니다.


B.7 Swagger UI

B.7.1 /docs 가 빈 화면이다

증상. http://localhost:4000/docs 가 흰 화면이거나, 틀만 보이고 엔드포인트 목록이 뜨지 않습니다.

원인.

확인. 개발자 도구 콘솔에 Refused to load the script … 나 Refused to apply inline style … Content Security Policy 같은 메시지가 있는지 봅니다.

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/docs
$ curl -s -I http://localhost:4000/docs | grep -i content-security-policy
$ curl -s http://localhost:4000/docs-json | jq '.paths | keys | length'

마지막 명령이 숫자를 찍으면 문서 자체는 정상으로 만들어지고 있는 것입니다.

해결. CSP 위반이라면 콘솔이 알려 주는 막힌 출처를 보고 판단합니다. helmet 에 /docs 경로만 다른 CSP 를 주거나, 개발 환경에서만 CSP 를 완화하는 방법이 있지만 부록 C 의 코드를 바꾸는 일이고 운영의 보안 헤더와 직결되므로 신중하게 결정합니다. 문서만 필요하다면 /docs-json 과 계약 파일(contract/openapi.json)로도 작업할 수 있습니다(10장).


B.8 프론트엔드

B.8.1 VITE_API_URL 이 undefined 다

증상. FE 의 요청이 http://localhost:5173/undefined/auth/csrf 같은 이상한 주소로 나가거나, import.meta.env.VITE_API_URL 을 찍으면 undefined 입니다.

원인.

확인.

$ cat web/.env
$ grep -rn "import.meta.env" web/src

해결. web/.env 에 VITE_API_URL=http://localhost:4000 을 적고 pnpm dev 를 다시 실행합니다(10장). TypeScript 가 import.meta.env.VITE_API_URL 의 타입을 모른다고 하면 Vite 의 vite/client 타입 선언이 포함돼 있는지 확인합니다.

⚠️ VITE_ 변수는 번들에 그대로 들어가 누구나 볼 수 있습니다. API 주소 같은 공개 값만 넣고, 비밀은 절대 넣지 않습니다.

B.8.2 openapi-typescript 가 옛 스키마를 본다

증상. 백엔드에 엔드포인트나 필드를 추가했는데, FE 에서 api.GET('/새경로') 가 타입 에러이거나 새 필드가 타입에 없습니다.

원인. src/api/schema.d.ts 는 생성된 시점의 계약을 담은 파일입니다. 백엔드를 고친 뒤 다시 만들지 않으면 옛 타입이 남습니다. gen:api 는 contract/openapi.json 을 입력으로 쓰므로, 그 파일도 백엔드에서 다시 내보내야 합니다. 두 단계 중 하나만 하면 여전히 옛 타입입니다. gen:api:live 를 쓴다면 실행 중인 API 가 새 코드로 다시 떴는지도 확인합니다.

확인.

$ ls -l contract/openapi.json web/src/api/schema.d.ts
$ jq '.paths | keys' contract/openapi.json
$ git diff --stat contract/openapi.json

해결. 두 단계를 순서대로 다시 합니다.

$ pnpm --dir api openapi:export
$ pnpm --dir web gen:api

그다음 FE 에서 타입 검사를 돌려 깨지는 곳을 고칩니다. 깨지는 곳이 곧 계약이 바뀐 곳입니다(10장). schema.d.ts 는 손으로 고치지 않습니다.

B.8.3 429 TOO_MANY_REQUESTS 로 로그인이 안 된다

증상. 비밀번호가 맞는데도 로그인 응답이 429 입니다.

원인. 로그인 시도 제한은 IP 당 1분 5회이고(5장), 성공·실패를 가리지 않고 셉니다. 13장의 시나리오 스크립트나 curl 실험을 연달아 돌리면 쉽게 넘습니다. CsrfGuard 보다 제한 가드가 먼저 돌기 때문에 CSRF 로 거절된 로그인 요청도 횟수에 들어갑니다(9장). 제한 기록은 메모리에 있어 API 를 다시 띄우면 초기화됩니다.

확인. 응답의 code 가 TOO_MANY_REQUESTS 인지 봅니다. 직전 1분 동안 보낸 로그인 요청 수를 셉니다.

해결. 1분 기다립니다. 실습 중이라면 API 를 다시 띄워도 됩니다. 제한 값을 늘려 문제를 피하지는 않습니다. 운영과 다른 값으로 테스트하게 됩니다.