[Nest.js] Google OAuth2.0 (OIDC) 로그인 구현하기

[Nest.js] Google OAuth2.0 (OIDC) 로그인 구현하기

Nest.js에서 Google OAuth2.0 (OIDC) 로그인을 구현하는 방법에 대해 알아보겠습니다.

  1. OAuth2.0과 OIDC의 차이점
  2. OIDC 인증 흐름 이해하기
  3. Google Cloud Console에서 OAuth 2.0 클라이언트 설정하기
  4. 백엔드 코드 작성하기
    1. GET /auth/google API 구현하기
    2. State와 Nonce의 역할
    3. POST /auth/google/authenticate API 구현하기
  5. 프론트엔드에서 필요한 작업
  6. 구글 로그인 테스트하기
  7. 마치며
    1. state 값 저장방식 (쿠키 vs 서버)
    2. 인가 코드 콜백은 어디로? (클라이언트 vs 서버)
  8. 참고 자료

OAuth2.0과 OIDC의 차이점

OAuth2.0을 사용하여 구글 로그인을 구현하기 전에, 많은 분들이 혼동하는 OAuth2.0과 OIDC(OpenID Connect)의 차이점에 대해 간단히 짚고 넘어가려 합니다.

OAuth2.0은 권한 부여 프레임워크로, 사용자가 애플리케이션에 특정 리소스에 대한 접근 권한을 부여할 수 있도록 합니다. 반면, OIDC(OpenID Connect)는 OAuth2.0 위에 구축된 인증 프로토콜로, 사용자의 신원을 확인하고 인증 정보를 제공하는 데 중점을 둡니다.

  • OAuth2.0 - 권한 부여(Authorization)
    • 사용자가 애플리케이션에 특정 리소스에 대한 접근 권한을 부여.
    • 예) 사용자가 애플리케이션에 자신의 구글 드라이브 파일에 접근할 수 있는 권한을 부여.
  • OIDC (OpenID Connect) - 인증(Authentication)
    • 사용자의 신원을 확인하고 인증 정보를 제공.
    • 예) 사용자가 애플리케이션에 구글 계정으로 로그인.


몇몇 개발자분들의 글을 보면 ‘OAuth2.0을 사용하여 소셜 로그인을 구현했다’ 라고 하는데 물론 OAuth2.0을 사용하여 로그인을 구현할 수 있지만, OAuth2.0을 사용하여 로그인을 구현하면 아래와 같은 문제가 발생할 수 있습니다.

OAuth 인증 시 주요 문제점

  1. 액세스 토큰을 인증 증거로 오해
    • 액세스 토큰은 보호된 리소스에 접근하기 위한 권한 부여 수단일 뿐, 그 자체로 사용자 인증을 증명하지는 않습니다.
      클라이언트는 토큰의 내용을 알 수 없으므로 사용자 정보를 직접 유도하기 어렵습니다.
      (쉬운 예시로, 놀이공원에 입장하는 티켓(액세스 토큰)을 통해 놀이공원(리소스 서버)에 들어갈 수는 있지만, 티켓만으로 그 사람이 누구인지(인증)를 알 수 없는 것과 같습니다.)
  2. API 접근 성공을 인증으로 간주
    • 유효한 토큰으로 사용자 정보를 가져올 수 있다고 해서 사용자가 현재 로그인 상태라고 단정할 수 없습니다.
      리프레시 토큰이나 권한 위임을 통해 사용자 없이도 토큰 발급이 가능하며, 사용자가 떠난 후에도 토큰은 한동안 유효하기 때문입니다.

OAuth 공식 문서에서 인증에 OAuth를 사용하는 것을 권장하지 않는 이유에 대해 자세히 살펴볼 수 있습니다.


OAuth2.0과 OIDC 각각 사용자 정보를 얻는 흐름을 간단히 비교해보면 다음과 같습니다.

OAuth2.0 vs OIDC

OIDC의 경우, OAuth2.0의 흐름에 ID 토큰이 추가되어 사용자의 신원을 확인할 수 있도록 합니다. ID 토큰은 JWT(JSON Web Token) 형식으로 발급되며, 사용자의 정보(예: 이메일, 이름 등)를 포함하고 있습니다.

ID 토큰을 통해 사용자 정보를 얻을 수 있기 때문에 사용자 정보를 가져오기 위한 별도의 API 호출이 필요하지 않아 네트워크 비용을 줄일 수 있고 더 안전하게 인증을 처리할 수 있습니다.

