Search

9. 기초부터 배우는 인증

이번 글에서는 NestJS로 사용자 인증 시스템을 구축하는 과정을 단계별로 살펴본다.

1. 개요

1.1. 인증 흐름

인증의 기본 흐름은 크게 가입로그인 두 가지로 나뉜다.
1.
가입
사용자가 이메일과 비밀번호를 제공하고, 서버는 이를 처리해 가입 절차를 완료한다.
이메일이 이미 등록되어 있는지 확인 후, 비밀번호를 안전하게 암호화하여 데이터베이스에 저장한다.
쿠키를 생성해 사용자 아이디를 저장하고, 추후 요청에 사용될 수 있도록 한다.
2.
로그인
사용자는 이메일과 비밀번호를 제공하고, 서버는 이를 통해 사용자를 인증한다.
비밀번호는 암호화된 값으로 비교하며, 인증이 완료되면 사용자 아이디를 포함한 쿠키를 발급한다.

1.2. 인증 처리 방식

인증 관련 로직을 어디에 구현할 것인가? 하는 문제는 중요한 설계 요소이다. 일반적으로 두 가지 방법이 있다.

1. Users 서비스에서 처리하는 방법

인증 관련 기능인 signup, singin 메서드를 Users 서비스에 포함시켜 관리하는 방법이다.
이 방법은 애플리케이션의 크기가 작을 때 적합하며, 사용자와 관련된 모든 로직을 한 곳에서 처리할 수 있다.

2. 별도의 인증 서비스를 구현하는 방법

애플리케이션의 규모가 커짐에 따라 인증 서비스라는 별도의 서비스를 만들어 signup, signin 등의 기능을 처리하는 방법이다.
이 방법은 서비스가 확장될 때 유연하고 관리가 용이해지며, 향후 사용자의 설정이나 비밀번호 재설정 기능등을 추가할 때 유리하다.

2. AuthService 생성

우리는 인증 처리 방식을 별도의 AuthService를 두어서 관리하기로 하였다.
AuthServiceUserService를 의존하기 때문에 다음과 같이 코드를 작성해준다.
auth.service.ts
import { Injectable } from '@nestjs/common'; import { UsersService } from './users.service'; @Injectable() export class AuthService { constructor(private readonly userService: UsersService) {} }
TypeScript
복사
그리고 UsersModuleprovidersAuthService를 제공하여 의존성 주입을 받을 수 있도록 만든다.
user.module.ts
import { AuthService } from './auth.service'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService, AuthService], }) export class UsersModule {}
TypeScript
복사

2.1. signup 메서드

signup 메서드는 사용자에게서 입력받은 이메일 주소와 비밀번호를 처리하여, 새로운 사용자로 시스템에 등록하는 역할을 한다. 구현 과정에서 다음과 같은 네 가지 주요 단계를 거친다.
1.
이메일 중복 확인
2.
비밀번호 해시화
3.
사용자 생성
4.
사용자 반환

1) 이메일 중복 확인

