Post

서버 구조 리팩토링 하기(인증 모듈)

서버 구조 리팩토링 하기(인증 모듈)

서론

디자인 패턴에 대해서 공부하고 이전에 했던 프로젝트를 다시 보니 리팩토링을 하면 좋을 것 같은 부분이 보이기 시작했다. 그 중에서 이번에는 인증과 관련된 부분에 대해서 리팩토링 해보려고 한다.

소셜 로그인

‘탐식당’ 프로젝트는 소셜 로그인 기능을 제공한다. 그 중에서도 애플과 카카오 플랫폼을 통해서 로그인이 가능하다. 애플 로그인에서 서버에서의 역할은 어플에서 얻어온 ID 토큰을 검증하는 방법이다.

애플 로그인
애플 로그인 로직

한마디로 OIDC 프로토콜을 이용한 인증 방법이다. 이미 애플 로그인에서 OIDC를 사용하기 때문에 카카오 역시 같은 인증 방법을 사용했다.

애플 로그인
카카오 OIDC 활성화

그래서 언뜻보면 카카오와 애플의 로그인 로직이 크게 다르지 않아보이지만 둘의 로그인 로직에는 약간의 차이가 있다. 가장 큰 차이로는 애플 로그인에선 AuthorizationCode를 받아서 회원 탈퇴를 위한 리프레시 토큰을 발급받아 저장하는 절차가 존재한다. 따라서 애플과 카카오의 로그인 서비스를 따로 구현해두었고 각 로그인 마다 다른 구현체를 사용해야하니 자연스레 API 역시도 분리됐다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @Operation(summary = "카카오 소셜 토큰 검증 API",description = "추가정보와 ID토큰을 받으면 ID토큰을 검증하고 통과 시" +
            "서버에서 발급한 토큰을 받습니다. 회원가입을 하지 않은 사용자의 경우 회원가입을 시킵니다.")
    @ResponseBody
    @PostMapping("/oauth2/kakao/token/validate")
    public ApiResponse<LoginResponseDTO.LoginDTO> validateKakoToken(@RequestBody @Valid LoginRequestDTO.KakaoTokenValidateDTO requestDTO)  {
        kakaoLoginService.validate(requestDTO.getIdentityToken());
        ...
    }

    @Operation(summary = "애플 소셜 토큰 검증 API",description = "추가정보와 ID토큰을 받으면 ID토큰을 검증하고 통과 시" +
            "액세스/리프레시 토큰을 얻어서 저장시키고. 응답으로 서버에서 발급한 토큰을 받습니다. 회원가입을 하지 않은 사용자의 경우 회원가입을 시킵니다.")
    @ResponseBody
    @PostMapping("/oauth2/apple/token/validate")
    public ApiResponse<LoginResponseDTO.LoginDTO> validateAppleToken(@RequestBody @Valid LoginRequestDTO.AppleTokenValidateDTO requestDTO)  {
      appleLoginService.validate(requestDTO.getIdentityToken());
      ...
    }

클래스 다이어그램 상으로 간단하게 살펴보면 다음과 같다.

애플 로그인
개선전 구조

인터페이스를 구현했지만 클라이언트인 Controller에서 구체적인 클래스에 의존하고 있기 때문에 다형성의 이점을 누리지 못한다. 지금 당장은 2개의 서비스에 의존하고 있지만 만약 소셜 로그인을 지원하는 플랫폼을 확장할수록 의존성이 계속해서 늘어나고 API의 개수도 늘어난다. 이를 해결하기 위해다른 구현체를 바꾸어 사용하는 전략 패턴을 적용시키기로 했다.

전략 패턴 적용

전략 패턴이란 특정 작업을 다양한 방법으로 수행할 수 있을 때, 각 수행 방법을 참조하는 Context를 따로 두어 클라이언트가 전략을 선택하여 동적으로 역할을 수행할 수 있도록 하는 패턴이다. 기본적인 방법에서는 Context가 멤버로 하나의 인터페이스를 가지고 Setter를 공개하는 방식으로 구현한다.

하지만 멀티 스레딩 환경에서 Setter를 이용해서 구현체를 바꾸는 것은 위험하다(non-thread-safe). 따라서 멤버로 하나의 인터페이스만 가지고 참조를 하는 것이 아니라 List를 통해서 여러개의 구현체를 동시에 참조하고 호출 시에 전략을 직접 명시하는 방법으로 안정적으로 명령을 수행할 수 있다. 여기서 더 조금 더 나은 방법으로 Map을 사용한다면 전략을 탐색하는 시간을 O(1)로 줄일 수 있다.

애플 로그인
개선 구조

인터페이스 통일하기

로그인 처리 로직 입력에서 공통적으로 받아야하는 것은 ID 토큰이고 로그인 처리 메서드도 아래 처럼 정의되어 있었다.

1
  public String authenticateAndProcessUser(String accessToken);

하지만 앞에서 말했듯 애플 로그인은 토큰 발급을 위해서 로그인 처리로직에서 AuthorizationCode도 같이 받아야 한다. 그렇다고 파라미터에 다른 로그인 로직에서는 사용하지 않는 String 필드를 하나 더 추가하는 것은 적절하지 않은 것 같다. 이를 해결하기 위해서 크게 3가지 방법이 있다.

