본문 바로가기

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이 붙습니다.

Nest HTTP · 자원과 메서드 계약

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

컬렉션·멤버·query·표현 메타데이터를 구분하고, 각 메서드의 성공과 실패 조건을 함께 읽습니다. 멱등성은 응답 코드가 아니라 반복 요청의 의도한 효과를 기준으로 판단합니다.

컬렉션과 멤버

/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

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여도 의도한 효과는 동일

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

HTTP 메서드 의미와 멱등성

  • GET과 HEAD는 안전한 메서드입니다. 클라이언트가 서버 상태 변경을 요청하지 않으며, 같은 요청을 반복해도 의도한 효과가 같습니다. 접근 로그처럼 부수적으로 생기는 내부 효과는 안전성 정의와 충돌하지 않습니다.
  • 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의 기본 동작에 맡기지 말고 304와 412를 계약 테스트로 고정합니다.

상태 코드와 표현

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

  • 컬렉션 조회는 비어 있어도 200 OK와 items: []를 반환합니다. 컬렉션 자체가 없다는 모델이 아니라면 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, {
  skipNullProperties: false,
}) {}

필드를 생략한 PATCH는 허용하지만 name·email에 null을 보내면 상속한 validator가 거부합니다. skipNullProperties: false는 null을 검증에서 건너뛰지 않게 하므로, service의 이메일 문자열 처리까지 잘못된 값이 전달되지 않습니다. 이 API에서 null은 필드 삭제 명령이 아닙니다.

페이지네이션 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: '[email protected]' },
    { id: 2, name: 'Bob', email: '[email protected]' },
  ];
  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 요청 수명 주기와 REST 책임 경계

guard·실행 전 interceptor·pipe·controller/service·반환 interceptor와 실패 filter의 실제 순서이다.

Nest 요청 · 실행 순서와 책임

입력은 handler 전에 검사하고, 자원 규칙은 service에서 확인합니다

성공 반환은 interceptor의 바깥 방향으로 되돌아옵니다. 처리되지 않은 예외는 filter로 분기합니다. 인증·입력·자원·출력의 책임을 이 순서에 맞춰 구분합니다.

정상 요청 실행과 미처리 예외의 분기 정상 경로 Guard→Interceptor전→Pipe→Controller→Service→반환interceptor→응답. 가드 거부 또는 pipe 검증 실패의 미처리 예외는 filter로 보내는 예시 경로이며 다른단계예외는본문범위설명에따른다. 가드 거부 검증 실패 Guard 인증 · 인가 Interceptor · 전 실행 감싸기 Pipe · DTO 변환 · 검증 Controller HTTP 결과 번역 Service 자원 규칙 · 저장 반환 interceptor 응답 DTO · 직렬화 바깥으로 반환 정상 응답 200 / 201 / 204 Exception filter 미처리 예외만 오류 응답
요청의 실행 순서와 정상 반환·예외 분기 가드는인증/인가,실행전interceptor는handler호출감싸기,pipe는입력검증,controller는HTTP번역,service는자원규칙을수행한다. 정상반환은DTO/직렬화와바깥interceptor를거쳐응답한다. 왼쪽거부경로와오른쪽검증실패경로는미처리예외만filter로보내는예시이며모든오류의발생위치를나열한것은아니다. 자세한다른단계오류와interceptor오류처리는원설명을따른다. Guard 인증 · 인가 판단 Interceptor · 전 허용된 실행 감싸기 Pipe · DTO 입력 변환 · 검증 Controller HTTP 결과로 번역 Service 고유성 · 페이지 · 저장 반환 경로 응답 DTO · 직렬화 바깥 interceptor HTTP 정상 응답 200 / 201 / 204 Exception filter 미처리 예외 경로 400 / 401 / 403 등

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

Exception branch

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

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

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

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 정상 경로는 Guard→Interceptor 실행 전 처리→Pipe→Controller→Service→반환 interceptor→응답이다. 가드 거부 또는 pipe 검증 실패의 미처리 예외는 filter로 보내는 예시 경로이며 다른 단계의 예외는 본문의 범위 설명을 따른다.
  • 가드는 인증/인가를, 실행 전 interceptor는 handler 호출 감싸기를, pipe는 입력 검증을, controller는 HTTP 계약 변환을, service는 자원 규칙의 적용을 맡는다. 정상 반환값은 DTO/직렬화와 바깥 interceptor를 거쳐 응답으로 전달된다. 왼쪽 거부 경로와 오른쪽 검증 실패 경로는 미처리 예외만 filter로 보내는 예시이며 모든 오류의 발생 위치를 나열한 것은 아니다. 다른 단계의 오류와 interceptor 오류 처리에 관한 자세한 내용은 원래 설명을 따른다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest 요청 수명 주기와 REST 책임 경계
