광고
~ / posts / developer
smith@lab:~/posts$ cat developer/spring-security-jwt-resource-server-settings-troubleshooting-guide.md

스프링 시큐리티 JWT 리소스 서버 설정 판단표, 401·403 확인 순서

oauth2ResourceServer().jwt() 를 켜고 나면 "어느 키 설정을 쓰고, 기본값 중 무엇을 바꿔야 하나"가 남아요. 결론부터 말하면 OIDC 메타데이터가 있는 인가 서버면 issuer-uri 와 audiences 를 같이 넣고, JWK Set 조회 타임아웃을 짧게 줄이고, 7.x 로 올렸다면 토큰 헤더의 typ 부터 확인하세요. 나머지 기본값(만료 허용 오차 60초, RS256 만 허용, SCOPE_ 접두사)은 바꿀지 말지를 표로 판단하면 돼요.

이 글은 필터가 도는 순서 대신 운영에서 고를 값과 장애 확인 순서를 다뤄요. 기준은 Spring Security 7.1.2·Spring Boot 4.1.2 이고, 숫자는 Apple M1·OpenJDK 24.0.2 에서 spring-security-oauth2-jose 7.1.2 jar 로 NimbusJwtDecoder 를 직접 돌려 얻었어요(2026-10-27).

광고

리소스 서버 키 설정 판단표, 무엇을 넣을까

키를 어디서 얻느냐가 첫 결정이에요. Spring Security 레퍼런스의 리소스 서버 JWT 장이 설명하는 네 가지 방법을, 운영에서 묻는 질문 순서대로 다시 정리했어요.

먼저 물을 것예라면기동이 인가 서버에 묶이나키 회전
토큰을 즉시 무효화해야 하나(퇴사·도난 즉시 차단)불투명 토큰 + 인트로스펙션아니오, 대신 요청마다 인가 서버 호출해당 없음
인가 서버가 /.well-known/openid-configuration 을 주나issuer-uri (+ audiences)아니오, JWT 가 담긴 첫 요청 때 조회자동
메타데이터는 없고 JWKS 주소만 아나jwk-set-uri + issuer-uri아니오자동
키가 하나뿐이고 바뀌지 않나(사내 배치·테스트)public-key-location아니오재배포해야 함

jwk-set-uri 를 쓸 때도 issuer-uri 를 같이 적는 게 좋아요. jwk-set-uri 가 있으면 키는 그 주소에서 받고, issuer-uri 는 iss 클레임 검사에만 쓰여요. 반대로 NimbusJwtDecoder 를 빈으로 직접 만들면 기본 검증기에 iss 검사가 없어서 JwtValidators.createDefaultWithIssuer(issuer) 를 따로 붙여야 해요. 속성으로 시작했다가 커스터마이징하면서 빈으로 바꿀 때 iss 검사가 조용히 빠지는 일이 흔해요.

즉시 무효화가 필요한데 JWT 를 고르면 결국 차단 목록 조회를 덧붙여 "인가 서버를 묻지 않는 검증"이라는 장점이 사라져요. 이때는 처음부터 불투명 토큰 인트로스펙션과 비교하는 편이 나아요. 인가 서버가 여러 개(테넌트별 발급자)라면 멀티테넌시 문서의 JwtIssuerAuthenticationManagerResolver 를 써요.

즉시 무효화 필요 여부, OIDC 메타데이터 제공 여부, JWKS 주소만 아는지, 키가 고정인지를 차례로 물어 불투명 토큰, issuer-uri, jwk-set-uri와 issuer-uri, public-key-location 중 하나로 가는 판단 순서도
위에서부터 물어 처음 '예'가 나온 칸의 설정을 써요

운영 전에 바꿀 기본값, 실제로 돌려 본 결과

기본값을 하나씩 확인하려고 RSA 키로 토큰을 직접 서명하고, 로컬 JWKS 서버를 띄워 NimbusJwtDecoder.withJwkSetUri(...) 로 디코드했어요. 결과와 Spring Boot 4.1.2 의 OAuth2ResourceServerProperties에 있는 속성 이름을 함께 적었어요.

항목기본값으로 돌려 본 결과권장바꾸는 곳
audaud: payment-api 토큰도 통과이 API 식별자를 꼭 넣기jwt.audiences
만료 허용 오차만료 59초 뒤 통과, 61초 뒤 401시계 동기화가 확실하면 10~30초로JwtTimestampValidator(Duration)
서명 알고리즘ES256 토큰은 "Another algorithm expected" 로 거절인가 서버가 쓰는 것만jwt.jws-algorithms
헤더 typ비었거나 JWT 면 통과, at+jwt 는 거절at+jwt 발급자면 RFC 9068 검증기로JwtValidators.createAtJwtValidator()
권한scope·scp 를 나눠 SCOPE_ 를 붙임역할 클레임을 쓰면 이름·접두사 지정jwt.authorities-claim-name, jwt.authority-prefix
getName()sub사람이 읽는 ID 가 필요하면 바꾸기jwt.principal-claim-name
JWK Set 조회첫 요청 때 1번, 그 뒤 1,000번 디코드에도 추가 조회 0번타임아웃 2~5초로 줄이기NimbusJwtDecoder 의 restOperations(...)

가장 놓치기 쉬운 줄은 typ 이에요. 7.1.2 의 JwtValidators 는 기본 묶음 맨 앞에 JwtTypeValidator.jwt() 를 두는데, 6.5.0 의 같은 메서드에는 이 검사가 없었어요. 6.x 에서 되던 API 가 7.x 로 올린 뒤 401 만 낸다면 토큰 헤더부터 보세요. RFC 9068(JWT 액세스 토큰 프로필)을 따르는 인가 서버는 typ 을 at+jwt 로 찍어요. 이때는 레퍼런스 예시처럼 validateTypes(false) 로 빌더의 Nimbus 쪽 검사를 끄고 createAtJwtValidator() 에 issuer·audience·clientId 를 넣어요. 이 검증기는 exp·sub·iat·jti·client_id 클레임이 모두 있어야 통과시켜요.

JWK Set 조회 타임아웃의 기본값은 레퍼런스 기준 연결·읽기 각 30초예요. JWKS 엔드포인트가 느려지면 키를 새로 받는 요청이 최대 30초씩 붙잡히고, 조회가 잠금 하나로 직렬화돼 있어 뒤 요청도 줄줄이 기다려요. 2~5초로 줄여 빨리 실패하게 하세요.

설정 파일로 옮기면 이 정도가 출발점이에요. 값은 예시이고 주소·식별자는 각자 인가 서버에 맞춰요.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://login.example.org/realms/shop
          audiences: order-api
          jws-algorithms: RS256
          authorities-claim-name: roles
          authority-prefix: ROLE_

토큰 수명 직접 계산, 폐기 노출 시간과 갱신 부하

JWT 리소스 서버는 서명과 시각만 봐요. 로그아웃하거나 계정을 정지해도 이미 나간 토큰은 exp 에 허용 오차 60초를 더한 시각까지 통과해요. 그래서 토큰 수명 + 60초가 "도난당한 토큰을 쓸 수 있는 최대 시간"이에요. 수명을 줄이면 이 시간이 줄지만, 그만큼 리프레시 요청이 늘어요. 동시 접속 사용자 10만 명이 모두 수명이 끝날 때마다 갱신한다고 놓고 계산하면 이래요.

액세스 토큰 수명별 인가 서버 갱신 요청 (동시 사용자 10만 명)
수명 5분수명 5분 · 333건/초333건/초수명 10분수명 10분 · 167건/초167건/초수명 15분수명 15분 · 111건/초111건/초수명 30분수명 30분 · 55.6건/초55.6건/초수명 60분수명 60분 · 27.8건/초27.8건/초

최대 노출 시간은 각각 6분, 11분, 16분, 31분, 61분 (수명 + 60초)

단위: 건/초 · 자료: 직접 계산 (초당 갱신 = 동시 사용자 ÷ 수명 초)
표로 보기
구분초당 갱신 요청
수명 5분333
수명 10분167
수명 15분111
수명 30분55.6
수명 60분27.8

5분과 15분 사이에서 노출 시간은 10분 줄고 갱신 부하는 3배가 돼요. 인가 서버가 초당 수백 건을 버티는지가 실제 하한을 정해요. 리프레시 토큰을 어떻게 회전시키고 저장할지는 액세스 토큰·리프레시 토큰 만료 시간 정하는 법에서 따로 다뤘어요.

허용 오차도 같은 계산에 들어가요. 수명이 5분이면 60초 오차는 노출 시간을 20% 늘려요. NTP 로 시계를 맞추고 있다면 JwtTimestampValidator(Duration.ofSeconds(30)) 정도로 줄여도 되지만, 두 서버의 시계 차가 허용 오차보다 크면 발급 직후 토큰이 nbf 에 걸려 401 이 나요. 토큰의 exp·iat 숫자는 타임스탬프 변환기에 넣으면 KST 로 바로 읽혀요.

서명 검증 비용, 디코드 한 번에 얼마나 드나

JWT 를 고르는 이유가 "인가 서버를 묻지 않아 빠르다"이니, 그 대신 내는 CPU 비용을 재 봤어요. 단일 스레드에서 워밍업 뒤 반복 측정한 값이에요.

