용어 설명
- Provider : Google, Github, Naver, Kakao... 등 OAuth2 인증을 제공해주는 기업을 말합니다.
- Attribute : Provider가 제공하는 정보 데이터 모음입니다. Map 형태로 제공됩니다. 이는 Provider마다 구성이 다르기 때문에, 반드시 전처리를 해서 사용해야 합니다.
리팩토리 전 클래스 구성
/**
* 각각 다른 곳에서 로그인을 요청한 유저의 attributes 정보를 평준화 하는 클래스
*/
@Deprecated
@SuppressWarnings({"unchecked"})
public class OAuthAttributesOld {
private Map<String, Object> attributes;
private String authId;
private String userName;
private String userEmail;
private String userPicture;
private OAuthAttributesOld() {
}
@Builder
private OAuthAttributesOld(Map<String, Object> attributes, String authId, String userName,
String userEmail, String userPicture) {
this.attributes = attributes;
this.authId = authId;
this.userName = userName;
this.userEmail = userEmail;
this.userPicture = userPicture;
}
public static OAuthAttributesOld of(String registrationId, Map<String, Object> attributes) {
switch (registrationId) {
case "naver":
return ofNaver(attributes);
case "kakao":
return ofKakao(attributes);
case "github":
return ofGitHub(attributes);
case "google":
return ofGoogle(attributes);
default:
throw new IllegalArgumentException("지원하지 않는 로그인 방식입니다.");
}
}
private static OAuthAttributesOld ofGoogle(Map<String, Object> attributes) {
return OAuthAttributesOld.builder()
.authId((String) attributes.get("sub"))
.userName((String) attributes.get("name"))
.userEmail((String) attributes.get("email"))
.userPicture((String) attributes.get("picture"))
.attributes(attributes)
.build();
}
private static OAuthAttributesOld ofGitHub(Map<String, Object> attributes) {
return OAuthAttributesOld.builder()
.authId(String.valueOf(attributes.get("id")))
.userName((String) attributes.get("name"))
.userEmail((String) attributes.get("login")) // github의 경우, email이 없을 수도 있기 때문에 "login"을 고유 식별자로 사용한다.
.userPicture((String) attributes.get("avatar_url"))
.attributes(attributes)
.build();
}
private static OAuthAttributesOld ofNaver(Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributesOld.builder()
.authId((String) response.get("id"))
.userName((String) response.get("name"))
.userEmail((String) response.get("email"))
.userPicture((String) response.get("profile_image"))
.attributes(response)
.build();
}
private static OAuthAttributesOld ofKakao(Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) response.get("profile");
return OAuthAttributesOld.builder()
.authId(String.valueOf(attributes.get("id")))
.userName((String) profile.get("nickname"))
.userEmail((String) response.get("email"))
.userPicture((String) profile.get("profile_image_url"))
.attributes(attributes)
.build();
}
// Map으로 바꿔주는 작업을 해야함. 그래야 DefaultOAuth2User를 똑같은 attribute key를 가진 객체로 바꿀 수 있음
// Missing attribute 'sub' in attributes -> "sub"이라는 키의 attribute가 있어야 하는듯 ?
// TODO: 여기에 일급 컬렉션을 적용하는 게 좋은 선택일까?
public Map<String, Object> parsedAttributes() {
Map<String, Object> map = new HashMap<>();
map.put("id", authId);
map.put("sub", userEmail);
map.put("name", userName);
map.put("picture", userPicture);
return map;
}
}
유저가 OAuth2로그인 요청 시, 로그인한 유저의 정보를 받아 Provider별로 객체를 생성해주는 위의 클래스를 하나 생성해서 사용하고 있었습니다.
딱 봐도 너무 난잡합니다. 이를 리팩토링 하기 위해, 클래스의 문제점을 살펴보기로 했습니다.
1. 문자열이 하드코딩 되어있다.
문자열을 하드코딩 할 경우, 의미 파악이 어렵고, 유지보수가 어려워진다는 문제가 발생할 수 있습니다. 그래서 이를 방지하고자 하드코딩 된 문자열을 목적에 따라 나누어 관리하기로 결정했고, 아래와 같이 구성했습니다.
- Map에 사용되는 Key는 단독 값이고, 해당 패키지 내에서만 사용될 예정이기 때문에 AttributeKeys라는 클래스 내에서 default 제어자를 갖는 static 상수로 관리하도록 구성했습니다.
- AttributeKeys라고 구성한 이유는, OAuth2User 객체의 Attribute에서 값을 꺼낼 때, Key 역할도 하기 때문입니다. Map은 OAuth2User 객체의 Attribute를 구성합니다.
- 카카오, 네이버의 Attribute에서 필요한 값에 접근하기 위해서는 attribute에 특정 key로 한두번 더 접근해야 합니다. 해당 Key도 함께 관리하도록 구성했습니다.
- Provider의 AttributeKey값들을 Enum에서 한번에 관리하도록 구성했습니다.
- Provider들은 Attribute 값에 접근하는 key가 각각 다르게 구성되어 있습니다.
- Enum에 registrationId도 구성해줌으로써, Enum을 해당 값으로 구분할 수 있도록 하였습니다.
// 1. Map에 사용되는 공통 Key와 카카오, 네이버에서 사용될 특정 Key를 default static 상수로 관리
public class AttributeKeys {
// Map에서 사용될 공통 키
static final String ID = "id";
static final String SUB = "sub";
static final String NAME = "name";
static final String PICTURE = "picture";
// 카카오, 네이버 등에서 사용되는 특정 키
static final String NAVER_RESPONSE_KEY = "response";
static final String KAKAO_ACCOUNT_KEY = "kakao_account";
static final String KAKAO_PROFILE_KEY = "profile";
}
///////////////////////////////////////////////////////////////////////
// 2. Provider 별 AttributeKey를 Enum으로 관리
@Getter
public enum ProviderInfo {
GOOGLE(
"google",
"sub",
"name",
"email",
"picture"
),
GITHUB(
"github",
"id",
"name",
"login",
"avatar_url"
),
NAVER(
"naver",
"id",
"name",
"email",
"profile_image"
),
KAKAO(
"kakao",
"id",
"nickname",
"email",
"profile_image_url"
);
private final String registrationId;
private final String authId;
private final String email;
private final String name;
private final String picture;
ProviderInfo(String registrationId, String authId,
String email, String name, String picture) {
this.registrationId = registrationId;
this.authId = authId;
this.email = email;
this.name = name;
this.picture = picture;
}
}
2. 한 클래스가 다양한 역할을 수행하고 있다.
위 클래스는 생각보다 다양한 역할을 수행하고 있었습니다.
- 파싱한 정보를 갖는 객체 역할
- registrationId와 attribute 정보를 받아 Provider별로 파싱하여 정보를 갖는 객체 생성
- 해당 객체를 Map 형태로 바꿔서 반환 (반환된 Map은 DefaultOAuth2User 객체를 만들 때 사용합니다.)
- 이렇게 반환함으로써, Map은 누군가가 데이터를 손쉽게 CRUD할 수 있도록 노출이 되어있는 상태.
객체지향적 설계의 기본은 역할과 책임의 구분입니다. 이를 실현하고자 아래와 같이 클래스 구성을 변경하였습니다.
파싱한 정보를 갖는 객체
Provider 별로 유저 정보를 파싱하고, 필요한 정보를 추출하여 저장하는 저장소입니다. 해당 객체에 할당된 값들은 최종적으로 Map을 가지고 있는 일급 컬렉션에 할당 될 것입니다.
@Getter
public class CommonAttributes {
private final String authId;
private final String email;
private final String name;
private final String picture;
@Builder
public CommonAttributes(String authId, String email, String name, String picture) {
this.authId = authId;
this.email = email;
this.name = name;
this.picture = picture;
}
}
Provider 정보와 Attributes 정보를 넘겨받아 CommonAttributes를 생성하는 객체
Provider 정보를 Enum 형태로(ProviderInfo) 관리하게 됨으로써, 생각지 못한 이점을 얻을 수 있었습니다.
원래는 Google과 Github의 Attribute key 값이 달라 따로 로직을 생성해줘야 했지만, 공통적으로 관리함으로써 로직을 한개로 통일할 수 있었습니다.
해당 객체는 Provider 정보를 ProviderInfo라는 Enum 형태로 넘겨받아 각 Provider에 맞는 로직을 수행하고, 결과적으로 CommonAttributes를 반환합니다.
@SuppressWarnings({"unchecked"})
public class CommonAttributesFactory {
// Provider 정보를 받아서 CommonAttributes를 만들어준다.
public static CommonAttributes getCommonAttributes(ProviderInfo providerInfo, Map<String, Object> attributes) {
// provider info 에 따라 각각 다른 commonAttributes 생성
if (providerInfo == GOOGLE || providerInfo == GITHUB) {
return ofDefaultProvider(providerInfo, attributes);
} else if (providerInfo == NAVER) {
return ofNaver(providerInfo, attributes);
} else if (providerInfo == KAKAO) {
return ofKaKao(providerInfo, attributes);
}
throw new IllegalArgumentException("지원하지 않는 로그인 방식입니다.");
}
private static CommonAttributes ofDefaultProvider(ProviderInfo providerInfo, Map<String, Object> attributes) {
return CommonAttributes.builder()
.authId(String.valueOf(attributes.get(providerInfo.getAuthId())))
.email(String.valueOf(attributes.get(providerInfo.getEmail())))
.name(String.valueOf(attributes.get(providerInfo.getName())))
.picture(String.valueOf(attributes.get(providerInfo.getPicture())))
.build();
}
private static CommonAttributes ofNaver(ProviderInfo providerInfo, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get(NAVER_RESPONSE_KEY);
return CommonAttributes.builder()
.authId(String.valueOf(response.get(providerInfo.getAuthId())))
.email(String.valueOf(response.get(providerInfo.getEmail())))
.name(String.valueOf(response.get(providerInfo.getName())))
.picture(String.valueOf(response.get(providerInfo.getPicture())))
.build();
}
private static CommonAttributes ofKaKao(ProviderInfo providerInfo, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get(KAKAO_ACCOUNT_KEY);
Map<String, Object> profile = (Map<String, Object>) response.get(KAKAO_PROFILE_KEY);
return CommonAttributes.builder()
.authId(String.valueOf(attributes.get(providerInfo.getAuthId())))
.email(String.valueOf(profile.get(providerInfo.getEmail())))
.name(String.valueOf(response.get(providerInfo.getName())))
.picture(String.valueOf(profile.get(providerInfo.getPicture())))
.build();
}
}
CommonAttributes 클래스를 Map으로 바꿔서 일급 컬렉션인 OAuth2Attributes로 변경해주고 이를 반환하는 객체
CustomUserDetailService에서 RegistrationId 정보와 Attributes 정보를 받아와서 메서드에 넘겨주는 역할을 합니다.
- registrationId를 getProviderInfo 메서드에 넘겨주고, 해당 메서드는 ProviderInfo에서 해당하는 Provider를 찾습니다.
- getCommonAttributes() 메서드에 위에서 찾은 ProviderInfo와 Attributes를 넘겨주고, 해당 메서드는 CommonAttributes를 반환합니다.
최종적으로 해당 객체는 CommonAttributes를 이용하여 OAuth2Attributes라는 일급 컬렉션 객체를 생성하여 반환합니다.
/**
* resistrationId와 attributes로 CommonAttributes를 만들어준다.
* CommonAttributes를 이용해서 일급 컬렉션인 OAuth2Attributes를 만들어주는 로직이다.
*/
public class OAuth2AttributesFactory {
public static OAuth2Attributes getOAuth2Attributes(String registrationId, Map<String, Object> attributes) {
//resistrationId로 provider를 찾는 게 필요하고
ProviderInfo providerInfo = getProviderInfo(registrationId);
//provider를 찾았으면, Provider마다 알맞게 처리하여 commonAttributes를 만들어주는 게 필요함.
CommonAttributes commonAttributes = CommonAttributesFactory
.getCommonAttributes(providerInfo, attributes);
return new OAuth2Attributes(commonAttributes);
}
// provider를 찾는다.
private static ProviderInfo getProviderInfo(String registrationId) {
return Arrays.stream(values())
.filter(keys -> keys.getRegistrationId().equals(registrationId))
.findAny()
.orElseThrow(() -> new IllegalArgumentException("지원하지 않는 로그인 방식입니다."));
}
}
최종적으로 Attributes 정보를 Map으로 관리하는 일급 컬렉션 역할을 하는 객체
CommonAttributes를 생성자의 인자로 받아야만 생성되는 객체입니다.
일급 컬렉션이므로, Map 형태로 관리되는 attributes에 외부에서 접근할 수 없도록 구성했으며, Map이 필요한 경우 unmodifiableMap을 이용해, 수정 불가능한 Map을 복사해서 반환해줌으로써 일급 컬렉션이 불변 특성을 유지하도록 했습니다.
일급 컬렉션을 사용하기로 한 이유는 Attribute 값은 한번 설정되면, 절대로 변경되어서는 안되기 때문입니다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class OAuth2Attributes {
private final Map<String, Object> attributes = new HashMap<>();
public OAuth2Attributes(CommonAttributes commonAttributes) {
attributes.put(ID, commonAttributes.getAuthId());
attributes.put(SUB, commonAttributes.getEmail());
attributes.put(NAME, commonAttributes.getName());
attributes.put(PICTURE, commonAttributes.getPicture());
}
public Map<String, Object> getAttributes() {
return Collections.unmodifiableMap(attributes);
}
}
한개의 클래스에서, 이래 저래 리팩토링을 하다 보니 총 6개로 클래스가 늘어나 있었습니다.
리팩토링을 하기 전에는 하드 코딩 + 역할/책임 분리 안함의 문제가 심각했다는 뜻이겠죠...
앞으로는 구성할 때 무지성으로 작성하지 않고 설계단을 충분히 더 고민할 필요가 있는 것 같습니다.
저는 저와 다른 의견을 너무 환영합니다.
더 개선할 부분이 있거나, 이건 다른 방향이 좋을 수도 있겠다는 여러분들의 생각을 언제나 기다리고 있습니다.
🙇♂️ 좋은 생각이 있다면 댓글 꼭 부탁드립니다! (글을 봐주실 지 모르겠지만요...)
참고
https://velog.io/@ohzzi/collection%EC%9D%98-%EB%B3%B5%EC%82%AC-new-copyOf-unmodifiable
https://tecoble.techcourse.co.kr/post/2020-05-08-First-Class-Collection/
댓글