첫 번째 단계는 사용자가 제공한 이메일 주소가 이미 다른 사용자의 이메일로 등록되어 있는지를 확인하는 단계이다. 이는 UserService에 이미 구현된 find 메서드를 활용하여, 주어진 이메일을 가진 사용자가 존재하는지 검색한다.
auth.service.ts
import { BadRequestException, Injectable } from '@nestjs/common'; import { UsersService } from './users.service'; @Injectable() export class AuthService { constructor(private readonly userService: UsersService) {} async signup(email: string, password: string) { // See if email is in use const users = await this.userService.find(email); if (users.length) { throw new BadRequestException('email in use'); } } }
TypeScript
복사
이때 반환되는 값은 배열 형태로, 배열의 길이가 0이 아니면 이미 해당 이메일을 사용하는 사용자가 있다는 뜻이므로, 이를 기반으로 에러를 던져주어야 한다.

2) 해시와 솔트로 비밀번호 암호화하기

지금까지 우리는 유저의 패스워드를 일반 텍스트를 그대로 저장하고 있었는데, 이 방법은 매우 위험한 방법이다.
해커가 데이터베이스를 훔쳐갈 경우, 다른 사이트에서 같은 비밀번호를 사용자의 계정까지 위험해질 수 있다.
따라서 비밀번호는 해시 값으로 변환해서 저장해야 한다.
해시 함수는 입력 값을 받아 고유한 해시 값을 생성한다. 비밀번호와 같은 민감한 정보는 해시 함수를 통해 변환하여 저장해야 한다. 해시 함수의 중요한 특성은 다음과 같다.
입력 값이 조금만 변경되어도 결과가 완전히 다르다.
예를 들어, “mypassword”와 “mypassword1”의 해시 값은 완전히 다르다.
또한, 해시된 값만 가지고 원래 값을 알 수 없다.
우리는 보안성을 강화하기 위해 singup 메소드를 구현할 때는 해싱 함수를 거쳐서 만들어진 해시 값만 데이터베이스에 저장하고, 원본 패스워드는 절대 저장하지 않는다.
singin 메소드를 구현할 때는 사용자가 입력한 패스워드를 해싱된 값과 비교하여 로그인 인증을 진행하면 된다.
다만 이 방법에도 치명적인 약점이 하나 존재하는데, 바로 Rainbow Table Attack이다.
Rainbow Table Attack은 해시 함수의 출력 값을 미리 계산하여 저장된 큰 테이블을 사용하는 공격 기법이다.
공격자는 데이터베이스에서 탈취한 해시 값을 Rainbow Table과 비교하여 원본 비밀번호를 유추하려 시도한다.
이 방식은 기존에 암호화된 비밀번호를 풀기 위해 시간과 비용을 많이 들여 계산하는 대신, 미리 계산된 테이블을 통해 빠르게 복호화할 수 있게 된다.
Rainbow Table Attack을 방어하려면 해시 값을 그대로 저장하는 것만으로는 충분하지 않다.
우리는 약간의 트릭을 줘서 간단한 방법으로 이 문제를 해결할 수 있다.
방법은 다음과 같다.
기존의 원본 패스워드에 무작위로 추가된 문자열(Salt)를 추가하여 해시 값을 생성하게 되면, 동일한 비밀번호라도 사용자마다 다른 해시 값이 생성된다. 이렇게 하면 미리 계산된 Rainbow Table이 의미를 잃게 되어 공격을 방어할 수 있다.
signin 메소드를 구현할 때는 사용자가 입력한 비밀번호를 처리하기 위해서는 데이터베이스에 저장된 유저의 salt 값을 가져와 원본 패스워드에 추가하여 해시 값을 생성한 뒤에, 이 값을 DB에 저장된 해시 값과 비교하면 된다.
이제 Node.js의 내장 모듈인 crypto를 사용해서 비밀번호를 해싱해보자.
먼저, crypto 모듈에서 randomBytesscrypt 함수를 가져오고, 콜백 기반의 scryptpromisify를 사용하여 프로미스 기반으로 변환한다.
auth.service.ts
import { randomBytes, scrypt as _scrypt } from 'crypto'; import { promisify } from 'util'; const scrypt = promisify(_scrypt);
TypeScript
복사
그리고 비밀번호를 안전하게 저장하기 위해 다음 세 단계를 거친다.
1.
랜덤한 salt를 생성한다.
2.
비밀번호에 salt를 추가하여 해싱한다.
3.
해시 값과 솔트를 합쳐서 데이터베이스에 저장할 형태로 변환한다.
auth.service.ts
async signup(email: string, password: string) { ... // Hash the users password // Generate a salt const salt = randomBytes(8).toString('hex'); // Hash the salt and the password together const hash = (await scrypt(password, salt, 32)) as Buffer; // Join the hashed result and the salt together const result = salt + '.' + hash.toString('hex'); }
TypeScript
복사
1.
솔트(salt) 생성
randomBytes(8)
8바이트 크기의 랜덤한 바이트 배열을 생성한다.
8바이트 = 64비트 (즉, 16자리의 16진수 문자열이 만들어짐)
예제 결과: <Buffer fb 83 f4 f7 74 f7 2b 87>
.toString('hex')
바이트 데이터를 사람이 읽을 수 있는 16진수 문자열(hexadecimal string)로 변환한다.
예제 결과: fb83f4f774f72b87
2.
비밀번호 + 솔트 결합 후 해싱
scrypt(password, salt, 32)
Scrypt 해시 함수는 비밀번호솔트를 합쳐서 해싱한다.
세 번째 인수 32는 출력되는 해시의 길이(바이트)를 의미한다.
32바이트 = 256비트 = 64자리의 16진수 문자열
scrypt는 비동기 함수이므로 await를 붙여서 실행 결과를 기다려야 한다.
as Buffer
TypeScript에서 반환 타입이 unknown으로 추론될 수 있어서, scrypt가 반환하는 타입을 명확하게 지정한다.
scrypt는 기본적으로 Buffer(버퍼) 객체를 반환한다.
3.
솔트와 해시 결합
salt + '.' + hash.toString('hex')
솔트와 해시값을 하나의 문자열로 결합한다.
.(마침표)를 사용하여 두 값을 구분한다.
hash.toString(’hex’) → 해시값을 16진수 문자열로 변환한다.
예제 결과: fb83f4f774f72b87.5e884898da28047151d0e56f8dc62927
왼쪽 부분: fb83f4f774f72b87 (16자리 솔트 값)
오른쪽 부분: 5e884898da28047151d0e56f8dc62927 (해싱된 비밀번호)

3) 유저 생성 및 반환

마지막으로 userServicecreate 메서드에 이메일과 해싱 된 패스워드를 넣어준 다음 유저를 생성하고 반환한다.
authservice.ts
async signup(email: string, password: string) { // See if email is in use const users = await this.userService.find(email); if (users.length) { throw new BadRequestException('email in use'); } // Hash the users password // Generate a salt const salt = randomBytes(8).toString('hex'); // Hash the salt and the password together const hash = (await scrypt(password, salt, 32)) as Buffer; // Join the hashed result and the salt together const result = salt + '.' + hash.toString('hex'); // Create a new user and save it const user = await this.userService.create(email, result); // return the user return user; }
TypeScript
복사

4) 테스트

비밀번호 해싱이 정상적으로 동작하는지 확인하기 위해 signup 핸들러에 다음과 같이 요청을 보내보자.
POST http://localhost:3000/auth/signup content-type: application/json { "email": "test@test.com", "password": "test" }
JSON
복사
이제 user 테이블을 확인해보면 다음과 같이 .을 기준으로 좌측에는 16자리 솔트 값, 우측에는 64자리의 16진수 문자열이 저장된 것을 확인할 수 있다.

2.2. signin 메서드

siginin 메서드는 사용자가 제공한 이메일과 비밀번호를 검증하여 로그인할 수 있도록 하는 기능을 수행한다.
signup과 유사한 작업을 하지만, 비밀번호 검증 절차가 추가된다.
sigin 메서드의 전체 코드는 다음과 같다.
auth.service.ts
async signin(email: string, password: string) { const [user] = await this.userService.find(email); if (!user) { throw new NotFoundException('user not found'); } const [salt, storedHash] = user.password.split('.'); const hash = (await scrypt(password, salt, 32)) as Buffer; if (storedHash !== hash.toString('hex')) { throw new BadRequestException('bad password'); } return user; }
TypeScript
복사
위 코드를 하나하나 단계별로 살펴보자.

1) 이메일로 사용자 찾기

