<Spring Security 아키텍처>

서블릿 필터는 기본적으로 서블릿 컨테이너가 관리하기 때문에 스프링 DI 컨테이너에서 직접 관리할 수 없다. Spring Boot를 사용하면 서블릿 컨텍스트와 스프링 컨텍스트를 연결할 수 있으며, FilterRegistrationBean이나 @Component를 이용해 필터를 스프링 빈으로 등록할 수 있다.
Spring Boot를 사용하지 않을 경우 DelegatingFilterProxy를 이용하여 이를 해결할 수 있는데, Spring Security는 이를 통해 서블릿 필터 체인과 스프링 빈을 연결한다.

DelegatingFilterProxy는 서블릿 필터 체인과 스프링 컨테이너 사이에서 두 환경을 연결해주는 다리 역할을 한다.

클라이언트의 요청이 발생하면, 해당 요청은 먼저 서블릿 필터 체인을 따라 이동하다가 DelegatingFilterProxy를 만나게 된다. DelegatingFilterProxy는 스프링 빈 저장소에서 springSecurityFilterChain이라는 이름으로 등록된 빈을 찾아 요청 처리를 위임한다.
이때 springSecurityFilterChain이라는 이름으로 등록된 스프링 빈의 실제 타입은 Spring Security의 FilterChainProxy이다. 그 후, FilterChainProxy는 Spring Security의 보안, 인증, 인가 처리를 담당하는 여러 필터들을 포함하고 있는 SecurityFilterChain에게 요청 처리를 위임한다.

이때 SecurityFilterChain은 여러 개 존재할 수 있으며, FilterChainProxy는 현재 요청을 처리하기에 가장 알맞은 SecurityFilterChain을 선택해 요청을 전달한다.

해당 요청을 처리하기 위해 선택된 SecurityFilterChain 내부에는 인증, 인가, 기타 보안 처리를 위한 다양한 필터들이 포함되어 있고, 이 필터들을 차례로 거치며 요청에 대한 사전 보안 작업이 수행된다.
이 모든 필터 처리가 성공적으로 끝나면, 요청은 DispatcherServlet으로 전달되고, 이후 적절한 컨트롤러에서 비즈니스 로직이 수행되는 흐름으로 이어진다.
<SecurityFilterChain>
SecurityFilterChain은 인증, 인가, 기타 보안 처리를 담당하는 핵심 컴포넌트다. 별도의 커스터마이징이 없다면, Spring Boot의 자동 구성에 따라 Spring Security가 제공하는 기본 설정의 SecurityFilterChain이 등록된다. 보통 Spring Security 의존성만 추가하고 애플리케이션을 실행하면, 기본 로그인 폼이 뜨고 세션 기반 인증이 작동한다.
우리 프로젝트는 폼 로그인도, 세션 기반 로그인도 사용하지 않는다. 즉, Spring Security에서 기본으로 제공하는 SecurityFilterChain 설정으로는 우리가 원하는 인증/인가 방식이 동작하지 않는다. 따라서 SecurityFilterChain을 우리 프로젝트에 맞게 직접 커스터마이징해줘야 한다.

HttpSecurity는 SecurityBuilder의 구현체중 하나로, 여러 SecurityConfigurer를 적용하여 최종적으로 SecurityFilterChain을 구성한다.
SecurityConfigurer는 인증, 인가, 세션 관리, CSRF 같은 개별 보안 기능을 담당하며, 해당 기능에 필요한 필터들을 생성하고 초기화한다. 이러한 SecurityConfigurer들이 조합되어 최종적으로 원하는 SecurityFilterChain이 만들어진다.
@Bean
public SecurityFilterChain customSecurityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin(Customizer.withDefaults()) // 폼 로그인을 위한 Configurer 적용
.cors(Customizer.withDefaults()) // CORS 처리를 위한 Configurer 적용
.csrf(Customizer.withDefaults()); // CSRF 보호를 위한 Configurer 적용
// 이 시점에 설정된 Configurer들의 init(), configure() 메서드가 실행되면서
// 필요한 필터들이 생성되고 최종적으로 SecurityFilterChain 객체가 반환된다.
return http.build();
}
예를 들어, customSecurityFilterChain이라는 이름의 SecurityFilterChain을 HttpSecurity가 제공하는 빌더를 통해 만들 수 있고 각각의 세부 설정은 해당하는 Configurer가 담당하며, Configurer를 통해 각 설정에 대한 세부적인 추가 설정도 가능하다.
이미 Spring Security에서 만들어 놓은 인증, 인가, 기타 보안 처리에 관한 Configurer들이 있으며, 이들을 HttpSecurity라는 빌더를 통해 API 레벨로 사용하면, 우리의 SecurityFilterChain에 필요한 필터들이 자동으로 생성된다고 이해했다.