따라서 이번 포스팅에서는 OIDC를 사용하여 구글 로그인을 구현하는 방법에 대해 알아보겠습니다.

OIDC 인증 흐름 이해하기

다음은 OIDC 인증 흐름을 나타낸 다이어그램입니다.

OIDC 인증 흐름

OIDC 인증 흐름 설명 보기
  1. 로그인 요청
    • 사용자가 ‘구글 로그인’ 버튼을 클릭합니다.
    • 클라이언트는 서버에 로그인을 요청하고, 서버는 사용자를 구글의 인증 페이지로 리다이렉트(Redirect)시킵니다.\ 이때 scope 파라미터에 반드시 openid를 포함해야 하며, 추가로 email, profile 등을 요청합니다.
  2. 인증 및 권한 승인
    • 사용자가 구글 로그인 페이지에서 아이디/비밀번호를 입력하고, 서비스가 요청한 정보 제공에 동의합니다.
    • 구글 인증 서버는 사용자의 신원을 확인하고, 해당 서비스가 사용자의 정보를 가져가는 것에 동의했는지 체크합니다.
  3. 인가 코드 발급
    • 구글 인증 서버가 브라우저를 통해 클라이언트의 콜백(Callback) URL로 인가 코드를 전달합니다.
    • 이 코드는 일종의 ‘교환권’으로, 보안을 위해 짧은 시간 동안만 유효합니다.
  4. 토큰 교환 요청
    • 클라이언트가 ‘인가 코드’를 들고 토큰을 발급받는 서버(Backend) API에 요청을 보냅니다. 그리고 서버에선 인가 코드를 들고 구글 인증 서버에 실제 토큰을 요청합니다.
    • 이때 서버는 자신의 Client IDClient Secret을 함께 보내어 정당한 서버임을 증명합니다.
  5. ID 토큰 및 액세스 토큰 발급
    • 구글 인증 서버는 코드를 검증한 후, ID 토큰액세스 토큰을 서버에 발급합니다.
    • (OIDC에서는 유저 정보가 담긴 JWT 형태의 ID 토큰이 함께 돌아옵니다.)
  6. ID 토큰 검증 및 유저 정보 획득
    • 서버는 받은 ID 토큰을 디코딩하여 유저의 이메일, 이름, 고유 식별자(sub) 등을 추출합니다.
    • 별도의 API 호출(구글 리소스 서버 요청) 없이도 토큰 자체를 검증(Signature 확인)함으로써 유저 정보를 즉시 확인할 수 있습니다. 이 과정에서 네트워크 비용이 절감됩니다.
  7. 서비스 로그인 완료
    • 획득한 유저 정보를 DB에 저장하거나 확인한 후, 우리 서비스 전용 토큰(JWT 등)을 발급하여 사용자에게 응답합니다.
    • 사용자는 최종적으로 서비스에 로그인된 상태가 됩니다.



처음 설명을 보면 이해하기 어려울 수 있기에 Google OAuth Playground에서 직접 OAuth 관련 요청을 시도해보는걸 추천드립니다.

사이트에 들어가보면 다음과 같은 화면이 나옵니다.
좌측 패널을 보시면 총 3단계로 나누어져 있습니다.

Step 1에서는 다양한 구글 API에 대한 인증 요청을 선택하여 시도해볼 수 있습니다.
Step 2에서는 인가 코드를 교환하여 액세스 토큰을 발급받는 과정을 시도해볼 수 있습니다.
Step 3에서는 발급받은 액세스 토큰을 사용하여 구글 API를 호출해볼 수 있습니다.

OAuth Playground

오른쪽 톱니바퀴를 누르면 설정 창이 나오게 되는데 일단 기본 설정으로 두고 시작하겠습니다. (나중에 커스텀 설정을 통해 내 서비스의 구글 로그인을 테스트 해볼 수 있습니다.)

OAuth Playground Setting

Step 1에서 Google OAuth2 API v2를 선택하고 https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile, openid 스코프를 추가합니다.
그리고 Authorize APIs 버튼을 클릭합니다.

OAuth Playground Scope

그러면 구글 로그인 페이지로 리다이렉트되며, 로그인을 진행하고 권한을 승인합니다.

OAuth Playground Login

승인 후에는 다시 OAuth Playground로 리다이렉트 되고 인가 코드를 발급받게됩니다.

