본문 바로가기
프로젝트

My Record - OAuth2 카카오 도전기! 리팩토링2

by ernest45 2025. 4. 16.

이전 글

My Record - OAuth2 카카오 도전기! 리팩토링 1편

트러블 슈팅

My Record - OAuth2 카카오 도전기 중 트러플 슈팅

 

원리는 다 설명했고 이제 차근 차근 코드를 따라가보자

 

 

1. yml 설정

spring:

  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

    defer-datasource-initialization: true




  h2:
    console:
      enabled: true

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:
              - email
              - profile
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            client-authentication-method: client_secret_post
            #인가코드를 받아 엑세스토큰 요청 떄 사용 즉 OAuth2LoginAuthenticationFilter에서 사용
            authorization-grant-type: authorization_code
            scope: # https://developers.kakao.com/docs/latest/ko/kakaologin/common#user-info
              - profile_nickname
              - account_email
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            client-name: Kakao
        provider:
          #Google, GitHub, Facebook, Okta는 CommonOAuth2Provider enum 에 미리 정의되어 있어서 필 x
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-info-authentication-method: header
            # 사용자 정보를 가져올 때 토큰을 보내는 방법 입니다 기본이 헤더
            user-name-attribute: id

jwt:
  issuer: ajufresh@gmaile.com
  secret_key: study-springboot
  #{OAUTH_CLIENT_ID}{OAUTH_CLIENT_SECRET}{JWT_SECRET_KEY}

 

 

yml의 민감 자료들은 .env파일로 설정했고, dotenv로 의존성 관리하고 있다.

 

 

이전 글 - 블로그 만들기 - 환경변수 dotenv으로 관리

 

블로그 만들기 - 환경변수 dotenv으로 관리

예전 프로젝트에서는 환경변수를 하드코딩식으로 local에 올렸었는데 (aws서버에서도),이번엔 .env 파일 + application.yml 조합으로 해봐야겠다.  그러기 위해서 가장 쉬운 방법이 dotenv라이브러리 활

ernest45.tistory.com

 

 

OAuth는 여러 플랫폼에서 지원하기에 주는 데이터를 지원하는 사이트에 맞춰서 받아야 한다.

근데 왜 이전에 google의 정보를 받을 때 ex) email,nickname 등을 정해주지 않았을까?

 

q) 구글은 왜 값을 따로 정해서 받아주지 않느냐?

 

a) Google, GitHub, Facebook, Okta는 CommonOAuth2Provider enum 에 미리 정의되어 있습니다. Google에 scope 값이 openid, profile, email이 설정되어 있는 것을 볼 수 있습니다.

이래서 구글은 직접 provider 값을 설정하지 않았다.

 

OAuth2ClientPropertiesRegistrationAdapter에서

 

 CommonOAuth2Provider를 활용하는데


OAuth2ClientPropertiesRegistrationAdapter는 애플리케이션 설정 파일(.properties 또는 .yml)에 정의된 OAuth2 관련 설정 정보를 처리하고, 이를 기반으로 ClientRegistration 객체를 생성하는 역할을 합니다. 이 클래스는 다음과 같은 과정을 통해 CommonOAuth2Provider를 활용합니다:
1. 애플리케이션 실행 시 설정 파일의 정보를 OAuth2ClientProperties 객체로 변환합니다
2. getClientRegistrations 메서드를 통해 Registration 정보를 가져와 clientRegistrations라는 Map 객체를 생성합니다
3. getClientRegistration 메서드에서 내부적으로 getCommonProvider 메서드를 호출합니다
4. getCommonProvider 메서드는 providerId(google, github 등)를 통해 CommonOAuth2Provider 객체를 가져옵니다

 

이런 역할을 한다 쉽게 말해서

 

 CommonOAuth2Provider를 활용해서 쉽게 google 로그인이 가능하다!

 

그렇지만 내가 만들려는 kakao의 경우 없기 때문에 먼저 provider를 구현해줘야겠다.

현재 확장을 위해 enum 타입으로 정의해줬고, naver도 추후에 만들 생각이다.