이제 SecurityFilterChain 안에 원하는 필터들을 어떻게 구성하는지 알게되었다. HttpSecurity 외에도 WebSecurity라는 빌더가 제공되는데, HttpSecurity가 개별적인 SecurityFilterChain을 구성하는 빌더라면, WebSecurity는 이렇게 구성된 여러 SecurityFilterChain들을 등록하고 관리하는 상위 빌더이다.
최종적으로 WebSecurity는 여러 개의 SecurityFilterChain을 모아 FilterChainProxy를 생성하고, 이 FilterChainProxy가 서블릿 필터로 등록되어 실제 요청 처리 과정에 참여한다.

내가 직접 구성한 SecurityFilterChain 안에 어떤 필터들이 포함되어 있는지 눈으로 확인해볼 수도 있다. HttpSecurity에 Configurer를 직접 추가하지 않더라도, Spring Security는 자체적으로 동작하기 위해 필요한 여러 기본 필터들을 자동으로 생성해서 등록해준다.
<OAuth2Client를 이용한 GitHub 소셜 로그인 구현>
OAuth를 이용한 사용자 인증은 내가 원하는 OAuth Provider의 공식문서를 보며 직접 구현 할 수도 있지만, Spring Security 측의 OAuth2Client 모듈을 이용하면 OAuth 인증 과정을 비교적 쉽게 구축하고, 필요한 부분만 선택적으로 커스터마이징할 수 있다.
실제로 두 가지 방식 모두 시도해 본 결과, 나는 OAuth2 Client 모듈을 사용하는 편이 안정성과 코드 가독성 측면에서 더 우수하다고 판단했다. 물론 Spring Security나 OAuth2 Client 없이 직접 구현하는 것을 선호하는 굉장히 실력 있는 개발자분들도 많다고 한다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
가장 먼저, Spring Security와 OAuth2 Client 관련 의존성을 프로젝트에 추가했다. 다음 단계로는 GitHub OAuth 로그인을 위한 설정 정보를 application.yml 파일에 작성해주는 작업이 필요하다.
application.yml에 필요한 설정을 살펴보기 전에, Authorization Code Grant 방식의 OAuth 로그인 플로우를 간단히 정리하는 것이 좋을 것 같다.

우선, 어떤 사용자가 우리의 서비스에서 로그인을 하기 위해서 "Github로 시작하기" 버튼을 누를 것이다.

사용자는 이런 Github 로그인 화면을 보게된다.

사용자가 "깃허브로 로그인" 버튼을 클릭하면, 내 서비스는 사용자를 GitHub 로그인 페이지로 리다이렉트 하면서 쿼리 스트링에 response_type, client_id, scope, state, redirect_uri 등의 파라미터를 함께 전달한다.

깃허브 로그인 페이지에 어떤 쿼리 파라미터를 함께 보내야 하는지에 대한 설명은 공식문서에 잘 나와 있다.

response_type의 경우, 값으로 "code"를 사용하면 OAuth의 여러 인증 방식 중 Authorization Code Grant 방식을 사용하겠다는 의미가 된다. OAuth에는 이 외에도 다양한 인증 방식이 존재한다.
client_id는 GitHub OAuth 로그인을 이용하기 위해 사전에 등록한 애플리케이션(Client)의 식별자를 의미한다. 즉, 내 서비스의 식별자 이다.
scope는 클라이언트(우리 서비스)가 사용자의 자원(Resource)에 대해 어떤 범위까지 접근을 요청하는지를 지정하는 값이다.
예를 들어 read:user 스코프를 사용하면, 우리 서비스는 GitHub 상의 사용자 프로필 정보를 조회할 수 있지만, 그 외의 작업은 불가능하다.
반면, follow:user 스코프를 사용하면, 우리 서비스는 사용자의 권한을 위임받아 다른 GitHub 사용자를 팔로우하거나 언팔로우할 수 있게 된다.

