[Spring] OAuth 없이 소셜 로그인 구현

2024. 11. 27. 00:51·Programming/Spring

0. 들어가기 전

진행 중인 프로젝트에서 웹 애플리케이션에서 앱으로 변경되면서 기존의 소셜 로그인 코드를 수정해야 했습니다. 프론트엔드 개발자와 논의한 결과, 카카오 로그인을 통해 인증 코드가 발급되면 해당 코드를 백엔드 서버에 전달하고, 백엔드에서는 이 코드를 사용해 액세스 토큰을 발급받아 사용자 정보를 가져오는 방식으로 진행하기로 했습니다. 

원래 OAuth는 코드 발급부터 사용자 정보 수집까지 자동화해 주지만, 이번에는 카카오 인증 코드를 수동으로 전달받아 처리하므로 OAuth 없이 직접 구현하는 방식으로 결정하게 되었습니다.

 

아래 글은 OAuth의 개념 설명과 구현 흐름을 정리한 글입니다. 

OAuth에 대한 개념이 궁금하신 분들은 아래 글을 참고해 주시면 감사하겠습니다!

https://young-code.tistory.com/8

 

[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (4) - OAuth란?

1. OAuth2.0a. OAuth2.0이란 'OAuth*(Open Authorization)*'는 인터넷 사용자들이 비밀번호를 제공하지 않고, 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있

young-code.tistory.com

 

인증 관련 패키지 구조는 다음과 같습니다.

 

1. AuthDTO

import lombok.Getter;

@Getter
public class AuthDTO {
    String authorizationCode;
}

이 클래스는 authorizationCode라는 하나의 필드를 가지고 있으며, @Getter 어노테이션이 적용하여 해당 필드에 대한 getter 메서드를 자동으로 생성합니다.

🤔 authorizationCode?
authorizationCode는 카카오 로그인 등의 OAuth 인증 과정에서 사용되는 인증 코드를 저장하는 필드입니다. 

 

2. KakaoUserInfoService

🎯KakaoUserInfoService 전체 코드

@Service
@Slf4j
public class KakaoUserInfoService {

    private static final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
    private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me";

    private String kakaoClientId = "카카오_클라이언트ID";

    private String redirectUri = "[IP주소]/callback";

    // 필요시 설정
    // @Value("${kakao.client-secret}")
    // private String kakaoClientSecret;

    /**
     * 인가 코드를 받아서 액세스 토큰을 발급받는 메서드
     */
    public Map<String, Object> getAccessToken(String authorizationCode) {
        log.info("Get access token");
        RestTemplate restTemplate = new RestTemplate();

        // HTTP 헤더 설정
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        // HTTP 바디(form-data) 설정
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("grant_type", "authorization_code");
        formData.add("client_id", kakaoClientId);
        formData.add("redirect_uri", redirectUri);
        formData.add("code", authorizationCode);
        // 필요시 client_secret 추가
        // formData.add("client_secret", kakaoClientSecret);

        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(formData, headers);

        try {
            ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
                    KAKAO_TOKEN_URL,
                    HttpMethod.POST,
                    requestEntity,
                    new ParameterizedTypeReference<>() {}
            );

            Map<String, Object> responseBody = response.getBody();
            log.info("토큰 응답: " + responseBody);
            return responseBody;
        } catch (HttpClientErrorException e) {
            log.error("카카오 토큰 발급 에러 응답: " + e.getResponseBodyAsString(), e);
            throw e;
        } catch (ResourceAccessException e) {
            log.error("카카오 토큰 발급 요청 중 네트워크 문제 발생: " + e.getMessage(), e);
            throw e;
        }
    }

    public String getKakaoAccessToken(String authorizationCode){
        Map<String, Object> tokenResponse = getAccessToken(authorizationCode);
        return (String) tokenResponse.get("access_token");
    }


    /**
     * 액세스 토큰을 이용해 사용자 정보를 가져오는 메서드
     */
    public Map<String, Object> getUserInfo(String accessToken) {
        log.info("카카오 사용자 정보 요청 시작");
        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("secure_resource", "false");
        formData.add("property_keys", "[\"kakao_account.email\",\"kakao_account.profile\",\"kakao_account.name\"]");

        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(formData, headers);

        try {
            ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
                    KAKAO_USER_INFO_URL,
                    HttpMethod.POST,
                    entity,
                    new ParameterizedTypeReference<>() {}
            );

            log.info("카카오 사용자 정보 응답: " + response.getBody());
            return response.getBody();
        } catch (HttpClientErrorException e) {
            // 에러 응답 바디 확인
            log.error("카카오 사용자 정보 API 에러 응답: " + e.getResponseBodyAsString(), e);
            throw e;
        } catch (ResourceAccessException e) {
            // 타임아웃 또는 네트워크 이슈 확인 필요
            log.error("카카오 사용자 정보 요청 중 네트워크 문제 발생: " + e.getMessage(), e);
            throw e;
        }
    }
}

