본문으로 건너뛰기

안동민 개발노트

본문 시작

RESTful API 설계 원칙과 구현

REST 제약과 HTTP 의미를 구분하고, 검증·페이지네이션·버저닝·직렬화 경계를 갖춘 NestJS 사용자 API를 구현합니다.

4장에서 인증과 권한 부여를 다뤘다면, 이번 장부터는 그 정책을 적용할 HTTP API 계약을 설계합니다.

REST(Representational State Transfer)는 특정 프레임워크나 URL 작명 규칙이 아니라 분산 하이퍼미디어 시스템을 위한 아키텍처 스타일입니다. NestJS의 데코레이터는 HTTP 계약을 코드로 옮기는 도구이며, 데코레이터 자체가 API를 REST로 만들어 주지는 않습니다.


REST와 리소스 중심 HTTP API

REST의 필수 제약은 클라이언트-서버 분리, 무상태 상호작용, 캐시, 유니폼 인터페이스, 계층화 시스템입니다. 코드를 내려 보내 클라이언트 기능을 확장하는 code-on-demand만 선택 제약입니다.

유니폼 인터페이스는 다시 다음 네 제약으로 구체화됩니다.

자원 식별: URI는 서버 내부 클래스나 테이블이 아니라 클라이언트가 다루는 개념적 자원을 식별합니다.

표현을 통한 조작: 클라이언트와 서버는 JSON 같은 표현을 교환합니다. 자원과 현재 표현은 같은 개념이 아닙니다.

자체 설명 메시지: 메서드, 상태 코드, 미디어 타입, 캐시 지시자처럼 메시지를 해석할 정보가 요청과 응답에 드러납니다.

애플리케이션 상태의 엔진인 하이퍼미디어: 서버가 제공한 링크와 제어 정보가 가능한 다음 전이를 안내합니다.

무상태라는 말은 서버가 사용자나 주문 같은 자원 상태를 저장하지 않는다는 뜻이 아닙니다. 각 요청을 처리하는 데 필요한 인증 정보와 문맥을 이전 요청의 대화 상태에 의존하지 않고 요청 자체에서 얻는다는 뜻입니다.

실무에서 HATEOAS를 제공하지 않는 JSON CRUD API도 흔히 “REST API”라고 부릅니다. 엄밀히는 REST의 모든 제약을 충족한다고 단정하기보다 리소스 중심 HTTP API라고 설명하는 편이 정확합니다. 이 장에서는 HTTP 의미를 지키는 리소스 중심 API를 구현합니다.


URI, 메서드와 응답을 하나의 계약으로 묶기

URI 작명과 복수형은 REST의 별도 제약이 아니라 팀이 일관되게 정할 설계 관례입니다.

  • 컬렉션은 /v1/users, 단일 자원은 /v1/users/42처럼 안정된 명사형 경로로 식별합니다.
  • /getUsers/createUser처럼 메서드 의미를 경로에 반복하지 않습니다.
  • 하위 경로는 소유 관계나 수명 주기가 실제로 종속될 때만 사용합니다. 깊은 /users/42/posts/9/comments/3 대신 독립 자원과 필터를 고려합니다.
  • ?page=2&limit=20 같은 query는 컬렉션의 선택·정렬·페이지를 표현합니다. query도 target URI와 캐시 키의 일부이므로 이름과 기본값을 문서화합니다.
  • breaking change에만 버전을 올립니다. Nest URI 버저닝을 쓰면 global prefix 뒤, controller 경로 앞에 v1이 붙습니다.
사용자 컬렉션과 단일 사용자 URI에 GET, POST, PUT, PATCH, DELETE의 안전성, 멱등성, 성공 상태와 오류 경계를 연결한 HTTP 계약표

Nest · Resource-oriented HTTP Contract

URI는 대상을, 메서드는 의도한 효과를 식별한다

