← all posts
DEV 2026.05.02 · 14 min read Intermediate

Spring Security 세션 관리의 네 가지 레이어

Session Fixation 방어부터 동시 세션 제어, 타임아웃 처리, Stateless 전환까지 — Spring Security가 세션 생명주기 전체를 어떻게 감시하는지 추적한다.


Spring Security의 세션 관련 코드를 처음 읽으면 낯선 클래스 이름들이 쏟아진다. ChangeSessionIdAuthenticationStrategy, ConcurrentSessionFilter, InvalidSessionStrategy, NullSecurityContextRepository… 이것들은 각각 독립된 기능이 아니다. 하나의 질문에 대한 서로 다른 답이다 — “서버는 누가 이 요청을 보냈는지 어떻게 신뢰하는가?” 그 신뢰를 구축하고, 유지하고, 철회하고, 마침내 포기하는 네 단계를 추적해보자.

신뢰의 출발점: 로그인 직후 세션 교체

세션 기반 인증의 근본적인 취약점은 로그인 전에 만들어진 세션 ID가 로그인 후에도 그대로 남는다는 데 있다. 공격자가 피해자에게 미리 알고 있는 세션 ID를 심어두고(JSESSIONID=ATTACKER_KNOWN_ID), 피해자가 그 ID로 로그인하면 공격자는 동일한 ID로 인증된 세션에 접근할 수 있다. 이것이 Session Fixation 공격이다.

Spring Security는 이를 인증 성공 직후, successfulAuthentication() 호출 직전에 차단한다.

// AbstractAuthenticationProcessingFilter.java
this.sessionStrategy.onAuthentication(authResult, request, response);
// ↑ 이 한 줄이 실행된 뒤에야 successfulAuthentication()이 호출된다

sessionStrategyCompositeSessionAuthenticationStrategy로, 내부적으로 여러 전략을 순서대로 실행한다. 가장 먼저 실행되는 ChangeSessionIdAuthenticationStrategy는 Servlet 3.1의 request.changeSessionId()를 호출해 세션 저장소의 키를 새 ID로 교체한다. 세션 객체 자체는 바뀌지 않으므로 장바구니 같은 세션 속성은 그대로 유지된다. 클라이언트에게는 Set-Cookie: JSESSIONID=SAFE_ID가 전송된다. 공격자가 알던 EVIL_ID는 서버에 존재하지 않는 ID가 됐다.

절대 하지 말아야 할 설정

sessionFixation().none()은 이 교체를 비활성화한다. “SPA라서 쿠키를 안 쓴다”는 이유로 끄는 경우가 있는데, URL 기반 세션 파라미터나 서브도메인 공격 등 다른 주입 벡터가 남아있다. 방어 비용이 거의 없으므로 항상 켜두어야 한다.

신뢰의 경계: 동시 세션을 몇 개까지 허용할 것인가

로그인에 성공했다고 해서 신뢰가 무한정 열리는 건 아니다. maximumSessions(1) 설정은 동일 사용자의 동시 세션 수를 제한한다. 이 기능의 핵심은 두 클래스가 맡는다.

ConcurrentSessionControlAuthenticationStrategy는 로그인 시점에 SessionRegistry에서 기존 세션 수를 조회한다. 한도를 초과하면 두 가지 선택이 있다.

  • maxSessionsPreventsLogin(false) (기본): 기존 세션 중 가장 오래된 것에 expireNow() 플래그를 설정하고 새 로그인을 허용한다.
  • maxSessionsPreventsLogin(true): 새 로그인 자체를 SessionAuthenticationException으로 차단한다.

expireNow()SessionInformation.expired = true를 설정할 뿐이다. 실제 HttpSession.invalidate()가 아니다. 만료된 세션에 다음 요청이 들어올 때 ConcurrentSessionFilter(필터 순서 100, 매우 초반에 실행)가 SessionRegistry를 조회해 expired=true를 감지하고 그때서야 세션을 무효화한다.

이 흐름이 제대로 작동하려면 두 가지가 필요하다.

@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl(); // 단일 서버용 InMemory
}

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
    // 세션 타임아웃 → sessionDestroyed 이벤트 → SessionRegistry 자동 정리
}

SessionRegistry@Bean으로 등록하지 않으면 각 SecurityFilterChain이 서로 다른 인스턴스를 사용해 세션 조회가 엉킨다. HttpSessionEventPublisher가 없으면 타임아웃된 세션이 SessionRegistry에 영원히 남는다.

신뢰의 소멸: 타임아웃과 즉각 처리

세션 타임아웃은 Spring Security가 아니라 Servlet 컨테이너(Tomcat)가 처리한다. Tomcat의 백그라운드 프로세스가 lastAccessedTime + maxInactiveInterval < 현재 시간인 세션을 invalidate()하고, HttpSessionListener.sessionDestroyed() 이벤트를 발행한다. Spring Security는 이 이벤트를 HttpSessionEventPublisher를 통해 받아 SessionRegistry를 정리한다.

Spring Security가 타임아웃을 “감지”하는 시점은 타임아웃 순간이 아니다. 만료된 세션 ID를 가진 요청이 들어올 때다.