여기까지가 ‘로그인 버튼 클릭’ 부터 ‘인가 코드 발급’까지의 흐름입니다.

OAuth Playground Authorized

이제 Step 2에서 인가 코드를 교환하여 액세스 토큰을 발급받을 수 있습니다.
Exchange authorization code for tokens 버튼을 클릭합니다.

OAuth Playground Exchange

그러면 액세스 토큰, 리프레시 토큰과 함께 ID 토큰도 발급받는 것을 확인할 수 있습니다.
액세스 토큰을 사용하여 Step 3에서 다른 구글 API를 호출해볼 수 있습니다만 지금은 구글 로그인 구현이 목적이기에 Step 3까지 진행하지 않아도 됩니다.

OAuth Playground Tokens

ID 토큰은 JWT 형식으로 되어 있으며, jwt.io에서 디코딩해보면 다음과 같이 사용자 정보가 포함되어 있는 것을 확인할 수 있습니다.

OAuth Playground ID Token

구글 ID 토큰의 페이로드에 대한 정보는 ID 토큰의 페이로드 문서를 참고해주세요.


ID 토큰 디코딩은 실제 서버에서 수행하며, 여기까지가 ‘인가 코드를 가지고 서비스 토큰 발급 요청’부터 ‘ID 토큰을 디코딩하여 사용자 정보 획득’까지의 흐름입니다.

OAuth Playground ID Token Decoded

Google Cloud Console에서 OAuth 2.0 클라이언트 설정하기

구글 로그인을 구현하기 위해서는 먼저 Google Cloud Console에서 OAuth 2.0 클라이언트를 설정해야 합니다.
Google Cloud Console에 접속한 후, 새 프로젝트를 생성하고 ‘API 및 서비스’ > ‘OAuth 동의 화면’ 메뉴를 클릭합니다.

Google Cloud Console OAuth Consent Screen

OAuth 개요에서 ‘시작하기’ 버튼을 클릭하여 프로젝트 구성을 시작합니다.
앱 이름, 사용자 지원 이메일, 개발자 연락처 정보 등을 알맞게 입력하시고 대상 유형은 ‘외부’를 선택합니다.

Google Cloud Console OAuth Consent Start

프로젝트 구성을 완료한 후, ‘OAuth 클라이언트 만들기’를 클릭하여 OAuth 2.0 클라이언트를 생성합니다.

Google Cloud Console Create OAuth Client

애플리케이션 유형은 ‘웹 애플리케이션’을 선택하고, 이름을 입력합니다.
‘승인된 JavaScript 원본’에는 클라이언트의 도메인을 입력하고, ‘승인된 리디렉션 URI’에는 구글 인증 후 리다이렉트될 클라이언트의 콜백 URL을 입력합니다.
(일단 로컬에서 테스트할 예정이기에 localhost를 사용하였지만, 실제 서비스에서는 배포된 도메인을 사용해야 합니다.)

Google Cloud Console Create OAuth Client Settings

설정을 완료하고 ‘만들기’ 버튼을 클릭하면 클라이언트 ID와 클라이언트 보안 비밀번호가 발급됩니다.
클라이언트 보안 비밀번호는 모달 창을 닫으면 다시 볼 수 없으니 안전한 곳에 보관해주세요!

다음으로 ‘데이터 액세스’ 탭으로 이동해서 ‘범위 추가 또는 삭제’ 버튼을 클릭합니다.

Google Cloud Console OAuth Scopes

.../auth/userinfo.email, .../auth/userinfo.profile, openid 범위를 선택하고 ‘업데이트’ 버튼을 클릭합니다.

Google Cloud Console OAuth Add Scopes

‘민감하지 않는 범위’에 선택한 범위들이 추가되었다면 아래 ‘Save’ 버튼을 클릭하여 저장합니다.

Google Cloud Console OAuth Scopes Saved

이제 ‘대상’ 탭으로 이동하여 ‘Add users’ 버튼을 클릭하고 테스트 사용자로 사용할 구글 계정을 추가합니다.

Google Cloud Console OAuth Add Test Users

이렇게 구글 OAuth 2.0 클라이언트 설정이 완료되었습니다.
이제 Nest.js 환경에서 구글 로그인을 구현해보겠습니다.

백엔드 코드 작성하기

