본문 바로가기

백엔드 API 구현

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

앞 글에서는 온라인 코드 에디터의 기본 저장 흐름과 실시간 협업 확장을 나누어 설계했습니다. 이 글부터는 그 설계를 바탕으로 NestJS 백엔드의 API를 구현합니다. 프로젝트 초기 설정, 인증, 프로젝트·파일 REST API를 차례로 다루며, 여기서는 자신의 파일을 조회하고 저장하는 기본 경로를 준비하고 WebSocket Gateway는 선택 확장으로 남겨 둡니다.

예제는 주요 파일을 중심으로 줄였습니다. 이어지는 글에서 생략한 파일·폴더 DTO와 UpdateProjectDto를 정의하고, 서버 진입 파일의 검증 설정도 확인합니다. DTO에 데코레이터를 붙이는 것만으로 요청 검증이 저절로 적용되지는 않습니다.

React 화면과 함께 실행할 때는 서버의 CORS 설정에 프런트엔드의 출처를 허용해야 합니다. 데이터베이스 연결과 환경 변수까지 준비한 뒤 모듈 연결과 실행에서 REST 요청을 확인하며, 그 전에는 아래 코드만 복사해 실행이 완성되었다고 판단하지 않습니다.


NestJS 프로젝트 초기 설정

먼저 NestJS CLI로 새 프로젝트를 만들고 필요한 패키지를 설치합니다. CLI 질문에서는 1장과 같이 CommonJS와 npm을 고릅니다.

# 새 프로젝트 생성
npx @nestjs/cli@12 new collaborative-code-editor-backend --no-observe

# 프로젝트 폴더로 이동
cd collaborative-code-editor-backend

# 데이터베이스, 설정, 인증 패키지 설치
npm install @nestjs/config @nestjs/typeorm typeorm pg bcryptjs @nestjs/jwt @nestjs/passport passport passport-jwt
npm install --save-dev @types/passport-jwt
# WebSocket 관련 패키지는 협업 확장에서 설치합니다.
  • @nestjs/typeorm, typeorm, pg: ORM인 TypeORM 1.x로 PostgreSQL과 연동합니다.
  • @nestjs/config: .env 파일과 실행 환경의 변수를 ConfigService로 읽습니다. 10장처럼 따로 dotenv를 설치할 필요가 없습니다.
  • bcryptjs: 비밀번호를 해시합니다. 현재 버전 3은 TypeScript 타입을 직접 제공하므로 @types/bcryptjs가 필요하지 않습니다.
  • @nestjs/jwt, @nestjs/passport, passport, passport-jwt: 4장처럼 JWT 기반 인증을 구현합니다.

데이터베이스 설정

프로젝트 루트에 .env 파일을 만들고 데이터베이스 연결 정보를 적습니다. 이 파일은 .gitignore에 넣어 커밋하지 않습니다.

# .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 모듈을 구성합니다. TypeOrmModule.forRootAsync()로 ConfigService를 주입받으면, .env를 읽은 뒤의 값을 확실하게 쓸 수 있고 필수 값이 없을 때 시작 단계에서 실패합니다.

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

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.getOrThrow<string>('DATABASE_HOST'),
        port: Number(config.get<string>('DATABASE_PORT', '5432')),
        username: config.getOrThrow<string>('DATABASE_USERNAME'),
        password: config.getOrThrow<string>('DATABASE_PASSWORD'),
        database: config.getOrThrow<string>('DATABASE_NAME'),
        autoLoadEntities: true, // forFeature()로 등록한 엔티티를 자동으로 포함
        synchronize: true, // 개발 단계에서만 사용합니다. 운영에서는 migration을 씁니다.
        logging: ['query', 'error'], // 개발 중 쿼리 로그 확인
      }),
    }),
  ],
})
export class AppModule {}

autoLoadEntities: true는 각 모듈이 TypeOrmModule.forFeature()로 등록한 엔티티를 연결에 자동으로 포함합니다. 엔티티 파일 경로를 glob으로 지정하는 방식보다 빌드 결과의 경로에 덜 의존합니다. synchronize: true는 엔티티에 맞춰 스키마를 자동으로 바꾸므로 개발에서만 쓰고, 운영에서는 데이터를 잃지 않도록 migration으로 스키마를 관리합니다.


사용자 인증 API 구현

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

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

Nest · Three authentication requests

회원가입·로그인·프로필의 경계

회원가입은 해시를 저장하고 메시지를, 로그인은 JWT를 반환합니다. 별도 프로필 요청에서 JWT와 사용자를 확인하고 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 응답에서 수행합니다.

email 사전 조회 후에도 동시 가입의 unique 충돌은 별도 처리해야 합니다. DTO 데코레이터는 본문에 정의되어 있지만, 이 문서에는 ValidationPipe 적용이나 여분 필드 거부 설정이 없습니다. 환경 변수 로드도 비밀키 길이 검증과는 다릅니다.

위 그림은 회원 가입, 로그인, 프로필이라는 세 HTTP 요청이 각각 어떻게 처리되는지 보여 줍니다. 회원 가입은 같은 이메일의 사용자가 없을 때 bcrypt.hash()로 만든 해시를 저장하고 메시지를 돌려주며, 이메일이 겹치면 ConflictException으로 멈춥니다. 로그인은 이메일로 사용자를 찾아 bcrypt.compare()로 비밀번호를 비교한 뒤 sub과 email을 담은 JWT를 발급하고, 사용자가 없거나 비교에 실패하면 UnauthorizedException으로 멈춥니다. 프로필 요청은 AuthGuard('jwt')가 Bearer 토큰을 검증하고, JwtStrategy.validate()가 payload.sub로 사용자를 찾으며, controller가 password를 뺀 나머지를 돌려줍니다. 그림에는 토큰 발급이 JwtService.sign으로 적혀 있지만, 아래 코드는 토큰을 Promise로 돌려주는 signAsync()를 씁니다.

User 엔티티

사용자 정보와 TypeORM 매핑을 정의합니다. 프로젝트 엔티티는 다음 글에서 만듭니다.

src/auth/entities/user.entity.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';

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

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

  @Column()
  password: string; // 해시한 비밀번호

  // 유니온 타입은 메타데이터로 추론되지 않으므로 컬럼 타입을 명시합니다.
  @Column({ type: 'varchar', nullable: true })
  nickname: string | null;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

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

nickname처럼 string | null 유니온 타입을 쓰는 컬럼은 TypeScript의 메타데이터로 데이터베이스 타입을 추론할 수 없으므로 type: 'varchar'를 명시합니다.

인증 DTO

요청 데이터를 검증할 DTO를 정의합니다. 5장의 ValidationPipe를 main.ts에 등록해야 실제로 검증됩니다.

npm install class-validator class-transformer
src/auth/dto/register-user.dto.ts
import { IsEmail, IsOptional, 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;

  @IsOptional()
  @IsString()
  nickname?: string;
}

nickname은 보내지 않아도 되는 값이므로 @IsOptional()을 붙입니다. 이 데코레이터가 없으면 @IsString()이 값이 없는 경우도 문자열이 아니라고 보고 요청을 거부합니다.

src/auth/dto/login-user.dto.ts
import { IsEmail, IsString } from 'class-validator';

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

  @IsString()
  password: string;
}

로그인 DTO에는 @MinLength()가 없습니다. 비밀번호가 맞는지는 서비스의 bcrypt.compare()가 판단하므로, 여기서는 두 값이 이메일 형식과 문자열인지만 확인합니다.

인증 서비스

회원 가입과 로그인의 업무 로직을 처리합니다. 흐름은 앞의 그림과 같고, 코드에서는 두 가지를 더 봅니다. login()은 사용자를 찾지 못한 경우와 비밀번호가 틀린 경우에 같은 Invalid credentials 메시지를 던지므로, 응답만으로는 어떤 이메일이 가입되어 있는지 알 수 없습니다. bcrypt.hash()의 두 번째 인자 10은 비용 값(cost)이며, 1 커질 때마다 해시 계산량이 두 배가 되어 무차별 대입이 그만큼 느려집니다.

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

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

  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: nickname ?? null,
    });
    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');
    }

    // 비밀 값과 만료 시간은 AuthModule의 JwtModule 설정을 씁니다.
    const payload = { sub: user.id, email: user.email };
    return { accessToken: await this.jwtService.signAsync(payload) };
  }

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

비밀 값과 만료 시간은 다음의 AuthModule에서 JwtModule.registerAsync()로 한 번만 정하므로, signAsync()에 다시 넘기지 않습니다. validateUser()는 사용자가 없을 수 있으므로 반환 타입을 User | null로 둡니다.

JWT 전략

