세션 JWT 인증 비교: 로그아웃 안 되는 토큰 때문에 밤샌 이야기 (2026)

목차

[2026-04-18 03:17:42] WARN  AuthMiddleware - Token validated but user banned 11min ago
[2026-04-18 03:17:43] WARN  AuthMiddleware - Token validated but user banned 12min ago
[2026-04-18 03:17:44] ERROR PaymentService - Banned user 8xx21 attempted refund (amount: 142,000)

새벽에 운영 알림이 울렸다. 차단당한 사용자의 토큰이 여전히 살아 있었다. 환불 API가 호출됐고, 결제 시스템은 정상으로 인식했다. 이게 세션 JWT 인증 비교를 다시 들여다보게 된 계기였다. JWT 만료 시간을 15분으로 줄여도, 차단 직후 15분 동안은 그 사용자가 무엇이든 할 수 있다는 사실은 그대로 남는다.

이 글은 그날 새벽부터 약 3주간 기존 JWT 구조를 다시 뜯어보고, 일부는 세션으로 되돌리고, 일부는 그대로 둔 과정의 기록이다. 결론부터 말하면 "JWT가 항상 옳다"는 말도, "세션이 구식이다"는 말도 둘 다 틀렸다는 게 현재 운영 중인 시스템에서 내린 판단이다.

사고가 난 그 시점의 구조

물론, 당시 시스템은 전형적인 JWT 구조였다. Access Token 15분, Refresh Token 7일. 토큰은 stateless라는 말을 믿고 서버 어디에도 토큰 상태를 저장하지 않았다. Node.js 20.11 + Express 4.19 + jsonwebtoken v9.0.2 조합이었다.

그래서, 문제가 생긴 흐름은 단순하다. 어드민에서 사용자를 차단했다. DB의 users.statusbanned로 바뀌었다. 그런데 인증 미들웨어는 토큰 서명만 검증했지, DB의 status 컬럼을 매번 조회하지 않았다. 그게 JWT를 쓰는 이유였으니까. DB 조회를 줄이려고 JWT를 쓴 건데, 매 요청마다 DB를 다시 조회하면 의미가 없다.

그런데, 결제 시스템은 토큰의 userId만 신뢰했다. 인증 미들웨어가 통과시켰으니 결제 로직 안에서 다시 사용자 상태를 검증할 이유가 없다고 본 게 설계 단계의 가정이었다. 이 가정이 무너지는 데 11분이 걸렸다.

첫 번째 시도 — 블랙리스트만 추가하면 되는 줄 알았다

가장 먼저 떠올린 해결책은 Redis 블랙리스트였다. 사용자를 차단할 때 해당 사용자의 모든 토큰 ID(jti)를 Redis에 넣고, 미들웨어에서 매 요청마다 체크하는 방식이다. 30분이면 짤 수 있을 거라고 생각했다.

실제로는 안 됐다. 이유는 두 가지였다.

또한, 첫째, 차단 시점에 사용자가 가진 모든 토큰의 jti를 서버가 모른다. JWT는 상태를 저장하지 않는 게 본질이라서 발급된 토큰 목록 자체가 없다. 사용자 단위로 블랙리스트를 걸려면 결국 user:8xx21:banned_at 같은 키를 두고 토큰의 iat(발급 시각)이 그 이전이면 거부해야 한다. 이 방식은 동작은 한다.

// 차단 처리
await redis.set(`user:${userId}:banned_at`, Date.now(), 'EX', 60 * 60 * 24 * 7);

// 미들웨어에서 검사
const bannedAt = await redis.get(`user:${payload.userId}:banned_at`);
if (bannedAt && payload.iat * 1000 < Number(bannedAt)) {
  return res.status(401).json({ error: 'TOKEN_REVOKED' });
}

둘째, 이렇게 만들고 보니 매 요청마다 Redis를 한 번씩 더 친다. JWT의 장점이 "서버 상태 조회 없이 검증 가능"인데, 그 장점이 사라졌다. 그러면 처음부터 Redis 세션을 쓰는 게 더 깔끔한 거 아닌가? 이 질문이 며칠 동안 머리에서 떠나지 않았다.

세션 JWT 인증 비교 — 표면적 차이가 아닌 운영 비용

물론, 흔히 보는 비교표는 "JWT는 stateless, 세션은 stateful" 수준에서 끝난다. 운영을 해보니 그 차이가 실무에서 어떤 비용으로 환산되는지가 진짜 핵심이었다. 작성 시점(2026년 6월) 기준으로 정리한 표다.

항목 세션 (Redis 기반) JWT (순수 stateless) JWT + 블랙리스트
인증 요청당 외부 I/O Redis GET 1회 없음 Redis GET 1회
즉시 로그아웃/차단 가능 불가 (만료까지 대기) 가능
수평 확장 Redis 공유 필요 매우 쉬움 Redis 공유 필요
토큰 크기 32~64B (세션 ID) 600~1200B (페이로드 따라) 동일
모바일·SPA 다중 디바이스 별도 관리 필요 자연스러움 자연스러움
서버 측 상태 변경 반영 즉시 만료 시점에 즉시

표만 보면 JWT + 블랙리스트가 좋아 보인다. 그런데 한 줄이 더 있어야 한다. 운영 복잡도다. JWT + 블랙리스트는 "stateless의 장점을 버리고, stateful의 단점만 가져온 구조"가 될 위험이 크다. 토큰 자체에 권한이 박혀 있어서 권한이 바뀌어도 토큰엔 반영이 안 되고, 그렇다고 매번 DB를 조회하면 JWT를 쓴 이유가 사라진다.

토큰 안에 무엇을 넣을 것인가

JWT를 쓰기로 했다면 페이로드 설계가 가장 중요하다. 흔한 실수는 role, permissions, tier 같이 자주 바뀌는 정보를 다 넣는 거다. 그러면 권한 변경 시 토큰이 거짓말을 하게 된다.

// 안 좋은 예
const payload = {
  userId: 8821,
  email: 'user@example.com',
  role: 'premium',          // 등급 강등되면? 토큰엔 그대로 'premium'
  permissions: ['read', 'write', 'admin'],  // 권한 회수 불가
  iat: Math.floor(Date.now() / 1000)
};

// 운영에 가까운 예
const payload = {
  sub: '8821',
  sid: 'sess_' + crypto.randomUUID(),  // 세션 식별자
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 60 * 15
};
// role, permissions는 매 요청마다 캐시 레이어에서 조회

sid를 넣는 순간 사실상 세션과 다를 게 없다는 의견도 있다. 어느 정도 맞다. 단, 이 구조는 토큰 서명 검증으로 위변조를 막고, 세션 조회는 캐시 레이어(Redis)에 두는 하이브리드다. 즉시 만료는 sid만 죽이면 된다.

만료 시간을 짧게 하면 다 해결되는가

따라서, "Access Token을 5분으로 줄이면 즉시 차단이 안 돼도 5분이면 풀리잖아"라는 의견을 자주 본다. 5분 동안 5분만 결제 한 번 일어나면 그 5분이 충분히 크다. 만료 시간 단축은 노출 범위를 줄이는 수단이지, 즉시 무효화의 대체재가 아니다. 둘은 다른 문제다.

성능 — 실제로 측정해본 차이

그런데, 세션이 매 요청마다 Redis를 친다는 점이 항상 부담일까. 실측해봤다. 환경은 AWS t3.medium 2대 + ElastiCache(Redis 7.2) r6g.large, 동일 리전이다. wrk로 30초간 부하를 줬다.

방식 RPS p50 (ms) p99 (ms) CPU (avg)
JWT (검증만) 14,820 3.1 18.4 41%
JWT + 블랙리스트 체크 11,640 4.2 24.7 44%
Redis 세션 11,290 4.4 26.1 43%

JWT 순수 검증이 가장 빠르긴 했다. 다만 그 차이는 30% 안쪽이다. 그리고 JWT + 블랙리스트와 Redis 세션은 거의 동일했다. 즉, "조회 한 번을 더 하느냐" 여부가 성능을 가르지, "JWT냐 세션이냐"가 가르는 게 아니다.

예를 들어, 체감으로는 p99가 30ms 이내라면 일반 웹 트래픽에서는 차이를 못 느낀다. 초당 만 단위 RPS가 안 나오는 서비스라면 이 비교는 사실상 의미가 없다고 본다. 이 부분은 우리 서비스 규모(피크 1,800 RPS)에서 내린 결론이라, 더 큰 규모에서는 또 다를 수 있다.

보안 관점에서의 실제 위험

토큰 탈취 시나리오를 비교해봤다. 둘 다 탈취되면 위험한 건 똑같다. 차이는 탈취 이후의 대응 가능성이다.

세션은 서버 측 저장소에서 해당 세션을 지우면 끝이다. Redis에서 DEL session:abcd1234 한 번이면 그 시점부터 해당 토큰은 무효다. 사용자가 다른 디바이스에 영향을 안 주고 싶다면 그 세션만 죽이면 되고, 모든 디바이스 로그아웃은 user:8821:sessions:* 패턴으로 전부 지우면 된다.

특히, JWT는 만료 전까지 살아있다. 블랙리스트를 운영하면 무효화는 가능하지만, 위에서 봤듯 "JWT를 쓰는 이유"가 흐려진다. 그래서 보안이 민감한 도메인(금융, 의료, 결제 관여)에서는 세션을 고수하는 팀이 여전히 많다는 평가가 많다. 우리도 결제 흐름은 세션으로 되돌렸다.

XSS와 CSRF에 대한 노출도 다르다. JWT를 localStorage에 두면 XSS 한 방에 끝난다. 세션 쿠키를 HttpOnly; Secure; SameSite=Lax로 두면 JavaScript에서 접근 자체가 불가능하다. JWT도 쿠키에 담아 같은 옵션을 주면 되지만, 그 시점에서 JWT의 "Authorization 헤더로 깔끔하게 보낸다"는 장점이 줄어든다.

그래서 우리는 어떻게 갈라놨나

