안동민 개발노트

본문 시작

백엔드 API 구현

온라인 코드 편집기의 데이터베이스를 연결하고 JWT 인증과 프로젝트·파일 관리 REST API를 NestJS로 구현합니다.

지난 절에서는 온라인 코드 에디터 및 실시간 협업 도구의 요구사항과 시스템 아키텍처를 설계했습니다.

이번 절에서는 그 설계를 바탕으로 NestJS 백엔드의 핵심인 API 구현을 시작합니다.

프로젝트 초기 설정부터 사용자 인증, 프로젝트/파일 관리를 위한 RESTful API 구현까지 순서대로 다룹니다.


NestJS 프로젝트 초기 설정

먼저 NestJS CLI를 사용하여 새로운 프로젝트를 생성합니다.

# NestJS CLI가 없다면 설치
npm install -g @nestjs/cli

# 새 프로젝트 생성
nest new collaborative-code-editor-backend

# 프로젝트 디렉토리로 이동
cd collaborative-code-editor-backend

# 필요한 기본 패키지 설치
npm install @nestjs/typeorm typeorm pg bcryptjs @nestjs/jwt passport @nestjs/passport passport-jwt @types/passport-jwt @types/bcryptjs dotenv
# WebSocket 및 Socket.IO 관련 패키지는 나중에 설치
# npm install @nestjs/websockets @nestjs/platform-socket.io socket.io @types/socket.io

설치된 주요 패키지 설명:

  • @nestjs/typeorm, typeorm, pg: TypeORM ORM을 사용하여 PostgreSQL 데이터베이스와 연동합니다.
  • bcryptjs: 비밀번호 해싱을 위한 라이브러리입니다.
  • @nestjs/jwt, passport, @nestjs/passport, passport-jwt: JWT(JSON Web Token) 기반 사용자 인증을 구현합니다.
  • dotenv: .env 파일에서 환경 변수를 로드합니다.

데이터베이스 설정

.env 파일을 생성하고 데이터베이스 연결 정보를 설정합니다.

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=your_db_user
DATABASE_PASSWORD=your_db_password
DATABASE_NAME=code_editor_db

# JWT Secret (보안을 위해 강력하고 긴 문자열 사용)
JWT_SECRET=super_secret_key_please_change_this_in_production

src/app.module.ts 파일을 수정하여 TypeORM 모듈을 설정합니다.

src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config'; // ConfigModule 임포트

