[TIL/Nest] 2025/05/12

원민관·2025년 5월 12일

[TIL]

목록 보기
179/201
post-thumbnail

Applying session-based authentication to the project ✍️

0. Overview ✅

현재 목표에 대해 브리핑하겠습니다.

  1. 이미 회원가입이 완료된(=기존 유저) 유저를 Database에서 찾는다.
  2. 해당 유저에 대한 validation을 진행한다.
  3. serializeUser()를 트리거 하여 세션 ID를 포함한 유저 정보를 프론트엔드로 반환한다.
  4. 인증된 사용자의 후속 요청에 대해서는, deserializeUser()를 트리거 하여 기존 유저 정보를 복원해서 프론트엔드로 전달한다.

위 사항이 전부입니다. 참 간단하죠?

1. Flow ✅

일반적인 passport-based flow는 다음과 같습니다.

  1. 컨트롤러 라우터 진입 전, UseGuards()를 통해 가드를 실행한다.
  2. Guard에서는 super.canActivate(context)를 통해 Strategy의 validate() 함수를 실행한다.
  3. 이후 Guard의 super.logIn(request)를 통해 serializeUser() 함수를 실행한다.

요컨대, Guard에서는 validation과 serialization을 실행합니다.

그런데 현재 프로젝트의 경우, Guard와 Strategy가 필요하지 않다는 결론에 도달하게 되었습니다.

2. Guard가 필요하지 않은 이유 1 ✅

Guard가 필요하지 않은 이유 1은, Strategy가 필요하지 않은 이유와 동일합니다.

import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {
  private readonly logger = new Logger(GoogleAuthGuard.name);

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const activate = (await super.canActivate(context)) as boolean;
    const request = context.switchToHttp().getRequest();

    if (activate) {
      await super.logIn(request);
      this.logger.log('로그인 완료');
    }

    return activate;
  }
}

super.canActivate(context)는 가드에서 가장 먼저 실행되는 함수입니다. 해당 함수가 strategy 파일에 작성된 validate() 함수를 실행하여, 유저 정보에 대한 validation을 진행하게 됩니다. 그런데 저는 컨트롤러에서 Guard를 적용하지 않았으니 activate 값은 false로 반환될 것입니다. 즉 Strategy 파일의 validate() 함수가 동작하지 않을 것입니다.

if (user.isExist) {
        const viaGoogleUser = await this.usersService.findByEmail(user.email);

        const addedProviderViaGoogleUser = { ...viaGoogleUser, provider };

        // 세션 로그인 처리 시작
        (req as any).login(addedProviderViaGoogleUser, (err: any) => {
          if (err) {
            throw new HttpException(
              '세션 로그인 실패',
              HttpStatus.INTERNAL_SERVER_ERROR,
            );
          }

          // 세션에 유저 정보 저장 (세션 미들웨어 사용)
          const user = (req as any).user;

          console.log(user);

          res.send({
            message: '기존 유저 데이터',
            user: { ...addedProviderViaGoogleUser, isExist: true },
          });
        });

        return;
      }

컨트롤러에서는 구글 테이블에서 유효한 유저를 찾은 뒤, 해당 유저의 isExist 속성값이 true라면, 구글을 통해 가입한 최종 유저 정보를 email로 찾게 됩니다. 그 값이 바로 viaGoogleUser 변수에 저장되죠.

// 인가 코드로 액세스 토큰 교환
const tokens = await this.googleAuthService.getToken(code);

// 액세스 토큰으로 사용자 정보 획득
const userData = await this.googleAuthService.getUserInfo(tokens.access_token);

// 기존 사용자인지 확인
const user = await this.googleAuthService.findUser(userData);

viaGoogleUser를 생성하기 직전에 동작하는 코드입니다. 유효한 인가 코드를 통해 액세스 토큰을 발급받고, 해당 토큰으로 구글 유저를 찾는 로직이죠. 위 코드 자체가 validation입니다.

즉, Guard가 필요하지 않은 이유 1 그리고 Strategy가 필요하지 않은 이유가 위 코드에 해당합니다. 이미 Guard의 첫 번째 존재 이유인 validation을 수행했기에, Guard와 Strategy가 필요하지 않습니다.

3. Guard가 필요하지 않은 이유 2 ✅

