728x90

NestJS 인증 기능 구현하기
본 포스팅은 인프런 강의에서 배운 내용을 개인적으로 정리한 글입니다.
nest g resource auth

📍회원가입
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
username: string
@Column()
password: string
}
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [AuthController],
providers: [AuthService]
})
export class AuthModule {}
// auth.service.ts
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>
) {}
async signUp(authCredentialsDto: AuthCredentialsDto): Promise<User> {
const { username, password } = authCredentialsDto
const user = this.userRepository.create({ username, password })
await this.userRepository.save(user)
return user
}
}
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('/signup')
signUp(@Body() authCredentialsDto: AuthCredentialsDto): Promise<void | User> {
return this.authService.signUp(authCredentialsDto)
}
}
유저 데이터 유효성 체크
https://github.com/typestack/class-validator
GitHub - typestack/class-validator: Decorator-based property validation for classes.
Decorator-based property validation for classes. Contribute to typestack/class-validator development by creating an account on GitHub.
github.com
export class AuthCredentialsDto {
@IsString()
@MinLength(4)
@MaxLength(20)
username: string
@IsString()
@MinLength(4)
@MaxLength(20)
@Matches(/^[a-zA-Z0-9]*$/, {
message: '비밀번호는 영어와 숫자만 포함할 수 있습니다.'
}) // 영어랑 숫자만 가능함
password: string
}
요청이 컨트롤러에 있는 핸들러로 들어왔을 때 dto에 있는 유효성 조건에 맞게 체크를 해주려면 validation pipe를 넣어야 한다.
@Post('/signup')
signUp(
@Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto
): Promise<void | User> {
return this.authService.signUp(authCredentialsDto)
}

유저 이름에 유니크한 값 주기
- respository에서 findOne 메서드를 이용해 이미 같은 유저 이름을 가진 id가 있는지 확인하고 없다면 데이터를 저장하는 방법
- 데이터베이스 레벨에서 만약 같은 이름을 가진 유저가 있다면 에러는 주는 방법
- user.entity.ts에서 원하는 유니크한 값 주면 된다.
- 이미 있는 유저를 다시 생성하려 하면 아래와 같은 500 에러가 나온다.
nestJS에서 에러가 발생하고 그걸 try-catch 구문인 catch에서 에러를 잡아주지 않으면 이 에러가 controller 레벨로 가서 그냥 500에러를 발생하기 때문. → try-catch로 에러를 잡아줘야 함!!


