icon
16장 : 실전 프로젝트

API 서버 구현


이전 절에서 풀 스택 프로젝트의 전반적인 구조를 설계하고, 클라이언트와 서버 간의 코드 공유 전략을 포함한 모노레포(Monorepo) 구성을 살펴보았습니다. 이제 설계된 구조를 바탕으로, 애플리케이션의 핵심 백본인 API 서버를 실제로 구현해 볼 차례입니다.

API 서버는 클라이언트의 요청을 받아 비즈니스 로직을 처리하고, 데이터베이스와 상호작용하여 데이터를 관리하며, 최종 결과를 클라이언트에게 전달하는 역할을 합니다. 여기서는 타입스크립트 기반의 백엔드 프레임워크인 NestJS를 활용하여 RESTful API 서버를 구현하는 과정을 상세히 다루겠습니다. NestJS는 모듈화된 구조, 의존성 주입(DI), 강력한 타입스크립트 지원으로 대규모 애플리케이션 개발에 매우 적합합니다.


NestJS 서버 초기 설정

이전 장에서 nest new 명령어로 NestJS 프로젝트를 생성했다면, 기본 설정은 이미 완료되어 있을 겁니다. 만약 Monorepo 구조 내의 packages/server 폴더에 NestJS 프로젝트를 생성했다면, 해당 폴더에서 작업합니다.

  1. 기본 포트 확인 및 변경: src/main.ts 파일에서 애플리케이션이 실행될 포트를 설정합니다. 기본적으로 3000번 포트를 사용하지만, 프론트엔드 애플리케이션과 충돌을 피하기 위해 다른 포트(예: 4000)를 사용할 수 있습니다.

    // packages/server/src/main.ts
    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 || 4000; // 환경 변수 또는 4000 포트 사용
      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) 설정을 합니다. 프론트엔드 애플리케이션이 다른 포트나 도메인에서 실행될 경우 필수입니다.
  2. 환경 변수 관리: 데이터베이스 연결 정보, API 키 등 민감하거나 환경별로 달라지는 설정 값은 **환경 변수(.env)**로 관리하는 것이 좋습니다. NestJS는 @nestjs/config 패키지를 통해 .env 파일을 쉽게 로드할 수 있도록 지원합니다.

    npm install @nestjs/config
    // packages/server/src/app.module.ts
    import { 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 패키지의 인터페이스를 확장하거나 참조할 수 있습니다.

// packages/server/src/user/entities/user.entity.ts
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;
}

DTO (Data Transfer Object) 정의

클라이언트로부터 요청을 받거나 클라이언트에게 응답을 보낼 때 데이터의 유효성을 검사하고 구조를 정의하는 DTO를 생성합니다. @nestjs/class-validator 데코레이터를 활용합니다.

// packages/server/src/user/dto/create-user.dto.ts
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;
}
// packages/server/src/user/dto/update-user.dto.ts
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) {}

서비스 구현

비즈니스 로직을 포함하며, TypeORMRepository를 사용하여 데이터베이스와 상호작용합니다.

// packages/server/src/user/user.service.ts
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 응답을 반환합니다.

// packages/server/src/user/user.controller.ts
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 모듈을 구성하여 서비스와 컨트롤러, 엔티티를 등록합니다.

// packages/server/src/user/user.module.ts
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 {}

인증 및 인가 구현