코드를 작성하기 전에 OIDC 인증을 위해 백엔드에서 구현해야 하는 기능들을 간단히 정리해보면 아래와 같습니다.

  1. 클라이언트에서 구글 로그인 요청을 처리하는 API 구현 (GET /auth/google)
  2. 클라이언트로부터 인가 코드를 받아 서비스 토큰을 발급하는 API 구현 (POST /auth/google/authenticate)
    • 인가 코드를 구글 인증 서버에 전달하여 ID 토큰과 액세스 토큰 발급 요청
    • 구글로부터 받은 ID 토큰을 검증하고 사용자 정보 추출
    • 추출한 사용자 정보를 바탕으로 서비스 로그인 처리

GET /auth/google API 구현하기

아래는 첫번째 기능인 ‘클라이언트에서 구글 로그인 요청을 처리하는 API’를 구현한 코드입니다.

// File: "auth.controller.ts"
// ... import statements ...

@ApiTags("Auth (인증)")
@Controller("auth")
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly googleAuthService: GoogleAuthService,
    private readonly appConfigService: AppConfigService,
  ) {}

  // ... other endpoints ...

  @ApiAuth.googleLogin()
  @Get("google")
  @HttpCode(HttpStatus.FOUND)
  googleLogin(@Res() res: Response): void {
    const { url, state, nonce }: IGoogleAuthOptions =
      this.googleAuthService.generateAuthOptions();

    const cookieOptions = this.getCommonCookieOptions(
      COOKIE_MAX_AGE.GOOGLE_OAUTH,
    );

    // 쿠키에 state와 nonce 저장
    res.cookie(COOKIE_NAME.GOOGLE_STATE, state, cookieOptions);
    res.cookie(COOKIE_NAME.GOOGLE_NONCE, nonce, cookieOptions);

    return res.redirect(url);
  }

  // ... other endpoints ...
}

코드를 살펴보면, GET /auth/google 엔드포인트에서 GoogleAuthServicegenerateAuthOptions() 메서드를 호출하여 구글 인증 URL과 함께 보안 파라미터인 state, nonce를 가져오고 있습니다.
그 후에 statenonce를 쿠키에 저장한 뒤, 클라이언트를 구글 인증 페이지로 리다이렉트시키고 있습니다.

다음으로 GoogleAuthServicegenerateAuthOptions() 메서드 코드를 살펴보겠습니다.

// File: "google-auth.service.ts"
// ... import statements ...

@Injectable()
export class GoogleAuthService {
  private readonly googleClient: OAuth2Client;
  private readonly logger = new Logger(GoogleAuthService.name);

  constructor(
    private readonly appConfigService: AppConfigService,
    private readonly userService: UserService,
    @Inject(AUTH_TOKENS.ITokenService)
    private readonly tokenService: ITokenService,
  ) {
    this.googleClient = new OAuth2Client({
      clientId: this.appConfigService.googleClientId,
      clientSecret: this.appConfigService.googleClientSecret,
      redirectUri: this.appConfigService.googleRedirectUri,
    });
  }

  /**
   * 구글 인증 URL 생성 및 보안 파라미터(state, nonce) 발급
   */
  generateAuthOptions(): IGoogleAuthOptions {
    this.logger.log("구글 인증 URL 생성을 시작합니다.");

    const rootUrl = "https://accounts.google.com/o/oauth2/v2/auth";
    const state = nanoid(STATE_LENGTH);
    const nonce = nanoid(NONCE_LENGTH);
    const options = {
      client_id: this.appConfigService.googleClientId,
      redirect_uri: this.appConfigService.googleRedirectUri,
      response_type: "code",
      scope: ["openid", "email", "profile"].join(" "),
      access_type: "offline",
      state,
      nonce,
      prompt: "select_account",
    };

    return {
      url: `${rootUrl}?${new URLSearchParams(options).toString()}`,
      state,
      nonce,
    };
  }

  // ... other methods ...
}

인증 URL Parameter에 대한 자세한 내용은 여기를 참고해주세요.


nanoid 라이브러리를 사용하여 statenonce를 생성하고 있는데 각각 어떤 역할을 하는지 간단히 설명드리겠습니다.