JWT 서명 검증과 디코드 1회 시간 (Apple M1, OpenJDK 24.0.2, 단일 스레드)
RS256 서명 검증 2048비트RS256 서명 검증 2048비트 · 201µs201µsNimbusJwtDecoder 디코드 전체NimbusJwtDecoder 디코드 전체 · 260µs260µsJWKS 경유 디코드 전체JWKS 경유 디코드 전체 · 287µs287µsRS256 서명 검증 4096비트RS256 서명 검증 4096비트 · 698µs698µsES256 서명 검증ES256 서명 검증 · 2,425µs2,425µs

같은 기기 OpenSSL 3.6.5 에서는 RS256 검증 88µs, ES256 검증 264µs 로 ES256 이 3배 느렸어요

단위: µs · 자료: 직접 측정 (java.security.Signature 와 spring-security-oauth2-jose 7.1.2, 2026-10-27)
표로 보기
구분1회 시간
RS256 서명 검증 2048비트201
NimbusJwtDecoder 디코드 전체260
JWKS 경유 디코드 전체287
RS256 서명 검증 4096비트698
ES256 서명 검증2,425

디코드 전체(파싱·서명·클레임 검사)가 260µs 면 코어 하나로 초당 약 3,800번이에요. 초당 1,000 요청이면 0.26코어, 5,000 요청이면 1.3코어를 인증에 써요. 게이트웨이처럼 인증만 하고 넘기는 서버라면 CPU 대부분이 여기로 가요.

두 가지가 눈에 띄어요. RSA 키를 4096비트로 키우면 검증이 3.5배 느려지고, ES256 은 토큰이 짧아지는 대신 검증이 RS256 보다 느렸어요. JDK 기본 공급자에서는 차이가 12배였고 OpenSSL 에서도 3배였어요. RSA 검증은 공개 지수가 작아 원래 빠르고, ECDSA 는 검증이 서명보다 무거운 구조라서 그래요. 인가 서버의 알고리즘을 바꿀 계획이면 리소스 서버 JVM 에서 같은 측정을 먼저 해 보세요. 알고리즘별 서명 원리는 JWT 구조와 서명 원리에 정리돼 있어요.

JWKS 조회 자체의 부하는 작아요. 레퍼런스 기준 JWK Set 은 메모리에 5분 캐시되니, 파드 100개면 5분에 100번, 초당 0.33번이에요.

JWK 키 회전 순서, 새 키와 옛 키를 언제 넣고 빼나

실험에서 확인한 동작 세 가지가 회전 순서를 정해요.

  1. JWKS 에 새 키를 올리고 그 키로 서명한 토큰을 보내면, 디코더가 모르는 kid 를 보고 즉시 JWK Set 을 다시 받아 통과시켰어요(추가 조회 1번).
  2. JWKS 에서 옛 키를 빼도, 캐시가 남아 있는 동안은 옛 키 토큰이 계속 통과했어요.
  3. JWKS 에 없는 kid 를 단 토큰을 10번 보내면 JWK Set 을 10번 다시 받았어요. 스프링이 Nimbus 의 조회 속도 제한(rateLimited)을 끄고 쓰기 때문이에요.

1번 덕분에 새 키를 미리 올려 두지 않아도 스프링 리소스 서버는 따라와요. 하지만 다른 언어의 서비스나 CDN 캐시는 그렇지 않을 수 있으니 새 키는 캐시 시간(5분) 이상 먼저 게시하는 게 안전해요. 옛 키는 마지막으로 그 키로 서명한 시각 + 토큰 수명 + 60초가 지난 뒤에 빼요. 그 전에 빼면 캐시가 갱신된 인스턴스부터 정상 토큰에 401 을 내요.

새 키 게시, 5분 뒤 새 키로 서명 시작, 옛 키로 서명한 마지막 토큰의 만료 15분과 허용 오차 1분이 지난 21분 뒤 옛 키 제거까지를 시간 축에 그린 키 회전 타임라인
수명 15분이면 게시부터 옛 키 제거까지 최소 21분이에요

3번은 보안 쪽 숙제예요. 가짜 kid 를 단 토큰을 대량으로 보내면 리소스 서버가 그만큼 인가 서버의 JWKS 를 두드려요. 인가 서버 JWKS 엔드포인트 앞에 캐시나 속도 제한을 두고, 리소스 서버 쪽 조회 타임아웃을 짧게 잡아 두면 피해가 한 곳에 머물러요.

광고
광고

401·403 운영 문제 확인 순서

