icon
4장 : 인증과 권한 부여

OAuth 2.0 및 소셜 로그인 통합


안녕하세요! 지난 절들에서 기본적인 로컬 인증과 JWT 기반의 인증 시스템, 그리고 역할 기반 접근 제어(RBAC)를 구현하는 방법을 살펴보았습니다. 이제 4장의 마지막으로, 현대 웹 애플리케이션에서 사용자 편의성을 극대화하고 가입 장벽을 낮추는 중요한 기능인 OAuth 2.0 기반의 소셜 로그인(Social Login) 통합에 대해 알아보겠습니다.

사용자들이 새로운 서비스에 가입할 때 매번 아이디와 비밀번호를 만들고 기억하는 것은 번거로운 일입니다. 소셜 로그인은 Google, Facebook, Kakao, Naver 등 기존에 사용하던 소셜 미디어 계정을 활용하여 간편하게 로그인할 수 있도록 해줍니다. 이는 사용자 경험을 향상시키고 개발자 입장에서는 자체적인 인증 시스템 구축 및 유지보수 부담을 줄여주는 장점이 있습니다.


OAuth 2.0이란 무엇인가?

OAuth 2.0은 사용자의 비밀번호를 직접 공유하지 않고도, 서드파티 애플리케이션(우리의 NestJS 서비스)이 사용자의 자원(Resource) 에 접근할 수 있도록 권한을 위임(Delegated Authorization) 하는 표준 프로토콜입니다. 흔히 '위임된 권한 부여 프레임워크'라고 불립니다.

소셜 로그인에서 OAuth 2.0의 역할을 이해하는 핵심 참여자는 다음과 같습니다.

  • 자원 소유자(Resource Owner): 자원(예: Google 프로필 정보)의 실제 소유자인 사용자입니다.
  • 클라이언트(Client): 자원 소유자의 자원에 접근하려는 애플리케이션입니다. 우리 NestJS 백엔드 서비스나 프론트엔드 애플리케이션이 될 수 있습니다.
  • 권한 서버(Authorization Server): 자원 소유자를 인증하고, 클라이언트에게 접근 토큰(Access Token)을 발급하는 서버입니다. (예: Google, Facebook의 OAuth 서버)
  • 자원 서버(Resource Server): 보호된 자원(사용자 프로필, 이메일 등)을 호스팅하는 서버입니다. (예: Google API 서버)

OAuth 2.0의 기본적인 흐름 (간략화된 인증 코드 부여 방식)

클라이언트가 권한 요청: 사용자가 'Google 로그인' 버튼을 클릭하면, 우리의 클라이언트(프론트엔드)는 Google 권한 서버로 인증 요청을 보냅니다.

사용자 인증 및 동의: Google 권한 서버는 사용자에게 Google 계정으로 로그인하도록 요청하고, 우리의 클라이언트가 어떤 정보에 접근하려는지 동의를 구합니다.

권한 코드 발급: 사용자가 동의하면, Google 권한 서버는 우리의 클라이언트에게 권한 코드(Authorization Code) 를 리다이렉트 URI를 통해 전달합니다.

접근 토큰 요청: 우리의 클라이언트(또는 백엔드)는 이 권한 코드를 사용하여 Google 권한 서버에 접근 토큰(Access Token) 을 요청합니다. 이때 클라이언트의 client_idclient_secret을 함께 전송하여 자신을 인증합니다.

접근 토큰 발급: Google 권한 서버는 유효성을 확인한 후 접근 토큰(때로는 Refresh Token도 함께)을 발급하여 우리의 클라이언트에 전달합니다.

자원 접근: 우리의 클라이언트(백엔드)는 이 접근 토큰을 사용하여 Google 자원 서버에 사용자 프로필 정보와 같은 보호된 자원을 요청합니다.

자원 반환: Google 자원 서버는 접근 토큰을 검증하고, 요청된 자원(사용자 정보)을 반환합니다.

서비스 로그인 처리: 우리의 NestJS 백엔드는 Google로부터 받은 사용자 정보를 바탕으로 자체 서비스의 사용자를 생성하거나 조회하고, 로그인 처리(예: 자체 JWT 발급)를 수행합니다.

이 복잡한 과정을 NestJS와 Passport.js가 매우 편리하게 추상화해 줍니다.


NestJS에서 Google OAuth 2.0 로그인 통합하기

Google OAuth 2.0을 NestJS에 통합하는 과정을 단계별로 살펴보겠습니다.

단계 1: Google Cloud Console에서 OAuth 클라이언트 ID 생성

Google Cloud Console에 접속하여 프로젝트를 생성하거나 선택합니다.

API 및 서비스 -> 사용자 인증 정보로 이동합니다.