명사형 URI는 팀 관례로 일관되게 유지하고, 재시도 가능성은 응답이 아니라 메서드가 요청한 효과를 기준으로 판단합니다.

컬렉션과 멤버

/v1/users는 컬렉션, /v1/users/42는 단일 자원을 식별합니다.

경로에 get·create 같은 CRUD 동사를 반복하지 않습니다.

선택과 페이지

?page=2&limit=20은 컬렉션의 선택 범위를 표현합니다.

query도 target URI와 캐시 키에 포함되므로 기본값과 상한을 계약으로 고정합니다.

표현 메타데이터

Content-Type은 보낸 표현, Accept는 원하는 응답 표현을 말합니다.

ETag는 응답 validator입니다.

재검증 헤더: If-None-Match

변경 조건 헤더: If-Match

사용자 자원에 적용한 HTTP 메서드 의미와 응답 경계
요청 의미 · 재시도 성공 주요 경계

GET

컬렉션 URI

페이지로 나눈 컬렉션 표현 조회

안전 · 멱등

200 · 빈 결과도 items: [] query 형식은 400, 인증·정책은 401/403

GET

멤버 URI

단일 사용자 표현 조회

안전 · 멱등

200 · validator 일치 시 304 잘못된 ID는 400, 없는 자원은 404

POST

컬렉션 URI

컬렉션에 서버가 식별자를 정한 자원 생성

안전하지 않음 · 메서드 의미는 비멱등

201 · Location · 생성 표현 DTO는 400, 고유성 충돌은 409

PUT

멤버 URI

알려진 target의 현재 표현을 전체 교체

안전하지 않음 · 멱등

기존 자원 200/204 · 새 target 생성 201 전체 교체 규칙과 생성 지원 여부를 API가 명시

PATCH

멤버 URI

정의된 patch document를 부분 적용

안전하지 않음 · 멱등 보장 없음

200 또는 본문 없는 204

문서 400 · 미디어 타입 415

적용 불가 422 · If-Match 불일치 412

DELETE

멤버 URI

target과의 현재 연결을 제거

안전하지 않음 · 멱등

본문 없는 204 반복 응답이 404여도 의도한 효과는 동일

조회 · GET

/v1/users/v1/users/:id를 조회합니다. GET은 안전하고 멱등적입니다.

빈 컬렉션은 200items: []입니다. validator가 같으면 304, malformed ID는 400, 없는 멤버는 404입니다.

생성 · POST

컬렉션에 새 자원을 만들고 201, Location, 생성 표현을 반환합니다.

안전하지 않고 메서드 의미는 비멱등입니다. 안전한 재시도는 별도 idempotency-key 계약이 필요합니다.

전체 교체 · PUT

알려진 target의 현재 표현을 전체 교체합니다. 안전하지 않지만 멱등적입니다.

기존 자원은 200/204, 새 target 생성은 201입니다. 생성을 지원하지 않는 정책도 명시합니다.

부분 적용 · PATCH

patch document 의미와 미디어 타입을 정의합니다. 안전하지 않고 자체로는 멱등을 보장하지 않습니다.

성공은 최신 표현의 200 또는 본문 없는 204입니다.

문서 400 · 미디어 타입 415 · 적용 불가 422

ETag는 응답 validator입니다.

If-Match 불일치는 저장 없이 412로 거부합니다.

삭제 · DELETE

안전하지 않지만 의도한 삭제 효과는 멱등적이며 성공 시 본문 없는 204를 사용할 수 있습니다.

반복 요청의 응답 코드가 달라도 멱등성은 깨지지 않습니다.

같은 경로에서도 메서드가 달라지면 요청의 의미와 재시도 조건이 달라집니다. 조건부 요청을 제공한다면 validator 생성·저장과 304·412 판정을 코드와 테스트로 함께 고정합니다.

