[Spring/SpringBoot] 카카오 로그인 API 구현하기 (New Ver✨)

Dev_ch·2023년 8월 17일
2

⬇️ 다른 소셜 로그인을 구현하고 싶다면?
네이버 로그인 API 구현하기 (New Ver✨)

최근 네이버 로그인 포스팅을 진행 했었는데 카카오 로그인과 구글 로그인을 다뤘던 이전 포스팅이 오래되기도 하고 코드가 별로 맘에 들지 않아서 새로 포스팅을 작성한다(...) 그래도 예전에 작성했던건 아까우니까 삭제는 하지 않으려 한다 🤣

네이버 로그인을 구현했던 방식과 거의 동일하기 때문에 한번 해봤으면 적용만 하면된다,,

이제 카카오 로그인을 구현해보도록 하자 🙇‍♂️


준비사항

카카오 developers 메인 홈페이지

저번과 마찬가지로 애플리케이션을 추가해 줄 것이다. 자신이 진행하는 프로젝트에 맞춰서 앱 아이콘, 이름 등을 넣어주도록 하자.

애플리케이션에 진입하면 왼쪽에 해당 리스트를 확인 할 수 있는데 우리가 중요하게 볼 것은

  1. 앱 키 -> REST API 키
  2. 플랫폼 -> 자신이 진행하는 프로젝트에 맞춰 도메인 등록
  3. 카카오 로그인 -> Redirect URI 작성
  4. 동의 항목 -> 프로젝트에 사용하려는 데이터 지정

플로우가 모든 소셜로그인이 거의 비슷한 것을 알 수 있다. 클라이언트 키 또는 REST API 키를 발급받고 플랫폼에 맞춰 도메인 등록, Redirect URI 작성, 동의 항목 지정 등등 소셜로그인을 구현할때 모든 플랫폼에서 거의 필수적으로 설정하다보니 흐름을 잘 이해하면 소셜로그인을 구현하는 것은 매우 쉬워진다 💭

위 사항을 완료했다면 본격적으로 개발을 진행해보자.


application 설정 파일 세팅

해당 포스팅은 yml 기준으로 작성되었다.

kakao:
  token-uri: https://kauth.kakao.com/oauth/token
  user-info-uri: https://kapi.kakao.com/v2/user/me
  grant-type: authorization_code
  client-id: 발급받은 REST API 키
  redirect-uri: 지정해둔 redirect-uri

redirect-uri 주소와, 발급받은 REST API 키 yml 파일에 작성해주자. 특히 secret 코드는 외부에 노출되지 않도록 조심해주자.

KakaoProperties 구현

@Data
@Configuration
@ConfigurationProperties(prefix = "kakao")
public class KakaoProperties {

    private String tokenUri;
    private String userInfoUri;
    private String grantType;
    private String clientId;
    private String redirectUri;
}

해당 클래스는 설정파일에 적어둔 데이터를 저장한다. 설명할게없다(..)

Controller 구현

    @GetMapping("/login/kakao")
    public CustomResponseEntity<UserResponse.Login> loginByKakao(@RequestParam(name = "code") String code) {
        return CustomResponseEntity.success(userService.loginByOAuth(code, KAKAO));
    }

클라이언트에게 매개변수를 하나 더 요청하여 하나의 Controller로 통합시켜 줄 수 있다. 다만, 해당 포스팅에서는 매핑 주소를 별개로 두었다.

위에서 만든 로그인 페이지에서 로그인을 완료하면 해당 주소로 redirect 된다. 로그인 페이지의 URL 파라미터의 response_type을 보면 code 라고 되어있는 부분을 볼 수 있는데 @RequestParam을 통해 인가코드를 요청 받는다고 보면 된다.

필자의 경우 하나의 서비스 메서드로 여러 플랫폼을 관리하기 위해 플랫폼에 맞는 상수값을 함께 매개변수로 넘겨주었다.

Service 구현

