HTTP 세션 수명 주기
게시판 인증 세션을 필요할 때만 만들고 세션 고정 공격 방어, 유휴 타임아웃, 세션 무효화, 분산 저장소와 동시 요청의 일관성을 검증합니다.
HTTP는 요청마다 독립적이지만 로그인한 사용자의 여러 요청은 같은 인증 상태를 참조해야 합니다.
서블릿 컨테이너는 브라우저의 세션 쿠키와 서버 저장소를 연결해 이 상태를 제공합니다.
중요한 것은 세션을 어디서든 편하게 꺼내는 것이 아니라 생성 시점, 보관 값, 만료 조건, 폐기 신호를 하나의 수명 주기로 설계하는 일입니다.
세션은 익명에서 활성으로 바뀌고 만료나 로그아웃에서 서버 상태가 끝난다
익명·로그인 후 새 세션·활성 조회·유휴 만료/로그아웃에서 서버 상태가 끝나는 수명 변화이다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
세션의 생성·조회·폐기와 두 만료 시계를 구분한다
조회는 getSession(false)로 생성하지 않습니다. 유휴 30분은 컨테이너 설정이고, 절대 8시간은 별도 정책입니다. 실제 mock 검사는 회원 41 저장과 명시적 invalidate를 확인합니다.
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 쿠키 제거가 요청됩니다. |
세션은 익명에서 활성으로 바뀌고 만료나 로그아웃에서 서버 상태가 끝난다
익명·로그인 후 새 세션·활성 조회·유휴 만료/로그아웃에서 서버 상태가 끝나는 수명 변화이다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
세션의 생성·조회·폐기와 두 만료 시계를 구분한다
조회는 getSession(false)로 생성하지 않습니다. 유휴 30분은 컨테이너 설정이고, 절대 8시간은 별도 정책입니다. 실제 mock 검사는 회원 41 저장과 명시적 invalidate를 확인합니다.
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 쿠키 제거가 요청됩니다. |
이 상태도는 수명 주기 설계와 실행 범위를 함께 구분합니다. 실제 모의 테스트는 1800초 유휴 설정과 회원 41 저장, 명시적 세션 폐기를 검사합니다. 연습의 AbsoluteSessionPolicy는 만료 시각 판정만 제공하며, 인증 시각 저장·요청 차단·세션 폐기와의 연결 및 실제 시간 만료 검사는 추가 구현입니다.
도식은 익명 요청에서 세션을 만들지 않는 시작 상태와 로그인 성공 뒤의 활성 상태를 분리합니다. 활성 요청은 유휴 시계만 갱신하며, 절대 수명 도달과 로그아웃은 모두 서버 상태 폐기로 끝납니다.
조회 요청의 세션 생성 방지
request.getSession()은 세션이 없으면 새로 만듭니다.
공개 페이지나 인증 확인 필터에서 이 메서드를 호출하면 모든 방문자에게 쿠키와 서버 저장 공간이 생깁니다.
조회만 할 때는 getSession(false)를 사용합니다.
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에서 직접 꺼내고 형 변환하는 반복을 줄일 수 있습니다.
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
Servlet은 쿠키를 사용할 수 없는 클라이언트를 위해 URL에 ;jsessionid=...를 붙이는 URL 재작성 방식을 지원합니다.
이 ID는 브라우저 방문 기록, 북마크, 접근 로그, 분석 도구와 외부 링크의 리퍼러에 남을 수 있으므로 일반적인 웹 애플리케이션에서는 쿠키 전용 정책을 명시하는 편이 안전합니다.
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는 세션 고정 공격 방어 기능을 제공하므로 직접 구현할 때도 같은 원칙을 지킵니다.
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은 마지막 접근 뒤 허용되는 유휴 시간입니다.
사용자가 계속 요청하면 연장될 수 있습니다.
세션 속성을 애플리케이션 코드에서 읽거나 쓰는 일 자체가 유휴 시계를 갱신하는 것은 아닙니다. 기준은 클라이언트의 세션 관련 요청이며, Servlet의 HttpSession 계약은 이 둘을 구분합니다. 인증 시각인 authenticatedAt은 최초 자격 증명 검증 성공 때 기록하고 정상 조회 때마다 다시 쓰지 않습니다.
예시 09:00에 로그인하고 유휴 한도는 30분, 절대 한도는 8시간이라고 하겠습니다. 16:59의 정상 요청 직전까지도 30분 미만의 간격으로 접근해 세션이 유지됐다고 가정합니다. 마지막 접근을 기준으로 한 유휴 기한은 연장될 수 있지만, 절대 기한은 여전히 17:00입니다. 17:00 요청은 유휴 조건만 보면 허용될 수 있어도 추가 절대 수명 정책에서는 거부해야 합니다. 두 기한 중 하나라도 인증을 허용하지 않으면 새 자격 증명 검증이 필요합니다.
민감한 관리 기능은 로그인 후 최대 8시간처럼 절대 수명이나 최근 재인증을 별도로 요구할 수 있습니다.
| 시계 | 기준 시각 | 정상 요청으로 연장 | 끝났을 때의 인증 |
|---|---|---|---|
| 유휴 수명 | 마지막 세션 접근 | 연장됨 | 서버 세션 만료 |
| 절대 수명 | 자격 증명을 검증한 시각 | 연장되지 않음 | 재인증 전까지 거부 |
| 쿠키 보관 | 브라우저가 받은 만료 속성 | 설정에 따라 다름 | 서버 세션 유효성과 별도 |
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을 클라이언트 파라미터 그대로 사용하면 열린 리다이렉트가 되므로 내부 경로인지 검증합니다.
세션 수명 주기 테스트
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);
}
}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시간 이후로 이동합니다.
경계 시각의 포함 여부를 테스트 이름과 검증에 명확히 적고, 타임아웃 처리는 필터나 인터셉터 한 곳에 모읍니다.
독립 확인
새로운 fixture에서 인증 시각은 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)은 첫 시각에서만 true이므로 원 코드의 부정 결과인 expired()는 차례로 false, true, true입니다. 정확한 기한도 만료에 포함합니다. 정상 요청이 있어 유휴 수명이 연장되더라도 원 인증 시각과 절대 기한은 바꾸지 않습니다.
이 helper의 true는 판정 결과입니다. 현재 SessionMemberReader가 자동으로 이 helper를 호출하거나 invalidate()를 실행하는 것은 아닙니다. 보호 요청의 필터·인터셉터에 연결한 별도 테스트에서는 만료 판정 후 서버 세션 폐기와 인증 차단을 확인해야 합니다. 반대로 mock의 maxInactiveInterval=1800 검사는 설정값을 확인하며 실제 벽시계 만료를 증명하지 않습니다. 계산, 정책 연결, 컨테이너 만료를 세 검사로 나누세요.
다음 문서에서는 모든 요청의 앞뒤를 감싸는 서블릿 Filter에서 인증·추적·본문 처리의 책임과 예외 정리를 다룹니다.