Spring Security OAuth2 Client 완벽 가이드: 카카오/구글 소셜 로그인부터 JWT 발급 아키텍처까지

Spring Security OAuth2 Client 완벽 가이드: 카카오/구글 소셜 로그인부터 JWT 발급 아키텍처까지

1. 서론

현대 웹 애플리케이션에서 소셜 로그인(Social Login)은 선택이 아닌 필수 기능이 되었습니다. 사용자 입장에서는 귀찮은 회원가입 절차를 건너뛸 수 있어 편리하고, 서비스 제공자 입장에서는 비밀번호 관리의 부담을 덜고 검증된 사용자 정보를 얻을 수 있다는 확실한 이점이 있기 때문입니다. 하지만 개발자 입장에서 소셜 로그인을 처음 구현하려고 하면 마주치는 장벽이 꽤 높습니다. “OAuth 2.0 프로토콜은 무엇이고, 리다이렉트 URI는 왜 맞춰야 하며, 구글과 카카오는 왜 설정 방법이 다른가?”와 같은 수많은 질문에 봉착하게 됩니다.

더 큰 문제는 Spring Security와 JWT를 함께 사용할 때 발생합니다. Spring Security OAuth2 Client 라이브러리는 기본적으로 세션(Session) 기반으로 동작하도록 설계되어 있습니다. 로그인이 성공하면 JSESSIONID를 발급하려고 하죠. 하지만 우리가 구축하고 있는 모던 아키텍처(React, Vue, Mobile App)는 대부분 Stateless한 JWT를 원합니다. 즉, 소셜 로그인 인증은 OAuth2로 처리하되, 최종적으로 우리 서비스의 Access Token(JWT)을 발급해 주는 ‘하이브리드 인증 처리‘가 필요합니다.

오늘은 OAuth 2.0의 표준 흐름인 Authorization Code Grant 방식의 이해부터 시작하여, Spring Security가 이 복잡한 과정을 어떻게 추상화하여 처리하는지, 그리고 가장 중요한 ‘로그인 성공 후 JWT를 발급하여 클라이언트로 전달하는 핸들러(Handler)‘를 어떻게 구현해야 하는지 A to Z를 완벽하게 정리해 드리겠습니다.


2. 본론

1. OAuth 2.0 프로토콜과 Authorization Code Grant 흐름

소셜 로그인의 핵심은 “사용자를 대신하여 서비스(Client)가 리소스 서버(Google, Kakao)에 접근할 권한을 위임받는 것“입니다. 이를 위해 여러 방식(Grant Type)이 존재하지만, 웹 서버 기반의 소셜 로그인에서는 보안성이 가장 높은 Authorization Code Grant(인가 코드 승인) 방식을 사용합니다.

이 방식의 흐름을 이해하는 것이 구현의 절반입니다.

  1. 사용자 접근: 사용자가 우리 웹사이트의 ‘구글 로그인’ 버튼을 클릭합니다.
  2. 인증 페이지 이동: 브라우저는 구글의 로그인 페이지로 리다이렉트 됩니다. (response_type=code 파라미터 포함)
  3. 사용자 동의: 사용자가 로그인을 하고 정보 제공에 동의합니다.
  4. 인가 코드 발급: 구글은 사전에 등록된 Redirect URI로 브라우저를 다시 리다이렉트 시키면서, URL 파라미터에 임시 비밀번호인 Authorization Code를 실어 보냅니다.
  5. 토큰 교환 (백엔드 처리): 우리 서버(Spring Boot)는 이 코드를 가로채서 구글 인증 서버에 직접 요청을 보내고, Access Token으로 교환합니다. (이 과정은 브라우저를 거치지 않으므로 안전합니다.)
  6. 유저 정보 조회: 발급받은 Access Token으로 구글 API를 호출하여 사용자 프로필(이메일, 이름 등)을 가져옵니다.

Spring Security OAuth2 Client 라이브러리는 놀랍게도 1번부터 6번까지의 과정을 설정 파일 몇 줄만으로 자동화해 줍니다. 개발자가 직접 HTTP 요청을 보내거나 코드를 파싱 할 필요가 거의 없습니다.

2. Spring Boot 설정 (application.yml과 Provider)

구현의 첫 단계는 build.gradle에 spring-boot-starter-oauth2-client 의존성을 추가하고 application.yml에 설정을 등록하는 것입니다.

스프링 시큐리티는 구글, 페이스북, 깃허브 같은 글로벌 서비스에 대해서는 CommonOAuth2Provider라는 Enum을 통해 기본 설정을 제공합니다. 따라서 client-id와 client-secret만 입력하면 됩니다. 하지만 **카카오(Kakao)**나 **네이버(Naver)**는 스프링 입장에서 비표준(Non-standard) 서비스이므로, provider 설정을 통해 인증 URL, 토큰 URL, 유저 정보 URL을 직접 명시해야 합니다.

YAML

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: "구글-클라이언트-ID"
            client-secret: "구글-시크릿"
            scope: profile, email
          kakao:
            client-id: "카카오-REST-API-키"
            client-secret: "카카오-시크릿"
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: "http://localhost:8080/login/oauth2/code/kakao"
            scope: profile_nickname, 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 # 카카오는 'id'가 고유 식별자

여기서 가장 주의할 점은 Redirect URI입니다. 스프링 시큐리티는 기본적으로 {baseUrl}/login/oauth2/code/{registrationId} 패턴을 리다이렉트 URL로 사용하도록 고정되어 있습니다. 카카오 개발자 센터나 구글 클라우드 콘솔에서도 이 주소를 정확하게 등록해주어야 에러(redirect_uri_mismatch)가 발생하지 않습니다.

소제목 3: OAuth2User와 CustomOAuth2UserService 구현