usersService.find(email) 메서드를 이용하여 해당 이메일을 가진 사용자를 검색한다.
그러나 find 메서드는 배열을 반환하므로 구조 분해 할당을 사용하여 단일 사용자 객체를 가져온다.
const [user] = await this.userService.find(email); if (!user) { throw new NotFoundException('user not found'); }
TypeScript
복사

2) 저장된 비밀번호 분리

사용자의 비밀번호는 DB에 솔트.해시 형식으로 저장되어 있으므로 이를 분리해야 한다.
const [salt, storedHash] = user.password.split('.');
TypeScript
복사

3) 입력된 비밀번호 해싱 및 검증

입력된 비밀번호와 저장된 솔트를 이용하여 해싱한 후, 데이터베이스에 저장된 해시값과 비교한다.
비밀번호가 일치하면 사용자 객체를 반환하고, 아니라면 에러를 출력한다.
const hash = (await scrypt(password, salt, 32)) as Buffer; if (storedHash !== hash.toString('hex')) { throw new BadRequestException('bad password'); } return user;
TypeScript
복사

4) signin 컨트롤러 추가

컨트롤러에 signin 컨트롤러를 추가하여 사용자가 로그인할 수 있도록 한다.
@Post('/signin') signin(@Body() body: CreateUserDto) { return this.authService.signin(body.email, body.password); }
TypeScript
복사

5) 테스트