실제 API 서버에서는 사용자의 신원을 확인하고(인증), 해당 사용자가 특정 리소스에 접근하거나 특정 작업을 수행할 권한이 있는지 확인하는(인가) 과정이 필수적입니다. NestJS에서는 @nestjs/passportpassport.js를 통합하여 이를 쉽게 구현할 수 있습니다. JWT(JSON Web Token)는 RESTful API에서 가장 흔히 사용되는 인증 방식 중 하나입니다.

  1. 필수 패키지 설치

    npm install @nestjs/passport passport passport-jwt @types/passport-jwt @types/passport bcryptjs
    npm install --save-dev @types/bcryptjs
  2. AuthModuleAuthService 생성: 로그인, 회원가입, JWT 발급 등의 로직을 처리하는 모듈과 서비스를 만듭니다.

    // packages/server/src/auth/auth.service.ts
    import { Injectable, UnauthorizedException } from '@nestjs/common';
    import { UserService } from '../user/user.service';
    import { JwtService } from '@nestjs/jwt';
    import * as bcrypt from 'bcrypt';
    import { User } from '../user/entities/user.entity';
    
    @Injectable()
    export class AuthService {
      constructor(
        private userService: UserService,
        private jwtService: JwtService, // JwtService 주입
      ) {}
    
      async validateUser(email: string, pass: string): Promise<User | null> {
        const user = await this.userService.findOneByEmail(email);
        if (user && (await bcrypt.compare(pass, user.password))) {
          // 비밀번호는 제외하고 반환
          const { password, ...result } = user;
          return result as User; // Partial<User>를 User로 캐스팅
        }
        return null;
      }
    
      async login(user: User) {
        const payload = { email: user.email, sub: user.id, isAdmin: user.isAdmin }; // JWT 페이로드
        return {
          accessToken: this.jwtService.sign(payload),
        };
      }
    }
  3. JwtStrategy 정의: JWT 토큰을 검증하고 사용자 정보를 추출하는 전략을 구현합니다.

    // packages/server/src/auth/jwt.strategy.ts
    import { Injectable } from '@nestjs/common';
    import { PassportStrategy } from '@nestjs/passport';
    import { Strategy, ExtractJwt } from 'passport-jwt';
    import { ConfigService } from '@nestjs/config'; // ConfigService 임포트
    
    @Injectable()
    export class JwtStrategy extends PassportStrategy(Strategy) {
      constructor(private configService: ConfigService) {
        super({
          jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer 토큰에서 JWT 추출
          ignoreExpiration: false, // 만료된 토큰 무시 안 함
          secretOrKey: configService.get<string>('JWT_SECRET'), // .env에서 JWT 비밀 키 가져오기
        });
      }
    
      async validate(payload: any) { // payload는 JWT에 서명된 데이터
        // 이 부분에서 실제 사용자 정보를 DB에서 가져오거나, 페이로드만 반환할 수 있습니다.
        // req.user 에 페이로드가 저장됩니다.
        return { userId: payload.sub, email: payload.email, isAdmin: payload.isAdmin };
      }
    }

    .env 파일에 JWT_SECRET=your_jwt_secret_key 추가

  4. AuthModule 설정: JwtModule과 PassportModule을 구성하고 서비스, 전략을 등록합니다.

    // packages/server/src/auth/auth.module.ts
    import { Module } from '@nestjs/common';
    import { PassportModule } from '@nestjs/passport';
    import { JwtModule } from '@nestjs/jwt';
    import { ConfigModule, ConfigService } from '@nestjs/config';
    import { AuthService } from './auth.service';
    import { UserModule } from '../user/user.module';
    import { LocalStrategy } from './local.strategy'; // 로컬 전략 (사용자 이름/비밀번호)
    import { JwtStrategy } from './jwt.strategy';
    import { AuthController } from './auth.controller';
    
    @Module({
      imports: [
        UserModule, // UserService를 사용하기 위해 UserModule 임포트
        PassportModule,
        JwtModule.registerAsync({ // 비동기로 JWT 설정 (ConfigService 주입)
          imports: [ConfigModule],
          useFactory: async (configService: ConfigService) => ({
            secret: configService.get<string>('JWT_SECRET'),
            signOptions: { expiresIn: '60m' }, // 토큰 만료 시간
          }),
          inject: [ConfigService],
        }),
      ],
      controllers: [AuthController],
      providers: [AuthService, LocalStrategy, JwtStrategy], // 전략 등록
      exports: [AuthService], // AuthService를 다른 모듈에서 사용 가능하도록 내보내기
    })
    export class AuthModule {}
  5. AuthController 구현: 로그인, 회원가입 엔드포인트를 정의합니다.

    // packages/server/src/auth/auth.controller.ts
    import { Controller, Post, Request, UseGuards, Body, HttpCode, HttpStatus } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { LocalAuthGuard } from './local-auth.guard'; // 로컬 인증 가드 (추후 생성)
    import { CreateUserDto } from '../user/dto/create-user.dto';
    import { UserService } from '../user/user.service';
    
    @Controller('auth')
    export class AuthController {
      constructor(
        private authService: AuthService,
        private userService: UserService, // 회원가입을 위해 UserService 사용
      ) {}
    
      @Post('register')
      @HttpCode(HttpStatus.CREATED)
      async register(@Body() createUserDto: CreateUserDto) {
        return this.userService.create(createUserDto);
      }
    
      @UseGuards(LocalAuthGuard) // LocalAuthGuard를 사용하여 로컬 전략 실행
      @Post('login')
      @HttpCode(HttpStatus.OK) // 200 OK 응답
      async login(@Request() req: any) { // @Request() 데코레이터로 요청 객체 접근
        // LocalAuthGuard가 req.user에 사용자 정보를 저장합니다.
        return this.authService.login(req.user);
      }
    }
  6. 인증 가드 (local-auth.guard.ts, jwt-auth.guard.ts): passport-jwtpassport-local을 NestJS 가드로 래핑합니다.

    // packages/server/src/auth/local-auth.guard.ts
    import { Injectable } from '@nestjs/common';
    import { AuthGuard } from '@nestjs/passport';
    
    @Injectable()
    export class LocalAuthGuard extends AuthGuard('local') {} // 'local' 전략 사용
    // packages/server/src/auth/jwt-auth.guard.ts
    import { Injectable } from '@nestjs/common';
    import { AuthGuard } from '@nestjs/passport';
    
    @Injectable()
    export class JwtAuthGuard extends AuthGuard('jwt') {} // 'jwt' 전략 사용

    이제 UserController나 다른 컨트롤러에 @UseGuards(JwtAuthGuard)를 적용하여 인증된 사용자만 접근할 수 있도록 설정합니다.