State와 Nonce의 역할

  • State
    • CSRF(Cross-Site Request Forgery) 공격을 방지하기 위한 보안 토큰입니다.
    • 클라이언트가 구글 로그인 요청을 서버로 보낼 때 생성하여 클라이언트에 전송하고, 구글 인증 서버는 이 값을 포함시켜 응답합니다.
    • 클라이언트가 이후에 서버에 서비스 토큰을 요청할 때, 서버는 state 값이 이전에 클라이언트에게 응답으로 보낸 값과 일치하는지 확인하여 요청의 정당성을 검증합니다.
  • Nonce
    • 재전송 공격(Replay Attack)을 방지하기 위한 임의의 값입니다.
    • 클라이언트가 구글 로그인 요청을 서버로 보낼 때 생성하여 클라이언트에 전송하고, 구글 인증 서버는 ID 토큰에 이 값을 포함시켜 응답합니다.
    • 서버는 ID 토큰에서 받은 nonce 값이 이전에 클라이언트에게 응답으로 보낸 값과 일치하는지 확인하여 토큰의 유효성을 검증합니다.

즉, state는 클라이언트의 요청이 위변조되지 않았는지 검증하는 역할을 하고, nonce는 ID 토큰이 변조되지 않았는지 검증하는 역할을 합니다.

위 코드를 정리해보면 GET /auth/google 엔드포인트에서 구글 인증 URL과 함께 state, nonce를 생성하여 클라이언트를 구글 인증 페이지로 리다이렉트시키는 기능을 구현한 것입니다.

POST /auth/google/authenticate API 구현하기

다음은 두번째 기능인 ‘클라이언트로부터 인가 코드를 받아 서비스 토큰을 발급하는 API’를 구현한 코드입니다.

// File: "auth.controller.ts"
// ... import statements ...

@ApiTags("Auth (인증)")
@Controller("auth")
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly googleAuthService: GoogleAuthService,
    private readonly appConfigService: AppConfigService,
  ) {}

  // ... other endpoints ...

  @ApiAuth.googleAuthenticate()
  @Post("google/authenticate")
  @HttpCode(HttpStatus.OK)
  @ResponseMessage("구글 로그인에 성공하였습니다.")
  async googleAuthenticate(
    @Body("code") code: string,
    @Body("state") requestState: string,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ): Promise<AccessTokenResponseDto> {
    if (!code || !requestState) {
      throw new UnauthorizedException(
        "필수 인증 파라미터(code, state)가 누락되었습니다.",
      );
    }

    const savedState = req.cookies[COOKIE_NAME.GOOGLE_STATE];
    const savedNonce = req.cookies[COOKIE_NAME.GOOGLE_NONCE];

    const params: IHandleGoogleLoginParams = {
      code,
      savedState,
      requestState,
      savedNonce,
    };
    const { accessToken, refreshToken } =
      await this.googleAuthService.handleGoogleLogin(params);

    res.clearCookie(COOKIE_NAME.GOOGLE_STATE);
    res.clearCookie(COOKIE_NAME.GOOGLE_NONCE);

    res.cookie(
      COOKIE_NAME.REFRESH_TOKEN,
      refreshToken,
      this.getCommonCookieOptions(COOKIE_MAX_AGE.REFRESH_TOKEN),
    );

    return { accessToken };
  }
}

POST /auth/google/authenticate에선 클라이언트 Body로 인가 코드(code)와 requestState, 쿠키로 savedStatesavedNonce 값을 받아 GoogleAuthServicehandleGoogleLogin() 메서드를 호출하여 서비스 토큰을 생성하고 있습니다.

GoogleAuthServicehandleGoogleLogin() 메서드에선 어떻게 서비스 토큰을 만드는지 살펴보겠습니다.

// File: "google-auth.service.ts"
// ... import statements ...

@Injectable()
export class GoogleAuthService {
  private readonly googleClient: OAuth2Client;
  private readonly logger = new Logger(GoogleAuthService.name);

  constructor(
    private readonly appConfigService: AppConfigService,
    private readonly userService: UserService,
    @Inject(AUTH_TOKENS.ITokenService)
    private readonly tokenService: ITokenService,
  ) {
    this.googleClient = new OAuth2Client({
      clientId: this.appConfigService.googleClientId,
      clientSecret: this.appConfigService.googleClientSecret,
      redirectUri: this.appConfigService.googleRedirectUri,
    });
  }

  // ... other methods ...

