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 · 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
| 요청 | 의미 · 재시도 | 성공 | 주요 경계 |
|---|---|---|---|
컬렉션 URI |
페이지로 나눈 컬렉션 표현 조회 안전 · 멱등 |
200 · 빈 결과도 items: [] |
query 형식은 400, 인증·정책은 401/403 |
멤버 URI |
단일 사용자 표현 조회 안전 · 멱등 |
200 · validator 일치 시 304 |
잘못된 ID는 400, 없는 자원은 404 |
컬렉션 URI |
컬렉션에 서버가 식별자를 정한 자원 생성 안전하지 않음 · 메서드 의미는 비멱등 |
201 · Location · 생성 표현 |
DTO는 400, 고유성 충돌은 409 |
멤버 URI |
알려진 target의 현재 표현을 전체 교체 안전하지 않음 · 멱등 |
기존 자원 200/204 · 새 target 생성 201 |
전체 교체 규칙과 생성 지원 여부를 API가 명시 |
멤버 URI |
정의된 patch document를 부분 적용 안전하지 않음 · 멱등 보장 없음 |
200 또는 본문 없는 204 |
문서 적용 불가 |
멤버 URI |
target과의 현재 연결을 제거 안전하지 않음 · 멱등 |
본문 없는 204 |
반복 응답이 404여도 의도한 효과는 동일 |
조회 · GET
/v1/users와 /v1/users/:id를 조회합니다. GET은 안전하고 멱등적입니다.
빈 컬렉션은 200과 items: []입니다. 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 메서드 의미와 멱등성
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를 사용할 수 있습니다.- 이 예제의
PATCHbody는 애플리케이션이 정의한 “필드 병합” 문서입니다. 표준 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 document400, 지원하지 않는 patch 미디어 타입415, 문법은 유효하지만 적용할 수 없는 document422, 실패한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가 런타임 검증 규칙을 얻을 수 없습니다.
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;
}import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}페이지네이션 query도 문자열을 직접 Number()로 바꾸지 않고 DTO에서 변환과 범위를 함께 검증합니다.
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를 명시적으로 사용합니다.
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 응답으로 번역할 때 결정합니다.
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로 오분류하지 않습니다.
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과 전역 부트스트랩
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}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 · 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
-
Guard · 인증과 권한
설정된 guard가 먼저 token과 정책을 판정합니다.
401인증 실패 ·403권한 실패 -
Interceptor · 호출 전 경계
요청 문맥을 준비한 뒤 handler 호출을 감싸고 반환·오류 경로를 관찰합니다.
다음 단계 호출 전 작업
-
Pipe / DTO · 변환과 검증
body·query DTO와
ParseIntPipe가 handler 전에 입력을 검사합니다.검증 실패는
400 -
Controller · HTTP 번역
route, method, header, status를 선언하고 domain 결과를 HTTP로 번역합니다.
없는 자원
404· 충돌409 -
Service · 자원 규칙
조회, 고유성, 페이지, 저장소와 트랜잭션을 처리합니다.
HTTP status 대신 domain 결과를 만듭니다.
-
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=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인지 테스트합니다.
REST API의 품질은 URI 모양 하나가 아니라 자원 식별, HTTP 의미, 입력 검증, domain 규칙, 출력 직렬화와 오류 경계가 같은 계약을 말하는지로 판단합니다.