지난 장에서는 JWT로 인증을 직접 구현했다. 로그인하면 서버가 토큰 하나를 발급하고, 그 뒤로는 매 요청마다 그 토큰을 검사해 “아, 이 사람은 아까 로그인한 그 사용자구나”를 알아본다. 서버는 누가 로그인했는지 따로 기억해 두지 않는다. 토큰 자체에 신원이 적혀 있으니까. 이게 상태 없는(stateless) 방식의 매력이었다.
그 장을 끝내며 남겨 둔 숙제를 하나 떠올려 보자. 토큰을 프런트에서 어디에 저장할 것인가. localStorage에 넣을까, 아니면 httpOnly 쿠키에 담을까? 프런트 개발자라면 이 질문에 한 번쯤 부딪혀 봤을 것이다. 그리고 답을 찾으려고 검색해 보면 의견이 정확히 둘로 갈린다. 어떤 글은 “localStorage는 XSS에 취약하니 절대 쓰지 마라”고 단호하게 말하고, 또 어떤 글은 “쿠키는 CSRF가 골치 아프다”며 머리를 절레절레 흔든다. 도대체 누구 말을 들어야 할지 난감하다.
이 “토큰을 어디 저장하지?”라는 작은 갈등을 한 발짝만 더 깊이 파고들면, 그 뒤에 훨씬 큰 갈림길이 숨어 있다는 걸 알게 된다. 바로 “그래서 인증 자체를 토큰 방식으로 할 것인가, 아니면 세션 방식으로 할 것인가” 하는 갈림길이다. 10장 끝에서 “JWT가 유일한 답은 아니다”라고 흘려 둔 그 다른 길을 오늘 직접 걸어 보자.
JWT가 답이라더니, 왜 다시 세션인가
조금 의아할 수 있다. JWT는 요즘 표준처럼 쓰이고 바로 앞 장에서 멀쩡히 구현도 했는데, 왜 이제 와서 한물간 것처럼 느껴지는 세션을 다시 꺼내는 걸까?
여기에 흥미로운 반전이 있다. 2025년 들어 개발자 커뮤니티에서는 “무조건 JWT”라는 분위기에 제동을 거는 목소리가 부쩍 늘었다(이 반전은 레퍼런스의 2025년 인증 논쟁 자료에 근거한 흐름이다). 한때 “세션은 옛날 방식, JWT가 현대적”이라는 공식이 거의 상식처럼 통했는데, 막상 JWT를 운영에 써 본 사람들이 하나둘 “잠깐, 이거 생각보다 불편한데?”라고 말하기 시작했다.
JWT로 “진짜 로그아웃”(탈취된 토큰을 지금 당장 막기)을 하려면 서버에 무엇이 필요할까?
이 지적은 뼈아프다. JWT를 쓰면서 “그래도 로그아웃은 되게 해야지”, “탈취된 토큰은 즉시 막아야지” 하다 보면 어느새 세션이 하던 일을 어색한 모양으로 다시 하고 있다. 그렇다면 차라리 처음부터 세션을 쓰는 게 나은 상황도 있지 않을까? 이 질문에 답하기 위해, 세션이 실제로 어떻게 동작하는지부터 손으로 만져 보자.
세션 — 서버가 기억하고, 쿠키는 번호표만 든다
세션 방식의 그림은 의외로 단순하다. 호텔 체크인에 빗대면 이해하기 쉽다.
호텔에 도착해 신분증을 보여 주고 체크인을 하면, 프런트 데스크는 “이 손님은 302호, 김아무개”라는 정보를 자기네 장부에 적어 둔다. 그리고 당신에게는 객실 키 하나를 건넨다. 그 키에는 당신의 이름도, 신용카드 정보도 적혀 있지 않다. 그저 “302”라는 번호만 박혀 있을 뿐이다. 이후 당신이 헬스장을 이용하거나 조식을 먹을 때 그 키를 내밀면, 직원이 번호를 보고 장부를 뒤져 “아, 302호 김아무개 손님이시군요”라고 알아본다.
세션이 정확히 이 모양이다. 로그인에 성공하면 서버는 자기 안에 “이 세션은 사용자 A다”라는 정보를 저장해 둔다. 이 저장소를 세션 스토어라 부른다. 그리고 클라이언트에게는 그 세션을 가리키는 세션 ID만 쿠키에 담아 건넨다. 세션 ID는 호텔 키의 “302”처럼 그저 번호표일 뿐이고, 진짜 신원 정보는 전부 서버 장부에 남아 있다. 이게 JWT와의 결정적 차이다. JWT는 신원을 토큰 자체에 적어 클라이언트에게 통째로 맡겼지만, 세션은 신원을 서버가 쥐고 클라이언트에겐 번호표만 준다.
taskId만 들고, 실제 객체는 store의 entities 맵에서 찾는다JSESSIONID만, 신원은 서버의 세션 스토어에이 차이가 왜 중요한지는 잠시 뒤에 보기로 하고, 일단 9장에서 그린 “필터체인에 어떤 검문소를 끼우느냐”의 그림을 다시 떠올려 보자. JWT 때는 그 줄에 “토큰을 검사하는 필터”를 끼웠다. 세션 방식에서는 그 자리에 “쿠키 속 세션 ID를 읽어 세션 스토어를 조회하는 필터”가 들어선다. 줄의 모양은 그대로고, 끼우는 검문소만 바뀐다. 다행히 이 검문소는 Spring Security가 거의 다 만들어 두었다. 토큰을 직접 파싱하고 서명을 검증하던 JWT 때보다 오히려 손이 덜 간다.
같은 TaskBoard에 세션을 입혀 보자
자, 그럼 우리 TaskBoard에 세션 방식을 두 번째 인증 모델로 입혀 보자. 한 프로젝트에서 두 방식을 다 만져 보면 차이가 몸으로 느껴진다.
먼저 Spring Security 설정이다. JWT 때 명시적으로 꺼 두었던 스위치가 하나 있었다. 세션을 만들지 않겠다는 설정 말이다. 세션 방식에서는 정반대로, 세션을 필요할 때 만들겠다고 돌려놓는다.
package com.example.taskboard.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/tasks/**").authenticated()
.anyRequest().permitAll()
)
// 세션을 필요할 때 만든다 — JWT 때의 STATELESS와 정반대
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.formLogin(form -> {}); // 폼 로그인을 켜두면 세션 발급까지 Security가 맡는다
return http.build();
}
}
한 줄씩 같이 읽어 보자. authorizeHttpRequests로 인가 규칙을 적는 건 9장·10장에서 본 그대로다. 로그인 경로는 누구나 열어 두고(permitAll), 할 일 API는 인증된 사용자만(authenticated) 통과시킨다. 핵심은 그다음 sessionManagement다. 여기서 SessionCreationPolicy.IF_REQUIRED는 “필요하면 세션을 만들어라”는 뜻이다. JWT 장에서 STATELESS로 못 박아 “세션은 절대 만들지 마”라고 했던 그 자리를, 정반대로 돌려놓았다. 이 한 줄이 stateless와 stateful을 가르는 분기점이다.
신기하게도 세션 ID 쿠키를 굽고, 세션 스토어에 저장하고, 다음 요청에서 그 쿠키를 읽어 사용자를 복원하는 그 모든 잔일에 우리는 거의 손대지 않는다. formLogin을 켜 두면 Spring Security가 로그인 성공 시 알아서 세션을 만들고 JSESSIONID라는 이름의 쿠키를 응답에 실어 보낸다. 브라우저는 그 쿠키를 보관했다가 다음 요청부터 자동으로 함께 보낸다. JWT 때 인증 필터를 직접 짜며 끙끙댔던 걸 떠올리면, 이쪽은 한결 수월하다. 서버가 상태를 들고 있는 대신 코드는 단순해진다.
원문 보정 · 앱 보충위 설정을 그대로 쓰면 로그인 창구는
/api/auth/login이 아니라formLogin이 만드는POST /login이다. 폼 형식(application/x-www-form-urlencoded)의username·password를 받고, 브라우저로localhost:8080/login을 열면 기본 로그인 폼도 보인다.permitAll()로 열어 둔/api/auth/login은 이 설정에서 쓰이지 않는다. 10장처럼 JSON을 받는 로그인 컨트롤러를 직접 두고 싶다면 주의할 점이 하나 있다. Spring Security 6부터는 컨트롤러에서 직접 인증한 결과를 세션에 자동으로 저장하지 않는다.SecurityContextRepository(세션이면HttpSessionSecurityContextRepository)로 명시적으로 저장하지 않으면, 로그인 응답은 200인데 다음 요청에서 다시 익명 사용자가 된다.
세션으로 로그인한 뒤 서버를 두 대로 늘렸다. 로드 밸런서가 요청을 두 서버에 번갈아 보내면?
ch11-1 같은 TaskBoard에 세션을 입히기명령과 기대 출력 펼치기
실행해 보기: 브라우저
./gradlew bootRun- 브라우저로
http://localhost:8080/api/tasks를 연다. 기본 로그인 폼(/login)으로 이동한다. ara/pass1234로 로그인하면 처음 열려던/api/tasks로 돌아와 JSON이 보인다.- 개발자 도구 Application(저장소) 탭 → Cookies →
http://localhost:8080에서JSESSIONID를 찾는다. HttpOnly에 체크돼 있다. 자바스크립트(document.cookie)로 읽을 수 없는 쿠키다. - 서버를 껐다 켜고 같은 탭에서
/api/tasks를 새로 고친다. 다시 로그인 폼이 나온다. 세션은 서버 메모리에 있었고, 쿠키는 그 세션을 가리키는 번호표일 뿐이었다. 서버가 재시작되면 번호표가 가리킬 곳이 사라진다.
실행해 보기: curl
로그인 폼에는 CSRF 토큰이 숨어 있다. 쿠키를 파일(jar)에 담아 가며 흉내 낸다.
curl -i http://localhost:8080/api/tasks
# 302, Location: http://localhost:8080/login
CSRF=$(curl -s -c jar -b jar http://localhost:8080/login | sed -n 's/.*name="_csrf" type="hidden" value="\([^"]*\)".*/\1/p')
curl -i -c jar -b jar -X POST http://localhost:8080/login \
-d "username=ara&password=pass1234&_csrf=$CSRF"
# 302, Set-Cookie: JSESSIONID=…; Path=/; HttpOnly
curl -i -b jar http://localhost:8080/api/tasks
# 200JWT 때와 달리 요청에 토큰을 싣지 않는다. 쿠키(-b jar)만 보내면 서버가 세션에서 사용자를 찾는다.
- 틀린 비밀번호:
302,Location: http://localhost:8080/login?error _csrf없이POST /login:403. CSRF 보호가 켜져 있어서 로그인 요청조차 토큰이 필요하다(11장 4절의 원문 보정 상자)
토큰 저장 위치, 이제 정면으로 — localStorage냐 httpOnly 쿠키냐
이 장 첫머리에서 미뤄 둔 그 질문으로 돌아오자. 토큰을, 혹은 세션 ID를 프런트에서 어디에 둘 것인가. 10장에서 “사소한 구현 디테일이 아니라 보안 결정”이라고만 못 박고 미룬 그 논의를 이제 세션을 손에 쥐었으니 제대로 펼칠 수 있다.
먼저 localStorage다. JWT를 받아 localStorage.setItem('token', ...)으로 저장하는 방식은 React 튜토리얼에 흔히 등장한다. 편하다. 꺼내서 Authorization 헤더에 실으면 그만이니까. 그런데 한 가지 결정적 약점이 있다. localStorage는 그 페이지에서 도는 모든 자바스크립트가 읽을 수 있다. 사이트에 XSS(스크립트 주입) 취약점이 하나라도 있으면, 끼어든 악성 스크립트가 토큰을 통째로 훔쳐 간다. 출입증을 책상 위에 펼쳐 둔 셈이다.
XSS로 악성 스크립트가 끼어든 페이지. 세션 쿠키가 HttpOnly라면 그 스크립트가 할 수 없는 일은?
그렇다면 쿠키가 무조건 정답일까? 그렇게 단순하지 않다. 쿠키는 브라우저가 자동으로 실어 보낸다는 그 편리함 때문에 또 다른 공격(CSRF — 사용자도 모르게 요청이 위조되는 것)에 대한 대비가 따로 필요하다. 거저 얻는 건 없다는 말은 여기서도 어김없다. 게다가 쿠키를 쓰는 순간, 5장에서 비워 두고 미뤄 둔 그 CORS 설정이 곧장 발목을 잡는다. 다음 절에서 그 설정을 마저 채운다.
원문 보정 · 앱 보충그 “따로 필요한 대비”는 이미 켜져 있다. Spring Security 6은 CSRF 보호가 기본으로 켜져 있고, 위
SessionSecurityConfig는 10장과 달리csrf().disable()을 하지 않았다. 그래서 React에서 쿠키 인증으로POST·PUT·DELETE를 보내면(로그인POST /login과POST /logout도 포함해) CSRF 토큰을 함께 보내기 전까지 403 Forbidden이 난다. 이건 버그가 아니라 제대로 동작하는 방어다. 막힌다고 끄지 말고, Spring Security 레퍼런스 CSRF 장의 단일 페이지 앱(SPA) 절을 따라 토큰을 쿠키로 받아 헤더(X-XSRF-TOKEN)에 실어 보내는 구성을 하자.
방식을 고르고 로그인 → 요청부터 해 보세요. 그다음 서버를 재시작하거나 두 대로 늘리고, 주입된 스크립트(XSS)를 실행해 보세요. 방식을 바꾸면 처음부터 다시 시작합니다.
브라우저 · localStorage
서버 1 · 기억하는 것
공격자
- 해볼 것
- JWT: 훔친 토큰이 사용자가 로그아웃한 뒤에도 통과하는 것 보기
- JWT: 서버 재시작이나 두 대 확장 뒤에도 로그인이 유지되는 것 보기
- 세션: 서버 재시작이나 두 대 확장으로 로그인이 풀리는 것 보기
- 세션: 주입된 스크립트가
JSESSIONID를 읽지 못하는 것 보기
쿠키 인증과 CORS가 다시 만나는 곳
세션을 쿠키로 주고받으니, 이제 5장에서 심어 둔 씨앗 하나를 거둘 때가 됐다. 바로 CORS와 allowCredentials다.
5장에서는 React 개발 서버(localhost:3000)에서 Spring 서버(localhost:8080)를 부를 수 있도록 CORS를 열었다. 그때 일부러 비워 두고 “11장에서 다시 만나자”고 미뤄 둔 설정이 있었는데, 그게 allowCredentials였다. 이제 그 약속을 지킬 차례다.
localhost:3000의 React가 로그인을 마친 뒤 fetch('http://localhost:8080/api/tasks')를 옵션 없이 부른다. 세션 쿠키가 따라갈까?
이걸 풀려면 양쪽에서 손발을 맞춰야 한다. 프런트에서는 요청을 보낼 때 “쿠키도 같이 보내줘”라고 명시해야 한다. fetch라면 credentials: 'include'를 붙인다.
// 프런트(React) 쪽 — 쿠키를 함께 실어 보내라고 명시
fetch('http://localhost:8080/api/tasks', {
credentials: 'include',
});
fetch(url, { credentials: 'include' })config.setAllowCredentials(true) + 정확한 allowedOriginsapp.example.com → api.example.net)에서는 쿠키 자신의 SameSite 속성이 세 번째 관문이 된다. localhost:3000 → :8080은 포트만 달라 같은 사이트라서 이 관문이 보이지 않을 뿐이다.그리고 서버에서는 5장에서 비워 뒀던 allowCredentials를 켠다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 와일드카드("*")가 아니라 정확한 출처를 적어야 한다
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // 쿠키 인증을 허용한다
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
여기서 5장에서 미리 경고해 둔 그 함정을 정면으로 마주한다. allowCredentials(true)를 켜는 순간, allowedOrigins를 와일드카드("*")로 두면 안 된다. 둘을 동시에 켜면 브라우저가 단호히 거부한다. 왜 그럴까? 생각해 보면 당연한 안전장치다. “아무 출처에서나(*) 쿠키를 실어 보내도 좋다”는 건, 곧 “어떤 악성 사이트가 사용자 몰래 우리 서버에 인증된 요청을 보내도 막지 않겠다”는 말과 같다. 너무 위험하다. 그래서 쿠키 인증을 허용할 때는 반드시 “이 출처에서 온 것만”이라고 정확히 못 박아야 한다. 위 코드에서 localhost:3000이라고 콕 집어 적은 게 그래서다.
원문 보정 · 앱 보충실제로 Spring Framework 5.3 이후에는 브라우저까지 가기도 전에 Spring이 먼저 거부한다. CORS 요청이 들어오면
CorsConfiguration이 출처를 검사하면서java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" …을 던진다. 그러니 이 조합은 브라우저 콘솔보다 서버 로그에서 먼저 보인다. 예외 메시지는 출처를 하나하나 적거나allowedOriginPatterns를 쓰라고 안내하는데,allowedOriginPatterns("*")는 요청 출처를 그대로 되돌려 주므로 결국 “아무 출처에서나 쿠키 인증”을 허락하는 셈이다. 패턴을 쓰더라도https://*.example.com처럼 좁혀 쓰자. 그리고 Spring Security를 쓰는 지금은 프리플라이트(OPTIONS) 요청이 인증 검사에 먼저 걸리지 않도록SessionSecurityConfig에.cors(Customizer.withDefaults())를 명시해 두는 편이 안전하다.
이 대목은 입문자가, 그리고 솔직히 경력자도 자주 헤매는 지점이다. AI에게 CORS 설정을 부탁하면 종종 allowedOrigins("*")에 allowCredentials(true)를 함께 켠 코드를 내놓는데, 그건 애초에 브라우저가 거부하는 조합이라 동작하지도 않고 위험하기까지 하다. 5장부터 이어 온 원칙은 여기서도 그대로다. AI는 설정의 뼈대를 잡아 주는 동반자이되, 무엇을 얼마나 열지는 끝까지 우리가 정한다. 이 조합만큼은 특히 눈을 부릅뜨고 봐야 한다.
“세션 쿠키로 로그인하는 SecurityConfig랑 CORS 설정 만들어줘. 프런트는 localhost:3000이야.” (Spring Boot 3.5 기준)
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
프런트의 fetch 옵션, 서버의 CORS 설정, 배포 구성, 세션 쿠키의 SameSite를 바꿔 가며 로그인 → 목록 요청을 해 보세요. 로그인은 위 보정 상자처럼 JSON을 받는 로그인 컨트롤러를 둔 경우를 가정하고, 콘솔의 에러 문구는 Chrome 기준입니다. CSRF는 이 실험에서 다루지 않습니다.
지금 설정
// http://localhost:3000
fetch('http://localhost:8080/api/tasks')
// http://localhost:8080
config.setAllowedOrigins(List.of("*"));브라우저 · localhost:8080 쿠키
- 해볼 것
- 로그인은 200인데 다음 요청이 인증 안 됨으로 끝나는 상황 만들기
allowCredentials(true)+"*"조합으로 서버 예외 내기credentials: 'include'인데 서버가 허락하지 않아 CORS 에러 보기- 쿠키 인증 성공시키기 (GET 200)
- 교차 사이트 배포에서 SameSite 기본값 때문에 쿠키가 실리지 않는 것 보기
ch11-2 쿠키 인증과 CORS가 다시 만나는 곳명령과 기대 출력 펼치기
실행해 보기: 브라우저
./gradlew bootRun- 브라우저로
http://localhost:8080/api/tasks를 열고ara/pass1234로 로그인한다. (/login을 직접 열어 로그인하면/로 가는데, 이 앱에는 루트 페이지가 없어서 404 오류 페이지가 나온다. 정상이다.) - 같은 브라우저에서
http://localhost:3000의 페이지를 연다. React 개발 서버가 없다면 빈 폴더에서npx serve -l 3000. - 그 페이지의 개발자 도구 콘솔에서 두 요청을 비교한다.
// 쿠키를 함께 싣는다
const a = await fetch('http://localhost:8080/api/tasks', { credentials: 'include' })
a.status, await a.json() // 200, 할 일 목록
// 쿠키를 싣지 않는다 (fetch 기본값)
const b = await fetch('http://localhost:8080/api/tasks')
b.redirected, b.url // true, 'http://localhost:8080/login'Network 탭에서 두 요청의 Request Headers를 비교한다. 첫 요청에만 Cookie: JSESSIONID=…가 있다. 둘째는 로그인하지 않은 요청으로 취급돼 로그인 폼으로 리다이렉트됐다.
콘솔에서 document.cookie를 쳐 봐도 JSESSIONID는 안 보인다. HttpOnly라 자바스크립트로 읽을 수 없고, 브라우저만 실어 보낸다.
실행해 보기: curl
curl -i -X OPTIONS http://localhost:8080/api/tasks \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET"HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Credentials: true
Access-Control-Allow-Credentials: true가 새로 붙었다. ch11-1에서는 이 헤더가 없어서, 브라우저가 쿠키를 실은 요청의 응답을 자바스크립트에 넘겨주지 않는다.
직접 깨뜨려 보기: *와 allowCredentials(true)
CorsConfig의 허용 출처를 List.of("*")로 바꾸고 띄운 뒤, Origin을 실어 요청한다.
curl -i http://localhost:8080/api/users/summary -H "Origin: http://localhost:3000" # 500
curl -i http://localhost:8080/api/users/summary # 200서버 로그:
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
브라우저 콘솔보다 서버가 먼저 거부한다. Origin이 없는 요청은 CORS 처리를 거치지 않으니 멀쩡하다. "아무 출처에서나 쿠키 인증"은 허락할 수 없다는 뜻이다. 확인했으면 git checkout -- .으로 되돌린다.
CSRF는 켜져 있다
이 설정은 10장과 달리 CSRF를 끄지 않았다. React에서 쿠키 인증으로 POST·PUT·DELETE를 보내면, CSRF 토큰을 함께 보내기 전까지 403이다(11장 4절의 원문 보정 상자). 막는 게 정상이고, 끄지 말고 Spring Security 레퍼런스 CSRF 장의 SPA 절을 따라 구성한다.
트레이드오프 — 정면으로 비교해 보자
이제 두 방식을 직접 만져 봤으니, 둘을 나란히 놓고 솔직하게 따져 보자. 어느 쪽이 “더 좋다”는 단순한 답은 없다. 각자 잘하는 일과 못하는 일이 분명히 갈린다.
즉시 폐기 — 세션의 압승. 가장 또렷한 차이가 여기다. “이 사용자를 지금 당장 로그아웃시켜라” 또는 “탈취가 의심되는 세션을 즉시 끊어라”가 필요할 때, 세션은 일이 간단하다. 서버 장부에서 해당 세션을 지워 버리면 그만이다. 다음 요청에서 번호표를 내밀어도 장부에 없으니 곧장 거부된다. 반면 JWT는 한번 발급하면 만료 시각까지 그 자체로 유효하다. 토큰을 강제로 무효화하려면 앞서 말한 블랙리스트를 따로 운영해야 하는데, 그 순간 stateless의 미덕이 무너진다. “모든 기기에서 로그아웃” 같은 기능도 세션 쪽이 훨씬 자연스럽다. 그 사용자의 세션을 전부 지우면 끝이니까.
확장성 — JWT의 영역. 반대로 서버를 여러 대로 늘리거나, 하나의 API를 웹·iOS·안드로이드 등 여러 클라이언트가 함께 쓰는 상황이라면 JWT가 빛난다. 토큰 자체에 신원이 담겨 있으니 어느 서버로 요청이 가든 공유 저장소를 조회할 필요가 없다. 세션은 이때 세션 스토어를 공유해야 하는 부담이 생긴다.
저장 위치와 XSS. 앞 절에서 정면으로 다룬 그 차이가 트레이드오프 표의 한 칸으로도 들어온다. JWT를 흔히 넣는 localStorage는 XSS에 토큰이 노출될 위험이 있고, 세션 ID를 담는 httpOnly 쿠키는 그 점에서 한 수 안전하다. 다만 쿠키엔 CSRF라는 다른 숙제가 따라온다는 것도 잊지 말자.
표로 한눈에 정리하면 이렇다.
| 기준 | 세션(stateful) | JWT(stateless) |
|---|---|---|
| 신원 저장 위치 | 서버(세션 스토어) | 토큰 자체(클라이언트) |
| 즉시 로그아웃·폐기 | 쉬움 — 장부에서 지우면 끝 | 어려움 — 블랙리스트 필요 |
| 서버 확장 | 세션 스토어 공유 필요 | 자유로움 |
| 여러 클라이언트(웹·모바일) | 다소 번거로움 | 적합 |
| 저장·XSS | httpOnly 쿠키로 보호 가능 | localStorage는 탈취 위험 |
| 구현 손품(Spring 기준) | 적음 — Security가 거의 처리 | 많음 — 필터·서명 직접 |
이 표를 보며 한 가지는 분명히 느꼈을 것이다. 어느 쪽도 모든 칸에서 이기지 않는다. 그래서 “무조건 JWT”나 “무조건 세션”이라는 말은 둘 다 미덥지 못하다. 중요한 건 우리 상황이 어느 칸을 더 무겁게 치느냐다.
그래서, 무엇을 고를까
기준이 생겼으니 의사결정 가이드로 정리해 보자. 완벽한 공식은 아니지만, 길을 고를 때 손에 쥘 나침반은 된다.
하나의 API를 여러 프런트엔드와 모바일 앱이 함께 물고, 서버를 수평으로 쭉쭉 늘릴 계획이라면 JWT가 잘 맞는 편이다. stateless의 확장성이 그대로 이점이 된다. 토큰 수명을 짧게 잡고 리프레시 토큰으로 갱신하는 구조를 더하면 폐기의 약점도 어느 정도 메울 수 있다.
반대로 즉시 로그아웃·강제 폐기가 중요하거나, “모든 기기 로그아웃” 같은 기능이 필요하거나, 무엇보다 단순함을 우선하고 싶다면 세션이 잘 맞는 편이다. 특히 단일 웹 애플리케이션 하나에 브라우저로만 접속하는 흔한 구조라면, 세션의 단순함과 httpOnly 쿠키의 안전함이 꽤 매력적이다. 솔직히 입문 단계에서 처음 만드는 서비스 대부분이 이 자리에 해당한다.
그러니 흔한 오해 하나만 짚고 가자. “세션은 구식, JWT는 현대식”이라는 이분법은 사실이 아니다. 둘은 세대 차이가 아니라 성격 차이다. 우리 TaskBoard처럼 단일 웹 앱으로 시작하는 서비스라면 오히려 세션이 더 단순하고 안전한 선택일 수 있다. 그런데도 많은 입문자가 “요즘은 다 JWT 쓴다더라”는 분위기만 보고 JWT부터 집어 든다. 그러다 로그아웃을 제대로 구현하려고 끙끙대며 블랙리스트를 만들기 시작하는 순간, 앞서 말한 “stateless를 버리고 stateful을 어색하게 재발명하는” 함정에 그대로 빠진다. 도구를 고를 때는 유행이 아니라 요구사항을 먼저 봐야 한다. 기억해 두자.
마무리
이번 장에서는 같은 TaskBoard에 두 번째 인증 모델인 세션을 입혀 보았다. JWT가 신원을 토큰에 적어 클라이언트에게 맡겼다면, 세션은 신원을 서버 장부에 두고 클라이언트에겐 번호표(세션 ID)만 쿠키로 건넨다. 그 덕에 즉시 폐기와 “모든 기기 로그아웃”이 쉬워지지만, 서버가 상태를 들고 있어야 한다. 그리고 토큰을 어디 저장할지의 오랜 숙제를 localStorage 대 httpOnly 쿠키로 정면으로 견주었다. 쿠키 인증을 켜는 순간 5장에서 미뤄 둔 allowCredentials와 allowedOrigins의 짝이 등장하고, 둘을 와일드카드와 함께 켜면 브라우저가 거부한다는 함정까지 직접 마주했다.
기억해 두자. 보안 방식에는 “정답”이 아니라 “트레이드오프”가 있을 뿐이다. JWT와 세션은 우열이 아니라 성격이 다르고, 어느 쪽을 고를지는 우리 상황이 어느 칸을 더 무겁게 치느냐로 갈린다. 이제 한 프로젝트에서 두 길을 다 걸어 봤으니, 누가 “넌 왜 그걸 골랐어?”라고 물으면 유행이 아니라 이유를 대며 답할 수 있다. 그게 이 장에서 얻은 진짜 무기다.
여기까지 보안의 세 단계인 기초(필터체인), JWT, 세션을 모두 통과했다. 서버는 더 이상 활짝 열린 문이 아니다. 그렇다면 이제 시선을 잠시 다른 곳으로 돌려 보자. 지금까지 우리 TaskBoard는 줄곧 JSON만 뱉어내는 API였다. 그런데 관리자용 화면 하나가 급히 필요하다면? React 앱을 또 하나 세우는 게 과할 때, 서버가 직접 HTML을 그려 내려보내는 길도 있다. 다음 장에서는 Thymeleaf로 그 길을 걸으며 SSR과 CSR을 프런트 관점에서 나란히 놓고, React를 쓰던 우리가 왜 굳이 서버 렌더링을 알아야 하는지 따져 본다.
1. JWT에서 탈취된 토큰을 지금 당장 막으려면?
2. localhost:3000에서 localhost:8080으로 세션 쿠키를 실어 보내려면 프런트에서 꼭 해야 하는 것은?
3. allowCredentials(true)와 allowedOrigins("*")를 함께 쓰면 Spring Framework 6에서는?
4. HttpOnly 세션 쿠키가 막아 주는 것은?
11장은 10장 JWT 설정과 함께 켤 수 없어서 레포의 ch11-session 브랜치에 있습니다. 10장 끝(ch10-5)에서 갈라지고, 12장은 main에서 10장 끝을 이어 갑니다. 각 단계는 본문의 해당 자리에 있습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch11-1ch11-1같은 TaskBoard에 세션을 입히기ch11-2쿠키 인증과 CORS가 다시 만나는 곳