멀티테넌트 JWKS 캐싱 지원 기법

멀티테넌트 환경에서 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 캐싱의 핵심은:

  1. 테넌트별 독립 캐시 키"tenant:{id}:decoder" 패턴
  2. 테넌트별 TTL 차등: 프리미엄=2h, 무료=30m
  3. 런타임 테넌트 등록: DB 연동 + 캐시 무효화 API
  4. 모니터링: HitRate, 테넌트 수, 미스 지연 추적

이 아키텍처로 수백 개 테넌트를 지원하면서도 서브 1ms JWT 검증을 유지할 수 있습니다.

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

댓글 남기기