Spring Security OAuth2 · OIDC 개념과 소셜 로그인 구현
OAuth2 Authorization Code Flow와 OIDC의 차이를 이해하고, Spring Security OAuth2 클라이언트로 Google·Kakao 소셜 로그인을 구현하는 방법을 실전 예제와 함께 정리합니다.
지난 글에서 AOP 기반 메서드 보안을 살펴봤습니다. 이번 글에서는 현대 웹 서비스에서 가장 흔히 사용되는 인증 방식인 OAuth2와 그 위에 인증 레이어를 추가한 OIDC의 개념을 정리하고, Spring Security OAuth2 클라이언트 설정으로 Google·Kakao 소셜 로그인을 구현하는 방법을 다룹니다.
OAuth2 vs OIDC — 핵심 차이
OAuth2와 OIDC는 자주 혼용되지만 목적이 다릅니다.
- OAuth2 (RFC 6749): 인가(Authorization) 프레임워크입니다. “이 앱이 내 Google 캘린더에 접근해도 되나요?”처럼 자원(리소스)에 대한 접근 권한을 위임하는 것이 목적입니다. Access Token을 발급하지만, 이 토큰에 사용자 신원 정보가 담겨 있다는 보장이 없습니다.
- OIDC (OpenID Connect): OAuth2 Authorization Code Flow 위에 인증(Authentication) 레이어를 추가한 표준입니다. Access Token과 함께 **ID Token(JWT)**을 발급하며, 이 JWT에는 사용자 이름, 이메일, 발급자 등 신원 정보(클레임)가 포함됩니다. Google·Kakao·GitHub 등 대부분의 소셜 로그인이 OIDC를 구현합니다.
Authorization Code Flow 전체 과정
총 8단계로 이루어집니다:
- 브라우저가
GET /oauth2/authorization/google을 앱에 요청합니다. - 앱이 인가 서버(Google)의 로그인 페이지로 302 리다이렉트합니다. 이때
client_id,redirect_uri,scope,state파라미터를 포함합니다. - 사용자가 Google에서 로그인하고 권한 동의(consent)를 합니다.
- 인가 서버가 앱의 callback URL(
/login/oauth2/code/google)으로 Authorization Code를 담아 리다이렉트합니다. - 앱이 백채널(브라우저 없이 서버 to 서버)로 인가 서버의
/token엔드포인트에 Authorization Code와client_secret을 보내 토큰을 교환합니다. - 인가 서버가 Access Token과 ID Token(OIDC)을 반환합니다.
- (선택) 앱이 Access Token으로
/userinfo엔드포인트를 호출해 사용자 프로필을 가져옵니다. - 앱이 사용자 세션을 생성하고 브라우저를 홈으로 리다이렉트합니다.
Authorization Code가 짧은 수명을 가지고 백채널 교환을 요구하는 이유: 브라우저 URL에 노출되는 Code만으로는 토큰을 얻을 수 없고 client_secret이 있어야 합니다. client_secret은 서버에만 보관되므로 MITM 공격에서 토큰이 탈취되지 않습니다.
Spring Security OAuth2 클라이언트 의존성
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
application.yml 설정
Google은 Spring Security가 내장 지원(CommonOAuth2Provider.GOOGLE)하므로 client-id와 client-secret만 설정하면 됩니다. Kakao처럼 사용자 정의 공급자는 provider 섹션에 엔드포인트를 직접 명시합니다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_SECRET}
scope: openid, profile, email
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: profile_nickname, account_email
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
SecurityFilterChain 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
CustomOAuth2UserService customOAuth2UserService) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)))
.logout(logout -> logout.logoutSuccessUrl("/"))
.build();
}
oauth2Login()을 추가하는 것만으로 Spring Security가 OAuth2 관련 엔드포인트(/oauth2/authorization/{registrationId}, /login/oauth2/code/{registrationId})를 자동으로 등록합니다.
OAuth2UserService 커스텀 — 소셜 사용자 DB 저장
소셜 로그인 사용자를 DB에 저장하거나 업데이트하려면 OAuth2UserService를 구현합니다.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate =
new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttribute = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(
registrationId, userNameAttribute, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(u -> u.updateProfile(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
OAuthAttributes는 공급자마다 다른 응답 구조를 통일하는 DTO입니다. Google은 name, email, picture 키를 직접 반환하지만, Kakao는 kakao_account.email처럼 중첩 구조입니다.
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
public static OAuthAttributes of(String registrationId,
String nameAttrKey, Map<String, Object> attributes) {
if ("kakao".equals(registrationId)) {
return ofKakao(nameAttrKey, attributes);
}
return ofGoogle(nameAttrKey, attributes);
}
private static OAuthAttributes ofGoogle(
String nameAttrKey, Map<String, Object> attrs) {
return OAuthAttributes.builder()
.name((String) attrs.get("name"))
.email((String) attrs.get("email"))
.picture((String) attrs.get("picture"))
.attributes(attrs)
.nameAttributeKey(nameAttrKey)
.build();
}
@SuppressWarnings("unchecked")
private static OAuthAttributes ofKakao(
String nameAttrKey, Map<String, Object> attrs) {
Map<String, Object> kakaoAccount =
(Map<String, Object>) attrs.get("kakao_account");
Map<String, Object> profile =
(Map<String, Object>) kakaoAccount.get("profile");
return OAuthAttributes.builder()
.name((String) profile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.picture((String) profile.get("profile_image_url"))
.attributes(attrs)
.nameAttributeKey(nameAttrKey)
.build();
}
}
현재 로그인 사용자 정보 접근
OAuth2로 로그인한 사용자 정보는 @AuthenticationPrincipal로 주입받습니다.
@GetMapping("/profile")
public String profile(@AuthenticationPrincipal OAuth2User principal, Model model) {
model.addAttribute("name", principal.getAttribute("name"));
model.addAttribute("email", principal.getAttribute("email"));
return "profile";
}
커스텀 UserDetails와 OAuth2를 함께 쓰는 경우 공통 인터페이스를 구현하거나 OidcUser/OAuth2User를 래핑하는 어댑터 패턴을 사용합니다.
OIDC ID Token 활용
openid scope를 포함하면 OIDC ID Token(JWT)이 발급됩니다. OidcUserService를 사용하면 OidcUser로 ID Token의 클레임에 직접 접근할 수 있습니다.
userInfoEndpoint(userInfo -> userInfo
.oidcUserService(customOidcUserService))
public class CustomOidcUserService
implements OAuth2UserService<OidcUserRequest, OidcUser> {
@Override
public OidcUser loadUser(OidcUserRequest userRequest) {
OidcUserService delegate = new OidcUserService();
OidcUser oidcUser = delegate.loadUser(userRequest);
String email = oidcUser.getEmail(); // ID Token 클레임
String sub = oidcUser.getSubject(); // 사용자 고유 ID
// ... DB 저장 로직
return oidcUser;
}
}
지난 글: Spring Security 메서드 보안 — @PreAuthorize · @PostAuthorize
다음 글: Spring Security JWT 인증 구현
읽어주셔서 감사합니다. 😊