  /**
   * 콜백 처리: 구글 인가 코드를 우리 서비스의 토큰으로 교환
   */
  async handleGoogleLogin(
    params: IHandleGoogleLoginParams,
  ): Promise<LoginResponseDto> {
    const { code, savedState, requestState, savedNonce } = params;

    this.logger.log("구글 로그인을 처리합니다.");

    if (!code) {
      throw new UnauthorizedException("인증 코드가 없습니다.");
    }

    if (!savedState || !savedNonce) {
      throw new UnauthorizedException(
        "로그인 세션이 만료되었습니다. 다시 시도해주세요.",
      );
    }

    // A. State 검증 (CSRF 방지)
    if (savedState !== requestState) {
      throw new UnauthorizedException("유효하지 않은 인증 상태(state)입니다.");
    }

    // B. 구글 토큰 발급 (ID Token 포함)
    const { id_token } = await this.exchangeCodeForTokens(code);

    if (!id_token) {
      throw new UnauthorizedException("구글 ID 토큰이 발급되지 않았습니다.");
    }

    // C. ID Token 검증 및 nonce 확인 (재전송 공격 방지)
    const googlePayload = await this.verifyGoogleIdToken(id_token);

    if (googlePayload.nonce !== savedNonce) {
      throw new UnauthorizedException(
        "ID 토큰 보안 검증(nonce)에 실패했습니다.",
      );
    }

    if (!googlePayload.email_verified) {
      throw new UnauthorizedException(
        "구글 이메일 인증이 완료되지 않았습니다.",
      );
    }

    // D. 우리 서비스 유저 처리 (회원가입 또는 로그인)
    const user = await this.userService.createSocialUser({
      email: googlePayload.email,
      nickname: googlePayload.name,
      socialId: googlePayload.sub,
      profileImageUrl: googlePayload.picture,
      provider: "GOOGLE",
    });

    const sub = user.getId().getValue();
    const email = user.getEmail().getValue();
    const role = user.getRole().getValue();

    const accessTokenPayload: IAccessTokenPayload = {
      sub,
      email,
      role,
    };
    const refreshTokenPayload: IRefreshTokenPayload = {
      sub,
    };

    // E. 자체 서비스 토큰 발급
    const [accessToken, refreshToken] = await Promise.all([
      this.tokenService.generateAccessToken(accessTokenPayload),
      this.tokenService.generateRefreshToken(refreshTokenPayload),
    ]);

    this.logger.log(`구글 로그인이 성공적으로 완료되었습니다: ${email}`);

    return new LoginResponseDto(accessToken, refreshToken);
  }

  /**
   * 구글 서버에 Code를 주고 ID Token을 받아옴
   */
  private async exchangeCodeForTokens(code: string): Promise<Credentials> {
    this.logger.log("구글 서버와 인가 코드를 토큰으로 교환합니다.");

    try {
      const { tokens } = await this.googleClient.getToken(code);
      return tokens;
    } catch (error) {
      let errorMessage = "구글 토큰 발급에 실패했습니다.";

      if (error instanceof Error) {
        // google-auth-library는 에러 발생 시 response data를 포함할 수 있습니다.
        const responseData = (error as any).response?.data;
        if (responseData) {
          this.logger.warn(
            `구글 토큰 교환 실패 상세: ${JSON.stringify(responseData)}`,
          );
          errorMessage = `구글 토큰 교환 실패: ${responseData.error_description || error.message}`;
        } else {
          errorMessage = `구글 토큰 교환 실패: ${error.message}`;
        }
      }

      throw new UnauthorizedException(errorMessage);
    }
  }