1. UserService

	private final List<OAuth2LoginService> oAuth2LoginServices;

    public UserResponse.Login loginByOAuth(String code, Platform platform) {
        Users userEntity = null;

        for (OAuth2LoginService oAuth2LoginService : oAuth2LoginServices) {
            if (oAuth2LoginService.supports().equals(platform)) {
                userEntity = oAuth2LoginService.toEntityUser(code, platform);
                break;
            }
        }

        if (userEntity == null) {
            throw new CustomException(UNEXPECTED_EXCEPTION);
        }

        // 프로젝트에 맞게 이후 로직을 작성

UserService 클래스에서는 프로젝트의 User 도메인에 영향을 주는 로직이기에, 해당 클래스에서 소셜로그인을 구현하면 단일책임의 원칙에서 벗어난다고 생각해 소셜 로그인을 구현하는 부분은 각각의 소셜 로그인에 맞는 Service 클래스에 구현하였다.

또한 객체지향의 OCP 원칙을 통해 if 문으로 해당하는 플랫폼의 Service 클래스의 메서드를 실행시키는 것이 아닌 하나의 인터페이스 구현체를 통해 플랫폼마다의 Service 클래스에서 Override 하여서 구현하였다.

for 문을 통해 구현된 플랫폼을 돌면서 매개변수와 같은 플랫폼일때 해당 플랫폼의 로그인을 메서드를 실행한다. 우리는 카카오 로그인을 구현하고 있었기 때문에 카카오 로그인 API를 구현하자.

2. OAuth2LoginService

public interface OAuth2LoginService {

    Platform supports();

    Users toEntityUser(String code, Platform platform);
}

해당 인터페이스 구현체는 supports()라는 메서드를 통해 해당 플랫폼이 어떤 것 인지 확인할 수 있고
실질적으로 로그인을 진행하는 메서드는 toEntityUser() 이다.

3. KakaoLoginService

@Service
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {

    private final RestTemplate restTemplate = new RestTemplate();
    private final KakaoProperties kakaoProperties;

    @Override
    public Platform supports() {
        return Platform.KAKAO;
    }

    @Override
    public UserResponse.OAuth toSocialEntityResponse(String code, Platform platform) {
        String accessToken = toRequestAccessToken(code);
        KakaoUserResponse profile = toRequestProfile(accessToken);

        return UserResponse.OAuth.builder()
                .email(profile.getKakaoAccount().getEmail())
                .name(profile.getProperties().getNickname())
                .profileImageUrl(
                        Optional.ofNullable(profile.getKakaoAccount().getProfile().getProfileImageUrl())
                )
                .build();
    }

OAuth2LoginService를 구현하게끔 implements를 넣어준다. 해당 메서드들을 상속받아 supports() 함수는 그대로 무슨 플랫폼인지 return 해주는 역할이다. UserService에서 for문을 돌면서 이 메서드를 확인하면서 요청받은 플랫폼의 로그인을 진행하는 것 이다.

로그인을 수행하는 메서드는 toEntityUser인데, 기능을 요약하자면 이렇다.

  1. 발급받은 인가코드를 통해 카카오 전용 AccessToken 발급
  2. 발급받은 AccessToken을 통해 카카오 로그인을 한 유저의 프로필 정보를 가져오기
  3. 가져온 프로필 정보를 통해 User 객체 생성 후 return

여기서 1, 2번 로직은 메서드로 분리되어있다. 실질적인 기능을 수행하기 때문에 해당 메서드들을 하나씩 살펴보자.

3-1. AccessToken 요청하기

private String toRequestAccessToken(String authorizationCode) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", kakaoProperties.getGrantType());
        params.add("client_id", kakaoProperties.getClientId());
        params.add("redirect_uri", kakaoProperties.getRedirectUri());
        params.add("code", authorizationCode);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<KakaoTokenResponse> response =
                restTemplate.postForEntity(kakaoProperties.getTokenUri(), request, KakaoTokenResponse.class);

        // Validate를 만드는 것을 추천

        return response.getBody().getAccessToken();
    }

KakaoLoginService에 restTemplate 이라는 의존성 객체를 생성해두었는데, 각각의 파라미터들을 담은 다음 restTemplate을 이용해 POST 요청을 보내고 미리 생성해둔 KakaoTokenResponse에 응답을 받는다. 그리고 해당 DTO 내부의 accessToken을 return한다.

KakaoTokenResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class KakaoTokenResponse {

    @JsonProperty("access_token")
    private String accessToken;
    @JsonProperty("token_type")
    private String tokenType;
    @JsonProperty("refresh_token")
    private String refreshToken;
    @JsonProperty("id_token")
    private String idToken;
    @JsonProperty("expires_in")
    private int expiresIn;
    private String scope;
    @JsonProperty("refresh_token_expires_in")
    private int refreshTokenExpiresIn;

}

위와 같은 DTO 클래스를 생성하여 AccessToken의 응답을 받았다.

3-2 유저 프로필 정보 가져오기

    private KakaoUserResponse toRequestProfile(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setBearerAuth(accessToken);

        ResponseEntity<KakaoUserResponse> response = restTemplate.postForEntity(
                kakaoProperties.getUserInfoUri(), new HttpEntity<>(headers), KakaoUserResponse.class
        );

		// Validate를 만드는 것을 추천

        return response.getBody();
    }

발급받은 AccessToken을 갖고 이번엔 헤더에 해당 토큰을 등록 후 restTemplate을 통해 다시한번 유저 프로필 정보를 POST 요청한다. 이번에도 유저 프로필 정보를 가져오기 위한 response DTO 클래스를 하나 생성해주자.

KakaoUserResponse
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class KakaoUserResponse {

    private long id;

    @JsonProperty("has_signed_up")
    private boolean hasSignedUp;

    @JsonProperty("connected_at")
    private LocalDateTime connectedAt;

    private KakaoProperties properties;

    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class KakaoProperties {
        private String nickname;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class KakaoAccount {
        @JsonProperty("profile_nickname_needs_agreement")
        private boolean profileNicknameNeedsAgreement;

        private KakaoProfile profile;

        @JsonProperty("has_email")
        private boolean hasEmail;

        @JsonProperty("email_needs_agreement")
        private boolean emailNeedsAgreement;

        @JsonProperty("is_email_valid")
        private boolean isEmailValid;

        @JsonProperty("is_email_verified")
        private boolean isEmailVerified;

        private String email;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class KakaoProfile {
        private String nickname;
        @JsonProperty("profile_image_url")
        private String profileImageUrl;
    }

}

자신이 동의한 항목에 따라 응답받을 변수들을 추가해주도록 하자.

KakaoLoginService 전체코드

@Service
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {

    private final RestTemplate restTemplate = new RestTemplate();
    private final KakaoProperties kakaoProperties;

    @Override
    public Platform supports() {
        return Platform.KAKAO;
    }

    @Override
    public UserResponse.OAuth toSocialEntityResponse(String code, Platform platform) {
        String accessToken = toRequestAccessToken(code);
        KakaoUserResponse profile = toRequestProfile(accessToken);

        return UserResponse.OAuth.builder()
                .email(profile.getKakaoAccount().getEmail())
                .name(profile.getProperties().getNickname())
                .profileImageUrl(
                        Optional.ofNullable(profile.getKakaoAccount().getProfile().getProfileImageUrl())
                )
                .build();
    }

    private String toRequestAccessToken(String authorizationCode) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", kakaoProperties.getGrantType());
        params.add("client_id", kakaoProperties.getClientId());
        params.add("redirect_uri", kakaoProperties.getRedirectUri());
        params.add("code", authorizationCode);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<KakaoTokenResponse> response =
                restTemplate.postForEntity(kakaoProperties.getTokenUri(), request, KakaoTokenResponse.class);

        if (!response.getStatusCode().is2xxSuccessful()) {
            throw new CustomException(FAIL);
        }

        return response.getBody().getAccessToken();
    }

    private KakaoUserResponse toRequestProfile(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setBearerAuth(accessToken);

        ResponseEntity<KakaoUserResponse> response = restTemplate.postForEntity(
                kakaoProperties.getUserInfoUri(), new HttpEntity<>(headers), KakaoUserResponse.class
        );

        if (!response.getStatusCode().is2xxSuccessful()) {
            throw new CustomException(FAIL);
        }

        return response.getBody();
    }
}

이렇게 하면 카카오 로그인 API를 구현 할 수 있다! 해당 프로필 정보나 토큰들을 갖고 자신이 진행하고 있는 프로젝트에는 어떻게 사용할지 설계를 하면 된다. 필자의 경우 JWT와 함께 사용했다.

만약 새로운 플랫폼이 추가된다면, OAuth2LoginService 구현체를 이용해 플랫폼을 추가해나가면 된다. 이렇게 하면 OCP 원칙을 적용하면서 다양한 플랫폼 로그인 서비스들을 지원할 수 있다.

저번 포스팅이랑 뭔가 글이 겹친다고? 기분탓일거다..

강조하지만, REST 형식으로 소셜로그인을 구현하는 것은 플랫폼마다 거의 비슷하다. 다른 부분을 굳이 꼽자면 발급되는 키가 다를 수 있다는 점, 요청을 할때 GET / POST 일 수 있다는 점, 그 외에는 거의 동일 하다.

이전 카카오, 구글 포스팅이 맘에 안들어서 다시 작성하고 있는데 이제 구글 남았다 ^-^
구글은 또 언제 쓸지 모르지만 일단 화이팅.

profile
내가 몰입하는 과정을 담은 곳

1개의 댓글

comment-user-thumbnail
2024년 8월 28일

구현된 github 링크좀 볼 수 있을까요??

답글 달기