a. 설정값 주입

private static final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me";

private String kakaoClientId = "카카오_클라이언트Id";

private String redirectUri = "[IP주소]/callback";

카카오 인증을 구현하기 위한 기본적인 설정값 주입합니다. 이후 인증 과정을 처리하는 데 사용됩니다.

1. KAKAO_TOKEN_URL : 카카오에서 인증 토큰을 발급받기 위한 URL을 저장하는 변수입니다.
2. KAKAO_USER_INFO_URL : 카카오 사용자 정보를 가져오기 위한 URL입니다. 이 이 URL을 통해 발급된 액세스 토큰을 사용하여 카카오에서 사용자 정보를 받아올 수 있습니다.
3. kakaoClientId : 카카오 애플리케이션의 클라이언트 ID를 저장하는 변수입니다.
4. redirectUrl : 카카오 로그인 후, 사용자가 인증을 완료하고 리다이렉션 될 URL입니다.

 

b. getAccessToken 메서드

public Map<String, Object> getAccessToken(String authorizationCode) {
    log.info("Get access token");
    RestTemplate restTemplate = new RestTemplate();

    // HTTP 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    // HTTP 바디(form-data) 설정
    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
    formData.add("grant_type", "authorization_code");
    formData.add("client_id", kakaoClientId);
    formData.add("redirect_uri", redirectUri);
    formData.add("code", authorizationCode);
    // 필요시 client_secret 추가
    // formData.add("client_secret", kakaoClientSecret);

    HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(formData, headers);

    try {
        ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
                KAKAO_TOKEN_URL,
                HttpMethod.POST,
                requestEntity,
                new ParameterizedTypeReference<>() {}
        );

        Map<String, Object> responseBody = response.getBody();
        log.info("토큰 응답: " + responseBody);
        return responseBody;
    } catch (HttpClientErrorException e) {
        log.error("카카오 토큰 발급 에러 응답: " + e.getResponseBodyAsString(), e);
        throw e;
    } catch (ResourceAccessException e) {
        log.error("카카오 토큰 발급 요청 중 네트워크 문제 발생: " + e.getMessage(), e);
        throw e;
    }
}

getAccessToken 메서드는 카카오 인증 서버에 액세스 토큰을 요청하는 기능을 구현한 것입니다. 

사용자가 카카오 로그인 후 얻은 authorizationCode를 이용해, 이를 카카오의 토큰 발급 API에 전달하여 액세스 토큰을 받아옵니다. 

먼저, RestTemplate을 사용하여 POST 요청을 보낼 준비를 합니다. 요청 본문에는 grant_type, client_id, redirect_uri, authorizationCode가 포함된 폼 데이터를 설정하고, 헤더에는 Content-Type을 application/x-www-form-urlencoded로 설정합니다. 그런 다음, restTemplate.exchange()를 통해 카카오의 토큰 발급 API에 POST 요청을 보냅니다. 

성공적으로 응답을 받으면 액세스 토큰을 포함한 응답을 반환하고, 에러가 발생하면 에러 메시지를 로그로 출력한 뒤 예외를 던집니다.

 

c. getKakaoAccessToken 메서드

public String getKakaoAccessToken(String authorizationCode){
    Map<String, Object> tokenResponse = getAccessToken(authorizationCode);
    return (String) tokenResponse.get("access_token");
}

