코딩은 내일부터

전략패턴을 이용한 외부API 추상화하기 본문

우아한 테크 코스(우테코)/우테코 공부

전략패턴을 이용한 외부API 추상화하기

zl존 비버 2023. 9. 10. 16:40
728x90

동글 프로젝트 특성상 블로그에 글을 발행하고, 글의 정보를 얻어오고,... 등등

이러한 정보를 얻기 위해서는 외부 API를 통해 데이터를 얻어오는데

요청하는 값과 응답하는 값이 비슷해서 공통 메서드를 분리해서 추상화시키면 좋을 거 같아서

팀원에게 의견을 말한 결과 추상화를 시키자고 결론을 내렸다.

 

이번 포스팅은 내가 어떻게 외부 API를 추상화를 진행했는지 글을 써보려고 한다.

 

 

원래 구조







먼저 원래 코드는 API를 처리하는 부분이 앞단에 있고 인증 인가를 담당하는 부분이 뒤에 있었다.

외부 API를 담당하는 부분을 우리 중요 비즈니스가 아닌

외부 시스템으로 보고 인프라스트럭처라고 생각해서

위에 사진에 있는 현재 구조가 아닌 기대하고 있는 구조를 생각하면서 리팩터링을 시작했다.

 

공통 메서드 추출

추상화를 하기 위해서 공통메서드를 추출해야 한다.

OAuth를 활용하여 로그인을 처리하려면 다음과 같은 공통적인 메서드를 뽑을 수 있다.

 

  1. 플랫폼에서 요구하는 Uri를 만드는 메소드
  2. AuthCode를 통해 AccessToken을 발급받는 메소드
  3. 발급받은 AccessToken을 통해 사용자의 정보를 요청하는 메소드
  4. 마지막으로 현재 객체가 어떤 플랫폼인지 알려주는 메소드

 

 

공통 메소드를 추출한 후 LoginClient라는 인터페이스를 만들어주었다.

 

 

 

구현체 만들기

인터페이스를 만들어줬으니 LoginClient의 구현체를 만들어 줘야 하는데

먼저 kakao에 AuthCode와 AccessToken을 처리하는 KakaoLoginTokenClient 클래스,

다음으로 발급받은 토큰으로 사용자의 정보를 요청하는 KakaoLoginUserInfoClient 클래스를 먼저 구현하겠다.

@Component
public class KakaoLoginTokenClient {

    private static final String AUTHORIZE_URL = "https://kauth.kakao.com/oauth/authorize";
    private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token";
    private static final String GRANT_TYPE = "authorization_code";
    private static final String RESPONSE_TYPE = "code";

    private final String kakaoClientId;
    private final String kakaoClientSecret;
    private final WebClient webClient;

    public KakaoLoginTokenClient(
            @Value("${kakao_client_id}") final String kakaoClientId,
            @Value("${kakao_client_secret}") final String kakaoClientSecret
    ) {
        this.kakaoClientId = kakaoClientId;
        this.kakaoClientSecret = kakaoClientSecret;
        this.webClient = WebClient.create();
    }

    public String createRedirectUri(final String redirectUri) {
        return UriComponentsBuilder.fromUriString(AUTHORIZE_URL)
                .queryParam("client_id", kakaoClientId)
                .queryParam("redirect_uri", redirectUri)
                .queryParam("response_type", RESPONSE_TYPE)
                .build()
                .toUriString();
    }

    public String request(final String authCode, final String redirectUri) {
        final BodyInserters.FormInserter<String> bodyForm = BodyInserters.fromFormData("grant_type", GRANT_TYPE)
                .with("client_id", kakaoClientId)
                .with("redirect_uri", redirectUri)
                .with("code", authCode)
                .with("client_secret", kakaoClientSecret);

        final KakaoTokenResponse kakaoTokenResponse = webClient.post()
                .uri(TOKEN_URL)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .acceptCharset(StandardCharsets.UTF_8)
                .body(bodyForm)
                .retrieve()
                .bodyToMono(KakaoTokenResponse.class)
                .block();
        return Objects.requireNonNull(kakaoTokenResponse).access_token();
    }
}
@Component
public class KakaoLoginUserInfoClient {

    private static final String PROFILE_URL = "https://kapi.kakao.com/v2/user/me";
    private final WebClient webClient;

    public KakaoLoginUserInfoClient() {
        this.webClient = WebClient.create();
    }

    public UserInfo request(final String accessToken) {
        return Objects.requireNonNull(webClient.get()
                .uri(PROFILE_URL)
                .header("Authorization", "Bearer " + accessToken)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> VendorApiException.handle4xxException(clientResponse.statusCode().value(), KAKAO.name()))
                .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException(clientResponse.toString())))
                .bodyToMono(KakaoProfileResponse.class)
                .block()).toUserInfo();
    }
}

