Skip to content

JWT는 은총알이 아니다 - Access/Refresh Token 설계와 대용량 Redis 인증

JWT는 은총알이 아니다 - Access/Refresh Token 설계와 대용량 Redis 인증

TL;DR

  • 배경: “JWT 쓰면 세션보다 좋다면서요?”로 시작된 인증 설계 회의에서 반복된 실수와 운영 이슈 정리
  • 함정 4종: Stateless 환상, localStorage 저장, RT 단일 발급, 로그아웃 미구현
  • 탈취 대응: Reuse Detection, 디바이스 핑거프린트, 지리적 이상 감지 → 강제 세션 종료
  • 대용량 운영: Redis 블랙리스트 + Bloom Filter, RT는 DB에 풀 저장 / Redis는 핫 데이터만
  • 핵심 교훈: “수평 확장이 필요한가?”를 먼저 묻고, 아니라면 세션이 거의 언제나 더 안전하고 단순함
💡전제
  • 대상 독자: Spring Security를 한 번이라도 설정해본 백엔드 개발자
  • 범위: Stateless JWT 인증 기준. OAuth2 Resource Server도 설계 원리는 동일
  • 키워드: Access Token(AT), Refresh Token(RT), Rotation, Reuse Detection

글 머리말

2년차 개발자가 인증 설계안을 들고 왔습니다.

“JWT로 갈게요. 세션보다 확장성 좋다면서요. Access Token은 localStorage에 넣고, 만료되면 Refresh Token으로 새로 발급받으면 됩니다.”

저는 5년 전에 똑같은 말을 했습니다. 거의 토씨 하나 안 틀리고요. 그 때 아무도 저한테 “왜”라고 안 물어봤고, 저는 잘 작동하니까 통과시켰습니다. 그 설계는 결국 로그아웃 기능이 없는 채로 1년을 운영됐습니다. 관리자 계정이 탈취되고도 14시간 동안 아무도 눈치채지 못했습니다.

시니어가 되고 나서 이 주제에 대한 제 입장이 완전히 바뀌었습니다. JWT는 설계 결정이지 기본값이 아닙니다. 이 글은 그 이후로 코드 리뷰와 설계 회의에서 반복해서 만난 다섯 가지 패턴을 정리한 것입니다.


먼저: 왜 JWT를 쓰는가 (트레이드오프부터)

JWT를 쓰는 이유를 한 줄로 요약하면 “인증 상태를 서버에 저장하지 않기 위해”입니다. 그래서 얻는 것과 잃는 것이 명확합니다.

항목세션 (Stateful)JWT (Stateless)
서버 메모리/DB 부하있음 (세션 저장소 필요)없음 (토큰이 곧 상태)
수평 확장세션 복제/공유 저장소 필요자연스럽게 됨
즉시 무효화가능 (세션 삭제)어려움 (블랙리스트 필요)
토큰 크기작음 (세션 ID만)큼 (payload + signature)
권한 변경 반영즉시토큰 만료까지 지연

수평 확장이 필요 없고 단일 도메인 웹 앱이라면 세션이 거의 언제나 더 안전하고 단순합니다. JWT가 필요한 진짜 경우는 두 가지입니다.

  1. 여러 서비스(마이크로서비스, 외부 파트너)가 같은 토큰을 검증해야 할 때
  2. 모바일/SPA + 여러 백엔드 + 수평 확장까지 겹칠 때

이게 아닌데 JWT를 고르면, 세션의 단점은 다 피하려다 JWT의 함정은 모두 맞게 됩니다.


함정 1: “JWT는 Stateless라 DB 조회가 없다”는 환상

가장 많이 듣는 주장입니다. 그리고 틀린 말입니다.

// 이상적인 JWT 필터의 그림 (현실엔 거의 없음)
public Authentication authenticate(String token) {
    Claims claims = jwtParser.parseClaimsJws(token).getBody();
    return new JwtAuthentication(claims.getSubject(), extractRoles(claims));
    // DB 조회 없음! 빠름! 확장성 좋음!
}

현실은 이렇게 됩니다.

public Authentication authenticate(String token) {
    Claims claims = jwtParser.parseClaimsJws(token).getBody();
    String userId = claims.getSubject();

    // 탈퇴한 사용자인지 확인 → DB 조회
    User user = userRepository.findByIdAndStatus(userId, ACTIVE)
        .orElseThrow(() -> new DisabledException("탈퇴 계정"));

    // 권한이 바뀌었는지 확인 → DB 조회 (또는 캐시)
    Set<Role> currentRoles = roleService.getCurrentRoles(userId);

    // 블랙리스트 확인 → Redis 조회
    if (tokenBlacklist.contains(claims.getId())) {
        throw new InvalidTokenException("무효화된 토큰");
    }

    return new JwtAuthentication(userId, currentRoles);
}