getKakaoAccessToken 메서드는 카카오 OAuth 인증을 통해 발급받은 액세스 토큰을 반환하는 기능을 합니다. 

getAccessToken 메서드를 호출하여 카카오 인증 코드(authorizationCode)로부터 액세스 토큰을 포함한 응답을 받아옵니다. 그런 다음, 응답에서 access_token을 추출하여 문자열 형태로 반환합니다.

 

d. getUserInfo 메서드

public Map<String, Object> getUserInfo(String accessToken) {
    log.info("카카오 사용자 정보 요청 시작");
    RestTemplate restTemplate = new RestTemplate();

    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + accessToken);
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
    formData.add("secure_resource", "false");
    formData.add("property_keys", "[\"kakao_account.email\",\"kakao_account.profile\",\"kakao_account.name\"]");

    HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(formData, headers);

    try {
        ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
                KAKAO_USER_INFO_URL,
                HttpMethod.POST,
                entity,
                new ParameterizedTypeReference<>() {}
        );

        log.info("카카오 사용자 정보 응답: " + response.getBody());
        return response.getBody();
    } catch (HttpClientErrorException e) {
        // 에러 응답 바디 확인
        log.error("카카오 사용자 정보 API 에러 응답: " + e.getResponseBodyAsString(), e);
        throw e;
    } catch (ResourceAccessException e) {
        // 타임아웃 또는 네트워크 이슈 확인 필요
        log.error("카카오 사용자 정보 요청 중 네트워크 문제 발생: " + e.getMessage(), e);
        throw e;
    }
}

getUserInfo 메서드는 카카오 API를 통해 사용자의 정보를 요청하는 기능을 구현한 것입니다.

먼저, 카카오 사용자 정보 API에 요청을 보내기 위해 RestTemplate 객체를 생성합니다. 요청 헤더에는 Authorization에 액세스 토큰을 담아 Bearer 방식으로 추가하고, Content-Type을 application/x-www-form-urlencoded로 설정합니다. 요청 본문에는 secure_resource와 property_keys를 설정하여, 사용자 이메일, 프로필, 이름 정보를 요청합니다.

이후 restTemplate.exchange() 메서드를 사용해 카카오의 사용자 정보 API에 POST 요청을 보내고, 응답을 Map<String, Object> 형태로 받아옵니다. 응답이 성공적으로 돌아오면 해당 정보를 로그에 기록하고 반환합니다.

만약 오류가 발생하면, 에러 메시지를 로그에 기록하고 예외를 던집니다. 네트워크 문제나 타임아웃이 발생할 경우에도 별도로 로그를 기록하고 예외를 처리합니다.

 

3. AuthService

@Transactional
public User loadUser(Map<String, Object> attributes){
    Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
    Map<String, Object> properties = (Map<String, Object>) kakaoAccount.get("profile");

    String email = (String) kakaoAccount.get("email");
    String nickname = (String) properties.get("nickname");
    String profileImage = (String) properties.get("profile_image_url");

    if (email == null) {
        throw new OAuth2AuthenticationException(ErrorCode.TOKEN_NOT_FOUND.getMessage());
    }

    User user = userRepository.findByEmail(email)
            .orElseGet(() -> {
                User newUser = User.builder()
                        .email(email)
                        .nickname(nickname)
                        .provider(AuthProvider.KAKAO)
                        .profileImage(profileImage)
                        .build();

                User savedUser = userRepository.save(newUser);

                Reward reward = Reward.builder()
                        .user(savedUser)
                        .experience(0)
                        .nutrition(0)
                        .build();
                rewardRepository.save(reward);

                createDefaultCategories(savedUser.getId());

                return savedUser;
            });
    user.updateNickname(nickname);
    userRepository.save(user);

    log.info("유저 로그인 성공!");

    return user;

}

 

loadUser 메서드는 카카오에서 제공한 사용자 정보를 기반으로 사용자를 로드하거나 새로 생성합니다.

