본문 바로가기

HTTP 세션 수명 주기

게시판 인증 세션을 필요할 때만 만들고 세션 고정 공격 방어, 유휴 타임아웃, 세션 무효화, 분산 저장소와 동시 요청의 일관성을 검증합니다.

HTTP는 요청마다 독립적이지만, 로그인한 사용자의 여러 요청은 같은 인증 상태를 참조해야 합니다. 서블릿 컨테이너는 브라우저의 세션 쿠키와 서버의 저장소를 연결해 이 상태를 제공합니다. 중요한 것은 세션을 어디서든 편하게 꺼내 쓰는 것이 아니라, 생성 시점과 보관할 값, 만료 조건, 폐기 신호를 하나의 수명 주기로 설계하는 일입니다.

세션은 익명에서 활성으로 바뀌고 만료나 로그아웃에서 서버 상태가 끝난다

익명·로그인 후 새 세션·활성 조회·유휴 만료/로그아웃에서 서버 상태가 끝나는 수명 변화이다.

조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 invalidate와 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

세션의 생성·조회·폐기와 두 만료 시계를 구분한다

조회는 getSession(false)로 생성하지 않습니다. 유휴 30분은 컨테이너 설정이고, 절대 8시간은 별도 정책입니다. 실제 mock 검사는 회원 41 저장과 명시적 invalidate를 확인합니다.

세션의 생성·조회·폐기와 두 만료 시계를 구분한다조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 invalidate와 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.익명 · 세션 없음getSession(false)활성 서버 세션작은 인증 주체유휴 만료컨테이너 30분 정책POST logoutinvalidate + 쿠키 만료접근 → 유휴 시계 갱신
생성 없는 조회와 활성 세션의 유지·종료기존 데스크톱 네 노드와 네 관계이다. 익명 조회 자체는 세션을 만들지 않고 앞 절의 로그인 성공이 활성 서버 세션으로 이동한다. 활성 요청의 반복은 유휴 시계 갱신이며 절대 기한 갱신이 아니다. 유휴 만료와 POST로그아웃은 별도 종료 경로이다. 절대8시간 helper와 정책 연결은 본문 연습이며 이 그림이 자동 연결을 증명하지 않는다.로그인 성공접근 시유휴만료로그아웃익명 · 세션 없음getSession(false)활성 서버 세션작은 인증 주체유휴 만료컨테이너 30분 정책POST logout서버 폐기 · 쿠키 만료

READ / NO SESSION CREATION

부재와 잘못된 속성 타입은 empty

SessionMemberReader는 getSession(false) 뒤 authenticatedMember의 typed 값을 확인합니다. @SessionAttribute(required=false)로 주체가 없으면 posts 화면은 로그인으로 redirect합니다. 조회 애노테이션은 로그인 상태를 저장하지 않습니다.

LOGIN / PREVIOUS LESSON

인증 성공 뒤 기존 세션을 교체

앞 LoginController가 기존 세션을 invalidate하고 새 세션에 AuthenticatedMember를 저장합니다. 공개 방문자에게 조회 과정에서 세션을 만들지 않으며 로그인 뒤 작은 주체만 보관합니다.

IDLE / ABSOLUTE / COOKIE

30분 유휴와 8시간 절대 수명은 별개

컨테이너의 30분 유휴는 접근으로 연장됩니다. 연습 helper는 authenticatedAt+lifetime 이상이면 만료지만 현재 reader/controller에 연결하지 않았습니다. 인증 시각 기록·거부·invalidate와 경계 시각 테스트는 추가 구현입니다. 브라우저 쿠키 보관 시간도 서버 수명과 다릅니다.

EXPLICIT INVALIDATION

서버 폐기와 동일 scope의 쿠키 만료

POST logout은 기존 세션을 invalidate하고 JSESSIONID를 maxAge=0, path=/, Secure, HttpOnly, SameSite=Lax로 만료시킵니다. 실제 배포의 발급 path/domain과 삭제 설정을 맞추며 POST라는 사실만으로 CSRF 검증이 구현되지는 않습니다.

MOCK LIFECYCLE TEST

1800초 설정 · 회원 41 · invalidate 확인