  /**
   * 구글 ID 토큰 검증
   */
  private async verifyGoogleIdToken(idToken: string): Promise<TokenPayload> {
    this.logger.log("구글 ID 토큰을 검증합니다.");
    try {
      const ticket = await this.googleClient.verifyIdToken({
        idToken,
        audience: this.appConfigService.googleClientId,
      });

      const payload = ticket.getPayload();
      if (!payload) {
        throw new Error("ID 토큰 페이로드가 비어있습니다.");
      }

      return payload;
    } catch (error) {
      throw new UnauthorizedException(
        `구글 ID 토큰 검증 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
      );
    }
  }
}

구글 ID 토큰 검증 단계는 ID 토큰 검증 가이드를 참고하시면 되며, 필자는 Google API 클라이언트 라이브러리를 사용하여 구현했습니다.


이제 코드를 단계별로 살펴보겠습니다.

A. State 검증

// A. State 검증 (CSRF 방지)
if (savedState !== requestState) {
  throw new UnauthorizedException("유효하지 않은 인증 상태(state)입니다.");
}

먼저 GET /auth/google 엔드포인트에서 쿠키에 저장했던 savedState 값과 클라이언트로부터 받은 requestState 값을 비교합니다. 만약 두 값이 일치하지 않는다면 CSRF 공격일 가능성이 있으므로 예외를 던집니다.

B. 구글 ID 토큰 발급

// B. 구글 토큰 발급 (ID Token 포함)
const { id_token } = await this.exchangeCodeForTokens(code);

if (!id_token) {
  throw new UnauthorizedException("구글 ID 토큰이 발급되지 않았습니다.");
}

exchangeCodeForTokens() 메서드를 호출하여 구글 인증 서버에 인가 코드를 전달하고 ID 토큰과 액세스 토큰을 발급받습니다.

C. ID 토큰 검증 및 nonce 확인

// C. ID Token 검증 및 nonce 확인 (재전송 공격 방지)
const googlePayload = await this.verifyGoogleIdToken(id_token);

if (googlePayload.nonce !== savedNonce) {
  throw new UnauthorizedException(
    "ID 토큰 보안 검증(nonce)에 실패했습니다.",
  );
}

if (!googlePayload.email_verified) {
  throw new UnauthorizedException(
    "구글 이메일 인증이 완료되지 않았습니다.",
  );
}

verifyGoogleIdToken() 메서드를 호출하여 구글로부터 받은 ID 토큰을 검증하고, 토큰의 페이로드를 가져옵니다.
그 후에 페이로드에 포함된 nonce 값과 쿠키에 저장했던 savedNonce 값을 비교하여 재전송 공격을 방지합니다.
또한, 구글 이메일 인증이 완료되었는지도 확인합니다.

D. 우리 서비스 유저 처리

// D. 우리 서비스 유저 처리 (회원가입 또는 로그인)
const user = await this.userService.createSocialUser({
  email: googlePayload.email,
  nickname: googlePayload.name,
  socialId: googlePayload.sub,
  profileImageUrl: googlePayload.picture,
  provider: "GOOGLE",
});

ID 토큰에서 추출한 사용자 정보를 바탕으로 UserServicecreateSocialUser() 메서드를 호출하여 우리 서비스의 유저를 생성하거나 기존 유저를 조회합니다.

E. 자체 서비스 토큰 발급

// E. 자체 서비스 토큰 발급
const [accessToken, refreshToken] = await Promise.all([
  this.tokenService.generateAccessToken(accessTokenPayload),
  this.tokenService.generateRefreshToken(refreshTokenPayload),
]);

this.logger.log(`구글 로그인이 성공적으로 완료되었습니다: ${email}`);

return new LoginResponseDto(accessToken, refreshToken);

마지막으로 자체 서비스 토큰(JWT)을 발급한 후 반환합니다.

프론트엔드에서 필요한 작업

저는 이번 포스팅에서 백엔드 구현에 집중했기에 프론트엔드 코드는 다루지 않았습니다.
혹여나 필요한 분들을 위해 간단히 프론트엔드에서 필요한 작업에 대해서 말해보자면 아래와 같습니다.

  1. 구글 로그인 버튼을 클릭했을 때 GET /auth/google API로 요청을 보내는 로직이 필요합니다.
  2. 구글 인증이 완료된 후 콜백 URL(http://localhost:5173/google-callback)에서 인가 코드와 state 값을 받아 POST /auth/google/authenticate API로 전달하는 작업이 필요합니다.

구글 로그인 테스트하기

이제 모든 구현이 완료되었으니 제가 만든 서비스에서 구글 로그인을 테스트해보겠습니다.
로그인 페이지에서 구글 로그인 버튼을 클릭합니다.

Google Login Button

구글 로그인 버튼을 클릭하면 GET /auth/google API가 호출되고, 클라이언트는 구글 인증 페이지로 리다이렉트됩니다.

구글 로그인 페이지에서 원하는 계정을 선택하고 권한을 승인합니다.

Google Grant Permission

승인 후에는 다시 클라이언트의 콜백 URL로 리다이렉트되고, 인가 코드와 state 값이 포함되어 있을겁니다.
프론트엔드에선 이 값을 추출하여 POST /auth/google/authenticate API로 전달합니다.
그 결과 응답값으로 서비스 토큰(액세스 토큰, 리프레시 토큰)을 받게 되고, 이를 통해 서비스에 로그인된 상태가 됩니다.

Google Login Success

마치며

이렇게 해서 Nest.js 환경에서 OIDC를 사용하여 구글 로그인 구현을 완료했습니다.

전체 코드가 궁금하신 분들은 여기를 참고해주세요.

아래부터는 제가 구글 로그인을 구현하면서 마주한 문제들과 그에 대한 생각을 정리해 보았으니 로그인 구현을 마치셨다면 한번쯤 읽어보시면 좋을 것 같습니다.

그럼 다음 포스트에서 뵙겠습니다! 뾰로롱~

프리렌 움짤1

state 값 저장방식 (쿠키 vs 서버)

state 값을 저장하는 이유는 CSRF 공격을 방지하기 위함인데, 제가 구현한 방식을 보다보면 이런 생각이 들 수 있습니다.
state 값을 클라이언트 쿠키에 저장하지 않고, 서버 세션이나 인메모리 캐시(Redis 등)에 저장하는게 더 안전하지 않을까?”
서버 세션이나 인메모리 캐시에 저장하는 방법이 보안상 더 안전할 수 있지만, 서버에 저장하게 된다면 다음과 같은 문제점이 발생할 수 있습니다.

먼저 서버에서 state 값을 관리해야 하므로 추가적인 저장소가 필요합니다. 그리고 로그인이 한번의 API 요청으로 완료되는게 아니라 두 단계로 나누어지기 때문에, 후에 토큰 발급 요청을 보내는 클라이언트가 이전에 로그인 요청을 보낸 클라이언트와 동일한지 확인하기 위해 세션 식별자나 기타 식별자를 함께 관리해야 합니다.

세션 식별자는 쿠키에 저장되기 때문에 결국 쿠키를 사용하여 클라이언트에 정보를 저장하게 되고, 서버에서 세션을 관리해야 하는 추가적인 복잡성이 발생합니다. 그리고 OAuth 인증 흐름에서는 stateless(무상태성)를 유지하는 것이 권장되기 때문에, 서버에서 상태를 관리하는 것은 이러한 원칙에 어긋날 수 있습니다. (서버가 여러 대일 경우 상태 동기화 작업도 해줘야 합니다.)

따라서 state 값을 클라이언트 쿠키에 저장하는 방식이 더 간단하게 구현할 수 있고 OAuth의 무상태성 원칙에도 부합한다고 생각하여 쿠키에 저장하는 방식을 선택했습니다.

인가 코드 콜백은 어디로? (클라이언트 vs 서버)

대부분의 글이나 예제를 살펴보면 인가 코드 콜백 URL을 서버로 지정하는 경우가 많습니다.
실제로 구글의 OIDC 서버 흐름 문서에서도 콜백 URL을 서버로 지정하고 있습니다.

그러나 이렇게 콜백 URL을 서버로 지정하게 되면 서버에서 다시 클라이언트로 리다이렉트 시켜주는 추가적인 작업이 필요합니다.
즉, 302 응답을 보내게 된다는 것인데 브라우저는 302 응답을 받으면 Body를 읽기 전에 Location 헤더에 적힌 주소로 즉시 이동(Redirect)해 버립니다.

쿠키로 서비스 토큰을 전달할 수 있지만 저희 서비스에선 액세스 토큰은 localStorage에, 리프레시 토큰은 쿠키에 저장하는 방식을 사용하고 싶었기 때문에 어떤 방법을 써야할지 고민했습니다.

가장 간단한 방법으론 액세스 토큰을 URL 파라미터에 포함시키는 방법이 있겠지만, URL에 민감한 정보를 담는 것은 보안상 좋지 않기에 이 방법은 제외했고, 또 다른 방법으로는 액세스 토큰도 httpOnly 옵션을 끈 쿠키에 임시 저장하여 클라이언트에서 이를 읽은 뒤 즉시 삭제하는 방법을 생각해봤습니다. 그러나 이 역시 보안상 좋지 않고 클라이언트에서 불필요한 작업이 추가된다는 단점이 있었습니다.

그래서 저는 인가 코드 콜백 URL을 클라이언트로 지정하는 방식을 선택했습니다.
중간에 클라이언트가 요청을 보내는 작업이 추가되긴 하지만, 제가 원하는대로 Body에 액세스 토큰을 실어 보낼 수 있고, 실제 구글과 통신하여 토큰을 바꾸고 유효성을 검증하는 모든 권한과 로직은 서버에 있기 때문에 서버 주도 방식 (Server-side Flow)을 유지할 수 있었기 때문입니다.

참고 자료


© 2026 NicoDora. All rights reserved.

Powered by Hydejack v9.1.6