HTTP 메서드 의미와 멱등성

  • GETHEAD는 안전한 메서드입니다. 클라이언트가 서버 상태 변경을 요청하지 않으며, 같은 요청을 반복해도 의도한 효과가 같습니다. 접근 로그처럼 부수적으로 생기는 내부 효과는 안전성 정의와 충돌하지 않습니다.
  • PUT은 알려진 target 자원의 현재 표현을 요청 본문으로 전체 교체할 때 사용하며 멱등적입니다.
  • DELETE도 의도한 효과가 멱등적입니다. 첫 요청은 204, 반복 요청은 404가 될 수 있어도 삭제 상태라는 효과는 같습니다.
  • POST의 메서드 의미는 멱등적이지 않습니다. 재시도가 필요한 생성 API는 서버가 idempotency key와 결과 저장 규칙을 별도 계약으로 제공해야 합니다.
  • PATCH는 안전하지도, 본질적으로 멱등적이지도 않습니다. 같은 patch document가 멱등적으로 적용되도록 설계할 수 있습니다. 동시 수정 충돌을 막으려면 응답의 강한 ETag를 validator로 보내고 변경 요청의 If-Match를 검사하며, 조건이 맞지 않으면 412 Precondition Failed를 반환합니다. 캐시 재검증에서는 If-None-Match가 현재 validator와 일치할 때 304 Not Modified를 사용할 수 있습니다.
  • 이 예제의 PATCH body는 애플리케이션이 정의한 “필드 병합” 문서입니다. 표준 JSON Merge Patch나 JSON Patch를 제공한다면 각각의 미디어 타입과 null·배열·연산 의미를 따로 문서화해야 합니다.

아래 메모리 예제는 validator 저장과 조건부 요청을 구현하지 않습니다. 실제 API는 강한 validator를 생성·갱신하고 If-Match를 원자적인 저장 조건과 함께 검사하거나, 그렇지 않다면 last-write-wins 정책을 명시해야 합니다. adapter의 기본 동작에 맡기지 말고 304412를 계약 테스트로 고정합니다.

상태 코드와 표현

성공 응답도 모두 200으로 통일하지 않습니다.

  • 컬렉션 조회는 비어 있어도 200 OKitems: []를 반환합니다. 컬렉션 자체가 없다는 모델이 아니라면 404가 아닙니다.
  • 생성은 201 Created와 생성된 자원을 가리키는 Location을 반환합니다.
  • 기존 자원 수정은 최신 표현을 보낼 때 200, 본문이 없을 때 204를 사용할 수 있습니다. PUT이 새 target을 생성했다면 201 Created를 반환합니다.
  • 204 No Content에는 응답 content를 넣지 않습니다.
  • 이 예제의 ValidationPipe와 path·query 검증 실패는 400입니다. PATCH 계약에서는 malformed document 400, 지원하지 않는 patch 미디어 타입 415, 문법은 유효하지만 적용할 수 없는 document 422, 실패한 If-Match 조건 412를 구분할 수 있습니다.
  • 인증 실패는 401, 인증됐지만 정책상 거부되면 403입니다.
  • 없는 단일 자원은 404, 이메일 고유 제약 같은 현재 자원 상태와의 충돌은 409입니다.
  • 예상하지 못한 서버 오류는 내부에 원인을 기록하되 클라이언트에는 구현 세부나 stack을 넣지 않은 500을 보냅니다.

요청의 Content-Type은 보낸 표현의 형식을 말하고, Accept는 받고 싶은 응답 형식을 말합니다. JSON API라면 성공 표현에 application/json을 사용하고, 일관된 오류 표현이 필요하면 RFC 9457의 application/problem+json을 전역 예외 필터에서 적용할 수 있습니다.


NestJS로 실행 가능한 사용자 API 만들기

예제는 메모리 저장소를 사용합니다. 실제 서비스에서는 service의 저장 부분을 TypeORM, Prisma, Mongoose 같은 영속성 계층과 트랜잭션으로 바꿉니다.

먼저 모듈과 필요한 패키지를 준비합니다.

