Published on

Spring Security 6 OAuth2 로그인 401, JWK 로테이션

Authors

운영 중인 Spring Boot 3 + Spring Security 6 환경에서 OAuth2 로그인 또는 JWT 검증이 잘 되다가, 특정 시점부터 갑자기 401 Unauthorized가 간헐적으로 발생하는 케이스가 있습니다. 로그를 보면 대개 JwtException 또는 InvalidBearerTokenException 형태로 떨어지고, 원인은 의외로 애플리케이션 코드가 아니라 IdP(Identity Provider)의 JWK 로테이션애플리케이션의 JWK 캐시 갱신 타이밍 불일치인 경우가 많습니다.

이 글에서는 다음을 다룹니다.

  • JWK 로테이션이 왜 401을 만들 수 있는지
  • Spring Security 6에서 JWK 조회/캐시가 어떻게 동작하는지
  • 장애를 재현하고 로그로 증명하는 방법
  • 해결 전략(캐시/타임아웃/재시도/멀티키 허용, 운영 체크리스트)

참고로, 이런 문제는 “한 번 터지면 계속 터지는” 유형이라기보다, 키가 바뀌는 순간(로테이션 윈도우) 에 집중적으로 발생합니다. 그래서 더 찾기 어렵습니다.

증상 패턴: 정상 → 특정 시점부터 401 스파이크

다음과 같은 패턴이면 JWK 로테이션을 강하게 의심할 수 있습니다.

  • 평소에는 정상인데, 하루 1회 또는 수시간 간격으로 401이 몰림
  • 같은 토큰이 어떤 인스턴스에서는 통과하고 어떤 인스턴스에서는 실패
  • 재시도하면 성공하기도 함(특히 브라우저 새로고침, 모바일 재로그인)

리소스 서버 기준으로는 보통 이런 예외가 보입니다.

  • org.springframework.security.oauth2.jwt.JwtException: An error occurred while attempting to decode the Jwt
  • org.springframework.security.oauth2.jwt.JwtValidationException: ...
  • org.springframework.security.oauth2.server.resource.InvalidBearerTokenException: ...

그리고 원인 체인은 종종 RemoteKeySourceException 또는 JWK endpoint 호출 실패/캐시 미스 형태로 이어집니다.

JWK 로테이션이 401을 만드는 메커니즘

IdP는 JWT를 서명할 때 개인키(private key)를 사용하고, 애플리케이션은 공개키(public key)로 서명을 검증합니다. 공개키는 보통 jwks_uri(JWK Set endpoint)에서 내려줍니다.

IdP가 키를 로테이션하면 다음이 바뀝니다.

  • 새 키 쌍 생성
  • JWT 헤더의 kid 값이 새 키를 가리키도록 변경
  • JWK Set에 새 공개키가 추가되거나, 기존 키가 제거됨

문제는 여기서 발생합니다.

  1. 애플리케이션이 JWK Set을 캐시하고 있음
  2. IdP가 새 kid로 서명한 토큰을 발급하기 시작함
  3. 애플리케이션 캐시에 새 kid에 해당하는 JWK가 없음
  4. 검증 실패로 401 발생

즉, 토큰은 정상인데 검증자가 최신 키를 아직 모르는 상태가 됩니다.

로테이션 윈도우에서 자주 생기는 운영 변수

  • 여러 인스턴스가 서로 다른 시점에 JWK를 갱신함
  • JWK endpoint 호출이 순간적으로 느리거나 실패함(네트워크, DNS, 프록시)
  • IdP가 새 키를 배포하는 과정에서 “추가 → 제거” 순서가 보장되지 않음
  • CDN 캐시가 JWK 응답을 오래 잡고 있음

이때 401은 애플리케이션의 인증 실패처럼 보이지만, 사실은 키 동기화 문제입니다.

Spring Security 6에서 JWK 조회/캐시 동작 이해

Spring Security 6 리소스 서버(JWT)는 보통 아래 설정 중 하나로 구성됩니다.

  • issuer-uri 기반 자동 구성(권장)
  • jwk-set-uri 직접 지정

예시(application.yml):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/realms/demo

issuer-uri를 주면 Spring이 OIDC discovery 문서에서 jwks_uri를 찾아 JWK Set을 가져옵니다.

핵심은 “JWK를 매 요청마다 가져오지 않는다”는 점입니다. 일반적으로는 내부 캐시를 두고, 토큰의 kid로 필요한 키를 찾아 검증합니다. 그런데 로테이션 직후 새 kid를 만나면, 구현에 따라 다음 중 하나가 됩니다.

  • 캐시 갱신을 시도하지만 네트워크 실패로 갱신 실패 → 401
  • 갱신은 했는데 응답이 구버전(프록시/CDN 캐시) → 401
  • 갱신 주기가 길어 새 키 반영이 늦음 → 401

따라서 해결의 방향은 로테이션 이벤트에 더 민감하게 캐시를 갱신하거나, 갱신 실패 시의 복원력을 높이는 것입니다.

1차 진단: kid 불일치와 JWK Set 확인

가장 먼저 해야 할 것은 “실패한 토큰의 kid가 JWK Set에 존재하는가”를 확인하는 것입니다.

토큰 헤더에서 kid 확인

JWT는 header.payload.signature 구조이므로, 헤더를 Base64URL 디코딩하면 kid를 볼 수 있습니다.

간단한 셸 예시(개념용):

TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9.eyJzdWIiOiJ1c2VyIn0.sgn"
HEADER=$(echo "$TOKEN" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null)
echo "$HEADER"

출력에 "kid":"..."가 보이면 그 값을 기록합니다.

JWK Set에 해당 kid가 있는지 확인

curl -s https://idp.example.com/realms/demo/protocol/openid-connect/certs | jq '.keys[].kid'
  • 실패 토큰의 kid가 목록에 없다면: IdP 로테이션 직후 JWK 미전파 또는 중간 캐시(CDN) 문제 가능성이 큽니다.
  • 목록에 있는데도 검증 실패라면: 알고리즘 불일치, 잘못된 issuer/audience, 키 타입 불일치 등을 추가로 봐야 합니다.

2차 진단: Spring 쪽 로그를 “키 로딩” 관점으로 보기

운영에서 가장 답답한 부분은 “401만 남고 왜 실패했는지 모르는” 상태입니다. 리소스 서버의 보안 로그 레벨을 올리면 단서가 나옵니다.

application.yml:

logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2.jwt: DEBUG

이후 401이 발생한 시점에 다음을 확인합니다.

  • JWK endpoint 호출 시도 여부
  • 호출 실패(타임아웃, 5xx, TLS) 여부
  • 특정 kid를 찾지 못했다는 메시지

만약 애플리케이션이 EKS 같은 환경이라면, 네트워크 문제는 다른 장애와도 결이 비슷합니다. 예를 들어 시스템 레벨에서 원인 추적이 필요할 때는 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 방식으로 “증상-로그-원인”을 좁히는 접근이 도움이 됩니다.

해결 전략 1: JWK 캐시 갱신 실패에 강하게 만들기

JWK 로테이션 자체는 정상 동작입니다. 문제는 “로테이션 시점에 키를 못 가져오는” 복원력입니다.

타임아웃/재시도(네트워크) 보강

JWK endpoint 호출이 느리거나 일시 실패하면 그대로 401이 튈 수 있습니다. 이때는 RestOperations 기반의 NimbusJwtDecoder를 직접 구성해 타임아웃을 명시하는 방식이 실무에서 효과적입니다.

아래는 jwkSetUri를 직접 쓰는 구성 예시입니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.web.client.RestTemplate;

@Configuration
public class JwtDecoderConfig {

    @Bean
    JwtDecoder jwtDecoder() {
        String jwkSetUri = "https://idp.example.com/realms/demo/protocol/openid-connect/certs";

        RestTemplate restTemplate = new RestTemplate();
        // 필요 시 ClientHttpRequestFactory를 교체해 connect/read timeout을 강제하세요.

        return NimbusJwtDecoder
            .withJwkSetUri(jwkSetUri)
            .restOperations(restTemplate)
            .build();
    }
}

운영에서는 RestTemplate 기본 타임아웃이 애매하게 길거나(또는 환경에 따라 무제한처럼 보이거나) 짧아서, 로테이션 순간 트래픽이 몰릴 때 병목이 되기도 합니다. 커넥션/리드 타임아웃을 명시하고, 가능하면 재시도 정책(인프라 레벨 또는 애플리케이션 레벨)을 둡니다.

해결 전략 2: JWK Set 응답의 캐시 계층 점검(CDN, 프록시)

IdP 앞에 CDN이나 API Gateway가 있고, jwks_uri가 캐시된다면 새 키 반영이 늦어질 수 있습니다.

  • Cache-Control 헤더가 어떻게 내려오는지 확인
  • CDN이 jwks_uri를 얼마나 오래 캐시하는지 확인
  • 로테이션 시점에 “새 키 추가 후 일정 시간 동안 구 키도 유지”하는지 확인

특히 “키를 바꾸는 순간”에는 JWK Set에 구 키와 신 키가 함께 존재해야 안전합니다. 구 키를 너무 빨리 제거하면, 아직 구 키로 서명된 토큰을 들고 있는 사용자 요청이 전부 401이 됩니다.

해결 전략 3: 다중 인스턴스 환경에서의 동시 갱신 폭주 방지

인스턴스가 많으면 로테이션 직후 모두가 동시에 JWK를 갱신하려고 하면서, IdP의 jwks_uri에 순간 부하가 생길 수 있습니다. 그러면 일부 인스턴스는 갱신 실패하고 401을 뿜습니다.

이 문제는 DB 커넥션 풀 고갈처럼 “동시성 폭주” 패턴과 유사합니다. 병목을 빠르게 진단하는 관점은 Spring Boot HikariCP 풀 고갈·DB 타임아웃 10분 진단 글의 접근(지표로 폭주 지점 찾기)과 비슷하게 적용할 수 있습니다.

대응 아이디어:

  • JWK fetch를 모든 인스턴스가 동시에 하지 않도록 jitter(랜덤 지연) 적용
  • 내부적으로 공유 캐시(예: Redis)에 JWK Set을 저장하고, 애플리케이션은 이를 참조
  • IdP 앞단에 rate limit이 있다면 예외적으로 로테이션 구간에 완화

해결 전략 4: issuer-uri 기반 자동 구성 vs jwk-set-uri 고정

issuer-uri 자동 구성은 편하지만, discovery 문서 접근/캐시까지 포함해 변수가 늘어날 수 있습니다. 반대로 jwk-set-uri를 고정하면 경로가 단순해져 장애 지점이 줄어듭니다.

예시:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://idp.example.com/realms/demo/protocol/openid-connect/certs
  • 네트워크 경로가 단순해지고, discovery 문서 문제를 배제할 수 있음
  • 대신 IdP 설정 변경 시 애플리케이션 설정도 같이 변경해야 함

운영에서 “401 스파이크”가 크고, 원인이 discovery 또는 중간 캐시로 의심된다면 jwk-set-uri 고정이 빠른 완화책이 됩니다.

해결 전략 5: 모니터링과 사전 워밍업(Pre-fetch)

로테이션은 예고 없이 일어나는 경우도 있지만, 많은 IdP는 일정 주기를 가집니다. 다음을 권장합니다.

  • jwks_uri를 주기적으로 긁어 JWK Set을 워밍업하는 잡 구성
  • 로테이션 직후(또는 의심 시점) JWK endpoint의 응답이 새 키를 포함하는지 자동 점검
  • 401 비율이 튀면 즉시 JWK endpoint 접근성, 응답 헤더, kid 목록을 함께 수집

EKS 환경이라면 메트릭 기반으로 원인 좁히기가 중요합니다. 클러스터 리소스가 0%로 보이거나 메트릭 파이프라인이 깨지면 진단이 늦어집니다. 필요하면 EKS에서 kubectl top이 0%일 때 Metrics API 점검 같은 체크로 관측 가능성을 먼저 복구하세요.

체크리스트: JWK 로테이션 401을 줄이는 운영 규칙

  • IdP 로테이션 정책 확인
    • 새 키 추가 후 구 키 유지 기간(최소 토큰 TTL 이상 권장)
  • jwks_uri 캐시 정책 확인
    • CDN/프록시가 캐시하면 TTL과 purge 전략 마련
  • 애플리케이션 네트워크 복원력
    • JWK fetch 타임아웃 명시
    • 일시 실패 시 재시도 또는 빠른 실패 후 재갱신 유도
  • 다중 인스턴스 동시 갱신 폭주 방지
    • 워밍업 잡, jitter, 공유 캐시 고려
  • 로그/지표
    • kid 기준으로 실패를 분류할 수 있게 로그에 남김

마무리

Spring Security 6에서 OAuth2 로그인/리소스 서버의 401을 만났을 때, 토큰 자체가 잘못됐다기보다 키를 검증하는 쪽이 로테이션을 따라가지 못하는 순간이 원인인 경우가 많습니다. 실패 토큰의 kid와 JWK Set의 kid 목록을 대조하는 것만으로도 진단 속도가 크게 빨라집니다.

해결은 대개 “JWK를 더 자주/더 안정적으로 가져오게 만들기”와 “로테이션 윈도우에서 구 키를 충분히 유지하기”로 수렴합니다. 위의 설정/운영 체크리스트를 적용하면, 로테이션 타이밍의 401 스파이크를 눈에 띄게 줄일 수 있습니다.