들어오는 이름마다 확인 후 그 사이트에 맞는 데이터를 받아올 수 있게 해줘야 한다.

 

 

 

OAuth2UserInfo

 

실제로  OAuth2 제공플랫폼에서 제공하는 데이터를 받기 위해 추가했다.

 

 

 

사실 지금 내가 kakao에서는 받는 값은 nicknameemail만 있으면 됨

 

 

 

 

카카오에서는 정보를 어떻게 주는 지 보자!

 

i

d는 필수이고,

카카오 계정 정보는 kakao_account 타입으로 반환해주는 것을 알 수 있다.

 

kakaoAccount에는 내가원하는 email이 있고

kakaoAccount 안에 profile 타입으로 감싸진 객체 안에 또 내가 원하는

nickname이 있다.

동의한 필요한 항목에 대해서 받아오는 OAuth2KakaoUserInfo 클래스

 

 

 

(주석 부분은 트러블 슈팅한 부분이라 추가 ++)

블로그 - OAuth2 카카오 도전기 중 트러플 슈팅

 

 

 

그럼 구글의 경우와 어디까지 알아서 해야할까?

사실 구글의 경우는 yml에 clientId와 secret만 정해놓으면

provider부터 시작해서 권한부여까지 기본적으로 끝마친 객체를 생성해 반환해주는 a-z까지 다 해준다.

 

 

그렇다면 바꿔 말해 내가 kakao의 경우 직접 받을 데이터를 파싱해주고, 마지막에 권한까지 생성해서 넘겨주면 된다.

 

 

자 그럼 이제 원래 OAuth2UserCustomService는

기존 구글만 구현되어 있다.

더보기
package me.hanjun.config.oauth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.hanjun.domain.User;
import me.hanjun.repository.UserRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Map;
@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    // 이 메서드로 자동으로 http요청을 보내주고 그걸 난 저장만하면 됨 (파싱  url마추고 다 해줌..)
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {

        log.info("1Starting loadUser for OAuth2UserRequest: {}", userRequest);
        OAuth2User user = super.loadUser(userRequest);
        if (user == null) {
            throw new OAuth2AuthenticationException("OAuth2 사용자 정보를 가져올 수 없음");
        }

        saveOrUpdate(user);

        return user;

    }

    private User saveOrUpdate(OAuth2User oAuth2User) {
        Map<String, Object> attributes = oAuth2User.getAttributes();
        String email = (String) attributes.get("email");
        if (email == null) {

            throw new OAuth2AuthenticationException("Email not found in OAuth2 user attributes");
        }
        String name = (String) attributes.get("name");

        User user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name))
                .orElse(User.builder()
                        .email(email)
                        .nickname(name)
                        .build());

        return userRepository.save(user);

    }
}

(loadUser에 관한 글++)

블로그 만들기 -Oauth2구글에 요청이 자동으로 간다고?

 

비교를 위해 간단히 설명하자면,


보다시피 웬만한 건 이미 정해져 있는 CommonOAuth2Provider에서 다 받아올 수 있어서

 

내가 필요한

 

 

 

name과 email만 추출해서 내 db 저장하면 끝!

권한이나 추가 정보를 담아 넘길 필요도 없다..

 

 

카카오의 경우 

