[Spring] OAuth Kakao, Git, Google 로그인 다형성을 활용하여 객체 지향적으로 리팩토링 하기

가오리·2024년 4월 14일
0

BackEnd

목록 보기
10/13
post-thumbnail

김영한님의 자바 강의 중 다형성과 설계 부분을 듣고 옛날에 진행했던 프로젝트의 OAuthService 코드가 떠올랐다.

다형성에 대해 공부하고 싶으면 이 링크를 방문하자

OAuthService 에 현재 kakao google git 에 대한 코드가 작성되어 있는데, 나중에 네이버나 다른 OAuth 서버가 추가될 수 있다. 이러한 상황에서 클라이언트의 코드(OAuthService)는 최소한 수정하고 다른 서버 추가는 인터페이스를 통해 구현하여 쉽게 추가하도록 수정해야 한다. -> open-closed

  1. Provider 에 따라 클라이언트의 코드가 변경되면 안된다.
    -> Provider 인터페이스를 만들고 이를 구현하여 서비스를 추가한다.

  2. OAuthServicekakao google 처럼 구현체는 몰라야 하며 Provider 라는 역할만 알고서도 로직이 돌아가야 한다.
    -> OAuthService 에서 구현체의 의존성을 제거하여 코드를 작성한다.

수정 전의 코드

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService {
    private final UserRepository userRepository;
    private final WebClient webClient;
    private final ObjectMapper mapper;

    private static HttpEntity<MultiValueMap<String, String>> getTokenRequest(String code, Provider provider) {
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8");

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", provider.getGrantType());
        params.add("client_id", provider.getClientId());
        params.add("redirect_uri", provider.getRedirectUri());
        params.add("code", code);
        params.add("client_secret", provider.getClientSecret());

        return new HttpEntity<>(params, headers);
    }

    private OauthToken parseToOAuthToken(Provider provider, String tokenResponse) {
        try {
            if (provider.equals(Provider.GIT)) {
                Map<String, String> map = new HashMap<>();
                String[] pairs = Objects.requireNonNull(tokenResponse).split("&");
                Arrays.stream(pairs)
                        .map(pair -> pair.split("=", 2))
                        .forEach(tokens -> {
                            String key = tokens[0];
                            String value = tokens[1];
                            map.put(key, value);
                        });
                tokenResponse = mapper.writeValueAsString(map);
            }
            return mapper.readValue(tokenResponse, OauthToken.class);
        } catch (JsonProcessingException e) {
            throw new JsonParsingException(e);
        }
    }

    public Mono<OauthToken> getOAuthToken(String code, String providerString) {
        Provider provider = getProvider(providerString);
        return webClient.post()
                .uri(uriBuilder -> uriBuilder.path(provider.getRequestTokenUrl()).build())
                .bodyValue(getTokenRequest(code, provider))
                .retrieve()
                .bodyToMono(String.class)
                .map(tokenResponse -> parseToOAuthToken(provider, tokenResponse));
    }

    public Long saveUser(Mono<OauthToken> token, String providerString) {
        Provider provider = getProvider(providerString);
        HttpHeaders headers = new HttpHeaders();
        headers.add(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + token.subscribe(OauthToken::getAccess_token));
        headers.add(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8");

        Profile profile = getProfile(provider, headers);
        // 프로필 정보로 회원 조회하여 존재하면 반환, 없으면 신규 회원 생성 후 반환
        User user = findUser(profile, provider);

        // 신규 회원이면 저장 후 토큰 생성을 위한 id 반환
        if (user.getId() == null) {
            user = userRepository.save(user);
        }
        return user.getId();
    }

    private Provider getProvider(String provider) {
        try {
            return Provider.valueOf(provider.toUpperCase());
        } catch (IllegalArgumentException e) {
            throw new UnKnownProviderException(e);
        }
    }

    private Profile getProfile(Provider provider, HttpHeaders headers) {
        return switch (provider) {
            case KAKAO -> findProfile(provider.getRequestInfoUrl(), headers, KakaoProfile.class);
            case GOOGLE -> findProfile(provider.getRequestInfoUrl(), headers, GoogleProfile.class);
            default -> findProfile(provider.getRequestInfoUrl(), headers, GitProfile.class);
        };
    }

    private <T> T findProfile(String uri, HttpHeaders headers, Class<T> type) {
        try {
            return mapper.readValue(webClient.post()
                    .uri(uri)
                    .headers(httpHeaders -> httpHeaders.addAll(headers))
                    .retrieve()
                    .bodyToMono(String.class)
                    .block(), type);
        } catch (JsonProcessingException e) {
            throw new JsonParsingException(e);
        }
    }

    private User findUser(Profile profile, Provider provider) {
        return userRepository.findByEmailAndProvider(profile.getEmail(), provider.getValue())
                .orElseGet(() ->
                        User.builder()
                                .profileImgUrl(profile.getPicture())
                                .nickname(profile.getName())
                                .email(profile.getEmail())
                                .provider(provider)
                                .userRole(UserRole.USER)
                                .description("joinBy" + provider)
                                .build());
    }

    public String createJWTToken(Long userId) {
        User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
        return JWT.create()
                .withSubject(user.getEmail())
                .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
                .withClaim("id", user.getId())
                .withClaim("nickname", user.getNickname())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
    }
}

문제가 되는 부분들을 살펴보자.

@Getter
@RequiredArgsConstructor
public enum Provider {
    KAKAO("kakao", "authorization_code",
            "4646a32b25c060e42407ceb8c13ef14a",
            "AWyAH1M24R9EYfUjJ1KCxcsh3DwvK8F7",
            "https:///oauth/callback/kakao",
            "kauth.kakao.com/oauth/token",
            "kapi.kakao.com/v2/user/me"),
    GOOGLE("google", "authorization_code",
            "278703087355-limdvm0almc07ldn934on122iorpfdv5.apps.googleusercontent.com",
            "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",
            "/oauth/callback/google",
            "oauth2.googleapis.com/token",
            "www.googleapis.com/oauth2/v3/userinfo"),
    GIT("git", "authorization_code",
            "Iv1.986aaa4d78140fb7",
            "0c8e730012e8ca8e41a3922358572457f5cc57e4",
            "/oauth/callback/git",
            "github.com/login/oauth/access_token",
            "api.github.com/user");

    private final String value;
    private final String grantType;
    private final String clientId;
    private final String clientSecret;
    private final String redirectUri;
    private final String requestTokenUrl;
    private final String requestInfoUrl;
}

수정 전의 코드도 어느정도 중복 코드를 제거하기 위해 Enum을 사용해서 Provider를 열거하고 이를 가져다 쓰는 방식을 사용했지만 이는 완전한 의존성을 제거할 수 없었다.

if (provider.equals(Provider.GIT)) {
	Map<String, String> map = new HashMap<>();
    String[] pairs = Objects.requireNonNull(tokenResponse).split("&");
    Arrays.stream(pairs)
    		.map(pair -> pair.split("=", 2))
            .forEach(tokens -> {
            	String key = tokens[0];
                String value = tokens[1];
                map.put(key, value);
			});
	tokenResponse = mapper.writeValueAsString(map);
}
return mapper.readValue(tokenResponse, OauthToken.class);

수정 전의 코드를 보면 provider 의 구현체에 따라 로직이 다르다.

public Long saveUser(Mono<OauthToken> token, String providerString) {
        Provider provider = getProvider(providerString);
        HttpHeaders headers = new HttpHeaders();
        headers.add(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + token.subscribe(OauthToken::getAccess_token));
        headers.add(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8");

        Profile profile = getProfile(provider, headers);
        // 프로필 정보로 회원 조회하여 존재하면 반환, 없으면 신규 회원 생성 후 반환
        User user = findUser(profile, provider);

        // 신규 회원이면 저장 후 토큰 생성을 위한 id 반환
        if (user.getId() == null) {
            user = userRepository.save(user);
        }
        return user.getId();
    }

또한, OAuthService 에서 User 를 의존하고 있다. 이는 단일 책임 원칙에 위배되며 OAuthService 의 책임을 OAuth 서버와 통신하여 데이터를 주고 받는 역할까지 하도록 수정할 것이다. 그리고 User 에 대한 것은 UserService 로 옮긴다.


    private Profile getProfile(Provider provider, HttpHeaders headers) {
        return switch (provider) {
            case KAKAO -> findProfile(provider.getRequestInfoUrl(), headers, KakaoProfile.class);
            case GOOGLE -> findProfile(provider.getRequestInfoUrl(), headers, GoogleProfile.class);
            default -> findProfile(provider.getRequestInfoUrl(), headers, GitProfile.class);
        };
    }

    private <T> T findProfile(String uri, HttpHeaders headers, Class<T> type) {
        try {
            return mapper.readValue(webClient.post()
                    .uri(uri)
                    .headers(httpHeaders -> httpHeaders.addAll(headers))
                    .retrieve()
                    .bodyToMono(String.class)
                    .block(), type);
        } catch (JsonProcessingException e) {
            throw new JsonParsingException(e);
        }
    }

이 코드를 보면 나름 중복 코드를 없애보겠다고 제네릭을 사용해서 각 OAuth 서버에게서 받아오는 유저 정보를 각 provider 에 맞는 Profile 에 넣어서 값을 가져오고 있다. 하지만 이 코드 또한 OAuthService 안에 있는 코드이며 이는 추후에 새로운 OAuth 서버가 추가되면 수정해야할 클라이언트 코드이다.

위의 문제들을 수정해보자.

수정 후 코드

Provider 인터페이스 정의

public interface Provider {

    String getRequestTokenUrl();

    String getGrantType();

    String getClientId();

    String getRedirectUri();

    String getClientSecret();

    String getTokenResponse(String tokenResponse) throws JsonProcessingException;

    String getValue();

    String getRequestInfoUrl();

    Class<? extends Profile> getProfileClass();
}

우선 Provider 역할을 할 인터페이스를 정의하였다. 그리고 각각의 구현체를 정의하였다.

KaKao

@Data
public class Kakao implements Provider {
    private final String value = "kakao";
    private final String grantType = "authorization_code";
    private final String clientId = "4646a32b25c060e42407ceb8c13ef14a";
    private final String clientSecret = "AWyAH1M24R9EYfUjJ1KCxcsh3DwvK8F7";
    private final String redirectUri = "https://172.16.210.80:80/oauth/callback/kakao";
    private final String requestTokenUrl = "kauth.kakao.com/oauth/token";
    private final String requestInfoUrl = "kapi.kakao.com/v2/user/me";

    @Override
    public String getTokenResponse(String tokenResponse) {
        return tokenResponse;
    }

    @Override
    public Class<? extends Profile> getProfileClass() {
        return KakaoProfile.class;
    }
}

Google

@Data
public class Git implements Provider {

    private final String value = "git";
    private final String grantType = "authorization_code";
    private final String clientId = "Iv1.986aaa4d78140fb7";
    private final String clientSecret = "0c8e730012e8ca8e41a3922358572457f5cc57e4";
    private final String redirectUri = "172.16.210.80:80/oauth/callback/git";
    private final String requestTokenUrl = "github.com/login/oauth/access_token";
    private final String requestInfoUrl = "api.github.com/user";

    @Override
    public String getTokenResponse(String tokenResponse) throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper();
        Map<String, String> map = new HashMap<>();
        String[] pairs = Objects.requireNonNull(tokenResponse).split("&");
        Arrays.stream(pairs)
                .map(pair -> pair.split("=", 2))
                .forEach(tokens -> {
                    String key = tokens[0];
                    String value = tokens[1];
                    map.put(key, value);
                });
        return mapper.writeValueAsString(map);
    }

    @Override
    public Class<? extends Profile> getProfileClass() {
        return GitProfile.class;
    }
}

위의 두 Provider 를 구현한 구현체를 보면 다른 부분이 있다. 바로 getTokenResponse 부분이다.

수정 전 OAuthService 코드

private OauthToken parseToOAuthToken(Provider provider, String tokenResponse) {
	try {
    	if (provider.equals(Provider.GIT)) {
        	Map<String, String> map = new HashMap<>();
            String[] pairs = Objects.requireNonNull(tokenResponse).split("&");
            Arrays.stream(pairs)
            		.map(pair -> pair.split("=", 2))
                    .forEach(tokens -> {
                    	String key = tokens[0];
                        String value = tokens[1];
                        map.put(key, value);
				});
			tokenResponse = mapper.writeValueAsString(map);
		}
        return mapper.readValue(tokenResponse, OauthToken.class);
	} catch (JsonProcessingException e) {
    	throw new JsonParsingException(e);
	}
}

이 수정 전의 코드를 보면 Provider.GIT 에 따라 수신한 tokenResponse 를 다르게 수정해야 한다. 위의 서비스 코드에서 구현체에 대한 의존성을 없애보자.

수정 후 코드

private OauthToken parseToOAuthToken(Provider provider, String tokenResponse) {
	try {
    	return mapper.readValue(provider.getTokenResponse(tokenResponse), OauthToken.class);
	} catch (JsonProcessingException e) {
    	throw new JsonParsingException(e);
	}
}

코드가 매우 짧아졌다. 모든 Provider 에 대해 OAuthService 는 똑같이 행동한다. 즉, 구현체에 대한 의존성이 없어졌다. 이를 가능하게 하기 위해 각 구현체 안에 getTokenResponse 메서드를 오버라이딩 해서 각 구현체에 맞게 수정하여 반환한다.

구현체 찾는 로직

수정 전

private Provider getProvider(String provider) {
	try {
    	return Provider.valueOf(provider.toUpperCase());
	} catch (IllegalArgumentException e) {
    	throw new UnKnownProviderException(e);
	}
}

수정 전의 코드는 ProviderEnum으로 정의했었기 때문에 String 으로 받은 provider 예) "kakao" 를 전체 대문자로 바꿔서 Enum 에서 찾아서 사용했다.

