Spring Security는 대부분의 작업을 자동으로 처리해주기 때문에, 개발자는 최소한의 설정만으로도 주요 소셜 로그인 기능을 구현할 수 있다.
하지만 이러한 자동화는 양날의 검이 될 수 있다. Spring Security가 너무 많은 부분을 알아서 처리해주기 때문에, 문제가 발생했을 때 내부 동작 방식을 이해하지 못하면 해결이 어려울 수 있다. 특히, 커스터마이징이나 디버깅이 필요할 경우 Spring Security의 OAuth 2.0 동작 원리를 파악하는 것이 중요하다.
이번 글에서는 OAuth 2.0이 Spring Security에서 어떻게 동작하고, 어떤 역할을 수행하는지 분석해본다.
OAuth는 다양한 방식으로 동작할 수 있지만, 소셜 로그인의 경우 Authorization Code Grant 방식을 사용한다.
이 방식의 가장 큰 특징은 ID/PW의 유효성 검증 후 바로 액세스 토큰을 발급하지 않는다는 점이다.
대신, Authorization Code라는 임시 코드를 먼저 발급받고, 이를 활용해 Access Token을 요청한다.
이 과정을 통해 민감한 정보의 직접적인 노출을 방지하면서도, 인증 과정의 신뢰성을 한층 더 높인다.
Authorization Code Grant 방식에서 두 번의 Redirection이 수행된다.
첫 번째 Redirection
사용자가 소셜 로그인 버튼을 클릭하면, 소셜 플랫폼의 인증 서버로 리디렉션된다.
두 번째 Redirection
소셜 플랫폼에서 사용자가 로그인을 완료하고 권한 승인을 마치면, Authorization Code가 부여된다.
이 코드는 사전에 지정된 애플리케이션의 엔드포인트로 리디렉션된다.
📌 프로젝트 적용방식 :
Spring Security를 기반으로 OAuth2를 처리하며, JWT 토큰 발급과 사용자 인증 상태를 관리
사용자가 /oauth2/authorization/{provider}
경로로 요청하면 Spring Security의 OAuth2LoginConfigurer가 이를 가로채서 처리한다.
/oauth2/authorization/{provider}
로 요청을 보내면 Spring Security가 이 경로를 감지한다.{provider}
는 OAuth2 제공자의 이름으로, Spring Security가 설정한 ClientRegistration 정보에 따라 동작한다.application.yml
또는 코드에서 설정된다. security:
oauth2:
client:
registration:
naver: # security에서 감지하는 provider(registration id)
client-name: NAVER
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
redirect-uri: "http://localhost:8080/login/oauth2/code/naver"
authorization-grant-type: authorization_code
scope:
- name
- email
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
❗️ 없는 registration id를 사용한 요청
WARN OAuth2AuthorizationRequestRedirectFilter[unsuccessfulRedirectForAuthorization:231] Authorization Request failed: org.springframework.security.oauth2.client.web.InvalidClientRegistrationIdException: Invalid Client Registration with Id: apple org.springframework.security.oauth2.client.web.InvalidClientRegistrationIdException: Invalid Client Registration with Id: apple
/oauth2/authorization/
경로이기만 하면 provider 값이 어떤 값이던 Security가 감지한다.- 이는 registration id와 provider id를 자율적으로 설정 가능하다는 것을 의미한다.
- yml파일에 기존에 존재하였던 naver의 registration id와 provider id를 apple로 변경하였더니 http://localhost:8080/oauth2/authorization/apple 로 요청했을때 네이버 로그인창으로 이동
❗️ .yml의 provider URI와 실제 Redirection된 URI가 다른 이유
Spring Security를 사용해 OAuth 2.0 소셜 로그인을 구현할 때,
.yml
파일에 설정한authorization-uri
와 실제 리디렉션된 URI가 서로 다를 수 있다. 예를 들어, 아래 설정에서 Google의 authorization-uri와 실제 요청된 URL이 다른 것을 확인할 수 있다.설정된 URI와 실제 URI 비교
- .yml에 설정된 URI:
https://accounts.google.com/o/oauth2/v2/auth
- 실제 이동한 URI:
https://accounts.google.com/v3/signin
security: oauth2: client: registration: google: client-name: GOOGLE client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: "http://localhost:8080/login/oauth2/code/google" authorization-grant-type: authorization_code scope: - email - profile provider: google: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth token-uri: https://oauth2.googleapis.com/token user-info-uri: https://openidconnect.googleapis.com/v1/userinfo user-name-attribute: sub
실제url :
https:// accounts.google.com/v3/signin/identifier?…
이유: Spring Security의 내장 Provider 기본 설정
Google, GitHub 등 일부 소셜 로그인 제공자는 Spring Security가 기본 URI 정보를 내장하고 있다.
Spring Security는DefaultOAuth2ProviderDetails
를 통해 OAuth 2.0 Provider의 표준 URI를 자동으로 관리한다. 따라서.yml
파일에서provider
정보를 생략하더라도, Spring Security는 Google의 인증 및 토큰 엔드포인트에 대한 정보를 이미 알고 있다.
이 과정에서, Spring Security는 Google의 최신 엔드포인트를 사용하여 요청을 리디렉션할 수 있다.
Google의 경우,https://accounts.google.com/v3/signin
은 Spring Security가 자동으로 처리하는 최신 엔드포인트다.
- ❗️실제로 .yml 파일에서 provider 부분을 제거해도 Google 로그인은 정상적으로 동작한다.
OAuth2AuthorizationCodeGrantRequest
를 통해 이 요청을 처리한다..yml
파일에 설정된 Redirect URI가 정확히 일치해야 다음 단계(Access Token 요청)로 넘어갈 수 있다❗️ 테스트 목적으로 회원정보 요청 단계를 일부러 고장 내고, Authorization Code가 성공적으로 넘어오는지 확인했다.
결과 확인
- 성공적으로 설정한 Redirect URI로 state와 code 값이 전달되는 것을 확인했다.
실제 응답 URL :http://localhost:8080/login/oauth2/code/google?state=0Cd20cWt4yC8P80%3Dcode=4%2F0AanRRrvEUWa128UWfpwM6L8oegw&scope=email+profile+openid…
설정한 .yml :
security: oauth2: client: registration: google: client-name: GOOGLE client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: "http://localhost:8080/login/oauth2/code/google" authorization-grant-type: authorization_code scope: - email - profile
소셜 로그인 과정에서 Authorization Code가 애플리케이션으로 전달되는 순간, 소셜 플랫폼은 인증 절차가 끝났다고 간주한다.
이는 소셜 플랫폼이 이미 다음을 완료했음을 의미한다.
소셜 플랫폼의 역할은 다음과 같
사용자의 신원 확인
사용자가 올바른 ID/PW를 입력했는지 확인하고, 로그인 과정을 진행한다.
권한 승인 처리
사용자가 애플리케이션에 접근 권한을 부여하도록 승인 요청을 처리한다.
Authorization Code 전달
승인된 권한에 따라 Authorization Code를 사전에 등록된 Redirect URI로 전달한다.
이 모든 작업이 완료되면, 소셜 플랫폼은 인증 과정에서 더 이상 개입하지 않는다.
이 설계는 책임 분리와 보안 모델 강화라는 OAuth 2.0의 철학을 반영한 것이다.
소셜 플랫폼에서 Authorization Code를 받은 이후부터는 애플리케이션이 주도적으로 작업을 이어간다.
Access Token 요청
애플리케이션은 Authorization Code를 사용해 OAuth2 제공자에게 Access Token을 요청한다.
사용자 정보 요청
발급받은 Access Token으로 소셜 플랫폼의 API를 호출하여 사용자 정보를 가져온다.
소셜 플랫폼에서 Authorization Code를 발급한 시점은 "내 역할은 끝났다" 는 신호다.
사용자가 소셜 플랫폼에 성공적으로 로그인했다.
사용자의 인증(ID/PW)이 완료되었고, 권한 승인도 마쳤다.
애플리케이션에 사용자 정보 접근 권한이 부여되었다.
Authorization Code를 통해 애플리케이션은 Access Token을 요청할 수 있다.
결과적으로, 소셜 플랫폼의 역할은 인증 대행으로 한정된다.
이후 과정(Access Token 요청, 사용자 정보 활용 등)은 애플리케이션이 주도하며, 이를 통해 사용자 인증 상태를 유지하고 필요한 정보를 처리한다.
customAccessTokenResponseClient
가 Authorization Code와 함께 OAuth2 제공자에게 액세스 토큰을 요청합니다.
RequestEntity
를 생성하여 토큰 URI에 POST 요청.grant_type
, code
, redirect_uri
, client_id
, client_secret
)를 설정. @Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> customAccessTokenResponseClient() {
DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
client.setRequestEntityConverter(grantRequest -> {
OAuth2AuthorizationRequest authorizationRequest = grantRequest.getAuthorizationExchange().getAuthorizationRequest();
OAuth2AuthorizationResponse authorizationResponse = grantRequest.getAuthorizationExchange().getAuthorizationResponse();
String tokenUri = grantRequest.getClientRegistration().getProviderDetails().getTokenUri();
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
parameters.add("grant_type", "authorization_code");
parameters.add("code", authorizationResponse.getCode());
parameters.add("redirect_uri", grantRequest.getClientRegistration().getRedirectUri());
parameters.add("client_id", grantRequest.getClientRegistration().getClientId());
parameters.add("client_secret", grantRequest.getClientRegistration().getClientSecret()); // 강제 추가
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
return new RequestEntity<>(parameters, headers, HttpMethod.POST, URI.create(tokenUri));
});
return client;
}
DefaultAuthorizationCodeTokenResponseClient
를 사용하며, 요청을 커스터마이징하기 위해 setRequestEntityConverter
를 설정
grantRequest
의 기본 정보 추출OAuth2AuthorizationRequest
와 OAuth2AuthorizationResponse
는 인증 과정에서 주고받은 정보를 담고 있음authorizationRequest
: 인증 요청 정보(리다이렉트 URI, 클라이언트 ID …)authorizationResponse
: 인증 제공자가 반환한 응답(Authorization Code, state …)tokenUri
: OAuth2 제공자의 토큰 엔드포인트 URL(yml provider에 설정)MultiValueMap
을 사용해 토큰 요청에 필요한 파라미터를 구성(grant_typ, code, redirect_uri, client_id, client_secret)
❗️ MultiValueMap
자바에 기본 내장된 인터페이스가 아닌 스프링에서 제공하는 인터페이스
Map이 인터페이스를 상속할 때 vlaue 값을 list로 감싼 채로 상속받음.
즉 하나의 key와 하나 이상의 value로 이루어진 리스트를 쌍으로 받음
Content-Type
을 application/x-www-form-urlencoded
로 설정.RequestEntity
는 HTTP 요청을 나타냅니다.parameters
, headers
, HttpMethod.POST
, URI.create(tokenUri)
를 사용해 요청 엔터티를 생성.DefaultAuthorizationCodeTokenResponseClient
반환3번에서 발급받았던 액세스 토큰을 사용하여 소셜 로그인 제공자의 사용자 정보를 요청.
CustomOAuth2UserService
에서 loadUser
메서드가 호출됩니다.❗️
DefaultOAuth2UserService
의loadUser
메서드
Spring Security가 제공하는 기본 구현으로, 액세스 토큰을 사용해 사용자 정보를 요청하고 응답을 파싱하여 반환합니다.
loadUser
메서드에서 제공자별 사용자 정보(Attributes)를 가져옵니다.
- Google, Kakao, Naver와 같이 제공자별 데이터 구조에 따라 사용자 정보를 파싱.
- 이메일, 이름, 프로필 정보 등을 추출(사용할 수 있는 scope 범위는 따로 설정해야함)
loadUser
메서드의 작동 방식 @Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
OAuth2User oAuth2User = super.loadUser(userRequest);
String providerName = userRequest.getClientRegistration().getClientName().toUpperCase();
Provider provider = getValidProvider(providerName);
Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> response = extractResponseAttributes(providerName, attributes);
String emailOrUniqueId = resolveEmailOrUniqueId(providerName, response, attributes);
String name = resolveName(providerName, attributes, response);
saveOrUpdateUser(emailOrUniqueId, name, provider);
return createCustomOAuth2User(response, providerName, emailOrUniqueId);
}
Spring Security가 loadUser
를 호출할 때 OAuth2UserRequest
객체를 전달합니다. 이 객체에는 다음 정보가 포함됩니다:
ClientRegistration
: 클라이언트 애플리케이션의 OAuth2 클라이언트 등록 정보 (ex. CLIENT ID, CLIENT SECRET등).accessToken
: 소셜 인증 제공자로부터 발급받은 액세스 토큰.additionalParameters
: 추가 요청 매개변수.DefaultOAuth2UserService
의 loadUser
메서드는 소셜 인증 제공자의 사용자 정보 API를 호출하여 정보를 가져옵니다:
OAuth2User oAuth2User = super.loadUser(userRequest);
loadUser
메서드는 소셜 제공자(구글, 카카오 등)에 따라 다른 사용자 정보 구조를 처리합니다. CustomOAuth2UserService
에서는 사용자 정보 응답을 커스터마이징하여 처리합니다:
getAttributes
: 사용자 정보 응답의 JSON 데이터를 Map<String, Object>
로 변환.extractResponseAttributes
: 소셜 제공자별로 다른 응답 형식을 처리하는 로직:KAKAO
: kakao_account
에서 이메일, 닉네임 등 추출.NAVER
: response
객체에서 필요한 데이터 추출.GOOGLE
: 기본 응답에서 데이터 추출.saveOrUpdateUser
메서드에서 동일한 email+provider 이면서 UserStatus가 ACTIVE인 사용자 조회.(프로젝트 중복회원 정책 고려)CustomOAuth2User
객체를 생성하여 인증된 사용자 정보를 보유..oauth2Login(oauth -> oauth
.tokenEndpoint(token -> token
.accessTokenResponseClient(customAccessTokenResponseClient())
)
.userInfoEndpoint(userInfo -> {
userInfo.userService(customOAuth2UserService);
})
.successHandler((request, response, authentication) -> {
response.sendRedirect("/users/signin/oauth");
})
.failureHandler((request, response, exception) -> {
throw new InvalidLoginException();
})
);
/users/signin/oauth
호출 AuthService
를 사용하여 JWT 액세스 토큰과 리프레시 토큰을 생성.(자체 로그인과 동일한 jwt 토큰 발급 로직 사용) @GetMapping("/users/signin/oauth")
public ResponseEntity<ResponseDTO<AuthResponseDto>> loginSuccess(Authentication authentication) {
if (authentication == null || !(authentication.getPrincipal() instanceof CustomOAuth2User)) {
throw new InvalidLoginException();
}
CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
String email = customOAuth2User.getEmail();
String provider = customOAuth2User.getProvider();
User user = userQueryRepository.findActiveUserByEmailAndProvider(email, Provider.valueOf(provider))
.orElseThrow(InvalidLoginException::new);
AuthResponseDto authResponse = authService.generateAndStoreTokens(user);
return ResponseEntity.ok(ResponseDTO.okWithData(authResponse));
}
OAuthController
의 /users/signin/oauth
엔드포인트에서 성공적으로 로그인한 사용자에 대한 응답 반환.
❗️ 최종 단계에서 /users/signin/oauth
로 호출을 하고, 기존 로그인 로직과 동일하게 토큰을 생성하여 응답한다