JWKS 캐시 프리페치와 백그라운드 리프레시 설정

Spring Security에서 JWKS 캐시 프리페치(애플리케이션 시작 시 미리 적재)와 백그라운드 리프레시(만료 직전 비동기 갱신)를 구현하려면, 커스텀 Cache 래퍼와 @Scheduled 태스크를 조합하는 것이 현실적인 최선입니다.

Spring 자체는 프리페치/백그라운드 리프레시를 공식 지원하지 않지만, Caffeine의 refreshAfterWrite와 Spring Scheduler를 활용하면 동일 효과를 낼 수 있습니다.

방식 1: Caffeine refreshAfterWrite + Spring Cache (권장)

Caffeine은 refreshAfterWrite 옵션으로 백그라운드 리프레시를 네이티브 지원합니다. 이 기능은 캐시 항목이 만료되기 전에 비동기로 갱신하며, 구중 요청에는 기존 캐시를 반환합니다.

의존성

<!-- pom.xml -->
<dependency>
 <groupId>com.github.ben-manes.caffeine</groupId>
 <artifactId>caffeine</artifactId>
</dependency>
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

설정 클래스

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import java.util.concurrent.TimeUnit;

@Configuration
@EnableScheduling
public class JWKSCacheConfig {

 /
 * JWKS 전용 CacheManager
 * - TTL: 1시간 (expireAfterWrite)
 * - 백그라운드 리프레시: 50분 후 자동 갱신 (refreshAfterWrite)
 * - 최대 entries: 10
 */
 @Bean
 public CacheManager jwksCacheManager() {
 CaffeineCacheManager cacheManager = new CaffeineCacheManager("jwks");
 cacheManager.setCaffeine(
 Caffeine.newBuilder()
 .expireAfterWrite(1, TimeUnit.HOURS) // 1시간 후 만료
 .refreshAfterWrite(50, TimeUnit.MINUTES) // 50분 후 백그라운드 갱신
 .maximumSize(10)
 .recordStats()
 );
 return cacheManager;
 }

 @Bean
 public JwtDecoder jwtDecoder(CacheManager jwksCacheManager) {
 return NimbusJwtDecoder.withJwkSetUri(
 "https://auth.example.com/oauth2/jwks")
 .cache(jwksCacheManager.getCache("jwks"))
 .build();
 }
}

동작 원리:

  • 첫 요청: JWKS 조회 → 캐시 적재
  • 50분 후: 다음 요청 시 기존 캐시 반환 + 백그라운드에서 비동기 갱신
  • 1시간 후: 만료, 다음 요청 시 새 JWKS 조회

이 방식은 블로킹 없이 캐시를 갱신하며, Spring Security 6.x와 완벽 호환됩니다.

방식 2: 수동 프리페치 + @Scheduled 리프레시

Caffeine의 refreshAfterWrite가 동작하려면 캐시에 값이 이미 존재해야 합니다. 따라서 애플리케이션 시작 시 프리페치를 별도로 구현해야 합니다.

JWKS 프리페치 서비스

import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

@Service
public class JWKSPrefetchService {

 private final CacheManager cacheManager;
 private final RestTemplate restTemplate;
 private final String jwksUri;

 public JWKSPrefetchService(
 CacheManager cacheManager,
 RestTemplate restTemplate,
 @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
 String jwksUri) {
 this.cacheManager = cacheManager;
 this.restTemplate = restTemplate;
 this.jwksUri = jwksUri;
 }

 /
 * 애플리케이션 시작 시 JWKS 프리페치
 */
 @EventListener(ApplicationReadyEvent.class)
 public void prefetchJWKS() {
 try {
 Map<String, Object> jwks = restTemplate.getForObject(jwksUri, Map.class);
 Cache jwksCache = cacheManager.getCache("jwks");
 if (jwksCache != null && jwks != null) {
 jwksCache.put("jwks", jwks);
 System.out.println("✅ JWKS 프리페치 완료: " + jwks.size() + " keys");
 }
 } catch (Exception e) {
 System.err.println("❌ JWKS 프리페치 실패: " + e.getMessage());
 }
 }

 /
 * 50분마다 백그라운드 리프레시 (Caffeine refreshAfterWrite 보완)
 */
 @Scheduled(fixedRateString = "${jwks.refresh.rate:3000000}") // 기본 50분
 public void refreshJWKS() {
 try {
 Map<String, Object> jwks = restTemplate.getForObject(jwksUri, Map.class);
 Cache jwksCache = cacheManager.getCache("jwks");
 if (jwksCache != null && jwks != null) {
 jwksCache.put("jwks", jwks);
 System.out.println("🔄 JWKS 백그라운드 리프레시 완료");
 }
 } catch (Exception e) {
 System.err.println("⚠️ JWKS 백그라운드 리프레시 실패: " + e.getMessage());
 }
 }
}

application.yml 설정

spring:
 security:
 oauth2:
 resourceserver:
 jwt:
 jwk-set-uri: https://auth.example.com/oauth2/jwks
 cache:
 type: caffeine
 cache-names: jwks

jwks:
 refresh:
 rate: 3000000 # 50분 (ms)

caffeine:
 spec: expireAfterWrite=1h,refreshAfterWrite=50m,maximumSize=10

방식 3: 커스텀 Cache 래퍼 (고급)

Spring의 Cache 추상화를 확장해 프리페치 + 백그라운드 리프레시 로직을 내장한 커스텀 캐시를 만들 수도 있습니다. 이 방식은 복잡하지만, 완전한 제어가 가능합니다.

커스텀 JWKSCache 구현

import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.TimeUnit;

@Component
public class JWKSCustomCache implements Cache {