실제 unit test는 maxInactiveInterval=1800, typed 회원 41 저장과 invalidate 뒤 creationTime 조회의 IllegalStateException을 확인합니다. 벽시계 만료, 로그인 ID 교체, 쿠키 재생 또는 절대 8시간 동작을 이 테스트가 증명하지 않습니다.

ADDITIONAL DEPLOYMENT CONTRACT

공유 저장의 TTL·직렬화·장애를 검증

COOKIE tracking mode가 URL 재작성 정책을 제한합니다. 다중 인스턴스의 공유 세션은 TTL, 직렬화 호환과 네트워크 장애를 추가로 검증합니다. 업무 aggregate의 동시 갱신은 세션 map 대신 DB 트랜잭션의 책임으로 둡니다.

조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 invalidate와 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 세션을 invalidate하고 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.
  • 기존 데스크톱 네 노드와 네 관계이다. 익명 조회 자체는 세션을 만들지 않고 앞 절의 로그인 성공이 활성 서버 세션으로 이동한다. 활성 요청의 반복은 유휴 시계 갱신이며 절대 기한 갱신이 아니다. 유휴 만료와 POST 로그아웃은 별도 종료 경로이다. 절대 8시간 helper와 정책 연결은 본문 연습이며 이 그림이 자동 연결을 증명하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1세션은 익명에서 활성으로 바뀌고 만료나 로그아웃에서 서버 상태가 끝난다
2조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 세션을 invalidate하고 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.
16기존 데스크톱 네 노드와 네 관계이다. 익명 조회 자체는 세션을 만들지 않고 앞 절의 로그인 성공이 활성 서버 세션으로 이동한다. 활성 요청의 반복은 유휴 시계 갱신이며 절대 기한 갱신이 아니다. 유휴 만료와 POST 로그아웃은 별도 종료 경로이다. 절대 8시간 helper와 정책 연결은 본문 연습이며 이 그림이 자동 연결을 증명하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
로그인 성공 후 세션

로그인 성공 뒤 서버 세션에 회원을 저장합니다.

회원: 41 · 유휴 설정: 1800초 · 조회: 세션을 새로 만들지 않음활성 서버 세션이 인증 조회의 입력이 됩니다.
기존 세션 조회

reader가 기존 로그인 세션을 읽으며 마지막 접근 기준의 컨테이너 유휴 정책이 적용됩니다.

세션: 기존 세션 · 유휴 기준: 마지막 접근 · absolute helper: reader에 미연결동일 세션의 활동이 이어집니다.
로그아웃 정리

logout은 세션을 invalidate하고 같은 범위의 만료 쿠키를 보냅니다.

서버 세션: invalidate · 쿠키: 만료 응답 · 테스트 후 접근: IllegalStateException서버 세션이 끝나고 client 쿠키 제거가 요청됩니다.

세션은 익명에서 활성으로 바뀌고 만료나 로그아웃에서 서버 상태가 끝난다

익명·로그인 후 새 세션·활성 조회·유휴 만료/로그아웃에서 서버 상태가 끝나는 수명 변화이다.

조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 invalidate와 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

세션의 생성·조회·폐기와 두 만료 시계를 구분한다

조회는 getSession(false)로 생성하지 않습니다. 유휴 30분은 컨테이너 설정이고, 절대 8시간은 별도 정책입니다. 실제 mock 검사는 회원 41 저장과 명시적 invalidate를 확인합니다.

세션의 생성·조회·폐기와 두 만료 시계를 구분한다조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 invalidate와 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.익명 · 세션 없음getSession(false)활성 서버 세션작은 인증 주체유휴 만료컨테이너 30분 정책POST logoutinvalidate + 쿠키 만료접근 → 유휴 시계 갱신
생성 없는 조회와 활성 세션의 유지·종료기존 데스크톱 네 노드와 네 관계이다. 익명 조회 자체는 세션을 만들지 않고 앞 절의 로그인 성공이 활성 서버 세션으로 이동한다. 활성 요청의 반복은 유휴 시계 갱신이며 절대 기한 갱신이 아니다. 유휴 만료와 POST로그아웃은 별도 종료 경로이다. 절대8시간 helper와 정책 연결은 본문 연습이며 이 그림이 자동 연결을 증명하지 않는다.로그인 성공접근 시유휴만료로그아웃익명 · 세션 없음getSession(false)활성 서버 세션작은 인증 주체유휴 만료컨테이너 30분 정책POST logout서버 폐기 · 쿠키 만료