1️⃣ 추가적인 파라미터로 받기

1
  public String authenticateAndProcessUser(String accessToken, Map<String, String> params);

장점: Map 자료구조를 이용해서 추가적인 파라미터를 받는 방법이다. 파라미터가 계속 추가되는 것을 반영할 수 있어서 확장성은 좋다.
단점: 인터페이스만을 보고 어떤 파라미터들이 넘어가는지 파악할 수가 없다.

2️⃣ DTO로 받기

1
2
3
4
5
6
  public class AuthDTO{
    @NotNull(message = "검증할 토큰이 필요합니다.")
      String idToken;
      String authCode;
  }
  public String authenticateAndProcessUser(AuthDTO dto);

장점: Map을 사용하는 것에 비해서 어떤 것들이 파라미터로 넘어가는지를 알 수 있고 각 속성에 맞는 세부적인 유효성을 검증하기에도 더 적합하다.
단점: 객체를 만들고 읽어오는 작업이 추가된다.

3️⃣ default 메서드를 이용하기

1
2
3
4
5
6
  public String authenticateAndProcessUser(String accessToken);

  default String authenticateAndProcessUser(String accessToken, String authenticationCode){
    // authenticationCode를 이용한 동작
    return authenticateAndProcessUser(accessToken);
  }

장점: 원래 사용하던 메서드의 파라미터를 바꿀 필요가 없어서 원형을 유지할 수 있다.
단점: 확장성이 떨어지고 추가적인 파라미터가 원래의 동작에 영향을 미치지 않을 때만 사용가능하다.

애플 로그인 이외에는 특별히 추가적인 로직이 필요하지 않을 것이라고 생각하여 처음에는 3번을 이용하려고 했다. 만약 메서드의 호출이 서로 영향을 미치지 않고 순차적으로 A(a) -> B(b)로 동작하면 문제가 없겠지만 authenticationCode을 통해 받아온 토큰을 같이 넘겨주어야 하기 때문에 A(a) -> B(b, ‘A 리턴값’)이 되기 때문에 추가적인 로직을 default 메서드를 통해서 분리할 수는 없었다.

따라서 나는 2번 DTO를 활용한 방법을 사용하기로 했다.

AuthContext

전략을 참조하는 AuthContext이다. 각 소셜 플랫폼에 따라 각기 다른 서비스를 호출해야하기 때문에 Map자료구조의 key로 social 플랫폼의 이름과 각 서비스를 매칭시켰다.

처음에는 ‘직접 빈에 등록된 객체들을 가져와서 Map을 초기화 시켜야하나?’ 했는데 스프링은 Map에 의존성을 주입시켜야 하는 경우 Map<String, T> 형태에서 bean name을 key로, T타입을 구현한 bean을 value로 자동으로 주입이 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
@RequiredArgsConstructor
public class AuthContext {
    private final Map<String, LoginService> strategyMap;

    public String executeLogin(String key, AuthDTO dto){
        return strategyMap.get(key).authenticateAndProcessUser(dto);
    }

    public TokenResponse executeGetAccessToken(String key, String code){
        return strategyMap.get(key).getAccessTokenByCode(code);
    }
}

Contoller에서 PathVariable을 통해 각 플랫폼 이름을 입력받기 때문에 key가 될 서비스 객체가 등록될 bean 이름도 이에 맞게 변경시켰다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 애플 로그인 서비스
@Service(value = "apple")
@RequiredArgsConstructor
@Slf4j
public class AppleLoginService implements LoginService{
  ...
}

// 카카오 로그인 서비스
@Service(value = "kakao")
@Slf4j
@RequiredArgsConstructor
public class KakaoLoginService implements LoginService {
  ...
}

결과

결론적으로 각 플랫폼 별로 구체적인 서비스 객체에 의존했던 것을 수행 전략을 관리하는 Context 객체에만 의존하는 구조로 바꿀 수 있었고 이에 따라 자연스레 플랫폼 별로 흩어져있던 API도 통일시킬 수 잇었다.

1
2
3
4
5
6
7
8
9
10
11
public class AuthController {
    private final AuthContext authContext;

    ...
    @ResponseBody
    @PostMapping("/oauth2/{provider}/token/validate")
    public ApiResponse<LoginResponseDTO.LoginDTO> validateAppleToken(@PathVariable String provider, @RequestBody @Valid AuthDTO dto){
        String accessToken = authContext.executeLogin(provider, dto);
        return ApiResponse.onSuccess(LoginConverter.toLoginDTO(accessToken));
    }
}

여담

리팩토링을 진행하다 보니 로그인 시의 처리 때문에 로그인 서비스에와 유저 서비스에 순환 의존 관계가 생기는 상황도 발생하였다.

애플 로그인
순환 의존 관계

인증 로직의 흐름 자체가 인증 -> 유저 로 넘어가고
회원 탈퇴의 흐름은 유저 -> 인증 으로 넘어가기 때문에 발생한다고 볼 수 있을 것 같다.
따라서 둘 간의 역할을 더 명확하게 구분하고 중간에 스프링 이벤트를 통해서 문제를 해결하기로 했는데 이 문제를 해결하는 것은 다음 글에서 이어서 봐보자

This post is licensed under CC BY 4.0 by the author.