nest g mo users
nest g co users
nest g s users
npm install class-validator class-transformer @nestjs/mapped-types

요청 DTO

DTO는 런타임 메타데이터가 남는 class로 선언합니다. interface나 type alias만으로는 ValidationPipe가 런타임 검증 규칙을 얻을 수 없습니다.

src/users/dto/create-user.dto.ts
import { Transform } from 'class-transformer';
import { IsEmail, IsString, Length, MaxLength } from 'class-validator';

export class CreateUserDto {
  @Transform(({ value }) =>
    typeof value === 'string' ? value.trim() : value,
  )
  @IsString()
  @Length(1, 80)
  name!: string;

  @Transform(({ value }) =>
    typeof value === 'string' ? value.trim() : value,
  )
  @IsEmail()
  @MaxLength(254)
  email!: string;
}
src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

페이지네이션 query도 문자열을 직접 Number()로 바꾸지 않고 DTO에서 변환과 범위를 함께 검증합니다.

src/users/dto/list-users-query.dto.ts
import { Type } from 'class-transformer';
import { IsInt, Max, Min } from 'class-validator';

export class ListUsersQueryDto {
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page = 1;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit = 20;
}

출력 DTO

저장 모델을 그대로 반환하면 password hash, 내부 상태, 새 컬럼이 API에 우연히 노출될 수 있습니다. 이 예제는 허용 필드만 복사하는 출력 DTO를 명시적으로 사용합니다.

src/users/dto/user-response.dto.ts
import type { UserRecord } from '../users.service';

export class UserResponseDto {
  constructor(
    public readonly id: number,
    public readonly name: string,
    public readonly email: string,
  ) {}

  static from(user: UserRecord): UserResponseDto {
    return new UserResponseDto(user.id, user.name, user.email);
  }
}

export interface UsersPageResponse {
  items: UserResponseDto[];
  page: number;
  limit: number;
  total: number;
}

ClassSerializerInterceptor@Exclude()를 선택해도 됩니다. 이 경우 Nest가 직렬화할 실제 class instance를 반환하는지 확인해야 합니다. 중요한 점은 persistence entity와 외부 response schema의 경계를 고정하는 것입니다.

Service: 자원 규칙과 저장 책임

service는 이메일 고유성, 조회·생성·수정·삭제와 페이지 계산을 맡습니다. HTTP 상태 코드는 controller가 domain 결과를 HTTP 응답으로 번역할 때 결정합니다.

src/users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface UserRecord {
  readonly id: number;
  readonly name: string;
  readonly email: string;
}

export interface UsersPage {
  items: UserRecord[];
  total: number;
}

export class EmailAlreadyUsedError extends Error {}

type CreateUserInput = Pick<UserRecord, 'name' | 'email'>;
type UpdateUserInput = Partial<CreateUserInput>;

@Injectable()
export class UsersService {
  private users: UserRecord[] = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
  ];
  private nextId = 3;

  findAll(page: number, limit: number): UsersPage {
    const start = (page - 1) * limit;

    return {
      items: this.users.slice(start, start + limit),
      total: this.users.length,
    };
  }

  findOne(id: number): UserRecord | undefined {
    return this.users.find((user) => user.id === id);
  }

  create(input: CreateUserInput): UserRecord {
    this.assertEmailAvailable(input.email);

    const user: UserRecord = { id: this.nextId++, ...input };
    this.users.push(user);
    return user;
  }

  update(id: number, input: UpdateUserInput): UserRecord | undefined {
    const index = this.users.findIndex((user) => user.id === id);
    if (index === -1) return undefined;

    if (input.email !== undefined) {
      this.assertEmailAvailable(input.email, id);
    }

    const user: UserRecord = { ...this.users[index], ...input, id };
    this.users[index] = user;
    return user;
  }

  remove(id: number): boolean {
    const index = this.users.findIndex((user) => user.id === id);
    if (index === -1) return false;

    this.users.splice(index, 1);
    return true;
  }

  private assertEmailAvailable(email: string, exceptId?: number): void {
    const normalized = email.toLowerCase();
    const exists = this.users.some(
      (user) =>
        user.id !== exceptId && user.email.toLowerCase() === normalized,
    );

    if (exists) throw new EmailAlreadyUsedError();
  }
}

