김영한님의 자바 강의 중 다형성과 설계 부분을 듣고 옛날에 진행했던 프로젝트의 OAuthService
코드가 떠올랐다.
OAuthService
에 현재 kakao
google
git
에 대한 코드가 작성되어 있는데, 나중에 네이버나 다른 OAuth
서버가 추가될 수 있다. 이러한 상황에서 클라이언트의 코드(OAuthService
)는 최소한 수정하고 다른 서버 추가는 인터페이스를 통해 구현하여 쉽게 추가하도록 수정해야 한다. -> open-closed
Provider
에 따라 클라이언트의 코드가 변경되면 안된다.
-> Provider
인터페이스를 만들고 이를 구현하여 서비스를 추가한다.
OAuthService
는 kakao
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
서버가 추가되면 수정해야할 클라이언트 코드이다.
위의 문제들을 수정해보자.
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;
}
}
@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);
}
}
수정 전의 코드는 Provider
를 Enum
으로 정의했었기 때문에 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());
로 추가해주기만 하면 된다.
수정 전
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));
}
수정 전의 코드를 보면 OAuthService
가 User
에 의존하고 있다. 위의 코드는 OAuth
서버에게서 받아온 유저 프로필을 토대로 UserRepository
에서 유저를 찾고 없으면 저장한 뒤 유저의 정보를 JWTToken
으로 변환하여 반환한다.
OAuthService
는 OAuth
서버와 데이터를 주고 받는 역할만 하도록 위의 로직을 전부 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);
}
}
위의 수정 전 코드를 보면 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
와 같은 구조로 변경하였다.
이제는 Kakao
가 KakaoProfile
에 의존하지 않는다.
@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;
}
}
최대한 다형성을 활용하여 코드를 수정해보았다. 단일 책임 원칙과 개방-폐쇄 원칙을 지키려고 노력해 보았다. 또한 비동기 통신을 적용하여 성능을 향상시키기도 하였다. 하지만 개선해야할 점이 많이 남았다. 공부를 더 열심히 해서 최대한 코드를 개선해 봐야겠다.