카카오에서 제공한 kakao_account와 profile 정보를 통해 이메일, 닉네임, 프로필 이미지를 추출합니다. 이메일이 없으면 예외를 발생시키고, 이메일이 있으면 해당 이메일로 기존 사용자를 찾아서 반환합니다. 만약 사용자가 없다면 새 사용자 정보를 생성하여 저장하고, 프로젝트 기반 기본 설정을 합니다. 마지막으로, 닉네임을 최신화하고 저장하여 최종 사용자 정보를 반환합니다.

💡OAuth를 사용했을 때, CustomOAuth2UserService에 있는 내용과 유사합니다.

 

4. AuthController

@PostMapping("/user/info")
public ApiResponse<UserAuthResponse> sendUserInitData(@RequestBody AuthDTO authDTO) {
    log.info("로그인 성공 후 유저 정보를 반환합니다.(액세스 토큰, 닉네임 ...)");

    String kakaoAccessToken = kakaoUserInfoService.getKakaoAccessToken(authDTO.getAuthorizationCode());
    Map<String, Object> userInfo = kakaoUserInfoService.getUserInfo(kakaoAccessToken);
    User user = authService.loadUser(userInfo);
    UserAuthResponse userAuthResponse = authService.issueAccessAndRefreshTokens(user.getId(), kakaoAccessToken);
    log.info("userAuthResponse.refreshToken: " + userAuthResponse.refreshToken());
    log.info("userAuthResponse.accessToken: " + userAuthResponse.accessToken());

    return ApiResponse.success(userAuthResponse);
}

 

클라이언트로부터 AuthDTO 객체를 받아와서, 그 안에 있는 카카오 인증 코드(authorizationCode)를 이용해 카카오 액세스 토큰을 발급받습니다. 발급받은 액세스 토큰을 사용해 카카오 사용자 정보를 조회하고, 해당 정보를 통해 loadUser 메서드를 호출하여 사용자 정보를 처리합니다. 그 후, issueAccessAndRefreshTokens 메서드를 호출하여 사용자에게 새로운 액세스 토큰과 리프레시 토큰을 발급하고, 이를 UserAuthResponse 객체로 반환합니다. 마지막으로, ApiResponse.success()를 사용해 응답을 클라이언트에게 전달합니다.

💡OAuth를 사용했을 때, OAuth2LoginSuccessHandler에 있는 내용과 유사합니다.

'Programming > Spring' 카테고리의 다른 글

[Spring] Spring Batch란? 간단한 개념과 코드 살펴보기  (0) 2025.02.05
[Spring] Virtual Thread 기반 최적화  (0) 2024.12.09
[Spring] API 공통 응답 포맷  (0) 2024.11.17
[Spring] 동시성 처리  (18) 2024.11.15
[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (5) - OAuth2.0 로그인 관련 클래스 생성  (0) 2024.11.12
'Programming/Spring' 카테고리의 다른 글
  • [Spring] Spring Batch란? 간단한 개념과 코드 살펴보기
  • [Spring] Virtual Thread 기반 최적화
  • [Spring] API 공통 응답 포맷
  • [Spring] 동시성 처리
chanyoungdev
chanyoungdev
chanyoungdev
  • chanyoungdev
    Young Code
    chanyoungdev
  • 전체
    오늘
    어제
    • 분류 전체보기 (28)
      • Programming (12)
        • Java (0)
        • Spring (10)
        • etc (2)
      • Data Infra (5)
        • Database (1)
        • Redis (2)
        • etc (2)
      • DevOps (4)
        • CI CD (1)
        • Nginx (2)
        • Docker (0)
        • Aws (0)
        • etc (1)
      • Algorithm (6)
      • etc (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • First Tech Blog
    • Github
  • 공지사항

  • 인기 글

  • 태그

    캐시
    Infra
    domain
    Mockito
    Spring Batch
    공통 응답
    단위 테스트
    infra architecture
    ssl
    Redis
    JWT
    spring security
    lock
    Virtual Thread
    cache
    elasticsearch
    nginx
    Algorithm
    OAuth
    junit
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
chanyoungdev
[Spring] OAuth 없이 소셜 로그인 구현
상단으로

티스토리툴바