UpdateUserDto에는 id가 없고 service의 update input도 name·email만 허용합니다. 따라서 body가 식별자를 덮어쓰지 못합니다.

Controller: HTTP 계약과 domain 결과 번역

ParseIntPipe는 잘못된 ID를 handler 실행 전에 400으로 거부합니다. unary +id처럼 NaN을 service까지 전달해 404로 오분류하지 않습니다.

src/users/users.controller.ts
import {
  Body,
  ConflictException,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  NotFoundException,
  Param,
  ParseIntPipe,
  Patch,
  Post,
  Query,
  Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import {
  UserResponseDto,
  type UsersPageResponse,
} from './dto/user-response.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import {
  EmailAlreadyUsedError,
  UsersService,
} from './users.service';

@Controller({ path: 'users', version: '1' })
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll(@Query() query: ListUsersQueryDto): UsersPageResponse {
    const result = this.usersService.findAll(query.page, query.limit);

    return {
      items: result.items.map(UserResponseDto.from),
      page: query.page,
      limit: query.limit,
      total: result.total,
    };
  }

  @Get(':id')
  findOne(
    @Param('id', ParseIntPipe) id: number,
  ): UserResponseDto {
    const user = this.usersService.findOne(id);
    if (!user) throw new NotFoundException('User not found');

    return UserResponseDto.from(user);
  }

  @Post()
  create(
    @Body() dto: CreateUserDto,
    @Res({ passthrough: true }) response: Response,
  ): UserResponseDto {
    try {
      const user = this.usersService.create(dto);
      response.setHeader('Location', '/v1/users/' + user.id);
      return UserResponseDto.from(user);
    } catch (error) {
      this.rethrowConflict(error);
    }
  }

  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() dto: UpdateUserDto,
  ): UserResponseDto {
    try {
      const user = this.usersService.update(id, dto);
      if (!user) throw new NotFoundException('User not found');

      return UserResponseDto.from(user);
    } catch (error) {
      this.rethrowConflict(error);
    }
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number): void {
    if (!this.usersService.remove(id)) {
      throw new NotFoundException('User not found');
    }
  }

  private rethrowConflict(error: unknown): never {
    if (error instanceof EmailAlreadyUsedError) {
      throw new ConflictException('Email already in use');
    }
    throw error;
  }
}

Nest의 standard response handling은 일반 handler 반환값을 JSON으로 직렬화하고 POST에 기본 201을 사용합니다. 생성된 ID에 따라 Location을 설정하기 위해 위 예제만 Express Response를 passthrough로 주입했습니다. passthrough: true가 없으면 해당 handler의 standard response handling이 비활성화됩니다. Fastify를 쓰거나 adapter 독립성이 필요하면 header 설정을 별도 interceptor나 adapter 추상화로 옮깁니다.

Module과 전역 부트스트랩

src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';

@Module({
  imports: [UsersModule],
})
export class AppModule {}
src/main.ts
import {
  ValidationPipe,
  VersioningType,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  });

  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
    }),
  );

  await app.listen(3000);
}

void bootstrap();

whitelist: true만 사용하면 decorator가 없는 추가 속성을 제거합니다. 여기에 forbidNonWhitelisted: true를 함께 쓰면 조용히 제거하는 대신 400으로 거부합니다. transform: true는 plain payload를 DTO instance로 변환하지만, 식별자에는 ParseIntPipe를 명시해 route 계약을 눈에 보이게 유지했습니다.

Nest 요청의 guard, 호출 전 interceptor, pipe와 DTO, controller와 service, 반환 interceptor와 직렬화로 이어지는 성공 경로와 처리되지 않은 예외가 filter로 분기하는 오류 경계를 정리한 흐름

