LIKET-객체지향스럽게 소셜 로그인 구현하기

민경찬·2024년 7월 6일
29

백엔드

목록 보기
17/19
post-thumbnail

라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.

안녕하세요. LIKET의 백엔드를 담당하고 있는 민경찬입니다. LIKET 팀은 백엔드에서 Nest.js를 사용하고 있습니다.

오늘은 Nest.js를 사용하여 소셜 로그인을 객체지향스럽게 개발했던 방법에 대해서 얘기해보려고 합니다.


소셜 로그인 플로우

카카오 로그인을 예시로 소셜 로그인의 흐름을 이해해봅시다.

클라이언트에서는 백엔드로 요청을 보내 카카오 로그인 페이지를 받을 경로를 얻습니다. 이후 클라이언트에서는 카카오 로그인 페이지에서 카카오 로그인을 통해 카카오의 callback URI를 받아 다시 백엔드로 인가 코드를 전달합니다.

백엔드에서는 카카오에서 제공하는 REST API를 통해 사용자 데이터를 받아 비즈니스 로직을 처리한 후 클라이언트에게 응답을 내려줍니다.

백엔드 서버에서 해야 할 역할은 크게 4가지로 구분해볼 수 있습니다.

  1. 로그인 페이지 URL 제공
  2. 소셜사 API를 통해 사용자 데이터 접근
  3. 비즈니스 로직 처리
  4. 완료 후 리다이렉트

그러나 현재 라이켓에서는 Naver, Kakao, Apple 세 가지 소셜 로그인 방식을 제공하고 있습니다. 위 4가지 처리 로직이 각각의 소셜사마다 미묘하게 다릅니다. 이를 해결하려면 매번 새로운 메서드를 만들어야 할까요?

❓ 메서드를 새롭게 만들어주는 것은...

메서드를 새롭게 정의하는 것은 상당히 간단한 방법입니다.

매번 새로운 컨트롤러 메서드와 서비스 메서드를 구현하면 됩니다. 위에서 보이는 메서드가 끝이 아닙니다.

콜백 요청을 받아줄 컨트롤러 메서드와 비즈니스 로직을 처리해줄 서비스 메서드도 있어야 합니다.

컨트롤러에는 총 6개의 메서드가 추가되었고 서비스에는 총 3개의 메서드가 추가되었습니다. 그러나 아직 Naver, Kakao, Apple 각각의 외부 API를 처리하는 부분은 나오지도 않았습니다.

하나의 클래스가 너무 많은 책임을 가지게 됩니다. 외부 API를 작성하는 부분은 복잡할 수밖에 없고, 코드는 더 알아보기 힘들게 됩니다.

이를 어떻게 해결 할 수 있을까요?


💡 전략패턴 도입하기

LIKET 팀에서는 이를 전략패턴을 사용하는 것으로 해결하였습니다.

전략 패턴이란 무엇일까요? 클래스 인스턴스가 할 수 있는 공통적인 일을 정의하고 각 인스턴스를 전략으로 삼아 코드에 등록하는 방법을 의미합니다.

어떻게 구현할 수 있을까요? 앞서 소셜 로그인에서 백엔드가 처리하는 4가지 일에 대해 알아봤습니다.

  1. 로그인 페이지 URL 제공
  2. 소셜사 API를 통해 사용자 데이터 접근
  3. 비즈니스 로직 처리
  4. 완료 후 리다이렉트

위 4가지는 각각의 Naver, Kako, Apple 전략에 필수적으로 포함되어야 하는 기능입니다.

따라서 인터페이스를 통해 이를 정의하고 각 전략을 구현할 수 있습니다.

export interface ISocialLoginStrategy {
  /**
   * 리다이렉트 경로 가져오기
   */
  getRedirectURL: () => string;

  /**
   * 소셜 사용자 정보 가져오기
   */
  getSocialLoginUser: (req: Request) => Promise<SocialLoginUser>;

  /**
   * 로그인
   */
  login: (socialLoginUser: SocialLoginUser) => Promise<LoginToken>;

  /**
   * 회원가입 경로 가져오기
   */
  getSignUpRedirectUrl: () => string;
}

전략을 정의해보았습니다.

그다음 단계는 무엇일까요? 전략을 구현하고 전략을 등록하는 일입니다. 물론 사용도 해봐야 합니다.

💡 전략 등록하기

객체지향에서 인터페이스를 만든다는 것은 구현과 사용을 분리할 수 있다는 것입니다. 소셜 로그인 흐름을 이해하기 쉽도록 구현은 미루고 먼저 등록한 후 사용해보겠습니다.

EnumRecord를 통해 전략 등록을 쉽게 구현할 수 있습니다.

export enum SocialProvider {
  KAKAO = 'kakao',
}

그 후 AuthService 생성자에서 이를 등록할 수 있습니다.

private readonly socialLoginStrategyMap: Record<
  SocialProvider,
  ISocialLoginStrategy
>;

constructor(
  private readonly kakaoLoginStrategy: KakaoLoginStrategy,
) {
  this.socialLoginStrategyMap = {
    [SocialProvider.KAKAO]: kakaoLoginStrategy,
  };
}

Record를 왜 사용하는 걸까요? Record를 적용하면 Enum에 값이 추가되었을 때 전략을 추가하지 않으면 에러가 발생합니다. 객체를 좀 더 엄격하게 정의하는 방법이죠.


💡 전략 사용하기

전략을 구현하기 전에 앞서 등록한 전략을 어떻게 사용하는지 알아보겠습니다.