리소스 서버의 실패 응답은 WWW-Authenticate 헤더에 이유를 담아요. 오류 코드 이름과 상태 코드는 RFC 6750 의 3.1절을 따르니, 응답 헤더 한 줄을 먼저 보는 게 가장 빨라요. 상태 코드 뜻이 헷갈리면 HTTP 상태 코드 조회를 같이 쓰세요.

  1. curl -si 로 헤더 확인. error 가 없는 Bearer 만 있으면 토큰이 서버에 닿지 않은 거예요. 프록시가 Authorization 헤더를 지웠거나, ?access_token= 쿼리로 보냈거나(기본으로 꺼져 있어요), 스킴이 Bearer 가 아닌 경우예요.
  2. invalid_token 이면 error_description 을 읽기. 아래 표에서 문구로 원인을 찾아요.
  3. 토큰 payload 를 열어 보기. JWT 디코더에 붙이면 typ·alg·kid·iss·aud·exp 를 한눈에 봐요. 브라우저 안에서만 풀어서 운영 토큰을 넣어도 외부로 나가지 않아요.
  4. insufficient_scope(403)이면 권한 문자열 비교. 토큰이 만든 권한 목록과 hasAuthority·hasRole 의 문자열을 그대로 맞대 봐요.
error_description 에 보이는 문구흔한 원인확인·조치
the given typ value needs to be one of [JWT]7.x 업그레이드, 인가 서버가 at+jwt 발급createAtJwtValidator() 로 교체
Another algorithm expected, or no matching key(s) foundES256 등 허용 안 된 알고리즘, JWKS 에 없는 kidjws-algorithms 추가, 키 회전 순서 점검
Jwt expired at …만료 + 60초 경과, 서버 시계 차클라이언트 갱신 로직, NTP
The iss claim is not validissuer-uri 끝 슬래시·대소문자 차이토큰의 iss 와 글자 단위로 비교
The aud claim is not validaudiences 와 토큰 aud 불일치인가 서버의 대상(리소스) 설정
Invalid signature다른 환경의 인가 서버 토큰, 토큰 잘림스테이징·운영 발급자 혼동 확인

표에 없는 경우도 있어요. issuer-uri 메타데이터는 기동 때가 아니라 첫 JWT 요청 때 조회해서, 리소스 서버가 인가 서버에 닿지 못해도(방화벽·내부 DNS) 기동은 성공하고 요청만 실패해요. 배포 직후 토큰을 실은 스모크 테스트 한 번을 넣어 두면 잡혀요. 토큰 보관 위치나 알고리즘 혼동 같은 클라이언트 쪽 점검은 JWT 보안 체크리스트에 모아 뒀어요.

도입 전에 따질 것, 어디서 검증할까

마지막으로 구조를 정할 때 물어볼 것들이에요.

광고
광고

자주 묻는 질문

issuer-uri 만 넣으면 aud 도 검사하나요?

아니에요. issuer-uri 는 iss 클레임만 검사해요. 이 API 를 위한 토큰인지 보려면 spring.security.oauth2.resourceserver.jwt.audiences 에 식별자를 넣어야 하고, 넣지 않으면 같은 인가 서버가 다른 API 용으로 발급한 토큰도 통과해요.

만료된 토큰이 1분쯤 더 통과하는 건 버그인가요?

정상이에요. 기본 JwtTimestampValidator 가 서버 간 시계 차를 견디려고 60초를 허용해요. 직접 돌려 보니 만료 59초 뒤는 통과, 61초 뒤는 401 이었어요. 만료 경계를 테스트할 때는 60초를 넘겨서 확인하세요.

스프링 시큐리티 7 로 올렸더니 모든 요청이 401 이에요.

토큰 헤더의 typ 을 먼저 보세요. 7.x 기본 검증기는 typ 이 비었거나 JWT 일 때만 받아요. 인가 서버가 at+jwt 를 찍는다면 JwtValidators.createAtJwtValidator() 로 바꾸고 issuer·audience·clientId 를 지정해요.

JWK Set 캐시 5분은 줄여야 하나요?

대개 그대로 둬도 돼요. 모르는 kid 가 오면 캐시와 상관없이 즉시 다시 받기 때문에, 새 키 반영이 늦어지는 일은 거의 없어요. 오히려 옛 키를 너무 일찍 빼는 실수가 더 흔하니, 회전 순서를 지키는 게 먼저예요.

액세스 토큰 수명은 몇 분이 적당한가요?

정답은 없고 노출 시간과 갱신 부하의 교환이에요. 동시 사용자 10만 명 기준으로 5분이면 초당 333건, 15분이면 111건의 갱신이 생기고, 노출 시간은 6분과 16분이에요. 인가 서버 용량과 차단 요구 수준을 같이 놓고 정하세요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 시큐리티 JWT 인증 원리 — 리소스 서버 검증 흐름에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#스프링 시큐리티 JWT 설정#리소스 서버 issuer-uri jwk-set-uri#JWT 401 403 원인#aud 검증 audiences#clock skew 60초#JWK 키 회전#at+jwt typ 오류#Spring Security 7
← 이전 글스프링 배치 파티셔닝 설정 고르는 법, gridSize·스레드·커넥션 풀 계산다음 글 →어텐션 구현 고르기, SDPA·FlashAttention 메모리와 GPU 비용
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →