RESTful API 설계 원칙과 구현
안녕하세요! 4장에서는 NestJS 애플리케이션의 보안과 사용자 관리에 필수적인 인증 및 권한 부여 시스템을 살펴보았습니다. 이제 5장에서는 현대 웹 애플리케이션의 백엔드 개발에서 가장 기본적이고 핵심적인 부분인 REST API 개발에 대해 다루겠습니다.
REST(Representational State Transfer)는 웹 서비스를 설계하는 데 사용되는 아키텍처 스타일입니다. RESTful API는 이러한 REST 아키텍처 스타일을 따르는 API를 의미하며, 웹의 기존 기술과 프로토콜(주로 HTTP)을 최대한 활용하여 효율적이고 확장 가능한 웹 서비스를 구축하는 데 중점을 둡니다. NestJS는 RESTful API를 빠르고 효율적으로 개발하는 데 매우 최적화된 프레임워크입니다.
REST란 무엇인가?
REST(Representational State Transfer)는 2000년 로이 필딩(Roy Fielding)의 박사 논문에서 제시된 웹 아키텍처 스타일입니다. 특정 기술이나 표준이 아니라, 웹의 장점을 최대한 활용할 수 있는 아키텍처 원칙의 집합입니다. REST를 따르는 시스템을 보통 RESTful하다고 표현합니다.
REST의 핵심 원칙 (제약 조건)
클라이언트-서버(Client-Server) 아키텍처: 클라이언트와 서버의 역할이 명확히 분리됩니다. 클라이언트는 사용자 인터페이스를 담당하고, 서버는 데이터 및 비즈니스 로직을 담당합니다. 이 분리를 통해 독립적인 개발과 확장이 가능해집니다.
무상태성(Stateless): 서버는 클라이언트의 요청 간에 어떤 클라이언트 상태도 유지하지 않습니다. 모든 요청은 그 자체로 필요한 모든 정보를 포함해야 합니다. 이는 서버의 확장성을 높이고, 신뢰성을 향상시킵니다. (세션이나 쿠키와 같은 상태 유지를 하지 않습니다. 필요한 모든 상태 정보는 클라이언트가 관리하여 요청과 함께 보냅니다.)
캐시 가능(Cacheable): 클라이언트는 응답을 캐시할 수 있어야 합니다. 서버는 응답에 캐시 가능 여부를 명시하여 클라이언트가 불필요한 요청을 줄이도록 돕습니다.
계층화된 시스템(Layered System): 클라이언트는 최종 서버와 직접 통신하는지, 중간 서버(프록시, 로드 밸런서 등)를 통하는지 알 수 없습니다. 각 계층은 독립적으로 변경될 수 있습니다.
유니폼 인터페이스(Uniform Interface): RESTful 시스템의 핵심으로, 모든 자원(Resource)에 대한 접근 방식이 통일되어야 합니다. 이는 다음과 같은 세부 제약 조건으로 이루어집니다.
- 자원의 식별(Identification of resources): 모든 자원은 고유한 URI(Uniform Resource Identifier)를 가집니다.
- 메시지를 통한 자원 조작(Manipulation of resources through representations): 클라이언트가 자원을 변경하려면, 자원의 표현(Representation)을 받아 수정 후 다시 서버로 전송합니다. (예: JSON, XML)
- 자체 설명적인 메시지(Self-descriptive messages): 메시지 자체에 해당 메시지를 이해하는 데 필요한 모든 정보(미디어 타입, 인코딩 등)가 포함되어야 합니다.
- 하이퍼미디어(HATEOAS: Hypermedia as the Engine of Application State): 애플리케이션의 상태는 하이퍼링크를 통해 전이되어야 합니다. 클라이언트는 서버가 제공하는 링크를 통해 다음 가능한 작업을 동적으로 찾아야 합니다. (이 부분은 RESTful API 설계에서 가장 어렵고 종종 생략되기도 합니다.)
RESTful API 설계 원칙
위의 REST 원칙을 기반으로 실제 RESTful API를 설계할 때 따르는 일반적인 관례들입니다.
자원(Resource) 중심의 URI 설계
- 명사 사용: URI는 리소스를 식별하는 데 사용되므로, 동사 대신 명사를 사용합니다.
GET /users
(O) vsGET /getUsers
(X)POST /posts
(O) vsPOST /createPost
(X)
- 복수형 명사 사용: 컬렉션(여러 개의 리소스)을 나타낼 때는 복수형 명사를 사용합니다.
GET /users
(모든 사용자 조회)GET /posts/123
(ID가 123인 게시물 조회)
- 하위 자원: 자원 간의 관계를 표현할 때는
/
를 사용하여 계층 구조를 나타냅니다.GET /users/1/posts
(ID가 1인 사용자의 모든 게시물 조회)GET /posts/123/comments/45
(ID가 123인 게시물의 ID가 45인 댓글 조회)
- URI에 CRUD 동사 사용 지양: URI는 리소스를 나타내고, 행위는 HTTP 메서드로 표현합니다.
HTTP 메서드 활용 (CRUD 매핑): HTTP 메서드는 리소스에 대한 행위를 나타냅니다.
GET
: 자원 조회 (멱등성, 안전성)GET /users
GET /users/1
POST
: 자원 생성 (비멱등성)POST /users
(새 사용자 생성)
PUT
: 자원 전체 교체/수정 (멱등성)PUT /users/1
(ID가 1인 사용자 정보를 완전히 새로운 정보로 교체)
PATCH
: 자원 부분 수정 (비멱등성, 하지만 설계에 따라 멱등적으로 만들 수 있음)PATCH /users/1
(ID가 1인 사용자 정보의 일부만 수정)
DELETE
: 자원 삭제 (멱등성)DELETE /users/1
상태 코드 활용: HTTP 상태 코드는 요청의 성공/실패 및 유형을 클라이언트에게 알려주는 중요한 수단입니다.
200 OK
: 요청 성공 (가장 일반적인 성공 응답)201 Created
: 자원 생성 성공 (POST 요청 후 새로운 자원 생성 시)204 No Content
: 요청은 성공했으나 반환할 내용이 없음 (PUT/DELETE 성공 시 종종 사용)400 Bad Request
: 클라이언트 요청 오류 (잘못된 파라미터, 유효성 검사 실패 등)401 Unauthorized
: 인증되지 않음 (로그인 필요)403 Forbidden
: 권한 없음 (인증되었으나 접근 권한 없음)404 Not Found
: 요청한 자원을 찾을 수 없음409 Conflict
: 자원 상태 충돌 (동일한 리소스 중복 생성 시도 등)500 Internal Server Error
: 서버 내부 오류 (예상치 못한 서버 측 문제)
표준 미디어 타입 사용 (JSON, XML): 데이터 통신 형식으로 JSON(JavaScript Object Notation)이 가장 널리 사용됩니다.
- 요청 시
Content-Type: application/json
- 응답 시
Content-Type: application/json
NestJS에서 RESTful API 구현
NestJS는 데코레이터와 모듈 기반 아키텍처를 통해 RESTful API를 매우 직관적으로 구현할 수 있도록 지원합니다.
먼저, 사용자 관리를 위한 간단한 RESTful API를 만들어보겠습니다.
단계 1: User 모듈 생성
NestJS CLI를 사용하여 users
모듈을 생성합니다.
nest g mo users
nest g co users
nest g s users
이는 users.module.ts
, users.controller.ts
, users.service.ts
파일을 생성합니다.
단계 2: User
엔티티 또는 스키마 정의 (데이터베이스 연동 전 가정)
현재는 간단하게 사용자 데이터를 메모리에서 관리하는 것으로 가정합니다. 실제 프로젝트에서는 TypeORM 엔티티, Mongoose 스키마 또는 Prisma 모델을 사용합니다.
import { Injectable } from '@nestjs/common';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
private nextId = 3;
findAll(): User[] {
return this.users;
}
findOne(id: number): User | undefined {
return this.users.find(user => user.id === id);
}
create(user: Omit<User, 'id'>): User {
const newUser = { id: this.nextId++, ...user };
this.users.push(newUser);
return newUser;
}
update(id: number, updateUser: Partial<User>): User | undefined {
const index = this.users.findIndex(user => user.id === id);
if (index === -1) {
return undefined;
}
this.users[index] = { ...this.users[index], ...updateUser };
return this.users[index];
}
remove(id: number): boolean {
const initialLength = this.users.length;
this.users = this.users.filter(user => user.id !== id);
return this.users.length < initialLength; // 삭제 성공 여부 반환
}
}
단계 3: DTO(Data Transfer Object) 정의
클라이언트와 서버 간 데이터 전송에 사용될 객체인 DTO를 정의합니다. 유효성 검사에도 사용됩니다.
import { IsString, IsEmail, IsNotEmpty } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEmail()
@IsNotEmpty()
email: string;
}
import { IsString, IsEmail, IsOptional } from 'class-validator';
import { PartialType } from '@nestjs/mapped-types'; // npm install @nestjs/mapped-types
export class UpdateUserDto extends PartialType(CreateUserDto) {
// PartialType을 사용하면 CreateUserDto의 모든 필드가 선택적으로 변환됩니다.
// 즉, name?: string; email?: string; 와 동일합니다.
}
단계 4: UsersController 구현 (RESTful API 엔드포인트)
import { Controller, Get, Post, Body, Param, Patch, Delete, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users') // 기본 경로: /users
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// GET /users - 모든 사용자 조회
@Get()
findAll() {
return this.usersService.findAll();
}
// GET /users/:id - 특정 사용자 조회
@Get(':id')
findOne(@Param('id') id: string) {
const user = this.usersService.findOne(+id); // +id로 문자열을 숫자로 변환
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
// POST /users - 새 사용자 생성
@Post()
@HttpCode(HttpStatus.CREATED) // 201 Created 상태 코드 반환
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
// PATCH /users/:id - 특정 사용자 부분 수정
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
const updatedUser = this.usersService.update(+id, updateUserDto);
if (!updatedUser) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return updatedUser;
}
// DELETE /users/:id - 특정 사용자 삭제
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // 204 No Content 상태 코드 반환 (성공적으로 삭제되었으나 응답 본문 없음)
remove(@Param('id') id: string) {
const removed = this.usersService.remove(+id);
if (!removed) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// 204 No Content는 본문 없이 응답하므로 return 값은 무시됩니다.
}
}
코드 설명
@Controller('users')
: 이 컨트롤러의 모든 라우트가/users
경로로 시작함을 명시합니다.@Get()
,@Post()
,@Patch()
,@Delete()
: HTTP 메서드를 명확히 지정합니다.@Param('id')
: URI 경로의:id
부분을 추출하여 메서드 인자로 주입합니다.@Body()
: 요청 본문(JSON 등)을 추출하여 DTO 객체로 주입합니다.@HttpCode(HttpStatus.CREATED)
: 기본 200 OK 대신201 Created
를 반환하도록 명시합니다.@HttpCode(HttpStatus.NO_CONTENT)
: 삭제 성공 시204 No Content
를 반환하도록 명시합니다.NotFoundException
: 자원을 찾을 수 없을 때 NestJS의 빌트인 예외를 사용하여404 Not Found
응답을 자동으로 반환합니다.
단계 5: AppModule
에 UsersModule
임포트
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module'; // UsersModule 임포트
@Module({
imports: [UsersModule], // UsersModule 추가
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
단계 6: 전역 유효성 검사 파이프 설정
main.ts
파일에서 DTO 유효성 검사를 위한 ValidationPipe
를 전역으로 설정합니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; // ValidationPipe 임포트
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 전역 유효성 검사 파이프 설정
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // DTO에 정의되지 않은 속성은 자동으로 제거
forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 있으면 에러 발생
transform: true, // DTO 타입으로 자동 변환 (예: URL 파라미터 문자열을 숫자 ID로)
}));
await app.listen(3000);
}
bootstrap();
RESTful API 테스트하기
애플리케이션을 실행하고(npm run start:dev
), Postman이나 유사한 도구를 사용하여 테스트합니다.
GET 모든 사용자 조회: GET http://localhost:3000/users
- 응답:
[{"id":1,"name":"Alice","email":"alice@example.com"},{"id":2,"name":"Bob","email":"bob@example.com"}]
GET 특정 사용자 조회: GET http://localhost:3000/users/1
- 응답:
{"id":1,"name":"Alice","email":"alice@example.com"}
GET 존재하지 않는 사용자: GET http://localhost:3000/users/999
- 응답:
404 Not Found
({"statusCode":404,"message":"User with ID 999 not found"}
)
POST 새 사용자 생성: POST http://localhost:3000/users
- Headers:
Content-Type: application/json
- Body:
{"name": "Charlie", "email": "charlie@example.com"}
- 응답:
201 Created
와 생성된 사용자 정보 ({"id":3,"name":"Charlie","email":"charlie@example.com"}
)
POST 잘못된 데이터: POST http://localhost:3000/users
- Body:
{"name": 123, "email": "invalid"}
- 응답:
400 Bad Request
(ValidationPipe
에 의해 유효성 검사 실패)
PATCH 사용자 부분 수정: PATCH http://localhost:3000/users/1
- Headers:
Content-Type: application/json
- Body:
{"name": "Alicia"}
- 응답:
200 OK
와 수정된 사용자 정보 ({"id":1,"name":"Alicia","email":"alice@example.com"}
)
DELETE 사용자 삭제: DELETE http://localhost:3000/users/1
- 응답:
204 No Content
이것으로 NestJS에서 RESTful API를 설계하고 구현하는 기본적인 방법을 알아보았습니다. RESTful 원칙을 준수하면 API의 직관성, 확장성, 유지보수성이 크게 향상됩니다. NestJS는 이러한 원칙을 쉽게 적용할 수 있는 강력한 도구들을 제공합니다.