Guard의 두 번째 존재 이유는 serializeUser()에 있습니다.

    if (activate) {
      await super.logIn(request);
      this.logger.log('로그인 완료');
    }

    return activate;

Strategy가 정상적으로 activate 되었다면, validation이 잘 진행되었겠죠. activate가 true 일 때, super.logIn(request)을 통해 serializeUser() 함수를 실행하게 됩니다.

if (user.isExist) {
        const viaGoogleUser = await this.usersService.findByEmail(user.email);

        const addedProviderViaGoogleUser = { ...viaGoogleUser, provider };

        // 세션 로그인 처리 시작
        (req as any).login(addedProviderViaGoogleUser, (err: any) => {
          if (err) {
            throw new HttpException(
              '세션 로그인 실패',
              HttpStatus.INTERNAL_SERVER_ERROR,
            );
          }

          // 세션에 유저 정보 저장 (세션 미들웨어 사용)
          const user = (req as any).user;

          console.log(user);

          res.send({
            message: '기존 유저 데이터',
            user: { ...addedProviderViaGoogleUser, isExist: true },
          });
        });

        return;
      }

컨트롤러 코드에서, 세션 로그인 처리 시작 부분을 살펴보겠습니다. 컨트롤러에서 (req as any).login()으로 세션 로그인을 처리하고 있음을 확인할 수 있습니다.

가드가 해야 할 두 번째 작업을 컨트롤러에서 이미 수행하고 있기에, Guard가 필요하지 않은 것입니다.

가드가 수행해야 할 validation(strategy에 작성됨)과 serializeUser() 트리거를 모두 컨트롤러에서 수행하고 있으니, Guard와 Strategy가 모두 필요 없게 된 상황입니다. 그런데 왜 이렇게 되었는지 살펴볼 필요가 있습니다.

