타입 안전 REST API
요청 경계에서 런타임 검증을 수행하고 DTO와 응답 계약으로 타입 안전한 REST API를 설계합니다.
TypeScript의 타입은 컴파일이 끝나면 사라집니다.
따라서 네트워크로 들어온 JSON이 선언한 인터페이스와 같은 모양이라고 보장할 수 없습니다.
타입 안전한 API는 정적 타입만 믿지 않고 요청 경계에서 런타임 검증을 수행합니다.
이 절에서는 회원가입과 게시글 API를 예로 들어 DTO, 검증 스키마, 오류 응답과 API 계약을 연결합니다.
아래 다이어그램은 외부 JSON이 검증과 변환을 거쳐 업무 코드로 들어가는 경계를 보여줍니다.
TypeScript 타입은 런타임 입력을 검사하지 않는다. unknown에서 시작해 검증된 DTO만 업무 코드로 넘긴다.
- HTTP 요청
request.json()의 결과는 신뢰하지 않고 unknown으로 받는다.
- 런타임 검증
스키마가 필수 필드, 형식, 길이를 실제 값에서 확인한다.
- 입력 DTO
성공한 값만 SignUpInput처럼 좁혀진 타입을 얻는다.
- 업무 로직
서비스는 검증된 입력만 받아 회원가입 규칙에 집중한다.
- 응답 변환
내부 모델에서 공개해도 되는 필드만 응답 DTO로 만든다.
API 경계와 타입의 한계
다음 코드는 JSON 결과를 SignUpInput이라고 단언합니다.
interface SignUpInput {
email: string;
password: string;
nickname: string;
}
const input = (await request.json()) as SignUpInput;as SignUpInput은 값을 검사하지 않습니다.
클라이언트가 email을 빠뜨리거나 password에 숫자를 보내도 런타임에는 그대로 통과합니다.
외부 입력은 먼저 unknown으로 받고 검증이 끝난 뒤 업무 타입으로 좁혀야 합니다.
const raw: unknown = await request.json();이 원칙은 HTTP 요청뿐 아니라 환경 변수, 파일, 메시지 큐와 데이터베이스 조회 결과에도 적용됩니다.
Zod로 입력 검증하기
Zod는 검증 규칙과 TypeScript 타입을 한곳에서 정의할 수 있는 라이브러리입니다.
npm install zod회원가입 입력 스키마를 작성합니다.
import { z } from 'zod';
export const signUpSchema = z.object({
email: z.string().email('올바른 이메일을 입력하세요.'),
password: z.string().min(10, '비밀번호는 10자 이상이어야 합니다.'),
nickname: z.string().trim().min(2).max(20),
}).strict();
export type SignUpInput = z.infer<typeof signUpSchema>;z.infer는 스키마에서 TypeScript 타입을 추론합니다.
검증 규칙과 인터페이스를 따로 작성할 때 생기는 불일치를 줄일 수 있습니다.
아래 다이어그램은 unknown, 런타임 스키마와 추론된 DTO가 맡는 역할을 구분합니다.
외부 값의 상태가 바뀌는 세 지점을 분리하면 타입 단언 없이 안전하게 좁힐 수 있다.
- unknown
입력 아직 구조를 확인하지 않은 외부 값이다. 속성 접근을 허용하지 않는다.
- Zod 스키마
검증 이메일 형식과 비밀번호 길이를 런타임에 검사한다.
- SignUpInput
결과 safeParse 성공 뒤 업무 함수가 사용할 수 있는 타입이다.
비밀번호 확인처럼 저장하지 않는 입력은 별도 폼 스키마에 두고, 저장 DTO로 변환할 때 제거합니다.
Express 라우트에서 검증하기
safeParse()는 성공과 실패를 구분한 판별 유니온을 반환합니다.
라우트가 호출할 회원 서비스의 입력과 반환 타입도 명시합니다.
다음 구현은 데이터베이스 대신 메모리를 사용하는 교육용 저장소 대역입니다.
import { randomUUID } from 'node:crypto';
import type { SignUpInput } from '../schemas/sign-up';
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 = {
id: randomUUID(),
email: input.email,
nickname: input.nickname,
} satisfies CreatedMember;
demoMembers.set(member.id, member);
return member;
}이 대역은 라우트의 타입 흐름을 실행해 보기 위한 것이므로 비밀번호를 저장하지 않습니다.
운영 서비스에서는 비밀번호를 안전하게 해시하고 이메일 중복을 검사한 뒤 데이터베이스 저장소를 호출합니다.
import { Router } from 'express';
import { signUpSchema } from '../schemas/sign-up';
import { createMember } from '../services/members';
export const membersRouter = Router();
membersRouter.post('/', async (request, response) => {
const result = signUpSchema.safeParse(request.body);
if (!result.success) {
return response.status(400).json({
code: 'INVALID_INPUT',
message: '회원가입 입력을 확인하세요.',
fields: result.error.flatten().fieldErrors,
});
}
const member = await createMember(result.data);
return response.status(201).json({
id: member.id,
email: member.email,
nickname: member.nickname,
});
});라우터를 선언한 뒤에는 Express 애플리케이션에 마운트해야 실제 POST /members 요청이 도달합니다.
import express from 'express';
import { membersRouter } from './routes/members';
const app = express();
app.use(express.json());
app.use('/members', membersRouter);
app.listen(3000, () => {
console.log('API server: http://localhost:3000');
});검증에 성공한 result.data는 SignUpInput 타입입니다.
업무 함수는 더 이상 알 수 없는 요청 객체 전체를 받을 필요가 없습니다.
비밀번호 해시나 내부 권한 같은 필드는 응답에 포함하지 않습니다.
DTO로 입력과 응답 분리
DTO(Data Transfer Object)는 경계를 넘는 데이터 모양을 표현합니다.
회원가입 입력, 데이터베이스 회원 모델, 공개 회원 응답은 서로 목적이 다릅니다.
interface MemberRecord {
id: string;
email: string;
passwordHash: string;
nickname: string;
role: 'member' | 'admin';
createdAt: Date;
}
interface MemberResponse {
id: string;
email: string;
nickname: string;
createdAt: string;
}
function toMemberResponse(member: MemberRecord): MemberResponse {
return {
id: member.id,
email: member.email,
nickname: member.nickname,
createdAt: member.createdAt.toISOString(),
};
}데이터베이스 모델을 그대로 응답하면 내부 필드가 우연히 노출될 수 있습니다.
명시적 변환 함수는 공개 계약과 저장 구조의 경계를 드러냅니다.
아래 다이어그램은 입력 DTO·저장 모델·응답 DTO 사이에서 허용하는 필드를 비교합니다.
회원가입 입력, 저장 모델, 공개 응답은 목적이 다르므로 같은 객체를 그대로 돌려쓰지 않는다.
- 필드
입력 DTO 저장 모델 · 응답 DTO
- email
필수 · 형식 검증 저장 · 공개
- password
평문 입력 해시만 저장 · 비공개
- nickname
길이 검증 저장 · 공개
- role
클라이언트 입력 금지 서버 결정 · 필요할 때만 공개
- createdAt
입력 없음 Date 저장 · ISO 문자열 응답
일관된 오류 응답
클라이언트가 오류를 안정적으로 처리하려면 응답 모양을 통일해야 합니다.
interface ApiError {
code: 'INVALID_INPUT' | 'EMAIL_TAKEN' | 'UNAUTHORIZED' | 'INTERNAL_ERROR';
message: string;
fields?: Record<string, string[]>;
}code는 프로그램이 분기하는 안정적인 값입니다.
message는 사용자가 읽을 설명입니다.
필드별 검증 문제는 fields에 담습니다.
서버 예외의 스택과 데이터베이스 오류 원문은 응답에 노출하지 않습니다.
아래 다이어그램은 검증·인증·권한·서버 실패를 상태 코드와 오류 계약에 연결합니다.
클라이언트는 문자열 메시지가 아니라 안정적인 code와 HTTP 상태를 기준으로 실패를 처리한다.
- 실패 경계
HTTP · code 클라이언트 처리
- 입력 검증
400 · INVALID_INPUT fields를 각 입력칸에 표시
- 인증 없음
401 · UNAUTHORIZED 로그인 화면 또는 재인증
- 권한 부족
403 · FORBIDDEN 허용되지 않은 동작 안내
- 이메일 중복
409 · EMAIL_TAKEN 중복 이메일 수정 요청
- 서버 오류
500 · INTERNAL_ERROR 일반 안내 · 상세 원문 비공개
응답 타입을 실제 값에 적용하기
satisfies를 사용하면 객체의 추론 정보를 유지하면서 공개 응답 계약을 검사할 수 있습니다.
function buildMemberResponse(member: MemberRecord) {
const responseBody = {
id: member.id,
email: member.email,
nickname: member.nickname,
createdAt: member.createdAt.toISOString(),
} satisfies MemberResponse;
return responseBody;
}필수 필드가 빠지거나 타입이 달라지면 컴파일 오류가 발생합니다.
다만 satisfies도 런타임 검증은 수행하지 않습니다.
외부에서 들어오는 입력에는 스키마 검증을, 내부에서 만드는 응답에는 타입 검사를 사용합니다.
라우트에서는 buildMemberResponse(member)가 반환한 값을 상태 코드와 함께 전송합니다.
OpenAPI로 HTTP 계약 공유
프론트엔드와 백엔드가 별도 프로젝트라면 TypeScript 타입을 직접 import하기 어렵습니다.
이때 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
fields:
type: object
additionalProperties:
type: array
items:
type: stringOpenAPI 문서에서는 성공 응답뿐 아니라 오류 상태도 계약으로 남깁니다.
문서에서 클라이언트 타입을 생성할 수 있지만 생성된 타입이 서버의 실제 동작을 자동으로 보장하지는 않습니다.
통합 테스트와 스키마 검증으로 구현과 계약이 함께 바뀌는지 확인합니다.
아래 다이어그램은 런타임 스키마, OpenAPI 문서와 생성된 클라이언트 타입의 관계를 정리합니다.
한 경계의 규칙이 런타임 검증, OpenAPI, 생성 타입과 통합 테스트에서 같은 의미를 가리켜야 한다.
- 런타임 스키마
서버가 실제 요청과 응답 값을 검사한다.
- OpenAPI 문서
경로, 메서드, 상태 코드와 스키마를 팀에 공개한다.
- 생성 타입
프론트엔드가 같은 계약으로 요청 코드를 작성한다.
- 통합 테스트
구현과 문서가 함께 바뀌는지 실제 HTTP로 확인한다.
게시글 API에 적용하기
게시글 작성 입력은 제목과 본문만 받습니다.
작성자 ID는 클라이언트 입력을 믿지 않고 인증 세션에서 가져옵니다.
const createPostSchema = z.object({
title: z.string().trim().min(1).max(100),
content: z.string().trim().min(1).max(20_000),
});
type CreatePostInput = z.infer<typeof createPostSchema>;경로 매개변수와 검색 조건도 문자열이므로 검증 대상입니다.
const postIdSchema = z.string().uuid();
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().min(1).max(100).default(20),
});z.coerce.number()는 쿼리 문자열을 숫자로 바꾼 뒤 범위를 검사합니다.
변환 규칙이 숨겨지지 않도록 쿼리 전용 스키마에 명시합니다.
설계 기준
외부 입력은 unknown에서 시작합니다.
런타임 스키마를 통과한 값만 업무 함수에 전달합니다.
저장 모델과 공개 응답 DTO를 분리합니다.
성공과 실패 응답의 상태 코드와 모양을 계약으로 관리합니다.
클라이언트가 보낸 작성자 ID와 역할 같은 보안 정보를 신뢰하지 않습니다.
TypeScript의 정적 타입과 런타임 검증을 함께 사용할 때 API 경계가 실제로 안전해집니다.
마지막 다이어그램으로 요청 수신부터 안전한 응답까지의 전체 흐름을 점검합니다.
회원가입 요청은 검증 성공과 실패를 먼저 나눈 뒤 업무 처리와 공개 응답 변환으로 이어진다.
- 요청 수신
JSON을 unknown으로 받고 signUpSchema.safeParse를 실행한다.
- 400 오류 응답
실패 INVALID_INPUT과 필드별 문제만 반환한다.
- 회원 생성
성공 검증된 DTO로 중복 확인, 해시, 저장을 수행한다.
- 응답 DTO 변환
passwordHash와 내부 권한을 제거한다.
- 계약 검사
satisfies로 공개 응답 필드를 확인한다.
- 201 응답
안전한 회원 정보와 Location을 반환한다.