@Module({
  imports: [
    // 환경 변수 로드
    ConfigModule.forRoot({
      isGlobal: true, // 전역적으로 사용 가능하도록 설정
      envFilePath: process.env.NODE_ENV === 'development' ? '.env.development' : '.env', // 환경별 .env 파일 지정 가능
    }),
    // TypeORM 설정
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DATABASE_HOST,
      port: parseInt(process.env.DATABASE_PORT, 10),
      username: process.env.DATABASE_USERNAME,
      password: process.env.DATABASE_PASSWORD,
      database: process.env.DATABASE_NAME,
      entities: [__dirname + '/**/*.entity{.ts,.js}'], // 엔티티 파일 경로 지정
      synchronize: true, // 개발 단계에서만 true (자동으로 DB 스키마 생성/업데이트). 프로덕션에서는 migration 사용 권장
      logging: ['query', 'error'], // 개발 시 쿼리 로그 확인
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

ConfigModule을 통해 .env 파일을 로드하여 환경 변수를 안전하게 사용할 수 있습니다.


사용자 인증 (Auth) API 구현

백엔드 API 구현에서는 요청이 들어오는 위치, 책임을 맡는 계층, 실패 응답으로 나가는 지점을 분리합니다.

회원가입·로그인·프로필 요청의 처리와 반환 경계

회원가입은 사용자를 저장하고 메시지를 반환합니다. 로그인은 비밀번호 비교 후 토큰을 발급합니다. 별도의 프로필 요청은 토큰 검증과 사용자 조회를 거쳐 응답에서 password를 제외합니다.

서로 다른 세 HTTP 요청 · 각 행의 처리 순서는 위에서 아래로
요청처리 책임과 순서성공 반환 · 중단 조건
회원가입

POST /auth/register

AuthController → AuthService.register

  1. 같은 email 사용자 조회
  2. bcrypt.hash(password, 10)
  3. 해시를 User.password에 넣고 저장

성공: { message }

조회에서 email 중복 발견: ConflictException으로 중단

로그인

POST /auth/login

AuthController → AuthService.login

  1. email로 User 조회
  2. bcrypt.compare로 비밀번호 비교
  3. JwtService.sign으로 토큰 발급

성공: { accessToken }

payload: sub: user.id, email: user.email · 만료 1h

사용자 없음 또는 비교 실패: UnauthorizedException

프로필

GET /auth/profile

  1. AuthGuard('jwt')가 Bearer JWT 검증
  2. JwtStrategy.validate가 payload.sub로 User 조회 후 반환
  3. 컨트롤러가 req.user에서 password를 제외

성공: 조회한 User에서 password를 뺀 나머지 필드

토큰 검증 실패 시 진입 거부. 사용자 조회 결과가 없으면 UnauthorizedException

회원가입 · POST /auth/register

AuthController → AuthService.register

  1. 같은 email 사용자 조회
  2. bcrypt.hash(password, 10)
  3. 해시를 User.password에 넣고 저장

성공: { message }. 조회에서 email 중복 발견 시 ConflictException으로 중단합니다.

로그인 · POST /auth/login

AuthController → AuthService.login

  1. email로 User 조회
  2. bcrypt.compare로 비밀번호 비교
  3. JwtService.sign으로 토큰 발급

성공: { accessToken }. payload는 sub: user.id, email: user.email이고 만료는 1h입니다. 사용자 없음 또는 비교 실패는 UnauthorizedException입니다.

프로필 · GET /auth/profile
  1. AuthGuard('jwt')가 Bearer JWT 검증
  2. JwtStrategy.validate가 payload.sub로 User 조회 후 반환
  3. 컨트롤러가 req.user에서 password를 제외

성공: 조회한 User에서 password를 뺀 나머지 필드. 토큰 검증 실패 시 진입을 거부하고, 사용자 조회 결과가 없으면 UnauthorizedException입니다.

로그인 응답의 토큰은 이후 프로필 요청의 Authorization: Bearer <accessToken> 헤더에 보냅니다. 토큰 발급과 검증은 별도 요청입니다.

제거 위치: 이 코드의 JwtStrategy는 최소 principal이 아니라 조회한 User를 반환합니다. password 제외는 getProfile 응답에서 수행합니다.

DTO 데코레이터는 본문에 정의되어 있지만, 이 문서에는 ValidationPipe 적용이나 여분 필드 거부 설정이 없습니다. 환경 변수 로드도 비밀키 길이 검증과는 다릅니다.

사용자 관리를 위한 엔티티, DTO, 서비스, 컨트롤러 및 JWT 전략을 구현합니다.

User Entity

사용자 정보와 TypeORM 매핑을 정의합니다.

src/auth/entities/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Project } from '../../project/entities/project.entity'; // Project 엔티티와 관계 설정 (미리 정의했다고 가정)

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true, nullable: false })
  email: string;

  @Column({ nullable: false })
  password: string; // 해싱된 비밀번호 저장

  @Column({ nullable: true })
  nickname: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany(() => Project, project => project.owner)
  projects: Project[];
}

Auth DTOs

요청(Request) 데이터의 유효성 검사를 위한 DTO를 정의합니다.

npm install class-validator class-transformer
src/auth/dto/register-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class RegisterUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(6, { message: 'Password must be at least 6 characters long' })
  password: string;

  @IsString()
  nickname?: string;
}
src/auth/dto/login-user.dto.ts
import { IsEmail, IsString } from 'class-validator';

export class LoginUserDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;
}

Auth Service

