Oauth로그인 리팩토링

프로젝트를 진행 하며 회원 가입 서비스의 일부분인 Oauth 로그인 부분을 총 3 단계에 걸쳐 리팩 토링을 진행하였고 그 과정에서 겪은 고민이나 해결 과정을 정리해보고자 합니다.

문제 정의

로그인 서비스를 진행하며 회원 가입을 Oauth를 통해 하기로 결정했습니다. 최초 Github를 통해 하기로 결정했고,  Github를 통해 회원 정보를 받아 Jwt를 통해 로그인 서비스를 구축했습니다.

문제 발생

팀원들 과의 의논 후 추가적인 회원 가입 플랫폼 확장을 위해 Kakao Oauth 또한 도입 하기로 했습니다. 여기서 문제가 발생했습니다.  확장성을 고려하지 않은 설계에 의해 다른 플랫폼 회원 가입시 API를 하나 더 추가 해야했고  기존의 코드에 중복이 많이 발생하게 되었습니다.

원인

    @Transactional
    public MemberLoginResponse login(String code) {
        AccessTokenResponseDTO token = oauth.getToken(code);
        logger.debug("token access 토큰 = {}", token);
        OAuthMemberInfoDTO memberInfo = oauth.getUserInfo(token.getAccessToken());

        if (MemberExists(memberInfo)) {
            Member member = findMemberByMemberName(memberInfo.getLogin());
            Member updateMember = memberRepository.save(member.update(memberInfo, token.getAccessToken()));
            String jwtToken = jwtService.createToken(updateMember);
            return MemberLoginResponse.of(updateMember, jwtToken);
        }

        Member member = memberRepository.save(Member.create(memberInfo, token.getAccessToken()));
        String jwtToken = jwtService.createToken(member);
        
        return MemberLoginResponse.of(member, jwtToken);
    }
  @Operation(
            summary = "깃허브 로그인",
            tags = "member",
            description = "사용자 깃허브를 통한 로그인"
    )
    @PostMapping("/auth/login")
    public BasicResponse<MemberLoginResponse> login(@RequestParam String code) throws IOException, InterruptedException {
        MemberLoginResponse memberResponseDTO = memberService.login(code);

        return BasicResponse.<MemberLoginResponse>builder()
                .success(true)
                .message("")
                .apiStatus(20000)
                .httpStatus(HttpStatus.OK)
                .data(memberResponseDTO)
                .build();
    }

컨트롤러와 서비스를 위와 같이 구현했습니다. Service 레이어에서 code를 매개변수로 받기 때문에 이 코드는 Oauth를 해주는 플랫폼으로 부터 받는 코드이고 이코드로는 사실 어떤 플랫폼으로 부터 받은 코드인지는 Spring에서 확인 할 수 가 없었습니다. 즉 Kakao 회원 가입 전용 Api를 만든다 하더라도 Service에서는 Kakao에서 온 것인지 Github에서 온 것인지 알 수 가 없습니다.

첫 번째 해결책

처음 든 생각은 단순히 API를 하나 더 만들자 였습니다. Kakao 전용 Api를 만들고 이를 구분 할 수 있는 인터페이스 타입을 만들어 Service 레이에서 타입을 구분하자 라는 생각했습니다.

컨트롤러에 Api를 하나 더 추가

  @Operation(
            summary = "카카오 로그인",
            tags = "members",
            description = "사용자 카카오를 통한 로그인"
    )
    @PostMapping("/auth/kakao/login")
    public BasicResponse<MemberLoginResponse> kakaoLogin(@RequestBody KakaoRequestCode params) {
        log.debug("프론트로 부터 받은 코드 = {}", params);
        MemberLoginResponse memberResponseDTO = memberService.login(params);

        return BasicResponse.send("카카오 로그인", memberResponseDTO);
    }

위와 같은 Api를 하나 더 만들었습니다. 사실상 github와 호출하는 uri와 request dto만 다를 뿐 같은 login 메서드를 사용해 return합니다.

각 플랫폼은 인터페이스를 구현하도록 한다.

@Getter
@NoArgsConstructor
public class KakaoRequestCode implements OAuthLoginParams {
    private String authorizationCode;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }


    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        return body;
    }
}
public interface OAuthLoginParams {
    OAuthProvider oAuthProvider();

    MultiValueMap<String, String> makeBody();
}
//이 인터페이스를 카카오와 깃허브가 각각 구현하도록 합니다.
  public interface Oauth {
    
    OAuthProvider oAuthProvider();

    AccessTokenResponseDTO getToken(OAuthLoginParams params);

    OAuthInfoResponse getUserInfo(String accessToken);
}
 

위와 같이 OAuthLoginParams  인터페이스를 통해  각 플랫폼을 비교하고 서비스에서 각 플랫폼에 맞게 끔 requestOAuthInfoService.request(params, userAgent); 에 맞는 OAuthInfoResponse(카카오나, 깃허브로 부터 받은 유저 정보)를 받도록 합니다.

@Component
public class RequestOAuthInfoService {
    private final Map<OAuthProvider, Oauth> clients;

    public RequestOAuthInfoService(List<Oauth> clients) {
        this.clients = clients.stream().collect(
                Collectors.toUnmodifiableMap(Oauth::oAuthProvider, Function.identity())
        );
    }