역할 기반 인가 구현

특정 API 엔드포인트에 관리자만 접근 가능하도록 하는 등 역할 기반 인가는 다음과 같이 구현할 수 있습니다.

  1. 역할 데코레이터 (packages/server/src/auth/roles.decorator.ts)

    // packages/server/src/auth/roles.decorator.ts
    import { SetMetadata } from '@nestjs/common';
    // import { UserRole } from '@my-fullstack-app/shared/interfaces'; // shared 패키지에서 역할 정의
    
    export const ROLES_KEY = 'roles';
    export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

    UserRoleshared 패키지에 정의된 enum 또는 type일 수 있습니다.

    // packages/shared/src/interfaces/user.ts (예시)
    export enum UserRole {
      User = 'user',
      Admin = 'admin',
    }
    
    export interface IUser {
      id: number;
      email: string;
      name: string;
      isAdmin: boolean; // 또는 role: UserRole;
    }
  2. 역할 가드 (packages/server/src/auth/roles.guard.ts)

    // packages/server/src/auth/roles.guard.ts
    import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
    import { Reflector } from '@nestjs/core';
    import { ROLES_KEY } from './roles.decorator';
    // import { UserRole } from '@my-fullstack-app/shared/interfaces';
    
    @Injectable()
    export class RolesGuard implements CanActivate {
      constructor(private reflector: Reflector) {}
    
      canActivate(context: ExecutionContext): boolean {
        const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
          context.getHandler(), // 메서드 레벨 데코레이터
          context.getClass(),   // 클래스 레벨 데코레이터
        ]);
        if (!requiredRoles) {
          return true; // 역할 제한이 없으면 접근 허용
        }
    
        const { user } = context.switchToHttp().getRequest();
        // req.user에 저장된 사용자의 역할(isAdmin 또는 role)을 확인
        // 여기서는 예시로 isAdmin 필드를 사용
        return requiredRoles.some((role) => {
          if (role === 'admin') {
            return user.isAdmin === true;
          }
          // 다른 역할 검증 로직 추가
          return false;
        });
      }
    }
  3. 컨트롤러에 적용

    // packages/server/src/user/user.controller.ts
    // ...
    import { JwtAuthGuard } from '../auth/jwt-auth.guard';
    import { RolesGuard } from '../auth/roles.guard';
    import { Roles } from '../auth/roles.decorator';
    import { UserRole } from '@my-fullstack-app/shared/interfaces/user'; // shared 인터페이스 임포트
    
    @Controller('users')
    @UseGuards(JwtAuthGuard, RolesGuard) // JwtAuthGuard 먼저 실행 후 RolesGuard 실행
    export class UserController {
      // ...
      @Get()
      @Roles(UserRole.Admin) // 관리자 역할만 접근 가능
      async findAll() {
        return this.userService.findAll();
      }
      // ...
    }

API 서버 실행

모든 설정과 구현이 완료되었다면, 백엔드 서버를 실행하여 API가 정상적으로 동작하는지 확인할 수 있습니다.

packages/server 디렉토리에서 다음 명령어를 실행합니다.

npm run start:dev

서버가 성공적으로 실행되면, http://localhost:4000 (또는 설정한 포트)에서 API가 서비스되고 있음을 확인할 수 있습니다. Postman, Insomnia 또는 브라우저의 개발자 도구를 사용하여 구현된 API 엔드포인트를 테스트해볼 수 있습니다.


결론

이 절에서는 NestJS를 활용하여 타입스크립트 기반의 API 서버를 구현하는 핵심 과정을 살펴보았습니다. 데이터베이스 연동(TypeORM), DTO 유효성 검사, 환경 변수 관리, 그리고 필수적인 인증 및 인가 시스템 구현까지 다룸으로써, 실제 프로젝트에서 필요한 백엔드 기능의 상당 부분을 구축하는 방법을 익혔습니다.

NestJS의 모듈화된 아키텍처와 강력한 데코레이터 기반의 문법은 타입스크립트와 완벽하게 조화되어, 구조화되고 유지보수하기 쉬운 API 서버를 구축하는 데 큰 이점을 제공합니다. 다음 절에서는 이 API 서버와 연동될 클라이언트(프론트엔드) 애플리케이션을 구현하는 방법에 대해 알아보겠습니다.