[Spring] Spring Security 세션 관리의 숨은 트리거
Spring Security는 Spring 내에서 인증 파이프라인을 제공하는, 필수적으로 사용되는 Spring 관련 라이브러리입니다.
Spring Security를 사용하지 않는다면 개발자는 아래와 같이 JWT 토큰을 수동으로 관리하고 검증해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 인터셉터나 필터에서 직접
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
String token = req.getHeader("Authorization");
if (token == null || !jwtUtil.isValid(token)) {
res.setStatus(401);
return false; // 여기서 직접 막음
}
User user = jwtUtil.parseUser(token);
req.setAttribute("currentUser", user); // 직접 어딘가에 담아서 넘김
return true;
}
}
그리고 이걸 ADMIN만 수용한다면 아래처럼 매번 if 문을 작성해야 합니다.
1
2
3
4
if (!user.getRole().equals("ADMIN"))
throw new ForbiddenException();
// (... 무한 반복)
하지만 Spring Security를 사용하면 아래와 같이 설정 후 필터를 적용해 매번 검증을 할 필요가 없어집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class JwtAuthFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
String token = req.getHeader("Authorization");
if (jwtUtil.isValid(token)) {
User user = jwtUtil.parseUser(token);
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth); // 보관 장소 지정
}
chain.doFilter(req, res);
}
}
// 규칙 선언 -> 이 필터를 지나는 모든 요청에 대해 검사(ADMIN 이신가요?)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
뒷단의 컨트롤러는 별도의 검증 로직 없이, 도달한 요청에 대해 비즈니스 로직을 수행할 수 있습니다.
Spring Security의 세션 관리
1
2
3
4
5
6
7
@PostMapping("/login")
public void login(@RequestBody LoginRequest req, HttpServletRequest request, HttpServletResponse response) {
User user = userService.authenticate(req.getUsername(), req.getPassword());
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
위 코드는 인증 정보인 auth를 스레드 로컬(SecurityContextHolder)에 저장합니다.
스레드 로컬에만 저장하고, 이 정보를 세션이나 쿠키에 저장하는 로직은 없습니다.
하지만 실제 웹 브라우저에서 확인해보니 세션 ID인 WEBAPP_SESSION이 확인됩니다.
이 세션에 뭐가 저장되어 있는지 확인해보기 위해, 이 값을 base64로 디코딩하여 세션 저장소인 Redis에 질의해보겠습니다.
Redis 세션 키 확인
1
2
3
redis-cli -h myredis.redis.cache.windows.net -p 6380 --tls
-a "$REDIS_PASS"
HKEYS webapp:session:sessions:<디코딩된세션ID>
Redis 응답
1
2
3
4
5
"creationTime"
"lastAccessedTime"
"sessionAttr:SPRING_SECURITY_CONTEXT"
"sessionAttr:is_tester"
"maxInactiveInterval"
Spring Security가 인증 정보를 세션에 저장할 때 쓰는 고정 키인 SPRING_SECURITY_CONTEXT가 존재함을 확인할 수 있습니다.
소스 코드는 명시적으로 스레드 로컬에만 인증 정보를 저장하고 있었습니다.
Spring Security 공식 문서에서는 Spring Security 6+부터 세션에 자동으로 인증 정보를 저장하지 않도록 바뀌었고, 사용자가 명시적으로 인증 정보를 세션에 저장하라고 안내하고 있습니다.
출자: spring.io-session-management
왜 이런 동작이?
Spring Security는 내부적으로 다양한 필터를 기본적으로 제공합니다.
아래 3개를 포함해 더 몇개의 필수적인 기능은 자동적으로 켜집니다.
SecurityContextHolderFilter: 세션에서 인증 정보를 읽어와 현재 요청 스레드에 채워 넣음ExceptionTranslationFilter: 인증/인가 실패 시 예외를 401/403 응답으로 변환AuthorizationFilter: URL별 권한 규칙(hasRole등) 최종 검사
하지만 일부는 사용자가 특정 설정을 했을 때만 켜집니다. (아래 2개 보다 더 있음)
SessionManagementFilter: 세션 관리, 하이재킹 방지OAuth2LoginAuthenticationFilter: OAuth2 인증
여기서 SessionManagementFilter의 경우, SessionManagementConfigurer의 메서드를 사용하면 내부적으로 필터가 켜집니다.
SessionManagementFilter는 세션 하이재킹 방지가 목적이라, 세션에 인증 정보가 없으면 세션 ID를 바꾸고 그 인증 정보를 세션에 저장합니다.
이 때문에 SessionManagementConfigurer의 메서드를 한 번이라도 사용하면, 명시적으로 저장하지 않아도 의도치 않게 세션에 인증 정보가 자동으로 저장됩니다.
명시적(Explicit) 세션 저장 적용
Spring 공식 문서에서는 세션 저장이 명시적으로 되어야 한다고 언급하고 있습니다.
Understanding Require Explicit Save
사용자가 세션을 아래와 같이 명시적으로 저장하는 것이 표준적인 방식입니다.
1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping("/login")
public void login(@RequestBody LoginRequest req, HttpServletRequest request, HttpServletResponse response) {
User user = userService.authenticate(req.getUsername(), req.getPassword());
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context); // 명시적 세션 저장!
new HttpSessionSecurityContextRepository().saveContext(context, request, response); // 세션에 인증 정보 명시적 저장
}
이럴 경우 SessionManagementFilter는 세션에 이미 인증 정보가 있기에, 굳이 세션 ID를 바꾸지도 않으며 별도로 세션에 인증 정보를 저장하지도 않습니다.
그렇다면 현재 방식은 표준은 준수했지만 세션 하이재킹에 취약한 코드라고 볼 수도 있습니다. (SessionManagementFilter는 로그인되는 순간에 세션 ID를 바꾸어 하이재킹 방지를 수행하는데, 이미 세션에 인증 정보가 있다면 검사 조차 안함.)
명시적 저장 + 하이재킹 방지
Spring Security는 AbstractAuthenticationProcessingFilter라는, 인증 처리를 위한 특별한 추상 클래스를 제공합니다.
이 필터는 지정한 로그인 URL(기본 /login)로 오는 요청만 가로채서 처리하며, 별도의 저장 호출 없이 ‘검증 시도 → 성공 시 ID 교체 → 저장 → 성공 핸들러 호출, 실패 시 실패 핸들러 호출’로 이어지는 로직이 이미 다 짜여 있는 상태입니다. 우리는 이걸 상속받아 내부의 attemptAuthentication()만 채우면 표준적이고 안정적인 로그인 코드가 완성됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
public class LoginFilter extends AbstractAuthenticationProcessingFilter {
@Override
protected Authentication attemptAuthentication(request, response) {
String username = request.getParameter("username");
String password = request.getParameter("password");
return authenticationManager.authenticate(token);
}
// successfulAuthentication()은 우리가 오버라이드 안 해도
// 부모 클래스에 이미 saveContext() 호출이 구현되어 있음
}
별도의 /login 컨트롤러도 필요 없이 해당 URL로 오는 요청을 필터단에서 처리하고, saveContext() 또한 부모 클래스에서 처리해줍니다. (명시적 저장)
이런 흐름에서는 기존의 SessionManagementFilter 자체를 등록할 필요도 없습니다.
실제로 Spring Security 공식 문서는 이 필터를 “Moving Away From SessionManagementFilter”라는 별도 섹션으로 다루며, 이 필터에 의존하던 설정들(sessionAuthenticationErrorUrl, sessionAuthenticationStrategy 등)은 6.x에서 효과가 없거나 예외를 던지도록 바뀌었습니다. 즉 이 필터는 하위 호환을 위해 남아있는 레거시 경로에 가깝습니다.
결과적으로 표준에 따른 명시적 저장과, 보안적으로도 안전한 로그인 처리가 완성되었습니다.
정리
명시적으로 로직을 작성하지 않으면 의도치 않은 동작으로 이어질 수 있습니다.
sessionCreationPolicy(IF_REQUIRED)가 대표적입니다.
겉으로 보면 이 설정은 단순히 세션 생성 시점을 정하는 옵션일 뿐입니다. 하지만 실제로는 SessionManagementFilter를 체인에 등록시키는 트리거이고, 결과적으로 로그인 인증 정보를 세션에 저장하는 역할까지 가집니다
문제는 이 기능은 의도되지 않았다는 점입니다 누군가 리팩토링이나 설정 정리 과정에서 이 한 줄을 무심코 지운다면, 세션 저장이 조용히 멈추고 로그인이 계속 풀리는 버그가 발생합니다. 그리고 원인을 코드에서 전혀 유추할 수 없기 때문에, 이걸 다시 찾아내는 데는 상당한 시간이 들 수밖에 없습니다.
그래서 세션에 인증 정보가 잘 저장되고 있다는 결과만 보고 “Spring이 알아서 처리해주는구나”라고 넘어가면 안 됩니다. 그 저장이 의도된 동작인지, 아니면 어디서 따라온 부수 효과인지를 구분할 수 있어야 합니다. 동작하고 있다는 사실이, 그 동작을 이해하고 있다는 뜻은 아님을 명심하게 되는 시간이 되었습니다.