결국 요청마다 최소 1~3번 외부 저장소를 조회합니다. 세션 한 번 조회와 뭐가 다른가요? 거의 없습니다. “Stateless”라는 단어에 속아서 세션을 버렸는데, 조회 횟수는 오히려 늘어난 경우를 여러 번 봤습니다.

시니어 관점 체크: “토큰만 검증하면 끝”이라는 설계서가 올라오면 무조건 되묻습니다. “탈퇴 처리는 어떻게 반영합니까? 권한 변경은요? 강제 로그아웃은요?” 답이 “토큰 만료까지 기다립니다”면 그 시스템은 컴플라이언스 감사에서 깨집니다.


함정 2: Access Token을 localStorage에 넣기

“XSS 걱정되니까 HttpOnly Cookie 쓰세요”라는 조언이 인터넷에 많습니다. 그런데 이 조언만 따르면 오히려 더 위험해집니다. XSS와 CSRF는 트레이드오프 관계입니다.

저장 위치XSS 취약CSRF 취약탈취 난이도
localStorageO (JS로 읽힘)X낮음
HttpOnly CookieX (JS 차단)O (기본)중간
HttpOnly + SameSite=Strict CookieX거의 X높음
Memory (변수)부분적X높음 (새로고침 시 소실)

실무 기본값은 이렇습니다.

  • AT: 메모리 저장 + 만료 15분 이하 (짧으면 탈취되도 피해 시간이 짧음)
  • RT: HttpOnly + Secure + SameSite=Strict Cookie + Path 제한 (/auth/refresh만)
// RT를 쿠키로 내려줄 때
ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", rt)
    .httpOnly(true)         // JS 접근 차단 (XSS 방어)
    .secure(true)           // HTTPS만
    .sameSite("Strict")     // 타 사이트 요청 시 미전송 (CSRF 방어)
    .path("/auth/refresh")  // 이 경로에만 전송 (공격 면적 축소)
    .maxAge(Duration.ofDays(14))
    .build();

시니어 관점 체크: “localStorage 쓰는 이유가 뭡니까?”로 질문합니다. 답이 “편해서”면 Cookie로 돌립니다. “CORS 때문에”면 대부분 CORS 설정을 잘못 잡은 거라 그걸 먼저 고칩니다.


함정 3: Refresh Token 하나만 발급하고 끝

“RT 있으니까 탈취되도 괜찮아요. 다시 발급받으면 되잖아요.”

아닙니다. RT야말로 탈취되면 가장 치명적입니다. RT 하나만 가져가면 공격자가 AT를 무한히 재발급할 수 있기 때문입니다. 단순 RT 설계의 수명은 길어야 6개월입니다.

RT Rotation + Reuse Detection이 진짜 표준입니다

제대로 된 RT 운영은 이렇게 해야 합니다.

@Transactional
public TokenPair refresh(String oldRt) {
    RefreshToken stored = rtRepository.findByToken(oldRt)
        .orElseThrow(() -> new InvalidTokenException("존재하지 않는 RT"));

    // 핵심 1: 이미 사용한 RT가 또 들어왔다 = 탈취 의심
    if (stored.isUsed()) {
        // 같은 사용자의 모든 RT 무효화 (연쇄 차단)
        rtRepository.revokeAllByUserId(stored.getUserId());
        alertService.notifySuspiciousActivity(stored.getUserId());
        throw new SecurityException("RT 재사용 감지 - 모든 세션 무효화");
    }

    // 핵심 2: 쓴 RT는 즉시 사용 처리 (일회용)
    stored.markAsUsed();

    // 핵심 3: 새 AT + 새 RT 동시 발급 (Rotation)
    String newAt = jwtProvider.createAccessToken(stored.getUserId());
    String newRt = jwtProvider.createRefreshToken(stored.getUserId());
    rtRepository.save(new RefreshToken(newRt, stored.getUserId()));

    return new TokenPair(newAt, newRt);
}

왜 이 설계가 중요한가를 위협 모델로 풀면 이렇습니다.

  • 공격자가 RT 탈취 → 새 AT 발급 → 피해자 RT는 이미 사용됨 표시
  • 피해자가 RT로 refresh 시도 → 재사용 감지 → 그 사용자의 모든 RT 무효화
  • 공격자도 다음 번 refresh에서 동시에 끊김
  • 운영팀은 탈취 시점을 로그로 정확히 포착 가능