export class UserRepository extends Repository<User> {
async createUser(authCredentialsDto: Auth): Promise<User> {
const { username, password } = authCredentialsDto
const user = this.create({
username,
password
})
try {
await this.save(user)
} catch (error) {
if (error.code === '23505') {
throw new Error('Username already exists')
}
throw new InternalServerErrorException()
}
return user
}
}
비밀번호 암호화하기
yarn add bcryptjs --save
비밀번호를 DB에 저장하는 방법
- 원본 비밀번호 저장 (Plain Text)
- 사용자가 입력한 비밀번호를 아무런 처리 없이 그대로 DB에 저장
입력: 1234
저장: 1234
- DB가 유출되면 비밀번호도 그대로 노출됨
- 법적으로도 위법 가능성 있음
- ❌ 절대 하면 안 되는 방식
- 양방향 암호화 (대칭키 암호화)
- 암호화 알고리즘과 암호화 키로 비밀번호를 암호화하고, 복호화도 가능
입력: 1234
암호화: gUuFwNo4zkMV+erdGtBlf5NunNgcELQuiCFJmCU4F+E=
복호화: 1234
- 암호화된 값은 사람이 못 읽음
- 복호화를 위해 암호화 키가 필요함
- 키가 유출되면 모든 비밀번호를 복호화할 수 있음
- 알고리즘은 대부분 공개되어 있어서 키만 털려도 위험
- 비밀번호 저장용으로는 부적절, 주민등록번호나 카드번호 등 복호화가 필요한 정보에만 제한 사용
- 단방향 해시 (SHA256 등, 솔트 없음)
- 비밀번호를 복호화할 수 없게 해시 함수로 암호화
입력: 1234
SHA256 → 03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4
- 복호화는 원래 불가능
- 같은 입력값이면 항상 같은 결과 → 예측 가능
- 공격자가 "레인보우 테이블"을 이용해 미리 계산된 해시값과 비교하여 비밀번호를 역추적할 수 있음
- 즉, 같은 해시 → 같은 비밀번호일 확률 높음
- 솔트 없이 단순 해시만 사용하면 위험
❌ 레인보우 테이블 (Rainbow Table)
- 저장 방법이 아니라 해시값을 공격하는 방식
- 사용자 아님, 공격자 입장에서 아래처럼 미리 해시값을 계산해둔 테이블을 만듦
1234 → 03ac6742...
letmein → 1c8bfe8f...
qwerty → abcd1234...
- 솔트가 없으면 이런 테이블로 해시값을 빠르게 역추적 가능
- 보안 기술이 아니라 해시값을 깨는 공격 기법
→ 그래서 단순 해시(SHA256 등)는 실무에서 안 씀
5. 솔트(salt) + 해시 저장 (bcrypt, argon2 등)
- 비밀번호에 랜덤한 문자열(Salt)을 붙인 후 해시 처리
- 동일한 비밀번호라도 서로 다른 해시값 생성됨
입력: 1234
생성된 솔트: kenfuduWssW
암호화: bcrypt("kenfuduWssW1234") → hashedPw123xyz
저장: hashedPassword + salt
- 복호화 불가
- 레인보우 테이블 무력화
- bcrypt, argon2, scrypt 등은 자동으로 솔트 처리 + 안전한 해시 적용
- 결론: 가장 안전하고 실무 표준
→ 모든 사이트/서비스에서 이 방법 사용
📍로그인
async signIn(authCredentialsDto: AuthCredentialsDto): Promise<string> {
const { username, password } = authCredentialsDto
const user = await this.userRepository.findOne({ where: { username } })
if (user && (await bcrypt.compare(password, user.password))) {
return '로그인 성공'
}
throw new UnauthorizedException('로그인 실패')
}
@Post('/signin')
signIn(
@Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto
): Promise<string> {
return this.authService.signIn(authCredentialsDto)
}

NestJS 커스텀 리포지토리 사용하기
useFactory란?
- NestJS 프로바이더 등록 시 직접 인스턴스를 생성해서 제공하는 함수
- 공장(factory) 함수라고 부름
- NestJS가 의존성 주입 시 해당 함수를 호출해 반환값을 주입함
왜 쓸까?
- NestJS는 보통
@InjectRepository()로 기본 저장소(Repository)를 바로 만들어 준다. - 그런데 내가 직접 만든 커스텀 저장소(UserRepository)는 NestJS가 알아서 만들지 못한다.
- 그래서 직접 "이렇게 만들어 주세요" 하고 알려줘야 하는 것!!!
// auth.module.ts
{
provide: UserRepository, // 이 이름으로 쓸 거임
useFactory: (dataSource: DataSource) => { // dataSource를 받아서
return new UserRepository(dataSource) // UserRepository를 직접 만들어서 넘김
},
inject: [DataSource], // dataSource는 NestJS가 알아서 넣어듐
}
// user.respository.ts (커스텀 repository)
export class UserRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager())
}
async createUser(authCredentialsDto: Auth): Promise<User> {
...
}
}
- constructor는 UserRepository가 생성될 때 NestJS로부터 DataSource 객체를 받는다.
- super(User, dataSource.createEntityManager())
- Repository 부모 클래스 생성자를 호출하면서
- 어떤 엔티티를 관리할지(User)와
- DB 작업을 할 EntityManager를 넘겨서 초기화한다.
📍JWT (JSON Web Token)
- 사용자 인증 및 정보 교환에 쓰이는 웹 표준 토큰
- 서버가 사용자에게 토큰을 발급하고, 이후 요청에 이 토큰을 포함해 인증을 진행한다.
- 토큰 자체에 정보를 담아서, 서버가 별도의 세션 저장 없이 인증 상태를 알 수 있다.
| 구분 | 내용 | 예시 (Base64 인코딩) |
| Header | 토큰 정보(알고리즘 등) | {"alg":"HS256","typ":"JWT"} |
| Payload | 사용자 정보(예: username, role, 만료시간) | {"sub":"1234567890","name":"John"} |
| Signature | Header와 Payload가 변조되지 않았는지 확인하는 안전장치 | HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) |