scope에 대한 더 자세한 내용은 Github 공식문서에 잘 정리되어 있으며, 우리 서비스에 필요한 권한 범위를 정확히 지정해 사용하면 된다. state와 redirect_uri는 뒤에서 정리할 생각이다.
잠깐 정리하자면, 사용자가 우리 서비스에서 "깃허브로 시작하기" 버튼을 누르면, 사용자의 웹 브라우저는 GitHub의 Authorization Server로 리다이렉트 되면서, 함께 전달받은 client_id, redirect_uri 등의 값이 사전에 등록한 애플리케이션 정보와 일치하는지를 검증하고 모두 일치하면 사용자에게 깃허브 로그인 페이지를 보여준다.

이후 사용자가 GitHub 로그인을 정상적으로 완료하면, GitHub는 우리가 사전에 설정해둔 redirect_uri 경로로 임시 코드(authorization code)를 포함하여 리다이렉트한다.

마지막 단계로, 클라이언트(우리 서비스)는 사용자가 전달한 임시 코드(authorization code)와 함께 client_id, client_secret, redirect_uri 등의 값을 GitHub에 전송한다. 그러면 GitHub는 이 값들을 검증한 뒤, 모든 값이 유효하다면 OAuth Access Token과 기타 관련 정보를 응답으로 반환한다.

이때 어떤 값을 어떤 경로로 보내야 하고, 어떤 응답을 받을 수 있는지는 GitHub 공식 문서에 상세히 안내되어 있다. 즉, GitHub OAuth 로그인 API를 사용하기 위해서는 공식 매뉴얼을 참고하여 위와 같은 흐름대로 구현하면 되며, 이 과정을 OAuth2Client 모듈을 활용하면 더 쉽게 구현이 가능하다.

Github의 경우 위와 같이 GitHub OAuth App을 등록하면, Client ID와 Client Secret을 발급받을 수 있으며, Authorization callback URL 설정을 통해 redirect_uri을 지정할 수 있다.
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
redirect-uri: ${REDIRECT_URI}
scope: ${SCOPE}
OAuth 로그인 플로우를 살펴 봤으니 다시 OAuth2 Client 사용을 위해 위와 같이 application.yml을 작성하면 된다.

GitHub와 같은 주요 OAuth 제공자의 경우, OAuth2Client 모듈에서 제공하는 CommonOAuth2Provider를 통해 OAuth에 필요한 기본 설정값들을 자동으로 구성할 수 있다.
예를 들어, GitHub 로그인 페이지 URI나 액세스 토큰을 교환하는 URI 등이 이미 정의되어 있기 때문에, 우리는 프로젝트마다 달라지는 client_id, client_secret, redirect_uri, scope 정도만 설정해주면 된다.

OAuth2Client 모듈이 OAuth 로그인과 관련된 Configurer를 위한 API를 이미 제공하므로, 나는 API 레벨에서 이를 활용하기만 하면 된다.
@Bean
public SecurityFilterChain customSecurityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin(Customizer.withDefaults()) // 폼 로그인을 위한 Configurer 사용
.cors(Customizer.withDefaults()) // cors를 처리하기 위한 Configurer 사용
.csrf(Customizer.withDefaults()) // csrf를 처리하기 위한 Configurer 사용
.oauth2Login(Customizer.withDefaults()); // OAuth 로그인을 처리하기 위한 Configurer 사용
// 이 시점에 내가 담은 Configurer들의 내부에서 init(), configure()가 실행되면서
// 필터들이 생성되고 최종적으로SecurityFilterChain 객체 반환
return http.build();
}
위와 같이 oauth2Login() API를 활용하면, 내가 만들고자 하는 SecurityFilterChain에 OAuth 로그인에 필요한 필터들이 추가된다.

oauth2Login()을 SecurityFilterChain에 포함시키면, OAuth2 로그인 흐름에 필요한 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter가 필터 체인에 추가되는 것을 확인할 수 있다.
application.yml에 정의된 환경변수와 CommonOAuth2Provider를 통해 GitHub 관련 URL 등을 자동으로 설정해준다. 만약 이 과정을 더 세부적으로 제어하고 싶다면, ClientRegistration에 대해서 알아보면 된다.

Spring Security는 기본적으로 로그인 및 로그아웃 페이지를 자동으로 생성해 제공한다. 현재 나는 폼 로그인과 GitHub OAuth 로그인을 모두 설정해두었기 때문에, 위와 같은 기본 화면이 출력된다. 하지만 내 프로젝트는 API 서버이므로 별도의 로그인/로그아웃 페이지는 필요 없고, GitHub 로그인을 유일한 인증 방식으로 사용할 예정이기 때문에 폼 로그인은 비활성화 할 생각이다.
만약 Spring Security의 AuthenticationEntryPoint를 커스터마이징 할 경우 Spring Security가 기본으로 제공하는 로그인 페이지는 비활성화되며 DefaultLoginPageGeneratingFilter와 DefaultLogoutPageGeneratingFilter도 등록되지 않는다.