Nest · Request Responsibility Boundary

검증은 handler 앞에서, 자원 규칙은 service 안에서 끝낸다

성공 요청은 handler를 감싼 interceptor 경로로 되돌아오고, 처리되지 않은 예외만 filter로 분기합니다. 이 경계를 지키면 권한·입력·domain·직렬화와 오류 응답 책임이 섞이지 않습니다.

1 · Guard

인증과 권한

설정된 guard가 controller보다 먼저 token과 접근 정책을 판정합니다.

인증 실패 401 · 권한 실패 403

2 · Interceptor

handler 호출 전 경계

설정된 interceptor가 먼저 실행되어 요청 문맥을 준비하고 이후 handler 호출을 감쌉니다.

반환·오류 경로를 관찰하거나 변환할 수 있음

3 · Pipe / DTO

변환과 입력 검증

ValidationPipe가 body·query를 검사하고 ParseIntPipe가 ID를 숫자로 제한합니다.

실패하면 handler를 실행하지 않고 400

4 · Controller

HTTP 계약 번역

versioned route, method, header와 status를 선언하고 domain 결과를 404·409로 번역합니다.

HTTP 세부를 service 안으로 흘리지 않습니다.

5 · Service

자원과 domain 규칙

조회·고유성·수정·삭제, 페이지 계산, 저장소와 트랜잭션을 처리합니다.

ID와 내부 불변식을 body가 덮어쓰지 못하게 합니다.

6 · Return interceptor

출력 직렬화와 응답

Controller가 명시적으로 response DTO로 변환하거나, ClassSerializerInterceptor가 class instance의 @Exclude/@Expose 직렬화 규칙을 적용합니다. 이후 반환값은 바깥 interceptor 순서로 되감깁니다.

200 · 201 · 본문 없는 204

  1. Guard · 인증과 권한

    설정된 guard가 먼저 token과 정책을 판정합니다.

    401 인증 실패 · 403 권한 실패

  2. Interceptor · 호출 전 경계

    요청 문맥을 준비한 뒤 handler 호출을 감싸고 반환·오류 경로를 관찰합니다.

    다음 단계 호출 전 작업

  3. Pipe / DTO · 변환과 검증

    body·query DTO와 ParseIntPipe가 handler 전에 입력을 검사합니다.

    검증 실패는 400

  4. Controller · HTTP 번역

    route, method, header, status를 선언하고 domain 결과를 HTTP로 번역합니다.

    없는 자원 404 · 충돌 409

  5. Service · 자원 규칙

    조회, 고유성, 페이지, 저장소와 트랜잭션을 처리합니다.

    HTTP status 대신 domain 결과를 만듭니다.

  6. Return interceptor · 출력

    Controller가 명시적으로 response DTO로 변환하거나, ClassSerializerInterceptor가 class instance의 @Exclude/@Expose 직렬화 규칙을 적용합니다. 이후 반환값은 바깥 interceptor 순서로 되감깁니다.

    200 · 201 · 본문 없는 204

Exception branch

처리되지 않은 예외만 filter로 분기

어느 단계에서든 interceptor가 처리하지 않은 예외가 생기면 남은 성공 경로를 건너뛰고 exception filter가 안전한 오류 envelope를 만듭니다.

예상 밖 오류는 내부에 기록하고 stack·DB 메시지를 숨긴 500 상태로 반환합니다. 오류 표현 계약을 채택했다면 application/problem+json Problem Details를 쓰며, 같은 형식은 4xx에도 적용할 수 있습니다.

Guard가 거부한 요청은 DTO나 자원 존재를 먼저 확인하지 않습니다. Pipe 실패도 controller와 service를 실행하지 않으며, 정상 반환과 처리되지 않은 예외는 서로 다른 경로를 택합니다.


인증·페이지네이션·오류 경계

