본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

HTTP 세션 수명주기

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

HTTP는 요청마다 독립적이지만 로그인한 사용자의 여러 요청은 같은 인증 상태를 참조해야 합니다.

서블릿 컨테이너는 브라우저의 세션 쿠키와 서버 저장소를 연결해 이 상태를 제공합니다.

중요한 것은 세션을 어디서든 편하게 꺼내는 것이 아니라 생성 시점, 보관 값, 만료 조건, 폐기 신호를 하나의 수명주기로 설계하는 일입니다.


조회 요청의 세션 생성 방지

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;

import org.springframework.stereotype.Component;

@Component
public final class SessionMemberReader {
    private static final String KEY = "authenticatedMember";

    public Optional<AuthenticatedMember> read(
            HttpServletRequest request
    ) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return Optional.empty();
        }
        Object value = session.getAttribute(KEY);
        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.AuthenticatedMember;

@Controller
public final class PostPageController {
    @GetMapping("/posts")
    String sessions(
            @SessionAttribute(
                    name = "authenticatedMember",
                    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

Servlet은 쿠키를 사용할 수 없는 클라이언트를 위해 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가 서버에서 더 이상 유효하지 않게 합니다.

경로와 도메인이 원래 쿠키와 다르면 삭제 헤더가 별도 쿠키를 만들 수 있으므로 발급 설정과 일치시킵니다.


유휴·절대 만료

서블릿 세션의 maxInactiveInterval은 마지막 접근 뒤 허용되는 유휴 시간입니다.

사용자가 계속 요청하면 연장될 수 있습니다.

민감한 관리 기능은 로그인 후 최대 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

로컬 HTTP 개발에서 secure=true이면 브라우저가 쿠키를 다시 보내지 않을 수 있습니다.

운영 환경 보안을 낮추지 말고 HTTPS 로컬 프로필 또는 명시적인 개발 프로필을 사용합니다.

리버스 프록시 뒤에서는 외부 스킴과 전달된 헤더 처리를 신뢰할 프록시 범위 안에서 설정합니다.

유휴 만료가 발생하면 저장소에 세션이 없어집니다.

HTML 요청은 원래 목적지를 보존한 로그인 리다이렉트를, API 요청은 401 ProblemDetail을 반환할 수 있습니다.

리다이렉트 URL을 클라이언트 파라미터 그대로 사용하면 열기 리다이렉트가 되므로 내부 경로인지 검증합니다.


세션 수명주기 테스트

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(
                "authenticatedMember",
                new SessionMemberReader.AuthenticatedMember(41L));

        assertThat(session.getMaxInactiveInterval()).isEqualTo(1_800);
        assertThat(session.getAttribute("authenticatedMember"))
                .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 메모리에 세션을 두고 로드 밸런서가 요청을 다른 인스턴스로 보내면 로그인 상태가 사라집니다.

고정 세션은 당장 동작할 수 있지만 인스턴스 장애와 확장 시 제약이 있습니다.

Spring Session과 Redis 같은 공유 저장소를 쓰면 모든 인스턴스가 같은 ID를 조회할 수 있지만 네트워크 장애, 직렬화 버전, TTL 운영이 추가됩니다.

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

동시 요청 두 개가 같은 세션 속성을 읽고 수정한 뒤 쓰면 갱신을 잃을 수 있습니다.

장바구니나 업무 애그리거트를 세션 맵에서 계속 수정하지 않고 데이터베이스 트랜잭션에 둡니다.

세션은 인증과 짧은 UI 상태처럼 최소한의 요청 컨텍스트에 적합합니다.

직렬화 가능한 객체를 넣었다고 업그레이드 안전성이 보장되지는 않습니다.

클래스 이름과 필드가 바뀌면 순차 배포 중 이전 인스턴스가 쓴 값을 새 인스턴스가 읽지 못할 수 있습니다.

단순한 ID와 스키마 버전을 저장하면 호환성 범위를 줄일 수 있습니다.


연습 문제

30분 유휴 타임아웃과 로그인 후 8시간 절대 수명을 모두 적용하세요.

세션에 authenticatedAt을 서버 시계으로 저장하고 보호 리소스 접근 때 확인합니다.

유휴 만료, 절대 만료, 정상 연장, 로그아웃 후 재생을 각각 자동화된 테스트로 구분하세요.

해설 보기

절대 수명은 클라이언트 쿠키 시간이 아니라 Instant와 주입한 Clock으로 계산합니다.

만료 시 세션을 무효화하고 인증 실패 유형을 외부에 지나치게 자세히 노출하지 않습니다.

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시간 이후로 이동합니다.

경계 시각의 포함 여부를 테스트 이름과 검증에 명확히 적고, 타임아웃 처리는 필터나 인터셉터 한 곳에 모읍니다.

다음 문서에서는 모든 요청의 앞뒤를 감싸는 서블릿 Filter에서 인증·추적·본문 처리의 책임과 예외 정리를 다룹니다.