[게시판 프로젝트] 소셜로그인 기능 구현(2) - 카카오로 로그인 하기

2024. 8. 27. 02:47웹 개발/프로젝트

회원 UserAccount 엔티티 수정

  • 회원 도메인이 인증정보가 없는 상황에서도 회원 정보를 저장할 수 있는 방법을 마련해 줘야 함
    • 소셜로그인 구현하기(1)에서 AuditingFields의 접근제어자를 protected로 열어줌
  • UserAccount 생성자에 String createdBy 추가
    • this.createdBy = createdBy;
    • this.modifiedBy = createdBy;
    • 수정하는 상황이 아니라, 최초로 생성하는 시점에 생성자와 수정자는 같기 때문에 createdBy
    private UserAccount(String userId, String userPassword, String email, String nickname, String memo, String createdBy) {
        this.userId = userId;
        this.userPassword = userPassword;
        this.email = email;
        this.nickname = nickname;
        this.memo = memo;
        this.createdBy = createdBy;
        this.modifiedBy = createdBy; //수정하는 상황이 아니라, 최초로 생성하는 시점에 생성자와 수정자는 같기 때문에 createdBy
    }
  • 모든 엔티티 메소드가 만들어질 때 호출되는 생성자 메서드 코드의 파리미터가 바뀜(String createdBy)가 추가됨
    • 그럼 이 도메인을 쓰는 모든 클래스들의 코드를 변경해 주어야할까? NO!
    • 생성자는 private으로 만들어서 접근이 안되고, 팩토리 메서드를 통해서 생성하게끔 코드를 사용하고 있기 때문에 코드를 변경 해 줄 필요 없음
    public static UserAccount of(String userId, String userPassword, String email, String nickname, String memo) {
        return UserAccount.of(userId, userPassword, email, nickname, memo, null); //null을 넣어줌
    }

	//인증 정보 없는데, 최초의 순간
    public static UserAccount of(String userId, String userPassword, String email, String nickname, String memo, String createdBy) {
        return new UserAccount(userId, userPassword, email, nickname, memo, createdBy);
    }

BoardPrincipal : OAuth 인증 정보를 다룰 수 있도록 기능 추가

public record BoardPrincipal(
        String username,
        String password,
        Collection<? extends GrantedAuthority> authorities,
        String email,
        String nickname,
        String memo
) implements UserDetails {

 

  • 우리는 BoardPrincipal이 UserDetails를 구현하도록 해서, 스프링 시큐리티가 인지할 수 있는 인증 정보를 담는 클래스 라는 사실을 전달할 수 있었음
기본 스프링 시큐리티 인증 정보뿐 아니라, OAuth2 인증 기술도 다룰 수 있게끔 만들어 주어야함
public record BoardPrincipal(
        String username,
        String password,
        Collection<? extends GrantedAuthority> authorities,
        String email,
        String nickname,
        String memo
        Map<String, Object> oAuth2Attribute;
) implements UserDetails, OAuth2User {
생성자 대응 변경
  • 기존의 oAuth2Attribute 없는 생성자 구조를 return of로 하여 변경
  • 새로운 oAuth2Attribute를 추가한 생성자 구조 추가
    public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
        Set<RoleType> roleTypes = Set.of(RoleType.USER);
        return of(username,password,email,nickname,memo,Map.of());
    }

    public static BoardPrincipal of(String username, String password, String email, String nickname, String memo, Map<String, Object> oAuth2Attribute) {
        Set<RoleType> roleTypes = Set.of(RoleType.USER);
        return new BoardPrincipal(
                username,
                password,
                roleTypes.stream()
                        .map(RoleType::getName)
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toUnmodifiableSet()),
                email,
                nickname,
                memo,
                oAuth2Attribute
        );
    }

 

OAuth2User 메서드 재정의
  • OAuth2 인증 서비스마다 인증 결과로 내려주는 회원 정보나 각종 인증 정보들이 RESTful 하게 만들어져 있어서 JSON 형식으로 응답을 받는다고 하더라도 다 그 구조가 다름
  • 따라서 어떤 구조든  key value 방식으로만 내려준다면 받아들일 수 있게끔 큰 범위로 뚫려 있는 것
    //OAuth2 인증 정보
    @Override public Map<String, Object> getAttributes() { return oAuth2Attribute;}
    @Override public String getName() {return username;}

카카오 인증 정보 정의 - KakaoOAuth2Response

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