이제 내 API 서버에는 필요하지 않은 필터들이 제거되었다. UsernamePasswordAuthenticationFilter는 폼 로그인 기반 인증을 처리하는 Spring Security의 기본 필터이다. 하지만 내 서비스에서는 사용자가 인증을 받을 때 JWT 인증 또는 GitHub OAuth 로그인만 사용하므로, 이 필터는 불필요하며 제거하였다.

DefaultResourcesFilter는 기본 UI에서 사용하는 CSS, JavaScript 등의 정적 리소스를 제공하는 필터이다. 하지만 내 서비스는 기본 로그인/로그아웃 페이지가 필요 없기 때문에, 이를 생성하는 필터들과 함께 제거하였다.

공식 문서를 확인하면 oauth2Login() API를 통해 OAuth2 Client에서 어떤 커스터마이징이 가능한지 잘 설명되어 있다.

이제 사용자가 프론트에서 제공하는 깃허브로 로그인 하기 버튼을 클릭했을 때, 깃허브 로그인 페이지로 여러 파라미터들을 갖고 리다이렉트 하도록 해줘야 한다.

내 SecurityFilterChain에는 Spring Security의 OAuth2 Client를 사용하여 oauth2Login() API를 이용하기 때문에, OAuth2AuthorizationRequestRedirectFilter라는 필터가 자동으로 등록된다.

이 필터는 /oauth2/authorization/{registrationId} 경로로 들어오는 요청을 처리하여, 사용자를 OAuth2 인증 서버의 로그인 페이지로 리다이렉트하는 역할을 한다.

이 과정에서 사용자의 리다이렉트 전 요청 정보를 임시로 저장하고, 사용자가 GitHub에서 로그인하여 임시 코드가 발급된 후, 리다이렉트된 페이지로 돌아오면 서버는 저장된 요청 정보와 리다이렉트 후 돌아온 요청을 비교한다.
이는 사용자가 깃허브에서 로그인에 성공하고 임시 인증코드와 함께 다시 리다이렉트 되어 돌아올 때, 해당 리다이렉트 요청이 해커에게 중간에 탈취되지 않았는지를 검증하기 위함이다.
이 작업을 담당하는 인터페이스는 AuthorizationRequestRepository로, Spring Security에서는 기본 구현체로 서버의 메모리에 사용자의 요청 정보를 저장하는 구현체를 제공한다. 이 때, 저장되는 사용자의 요청 객체는 OAuth2AuthorizationRequest이다.

OAuth2AuthorizationRequest 안에는 위에서 사용자의 요청을 저장하기 위한 여러가지 여러 정보들이 포함되어 있다.

하지만 우리 서비스는 단일 API 서버 인스턴스로 사용자의 요청을 처리하지 않는다. 실제로는 여러대의 API 서버 인스턴스들이 존재하게 된다.

즉, 위의 그림처럼 사용자의 첫 요청이 1번 서버로 들어가면서 OAuth2AuthorizationRequest가 해당 서버에 저장된 이후,

정상적으로 GitHub에서 로그인한 후, 임시 인증 코드를 갖고 2번 서버로 리다이렉트되어 돌아오게 되면, 서버는 OAuth2AuthorizationRequest를 검증하는 과정에서 해당 정보가 존재하지 않거나 일치하지 않아 문제가 발생하게 된다. 즉, 이 부분에 대한 커스터마이징이 필요하다.

OAuth2 Client의 공식 문서를 살펴보면 어떤 부분을 커스터마이징 해야 하는지 나와 있다.
GitHub - callicoder/spring-boot-react-oauth2-social-login-demo: Spring Boot React OAuth2 Social Login with Google, Facebook, and
Spring Boot React OAuth2 Social Login with Google, Facebook, and Github - callicoder/spring-boot-react-oauth2-social-login-demo
github.com
위의 레퍼런스를 참고하여 쿠키 기반의 AuthorizationRequestRepository 구현체를 작성했고
@Bean
public SecurityFilterChain moyoySecurityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin(AbstractHttpConfigurer::disable) // Form Login Disable
.cors(Customizer.withDefaults()) // CORS
.csrf(Customizer.withDefaults()) // CSRF
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/reissue/token", "/health", "/","/permit/all/test", "/error/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2 // OAuth2 Client Login API
.loginPage("/login") // Default Login, Logout Page Disable
.authorizationEndpoint(authorization -> authorization
.baseUri("/auth/login")
.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository)
)
)
return http.build();
}
이를 나의 SecurityFilterChain에 등록했다.