사용자 관련 비즈니스 로직(회원가입, 로그인)을 처리합니다.

src/auth/auth.service.ts
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { RegisterUserDto } from './dto/register-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import * as bcrypt from 'bcryptjs';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  async register(registerUserDto: RegisterUserDto): Promise<{ message: string }> {
    const { email, password, nickname } = registerUserDto;

    const existingUser = await this.usersRepository.findOne({ where: { email } });
    if (existingUser) {
      throw new ConflictException('Email already registered');
    }

    const hashedPassword = await bcrypt.hash(password, 10); // 비밀번호 해싱

    const newUser = this.usersRepository.create({
      email,
      password: hashedPassword,
      nickname,
    });
    await this.usersRepository.save(newUser);
    return { message: 'User registered successfully' };
  }

  async login(loginUserDto: LoginUserDto): Promise<{ accessToken: string }> {
    const { email, password } = loginUserDto;

    const user = await this.usersRepository.findOne({ where: { email } });
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    // JWT 페이로드 (사용자 ID를 포함)
    const payload = { sub: user.id, email: user.email };
    const accessToken = this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET'),
      expiresIn: '1h', // 토큰 만료 시간
    });

    return { accessToken };
  }

  // JWT 전략에서 사용될 사용자 조회 메서드
  async validateUser(userId: string): Promise<User> {
    return this.usersRepository.findOne({ where: { id: userId } });
  }
}

JWT Strategy (Passport)

요청 헤더의 JWT를 검증하고 사용자 정보를 추출합니다.

src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service'; // AuthService 주입
import { User } from './entities/user.entity';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
    private authService: AuthService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer 토큰에서 JWT 추출
      ignoreExpiration: false, // 만료된 토큰 거부
      secretOrKey: configService.get<string>('JWT_SECRET'), // JWT Secret
    });
  }

  async validate(payload: any): Promise<User> {
    const user = await this.authService.validateUser(payload.sub);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

Auth Controller

API 엔드포인트를 정의합니다.

src/auth/auth.controller.ts
import { Controller, Post, Body, Get, UseGuards, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterUserDto } from './dto/register-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import { AuthGuard } from '@nestjs/passport'; // AuthGuard 임포트

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register')
  async register(@Body() registerUserDto: RegisterUserDto) {
    return this.authService.register(registerUserDto);
  }

  @Post('login')
  async login(@Body() loginUserDto: LoginUserDto) {
    return this.authService.login(loginUserDto);
  }

  @UseGuards(AuthGuard('jwt')) // JWT 가드 적용
  @Get('profile')
  getProfile(@Req() req) {
    // req.user에는 JwtStrategy.validate()에서 반환된 사용자 정보가 담겨있음
    const { password, ...result } = req.user; // 비밀번호는 제외하고 반환
    return result;
  }
}

Auth Module

Auth 관련 모든 컴포넌트들을 묶습니다.

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]), // User 엔티티 등록
    PassportModule,
    JwtModule.registerAsync({ // 비동기적으로 JWT Secret 로드
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: '1h' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService, JwtModule], // 다른 모듈에서 AuthService와 JwtModule 사용 가능하도록 내보내기
})
export class AuthModule {}

AppModule에 AuthModule 등록

src/app.module.ts에 AuthModule을 임포트합니다.

src/app.module.ts (수정)
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module'; // AuthModule 임포트

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, envFilePath: process.env.NODE_ENV === 'development' ? '.env.development' : '.env' }),
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DATABASE_HOST,
      port: parseInt(process.env.DATABASE_PORT, 10),
      username: process.env.DATABASE_USERNAME,
      password: process.env.DATABASE_PASSWORD,
      database: process.env.DATABASE_NAME,
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true,
      logging: ['query', 'error'],
    }),
    AuthModule, // AuthModule 등록
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

아래 다이어그램은 회원가입과 로그인 이후, JWT가 보호 API 접근까지 전달되는 인증 파이프라인을 단계별로 보여줍니다.


이어서 보기