수정 후

Provider provider = ProviderList.findProvider(providerString);

ProviderList

public abstract class ProviderList {

    private static final Map<String, Provider> providers = new HashMap<>();

    static {
        // Provider 등록
        registerProvider("kakao", new Kakao());
        registerProvider("google", new Google());
        registerProvider("git", new Git());
    }

    private static void registerProvider(String name, Provider provider) {
        providers.put(name, provider);
    }

    public static Provider findProvider(String provider) {
        if (providers.containsKey(provider)) {
            return providers.get(provider);
        } else {
            throw new UnKnownProviderException();
        }
    }
}
  • ProviderList 를 추상 클래스로 정의하여 외부에서는 생성하지 못하게 막는다.

  • ProviderList 클래스가 로드될 때 정적 초기화 블록을 사용하여 각 Provider 인스턴스를 등록한다.

  • regitserProvider 메소드는 처음 클래스가 로드될때만 실행되고 추후에는 Provider 맵을 수정할 수 없게 private 으로 설정한다.

  • findProvider 메서드를 static 메서드로 만들어 외부에서 바로 사용할 수 있도록 하였다.

나중에 구현체가 추가된다면 구현체를 정의하고 regitserProvider("new", new New()); 로 추가해주기만 하면 된다.

User 의존성 제거