위 메모리 예제는 HTTP와 DTO에 집중하려고 guard를 생략했습니다. 이메일이 포함된 사용자 API를 그대로 익명 공개해서는 안 됩니다.

  • 4장의 전역 JwtAuthGuard 같은 기본 거부 인증 정책을 적용하고, 공개 route만 명시적으로 예외 처리합니다.
  • 인증 실패는 401, 인증된 사용자가 다른 사용자의 자원을 읽거나 수정하려는 권한 실패는 403입니다. 존재 여부 노출을 막기 위해 404로 통일하는 정책을 택할 수 있지만 일관되게 문서화해야 합니다.
  • Nest 요청 수명 주기에서 guard는 interceptor·pipe와 controller보다 먼저 실행됩니다. interceptor는 handler 호출 전후를 감싸고, pipe는 handler 전에 매개변수를 변환·검증합니다. 거부할 요청에 대해 body 검증이나 자원 존재 조회를 먼저 하지 않습니다.
  • page/limit 방식은 작은 예제에는 단순하지만, 변경이 잦고 큰 컬렉션에서는 항목 중복·누락이 생길 수 있습니다. 안정적인 정렬 키를 가진 cursor pagination을 고려합니다.
  • 목록 응답 모양은 항상 { items, page, limit, total }로 유지합니다. 빈 목록도 같은 모양입니다.
  • 알려진 domain 실패는 controller에서 404·409 같은 HTTP 결과로 번역합니다. 정상 반환값은 바깥쪽으로 되감기는 interceptor와 standard response handling을 거쳐 직렬화됩니다. ClassSerializerInterceptor를 선택했다면 이 반환 경로에서 동작합니다.
  • 남은 성공 경로를 모두 지난 뒤 filter를 항상 실행하는 것이 아닙니다. 어느 단계에서든 interceptor가 처리하지 않은 예외가 생기면 exception filter 경로로 분기해 예상하지 못한 오류를 기록하고 안전한 500으로 바꿉니다. RFC 9457 Problem Details를 적용한다면 validation·guard·handler 실패의 외부 오류 계약을 일관되게 유지합니다.

계약 테스트

서버를 실행한 뒤 다음 경계를 함께 확인합니다.

GET /v1/users?page=1&limit=20200{"items":[...],"page":1,"limit":20,"total":2}를 반환합니다. 결과가 없어도 items는 빈 배열입니다.

GET /v1/users/not-a-number?page=0은 handler 실행 전에 400으로 거부됩니다.

POST /v1/users201, 생성된 출력 DTO와 Location: /v1/users/3을 반환합니다. Nest에서 POST의 기본 성공 코드는 201입니다.

같은 email을 다시 생성하거나 다른 사용자의 email로 수정하면 service의 고유성 규칙이 controller에서 409로 번역됩니다.

없는 숫자 ID를 조회·수정·삭제하면 404입니다. malformed ID의 400과 구분합니다.

DELETE /v1/users/1 성공은 본문 없는 204입니다. 같은 삭제를 반복했을 때 404가 되더라도 DELETE의 의도한 효과는 멱등적입니다.

등록하지 않은 /v2/users는 URI version이 맞지 않으므로 404입니다. breaking response change는 새 controller version과 계약 테스트로 분리합니다.

인증 guard를 연결한 뒤에는 token 누락·검증 실패가 401, 권한 부족이 403인지 확인합니다. 권한 거부가 DTO 오류나 자원 존재 여부보다 먼저 결정되는지도 테스트합니다.

조건부 요청을 구현했다면 현재 ETag와 같은 If-None-Match 조회는 304, 오래된 If-Match 변경 요청은 저장 없이 412인지 테스트합니다.

REST API의 품질은 URI 모양 하나가 아니라 자원 식별, HTTP 의미, 입력 검증, domain 규칙, 출력 직렬화와 오류 경계가 같은 계약을 말하는지로 판단합니다.