목차
로그아웃 버튼을 눌렀는데, 미리 복사해둔 토큰으로 API를 부르니 그대로 200이 온다.
로그아웃했는데 토큰이 아직 살아 있다
보안 점검에서 자주 나오는 지적이다. 로그아웃 API는 200을 주고 화면도 로그인 페이지로 돌아간다. 그런데 브라우저 개발자 도구에서 미리 복사해둔 토큰을 그대로 써보면 요청이 통과한다.
담당자가 로그아웃 코드를 열어본다. 버그가 없다.
@PostMapping("/logout")
public void logout() {
// 서버가 할 일이 없다. 클라이언트가 토큰을 지운다
}정말로 할 일이 없다. 서버가 아무것도 기억하지 않기로 했으니 지울 것도 없다. 버그가 아니라 선택의 결과다.
같은 상황을 세션으로 만들면 session.invalidate() 한 줄로 끝난다. 그러면 세션이 그냥 더 나은 것 아닌가. 그렇지도 않다. 이 글은 그 둘을 축을 정해 견준다.
같은 문제에 정반대 답이다
두 방식은 취향이 다른 게 아니라 같은 문제에 반대편 답을 낸 것이다.
문제는 하나다. “로그인 결과를 어디에 둘 것인가.”
한쪽으로 가면 얻는 것과 잃는 것이 세트로 따라온다. 그래서 “어느 쪽이 옳은가”는 답이 없고, 무엇을 얻고 무엇을 포기하는지만 말할 수 있다.
아래 네 개의 축으로 본다. 상태·무효화·확장·탈취다.
축 하나: 상태를 누가 갖나
가장 밑에 있는 축이다. 나머지 셋이 전부 여기서 파생된다.
- 세션 - 서버가 상태를 갖는다. 세션 저장소에 “a3f9는 7번 사용자”라는 사실이 들어 있다.
- 토큰 - 클라이언트가 상태를 갖는다. 서버는 받은 값을 검증만 한다.
이 차이가 요청 한 건을 처리하는 절차를 바꾼다.
| 세션 | 토큰 | |
|---|---|---|
| 신원을 알아내는 법 | 저장소 조회 | 서명 검증 계산 |
| 외부 의존 | 세션 저장소 | 없음(서명 키만) |
| 서버가 죽으면 | 저장소가 살아 있으면 유지 | 애초에 서버에 없다 |
여기서 흔한 오해를 하나 짚는다. “토큰은 상태가 없다”가 아니라 “서버가 상태를 안 갖는다” 이다. 상태는 그대로 있다. 위치가 클라이언트로 옮겨갔을 뿐이다.
옮겨갔다는 게 중요하다. 상태가 클라이언트에 있으면 서버가 그걸 마음대로 바꿀 수 없다. 다음 축이 정확히 그 이야기다.
축 둘: 무효화가 되나
여는 장면의 그 문제다.
세션은 서버가 기억하니 서버가 지울 수 있다. 지우는 순간 끝이다.
토큰은 만료가 사실상 유일한 브레이크다. 발급한 뒤에는 손댈 방법이 없다.
즉시 반영이 필요한 일들이 여기 다 걸린다.
- 로그아웃
- 계정 정지·도용 대응
- 권한 회수 (관리자에서 일반 회원으로 내리기)
- 비밀번호 변경 후 다른 기기 끊기
토큰으로 이걸 하려면 결국 서버가 무언가를 기억하기 시작해야 한다.
| 방법 | 성질 |
|---|---|
| 만료를 짧게 | 저장소 없이 위험 시간만 줄인다. 근본 해결은 아니다 |
| 폐기 목록 | 무효화된 토큰을 저장소에 모아두고 요청마다 조회한다 |
| 갱신 토큰만 서버 저장 | 자주 오는 요청은 조회 없이, 갱신할 때만 조회한다 |
세 번째가 실무에서 가장 흔하다. 그런데 잘 보면 접근 토큰의 수명만큼은 여전히 못 끊는다. 접근 토큰이 15분이면 로그아웃 후 최대 15분은 살아 있다. 이걸 “받아들일 수 있는가”가 선택의 기준이 된다. 쇼핑몰 장바구니라면 받아들일 만하고, 송금 기능이라면 곤란하다.
두 번째(폐기 목록)를 택하면 요청마다 조회가 생겨서 “서버가 기억하지 않는다”는 장점이 사라진다. 그러면 세션과 무엇이 다른지 다시 따져봐야 한다.
축 셋: 서버를 늘릴 때
서버가 한 대에서 여러 대가 되는 순간을 보자.
세션은 저장소를 공유해야 한다. 서버 A에서 만든 세션을 서버 B가 읽어야 하니까. 그래서 Redis 같은 공용 저장소가 하나 늘고, 그 저장소가 죽으면 전원이 로그아웃된다.
토큰은 각 서버가 같은 서명 키만 갖고 있으면 된다. 어느 서버로 요청이 가든 똑같이 검증한다. 서버를 스무 대로 늘려도 새로 준비할 게 없다.
이 축에서는 토큰이 분명히 유리하다. 특히 이런 자리에서 그렇다.
- 서비스가 여럿으로 쪼개져 있고 각자 인증을 확인해야 한다
- 요청량이 크게 출렁여서 서버를 자주 늘렸다 줄인다
- 인증을 발급하는 쪽과 검증하는 쪽이 다른 조직이다
다만 값을 정확히 봐야 한다. 세션이 늘리는 것은 인프라(저장소)이고, 토큰이 늘리는 것은 규칙(만료·갱신·키 관리)이다. 저장소 한 대를 운영하는 부담과, 갱신 흐름을 모든 클라이언트에 정확히 구현하는 부담 중 어느 쪽이 큰지는 팀에 따라 다르다.
세션 저장소를 피하려고 로드 밸런서에서 같은 사용자를 항상 같은 서버로 보내는 방법도 있다. 동작은 하지만, 그 서버를 재시작하면 거기 붙은 사용자만 로그아웃되고 서버별 부하도 고르지 않게 된다. 세션 저장소를 공유하는 편이 대체로 단순하다.
축 넷: 탈취되면 무엇을 잃나
둘 다 탈취되면 그 사람 행세를 할 수 있다. 여기까지는 같다. 다른 건 그 다음이다.
| 세션 ID를 뺏기면 | 토큰을 뺏기면 | |
|---|---|---|
| 공격자가 읽는 정보 | 없다(뜻 없는 문자열) | 페이로드 전부 |
| 알아채면 | 세션을 지워 즉시 끊는다 | 만료까지 못 끊는다 |
| 피해 범위 | 그 세션 하나 | 그 토큰 하나 |
세션 ID는 그 자체로 아무 정보가 없다. 그래서 탈취돼도 “이 사람이 누구인지”까지 새지는 않는다. 토큰은 페이로드가 그대로 읽히니 담아둔 것이 다 드러난다. 앞 글에서 민감정보를 담지 말라고 한 이유가 여기서 실제 피해로 나타난다.
그리고 훨씬 무거운 차이가 하나 있다. 서명 키가 새면 피해의 성격이 달라진다.
- 세션 저장소가 털리면 → 그 안에 있던 세션들이 위험하다. 전부 지우면 끝난다.
- 서명 키가 새면 → 공격자가 아무 사용자의 토큰이나 새로 만든다. 존재하지 않는 관리자 계정도 만들어낼 수 있다. 키를 교체하는 순간 기존 토큰이 전부 무효가 되니 전원이 로그아웃된다.
그래서 토큰 방식은 키를 교체할 수 있는 구조를 미리 만들어두는 것이 운영의 일부다. “키가 샜을 때 어떻게 바꾸나”에 답이 없으면, 사고가 났을 때 선택지가 서비스 중단뿐이다.
한 장으로 견주면
네 축을 한 표로 모은다.
| 축 | 세션 | 토큰 |
|---|---|---|
| 상태 위치 | 서버 | 클라이언트 |
| 요청 처리 | 저장소 조회 | 서명 검증 |
| 즉시 무효화 | 된다 | 안 된다 |
| 서버 늘리기 | 저장소를 공유해야 | 키만 같으면 된다 |
| 탈취 시 정보 노출 | 없다 | 페이로드가 읽힌다 |
| 사고 대응 | 세션을 지운다 | 만료를 기다리거나 키를 바꾼다 |
| 브라우저 밖(앱·서버 간) | 쿠키가 없어 불편 | 헤더로 자연스럽다 |
| 늘어나는 부담 | 인프라 | 규칙과 키 관리 |
읽어보면 한쪽이 다른 쪽을 이기는 줄이 하나도 없다. 위쪽 절반은 세션이 유리하고 아래쪽은 토큰이 유리하다. 이건 우연이 아니라, 애초에 “상태를 어디 두나”라는 하나의 결정에서 갈라져 나온 결과라서 그렇다.
실무에서는 섞는다
그리고 실제 시스템은 대체로 한쪽만 쓰지 않는다.
가장 흔한 조합이 앞 글에서 본 것이다. 짧은 접근 토큰 + 서버가 기억하는 갱신 토큰.
자주 오는 요청에서는 조회를 없애 토큰의 장점을 취하고, 드물게 오는 갱신에서만 조회를 남겨 세션의 통제력을 일부 가져온다. 순수한 무상태를 포기하는 대신 무효화를 절반쯤 되찾는 절충이다.
한 시스템 안에서 영역별로 갈라 쓰기도 한다.
- 관리자 화면은 세션 - 사용자 수가 적고, 권한 회수를 즉시 반영해야 한다
- 공개 API는 토큰 - 클라이언트가 브라우저가 아니고, 호출량이 많다
“우리는 JWT를 씁니다”라고 말하는 시스템의 상당수가 실제로는 갱신 토큰을 서버에 저장하고 있다. 이걸 두고 “제대로 된 JWT가 아니다”라고 할 필요는 없다. 무상태 자체가 목표였던 적이 없기 때문이다. 목표는 요청 처리를 가볍게 하는 것이었고, 절충은 그 목표를 지키면서 잃은 것을 되찾는 방법이다.
무엇을 보고 고르나
정리하면 질문은 이 몇 가지로 좁혀진다.
| 이런 상황 | 기우는 쪽 |
|---|---|
| 브라우저에서 쓰는 일반 웹 서비스 | 세션 |
| 로그아웃·권한 회수가 즉시 반영돼야 한다 | 세션 |
| 인증 관련 인프라를 최소로 유지하고 싶다 | 세션 |
| 서비스가 여럿으로 쪼개져 각자 검증한다 | 토큰 |
| 모바일 앱·서버 간 호출처럼 브라우저가 없다 | 토큰 |
| 발급자와 검증자가 다른 조직이다 | 토큰 |
| 서버를 자주 늘렸다 줄인다 | 토큰 |
가장 위험한 판단 근거는 “요즘은 다들 JWT를 쓴다” 다. 그 문장에는 이 시스템이 무엇을 포기해도 되는지에 대한 정보가 하나도 없다.
반대로 “세션은 확장이 안 된다” 도 사실이 아니다. 세션 저장소를 공유하는 구성은 오래되고 검증된 방식이고, 상당한 규모까지 문제없이 간다.
고를 때 스스로에게 물을 것은 하나다. “로그아웃한 사용자가 15분 더 접근할 수 있어도 되는가.” 이 질문에 “안 된다”면 세션 쪽이거나, 토큰이더라도 조회를 다시 들여야 한다. “된다”면 토큰의 장점을 온전히 가져갈 수 있다.
정리
| 같은 문제 | 로그인 결과를 어디에 둘 것인가 |
| 세션 | 서버에 둔다. 조회가 붙고, 대신 통제할 수 있다 |
| 토큰 | 클라이언트에 둔다. 조회가 없고, 대신 통제할 수 없다 |
| 무효화 | 세션은 즉시. 토큰은 만료를 기다린다 |
| 확장 | 세션은 저장소 공유. 토큰은 키만 공유 |
| 탈취 | 세션 ID엔 정보가 없다. 토큰은 페이로드가 읽힌다 |
| 실무 | 짧은 접근 토큰 + 서버가 기억하는 갱신 토큰 |
| 고르는 기준 | 즉시 무효화가 필요한가, 검증자가 흩어져 있는가 |
여기까지가 한 서비스가 자기 사용자를 인증하는 이야기였다. 그런데 “구글로 로그인”처럼 다른 서비스의 사용자를 데려오는 경우가 있다. 그때는 남의 집 비밀번호를 우리가 받아서는 안 된다. 다음 글에서 OAuth 2.0을 연다.