본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : 백엔드 개발

타입 안전 REST API

요청 경계에서 런타임 검증을 수행하고 DTO와 응답 계약으로 타입 안전한 REST API를 설계합니다.

TypeScript의 타입은 컴파일이 끝나면 사라집니다.

따라서 네트워크로 들어온 JSON이 선언한 인터페이스와 같은 모양이라고 보장할 수 없습니다.

타입 안전한 API는 정적 타입만 믿지 않고 요청 경계에서 런타임 검증을 수행합니다.

이 절에서는 회원가입과 게시글 API를 예로 들어 DTO, 검증 스키마, 오류 응답과 API 계약을 연결합니다.

아래 다이어그램은 외부 JSON이 검증과 변환을 거쳐 업무 코드로 들어가는 경계를 보여줍니다.


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

회원가입 입력 스키마를 작성합니다.

src/schemas/sign-up.ts
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가 맡는 역할을 구분합니다.

비밀번호 확인처럼 저장하지 않는 입력은 별도 폼 스키마에 두고, 저장 DTO로 변환할 때 제거합니다.


Express 라우트에서 검증하기

safeParse()는 성공과 실패를 구분한 판별 유니온을 반환합니다.

라우트가 호출할 회원 서비스의 입력과 반환 타입도 명시합니다.

다음 구현은 데이터베이스 대신 메모리를 사용하는 교육용 저장소 대역입니다.

src/services/members.ts
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;
}

이 대역은 라우트의 타입 흐름을 실행해 보기 위한 것이므로 비밀번호를 저장하지 않습니다.

운영 서비스에서는 비밀번호를 안전하게 해시하고 이메일 중복을 검사한 뒤 데이터베이스 저장소를 호출합니다.

src/routes/members.ts
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 요청이 도달합니다.

src/index.ts
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.dataSignUpInput 타입입니다.

업무 함수는 더 이상 알 수 없는 요청 객체 전체를 받을 필요가 없습니다.

비밀번호 해시나 내부 권한 같은 필드는 응답에 포함하지 않습니다.


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 사이에서 허용하는 필드를 비교합니다.


일관된 오류 응답

클라이언트가 오류를 안정적으로 처리하려면 응답 모양을 통일해야 합니다.

interface ApiError {
  code: 'INVALID_INPUT' | 'EMAIL_TAKEN' | 'UNAUTHORIZED' | 'INTERNAL_ERROR';
  message: string;
  fields?: Record<string, string[]>;
}

code는 프로그램이 분기하는 안정적인 값입니다.

message는 사용자가 읽을 설명입니다.

필드별 검증 문제는 fields에 담습니다.

서버 예외의 스택과 데이터베이스 오류 원문은 응답에 노출하지 않습니다.

아래 다이어그램은 검증·인증·권한·서버 실패를 상태 코드와 오류 계약에 연결합니다.


응답 타입을 실제 값에 적용하기

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.yaml
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: string

OpenAPI 문서에서는 성공 응답뿐 아니라 오류 상태도 계약으로 남깁니다.

문서에서 클라이언트 타입을 생성할 수 있지만 생성된 타입이 서버의 실제 동작을 자동으로 보장하지는 않습니다.

통합 테스트와 스키마 검증으로 구현과 계약이 함께 바뀌는지 확인합니다.

아래 다이어그램은 런타임 스키마, OpenAPI 문서와 생성된 클라이언트 타입의 관계를 정리합니다.


게시글 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 경계가 실제로 안전해집니다.

마지막 다이어그램으로 요청 수신부터 안전한 응답까지의 전체 흐름을 점검합니다.