API 서버 구현
NestJS로 엔티티·DTO·서비스·컨트롤러를 연결하고 인증 토큰과 역할 기반 인가를 갖춘 API 서버를 구현합니다.
이전 절에서 풀 스택 프로젝트의 전반적인 구조를 설계하고, 클라이언트와 서버 간의 코드 공유 전략을 포함한 모노레포(Monorepo) 구성을 살펴보았습니다.
이제 설계된 구조를 바탕으로, 애플리케이션의 핵심 백본인 API 서버를 실제로 구현해 볼 차례입니다.
API 서버는 클라이언트 요청을 받아 비즈니스 로직을 처리하고 DB와 상호작용한 뒤, 결과를 다시 클라이언트에 전달하는 역할을 합니다.
여기서는 타입스크립트 기반 백엔드 프레임워크 NestJS로 RESTful API 서버를 구현하는 과정을 다룹니다.
NestJS는 모듈화 구조, 의존성 주입(DI), 타입스크립트 지원이 강해 대규모 애플리케이션 개발에 적합합니다.
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의 강력한 기능 중 하나로,@nestjs/class-validator와@nestjs/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를 생성합니다.
@nestjs/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.`);
}
}
}컨트롤러 구현
클라이언트 요청을 받아 서비스 계층으로 위임하고, 적절한 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') // '/api/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);
}
}모듈 설정
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 {}