API 서버 구현
NestJS로 엔티티·DTO·서비스·컨트롤러를 연결하고 인증 토큰과 역할 기반 인가를 갖춘 API 서버를 구현합니다.
이전 절에서 풀 스택 프로젝트의 전반적인 구조를 설계하고, 클라이언트와 서버 간의 코드 공유 전략을 포함한 모노레포(Monorepo) 구성을 살펴보았습니다.
이제 설계된 구조를 바탕으로, 애플리케이션의 핵심 백본인 API 서버를 실제로 구현해 볼 차례입니다.
API 서버는 클라이언트 요청을 받아 비즈니스 로직을 처리하고 DB와 상호작용한 뒤, 결과를 다시 클라이언트에 전달하는 역할을 합니다.
여기서는 타입스크립트 기반 백엔드 프레임워크 NestJS로 RESTful API 서버를 구현하는 과정을 다룹니다.
NestJS는 모듈화 구조, 의존성 주입(DI), 타입스크립트 지원이 강해 대규모 애플리케이션 개발에 적합합니다.
그림은 완성할 책임 경계이며 아래 CRUD의 보안 보장을 나타내지 않습니다. 원문은 가드가 주석 처리되어 있고 isAdmin 입력을 저장하며, 비밀번호 해시가 든 엔터티를 그대로 반환합니다. 공개 운영 API로 사용하려면 관리자 변경을 별도 인가된 경로로 제한하고 공개 응답 DTO를 적용해야 합니다.
NESTJS RESPONSIBILITY PIPELINE
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다
controller는 transport를 조정하고 service는 정책을 소유하며 repository adapter는 저장 세부를 처리한다.
- HTTP request
unknown body
- Validation pipe
parse · reject
- Controller
transport mapping
- Service
domain policy
- Repository
persistence
- Public DTO
stable response
- 핵심 경계
- 검증된 흐름
- 조건·되먹임
NestJS 서버 초기 설정
이전 장에서 nest new 명령어로 NestJS 프로젝트를 생성했다면, 기본 설정은 이미 완료되어 있을 겁니다.
만약 Monorepo 구조 내의 packages/server 폴더에 NestJS 프로젝트를 생성했다면, 해당 폴더에서 작업합니다.
기본 포트 확인 및 변경:
src/main.ts 파일에서 애플리케이션이 실행될 포트를 설정합니다.
기본적으로 3000번 포트를 사용하지만, 프론트엔드 애플리케이션과 충돌을 피하기 위해 다른 포트(예: 4100)를 사용할 수 있습니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 전역 파이프를 적용하여 DTO 유효성 검사 활성화
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // DTO에 정의되지 않은 속성은 제거
forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 있으면 에러 발생
transform: true, // DTO 타입에 따라 자동으로 변환 (예: string -> number)
}));
// CORS 설정 (프론트엔드와 통신을 위해 필수)
app.enableCors({
origin: 'http://localhost:3000', // 클라이언트 애플리케이션의 주소
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // 쿠키/인증 헤더 전송 허용
});
const PORT = process.env.PORT || 4100; // 환경 변수 또는 4100 포트 사용
await app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
bootstrap();ValidationPipe: NestJS의 강력한 기능 중 하나로,class-validator와class-transformer패키지를 사용하여 들어오는 요청 본문(DTO)의 유효성을 자동으로 검사합니다.enableCors(): 서로 다른 오리진(Origin) 간의 요청을 허용하는 CORS(Cross-Origin Resource Sharing) 설정을 합니다. 프론트엔드 애플리케이션이 다른 포트나 도메인에서 실행될 경우 필수입니다.
환경 변수 관리: 데이터베이스 연결 정보, API 키 등 민감하거나 환경별로 달라지는 설정 값은 환경 변수(.env)로 관리하는 것이 좋습니다.
NestJS는 @nestjs/config 패키지를 통해 .env 파일을 쉽게 로드할 수 있도록 지원합니다.
npm install @nestjs/configimport { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module'; // UserModule 임포트
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 어디서든 ConfigService를 주입받아 사용 가능
envFilePath: '.env', // .env 파일 경로 지정
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'], // 모든 엔티티 파일을 자동으로 찾음
synchronize: process.env.NODE_ENV === 'development', // 개발 환경에서만 동기화
logging: process.env.NODE_ENV === 'development', // 개발 환경에서만 로깅
}),
UserModule, // 사용자 모듈 추가
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}프로젝트 루트에 .env 파일을 생성하고 데이터베이스 정보를 추가합니다.
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=myuser
DATABASE_PASSWORD=mypassword
DATABASE_NAME=mydb핵심 비즈니스 로직 구현
이전 장에서 TypeORM을 사용한 사용자 모듈 예시를 기반으로, NestJS의 컨트롤러, 서비스, 엔티티, DTO를 통해 CRUD(Create, Read, Update, Delete) API를 구현합니다.
엔티티 정의
데이터베이스 테이블과 매핑될 엔티티를 정의합니다.
shared 패키지의 인터페이스를 확장하거나 참조할 수 있습니다.
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
// import { IUser } from '@my-fullstack-app/shared/interfaces'; // shared 패키지에서 인터페이스 임포트
@Entity()
export class User { // implements IUser (IUser 인터페이스를 구현할 수도 있습니다.)
@PrimaryGeneratedColumn()
id!: number;
@Column({ unique: true })
email!: string;
@Column()
password!: string; // 실제 앱에서는 해싱된 비밀번호 저장
@Column()
name!: string;
@Column({ default: false })
isAdmin!: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}API 계약용 DTO 정의
클라이언트로부터 요청을 받거나 클라이언트에게 응답을 보낼 때 데이터의 유효성을 검사하고 구조를 정의하는 DTO를 생성합니다.
class-validator 데코레이터를 활용합니다.
import { IsEmail, IsString, MinLength, IsBoolean, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsEmail({}, { message: '유효한 이메일 형식이 아닙니다.' })
email!: string;
@IsString({ message: '비밀번호는 문자열이어야 합니다.' })
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@IsString({ message: '이름은 문자열이어야 합니다.' })
name!: string;
@IsOptional()
@IsBoolean({ message: '관리자 여부는 불리언 값이어야 합니다.' })
isAdmin?: boolean;
}import { PartialType } from '@nestjs/mapped-types'; // npm install @nestjs/mapped-types
import { CreateUserDto } from './create-user.dto';
// PartialType을 사용하여 CreateUserDto의 모든 필드를 선택적(optional)으로 만듭니다.
export class UpdateUserDto extends PartialType(CreateUserDto) {}서비스 구현
비즈니스 로직을 포함하며, TypeORM의 Repository를 사용하여 데이터베이스와 상호작용합니다.
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import * as bcrypt from 'bcrypt'; // npm install bcryptjs @types/bcryptjs
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10); // 비밀번호 해싱
const newUser = this.usersRepository.create({
...createUserDto,
password: hashedPassword,
});
return this.usersRepository.save(newUser);
}
async findAll(): Promise<User[]> {
return this.usersRepository.find();
}
async findOneById(id: number): Promise<User> {
const user = await this.usersRepository.findOneBy({ id });
if (!user) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
return user;
}
async findOneByEmail(email: string): Promise<User | undefined> {
return this.usersRepository.findOne({ where: { email } });
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOneById(id); // 먼저 사용자를 찾고 없으면 예외 발생
// 비밀번호가 포함되어 있다면 해싱
if (updateUserDto.password) {
updateUserDto.password = await bcrypt.hash(updateUserDto.password, 10);
}
// 변경된 속성을 엔티티에 병합하고 저장
this.usersRepository.merge(user, updateUserDto);
return this.usersRepository.save(user);
}
async remove(id: number): Promise<void> {
const result = await this.usersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
}
}findOneByEmail은 현재 TypeORM의 미조회 값이 null이므로 반환 타입의 undefined와 맞지 않습니다. bcrypt import와 bcryptjs 설치 안내도 일치시켜야 합니다. PostgreSQL에서 onUpdate: CURRENT_TIMESTAMP 옵션만으로 갱신 시각이 자동 유지된다고 가정하지 말고 갱신 컬럼 동작을 별도로 구현·확인해야 합니다.
컨트롤러 구현
클라이언트 요청을 받아 서비스 계층으로 위임하고, 적절한 HTTP 응답을 반환합니다.
import { Controller, Get, Post, Body, Param, Put, Delete, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
// import { AuthGuard } from '../auth/auth.guard'; // 인증 가드 예시
// import { RolesGuard } from '../auth/roles.guard'; // 역할 기반 가드 예시
// import { Roles } from '../auth/roles.decorator'; // 역할 데코레이터 예시
// import { UserRole } from '@my-fullstack-app/shared/interfaces'; // shared 패키지에서 역할 타입 임포트
@Controller('users') // 전역 prefix가 없는 이 예제의 '/users' 경로
// @UseGuards(AuthGuard) // 모든 사용자 API에 인증 가드 적용 (로그인 후 접근)
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@HttpCode(HttpStatus.CREATED) // 201 Created 응답
async create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@Get()
// @Roles(UserRole.Admin) // 특정 역할만 접근 가능 (예: 관리자만 모든 사용자 조회)
// @UseGuards(RolesGuard)
async findAll() {
return this.userService.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.userService.findOneById(+id);
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(+id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // 204 No Content 응답
async remove(@Param('id') id: string) {
await this.userService.remove(+id);
}
}이 예제는 전역 /api prefix를 설정하지 않아 실제 경로는 /users입니다. +id는 숫자 변환일 뿐 정수·양수 ID 검증이 아니며, DTO whitelist는 허용된 isAdmin의 수정 권한을 판단하지 않습니다.
모듈 설정
User 모듈을 구성하여 서비스와 컨트롤러, 엔티티를 등록합니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])], // User 엔티티를 이 모듈에서 사용 가능하게 등록
controllers: [UserController],
providers: [UserService],
exports: [UserService], // 다른 모듈(예: AuthModule)에서 UserService를 주입받을 수 있도록 내보내기
})
export class UserModule {}