요청 헤더의 JWT를 검증하고 사용자 정보를 꺼냅니다. 아래 전략에서는 super()에 넘긴 세 옵션과 validate()를 봅니다. Passport는 Authorization 헤더의 Bearer 토큰을 꺼내 서명과 만료를 먼저 검사하고, 통과한 경우에만 디코딩한 payload로 validate()를 부릅니다. 그래서 validate()는 토큰을 다시 검증하지 않고, 토큰의 sub에 해당하는 사용자가 아직 있는지만 확인합니다.

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

interface JwtPayload {
  sub: string;
  email: string;
}

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

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

secretOrKey에는 문자열이 꼭 있어야 하므로 getOrThrow()로 읽습니다. validate()가 반환한 값은 요청의 req.user가 됩니다.

인증 controller

API 엔드포인트를 정의합니다. controller 접두사가 auth이므로 경로는 POST /auth/register, POST /auth/login, GET /auth/profile 세 개입니다. 앞의 두 경로는 토큰 없이 호출할 수 있고, profile만 @UseGuards(AuthGuard('jwt'))로 앞의 JWT 전략을 거칩니다. guard가 토큰을 거부하면 핸들러는 실행되지 않고 401 응답이 돌아갑니다.

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

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

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

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

  @UseGuards(AuthGuard('jwt'))
  @Get('profile')
  getProfile(@Req() req: { user: User }) {
    // req.user에는 JwtStrategy.validate()가 반환한 사용자가 들어 있습니다.
    const { password: _password, ...profile } = req.user;
    return profile;
  }
}

새 프로젝트는 strict 모드이므로 @Req() req처럼 타입 없이 쓰면 req가 암묵적인 any가 되어 컴파일 오류가 납니다. 그래서 req.user의 모양을 { user: User }로 명시합니다. 응답에서 password 필드는 빼고 나머지만 돌려줍니다.

인증 모듈

인증에 필요한 구성 요소를 하나의 모듈로 묶습니다. 아래 모듈에서는 imports의 세 항목을 보면 됩니다. TypeOrmModule.forFeature([User])는 서비스가 @InjectRepository(User)로 받을 저장소를, PassportModule은 AuthGuard가 쓰는 Passport 연동을, JwtModule.registerAsync()는 JwtService를 등록합니다. registerAsync()의 useFactory는 ConfigService를 주입받아 실행되므로, .env에서 읽은 JWT_SECRET을 서명 비밀로 쓸 수 있습니다.

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 {}

exports에 AuthService와 JwtModule을 넣었으므로, AuthModule을 가져오는 다른 모듈도 사용자를 조회하거나 JwtService를 쓸 수 있습니다. JwtStrategy는 providers에 등록만 해 두면 Passport에 jwt라는 이름으로 등록되므로, AuthGuard('jwt')가 이 전략을 찾습니다. 이제 AppModule에 AuthModule을 추가합니다.

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

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.getOrThrow<string>('DATABASE_HOST'),
        port: Number(config.get<string>('DATABASE_PORT', '5432')),
        username: config.getOrThrow<string>('DATABASE_USERNAME'),
        password: config.getOrThrow<string>('DATABASE_PASSWORD'),
        database: config.getOrThrow<string>('DATABASE_NAME'),
        autoLoadEntities: true,
        synchronize: true,
        logging: ['query', 'error'],
      }),
    }),
    AuthModule,
  ],
})
export class AppModule {}

앞의 AppModule과 달라진 곳은 imports 끝의 AuthModule 한 줄입니다. AuthModule이 forFeature([User])로 등록한 엔티티는 autoLoadEntities로 연결에 포함됩니다. 다만 User 엔티티는 다음 글에서 만들 Project 엔티티를 가져오므로, 이 글까지의 코드만으로는 서버를 시작할 수 없습니다. 모듈 연결과 실행에서 ProjectModule까지 등록한 뒤 서버를 시작하면 synchronize가 네 엔티티에 맞는 테이블을 만듭니다.

더 깊이: bcryptjs가 검사하지 않는 비밀번호 길이

bcryptjs의 README는 입력의 최대 길이가 72바이트이고, 만들어지는 해시는 60자라고 설명합니다. 그런데 이 라이브러리는 C++ 바인딩과의 호환을 위해 72바이트를 넘는 입력을 따로 막지 않으며, 그런 입력은 잘린 앞부분만으로 해시됩니다. 그래서 길이가 문제가 되는 곳에서는 bcrypt.truncates(password)로 입력이 잘릴지 직접 확인하라고 안내합니다. 이 글의 RegisterUserDto는 최소 길이만 검사하고 최대 길이는 검사하지 않으므로, 아주 긴 비밀번호는 앞의 72바이트가 같기만 하면 뒷부분이 달라도 같은 비밀번호로 검증됩니다.

길이는 바이트로 세어야 한다는 점도 주의합니다. UTF-8에서 한글 한 글자는 3바이트이므로, @MaxLength()로 글자 수를 제한하더라도 72바이트를 넘지 않는다는 보장은 되지 않습니다. 또 README는 bcryptjs가 순수 JavaScript로 작성되어 C++ 바인딩보다 30% 정도 느리고, 그만큼 같은 시간에 처리할 수 있는 반복 횟수가 줄어든다고 밝힙니다. 비용 값을 정할 때는 이 차이를 고려해 실제 서버에서 해시 하나에 걸리는 시간을 측정합니다.


정리

  • 프로젝트는 npx @nestjs/cli@12로 만들고, TypeORM·PostgreSQL·설정·인증 패키지를 설치합니다. bcryptjs 3은 타입을 직접 제공합니다.
  • TypeOrmModule.forRootAsync()로 ConfigService를 주입받아 연결 정보를 읽고, 필수 값은 getOrThrow()로 시작 단계에서 확인합니다.
  • 회원 가입은 비밀번호를 해시해 저장하고, 로그인은 비밀번호를 비교한 뒤 sub과 email을 담은 JWT를 발급합니다.
  • JwtStrategy.validate()가 반환한 사용자가 req.user가 되며, strict 모드에서는 @Req() 매개변수의 타입을 명시합니다.
  • 선택 필드에는 @IsOptional()을, 유니온 타입 컬럼에는 TypeORM의 type을 명시합니다.

연습 문제

1. 같은 이메일로 다시 가입하면

[email protected]으로 가입한 뒤 같은 이메일로 다시 POST /auth/register를 보냈습니다. 응답은 무엇일까요? 두 요청이 거의 동시에 들어와 둘 다 findOne()에서 사용자를 찾지 못했다면 어떻게 될까요?

해설 보기

두 번째 요청은 findOne()이 기존 사용자를 찾으므로 ConflictException으로 409 Conflict가 됩니다.

두 요청이 동시에 들어오면 둘 다 조회 단계를 통과해 save()까지 갈 수 있습니다. 이때는 email 컬럼의 unique 제약이 두 번째 저장을 데이터베이스에서 막아 오류가 나지만, 그 오류는 ConflictException이 아니므로 기본 필터가 500으로 응답할 수 있습니다. 최종 방어선은 데이터베이스의 고유 제약이며, 그 제약 위반을 잡아 409로 바꾸는 처리를 더하면 응답도 일관되게 됩니다.

2. nickname 없이 가입하면

{"email":"[email protected]","password":"secret123"}처럼 nickname 없이 가입 요청을 보냈습니다. RegisterUserDto에서 @IsOptional()을 빼면 응답이 어떻게 달라질까요?

해설 보기

@IsOptional()이 있으면 값이 없는 nickname은 검사를 건너뛰므로 가입이 성공하고, 서비스가 nickname: null로 저장합니다.

@IsOptional()을 빼면 @IsString()이 값이 없는 경우도 문자열이 아니라고 판단하므로, ValidationPipe가 400 Bad Request로 요청을 거부합니다. 타입 표기의 ?는 TypeScript를 위한 정보일 뿐 런타임 검증에서 선택 필드로 만들어 주지 않습니다.

3. 프로필 응답의 비밀번호

getProfile()에서 password를 빼는 구조 분해를 지우고 return req.user;로 바꾸었습니다. 응답에 무엇이 담길까요? 이 처리를 controller 대신 어디에 두면 실수를 줄일 수 있을까요?

해설 보기

req.user는 데이터베이스에서 조회한 User 엔티티이므로, 응답에 해시한 비밀번호까지 그대로 담깁니다. 해시라도 외부에 내보내면 오프라인 추측 공격의 재료가 됩니다.

5장처럼 응답 DTO를 따로 만들어 허용한 필드만 복사하거나, 엔티티의 password 컬럼에 select: false를 주어 기본 조회에서 빠지게 하면, controller마다 빼는 것을 잊는 실수를 줄일 수 있습니다. select: false를 쓴다면 로그인처럼 비밀번호가 필요한 조회에서만 명시적으로 선택해야 합니다.

다음 글 프로젝트·파일 데이터 모델에서는 사용자가 소유하는 프로젝트와 그 안의 파일·폴더 엔티티를 정의합니다.