멀티테넌트 환경에서 JWKS를 캐싱할 때 핵심은 테넌트별 독립 캐시 키, 테넌트별 TTL 차등 적용, 캐시 오염 방지, 동적 테넌트 등록을 어떻게 처리하느냐입니다. Spring Security + Caffeine 조합으로 이 네 가지를 모두 만족하는 아키텍처를 설계할 수 있습니다.
아키텍처 개요
멀티테넌트 JWKS 캐싱의 기본 구조는 다음과 같습니다:
┌─────────────────────────────────────────────────────────────┐
│ API Gateway / Load Balancer │
│ (X-Tenant-ID 또는 subdomain 으로 테넌트 식별) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ TenantAwareJwtDecoder (커스텀) │
│ 1. 요청에서 tenantId 추출 │
│ 2. tenantId 로 JWKS URI 매핑 │
│ 3. 테넌트별 JwtDecoder 캐시 조회/생성 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Caffeine Cache (테넌트별 독립 entries) │
│ Key: "tenant:{tenantId}:jwks" │
│ TTL: 테넌트별 차등 적용 (예: premium=2h, free=30m) │
│ 최대 entries: 테넌트당 5~10 keys │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 테넌트별 인증 서버 (Keycloak, Auth0, Cognito 등) │
│ - tenant1.auth.com/.well-known/jwks │
│ - tenant2.auth.com/.well-known/jwks │
└─────────────────────────────────────────────────────────────┘
핵심 구현 패턴
1. 테넌트 식별 전략
먼저 요청에서 tenantId를 추출해야 합니다. 주요 전략은 다음과 같습니다:
| 전략 | 예시 | 사용 사례 |
|---|---|---|
| HTTP 헤더 | X-Tenant-ID: acme-corp |
API 게이트웨이, 내부 서비스 |
| 서브도메인 | acme.api.example.com → acme |
SaaS, 화이트라벨 |
JWT iss claims |
iss: https://auth.acme.com |
토큰 기반 라우팅 |
| 경로 기반 | /api/tenants/acme/messages |
레거시 호환 |
가장 일반적인 것은 헤더 기반과 JWT iss 기반입니다.
2. 테넌트별 JwtDecoder 캐싱 (Spring Security 6.x)
JwtDecoder 자체를 테넌트별로 캐시하는 방식이 가장 성능이 좋습니다. 각 테넌트는 고유의 JWKS URI를 가지며, Caffeine이 테넌트별 TTL과 사이즈를 관리합니다.
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@Component
public class TenantAwareJwtDecoderProvider {
/
* 테넌트별 JwtDecoder 캐시
* Key: tenantId
* Value: JwtDecoder (내부에 JWKS 캐시 포함)
*/
private final com.github.benmanes.caffeine.cache.Cache<String, JwtDecoder> decoderCache;
/
* 테넌트별 JWKS URI 매핑 (DB 또는 설정에서 동적 로드 가능)
*/
private final Map<String, String> tenantJwksUriMap = new ConcurrentHashMap<>();
public TenantAwareJwtDecoderProvider() {
this.decoderCache = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.HOURS) // 1시간 동안 접근 없으면 제거
.maximumSize(100) // 최대 100 테넌트
.recordStats()
.build();
}
/
* 테넌트 ID로 JwtDecoder 조회 (캐시 미스 시 자동 생성)
*/
public JwtDecoder getDecoder(String tenantId) {
return decoderCache.get(tenantId, createDecoderForTenant(tenantId));
}
/
* 테넌트별 JwtDecoder 생성 팩토리
*/
private Function<String, JwtDecoder> createDecoderForTenant(String tenantId) {
return tid -> {
String jwksUri = getJwksUriForTenant(tid);
if (jwksUri == null) {
throw new IllegalArgumentException("Unknown tenant: " + tid);
}
// 테넌트별 JWKS 캐시 생성 (Caffeine 내부 캐시)
var jwksCache = new CaffeineCache("jwks-" + tid,
Caffeine.newBuilder()
.expireAfterWrite(getTTLForTenant(tid), TimeUnit.MINUTES)
.refreshAfterWrite(getRefreshTimeForTenant(tid), TimeUnit.MINUTES)
.maximumSize(10)
.build()
);
return NimbusJwtDecoder.withJwkSetUri(jwksUri)
.cache(jwksCache)
.build();
};
}
/
* 테넌트별 JWKS URI 조회 (DB, 설정, 또는 동적 발견)
*/
private String getJwksUriForTenant(String tenantId) {
// 예시: 설정에서 조회, 실제로는 DB 또는 서비스 호출
return tenantJwksUriMap.computeIfAbsent(tenantId, this::discoverJwksUri);
}
/
* 동적 JWKS URI 발견 (예: 테넌트 설정 API 호출)
*/
private String discoverJwksUri(String tenantId) {
// 실제 구현: 테넌트 설정 DB 조회 또는 메타데이터 API 호출
// 예: https://auth.{tenantId}.saas.com/.well-known/oauth-protected-resource
return switch (tenantId) {
case "acme" -> "https://auth.acme.com/oauth2/jwks";
case "globex" -> "https://auth.globex.com/oauth2/jwks";
case "initech" -> "https://auth.initech.com/oauth2/jwks";
default -> null; // 알 수 없는 테넌트
};
}
/
* 테넌트별 TTL 차등 적용 (예: 프리미엄 = 2h, 무료 = 30m)
*/
private int getTTLForTenant(String tenantId) {
return isPremiumTenant(tenantId) ? 120 : 30;
}
/
* 테넌트별 리프레시 시점
*/
private int getRefreshTimeForTenant(String tenantId) {
return isPremiumTenant(tenantId) ? 100 : 25;
}
private boolean isPremiumTenant(String tenantId) {
// 실제 구현: DB 에서 테넌트 등급 조회
return "acme".equals(tenantId) || "globex".equals(tenantId);
}
/
* 캐시 통계 (모니터링용)
*/
public Map<String, Object> getCacheStats() {
return Map.of(
"totalTenants", (long) decoderCache.estimatedSize(),
"hitRate", decoderCache.stats().hitRate(),
"missRate", decoderCache.stats().missRate(),
"evictionCount", decoderCache.stats().evictionCount()
);
}
}
3. 커스텀 JwtDecoder 빈 등록 (런타임 테넌트 라우팅)
Spring Security의 기본 JwtDecoder 빈을 커스텀 래퍼로 대체하여, 요청마다 테넌트를 식별하고 해당 테넌트의 JwtDecoder를 위임하도록 합니다.
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Component
public class MultiTenantJwtDecoder implements JwtDecoder {
private final TenantAwareJwtDecoderProvider decoderProvider;
private final JwtAuthenticationConverter jwtConverter;
public MultiTenantJwtDecoder(
TenantAwareJwtDecoderProvider decoderProvider,
JwtAuthenticationConverter jwtConverter) {
this.decoderProvider = decoderProvider;
this.jwtConverter = jwtConverter;
}
@Override
public org.springframework.security.oauth2.jwt.Jwt decode(String token) throws org.springframework.security.oauth2.jwt.JwtException {
// 1. 요청에서 tenantId 추출 (헤더, 서브도메인, 또는 토큰 claims)
String tenantId = extractTenantId();
if (tenantId == null) {
throw new org.springframework.security.oauth2.jwt.JwtException("Tenant ID not found");
}
// 2. 테넌트별 JwtDecoder 조회 (캐시 히트 시 즉시 반환)
JwtDecoder decoder = decoderProvider.getDecoder(tenantId);
// 3. 위임 검증
return decoder.decode(token);
}
private String extractTenantId() {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return null;
HttpServletRequest request = attrs.getRequest();
// 전략 1: HTTP 헤더 (X-Tenant-ID)
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId != null && !tenantId.isBlank()) {
return tenantId;
}
// 전략 2: 서브도메인 (acme.api.example.com → acme)
String host = request.getServerName();
if (host.contains(".")) {
String parts = host.split("\\.");
if (parts.length > 2 && !"www".equals(parts)) {
return parts;
}
}
// 전략 3: JWT claims 에서 추출 (iss 기반)
// 주의: 이 경우 토큰을 한 번 파싱해야 하므로 성능 주의
return null;
}
}
4. SecurityFilterChain 설정
이제 커스텀 JwtDecoder를 Spring Security 에 등록합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, MultiTenantJwtDecoder jwtDecoder) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder) // 커스텀 멀티테넌트 디코더 사용
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
grantedAuthoritiesConverter.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
캐시 키 설계 원칙
멀티테넌트 캐싱에서 가장 중요한 것은 캐시 키 네이밍입니다. 잘못된 키 설계는 캐시 오염(테넌트 A 의 키로 테넌트 B 의 JWKS 조회)을 유발합니다.
올바른 캐시 키 패턴
"tenant:{tenantId}:jwks" // JWKS 자체 캐시
"tenant:{tenantId}:decoder" // JwtDecoder 인스턴스 캐시
"tenant:{tenantId}:config" // 테넌트 설정 (JWKS URI 등)
잘못된 예 (피해야 할 패턴)
"jwks" // 테넌트 구분 없음 → 오염 발생
"{jwksUri}" // URI 만으로는 테넌트 식별 불가
"tenant_{issuer}" // issuer 는 동일할 수 있음 (공유 IdP)
동적 테넌트 등록 지원
새 테넌트가 추가될 때마다 애플리케이션을 재시작하지 않으려면, 런타임 테넌트 등록 API를 제공해야 합니다.
@RestController
@RequestMapping("/admin/tenants")
public class TenantManagementController {
private final TenantAwareJwtDecoderProvider decoderProvider;
public TenantManagementController(TenantAwareJwtDecoderProvider decoderProvider) {
this.decoderProvider = decoderProvider;
}
/
* 새 테넌트 등록 (관리자 전용)
*/
@PostMapping
public ResponseEntity<Void> registerTenant(
@RequestBody TenantRegistrationRequest request) {
// 실제 구현: DB 에 테넌트 정보 저장 후 캐시 무효화
System.out.println("✅ 테넌트 등록: " + request.tenantId() + " → " + request.jwksUri());
return ResponseEntity.ok().build();
}
/
* 테넌트 캐시 무효화 (키 회전 시)
*/
@DeleteMapping("/{tenantId}/cache")
public ResponseEntity<Void> invalidateCache(@PathVariable String tenantId) {
// 실제 구현: decoderProvider.evict(tenantId)
System.out.println("🔄 테넌트 캐시 무효화: " + tenantId);
return ResponseEntity.ok().build();
}
/
* 캐시 통계 조회
*/
@GetMapping("/cache-stats")
public ResponseEntity<Map<String, Object>> getCacheStats() {
return ResponseEntity.ok(decoderProvider.getCacheStats());
}
public record TenantRegistrationRequest(String tenantId, String jwksUri) {}
}
모니터링 및 운영
1. Actuator 메트릭
Caffeine 통계를 Prometheus 로 노출합니다.
# application.yml
management:
endpoints:
web:
exposure:
include: metrics, prometheus
metrics:
tags:
application: ${spring.application.name}
// CaffeineStatsConfig.java
@Bean
public MeterBinder caffeineCacheMetrics(TenantAwareJwtDecoderProvider provider) {
return (registry) -> {
Map<String, Object> stats = provider.getCacheStats();
registry.gauge("jwks.cache.tenants", stats.get("totalTenants"));
registry.gauge("jwks.cache.hitRate", stats.get("hitRate"));
registry.gauge("jwks.cache.missRate", stats.get("missRate"));
};
}
2. 권장 운영 지표
| 지표 | 목표치 | 조치 임계값 |
|---|---|---|
| 캐시 히트율 | >95% | <90% 시 TTL 증가 검토 |
| 테넌트 수 | <100 | >80 시 maximumSize 증가 |
| 캐시 미스 지연 | <200ms | >500ms 시 JWKS URI 최적화 |
| 키 회전 감지 | <1분 | >5분 시 리프레시 주기 단축 |
요약
멀티테넌트 JWKS 캐싱의 핵심은:
- 테넌트별 독립 캐시 키:
"tenant:{id}:decoder"패턴 - 테넌트별 TTL 차등: 프리미엄=2h, 무료=30m
- 런타임 테넌트 등록: DB 연동 + 캐시 무효화 API
- 모니터링: HitRate, 테넌트 수, 미스 지연 추적
이 아키텍처로 수백 개 테넌트를 지원하면서도 서브 1ms JWT 검증을 유지할 수 있습니다.
원하시면 전체 샘플 프로젝트 (Spring Boot 3 + 멀티테넌트 Keycloak)를 만들어 드릴까요?