이걸 구현 안 하면 RT 유효기간(보통 14~30일) 내내 공격자가 자유롭게 드나듭니다.

시니어 관점 체크: RT를 DB/Redis에 저장하지 않는 설계서는 통과시키지 않습니다. “RT도 JWT니까 검증만 하면 돼요”는 탈취를 감지할 방법이 없다는 뜻입니다.


함정 4: 로그아웃 구현의 덫

“JWT는 Stateless라 로그아웃이 없어요. 클라이언트에서 토큰만 지우면 됩니다.”

감사에서 깨질 대표적인 멘트입니다. 관리자가 “이 사용자 즉시 차단”을 요청했는데 “15분 기다리세요”라고 답할 수 있는 시스템은 없습니다.

로그아웃 구현에는 두 가지 길이 있습니다.

옵션 A: AT 블랙리스트 (Redis)

@PostMapping("/logout")
public void logout(@RequestHeader("Authorization") String bearer,
                   @CookieValue("refresh_token") String rt) {
    String at = bearer.substring(7);
    Claims claims = jwtProvider.parse(at);

    // AT는 만료시간까지만 블랙리스트에 (Redis TTL로 자동 삭제)
    long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
    blacklistRedis.set("bl:" + claims.getId(), "1", ttl, TimeUnit.MILLISECONDS);

    // RT는 DB에서 무효화 (Rotation 테이블)
    rtRepository.revokeByToken(rt);
}

옵션 B: AT 수명을 아예 짧게 (Blacklist 없이)

AT를 2~5분으로 줄이면 블랙리스트 없이도 “5분 내 차단” 보장이 됩니다. 단, RT revoke는 여전히 필요합니다.

어떤 걸 고를지는 트래픽과 보안 요구사항입니다.

  • 일반 서비스: AT 15~30분 + RT revoke만 (블랙리스트 없음)
  • 금융/의료: AT 5분 + RT revoke + AT 블랙리스트 전부
  • 초고 트래픽: AT 15분 + 블랙리스트는 Redis Bloom Filter로 최적화

시니어 관점 체크: “로그아웃은 프론트에서 토큰 지우면 돼요”는 즉시 반려 사유입니다. 실제 위협 시나리오(탈취 신고, 관리자 강제 차단, 비밀번호 변경)를 놓고 몇 분 내에 차단되어야 하는가부터 정해야 합니다.


토큰이 탈취됐다는 걸 어떻게 아는가

“RT Rotation으로 재사용만 감지하면 된다”고 생각하기 쉽지만, 현실의 탈취는 대부분 Rotation 사이클 안에서 벌어집니다. 공격자는 RT를 훔친 즉시 한 번 쓰고 끝나는 게 아니라, AT를 계속 발급받으면서 정상 사용자처럼 보이려 합니다.

운영 중인 시스템에서 실제로 돌리는 탐지 신호는 네 가지입니다.

  1. RT 재사용 (Reuse Detection) — 이미 위에서 다룬 방식. 1차 방어선
  2. 디바이스 핑거프린트 변화 — User-Agent + 화면 해상도 + 타임존 해시를 저장해두고 RT refresh 시 불일치 감지
  3. 지리적 이상 (Impossible Travel) — 5분 전 서울 IP, 지금 상파울루 IP면 물리적으로 불가능
  4. 동시 세션 카운트 급증 — 평소 2~3개 디바이스 쓰는 사용자가 갑자기 7개에서 접속
public void validateRefreshContext(String rt, HttpServletRequest request) {
    RefreshToken stored = rtRepository.findByToken(rt).orElseThrow();

    String currentFingerprint = fingerprintService.extract(request);
    String currentIp = request.getRemoteAddr();

    // 디바이스 핑거프린트 불일치
    if (!stored.getFingerprint().equals(currentFingerprint)) {
        riskScore += 30;
    }

    // 지리적 이상
    if (geoService.isImpossibleTravel(stored.getLastIp(), currentIp, stored.getLastUsedAt())) {
        riskScore += 50;
    }

    if (riskScore >= 50) {
        // 해당 사용자의 모든 RT 즉시 무효화 + 본인 확인 알림
        rtRepository.revokeAllByUserId(stored.getUserId());
        alertService.sendSecurityAlert(stored.getUserId(), request);
        throw new SecurityException("의심스러운 활동 감지");
    }
}

탐지 후 대응 순서는 이렇게 정리됩니다.

  • 즉시: 해당 사용자의 모든 RT + AT 블랙리스트 등록
  • 1분 내: 이메일/SMS로 본인 확인 발송
  • 10분 내: Audit log 덤프 + 보안팀 슬랙 알림
  • 사후: 비밀번호 강제 재설정 안내

실무 관점 체크: “RT 검증만 잘하면 된다”고 주장하는 설계는 Rotation 한 사이클 안(보통 15분~1시간)에 일어나는 공격에 무방비입니다. 위의 4가지 신호 중 최소 2개는 함께 구현해야 실효성이 있습니다.


대용량 시스템의 Redis 기반 토큰 관리

수백만 DAU 서비스에서 JWT 인증을 운영할 때 제일 먼저 깨지는 게 Redis입니다. “블랙리스트는 Redis에 넣으면 되죠”라는 답은 2만 DAU까지만 통합니다. 몇 가지 실전 패턴을 정리합니다.

패턴 1: Redis 블랙리스트에 Bloom Filter 앞세우기

매 요청마다 블랙리스트를 조회하면 Redis가 병목입니다. 그런데 실제 블랙리스트에 올라가는 토큰은 전체의 0.1%도 안 됩니다.

public boolean isBlacklisted(String jti) {
    // 1단계: Bloom Filter로 빠르게 걸러냄 (로컬 메모리, 수 μs)
    if (!bloomFilter.mightContain(jti)) {
        return false;  // 99% 요청이 여기서 끝남
    }
    // 2단계: Bloom Filter가 "있을 수도" 하면 Redis로 확인
    return redis.exists("bl:" + jti);
}

효과: Redis 부하 95% 이상 감소. False Positive(실제론 없는데 있다고 나옴)만큼만 Redis 조회로 확인하므로 정확성은 보장됩니다.

패턴 2: RT는 DB, Redis는 핫 캐시만

RT 수명이 30일이고 사용자가 500만이면 RT 저장소에 최소 500만~1000만 엔트리가 누적됩니다. Redis에 다 넣는 건 비쌉니다.

  • DB (MySQL/PostgreSQL): 모든 RT 저장 (SSoT)
  • Redis: 최근 24시간 active RT만 캐시 (TTL 24h)
  • 조회 순서: Redis → miss면 DB fallback → DB 히트 시 Redis에 write-through

이렇게 하면 Redis 사용량은 active 사용자 수만큼만 유지됩니다. 휴면 사용자가 90%인 서비스에서 Redis 비용이 1/10로 떨어집니다.

패턴 3: AT 수명을 극단적으로 줄여 블랙리스트 자체를 없애기

블랙리스트 운영이 부담스러우면 아예 없애는 게 답입니다. AT 수명을 5분 이하로 잡으면:

  • 탈취된 AT의 유효 시간이 최대 5분
  • 로그아웃 요청은 “RT만 revoke + AT는 5분 안에 자연 소멸”
  • Redis 블랙리스트 불필요 → 인증 핫패스에서 Redis 조회 0회

단점: refresh 트래픽이 12배 증가 (15분→5분). 하지만 refresh는 AT보다 훨씬 적은 요청이라 전체 Redis 부하는 오히려 줄어듭니다.

패턴 4: Redis 장애가 인증을 죽이지 않도록

Redis가 죽으면 인증이 전면 중단되는 설계는 SPOF(Single Point of Failure) 입니다. 실제로 겪은 서비스가 있다면 다시는 겪고 싶지 않을 겁니다.

  • Redis Cluster + Sentinel: 기본 요건
  • Circuit Breaker: Redis 응답 없으면 3초 내 fallback 발동
  • Degraded Mode: Redis 다운 시 “블랙리스트 조회는 스킵, RT 검증은 DB로만” 같은 안전 모드
  • 지역 장애 대비: Multi-region Redis는 과잉일 수 있으니 AT 수명을 줄이는 쪽이 더 싸게 먹힘

운영 수치 감각 (일반적인 업계 레퍼런스)

메트릭목표
AT 블랙리스트 조회 (Bloom Filter 적용)p99 < 1ms
RT Rotationp99 < 5ms
Bloom Filter 적중률95% 이상
Redis 장애 시 fallback 지연p99 < 10ms

다중 디바이스 로그인 관리

“PC에서 로그아웃했는데 모바일은 유지하고 싶어요” 같은 요구는 표준 기능입니다. 이걸 제대로 하려면 RT 저장소가 사용자 × 디바이스 단위여야 합니다.

// RT 발급 시 디바이스 정보 함께 저장
public TokenPair issue(String userId, DeviceInfo device) {
    String deviceId = device.getFingerprintHash();

    RefreshToken rt = new RefreshToken(
        UUID.randomUUID().toString(),
        userId,
        deviceId,
        device.getUserAgent(),
        device.getIp()
    );
    rtRepository.save(rt);

    return new TokenPair(
        jwtProvider.createAccessToken(userId, deviceId),
        rt.getToken()
    );
}

// "이 기기에서만 로그아웃"
public void logoutDevice(String userId, String deviceId) {
    rtRepository.revokeByUserAndDevice(userId, deviceId);
}

// "전체 로그아웃" (비밀번호 변경 시 필수)
public void logoutAll(String userId) {
    rtRepository.revokeAllByUserId(userId);
}

활성 세션 목록 노출

“로그인된 기기 목록 보기” 화면을 제공하면 사용자가 이상한 세션을 직접 발견하고 끊을 수 있습니다. 이건 보안 기능이자 UX 기능입니다.

[
  { "device": "Chrome on MacBook Pro", "lastSeen": "2026-04-21T08:12:00", "current": true },
  { "device": "Safari on iPhone 15", "lastSeen": "2026-04-20T22:34:00", "current": false },
  { "device": "Edge on Windows 11 (Seoul)", "lastSeen": "2026-04-19T14:02:00", "current": false }
]

대형 서비스(Google, GitHub, Slack)가 전부 제공하는 이 기능은 RT 저장소 설계가 디바이스 단위로 되어 있어야 가능합니다.


운영에서 답해야 하는 질문들

실무에서 인증 설계 리뷰 때 반드시 짚고 넘어가야 하는 질문들입니다. 답이 명확히 안 나오면 설계서를 다시 써야 합니다.

  • Q. “100만 동시 접속인데 로그아웃이 즉시 반영되나요?” → AT 수명 5분 이하 + RT revoke로 “최대 5분 내 차단” 보장. 엄격한 즉시 반영이 필요하면 AT 블랙리스트까지.

  • Q. “Redis가 죽으면 인증이 전부 멈추나요?” → Circuit Breaker + Degraded Mode로 “블랙리스트 스킵, RT 검증만 DB로” 경로 확보. Redis는 성능용이지 정확성의 SSoT가 아니어야 함.

  • Q. “RT가 탈취됐다는 걸 어떻게 감지합니까?” → Reuse Detection + 디바이스 핑거프린트 + 지리적 이상 + 세션 카운트. 최소 2개 이상 조합.

  • Q. “같은 사용자가 5개 기기에서 동시 로그인되면 어떻게 관리하나요?” → RT 저장소를 userId × deviceId 복합키로 설계. “이 기기만 로그아웃” 기능 제공.

  • Q. “토큰 크기가 1KB인데 요청이 10만 TPS면 대역폭 비용은요?” → 클레임 최소화(sub, exp, jti만). 권한은 서버에서 재조회. 10만 TPS × 1KB = 100MB/s는 실제로 뼈아픕니다.

  • Q. “JWT 서명 알고리즘은 뭘 쓰시나요?” → 단일 서비스면 HS256도 OK. 마이크로서비스 3개 이상이면 RS256으로 public key 검증 구조. 시크릿을 모든 서비스가 공유하는 건 사고의 지름길.


마무리: JWT는 도구일 뿐

정리하면 이렇게 됩니다.

  • JWT는 Stateless의 이름으로 들어오지만, 실제로는 Stateful 요구사항(탈퇴, 권한 변경, 로그아웃)에 부딪힘
  • AT는 메모리 + 짧은 수명, RT는 HttpOnly Cookie + Rotation + Reuse Detection
  • 탈취 탐지는 Reuse Detection 하나로 부족. 핑거프린트·지리·세션 카운트 조합 필수
  • 대용량에서 Redis는 Bloom Filter로 앞세우고, RT는 DB SSoT + Redis 핫 캐시 패턴
  • Redis가 죽어도 인증은 살아야 함. Circuit Breaker + Degraded Mode

저는 이제 인증 설계 리뷰에서 JWT 얘기가 나오면 이 질문부터 합니다.

“세션으로 안 되는 이유가 뭔가요?”

이 질문에 3초 안에 답이 안 나오면, 그 팀은 아직 JWT가 필요한 단계가 아닙니다.

JWT는 도구지 답이 아닙니다. 위협 모델을 먼저 그리고, 트레이드오프를 알고 쓰면 훌륭한 도구입니다. 반대로 쓰면 세션 시절보다 오히려 더 취약한 시스템이 됩니다. 제가 5년 전에 겪은 걸 반복할 필요는 없으니까요.


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site