// SessionManagementFilter.java
if (req.getRequestedSessionId() != null        // 쿠키에 세션 ID 있음
        && !req.isRequestedSessionIdValid()) {  // 그 세션이 서버에 없음
    invalidSessionStrategy.onInvalidSessionDetected(req, response);
    return;
}

이 두 조건이 모두 참일 때만 InvalidSessionStrategy가 호출된다. 쿠키가 아예 없는 첫 방문자는 해당하지 않는다.

API 환경에서 302 리다이렉트 대신 JSON 응답을 반환하려면 커스텀 InvalidSessionStrategy를 구현한다.

@Component
public class SmartInvalidSessionStrategy implements InvalidSessionStrategy {
    @Override
    public void onInvalidSessionDetected(HttpServletRequest request,
                                          HttpServletResponse response) throws IOException {
        boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"))
            || (request.getHeader("Accept") != null
                && request.getHeader("Accept").contains("application/json"));

        if (isAjax) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"error\":\"SESSION_EXPIRED\"}");
        } else {
            response.sendRedirect("/session-timeout");
        }
    }
}

신뢰의 포기: STATELESS와 JWT

세션 기반 신뢰의 근본 문제는 수평 확장이다. 서버 A에서 만든 세션은 서버 B가 모른다. Redis 같은 공유 세션 저장소로 해결할 수 있지만, 이는 새로운 단일 장애 지점이 된다.

SessionCreationPolicy.STATELESS는 이 문제를 정면으로 포기하는 설정이다. 세션을 만들지도, 읽지도 않는다. 내부적으로 SecurityContextRepositoryNullSecurityContextRepository로 교체된다.

public class NullSecurityContextRepository implements SecurityContextRepository {
    @Override
    public void saveContext(SecurityContext context,
                             HttpServletRequest request,
                             HttpServletResponse response) {
        // 아무것도 하지 않는다
    }
    @Override
    public boolean containsContext(HttpServletRequest request) {
        return false; // 항상 false
    }
}

매 요청마다 JwtAuthenticationFilterAuthorization: Bearer 헤더에서 토큰을 추출하고, 서명을 검증하고, SecurityContextHolderAuthentication을 설정한다. 요청이 끝나면 clearContext()로 제거된다. 세션 I/O가 없으므로 어느 서버로 라우팅되든 동일하게 동작한다.

JWT에서 주의할 점은 토큰 즉시 무효화가 불가능하다는 것이다. 계정 정지를 토큰 만료 전에 반영하려면 Redis 블랙리스트가 필요하다 — 이 순간 “완전한 Stateless”는 사라진다. 짧은 액세스 토큰 만료 시간(15분)과 Redis 블랙리스트를 조합하면 DB 조회 없이도 계정 정지를 수초 내 반영할 수 있다.

CSRF: 세션 기반 신뢰의 역습

세션 쿠키 기반 인증에는 CSRF라는 대가가 따른다. 브라우저는 타 도메인 요청에도 해당 도메인의 쿠키를 자동으로 첨부하기 때문이다. 공격자 페이지에서 bank.com으로 폼을 자동 제출하면, 피해자의 JSESSIONID가 함께 전송돼 인증된 요청으로 처리된다.

CsrfFilter(순서 100)는 POST, PUT, DELETE, PATCH 요청에 대해 동기화 토큰을 검증한다. 서버가 발급한 토큰과 요청에 포함된 토큰이 일치해야 통과한다.

환경에 따라 저장소를 선택한다.

환경저장소전송 방식
SSR (Thymeleaf)HttpSessionCsrfTokenRepository_csrf 히든 필드
SPA (Angular/React)CookieCsrfTokenRepository.withHttpOnlyFalse()X-XSRF-TOKEN 헤더
REST API (Bearer 토큰)csrf().disable() 안전

CookieCsrfTokenRepository에서 HttpOnly=false가 의도된 설계인 이유가 있다. SPA의 JavaScript가 XSRF-TOKEN 쿠키를 읽어 X-XSRF-TOKEN 헤더에 설정해야 하기 때문이다. HttpOnly=true이면 JavaScript가 읽지 못해 자동 처리가 불가능해진다. 단, 이는 XSS 취약점이 있을 때 토큰 탈취가 가능하다는 의미이므로 Content-Security-Policy 헤더가 필수 보완책이 된다.

csrf().disable()이 안전한 조건은 세 가지다: 세션/쿠키 인증 미사용, Bearer 토큰으로만 인증, 브라우저 클라이언트 없음. 셋 중 하나라도 불충족이면 CSRF를 유지해야 한다.

정리

Spring Security 세션 관리의 네 레이어는 하나의 연속된 질문에 답한다.

  • 로그인 직후: ChangeSessionIdAuthenticationStrategy가 세션 ID를 교체해 Fixation 공격을 차단한다.
  • 동시 접속 제어: ConcurrentSessionControlAuthenticationStrategyConcurrentSessionFilter가 한도를 강제하고 만료를 감지한다.
  • 비활동 시간 초과: Tomcat이 세션을 삭제하고 SessionManagementFilter가 다음 요청에서 감지해 InvalidSessionStrategy를 실행한다.
  • 상태 포기: STATELESS 정책과 NullSecurityContextRepository로 세션 없이 매 요