AuthModule, AuthController, AuthService로 이해한 로그인 흐름
NestJS에서 AuthModule, AuthController, AuthService를 통해 로그인 요청을 검증하고 JWT access token을 발급하는 흐름을 정리한 글
이전 글에서는 CreateUserDto와 UpdateUserDto를 기준으로, 프론트에서 들어오는 사용자 데이터가 어떻게 검증되는지 정리했다.
이번 글에서는 인증 흐름으로 넘어가보려고 한다.
지금까지는 사용자 생성, 조회, DTO 검증 흐름을 봤다면, 이제는 사용자가 이메일과 비밀번호로 로그인했을 때 백엔드에서 어떤 일이 일어나는지 정리할 차례다.
이번 글에서는 AuthModule, AuthController, AuthService를 한 번에 묶어서 정리한다.
기존 글에서는 Module, Controller, Service를 따로 나눠서 봤지만, 이제는 NestJS의 기본 구조에 어느 정도 익숙해졌기 때문에 로그인 흐름 전체를 하나의 글로 연결해서 보는 것이 더 자연스럽다고 생각했다.
운영 중인 서비스 코드이기 때문에 실제 코드를 그대로 공개하지 않고, 핵심 구조와 흐름 중심으로 정리했다.
전체 로그인 흐름
로그인 요청의 전체 흐름은 다음과 같다.
프론트에서 로그인 요청
↓
POST /auth/login
↓
AuthController.login()
↓
AuthService.validateUser()
↓
UsersService.findByEmail()
↓
bcrypt.compare()
↓
AuthService.login()
↓
JWT access_token 반환
조금 더 역할별로 나누면 다음과 같다.
AuthModule
→ 인증 기능에 필요한 모듈과 서비스를 조립한다.
AuthController
→ 로그인 요청을 받는다.
AuthService
→ 이메일/비밀번호를 검증하고 JWT를 발급한다.
이번 글에서 가장 중요한 흐름은 다음 두 단계다.
1. validateUser()
→ 이메일과 비밀번호가 맞는지 확인한다.
2. login()
→ 검증된 사용자에게 JWT access_token을 발급한다.
1. AuthModule은 인증 기능을 조립하는 모듈이다
먼저 AuthModule은 인증 기능을 하나로 묶는 모듈이다.
구조를 단순화하면 다음과 같다.
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.getOrThrow<string>('JWT_EXPIRES_IN') as any,
},
}),
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
AuthModule에는 인증에 필요한 여러 요소가 연결되어 있다.
UsersModule
→ 로그인할 때 유저를 찾기 위해 필요하다.
PassportModule
→ JWT 인증 Guard와 Strategy 기반으로 사용된다.
JwtModule
→ JWT 토큰 발급에 필요하다.
AuthService
→ 로그인 검증과 토큰 발급 로직을 담당한다.
JwtStrategy
→ 요청에 포함된 JWT 토큰을 검증하는 전략이다.
AuthController
→ 로그인 API 요청을 받는다.
즉, AuthModule은 직접 로그인 로직을 처리하는 곳이라기보다, 인증 기능에 필요한 부품들을 NestJS에 등록하고 연결하는 역할을 한다.
2. UsersModule을 가져오는 이유
AuthModule에서 중요한 부분 중 하나는 UsersModule을 import한다는 점이다.
imports: [
UsersModule,
...
]
로그인을 하려면 먼저 사용자가 입력한 이메일을 가진 유저가 DB에 있는지 확인해야 한다.
예를 들어 사용자가 로그인 폼에 다음 값을 입력했다고 해보자.
{
"email": "test@example.com",
"password": "password1234"
}
백엔드는 먼저 이 이메일을 가진 유저를 찾아야 한다.
이메일로 유저 조회
↓
유저가 있으면 비밀번호 비교
↓
비밀번호가 맞으면 JWT 발급
이메일로 유저를 찾는 기능은 UsersService에 있다.
this.usersService.findByEmail(email)
그래서 AuthService가 UsersService를 사용할 수 있어야 한다.
UsersModule에서 UsersService를 exports
↓
AuthModule에서 UsersModule을 imports
↓
AuthService에서 UsersService를 주입받을 수 있음
즉, 인증 로직은 사용자 정보를 필요로 하기 때문에 AuthModule은 UsersModule에 의존한다.
3. JwtModule.registerAsync로 JWT 설정하기
AuthModule에서 가장 눈에 띄는 부분은 JwtModule.registerAsync()다.
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.getOrThrow<string>('JWT_EXPIRES_IN') as any,
},
}),
})
JWT를 발급하려면 최소한 두 가지 설정이 필요하다.
JWT_SECRET
→ 토큰을 서명하고 검증할 때 사용하는 비밀키
JWT_EXPIRES_IN
→ 토큰 만료 시간
이 값들은 코드에 직접 작성하면 안 된다.
예를 들어 이런 식으로 쓰면 위험하다.
JwtModule.register({
secret: 'my-secret',
signOptions: { expiresIn: '1h' },
})
비밀키가 코드에 노출될 수 있고, 개발 환경과 운영 환경에서 값을 다르게 관리하기도 어렵다.
그래서 현재 구조에서는 ConfigService를 통해 환경변수에서 값을 가져온다.
configService.getOrThrow<string>('JWT_SECRET')
getOrThrow()는 값이 없으면 에러를 던진다.
JWT 비밀키가 없는 상태로 서버가 실행되면 토큰을 안전하게 만들 수 없기 때문에, 서버 시작 시점에 바로 문제를 발견하는 편이 더 안전하다.
4. JWT 만료 시간 설정
JWT 설정에는 만료 시간도 포함된다.
signOptions: {
expiresIn: configService.getOrThrow<string>('JWT_EXPIRES_IN') as any,
}
예를 들어 환경변수에 다음과 같이 설정할 수 있다.
JWT_EXPIRES_IN=1h
그러면 발급된 토큰은 1시간 뒤 만료된다.
테스트 환경에서는 만료 처리를 확인하기 위해 더 짧게 둘 수도 있다.
JWT_EXPIRES_IN=5s
이렇게 설정하면 로그인 후 5초 뒤 보호된 API를 호출했을 때 토큰 만료 처리가 제대로 되는지 확인할 수 있다.
JWT 만료 시간은 보안과 사용자 경험 사이에서 조정해야 하는 값이다.
너무 짧으면 사용자가 자주 로그아웃된다.
너무 길면 토큰이 탈취되었을 때 위험이 커진다.
5. AuthController는 로그인 요청을 받는 입구다
이제 AuthController를 보자.
구조를 단순화하면 다음과 같다.
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LocalLoginDto) {
const user = await this.authService.validateUser(loginDto);
return this.authService.login(user);
}
}
@Controller('auth')는 이 Controller의 기본 경로가 /auth라는 뜻이다.
그리고 @Post('login')이 붙어 있으므로 최종 로그인 API 주소는 다음과 같다.
POST /auth/login
즉 프론트에서 로그인 요청을 보내면 이 메서드가 실행된다.
POST /auth/login
↓
AuthController.login()
6. LocalLoginDto로 로그인 요청 body 받기
로그인 요청 body를 LocalLoginDto로 받는다.
async login(@Body() loginDto: LocalLoginDto)
DTO를 사용하면 로그인 요청에 필요한 데이터 구조를 명확히 표현할 수 있다.
구조를 단순화하면 다음과 같다.
export class LocalLoginDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
password: string;
}
즉 로그인 요청에는 다음 두 값이 필요하다.
email
→ 이메일 형식이어야 하고 비어 있으면 안 된다.
password
→ 문자열이어야 하고 비어 있으면 안 된다.
이전 글에서 본 ValidationPipe와 연결하면 흐름은 다음과 같다.
프론트 로그인 요청
↓
ValidationPipe
↓
LocalLoginDto 검증
↓
AuthController.login() 실행
이렇게 하면 로그인 요청에서도 DTO 기반 검증을 적용할 수 있다.
7. 로그인은 왜 POST로 받을까?
로그인 API는 POST 요청으로 만든다.
@Post('login')
로그인은 사용자의 이메일과 비밀번호를 서버로 보내는 요청이다.
{
"email": "test@example.com",
"password": "password1234"
}
이런 민감한 값을 URL에 담아 보내면 안 된다.
예를 들어 GET 요청으로 이렇게 보내면 위험하다.
GET /auth/login?email=test@example.com&password=password1234
URL은 브라우저 기록, 서버 로그, 프록시 로그 등에 남을 수 있다.
그래서 로그인 요청은 보통 POST로 보내고, 이메일과 비밀번호는 request body에 담는다.
POST /auth/login
body: { email, password }
8. @HttpCode(HttpStatus.OK)를 사용하는 이유
로그인 API에는 다음 데코레이터가 붙어 있다.
@HttpCode(HttpStatus.OK)
NestJS에서 POST 요청은 기본적으로 성공 시 201 Created를 반환할 수 있다.
하지만 로그인은 새로운 리소스를 생성하는 요청이라기보다, 사용자의 정보를 확인하고 토큰을 발급하는 요청에 가깝다.
그래서 성공 응답으로 200 OK를 명시했다.
@HttpCode(HttpStatus.OK)
이 코드는 아래와 같은 의미다.
로그인 성공 시 200 OK로 응답한다.
HttpStatus.OK는 숫자 200을 의미한다.
그냥 @HttpCode(200)이라고 써도 되지만, HttpStatus.OK를 사용하면 의미가 더 명확하다.
9. AuthController는 검증과 발급을 AuthService에 맡긴다
AuthController.login() 안에서는 크게 두 가지 일을 한다.
const user = await this.authService.validateUser(loginDto);
return this.authService.login(user);
첫 번째 줄은 사용자가 로그인 가능한지 확인하는 단계다.
this.authService.validateUser(loginDto)
두 번째 줄은 검증된 유저에게 JWT를 발급하는 단계다.
this.authService.login(user)
즉 AuthController는 직접 DB에서 유저를 찾거나, bcrypt로 비밀번호를 비교하거나, JWT를 만들지 않는다.
그 일은 AuthService가 담당한다.
역할을 나누면 다음과 같다.
AuthController
→ 로그인 요청을 받고 DTO를 전달한다.
AuthService
→ 이메일/비밀번호 검증과 JWT 발급을 처리한다.
10. AuthService는 실제 로그인 로직을 처리한다
이제 AuthService를 보자.
구조를 단순화하면 다음과 같다.
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async validateUser(loginDto: LocalLoginDto): Promise<any> {
const user = await this.usersService.findByEmail(loginDto.email);
if (user && user.password) {
const isMatch = await bcrypt.compare(loginDto.password, user.password);
if (isMatch) {
const { password, ...result } = user;
return result;
}
}
throw new UnauthorizedException('이메일 또는 비밀번호가 일치하지 않습니다.');
}
async login(user: any) {
const payload = {
email: user.email,
sub: user.id,
role: user.role,
};
return {
access_token: this.jwtService.sign(payload),
};
}
}
AuthService는 두 가지 도구를 주입받는다.
UsersService
→ 이메일로 유저를 찾기 위해 필요하다.
JwtService
→ JWT access_token을 발급하기 위해 필요하다.
이렇게 생성자에서 주입받는다.
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
11. validateUser는 이메일과 비밀번호를 검증한다
validateUser()는 로그인에서 가장 중요한 메서드다.
async validateUser(loginDto: LocalLoginDto): Promise<any> {
const user = await this.usersService.findByEmail(loginDto.email);
if (user && user.password) {
const isMatch = await bcrypt.compare(loginDto.password, user.password);
if (isMatch) {
const { password, ...result } = user;
return result;
}
}
throw new UnauthorizedException('이메일 또는 비밀번호가 일치하지 않습니다.');
}
이 메서드는 다음 순서로 동작한다.
LocalLoginDto를 받는다.
↓
loginDto.email로 유저를 찾는다.
↓
유저가 있고 password도 있으면
↓
bcrypt.compare()로 비밀번호를 비교한다.
↓
비밀번호가 맞으면 password를 제거한 유저 정보를 반환한다.
↓
실패하면 401 UnauthorizedException을 던진다.
한 문장으로 정리하면 다음과 같다.
validateUser()는 로그인 요청으로 들어온 이메일과 비밀번호가 실제 DB의 유저 정보와 일치하는지 확인하는 메서드다.
12. 이메일로 유저 찾기
첫 번째 단계는 이메일로 유저를 찾는 것이다.
const user = await this.usersService.findByEmail(loginDto.email);
로그인 요청에는 이메일이 들어온다.
{
"email": "test@example.com",
"password": "password1234"
}
AuthService는 이 이메일을 UsersService.findByEmail()에 넘긴다.
흐름은 다음과 같다.
AuthService.validateUser()
↓
UsersService.findByEmail(email)
↓
Prisma user.findUnique()
↓
User 테이블에서 email이 같은 유저 조회
유저가 있으면 user에 객체가 들어오고, 없으면 null이 들어올 수 있다.
13. 유저와 password가 모두 있는지 확인한다
다음 조건이 있다.
if (user && user.password) {
이 조건은 두 가지를 확인한다.
1. 이메일에 해당하는 유저가 있는가?
2. 그 유저에게 password가 저장되어 있는가?
이렇게 확인하는 이유는 두 가지다.
첫째, 유저가 없는데 user.password에 접근하면 에러가 날 수 있다.
null.password
둘째, 이 프로젝트에는 게스트 사용자나 소셜 로그인 사용자가 있을 수 있다.
이런 사용자는 자체 비밀번호가 없을 수 있다.
일반 이메일 로그인 사용자
→ password 있음
게스트 사용자
→ password 없을 수 있음
소셜 로그인 사용자
→ password 없을 수 있음
그래서 로컬 로그인에서는 유저가 존재하고, 그 유저에게 password가 있을 때만 bcrypt 비교를 진행한다.
14. bcrypt.compare로 비밀번호를 비교한다
비밀번호 비교는 bcrypt로 한다.
const isMatch = await bcrypt.compare(loginDto.password, user.password);
여기서 loginDto.password는 사용자가 방금 입력한 원본 비밀번호다.
loginDto.password = "password1234"
user.password는 DB에 저장된 해시 비밀번호다.
user.password = "$2b$10$..."
이 둘은 단순히 ===로 비교하면 안 된다.
잘못된 방식은 다음과 같다.
loginDto.password === user.password
DB에는 원본 비밀번호가 아니라 해시된 값이 저장되어 있기 때문이다.
그래서 bcrypt가 제공하는 compare()를 사용한다.
bcrypt.compare(입력한 비밀번호, DB에 저장된 해시)
결과는 boolean이다.
비밀번호가 맞으면 true
틀리면 false
즉, isMatch가 true일 때만 로그인 성공으로 볼 수 있다.
15. 로그인 성공 시 password를 제거한다
비밀번호가 맞으면 다음 코드가 실행된다.
const { password, ...result } = user;
return result;
이 코드는 DB에서 조회한 user 객체에서 password만 제거하고 나머지를 반환한다.
예를 들어 DB에서 가져온 user가 다음과 같다고 해보자.
const user = {
id: 'user-id',
email: 'test@example.com',
password: '$2b$10$...',
name: '사용자',
role: 'USER',
};
구조 분해 할당을 사용하면:
const { password, ...result } = user;
password에는 해시된 비밀번호가 들어가고,
password = '$2b$10$...'
result에는 password를 제외한 나머지 값이 들어간다.
result = {
id: 'user-id',
email: 'test@example.com',
name: '사용자',
role: 'USER',
}
로그인에 성공했다고 해서 비밀번호 해시를 외부로 넘기면 안 된다.
비밀번호 해시는 원본 비밀번호는 아니지만 여전히 민감한 정보다.
그래서 Service 밖으로 내보내기 전에 password를 제거하는 것이 좋다.
16. 로그인 실패는 같은 메시지로 처리한다
검증에 실패하면 다음 예외를 던진다.
throw new UnauthorizedException('이메일 또는 비밀번호가 일치하지 않습니다.');
실패 이유는 여러 가지일 수 있다.
이메일에 해당하는 유저가 없음
유저는 있지만 password가 없음
비밀번호가 일치하지 않음
하지만 응답 메시지는 하나로 통일한다.
이메일 또는 비밀번호가 일치하지 않습니다.
이건 보안적으로 좋은 방식이다.
예를 들어 서버가 다음처럼 구체적으로 알려준다고 해보자.
이 이메일은 가입되어 있지 않습니다.
그러면 공격자는 어떤 이메일이 가입되어 있는지 추측할 수 있다.
또는:
비밀번호가 틀렸습니다.
라고 알려주면 해당 이메일이 가입되어 있다는 힌트가 될 수 있다.
그래서 로그인 실패 메시지는 보통 일부러 모호하게 처리한다.
이메일 또는 비밀번호가 일치하지 않습니다.
17. login은 JWT access_token을 발급한다
validateUser()를 통과하면, AuthController는 AuthService.login(user)를 호출한다.
return this.authService.login(user);
login() 메서드는 검증된 유저 정보를 바탕으로 JWT를 만든다.
async login(user: any) {
const payload = {
email: user.email,
sub: user.id,
role: user.role,
};
return {
access_token: this.jwtService.sign(payload),
};
}
이 메서드는 크게 두 단계로 동작한다.
1. JWT payload를 만든다.
2. jwtService.sign(payload)로 access_token을 발급한다.
18. JWT payload에는 필요한 정보만 담는다
JWT payload는 토큰 안에 담을 사용자 정보다.
const payload = {
email: user.email,
sub: user.id,
role: user.role,
};
현재 payload에는 세 가지 값이 들어간다.
email
→ 사용자 이메일
sub
→ 사용자 id
role
→ 사용자 권한
여기서 sub는 JWT에서 자주 사용하는 표준적인 필드 이름이다.
subject의 줄임말이고, 보통 “이 토큰의 주인”을 의미한다.
sub = user.id
즉, 이 토큰이 어떤 사용자를 나타내는지 식별하기 위해 user id를 sub에 넣는다.
role은 이후 권한 검사에서 사용할 수 있다.
예를 들어 관리자 전용 API라면 다음과 같은 판단이 가능하다.
role이 ADMIN이면 허용
role이 USER이면 거부
다만 JWT payload에는 너무 많은 정보를 넣으면 안 된다.
JWT는 프론트가 가지고 있는 토큰이고, 완전히 숨겨진 저장소가 아니다.
그래서 payload에는 필요한 최소한의 식별 정보만 담는 것이 좋다.
19. jwtService.sign으로 토큰 발급하기
JWT 문자열은 jwtService.sign()으로 만든다.
this.jwtService.sign(payload)
이때 AuthModule에서 설정한 값이 사용된다.
JWT_SECRET
→ 토큰 서명에 사용
JWT_EXPIRES_IN
→ 토큰 만료 시간에 사용
최종 응답은 다음과 같은 형태다.
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
프론트는 이 토큰을 저장해두고, 이후 보호된 API를 호출할 때 Authorization 헤더에 담아 보낸다.
Authorization: Bearer access_token
예를 들면 다음과 같다.
fetch('/users', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
이후 백엔드에서는 JwtAuthGuard와 JwtStrategy가 이 토큰을 검사하게 된다.
20. 로그인 전체 흐름 다시 보기
지금까지 본 내용을 한 번에 연결하면 다음과 같다.
1. 프론트에서 POST /auth/login 요청
body: { email, password }
2. ValidationPipe가 LocalLoginDto 검증
3. AuthController.login() 실행
4. AuthService.validateUser(loginDto) 호출
5. UsersService.findByEmail(loginDto.email)로 유저 조회
6. 유저가 있고 password도 있으면 bcrypt.compare() 실행
7. 비밀번호가 맞으면 password 제거한 user 반환
8. AuthService.login(user) 호출
9. payload 생성
{
email,
sub: user.id,
role
}
10. jwtService.sign(payload)로 access_token 생성
11. 프론트에 access_token 반환
로그인은 단순히 “이메일과 비밀번호를 확인한다”로 끝나는 것이 아니다.
DTO 검증
↓
유저 조회
↓
비밀번호 해시 비교
↓
민감 정보 제거
↓
JWT payload 구성
↓
access_token 발급
이 단계들이 순서대로 연결되어야 안전한 로그인 흐름이 된다.
21. 현재 구조에서 개선할 수 있는 점
로직에서는 로그인 요청에 LocalLoginDto를 사용하고 있다.
any로 받는 방식보다 훨씬 안전하다.
async login(@Body() loginDto: LocalLoginDto)
다만 여전히 개선할 수 있는 부분도 있다.
예를 들어 AuthService의 반환 타입은 아직 any에 가깝다.
async validateUser(loginDto: LocalLoginDto): Promise<any>
async login(user: any)
초반 개발에서는 빠르게 구현하기 위해 any를 사용할 수 있지만, 나중에는 타입을 더 명확히 하는 것이 좋다.
예를 들어 다음과 같은 방향을 생각할 수 있다.
password가 제거된 유저 타입 정의
JWT payload 타입 정의
로그인 응답 DTO 정의
이렇게 하면 Service가 어떤 값을 받고 어떤 값을 반환하는지 더 명확해진다.
또한 access token만 내려줄 것인지, refresh token을 함께 사용할 것인지도 추후 인증 구조에서 고민할 수 있다.
정리
이번 글에서는 AuthModule, AuthController, AuthService를 기준으로 로그인 흐름을 정리했다.
AuthModule은 인증 기능에 필요한 모듈과 Provider를 조립한다.
UsersModule
→ 로그인할 때 유저 조회를 위해 필요
PassportModule
→ JWT 인증 Guard/Strategy 기반
JwtModule
→ JWT 토큰 발급을 위해 필요
AuthService
→ 이메일/비밀번호 검증과 JWT 발급 담당
JwtStrategy
→ JWT 토큰 검증 전략
AuthController
→ 로그인 요청 입구
AuthController는 로그인 요청을 받는다.
POST /auth/login
그리고 요청 body를 LocalLoginDto로 받아 AuthService에 넘긴다.
AuthController.login()
↓
AuthService.validateUser(loginDto)
↓
AuthService.login(user)
AuthService는 실제 로그인 로직을 처리한다.
validateUser()
→ 이메일로 유저를 찾고
→ bcrypt로 비밀번호를 비교하고
→ 성공하면 password를 제거한 유저를 반환하고
→ 실패하면 401 에러를 던진다.
login()
→ 검증된 유저로 JWT payload를 만들고
→ jwtService.sign()으로 access_token을 발급한다.