@SuppressWarnings("unchecked") // TODO: Map -> Object 변환 로직이 있어 제네릭 타입 캐스팅 문제를 무시한다. 더 좋은 방법이 있다면 고려할 수 있음.
public record KakaoOAuth2Response(
        Long id,//회원번호
        LocalDateTime connectedAt,//서비스에 연결 완료된 시각
        Map<String, Object> properties,//사용자 프로퍼티 - JSON
        KakaoAccount kakaoAccount//카카오 계정 정보 - KakaoAccount
) {
    public record KakaoAccount(
            Boolean profileNicknameNeedsAgreement,
            Profile profile,
            Boolean hasEmail,
            Boolean emailNeedsAgreement,
            Boolean isEmailValid,
            Boolean isEmailVerified,
            String email
    ) {
        public record Profile(String nickname) {
            public static Profile from(Map<String, Object> attributes) {
                return new Profile(String.valueOf(attributes.get("nickname")));
            }
        }

        public static KakaoAccount from(Map<String, Object> attributes) {
            return new KakaoAccount(
                    Boolean.valueOf(String.valueOf(attributes.get("profile_nickname_needs_agreement"))),
                    Profile.from((Map<String, Object>) attributes.get("profile")),
                    Boolean.valueOf(String.valueOf(attributes.get("has_email"))),
                    Boolean.valueOf(String.valueOf(attributes.get("email_needs_agreement"))),
                    Boolean.valueOf(String.valueOf(attributes.get("is_email_valid"))),
                    Boolean.valueOf(String.valueOf(attributes.get("is_email_verified"))),
                    String.valueOf(attributes.get("email"))
            );
        }

        public String nickname() { return this.profile().nickname(); }
    }

    public static KakaoOAuth2Response from(Map<String, Object> attributes) {
        return new KakaoOAuth2Response(
                Long.valueOf(String.valueOf(attributes.get("id"))),
                LocalDateTime.parse(
                        String.valueOf(attributes.get("connected_at")),
                        DateTimeFormatter.ISO_INSTANT.withZone(ZoneId.systemDefault())
                ),
                (Map<String, Object>) attributes.get("properties"),
                KakaoAccount.from((Map<String, Object>) attributes.get("kakao_account"))
        );
    }

    public String email() { return this.kakaoAccount().email(); }
    public String nickname() { return this.kakaoAccount().nickname(); }
}
  • 이 코드는 카카오 OAuth2 인증 응답을 처리하기 위한 Java record 타입의 클래스를 정의한 것입니다.
  • record는 Java 14에서 도입된 새로운 클래스 타입으로, 불변 객체를 쉽게 정의할 수 있습니다.
클래스 및 레코드 정의
 
@SuppressWarnings("unchecked")
  • 제네릭 타입 캐스팅에 대한 경고를 무시하겠다는 어노테이션입니다.
  • 이 코드에서는 Map<String, Object> 타입을 다른 구체적인 제네릭 타입으로 변환할 때 경고가 발생할 수 있기 때문에 사용되었습니다.
