타입 안전 REST API
타입 단언이 네트워크로 들어온 JSON을 검사하지 않는다는 점에서 출발해, Zod 4 스키마로 요청을 검증하고 그 스키마에서 타입을 얻으며, 저장 모델과 공개 응답을 나누고, 오류 응답의 모양과 OpenAPI 문서로 클라이언트와 API의 약속을 맞춥니다.
타입스크립트의 타입은 컴파일이 끝나면 사라집니다. 그래서 네트워크로 들어온 JSON이 선언한 인터페이스와 같은 모양이라는 것을 타입만으로는 보장할 수 없고, 앞의 두 글에서도 이 점을 여러 번 확인했습니다. 타입을 지키는 API는 요청이 들어오는 입구에서 값을 실제로 검사하고, 검사를 통과한 값에만 타입을 붙입니다.
이 글은 회원가입 API를 예로 들어, Zod로 검증 규칙을 적고 그 규칙에서 타입을 얻는 방법을 봅니다. 이어서 저장하는 모델과 클라이언트에 보내는 응답을 나누고, 오류 응답의 모양과 OpenAPI 문서로 클라이언트와의 약속을 정리합니다. 예제는 Node.js와 Express에서 설정한 프로젝트를 그대로 씁니다.
타입 단언의 한계
다음 코드는 요청 본문을 SignUpInput이라고 단언합니다. 회원가입에 필요한 세 필드를 인터페이스로 적고, 마지막 줄에서 as로 request.body를 그 타입이라고 단정하는 흔한 방식입니다. 이 코드는 타입 검사를 통과하고 편집기의 자동 완성도 잘 동작하지만, 그렇다고 값이 올바르다는 뜻은 아닙니다.
interface SignUpInput {
email: string;
password: string;
nickname: string;
}
const input = request.body as SignUpInput;as SignUpInput은 값을 검사하지 않고 컴파일러에게 그렇게 믿으라고 알릴 뿐입니다. 클라이언트가 email을 빠뜨리거나 password에 숫자를 보내도 실행 중에는 그대로 통과하고, 그 값을 쓰는 코드의 어딘가에서 뒤늦게 문제가 됩니다. 바깥에서 들어온 값은 unknown으로 받아 검사한 뒤에 업무에 쓰는 타입으로 좁혀야 합니다.
이 원칙은 HTTP 요청만의 이야기가 아닙니다. 환경 변수, 파일의 내용, 메시지 큐의 메시지, 다른 서비스의 응답처럼 프로그램 바깥에서 오는 모든 값이 같은 처지이며, 데이터베이스에서 읽은 값도 스키마가 바뀌었을 수 있다면 검사의 대상입니다.
Zod로 스키마 적기
Zod는 값의 모양과 규칙을 스키마(schema)로 적고, 그 스키마로 실행 중에 값을 검사하며, 같은 스키마에서 타입스크립트의 타입을 얻게 해 주는 라이브러리입니다. 2026년 10월 현재 최신인 Zod 4를 설치합니다.
npm install zodimport { z } from "zod";
export const signUpSchema = z.strictObject({
email: z.email("올바른 이메일을 입력하세요."),
password: z.string().min(10, "비밀번호는 10자 이상이어야 합니다."),
nickname: z.string().trim().min(2, "닉네임은 2자 이상이어야 합니다.").max(20, "닉네임은 20자 이하여야 합니다."),
});
export type SignUpInput = z.infer<typeof signUpSchema>;z.strictObject는 스키마에 없는 키가 들어오면 거절하는 객체 스키마이고, z.email()은 문자열이 이메일 형식인지 검사합니다. 규칙 뒤의 문자열은 그 규칙에 맞지 않을 때의 오류 메시지입니다. z.infer<typeof signUpSchema>는 스키마에서 { email: string; password: string; nickname: string } 타입을 만들어 주므로, 검증 규칙과 인터페이스를 따로 적다가 둘이 어긋나는 일이 없습니다.
nickname의 trim()은 검사만 하지 않고 값을 바꿉니다. 앞뒤 공백을 지운 뒤에 길이를 검사하므로, 검증에 성공하면 공백이 지워진 새 값을 얻습니다.
const ok = signUpSchema.safeParse({ email: "[email protected]", password: "correct-horse", nickname: " Ab " });
if (ok.success) {
console.log(JSON.stringify(ok.data.nickname));
}
const bad = signUpSchema.safeParse({ email: "mina", password: "short", nickname: " A ", role: "admin" });
if (!bad.success) {
console.log(JSON.stringify(z.flattenError(bad.error)));
}"Ab"
{"formErrors":["Unrecognized key: \"role\""],"fieldErrors":{"email":["올바른 이메일을 입력하세요."],"password":["비밀번호는 10자 이상이어야 합니다."],"nickname":["닉네임은 2자 이상이어야 합니다."]}}safeParse는 예외를 던지지 않고 success로 성공과 실패를 구별하는 판별 유니온을 돌려주므로, if (ok.success) 안에서만 ok.data를 읽을 수 있습니다. 두 번째 입력은 세 필드가 모두 규칙에 맞지 않고, " A "도 공백을 지우면 한 글자여서 실패합니다. z.flattenError는 오류를 필드별 메시지인 fieldErrors와 특정 필드에 속하지 않는 객체 전체의 오류인 formErrors로 나누며, 알 수 없는 키 role은 객체 전체의 오류로 분류됩니다.
더 깊이: 스키마의 입력 타입과 출력 타입
값을 바꾸는 규칙이 들어간 스키마에는 타입이 두 개 있습니다. 검사하기 전에 받아들이는 값의 입력 타입과, 검사와 변환을 마친 뒤 data로 나오는 값의 출력 타입입니다. Zod의 기본 안내는 z.string().transform((val) => val.length)를 예로 드는데, 이 스키마는 문자열을 받아 그 길이인 숫자를 내놓으므로 입력 타입은 string이고 출력 타입은 number입니다. 두 타입은 각각 z.input<typeof schema>와 z.output<typeof schema>로 꺼낼 수 있고, 이 글의 SignUpInput을 만든 z.infer는 출력 타입을 돌려줍니다.
signUpSchema의 trim()은 문자열을 문자열로 바꾸므로 두 타입이 같지만, 변환이 타입을 바꾸는 스키마라면 어느 쪽 타입을 쓰는지가 중요해집니다. 요청을 만들어 보내는 클라이언트의 코드는 서버가 받아들이는 모양, 곧 입력 타입에 맞추어야 하고, 검증을 통과한 값을 다루는 서버의 코드는 출력 타입을 씁니다. 같은 스키마에서 나온 타입이라도 z.infer 하나로 양쪽을 모두 적으면, 변환이 들어간 필드에서 클라이언트가 보낼 값의 타입이 틀리게 적힙니다.
라우트에서 검증하기
이제 회원가입 라우트에서 이 스키마를 씁니다. 먼저 라우트가 부를 회원 서비스를 만드는데, 데이터베이스 대신 메모리의 Map에 저장하는 교육용 대역입니다.
import { randomUUID } from "node:crypto";
import type { SignUpInput } from "../schemas/sign-up.js";
export interface CreatedMember {
id: string;
email: string;
nickname: string;
}
const demoMembers = new Map<string, CreatedMember>();
export async function createMember(input: SignUpInput): Promise<CreatedMember> {
const member: CreatedMember = { id: randomUUID(), email: input.email, nickname: input.nickname };
demoMembers.set(member.id, member);
return member;
}이 대역은 라우트의 흐름을 따라가 보기 위한 것이어서 비밀번호를 저장하지 않고, 같은 이메일로 두 번 가입해도 막지 않습니다. 실제 서비스라면 비밀번호를 해시해서 저장하고, 이메일이 이미 쓰이고 있는지 검사한 뒤 데이터베이스에 저장합니다.
import express from "express";
import { z } from "zod";
import { signUpSchema } from "../schemas/sign-up.js";
import { createMember } from "../services/members.js";
export const membersRouter = express.Router();
membersRouter.post("/", async (request, response) => {
const result = signUpSchema.safeParse(request.body);
if (!result.success) {
const { formErrors, fieldErrors } = z.flattenError(result.error);
response.status(400).json({
code: "INVALID_INPUT",
message: "회원가입 입력을 확인하세요.",
formErrors,
fields: fieldErrors,
});
return;
}
const member = await createMember(result.data);
response.status(201).json({ id: member.id, email: member.email, nickname: member.nickname });
});src/index.ts에서 app.use(express.json()); 뒤에 app.use("/members", membersRouter);로 이 라우터를 등록하면 POST /members 요청이 여기로 들어옵니다. 검증에 실패하면 저장하기 전에 400을 돌려주고, 성공하면 result.data, 즉 공백이 지워진 SignUpInput만 서비스에 넘깁니다. request.body를 그대로 넘기지 않으므로, 서비스는 검사하지 않은 값이나 trim() 전의 값을 볼 일이 없습니다.
타입 안전은 JSON 검증에서 시작해 공개 DTO에서 끝난다 — 상태 추적
signup safeParse/trim 뒤 검증 값과 201 공개 응답 또는 400 INVALID_INPUT이 달라진다. 원본 입력/검증/저장/공개 필드를 추적할 가치가 있다.
RUNTIME TRUST BOUNDARY
타입 안전은 JSON 검증에서 시작해 공개 DTO에서 끝난다
원문 회원가입은 Zod safeParse 뒤 메모리 대역을 호출하고 id·email·nickname만 201로 반환합니다. 권한 정책과 저장 모델→공개 DTO 변환은 뒤의 확장 설계이며, 실패는 400 INVALID_INPUT 계약으로 분리합니다.
단계별 조건과 보충 설명
- unknown JSON
신뢰 전 입력
- 런타임 스키마 검증
parse · reject
- 권한 정책
principal · resource
- 도메인 작업
검증된 값만
- 응답 allowlist
내부 필드 제거
- 공개 DTO
성공은 허용 필드, 실패는 별도 오류 계약
- 핵심 경계
- 검증된 흐름
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | unknown JSON: 신뢰 전 입력 |
| 원도표 조건·주석 | 런타임 스키마 검증: parse · reject |
| 원도표 조건·주석 | 권한 정책: principal · resource |
| 원도표 조건·주석 | 도메인 작업: 검증된 값만 |
| 원도표 조건·주석 | 응답 allowlist: 내부 필드 제거 |
| 원도표 조건·주석 | 공개 DTO: 성공은 허용 필드, 실패는 별도 오류 계약 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| raw signup | 입력: unknown body / 저장: 아직 없음 | 외부 입력을 Zod에 전달한다 | 정적 DTO 표기만으로 받은 값을 신뢰하지 않는다 |
| safeParse 결과 | 실패: 400 INVALID_INPUT / 통과: 검증·정규화 값 | 실패면 400 INVALID_INPUT, 통과면 trim된 검증 값을 얻는다 | 실패한 입력은 저장 경로로 가지 않는다 |
| 저장과 공개 응답 | HTTP: 201 / 공개: id·email·nickname | memory fake 결과에서 id/email/nickname만 201로 응답한다 | 저장 객체 전체를 자동으로 공개하지 않는다 |
타입 안전은 JSON 검증에서 시작해 공개 DTO에서 끝난다 — 상태 추적
signup safeParse/trim 뒤 검증 값과 201 공개 응답 또는 400 INVALID_INPUT이 달라진다. 원본 입력/검증/저장/공개 필드를 추적할 가치가 있다.
RUNTIME TRUST BOUNDARY
타입 안전은 JSON 검증에서 시작해 공개 DTO에서 끝난다
원문 회원가입은 Zod safeParse 뒤 메모리 대역을 호출하고 id·email·nickname만 201로 반환합니다. 권한 정책과 저장 모델→공개 DTO 변환은 뒤의 확장 설계이며, 실패는 400 INVALID_INPUT 계약으로 분리합니다.
단계별 조건과 보충 설명
- unknown JSON
신뢰 전 입력
- 런타임 스키마 검증
parse · reject
- 권한 정책
principal · resource
- 도메인 작업
검증된 값만
- 응답 allowlist
내부 필드 제거
- 공개 DTO
성공은 허용 필드, 실패는 별도 오류 계약
- 핵심 경계
- 검증된 흐름
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | unknown JSON: 신뢰 전 입력 |
| 원도표 조건·주석 | 런타임 스키마 검증: parse · reject |
| 원도표 조건·주석 | 권한 정책: principal · resource |
| 원도표 조건·주석 | 도메인 작업: 검증된 값만 |
| 원도표 조건·주석 | 응답 allowlist: 내부 필드 제거 |
| 원도표 조건·주석 | 공개 DTO: 성공은 허용 필드, 실패는 별도 오류 계약 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| raw signup | 입력: unknown body / 저장: 아직 없음 | 외부 입력을 Zod에 전달한다 | 정적 DTO 표기만으로 받은 값을 신뢰하지 않는다 |
| safeParse 결과 | 실패: 400 INVALID_INPUT / 통과: 검증·정규화 값 | 실패면 400 INVALID_INPUT, 통과면 trim된 검증 값을 얻는다 | 실패한 입력은 저장 경로로 가지 않는다 |
| 저장과 공개 응답 | HTTP: 201 / 공개: id·email·nickname | memory fake 결과에서 id/email/nickname만 201로 응답한다 | 저장 객체 전체를 자동으로 공개하지 않는다 |
그림처럼 unknown인 요청 JSON은 Zod의 safeParse를 지나며, 실패하면 저장하지 않고 400 INVALID_INPUT으로 돌아가고 성공하면 검증된 값만 메모리 대역에 저장됩니다. 응답에는 id, email, nickname 세 필드만 담아 201로 보냅니다. 그림의 권한 정책과, 저장 모델을 공개 응답으로 바꾸는 단계는 이 라우트를 넓힌 설계로 다음 절에서 봅니다.
저장 모델과 공개 응답 나누기
API를 오가는 데이터의 모양을 DTO(Data Transfer Object)라고 부릅니다. 회원가입의 입력, 데이터베이스에 저장하는 회원 모델, 클라이언트에 보여 주는 회원 정보는 쓰는 곳이 다르므로 서로 다른 타입으로 둡니다.
export interface MemberRecord {
id: string;
email: string;
passwordHash: string;
nickname: string;
role: "member" | "admin";
createdAt: Date;
}
export interface MemberResponse {
id: string;
email: string;
nickname: string;
createdAt: string;
}
export function toMemberResponse(member: MemberRecord): MemberResponse {
return {
id: member.id,
email: member.email,
nickname: member.nickname,
createdAt: member.createdAt.toISOString(),
} satisfies MemberResponse;
}저장 모델을 그대로 response.json에 넘기면 passwordHash와 role 같은 내부 필드가 함께 나갑니다. toMemberResponse처럼 공개할 필드만 골라 새 객체를 만드는 함수를 두면, 무엇을 공개하는지가 코드에 드러나고 저장 모델에 필드를 더해도 응답이 저절로 늘어나지 않습니다. Date는 JSON에 그대로 담을 수 없으므로 toISOString()으로 문자열로 바꾸었습니다.
satisfies MemberResponse는 이 객체가 응답의 모양에 맞는지 검사하면서 객체 리터럴의 타입을 그대로 둡니다. 필드를 빠뜨리거나 createdAt에 Date를 그대로 넣으면 진단되고, 객체 리터럴이므로 passwordHash처럼 응답에 없는 필드를 실수로 넣어도 진단됩니다. 다만 이것도 컴파일할 때의 검사이므로, 바깥에서 들어오는 입력에는 스키마 검증을, 서버가 직접 만드는 응답에는 타입 검사를 씁니다.
일관된 오류 응답
클라이언트가 오류를 안정적으로 처리하려면, 어떤 오류든 같은 모양으로 돌아와야 합니다. 다음 ApiError는 그 공통 모양을 타입으로 적은 것입니다. code를 문자열 리터럴 유니온으로 적었으므로, 정하지 않은 오류 코드를 넣으면 컴파일할 때 진단됩니다.
export interface ApiError {
code: "INVALID_INPUT" | "EMAIL_TAKEN" | "UNAUTHORIZED" | "INTERNAL_ERROR";
message: string;
formErrors?: string[];
fields?: Record<string, string[]>;
}code는 클라이언트의 프로그램이 분기에 쓰는 고정된 값이고, message는 사람이 읽는 설명입니다. 검증 오류는 앞의 라우트처럼 필드별 메시지를 fields에, 객체 전체의 메시지를 formErrors에 담습니다. 알 수 없는 키만 있는 요청은 fields가 빈 객체이고 formErrors에만 메시지가 들어가므로, 클라이언트는 fields가 비었다고 성공으로 보지 말고 상태 코드와 code로 판단해야 합니다.
서버 예외의 스택이나 데이터베이스 오류의 원문은 응답에 담지 않고 서버의 로그에만 남깁니다. 또 EMAIL_TAKEN을 타입에 적어 두었다고 그 오류가 생기는 것은 아니며, 이메일 중복을 실제로 검사하는 코드를 서비스에 만들어야 이 값이 쓰입니다.
OpenAPI로 약속 나누기
프론트엔드와 백엔드가 서로 다른 저장소에 있으면, 서버의 타입스크립트 타입을 클라이언트가 바로 가져다 쓰기 어렵습니다. 이때 OpenAPI 문서로 경로와 메서드, 요청과 응답의 스키마를 언어와 상관없는 형식으로 나눕니다.
openapi: 3.1.0
info:
title: Member API
version: 1.0.0
paths:
/members:
post:
summary: 회원가입
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SignUpInput"
responses:
"201":
description: 가입 완료
content:
application/json:
schema:
$ref: "#/components/schemas/CreatedMember"
"400":
description: 입력 오류
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
SignUpInput:
type: object
additionalProperties: false
required: [email, password, nickname]
properties:
email: { type: string, format: email }
password: { type: string, minLength: 10 }
nickname: { type: string, minLength: 2, maxLength: 20 }
CreatedMember:
type: object
required: [id, email, nickname]
properties:
id: { type: string, format: uuid }
email: { type: string, format: email }
nickname: { type: string }
ErrorResponse:
type: object
required: [code, message]
properties:
code: { type: string, enum: [INVALID_INPUT] }
message: { type: string }
formErrors: { type: array, items: { type: string } }
fields:
type: object
additionalProperties: { type: array, items: { type: string } }문서에는 성공 응답뿐 아니라 400 오류의 모양도 적어, 클라이언트가 실패도 계획해서 처리하게 합니다. 이 문서에서 클라이언트의 타입스크립트 타입을 만들어 주는 도구도 있지만, 만들어진 타입이 서버가 실제로 그렇게 동작한다는 것까지 보장하지는 않습니다.
SCHEMA DEPENDENCY
하나의 API 스키마를 네 검증 표면이 함께 소비한다
목표 구조: 본문의 Zod와 OpenAPI YAML은 수동으로 따로 작성되어 자동 동기화되지 않습니다. 네 표면을 같은 변경으로 맞추고, trim 같은 변환과 실제 오류 응답은 계약 테스트로 확인합니다. 생성 TypeScript 타입은 런타임 검증을 대신하지 않습니다.
- API 스키마
네 표면이 공통으로 소비할 변경 원천
- 서버 검증
runtime parser
- OpenAPI
public contract
- 생성 클라이언트
compile-time types
- 계약 테스트
wire compatibility
- 핵심 경계
- 검증된 흐름
그림은 하나의 스키마를 서버의 검증, OpenAPI 문서, 클라이언트 타입, 문서와 실제 응답을 비교하는 테스트가 함께 쓰는 목표 구조입니다. 이 글의 Zod 스키마와 YAML은 손으로 따로 적어서 자동으로 맞춰지지 않으며, 특히 trim() 같은 변환은 YAML의 길이 제약으로 나타낼 수 없습니다. Zod 4는 z.toJSONSchema()로 스키마를 JSON Schema로 바꿀 수 있어 문서를 스키마에서 만드는 데 쓸 수 있고, 어느 방식이든 실제 요청을 보내 응답이 문서와 같은지 확인하는 테스트로 문서와 구현이 함께 바뀌는지 확인합니다.
경로와 쿼리 검증
경로 매개변수와 쿼리 문자열도 클라이언트가 보낸 문자열이므로 검사의 대상입니다. 다음 파일에는 게시글 본문, 경로의 게시글 ID, 목록 쿼리를 검사하는 세 스키마가 있고, 맨 아래 세 줄은 쿼리 스키마에 서로 다른 입력을 넣어 결과를 출력합니다.
import { z } from "zod";
export const createPostSchema = z.strictObject({
title: z.string().trim().min(1).max(100),
content: z.string().trim().min(1).max(20_000),
});
export const postIdSchema = z.uuid();
export const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().min(1).max(100).default(20),
});
console.log(JSON.stringify(listQuerySchema.parse({})));
console.log(JSON.stringify(listQuerySchema.parse({ page: "3", size: "50" })));
console.log(listQuerySchema.safeParse({ page: "", size: "2" }).success);{"page":1,"size":20}
{"page":3,"size":50}
falsez.coerce.number()는 값을 Number(...)로 바꾼 뒤 검사하므로, 쿼리의 문자열 "3"이 숫자 3이 됩니다. 값이 없으면 default의 값을 쓰고, 빈 문자열은 Number("")가 0이어서 min(1)에 걸려 실패합니다. 이런 변환은 눈에 띄지 않게 일어나므로, 쿼리용 스키마를 따로 두어 어떤 값이 어떻게 바뀌는지를 한곳에 적어 둡니다.
게시글의 작성자 ID는 입력 스키마에 없습니다. 작성자는 클라이언트가 보낸 값이 아니라 인증을 거친 사용자 정보에서 가져와야 하므로, createPostSchema가 z.strictObject로 authorId를 거절하는 것은 의도한 동작입니다. 역할이나 권한처럼 보안과 관계된 값은 모두 같은 방식으로 다룹니다.
정리
- 타입 단언은 값을 검사하지 않으므로, 바깥에서 들어온 값은
unknown으로 받아 검사한 뒤에 타입을 붙입니다. - Zod는 스키마로 실행 중에 값을 검사하고
z.infer로 같은 스키마에서 타입을 얻으며,trim()같은 변환이 적용된 값은safeParse의data에서 얻습니다. - 검증에 실패하면 저장하기 전에 400을 돌려주고, 필드별 오류와 객체 전체의 오류를 나누어 일관된 모양으로 보냅니다.
- 저장 모델과 공개 응답을 다른 타입으로 두고, 공개할 필드만 고르는 변환 함수를 거쳐 응답합니다.
- OpenAPI 문서는 성공과 오류의 모양을 언어와 상관없이 나누지만, 문서와 구현이 맞는지는 실제 응답을 비교하는 테스트로 확인합니다.
- 경로와 쿼리도 문자열이므로 스키마로 바꾸고 검사하며, 작성자나 역할 같은 보안 정보는 클라이언트의 입력에서 받지 않습니다.
연습 문제
1. 변환된 닉네임
다른 필드가 모두 올바른 회원가입 요청에서 닉네임이 " Ab "인 경우와 " A "인 경우를 비교하세요. 성공한 경우 request.body.nickname과 서비스가 받는 닉네임은 각각 무엇인가요? YAML의 길이 제약만으로 검사하면 두 번째 요청도 거절되나요?
해설 보기
" Ab "는 성공하며, request.body.nickname은 원래 값 " Ab " 그대로이고 서비스는 공백이 지워진 "Ab"를 받습니다. " A "는 공백을 지우면 한 글자여서 거절됩니다. YAML에는 공백을 지우는 변환이 없어 원래 길이 3이 minLength: 2를 만족하므로, 문서의 제약만으로는 같은 거절을 재현하지 못하고 실제 응답을 확인하는 테스트가 필요합니다.
2. fields가 빈 오류
올바른 세 필드에 role: "admin"만 더한 요청을 보냈습니다. 응답의 상태 코드와 formErrors, fields는 무엇인가요? 클라이언트는 이 응답을 어떻게 판단해야 하나요?
해설 보기
상태 코드는 400이고, formErrors에는 Unrecognized key: "role"이 들어가며 fields는 빈 객체입니다. 알 수 없는 키는 특정 필드가 아니라 객체 전체의 오류이기 때문입니다. 클라이언트는 fields가 비었다고 성공으로 보지 말고, 상태 코드 400과 code의 INVALID_INPUT으로 실패를 판단한 뒤 formErrors의 메시지를 보여 줍니다.
3. 쿼리의 변환
listQuerySchema에 { size: "0" }과 { page: "2.5" }를 넣으면 각각 어떻게 되나요?
해설 보기
{ size: "0" }은 size가 숫자 0으로 바뀐 뒤 min(1)에 걸려 실패합니다. { page: "2.5" }는 숫자 2.5로 바뀌지만 int()에 걸려 실패합니다. 두 경우 모두 빠진 다른 필드는 기본값을 쓰지만, 한 필드라도 실패하면 객체 전체의 검증이 실패합니다.
다음 글 ORM 사용에서는 메모리 대역 대신 실제 데이터베이스에 저장하도록 ORM을 쓰고, 스키마 변경을 안전하게 적용하는 마이그레이션의 순서를 다룹니다.