BlueNyang
[Spring Boot] OAuth2 + JWT 완벽 가이드 (feat. Kakao Login)
Spring

[Spring Boot] OAuth2 + JWT 완벽 가이드 (feat. Kakao Login)

BlueNyangBlueNyang
·
·
약 2분
·
# oauth2# jwt# spring-security# social-login# stateless-authentication

서비스 초기 단계에서 인증 시스템을 구축할 때, OAuth2 소셜 로그인은 사용자의 진입 장벽을 낮추는 최고의 선택입니다. 여기에 **JWT(JSON Web Token)**를 더하면 세션(Session) 기반 인증의 한계를 넘어, 확장성 있는 무상태(Stateless) 인증 시스템을 만들 수 있습니다.

이번 글에서는 **Spring Security 6 (Lambda DSL)**와 OAuth2 Client를 연동하여 카카오 로그인을 구현하고, 최종적으로 우리 서비스 전용의 JWT를 발급하는 과정을 다룹니다.


1. 전체 아키텍처 및 흐름

사용자가 "카카오 로그인" 버튼을 눌렀을 때, 내부적으로 어떤 일이 일어나는지 알아보겠습니다. 이는 Kakao developers의 공식 문서에서 제공하는 OAuth2 인증 시퀀스 다이어그램을 기반으로 하였습니다.

Spring-Boot와-카카오-로그인-인증-시퀀스-다이어그램
Spring-Boot와-카카오-로그인-인증-시퀀스-다이어그램
  1. Client: 카카오 로그인 요청 (백엔드 특정 URI로 접근).
  2. Kakao Auth Server: 사용자 인증 후 Authorization Code를 백엔드로 전달.
  3. Spring Boot: Code를 받아 Access Token을 요청하고, 이를 통해 **사용자 정보(UserInfo)**를 획득.
  4. Service Layer: 받아온 정보로 회원을 가입시키거나 정보를 업데이트 (DB 저장).
  5. SuccessHandler: 인증이 완료되면 **자체 JWT(Access/Refresh)**를 발급하여 Client로 전달.

2. 사전 준비 (설정 및 의존성)

2.1. build.gradle

Spring Boot 3.x 버전을 기준으로 필요한 의존성을 추가합니다.

groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // 핵심
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // JWT 라이브러리 (jjwt 0.13.0 기준)
    implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
}

2.2. application.yml

카카오 개발자 센터에서 발급받은 키와 Redirect URI를 설정합니다. 이 부분이 설정되어 있지 않으면 OAuth2 흐름 자체가 시작되지 않습니다.

카카오-개발자-센터의-설정-화면
카카오-개발자-센터의-설정-화면
yaml
spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID} # REST API 키
            client-secret: ${KAKAO_CLIENT_SECRET} # (선택) Client Secret 키
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            # redirect-uri: 카카오에 등록한 URI와 일치해야 함
            redirect-uri: "http://localhost:8080/login/oauth2/code/kakao"
            scope:
              - profile_nickname
              - profile_image
              - account_email
            client-name: Kakao
        provider:
          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-name-attribute: id # 카카오 유저 식별자(PK)

3. 사용자 정보 표준화 (OAuth2UserInfo)

구글, 네이버, 카카오 등 다양한 공급자(Provider)는 각기 다른 JSON 응답 구조를 가집니다. 이를 하나의 인터페이스로 묶어 다형성을 활용합니다.

3.1. Interface 정의

java
public interface OAuth2UserInfo {
    String getProviderId();
    String getProvider();
    String getEmail();
    String getNickname();

    // DB 저장용
    User toEntity();
}

3.2. Kakao 구현체

카카오는 kakao_account 내부에 프로필 정보가 중첩된 구조이므로, 아래와 같이 파싱이 필요합니다.

java
public class KakaoOAuth2UserInfo implements OAuth2UserInfo {

    private Map<String, Object> attributes;
    private Map<String, Object> kakaoAccount;
    private Map<String, Object> kakaoProfile;

    public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
        this.kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        this.kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
    }

    @Override public String getProviderId() { return String.valueOf(attributes.get("id")); }
    @Override public String getProvider() { return "kakao"; }
    @Override public String getEmail() { return (String) kakaoAccount.get("email"); }
    @Override public String getNickname() { return (String) kakaoProfile.get("nickname"); }

    // DB 저장용
    public User toEntity() {
        return User.builder()
                .provider(provider)
                .providerId(providerId)
                .email(email)
                .nickname(nickname)
                .role(Role.USER)
                .build();
    }
}

