[내일배움캠프] Spring Security + OAuth 2.0
1. Spring Security란?
Spring Security란 Spring 기반 애플리케이션의 보안을 담당하는 프레임워크이며, 인증, 인가, CSRF, 세션 관리, 비밀번호 암호화 등 다양한 기능을 제공하고 있다. Spring Security는 기본적으로 Servlet Filter 기반으로 동작하며, FilterChainProxy를 통해 보안 관련 필터들을 순차적으로 적용한다.
Spring Security가 강력한 이유는 Spring Security가 제공하는 아키텍처 위에서 개발할 경우 내가 크게 노력하지 않아도 객체지향이 녹여진 코드 작성이 가능하다는 데에 있다. 따라서 Spring Security를 쓸거면 반드시 아키텍처에 대한 확실한 이해를 가지는 게 필수다.
1-1. Spring Security 구조

- DelegatingFilterProxy는 내부적으로 Spring Bean으로 등록된 필터(기본적으로 FilterChainProxy)에게 작업을 위임한다.
- 서블릿 필터는 원래 스프링 컨테이너 바깥에서 동작한다. 그런데 Spring Security는 수많은 필터들을 관리하므로, 이 필터들을 스프링 빈으로 등록하고 DI(의존성 주입), 설정 등을 쓰기 위해 DelegatingFilterProxy가 서블릿 필터인 척 하면서 Spring의 FilterChainProxy 빈에 위임한다.
- 스프링 시큐리티 의존성을 추가하면 기본적인 DefaultSecurityFilterChain 하나가 등록된다.
- FilterChainProxy는 여러 개의 SecurityFilterChain을 관리하고 실행하는 핵심 필터로, 요청에 맞는 SecurityFilterChain을 찾고 그에 해당하는 보안 필터들을 실행한다.
FilterChainProxy (하나)
├── SecurityFilterChain 1 (매칭: /admin/**)
│ ├── Filter A
│ ├── Filter B
│ └── Filter ...
└── SecurityFilterChain 2 (매칭: /api/**)
├── Filter X
├── Filter Y
└── Filter ...
- SecurityFilterChain은 FilterChainProxy가 관리하는 보안 필터들의 리스트로, UsernamePasswordAuthenticationFilter, JwtAuthenticationFilter, ExceptionTranslationFilter 등 실제 필터들이 포함된다.
- 내가 원하는 SecurityFilterChain을 등록하기 위해서는 SecurityFilterChain을 리턴하는 @Bean 메소드를 등록하면 된다.
1-2. Spring Security 동작 흐름

- 사용자가 요청(Request)을 보낸다.
- 요청은 FilterChainProxy에 의해 등록된 여러 보안 필터들을 통과한다.
- 인증 요청 시 UsernamePasswordAuthenticationFilter가 요청을 가로채고 아이디/비밀번호를 추출한다.
- 이 정보를 AuthenticationManager에 전달한다.
- AuthenticationManager은 하나 이상의 AuthenticationProvider를 통해 인증을 수행한다.
- 인증에 성공하면 Authentication 객체를 생성하고 ❗SecurityContextHolder에 저장한다.
- 이후 요청 시 이 인증 정보를 통해 권한을 확인하고 접근 허용 여부를 결정한다.
- 모든 인증 및 인가 절차 후 실제 컨트롤러로 요청이 전달된다.
💁♀️ Tip: 인증이 완료되면 Authentication 객체는 세션 기반으로 저장되며, 이후 요청에서는 해당 객체가 재활용된다. 만약 stateless한 구조(JWT 등)를 사용할 경우, 이 과정을 별도로 구성해야 한다.
1-3. Spring Security 핵심 컴포넌트
- AuthenticationManager
- 인증 요청(Authentication)을 받아서, 내부적으로 여러 AuthenticationProvider에게 위임한다.
- 성공하면 인증된 Authentication 객체를 반환한다.
- Controller 느낌!
Authentication auth = authenticationManager.authenticate(authenticationRequest);
- AuthenticationProvider
- 인증을 실제 수행하는 객체로, 여러 개 등록가능하다. (ex. JWT 인증, DB 기반 인증, 소셜 로그인 등)
- 예: UsernamePasswordAuthenticationToken이 들어오면, 해당 정보를 기반으로 유저를 검증하고 인증 토큰을 반환
- Service 느낌!
public Authentication authenticate(Authentication authentication) {
// 인자로 받은 Authentication은 요청으로 보낸 사용자 정보로, authenticated = false 상태임
// 실제 인증 로직 (DB 조회 등)
// ...
return new UsernamePasswordAuthenticationToken(userDetails, ..., authorities);
}
- UserDetailsService
- 유저 정보를 로드하는 인터페이스로, 주로 AuthenticationProvider 내부에서 호출된다.
- 예: username으로 DB에서 유저 정보를 조회
- ❗UserDetailsService와 UserDetails는 Spring Security의 인증 구조에서 편의를 위해 제공되는 인터페이스일 뿐, 필수는 아니다. 직접 AuthenticationProvider를 구현한다면, 도메인에 프레임워크 의존을 줄이고 더 유연하고 명확한 구조를 설계할 수 있다.
UserDetails userDetails = userDetailsService.loadUserByUsername("admin");
- SecurityContextHolder
- static 메서드를 통해 접근하는 ThreadLocal 기반 저장소로, 내부적으로 SecurityContext 객체를 가지고 있다. 이 SecurityContext 안에 Authentication 객체가 들어있다.
- 이후 인증된 요청에서는 SecurityContextHolder.getContext().getAuthentication() 으로 현재 사용자 정보에 접근 가능하다.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
1-4. 인가(Authorization) 과정
인증된 사용자가 특정 자원에 접근 가능한지 확인하는 과정으로, 스프링에서는 주로 @PreAuthorize, hasRole, hasAuthority 표현식 사용한다.
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) {...}
1-5. SecurityConfig 예시
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests()
.requestMatchers("/login", "/register").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/home")
.and()
.logout()
.logoutSuccessUrl("/login");
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
1-6. 필터에서의 예외 처리
보통 필터나 Spring MVC에서 예외가 발생하면, WAS까지 예외가 전달되고, WAS는 다시 에러 처리를 위한 요청을 Spring MVC에 보낸다. (필터단에서 DispatcherType.REQUEST 설정을 통해 예외 요청에서 필터를 재호출하지 않도록 설정할 순 있다.) 하지만 API 기반에서는 보통 ExceptionHandler로 예외 응답을 처리하고, WAS가 다시 예외 요청을 보내지 않도록 한다.
그렇다면 필터에서 발생한 예외를 커스텀하려면 어떻게 해야할까? 발생한 예외를 잡아서 처리하는 필터를 구현해야한다. Spring Security에서는 AuthenticationEntryPoint나 AccessDeniedHandler와 같은 예외 처리 핸들러가 이를 처리한다. AccessDeniedException는 AccessDeniedHandler가, AuthenticationException는 AuthenticationEntryPoint가 처리한다.
response.setStatus()와 같은 방식으로 응답을 직접 처리하면, Spring이 제공하는 기본 오류 처리 흐름이 중단된다. 즉,
response.setStatus()와 objectMapper.writeValue()로 오류 메시지를 직접 작성하고 응답을 보내게 되면, WAS는 이미 직접 처리된 응답을 받게 되어 /error 요청을 다시 보내지 않는다.
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
2. JWT 기반 인증
Spring Security는 기본적으로 세션 기반 인증으로, 인증 성공 시 서버가 세션(HttpSession) 생성하고 SecurityContext 저장한 후, 이후 요청마다 세션 쿠키(JSESSIONID)를 통해 인증 유지한다.
2-1. 인증 흐름
JWT 기반 인증은 세션을 사용하지 않고, 클라이언트가 인증 성공 시 JWT 토큰을 발급받은 후, 서버는 매 요청마다 토큰을 검증하고 Authentication을 생성하여 SecurityContextHolder에 수동 저장하는 과정을 거친다.
-> 매 요청마다 JWT 토큰을 검증하고, 인증 정보를 만들어 SecurityContextHolder에 주입하는 커스텀 필터를 구현해야한다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // preAuthorize 설정시
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtFilter jwtFilter() {
return new JwtFilter(jwtUtil, userDetailsService);
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
UsernamePasswordAuthenticationFilter는 로그인 폼을 통한 인증을 처리하는 필터이다. 하지만 로그인 페이지 없이 세션 기반 인증을 처리하려면, UsernamePasswordAuthenticationFilter와 유사한 인증 필터를 직접 구현해야 한다.
*실제로, security config를 구현해서 실행시키면 로그인 폼이 뜨지 않고, UsernamePasswordAuthenticationFilter 또한 실행되지 않는 것을 볼 수 있다. (로그인폼 설정과 함께 실행시키면 작동함)
2-2. JwtFilter 구현
OncePerRequestFilter 는 Spring Security 가 아니라 Spring Web 이 제공하는 Filter 이다. 따라서, OncePerRequestFilter 를 통해 JWT 인증을 구현했으면 Spring Security 를 의존하지 말아야 한다. 정작 이런식으로 개발하는 개발자들은 대부분 본인이 Spring Security 를 사용하는줄 알고 어떻게든 해당 프레임워크에 잘못 작성된 코드를 끼워넣기 위해 노력한다.
Spring Security 위에서 동작하는 Filter 를 구현하고 싶다면 AbstractAuthenticationProcessingFilter 추상 클래스를 상속받은 구현체를 만들어야 한다. 그래야 Authentication Object, ProviderManager, AuthenticationProvider 와 같은 Spring Security 가 만들어 놓은 아키텍처 위에서 내가 원하는 협력을 자연스럽게 만들어 나갈 수 있다.

또한, UserDetailsService를 커스텀하지 않고, UserService에 접근하도록 하자. 이는 자기가 개발하고 있는 환경과 맞지 않다면 자기가 개발하고 있는 환경을 이러한 프레임워크 구조에 맞추기는 문제를 해결하기 위함이다. UserDetailsService 가 가지는 책임 loadUserByUsername 가 내가 만들고 있는 로그인이라는 협력과 맞지 않는 메소드 이름이더라도 그냥 감안하고 사용한다.
직접 AuthenticationProvider 의 구현체를 만들었다면 굳이 UserDetailsService 의 구현체를 이용해 사용자 정보를 읽어올 필요가 전혀 없다. 이미 프로젝트에 구현되어 있을 UserService 를 이용해 사용자 정보를 읽어오면 된다.
이러한 방식의 사용은 점점더 안좋은 상황으로 코드를 만들어버린다. UserDetailsService 의 구현체를 만들게 되면 loadUserByUsername 를 오버라이딩 하게되고 이 메소드는 UserDetails 를 반환해야한다. 이로인해 Domain Entity 인 User 가 UserDetails 를 구현하도록 만들기도한다. Domain Entity 까지 프레임워크인 Spring Security 에 의존하는 구조가 되는 것이다.
2-2. JWT 기반 로그아웃
JWT 자체는 상태를 가지지 않기 때문에 "로그아웃" 개념이 존재하지 않는다. 따라서, 로그아웃 시 Refresh Token을 삭제하거나, 블랙리스트(DB/Redis)에 등록한다. Access Token은 보통 클라이언트에서 제거한다.
2-3. 쿠키 기반 JWT 인증 처리
- 쿠키 사용 이유
- 보안 설정 시 HttpOnly, Secure 속성을 통해 XSS, CSRF 방어 강화
- 클라이언트에서 Authorization 헤더 대신 쿠키 자동 포함 가능
- 서버 응답 시 쿠키 설정 예시
ResponseCookie cookie = ResponseCookie.from("access_token", jwtToken)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(Duration.ofMinutes(15))
.build();
response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
- 인증 처리 흐름
- 클라이언트가 요청 시 쿠키 자동 포함
- 커스텀 필터에서 쿠키를 파싱하고 JWT 검증 후 인증 처리
3. OAuth 2.0
3.1 OAuth 2.0이란?
OAuth 2.0은 소셜 로그인만을 위한 기술이 아니다. OAuth 2.0은 클라이언트 애플리케이션이 사용자의 리소스(예: 캘린더, 이메일, 프로필 정보 등) 에 접근할 수 있도록, 권한을 위임하는 표준 인증 프로토콜이다.
우리가 흔히 말하는 소셜 로그인은 OAuth 2.0을 이용한 한 가지 구현 사례일 뿐이며, 더 넓은 범위에서 보면 OAuth 2.0은 인증보다는 '인가(authorization)'에 초점을 둔 기술이다.
-> "내 리소스에 제3자 앱이 접근할 수 있도록 내가 권한을 위임한다"
OAuth 2.0은 환경에 따라 권한 부여 방식을 사용할 수 있도록 4가지 프로토콜(Flow)를 제공한다. 그 중에서 우리는 Authorization Code Grant 방식만 알아본다.
3-2. OAuth 2.0 동작 과정

- Resource Owner는 사용자, Client는 내가 만들 서버, OAuth Provider는 카카오가 된다.
- OAuth Provider는 Authroization Server와 Resource Server로 이루어져 있다.
- 카카오 로그인 페이지는 카카오에서 구현한 로그인 페이지이다.
- Redirect Url은 OAuth Provider로부터 Authorization Code를 받을 Url이다.
3-3. Spring Security 없이 소셜 로그인 구현하기
소셜 로그인은 수단에 불과하고, 궁극적인 목표는 사용자 인증이다. 따라서 인증에 성공할 경우 내 시스템이 선택한 인증 방식(세션, JWT 등)에 따른 결과를 내려줘야 한다.
- 로그인 페이지로 Redirect 해주는 API
- Authorization Code를 받아서 인증을 처리해주는 API

3-4. Spring Security OAuth2 Client 개요

🔁 Spring Security의 Trade-Off
Spring Security는 기본 구조를 빠르게 구현하는 데에는 편리하지만, 내부 동작이 복잡하기 때문에 이슈가 발생했을 때 원인을 추적하기 어려울 수 있다.
만약 내가 객체 지향적으로 구조를 잘 설계할 자신이 있다면, 인증과 인가 기능을 직접 구현하는 것도 좋은 선택이 될 수 있다. 하지만 빠른 개발이 필요한 상황이라면, Spring Security를 활용하는 것도 충분히 실용적인 접근이다.