아아아.. 과연 나는 Auth를 처음부터 만들 수 있을 지에 대해서 고민해보았다. 무엇인가 정리가 안된 느낌이 들어 처음 세팅환경부터 새롭게 정리를 해보았다. 이것을 두고 두고 보면서 뇌에 새기도록 하겠다!!
먼저, 가장 기본이 되는 local에서의 로그인부터 JWT 인증과 OAuth 로그인까지 진행하려고한다.
npm install @nestjs/typeorm typeorm pg
npm install -D @types/pg
npm install @nestjs/jwt @nestjs/passport passport passport-local passport-jwt bcrypt
npm install -D @types/passport-local @types/passport-jwt @types/bcrypt
npm install @nestjs/config
npm install class-validator class-transformer
npm install @nestjs/swagger swagger-ui-express
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 전역 유효성 검사 파이프 설정
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
// Swagger 설정
const config = new DocumentBuilder()
.setTitle('NestJS Practice API')
.setDescription('기업 사전과제 연습용 API 문서')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'JWT 토큰을 입력하세요',
in: 'header',
},
'access-token',
)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(3000);
console.log('🚀 Server is running on http://localhost:3000');
console.log('📚 Swagger documentation: http://localhost:3000/api');
}
bootstrap();
New-Item -Path .env -ItemType File
notepad .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your_password
DATABASE_NAME=nestjs_practice
JWT_SECRET=your_jwt_secret_key_here
JWT_EXPIRES_IN=1h
PORT=3000
docker --version
docker-compose --version
New-Item -Path docker-compose.yml -ItemType File
notepad docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15
container_name: nestjs_postgres
restart: always
environment:
POSTGRES_DB: ~~
POSTGRES_USER: ~~
POSTGRES_PASSWORD: ~~
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
docker-compose up -d
docker-compose ps
docker exec -it nestjs_postgres psql -U postgres -d nestjs_practice
-- 현재 데이터베이스의 테이블 목록 확인
\dt
-- 현재 연결된 데이터베이스 정보 확인
\conninfo
-- 사용자 목록 확인
\du
-- 도움말 보기
\?
-- PostgreSQL 콘솔 종료
\q
# 컨테이너 시작
docker-compose up -d
# 컨테이너 중지
docker-compose down
# 컨테이너 상태 확인
docker-compose ps
# 로그 확인
docker-compose logs postgres
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT),
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true, // 개발 환경에서만 사용
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
mkdir src/entites
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
username: string;
@Column()
password: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
npm run start:dev
docker exec -it nestjs_postgres psql -U postgres -d nestjs_practice
\dt
nest generate module auth
nest generate service auth
nest generate controller auth
nest generate module user
nest generate service user
nest generate controller user
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class RegisterDto {
@IsEmail({}, { message: '올바른 이메일 형식을 입력해주세요.' })
email: string;
@IsString()
@MinLength(2, { message: '사용자명은 최소 2자 이상이어야 합니다.' })
@MaxLength(20, { message: '사용자명은 최대 20자까지 가능합니다.' })
username: string;
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
@MaxLength(20, { message: '비밀번호는 최대 20자까지 가능합니다.' })
@Matches(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{ message: '비밀번호는 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.' }
)
password: string;
}
}
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
example: 'user@example.com',
description: '사용자 이메일',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해주세요.' })
email: string;
@ApiProperty({
example: 'Test123!',
description: '비밀번호',
})
@IsString()
@MinLength(1, { message: '비밀번호를 입력해주세요.' })
password: string;
}
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { RegisterDto } from '../dto/auth/register.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async create(registerDto: RegisterDto): Promise<User> {
const { email, username, password } = registerDto;
// 이메일 중복 확인
const existingUser = await this.userRepository.findOne({
where: { email },
});
if (existingUser) {
throw new ConflictException('이미 존재하는 이메일입니다.');
}
// 비밀번호 암호화
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const user = this.userRepository.create({
email,
username,
password: hashedPassword,
});
return await this.userRepository.save(user);
}
async findByEmail(email: string): Promise<User | null> {
return await this.userRepository.findOne({ where: { email } });
}
async findById(id: number): Promise<User | null> {
return await this.userRepository.findOne({ where: { id } });
}
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
controllers: [UserController],
exports: [UserService], // AuthService에서 사용하기 위해 export
})
export class UserModule {}
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { LoginDto } from 'src/dto/auth/login.dto';
import { RegisterDto } from 'src/dto/auth/register.dto';
import { UserService } from 'src/user/user.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async register(registerDto: RegisterDto) {
const user = await this.userService.create(registerDto);
const { password, ...userData } = user;
return {
message: '회원가입이 완료되었습니다.',
user: userData,
};
}
async login(loginDto: LoginDto) {
const { email, password } = loginDto;
const user = await this.userService.findByEmail(email);
if (!user) {
throw new UnauthorizedException(
'이메일 또는 비밀번호가 올바르지 않습니다.',
);
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException(
'이메일 또는 비밀번호가 올바르지 않습니다.',
);
}
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload);
return {
message: '로그인이 완료되었습니다.',
accessToken,
user: {
id: user.id,
email: user.email,
username: user.username,
},
};
}
}
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
@Module({
imports: [
UserModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<string>('JWT_EXPIRES_IN') },
}),
}),
],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
현재 사용자가 로그인 시 accessToken을 return해주는데, 한 가지 더 보안해야할 사항이 있습니다. 바로 refreshToken도 같이 return을 해주어야 합니다.
왜?? refreshToken이 필요할까~~?
사용자가 앱을 사용하는 중에 토큰이 만료되면?
→ 갑자기 로그인 페이지로 이동
→ 작업 중이던 내용 손실 위험
토큰 수명을 짧게 하면 → 보안 UP, 사용자 경험 DOWN
토큰 수명을 길게 하면 → 보안 DOWN, 사용자 경험 UP
{
accessToken: "짧은 수명 토큰", // 15분-1시간
refreshToken: "긴 수명 토큰" // 7일-30일
}
// 1. 로그인 성공
// → accessToken(15분) + refreshToken(7일) 발급
// 2. API 요청
// → accessToken으로 인증
// 3. accessToken 만료
// → refreshToken으로 새 accessToken 발급
// 4. 사용자는 모르게 자동 갱신
// → 끊김 없는 사용자 경험
// accessToken 탈취 시
// → 15분 후 자동 만료, 피해 최소화
// refreshToken 탈취 시
// → 서버에서 즉시 무효화 가능
// → 로그아웃 시 DB에서 삭제
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
...
@Column({ type: 'varchar', nullable: true, default: null })
refreshToken: string | null;
}
async login(loginDto: LoginDto) {
const { email, password } = loginDto;
...
// 토큰 생성
const tokens = await this.generateTokens(user.id, user.email);
// refreshToken을 데이터베이스에 저장
await this.userService.updateRefreshToken(user.id, tokens.refreshToken);
return {
message: '로그인이 완료되었습니다.',
...tokens,
user: {
id: user.id,
email: user.email,
username: user.username,
},
};
}
async logout(userId: number) {
// refreshToken 무효화
await this.userService.updateRefreshToken(userId, null);
return {
message: '로그아웃이 완료되었습니다.',
};
}
async refresh(refreshToken: string) {
try {
// refreshToken 검증
const payload = this.jwtService.verify(refreshToken);
// 사용자 찾기
const user = await this.userService.findById(payload.sub);
if (!user || user.refreshToken !== refreshToken) {
throw new UnauthorizedException('유효하지 않은 토큰입니다.');
}
// 새 토큰 발급
const tokens = await this.generateTokens(user.id, user.email);
// 새 refreshToken 저장
await this.userService.updateRefreshToken(user.id, tokens.refreshToken);
return {
message: '토큰이 갱신되었습니다.',
...tokens,
};
} catch (error) {
console.log(error);
throw new UnauthorizedException('유효하지 않은 토큰입니다.');
}
}
private async generateTokens(userId: number, email: string) {
const payload = { sub: userId, email };
return {
accessToken: this.jwtService.sign(payload, { expiresIn: '15m' }),
refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}
...
async updateRefreshToken(userId: number, refreshToken: string | null): Promise<void> {
await this.userRepository.update(userId, { refreshToken });
}
여기까지 인증에 대한 Service 구축은 완료했고 실제로 API 엔드포인트를 만들어서 테스트를 진행해보자규~
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class RefreshDto {
@ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Refresh Token',
})
@IsString()
refreshToken: string;
}
import { IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LogoutDto {
@ApiProperty({
example: 1,
description: '사용자 ID',
})
@IsNumber()
userId: number;
}
실제로 DTO의 property가 한 개일 때는 DTO를 사용하지 않고 매개변수에 원시 타입으로 적는다. 하지만 swagger에서 API 테스트를 위해 한 개 일지라도 DTO를 사용했다.
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from 'src/dto/auth/register.dto';
import { ApiOperation } from '@nestjs/swagger';
import { LoginDto } from 'src/dto/auth/login.dto';
import { RefreshDto } from 'src/dto/auth/refresh.dto';
import { LogoutDto } from 'src/dto/auth/logout.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: '회원가입' })
async register(@Body() registerDto: RegisterDto) {
return await this.authService.register(registerDto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그인' })
async login(@Body() loginDto: LoginDto) {
return await this.authService.login(loginDto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃' })
async logOut(@Body() { userId }: LogoutDto) {
return await this.authService.logout(userId);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '토큰 갱신' })
async refresh(@Body() { refreshToken }: RefreshDto) {
return await this.authService.refresh(refreshToken);
}
}
간단한 테스트를 위해 일단은 useGuard를 사용하지 않고 API 테스트르 해보았다.

자, 현재 logout API를 보면 다른 누군가가 다른 사용자를 로그아웃 시킬 수 있는 구조다. 이것을 막기 위해 JWT Guard가 필요하다!
npm install @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UserService } from '../../user/user.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(strategy) {
constructor(
private readonly configService: ConfigService,
private readonly userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'park_secret_key',
});
}
async validate(payload: any) {
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('토큰이 유효하지 않습니다.');
}
return {
id: user.id,
email: user.email,
username: user.username,
};
}
}
JWT Strategy는 "JWT 토큰으로 사용자의 신분을 확인하는 역할"
1. 토큰에서 사용자 ID 추출
2. 데이터베이스에서 사용자 찾기
3. 유효한 사용자인지 확인
4. 사용자 정보 반환
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
JWT Guard는 "API 엔드포인트 앞에서 실제로 막는 역할"
@UseGuards(JwtAuthGuard) // ← 문지기 배치! @Post('logout') async logout() { // 여기는 인증된 사용자만 들어올 수 있음! }
import { Module } from '@nestjs/common';
...
import { PassportModule } from '@nestjs/passport'; // ← 추가
import { JwtStrategy } from './strategies/jwt.strategy'; // ← 추가
@Module({
imports: [
UserModule,
PassportModule, // ← 추가
JwtModule.registerAsync({
inject: [ConfigService],
...
}),
}),
],
providers: [AuthService, JwtStrategy], // ← JwtStrategy 추가
controllers: [AuthController],
})
export class AuthModule {}
@UseGuards(JwtAuthGuard) // 추가
@ApiBearerAuth('access-token') // swagger에서 Authorize버튼
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃' })
async logOut(@Body() { userId }: LogoutDto) {
return await this.authService.logout(userId);
}
자, 근데 한가지 문제가 있다, Body로 userId를 받아서 처리하기 보다는 현재 인증된 Token의 Request 정보를 이용해 id를 추출해 그 사용자를 로그아웃 시키면 된다.
@UseGuards(JwtAuthGuard) // 추가
@ApiBearerAuth('access-token') // swagger에서 Authorize버튼
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃' })
async logOut(@Request() req: AuthRequest) {
return await this.authService.logout(req.user.id);
}
import { Request } from 'express';
// JWT에서 추출된 사용자 정보
export interface AuthUser {
id: number;
email: string;
username: string;
}
// 인증된 Request 타입
export interface AuthRequest extends Request {
user: AuthUser;
}
Strategy가 해석한 토큰의 정보의 사용자를 반환해 request로 요청한 것을 DTO를 생성
자, 현재까지 Guard의 흐름을 요약해보면 다음과 같다.
- Guard가 요청 가로채기
사용자 요청 → JwtAuthGuard → "잠깐! 토큰부터 확인!"
- Strategy가 토큰 검증
Guard → JwtStrategy → "이 토큰 유효한가?" Strategy → DB 조회 → 사용자 정보 반환
- req.user에 사용자 정보 주입
Strategy → Guard → Controller req.user = { id: 1, email: '...', username: '...' }
- Controller에서 안전한 처리
async logout(@Request() req) { // req.user.id는 토큰에서 검증된 진짜 사용자 ID! // 다른 사용자를 로그아웃시킬 수 없음! return await this.authService.logout(req.user.id); }
자, 현재까지 Swagger에서 register 테스트를 해보았는데, 실제로 데이터가 DB에 삽입이 되었는지 확인해보기 위해서 PostgreSQL에 접속해서 콘솔로 확인해 보도록 하자!
docker exec -it nestjs_postgres psql -U postgres -d nestjs_practice
-- 모든 테이블 확인
\dt
-- users 테이블 구조 확인
\d users
-- 모든 사용자 조회
SELECT * FROM users;
-- 특정 사용자 조회
SELECT * FROM users WHERE email = 'test@example.com';
-- 사용자 수 확인
SELECT COUNT(*) FROM users;
-- 최근 생성된 사용자 5명
SELECT id, email, username, "createdAt" FROM users
ORDER BY "createdAt" DESC
LIMIT 5;
-- 로그아웃한 사용자 (refreshToken이 NULL)
SELECT id, email, username FROM users
WHERE "refreshToken" IS NULL;
-- 로그인한 사용자 (refreshToken이 있음)
SELECT id, email, username FROM users
WHERE "refreshToken" IS NOT NULL;

사용자가 잘 조회가 된다!
좀 더 편리한 방법으로는 GUI 도구를 사용하는 것이다.
pgAdmin을 설치하여 다운로드 후 실행하여 Servers-> Resiter -> server -> General/connection에 입력 후 아래 정보를 서버로 연결한다.

자, 여기까지 Local 로그인 방식은 완료됐고, 다음으로 Oauth 소셜 로그인 기능까지 구현하고 마무리 해야겠다!
npm install passport-google-oauth20
npm install -D @types/passport-google-oauth20
승인된 리디렉션 URI:
http://localhost:3001/auth/google/callback
GOOGLE_CLIENT_ID=your-google-client-id-here
GOOGLE_CLIENT_SECRET=your-google-client-secret-here
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private authService: AuthService) {
super({
clientID: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
callbackURL:
process.env.GOOGLE_CALLBACK_URL ||
'http://localhost:4000/auth/google/callback',
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
) {
const { name, emails, photos } = profile;
const user = {
email: emails[0].value,
username: name.givenName + ' ' + name.familyName,
profileImage: photos[0].value,
provider: 'google',
providerId: profile.id,
};
const jwtToken = await this.authService.validateOAuthUser(user);
done(null, jwtToken);
}
}
다음으로 Oauth용 DTO를 생성하겠다. 나는 google, kakao를 사용할 것 이기 때문에 provider에는 'google' or 'kakao'를 보내면 된다.
export class OAuthUserDto {
email: string;
username: string;
profileImage?: string;
provider: string;
providerId: string;
}
Oauth를 사용하기 위해서 기존의 User Entity를 수정해야한다.
수정된 항목은 password는 null이 허용되어야한다.
Why?? -> Oauth 사용자는 로그인 시 비밀번호가 없을 수 있기 떄문이다.
추가된 항목은 provider, providerId, profileImage 컬럼이 추가되었다.
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
...
@Column({ nullable: true })
password: string;
@Column({ default: 'local' }) // 'local', 'google', 'kakao'
provider: string;
@Column({ nullable: true })
providerId: string;
@Column({ nullable: true })
profileImage: string;
다음으로 Auth Service에 Oauth 로직을 추가해야한다.
Oauth 로그인 시 기존에 사용중인 이메일의 local 사용자가 있으면 해당 사용자를 Oauth 사용자로 update 해야하고, 그렇지 않으면 신규 Oauth 사용자로 create 하면 될 것이고. 그리고 JWT 토큰을 생성해서 return 하면 될 것이다.
// 기존 imports에 추가
import { OAuthUserDto } from '../dto/auth/oauth.dto';
@Injectable()
export class AuthService {
// ... 기존 메서드들
// OAuth 사용자 검증/생성
async validateOAuthUser(oauthUser: OAuthUserDto) {
const { email, username, profileImage, provider, providerId } = oauthUser;
// 기존 사용자 찾기 (이메일로)
let user = await this.userService.findByEmail(email);
if (user) {
// 기존 사용자 - provider 정보 업데이트
if (user.provider === 'local') {
await this.userService.updateProvider(user.id, provider, providerId, profileImage);
}
} else {
// 신규 사용자 - OAuth 사용자 생성
user = await this.userService.createOAuthUser({
email,
username,
provider,
providerId,
profileImage,
});
}
// JWT 토큰 생성
const tokens = await this.generateTokens(user.id, user.email);
// refreshToken 저장
await this.userService.updateRefreshToken(user.id, tokens.refreshToken);
return {
message: `${provider} 로그인이 완료되었습니다.`,
...tokens,
user: {
id: user.id,
email: user.email,
username: user.username,
profileImage: user.profileImage || null,
},
};
}
// ... 기존 generateTokens 메서드
}
그리고나서 UserService에서 Oauth 메서드를 추가해야한다.
OAuth로 로그인 한 사용자를 Create하는 메서드인 createOauthUser와
OAUth로 사용자 정보를 Update하는 메서드인 updateProvider 메서드를 구현해야한다.
// 기존 imports에 추가
import { OAuthUserDto } from '../dto/auth/oauth.dto';
@Injectable()
export class UserService {
// ... 기존 메서드들
// OAuth 사용자 생성
async createOAuthUser(oauthData: OAuthUserDto): Promise<User> {
const user = this.userRepository.create({
email: oauthData.email,
username: oauthData.username,
provider: oauthData.provider,
providerId: oauthData.providerId,
profileImage: oauthData.profileImage,
password: null, // OAuth 사용자는 비밀번호 없음
});
return await this.userRepository.save(user);
}
// Provider 정보 업데이트
async updateProvider(
userId: number,
provider: string,
providerId: string,
profileImage?: string
) {
const updateData: any = { provider, providerId };
if (profileImage) {
updateData.profileImage = profileImage;
}
await this.userRepository.update(userId, updateData);
}
// ... 기존 메서드들
}
지금 까지 작성한 코드 중 문제점이 하나 있다!
OAuth 로그인 시 password는 null인 상태이다.
현재 Auth Service에서 login 메서드를 보면
// 비밀번호 확인
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.');
}
user의 password와 compare하고 있는데, user의 password는 optional하다. OAuth 사용자인 경우에는 null이기 떄문에 에러가 발생한다. 따라서, 아래의 코드를 추가해준다.
또, Resiter 메서드에서 OAUth 시 이미 가입된 Local 사용자 체크도 해준다.
async login(loginDto: LoginDto) {
...
// OAuth 사용자 체크 (비밀번호가 없는 경우)
if (user.provider !== 'local') {
throw new UnauthorizedException('소셜 로그인으로 가입된 계정입니다. 소셜 로그인을 이용해주세요.');
}
// 비밀번호가 null인 경우 체크
if (!user.password) {
throw new UnauthorizedException('비밀번호가 설정되지 않은 계정입니다.');
}
// 비밀번호 확인
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.');
}
async register(registerDto: RegisterDto) {
const { email } = registerDto;
const existingUser = await this.userService.findByEmail(email);
if (existingUser) {
if (existingUser.provider !== 'local' || existingUser.provider !== null) {
throw new ConflictException('이미 존재하는 이메일입니다.');
}
throw new ConflictException('이미 존재하는 이메일입니다.');
}
...
명시적 타입 지정으로 해결행야한다.
@Column({
type: 'varchar', // ← 명시적 타입 지정
nullable: true, // ← NULL 허용
default: null // ← 기본값 설정
})
password: string | null;
@Column({
type: 'varchar', // ← 명시적 타입 지정
default: 'local'
})
provider: string;
@Column({
type: 'varchar', // ← 명시적 타입 지정
nullable: true
})
providerId: string | null;
@Column({
type: 'varchar', // ← 명시적 타입 지정
nullable: true
})
profileImage: string | null;
...
import { GoogleStrategy } from './strategies/google.strategy';
@Module({
...
providers: [AuthService, JwtStrategy, GoogleStrategy],
})
자, 전체적인 Google OAuth 로그인 플로우를 설명해보면 다음과 같다.
1. 사용자가 "Google 로그인" 클릭
↓
2. GET /auth/google 호출
↓
3. GoogleStrategy가 Google OAuth 페이지로 리디렉션
↓
4. 사용자가 Google에서 로그인/권한 승인
↓
5. Google이 /auth/google/callback으로 리디렉션
↓
6. GoogleStrategy가 사용자 정보 받아서 validateOAuthUser 호출
↓
7. AuthService에서:
- 기존 사용자 있으면 → provider 정보 업데이트
- 기존 사용자 없으면 → 새 사용자 생성 (자동 회원가입!)
↓
8. JWT 토큰 발급 후 응답

로그인 후 응답값이 잘 보이는 것이 확인이 된다!
npm install passport-kakao
npm install -D @types/passport-kakao

동의 항목 설정에서 필수 항목을 설정해주어야 닉네임, 프로필 사진을 가져올 수 있다.
앱-> 일반 -> 앱 키-> REST API 키
= KAKAO_CLIENT_ID
카카오 로그인-> 일반 -> Client Secret
= KAKAO_CLIENT_SECRET
KAKAO_CLIENT_ID=your-kakao-client-id
KAKAO_CLIENT_SECRET=your-kakao-client-secret
KAKAO_CALLBACK_URL=http://localhost:3001/auth/kakao/callback
.env에 위 항목을 작성하면 된다.
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-kakao';
import { AuthService } from '../auth.service';
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor(private authService: AuthService) {
super({
clientID: process.env.KAKAO_CLIENT_ID || '',
clientSecret: process.env.KAKAO_CLIENT_SECRET || '',
callbackURL:
process.env.KAKAO_CALLBACK_URL ||
'http://localhost:3001/auth/kakao/callback',
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: any,
) {
const { id, username, _json } = profile;
// Kakao에서 제공하는 정보
const user = {
email: _json.kakao_account?.email || `kakao_${id}@kakao.com`, // 이메일이 없을 수 있음
username: username || _json.properties?.nickname || `KakaoUser_${id}`,
profileImage: _json.properties?.profile_image || null,
provider: 'kakao',
providerId: id.toString(),
};
const jwtToken = await this.authService.validateOAuthUser(user);
done(null, jwtToken);
}
}
@Module({
...
providers: [AuthService, JwtStrategy, GoogleStrategy, KakaoStrategy], // ← KakaoStrategy 추가
// Kakao OAuth 추가
@Get('kakao')
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: 'Kakao 로그인' })
async kakaoAuth() {
// Kakao로 리디렉션
}
@Get('kakao/callback')
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: 'Kakao 로그인 콜백' })
kakaoCallback(@Request() req: any) {
return req.user;
}

여기까지 OAuth 로그인이 잘 작동되어지는 것을 볼 수 있다.