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)를 만들어 드릴까요?