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

앱 > 앱 생성 에서 기본 정보 입력
앱 설정 > 앱 > 생성한 앱 클릭 > 플랫폼 키에서 개발하는 환경에 맞는 키를 application.properties 에 붙여넣기(GitHub Repository에 노출 안되게 조심하기‼️)
# 어떤 앱에서 요청이 왔는지 카카오에서 식별할 식별 키
spring.security.oauth2.client.registration.kakao.client-id=YOUR_KAKAO_REST_API_KEY
클라이언트 시크릿 > 카카오 로그인 코드 복사 및 붙여넣기(GitHub Repository에 노출 안되게 조심하기‼️)
# API 키와 함께 앱을 인증하는 비밀 키. access_token을 요청할 때 필요함
spring.security.oauth2.client.registration.kakao.client-secret=YOUR_KAKAO_CLIENT_SECRET

REST API 키 수정 화면 > 카카오 로그인 리다이렉트 URI 에 동일하게 등록이 필요해요# 카카오 로그인 성공 후 authorization code를 받을 Callback URI(각자 정하기 나름!)
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/api/login/oauth2/code/kakao
# 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
이제 설정 파일을 바탕으로 실제 자바 코드를 작성해 보겠습니다.
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'
}
스프링 부트 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();
}
}
소셜 로그인으로 가져온 사용자 정보를 저장할 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);
}
여러 소셜 로그인(구글, 카카오, 네이버)의 응답 형태가 서로 다르기 때문에, 공통 인터페이스를 정의하고 각 소셜 서비스별로 구현체를 만들어 데이터를 통일성 있게 처리합니다.
public interface OAuth2UserInfo {
String getProviderId();
String getProvider();
String getEmail();
String getName();
}
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");
}
}
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");
}
}
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");
}
}
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();
}
}
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);
}
}
SecurityConfig에서 사용되는 JWT 관련 컴포넌트와 로그인 성공 핸들러입니다.
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;
}
}
// ... 토큰에서 사용자 정보 추출 메서드 등 추가
}
소셜 로그인 성공 후 실행되는 핸들러입니다.
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);
}
}
모든 요청에 대해 헤더를 검사하여 유효한 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;
}
}
간단한 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 데이터를 로그로 찍어서 구조를 확인해보세요.
내용이 정말 길어서 따라오기 벅차긴 했지만, 구현하니 뿌듯하더라구요☺️
여기까지 따라오신 분들 모두 구현에 성공하시길 바랄게요!
질문은 댓글로 남겨주시면 답변 드리겠습니다.
감사합니다🙏