사용자 인증 정보 만들기 -> OAuth 클라이언트 ID를 선택합니다.

애플리케이션 유형웹 애플리케이션으로 선택합니다.

승인된 자바스크립트 원본승인된 리디렉션 URI를 설정합니다.

  • 개발 환경 예시
    • 승인된 자바스크립트 원본: http://localhost:3000 (NestJS 프론트엔드 또는 Postman 등에서 요청할 URI)
    • 승인된 리디렉션 URI: http://localhost:3000/auth/google/callback (NestJS 백엔드에서 Google로부터 콜백을 받을 URI) (운영 환경에서는 실제 도메인을 사용해야 합니다.)

생성 후 클라이언트 ID클라이언트 보안 비밀(Client Secret) 을 복사해 둡니다. 이 값들은 .env 파일에 저장할 것입니다.

단계 2: 필요한 패키지 설치

npm install passport-google-oauth20 @nestjs/passport
npm install --save-dev @types/passport-google-oauth20

단계 3: 환경 변수 설정

.env 파일에 Google OAuth 클라이언트 ID와 보안 비밀, 그리고 리디렉션 URI를 추가합니다.

# .env
GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback

단계 4: Google OAuth 전략 구현 (GoogleStrategy)

Passport의 GoogleStrategy를 사용하여 Google OAuth 인증 로직을 정의합니다.

src/auth/strategies/google.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service'; // 자체 인증 서비스

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { // 'google'은 전략 이름
  constructor(
    private configService: ConfigService,
    private authService: AuthService, // 사용자 생성/조회를 위한 서비스
  ) {
    super({
      clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
      clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET'),
      callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
      scope: ['email', 'profile'], // Google로부터 어떤 정보에 대한 권한을 요청할지 지정
    });
  }

  // Google에서 인증 후 콜백될 때 실행되는 validate 메서드
  // accessToken: Google API 접근에 사용될 토큰
  // refreshToken: accessToken이 만료되었을 때 재발급에 사용될 토큰 (선택 사항)
  // profile: Google로부터 받은 사용자 프로필 정보
  // done: Passport 콜백 함수
  async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise<any> {
    const { name, emails, photos } = profile;
    const user = {
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      picture: photos[0].value,
      accessToken, // 필요에 따라 accessToken 저장
      refreshToken, // 필요에 따라 refreshToken 저장
      // ... 기타 필요한 정보
    };

    // 실제로는 이메일 등을 기반으로 DB에서 사용자를 조회하거나 새로 생성하는 로직
    // 예시: this.authService.findOrCreateUser(user);
    console.log('Google Profile:', user);

    // done(error, user_object) 형식으로 호출
    // user_object는 req.user에 주입됩니다.
    done(null, user);
  }
}

단계 5: AuthModuleGoogleStrategy 등록

src/auth/auth.module.ts (업데이트)
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy'; // GoogleStrategy 임포트
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    ConfigModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: '600s' },
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    GoogleStrategy, // GoogleStrategy 추가
  ],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

단계 6: 인증 컨트롤러(AuthController)에 Google OAuth 라우트 추가

src/auth/auth.controller.ts (업데이트)
import { Controller, Post, Request, UseGuards, Get, Res } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { Response } from 'express'; // Express Response 객체 사용

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(AuthGuard('jwt'))
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }

  // ------------------ Google OAuth 2.0 라우트 추가 ------------------

  // 1. Google 로그인 시작 (Google 인증 페이지로 리다이렉션)
  @Get('google')
  @UseGuards(AuthGuard('google')) // 'google' 전략을 사용
  async googleAuth(@Request() req) {
    // 이 라우트는 Google 로그인 페이지로 리다이렉션되므로 실제 로직은 거의 없습니다.
  }

  // 2. Google OAuth 콜백 처리 (Google에서 인증 후 리다이렉트)
  @Get('google/callback')
  @UseGuards(AuthGuard('google')) // 'google' 전략을 사용
  async googleAuthRedirect(@Request() req, @Res() res: Response) {
    // req.user에는 GoogleStrategy.validate()에서 반환된 사용자 정보가 포함됩니다.
    console.log('Google Callback User:', req.user);

    // TODO: 여기에서 서비스의 사용자 테이블에 저장하거나 업데이트하는 로직을 추가합니다.
    // 또한, 서비스 자체의 JWT를 발급하여 클라이언트에 전달해야 합니다.

    // 예시: 자체 JWT를 발급하고 프론트엔드로 리다이렉션
    const serviceJwt = await this.authService.login(req.user); // 우리의 AuthService를 사용 (JWT 발급)
    const frontendRedirectUrl = `http://localhost:3000/dashboard?token=${serviceJwt.access_token}`;
    res.redirect(frontendRedirectUrl); // 프론트엔드 대시보드 페이지로 JWT와 함께 리다이렉트
  }
}
  • @UseGuards(AuthGuard('google')): 이 가드는 GoogleStrategy를 활성화하여 Google OAuth 흐름을 시작하거나 콜백을 처리합니다.
  • @Get('google'): 이 엔드포인트에 접근하면 Passport가 Google 로그인 페이지로 사용자를 리다이렉션합니다.
  • @Get('google/callback'): Google 인증이 성공하면 이 URL로 사용자를 다시 리다이렉션합니다. 이때 Google에서 발급한 Access Token과 사용자 프로필 정보가 전달되며, GoogleStrategyvalidate 메서드가 실행됩니다. validate 메서드에서 반환된 user 객체는 req.user에 주입됩니다.
  • 콜백 라우트에서는 Google로부터 받은 사용자 정보를 바탕으로 우리의 서비스 자체의 사용자 계정을 생성하거나 조회하고, 자체적인 JWT를 발급하여 클라이언트로 전달하는 것이 일반적인 패턴입니다. 위 예시에서는 간단히 this.authService.login(req.user)를 통해 자체 JWT를 발급하고, 이를 프론트엔드 URL의 쿼리 파라미터로 전달하는 리다이렉션을 보여줍니다. 실제 프론트엔드에서는 이 토큰을 받아 로컬 스토리지 등에 저장하여 이후 요청에 사용합니다.