시그니처 검증 과정
- 클라이언트가 요청할 때 JWT를 헤더에 담아 보낸다.
- 서버는 JWT를 분해해서 Header와 Payload를 꺼낸다.
- 서버는 자신이 알고 있는
SecretKey로 다시 위와 같이 시그니처를 생성한다. - 서버가 만든 시그니처와 클라이언트가 보낸 시그니처가 일치하면
→ JWT가 변조되지 않은 것이므로 인증 성공
다르면 → 토큰 위조 또는 변조로 판단해 인증 실패

Passport, Jwt 이용해서 토큰 인증 후 유저 정보 가져오기
yarn add @nestjs/jwt @nestjs/passport passport passport-jwt --save
- auth 모듈에 등록하기
- passport 모듈 등록하기
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: 'Secret1234',
signOptions: {
expiresIn: 60 * 60 // 1 hour
}
}),
TypeOrmModule.forFeature([User])
],
3. 로그인 성공시 JWT를 이용해 토큰생성해주기
async signIn(
authCredentialsDto: AuthCredentialsDto
): Promise<{ accessToken: string }> {
const { username, password } = authCredentialsDto
const user = await this.userRepository.findOne({ where: { username } })
if (user && (await bcrypt.compare(password, user.password))) {
// 유저 토큰 생성 (Secret + Payload) // 중요 정보는 넣으면 안 됨
const payload = { username }
const accessToken = await this.jwtService.sign(payload)
return { accessToken: accessToken }
}
throw new UnauthorizedException('로그인 실패')
}
}

yarn add @types/passport-jwt --save
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(UserRepository)
private userRepository: UserRepository
) {
super({
secretOrKey: 'Secret1234', // 유효한지 체크하는 비밀 키
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()
})
}
async validate(payload) {
const { username } = payload
const user: User | null = await this.userRepository.findOne({
where: { username }
})
if (!user) {
throw new UnauthorizedException('유효하지 않은 토큰입니다.')
}
return user // 유저가 있으면 인증 성공
}
}
NestJS 에서 Middleware…
- 클라이언트의 요청을 가로채서 필요한 전처리 수행
- 요청에 대해 로그 남기기, 인증 검사, 헤더 수정 등 가능
- 컨트롤러에 요청이 전달되기 전에 실행됨
next()를 호출해야 요청이 다음 단계(다음 미들웨어나 컨트롤러)로 넘어감
| 개념 | 역할 | 언제 사용? |
| Pipes | 데이터 변환, 유효성 검사 | 요청 데이터(validation, 변환) 처리 시 |
| Filters | 예외 처리 (에러 잡아서 응답 생성) | 에러 발생 시 맞춤 에러 메시지 처리할 때 |
| Guards | 권한 검사, 인증 | 요청이 허용되는지 여부(권한 검사) 판단 시 |
| Interceptors | 요청/응답 가로채서 추가 작업 수행 | 로깅, 응답 포맷 변환, 캐싱, 타임아웃 등 |
@Post('/test')
@UseGuards(AuthGuard())
test(@Req() req) {
console.log(req) // JWT가 검증되면 user 정보가 들어있음
}

올바른 token을 넣어서 post test를 해보면 user 정보를 볼 수 있다!!
→ console.log(req.user)하면 유저 객체를 얻을 수 있다.
바로 user 라는 파라미터로 가져올 수 있는 방법은?
// get-user.decorator.ts
export const GetUser = createParamDecorator(
(data, ctx: ExecutionContext): User => {
const req = ctx.switchToHttp().getRequest()
return req.user
}
)

'✨BACKEND > 📍NestJS' 카테고리의 다른 글
| NestJS 회원가입/로그인 구현하기(feat. MySQL) (1) | 2025.08.30 |
|---|---|
| NestJS 게시판 CRUD 구현하기 (7) | 2025.07.27 |
| NestJS 개념 (6) | 2025.07.20 |