티스토리 뷰

내용 구성

  1. 상황과 문제점
  2. 전략 패턴(Strategy Pattern)이란?
  3. 소셜 로그인 인터페이스 정의
  4. Controller 코드 수정
  5. Service 코드 수정
  6. 마무리

1. 상황과 문제점

요약: 확장성이 고려되지 않은 기존 소셜 로그인 코드에, 카카오 로그인 기능을 추가해야 하는 문제!

 

현재 프로젝트는 애플 로그인만 지원한다. 기획할 때부터 그러기로 했고, 그렇게 개발과 1차 배포를 끝냈다.

 

프로젝트를 재정비하면서 카카오 로그인을 추가 지원하기로 결정했다. 내가 카카오 로그인 기능을 맡았으므로, 일단 기존 소셜 로그인 코드를 확인했다.

 

@Controller('auth')
export class AuthController {
    @Post('login')
    login(@Req() request, @Body() createAuthDto: CreateAuthRequestDto) {
        return this.authService.login(request, createAuthDto);
    }
}

 

로그인 API는 깔끔하게 /auth/login 엔드포인트 1개만 가지고, 서비스단의 login 메서드를 호출한다.

 

export class AuthService {
  ...
  
  async login(request, createAuthDto: CreateAuthRequestDto) {
    ...
    const idToken = createAuthDto.idToken;
    const appleId = (await this.decodeIdToken(idToken)).sub;

    let user = await this.usersService.getUserInfoByResourceId(appleId);
    
    ...
  }
  