4. Controller ✅

  @Post('user')
  async redirect(
    @Body('code') code: string,
    @Body('provider') provider: string,
    @Req() req: Request,
    @Res() res: Response,
  ) {
    try {
      // 인가 코드로 액세스 토큰 교환
      const tokens = await this.googleAuthService.getToken(code);

      // 액세스 토큰으로 사용자 정보 획득
      const userData = await this.googleAuthService.getUserInfo(
        tokens.access_token,
      );

      // 기존 사용자인지 확인
      const user = await this.googleAuthService.findUser(userData);

      if (user.isExist) {
        const viaGoogleUser = await this.usersService.findByEmail(user.email);

        const addedProviderViaGoogleUser = { ...viaGoogleUser, provider };

        // 세션 로그인 처리 시작
        (req as any).login(addedProviderViaGoogleUser, (err: any) => {
          if (err) {
            throw new HttpException(
              '세션 로그인 실패',
              HttpStatus.INTERNAL_SERVER_ERROR,
            );
          }

          // 세션에 유저 정보 저장 (세션 미들웨어 사용)
          const user = (req as any).user;

          console.log(user);

          res.send({
            message: '기존 유저 데이터',
            user: { ...addedProviderViaGoogleUser, isExist: true },
          });
        });

        return;
      } else {
        res.send({
          message: '신규 유저 데이터',
          user,
          accessToken: tokens.access_token,
          refreshToken: tokens.refresh_token,
          expiresIn: tokens.expires_in,
        });
      }
    } catch {
      throw new HttpException(
        '인증 처리 중 오류가 발생했습니다',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

1. Flow 파트에서, "컨트롤러 라우터 진입 전 UseGuards()를 통해 가드를 실행한다."라고 했습니다. 즉, 가드는 컨트롤러 라우터 진입 전에 실행됩니다. 그런데 실직적인 로그인 로직은 컨트롤러 내부에서 실행되어야 합니다.

프론트엔드 RedirectPage에서 인가 코드를 백엔드로 전송하고, 해당 인가 코드를 통해 액세스 토큰을 발급받은 뒤, 액세스 토큰을 통해 구글 테이블 유저를 찾는 과정이 반드시 전제되어야 합니다. 따라서 실질적인 로그인은 컨트롤러 내부에서 진행될 수밖에 없습니다.

세션 방식에서의 로그인은 사실 <validation+serialization>입니다. 이것은 가드의 역할이기도 하죠. 가드의 역할이 컨트롤러 라우터 진입 전이 아니라 진입 후에 수행되어야 한다는 구조적인 특성 때문에, Guard와 그 연장선에서 동작하는 Strategy가 필요치 않다는 결론에 도달했습니다.

그런데 (req as any).login()에서 login은 갑자기 어디에서 나온 것일까요? 관련해서 전역 설정에 대해 살펴보는 게 좋겠습니다.

5. Global Configuration ✅

5-1. passport.initialize() 🚨

main.ts 파일에서 passport.initialize()를 실행하는 순간, requset에 적용할 수 있는 req.login(), req.logout(), req.isAuthenticated()가 생성됩니다.

만약 누군가가, "passport.initialize()를 glbal 하게 설정하셨는데 해당 코드가 어떤 기능을 수행하는지 설명해주실래요?"라고 묻는다면, "requset에 대한 몇 가지 메서드를 제공하는 기능을 수행합니다!"라고 씩씩하게 말하면 되겠습니다.

5-2. passport.session() 🚨

passport.session()은 deserializer()를 실행하는 역할을 합니다.

이때, req.user는 실제 DB에 있는 유저 정보를 의미하고, req.session.user는 세션 상의 유저 정보를 의미합니다.

만약 실제 DB에 있는 유저의 role이 admin인데, 최근에 강등되었다고 가정합시다. 세션 상의 role은 guest인데, 실제 DB의 role이 admin이라면 조정이 필요하겠죠. 상황에 맞게 구현하면 되겠습니다.

5-3. serializer는 단 하나여야 하는가 🚨

우리가 어떤 종류의 configuration을 global 하게 설정했다는 것은, 해당 설정이 애플리케이션 전역에 일관되게 적용된다는 것을 의미합니다.

예를 들어, passport.serializeUser()를 글로벌하게 한 번만 정의했다면, 어떤 전략(Strategy)이 사용되든지 간에 이 직렬화 방식이 공통적으로 사용됩니다.

따라서 서로 다른 소셜 로그인 전략(Google, Naver 등)을 사용할 경우에도 하나의 serializer가 모든 경우를 처리해야 하며, 이때 유저 객체의 구조를 일관되게 맞추는 것이 중요합니다. 즉, serializer는 단 하나여야 한다는 결론에 이르게 되었습니다.

  async deserializeUser(
    payload: { id: string; provider: string },
    done: (err: any, user?: any) => void,
  ) {
    try {
      let user;

      if (payload.provider === 'google') {
        const googleUser = await this.googleAuthService.findUser(payload.id);

        if (!googleUser) {
          return done(null, false);
        }

        user = await this.usersService.findByEmail(googleUser.email);

        if (user) {
          user.provider = 'google';
        }
      } else {
        user = await this.usersService.findById(payload.id);
      }

      if (!user) {
        return done(null, false);
      }

      done(null, user);
    } catch (error) {
      done(error);
    }
  }

그래서 serialize 관련 로직에서는, 위 코드와 같이 provider로 분기 처리를 하는 과정이 필요하게 됩니다. 정리하자면 다음과 같습니다.

  1. passport.initialize()를 통해 requset에 login()을 적용하게 되면 serializeUser()가 실행되어 세션 ID를 만들게 됩니다. 즉 serializeUser()는 세션 ID 생성기입니다.

  2. passport.session()는 deserializeUser()를 실행합니다. deserializeUser()는 req.user를 만드는 역할을 합니다. 즉, deserializeUser()는 req.user 생성기입니다.

  3. serialize 로직은 단 하나여야 하므로, provider 값을 통해 내부에서 분기 처리를 진행해야 합니다.

6. 회고 ✅

처음에는 Guard와 Strategy를 적용하는 데 실패했다고 생각했습니다. 하지만 현재 로직에는 애초에 그것들이 필요하지 않았고, 왜 필요한지 않은지에 대한 근거를 찾는 데 집중했습니다.

지금 우리가 가진 정보만으로 성공과 실패를 단정 짓기는 어렵습니다. 애초에 우리는 길고 긴 인생에 대해 섣불리 단정 지을 수 있을 만큼 머리가 샤프하지 않습니다.

자신 있게 틀릴 수 있는 태도가 더 중요하다고 생각합니다. 틀릴 수는 있어도, 생각 없이 반복하는 앵무새가 되어선 안 되겠죠. 나중에 돌아봤을 때 틀렸다고 생각했던 것이, 틀린 것이 아닐 수도 있으니까요.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글