"스프링 시큐리티 설정 소스 가져다 붙였는데 로그인이 안 됩니다. 어디서부터 디버깅해야 하죠?" 주니어 시절 누구나 한 번쯤 외쳐본 절규다. 그냥 검은 상자(Black Box)처럼 두고 대충 돌아가면 넘어가고 싶겠지만, 실무에서 멀티 테넌트 환경을 구축하거나, OAuth2 패킷을 쪼개 커스텀 토큰을 박아 넣거나, 갑자기 사내 SSO 연동이 들이닥치는 순간 이 거대한 프레임워크는 우리에게 처절한 고통을 안겨준다. 내부 흐름을 명확히 모른 채 추상화된 메서드만 몇 개 오버라이드했다가는 보안 구멍이 뚫리거나 수많은 쓰레드가 세션 지옥에 갇혀 죽어가는 꼴을 보게 된다.
왜 굳이 이 복잡한 스프링 시큐리티 내부를 다 까봐야 하는가
스프링 시큐리티는 수십 개의 서블릿 필터(Filter)가 체인 형태로 엮여 동작하는 정교한 기계 장치다. 평소에는 @EnableWebSecurity 선언하고 몇 줄 적어두면 마법처럼 인증을 처리해 주는 고마운 녀석처럼 보인다. 하지만 실무에서는 반드시 예외적인 비즈니스 요구사항이 터져 나온다. "특정 API 호출할 때만 레거시 DB의 해시 알고리즘을 타야 한다", "로그인 실패 시 단순 리다이렉트가 아니라 특정 감사(Audit) 로그를 비동기로 남기고 차단 정책을 가동해야 한다" 같은 요구사항들 말이다. 이 내부 파이프라인을 모르면 엉뚱한 필터에 로그를 찍으며 밤을 새우거나, 필터 순서를 잘못 꼬아놓아서 인증도 안 된 요청이 비즈니스 로직으로 직행하는 대형 보안 사고를 치게 된다.