설정이 끝나면 스프링은 소셜 로그인 후 사용자 정보를 DefaultOAuth2User 객체에 담아줍니다. 하지만 구글은 subemail을 주고, 카카오는 idkakao_account 안에 이메일을 주는 등 데이터 구조가 제각각입니다. 이를 통일된 포맷으로 관리하고, 우리 DB에 회원 정보를 저장(Join)하거나 업데이트하기 위해 **DefaultOAuth2UserService**를 상속받은 커스텀 서비스를 구현해야 합니다.

Java

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

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

        // 2. 서비스 구분 (google, kakao 등)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        
        // 3. 유저 정보 속성 추출 (서비스마다 다름 -> OAuthAttributes 객체로 분리 권장)
        Map<String, Object> attributes = oAuth2User.getAttributes();
        OAuthAttributes extractAttributes = OAuthAttributes.of(registrationId, attributes);

        // 4. 우리 DB에 저장하거나 업데이트 (SaveOrUpdate)
        User user = saveOrUpdate(extractAttributes);

        // 5. 시큐리티 세션에 저장할 UserPrincipal 반환
        return new CustomOAuth2User(user, attributes);
    }
    // ... saveOrUpdate 메서드 구현 ...
}

이 단계까지 완료되면 소셜 로그인 버튼을 눌렀을 때, DB에 회원 정보가 저장되는 것까지는 성공합니다. 하지만 여전히 브라우저에는 세션 쿠키가 남게 됩니다. 이제 JWT를 발급할 차례입니다.

소제목 4: 화룡점정, OAuth2SuccessHandler를 통한 JWT 발급

이 부분이 오늘 포스팅의 핵심이자 가장 많은 개발자가 헤매는 구간입니다. 스프링 시큐리티는 인증이 성공하면 AuthenticationSuccessHandler를 호출합니다. 우리는 이를 커스텀하여 **”리다이렉트 대신 JWT를 생성하여 프론트엔드로 전달”**하는 로직을 작성해야 합니다.

[JWT 전달 전략]

프론트엔드(React/Vue)와 백엔드가 분리된 환경에서는 단순히 JSON을 반환하는 것으로는 부족합니다. OAuth2 흐름상 마지막 단계는 브라우저 리다이렉트이기 때문입니다. 보통 두 가지 방법을 사용합니다.

  1. Query Parameter: http://localhost:3000/oauth2/redirect?token=xxxxx 형태로 리다이렉트 시킵니다. 구현이 쉽지만 URL에 토큰이 노출되어 보안상 약간의 위험이 있습니다.
  2. Cookie: JWT를 HttpOnly 쿠키에 담아서 리다이렉트 시키거나, 임시 토큰만 URL로 보내고 프론트가 다시 요청하여 본 토큰을 받는 방식입니다.

여기서는 가장 직관적인 Query Parameter 방식을 예시로 들겠습니다.

Java

@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        
        // 1. JWT 토큰 생성 (Access + Refresh)
        String accessToken = jwtProvider.createAccessToken(oAuth2User.getEmail());
        String refreshToken = jwtProvider.createRefreshToken(oAuth2User.getEmail());

        // 2. 리다이렉트 URI 설정 (프론트엔드 주소)
        // 토큰을 쿼리 파라미터에 추가
        String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth/callback")
                .queryParam("accessToken", accessToken)
                .queryParam("refreshToken", refreshToken)
                .build().toUriString();

        // 3. 리다이렉트 수행
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

마지막으로 SecurityConfig에 이 핸들러를 등록해 주면 완성입니다.

Java

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ... CSRF, Session 설정 (Stateless) ...
        .oauth2Login(oauth2 -> oauth2
            .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) // 유저 정보 처리
            .successHandler(oAuth2LoginSuccessHandler) // [핵심] 성공 시 JWT 발급
            .failureHandler(oAuth2LoginFailureHandler)
        );
    return http.build();
}

3. 결론

지금까지 Spring Security OAuth2 Client를 활용하여 소셜 로그인을 구현하고, 이를 JWT 아키텍처와 결합하는 전체 과정을 살펴보았습니다.

과거에는 OAuth2 흐름을 직접 구현하기 위해 RestTemplate으로 요청을 보내고 코드를 파싱 하는 등 수많은 보일러플레이트 코드가 필요했습니다. 하지만 Spring Security OAuth2 Client 라이브러리는 application.yml 설정과 UserServiceSuccessHandler 구현만으로 이 복잡한 과정을 표준화된 인터페이스 안에 깔끔하게 담아냈습니다.

핵심은 **”인증(Authentication)은 OAuth2에게 맡기고, 인가(Authorization) 및 세션 관리는 우리 시스템의 JWT로 통일한다”**는 하이브리드 전략입니다. 이 구조를 이해하고 나면, 추후 네이버나 페이스북, 혹은 사내 SSO를 붙이더라도 UserService의 속성 추출 로직만 추가하면 되는 뛰어난 확장성을 얻게 됩니다. 오늘 소개한 코드를 바탕으로 여러분의 서비스에 편리하고 안전한 소셜 로그인 기능을 탑재해 보시기 바랍니다. 사용자 경험(UX)이 획기적으로 개선될 것입니다.


이제 소셜 로그인까지 완벽하게 구현되었습니다. 하지만 서버가 배포되고 운영되다 보면 “어? 이 API 왜 갑자기 느려졌지?” 하는 순간이 반드시 옵니다. 백엔드 성능 최적화의 첫걸음인 “JPA N+1 문제의 원인 분석과 QueryDSL을 활용한 해결 전략“에 대해 포스팅을 이어가 볼까요? 면접 질문 0순위이자 실무 성능 튜닝의 핵심입니다.

댓글 남기기