먼저 소셜사가 제공하는 로그인 페이지로 Redirect 시켜주는 컨트롤러 메서드를 구현해야 합니다.

/**
* 소셜 로그인 시도
*/
@Get('/:provider')
public async socialLogin(
  @Param('provider', SocialProviderPipe) provider: SocialProvider,
  @Res() res: Response,
) {
    const url = this.authService.getSocialLoginUrl(provider);
    res.redirect(url);
}

반드시 Enum에 등록된 Provider만을 입력받을 수 있도록 SocialProviderPipe를 통해 등록된 전략만을 꺼낼 수 있도록 보장합니다.

그 후 전략을 꺼내 사용할 수 있도록 서비스 메서드를 호출합니다. 서비스 메서드는 아주 간단하게 구현할 수 있습니다.

/**
* URL 가져오기
*/
public getSocialLoginUrl(provider: SocialProvider) {
  const strategy = this.socialLoginStrategyMap[provider];

  return strategy.getRedirectURL();
}

프로바이더에 맞는 전략을 꺼내 전략의 getRedirectURL를 호출하여 리턴해주면 됩니다.

다음 단계에서는 callback URI를 통해 전달받은 정보를 바탕으로 비즈니스 로직을 처리해야 합니다.

/**
* 소셜 로그인 콜백 API
*/
@Get('/:provider/callback')
public async socialLoginCallback(
  @Param('provider', SocialProviderPipe) provider: SocialProvider,
  @Req() req: Request,
  @Res() res: Response,
) {
    await this.authService.socialLogin(req, res, provider);
}

컨트롤러는 마찬가지로 프로바이더를 입력받고 서비스로 이를 전달해줍니다.

/**
* 소셜 로그인
*/
public async socialLogin(
  req: Request,
  res: Response,
  provider: SocialProvider,
) {
  // 전략 꺼내오기
  const strategy = this.socialLoginStrategyMap[provider];

  try {
    // 사용자 정보 확인
    const socialLoginUser = await strategy.getSocialLoginUser(req);

    // 첫 로그인 확인
    const isFirstLogin = await this.checkFirstSocialLogin(socialLoginUser);
    if (isFirstLogin) {
      // 소셜 로그인 사용자 회원가입
    }

    // 토큰 발행 후 redirect
    const loginToken = await strategy.login(socialLoginUser);
    res.redirect('/social-login-complete?token=' + loginToken.accessToken);
  } catch (err) {
    res.redirect('/error');
  }
}

마지막으로 비즈니스 로직을 처리한 후 적절한 곳으로 리디렉션합니다.

위 메서드에서는 전략 인터페이스의 getSocialLoginUserlogin메서드를 사용하였습니다.

그림으로 정리해보면 다음과 같습니다.

  • 컨트롤러
    -> 올바른 프로바이더를 입력받아 서비스 메서드 호출
  • 서비스
    -> 컨트롤러로 전달받은 프로바이더를 통해 전략을 꺼내 비즈니스 로직 처리

등록하고 사용은 여기까지 전부입니다. 제일 어려운 부분이였습니다. 구현은 어떻게 할까요?

💡 전략 구현하기

오늘 글의 핵심입니다. 전략을 구현하는 것은 정말 간단한 일입니다. 파라미터를 가지고 카카오, 네이버, 애플 등 소셜사에서 제공하는 외부 API를 호출하여 원하는 값에 도달하기만 하면 됩니다.

@Injectable()
export class KakaoLoginStrategy implements ISocialLoginStrategy {
  getRedirectURL: () => string;

  getSocialLoginUser: (req: Request) => Promise<SocialLoginUser>;

  login: (socialLoginUser: SocialLoginUser) => Promise<LoginToken>;

  getSignUpRedirectUrl: () => string;
}

구현은 너무나도 쉽습니다. 문서에서 나온 방법대로 각 메서드를 채워주기만 하면 됩니다.

애플 전략도, 네이버 전략도 마찬가지로 구현하기만 하면 됩니다.


🧐 전략 패턴이 그래서 뭐가 좋은건가요?

전략 패턴을 구현하고 실제로 API를 통해 호출되도록하기까지 그 어떤 컨트롤러 메서드도, 서비스 메서드도 건드릴 필요가 없습니다.

이는 OCP: 개방 폐쇄 원칙을 완벽하게 만족한 것이죠. 기존 코드는 건드리지 않고 기능이 추가될 때 전략을 그저 구현하기만 하면 됩니다.

애플 로그인과 네이버 로그인도 추가한 그림은 어떨까요?

Map에 등록하기만 한다면 컨트롤러와 서비스 메서드들을 건드리지 않고 기능을 추가할 수 있습니다. 단위 테스트 또한 안전하게 초록불이 들어올 것입니다. 메서드 변경점이 없으니까요!

기존에 비슷한 메서드가 반복되던 코드도 현저히 줄었습니다. 개방 폐쇄 원칙을 만족하여 기능을 유연하게 추가할 수 있게 되었고 반복되는 코드를 줄임으로써 유지보수도 좋아졌습니다.

Passport 라이브러리 또한 전략패턴을 사용하고 있습니다.


⭐️ 결론

앞으로 어떤 소셜 로그인이 추가되더라도 전략하나를 간단하게 구현함으로써 기능을 추가할 수 있게 되었습니다. 기존 그 어떤 메서드도 변경하지 않고 말이죠.

언제나 최선의 고민을 이어나가며 개발하는 LIKET팀의 민경찬이였습니다.

감사합니다.

0개의 댓글