따라하며 배우는 NestJS 강의 내용을 정리한 글이다.
1. Auth 모듈 구조
인증 기능은 게시물(boards)과 독립된 별도 모듈로 만든다. NestJS CLI로 scaffolding하면 기본 구조가 자동 생성된다.
nest g module auth
nest g controller auth --no-spec
nest g service auth --no-spec
세 명령을 실행하면 src/auth/ 디렉토리 아래 auth.module.ts, auth.controller.ts, auth.service.ts가 생성되고, app.module.ts에 AuthModule이 자동 등록된다.
| 파일 | 역할 |
|---|---|
| AuthController | POST /auth/signup, /auth/signin 라우트 처리 |
| AuthService | Repository에 작업을 위임하는 비즈니스 레이어 |
| UserRepository | User DB 작업 캡슐화 (signUp, 조회 등) |
| User (Entity) | DB 테이블 매핑 (id, username, password) |
Repository에 signUp 같은 도메인 메서드를 캡슐화하면 Service 코드가 단순해지고, DB 접근 방식이 바뀌어도 Service를 건드리지 않아도 된다.
2. User Entity
이번 챕터에서 처음 등장하는 User 테이블이다. @Unique(['username'])이 핵심인데, DB 레벨에서 username 중복을 막는 unique 인덱스를 생성한다.
// src/auth/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity()
@Unique(['username'])
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
}
강의 원본과의 차이
강의 원본 코드는extends BaseEntity를 사용한다. TypeORM 1.x에서 BaseEntity 자체가 제거된 것은 아니지만, Repository 패턴과 혼용하면 ORM 레이어가 이중으로 열리는 혼란이 생긴다.extends BaseEntity없이 순수 Data Mapper 패턴으로 작성하는 것이 권장된다.
@Unique 데코레이터는 클래스 레벨에서 복합 unique 제약도 선언할 수 있다. @Unique(['username', 'email'])처럼 배열로 여러 컬럼을 묶으면 함께 중복을 체크한다. 단일 컬럼 unique라면 @Column({ unique: true })도 동일한 효과를 낸다.
3. AuthCredentialsDto — 입력 검증
회원가입과 로그인에 공통으로 쓰이는 DTO다. class-validator 데코레이터로 요청 데이터의 유효성을 선언적으로 정의한다.
// src/auth/dto/auth-credentials.dto.ts
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class AuthCredentialsDto {
@IsString()
@MinLength(4)
@MaxLength(20)
username: string;
@IsString()
@MinLength(8)
@MaxLength(20)
@Matches(/^[a-zA-Z0-9]*$/, {
message: 'password only accepts english and numbers',
})
password: string;
}
| 데코레이터 | 설명 |
|---|---|
| @IsString() | 값이 문자열인지 검사한다. undefined, number 등이 오면 400 에러를 던진다. |
| @MinLength / @MaxLength | 문자열 길이 범위를 제한한다. username 4~20자, password 8~20자. |
| @Matches(regex, options) | 정규식 패턴을 통과해야 한다. options.message로 오류 메시지를 커스터마이징할 수 있다. |
| /^[a-zA-Z0-9]*$/ | 영문자와 숫자만 허용한다. 특수문자(!@#$)가 포함되면 검증 실패. |
ValidationPipe는 글로벌로 등록했으므로(main.ts) 각 컨트롤러에서 @Body()만 써도 유효성 검사가 적용된다. 강의 원본은 파라미터 레벨에 @Body(ValidationPipe)를 명시하는 방식을 사용한다.
4. bcrypt 해싱 흐름
비밀번호는 절대 평문으로 저장하지 않는다. bcrypt는 단방향 해싱으로, 같은 비밀번호라도 매번 다른 해시 값이 만들어진다. 로그인 시 검증은 원본 비밀번호를 복호화하는 것이 아니라, 다시 해싱해서 저장된 해시와 비교하는 방식으로 이루어진다.
await bcrypt.genSalt() — 랜덤한 salt 문자열을 생성한다. 기본 cost factor는 10이다. 숫자가 높을수록 해싱이 느려지지만 더 안전하다.await bcrypt.hash(password, salt) — 평문 비밀번호와 salt를 결합해 해시를 만든다. 같은 비밀번호라도 salt가 다르면 해시가 달라진다.hashedPassword를 DB에 저장한다. 해시는 역산이 불가능하다.await bcrypt.compare(plainPassword, hashedPassword) — 입력 비밀번호를 같은 방식으로 해싱한 뒤 저장된 해시와 비교한다. 복호화가 아니다.bcrypt vs bcryptjs
강의 원본은bcryptjs(순수 JavaScript 구현)를 사용한다.bcrypt는 native C++ binding을 사용해 성능이 더 높지만, 빌드 환경(node-gyp)이 필요하다. macOS에서는 Xcode Command Line Tools가 있으면 정상 설치된다. Windows나 Docker 환경에서 빌드 오류가 생기면bcryptjs로 교체하고import * as bcrypt from 'bcryptjs'로만 바꾸면 API가 동일하게 동작한다.
5. UserRepository — signUp 구현
비밀번호 해싱과 DB 저장을 Repository에 캡슐화한다. PostgreSQL 에러 코드 23505는 unique 제약 위반 코드다. 이 코드를 잡아 ConflictException으로 변환하면 클라이언트에게 409 상태 코드가 전달된다.
TypeORM 0.2 vs 1.x 커스텀 Repository 패턴
| 구분 | TypeORM 0.2 (강의 원본) | TypeORM 1.x (현재) |
|---|---|---|
| Repository 선언 | @EntityRepository(User) 데코레이터 사용 |
@Injectable() + DataSource 주입 |
| 모듈 등록 | TypeOrmModule.forFeature([UserRepository]) |
forFeature([User]) + providers: [UserRepository] |
| 주입 방법 | @InjectRepository(UserRepository) |
생성자 타입 기반 직접 주입 |
| 비고 | TypeORM 0.3+에서 @EntityRepository 제거됨 |
현재 권장 방식 |
// src/auth/user.repository.ts
import {
ConflictException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { DataSource, Repository } from 'typeorm';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';
import { User } from './user.entity';
@Injectable()
export class UserRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager());
}
async signUp(authCredentialsDto: AuthCredentialsDto): Promise<void> {
const { username, password } = authCredentialsDto;
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(password, salt);
const user = this.create({ username, password: hashedPassword });
try {
await this.save(user);
} catch (error) {
if (error.code === '23505') {
throw new ConflictException('Existing username');
} else {
throw new InternalServerErrorException();
}
}
}
}
TypeORM 1.x에서 모듈 등록 방식의 차이
강의 원본(0.2):TypeOrmModule.forFeature([UserRepository])— Repository 클래스 자체를 forFeature에 등록.
현재 구현(1.x):TypeOrmModule.forFeature([User])+providers: [UserRepository]— Entity는 forFeature에, 커스텀 Repository는 일반 provider로 등록.@InjectRepository()없이 생성자에서 직접 주입.
6. AuthService, AuthController, AuthModule
Service는 Repository에 작업을 위임하는 얇은 레이어다. Controller는 HTTP 요청을 받아 Service를 호출한다. Module은 세 파일을 하나의 단위로 묶는다.
AuthService
// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';
import { UserRepository } from './user.repository';
@Injectable()
export class AuthService {
constructor(private userRepository: UserRepository) {}
async signUp(authCredentialsDto: AuthCredentialsDto): Promise<void> {
return this.userRepository.signUp(authCredentialsDto);
}
}
AuthController
// src/auth/auth.controller.ts
import { Body, Controller, Post, ValidationPipe } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('/signup')
signUp(
@Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto,
): Promise<void> {
return this.authService.signUp(authCredentialsDto);
}
}
AuthModule
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [AuthController],
providers: [AuthService, UserRepository],
})
export class AuthModule {}
7. 동작 확인
서버를 실행하고 아래 시나리오로 검증한다.
첫 번째 회원가입 (201)
curl -X POST http://localhost:3000/auth/signup \
-H 'Content-Type: application/json' \
-d '{"username":"issac","password":"test1234"}'
중복 username (409)
curl -X POST http://localhost:3000/auth/signup \
-H 'Content-Type: application/json' \
-d '{"username":"issac","password":"test5678"}'
유효성 검증 실패 (400)
curl -X POST http://localhost:3000/auth/signup \
-H 'Content-Type: application/json' \
-d '{"username":"issac2","password":"123"}'
curl -X POST http://localhost:3000/auth/signup \
-H 'Content-Type: application/json' \
-d '{"username":"issac3","password":"test!@#$"}'
pgAdmin에서 user 테이블을 확인하면 password 컬럼에 bcrypt 해시값($2b$10$...)이 저장되어 있다. 평문이 저장되지 않음을 확인한다.
8. 정리
인증 모듈을 독립 구조로 설계하고 bcrypt 해싱으로 안전한 회원가입을 구현했다. TypeORM 1.x에서는 @EntityRepository가 사라지고, DataSource를 주입받는 커스텀 Repository 패턴으로 대체된다. 모듈 등록도 forFeature에 Entity를, providers에 Repository를 분리해서 등록하는 방식으로 바뀐다.
| 핵심 개념 | 내용 |
|---|---|
| @Unique(['username']) | DB 레벨 unique 인덱스를 생성한다. 중복 삽입 시 PostgreSQL 에러 코드 23505를 반환. |
| bcrypt.genSalt / hash | salt를 생성하고 비밀번호를 단방향 해싱한다. 같은 비밀번호도 매번 다른 해시가 생성된다. |
| error.code === '23505' | PostgreSQL unique 제약 위반 코드. ConflictException(409)으로 변환해 클라이언트에 전달한다. |
| DataSource 주입 | TypeORM 1.x 커스텀 Repository 패턴. super(User, dataSource.createEntityManager())로 초기화. |
다음 편에서는 JWT를 이용한 로그인(signIn) 기능과 토큰 발급을 구현한다.
'Study > Node.JS' 카테고리의 다른 글
| [NestJS] JWT 로그인 구현 (0) | 2026.05.31 |
|---|---|
| [NestJS] PostgreSQL과 TypeORM (0) | 2026.05.24 |
| [Nest.js] 메모리 CRUD와 Pipes (0) | 2026.05.17 |
| [NestJS] NestJs 소개와 기본 구조 (0) | 2026.05.17 |
| [Express] 인증 & JWT & OAuth (0) | 2026.04.08 |