READ / NO SESSION CREATION

부재와 잘못된 속성 타입은 empty

SessionMemberReader는 getSession(false) 뒤 authenticatedMember의 typed 값을 확인합니다. @SessionAttribute(required=false)로 주체가 없으면 posts 화면은 로그인으로 redirect합니다. 조회 애노테이션은 로그인 상태를 저장하지 않습니다.

LOGIN / PREVIOUS LESSON

인증 성공 뒤 기존 세션을 교체

앞 LoginController가 기존 세션을 invalidate하고 새 세션에 AuthenticatedMember를 저장합니다. 공개 방문자에게 조회 과정에서 세션을 만들지 않으며 로그인 뒤 작은 주체만 보관합니다.

IDLE / ABSOLUTE / COOKIE

30분 유휴와 8시간 절대 수명은 별개

컨테이너의 30분 유휴는 접근으로 연장됩니다. 연습 helper는 authenticatedAt+lifetime 이상이면 만료지만 현재 reader/controller에 연결하지 않았습니다. 인증 시각 기록·거부·invalidate와 경계 시각 테스트는 추가 구현입니다. 브라우저 쿠키 보관 시간도 서버 수명과 다릅니다.

EXPLICIT INVALIDATION

서버 폐기와 동일 scope의 쿠키 만료

POST logout은 기존 세션을 invalidate하고 JSESSIONID를 maxAge=0, path=/, Secure, HttpOnly, SameSite=Lax로 만료시킵니다. 실제 배포의 발급 path/domain과 삭제 설정을 맞추며 POST라는 사실만으로 CSRF 검증이 구현되지는 않습니다.

MOCK LIFECYCLE TEST

1800초 설정 · 회원 41 · invalidate 확인

실제 unit test는 maxInactiveInterval=1800, typed 회원 41 저장과 invalidate 뒤 creationTime 조회의 IllegalStateException을 확인합니다. 벽시계 만료, 로그인 ID 교체, 쿠키 재생 또는 절대 8시간 동작을 이 테스트가 증명하지 않습니다.

ADDITIONAL DEPLOYMENT CONTRACT

공유 저장의 TTL·직렬화·장애를 검증

COOKIE tracking mode가 URL 재작성 정책을 제한합니다. 다중 인스턴스의 공유 세션은 TTL, 직렬화 호환과 네트워크 장애를 추가로 검증합니다. 업무 aggregate의 동시 갱신은 세션 map 대신 DB 트랜잭션의 책임으로 둡니다.

조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 invalidate와 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 세션을 invalidate하고 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.
  • 기존 데스크톱 네 노드와 네 관계이다. 익명 조회 자체는 세션을 만들지 않고 앞 절의 로그인 성공이 활성 서버 세션으로 이동한다. 활성 요청의 반복은 유휴 시계 갱신이며 절대 기한 갱신이 아니다. 유휴 만료와 POST 로그아웃은 별도 종료 경로이다. 절대 8시간 helper와 정책 연결은 본문 연습이며 이 그림이 자동 연결을 증명하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1세션은 익명에서 활성으로 바뀌고 만료나 로그아웃에서 서버 상태가 끝난다
2조회는 세션을 만들지 않으며 로그인 성공 후의 서버 세션을 읽습니다. 유휴 만료는 마지막 접근을 기준으로 하는 컨테이너 정책이고 로그아웃은 세션을 invalidate하고 같은 범위의 만료 쿠키를 보냅니다. 실제 테스트는 1800초 설정, 회원 41 저장, invalidate 뒤 IllegalStateException을 확인합니다. AbsoluteSessionPolicy는 deadline 이상을 만료로 반환하는 연습 helper이며 현재 인증 reader에 연결되어 있지 않습니다.
16기존 데스크톱 네 노드와 네 관계이다. 익명 조회 자체는 세션을 만들지 않고 앞 절의 로그인 성공이 활성 서버 세션으로 이동한다. 활성 요청의 반복은 유휴 시계 갱신이며 절대 기한 갱신이 아니다. 유휴 만료와 POST 로그아웃은 별도 종료 경로이다. 절대 8시간 helper와 정책 연결은 본문 연습이며 이 그림이 자동 연결을 증명하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
로그인 성공 후 세션

로그인 성공 뒤 서버 세션에 회원을 저장합니다.

회원: 41 · 유휴 설정: 1800초 · 조회: 세션을 새로 만들지 않음활성 서버 세션이 인증 조회의 입력이 됩니다.
기존 세션 조회

reader가 기존 로그인 세션을 읽으며 마지막 접근 기준의 컨테이너 유휴 정책이 적용됩니다.

세션: 기존 세션 · 유휴 기준: 마지막 접근 · absolute helper: reader에 미연결동일 세션의 활동이 이어집니다.
로그아웃 정리

logout은 세션을 invalidate하고 같은 범위의 만료 쿠키를 보냅니다.

서버 세션: invalidate · 쿠키: 만료 응답 · 테스트 후 접근: IllegalStateException서버 세션이 끝나고 client 쿠키 제거가 요청됩니다.

그림은 세션이 없는 익명 상태에서 getSession(false)로 조회해도 세션을 만들지 않고, 앞 글의 로그인에 성공해야 작은 인증 주체를 가진 활성 서버 세션으로 넘어가는 모습을 보여 줍니다. 활성 상태에서 요청이 반복되면 유휴 시계만 갱신되고, 컨테이너의 30분 유휴 만료나 POST 로그아웃의 invalidate와 쿠키 만료로 끝납니다. 아래 칸들은 실제 모의 테스트가 확인하는 1800초 설정과 회원 41의 저장, 명시적인 폐기를, 연습의 AbsoluteSessionPolicy가 만료 시각만 판정하는 도우미라는 점과 나눠 설명합니다. 인증 시각의 저장과 요청 차단, 세션 폐기와의 연결, 실제 시간의 만료 검사는 추가로 구현해야 합니다.


조회 요청에서 세션을 만들지 않습니다

request.getSession()은 세션이 없으면 새로 만듭니다. 공개 페이지나 인증을 확인하는 필터에서 이 메서드를 호출하면, 모든 방문자에게 쿠키와 서버 저장 공간이 생깁니다. 조회만 할 때는 getSession(false)를 씁니다.

src/main/java/board/web/auth/SessionMemberReader.java
package board.web.auth;

import java.util.Optional;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

public final class SessionMemberReader {
    public static final String AUTHENTICATED_MEMBER_ATTRIBUTE =
            "authenticatedMember";

    public Optional<AuthenticatedMember> read(
            HttpServletRequest request
    ) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return Optional.empty();
        }
        Object value = session.getAttribute(
                AUTHENTICATED_MEMBER_ATTRIBUTE);
        if (value instanceof AuthenticatedMember member) {
            return Optional.of(member);
        }
        return Optional.empty();
    }

    public record AuthenticatedMember(long id) {
        public AuthenticatedMember {
            if (id <= 0) {
                throw new IllegalArgumentException("positive member id required");
            }
        }
    }
}

세션 속성에는 작은 식별자와 최소한의 인증 스냅샷만 둡니다. JPA 엔티티나 열린 연결, 큰 검색 결과를 넣으면 직렬화 문제와 오래된 상태 문제가 생깁니다. 회원 이름이나 권한이 바뀔 수 있다면, 조회할 때 갱신할지, 버전을 붙일지, TTL이 짧은 캐시를 둘지 명시합니다.


@SessionAttribute로 조회하기

@SessionAttribute는 현재 세션에서 이름이 같은 속성을 찾아 컨트롤러 파라미터로 넘깁니다. 세션을 새로 만들거나 로그인 상태를 저장하는 애노테이션이 아닙니다. 컨트롤러 한두 개가 기존 인증 스냅샷을 읽을 때, HttpServletRequest에서 직접 꺼내 형 변환하는 반복을 줄여 줍니다.

src/main/java/board/web/post/PostPageController.java
package board.web.post;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.SessionAttribute;

import board.web.auth.SessionMemberReader;
import board.web.auth.SessionMemberReader.AuthenticatedMember;

@Controller
public final class PostPageController {
    @GetMapping("/posts")
    String sessions(
            @SessionAttribute(
                    name = SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
                    required = false)
            AuthenticatedMember member,
            Model model
    ) {
        if (member == null) {
            return "redirect:/login";
        }
        model.addAttribute("memberId", member.id());
        return "posts/list";
    }
}