소셜 로그인 테스트해보기

애플리케이션을 실행합니다. npm run start:dev

웹 브라우저에서 http://localhost:3000/auth/google로 직접 접속합니다.

  • 그러면 Google 로그인 페이지로 리다이렉션됩니다.
  • Google 계정으로 로그인하고, 애플리케이션에 대한 권한 요청을 승인합니다.
  • 성공적으로 인증되면, Google Cloud Console에 설정했던 GOOGLE_CALLBACK_URL (http://localhost:3000/auth/google/callback)로 리다이렉션되고, NestJS 서버의 콘솔에 Google 프로필 정보가 출력됩니다.
  • 마지막으로, NestJS 서버가 자체 JWT를 포함하여 http://localhost:3000/dashboard?token=...와 같은 URL로 다시 브라우저를 리다이렉션할 것입니다. (물론 dashboard 라우트는 존재하지 않을 수 있습니다.)

추가 고려사항 및 모범 사례

  • 에러 처리: OAuth 흐름 중 발생할 수 있는 오류(예: Google 인증 실패)에 대한 적절한 에러 처리 로직을 추가해야 합니다.
  • 사용자 정보 매핑: Google, Facebook 등 각 소셜 서비스에서 제공하는 사용자 정보의 필드명이 다를 수 있으므로, 이를 우리 서비스의 사용자 스키마에 맞게 매핑하는 로직이 필요합니다.
  • 계정 연결/분리: 이미 로컬 계정이 있는 사용자가 소셜 계정으로 로그인할 경우, 기존 계정과 소셜 계정을 연결하거나 분리하는 기능도 고려해야 합니다.
  • Refresh Token: Access Token이 만료되면 새로운 Access Token을 발급받기 위해 Refresh Token을 사용합니다. Refresh Token은 민감하므로 DB에 안전하게 저장하고, Access Token보다 훨씬 긴 만료 기간을 가집니다.
  • CORS 설정: 프론트엔드 애플리케이션의 도메인이 백엔드와 다를 경우, NestJS에 올바른 CORS(Cross-Origin Resource Sharing) 설정을 적용해야 합니다.
  • 다른 소셜 로그인: Facebook, Kakao, Naver 등 다른 소셜 로그인도 Passport 전략(passport-facebook, passport-kakao 등)을 설치하고 유사한 방식으로 구현할 수 있습니다.

이것으로 NestJS에서 OAuth 2.0 기반의 소셜 로그인을 통합하는 방법을 알아보았습니다. Passport.js는 다양한 외부 서비스와의 인증 연동을 위한 강력하고 일관된 인터페이스를 제공하여 개발자가 복잡한 OAuth 흐름에 직접 관여할 필요 없이 인증 기능을 구현할 수 있도록 돕습니다.

이제 여러분은 NestJS 애플리케이션에 로컬 인증, JWT 인증, RBAC 기반 권한 부여, 그리고 소셜 로그인까지 구현할 수 있는 역량을 갖추게 되었습니다. 이러한 인증 및 권한 부여 시스템은 모든 웹 애플리케이션의 보안과 사용자 경험에 있어 핵심적인 요소입니다.