요청부터 SecurityContext까지: 전체 흐름 아키텍처
HTTP 요청이 들어와서 컨트롤러에 도달하기 전까지, 스프링 시큐리티 내부에서 벌어지는 일들을 뼈대만 추려보면 거대한 공장 컨베이어 벨트와 같다. 각 공정의 부품들이 어떻게 맞물려 돌아가는지 순서대로 추적해보자.
| 단계 | 컴포넌트 | 핵심 역할 및 행위 |
|---|---|---|
| 1 | FilterChainProxy | WAS(톰캣)의 필터 영역에서 요청을 낚아채 스프링 빈으로 등록된 SecurityFilterChain으로 토스한다. |
| 2 | UsernamePasswordAuthenticationFilter | HttpServletRequest에서 ID와 PW를 추출하여 unauthenticated 상태의 UsernamePasswordAuthenticationToken을 발행한다. |
| 3 | ProviderManager (AuthenticationManager) | 전달받은 토큰을 처리할 수 있는 AuthenticationProvider를 순회하며 인증 권한을 위임한다. |
| 4 | DaoAuthenticationProvider | UserDetailsService를 호출해 DB에서 사용자 정보를 로드하고, PasswordEncoder를 통해 입력값과 비교 검증한다. |
| 5 | SecurityContextHolder | 인증이 완료된 검증된 Authentication 객체를 받아 현재 실행 중인 ThreadLocal 공간에 박아 넣는다. |
실무자가 겪는 지옥: 폼 로그인 기본 예제와 숨겨진 부작용
가장 흔하게 사용하는 스프링 시큐리티 6.x 기준의 폼 로그인 기본 설정 코드를 살펴보자. 흔한 코드지만, 운영 환경 관점에서 이 코드가 품고 있는 무시무시한 기본값들을 눈여겨봐야 한다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // REST API가 아닌 세션 기반 환경에서 이거 그냥 끄면 CSRF 공격에 무방비로 털린다.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/perform_login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true"));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// 실무에서는 CPU 연산 부하를 조절하는 strength 인자 설정을 고민해야 한다.
return new BCryptPasswordEncoder(12);
}
}
여기서 loginProcessingUrl("/perform_login")을 지정하는 순간, 스프링 시큐리티 내부의 UsernamePasswordAuthenticationFilter는 내부적으로 AntPathRequestMatcher("/perform_login", "POST")를 생성한다. 즉, 개발자가 컨트롤러에 /perform_login 이라는 URL을 매핑한 적이 없어도 이 필터가 요청을 낚아채서 자기 혼자 북 치고 장구 치고 다 한다는 뜻이다. 이걸 모르고 컨트롤러에 똑같은 URL로 POST 메서드를 만들어두고 왜 안 타냐고 울부짖는 주니어들이 정말 많다.
인증 처리의 심장부: 토큰과 Provider의 은밀한 상호작용
요청이 들어오면 필터의 attemptAuthentication() 메서드가 실행된다. 여기서 핵심은 토큰의 '신분 변화'다. 처음 추출된 토큰은 authenticated = false 상태다. 자격 증명만 담긴 종이쪽지에 불과하다.
이 미검증 토큰이 ProviderManager에게 전달되면, 자기가 거느리고 있는 수많은 AuthenticationProvider들에게 "이 토큰 처리할 수 있는 놈 누구냐?"라고 묻는다. 이때 사용되는 메서드가 바로 supports(Class<?> authentication)다. 폼 로그인의 경우 DaoAuthenticationProvider가 손을 번쩍 든다.
이제 DaoAuthenticationProvider는 개발자가 구현해 놓은 UserDetailsService.loadUserByUsername()을 호출한다. 여기서 DB를 조회해 UserDetails를 가져오는데, 많은 이들이 착각하는 게 있다. UserDetailsService는 인증을 처리하는 곳이 아니다. 오직 DB나 외부 저장소에서 유저 객체를 '조회'해오는 역할만 담당한다. 실제 비밀번호가 맞는지 틀리는지 검증하는 무거운 로직은 Provider 내부의 additionalAuthenticationChecks() 메서드 안에서 PasswordEncoder.matches()를 돌려 처리한다. 이 분리 구조를 알아야 커스텀 인증을 만들 때 코드를 짤 위치를 명확히 잡을 수 있다.
세션과 토큰 저장: SecurityContext를 둘러싼 ThreadLocal의 덫
인증이 성공하면 완전체 토큰(Principal, Authorities가 꽉 차고 authenticated = true가 된 객체)이 반환된다. 필터는 이 승리 요물을 SecurityContextHolder.getContext().setAuthentication(authResult)를 통해 보관한다. 기본 전략은 MODE_THREADLOCAL이다. 즉, 톰캣이 할당해 준 해당 요청 쓰레드 안에서만 전역적으로 접근할 수 있는 보관함에 넣는 것이다.
전통적인 웹 환경이라면 SecurityContextRepository(기본적으로 HttpSessionSecurityContextRepository)가 개입해서 이 컨텍스트를 톰캣의 HttpSession에 쑤셔 넣는다. 그래야 다음 요청이 들어왔을 때 JSESSIONID를 보고 세션에서 컨텍스트를 꺼내와 다시 ThreadLocal에 채워줄 수 있기 때문이다. 만약 비동기 처리를 위해 @Async를 남발하거나 별도 쓰레드 풀을 가동해 로직을 수행하면, 이 시큐리티 컨텍스트가 유실되는 현상을 겪게 된다. 쓰레드가 달라지면 ThreadLocal은 아무것도 공유하지 못하기 때문이다. 이럴 때는 SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) 같은 설정을 고민하거나 스프링이 제공하는 DelegatingSecurityContextExecutor를 써야 장장 몇 시간 동안의 삽질을 막을 수 있다.
REST API 환경에서 폼 로그인을 그대로 쓰면 정말 비용이 줄어드나요?
결론부터 말하면 아니다. 오히려 아키텍처가 꼬이면서 리소스 비용과 디버깅 공수가 기하급수적으로 늘어난다. 현대의 백엔드 아키텍처는 대부분 무상태(Stateless) 아키텍처를 지향한다. 프론트엔드(React, Vue, Next.js)와 모바일 앱이 서버 인프라를 공유하는 구조에서 스프링 시큐리티의 기본 폼 로그인(세션 방식)을 고집하면 다음과 같은 트레이드오프가 발생한다.
- 세션 동기화 비용 폭발: 서버 인프라를 스케일아웃(Scale-out)할 때, 여러 대의 서버가 동일한 세션을 공유해야 하므로 Redis 같은 세션 클러스터링 저장소가 강제된다. 이는 곧 인프라 비용과 네트워크 Latency 증가로 이어진다.
- 쿠키-세션 정책의 한계: 모바일 앱이나 크로스 도메인 환경에서 쿠키의 SameSite 제약 조건을 우회하기 위한 삽질이 시작된다. CORS 설정 하나 바꿀 때마다 온갖 필터에서 예외가 터져 나오기 일쑤다.
따라서 REST API 환경에서는 세션을 과감히 SessionCreationPolicy.STATELESS로 끄고, UsernamePasswordAuthenticationFilter 자리에 JWT 토큰을 파싱하는 커스텀 필터(예: OncePerRequestFilter 상속 구현체)를 끼워 넣는 것이 정석이다. 매 요청마다 토큰을 검증해 SecurityContextHolder를 채워주는 무상태 방식으로 전환해야만 서버가 수평 확장될 때 가볍게 버틸 수 있다.
실전 비법: 레거시 연동을 위한 커스텀 AuthenticationProvider 구현
현업에서는 이미 구축된 외부 사내 인증 API를 태우거나 특별한 복호화 로직을 거쳐야 하는 경우가 수두룩하다. 이럴 때는 시큐리티의 틀을 깨지 말고 AuthenticationProvider를 직접 커스텀해서 시큐리티 엔진에 공급해 줘야 한다.
@Component
public class CustomLegacyAuthenticationProvider implements AuthenticationProvider {
private final LegacyUserWebService legacyUserWebService;
public CustomLegacyAuthenticationProvider(LegacyUserWebService legacyUserWebService) {
this.legacyUserWebService = legacyUserWebService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String) authentication.getCredentials();
// 외부 레거시 시스템이나 별도 API 호출을 통해 인증을 대행하는 시나리오
LegacyUserDto legacyUser = legacyUserWebService.callAuth(username, password);
if (legacyUser == null || !legacyUser.isValid()) {
// 반드시 스프링 시큐리티가 정의한 예외 구조를 따라야 후속 실패 핸들러가 정상 작동한다.
throw new BadCredentialsException("레거시 인증 시스템으로부터 인증을 실패했습니다.");
}
// 권한 매핑 처리
List authorities = legacyUser.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 인증이 완료된 완전한 토큰 생성 (두 번째 인자는 비밀번호 보완을 위해 null 처리하거나 메타데이터 전달)
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
@Override
public boolean supports(Class<?> authentication) {
// 내가 어떤 타입의 토큰이 들어올 때 이 프로바이더를 가동할지 정의한다.
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
이렇게 만든 프로바이더를 AuthenticationManager에 등록해 두면, 스프링 시큐리티는 기존의 로그인 필터 흐름을 그대로 유지하면서도 내부 알맹이 검증 로직만 우리가 원하는 레거시 인증 인프라로 교체해 준다. 프레임워크의 유연성을 극대화하는 방식이다.
실무자 디버깅 체크리스트: 에러가 터졌을 때 추적하는 요령
시큐리티가 적용된 프로젝트에서 로그인이 먹통이 되었을 때, 무작정 구글링부터 하지 말고 다음 4단계를 순서대로 브레이크 포인트를 걸어 찍어봐라. 무조건 답이 나온다.
- 1단계:
FilterChainProxy의doFilter내부 내부 변수인filters목록을 확인하라. 현재 내 요청 URL이 내가 의도한 커스텀 필터를 순서대로 통과하고 있는지, 순서가 뒤틀리진 않았는지 눈으로 확인하는 게 우선이다. - 2단계:
AbstractAuthenticationProcessingFilter의attemptAuthentication에 브레이크 포인트를 걸어라. 여기서 HttpServletRequest 내의 스트림이나 파라미터가 유실되어username이null로 들어오고 있다면, 프론트엔드의 Content-Type 설정(JSON vs Form-urlencoded)이 서버 필터 요구사항과 어긋난 것이다. - 3단계:
ProviderManager의authenticate메서드 안에서 loop 도는 구절을 봐라. 내 토큰을 받아 처리하는 Provider가DaoAuthenticationProvider가 맞는지, 아니면 처리할 놈을 못 찾아서ProviderNotFoundException을 던지며 튕겨나가는지 잡아내야 한다. - 4단계: 인증 성공 직후
successfulAuthentication메서드로 진입하는지 봐라. 여기가 타는데도 다음 페이지에서 인가(Authorization) 에러가 난다면,UserDetails에 세팅한GrantedAuthority목록에ROLE_접두사가 빠졌거나 인가 규칙 문자열이 불일치하는 권한 누락 문제다.
결국 어떤 선택을 해야 하는가?
스프링 시큐리티를 다루는 실무 아키텍트라면 프로젝트의 성격에 맞게 명확한 노선을 타야 한다. 무조건 최신 트렌드라고 JWT를 바를 필요도 없고, 옛날 방식이라고 무조건 폼 로그인을 비하할 필요도 없다. 구조적 상황에 따른 나침반을 제시하며 글을 맺는다.
단일 서버 내부에서 타임리프나 JSP 같은 템플릿 엔진을 태워 빠르게 MVP를 뽑아내야 하는 내부 시스템이나 관리자 어플리케이션이라면, 기본 제공되는 폼 로그인과 HttpSession 기반 아키텍처를 그대로 가져가라. 시큐리티가 제공하는 CSRF 방어, 세션 고정 보호(Session Fixation Protection) 기능을 고스란히 날로 먹을 수 있어 보안 가성비가 최고다.
반면 MSA(Microservice Architecture)로 쪼개져 있거나, 대규모 트래픽 분산을 위해 클라우드 네이티브 환경에서 기동하는 REST API 서버라면 폼 로그인을 과감히 집어치우고 STATELESS 설정 후 JWT 혹은 OAuth2 opaque 토큰 기반의 커스텀 필터 전략으로 선회하라. 초기 인프라 설정과 토큰 탈취 대응(Refresh Token 로테이션 등)을 위한 러닝커브 및 구현 비용은 수배로 들겠지만, 트래픽이 몰릴 때 세션 동기화 지옥 때문에 DB나 Redis 인프라가 통째로 내려앉는 대참사를 미연에 방지하는 유일한 길이다.
JPA 연관관계 매핑의 늪에서 벗어나는 실전 설계 단순화 가이드
프로젝트 오픈 직전, 무심코 날린 주문 조회 API 한 줄에 수십 개의 하위 쿼리가 폭포수처럼 쏟아지는 걸 보며 식은땀을 흘려본 적이 있을 것이다. JPA는 달콤하지만 그 이면에 숨겨진 엔티티 연관
byteandbit.tistory.com
'Back-end & 알고리즘' 카테고리의 다른 글
| 자바 예외처리, 당신의 서비스 레이어가 맨날 스파게티 코드가 되는 이유 (0) | 2026.06.02 |
|---|---|
| 우선순위 큐 코딩테스트: 정렬 함수만 쓰다가 효율성 터지는 이유 (0) | 2026.06.01 |
| JPA 연관관계 매핑의 늪에서 벗어나는 실전 설계 단순화 가이드 (0) | 2026.05.25 |
| 코딩테스트 DP(동적계획법) 뇌정지 탈출법: 실전에서 점화식이 안 떠오를 때 던져야 할 7가지 질문 (0) | 2026.05.23 |
| 코테용 이분탐색은 다르다! 무한 루프 없는 매개 변수 탐색 실전 템플릿 (0) | 2026.05.22 |