이제 사용자가 정상적으로 GitHub에서 로그인하면, 사용자는 Authorization Code, 내가 발급한 쿠키, GitHub에서 제공한 임시 인증 코드, 그리고 state 파라미터를 함께 포함하여 내 서버로 리다이렉트된다.

실제로 사용자가 내 서버로 리다이렉트되면서 돌아오는 내용을 확인해 보면, 위와 같은 값들이 포함되어 있다. 여기서 code는 GitHub에서 발급한 Authorization Code를 의미한다.
state는 Github에서 로그인 후 Authorization Code를 갖고 리다이렉트 하는 사용자의 요청을 신뢰할 수 있는지 판단하기 위해 사용되는 파라미터이다. 최초 요청 시 서버가 생성해 보낸 값과 리다이렉트 시점의 값이 일치하는지를 확인함으로써, CSRF와 같은 위조 요청을 방지할 수 있다.

이제 리다이렉트 URI로 돌아온 사용자의 요청에서 인증 코드를 추출하고, 이 인증 코드를 GitHub Authorization Server에 전달하여 OAuth Access Token을 발급받아야 한다. 이 과정을 Spring Security OAuth2 Client에서는 OAuth2LoginAuthenticationFilter가 담당한다.

GitHub OAuth 앱을 등록할 때 설정한 callback URL은 사용자가 GitHub에서 로그인을 완료한 후 인증 코드를 가지고 리다이렉트되는 redirect_uri다. 이 redirect_uri는 기본적으로 /login/oauth2/code/* 형식으로 맞춰야 Spring Security의 OAuth2LoginAuthenticationFilter가 요청을 처리할 수 있다. 물론 위의 경로는 원하는 경로로 커스터마이징 가능하다.
OAuth2LoginAuthenticationFilter는 이름 그대로 OAuth 로그인 인증 필터이다. Spring Security에서 사용자의 인증은 Authentication이라는 하나의 객체로 관리 된다. 이 때, Authentication은 인터페이스이고 실제로 폼 로그인, OAuth 로그인 등 어떤 로그인을 수행하느냐에 따라서 실제 구현체는 달라진다.
OAuth2LoginAuthenticationFilter 즉, OAuth 인증 필터에서 실제로 어떤 작업이 발생하는지 살펴본 후, 내 프로젝트의 요구사항에 맞게 커스터마이징할 부분이 있었다.
- OAuth 리다이렉트 필터는 사용자를 실제 GitHub 로그인 페이지로 리다이렉트 하고, 사용자가 정상적으로 Github에서 로그인하면 Github에서 발급해 준 인증 코드를 갖고 내 서버의 redirect_uri 경로로 리다이렉트되어 돌아온다.
- redirect_uri를 login/oauth2/code/github 에서 특별히 커스터마이징하여 변경하지 않았다면, OAuth 인증 필터가 해당 요청은 OAuth 인증 필터를 지나간다.
- OAuth 인증 필터에서는 GitHub 로그인 페이지로 리다이렉트되기 전의 사용자 요청 정보와 GitHub에 로그인 후 리다이렉트 되어 돌아온 현재 요청 정보를 비교하여 올바른 요청인지 검증한다.
- 검증된 요청에 문제가 없다면, OAuth2LoginAuthenticationToken이라는 이름의 아직 인증되지 않은 사용자의 인증 객체를 생성한다. 이 인증 객체에는 사용자의 인증 요청을 위한 여러 가지 정보가 포함되어 있다. 이 객체의 이름을 언급하는 이유는, 해당 객체의 이름이 중요하기 때문이다. 해당 토큰을 아직 인증처리가 안된 사용자의 인증 객체라고 생각하면 된다.
- 이제 인증 객체를 인증 관리자에게 전달한다. 인증 관리자는 전달받은 인증 객체를 처리할 수 있는 여러 인증 제공자를 보유하고 있다. 시큐리티에는 OAuth 인증 제공자 외에도 다양한 로그인 방법을 처리할 수 있는 여러 인증 제공자들이 존재한다. 인증 관리자는 이 중에서 OAuth 로그인과 관련된 인증 제공자를 선택하고, 인증 객체의 실제 인증 처리를 해당 제공자에게 위임한다.
- 인증 요청을 전달 받은 인증 제공자는 내부에서, OAuth Authorization Code Grant 방식을 사용해 사용자가 들고온 Authorization Code와 OAuth Access Token을 교환하는 작업을 담당하는 다른 인증 제공자에게 이 작업을 위임한다. 여기서 처음 인증 관리자에게 인증 요청을 위임받은 인증 제공자는 OAuth 로그인 인증 제공자이고, OAuth 로그인 인증 제공자에게 다시 인증 요청을 위임받은 실제 인증 제공자는 OAuth 인증 코드 인증 제공자이다. 이 두 인증 제공자는 서로 다른 역할을 수행하므로 혼동하지 않도록 주의해야 한다.
- 실제 Authorization Code와 OAuth Access Token의 교환 작업을 맡은 인증 제공자는 RestClient를 사용하여 OAuth Access Token, Refresh Token 및 각 토큰의 만료 기간과 같은 추가 정보를 사용자의 Authorization Code와 교환해 온다. 이를 OAuth 로그인 인증 제공자에게 반환한다.
- OAuth 로그인 인증 제공자는 DefaultOAuth2UserService에 Access Token과 관련된 값을 전달하고 해당 서비스는 OAuth Access Token을 사용하여 실제 사용자의 GitHub 프로필 정보를 GitHub API를 호출해서 가져온다. 여기서 이 서비스의 이름이 중요하다.
- 해당 서비스는 GitHub API에서 가져온 사용자 정보를 기반으로 한 DefaultOAuth2User 객체를 OAuth 로그인 인증 제공자에게 반환한다. 여기서 이 객체의 이름이 중요하다.
- OAuth 로그인 인증 제공자는 사용자의 정보인 DefaultOAuth2User와 사용자의 OAuth Access Token, Refresh Token 등을 비롯한 여러 정보를 포함한 새로운 사용자 인증 객체인 OAuth2LoginAuthenticationToken을 생성한다. 이 객체를 인증 관리자에게 반환하고 인증 관리자는 이 인증 객체를 다시 OAuth 인증 필터에게 전달한다.
- OAuth 인증 필터는 전달받은 인증 객체인 OAuth2LoginAuthenticationToken에서 DefaultOAuth2User와 인증된 사용자의 권한 등의 정보를 추출하여 OAuth2AuthenticationToken이라는 최종 인증 객체로 변환한다. 이때 중요한 건, OAuth2AuthenticationToken에는 사용자의 OAuth Access Token과 Refresh Token 등의 정보가 포함되지 않는다는 점이다. 이 정보들은 OAuth2AuthenticationToken이 아닌, OAuth2AuthorizedClient라는 객체에 담겨 별도로 관리된다. 이 객체는 OAuth2AuthorizedClientRepository라는 리포지토리를 통해 관리되며, 기본적으로 요청을 처리한 서버의 메모리에 저장된다.
- 마지막으로, 최종 인증 객체인 OAuth2AuthenticationToken을 SecurityContext에 저장하고, 이를 쓰레드 로컬 저장소에 설정한다. 이후, SecurityContextRepository를 통해 사용자의 SecurityContext를 세션에 저장하는데 이는 중요한 포인트다. 이후 인증 성공 핸들러를 호출하고 작업이 종료 된다.
참고로, 인가 처리는 SecurityFilterChain의 인가 담당 필터에서 이루어 진다. OAuth 인증 필터에서 발생하는 여러 작업을 살펴보았는데, 이를 하나씩 되짚어보면서 내 프로젝트에서 필요한 부분을 커스터마이징 했다.

우선 이 부분에서 DefaultOAuth2UserService를 조금 더 자세히 알아보고 내 서비스에 맞게 커스터마이징 할 필요가 있었다.

내 프로젝트에서는 GitHub OAuth만 이용하기 때문에, GitHub를 기준으로 해당 서비스는 GitHub 상의 사용자 고유 식별자를 판별한 후, 실제 GitHub 사용자 프로필 조회 요청을 보낸다. 그 후, 응답으로 받은 사용자 정보를 attributes에 저장하고, 사용자의 권한 정보를 authorities에 저장한 후, 이를 바탕으로 DefaultOAuth2User 객체를 생성하여 반환한다.
DefaultOAuth2UserService에는 GitHub OAuth로 로그인한 사용자가 기존 회원인지 확인하여 프로필을 업데이트 하거나, 처음 로그인한 사용자라면 회원가입 하는 로직이 없다.

또한, 최종적으로 반환되는 DefaultOAuth2User 객체에는 내 서비스에서 사용하지 않는 GitHub 사용자 정보까지 모두 포함되어 있었다. 이 객체는 이후 인증 객체의 Principal로서 SecurityContext 내에 유지되는데, 우리 서비스에서는 41개의 attributes 중 실제로 사용하는 attribute는 3개에 불과 했다.
불필요한 데이터를 계속 보관하는 것은 메모리 낭비라고 판단하여, DefaultOAuth2User를 GithubOAuth2User라는 커스텀 객체로 변환한 뒤 UserId를 비롯한 꼭 유지해야 되는 속성만 담아두고 이를 반환하도록 수정했다. 기존 DefaultOAuth2User는 더 이상 참조되지 않아 가비지 컬렉션에 의해 정리될 수 있도록 했다.

우리 서비스는 사용자의 Github Access Token을 활용하여 사용자의 팔로우/언팔로우 요청을 대행하거나, 서비스 내 사용자 정보와 GitHub 사용자 정보를 주기적으로 동기화하는 등의 작업을 진행해야 한다. 이러한 이유로 Access Token을 지속적으로 관리하고 활용할 수 있는 방식으로 커스터마이징이 필요했다.

이 부분을 담당하는 인터페이스는 OAuth2AuthorizedClientService와 OAuth2AuthorizedClientRepository였다.
OAuth2AuthorizedClientService를 통해 특정 사용자의 OAuth2 Access Token을 저장소에서 조회할 수 있었다.
각각의 인터페이스에 대한 기본 구현체는 AuthenticatedPrincipalOAuth2AuthorizedClientRepository와 InMemoryOAuth2AuthorizedClientService였다. 처음에는 서비스 계층은 그대로 두고, 실제 저장소와의 소통을 담당하는 Repository만 커스터마이징하면 될 것이라고 판단했다.
하지만 내부를 살펴본 결과, 예상과 달리 Repository는 단순히 요청 스코프에서의 임시 저장 역할만 수행하고 있었고, 실질적인 저장 및 조회 로직은 OAuth2AuthorizedClientService에 집중되어 있었다.

즉, Repository가 Service를 이용해서 실제 저장소에 있는 사용자의 토큰에 접근하는 구조였다.

Service의 기본 구현체인 InMemoryOAuth2AuthorizedClientService 내부에서는 실제로 Map을 이용해 서버 메모리 상에 사용자의 토큰을 저장하고 있었다. 즉, 다중 API 서버 인스턴스 환경에서 커스터마이징이 필요한 대상은 Repository가 아니라 Service이다.
우리 프로젝트는 매우 저 예산 프로젝트이고 인프라에 MySQL, Redis가 포함되어 있다. 만약, DB 노드 다운으로 인한 단일 장애점 문제를 대비해야 될 시점이 오면 MySQL을 우선으로 확장해 나갈 것 같다. 따라서 사용자 토큰 정보를 RDB에 저장하여 사용하는 방안을 선택했다.

다행히도 JdbcOAuth2AuthorizedClientService라는 구현체를 이미 Security에서 제공하고 있었다. 이를 활용하여 커스터마이징을 진행했으며, 그 결과 OAuth2AuthorizedClientService의 구현체를 손쉽게 변경할 수 있었다.

이제 OAuth 인증 필터에서 발생하는 예외 처리를 내가 원하는대로 커스터마이징 하면 되는데 OAuth2LoginAuthenticationFilter에서 발생한 인증 예외는 ExceptionTranslationFilter나 AuthenticationEntryPoint로 전달되지 않고, 해당 필터 내부에서 정의된 AuthenticationFailureHandler를 통해 직접 처리된다.

이를 별도로 커스터마이징하지 않으면, 기본적으로 SimpleUrlAuthenticationFailureHandler가 처리한다. 이 구현체는 인증 실패 시 실패 URL로 포워드하거나 리다이렉트하고, 설정되어 있지 않으면 401 에러를 응답으로 보낸다. 하지만 나는 API 서버를 개발하고 있기 때문에, 그냥 로그를 남기고 JSON 형태의 에러 응답을 반환하도록 커스터마이징했다.

우리 API 서버는 여러 대의 EC2 인스턴스에서 동작하고 세션저장소나 세션 클러스터링을 사용하지 않고 토큰 기반 인증 유지를 사용하기 때문에 SecurityContext를 세션에 저장할 필요가 없다.

OAuth 인증 필터에서 사용자의 인증이 성공하면 위의 메서드가 실행 된다. 이때, 인증된 사용자의 정보를 담은 SecurityContext 객체가 생성되어 쓰레드 로컬 저장소에 저장되고 SecurityContextRepository를 이용해 이 SecurityContext 객체를 어딘가에 추가적으로 저장하게 된다.
쓰레드 로컬 저장소는 요청 쓰레드의 생명 주기와 맞물려 동작하므로, 요청이 끝나면 자동으로 초기된다. 쓰레드 풀을 사용하지 않을 경우 쓰레드 자체가 파기된다. 따라서 추가적으로 어딘가에 사용자의 Security Context를 저장하지 않으면 사용자는 매번 로그인을 해야한다.

Spring Security 에서는 SecurityContextRepository의 기본 구현체로 DelegatingSecurityContextRepository를 제공한다. 이 구현체는 SecurityContext를 세션과 해당 요청에 저장하는 역할을 한다.
@Bean
public SecurityFilterChain moyoySecurityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.csrf(Customizer.withDefaults())
// 추가
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
...
나는 토큰 방식의 인증 유지를 사용하기 때문에 Security Context가 세션에 저장되지 않게 하기 위해서 위와 같은 설정을 추가해 줬다.

이제 SecurityContextRepository의 구현체는 요청 기반의 구현체로만 변경되었고 인증 필터에서 사용자의 인증이 성공할 경우, 더 이상 세션에 SecurityContext가 저장되지 않는다.
<참고자료>
스프링 시큐리티 OAuth2| 정수원 - 인프런 강의
현재 평점 4.9점 수강생 2,385명인 강의를 만나보세요. 스프링 시큐리티 OAuth2의 기본 개념부터 API 사용법과 내부 아키텍처를 학습합니다. 아울러 OAuth2 Client, OAuth2 Resource Server, Authorization Server를 통
www.inflearn.com
스프링 시큐리티 완전 정복 [6.x 개정판]| 정수원 - 인프런 강의
현재 평점 4.9점 수강생 2,132명인 강의를 만나보세요. 스프링 시큐리티 6.x 최신 버전으로 제작된 개정판 강의로 초급에서 중.고급에 이르기까지 스프링 시큐리티의 기본 개념부터 API 사용법과 내
www.inflearn.com
[Spring] 필터(Filter)가 스프링 빈 등록과 주입이 가능한 이유(DelegatingFilterProxy의 등장) - (2)
몇몇 포스팅과 조금 오래된 책들을 보면 필터(Filter)는 서블릿 기술이라서 Spring의 빈으로 등록할 수 없으며 빈을 주입받을수도 없다는 내용이 나옵니다. 하지만 실제로 테스트를 해보면 Filter 역
mangkyu.tistory.com
Architecture :: Spring Security
The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like exploit protection, authentication, authorization, and more. The filters are executed in a spec
docs.spring.io
Authorizing OAuth apps - GitHub Docs
You can enable other users to authorize your OAuth app.
docs.github.com
스프링 부트 - 핵심 원리와 활용| 김영한 - 인프런 강의
현재 평점 5.0점 수강생 13,299명인 강의를 만나보세요. 실무에 필요한 스프링 부트는 이 강의 하나로 모두 정리해드립니다. 스프링 부트의 내부 동작 원리, 스프링 부트 라이브러리 만들기, 스프
www.inflearn.com
OAuth 2.0 Client :: Spring Security
The HttpSecurity.oauth2Client() DSL provides a number of configuration options for customizing the core components used by OAuth 2.0 Client. In addition, HttpSecurity.oauth2Client().authorizationCodeGrant() enables the customization of the Authorization Co
docs.spring.io
DefaultResourcesFilter (spring-security-docs 6.5.5 API)
Create an instance of DefaultResourcesFilter serving Spring Security's default webauthn javascript. The created DefaultResourcesFilter matches requests HTTP GET /login/webauthn.js, and returns the default webauthn javascript at org/springframework/security
docs.spring.io
'토이 프로젝트 > 깃허브 연동 로그인' 카테고리의 다른 글
| 깃허브 연동 로그인 [03] - JWT 도입 (0) | 2025.10.02 |
|---|---|
| 깃허브 연동 로그인 [01] - 로그인 구현 전 고민해 본 것들 (0) | 2025.09.30 |