 private final String name = "jwks";
 private final String jwksUri;
 private final RestTemplate restTemplate;
 private final Executor refreshExecutor = Executors.newSingleThreadExecutor();

 private final AtomicReference<Map<String, Object>> cachedJWKS = new AtomicReference<>();
 private final AtomicReference<Long> lastFetchTime = new AtomicReference<>(0L);

 private final long ttlMs = TimeUnit.HOURS.toMillis(1); // 1시간
 private final long refreshBeforeMs = TimeUnit.MINUTES.toMillis(50); // 50분 후 리프레시

 public JWKSCustomCache(
 @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwksUri,
 RestTemplate restTemplate) {
 this.jwksUri = jwksUri;
 this.restTemplate = restTemplate;
 }

 @Override
 public String getName() {
 return name;
 }

 @Override
 public Object getNativeCache() {
 return this;
 }

 @Override
 public ValueWrapper get(Object key) {
 Map<String, Object> jwks = cachedJWKS.get();
 if (jwks == null) {
 // 캐시 미스: 동기 조회
 jwks = fetchJWKS();
 } else {
 // 백그라운드 리프레시 체크
 long elapsed = System.currentTimeMillis() - lastFetchTime.get();
 if (elapsed > refreshBeforeMs) {
 triggerBackgroundRefresh();
 }
 }
 return new SimpleValueWrapper(jwks);
 }

 @Override
 public void put(Object key, Object value) {
 if (value instanceof Map) {
 cachedJWKS.set((Map<String, Object>) value);
 lastFetchTime.set(System.currentTimeMillis());
 }
 }

 private Map<String, Object> fetchJWKS() {
 Map<String, Object> jwks = restTemplate.getForObject(jwksUri, Map.class);
 cachedJWKS.set(jwks);
 lastFetchTime.set(System.currentTimeMillis());
 return jwks;
 }

 private void triggerBackgroundRefresh() {
 refreshExecutor.execute(() -> {
 try {
 Map<String, Object> freshJWKS = restTemplate.getForObject(jwksUri, Map.class);
 cachedJWKS.set(freshJWKS);
 lastFetchTime.set(System.currentTimeMillis());
 System.out.println("🔄 JWKS 백그라운드 리프레시 완료");
 } catch (Exception e) {
 System.err.println("⚠️ 백그라운드 리프레시 실패: " + e.getMessage());
 }
 });
 }

 // 나머지 메서드는 미구현 또는 위임
 @Override public void evict(Object key) {}
 @Override public void clear() {}
 @Override public <T> T get(Object key, Class<T> type) { return null; }
 @Override public ValueWrapper putIfAbsent(Object key, Object value) { return null; }
}

설정 클래스

@Configuration
@EnableScheduling
public class JWKSCacheConfig {

 @Bean
 public CacheManager jwksCacheManager(JWKSCustomCache jwksCustomCache) {
 SimpleCacheManager cacheManager = new SimpleCacheManager();
 cacheManager.setCaches(java.util.List.of(jwksCustomCache));
 return cacheManager;
 }

 @Bean
 public JwtDecoder jwtDecoder(CacheManager jwksCacheManager) {
 return NimbusJwtDecoder.withJwkSetUri(
 "https://auth.example.com/oauth2/jwks")
 .cache(jwksCacheManager.getCache("jwks"))
 .build();
 }
}

프리페치 활성화 확인

애플리케이션 로그에서 다음 메시지를 확인하면 성공입니다:

✅ JWKS 프리페치 완료: 3 keys
🔄 JWKS 백그라운드 리프레시 완료

운영 팁

1. 헬스 체크 엔드포인트

JWKS 캐시 상태를 모니터링할 수 있는 엔드포인트를 추가하세요.

@RestController
@RequestMapping("/actuator/jwks")
public class JWKSMonitoringEndpoint {

 private final CacheManager cacheManager;

 public JWKSMonitoringEndpoint(CacheManager cacheManager) {
 this.cacheManager = cacheManager;
 }

 @GetMapping
 public Map<String, Object> jwksStatus() {
 Cache jwksCache = cacheManager.getCache("jwks");
 Map<String, Object> status = new HashMap<>();

 if (jwksCache != null && jwksCache.get("jwks") != null) {
 Map<String, Object> jwks = (Map<String, Object>) jwksCache.get("jwks").get();
 status.put("cached", true);
 status.put("keysCount", ((Map<?, ?>) jwks.get("keys")).size());
 } else {
 status.put("cached", false);
 }

 return status;
 }
}

2. 권장 설정값

환경 TTL 리프레시 시점 프리페치
프로덕션 1시간 50분 후 필수
개발 15분 10분 후 선택
키 회전 잦음 30분 25분 후 필수

3. 장애 대응

JWKS 조회 실패 시에도 기존 캐시는 유지되도록 해야 합니다. 위 예제는 예외를 잡고 로그만 남기므로, 캐시가 만료되지 않는 한 JWT 검증은 계속됩니다.

요약

  • 프리페치@EventListener(ApplicationReadyEvent)로 시작 시 JWKS 미리 적재
  • 백그라운드 리프레시: Caffeine refreshAfterWrite + @Scheduled 조합
  • TTL: 1시간, 리프레시: 50분 후가 무난함
  • 모니터링: Actuator 엔드포인트로 캐시 상태 추적

이 구성으로 첫 요청 지연 제거 + 블로킹 없는 캐시 갱신을 동시에 달성할 수 있습니다.

원하시면 전체 샘플 프로젝트 (Spring Boot 3 + Keycloak + Caffeine)를 만들어 드릴까요?

댓글 남기기