“프로젝트 끝나고 몇 주 지나서 다시 코드를 보다가, 이 부분은 따로 정리해두는 게 좋겠다는 생각이 들었다.”
JWT 발급까지는 그냥 교과서대로 했는데, "권한이 바뀌면 어떡하지?"라는 질문에서 설계가 복잡해지기 시작했다.
프로젝트 코드: GitHub
JWT의 핵심은 서버가 상태를 저장하지 않아도 된다는 거다. 토큰 안에 userId, role 같은 정보가 다 들어있으니까 매 요청마다 DB 조회 없이 토큰만 검증하면 끝.
근데 이 구조에서 문제가 생기는 상황이 있다.
관리자가 특정 유저의 권한을 OWNER에서 CUSTOMER로 강등시켰다. 그런데 그 유저가 들고 있는 토큰 안에는 아직 role: OWNER가 적혀있다. 토큰 만료 전까지는 계속 OWNER 권한으로 API를 호출할 수 있다.
토큰이 유효하면 DB를 안 보니까. 이게 보안 공백이다.
해결하려면 결국 role을 서버에서 재검증해야 한다. 근데 그걸 DB에서 직접 조회하면 매 요청마다 DB에 쿼리가 나가고, JWT 기반 인증의 성능상 이점이 줄어든다.
DB 직접 조회는 평균 5~50ms, Redis는 1ms 미만이다. 성능 차이가 크다.
그래서 role을 Redis에 캐싱해두고, DB는 캐시 미스가 났을 때만 조회하는 방식으로 설계했다. 캐싱의 핵심은 두 가지 문제를 해결하는 거다.
1. 속도 — 매 요청 DB 조회 없이 Redis hit으로 처리
2. 정합성 — 권한이 바뀌면 Redis도 즉시 업데이트
정합성을 못 지키면 캐시를 쓰는 의미가 없어진다. 그냥 오래된 데이터를 빠르게 내려주는 게 되니까.

코드로 보면 이렇다.
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에 같이 저장한다.
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를 업데이트한다.
redisService.set("role:" + targetUser.getId(), newRole.name(), Duration.ofMinutes(30));redisService.set("role:" + targetUser.getId(), newRole.name(), Duration.ofMinutes(30));계정 삭제 시 — Redis에서 즉시 삭제한다.
redisService.delete("role:" + targetUser.getId());redisService.delete("role:" + targetUser.getId());삭제된 유저의 캐시가 남아있으면 다음 요청에서 DB 조회 → 유저 없음 → 401이 되는데, 굳이 캐시를 30분 동안 들고 있을 이유가 없으니 즉시 지운다.
동시 접속자 300명 기준 부하 테스트 결과다.
(K6 부하 테스트 동시 접속자 300명 기준)
| 항목 | Redis 전 | Redis 후 |
|---|---|---|
| 초당 처리량 | 429/s | 657/s |
| 평균 응답속도 | 591ms | 352ms |
| 응답 실패율 | 1.49% | 0.06% |
DB 조회 수가 줄면서 커넥션 경합이 감소했고, 그 영향이 실패율에도 나타났다.
정확히는 "순수 Stateless"에서 "Redis를 통한 최소한의 상태 관리"로 이동한 거다.
순수 Stateless JWT는 토큰만 검증하면 끝이라 DB 의존이 없다. 근데 두 가지가 필요한 순간 어느 정도 상태 저장이 불가피해진다.
하나는 권한 실시간 반영. 위에서 얘기한 보안 공백 문제다.
다른 하나는 로그아웃 즉시 무효화. Stateless JWT만으로는 로그아웃된 토큰을 만료 전에 차단할 방법이 없다. 그래서 로그아웃 시 해당 토큰을 Redis 블랙리스트에 올려두고 요청마다 체크한다.
Redis는 인메모리라 DB보다 훨씬 가볍고, TTL로 자동 만료되니까 장기 보존도 없다. "Stateless를 포기했다"기보다 "필요한 최소한의 상태를 가장 빠른 저장소에 뒀다"는 느낌이 더 맞다.
캐시 미스 시 DB 조회가 일종의 Fallback 역할을 하긴 한다. 근데 실제 Redis 장애는 null 반환이 아니라 Connection Exception이나 Timeout 형태로 터지는 경우가 많다. 지금 구조는 그 상황에 대한 명시적인 예외 처리가 없어서, 운영 환경이었다면 Circuit Breaker 수준의 대응을 추가로 고민했을 것 같다.
핵심은 캐시 정합성이다. 빠른 게 중요한 게 아니라, 빠르면서 맞아야 한다. 그래서 role이 바뀌는 모든 시점(로그인, 권한 변경, 삭제)에 Redis도 함께 처리하는 코드를 짰다.
구현 자체는 단순한데, 왜 이 구조인지를 이해하는 데 시간이 좀 걸렸다.