1.
올바른 이메일과 비밀번호 제공 시 사용자 정보 반환
POST http://localhost:3000/auth/signin content-type: application/json { "email": "test@test.com", "password": "test" } # 출력 HTTP/1.1 201 Created { "id": 3, "email": "test@test.com" }
JSON
복사
2.
잘못된 비밀번호 입력 시 400 Bad Request 반환
POST http://localhost:3000/auth/signin content-type: application/json { "email": "test@test.com", "password": "test123" } # 출력 { "statusCode": 400, "message": "bad password", "error": "Bad Request" }
JSON
복사
3.
존재하지 않는 이메일 입력 시 404 Not Found 반환
POST http://localhost:3000/auth/signin content-type: application/json { "email": "apple@test.com", "password": "test" } # 출력 { "statusCode": 404, "message": "user not found", "error": "Not Found" }
JSON
복사

3. Cookie-Session을 이용한 인증 처리

이제 cookie-session 패키지를 활용하여 사용자의 로그인 상태를 유지할 수 있도록 구현해보자.
cookie-session은 쿠키를 활용하여 사용자의 세션을 관리하는 미들웨어이다.
이 패키지는 세션 데이터를 암호화된 쿠키에 저장하고, 서버로 요청이 들어올 때 이를 해석하여 활용할 수 있도록 도와준다.
동작 방식은 다음과 같다.
1.
클라이언트가 요청을 보낼 때, Cookie 헤더에 암호화된 문자열이 포함된다.
2.
서버에서 cookie-session 미들웨어가 이를 해석하여 Session 객체로 변환한다.
3.
라우트 핸들러에서 Session 객체를 사용하여 데이터를 읽거나 수정할 수 있다.
4.
수정된 Session 객체는 다시 암호화된 쿠키로 변환되어 Set-Cookie 헤더를 통해 클라이언트에게 반환된다.
5.
이후 요청에서는 갱신된 세션 정보가 포함된 쿠키가 함께 전송된다.

3.1. 프로젝트에 Cookie-Session 설정하기

1) 패키지 설치

먼저 cookie-session 패키지와 TypeScript 타입 정의 파일을 설치해야 한다.
npm install cookie-session @types/cookie-session
Shell
복사

2) cookie-session 미들웨어 설정

main.ts 파일을 열어 cookie-session 미들웨어를 설정한다.
주의할 점은 cookie-session은 NestJS의 tsconfig.json 설정과 호환되지 않는 문제가 있기 때문에, 일반적인 import 문법 대신 require를 사용해야 한다.
main.ts
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.moudle'; const cookieSession = require('cookie-session'); async function bootstrap() { const app = await NestFactory.create(AppModule); app.use( cookieSession({ keys: ['my-secret-key'], // 쿠키를 암호화하는 키 (배열 형태) }), ); await app.listen(3000); } bootstrap();
TypeScript
복사
여기서 keys 속성에 포함된 문자열은 쿠키의 암호화 키 역할을 한다.
이 값은 보안상 중요한 요소이므로, .env 파일 등에 저장하여 관리하는 것이 좋다.

3.2. 사용자 인증 및 세션 관리

1) 회원가입 (Signup) 및 로그인 (Signin) 시 세션 업데이트

사용자가 회원가입 또는 로그인을 하면, 해당 사용자의 ID를 Session 객체에 저장하여 인증 상태를 유지한다.
이를 위해 Session 데코레이터를 사용하여 세션을 인자로 받도록 수정한다.
users.controller.ts
import { Session } from '@nestjs/common'; @Post('/signup') async createUser(@Body() body: CreateUserDto, @Session() session: any) { const user = await this.authService.signup(body.email, body.password); session.userId = user.id; return user; } @Post('/signin') async signin(@Body() body: CreateUserDto, @Session() session: any) { const user = await this.authService.signin(body.email, body.password); session.userId = user.id; return user; }
TypeScript
복사
반환값을 직접 응답하는 대신, 사용자 정보를 user 변수에 저장한다.
session.userId = user.id를 할당하여 세션에 사용자 정보를 저장한다.

2) 요청 테스트

가입 요청(signup)을 보내면 Set-Cookie 헤더를 통해 클라이언트에 쿠키가 전달되는 것을 볼 수 있다.
POST http://localhost:3000/auth/signup content-type: application/json { "email": "test345@test.com", "password": "12345" } ### 응답 HTTP/1.1 201 Created Set-Cookie: session=eyJjb2xvciI6InJlZCIsInVzZXJJZCI6NX0=; path=/; httponly,session.sig=BdPRzIv5yzaDfG5XHf0dW1HEGj8; path=/; httponly { "id": 5, "email": "test345@test.com" }
JSON
복사
반면 로그인 요청(siginin)을 보내면 Session 객체는 이미 동일한 userId 값을 가지고 있기 때문에 Set-Cookie 헤더가 포함되지 않는다. 즉, 세션에 변화가 없으면 쿠기가 갱싱되지 않는다.
POST http://localhost:3000/auth/signin content-type: application/json { "email": "test345@test.com", "password": "12345" } ### 응답 HTTP/1.1 201 Created { "id": 5, "email": "test345@test.com" }
JSON
복사

3) 로그인된 사용자 확인 (whoami)

애플리케이션에서 현재 로그인된 사용자의 정보를 확인하려면 새로운 라우트 핸들러를 추가해야 한다.
이 핸들러는 사용자가 로그인한 계정 정보를 반환하는 역할을 한다.
users.controller.ts
@Get('/whoami') whoAmI(@Session() session: any) { return this.usersService.findOne(session.userId); }
TypeScript
복사
이 코드에서 Session 객체를 인자로 받아, session.userId를 이용해 데이터베이스에서 해당 사용자의 정보를 조회한다. 만약 사용자가 로그인하지 않았다면 session.userIdundefined이며, 결과적으로 findOne(null)을 호출하게 된다.
GET http://localhost:3000/auth/whoAmI ### 응답 HTTP/1.1 200 OK { "id": 5, "email": "test345@test.com" }
JSON
복사

4) 로그아웃 기능

사용자가 애플리케이션에서 로그아웃할 수 있도록, signout 엔드포인트를 추가한다.
로그아웃 처리는 세션에서 userId 값을 null로 설정하면 된다.
users.controller.ts
@Post('/signout') signOut(@Session() session: any) { session.userId = null; }
TypeScript
복사
이제 사용자가 POST /signout 요청을 보내면, 해당 사용자의 session.userIdnull로 변경된다.
POST http://localhost:3000/auth/signout
TypeScript
복사
이후, GET /whoami 요청을 보내면 사용자 정보를 가져올 수 없기 때문에 NotFoundException를 응답으로 받기를 기대할 것이다.
GET http://localhost:3000/auth/whoAmI
TypeScript
복사
하지만 SQLite의 특성상, findOne(null)을 호출하면 사용자의 첫 번째 정보를 반환하는 문제가 발생한다.
이를 방지하기 위해 findOne 메서드에서 null이나 undefined 값이 전달되었을 때, 즉시 null을 반환하도록 수정해야한다.
users.service.ts
async findOne(id: number) { const user = await this.repo.findOne({ where: { id } }); if (!id || !user) { throw new NotFoundException('user not found'); } return user; }
TypeScript
복사

4. 가드와 인터셉터 구현

지금까지는 기본적인 기능(회원가입, 로그인, 로그아웃, 현재 로그인한 사용자 정보 조회)을 구현했다면, 이제 인증 관련 기능을 자동화할 수 있는 도구를 추가할 차례이다.
1.
Guard: 로그인 여부 확인
2.
Interceptor + Decorator: 현재 로그인된 사용자 정보 자동 제공

4.1. 현재 로그인된 사용자 정보 자동 제공

우리의 목표는 사용자의 Session 객체에 접근userId를 가져오고, 해당 ID를 기반으로 실제 사용자 정보를 조회하여 라우트 핸들러에 전달하는 것을 목표로 한다. 이를 위해 커스텀 데코레이터를 만들 것이다.

1) CurrentUser 데코레이터 구현

우선, users 디렉터리 내에 decorators 폴더를 생성한 후, current-user.decorator.ts 파일을 만든다.
createParamDecoratorExecutionContext를 활용하여 CurrentUser 데코레이터를 구현한다.
decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; export const CureentUser = createParamDecorator( (data: never, context: ExecutionContext) => { const request = context.switchToHttp().getRequest(); // 사용자 DB 조회 return '실제 사용자 정보'; }, );
TypeScript
복사
하지만 위 코드에서 세션 정보를 읽어 userId를 반환할 수는 있지만, 해당 ID를 기반으로 실제 사용자 정보를 조회하는 것은 어렵다. UsersService는 의존성 주입 시스템(DI)을 통해 관리되므로, 데코레이터 내부에서 직접 사용할 수 없다.
따라서, 인터셉터를 활용하여 이 문제를 해결해야 한다.
인터셉터에서 UsersService를 이용해 데이터베이스에서 해당 사용자를 조회한 다음, CurrentUser 데코레이터로 넘겨준다.

2) CurrentUserInterceptor 인터셉터 구현

이제 users 폴더 아래 interceptors 디렉터리를 만들고, current-user.interceptor.ts 파일을 생성한다.
interceptors/current-user.interceptor.ts
import { NestInterceptor, ExecutionContext, CallHandler, Injectable, } from '@nestjs/common'; import { UsersService } from '../users.service'; @Injectable() export class CurrentUserInterceptor implements NestInterceptor { constructor(private readonly userService: UsersService) {} async intercept(context: ExecutionContext, handler: CallHandler<any>) { const request = context.switchToHttp().getRequest(); const { userId } = request.session; if (userId) { const user = await this.userService.findOne(userId); request.currentUser = user; } return handler.handle(); } }
TypeScript
복사
Injectable() 데코레이터
UsersService를 인터셉터에 주입하기 위해서는 Injectable 데코레이터가 필요하다.
intercept() 메서드
이 메서드는 인터셉터가 요청을 가로챌 때 호출된다.
이 메서드 안에서 요청 핸들러 실행 전후에 원하는 로직을 처리할 수 있다.
context
ExecutionContext는 현재 요청에 대한 정보를 담고 있다.
이 정보는 요청의 URL, HTTP 메서드, 사용자 정보 등을 포함한다.
handler
CallHandler는 실제 라우트 핸들러를 실행하는 객체이다.
handler.handle()을 호출하면 요청이 실제 핸들러로 전달된다.
그렇다면 데코레이터는 사용하지 않고, 인터셉터만 사용하면 되는거 아닌가? 그렇게 해도 된다. 하지만, 커스텀 데코레이터를 사용하지 않는다면 핸들러에서 @Request()로 요청을 가져와 request.currentUser를 사용해야 하는데 이러면 그 의미가 모호해질 수 있다. 따라서, CurrentUser 데코레이터를 사용하면 “현재 사용자를 가져오는 작업”의 의도를 더 직관적으로 전달할 수 있다. 또한, 추후에 currentUser를 가져오는 방법이 바뀐다면, 인터셉터에서 처리하는 방식만 바꾸면 된다. 핸들러에서 직접 request.currentUser를 사용하는 방식으로 작성되면, 나중에 코드 변경이 필요할 때 모든 핸들러에서 일일이 수정해야 하는 불편함이 생긴다.

3) 인터셉터를 모듈에 프로바이더로 추가하기

인터셉터를 사용하려면 이를 모듈에 프로바이더로 등록해야 한다. UsersModule 내에서 인터셉터를 프로바이더 목록에 추가하고, 이를 통해 의존성 주입을 가능하게 한다.
users.module.ts
import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './users.entity'; import { AuthService } from './auth.service'; import { CurrentUserInterceptor } from './interceptors/current-user.interceptor'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService, AuthService, CurrentUserInterceptor], }) export class UsersModule {}
TypeScript
복사

4) 컨트롤러에서 인터셉터와 데코레이터 사용하기

이제 인터셉터를 컨트롤러에 적용해보자. @UserInterceptors() 데코레이터를 사용하여 컨트롤러에 인터셉터를 적용할 수 있다. 이렇게 하면 해당 컨트롤러에 속한 모든 핸들러에서 인터셉터가 실행된다.
그리고 CurrentUser 데코레이터를 사용하면 현재 사용자 정보를 손쉽게 받을 수 있다.
@Controller('auth') @UseInterceptors(CurrentUserInterceptor) // 인터셉터를 컨트롤러에 적용 @Serialize(UserDto) export class UsersController { @Get('/whoami') whoAmI(@CurrentUser() user: User) { return user; // currentUser 데코레이터로 사용자 정보 반환 } }
TypeScript
복사

5) 전역 인터셉터 설정

현재 입터셉터는 특정 컨트롤러에만 적용되어 있다. 예를 들어, @UseInterceptors()를 각 컨트롤러에 명시적으로 추가해야 하며, 각 컨트롤러마다 인터셉터를 임포트하고 설정해야 한다. 이 방식은 각 컨트롤러에 반복적으로 코드를 추가해야 하므로 효율적이지 않으며, 관리가 번거로울 수 있다.
전역적으로 인터셉터를 적용하면 모든 리퀘스트에 대해 하나의 인터셉터 인스턴스를 사용하여 처리할 수 있다. 이렇게 하면 코드 중복을 제거하고, 리퀘스트가 어디로 들어가든 간에 인터셉터가 자동으로 실행되므로 관리가 훨씬 쉬워진다.
전역 인터셉터를 등록하기 위해 UsersModule 파일에서 APP_INTERCEPTOR를 임포트하고, 이를 통해 전역적으로 인터셉터를 등록할 수 있다.
users.module.ts
import { Module } from '@nestjs/common'; import { CurrentUserInterceptor } from './interceptors/current-user.interceptor'; import { APP_INTERCEPTOR } from '@nestjs/core'; @Module({ providers: [ { provide: APP_INTERCEPTOR, useClass: CurrentUserInterceptor, }, ], }) export class UsersModule {}
TypeScript
복사
APP_INTERCEPTOR를 사용하여 CurrentUserInterceptor를 프로바이더 배열에 추가합니다. 이때 useClass 속성에 인터셉터 클래스를 넣어줍니다.
하지만, 이렇게 전역으로 설정하면 유저 정보가 필요하지 않은 컨트롤러에서도 사용자 정보 조회가 수행될 수 있어 불필요한 데이터 조회가 발생할 수 있다. 이는 성능에 영향을 미칠 수 있으므로, 애플리케이션의 요구사항에 맞게 조정해야 한다.

4.2. 로그인 여부 확인

이제 사용자가 로그인된 상태인지 확인하며,
가드는 요청이 컨트롤러나 라우트 핸들러로 도달하기 전에 그 접근을 제어하는 역할을 한다.
주로 사용자가 로그인되었는지 확인하고, 이에 따라 요청을 허용하거나 거부하는데 사용된다.
가드는 canActivate라는 메서드를 가진 클래스로 구현되며, 이 메서드는 요청이 들어올 때마다 자동으로 호출된다.
canActivate 메서드에서는 truefalse를 반환하여 요청을 허용하거나 거부한다. 만약 false가 반환되면, 해당 요청은 차단되고, true가 반환되면 요청이 정상적으로 처리된다.
가드는 애플리케이션의 전역, 컨트롤러, 또는 각 핸들러에 개별적으로 적용할 수 있다.

1) AuthGuard 클래스 구현

먼저 src/guards 폴더를 만들고, auth.guard.ts 파일을 생성한다. 이 파일에서 CanActivateExecutionContext를 임포트한 후, AuthGuard 클래스를 정의한다.
guards/auth.guard.ts
import { CanActivate, ExecutionContext } from '@nestjs/common'; export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const userId = request.session?.userId; return !!userId; // UserId가 있으면 로그인된 상태로 간주 } }
TypeScript
복사
위의 코드에서 canActivate 메서드는 request.session.userId를 통해 사용자가 로그인되었는지 확인한다. userId가 존재하면 true를 반환하고, 존재하지 않으면 false를 반환하여 접근을 차단한다.

2) 가드 적용하기

이제 이 AuthGuard를 컨트롤러에 적용해보자. 특정 핸들러에만 가드를 적용하려면, 해당 핸들러에 @UseGuards(AuthGuard) 데코레이터를 추가한다.
@Get('/whoami') @UseGuards(AuthGuard) whoAmI(@CurrentUser() user: User) { return user; }
TypeScript
복사
위의 코드에서는 @UseGuards(AuthGuard)를 사용하여 whoAmI 핸들러에만 AuthGuard를 적용했다. 이제 로그인되지 않은 사용자가 이 API에 접근하면 자동으로 403 상태 코드가 반환된다.

3) 테스트

이제 이 인증 시스템을 테스트할 차례이다.
로그인되지 않은 상태에서 테스트
GET http://localhost:3000/auth/whoAmI { "statusCode": 403, "message": "Forbidden resource", "error": "Forbidden" }
JSON
복사
로그인된 상태에서 테스트
GET http://localhost:3000/auth/whoAmI { "id": 5, "email": "test345@test.com" }
JSON
복사