수정 전

private User findUser(Profile profile, Provider provider) {
	return userRepository.findByEmailAndProvider(profile.getEmail(), provider.getValue())
                .orElseGet(() ->
                        User.builder()
                                .profileImgUrl(profile.getPicture())
                                .nickname(profile.getName())
                                .email(profile.getEmail())
                                .provider(provider)
                                .userRole(UserRole.USER)
                                .description("joinBy" + provider)
                                .build());
    }

public String createJWTToken(Long userId) {
	User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
    return JWT.create()
                .withSubject(user.getEmail())
                .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
                .withClaim("id", user.getId())
                .withClaim("nickname", user.getNickname())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
}

수정 전의 코드를 보면 OAuthServiceUser 에 의존하고 있다. 위의 코드는 OAuth 서버에게서 받아온 유저 프로필을 토대로 UserRepository에서 유저를 찾고 없으면 저장한 뒤 유저의 정보를 JWTToken으로 변환하여 반환한다.

OAuthServiceOAuth 서버와 데이터를 주고 받는 역할만 하도록 위의 로직을 전부 UserService 로 옮겼다.

각 provider에 맞는 Profile로 조회

수정 전

private Profile getProfile(Provider provider, HttpHeaders headers) {
	return switch (provider) {
    	case KAKAO -> findProfile(provider.getRequestInfoUrl(), headers, KakaoProfile.class);
        case GOOGLE -> findProfile(provider.getRequestInfoUrl(), headers, GoogleProfile.class);
        default -> findProfile(provider.getRequestInfoUrl(), headers, GitProfile.class);
	};
}