기본값인 required = true에서는 속성이 없으면 인자를 해석하는 단계에서 요청이 실패합니다. 공개 페이지와 인증 페이지를 함께 처리한다면 required = false로 없을 수 있다는 것을 명시하고, 리다이렉트나 401을 고릅니다. 반대로 모든 보호 엔드포인트가 같은 인증 규칙을 요구한다면, 컨트롤러마다 애노테이션을 반복하지 말고 필터나 인터셉터, 사용자 정의 인자 리졸버 한곳에서 정책을 적용합니다.


TrackingModes

서블릿은 쿠키를 쓸 수 없는 클라이언트를 위해 URL에 ;jsessionid=...를 붙이는 URL 재작성 방식을 지원합니다. 이 ID는 브라우저의 방문 기록과 북마크, 접근 로그, 분석 도구, 외부 링크의 리퍼러에 남을 수 있으므로, 일반적인 웹 애플리케이션에서는 쿠키만 쓰도록 명시하는 편이 안전합니다.

src/main/java/board/web/auth/SessionTrackingConfiguration.java
package board.web.auth;

import java.util.EnumSet;

import jakarta.servlet.SessionTrackingMode;

import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class SessionTrackingConfiguration {
    @Bean
    ServletContextInitializer cookieOnlySessionTracking() {
        return servletContext -> servletContext
                .setSessionTrackingModes(
                        EnumSet.of(SessionTrackingMode.COOKIE));
    }
}

URL 재작성을 허용해야 하는 폐쇄형 레거시 클라이언트라면 response.encodeURL()과 리다이렉트 인코딩을 빠뜨리지 않아야 하지만, 먼저 클라이언트가 쿠키를 쓸 수 있게 고치는 비용과 비교해 봅니다. URL에 들어온 세션 ID를 애플리케이션 코드가 쿼리 파라미터처럼 직접 읽어서는 안 됩니다. 쿠키 전용으로 바꾼 뒤에는 쿠키를 막은 클라이언트가 익명 상태로 남는지, 응답 링크에 jsessionid가 나타나지 않는지 확인합니다.


로그인과 로그아웃의 세션 수명

공격자가 미리 알고 있는 세션 ID를 피해자가 로그인한 뒤에도 쓰게 만들면 세션 고정 공격이 됩니다. 인증 수준이 올라가는 시점에 ID를 바꾸거나, 기존 세션을 invalidate()로 무효화하고 새 세션을 만듭니다. Spring Security는 세션 고정 공격의 방어 기능을 제공하며, 직접 구현할 때도 같은 원칙을 지킵니다.

src/main/java/board/web/auth/LogoutController.java
package board.web.auth;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public final class LogoutController {
    @PostMapping("/logout")
    String logout(
            HttpServletRequest request,
            HttpServletResponse response
    ) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        ResponseCookie expired = ResponseCookie.from("JSESSIONID", "")
                .httpOnly(true)
                .secure(true)
                .sameSite("Lax")
                .path("/")
                .maxAge(0)
                .build();
        response.addHeader("Set-Cookie", expired.toString());
        return "redirect:/";
    }
}

로그아웃은 GET 링크가 아니라, CSRF 보호를 받는 POST 같은 상태 변경 메서드를 씁니다. 브라우저의 쿠키를 지우면 사용자 화면에 바로 반영되고, invalidate()는 탈취된 ID가 서버에서 더 이상 통하지 않게 합니다. 삭제할 쿠키의 경로와 도메인이 원래 쿠키와 다르면 삭제 헤더가 별도의 쿠키를 만들 수 있으므로, 발급할 때의 설정과 맞춥니다.

더 깊이: 세션을 버리지 않고 ID만 바꾸는 changeSessionId

서블릿 3.1부터 HttpServletRequest에는 changeSessionId()가 있습니다. 서블릿 API 문서는 이 메서드가 요청에 연결된 현재 세션의 ID를 바꾸고 새 ID를 돌려준다고 설명하며, 연결된 세션이 없으면 IllegalStateException을 던집니다. invalidate() 뒤에 getSession(true)를 부르는 방식은 세션 객체 자체를 새로 만들지만, changeSessionId()는 같은 세션을 그대로 두고 ID만 바꿉니다. 그래서 로그인 전에 담아 둔 속성도 바뀐 ID 아래에 그대로 남습니다.

Spring Security의 세션 고정 방어도 이 차이를 선택지로 내놓습니다. Spring Security 문서에 따르면 서블릿 3.1 이상의 컨테이너에서는 changeSessionId가 기본값입니다. newSession은 기존 데이터를 복사하지 않은 새 세션을 만들되 Spring Security가 쓰는 속성만 옮기고, migrateSession은 새 세션을 만들어 기존 속성을 모두 복사합니다. 앞 글의 로그인처럼 익명 상태에서 쌓인 값을 하나도 넘기지 않으려면 새 세션을 만드는 쪽이 맞고, 로그인 전에 고른 표시 언어처럼 짧은 화면 상태를 이어 가야 한다면 ID만 바꾸는 쪽이 간단합니다. 어느 쪽이든 공격자가 미리 알던 ID는 로그인 뒤에 더 이상 통하지 않습니다.


유휴 만료와 절대 만료

서블릿 세션의 maxInactiveInterval은 마지막 접근 뒤에 허용되는 유휴 시간이며, 사용자가 계속 요청하면 늘어날 수 있습니다. 애플리케이션 코드가 세션 속성을 읽거나 쓰는 것 자체가 유휴 시계를 갱신하지는 않습니다. 기준은 클라이언트가 보낸 세션 관련 요청이며, 서블릿의 HttpSession 명세는 이 둘을 구분합니다. 인증 시각인 authenticatedAt은 처음 자격 증명을 검증했을 때 기록하고, 정상적인 조회 때마다 다시 쓰지 않습니다.

예를 들어 09:00에 로그인했고 유휴 한도는 30분, 절대 한도는 8시간이라고 합시다. 16:59의 정상 요청 직전까지 30분보다 짧은 간격으로 접근해 세션이 유지되었다고 가정하면, 마지막 접근을 기준으로 한 유휴 기한은 늘어날 수 있지만 절대 기한은 여전히 17:00입니다. 17:00의 요청은 유휴 조건만 보면 허용될 수 있어도 절대 수명 정책에서는 거부해야 합니다. 두 기한 가운데 하나라도 인증을 허용하지 않으면 자격 증명을 새로 검증해야 합니다. 민감한 관리 기능은 로그인 뒤 최대 8시간 같은 절대 수명이나 최근의 재인증을 따로 요구할 수 있습니다.

시계기준 시각정상 요청으로 늘어나는지끝났을 때의 인증
유휴 수명마지막 세션 접근늘어남서버 세션 만료
절대 수명자격 증명을 검증한 시각늘어나지 않음재인증 전까지 거부
쿠키 보관브라우저가 받은 만료 속성설정에 따라 다름서버 세션의 유효성과 별개
src/main/resources/application.properties
server.servlet.session.timeout=30m
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB

로컬 HTTP 개발에서 secure=true이면 브라우저가 쿠키를 다시 보내지 않을 수 있습니다. 운영 환경의 보안을 낮추지 말고, HTTPS 로컬 프로필이나 명시적인 개발 프로필을 씁니다. 리버스 프록시 뒤에서는 외부 스킴과 전달된 헤더를, 믿을 수 있는 프록시의 범위 안에서만 처리하도록 설정합니다.

유휴 만료가 일어나면 저장소에서 세션이 사라집니다. HTML 요청에는 원래 목적지를 보존한 로그인 리다이렉트를, API 요청에는 401 ProblemDetail을 돌려줄 수 있습니다. 리다이렉트 URL을 클라이언트 파라미터 그대로 쓰면 열린 리다이렉트(open redirect)가 되므로, 내부 경로인지 검증합니다.

더 깊이: 30m은 서버마다 같은 30분일까

server.servlet.session.timeout은 단위를 붙이지 않으면 초로 읽히고, 기본값은 30분입니다. 하지만 이 값이 서블릿 컨테이너에 전달되는 방식은 서버마다 다릅니다. Spring Boot 4.1.2의 TomcatServletWebServerFactory는 이 값을 분 단위로 바꿔 Tomcat 컨텍스트에 넘기는데, Duration.toMinutes()로 나머지를 버리고 0보다 큰 값은 최소 1분으로 올립니다. 그래서 Tomcat에서는 90s가 1분, 150s가 2분이 됩니다. 반면 Jetty용 팩토리는 같은 값을 초 단위 그대로 넘기므로, 분으로 나누어떨어지지 않는 값은 서버를 바꾸면 동작이 달라집니다.

만료된 세션이 언제 지워지는지도 한 시점으로 정해져 있지 않습니다. Tomcat은 새 세션에 컨텍스트 설정값의 60배를 초 단위 유휴 한도로 주고, 요청이 그 세션을 찾을 때 마지막 접근 뒤 지난 시간이 한도 이상이면 그 자리에서 만료시킵니다. 아무도 찾지 않는 세션은 백그라운드 작업이 몇 번에 한 번씩(processExpiresFrequency, 기본값 6) 모아서 정리하므로, 만료 리스너가 호출되는 시각은 기한보다 늦을 수 있습니다. 또 서블릿 명세에서 유휴 한도가 0 이하이면 유휴 시간으로는 만료하지 않는다는 뜻이므로, 테스트를 빠르게 하려고 0을 넣으면 의도와 반대로 동작합니다.


세션 수명 주기 테스트

src/test/java/board/web/auth/SessionLifecycleTest.java
package board.web.auth;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;

class SessionLifecycleTest {
    @Test
    void session은_명시한_idle_timeout과_폐기_신호를_가진다() {
        var request = new MockHttpServletRequest();
        var session = request.getSession(true);
        session.setMaxInactiveInterval(1_800);
        session.setAttribute(
                SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
                new SessionMemberReader.AuthenticatedMember(41L));

        assertThat(session.getMaxInactiveInterval()).isEqualTo(1_800);
        assertThat(session.getAttribute(
                SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE))
                .isEqualTo(new SessionMemberReader.AuthenticatedMember(41L));

        session.invalidate();

        assertThatThrownBy(session::getCreationTime)
                .isInstanceOf(IllegalStateException.class);
    }
}
session 수명 주기 결과
created = true
idle timeout = 1800 seconds
authenticated member = 41
invalidate executed = true
subsequent access = IllegalStateException

모의 테스트는 실제 벽시계로 만료를 기다리지 않습니다. 통합 테스트에서는 시간을 제어할 수 있는 세션 저장소나 내장 컨테이너 설정을 써서, 만료된 뒤 같은 ID가 인증되지 않는지 확인합니다. Thread.sleep으로 30분을 재현하는 테스트는 느리고 불안정합니다.


다중 인스턴스의 세션 저장소

한 JVM의 메모리에 세션을 두고 로드 밸런서가 요청을 다른 인스턴스로 보내면 로그인 상태가 사라집니다. 고정 세션(sticky session)은 당장은 동작하지만 인스턴스 장애와 확장에 제약이 있습니다. Spring Session과 Redis 같은 공유 저장소를 쓰면 모든 인스턴스가 같은 ID를 조회할 수 있지만, 네트워크 장애와 직렬화 버전, TTL 운영이라는 부담이 더해집니다.

저장 방식장점확인할 실패
단일 JVM단순하고 빠름재시작과 다중 인스턴스
고정 라우팅도입이 쉬움인스턴스 장애와 불균형
공유 저장소수평 확장과 중앙 만료네트워크와 TTL, 직렬화

동시에 온 두 요청이 같은 세션 속성을 읽고 고쳐 쓰면 한쪽의 갱신을 잃을 수 있습니다. 장바구니나 업무 애그리거트를 세션 맵에서 계속 고치지 말고 데이터베이스 트랜잭션에 둡니다. 세션은 인증과 짧은 화면 상태처럼 최소한의 요청 문맥에 알맞습니다.

직렬화할 수 있는 객체를 넣었다고 업그레이드가 안전한 것은 아닙니다. 클래스 이름이나 필드가 바뀌면 순차 배포 중에 이전 인스턴스가 쓴 값을 새 인스턴스가 읽지 못할 수 있습니다. 단순한 ID와 스키마 버전을 저장하면 호환해야 할 범위를 줄일 수 있습니다.