6정상 경로는 Guard→Interceptor 실행 전 처리→Pipe→Controller→Service→반환 interceptor→응답이다. 가드 거부 또는 pipe 검증 실패의 미처리 예외는 filter로 보내는 예시 경로이며 다른 단계의 예외는 본문의 범위 설명을 따른다.
28가드는 인증/인가를, 실행 전 interceptor는 handler 호출 감싸기를, pipe는 입력 검증을, controller는 HTTP 계약 변환을, service는 자원 규칙의 적용을 맡는다. 정상 반환값은 DTO/직렬화와 바깥 interceptor를 거쳐 응답으로 전달된다. 왼쪽 거부 경로와 오른쪽 검증 실패 경로는 미처리 예외만 filter로 보내는 예시이며 모든 오류의 발생 위치를 나열한 것은 아니다. 다른 단계의 오류와 interceptor 오류 처리에 관한 자세한 내용은 원래 설명을 따른다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
guard와 입력 준비

Guard·interceptor 전·Pipe를 지납니다.

거부·검증 실패: filter · 정상: handler업무 전에 입력과 접근이 확인됩니다.
controller·service

controller가 service를 호출하여 결과를 받습니다.

실행: controller·service · 자료: 반환값입력이 업무 결과로 바뀝니다.
반환 interceptor

정상 결과는 반환 interceptor 뒤 응답으로 갑니다.

정상: 반환 처리 · 미처리 예외: 일치 filter업무 결과와 오류의 출구를 구분합니다.

Nest 요청 수명 주기와 REST 책임 경계

guard·실행 전 interceptor·pipe·controller/service·반환 interceptor와 실패 filter의 실제 순서이다.

Nest 요청 · 실행 순서와 책임

입력은 handler 전에 검사하고, 자원 규칙은 service에서 확인합니다

성공 반환은 interceptor의 바깥 방향으로 되돌아옵니다. 처리되지 않은 예외는 filter로 분기합니다. 인증·입력·자원·출력의 책임을 이 순서에 맞춰 구분합니다.

정상 요청 실행과 미처리 예외의 분기 정상 경로 Guard→Interceptor전→Pipe→Controller→Service→반환interceptor→응답. 가드 거부 또는 pipe 검증 실패의 미처리 예외는 filter로 보내는 예시 경로이며 다른단계예외는본문범위설명에따른다. 가드 거부 검증 실패 Guard 인증 · 인가 Interceptor · 전 실행 감싸기 Pipe · DTO 변환 · 검증 Controller HTTP 결과 번역 Service 자원 규칙 · 저장 반환 interceptor 응답 DTO · 직렬화 바깥으로 반환 정상 응답 200 / 201 / 204 Exception filter 미처리 예외만 오류 응답
요청의 실행 순서와 정상 반환·예외 분기 가드는인증/인가,실행전interceptor는handler호출감싸기,pipe는입력검증,controller는HTTP번역,service는자원규칙을수행한다. 정상반환은DTO/직렬화와바깥interceptor를거쳐응답한다. 왼쪽거부경로와오른쪽검증실패경로는미처리예외만filter로보내는예시이며모든오류의발생위치를나열한것은아니다. 자세한다른단계오류와interceptor오류처리는원설명을따른다. Guard 인증 · 인가 판단 Interceptor · 전 허용된 실행 감싸기 Pipe · DTO 입력 변환 · 검증 Controller HTTP 결과로 번역 Service 고유성 · 페이지 · 저장 반환 경로 응답 DTO · 직렬화 바깥 interceptor HTTP 정상 응답 200 / 201 / 204 Exception filter 미처리 예외 경로 400 / 401 / 403 등

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

Exception branch

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

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

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

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 정상 경로는 Guard→Interceptor 실행 전 처리→Pipe→Controller→Service→반환 interceptor→응답이다. 가드 거부 또는 pipe 검증 실패의 미처리 예외는 filter로 보내는 예시 경로이며 다른 단계의 예외는 본문의 범위 설명을 따른다.
  • 가드는 인증/인가를, 실행 전 interceptor는 handler 호출 감싸기를, pipe는 입력 검증을, controller는 HTTP 계약 변환을, service는 자원 규칙의 적용을 맡는다. 정상 반환값은 DTO/직렬화와 바깥 interceptor를 거쳐 응답으로 전달된다. 왼쪽 거부 경로와 오른쪽 검증 실패 경로는 미처리 예외만 filter로 보내는 예시이며 모든 오류의 발생 위치를 나열한 것은 아니다. 다른 단계의 오류와 interceptor 오류 처리에 관한 자세한 내용은 원래 설명을 따른다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest 요청 수명 주기와 REST 책임 경계
