OCP를 준수한 소셜 로그인 설계

김민우·2024년 2월 22일
0

잡동사니

목록 보기
20/22

최근 프로젝트에서 소셜 로그인 구현을 맡게되어 관련 코드를 공유하고자 합니다. 일단 소셜 로그인의 경우 다음 3가지를 고려했습니다.

  • 네이버
  • 카카오
  • 구글

과정은 아래와 같습니다.

  1. 소셜로 부터 인가 코드 받아오기
  2. 인가 코드를 통해 소셜 토큰 받아오기
  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가지 원칙을 실제 코드로 적용해보니 설명에 사용되는 추상적인 단어들이 어떤 뜻인지 이해가 되는 것 같습니다!

0개의 댓글