jose의 createRemoteJWKSet()은 내장된 TTL 기반 캐싱을 제공하지만, 프로덕션에서는 여기에 에러 기반 무효화, 캐시 워밍, 공유 캐시 계층을 추가해 성능과 가용성을 동시에 확보해야 합니다.
jose 기본 캐싱 메커니즘
createRemoteJWKSet()은 호출될 때마다 HTTP 요청을 보내지 않습니다. 내부적으로 자동 캐시를 유지하며, 기본적으로 5~6분 TTL로 동작합니다. 공식 문서와 소스코드는 이를 위해 옵션을 통해 캐시 상태를 주입하고 재사용하는 패턴을 제공합니다.
import * as jose from 'jose'
const JWKS = jose.createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
)
위 코드만으로도 자동 캐싱 + 키 회전 추적이 이루어지지만, 프로덕션에서는 다음 4가지 기법을 추가로 적용하는 것이 권장됩니다.
최적화 기법 1: TTL 조정과 캐시 구조 주입
jose는 캐시 수명을 직접 제어하는 공개 API를 제공하지는 않지만, 캐시 구조체를 주입하는 방식으로 초기 상태와 유효 기간을 간접 제어할 수 있습니다.
import * as jose from 'jose'
// 애플리케이션 시작 시 이전에 캐시된 JWKS 복원 (선택)
const previousCache = await getPreviouslyCachedJWKS() // 예: Redis/파일에서 복원
const JWKS = jose.createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
{
: previousCache || undefined
}
)
이 방식은 재시작 시 캐시 워밍 효과를 주며, 첫 요청부터 원격 조회를 피할 수 있게 합니다.
권장 TTL 전략:
- 기본값 (5~6분): 대부분의 서비스에 적합
- 짧은 TTL (1~2분): 키 회전이 잦은 환경 (예: 단기 인증서)
- 긴 TTL (15분): 키 회전이 드물고 성능이 중요한 환경
너무 짧은 TTL은 JWKS 엔드포인트에 부하를 주고, 너무 긴 TTL은 키 회전 지연을 유발합니다.
최적화 기법 2: 에러 기반 캐시 무효화
캐시된 키로 검증 실패 시 자동으로 fresh JWKS를 재조회하는 패턴이 가장 중요합니다. jose는 ERR_JWKS_NO_MATCHING_KEY 오류를 통해 이를 구현할 수 있게 합니다.
import * as jose from 'jose'
const JWKS = jose.createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
)
async function verifyWithCacheInvalidation(token, options) {
try {
return await jose.jwtVerify(token, JWKS, options)
} catch (error) {
// 캐시된 키에 kid가 없으면 fresh JWKS 재조회 후 재시도
if (error?.code === 'ERR_JWKS_NO_MATCHING_KEY') {
// 내부적으로 JWKS 캐시가 자동 갱신됨
return await jose.jwtVerify(token, JWKS, options)
}
throw error
}
}
이 패턴은 키 회전 직후의 토큰도 지연 없이 검증할 수 있게 하며, Auth0, Keycloak 등 주요 OIDC 제공자가 권장하는 방식입니다.
최적화 기법 3: 애플리케이션 시작 시 캐시 워밍
첫 JWT 검증 시 발생할 수 있는 콜드 스타트 지연을 방지하려면, 애플리케이션 부팅 시 JWKS를 미리 조회해 캐시를 채워야 합니다.
import * as jose from 'jose'
const JWKS_URL = new URL('https://auth.example.com/.well-known/jwks.json')
const JWKS = jose.createRemoteJWKSet(JWKS_URL)
// 애플리케이션 시작 시 캐시 워밍
async function warmJWKSCache() {
try {
// 더미 토큰 검증으로 캐시 적재 (실패해도 캐시는 채워짐)
await jose.jwtVerify('dummy.token.here', JWKS, {
issuer: 'https://auth.example.com/',
audience: 'api://my-service'
}).catch(() => {}) // 의도적 무시
console.log('JWKS cache warmed up')
} catch (error) {
console.error('JWKS warm-up failed:', error)
}
}
// 서버 시작 시 실행
warmJWKSCache()
더 정교하게는 fetch()로 직접 JWKS를 먼저 내려받고, 이를 로 주입하는 방식도 가능합니다.
async function warmJWKSCacheWithPrefetch() {
const response = await fetch(JWKS_URL)
const jwksJson = await response.json()
const JWKS = jose.createRemoteJWKSet(JWKS_URL, {
: {
keys: jwksJson.keys,
uat: Math.floor(Date.now() / 1000) // updated_at timestamp
}
})
return JWKS
}
최적화 기법 4: 공유 캐시 계층 (Redis/Memory)
단일 인스턴스 환경에서는 내장 캐시로 충분하지만, 다중 인스턴스/서버리스 환경에서는 공유 캐시 계층을 두는 것이 효율적입니다.
Redis 기반 공유 캐시 예제
import * as jose from 'jose'
import { createClient } from 'redis'
const redis = createClient({ url: 'redis://localhost:6379' })
await redis.connect()
const JWKS_URL = 'https://auth.example.com/.well-known/jwks.json'
const CACHE_KEY = `jwks:${JWKS_URL}`
const CACHE_TTL = 600 // 10분
async function getCachedJWKS() {
const cached = await redis.get(CACHE_KEY)
if (cached) {
return JSON.parse(cached)
}
return null
}
async function setCachedJWKS(jwks) {
await redis.set(CACHE_KEY, JSON.stringify(jwks), { EX: CACHE_TTL })
}
async function createJWKSSetWithRedisCache() {
// Redis 캐시 확인
let cached = await getCachedJWKS()
if (!cached) {
// 캐시 미스: 직접 fetch 후 캐시 적재
const response = await fetch(JWKS_URL)
const jwksJson = await response.json()
await setCachedJWKS(jwksJson)
cached = jwksJson
}
// jose 캐시에 주입
return jose.createRemoteJWKSet(new URL(JWKS_URL), {
: {
keys: cached.keys,
uat: Math.floor(Date.now() / 1000)
}
})
}
const JWKS = await createJWKSSetWithRedisCache()
이 방식은 인스턴스 간 캐시 일관성을 보장하며, JWKS 엔드포인트 호출을 인스턴스당 1회/10분으로 줄일 수 있습니다.
최적화 기법 5: HTTP 캐시 헤더 활용
JWKS 엔드포인트를 직접 운영한다면, Cache-Control 헤더로 클라이언트 캐싱을 유도할 수 있습니다.
Cache-Control: public, max-age=600, must-revalidate
max-age=600: 10분 간 캐시 유효must-revalidate: 만료 후 재검증 필수stale-while-revalidate=60: 만료 후 1분 간 stale 캐시 허용하면서 백그라운드 갱신
jose의 내장 캐시는 이 헤더를 직접 읽지는 않지만, 프록시/CDN 계층에서 캐싱되도록 해 전체 트래픽을 줄일 수 있습니다.
성능 비교 (실무 기준)
| 전략 | JWKS 호출/10분 | 평균 검증 지연 | 키 회전 반영 |
|---|---|---|---|
| 캐싱 없음 | 매 요청마다 | 50~150ms | 즉시 |
| 내장 캐싱 (기본) | 1~2회 | 5~10ms (캐시 히트) | 5~6분 후 |
| 에러 기반 무효화 | 1~3회 | 5~10ms | 회전 직후 1회 추가 |
| 캐시 워밍 추가 | 1회 (부팅 시) | 1~5ms (첫 요청부터 히트) | 동일 |
| Redis 공유 캐시 | 인스턴스당 1회 | 1~5ms | 동일 |
프로덕션에서는 내장 캐싱 + 에러 기반 무효화 + 캐시 워밍 조합이 가장 효율적이며, 대규모 환경에서는 Redis 공유 캐시를 추가합니다.
프로덕션 권장 구성
import * as jose from 'jose'
import { createClient } from 'redis'
const JWKS_URL = new URL('https://auth.example.com/.well-known/jwks.json')
const ISSUER = 'https://auth.example.com/'
const AUDIENCE = 'api://my-service'
// Redis 클라이언트 (선택, 다중 인스턴스 환경)
const redis = process.env.REDIS_URL ? createClient({ url: process.env.REDIS_URL }) : null
if (redis) await redis.connect()
async function getCachedJWKSFromRedis() {
if (!redis) return null
const cached = await redis.get(`jwks:${JWKS_URL}`)
return cached ? JSON.parse(cached) : null
}
async function cacheJWKSToRedis(jwks) {
if (!redis) return
await redis.set(`jwks:${JWKS_URL}`, JSON.stringify(jwks), { EX: 600 })
}
async function createOptimizedJWKSSet() {
let initialCache
// Redis 캐시 시도
if (redis) {
initialCache = await getCachedJWKSFromRedis()
}
// 캐시 미스 시 직접 fetch
if (!initialCache) {
try {
const response = await fetch(JWKS_URL)
const jwksJson = await response.json()
initialCache = jwksJson
// Redis 에 저장
if (redis) {
await cacheJWKSToRedis(jwksJson)
}
} catch (error) {
console.error('JWKS prefetch failed:', error)
}
}
// jose 캐시 주입
const JWKS = jose.createRemoteJWKSet(JWKS_URL, {
: initialCache
? { keys: initialCache.keys, uat: Math.floor(Date.now() / 1000) }
: undefined
})
return JWKS
}
const JWKS = await createOptimizedJWKSSet()
async function verifyAccessToken(token) {
try {
return await jose.jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms:
})
} catch (error) {
// 에러 기반 캐시 무효화
if (error?.code === 'ERR_JWKS_NO_MATCHING_KEY') {
// Redis 캐시 무효화 (선택)
if (redis) {
await redis.del(`jwks:${JWKS_URL}`)
}
// 재시도
return await jose.jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms:
})
}
throw error
}
}
모니터링 지표
프로덕션에서는 다음 지표를 추적해야 합니다:
- JWKS fetch 실패율: 1% 초과 시 경고
- 캐시 히트율: 95% 미만이면 TTL 조정 검토
- 평균 검증 지연: 50ms 초과 시 캐시 전략 재검토
- 키 회전 감지 횟수: 예상보다 잦으면 인증 서버 설정 확인
요약
jose JWKS 캐싱 최적화의 핵심은:
- 내장 캐싱 활용 (기본 5~6분 TTL)
- 에러 기반 무효화 (
ERR_JWKS_NO_MATCHING_KEY재시도) - 캐시 워밍 (부팅 시 미리 적재)
- 공유 캐시 (Redis 등, 다중 인스턴스 환경)
- HTTP 캐시 헤더 (JWKS 엔드포인트 운영 시)
이 조합으로 제로다운타임 키 회전과 서브 10ms 검증 지연을 동시에 달성할 수 있습니다.