wonslee / object-study

📔오브젝트 예제 코드를 따라 공부, 토론하는 스터디 그룹
0 stars 1 forks source link

OCP를 준수한 소셜 회원 설계 (feat. OAuth2, Spring Secuirty) #60

Open kmw2378 opened 4 months ago

kmw2378 commented 4 months ago

최근 프로젝트에서 소셜 로그인 구현을 맡게되어 관련 코드를 공유하고자 합니다! 일단 소셜 로그인의 경우 다음 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());
    }
}

이처럼 스터디에서 배운 이론적인 개념을 실제 개발에 적용을 하니 공부한 내용이 더 와닿는 느낌을 받았습니다.

wonslee commented 4 months ago

너무 잘 읽었습니다 민우님..! 좋은 고민을 하시는게 느껴지고 자극이 되네요. OCP를 준수하는 코드라는 점에서 완전 동의합니다.

저도 ENUM에서 팩토리 메서드를 구현하는 패턴을 많이 사용해왔어서 반갑네요.
mapper과 같이 메서드를 인스턴스 변수로 저장하는 방식은 처음 봐서 신기하기도 합니다 :+1: