[Spring] 소셜 로그인 구현하기(JWT 사용)

지원·2026년 1월 9일
post-thumbnail

이번에 개인 프로젝트를 진행하면서 구현했던 OAuth(소셜 로그인) 연동 과정을 기록해보려 합니다.
client-id, client-secret 등을 발급 받는 방법은 구글, 네이버 등 모두 비슷하니 카카오에서 발급하는 방법만 아래에 기술하겠습니다.

개발 환경 : Spring Boot 3.5, Java 17, JWT 사용

카카오 간편 로그인 구현

서비스 로그인 과정 Sequence Diagram

출처 : 카카오 디벨로퍼즈 문서

1. 카카오 디벨로퍼(https://developers.kakao.com/) 가입하기

2. 앱 > 앱 생성 에서 기본 정보 입력

create-app

3. 앱 설정 > 앱 > 생성한 앱 클릭 > 플랫폼 키에서 개발하는 환경에 맞는 키를 application.properties 에 붙여넣기

(GitHub Repository에 노출 안되게 조심하기‼️)

# 어떤 앱에서 요청이 왔는지 카카오에서 식별할 식별 키
spring.security.oauth2.client.registration.kakao.client-id=YOUR_KAKAO_REST_API_KEY

4. REST API 키 하단에 있는 클라이언트 시크릿 > 카카오 로그인 코드 복사 및 붙여넣기

(GitHub Repository에 노출 안되게 조심하기‼️)

# API 키와 함께 앱을 인증하는 비밀 키. access_token을 요청할 때 필요함
spring.security.oauth2.client.registration.kakao.client-secret=YOUR_KAKAO_CLIENT_SECRET

api-key

5. 카카오 로그인 성공 후 authorization code를 받을 백엔드 주소 등록

  • 프론트엔드에서 버튼을 눌렀을 때 이동할 주소와 동일해야 오류가 발생하지 않아요
  • REST API 키 수정 화면 > 카카오 로그인 리다이렉트 URI 에 동일하게 등록이 필요해요
# 카카오 로그인 성공 후 authorization code를 받을 Callback URI(각자 정하기 나름!)
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/api/login/oauth2/code/kakao

6. 아래 설정을 application.properties 에 붙여넣기

# OAuth2 - Kakao
# OAuth의 여러 인증 방식 중 Authorization code grant를 사용하겠다는 설정
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code

# 카카오에서 가져올 사용자의 정보 범위 설정
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email

# client-secret을 어떻게 전달할지 정하는 설정
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post

# Provider 정보
# 사용자가 카카오 로그인 페이지로 리다이렉트 되는 URL
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize

# authorization code(인가코드 : code의 값)를 access_token으로 교환할 엔드포인트
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token

# access_token으로 카카오 사용자의 정보를 조회하는 엔드포인트
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me

# 응답에서 사용자의 고유 식별자로 사용할 필드명
spring.security.oauth2.client.provider.kakao.user-name-attribute=id


# OAuth2 - Google
spring.security.oauth2.client.registration.google.client-id=YOUR_GOOGLE_CLIENT_ID
spring.security.oauth2.client.registration.google.client-secret=YOUR_GOOGLE_CLIENT_SECRET
spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/api/login/oauth2/code/google
spring.security.oauth2.client.registration.google.scope=profile,email

# OAuth2 - Naver
spring.security.oauth2.client.registration.naver.client-id=YOUR_NAVER_CLIENT_ID
spring.security.oauth2.client.registration.naver.client-secret=YOUR_NAVER_CLIENT_SECRET
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/api/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email

spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response

프로젝트 구현

이제 설정 파일을 바탕으로 실제 자바 코드를 작성해 보겠습니다.

1. 의존성 추가 (build.gradle)

Spring Security와 OAuth2 Client 라이브러리를 추가합니다.

dependencies {
	// Spring Security & OAuth2 Client
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    // Web (Controller 등)
    implementation 'org.springframework.boot:spring-boot-starter-web'
    
    // Lombok, DB 관련 의존성은 프로젝트 환경에 맞게 추가
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

2. SecurityConfig 설정

스프링 부트 3.x 버전부터는 SecurityFilterChain을 Bean으로 등록하여 보안 설정합니다.
저는 JWT 사용을 위해 세션을 Stateless로 설정하고, application.properties에 지정한 리다이렉트 URI 경로와 일치하도록 엔드포인트를 커스텀합니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        
        http
            .csrf(csrf -> csrf.disable()) // CSRF 비활성화
            .formLogin(form -> form.disable()) // Form Login 비활성화
            .httpBasic(basic -> basic.disable()) // HTTP Basic 비활성화
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 미사용 (JWT)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/api/auth/**").permitAll()
                .requestMatchers("/login/oauth2/**", "/oauth2/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(endpoint -> endpoint
                    .baseUri("/api/oauth2/authorization") // 소셜 로그인 연결 주소 커스텀
                )
                .redirectionEndpoint(endpoint -> endpoint
                    .baseUri("/api/login/oauth2/code/*") // 리다이렉트 주소 커스텀
                )
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService) // 사용자 정보 로드 서비스 등록
                )
                .successHandler(oAuth2SuccessHandler) // 로그인 성공 시 JWT 발급 처리
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

3. UserEntity & Repository

소셜 로그인으로 가져온 사용자 정보를 저장할 User Entity와 User Repository입니다.

// User Entity
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    private String password; // OAuth 사용자는 null

    @Column(nullable = false, length = 100)
    private String name;

    @Column(length = 500)
    private String profileImageUrl;

    @Column(length = 50)
    private String provider; //  'kakao', 'google', 'naver'

    @Column(length = 255)
    private String providerId; // OAuth 제공자의 사용자 ID

}

// User Repository
import com.lifemanager.life_manager.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    Optional<User> findByProviderAndProviderId(String provider, String providerId);

    boolean existsByEmail(String email);
}

4. OAuth2UserInfo (Interface & Implementations)

여러 소셜 로그인(구글, 카카오, 네이버)의 응답 형태가 서로 다르기 때문에, 공통 인터페이스를 정의하고 각 소셜 서비스별로 구현체를 만들어 데이터를 통일성 있게 처리합니다.

1) OAuth2UserInfo Interface

public interface OAuth2UserInfo {
	String getProviderId();
    String getProvider();
    String getEmail();
    String getName();
}

2) 구현체 (Kakao, Google, Naver)

  • KakaoOAuth2UserInfo
    카카오는 kakao_account 내부에 profile이 존재하는 등 구조가 깊으므로 계층적인 파싱이 필요합니다.
import java.util.Map;

public class KakaoOAuth2UserInfo implements OAuth2UserInfo {
	
    private Map<String, Object> attributes;
    
    public KakaoOauth2UserInfo(Map<String, Object> attributes) {
    	this.attributes = attributes;
    }
    
    @Override
    public String getProviderId() {
    	return String.valueOf(attributes.get("id");
    }
    
    @Override
    public String getProvider() {
    	return "kakao";
    }
    
    @Override
    @SuppressWarnings("unchecked")
    public String getEmail() {
    	Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        
        if (kakaoAccount == null) {
        	return null;
        }
        
        return (String) kakaoAccount.get("email");
    }
    
    @Override
    @SuppressWarnings("unchecked")
    public String getName() {
    	Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
        
        if (properties == null) {
        	return null;
        }
        
        return (String) properties.get("nickname");
    }
}
  • GoogleOAuth2UserInfo
import java.util.Map;

public class GoogleOAuth2UserInfo implements OAuth2UserInfo {
    private Map<String, Object> attributes;

    public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}
  • NaverOAuth2UserInfo
    네이버는 response라는 키 값 안에 사용자 정보가 담겨 옵니다.
import java.util.Map;

public class NaverOAuth2UserInfo implements OAuth2UserInfo {
    private Map<String, Object> attributes;

    public NaverOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    @SuppressWarnings("unchecked")
    public String getProviderId() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        if (response == null) {
            return null;
        }
        return (String) response.get("id");
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    @SuppressWarnings("unchecked")
    public String getEmail() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        if (response == null) {
            return null;
        }
        return (String) response.get("email");
    }

    @Override
    @SuppressWarnings("unchecked")
    public String getName() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        if (response == null) {
            return null;
        }
        return (String) response.get("name");
    }
}

5. CustomOAuth2User

Security Context에 저장된 인증 객체입니다. 우리 서비스의 User 엔티티와 OAuth2 제공자가 리턴한 OAuth2User 객체를 함께 보관하여, 컨트롤러 등에서 쉽게 사용자 정보를 꺼내 쓸 수 있도록 래핑(Wrapping) 합니다.

import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.Collection;
import java.util.Map;

@Getter
public class CustomOAuth2User implements OAuth2User {

    private OAuth2User oAuth2User;
    private User user;

    public CustomOAuth2User(OAuth2User oAuth2User, User user) {
        this.oAuth2User = oAuth2User;
        this.user = user;
    }

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return oAuth2User.getAuthorities();
    }

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

6. CustomOAuth2UserService

OAuth2 공급자로부터 받은 사용자 정보를 가공하여 회원가입 또는 정보 수정을 처리하고, CustomOAuth2User를 반환합니다.

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
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;
import java.util.Optional;
import java.util.UUID;

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

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 1. 소셜 로그인 API의 사용자 정보 요청
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        // 2. 소셜 타입에 맞게 유저 정보 추출
        OAuth2UserInfo oAuth2UserInfo = getOAuth2UserInfo(registrationId, attributes);

        if (oAuth2UserInfo.getEmail() == null) {
            throw new OAuth2AuthenticationException("이메일을 가져올 수 없습니다");
        }

        // 3. 회원가입 또는 정보 업데이트
        User user = saveOrUpdate(oAuth2UserInfo);

        // 4. User 객체를 포함한 CustomOAuth2User 반환
        return new CustomOAuth2User(oAuth2User, user);
    }

    private OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
        return switch (registrationId) {
            case "kakao" -> new KakaoOAuth2UserInfo(attributes);
            case "google" -> new GoogleOAuth2UserInfo(attributes);
            case "naver" -> new NaverOAuth2UserInfo(attributes);
            default -> throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다: " + registrationId);
        };
    }

    private User saveOrUpdate(OAuth2UserInfo oAuth2UserInfo) {
        // 소셜 로그인 유저를 구분하기 위해 'provider_email' 형식으로 이메일 저장
        String email = oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getEmail();

        Optional<User> userOptional = userRepository.findByEmail(email);

        User user;
        if (userOptional.isPresent()) {
            user = userOptional.get();
            user.setName(oAuth2UserInfo.getName());
        } else {
            user = User.builder()
                    .email(email)
                    .password(passwordEncoder.encode(UUID.randomUUID().toString()))
                    .name(oAuth2UserInfo.getName())
                    .provider(oAuth2UserInfo.getProvider())
                    .providerId(oAuth2UserInfo.getProviderId())
                    .build();
            log.info("새로운 OAuth2 사용자 등록 - provider: {}, email: {}",
                    oAuth2UserInfo.getProvider(), oAuth2UserInfo.getEmail());
        }

        return userRepository.save(user);
    }
}

7. JWT & Handler 구현

SecurityConfig에서 사용되는 JWT 관련 컴포넌트와 로그인 성공 핸들러입니다.

1) JwtTokenProvider

JWT 토큰 생성 및 검증을 담당하는 클래스입니다.

@Component
@Slf4j
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;
    
    @Value("${jwt.expiration}")
    private long expirationTime;

    private Key key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
    }

    public String generateToken(String email, String role) {
        return Jwts.builder()
                .setSubject(email)
                .claim("role", role)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expirationTime))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.error("Invalid JWT token", e);
            return false;
        }
    }
    
    // ... 토큰에서 사용자 정보 추출 메서드 등 추가
}

2) OAuth2SuccessHandler

소셜 로그인 성공 후 실행되는 핸들러입니다.
CustomOAuth2UserService에서 넘겨준 인증 정보를 바탕으로 JWT를 생성하고, 프론트엔드로 리다이렉트 시킵니다.

@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        
        String accessToken = jwtTokenProvider.generateToken(oAuth2User.getName(), oAuth2User.getUser().getRole().getKey());
        
        // 프론트엔드 주소로 리다이렉트 (쿼리 파라미터로 토큰 전달)
        String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect")
                .queryParam("accessToken", accessToken)
                .build().toUriString();

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

3) JwtAuthenticationFilter

모든 요청에 대해 헤더를 검사하여 유효한 JWT 토큰이 있는지 확인하는 필터입니다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 SecurityContext에 인증 정보 저장
            // ... (Authentication 객체 생성 및 저장 로직)
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

8. 로그인 테스트

간단한 index.html을 만들어 테스트 해보겠습니다.
Spring Security OAuth2 Client는 기본적으로 /oauth2/authorization/{registrationId} 형식의 요청을 가로채서 소셜 로그인 페이지로 리다이렉트합니다.

<!-- index.html -->
<h1>소셜 로그인 테스트</h1>
<a href="/api/oauth2/authorization/kakao" class="btn btn-warning">Kakao Login</a>
<a href="/api/oauth2/authorization/google" class="btn btn-primary">Google Login</a>
<a href="/api/oauth2/authorization/naver" class="btn btn-success">Naver Login</a>

💡 트러블 슈팅 (자주 겪는 오류)

Q. redirect_uri_mismatch 에러가 떠요!
A. 개발자 센터의 Redirect URI 설정과 application.properties의 설정이 토씨 하나 안 틀리고 정확히 일치해야 합니다. 특히 http vs https, 혹은 끝에 / 유무를 확인해 보세요.


Q. NullPointerException이 발생해요.
A. CustomOAuth2UserService에서 카카오의 응답 구조(Map)를 파싱할 때 키 값이 잘못되었을 확률이 높습니다. Log4j를 추가하여 log.debug(attributes) 혹은 log.info(attributes)로 OAuth2 제공자(Kakao, Google, Naver)가 보내주는 전체 JSON 데이터를 로그로 찍어서 구조를 확인해보세요.

마치며

내용이 정말 길어서 따라오기 벅차긴 했지만, 구현하니 뿌듯하더라구요☺️
여기까지 따라오신 분들 모두 구현에 성공하시길 바랄게요!
질문은 댓글로 남겨주시면 답변 드리겠습니다.

감사합니다🙏

profile
느려도 천천히라도 기록하는 백엔드 개발자👩🏻‍💻

0개의 댓글