    public OAuthInfoResponse request(OAuthLoginParams params) {
        Oauth client = clients.get(params.oAuthProvider());
        AccessTokenResponseDTO token = client.getToken(params);
        return client.getUserInfo(String.valueOf(token));
    }
}

위 서비스 클레스에서 parms를 비교하여 각 oauth에 맞는 Dto를 반환하도록 합니다.

  @Transactional
    public MemberLoginResponse login(OAuthLoginParams params, String userAgent) throws IOException {
        OAuthInfoResponse oAuthInfoResponse = requestOAuthInfoService.request(params, userAgent);

      ````생략
        return MemberLoginResponse.of(member, jwtToken);
    }

이로서 Reuqest에 따라 각 플렛폼을 구분 하여 그에 맞는 dto를 반환 할 수 있게 되었습니다.

두 번째 문제

하지만 다음과 같이 해도 문제가 완전히 해결되지는 않습니다. 사실 플렛폼이 추가된다고 Api가 늘어나는 건 자연스럽지 못한 흐름이라고 생각했습니다.

요구 사항이 변경 될 시 만약 Naver나 Google같은 다른 서비스가 추가 된다고 가정했을 때 지금의 구조에서는 플랫폼이 추가 될 때마다 Api를 추가 해야하는데 이는 테스트 관점에서 봤을 때도 비효율적이고 굳이 반복되는 지루한 코드를 써야 하는 비효율성도 있습니다.  

타입이 다르다 하더라도  login서비스 코드는 작은 부분 빼고는 다 같습니다. 메서드는 하나로 유지 하고 , 또한 불필요한 클래스나 인터페이스를 죽여 역할과 협력에 맞게  리팩토링이 필요한 시점이라 판단했습니다.

정리해보자면

  1. 컨트롤러의 메서드는 플렛폼이 늘어나도 하나로 유지하자.
  2. 코드의 가독성이 떨어지고 클래스들의 역할이 모호한 부분이 많다. 리팩토링이 필요하다.

두 번째 해결책

    @PostMapping("/{provider}/login")
    public BasicResponse<LoginResponse> login(@PathVariable OAuthProvider provider,
                                              @RequestBody @Valid LoginRequest request,
                                              @NotNullParam(message = "code 값은 반드시 들어와야 합니다.") String code) {
        LoginResponse login = authService.login(provider, request, code);
        return BasicResponse.send(HttpStatus.OK.value(), "카카오 로그인", login);
    }

"/{provider}/login"  provider를 @PathVariable로 받는 형식으로 변경합니다.

OAuthProvider는 Enum을 통해 밑에와 같이 구현해 줍니다.


@Getter
@RequiredArgsConstructor
public enum OAuthProvider {

    KAKAO("kakao"),
    GITHUB("github");

    private final String name;
    private OAuthRequester oAuthRequester;

    public static OAuthProvider of(final String name) {
        return Arrays.stream(OAuthProvider.values())
                .filter(provider -> provider.name.equals(name))
                .findFirst()
                .orElseThrow(() -> new NotFoundException(ErrorMessage.OAUTH_PROVIDER_NOT_FOUND));
    }

    private void injectOAuthClient(OAuthRequester oAuthRequester) {
        this.oAuthRequester = oAuthRequester;
    }

    @RequiredArgsConstructor
    @Component
    static class OAuthClientInjector {

        private final GithubRequester githubRequester;
        private final KakaoRequester kakaoRequester;

        @PostConstruct
        public void injectOAuthClient() {
            Arrays.stream(OAuthProvider.values()).forEach(oAuthProvider -> {
                if (oAuthProvider == GITHUB) {
                    oAuthProvider.injectOAuthClient(githubRequester);
                }
                if (oAuthProvider == KAKAO) {
                    oAuthProvider.injectOAuthClient(kakaoRequester);
                }
            });
        }
    }
}

Injection 해주는 내부 클래스를 구현한 후 @PostConstruct를통해(Spring에서 라이프 사이클을 고려한 권장하는 방법)을 통해  oAuthRequster 인터페이스에 컨트롤러에서 들어온 oAuthProvider의 enum값에 맞는 값을 inject해줍니다. 이렇게 된다면 최종 적으로  oAuthProvider의 필드인 oAuthRequester에 플랫폼에 맞는 알맞은 값이 들어오게 됩니다. 그 후

 @Transactional
    public LoginResponse login(OAuthProvider oAuthProvider, LoginRequest request, String code) {
        OAuthRequester oAuthRequester = oAuthProvider.getOAuthRequester();
        OauthTokenResponse tokenResponse = oAuthRequester.getToken(code);
		```생략
    }

oAuthProvider.getOAuthRequester() 를 통해 oAuthRequester를 가져온 후 토큰을 얻어 오면됩니다. 이 때 getToken은  결국 get을 통해 가져온 플랫폼에 그전과 같이 회원 정보를 요청 하게 됩니다.


결과

이를 통해 최종적으로 네이버나 구글 과 같이 다른 플랫폼을 추가 하고 싶을 경우 서비스, 컨트롤러 레이어 코드는 건들지 않고 Enum에 추가 해준 후 OAuthRequester의 구현체만 추가해 주면 됩니다.

이번 리팩토링을 통해 확실히 인터페이스와, Enum을 활용해 코드의 중복을 줄이고 Controller, Service 코드는 변경이 없다는 점이 좋았습니다.

깃허브 출처: https://github.com/masters2023-2nd-project-02/second-hand