private <T> T findProfile(String uri, HttpHeaders headers, Class<T> type) {
	try {
    	return mapper.readValue(webClient.post()
                    .uri(uri)
                    .headers(httpHeaders -> httpHeaders.addAll(headers))
                    .retrieve()
                    .bodyToMono(String.class)
                    .block(), type);
	} catch (JsonProcessingException e) {
    	throw new JsonParsingException(e);
	}
}

위의 수정 전 코드를 보면 Enum 안에 있는 구현체들을 의존하고 있으며, 또한 각 Provider 에 맞는 Profile 들 또한 의존하고 있다. OAuth 서버에게서 유저 정보를 받아오는 로직은 제네릭을 사용하여서 중복을 최대한 제거했지만 아직 의존성은 제거하지 못했다.

수정 후

public Mono<Profile> getProfile(Mono<OauthToken> tokenMono, String providerString) {
	Provider provider = ProviderList.findProvider(providerString);
	return tokenMono.flatMap(token -> {
        HttpHeaders headers = new HttpHeaders();
        headers.add(HEADER_STRING, TOKEN_PREFIX + token.getAccess_token());
        headers.add(CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8");

	return webClient.post()
    		.uri(provider.getRequestInfoUrl())
            .headers(httpHeaders -> httpHeaders.addAll(headers))
            .retrieve()
            .bodyToMono(provider.getProfileClass())
            .onErrorMap(JsonProcessingException.class, JsonParsingException::new);
	});
}

Mono를 도입하면서 알아보기 힘들게 되었지만, 이 코드에서 중요한 부분은 provider.getProfileClass() 부분이다.

public interface Provider {
    Class<? extends Profile> getProfileClass();
}

Provider 인터페이스를 정의할 때, 각 Provider 에 맞는 Profile.class 를 반환하는 코드를 작성했다.

@Data
public class Kakao implements Provider {
    @Override
    public Class<? extends Profile> getProfileClass() {
        return KakaoProfile.class;
    }
}

Kakao 구현체에서는 KakaoProfile.class 를 반환한다.

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoProfile implements Profile {
    private Long id;
    private String connectedAt;
    private Properties properties;
    private KakaoAccount kakaoAccount;

    @Override
    public String getEmail() {
        return kakaoAccount.getEmail();
    }

    @Override
    public String getName() {
        return kakaoAccount.profile.nickname;
    }

    @Override
    public String getPicture() {
        return kakaoAccount.profile.profile_image_url;
    }

    @Data
    public static class Properties {
        public String nickname;
        public String profile_image; // 이미지 경로 필드1
        public String thumbnail_image;
    }

    @Data
    public static class KakaoAccount {
        public Boolean profile_nickname_needs_agreement;
        public Boolean profile_image_needs_agreement;
        public Profile profile;
        public Boolean has_email;
        public Boolean email_needs_agreement;
        public Boolean is_email_valid;
        public Boolean is_email_verified;
        public String email;

        @Data
        public static class Profile {
            public String nickname;
            public String thumbnail_image_url;
            public String profile_image_url; // 이미지 경로 필드2
            public Boolean is_default_image;
        }
    }
}

위의 Kakao 구현체에서 KakapProfile 을 의존하는 문제가 있긴하다. 하지만 둘은 밀접한 관계를 가지므로 이 정도의 의존성은 문제가 없을 것이다. (kakao 에서 GoogleProfile 을 사용하도록 수정될 일은 없지 않나?)

또한 Profile 도 인터페이스로 정의하여 새로운 OAuth 서버가 추가되어도 인터페이스를 구현하여 사용하면 되므로 OAuthService 에서는 Profile 에 대한 구현체를 몰라도 된다.

수정 후 2

public abstract class ProfileList {

    private static final Map<String, Profile> profiles = new HashMap<>();

    static {
        // Provider 등록
        registerProfile("kakao", new KakaoProfile());
        registerProfile("google", new GoogleProfile());
        registerProfile("git", new GitProfile());
    }

    private static void registerProfile(String name, Profile profile) {
        profiles.put(name, profile);
    }

    public static Profile findProfile(String profile) {
        if (profiles.containsKey(profile)) {
            return profiles.get(profile);
        } else {
            throw new UnKnownProviderException();
        }
    }
}
  • Profile 찾는 것도 위에서 사용한 ProviderList 와 같은 구조로 변경하였다.

  • 이제는 KakaoKakaoProfile 에 의존하지 않는다.

수정 후 전체 코드

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService {
    private final WebClient webClient;
    private final ObjectMapper mapper;

    public Mono<OauthToken> getOAuthToken(String code, String providerString) {
        Provider provider = ProviderList.findProvider(providerString);
        return webClient.post()
                .uri(uriBuilder -> uriBuilder.path(provider.getRequestTokenUrl()).build())
                .bodyValue(getTokenRequest(code, provider))
                .retrieve()
                .bodyToMono(String.class)
                .map(tokenResponse -> parseToOAuthToken(provider, tokenResponse));
    }

    public Mono<Profile> getProfile(Mono<OauthToken> tokenMono, String providerString) {
        Provider provider = ProviderList.findProvider(providerString);
        return tokenMono.flatMap(token ->
                webClient.post()
                        .uri(provider.getRequestInfoUrl())
                        .headers(httpHeaders -> createHeaders(Optional.of(token.getAccess_token())))
                        .retrieve()
                        .bodyToMono(ProfileList.findProfile(provider.getValue()).getClass())
                        .onErrorMap(JsonProcessingException.class, JsonParsingException::new));
    }

    private HttpEntity<MultiValueMap<String, String>> getTokenRequest(String code, Provider provider) {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", provider.getGrantType());
        params.add("client_id", provider.getClientId());
        params.add("redirect_uri", provider.getRedirectUri());
        params.add("code", code);
        params.add("client_secret", provider.getClientSecret());

        return new HttpEntity<>(params, createHeaders(Optional.empty()));
    }

    private OauthToken parseToOAuthToken(Provider provider, String tokenResponse) {
        try {
            return mapper.readValue(provider.getTokenResponse(tokenResponse), OauthToken.class);
        } catch (JsonProcessingException e) {
            throw new JsonParsingException(e);
        }
    }

    private HttpHeaders createHeaders(Optional<String> accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(APPLICATION_FORM_URLENCODED);
        accessToken.ifPresent(token -> headers.add(HEADER_STRING, TOKEN_PREFIX + token));
        return headers;
    }
}

최대한 다형성을 활용하여 코드를 수정해보았다. 단일 책임 원칙과 개방-폐쇄 원칙을 지키려고 노력해 보았다. 또한 비동기 통신을 적용하여 성능을 향상시키기도 하였다. 하지만 개선해야할 점이 많이 남았다. 공부를 더 열심히 해서 최대한 코드를 개선해 봐야겠다.

profile
가오리의 개발 이야기

0개의 댓글