더보기
package me.hanjun.config.oauth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.hanjun.config.oauth.provider.OAuth2UserInfo;
import me.hanjun.config.oauth.provider.OAuth2UserInfoFactory;
import me.hanjun.domain.User;
import me.hanjun.repository.UserRepository;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    // 이 메서드로 자동으로 http요청을 보내주고 그걸 난 저장만하면 됨 (파싱  url마추고 다 해줌..)
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {

        log.info("1Starting loadUser for OAuth2UserRequest: {}", userRequest);
        OAuth2User user = super.loadUser(userRequest);
        if (user == null) {
            throw new OAuth2AuthenticationException("OAuth2 사용자 정보를 가져올 수 없음");
        }
        try{
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        //OAuth2UserRequest 네이버 구현 시 추가
        if ("kakao".equals(registrationId)) {
            return processKakaoUser(userRequest,user);
        }

        saveOrUpdate(user);

        return user;

    }catch (Exception ex) {
            log.error("OAuth2 사용자 처리 중 오류 발생", ex);
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
        }
    }


    private User saveOrUpdate(OAuth2User oAuth2User) {
        Map<String, Object> attributes = oAuth2User.getAttributes();
        String email = (String) attributes.get("email");
        if (email == null) {

            throw new OAuth2AuthenticationException("Email not found in OAuth2 user attributes");
        }
        String name = (String) attributes.get("name");

        User user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name))
                .orElse(User.builder()
                        .email(email)
                        .nickname(name)
                        .build());

        return userRepository.save(user);

    }

    private OAuth2User processKakaoUser(OAuth2UserRequest userRequest,OAuth2User oAuth2User) {
        String accessToken = userRequest.getAccessToken().getTokenValue();
        log.debug("Kakao OAuth2User attributes: {}", oAuth2User.getAttributes());
        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.create(
                "kakao",
                accessToken,
                oAuth2User.getAttributes()
        );

        String email = userInfo.getEmail();
        if (email == null) {
            throw new OAuth2AuthenticationException("Email not found from Kakao");
        }

        String nickname = userInfo.getNickname();

        // DB에 사용자 정보 저장 또는 업데이트
        User user = userRepository.findByEmail(email)
                .map(existingUser -> existingUser.update(nickname))
                .orElse(User.builder()
                        .email(email)
                        .nickname(nickname)
                        .build());

        user = userRepository.save(user);

        Map<String, Object> attributes = new HashMap<>();
        // 원본 속성을 안전하게 복사
        for (Map.Entry<String, Object> entry : oAuth2User.getAttributes().entrySet()) {
            attributes.put(entry.getKey(), entry.getValue());
        }

//         추가 정보 설정
        attributes.put("email", email);
        attributes.put("nickname", nickname);

        // 권한 정보 설정
        Collection<GrantedAuthority> authorities = Collections.singletonList(
                new SimpleGrantedAuthority("user")
        );

        // DefaultOAuth2User 객체 생성하여 반환
        return new DefaultOAuth2User(
                authorities,
                attributes,
                "id" // nameAttributeKey - Kakao의 경우 "id"가 사용자 식별자
        );


    }
}

 

google도 provider를 직접 구현해서 받아도 되지만, 이미 구현한 google에서 카카오만 따로 분기처리를 하고 싶었다.

 

 

 

 

기존 코드에서

요청 시 포함되어있는 registrationId에서 kakao의 경우에만 새로 받아주고

관련 코드만 만들어주면 됨

 

 

 

간단하게

 

 

Kakao에서 받은 정보를 바탕으로

사용자 이메일 기준으로 DB 저장/업데이트

사용자 정보를 담은 OAuth2User를 만들어 반환하는 메서드이고

 

 

 

 

OAuth2User attributes를 추가 가공하고 (안해도 됨)

 

마지막에 권한을 생성해서 반환하면 된다.

 

사실 우리 서버는 아직 인가가 없어서 권한은 필요없지만,

OAuth2User만들려면 권한은 무조건 필수이다..

 

 

 

 

 

 

 

 

 

 

마무리

 

카카오의 경우 내가 어디서부터 어디까지 커스텀 해야할 지 구글 찾아 보는 게 제일 어려웠다.

 

사실 다른 자료들은 redirect까지 필터로 시키지 않고 구현하는 곳도 많아서  예전에 이런 부분에서 너무 헷갈렸다. 

 

가뜩이나 필터에 대해 이해하기 어려울 수준이였는데 지금은 개략적으로 어떤 필터가 어떤 역할을 하고

어느 필터를 부르게 되고가 그려지니까 OAuth 카카오 커스텀도 해결할 수 있었다..

 

햇병아리 시절에서 조금은 자라난 거 같아서 진짜 행복하구만

 

 

 

 

 

 

 

 

 

 

 

코드를 전부 첨부하기엔 너무 기니 github 주소를 남기겠다

https://github.com/Ernest45/springboot-blog

 

 

 

 

 

 

 

 

 

 

 

 

 

 

https://velog.io/@nefertiri/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-OAuth2-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-01

 

 

https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.html