public record KakaoOAuth2Response(
        Long id,
        LocalDateTime connectedAt,
        Map<String, Object> properties,
        KakaoAccount kakaoAccount
) {
 
  • KakaoOAuth2Response라는 레코드 타입을 정의합니다. 이 레코드는 다음의 필드를 가집니다:
    • id: 카카오 회원 번호 (Long 타입)
    • connectedAt: 서비스에 연결이 완료된 시간 (LocalDateTime 타입)
    • properties: 사용자 프로퍼티를 담고 있는 맵 (Map<String, Object> 타입)
    • kakaoAccount: 카카오 계정 정보를 담은 KakaoAccount 레코드
내부 레코드 KakaoAccount
    public record KakaoAccount(
            Boolean profileNicknameNeedsAgreement,
            Profile profile,
            Boolean hasEmail,
            Boolean emailNeedsAgreement,
            Boolean isEmailValid,
            Boolean isEmailVerified,
            String email
    ) {
 
  • KakaoAccount라는 내부 레코드 타입을 정의합니다. 이 레코드는 카카오 계정 정보를 담습니다:
    • profileNicknameNeedsAgreement: 닉네임 제공 동의 여부 (Boolean 타입)
    • profile: 프로필 정보를 담은 Profile 레코드 (Profile 타입)
    • hasEmail: 이메일 존재 여부 (Boolean 타입)
    • emailNeedsAgreement: 이메일 제공 동의 여부 (Boolean 타입)
    • isEmailValid: 이메일 유효 여부 (Boolean 타입)
    • isEmailVerified: 이메일 인증 여부 (Boolean 타입)
    • email: 이메일 주소 (String 타입)
        public record Profile(String nickname) {
  • Profile이라는 또 다른 내부 레코드 타입을 정의합니다. 이 레코드는 사용자의 닉네임을 담습니다:
    • nickname: 닉네임 (String 타입)
            public static Profile from(Map<String, Object> attributes) {
                return new Profile(String.valueOf(attributes.get("nickname")));
            }
  • Profile 레코드의 정적 메서드 from은 주어진 Map<String, Object>에서 닉네임을 추출해 Profile 객체를 생성합니다.
  • 이 때 Map에서 값을 가져올 때 String으로 변환하고 있습니다.
        public static KakaoAccount from(Map<String, Object> attributes) {
            return new KakaoAccount(
                    Boolean.valueOf(String.valueOf(attributes.get("profile_nickname_needs_agreement"))),
                    Profile.from((Map<String, Object>) attributes.get("profile")),
                    Boolean.valueOf(String.valueOf(attributes.get("has_email"))),
                    Boolean.valueOf(String.valueOf(attributes.get("email_needs_agreement"))),
                    Boolean.valueOf(String.valueOf(attributes.get("is_email_valid"))),
                    Boolean.valueOf(String.valueOf(attributes.get("is_email_verified"))),
                    String.valueOf(attributes.get("email"))
            );
        }
  • KakaoAccount 레코드의 정적 메서드 from은 Map<String, Object>에서 각 필드를 추출해 KakaoAccount 객체를 생성합니다. 각 필드는 적절한 타입으로 변환됩니다.
        public String nickname() { return this.profile().nickname(); }
 
  • KakaoAccount 레코드에서 nickname 필드를 반환하는 편의 메서드입니다. 내부 Profile 객체에서 닉네임을 가져옵니다.
KakaoOAuth2Response의 정적 메서드와 편의 메서드
    public static KakaoOAuth2Response from(Map<String, Object> attributes) {
        return new KakaoOAuth2Response(
                Long.valueOf(String.valueOf(attributes.get("id"))),
                LocalDateTime.parse(
                        String.valueOf(attributes.get("connected_at")),
                        DateTimeFormatter.ISO_INSTANT.withZone(ZoneId.systemDefault())
                ),
                (Map<String, Object>) attributes.get("properties"),
                KakaoAccount.from((Map<String, Object>) attributes.get("kakao_account"))
        );
    }
  • KakaoOAuth2Response 레코드의 정적 메서드 from은 Map<String, Object>에서 값을 추출해 KakaoOAuth2Response 객체를 생성합니다.
    • id는 Long 타입으로 변환합니다.
    • connectedAt은 LocalDateTime으로 변환하며, ISO 8601 포맷을 기준으로 하고 시스템의 기본 시간대를 사용합니다.
    • properties는 맵 타입으로 그대로 사용됩니다.
    • kakaoAccount는 KakaoAccount 객체로 변환됩니다.
    public String email() { return this.kakaoAccount().email(); }
    public String nickname() { return this.kakaoAccount().nickname(); }
}​
  • KakaoOAuth2Response에서 email과 nickname 필드를 반환하는 편의 메서드입니다. 내부 KakaoAccount 객체의 값을 반환합니다.
요약
  • 이 코드는 카카오 OAuth2 인증 응답을 객체로 변환하여 사용할 수 있도록 설계되었습니다.
  • JSON 형태로 제공되는 응답을 Map<String, Object>로 받고, 이를 KakaoOAuth2Response 객체로 변환하여 필요한 정보를 쉽게 접근할 수 있도록 합니다.
  • record를 사용해 불변 객체를 간결하게 정의했으며, 정적 팩토리 메서드(from)를 통해 JSON 데이터를 객체로 매핑하고 있습니다.

KakaoOAuth2ResponseTest

  • 이 테스트 코드는 KakaoOAuth2Response 클래스가 JSON 형식의 인증 응답을 올바르게 파싱하여 우리가 의도하는 데이터 디자인으로 들어오는지
  • 즉, KakaoOAuth2Response 객체로 변환하는지 확인하기 위해 작성되었습니다. 
클래스와 테스트 설정
@DisplayName("DTO - Kakao OAuth 2.0 인증 응답 데이터 테스트")
class KakaoOAuth2ResponseTest {
  • KakaoOAuth2ResponseTest라는 JUnit 테스트 클래스를 정의하고 있습니다. 
    private final ObjectMapper mapper = new ObjectMapper();
  • ObjectMapper는 Jackson 라이브러리의 객체로, JSON 데이터를 Java 객체로 변환하거나 Java 객체를 JSON으로 변환할 때 사용됩니다. 여기서는 JSON 데이터를 Map으로 변환하는 데 사용됩니다.
개별 테스트 메서드
    @DisplayName("인증 결과를 Map(deserialized json)으로 받으면, 카카오 인증 응답 객체로 변환한다.")
    @Test
    void givenMapFromJson_whenInstantiating_thenReturnsKakaoResponseObject() throws Exception {
  • 이 메서드는 given-when-then 패턴을 사용하여 테스트를 구성합니다.
  • @DisplayName은 테스트 메서드의 설명을 제공하며, @Test는 이 메서드가 테스트 메서드임을 나타냅니다.
  • 이 메서드는 Exception을 던질 수 있습니다. 주로 ObjectMapper를 사용해 JSON을 파싱할 때 발생할 수 있는 예외를 처리하기 위함입니다.
Given
        String serializedResponse = """
                {
                    "id": 1234567890,
                    "connected_at": "2022-01-02T00:12:34Z",
                    "properties": {
                        "nickname": "홍길동"
                    },
                    "kakao_account": {
                        "profile_nickname_needs_agreement": false,
                        "profile": {
                            "nickname": "홍길동"
                        },
                        "has_email": true,
                        "email_needs_agreement": false,
                        "is_email_valid": true,
                        "is_email_verified": true,
                        "email": "test@gmail.com"
                    }
                }
                """;
        Map<String, Object> attributes = mapper.readValue(serializedResponse, new TypeReference<>() {});
  • serializedResponse는 JSON 형식으로 카카오 OAuth2 응답 데이터를 정의하고 있습니다. 이 JSON은 사용자의 ID, 연결 시간, 프로필 정보, 이메일 정보를 포함하고 있습니다.
  • ObjectMapper의 readValue 메서드를 사용해 JSON 문자열을 Map<String, Object>로 디시리얼라이즈합니다. TypeReference<>는 타입 정보를 제공하기 위해 사용됩니다.
When
        KakaoOAuth2Response result = KakaoOAuth2Response.from(attributes);
  • KakaoOAuth2Response.from(attributes)를 호출하여, Map<String, Object> 형식의 데이터를 KakaoOAuth2Response 객체로 변환합니다. 여기서 attributes는 앞에서 디시리얼라이즈한 JSON 데이터를 포함하고 있습니다.
Then
        assertThat(result)
                .hasFieldOrPropertyWithValue("id", 1234567890L)
                .hasFieldOrPropertyWithValue("connectedAt", ZonedDateTime.of(2022, 1, 2, 0, 12, 34, 0, ZoneOffset.UTC)
                        .withZoneSameInstant(ZoneId.systemDefault())
                        .toLocalDateTime()
                )
                .hasFieldOrPropertyWithValue("kakaoAccount.profile.nickname", "홍길동")
                .hasFieldOrPropertyWithValue("kakaoAccount.email", "test@gmail.com");
  • assertThat(result)를 사용해 result 객체가 예상한 값을 가지는지 검증합니다. result 객체는 KakaoOAuth2Response 타입입니다.
  • hasFieldOrPropertyWithValue 메서드를 사용해 필드 값이 예상한 값과 일치하는지 확인합니다:
    • id 필드가 1234567890L인지 확인합니다.
    • connectedAt 필드가 "2022-01-02T00:12:34Z"로부터 변환된 LocalDateTime과 일치하는지 확인합니다.
    • kakaoAccount.profile.nickname 필드가 "홍길동"인지 확인합니다.
    • kakaoAccount.email 필드가 "test@gmail.com"인지 확인합니다.
요약
  • 이 테스트는 KakaoOAuth2Response.from() 메서드가 Map<String, Object>로부터 올바르게 데이터를 읽어와 KakaoOAuth2Response 객체를 생성하는지 확인합니다
  • 특히, id, connectedAt, profile.nickname, 그리고 email 필드가 예상한 값과 일치하는지 검증합니다.
  • 이 테스트는 JSON을 Java 객체로 변환하는 로직이 올바르게 작동하는지 확인하는 데 매우 유용합니다