  async decodeIdToken(idToken) { // 애플의 IdToken 디코딩 메서드 }

  clientSecretGenerator(clientId) { // 애플 로그인 관련 클라이언트 비밀키 생성 함수 }

  async withdrawal(request, deleteAuthDto) {  // 애플 로그인 계정 탈퇴 코드 }
  
}

 

AuthService의 login()과 withdrawal() 메서드를 비롯해 모든 메서드가 애플 로그인에 한정적으로 작성되어 있다.

 

그럼 이 코드에 카카오 로그인 코드를 어떻게 추가할까? 2가지 방법을 생각해볼 수 있다.

 

  • 카카오 로그인 전용 API와 메서드 작성하기
    • Controller에 카카오 로그인용 API 추가
    • Service에 loginKakao(), withdrawalKakao() 작성하기

 

  • 기존 애플 로그인용 코드에 분기문으로 카카오 추가하기
    • Controller의 기존 login API에 경로 파라미터나 쿼리 파라미터로 소셜 종류 전달
    • Service의 기존 login(), withdrawal() 메서드에서 if문으로 애플 / 카카오 로그인 처리하기

 

음... 둘 다 별로다. 둘 다 똑같은 문제를 가지고 있다.

새로운 소셜 로그인을 추가할 때마다 로그인/탈퇴라는 핵심 공통 기능을 구현하는 상세 코드가 길어져서 Controller와 Service가 담당하는 일을 한 눈에 확인하기 어려워진다.

그리고 새로운 소셜 로그인을 지원하려고 할 때마다 Controller와 Service 코드를 수정해야 한다. 새 소셜 로그인을 지원하기 위해 Controller와 Service 코드를 수정해야 하는 건 단일책임원칙(SRP)에 어긋난다고 생각된다. AuthController와 AuthService는 딱 로그인 / 탈퇴 / (토큰 재발급)을 담당하는 것이지 애플용, 카카오용, 네이버용 등 세세한 구현 부분까지 담당하진 않는다.

또 실수로 Controller나 Service의 다른 코드를 건드려서 의도치 않은 문제가 발생할 수도 있을 테다.

 

어떻게 해야 최선의 코드를 작성할 수 있을지 고민했다. 그리고 난 설 연휴 때 디자인 패턴 책을 읽으면서 해결 방법을 알게 됐다.

 

 

 

2. 전략 패턴(Strategy Pattern)이란?

이 글은 전략 패턴을 적용하는 과정을 이야기하기 위한 것이기 때문에 전략 패턴은 간단하게 설명하고 넘어가겠다.

 

사전적으로 정의하면 전략 패턴은 알고리즘군을 정의하고 캡슐화하여 필요에 따라 알고리즘군을 교체할 수 있는 패턴이다.

 

...이렇게 말하면 전략 패턴을 모르는 사람에겐 잘 와닿지 않을 것 같다. 그러나 사실 간단한 아이디어다. 지금처럼 소셜 로그인이라는 기능을 구현한다고 하자. 원래는 애플 로그인만 지원하기로 했지만, 1차 배포를 끝낸 후 카카오 로그인을 추가하기로 했다. 그런데 사실 카카오 로그인뿐만 아니라, 구글 로그인, 네이버 로그인, 페이스북 로그인, 깃허브 로그인 등등 언제든지 다양한 종류의 소셜 로그인을 추가할 수 있다. 이때마다 위에서 언급한 것처럼 if문으로 로그인하려는 소셜 타입을 확인하는 코드를 추가할 순 없다. 너무 비효율적이다.

 

Strategy 패턴 적용 모습

 

이런 문제를 해결하기 위해 나온 것이 전략 패턴이다. 로그인을 처리하는 Service 계층은 소셜 로그인 인터페이스에만 의존한다. 구체적인 애플/카카오/구글 등의 실제 로그인 코드는 소셜 로그인 인터페이스를 구현(implements)하도록 한다. 인터페이스를 구현한 클래스는 당연히 인터페이스의 범주에 속할 수 있다. Service 계층에 인터페이스로 코드를 작성해도, 실제 실행될 때 구현체가 Service의 인터페이스 타입에 할당돼도 잘 동작할 수 있다는 의미이다.

 

 

 

3. 소셜 로그인 인터페이스 정의

// social-login-strategy.interface.ts

import { SocialWithdrawRequestDto } from './dto/social-withdraw-request.dto';
import { SocialLoginRequestDto } from './dto/social-login-request.dto';

export interface SocialLoginStrategy {
  login(socialLoginRequestDto: SocialLoginRequestDto): Promise<{ resourceId: string; email: string }>;
  withdraw(resourceId: string, socialWithdrawRequestDto: SocialWithdrawRequestDto): Promise<void>;
}

 

소셜 로그인과 탈퇴만 있으면 된다!

 

이제 Service 코드에 있던 애플의 IdToken 디코딩 메서드(decodeIdToken)와 클라이언트 비밀키 생성 메서드(clientSecretGenerator)는 모두 애플 로그인 구현체로 이동할 예정이다. 애플 관련 코드는 애플 로그인 구현체에만 작성되고, 로그인/탈퇴 공통 로직만 Service에 작성되는 것이다.

 

// social-login-request.dto.ts
export class SocialLoginRequestDto {
  @IsString()
  idToken: string;

  @IsEmail()
  @MinLength(4)
  @MaxLength(35)
  @IsOptional()
  email?: string;
}

// social-withdraw-request.dto.ts
export class SocialWithdrawRequestDto {
  @IsString()
  idToken?: string;

  @IsString()
  authorizationCode?: string;
}

 

참고로 로그인과 탈퇴 요청 DTO는 이러하다.

 

로그인 요청 시 이메일을 받는 이유는 회원가입 시 메일로 본인 확인을 거쳤기 때문이다. (현재는 속도 이슈로 제거한 기능이고, 팀원이 담당한 부분)

 

애플은 이메일을 필수로 받지 않아도 된다. 만약 사용자가 이메일을 가린 채 가입하더라도, 애플이 해당 회원에게 메일을 보낼 수 있는 기능을 제공하기 때문이다. 그러나 카카오 로그인의 경우엔 사용자가 이메일을 보여주지 않으면 메일을 보낼 수 없으므로, 이메일을 필수 제공하도록 설정했다. 요는, 애플 로그인 시 이메일 필드가 없을 수도 있기 때문에 옵셔널로 처리했다.

 

탈퇴 요청 시 애플은 idToken과 authorizationCode가 필수지만, 카카오는 둘 다 없다. 그래서 옵셔널이다.

 

사실 애플 로그인에선 필수인 필드인데 카카오 로그인에선 필요 없다고 옵셔널로 처리하는 게 다소 마음에 걸리긴 하다. 더 깔끔한 방법이 있을 것 같다.

 

 

 

4. Controller 코드 수정

  • 수정 전 코드
@Post('login')
login(@Req() request, @Body() createAuthDto: CreateAuthRequestDto) {
    return this.authService.login(request, createAuthDto);
}

 

기존 Controller의 login 라우터 코드다. 애플 로그인만 지원했기 때문에 어떤 소셜로 로그인하는지 알 수 있는 헤더나 경로 파라미터가 존재하지 않는다.

 

 

  • 수정 후 코드
@Post('login/:social')
socialLogin(
@Req() request,
@Param('social') social: string,
@Body() socialLoginRequestDto: SocialLoginRequestDto
) {
    const headerMap: Map<string, string> = this.makeHeaderMap(request);
    return this.authService.login(social, headerMap, socialLoginRequestDto);
}

private makeHeaderMap(request): Map<string, string> {
    return Object.keys(request.headers).reduce((m, key) => {
      m.set(key, request.headers[key]);
      return m;
    }, new Map<string, string>());
}

 

어떤 소셜로 로그인하는지를 알려주는 경로 파라미터를 추가했다. 탈퇴도 동일하게 작업했다.

 

추가로, 기존 코드처럼 request 객체를 통째로 넘기는 대신 header 정보만 맵 자료 구조에 담아 Service의 login 메서드에 넘겨주도록 수정했다. login 메서드에서 몇 가지 헤더 정보가 필요하기 때문이다.

 

 

 

5. Service 코드 수정

async login(social: string, headerMap: Map<string, string>, socialLoginRequestDto: SocialLoginRequestDto) {
    ...

    const socialLoginStrategy: SocialLoginStrategy = this.getLoginStrategy(social);

    const { resourceId, email } = await socialLoginStrategy.login(socialLoginRequestDto);
    const findUser = await this.usersService.getUserInfoByResourceId(resourceId);

    ...
    return { accessToken, refreshToken };
}
  
private getLoginStrategy(social: string) {
    const socialLoginStrategy: SocialLoginStrategy = this.socialLoginStrategyMap.get(social);

    if (!socialLoginStrategy) {
      throw new BadRequestException('지원하지 않는 소셜 로그인 플랫폼입니다');
    }

    return socialLoginStrategy;
}

 

login 메서드에서 getLoginStrategy()를 통해 로그인을 수행할 적절한 구현체를 찾는다. 그리곤 해당 구현체의 login() 메서드를 호출해 로그인을 진행한다.

 

 

 

6. 마무리

기존 애플 로그인에 국한됐던 코드에 Strategy 패턴을 적용해 리팩토링해보았다. 덕분에 확장성이 향상되어 카카오 로그인 기능도 깔끔하게 추가할 수 있었다.

 

선배 개발자들의 경험에서 우러나온 디자인 패턴. 사실 디자인 패턴을 적용해야지! 하고 코드를 작성한 건 처음인데, 굉장히 매력적이다. 지혜가 담긴 디자인 패턴을 이제부턴 적극적으로 활용해야겠다.

728x90