JWT 인증 필터에 Redis 캐싱 도입 — 매 요청마다 DB를 찌르지 말자
오늘은 JWT 인증 필터에서 발생하는 불필요한 DB 조회를 Redis 캐싱으로 제거했습니다.
TechFork의 인증 구조는 JWT 기반입니다. 사용자가 API를 호출할 때마다 JwtAuthenticationFilter가 토큰을 검증하고, 사용자 정보를 꺼내서 SecurityContext에 세팅합니다. 문제는 이 과정에서 매 요청마다 MySQL을 조회하고 있었다는 점입니다. 검색 한 번, 추천 한 번, 북마크 한 번... 인증된 사용자의 모든 API 호출이 SELECT * FROM user WHERE id = ?를 동반하고 있었습니다.
사용자 수가 적은 지금은 체감이 안 되지만, 이건 트래픽이 늘어나면 확실하게 병목이 됩니다. 커넥션 풀도 10개로 고정해둔 상황이라, 인증 조회가 커넥션을 잡아먹으면 비즈니스 쿼리에 쓸 커넥션이 부족해질 수 있습니다.
기존 구조: 매 요청마다 DB 조회
캐싱 도입 전 JwtAuthenticationFilter의 핵심 흐름은 이랬습니다.
요청 → JWT 검증 → userId 추출 → DB에서 User 조회 → UserPrincipal 생성 → SecurityContext 세팅
코드로 보면:
Long userId = jwtUtil.getUserIdFromToken(jwt);
// 매 요청마다 이 쿼리가 나감
User user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND));
UserPrincipal userPrincipal = UserPrincipal.buildUserPrincipal(user);
JWT 안에는 이미 userId와 role이 들어있지만, UserPrincipal을 만들려면 status, email 같은 추가 정보가 필요합니다. 그래서 DB 조회가 불가피했습니다.
그런데 사용자의 role이나 status가 요청마다 바뀌는 건 아닙니다. 한 번 로그인하면 Access Token이 만료될 때까지는 대부분 동일한 정보입니다. 이걸 캐싱하지 않을 이유가 없었습니다.
변경된 구조: Redis Cache-Aside 패턴
요청 → JWT 검증 → userId 추출 → Redis 캐시 조회
├── HIT → UserPrincipal 반환 (DB 안 감)
└── MISS → DB 조회 → UserPrincipal 생성 → Redis에 캐싱 → 반환
캐시가 있으면 DB를 건드리지 않고, 없을 때만 DB에서 가져와서 캐시에 넣는 전형적인 Cache-Aside 패턴입니다.
UserAuthCacheService 구현
캐시 서비스를 별도 클래스로 분리했습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class UserAuthCacheService {
private static final String DELIMITER = "|";
private static final int FIELD_COUNT = 4;
private final StringRedisTemplate redisTemplate;
public UserPrincipal get(Long userId) {
String key = buildKey(userId);
String cached = redisTemplate.opsForValue().get(key);
if (cached == null) {
return null;
}
return deserialize(cached);
}
public void put(Long userId, User user, long ttlMillis) {
String key = buildKey(userId);
String value = serialize(user);
redisTemplate.opsForValue().set(key, value, ttlMillis, TimeUnit.MILLISECONDS);
}
public void evict(Long userId) {
String key = buildKey(userId);
redisTemplate.delete(key);
}
private String buildKey(Long userId) {
return RedisKey.USER_AUTH_PREFIX + userId;
}
}
몇 가지 설계 결정에 대해 정리합니다.
왜 StringRedisTemplate인가
RedisTemplate<String, Object>에 Jackson 직렬화를 붙이는 방법도 있지만, 인증 캐시에 저장하는 데이터는 id|role|status|email 네 개 필드가 전부입니다. 이 정도면 JSON 오버헤드가 불필요합니다.
private String serialize(User user) {
return user.getId()
+ DELIMITER + user.getRole().name()
+ DELIMITER + user.getStatus().name()
+ DELIMITER + (user.getEmail() != null ? user.getEmail() : "");
}
파이프(|) 구분자로 직렬화하면 Redis에 저장되는 값은 1|USER|ACTIVE|user@email.com 같은 단순 문자열입니다. Jackson 대비 저장 용량도 작고, 직렬화/역직렬화 비용도 무시할 수준입니다.
역직렬화도 단순합니다.
private UserPrincipal deserialize(String value) {
String[] parts = value.split("\\" + DELIMITER, FIELD_COUNT);
if (parts.length != FIELD_COUNT) {
log.warn("Invalid user auth cache format: {}", value);
return null;
}
return UserPrincipal.builder()
.id(Long.parseLong(parts[0]))
.role(Role.valueOf(parts[1]))
.status(UserStatus.valueOf(parts[2]))
.email(parts[3].isEmpty() ? null : parts[3])
.build();
}
포맷이 깨지면 null을 반환합니다. 그러면 필터에서 캐시 미스로 처리되어 DB에서 다시 가져오므로, 잘못된 캐시 데이터가 인증 오류로 이어지지 않습니다.
왜 TTL을 Access Token 만료 시간에 맞추는가
userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration());
캐시 TTL을 Access Token의 만료 시간과 동일하게 설정했습니다. 이유는 간단합니다. Access Token이 만료되면 어차피 Refresh Token으로 재발급을 받아야 하고, 그 시점에 캐시도 새로 갱신됩니다. 토큰보다 캐시가 오래 살아있을 이유가 없습니다.
토큰 재발급 시점에도 캐시를 갱신합니다.
// AuthService.refreshToken()
public TokenRefreshResponse refreshToken(String refreshToken, HttpServletResponse response) {
// ... 토큰 검증 ...
User user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND));
JwtDTO newTokens = jwtUtil.generateTokens(userId, user.getRole());
// 토큰 재발급 시 캐시도 갱신
userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration());
return TokenRefreshResponse.builder()
.accessToken(newTokens.accessToken())
.build();
}
이렇게 하면 사용자가 활동하는 동안에는 캐시가 계속 살아있고, 비활동 상태가 되면 토큰 만료와 함께 캐시도 자연스럽게 사라집니다.
Redis 키 설계
public final class RedisKey {
public static final String REFRESH_TOKEN_PREFIX = "refreshToken:";
public static final String USER_AUTH_PREFIX = "user:auth:";
}
키 프리픽스를 상수로 관리합니다. 실제 Redis에 저장되는 키는 user:auth:1, user:auth:42 같은 형태입니다. 기존에 Refresh Token도 Redis에 refreshToken:{userId} 형태로 저장하고 있었으므로, 네이밍 컨벤션을 맞췄습니다.
JwtAuthenticationFilter 변경
필터의 변경은 최소한으로 유지했습니다.
Long userId = jwtUtil.getUserIdFromToken(jwt);
// 1. Redis 캐시에서 먼저 조회
UserPrincipal userPrincipal = userAuthCacheService.get(userId);
// 2. 캐시 미스 → DB 조회 후 캐싱
if (userPrincipal == null) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND));
userPrincipal = UserPrincipal.buildUserPrincipal(user);
if (userPrincipal.getStatus() == UserStatus.WITHDRAWN) {
throw new GeneralException(AuthErrorCode.WITHDRAWN_USER);
}
userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration());
} else if (userPrincipal.getStatus() == UserStatus.WITHDRAWN) {
throw new GeneralException(AuthErrorCode.WITHDRAWN_USER);
}
// 3. SecurityContext 세팅 (기존과 동일)
UsernamePasswordAuthenticationToken authentication = createAuthentication(userPrincipal, request);
SecurityContextHolder.getContext().setAuthentication(authentication);
기존 코드에서 userAuthCacheService.get() 한 줄이 앞에 추가되고, DB 조회 후 put() 한 줄이 추가된 것이 핵심입니다. 탈퇴 사용자(WITHDRAWN) 체크는 캐시 히트/미스 양쪽 모두에서 수행합니다.
캐시 무효화 시점
캐시를 도입하면 반드시 "언제 지울 것인가"를 고민해야 합니다. 캐시된 데이터와 DB의 실제 데이터가 어긋나면 인증이 꼬이기 때문입니다.
TechFork에서 사용자 정보가 변경되는 시나리오를 정리하고, 각각에 대해 캐시를 어떻게 처리할지 결정했습니다.
명시적 evict가 필요한 경우
온보딩 완료 — 사용자의 status가 PENDING → ACTIVE로 바뀝니다. 캐시에 PENDING 상태가 남아있으면, 다음 요청에서 온보딩을 완료한 사용자를 여전히 미완료 상태로 인식합니다.
// UserCommandService.completeOnboarding()
public void completeOnboarding(Long userId, OnboardingRequest request) {
User user = userRepository.findByIdWithInterestCategories(userId)
.orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND));
user.updateUser(request.nickname(), request.email(), request.description());
interestCommandService.saveUserInterests(user, new SaveInterestRequest(request.interests()));
userAuthCacheService.evict(userId); // status가 ACTIVE로 변경되었으므로 캐시 무효화
}
회원 탈퇴 — status가 ACTIVE → WITHDRAWN으로 바뀌고, 개인정보가 즉시 익명화됩니다. 캐시에 이전 상태가 남아있으면 탈퇴 후에도 잠깐 동안 정상 사용자처럼 인증이 통과될 수 있습니다.
// UserCommandService.withdrawUser()
public void withdrawUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND));
if (user.isWithdrawn()) {
throw new GeneralException(UserErrorCode.ALREADY_WITHDRAWN);
}
user.withdraw();
userAuthCacheService.evict(userId); // 탈퇴 즉시 캐시 무효화
}
두 경우 모두 상태 변경이 즉시 반영되어야 하는 시나리오입니다. TTL 만료를 기다리면 그 사이에 잘못된 인증 상태로 요청이 처리될 수 있으므로, 명시적으로 evict()를 호출합니다.
TTL 자연 만료로 충분한 경우
반면, 모든 변경에 evict()가 필요한 건 아닙니다. role 변경은 관리자가 수동으로 하는 경우뿐이고, email은 수정 API 자체를 제공하지 않으므로 변경될 일이 없습니다. 프로필 수정(updateProfile)도 닉네임과 자기소개만 변경 가능하고 캐시에 저장되는 필드(id, role, status, email)에는 영향을 주지 않습니다.
정리하면 원칙은 이렇습니다. 사용자 상태(status)가 바뀌는 핵심 시점에는 명시적 evict, 나머지는 TTL 자연 만료.
전체 인증 흐름 정리
캐싱 도입 후 TechFork의 인증/인가 관련 Redis 사용을 정리하면 이렇습니다.
[로그인 시]
OAuth2 로그인 성공
→ Refresh Token을 Redis에 저장 (refreshToken:{userId})
→ Access Token + Refresh Token을 클라이언트에 전달
[API 요청 시]
JwtAuthenticationFilter
→ JWT에서 userId 추출
→ Redis에서 user:auth:{userId} 조회
├── HIT → UserPrincipal 반환 (DB 조회 없음)
└── MISS → DB 조회 → Redis에 캐싱 (TTL = Access Token 만료 시간)
→ SecurityContext에 인증 정보 세팅
[토큰 재발급 시]
AuthService.refreshToken()
→ Redis에서 Refresh Token 검증
→ 새 토큰 발급
→ user:auth:{userId} 캐시 갱신
[로그아웃 시]
AuthService.logout()
→ Redis에서 Refresh Token 삭제
→ (캐시는 TTL로 자연 만료)
마무리
정리하면 오늘 한 작업의 핵심은 이렇습니다.
- 문제: JWT 인증 필터에서 매 요청마다 MySQL 조회 발생
- 해결: Redis Cache-Aside 패턴으로 사용자 인증 정보를 캐싱
- 직렬화: JSON 대신 파이프 구분자 문자열로 경량화
- TTL: Access Token 만료 시간과 동일하게 설정하여 자연스러운 생명주기 관리
- 무효화: 온보딩 완료·회원 탈퇴 등 status 변경 시 즉시 evict, 나머지는 TTL 자연 만료
인증 필터는 모든 인증된 요청이 거쳐가는 곳이라, 여기서 DB 조회 하나를 줄이는 것은 전체 시스템 성능에 직접적인 영향을 준다고 생각했습니다.
'프로젝트 > Techfork' 카테고리의 다른 글
| 검색 품질을 5단계 실험으로 개선한 과정 — 필드 가중치부터 쿼리 구조까지 (0) | 2026.03.25 |
|---|---|
| 검색 평가용 Ground Truth 구축 정리 (0) | 2026.03.25 |
| [26/02/23] 오늘의 개발일지 - HikariCP 커넥션 풀 설정 (0) | 2026.03.02 |
| [26/02/22] 오늘의 개발 일지 - Logback MDC, 파일 롤링, 비동기 스레드 MDC 전파 (0) | 2026.03.02 |
| [26/02/21] 오늘의 개발 일지 - Apple 키 파일 보안 취약점 해소와 github actions 의존성 최신화 (0) | 2026.02.21 |