정리

  • 조회만 할 때는 getSession(false)를 써서 익명 방문자에게 세션을 만들지 않고, 세션에는 작은 인증 스냅샷만 둡니다.
  • @SessionAttribute는 기존 세션의 속성을 읽기만 하며, 모든 보호 엔드포인트의 규칙은 필터나 인터셉터 한곳에서 적용합니다.
  • 로그인할 때는 세션 ID를 바꾸고, 로그아웃은 CSRF 보호를 받는 POST로 서버 세션을 폐기하며 같은 범위의 쿠키를 만료시킵니다.
  • 유휴 만료는 접근할 때마다 늘어나지만 절대 만료는 인증 시각에서 고정되므로, 두 시계를 따로 검사합니다.
  • 다중 인스턴스에서는 공유 저장소의 TTL과 직렬화 호환을 확인하고, 업무 상태의 동시 갱신은 세션이 아니라 DB 트랜잭션에 맡깁니다.

연습 문제

1. 유휴 수명과 절대 수명 함께 적용하기

30분의 유휴 타임아웃과 로그인 뒤 8시간의 절대 수명을 모두 적용하려고 합니다. 세션에 authenticatedAt을 서버 시계로 저장하고 보호된 리소스에 접근할 때 확인합니다. 유휴 만료와 절대 만료, 정상적인 연장, 로그아웃 뒤의 재사용을 각각 어떻게 자동화된 테스트로 구분하나요?

해설 보기

절대 수명은 클라이언트 쿠키의 시간이 아니라 Instant와 주입한 Clock으로 계산합니다. 만료되면 세션을 무효화하고, 인증 실패의 종류를 바깥에 너무 자세히 알려 주지 않습니다.

src/main/java/board/web/auth/AbsoluteSessionPolicy.java
package board.web.auth;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;

public final class AbsoluteSessionPolicy {
    private final Clock clock;
    private final Duration lifetime;

    public AbsoluteSessionPolicy(Clock clock, Duration lifetime) {
        this.clock = clock;
        this.lifetime = lifetime;
    }

    public boolean expired(Instant authenticatedAt) {
        Instant deadline = authenticatedAt.plus(lifetime);
        return !clock.instant().isBefore(deadline);
    }
}

고정 시계를 7시간 59분, 정확히 8시간, 8시간이 지난 시각으로 옮겨 가며 검사합니다. 기한 시각을 만료에 포함하는지는 테스트 이름과 검증에 분명히 적고, 타임아웃 처리는 필터나 인터셉터 한곳에 모읍니다. 유휴 만료는 시간을 제어할 수 있는 세션 저장소로, 로그아웃 뒤의 재사용은 같은 세션 ID로 다시 요청했을 때 인증되지 않는지로 확인합니다.

2. 절대 기한 판정하기

새 예제에서 인증 시각은 2026-10-06T09:00:00Z, 절대 수명은 8시간으로 고정합니다. AbsoluteSessionPolicy의 Clock을 각각 16:59:59Z, 17:00:00Z, 17:00:01Z로 주입하면 expired()는 어떤 값을 돌려주나요? 16:59:59Z에 정상적으로 접근했다는 사실이 두 번째 판정을 바꾸는지도 설명하세요.

해설 보기

기한은 인증 시각에 8시간을 더한 17:00:00Z입니다. isBefore(deadline)은 첫 시각에서만 참이므로, 그 부정인 expired()는 차례로 거짓, 참, 참이며 정확한 기한도 만료에 포함됩니다. 정상 요청으로 유휴 수명이 늘어나더라도 인증 시각과 절대 기한은 바뀌지 않습니다.

이 도우미가 참을 돌려주는 것은 판정일 뿐입니다. 지금의 SessionMemberReader가 이 도우미를 자동으로 호출하거나 invalidate()를 실행하지는 않습니다. 보호 요청의 필터나 인터셉터에 연결한 별도의 테스트에서, 만료 판정 뒤 서버 세션의 폐기와 인증 차단을 확인해야 합니다. 반대로 모의 객체의 maxInactiveInterval=1800 검사는 설정 값을 확인할 뿐 실제 벽시계 만료를 보여 주지 않으므로, 계산과 정책 연결, 컨테이너의 만료를 세 가지 검사로 나눕니다.

다음 글 서블릿 필터에서는 모든 요청의 앞뒤를 감싸는 서블릿 필터가 인증과 추적, 본문 처리에서 무엇을 맡고 예외를 어떻게 정리하는지 다룹니다.