
이 장을 마치면
- Docker 로 PostgreSQL 17 을 띄우고, 마이그레이션 SQL 로
users·refresh_tokens테이블을 만들 수 있다.- NestJS 프로젝트를 스캐폴드하고, 이 책에 필요 없는 예제 코드를 걷어 낼 수 있다.
- 환경변수를 부팅할 때 한 번에 읽고 검증하는 설정 계층(
AppConfig)을 만들고, 값이 빠지면 서버가 아예 뜨지 않는 것을 확인할 수 있다.pg커넥션 풀을 NestJS 의 의존성 주입에 올리고, 서버가 꺼질 때 풀을 닫을 수 있다.http://localhost:4000/docs에서 아직 비어 있는 Swagger UI 를 띄울 수 있다.
들어가며
1장과 2장에서 우리는 인증이 "서버가 무엇을 기억하는가" 의 문제라는 것, 그리고 이 책이 액세스 토큰(JWT)과 리프레시 토큰(불투명 난수)을 나눠 쓰는 설계를 택한 이유를 살펴봤습니다. 이제부터는 그 설계를 실제 코드로 옮깁니다.
코드를 쓰기 전에 발 디딜 곳부터 만들어야 합니다. 이 장에서 만드는 것은 기능이 하나도 없는 서버입니다. 로그인도 가입도 없습니다. 그런데도 이 장을 한 장으로 따로 떼어 둔 데는 이유가 있습니다. 인증 코드의 버그 가운데 상당수는 인증 로직이 아니라 설정에서 옵니다. 비밀 키가 비어 있는데 서버가 그냥 떠서 빈 문자열로 토큰을 서명하거나, COOKIE_SECURE 를 오타로 적어서 운영에서 쿠키가 평문 HTTP 로 오가거나, CORS 허용 목록에 * 가 들어가 있는 식입니다. 이런 문제는 테스트로 잘 잡히지 않고, 잡히더라도 한참 뒤에야 드러납니다.
그래서 이 장의 목표는 "잘못 설정된 서버는 아예 뜨지 않는다" 를 먼저 보장하는 것입니다. 환경변수를 여기저기서 process.env.X 로 꺼내 쓰는 대신, 부팅 시점에 한 클래스가 전부 읽고 형식을 검사하고 타입이 붙은 값으로 바꿔 둡니다. 나머지 코드는 이 클래스만 봅니다. 4장부터 쓰는 모든 서비스가 이 위에 올라갑니다.
이 장에서 만드는 파일은 다음과 같습니다.
auth-lab/
├── docker-compose.yml
└── api/
├── .env
├── db/migrations/001_init.sql
└── src/
├── main.ts
├── app.module.ts
├── openapi.ts
├── config/app-config.ts
└── database/database.module.ts
3.1 폴더를 만들고 DB 를 띄운다
머리말의 폴더 구조대로 ~/Workspace/auth-lab 을 작업 루트로 씁니다. 책 원고가 들어 있는 curriculum/ 옆에 실습 코드가 자리를 잡습니다.
$ mkdir -p ~/Workspace/auth-lab
$ cd ~/Workspace/auth-lab
docker-compose.yml
데이터베이스는 PostgreSQL 17 을 Docker 로 띄웁니다. 로컬에 PostgreSQL 을 직접 설치해도 되지만, 버전과 포트와 계정을 책과 똑같이 맞추려면 컨테이너가 가장 간단합니다. 작업 루트에 다음 파일을 만듭니다.
# docker-compose.yml
services:
db:
image: postgres:17
environment:
POSTGRES_USER: auth
POSTGRES_PASSWORD: auth
POSTGRES_DB: auth_lab
ports:
- "55432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
몇 가지를 짚고 넘어가겠습니다.
environment 의 세 값은 공식 postgres 이미지가 처음 초기화될 때 읽는 값입니다. 컨테이너가 처음 뜰 때 데이터 디렉터리가 비어 있으면 auth 라는 사용자와 auth_lab 이라는 데이터베이스를 만들어 둡니다. "처음" 이라는 말이 중요합니다. 이미 데이터가 들어 있는 볼륨으로 다시 띄우면 이 값들을 바꿔도 반영되지 않습니다. 비밀번호를 바꿨는데 접속이 안 된다면 대개 이 때문입니다.
ports 의 "55432:5432" 는 호스트의 55432 번 포트를 컨테이너의 5432 번 포트로 연결합니다. 기본 포트인 5432 를 그대로 쓰지 않은 것은 여러분 컴퓨터에 이미 PostgreSQL 이 떠 있을 가능성이 크기 때문입니다. 겹치지 않을 만한 번호를 골랐을 뿐 특별한 의미는 없습니다. 따옴표로 감싼 이유는 YAML 1.1 에 22:22 처럼 콜론으로 이어진 작은 숫자를 60진수 숫자로 읽는 규칙이 있어서입니다. 이 값은 그 규칙에 걸리지 않지만, 포트 매핑은 늘 문자열로 적는 습관을 들이는 편이 안전하고 Docker 문서도 그렇게 권합니다.
volumes 의 pgdata 는 이름 있는 볼륨입니다. 컨테이너를 지웠다가 다시 만들어도 데이터가 남습니다. 실습 중에 DB 를 처음 상태로 되돌리고 싶으면 docker compose down -v 로 볼륨까지 지우면 됩니다.
컨테이너를 띄웁니다.
$ docker compose up -d
$ docker compose ps
예시 출력:
NAME IMAGE COMMAND SERVICE STATUS PORTS
auth-lab-db-1 postgres:17 "docker-entrypoint.s…" db Up 3 seconds 0.0.0.0:55432->5432/tcp
STATUS 가 Up 이고 PORTS 에 55432->5432 가 보이면 됩니다. 컨테이너 이름은 폴더 이름을 따서 붙으므로 여러분 화면과 다를 수 있습니다.
3.2 NestJS 프로젝트를 만든다
API 는 NestJS 로 만듭니다. NestJS 는 모듈·컨트롤러·프로바이더라는 틀 위에 의존성 주입을 얹은 프레임워크입니다. 이 책이 NestJS 를 고른 것은 인증 코드의 구성 요소(설정, DB 풀, 토큰 서비스, 가드)가 각자 무엇에 의존하는지를 코드에 명시적으로 드러내 주기 때문입니다. 누가 비밀 키를 읽는지, 누가 DB 에 쓰는지가 생성자 한 줄에 다 보입니다.
작업 루트에서 Nest CLI 로 프로젝트를 만듭니다. CLI 를 전역으로 설치하지 않고 pnpm dlx 로 한 번만 내려받아 실행합니다.
$ cd ~/Workspace/auth-lab
$ pnpm dlx @nestjs/cli new api --package-manager pnpm --skip-git --no-observe
--package-manager pnpm 은 패키지 매니저를, --no-observe 는 관측(observability) 도구를 붙일지 묻는 질문을 건너뜁니다. --skip-git 은 api/ 안에 따로 git 저장소를 만들지 않게 합니다(저장소는 auth-lab/ 하나로 두는 편이 편합니다).
이 책을 쓰며 Nest CLI 12.0.5 로 직접 만들어 보니, 스캐폴드는 모듈 형식을 묻지 않고 ESM 프로젝트(package.json 의 "type": "module", tsconfig.json 의 "module": "nodenext")를 만들고, 테스트 러너로 Vitest 를 넣습니다. 이 책의 코드는 CommonJS 기준이므로 두 줄을 바꿉니다.
$ cd api
package.json에서"type": "module",줄을 지웁니다.tsconfig.json의"module": "nodenext"를"module": "node20"으로 바꿉니다."moduleResolution": "nodenext"는 그대로 둡니다.
module: node20 은 "package.json 에 type 이 없으면 CommonJS 로 컴파일하되, ESM 으로만 배포되는 패키지도 require 로 불러올 수 있다" 는 설정입니다. NestJS 12 의 패키지들은 ESM 으로 배포되는데, Node.js 20.19+/22.12+ 는 CommonJS 코드에서 ESM 패키지를 require 할 수 있어서 이 조합이 동작합니다. 스캐폴드의 src/main.ts 는 ESM 전용 문법(최상위 await, ./app.module.js 확장자)을 쓰지만 곧 이 책의 main.ts 로 바꿔 쓰므로 신경 쓰지 않아도 됩니다.
💡 CommonJS 로 바꾸는 이유. ESM 으로 컴파일하면 Node.js 규칙상 상대 경로 import 에 ./app.module.js 처럼 확장자를 붙여야 합니다. 이 책의 코드는 import { AppModule } from './app.module'; 처럼 확장자 없이 씁니다. 인증을 배우는 책에서 모듈 시스템 설정과 씨름할 이유는 없으니 CommonJS 로 갑니다. ESM 그대로 가고 싶다면 모든 상대 import 에 .js 를 붙이면 됩니다. 나머지 코드는 같습니다.
스캐폴드가 끝나면 api/ 폴더가 생깁니다. 안을 보면 대략 이런 모양입니다.
api/
├── src/
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ └── main.ts
├── test/
│ └── app.e2e-spec.ts
├── nest-cli.json
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── vitest.config.ts
└── vitest.config.e2e.ts
정확한 파일 목록은 CLI 버전에 따라 조금 다를 수 있습니다. 여기서 확인할 것은 tsconfig.json 에 experimentalDecorators 와 emitDecoratorMetadata 가 켜져 있는지입니다. NestJS 의 의존성 주입은 TypeScript 가 컴파일할 때 남겨 주는 생성자 매개변수의 타입 정보(데코레이터 메타데이터)를 읽어서 동작합니다. 이 옵션이 꺼져 있으면 constructor(private readonly config: AppConfig) 같은 코드가 무엇을 주입해야 할지 모르게 됩니다. 스캐폴드는 기본으로 켜 두므로 지우지만 않으면 됩니다.
예제 코드를 걷어 낸다
스캐폴드는 GET / 에 Hello World! 를 돌려주는 AppController 와 AppService 를 만들어 둡니다. 이 책에서는 쓰지 않으므로 지웁니다.
$ rm src/app.controller.ts src/app.service.ts src/app.controller.spec.ts test/app.e2e-spec.ts
test/app.e2e-spec.ts 는 GET / 가 Hello World! 를 돌려주는지 확인하는 예제라 같이 지웁니다. e2e 테스트는 13장에서 이 책의 시나리오로 새로 씁니다. vitest.config.e2e.ts 는 남겨 둡니다. 13장에서 그대로 씁니다.
src/app.module.ts 는 지운 두 클래스를 가리키고 있으니 곧 다시 씁니다. 그 전에 패키지부터 설치합니다.
3.3 패키지를 설치한다
3장부터 5장까지 쓰는 패키지를 한 번에 설치합니다. 장마다 나눠서 설치해도 되지만, 한 번에 받아 두면 뒤에서 흐름이 끊기지 않습니다.
$ pnpm add @nestjs/config @nestjs/jwt @nestjs/swagger @nestjs/throttler \
pg argon2 cookie-parser helmet class-validator class-transformer
$ pnpm add -D @types/pg @types/cookie-parser
각 패키지가 어디에 쓰이는지 정리해 둡니다. 버전은 머리말의 표를 기준으로 합니다.
| 패키지 | 쓰는 곳 | 처음 쓰는 장 |
|---|---|---|
@nestjs/config | .env 파일을 읽어 ConfigService 로 제공 | 3장 |
@nestjs/swagger | 코드의 데코레이터에서 OpenAPI 문서를 만들고 Swagger UI 를 띄움 | 3장 |
pg | PostgreSQL 드라이버. 커넥션 풀 | 3장 |
helmet | 보안 관련 응답 헤더를 한 번에 설정 | 3장 |
cookie-parser | 요청의 Cookie 헤더를 req.cookies 객체로 파싱 | 3장 (실제로 읽는 것은 6장부터) |
class-validator · class-transformer | 요청 본문(DTO)의 형식 검사와 변환 | 3장 (ValidationPipe), 4장 |
argon2 | 비밀번호 해싱 | 4장 |
@nestjs/jwt | JWT 서명·검증 | 5장 |
@nestjs/throttler | 로그인 시도 횟수 제한 | 5장 |
@types/pg 와 @types/cookie-parser 는 두 패키지가 자체 타입 정의를 싣고 있지 않아서 따로 받는 타입 정의입니다. 실행에는 필요 없으므로 개발 의존성(-D)으로 넣습니다.
⚠️ argon2 는 C 로 짠 네이티브 모듈입니다. pnpm(이 책은 11.20 을 썼습니다)은 의존성의 설치 스크립트를 기본으로 막기 때문에, 설치 끝에 이런 줄이 나옵니다.
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @scarf/scarf@1.4.0, argon2@0.45.1
그리고 api/pnpm-workspace.yaml 에 "true 나 false 로 정하라" 는 자리가 생깁니다. argon2 는 허용하고, @scarf/scarf(일부 패키지가 넣어 둔 사용 통계 수집 스크립트)는 막습니다.
# api/pnpm-workspace.yaml
allowBuilds:
'@scarf/scarf': false
argon2: true
저장한 뒤 pnpm install 을 한 번 더 하면 argon2 의 설치 스크립트가 돌고 경고가 사라집니다. 이 결정을 해 두지 않으면 pnpm exec 같은 명령이 실행 전에 설치 상태를 검사하다 실패합니다. (macOS arm64 에서는 설치 스크립트 없이도 패키지 안의 미리 빌드된 바이너리로 argon2 가 동작했습니다. 그래도 결정은 해 두어야 명령이 멈추지 않습니다.)
3.4 환경변수 — .env
API 가 읽을 설정을 api/.env 에 적습니다.
# api/.env
PORT=4000
DATABASE_URL=postgres://auth:auth@localhost:55432/auth_lab
# openssl rand -base64 48 로 만든 값을 넣는다. 둘은 서로 달라야 한다.
JWT_ACCESS_SECRET=change-me-change-me-change-me-change-me-0001
CSRF_SECRET=change-me-change-me-change-me-change-me-0002
JWT_ISSUER=auth-lab-api
JWT_AUDIENCE=auth-lab-web
ACCESS_TOKEN_TTL_SEC=600
REFRESH_TOKEN_TTL_SEC=1209600
SESSION_MAX_AGE_SEC=2592000
REFRESH_REUSE_GRACE_SEC=10
WEB_ORIGINS=http://localhost:5173
API_ORIGIN=http://localhost:4000
COOKIE_SECURE=true
SWAGGER_ENABLED=true
값 하나하나가 2장에서 정한 설계 표와 대응합니다. 한 줄씩 봅시다.
PORT, DATABASE_URL. API 는 4000 번 포트에서 뜨고, 앞에서 띄운 DB 컨테이너(호스트 포트 55432)에 auth/auth 계정으로 접속합니다. DATABASE_URL 은 postgres://사용자:비밀번호@호스트:포트/DB이름 형식의 접속 문자열이고, pg 가 그대로 이해합니다.
JWT_ACCESS_SECRET, CSRF_SECRET. 액세스 토큰을 HS256 으로 서명할 비밀 키와, 9장에서 CSRF 토큰에 HMAC 서명을 붙일 비밀 키입니다. 여기 적힌 change-me-… 는 책의 예시를 재현하기 쉽게 하려고 넣은 자리 표시 값입니다. 여러분 컴퓨터에서는 주석대로 openssl rand -base64 48 을 두 번 실행해 나온 값으로 바꾸는 것을 권합니다. 두 키를 서로 다르게 두는 이유는 한쪽 용도로 만든 서명이 다른 용도에서 유효하게 받아들여지는 일을 원천적으로 막기 위해서입니다. 한 키가 새도 다른 쪽까지 무너지지 않는다는 효과도 있습니다.
JWT_ISSUER, JWT_AUDIENCE. JWT 의 iss(누가 발급했는가)와 aud(누구에게 쓰라고 발급했는가) 클레임에 넣을 값입니다. 5장에서 검증할 때 이 두 값을 고정해 두고, 다른 값이 들어 있는 토큰을 거부하는 실습을 합니다.
*_TTL_SEC, SESSION_MAX_AGE_SEC, REFRESH_REUSE_GRACE_SEC. 시간 값은 모두 초 단위입니다. 액세스 토큰 600초(10분), 리프레시 토큰 1,209,600초(14일), 세션 상한 2,592,000초(30일), 재사용 탐지의 유예 시간(grace) 10초입니다. 이름 끝에 _SEC 를 붙여 단위를 이름에 박아 둔 것은 의도적입니다. 어떤 라이브러리는 밀리초를, 어떤 라이브러리는 초를, 어떤 라이브러리는 '10m' 같은 문자열을 받습니다. 설정 파일에서만큼은 단위를 헷갈릴 여지를 없애 둡니다.
WEB_ORIGINS, API_ORIGIN. 1장에서 다룬 출처(origin) 입니다. WEB_ORIGINS 는 CORS 와 CSRF 검사에서 허용할 프론트엔드 출처 목록이고, 여러 개면 쉼표로 구분합니다. API_ORIGIN 은 API 자신의 출처로, Swagger UI 에서 요청을 보낼 때 필요해집니다(9장).
COOKIE_SECURE, SWAGGER_ENABLED. 켜고 끄는 값입니다. COOKIE_SECURE=true 이면 쿠키에 Secure 속성이 붙습니다. 개발 환경인데도 true 로 두는 것은 브라우저가 http://localhost 를 안전한 출처로 취급해 Secure 쿠키를 받아 주기 때문입니다. 개발과 운영의 쿠키 속성을 똑같이 맞춰 두면 운영에서만 터지는 문제가 줄어듭니다. 이 부분은 6장에서 쿠키를 실제로 내려보낼 때 자세히 봅니다.
💡 부록 C 의 .env 에는 이 밖에 LAB_ 으로 시작하는 네 줄이 더 있습니다. 8장의 CSRF 실습실에서 쓰는 값이라 그 장에서 추가합니다.
⚠️ .env 에는 비밀 키가 들어 있으므로 저장소에 올리지 않습니다. 스캐폴드가 만든 api/.gitignore 에 .env 가 들어 있는지 확인하고, 없으면 한 줄 추가하세요.
3.5 테이블을 만든다 — 001_init.sql
이 책은 ORM 을 쓰지 않습니다(머리말 참조). 테이블도 SQL 파일 하나로 만듭니다. 마이그레이션 도구를 따로 두지 않고, SQL 파일을 컨테이너 안의 psql 에 흘려 넣는 가장 단순한 방법을 씁니다.
-- api/db/migrations/001_init.sql
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
nickname text NOT NULL,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id uuid NOT NULL,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
session_expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz,
revoke_reason text CHECK (revoke_reason IN
('rotated','logout','logout_all','session_deleted','reuse_detected')),
replaced_by uuid REFERENCES refresh_tokens(id),
user_agent text,
ip inet
);
CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens (family_id);
CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens (user_id);
refresh_tokens 테이블은 6장에 가서야 씁니다. 그런데도 지금 같이 만드는 것은 스키마를 한 파일에서 한눈에 보는 편이 이해하기 쉽기 때문입니다. 두 테이블이 어떻게 이어지는지, 어떤 열이 무엇을 위해 있는지는 쓰게 될 장에서 자세히 설명하고, 여기서는 전체 모양만 봅니다.
users 테이블. id 는 UUID 이고 기본값은 gen_random_uuid() 입니다. 이 함수는 PostgreSQL 13 부터 확장 없이 기본으로 들어 있습니다. 순번(serial) 대신 UUID 를 쓰는 이유는 사용자 id 가 JWT 의 sub 클레임으로 밖에 나가기 때문입니다. 순번이면 id 만 보고 가입자 수나 가입 순서를 짐작할 수 있고, 남의 id 를 추측하기도 쉽습니다. email 에는 UNIQUE 제약이 붙어 있습니다. 4장에서 중복 가입을 판정할 때 이 제약을 그대로 이용합니다. password_hash 에는 비밀번호 원문이 아니라 argon2id 해시 문자열이 들어갑니다. 열 이름에 _hash 를 붙여 두면 누가 이 열에 원문을 넣으려 할 때 이름부터 어색해 보입니다. 작지만 효과 있는 장치입니다.
refresh_tokens 테이블. 리프레시 토큰 원문은 저장하지 않고 token_hash(SHA-256)만 저장합니다. family_id 는 로그인 한 번에서 시작된 토큰들의 묶음, 즉 세션을 가리킵니다. revoked_at·revoke_reason·replaced_by 는 7장의 회전과 재사용 탐지에서, user_agent·ip 는 세션 목록 화면에서 씁니다. revoke_reason 의 CHECK 제약은 폐기 사유를 다섯 가지로 못 박아 둡니다. 코드에서 오타가 난 사유 문자열을 넣으려 하면 DB 가 거절합니다.
IF NOT EXISTS. 모든 CREATE 에 붙어 있습니다. 같은 파일을 두 번 실행해도 에러가 나지 않게 하려는 것입니다. 실습 중에 마이그레이션을 여러 번 돌리게 될 텐데, 그때마다 "이미 있다" 는 에러를 보지 않아도 됩니다. 단, 이 방식은 이미 있는 테이블의 열을 바꾸지는 못합니다. 열 정의를 고친 뒤 반영하려면 docker compose down -v 로 DB 를 비우고 다시 실행해야 합니다.
스크립트 두 개
SQL 을 실행하는 명령을 api/package.json 의 scripts 에 추가합니다. 스캐폴드가 만든 기존 스크립트(build, start:dev 등)는 그대로 두고 두 줄만 더합니다.
// api/package.json 의 "scripts" 에 추가
{
"scripts": {
"db:migrate": "cat db/migrations/*.sql | docker compose -f ../docker-compose.yml exec -T db psql -v ON_ERROR_STOP=1 -U auth -d auth_lab",
"db:psql": "docker compose -f ../docker-compose.yml exec db psql -U auth -d auth_lab"
}
}
db:migrate 는 db/migrations/ 의 SQL 파일을 이름순으로 이어 붙여 컨테이너 안의 psql 로 보냅니다. 파일 이름 앞에 001_ 처럼 번호를 붙이는 것은 이 순서를 정하기 위해서입니다. 명령의 각 부분을 풀어 보면 이렇습니다.
docker compose -f ../docker-compose.yml— 이 스크립트는api/안에서 실행되므로, 한 단계 위의 compose 파일을 명시합니다.exec -T db—db서비스 컨테이너 안에서 명령을 실행합니다.-T는 가상 터미널을 붙이지 않는다는 뜻입니다. 파이프(|)로 입력을 넘길 때는 터미널이 없어야 입력이 제대로 흘러 들어갑니다.psql -v ON_ERROR_STOP=1— SQL 하나가 실패하면 거기서 멈추고 실패 코드로 끝나게 합니다. 이 옵션이 없으면psql은 에러를 출력하고도 다음 문장을 계속 실행하고, 마지막에는 성공한 것처럼 끝납니다. 반쯤 적용된 스키마는 찾기 어려운 문제를 만듭니다.
db:psql 은 같은 컨테이너에 대화형으로 접속합니다. 이쪽은 터미널이 필요하므로 -T 가 없습니다. 4장부터 DB 에 무엇이 들어갔는지 확인할 때 계속 씁니다.
실행해 봅니다.
$ pnpm db:migrate
예시 출력:
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
테이블이 생겼는지 봅니다.
$ pnpm db:psql
auth_lab=# \dt
예시 출력:
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+-------
public | refresh_tokens | table | auth
public | users | table | auth
(2 rows)
\q 로 빠져나옵니다. 한 번 더 pnpm db:migrate 를 실행해 보세요. IF NOT EXISTS 덕분에 에러 없이 NOTICE: relation "users" already exists, skipping 같은 알림만 나오고 끝나야 합니다.
3.6 설정 계층 — AppConfig
이제 이 장의 핵심인 설정 클래스를 만듭니다. 전체 코드를 먼저 보고, 부분별로 나눠 설명하겠습니다.
// api/src/config/app-config.ts
import { Global, Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Injectable()
export class AppConfig {
readonly port: number;
readonly databaseUrl: string;
readonly jwtAccessSecret: string;
readonly jwtIssuer: string;
readonly jwtAudience: string;
readonly accessTokenTtlSec: number;
readonly refreshTokenTtlSec: number;
readonly sessionMaxAgeSec: number;
readonly refreshReuseGraceSec: number;
readonly csrfSecret: string;
readonly webOrigins: string[];
readonly apiOrigin: string;
readonly cookieSecure: boolean;
readonly swaggerEnabled: boolean;
constructor(config: ConfigService) {
const str = (key: string) => config.getOrThrow<string>(key);
const num = (key: string) => {
const value = Number(str(key));
if (!Number.isFinite(value)) throw new Error(`${key} 는 숫자여야 한다`);
return value;
};
const secret = (key: string) => {
const value = str(key);
if (value.length < 32) throw new Error(`${key} 는 32자 이상이어야 한다`);
return value;
};
const flag = (key: string) => config.get<string>(key) === 'true';
this.port = num('PORT');
this.databaseUrl = str('DATABASE_URL');
this.jwtAccessSecret = secret('JWT_ACCESS_SECRET');
this.jwtIssuer = str('JWT_ISSUER');
this.jwtAudience = str('JWT_AUDIENCE');
this.accessTokenTtlSec = num('ACCESS_TOKEN_TTL_SEC');
this.refreshTokenTtlSec = num('REFRESH_TOKEN_TTL_SEC');
this.sessionMaxAgeSec = num('SESSION_MAX_AGE_SEC');
this.refreshReuseGraceSec = num('REFRESH_REUSE_GRACE_SEC');
this.csrfSecret = secret('CSRF_SECRET');
this.webOrigins = str('WEB_ORIGINS').split(',').map((o) => o.trim());
this.apiOrigin = str('API_ORIGIN');
this.cookieSecure = flag('COOKIE_SECURE');
this.swaggerEnabled = flag('SWAGGER_ENABLED');
}
/** CSRF Origin 검사 허용 목록. Swagger UI 는 API 자신의 출처에서 요청한다. */
get csrfAllowedOrigins(): string[] {
return this.swaggerEnabled ? [...this.webOrigins, this.apiOrigin] : this.webOrigins;
}
}
@Global()
@Module({
imports: [ConfigModule.forRoot()],
providers: [AppConfig],
exports: [AppConfig],
})
export class AppConfigModule {}
왜 ConfigService 를 바로 쓰지 않는가
@nestjs/config 는 ConfigService 라는 서비스를 줍니다. 어디서든 주입받아 config.get('JWT_ISSUER') 처럼 꺼내 쓸 수 있습니다. 그런데 이 책은 그 위에 AppConfig 라는 클래스를 한 겹 더 씌웠습니다. 이유는 세 가지입니다.
첫째, 실패 시점을 앞당깁니다. ConfigService.get 을 여기저기서 부르면 값이 빠졌다는 사실은 그 코드가 처음 실행될 때에야 드러납니다. JWT_AUDIENCE 가 빠져 있다면 서버는 멀쩡히 뜨고, 누군가 처음 로그인하는 순간 터집니다. 운영에서는 배포하고 한참 뒤일 수 있습니다. AppConfig 는 생성자에서 모든 값을 한 번에 읽으므로, 값 하나라도 빠지거나 형식이 틀리면 서버가 부팅하는 도중에 멈춥니다. 배포 파이프라인이 곧바로 실패를 알려 줍니다.
둘째, 타입이 붙습니다. 환경변수는 전부 문자열입니다. ACCESS_TOKEN_TTL_SEC 는 '600' 이지 600 이 아닙니다. 이것을 쓰는 곳마다 Number(...) 로 바꾸다 보면 어딘가에서 한 번은 빠뜨리고, '600' + 10 이 '60010' 이 되는 사고가 납니다. AppConfig 는 읽는 순간 숫자로 바꿔 number 타입의 필드에 넣어 둡니다. 쓰는 쪽에서는 config.accessTokenTtlSec 가 숫자라는 것을 TypeScript 가 보장합니다.
셋째, 무엇을 설정할 수 있는지가 한 파일에 다 보입니다. 필드 목록이 곧 이 서버의 설정 목록입니다. 새 팀원이 "이 서버를 띄우려면 무엇이 필요한가" 를 물으면 이 파일을 보여 주면 됩니다.
필드 선언
클래스 맨 위의 필드는 전부 readonly 입니다. 설정은 부팅할 때 정해지고 실행 중에는 바뀌지 않아야 합니다. 누군가 실수로 config.cookieSecure = false 를 쓰면 컴파일러가 막아 줍니다.
네 개의 읽기 함수
생성자 안에 작은 함수 네 개가 있습니다. 모든 필드가 이 넷 중 하나를 거쳐서 채워집니다.
str 은 ConfigService.getOrThrow 를 부릅니다. get 과 달리 getOrThrow 는 값이 없으면 undefined 를 돌려주는 대신 예외를 던집니다. 필수 값을 읽는 가장 기본적인 함수입니다.
num 은 str 로 읽은 값을 Number 로 바꾸고 Number.isFinite 로 검사합니다. Number('abc') 는 NaN 이 되는데, NaN 은 에러를 내지 않고 조용히 계산을 오염시킵니다. ACCESS_TOKEN_TTL_SEC=10m 처럼 단위를 붙여 쓰는 흔한 실수가 여기서 잡힙니다.
secret 은 길이가 32자 이상인지 확인합니다. HS256 은 키가 짧아도 동작은 합니다. 하지만 짧은 키는 공격자가 토큰 하나를 손에 넣은 뒤 오프라인에서 키를 추측해 볼 수 있게 만듭니다. 토큰의 서명을 맞춰 보는 데는 서버가 필요 없기 때문입니다. secret, password123 같은 값으로 서명된 JWT 는 실제로 사전 대입으로 풀립니다. 32자라는 기준은 "사람이 손으로 적은 짧은 값은 거부한다" 는 최소한의 안전장치입니다. openssl rand -base64 48 로 만든 값은 64자라 넉넉히 통과합니다.
flag 는 켜고 끄는 값을 읽습니다. 다른 셋과 달리 getOrThrow 가 아니라 get 을 쓰고, 값이 정확히 문자열 'true' 일 때만 true 가 됩니다. 값이 없으면 false 입니다. 'True', '1', 'yes' 도 전부 false 입니다. 너무 엄격해 보일 수 있지만, 이렇게 하면 애매한 값은 언제나 꺼진 쪽으로 해석됩니다. COOKIE_SECURE 를 끄는 쪽이 위험하지 않느냐고 물을 수 있는데, 그 점은 이 장의 연습문제에서 한 번 생각해 봅니다.
값 읽기
필드 채우기는 대부분 설명이 필요 없을 만큼 단순합니다. 눈여겨볼 것은 webOrigins 한 줄입니다.
this.webOrigins = str('WEB_ORIGINS').split(',').map((o) => o.trim());
쉼표로 나눈 뒤 앞뒤 공백을 지웁니다. .env 에 http://localhost:5173, http://localhost:5174 처럼 쉼표 뒤에 공백을 넣어도 동작하게 하려는 것입니다. 이 목록은 CORS 와 CSRF 검사에서 문자열 그대로 비교되므로, 공백 하나가 섞이면 정상 요청이 모두 거부됩니다. 끝에 슬래시(/)를 붙이지 않는 것도 같은 이유입니다. 브라우저가 보내는 Origin 헤더에는 경로가 없어서 http://localhost:5173/ 과는 절대 같아지지 않습니다.
csrfAllowedOrigins
마지막의 게터는 9장에서 쓸 CSRF 출처 허용 목록입니다. Swagger UI 가 켜져 있으면 API 자신의 출처(http://localhost:4000)를 목록에 더합니다. Swagger UI 는 http://localhost:4000/docs 에서 뜨므로, 거기서 보내는 요청의 Origin 은 API 자신의 출처가 되기 때문입니다. 지금은 쓰는 곳이 없지만, "Swagger 를 켜면 허용 목록이 넓어진다" 는 사실이 설정 파일에 드러나 있도록 여기에 둡니다.
AppConfigModule
파일 맨 아래의 모듈은 AppConfig 를 NestJS 에 등록합니다.
imports: [ConfigModule.forRoot()] 가 .env 를 읽어 ConfigService 를 만듭니다. forRoot() 에 아무 옵션도 주지 않으면 현재 작업 디렉터리의 .env 를 읽습니다. 그래서 서버는 반드시 api/ 안에서 실행해야 합니다. 그리고 이미 process.env 에 같은 이름의 값이 있으면 그쪽이 .env 보다 우선합니다. 셸에서 PORT=5000 pnpm start:dev 처럼 일시적으로 값을 덮어쓸 수 있다는 뜻입니다.
@Global() 은 이 모듈이 내보내는 프로바이더를 모든 모듈에서 import 없이 주입받을 수 있게 합니다. NestJS 에서는 원래 다른 모듈의 프로바이더를 쓰려면 그 모듈을 imports 에 넣어야 합니다. 설정은 거의 모든 모듈이 쓰므로 매번 import 하는 대신 전역으로 둡니다. 전역 모듈은 편하지만 의존 관계를 흐리게 만들기 때문에, 이 책에서는 설정과 DB 풀 두 개에만 씁니다.
3.7 DB 커넥션 풀 — DatabaseModule
다음은 PostgreSQL 커넥션 풀을 만드는 모듈입니다.
// api/src/database/database.module.ts
import { Global, Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import { Pool } from 'pg';
import { AppConfig } from '../config/app-config';
export const PG_POOL = Symbol('PG_POOL');
@Global()
@Module({
providers: [
{
provide: PG_POOL,
inject: [AppConfig],
useFactory: (config: AppConfig) => new Pool({ connectionString: config.databaseUrl }),
},
],
exports: [PG_POOL],
})
export class DatabaseModule implements OnApplicationShutdown {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async onApplicationShutdown() {
await this.pool.end();
}
}
커넥션 풀이란
DB 연결을 여는 데는 TCP 연결, 인증, 세션 초기화가 필요해 비용이 큽니다. 요청마다 연결을 새로 열고 닫으면 느립니다. 커넥션 풀은 연결 몇 개를 열어 두고 돌려 씁니다. 쿼리를 보낼 때 풀에서 연결 하나를 빌리고, 끝나면 돌려놓습니다. pg 의 Pool 이 이 일을 합니다. pool.query(...) 를 부르면 빌리고 돌려놓는 과정을 알아서 해 줍니다. 7장에서는 트랜잭션을 위해 연결 하나를 직접 빌려(pool.connect()) 여러 쿼리를 묶는 방법도 씁니다.
PG_POOL 토큰
NestJS 의 의존성 주입은 보통 클래스를 열쇠로 씁니다. constructor(private readonly config: AppConfig) 라고 쓰면 AppConfig 클래스가 열쇠입니다. 그런데 Pool 은 우리가 만든 클래스가 아니고, 설정값을 넣어 직접 생성해야 합니다. 이럴 때는 따로 열쇠를 만들고 그 열쇠에 값을 묶습니다. Symbol('PG_POOL') 이 그 열쇠입니다. 문자열 'PG_POOL' 을 써도 동작하지만, 심볼은 다른 곳에서 우연히 같은 이름을 써도 절대 겹치지 않습니다.
useFactory 와 inject
프로바이더 정의의 useFactory 는 값을 만드는 함수이고, inject 는 그 함수에 넘길 의존성 목록입니다. NestJS 는 AppConfig 를 먼저 만든 뒤 이 함수에 넘겨 Pool 을 만듭니다. DATABASE_URL 을 여기서 다시 process.env 로 읽지 않고 AppConfig 에서 받는 것이 중요합니다. 설정을 읽는 경로가 하나로 모여야 앞 절의 검증이 의미를 가집니다.
💡 new Pool(...) 은 이 시점에 DB 에 접속하지 않습니다. 풀은 첫 쿼리가 올 때 연결을 엽니다. 그래서 DB 가 꺼져 있어도 서버 자체는 뜹니다. DB 접속 실패는 첫 쿼리에서 드러납니다. 10장에서 계약 파일(openapi.json)을 뽑을 때 DB 없이 서버 코드를 돌릴 수 있는 것도 이 덕분입니다.
종료할 때 풀을 닫는다
DatabaseModule 클래스는 OnApplicationShutdown 을 구현합니다. NestJS 앱이 종료될 때 이 메서드가 불리고, 여기서 pool.end() 로 열린 연결을 모두 닫습니다. 이것을 빼먹으면 개발 서버를 재시작할 때마다 이전 프로세스의 연결이 DB 쪽에 한동안 남습니다. main.ts 의 app.enableShutdownHooks() 가 함께 있어야 Ctrl+C 같은 종료 신호에서 이 메서드가 불립니다.
생성자의 @Inject(PG_POOL) 은 클래스가 아닌 열쇠로 주입받을 때 쓰는 문법입니다. 4장부터 만드는 저장소(repository) 클래스들도 모두 이 방식으로 풀을 받습니다. 이 모듈도 @Global() 이므로 저장소가 있는 모듈에서 따로 import 할 필요가 없습니다.
3.8 Swagger 문서 — openapi.ts
API 문서를 만드는 설정을 별도 파일에 둡니다.
// api/src/openapi.ts
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
export function buildOpenApi(app: INestApplication): OpenAPIObject {
const config = new DocumentBuilder()
.setTitle('Auth Lab API')
.setDescription(
[
'로그인 인증 실습 API.',
'',
'**쿠키를 쓰는 요청(login·refresh·logout)은 CSRF 토큰이 필요하다.**',
'1. `GET /auth/csrf` 를 실행해 `csrfToken` 을 복사한다.',
'2. 오른쪽 위 Authorize → `csrf` 에 붙여 넣는다.',
'3. 로그인 응답의 `accessToken` 을 Authorize → `access-token` 에 붙여 넣는다.',
].join('\n'),
)
.setVersion('1.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'access-token')
.addApiKey({ type: 'apiKey', in: 'header', name: 'X-CSRF-Token' }, 'csrf')
.addCookieAuth('refresh_token', { type: 'apiKey', in: 'cookie' }, 'refresh-cookie')
.build();
return SwaggerModule.createDocument(app, config);
}
이 파일은 이 책의 최종 모습 그대로입니다. 설명문(setDescription)에 CSRF 이야기가 들어 있는 것은 9장까지 가야 의미가 생기지만, 문서의 틀은 처음부터 완성해 두는 편이 낫습니다.
DocumentBuilder 는 OpenAPI 문서의 머리 부분(제목, 설명, 버전)과 보안 방식(security scheme) 을 정의합니다. 보안 방식이 세 개 등록되어 있습니다.
| 이름 | 종류 | 뜻 | 쓰는 장 |
|---|---|---|---|
access-token | HTTP Bearer | Authorization: Bearer <JWT> 헤더 | 5장 |
csrf | API 키 (헤더) | X-CSRF-Token 헤더 | 9장 |
refresh-cookie | API 키 (쿠키) | refresh_token 쿠키 | 6장 |
각 이름은 나중에 컨트롤러에서 @ApiBearerAuth('access-token') 처럼 참조합니다. 그러면 Swagger UI 의 해당 엔드포인트에 자물쇠 아이콘이 붙고, 오른쪽 위 Authorize 버튼에 입력한 값이 요청에 자동으로 실립니다. 문서가 곧 테스트 도구가 되는 셈입니다.
문서를 만드는 일을 main.ts 에 두지 않고 함수로 뺀 것은 문서 구성을 한곳에 모아 두기 위해서입니다. 10장에서 이 문서를 파일로 내보낼 때도 같은 함수가 만든 문서를 씁니다.
3.9 부트스트랩 — main.ts 와 app.module.ts
이제 모든 것을 엮습니다. 먼저 루트 모듈입니다. 스캐폴드가 만든 내용을 지우고 다음으로 바꿉니다.
// api/src/app.module.ts
import { Module } from '@nestjs/common';
import { AppConfigModule } from './config/app-config';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [AppConfigModule, DatabaseModule],
})
export class AppModule {}
지금은 설정과 DB 두 모듈뿐입니다. 4장에서 UsersModule·AuthModule 이, 8장에서 LabModule 이 이 배열에 추가됩니다. 컨트롤러가 하나도 없으므로 이 서버에는 아직 엔드포인트가 없습니다.
다음은 진입점인 main.ts 입니다. 이것도 스캐폴드의 내용을 모두 지우고 새로 씁니다.
// api/src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AppConfig } from './config/app-config';
import { buildOpenApi } from './openapi';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(AppConfig);
app.use(helmet());
app.use(cookieParser());
app.enableCors({
origin: config.webOrigins, // 명시적인 배열. true(요청 출처 반사)나 '*' 를 쓰지 않는다
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.enableShutdownHooks();
const document = buildOpenApi(app);
if (config.swaggerEnabled) {
SwaggerModule.setup('docs', app, document, {
swaggerOptions: { persistAuthorization: true, withCredentials: true },
});
}
await app.listen(config.port);
}
void bootstrap();
💡 부록 C 의 main.ts 와 비교하면 세 군데가 빠져 있습니다. ValidationPipe 의 exceptionFactory 와 그 import 는 4장에서 에러 응답 형식을 정할 때, --export-openapi 분기와 writeFileSync import 는 10장에서 계약 파일을 내보낼 때 추가합니다.
위에서부터 차례로 봅니다.
app.get(AppConfig). 앱이 만들어진 뒤 의존성 주입 컨테이너에서 AppConfig 인스턴스를 꺼냅니다. 컨트롤러나 서비스처럼 생성자로 주입받을 수 없는 main.ts 에서는 이렇게 꺼내 씁니다. NestFactory.create 가 모듈을 초기화하는 도중에 AppConfig 의 생성자가 돌기 때문에, 설정 검증에 실패하면 이 줄에 오기도 전에 부팅이 멈춥니다.
helmet(). 보안 관련 응답 헤더 여러 개를 기본값으로 붙입니다. 브라우저가 응답의 콘텐츠 타입을 추측하지 못하게 하는 X-Content-Type-Options: nosniff, HTTPS 만 쓰도록 하는 Strict-Transport-Security, 다른 사이트가 이 페이지를 iframe 으로 싣지 못하게 하는 헤더 등이 들어갑니다. API 서버에는 대부분 크게 상관없어 보이지만, Swagger UI 처럼 HTML 을 내려보내는 경로가 있고, 기본값을 켜 두는 데 드는 비용이 거의 없으므로 켭니다.
cookieParser(). 요청의 Cookie 헤더를 파싱해 req.cookies 객체로 만들어 줍니다. 6장에서 리프레시 토큰 쿠키를, 9장에서 CSRF 쿠키를 읽을 때 필요합니다. 지금은 쓰는 곳이 없지만, 이 한 줄이 빠지면 req.cookies 가 undefined 가 되어 리프레시가 늘 실패합니다. 원인을 찾기 어려운 실수라 처음부터 넣어 둡니다.
enableCors. 1장에서 다룬 CORS 설정입니다. 프론트엔드(localhost:5173)와 API(localhost:4000)는 출처가 다르므로, 브라우저에서 API 를 부르려면 API 가 CORS 로 허락해 줘야 합니다. 네 가지 옵션이 모두 의도를 가지고 있습니다.
origin: config.webOrigins— 허용할 출처를 명시적인 배열로 줍니다. 여기에true를 주면 요청에 온Origin을 그대로 되돌려 주는데, 이러면 사실상 모든 출처를 허용하는 것과 같습니다.'*'는 다음 옵션과 함께 쓸 수 없습니다.credentials: true— 브라우저가 쿠키를 실은 요청의 응답을 읽을 수 있게 합니다. 6장부터 리프레시 토큰 쿠키를 주고받으려면 필요합니다. 브라우저는 이 옵션이 켜진 응답에서Access-Control-Allow-Origin: *를 거부하므로, 허용 목록이 구체적인 출처여야 합니다.methods— 이 API 가 쓰는 메서드만 나열합니다.PUT은 쓰지 않으므로 넣지 않습니다.allowedHeaders— 브라우저가 보낼 수 있는 요청 헤더입니다.Authorization은 액세스 토큰(5장),X-CSRF-Token은 CSRF 토큰(9장)용입니다.
ValidationPipe. 요청 본문을 DTO 클래스에 맞춰 검증하는 전역 파이프입니다. 세 옵션은 4장에서 DTO 를 만들 때 자세히 설명합니다. 한 줄씩만 짚으면, whitelist 는 DTO 에 정의되지 않은 속성을 걷어 내고, forbidNonWhitelisted 는 걷어 내는 대신 요청 자체를 거부하며, transform 은 검증을 통과한 값을 DTO 클래스 인스턴스로 바꿔 컨트롤러에 넘깁니다.
enableShutdownHooks. 프로세스가 종료 신호를 받으면 NestJS 의 종료 훅(onApplicationShutdown 등)을 부르게 합니다. 앞 절의 DatabaseModule 이 풀을 닫으려면 이것이 필요합니다.
Swagger. buildOpenApi 로 문서를 만들고, SWAGGER_ENABLED=true 일 때만 /docs 경로에 UI 를 붙입니다. 운영에서는 끄는 것이 원칙입니다(14장). 두 옵션을 봅니다.
persistAuthorization: true— Authorize 에 입력한 값을 브라우저에 저장해 두어, 새로고침해도 다시 입력하지 않게 합니다. 실습 중에는 편하지만 운영 문서에는 켜지 않는 편이 좋습니다.withCredentials: true— Swagger UI 가 요청을 보낼 때 쿠키를 싣게 합니다. 6장에서 로그인하면 리프레시 토큰 쿠키가 브라우저에 저장되는데, 이 옵션이 있어야 Swagger UI 에서/auth/refresh를 눌렀을 때 그 쿠키가 따라갑니다.
app.listen(config.port). 스캐폴드는 3000 번 포트를 하드코딩해 두었지만, 우리는 AppConfig 의 값을 씁니다.
⚠️ import cookieParser from 'cookie-parser' 처럼 CommonJS 패키지를 기본(default) import 로 불러오려면 TypeScript 의 esModuleInterop 이 켜져 있어야 합니다. 꺼진 상태로 컴파일되면 실행할 때 cookieParser is not a function 같은 에러가 납니다. 스캐폴드의 tsconfig.json 과 여러분이 쓰는 TypeScript 버전의 기본값을 확인하세요.
3.10 서버를 띄운다
개발 서버를 띄웁니다. 스캐폴드가 만든 start:dev 스크립트는 파일이 바뀔 때마다 다시 컴파일하고 재시작합니다.
$ pnpm start:dev
예시 출력:
[Nest] 41234 - 2026. 09. 24. 오후 2:10:31 LOG [NestFactory] Starting Nest application...
[Nest] 41234 - 2026. 09. 24. 오후 2:10:31 LOG [InstanceLoader] AppConfigModule dependencies initialized
[Nest] 41234 - 2026. 09. 24. 오후 2:10:31 LOG [InstanceLoader] ConfigModule dependencies initialized
[Nest] 41234 - 2026. 09. 24. 오후 2:10:31 LOG [InstanceLoader] DatabaseModule dependencies initialized
[Nest] 41234 - 2026. 09. 24. 오후 2:10:31 LOG [NestApplication] Nest application successfully started
브라우저로 http://localhost:4000/docs 를 엽니다. 제목 Auth Lab API 와 설명문, 오른쪽 위의 Authorize 버튼이 보이고, 본문에는 엔드포인트가 하나도 없다는 안내(No operations defined in spec! 같은 문구)가 보이면 성공입니다. Authorize 를 눌러 access-token, csrf, refresh-cookie 세 항목이 보이는지도 확인합니다.
같은 문서를 JSON 으로도 볼 수 있습니다. @nestjs/swagger 는 UI 경로 뒤에 -json 을 붙인 경로로 원본 문서를 내려줍니다.
$ curl -s http://localhost:4000/docs-json | jq '.info.title, (.paths | length), (.components.securitySchemes | keys)'
예시 출력:
"Auth Lab API"
0
[
"access-token",
"csrf",
"refresh-cookie"
]
paths 가 0개입니다. 4장에서 첫 엔드포인트가 생깁니다.
helmet 이 헤더를 붙이는지도 봅니다. 엔드포인트가 없으니 아무 경로나 불러 404 응답의 헤더를 봅니다.
$ curl -si http://localhost:4000/ | head -n 12
예시 출력(일부):
HTTP/1.1 404 Not Found
Content-Security-Policy: default-src 'self';base-uri 'self';…
Cross-Origin-Opener-Policy: same-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
…
설정이 틀리면 부팅이 멈추는가
이 장의 가장 중요한 확인입니다. 서버를 멈추고(Ctrl+C), .env 에서 JWT_ISSUER 줄을 주석 처리한 뒤 다시 띄워 봅니다.
# api/.env
# JWT_ISSUER=auth-lab-api
$ pnpm start:dev
예시 출력:
[Nest] 41301 - … ERROR [ExceptionHandler] TypeError: Configuration key "JWT_ISSUER" does not exist
at ConfigService.getOrThrow (…)
at str (…/src/config/app-config.ts:…)
at new AppConfig (…/src/config/app-config.ts:…)
서버가 뜨지 않고 어떤 키가 없는지 알려 주며 멈춥니다. 에러 문구는 @nestjs/config 버전에 따라 조금 다를 수 있지만, 키 이름이 나오고 부팅이 멈춘다는 점은 같습니다.
값을 되돌리고, 이번에는 비밀 키를 짧게 바꿔 봅니다.
JWT_ACCESS_SECRET=short
예시 출력:
[Nest] 41355 - … ERROR [ExceptionHandler] Error: JWT_ACCESS_SECRET 는 32자 이상이어야 한다
숫자 자리에 단위를 붙인 경우(ACCESS_TOKEN_TTL_SEC=10m)도 해 보세요. ACCESS_TOKEN_TTL_SEC 는 숫자여야 한다 로 멈춰야 합니다. 확인이 끝나면 .env 를 원래대로 돌려 놓습니다.
확인하기
다음이 모두 통과하면 4장으로 넘어갑니다.
| # | 명령 | 기대 결과 |
|---|---|---|
| 1 | docker compose ps (작업 루트에서) | db 서비스가 Up, 0.0.0.0:55432->5432/tcp |
| 2 | pnpm db:migrate (두 번 실행) | 두 번 모두 에러 없이 끝난다. 두 번째는 already exists, skipping 알림만 |
| 3 | pnpm db:psql 후 \dt | users, refresh_tokens 두 테이블 |
| 4 | pnpm start:dev | Nest application successfully started |
| 5 | 브라우저 http://localhost:4000/docs | 제목 Auth Lab API, Authorize 에 보안 방식 3개, 엔드포인트 0개 |
| 6 | curl -s http://localhost:4000/docs-json | jq '.paths | length' | 0 |
| 7 | .env 에서 JWT_ISSUER 를 지우고 재시작 | 키 이름이 담긴 에러로 부팅 실패 |
| 8 | JWT_ACCESS_SECRET=short 로 재시작 | JWT_ACCESS_SECRET 는 32자 이상이어야 한다 로 부팅 실패 |
자주 하는 실수
pnpm db:migrate 가 service "db" is not running 으로 실패한다. DB 컨테이너가 떠 있지 않습니다. 작업 루트(~/Workspace/auth-lab)에서 docker compose up -d 를 먼저 실행합니다. api/ 안에서 docker compose up -d 를 치면 compose 파일을 찾지 못합니다.
접속 문자열이 맞는데 password authentication failed for user "auth" 가 난다. 예전에 다른 비밀번호로 만든 pgdata 볼륨이 남아 있을 가능성이 큽니다. POSTGRES_PASSWORD 는 볼륨이 비어 있을 때 한 번만 적용됩니다. docker compose down -v 로 볼륨을 지우고 다시 띄운 뒤 pnpm db:migrate 를 다시 실행합니다. 이 명령은 DB 의 데이터를 모두 지운다는 점에 주의하세요.
port is already allocated 로 컨테이너가 안 뜬다. 55432 번 포트를 다른 프로세스가 쓰고 있습니다. docker-compose.yml 의 앞 번호와 .env 의 DATABASE_URL 포트를 함께 바꿉니다.
서버를 띄웠는데 설정 키가 없다고 한다. .env 에는 분명히 있다. 서버를 api/ 가 아닌 곳에서 실행했습니다. ConfigModule.forRoot() 는 현재 작업 디렉터리의 .env 를 읽습니다. cd api 후 실행합니다.
WEB_ORIGINS 끝에 / 를 붙였다. http://localhost:5173/ 은 출처가 아닙니다. 지금은 증상이 없지만 11장에서 프론트엔드를 붙이는 순간 모든 요청이 CORS 로 막힙니다. 출처는 스킴·호스트·포트까지만 적습니다.
cookieParser is not a function 또는 helmet is not a function 으로 부팅이 실패한다. CommonJS 패키지를 default import 로 불러왔는데 esModuleInterop 이 꺼져 있을 때 나는 증상입니다. tsconfig.json 의 컴파일 옵션을 확인합니다.
Swagger UI 가 하얀 화면으로 뜬다. 브라우저 개발자 도구의 콘솔을 봅니다. Content Security Policy 위반 메시지가 있다면 helmet 이 붙인 CSP 헤더가 Swagger UI 의 리소스를 막고 있는 것입니다. @nestjs/swagger 와 helmet 버전 조합에 따라 생길 수 있으니 두 패키지의 공식 문서에서 CSP 관련 안내를 확인하세요.
test/ 의 e2e 테스트가 실패한다. 예제 컨트롤러를 지웠으니 당연합니다. 13장에서 새로 씁니다.
정리
이 장에서는 기능이 없는 서버를 만들었습니다. 그러나 이 서버에는 이미 인증 설계의 뼈대가 몇 가지 들어 있습니다.
- DB 는 Docker 로 띄우고, 스키마는 번호 붙은 SQL 파일을
psql에 흘려 넣어 만듭니다.ON_ERROR_STOP=1로 반쯤 적용된 스키마를 막고,IF NOT EXISTS로 여러 번 실행해도 안전하게 했습니다. - 환경변수는
AppConfig한 곳에서 부팅할 때 전부 읽고 검증합니다. 필수 값이 없거나, 숫자가 아니거나, 비밀 키가 짧으면 서버가 뜨지 않습니다. 나머지 코드는 타입이 붙은AppConfig필드만 봅니다. pg커넥션 풀은PG_POOL심볼로 주입하고, 종료할 때 닫습니다.main.ts에서 helmet, 쿠키 파서, 명시적인 출처 목록의 CORS, 전역 검증 파이프, Swagger 를 켰습니다.- Swagger 문서에는 이 책이 쓸 세 가지 보안 방식(
access-token,csrf,refresh-cookie)을 미리 등록했습니다.
다음 장에서는 첫 엔드포인트인 가입을 만듭니다. 비밀번호를 어떻게 저장해야 하는지, 에러 응답은 어떤 모양이어야 하는지, 그리고 Swagger 문서를 어떤 규칙으로 쓸지를 정합니다.
연습문제
1. .env 에서 ACCESS_TOKEN_TTL_SEC= 처럼 값을 비워 두면 어떻게 될까요? num 함수의 동작을 따라가며 예상한 뒤 실제로 확인해 보세요.
해답
ConfigService.getOrThrow 는 키가 없을 때 예외를 던집니다. 값이 빈 문자열이면 키는 있는 것이므로 '' 가 돌아옵니다. 그다음 Number('') 는 NaN 이 아니라 0 입니다. Number.isFinite(0) 은 true 이므로 검증을 통과하고, 액세스 토큰의 수명이 0초가 됩니다. 발급하자마자 만료되는 토큰이 나오는 셈입니다.
이 책의 AppConfig 는 이 경우를 막지 않습니다. 막고 싶다면 num 에 value > 0 같은 범위 검사를 더할 수 있습니다. 다만 REFRESH_REUSE_GRACE_SEC 처럼 0 이 의미 있는 값일 수 있는 항목도 있으니, 항목별로 허용 범위를 따로 정해야 합니다. 이 연습문제의 요점은 "문자열을 숫자로 바꾸는 검증은 생각보다 구멍이 많다" 는 것입니다.
2. flag 함수는 값이 없으면 false 를 돌려줍니다. 그렇다면 운영 환경에서 누군가 COOKIE_SECURE 를 빠뜨리면 쿠키에서 Secure 가 빠지게 됩니다. SWAGGER_ENABLED 는 빠뜨리면 꺼지므로 안전한 쪽입니다. 두 값에 대해 "빠뜨렸을 때 안전한 기본값" 은 각각 무엇이고, 이 책의 flag 는 어느 쪽에 맞춰져 있나요?
해답
빠뜨렸을 때 안전한 쪽은 SWAGGER_ENABLED 는 꺼짐, COOKIE_SECURE 는 켜짐입니다. 이 책의 flag 는 모든 값을 "없으면 꺼짐" 으로 읽으므로 SWAGGER_ENABLED 에는 맞고 COOKIE_SECURE 에는 반대입니다.
이 책은 .env 에 모든 값을 적어 두고, 14장의 운영 체크리스트에서 COOKIE_SECURE=true 를 확인 항목으로 다룹니다. 더 엄격하게 하고 싶다면 COOKIE_SECURE 만 str 로 읽어 값이 없을 때 부팅이 멈추게 하는 방법이 있습니다. 어느 쪽이든 "기본값이 안전한 쪽인가" 를 값마다 따져 보는 습관이 중요합니다.
3. DatabaseModule 에서 useFactory 안의 config.databaseUrl 대신 process.env.DATABASE_URL 을 써도 서버는 똑같이 뜹니다. 그런데도 이 책이 AppConfig 를 거치게 한 이유를 두 가지 드세요.
해답
첫째, 검증을 거친 값만 쓰게 하기 위해서입니다. AppConfig 를 거치면 DATABASE_URL 이 없을 때 부팅 단계에서 멈춥니다. process.env 를 바로 읽으면 undefined 가 Pool 에 넘어가고, pg 는 접속 문자열이 없으면 다른 환경변수나 기본값으로 접속을 시도하기 때문에 엉뚱한 곳에 붙거나 첫 쿼리에서야 실패합니다.
둘째, 설정을 읽는 경로를 하나로 모으기 위해서입니다. 설정을 어디서 읽는지가 흩어지면 "이 서버가 어떤 환경변수에 의존하는가" 를 알기 위해 코드 전체를 뒤져야 합니다. 테스트에서 설정을 바꿔 끼울 때도 AppConfig 하나만 바꾸면 됩니다.
4. CORS 설정에서 origin: true 를 쓰면 무엇이 문제인지, credentials: true 와 함께 생각해 설명해 보세요.
해답
origin: true 는 요청의 Origin 헤더를 그대로 Access-Control-Allow-Origin 으로 되돌려 줍니다. 결과적으로 어떤 출처든 허용됩니다. 여기에 credentials: true 가 더해지면, 아무 사이트나 사용자의 브라우저를 통해 쿠키를 실은 요청을 보내고 그 응답까지 읽을 수 있게 됩니다. '*' 는 브라우저가 credentials 와 함께 쓰는 것을 거부하므로 오히려 안전하게 실패하지만, true 는 그 제한을 우회해 버립니다. 그래서 허용 목록은 항상 명시적인 배열로 둡니다.