3주 동안 고민한 끝에 도메인별로 다르게 가기로 결정했다. 한 가지 방식을 모든 곳에 적용하려고 했던 게 처음의 잘못이었다고 본다.

영역 선택 이유
웹 로그인 (메인 서비스) 세션 쿠키 즉시 차단/로그아웃, XSS 노출 최소화
결제·환불·정산 흐름 세션 + 단계별 재인증 사고 재발 방지
외부 공개 API (B2B) JWT (긴 수명) 클라이언트가 서버를 신뢰, 즉시 회수는 키 로테이션으로
모바일 앱 JWT + Refresh + 디바이스별 sid 오프라인 일시적 동작, 다중 디바이스
마이크로서비스 간 호출 단명 JWT (5분) 서비스 메시 내부, 외부 노출 없음

이 구분의 핵심은 "토큰을 누가 신뢰하는가"였다. 우리가 직접 제어하는 클라이언트(웹, 결제)는 세션으로 묶고, 우리가 제어하지 못하는 클라이언트(B2B, 모바일)는 JWT를 썼다.

마이그레이션할 때 부딪힌 실제 함정

즉, JWT에서 세션으로 일부 되돌리는 작업도 단순하지 않았다. 가장 큰 문제는 이미 발급된 JWT를 들고 있는 사용자들이었다. 발급 시점에 만료가 7일이라, 7일 동안은 둘 다 받아줘야 한다. 미들웨어에 두 가지 검증 경로를 깔고, 응답 헤더에 X-Auth-Migrate: please-relogin을 붙여 클라이언트가 조용히 재로그인하도록 유도했다.

async function authMiddleware(req, res, next) {
  const cookieSession = req.cookies.sid;
  const bearerToken = req.headers.authorization?.replace('Bearer ', '');

  if (cookieSession) {
    const session = await redis.get(`session:${cookieSession}`);
    if (session) {
      req.user = JSON.parse(session);
      return next();
    }
  }

  if (bearerToken) {
    try {
      const payload = jwt.verify(bearerToken, JWT_SECRET);
      // 차단 사용자 재확인 — 사고 이후 추가됨
      const banned = await redis.get(`user:${payload.sub}:banned_at`);
      if (banned && payload.iat * 1000 < Number(banned)) {
        return res.status(401).json({ error: 'TOKEN_REVOKED' });
      }
      req.user = { id: payload.sub };
      res.setHeader('X-Auth-Migrate', 'please-relogin');
      return next();
    } catch (e) {
      // 검증 실패는 다음 단계로
    }
  }

  return res.status(401).json({ error: 'AUTH_REQUIRED' });
}

이 코드는 깔끔하지 않다. 다만 7일 호환 기간 동안만 살아있다가 사라질 미들웨어라 일단 이 형태로 운영 중이다.

결정하기 전에 던질 질문들

그런데, 기술 선택 글을 보면 "당신의 요구사항을 분석하세요"라는 말로 끝나는 경우가 많다. 그건 도움이 안 된다. 실제로 물어봐야 할 질문은 더 좁다.

  • 차단된 사용자가 1분이라도 더 활동하면 사고로 분류되는가? → 세션 또는 즉시 무효화 가능한 구조
  • 클라이언트(앱, 외부 파트너)가 서버와 항상 연결되어 있다고 가정할 수 있는가? → 그렇다면 세션, 아니라면 JWT
  • 같은 사용자가 5개 이상 디바이스에 동시 로그인하는 게 흔한가? → 디바이스별 sid를 가진 JWT가 관리하기 편하다
  • 인증 트래픽이 초당 수천 건을 넘는가? → 그 규모면 stateless가 운영상 유리한 구간으로 들어간다
  • 보안 사고 시 즉시 전체 강제 로그아웃이 필요한가? → 세션은 키 패턴 삭제 한 번, JWT는 시크릿 로테이션이 필요하다

한편, 이 다섯 가지 중 세 개 이상이 "그렇다"라면 세션 쪽이 운영이 편한 경우가 많더라. 반대면 JWT 쪽으로 기우는 게 합리적인 것 같다.

지금 바로 점검할 수 있는 것

  • 운영 중인 JWT가 있다면 페이로드에 role, permissions, tier 같이 가변 정보가 들어있는지 확인하라. 들어있다면 권한 강등이 즉시 반영되지 않는 구멍이 있다.
  • 차단/탈퇴 직후 해당 사용자 토큰이 얼마나 살아남는지 측정해보라. 측정 자체가 안 된다면 그 수치를 모르고 운영 중이라는 뜻이다.
  • localStorage에 JWT를 저장하고 있다면 HttpOnly 쿠키로 옮기는 PoC를 만들어보라. 보안 한 단계 위로 간다.

또한, (개인적으로 가장 후회되는 건, 처음 JWT를 도입할 때 "즉시 무효화 시나리오"를 요구사항에 안 넣었다는 점이다. 그게 빠진 인증 설계는 언젠가 한 번 사고가 나는 것 같다.) 다음엔 OAuth2 Token Introspection을 자체 도입해 JWT의 즉시 무효화를 깔끔하게 풀어보는 실험을 해볼 생각이다.

관련 글