구현한 과정이 궁금하시다면 여기를 누르시면 됩니다.

(링크에서 구현과정을 KakaoLoginTokenClient, KakaoLoginUserInfoClient를 하나의 클래스로 구현했습니다.)

 

구현을 했으니 이제 LoginClient의 구현체의 맞는 메서드를 작성해 주었다.

@Component
@RequiredArgsConstructor
public class KakaoLoginClient implements LoginClient {

    private final KakaoLoginTokenClient tokenClient;
    private final KakaoLoginUserInfoClient userInfoClient;

    @Override
    public String createRedirectUri(final String redirectUri) {
        return tokenClient.createRedirectUri(redirectUri);
    }

    @Override
    public String requestToken(final String authCode, final String redirectUri) {
        return tokenClient.request(authCode, redirectUri);
    }

    @Override
    public UserInfo findUserInfo(final String accessToken) {
        return userInfoClient.request(accessToken);
    }

    @Override
    public SocialType getSocialType() {
        return SocialType.KAKAO;
    }
}

 

 

 

일급 컬렉션 만들기

위에서는 kakaoOAuth만 구현을 진행했지만,

여러 플랫폼을(naver, github...)을 구현하고 중복코드를 없애고

확장성이라는 이점을 이용하기 위해 LoginClient의 구현체가 여러 개 있을 것이다.

 

이러한 일급컬렉션을 모아두는 일급컬랙션을 만들 것이다.

public class LoginClients {

    private final Map<SocialType, LoginClient> clients;

    public LoginClients(final Set<LoginClient> clients) {
        final EnumMap<SocialType, LoginClient> mapping = new EnumMap<>(SocialType.class);
        clients.forEach(client -> mapping.put(client.getSocialType(), client));
        this.clients = mapping;
    }

    public String redirectUri(final SocialType socialType, final String redirectUri) {
        final LoginClient client = getClient(socialType);
        return client.createRedirectUri(redirectUri);
    }

    public UserInfo findUserInfo(final SocialType socialType, final String code, final String redirectUri) {
        final LoginClient client = getClient(socialType);
        final String accessToken = client.requestToken(code, redirectUri);
        return client.findUserInfo(accessToken);
    }

    private LoginClient getClient(final SocialType socialType) {
        return Optional.ofNullable(clients.get(socialType))
                .orElseThrow(() -> new IllegalArgumentException("해당 OAuth2 제공자는 지원되지 않습니다."));
    }
}

이렇게 일급컬랙션을 만들었고 여기서 중요하게 봐야 할 거는 getClient메서드다.

프론트에서 요청하는 플랫폼의 타입을 getClient를 통해 알맞은 구현체를 return 하도록 설계한 것이다.

 

 

 

 

빈으로 등록하기

이제 거의 다 왔다!!   빈으로 등록하는 방법을 보겠다.

 

처음에는 다음과 같이 new키워드를 사용해서 구현체들을 일급컬랙션에 등록시킬 예정이었다.

@Configuration
public class VendorConfiguration {
 
    @Bean
    public LoginClients loginClients() {
        return new LoginClients(new KakaoLoginClient(), new NaverLoginClient() ....);
    }
}

 

그러면 나중에 로그인 플랫폼을 늘리면 LoginClient의 구현체를 구현하고,

또 loginClients메서드 안에 new 구현체()를 써줘야 한다.

별거 아닐 수 있지만 깜빡하고 loginClients안에 만들어둔 구현체를 등록 안 시켜주면 휴먼에러가 발생할 수 있다.

 

이러한 문제를 해결하기 위해 알아보던 중

스프링에서 다형성(Polymorphism)을 이용한 빈을 주입해 줄 수 있는 방법을 찾았다.

@Configuration
public class VendorConfiguration {
    @Bean
    public LoginClients loginClients(final Set<LoginClient> clients) {
        return new LoginClients(clients);
    }
}

이렇게 컬렉션을 파라미터에 정의해 주고 @Bean어노테이션으로 정의해 주면

loginClient의 구현체들이 LoginClients생성자 안으로 DI가 일어난다.

 

 

정리 및 적용 코드 링크

동글 프로젝트에 로직이 돌아가려면 API요청이 거의 필수적이다.

인터페이스로 추상화시켜 확장성과 용이성 그리고 테스트를 할 때도 테스트 객체를

생성자에 넣어줘서 테스트을 보다 편리하기 진행할 것이다.

 

이렇게 프로젝트에 전략패턴을 이용해서 리팩터링 해보았다!!

https://github.com/woowacourse-teams/2023-dong-gle/pull/371

https://github.com/woowacourse-teams/2023-dong-gle/pull/376