4. 핵심 로직

4.1. CustomOAuth2User (DTO)

OAuth2User를 상속받아 Spring Security가 인식할 수 있는 사용자 객체로 변환합니다.

java
@Getter
public class CustomOAuth2User implements OAuth2User {
    private final OAuth2User oAuth2User;
    private final Long userId;
    private final Role role;

    public CustomOAuth2User(OAuth2User oAuth2User, Long userId, Role role) {
        this.oAuth2User = oAuth2User;
        this.userId = userId;
        this.role = role;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return oAuth2User.getAttributes();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(role.getKey()));
    }

    @Override
    public String getName() {
        return oAuth2User.getName();
    }
}

4.2. CustomOAuth2UserService

DefaultOAuth2UserService를 상속받아, 카카오에서 받은 사용자 정보를 **우리 서비스의 DB에 저장(회원가입/갱신)**하는 로직을 담당합니다.

java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
                                throws OAuth2AuthenticationException {
        // 카카오에서 유저 정보 가져오기
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // provider 판별 (kakao, naver 등)
        String registrationId = userRequest.getClientRegistration()
                                            .getRegistrationId();
        OAuth2UserInfo oAuth2UserInfo = null;

        if (registrationId.equals("kakao")) {
            oAuth2UserInfo = new KakaoOAuth2UserInfo(
                oAuth2User.getAttributes());
        }
        // else if (naver/google 등) ...

        // DB 저장 또는 업데이트 (강제 회원가입 로직)
        User user = saveOrUpdate(oAuth2UserInfo);

        // UserDetails로 반환 (여기서 반환한 객체가 SuccessHandler로 넘어감)
        return new CustomOAuth2User(
            oAuth2User,
            user.getId(),
            user.getRole()
        );
    }

    private User saveOrUpdate(OAuth2UserInfo attributes) {
        User user = userRepository.findByProviderId(attributes.getProviderId())
                // 이미 가입된 경우 정보 업데이트
                .map(entity -> entity.update(attributes.getNickname()))
                // 신규 가입
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}
Note
CustomOAuth2UserOAuth2User를 구현한 DTO 클래스입니다. 내부적으로 우리 서비스의 User 엔티티를 필드로 가지고 있으면 Handler에서 꺼내 쓰기 편합니다.

5. 토큰 발급: OAuth2SuccessHandler

인증이 성공하면 호출되는 핸들러입니다. 여기서 우리 서비스 전용 JWT를 생성하고 프론트엔드로 전달합니다.

java
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {

        // CustomOAuth2UserService에서 넘겨준 User 정보 획득
        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

        // JWT 토큰 생성 (Access & Refresh)
        // JWT에 들어갈 정보는 민감정보를 제외한 식별자(userId)와 권한(Role) 정도가 적당합니다.
        String accessToken = jwtTokenProvider.createAccessToken(customUserDetails.getUserId(), customUserDetails.getRole());
        String refreshToken = jwtTokenProvider.createRefreshToken(customUserDetails.getUserId());

        // 토큰 전달 (Redirect)
        // Tip: Access Token은 Query Param으로 노출하기보다
        //       프론트엔드와 별도 통신하거나, Refresh Token을
        //       HttpOnly Cookie에 담는 방식이 안전합니다.
        String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth/callback")
                .queryParam("accessToken", accessToken)
                .build().toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

6. SecurityConfig 설정

마지막으로 위에서 만든 컴포넌트들을 Spring Security 설정에 조립합니다. Spring Security 6.1 이상부터 권장되는 Lambda DSL 방식을 사용했습니다.

java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable) // JWT 사용 시 CSRF disable
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
             // 세션 미사용
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // OAuth2 설정
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService) // 유저 정보 로드 및 저장
                )
                .successHandler(oAuth2SuccessHandler) // 성공 시 토큰 발급
            );

        return http.build();
    }
}

7. 테스트 및 마무리

이제 서버를 실행하고 브라우저에서 http://localhost:8080/oauth2/authorization/kakao로 접속해 봅니다.

로그인을 완료하면, 설정한 CustomOAuth2UserService가 DB에 유저를 저장하고, OAuth2SuccessHandler가 JWT를 생성하여 프론트엔드 URL로 리다이렉트 시켜주는 것을 확인할 수 있습니다.

BlueNyang
작성자BlueNyang
라이선스
CC BY NC
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus