
이 장을 마치면
- "FE 는 Swagger 문서만 보고 작업한다" 는 말이 구체적으로 무엇을 약속하는지 설명할 수 있다.
- BE 의 문서화 규칙 하나하나가 FE 의 타입 안전성에 어떻게 이어지는지 말할 수 있다.
pnpm openapi:export로 계약 파일contract/openapi.json을 내보내고, 그 파일을 저장소에 커밋하는 이유를 안다.- Vite + React 프로젝트를 만들고
pnpm gen:api로schema.d.ts를 생성해openapi-fetch클라이언트를 세운다.- 계약이 바뀌었을 때 FE 의 어디가 컴파일 오류로 드러나는지 예측할 수 있다.
들어가며
9장까지 우리는 백엔드만 만들었습니다. 가입, 로그인, 리프레시 토큰 회전, 재사용 탐지, 세션 목록, CSRF 세 겹까지 서버 쪽 설계는 모두 갖췄습니다. 이제 4부에서는 이 API 를 브라우저에서 부르는 프론트엔드를 만듭니다.
그런데 프론트엔드 코드를 한 줄 쓰기 전에 정해야 할 것이 있습니다. 프론트엔드 개발자는 무엇을 보고 일하는가 입니다. 이 책을 혼자 따라가는 독자라면 BE 코드를 열어 보면 그만이지만, 실제 팀에서는 FE 와 BE 를 다른 사람이 맡는 경우가 많습니다. FE 개발자가 NestJS 컨트롤러를 열어 데코레이터를 읽고, 서비스 코드에서 어떤 에러를 던지는지 추적하는 방식은 오래가지 못합니다. BE 코드가 바뀔 때마다 FE 개발자가 그 변경을 알아채야 하기 때문입니다.
이 책이 택한 방식은 머리말에 적은 대로 "FE 는 Swagger 문서만 보고 작업한다" 입니다. BE 는 자기 API 가 무엇을 받고 무엇을 돌려주는지를 OpenAPI 문서로 내놓고, FE 는 그 문서에서 TypeScript 타입을 자동으로 만들어 씁니다. 문서가 바뀌면 타입이 바뀌고, 타입이 바뀌면 FE 코드 중 맞지 않게 된 곳이 컴파일 오류로 드러납니다.
이 장에서는 이 방식을 계약(contract) 이라는 관점에서 정리합니다. 4장부터 조금씩 붙여 온 Swagger 데코레이터가 왜 그렇게 생겼는지 다시 짚고, 문서를 파일로 내보내 저장소에 커밋하는 흐름을 만든 뒤, 그 파일에서 FE 프로젝트의 타입과 API 클라이언트를 세웁니다. 이 장이 끝나면 web/ 폴더에 화면은 아직 없지만, 타입이 붙은 API 클라이언트가 준비됩니다. 화면은 11장에서 만듭니다.
10.1 계약이라는 말 — FE 는 무엇을 믿고 일하는가
"계약" 이라는 말을 이 책에서는 이렇게 씁니다. API 를 제공하는 쪽과 쓰는 쪽이 서로 믿고 기대는 약속의 목록입니다. 구체적으로는 다음을 적은 것입니다.
- 어떤 경로와 메서드가 있는가 (
POST /auth/login) - 요청 본문은 어떤 모양인가 (
{ email, password }) - 성공하면 어떤 상태 코드와 본문이 오는가 (
200 TokenResponseDto) - 실패하면 어떤 상태 코드와 어떤 에러 코드가 올 수 있는가 (
401 INVALID_CREDENTIALS,403 CSRF_TOKEN_INVALID,429 TOO_MANY_REQUESTS) - 어떤 인증 수단이 필요한가 (Bearer 헤더, CSRF 헤더, 리프레시 쿠키)
이 목록을 사람이 위키에 따로 적어 두면 금방 코드와 어긋납니다. 코드를 고친 사람이 문서 고치는 것을 잊기 때문입니다. 그래서 이 책은 문서를 코드에서 만듭니다. @nestjs/swagger 는 컨트롤러와 DTO 에 붙은 데코레이터를 읽어 OpenAPI 문서를 만들어 줍니다. 데코레이터는 코드 바로 옆에 있으니, 코드를 고칠 때 같이 고치게 됩니다.
여기까지는 흔한 이야기입니다. 이 책이 한 걸음 더 나아가는 곳은 FE 가 이 문서를 사람 눈으로만 읽지 않는다는 점입니다. FE 는 openapi-typescript 로 문서를 TypeScript 타입으로 바꾸고, openapi-fetch 라는 얇은 클라이언트로 그 타입을 그대로 씁니다. 그러면 흐름이 이렇게 됩니다.
BE 코드 계약 파일 FE 타입 FE 코드
──────── ───────── ─────── ───────
DTO·컨트롤러 데코레이터 ──▶ contract/openapi.json ──────▶ web/src/api/schema.d.ts ──▶ api.POST('/auth/login', …)
(pnpm openapi:export) (git 에 커밋) (pnpm gen:api) (tsc 가 검사)
이 흐름에서 중요한 성질이 두 가지 있습니다.
첫째, 문서에 없는 것은 FE 타입에도 없습니다. BE 가 에러 응답을 문서화하지 않으면 FE 는 그 에러의 모양을 타입으로 알 수 없습니다. BE 가 필드 하나에 @ApiProperty 를 빠뜨리면 FE 타입에서 그 필드가 사라집니다. 그래서 이 책에서는 BE 의 문서화 품질이 곧 FE 의 타입 안전성입니다. 10.2절의 규칙들이 까다로워 보이는 이유가 여기 있습니다.
둘째, 계약이 바뀌면 FE 의 어긋난 곳이 컴파일 단계에서 드러납니다. 런타임에 화면이 깨진 뒤에야 알게 되는 것이 아니라, pnpm gen:api 다음에 tsc 를 돌리면 고쳐야 할 줄이 목록으로 나옵니다. 10.9절에서 이것을 직접 따라가 봅니다.
💡 이 방식을 흔히 "스키마 우선" 또는 "계약 우선" 이라고 부르는데, 엄밀히 말하면 이 책은 코드 우선입니다. 계약 파일을 먼저 손으로 쓰고 코드를 맞추는 것이 아니라, BE 코드에서 계약을 뽑아냅니다. 어느 쪽이든 FE 가 계약만 보고 일한다는 점은 같습니다. 이 책이 코드 우선을 택한 이유는 단순합니다. 문서와 코드가 한 곳에 있으면 어긋날 틈이 줄어듭니다.
10.2 BE 쪽 문서화 규칙 되짚기
4장부터 9장까지 컨트롤러를 만들 때마다 Swagger 데코레이터를 붙였습니다. 그때는 규칙을 하나씩 소개만 했는데, 이제 FE 쪽에서 그 규칙들이 어떤 결과로 이어지는지 알 수 있으니 한자리에 모아 다시 봅니다.
모든 DTO 필드에 @ApiProperty
@nestjs/swagger 는 TypeScript 타입을 직접 읽지 못합니다. TypeScript 의 타입 정보는 컴파일하면 사라지기 때문입니다. 대신 데코레이터가 남긴 메타데이터를 읽습니다. 그래서 DTO 필드에 @ApiProperty 가 없으면 그 필드는 문서에 아예 나오지 않습니다.
// api/src/users/dto.ts
export class UserDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'kim@example.com' })
email!: string;
@ApiProperty({ example: '김개발' })
nickname!: string;
@ApiProperty({ format: 'date-time' })
createdAt!: string;
}
네 필드 모두에 데코레이터가 붙어 있습니다. 만약 createdAt 에서 @ApiProperty 를 빠뜨리면 무슨 일이 생길까요? 서버는 여전히 createdAt 을 응답에 담아 보냅니다. toUserDto 가 그 필드를 채우니까요. 하지만 문서에는 없으니 FE 타입 User 에도 createdAt 이 없습니다. FE 개발자가 user.createdAt 을 쓰려고 하면 컴파일러가 "그런 필드는 없다" 고 막습니다. 데이터는 오는데 타입이 막는, 가장 헷갈리는 종류의 어긋남입니다.
format: 'uuid', format: 'date-time' 같은 값은 타입 자체를 바꾸지는 않습니다(둘 다 string 입니다). 하지만 생성된 타입의 주석으로 남아 FE 개발자가 값의 모양을 알 수 있게 해 줍니다. example 은 Swagger UI 의 "Try it out" 에 기본값으로 들어가 사람이 시험해 볼 때 편합니다.
에러 응답마다 올 수 있는 code 를 나열한다
4장에서 모든 에러 응답을 { statusCode, code, message } 한 모양으로 정했습니다. 그리고 컨트롤러마다 에러 응답 데코레이터를 달고, description 에 그 상태로 올 수 있는 code 를 전부 적었습니다.
// api/src/auth/auth.controller.ts
@ApiUnauthorizedResponse({ type: ErrorResponseDto, description: 'REFRESH_TOKEN_INVALID | REFRESH_TOKEN_REUSED' })
@ApiConflictResponse({ type: ErrorResponseDto, description: 'REFRESH_TOKEN_ROTATED — 한 번 더 시도한다' })
@ApiForbiddenResponse({ type: ErrorResponseDto, description: CSRF_ERRORS })
이 데코레이터는 두 가지 일을 합니다. type: ErrorResponseDto 는 기계가 읽는 부분입니다. 이것이 있어야 FE 타입에서 error 가 ErrorResponseDto 모양이 됩니다(10.8절). description 은 사람이 읽는 부분입니다. code 필드의 타입은 모든 에러 코드의 합집합이라서, "/auth/refresh 의 401 에서는 이 둘만 온다" 는 정보는 타입에 담기지 않습니다. 그 정보를 FE 개발자에게 전하는 것이 description 입니다.
⚠️ 에러 응답 데코레이터를 빠뜨리면 서버는 여전히 그 에러를 보내지만, FE 타입은 그 응답이 있다는 것을 모릅니다. 문서에 없는 에러는 FE 입장에서 "일어나지 않는 일" 이 됩니다.
보안 스킴 세 개
openapi.ts 에서 인증 수단 세 가지를 이름을 붙여 등록했습니다.
// api/src/openapi.ts
.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')
그리고 엔드포인트마다 어떤 스킴이 필요한지 표시했습니다.
| 엔드포인트 | 붙인 데코레이터 | 뜻 |
|---|---|---|
POST /auth/login | @ApiSecurity('csrf') | CSRF 헤더 필요 |
POST /auth/refresh, POST /auth/logout | @ApiSecurity('csrf') + @ApiCookieAuth('refresh-cookie') | CSRF 헤더 + 리프레시 쿠키 |
POST /auth/logout-all, GET /auth/sessions, DELETE /auth/sessions/{sessionId}, /users/* | @ApiBearerAuth('access-token') | 액세스 토큰 |
이 표시는 주로 사람과 Swagger UI 를 위한 것입니다. Swagger UI 오른쪽 위의 Authorize 버튼을 누르면 access-token 과 csrf 칸이 나오고, 거기 값을 넣어 두면 해당 표시가 붙은 요청에 헤더가 자동으로 실립니다. 문서 첫머리의 안내문(GET /auth/csrf → Authorize 의 csrf 에 붙여 넣기 → 로그인)이 그 사용법입니다.
FE 타입 쪽에서는 사정이 조금 다릅니다. openapi-typescript 는 보안 스킴을 요청 헤더 타입으로 바꿔 주지 않는 것으로 알려져 있습니다(정확한 동작은 사용하는 버전의 공식 문서에서 확인하세요). 그래서 FE 는 X-CSRF-Token 헤더를 직접 붙이고, 액세스 토큰도 직접 붙입니다(12장). 그렇다면 스킴 표시는 FE 에게 무슨 쓸모가 있을까요? "이 엔드포인트는 무엇을 요구하는가" 를 문서에서 한눈에 읽을 수 있다는 것입니다. logout-all 에 csrf 표시가 없고 access-token 표시만 있는 것을 보면, FE 개발자는 이 요청이 쿠키가 아니라 Bearer 헤더로 인증한다는 것, 그래서 CSRF 대상이 아니라는 것을 코드를 열지 않고 압니다.
💡 refresh-cookie 스킴은 Swagger UI 의 Authorize 에서 값을 넣어도 실제로 쿠키를 심지는 못합니다. 브라우저에서 스크립트가 다른 출처용 쿠키를 마음대로 만들 수 없기 때문입니다. Swagger UI 는 API 와 같은 출처(localhost:4000)에서 뜨므로, 로그인 응답이 심은 진짜 쿠키가 자동으로 실립니다. refresh-cookie 표시는 "이 요청은 쿠키가 필요하다" 는 문서 역할만 합니다.
enumName 으로 ErrorCode 를 하나의 타입으로
// api/src/common/error-response.dto.ts
@ApiProperty({ enum: ErrorCode, enumName: 'ErrorCode', example: ErrorCode.ACCESS_TOKEN_INVALID })
code!: ErrorCode;
enum: ErrorCode 만 주면 @nestjs/swagger 는 code 필드 안에 열두 개 값의 목록을 그 자리에 풀어서 씁니다. enumName: 'ErrorCode' 를 더하면 목록을 components.schemas.ErrorCode 라는 이름 붙은 스키마로 한 번만 정의하고, code 필드는 그것을 참조($ref)합니다.
이 차이가 FE 에서 드러납니다. 이름이 붙으면 생성된 타입에 components['schemas']['ErrorCode'] 라는 독립된 타입이 생깁니다. FE 개발자는 "에러 코드 전체의 합집합" 을 하나의 이름으로 가져다 쓸 수 있습니다. 이름이 없으면 같은 합집합이 code 필드 안에 익명으로 박혀 있어서, 필요하면 ErrorResponse['code'] 처럼 필드를 거쳐 꺼내야 합니다. 에러 코드를 담는 DTO 가 나중에 하나 더 생기면 차이는 더 커집니다. 이름이 없으면 똑같은 목록이 두 곳에 풀려 쓰이고, 두 목록이 같은 것이라는 사실을 FE 가 알 방법이 없습니다.
nullable 과 type 을 같이 쓰는 경우
// api/src/auth/dto.ts
@ApiProperty({ type: String, nullable: true })
userAgent!: string | null;
세션 목록의 userAgent 는 없을 수도 있습니다(요청에 User-Agent 헤더가 없었다면). TypeScript 로는 string | null 이지만, 앞에서 말했듯 @nestjs/swagger 는 TypeScript 타입을 직접 보지 못하고 컴파일러가 남긴 메타데이터를 봅니다. 그런데 string | null 같은 합집합 타입은 메타데이터에 Object 로만 남습니다. 그대로 두면 문서에 "무언가 객체" 라고 적힙니다. 그래서 type: String 으로 실제 타입을 알려 주고, nullable: true 로 null 이 올 수 있다고 알려 줍니다.
생성된 FE 타입에서는 이것이 userAgent: string | null 이 됩니다. FE 코드가 s.userAgent ?? '알 수 없음' 처럼 null 을 처리하지 않으면 컴파일러가 알려 줍니다. nullable 을 빠뜨리면 FE 타입이 string 이 되어, null 이 실제로 왔을 때 화면에 null 이라는 글자가 찍히거나 문자열 메서드를 부르다 런타임 오류가 납니다.
isArray
// api/src/auth/auth.controller.ts
@ApiOkResponse({ type: SessionDto, isArray: true })
GET /auth/sessions 는 세션의 배열을 돌려줍니다. 메서드의 반환 타입이 Promise<SessionDto[]> 이지만, 이것 역시 메타데이터에는 "배열" 이라는 정보가 제대로 남지 않습니다. isArray: true 가 없으면 문서는 SessionDto 하나를 돌려준다고 적고, FE 는 data.map(...) 을 쓰려다 컴파일 오류를 만납니다. 거꾸로, 실제로는 배열이 오는데 타입은 객체 하나라고 믿는 코드가 생길 수도 있습니다.
여기까지 규칙 여섯 가지를 한 문장으로 줄이면 이렇습니다. FE 타입에 나와야 하는 사실은 전부 데코레이터에 적는다. 서버가 무엇을 실제로 보내는지와 무관하게, 문서에 적힌 것만 FE 에 전해집니다.
10.3 계약을 파일로 내보내기
지금 API 를 띄우면 Swagger UI 가 http://localhost:4000/docs 에, 그리고 같은 문서의 JSON 이 http://localhost:4000/docs-json 에 뜹니다(SwaggerModule.setup('docs', …) 의 기본 동작입니다. 경로 규칙은 버전에 따라 다를 수 있으니 공식 문서에서 확인하세요). FE 가 이 URL 에서 바로 타입을 만들 수도 있습니다. 하지만 그러려면 FE 개발자의 컴퓨터에서 BE 서버가 돌고 있어야 합니다. DB 도 떠 있어야 하고, .env 도 맞춰야 합니다. 그리고 "지금 이 순간 BE 서버가 내놓는 문서" 는 어느 커밋의 것인지 기록에 남지 않습니다.
그래서 문서를 파일로 내보내 저장소에 커밋합니다. 먼저 main.ts 에 내보내기 분기를 넣습니다. 9장까지의 main.ts 에 없던 부분은 writeFileSync import 와 --export-openapi 분기, 두 곳입니다. 전체 파일을 보입니다.
// 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 { writeFileSync } from 'node:fs';
import { AppModule } from './app.module';
import { validationExceptionFactory } from './common/validation';
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,
exceptionFactory: validationExceptionFactory,
}),
);
app.enableShutdownHooks();
const document = buildOpenApi(app);
// pnpm openapi:export — 계약 파일만 쓰고 끝낸다 (10장)
const exportAt = process.argv.indexOf('--export-openapi');
if (exportAt !== -1) {
writeFileSync(process.argv[exportAt + 1] ?? 'openapi.json', JSON.stringify(document, null, 2) + '\n');
await app.close();
return;
}
if (config.swaggerEnabled) {
SwaggerModule.setup('docs', app, document, {
swaggerOptions: { persistAuthorization: true, withCredentials: true },
});
}
await app.listen(config.port);
}
void bootstrap();
⚠️ 여러분의 main.ts 에서 buildOpenApi(app) 을 if (config.swaggerEnabled) 안에서 부르고 있었다면, 위처럼 const document = buildOpenApi(app); 를 if 밖으로, 내보내기 분기보다 앞으로 옮기세요. 내보내기는 SWAGGER_ENABLED 값과 상관없이 동작해야 합니다. 운영 설정처럼 Swagger UI 를 꺼 둔 상태에서도 계약 파일은 뽑을 수 있어야 하기 때문입니다.
분기의 각 줄이 왜 이렇게 생겼는지 봅시다.
process.argv.indexOf('--export-openapi')— 별도의 스크립트 파일을 만들지 않고, 같은main.ts를 인자 하나로 다르게 돌립니다. 이렇게 하면 문서를 만드는 경로가 실제 서버와 완전히 같습니다. 같은AppModule, 같은buildOpenApi를 거치니 "내보낸 문서" 와 "서버가 내놓는 문서" 가 달라질 수 없습니다.process.argv[exportAt + 1] ?? 'openapi.json'— 인자 바로 뒤의 값을 출력 경로로 씁니다. 없으면 현재 폴더의openapi.json입니다.JSON.stringify(document, null, 2) + '\n'— 들여쓰기 두 칸으로 예쁘게 씁니다. 한 줄로 쓰면 PR 에서 diff 가 "한 줄이 통째로 바뀌었다" 로만 보입니다. 끝의 줄바꿈 하나는 많은 편집기와git이 "파일 끝에 줄바꿈 없음" 경고를 내지 않게 하려는 것입니다.await app.close(); return;—app.listen에 도달하기 전에 끝냅니다. 포트를 열지 않으니, 개발 서버가 4000번 포트를 쓰고 있어도 내보내기가 충돌 없이 돕니다.app.close()는DatabaseModule의onApplicationShutdown을 불러 커넥션 풀을 닫습니다.
다음은 스크립트입니다. api/package.json 의 scripts 에 한 줄을 더합니다.
// 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",
"openapi:export": "nest build && node dist/main.js --export-openapi ../contract/openapi.json"
}
}
nest build 로 컴파일한 뒤 결과물을 인자와 함께 실행합니다. ts-node 같은 것으로 바로 돌리지 않고 빌드를 거치는 이유는, 데코레이터 메타데이터가 실제 빌드 설정 그대로 만들어진 코드에서 문서를 뽑기 위해서입니다. 출력 경로 ../contract/openapi.json 은 api/ 폴더에서 한 단계 올라간 auth-lab/contract/ 입니다. 머리말의 폴더 구조에 적은 그 자리입니다.
writeFileSync 는 폴더를 만들어 주지 않으므로, 처음 한 번은 폴더를 만들어야 합니다.
$ cd ~/Workspace/auth-lab
$ mkdir -p contract
$ cd api
$ pnpm openapi:export
예시 출력:
> nest build && node dist/main.js --export-openapi ../contract/openapi.json
[Nest] 41822 - 2026. 09. 24. 오전 10:12:03 LOG [NestFactory] Starting Nest application...
[Nest] 41822 - 2026. 09. 24. 오전 10:12:03 LOG [InstanceLoader] AppConfigModule dependencies initialized +8ms
[Nest] 41822 - 2026. 09. 24. 오전 10:12:03 LOG [InstanceLoader] DatabaseModule dependencies initialized +0ms
…
[Nest] 41822 - 2026. 09. 24. 오전 10:12:03 LOG [RoutesResolver] AuthController {/auth}: +3ms
…
RoutesResolver 로그까지 나오고 프로세스가 스스로 끝나면 성공입니다. 파일을 확인합니다.
$ jq '.paths | keys' ../contract/openapi.json
예시 출력:
[
"/auth/csrf",
"/auth/login",
"/auth/logout",
"/auth/logout-all",
"/auth/refresh",
"/auth/sessions",
"/auth/sessions/{sessionId}",
"/auth/signup",
"/lab/login",
"/lab/me",
"/lab/nickname",
"/lab/settings",
"/users/me"
]
DB 가 꺼져 있어도 내보내기가 되는 이유
위 명령은 Docker 의 PostgreSQL 을 꺼 둔 채로 돌려도 성공합니다. 한번 확인해 보세요.
$ docker compose -f ../docker-compose.yml stop db
$ pnpm openapi:export # 그래도 성공한다
$ docker compose -f ../docker-compose.yml start db
DatabaseModule 이 앱이 뜰 때 new Pool({ connectionString }) 을 만들기는 하지만, pg 의 Pool 은 첫 쿼리를 보낼 때까지 실제로 연결하지 않습니다. 풀은 "연결이 필요해지면 이 주소로 연결하겠다" 는 설정만 들고 있다가, pool.query() 나 pool.connect() 가 처음 불릴 때 소켓을 엽니다. 내보내기 경로는 앱을 만들고 문서를 뽑고 바로 닫을 뿐, 쿼리를 하나도 보내지 않습니다. 그래서 DB 없이 돕니다. 3장에서 커넥션 풀을 이렇게 만든 덕을 여기서 봅니다.
이 성질은 CI 에서 특히 쓸모가 있습니다. 계약 파일이 최신인지 검사하는 작업에 DB 컨테이너를 띄울 필요가 없습니다.
⚠️ 다만 환경변수는 필요합니다. AppConfig 생성자가 JWT_ACCESS_SECRET 이 32자 이상인지까지 검사하므로, api/.env 가 없는 곳(예: 새로 받은 CI 러너)에서 내보내기를 돌리면 설정 검증에서 먼저 실패합니다. CI 에서 돌릴 때는 실습용 .env 와 같은 모양의 더미 값을 넣어 주세요. 값 자체는 문서 내용에 영향을 주지 않습니다.
10.4 계약 파일을 저장소에 커밋하는 이유
contract/openapi.json 은 빌드 산출물처럼 보입니다. 보통 산출물은 .gitignore 에 넣는데, 이 파일은 일부러 커밋합니다. 이유는 두 가지입니다.
이유 1 — PR 에서 계약의 변화가 보인다
BE 개발자가 UserDto 에 필드를 하나 더했다고 합시다. 코드 변경만 보면 리뷰어는 "DTO 에 필드가 생겼구나" 정도로 읽고 넘어가기 쉽습니다. 그런데 PR 에 contract/openapi.json 의 diff 가 같이 올라오면, API 를 쓰는 쪽에서 무엇이 달라지는지가 따로 드러납니다.
"UserDto": {
"type": "object",
"properties": {
…
"createdAt": {
"type": "string",
"format": "date-time"
- }
+ },
+ "locale": {
+ "type": "string",
+ "example": "ko"
+ }
},
"required": [
"id",
"email",
"nickname",
- "createdAt"
+ "createdAt",
+ "locale"
]
},
(위는 설명을 위한 가상의 변경입니다. 부록 C 의 UserDto 에는 locale 이 없습니다.)
리뷰어는 이 diff 를 보고 "locale 이 필수 필드로 들어왔다" 는 것을 압니다. 응답에 필드가 더해진 것이라 기존 FE 는 깨지지 않지만, 만약 같은 DTO 가 요청 본문에도 쓰였다면 기존 클라이언트가 전부 깨질 변경입니다. 이런 판단을 코드가 아니라 계약을 보고 할 수 있게 해 주는 것이 계약 파일 커밋의 첫 번째 이유입니다. FE 개발자를 PR 리뷰어로 넣으면, FE 개발자는 contract/ 폴더의 diff 만 봐도 자기에게 영향이 있는지 판단할 수 있습니다.
이유 2 — FE 가 BE 서버를 띄우지 않아도 된다
FE 개발자가 타입을 새로 만들 때 필요한 것은 contract/openapi.json 파일 하나입니다. Docker 도, PostgreSQL 도, NestJS 도, api/.env 도 필요 없습니다. 저장소를 받아 pnpm gen:api 한 번이면 타입이 최신이 됩니다. 그리고 그 타입은 지금 체크아웃한 커밋의 계약과 정확히 같습니다. 브랜치를 옮기면 계약 파일도 같이 옮겨지므로, "BE 서버는 main 브랜치로 떠 있는데 FE 는 기능 브랜치를 보고 있어서 타입이 안 맞는다" 같은 일이 생기지 않습니다.
/docs-json 과의 관계
그렇다면 /docs-json 은 쓸모가 없어졌을까요? 아닙니다. 두 입력은 쓰임새가 다릅니다.
| 입력 | 스크립트 | 언제 |
|---|---|---|
../contract/openapi.json | pnpm gen:api | 기본. 커밋된 계약에서 타입을 만든다 |
http://localhost:4000/docs-json | pnpm gen:api:live | BE 와 FE 를 한 사람이 동시에 고치는 중이라 아직 계약을 내보내지 않았을 때 |
두 입력은 같은 문서입니다. main.ts 를 보면 내보내기 분기와 SwaggerModule.setup 이 같은 document 변수를 씁니다. 차이는 "어느 시점의 문서냐" 뿐입니다. 파일은 커밋된 시점의 것이고, /docs-json 은 지금 돌고 있는 서버 코드의 것입니다.
그래서 흐름은 이렇게 정합니다. 혼자 BE 와 FE 를 오가며 실험할 때는 gen:api:live 로 빠르게 맞춰 보고, PR 을 올리기 전에는 반드시 pnpm openapi:export 로 계약 파일을 갱신해 커밋합니다. FE 는 언제나 gen:api 를 기준으로 삼습니다.
💡 /docs-json 은 SWAGGER_ENABLED=true 일 때만 열립니다. 14장에서 운영 환경은 Swagger 를 끄라고 권하는데, 그래도 계약 파일은 영향을 받지 않습니다. 내보내기는 SWAGGER_ENABLED 를 보지 않기 때문입니다.
10.5 프론트엔드 프로젝트 만들기
이제 web/ 을 만듭니다. auth-lab/ 에서 Vite 의 프로젝트 생성기를 부릅니다.
$ cd ~/Workspace/auth-lab
$ pnpm create vite web --template react-ts
$ cd web
$ pnpm install
react-ts 는 "React + TypeScript" 템플릿의 이름입니다. 생성기가 몇 가지를 물어볼 수 있는데(예: 개발 서버를 바로 띄울지), 버전에 따라 질문과 선택지가 달라집니다. 템플릿 이름도 바뀔 수 있으니, 위 명령이 그대로 되지 않으면 pnpm create vite 만 실행해 대화형으로 React 와 TypeScript 를 고르거나 Vite 공식 문서의 "Scaffolding" 절을 확인하세요. 이 책을 쓸 때 생성기(create-vite 9.2)가 만든 프로젝트에는 React 19.2, Vite 8.3, TypeScript 6.0 이 들어 있었습니다.
다음으로 이 책이 쓰는 라이브러리를 더합니다.
$ pnpm add openapi-fetch react-router
$ pnpm add -D openapi-typescript
openapi-fetch— 생성된 타입을 받아api.GET('/users/me')처럼 부를 수 있게 해 주는 아주 얇은fetch래퍼입니다. 런타임 코드가 작고, 요청 경로와 본문, 응답이 전부 타입으로 검사됩니다.react-router— 화면 라우팅입니다. 이 책은 선언형BrowserRouter·Routes방식을 씁니다(11장).openapi-typescript— 계약 파일을 읽어schema.d.ts를 만드는 CLI 입니다. 빌드에만 필요하니-D로 넣습니다.
⚠️ openapi-typescript 7.13 은 peer 의존성으로 typescript@^5.x 를 적어 두었고, Vite 템플릿은 TypeScript 6 을 설치합니다. pnpm add 할 때 "unmet peer" 경고가 나올 수 있습니다. pnpm 은 기본 설정에서 이 경고로 설치를 멈추지 않습니다. pnpm gen:api 가 정상적으로 파일을 만들면 그대로 진행하고, 문제가 생기면 openapi-typescript 의 공식 문서와 릴리스 노트에서 TypeScript 6 지원 여부를 확인하세요.
템플릿이 만든 파일 중 src/App.tsx, src/main.tsx 는 11장에서 통째로 바꿉니다. src/App.css, src/index.css, src/assets/ 는 이 책의 코드가 쓰지 않으니 지워도 되고 그대로 둬도 됩니다. 11장의 main.tsx 는 index.css 를 import 하지 않습니다.
web/.env
# web/.env
VITE_API_URL=http://localhost:4000
Vite 는 VITE_ 로 시작하는 환경변수만 브라우저 코드에 넘겨 줍니다(import.meta.env.VITE_API_URL). 접두사가 없는 변수는 번들에 들어가지 않습니다. 실수로 비밀 값이 브라우저로 새는 것을 막으려는 Vite 의 규칙입니다. 반대로 말하면 VITE_ 가 붙은 값은 누구나 볼 수 있습니다. API 주소처럼 공개돼도 되는 값만 넣습니다.
web/vite.config.ts
템플릿이 만든 파일에 server 설정 한 줄을 더합니다.
// web/vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
// API 와 같은 호스트명(localhost)으로 연다. 127.0.0.1 로 열면 다른 사이트가 되어 SameSite=Strict 쿠키가 안 실린다.
server: { host: 'localhost', port: 5173, strictPort: true },
});
이 한 줄에 1장과 9장의 내용이 들어 있습니다.
host: 'localhost'— 1장에서 사이트는 스킴과 등록 가능한 도메인으로 정해지고 포트는 보지 않는다고 했습니다.http://localhost:5173과http://localhost:4000은 출처는 다르지만 같은 사이트입니다. 그래서SameSite=Strict인refresh_token쿠키가 FE 에서 API 로 가는 요청에 실립니다. 그런데 FE 를http://127.0.0.1:5173으로 열면127.0.0.1과localhost는 서로 다른 호스트라 다른 사이트가 됩니다. 9장에서 공격 페이지를127.0.0.1:5174로 열어 쿠키가 실리지 않는 것을 확인했던 바로 그 상황이 우리 앱에서 일어납니다. 로그인은 되는데 새로고침하면 풀리는, 원인을 찾기 어려운 증상이 됩니다. 게다가 API 의 CORS 허용 목록(WEB_ORIGINS)에는http://localhost:5173만 있으므로 요청 자체도 거절됩니다.port: 5173, strictPort: true— 5173 이 이미 쓰이고 있으면 Vite 는 기본적으로 5174 로 넘어갑니다. 그런데 5174 는 공격 페이지 자리이고, 무엇보다WEB_ORIGINS에 없는 출처라 CORS 와 CSRF Origin 검사에서 막힙니다.strictPort: true는 다른 포트로 넘어가지 말고 차라리 실패하라는 설정입니다. 조용히 다른 포트로 뜨는 것보다 바로 실패하는 편이 원인을 찾기 쉽습니다.
타입 생성 스크립트
web/package.json 의 scripts 에 두 줄을 더합니다.
// web/package.json (scripts 에 더한다)
{
"scripts": {
"gen:api": "openapi-typescript ../contract/openapi.json -o src/api/schema.d.ts",
"gen:api:live": "openapi-typescript http://localhost:4000/docs-json -o src/api/schema.d.ts"
}
}
두 스크립트는 입력만 다르고 출력은 같은 파일입니다. 실행합니다.
$ mkdir -p src/api
$ pnpm gen:api
예시 출력:
✨ openapi-typescript 7.13.0
🚀 ../contract/openapi.json → src/api/schema.d.ts [85ms]
src/api/schema.d.ts 는 자동 생성 파일이라 손으로 고치지 않습니다. 고쳐도 다음 gen:api 에서 덮어씌워집니다. 타입이 마음에 들지 않으면 BE 의 데코레이터를 고치고 다시 내보내는 것이 올바른 길입니다. 이 파일은 저장소에 커밋합니다. FE 코드를 받은 사람이 gen:api 를 먼저 돌리지 않아도 바로 tsc 가 돌게 하려는 것입니다.
10.6 생성된 schema.d.ts 읽어 보기
생성된 파일은 수백 줄이지만 뼈대는 세 개의 인터페이스뿐입니다. paths, components, operations 입니다. 실제 파일에서 몇 부분만 옮기면 대략 이렇게 생겼습니다. 공백과 주석, 연산 이름은 생성기 버전과 @nestjs/swagger 버전에 따라 조금씩 다릅니다.
// web/src/api/schema.d.ts (자동 생성 — 발췌, 예시)
export interface paths {
"/auth/refresh": {
parameters: { query?: never; header?: never; path?: never; cookie?: never };
get?: never;
put?: never;
/**
* 리프레시 토큰 회전
* @description 쿠키의 refresh_token 을 새 것으로 바꾸고 새 액세스 토큰을 준다.
*/
post: operations["AuthController_refresh"];
delete?: never;
// …
};
"/auth/sessions/{sessionId}": {
// …
/** 세션 하나 끊기 */
delete: operations["AuthController_deleteSession"];
};
// …
}
export interface components {
schemas: {
UserDto: {
/** Format: uuid */
id: string;
/** @example kim@example.com */
email: string;
/** @example 김개발 */
nickname: string;
/** Format: date-time */
createdAt: string;
};
/** @enum {string} */
ErrorCode: "VALIDATION_FAILED" | "INVALID_CREDENTIALS" | "ACCESS_TOKEN_INVALID" | "REFRESH_TOKEN_INVALID"
| "REFRESH_TOKEN_REUSED" | "REFRESH_TOKEN_ROTATED" | "CSRF_ORIGIN_REJECTED" | "CSRF_TOKEN_INVALID"
| "EMAIL_TAKEN" | "SESSION_NOT_FOUND" | "TOO_MANY_REQUESTS" | "LAB_DISABLED";
ErrorResponseDto: {
/** @example 401 */
statusCode: number;
/** @example ACCESS_TOKEN_INVALID */
code: components["schemas"]["ErrorCode"];
/** @example 액세스 토큰이 유효하지 않다 */
message: string;
};
TokenResponseDto: {
/** @description JWT. 메모리에만 보관한다 */
accessToken: string;
/** @enum {string} */
tokenType: "Bearer";
/** @example 600 */
expiresIn: number;
user: components["schemas"]["UserDto"];
};
SessionDto: {
sessionId: string;
createdAt: string;
lastUsedAt: string;
userAgent: string | null;
current: boolean;
};
// …
};
// …
}
export interface operations {
AuthController_refresh: {
parameters: { query?: never; header?: never; path?: never; cookie?: never };
requestBody?: never;
responses: {
200: {
headers: { [name: string]: unknown };
content: { "application/json": components["schemas"]["TokenResponseDto"] };
};
401: {
headers: { [name: string]: unknown };
content: { "application/json": components["schemas"]["ErrorResponseDto"] };
};
403: { /* ErrorResponseDto */ };
409: { /* ErrorResponseDto */ };
};
};
// …
}
하나씩 읽어 봅시다.
paths 는 경로 문자열을 키로 하는 표입니다. 각 경로 아래에 HTTP 메서드별 항목이 있고, 그 경로에 없는 메서드는 ?: never 로 막혀 있습니다. /auth/refresh 에는 post 만 있고 get 은 never 입니다. 그래서 FE 가 authApi.GET('/auth/refresh') 를 쓰면 컴파일러가 거절합니다. 경로 문자열의 오타도 마찬가지로 잡힙니다. /auth/sessions/{sessionId} 처럼 경로 변수가 있는 곳은 중괄호가 그대로 키에 들어가고, 부를 때 params: { path: { sessionId } } 로 값을 채웁니다(12장의 MePage).
components.schemas 는 DTO 들입니다. 10.2절의 규칙이 어떤 결과를 냈는지 여기서 확인할 수 있습니다.
UserDto의 네 필드가 모두 있고,format과example이 주석으로 남았습니다.ErrorCode가 독립된 타입으로 생겼고ErrorResponseDto.code는 그것을 가리킵니다.enumName덕분입니다.TokenResponseDto.tokenType은string이 아니라"Bearer"한 값만 가능한 타입입니다. BE 의enum: ['Bearer']가 이렇게 옮겨졌습니다.SessionDto.userAgent는string | null입니다.nullable: true덕분입니다.
operations 는 각 요청의 상세입니다. 연산 이름(AuthController_refresh)은 @nestjs/swagger 가 "컨트롤러 이름_메서드 이름" 으로 기본 생성한 operationId 에서 왔습니다. 여기서 가장 중요한 부분은 responses 입니다. 문서화한 상태 코드마다 항목이 하나씩 있습니다. /auth/refresh 는 200, 401, 403, 409 네 개입니다. 컨트롤러에 붙인 @ApiOkResponse, @ApiUnauthorizedResponse, @ApiForbiddenResponse, @ApiConflictResponse 와 정확히 대응합니다.
눈여겨볼 것이 하나 더 있습니다. parameters.header 가 never 입니다. X-CSRF-Token 헤더가 타입에 없습니다. 10.2절에서 말했듯 보안 스킴은 요청 파라미터로 옮겨지지 않기 때문입니다. FE 는 headers: { 'X-CSRF-Token': … } 를 옵션으로 직접 넘깁니다. openapi-fetch 는 문서에 없는 헤더도 받아 줍니다.
10.7 api/client.ts — 클라이언트 두 개
이제 이 타입으로 클라이언트를 만듭니다.
// web/src/api/client.ts
import createClient from 'openapi-fetch';
import type { components, paths } from './schema';
const baseUrl = import.meta.env.VITE_API_URL;
/** 앱 API 용. 12장에서 fetch 자리에 authFetch 를 끼워 401 이면 refresh 후 한 번 재시도하게 만든다. */
export const api = createClient<paths>({ baseUrl, credentials: 'include' });
/** /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'];
지금은 api 와 authApi 가 똑같이 생겼습니다. 둘 다 fetch 옵션 없이 기본 fetch 를 씁니다. 그런데도 굳이 둘로 나눠 둔 것은 12장을 위한 준비입니다. 12장에서 api 에만 fetch: authFetch 를 끼웁니다. authFetch 는 요청에 액세스 토큰을 붙이고, 401 이 오면 refresh 를 한 번 한 뒤 재시도하는 함수입니다. 그런데 refresh 요청 자체가 이 재시도 장치를 거치면 어떻게 될까요? refresh 가 401 을 받으면 "refresh 하고 재시도" 하려고 refresh 를 다시 부르고, 그 refresh 는 자기 자신이 끝나기를 기다리게 됩니다. 그래서 /auth/* 의 쿠키 기반 요청은 재시도 장치가 없는 authApi 로만 부릅니다. 지금 이름을 나눠 두면 11장의 코드가 처음부터 올바른 쪽을 부르게 되고, 12장에서는 api 한 줄만 고치면 됩니다.
나머지 부분도 이유를 적어 둡니다.
import type—schema.d.ts는 타입만 있는 파일입니다. 템플릿의tsconfig가verbatimModuleSyntax를 켜 두었기 때문에 타입만 가져올 때는import type으로 적어야 합니다. 그래야 번들에 존재하지 않는 모듈을 불러오려는 코드가 남지 않습니다.createClient<paths>— 제네릭 인자로paths를 넘기는 것이 전부입니다. 이것으로api.GET,api.POST의 경로 인자가paths의 키로 제한되고, 각 경로의 요청·응답 타입이 연결됩니다.credentials: 'include'— 1장에서 다룬 대로, 다른 출처로 가는fetch는 기본적으로 쿠키를 싣지도 받지도 않습니다. FE(localhost:5173)와 API(localhost:4000)는 다른 출처이므로, 이 옵션이 없으면refresh_token과csrf_token쿠키가 오가지 않습니다. 로그인은 성공한 것처럼 보이는데 refresh 가 늘 401 인 증상이 여기서 나옵니다. 모든 요청에 같은 옵션을 주려고 클라이언트를 만들 때 한 번에 넣습니다.- 타입 별칭 —
components['schemas']['UserDto']를 매번 쓰기는 번거로우니 짧은 이름을 붙입니다.Schemas를 따로 내보낸 것은 필요할 때Schemas['ErrorCode']처럼 다른 스키마도 바로 꺼내 쓰게 하려는 것입니다. 이 별칭들이 FE 코드와 생성 파일 사이의 접점입니다. 11장 이후의 코드는schema.d.ts를 직접 import 하지 않고 여기서만 가져옵니다.
10.8 openapi-fetch 가 돌려주는 것 — data, error, response
openapi-fetch 의 요청 함수는 예외를 던지는 대신 객체 하나를 돌려줍니다.
const { data, error, response } = await authApi.POST('/auth/login', {
body: { email, password },
headers: { 'X-CSRF-Token': csrfToken },
});
response— 원래의Response객체입니다. 상태 코드(response.status)나 헤더를 직접 봐야 할 때 씁니다.data— 응답이 성공(2xx)이면 본문이 파싱돼 들어 있고, 아니면undefined입니다.error— 응답이 실패(4xx·5xx)면 본문이 들어 있고, 아니면undefined입니다.
타입도 이 구조를 따라갑니다. 이 책이 쓰는 0.17 버전의 타입 정의를 보면 반환 타입은 대략 다음과 같은 두 갈래 합집합입니다.
// openapi-fetch 의 반환 타입 (개념적으로 줄인 것)
| { data: 성공응답본문; error?: never; response: Response }
| { data?: never; error: 실패응답본문; response: Response }
여기서 "성공응답본문" 과 "실패응답본문" 은 operations 의 responses 에서 옵니다. 성공 쪽은 2xx 항목, 실패 쪽은 4xx·5xx 항목입니다. /auth/login 은 401·403·429 를 모두 ErrorResponseDto 로 문서화했으니, error 는 ErrorResponseDto 타입이 됩니다.
이것이 10.1절에서 말한 "BE 의 문서화가 FE 의 타입 안전성을 결정한다" 의 구체적인 모습입니다. 에러 응답을 문서화하지 않은 엔드포인트를 생각해 봅시다. GET /auth/csrf 는 성공 응답만 문서화돼 있습니다. 그러면 error 에 들어갈 타입이 없습니다. 실제로 서버가 500 을 돌려주면 런타임에는 error 에 무언가가 들어오지만, 타입은 그것을 모릅니다(이때 error 가 never 로 보일지 unknown 으로 보일지는 버전마다 다를 수 있으니 공식 문서에서 확인하세요). 11장의 ensureCsrf 가 data! 로 쓰는 것은 이 엔드포인트가 정상 상황에서 실패하지 않는다고 보기 때문입니다.
에러 code 로 분기하면 오타가 컴파일 단계에서 잡힌다
error 가 ErrorResponseDto 타입이면 error.code 는 ErrorCode 합집합 타입입니다. 11장의 가입 화면에서 이렇게 씁니다.
// web/src/pages/SignupPage.tsx (11장에서 만든다)
if (error) {
setError(error.code === 'EMAIL_TAKEN' ? '이미 가입된 이메일입니다.' : error.message);
return;
}
이 코드에서 'EMAIL_TAKEN' 을 'EMAIL_TAKN' 으로 잘못 쓰면 어떻게 될까요? error.code 의 타입에 'EMAIL_TAKN' 이라는 값이 없으므로, TypeScript 는 "이 비교는 겹치는 값이 없어 의도하지 않은 것 같다" 는 오류를 냅니다. 문자열 비교인데도 오타가 컴파일 단계에서 잡힙니다.
switch 로 분기하면 한 걸음 더 나아갈 수 있습니다. case 안에서 error.code 는 그 값 하나로 좁혀지고, 모든 경우를 처리했는지 never 로 검사할 수도 있습니다(연습문제 3).
이렇게 분기의 기준을 message 가 아니라 code 로 두는 것은 4장에서 정한 규칙입니다. message 는 사람이 읽는 문장이라 언제든 바뀔 수 있고, 타입도 그냥 string 입니다. code 는 계약의 일부이고 타입이 있습니다.
if (!data) 로 갈라도 error 가 좁혀지지 않을 때
11장의 코드에는 이런 줄이 있습니다.
if (!data) return error as ErrorResponse;
data 가 없으면 error 가 있어야 하는데 왜 as 로 단언할까요? 구조 분해로 data 와 error 를 별개의 변수로 꺼내면, TypeScript 가 "data 가 없다" 는 사실로 error 의 타입을 좁혀 주지 못하는 경우가 있습니다. 그래서 error 가 여전히 ErrorResponseDto | undefined 로 보입니다. 응답 객체를 통째로 받아 if (result.error) 로 가르면 좁혀지지만, 이 책의 코드는 짧게 쓰려고 구조 분해를 택했고 대신 단언을 한 번 둡니다. 서버가 실패 응답에 본문을 늘 싣는다는 것은 4장의 에러 형식 규칙이 보장합니다.
10.9 계약이 바뀔 때 — 사고실험
이 절은 부록 C 의 코드를 실제로 바꾸지 않고 머릿속으로 따라가는 연습입니다. 따라 해 보고 싶다면 별도 브랜치에서 하고, 끝나면 되돌리세요.
BE 개발자가 UserDto 를 손봤다고 가정합니다. 그 뒤 흐름은 늘 같습니다.
BE: DTO 수정 → pnpm openapi:export → contract/openapi.json 변경 → PR
FE: pnpm gen:api → schema.d.ts 변경 → pnpm exec tsc -b → 깨지는 곳 = 고칠 곳
pnpm exec tsc -b 는 템플릿의 build 스크립트가 쓰는 타입 검사 명령입니다(템플릿의 tsconfig 가 noEmit 이라 파일을 만들지 않고 검사만 합니다). 세 가지 경우로 나눠 봅니다. 11장과 12장의 코드가 다 있다고 치고 읽으세요.
경우 1 — 응답에 필드를 더한다
UserDto 에 @ApiProperty({ example: 'ko' }) locale!: string; 을 더했다고 합시다. 10.4절의 diff 가 그 모습입니다.
gen:api 뒤에 User 타입에 locale: string 이 생깁니다. 그런데 tsc 는 아무 곳도 깨지지 않습니다. FE 코드는 User 를 읽기만 하고 만들지 않기 때문입니다. 필드가 하나 더 있는 객체는 원래 타입이 요구하는 것을 다 갖고 있으니 문제가 없습니다. 이것이 "응답에 필드를 더하는 것은 하위 호환 변경" 이라는 말의 뜻입니다.
그래서 이 경우의 알림은 컴파일러가 아니라 PR 의 계약 diff 가 해 줍니다. FE 는 새 필드를 화면에 쓰고 싶을 때 쓰면 됩니다.
경우 2 — 응답 필드의 이름을 바꾼다
이번에는 nickname 을 displayName 으로 바꿨다고 합시다. gen:api 뒤에 User 에서 nickname 이 사라지고 displayName 이 생깁니다. tsc 를 돌리면 이런 오류들이 나옵니다(예시 출력).
src/pages/MePage.tsx:…: error TS2339: Property 'nickname' does not exist on type '{ id: string; email: string; displayName: string; createdAt: string; }'.
src/pages/MePage.tsx:…: error TS2339: Property 'nickname' does not exist on type …
MePage 에서 session.user.nickname 을 쓰는 줄들입니다. 서버는 이미 displayName 으로 보내는데 FE 가 nickname 을 읽으면 런타임에는 undefined 가 화면에 찍혔을 것입니다. 그 일을 컴파일 단계에서 막았습니다.
그런데 PATCH /users/me 의 요청 본문 { nickname } 은 어떨까요? 요청 DTO(UpdateNicknameDto)는 따로 있으니, BE 가 그쪽을 바꾸지 않았다면 FE 의 요청 코드는 그대로 통과합니다. 응답 DTO 와 요청 DTO 가 따로 문서화된다는 것도 계약 diff 를 보며 알게 되는 사실입니다.
경우 3 — 요청 본문에 필수 필드를 더한다
마지막으로 가입 요청 SignupDto 에 필수 필드 agreedToTerms: boolean 을 더했다고 합시다. gen:api 뒤 tsc 는 SignupPage 의 authApi.POST('/auth/signup', { body: { … } }) 에서 "agreedToTerms 가 빠졌다" 는 오류를 냅니다. 이 경우가 가장 위험한 변경입니다. 이미 배포된 FE 는 새 필드를 보내지 않으니, BE 를 먼저 배포하는 순간 가입이 전부 400 VALIDATION_FAILED 가 됩니다. 컴파일러가 깨진 곳을 알려 주는 것은 FE 저장소 안의 이야기이고, 배포 순서는 여전히 사람이 정해야 합니다. 계약 diff 에서 required 목록에 요청 필드가 늘어난 것을 보면 리뷰어가 멈춰 세워야 하는 이유가 이것입니다.
세 경우를 표로 정리합니다.
| 변경 | tsc | 누가 알려 주나 | 배포 순서 |
|---|---|---|---|
| 응답 필드 추가 | 통과 | PR 의 계약 diff | 상관없음 |
| 응답 필드 이름 변경·삭제 | 읽는 곳에서 깨짐 | 컴파일러 | FE 를 먼저(두 이름을 다 받게) 또는 BE 가 한동안 둘 다 보냄 |
| 요청 필수 필드 추가 | 보내는 곳에서 깨짐 | 컴파일러 | BE 가 한동안 선택 필드로 받은 뒤 필수로 |
확인하기
다음이 모두 되면 11장으로 넘어갑니다.
-
계약 내보내기 —
api/에서pnpm openapi:export가 성공하고,jq '.paths | keys' ../contract/openapi.json에/auth/refresh를 포함한 13개 경로가 보입니다. -
DB 없이 내보내기 —
docker compose -f ../docker-compose.yml stop db뒤에도 1번이 성공합니다. 끝나면start db로 되돌립니다. -
문서화 규칙이 반영됐는지 — 다음 명령의 결과를 확인합니다.
$ jq '.components.schemas.ErrorResponseDto.properties.code' ../contract/openapi.json $ jq '.components.schemas.SessionDto.properties.userAgent' ../contract/openapi.json $ jq '.paths."/auth/sessions".get.responses."200".content."application/json".schema' ../contract/openapi.json기대: 첫 번째는
"$ref": "#/components/schemas/ErrorCode"를 포함하고, 두 번째는"nullable": true를 포함하고, 세 번째는"type": "array"를 포함합니다. -
타입 생성 —
web/에서pnpm gen:api가src/api/schema.d.ts를 만들고, 그 안에ErrorCode:와userAgent: string | null이 있습니다.$ grep -n "ErrorCode:\|userAgent: string | null" src/api/schema.d.ts -
타입 검사 —
src/api/client.ts를 만든 뒤pnpm exec tsc -b가 오류 없이 끝납니다. 템플릿의App.tsx가 아직 남아 있어도 이 단계는 통과해야 합니다. -
경로 오타가 잡히는지 —
client.ts끝에 잠깐void authApi.GET('/auth/refersh');를 적고tsc를 돌려 오류가 나는 것을 본 뒤 지웁니다.
자주 하는 실수
ENOENT: no such file or directory, open '../contract/openapi.json'
contract/ 폴더가 없습니다. writeFileSync 는 폴더를 만들지 않습니다. auth-lab/ 에서 mkdir -p contract 를 한 번 합니다.
내보내기가 JWT_ACCESS_SECRET 는 32자 이상이어야 한다 같은 설정 오류로 끝난다
api/.env 를 읽지 못했습니다. 스크립트는 api/ 폴더에서 실행해야 ConfigModule 이 .env 를 찾습니다. CI 라면 더미 환경변수를 넣습니다.
node dist/main.js 가 Cannot find module 로 실패한다
nest build 의 출력 위치가 다를 수 있습니다. tsconfig 의 포함 범위에 src/ 밖의 .ts 파일(예: 루트의 설정 파일)이 들어가면 출력이 dist/src/main.js 로 밀리는 경우가 있습니다. ls dist 로 실제 위치를 확인하고, tsconfig.build.json 의 include·exclude 를 점검하세요.
FE 타입에 필드가 없는데 서버 응답에는 있다
DTO 필드에 @ApiProperty 가 빠졌거나, 고친 뒤 openapi:export 를 다시 돌리지 않았습니다. schema.d.ts 를 손으로 고치지 말고 BE 를 고쳐 다시 내보냅니다.
error 의 타입이 이상하다 (never 이거나 속성이 없다)
그 엔드포인트에 에러 응답 데코레이터가 없습니다. 서버가 실제로 보내는 상태 코드마다 @ApiXxxResponse({ type: ErrorResponseDto, description: '…' }) 를 붙입니다.
Vite 가 5174 번으로 떴다 / Port 5173 is in use 로 실패한다
strictPort: true 가 없으면 조용히 5174 로 넘어가 CORS 와 CSRF Origin 검사에서 막힙니다. 있으면 실패합니다. 5173 을 쓰고 있는 다른 프로세스를 끄세요.
127.0.0.1:5173 으로 열었더니 모든 API 요청이 CORS 오류다
FE 는 반드시 http://localhost:5173 으로 엽니다. 127.0.0.1 은 WEB_ORIGINS 에 없고, 쿠키 입장에서도 다른 사이트입니다.
정리
- 이 책에서 계약은 경로·요청·응답·에러 코드·인증 수단의 목록이고, BE 코드의 데코레이터에서 자동으로 만들어집니다. FE 는 이 계약만 보고 일합니다.
- 문서에 없는 것은 FE 타입에도 없습니다. 그래서
@ApiProperty를 모든 필드에, 에러 응답 데코레이터를 모든 실패 상태에 붙이고,enumName·nullable·isArray로 타입 정보를 빠짐없이 전합니다. main.ts의--export-openapi분기로 서버와 같은 경로로 문서를 만들어contract/openapi.json에 씁니다.pg의Pool은 첫 쿼리 전까지 연결하지 않으므로 DB 없이도 됩니다.- 계약 파일을 커밋하면 PR 에서 계약의 변화가 보이고, FE 는 BE 서버 없이 타입을 만들 수 있습니다.
/docs-json은 같은 문서의 "지금 서버" 판입니다. openapi-fetch는{ data, error, response }를 돌려주고,error의 타입은 문서화된 실패 응답에서만 나옵니다. 에러 분기는code로 합니다.api와authApi는 지금은 같지만, 12장에서api에만authFetch를 끼웁니다.
연습문제
1. GET /auth/csrf 에는 에러 응답 데코레이터가 없습니다. 이 엔드포인트가 500 을 돌려준다면 FE 의 ensureCsrf 에서 런타임에 무슨 일이 일어날까요? 타입은 그것을 알려 줄 수 있을까요?
해답
ensureCsrf 는 const { data } = await authApi.GET('/auth/csrf') 뒤에 data!.csrfToken 을 읽습니다. 500 이면 data 가 undefined 이므로 undefined 의 속성을 읽다가 TypeError 가 던져집니다. 타입은 이것을 알려 주지 못합니다. 문서에 실패 응답이 없으니 타입 입장에서는 이 요청이 실패하지 않고, ! 단언은 그 판단을 개발자가 한 번 더 확인한 것입니다. 이 엔드포인트가 정말로 실패할 수 있다고 보면, BE 가 해당 응답을 문서화하고 FE 가 error 를 처리하도록 바꾸는 것이 계약을 따르는 방법입니다.
2. 팀원이 "contract/openapi.json 은 빌드 산출물이니 .gitignore 에 넣고, FE 는 늘 gen:api:live 를 쓰자" 고 제안했습니다. 이 제안을 따랐을 때 잃는 것을 두 가지 말해 보세요.
해답
첫째, PR 에서 계약의 변화가 보이지 않게 됩니다. 리뷰어는 DTO 코드 변경만 보고 그것이 API 사용자에게 어떤 영향을 주는지(필수 필드 추가인지, 이름 변경인지)를 따로 추론해야 합니다. 둘째, FE 개발자가 타입을 만들려면 BE 서버와 DB, .env 를 전부 띄워야 하고, 그렇게 만든 타입이 어느 커밋의 계약인지도 기록에 남지 않습니다. 브랜치를 오갈 때 FE 타입과 BE 서버의 버전이 어긋나기 쉽습니다.
3. 11장의 LoginPage 는 로그인 실패를 failure.code === 'TOO_MANY_REQUESTS' 한 가지로만 가릅니다. 모든 ErrorCode 에 대해 문구를 빠짐없이 정하도록 강제하고 싶다면 어떻게 쓸 수 있을까요? (부록 C 를 바꾸라는 뜻이 아니라, 타입으로 강제하는 방법을 묻는 문제입니다.)
해답
Record<ErrorCode, string> 타입의 객체를 쓰면 됩니다. ErrorCode 는 Schemas['ErrorCode'] 로 꺼낼 수 있습니다.
import type { Schemas } from '../api/client';
const ERROR_TEXT: Record<Schemas['ErrorCode'], string> = {
VALIDATION_FAILED: '입력값을 확인하세요.',
INVALID_CREDENTIALS: '이메일 또는 비밀번호가 맞지 않습니다.',
// … 12개를 전부 적어야 컴파일된다
};
키 하나라도 빠지면 컴파일 오류가 납니다. BE 가 새 에러 코드를 더하고 계약을 내보내면, gen:api 뒤에 이 객체에서 "NEW_CODE 가 빠졌다" 는 오류가 나서 FE 가 문구를 정하도록 강제됩니다. switch 를 쓴다면 default 에서 const unreachable: never = code; 를 두는 방법도 같은 효과를 냅니다. enumName 으로 ErrorCode 를 이름 붙은 타입으로 뽑아 두었기 때문에 이렇게 쓰기가 쉽습니다.
4. CI 에서 "계약 파일이 코드와 맞는지" 를 검사하는 단계를 만들고 싶습니다. DB 컨테이너 없이 어떤 명령 두 개로 검사할 수 있을까요?
해답
api/ 에서 pnpm openapi:export 를 돌린 뒤 git diff --exit-code ../contract/openapi.json 을 실행합니다. 개발자가 DTO 를 고치고 내보내기를 잊었다면 파일이 달라져 diff 가 생기고, --exit-code 때문에 명령이 실패해 CI 가 멈춥니다. pg 의 Pool 이 첫 쿼리 전까지 연결하지 않으므로 DB 는 필요 없고, AppConfig 검증을 통과할 더미 환경변수만 있으면 됩니다.
5. SessionDto.userAgent 의 데코레이터에서 type: String 을 빼고 nullable: true 만 남기면 계약 파일과 FE 타입은 어떻게 달라질까요?
해답
string | null 이라는 합집합 타입은 데코레이터 메타데이터에 Object 로 남기 때문에, @nestjs/swagger 는 이 필드를 문자열이 아니라 객체로 문서화합니다. 계약 파일에는 "type": "object" 에 가까운 모양이 들어가고, FE 타입은 string | null 이 아니라 객체 타입(또는 그에 준하는 것)과 null 의 합집합이 됩니다. 그러면 s.userAgent ?? '알 수 없음' 을 JSX 에 넣을 때 타입이 맞지 않는 등 엉뚱한 곳에서 오류가 납니다. 합집합 타입을 가진 필드는 type 을 명시해야 한다는 것이 이 규칙의 이유입니다.