최근 프로젝트에서 소셜 로그인 구현을 맡게되어 관련 코드를 공유하고자 합니다. 일단 소셜 로그인의 경우 다음 3가지를 고려했습니다.
과정은 아래와 같습니다.
이 3개 플랫폼 모두 소셜 토큰을 통해 사용자 정보를 가져와 저장하는 과정에 다형성을 적용하고자 하였습니다. 우선 이 3개를 모두 포괄하는 슈퍼 클래스는 아래와 같습니다.
OAuthProfile.java
public abstract class OAuthProfile {
protected final Map<String, Object> attributes;
protected OAuthProfile(final Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getEmail();
public abstract String getGender();
public abstract String getName();
public abstract String getPhoneNumber();
public abstract String getProvider();
public abstract String getProviderId();
}
소셜 토큰을 통해 사용자 정보를 Map<String, Object>
로 파싱하여 저장 후 소셜(서브 클래스)마다 추상 메서드를 구현하여 알맞는 정보를 가져옵니다.
카카오의 경우 아래와 같이 응답을 보냅니다.
HTTP/1.1 200 OK
{
"id":123456789,
"connected_at": "2022-04-11T01:45:28Z",
"kakao_account": {
// 프로필 또는 닉네임 동의항목 필요
"profile_nickname_needs_agreement ": false,
// 프로필 또는 프로필 사진 동의항목 필요
"profile_image_needs_agreement ": false,
"profile": {
// 프로필 또는 닉네임 동의항목 필요
"nickname": "홍길동",
// 프로필 또는 프로필 사진 동의항목 필요
"thumbnail_image_url": "http://yyy.kakao.com/.../img_110x110.jpg",
"profile_image_url": "http://yyy.kakao.com/dn/.../img_640x640.jpg",
"is_default_image":false
},
// 이름 동의항목 필요
"name_needs_agreement":false,
"name":"홍길동",
// 카카오계정(이메일) 동의항목 필요
"email_needs_agreement":false,
"is_email_valid": true,
"is_email_verified": true,
"email": "sample@sample.com",
// 연령대 동의항목 필요
"age_range_needs_agreement":false,
"age_range":"20~29",
// 출생 연도 동의항목 필요
"birthyear_needs_agreement": false,
"birthyear": "2002",
// 생일 동의항목 필요
"birthday_needs_agreement":false,
"birthday":"1130",
"birthday_type":"SOLAR",
// 성별 동의항목 필요
"gender_needs_agreement":false,
"gender":"female",
// 카카오계정(전화번호) 동의항목 필요
"phone_number_needs_agreement": false,
"phone_number": "+82 010-1234-5678",
// CI(연계정보) 동의항목 필요
"ci_needs_agreement": false,
"ci": "${CI}",
"ci_authenticated_at": "2019-03-11T11:25:22Z",
},
"properties":{
"${CUSTOM_PROPERTY_KEY}": "${CUSTOM_PROPERTY_VALUE}",
...
},
"for_partner": {
"uuid": "${UUID}"
}
}
이를 Map<String, Object>
로 관리하고 메서드마다 이를 통해 알맞는 값을 반환하도록 구현했습니다.
KakaoProfile.java
public class KakaoProfile extends OAuthProfile {
public KakaoProfile(final Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getEmail() {
return (String) getProfile().get("email");
}
@Override
public String getGender() {
return (String) getAccount().get("gender");
}
@Override
public String getName() {
return (String) getAccount().get("nickname");
}
@Override
public String getPhoneNumber() {
return (String) getAccount().get("phone_number");
}
@Override
public String getProvider() {
return "KAKAO";
}
@Override
public String getProviderId() {
return (String) attributes.get("id");
}
private Map<String, Object> getAccount() {
return (Map<String, Object>) attributes.get("kakao_account");
}
private Map<String, Object> getProfile() {
return (Map<String, Object>) getAccount().get("profile");
}
}
네이버나 구글도 카카오와 비슷하게 Map<String, Object>
로 응답값을 파싱할 수 있으며 추상 메서드를 구현함으로써 알맞는 값을 반환할 수 있습니다. 우선 프로젝트에서 카카오 로그인만 구현하기로 해서 네이버, 구글 구현체는 만들지 않았습니다. 그러나, 이는 추후 확장성을 고려했을 때 정말 좋은 설계라 생각합니다. 기능 추가시 기존 코드를 수정하지 않으므로 OCP를 준수한다 볼 수 있습니다.
추가로 앞에서 배운 Factory 패턴을 적용하여 SRP도 준수할 수 있습니다.
if 분기문을 사용한 팩토리 패턴
public class OAuthProfileFactory {
public static OAuthProfile of(String registrationId, Map<String, Object> attributes) {
return switch (registrationId.toLowerCase()) {
case "google" -> new GoogleProfile(attributes);
case "naver" -> new NaverProfile(attributes);
case "kakao" -> new KakaoProfile(attributes);
default -> throw new NotFoundException(ErrorCode.NOT_FOUND_SOCIAL_INFO);
};
}
}
else if
, else
, switch
/case
문법을 지양하고 싶어 고민하다 enum, 함수형 인터페이스를 생각했습니다.
enum, 함수형 인터페이스를 사용한 팩토리 패턴
public enum OAuthProfileFactory {
KAKAO("kakao", KakaoProfile::new);
private final String registrationId;
private final Function<Map<String, Object>, OAuthProfile> mapper;
OAuthProfileFactory(final String provider,
final Function<Map<String, Object>, OAuthProfile> mapper) {
this.registrationId = provider;
this.mapper = mapper;
}
public static OAuthProfile of(final Map<String, Object> attributes,
final String registrationId) {
return Arrays.stream(values())
.filter(value -> value.registrationId.equals(registrationId))
.findAny()
.map(value -> value.mapper.apply(attributes))
.orElseThrow(() -> new IllegalArgumentException());
}
}
팩토리 패턴 + 다형성을 활용하니 추후 예상되는 유지보수나 확장에 유연하게 대처할 수 있다고 생각이 들었습니다. 이론으로만 배우던 객체지향 설계 5가지 원칙을 실제 코드로 적용해보니 설명에 사용되는 추상적인 단어들이 어떤 뜻인지 이해가 되는 것 같습니다!