6정상 경로는 Guard→Interceptor 실행 전 처리→Pipe→Controller→Service→반환 interceptor→응답이다. 가드 거부 또는 pipe 검증 실패의 미처리 예외는 filter로 보내는 예시 경로이며 다른 단계의 예외는 본문의 범위 설명을 따른다.
28가드는 인증/인가를, 실행 전 interceptor는 handler 호출 감싸기를, pipe는 입력 검증을, controller는 HTTP 계약 변환을, service는 자원 규칙의 적용을 맡는다. 정상 반환값은 DTO/직렬화와 바깥 interceptor를 거쳐 응답으로 전달된다. 왼쪽 거부 경로와 오른쪽 검증 실패 경로는 미처리 예외만 filter로 보내는 예시이며 모든 오류의 발생 위치를 나열한 것은 아니다. 다른 단계의 오류와 interceptor 오류 처리에 관한 자세한 내용은 원래 설명을 따른다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
guard와 입력 준비

Guard·interceptor 전·Pipe를 지납니다.

거부·검증 실패: filter · 정상: handler업무 전에 입력과 접근이 확인됩니다.
controller·service

controller가 service를 호출하여 결과를 받습니다.

실행: controller·service · 자료: 반환값입력이 업무 결과로 바뀝니다.
반환 interceptor

정상 결과는 반환 interceptor 뒤 응답으로 갑니다.

정상: 반환 처리 · 미처리 예외: 일치 filter업무 결과와 오류의 출구를 구분합니다.

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

위 메모리 예제는 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 실패의 외부 오류 계약을 일관되게 유지합니다.

계약 테스트

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

초기 두 사용자로 페이지를 따라 확인하기

서버를 막 시작해 Alice와 Bob만 있는 메모리 상태에서 GET /v1/users?page=2&limit=1을 요청하면 start = (2 - 1) × 1 = 1이므로 Bob 한 명과 page: 2, limit: 1, total: 2가 반환됩니다. 같은 조건에서 page=3이면 items: []이지만 컬렉션 조회는 성공한 200입니다. 다음 요청은 기존 이메일과 충돌하지 않는 새 사용자를 만듭니다.

curl -i -X POST http://localhost:3000/v1/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"David","email":"[email protected]"}'

초기 상태의 첫 성공 생성이면 응답은 201이고 ID는 3, Location은 /v1/users/3입니다. 앞서 생성 요청을 수행했다면 ID와 전체 건수는 달라집니다. 메모리 예제는 서버를 다시 시작하면 초기 상태로 돌아옵니다. 이 명령은 guard를 생략한 본문 설정 기준이며, 인증을 연결한 뒤에는 그 API 계약에 맞는 토큰도 보내야 합니다.

아래 항목은 한 번에 순서대로 실행하는 요청 목록이 아니라 계약별 확인 사례입니다. 각 사례는 Alice와 Bob만 있는 초기 상태에서 시작하며, 같은 이메일 재생성이나 반복 삭제처럼 한 항목 안에서 요구하는 선행 요청만 먼저 수행합니다. 위의 David 생성 curl을 실행한 상태라면 전체 건수는 3이므로, 아래의 초기 상태 수치를 확인하기 전에 서버를 다시 시작하세요.

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

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

POST /v1/users는 201, 생성된 출력 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인지 테스트합니다.

응답 코드와 효과를 따로 판단하기

초기 상태에서 DELETE /v1/users/1을 두 번 보냈습니다. 첫 응답은 204이고 두 번째는 404입니다. 이 차이 때문에 DELETE가 멱등적이지 않은가요? 두 삭제 후 GET /v1/users?page=2&limit=1의 목록·전체 건수·상태도 직접 계산하세요.

메모리 상태와 HTTP 의미로 풀이하기

두 요청의 의도한 효과는 ID 1인 사용자가 없는 상태이므로 멱등성과 응답 코드의 동일성은 다른 조건입니다. 첫 삭제 뒤 Bob 한 명만 남고 두 번째 삭제는 상태를 더 바꾸지 않습니다. 페이지 시작 위치는 1인데 항목은 한 개뿐이므로 items: [], total: 1, 상태는 200입니다. 이 연습에서는 그 사이 다른 생성·삭제 요청이 없다고 가정합니다.

REST API의 품질은 URI 모양 하나가 아니라 자원 식별, HTTP 의미, 입력 검증, domain 규칙, 출력 직렬화와 오류 경계가 일관된 요청·응답 규칙을 따르는지로 판단합니다.