본문 바로가기
won2dev-log
HomeArchiveTagsCategoriesAboutProjects
HomeArchiveTagsCategoriesAboutProjects
won2dev-logwon2dev-logwon2dev-log

비전공 개발자의 로그 | won2dev-log

Navigation
  • Home
  • Archive
  • About
  • Projects
Categories
  • Docs
  • TIL
  • Project
  • Automation
  • Git · GitHub
더보기
Tags
  • TIL
  • Java
  • Spring
  • Backend
  • n8n
더보기
About

기록을 거름 삼아 공유는 성장을 만든다.

LicensePrivacy
© won2dev 2026. All rights reserved.
Home›Project›JWT는 Stateless가 장점인데, 왜 Redis까지 붙였나
Project

JWT는 Stateless가 장점인데, 왜 Redis까지 붙였나

won2dev·2026년 05월 29일
#Spring#TIL#DB
JWT는 Stateless가 장점인데, 왜 Redis까지 붙였나
“프로젝트 끝나고 몇 주 지나서 다시 코드를 보다가, 이 부분은 따로 정리해두는 게 좋겠다는 생각이 들었다.”

JWT 발급까지는 그냥 교과서대로 했는데, "권한이 바뀌면 어떡하지?"라는 질문에서 설계가 복잡해지기 시작했다.


프로젝트 코드: GitHub


JWT는 원래 Stateless가 장점인데

JWT의 핵심은 서버가 상태를 저장하지 않아도 된다는 거다. 토큰 안에 userId, role 같은 정보가 다 들어있으니까 매 요청마다 DB 조회 없이 토큰만 검증하면 끝.

근데 이 구조에서 문제가 생기는 상황이 있다.

관리자가 특정 유저의 권한을 OWNER에서 CUSTOMER로 강등시켰다. 그런데 그 유저가 들고 있는 토큰 안에는 아직 role: OWNER가 적혀있다. 토큰 만료 전까지는 계속 OWNER 권한으로 API를 호출할 수 있다.

토큰이 유효하면 DB를 안 보니까. 이게 보안 공백이다.

해결하려면 결국 role을 서버에서 재검증해야 한다. 근데 그걸 DB에서 직접 조회하면 매 요청마다 DB에 쿼리가 나가고, JWT 기반 인증의 성능상 이점이 줄어든다.


Redis를 끼워 넣은 이유

DB 직접 조회는 평균 5~50ms, Redis는 1ms 미만이다. 성능 차이가 크다.

그래서 role을 Redis에 캐싱해두고, DB는 캐시 미스가 났을 때만 조회하는 방식으로 설계했다. 캐싱의 핵심은 두 가지 문제를 해결하는 거다.

1. 속도 — 매 요청 DB 조회 없이 Redis hit으로 처리

2. 정합성 — 권한이 바뀌면 Redis도 즉시 업데이트

정합성을 못 지키면 캐시를 쓰는 의미가 없어진다. 그냥 오래된 데이터를 빠르게 내려주는 게 되니까.


실제 구현 — 검증 흐름

팀 프로젝트 발표자료 일부
팀 프로젝트 발표자료 일부

코드로 보면 이렇다.

java
String cachedRole = redisService.get("role:" + userId);

if (cachedRole == null) {
    // Redis 미스 → DB Fallback
    var user = userInfoRepository.findByIdAndDeletedAtIsNull(userId).orElse(null);
    if (user == null) {
        sendErrorResponse(response);
        return;
    }
    cachedRole = user.getRole().name();
    redisService.set("role:" + userId, cachedRole, Duration.ofMinutes(30));
}

// 토큰 role vs 캐시 role 비교
if (!role.equals(cachedRole)) {
    sendErrorResponse(response);
    return;
}
String cachedRole = redisService.get("role:" + userId);

if (cachedRole == null) {
    // Redis 미스 → DB Fallback
    var user = userInfoRepository.findByIdAndDeletedAtIsNull(userId).orElse(null);
    if (user == null) {
        sendErrorResponse(response);
        return;
    }
    cachedRole = user.getRole().name();
    redisService.set("role:" + userId, cachedRole, Duration.ofMinutes(30));
}

// 토큰 role vs 캐시 role 비교
if (!role.equals(cachedRole)) {
    sendErrorResponse(response);
    return;
}

미스가 나면 DB에서 가져오고, 가져온 걸 다시 캐싱한다. 다음 요청부터는 Redis에서 바로 꺼낼 수 있다.


캐시 일관성을 어떻게 보장했나

Redis 캐시가 틀린 값을 들고 있으면 오히려 독이다. 그래서 role 값이 바뀌는 모든 시점에 Redis도 함께 처리한다.

로그인 시 — JWT 발급할 때 role도 Redis에 같이 저장한다.

java
redisService.set(
    "role:" + user.getId(),
    user.getRole().name(),
    Duration.ofMillis(jwtUtil.getAccessTokenExpiration())
);
redisService.set(
    "role:" + user.getId(),
    user.getRole().name(),
    Duration.ofMillis(jwtUtil.getAccessTokenExpiration())
);

TTL을 액세스 토큰 만료 시간과 맞췄다. 토큰이 만료되면 어차피 role 캐시도 의미가 없으니까.

권한 변경 시 — role 변경 즉시 Redis를 업데이트한다.

java
redisService.set("role:" + targetUser.getId(), newRole.name(), Duration.ofMinutes(30));
redisService.set("role:" + targetUser.getId(), newRole.name(), Duration.ofMinutes(30));

계정 삭제 시 — Redis에서 즉시 삭제한다.

java
redisService.delete("role:" + targetUser.getId());
redisService.delete("role:" + targetUser.getId());

삭제된 유저의 캐시가 남아있으면 다음 요청에서 DB 조회 → 유저 없음 → 401이 되는데, 굳이 캐시를 30분 동안 들고 있을 이유가 없으니 즉시 지운다.


실제로 얼마나 차이가 났나

동시 접속자 300명 기준 부하 테스트 결과다.

(K6 부하 테스트 동시 접속자 300명 기준)

항목Redis 전Redis 후
초당 처리량429/s657/s
평균 응답속도591ms352ms
응답 실패율1.49%0.06%

DB 조회 수가 줄면서 커넥션 경합이 감소했고, 그 영향이 실패율에도 나타났다.


JWT Stateless를 완전히 포기한 건 아니다

정확히는 "순수 Stateless"에서 "Redis를 통한 최소한의 상태 관리"로 이동한 거다.

순수 Stateless JWT는 토큰만 검증하면 끝이라 DB 의존이 없다. 근데 두 가지가 필요한 순간 어느 정도 상태 저장이 불가피해진다.

하나는 권한 실시간 반영. 위에서 얘기한 보안 공백 문제다.

다른 하나는 로그아웃 즉시 무효화. Stateless JWT만으로는 로그아웃된 토큰을 만료 전에 차단할 방법이 없다. 그래서 로그아웃 시 해당 토큰을 Redis 블랙리스트에 올려두고 요청마다 체크한다.

Redis는 인메모리라 DB보다 훨씬 가볍고, TTL로 자동 만료되니까 장기 보존도 없다. "Stateless를 포기했다"기보다 "필요한 최소한의 상태를 가장 빠른 저장소에 뒀다"는 느낌이 더 맞다.


한계

캐시 미스 시 DB 조회가 일종의 Fallback 역할을 하긴 한다. 근데 실제 Redis 장애는 null 반환이 아니라 Connection Exception이나 Timeout 형태로 터지는 경우가 많다. 지금 구조는 그 상황에 대한 명시적인 예외 처리가 없어서, 운영 환경이었다면 Circuit Breaker 수준의 대응을 추가로 고민했을 것 같다.


정리

핵심은 캐시 정합성이다. 빠른 게 중요한 게 아니라, 빠르면서 맞아야 한다. 그래서 role이 바뀌는 모든 시점(로그인, 권한 변경, 삭제)에 Redis도 함께 처리하는 코드를 짰다.

구현 자체는 단순한데, 왜 이 구조인지를 이해하는 데 시간이 좀 걸렸다.

공유하기
이전 글AI 에이전트에게 부하테스트를 맡겼더니, 테스트 환경을 직접 만들고 버그까지 찾아냈다다음 글 Keycloak을 처음 쓰면서 배운 것들 — 그게 뭔지도 모르고 시작했다

목차

  • JWT는 원래 Stateless가 장점인데
  • Redis를 끼워 넣은 이유
  • 실제 구현 — 검증 흐름
  • 캐시 일관성을 어떻게 보장했나
  • 실제로 얼마나 차이가 났나
  • JWT Stateless를 완전히 포기한 건 아니다
  • 한계
  • 정리

카테고리

Project

태그

#Spring#TIL#DB

최근 글

AI 에이전트에게 부하테스트를 맡겼더니, 테스트 환경을 직접 만들고 버그까지 찾아냈다Keycloak을 처음 쓰면서 배운 것들 — 그게 뭔지도 모르고 시작했다MSA에서 트랜잭션은 왜 어려운가 — 분산 트랜잭션과 Saga PatternFK는 포인터다 — 주문 시스템에서 스냅샷이 필요한 이유Kafka 입문 — 메시징 큐가 왜 필요한가