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 를 써요.
운영 전에 바꿀 기본값, 실제로 돌려 본 결과
기본값을 하나씩 확인하려고 RSA 키로 토큰을 직접 서명하고, 로컬 JWKS 서버를 띄워 NimbusJwtDecoder.withJwkSetUri(...) 로 디코드했어요. 결과와 Spring Boot 4.1.2 의 OAuth2ResourceServerProperties에 있는 속성 이름을 함께 적었어요.
| 항목 | 기본값으로 돌려 본 결과 | 권장 | 바꾸는 곳 |
|---|---|---|---|
aud | aud: 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만 명이 모두 수명이 끝날 때마다 갱신한다고 놓고 계산하면 이래요.
5분과 15분 사이에서 노출 시간은 10분 줄고 갱신 부하는 3배가 돼요. 인가 서버가 초당 수백 건을 버티는지가 실제 하한을 정해요. 리프레시 토큰을 어떻게 회전시키고 저장할지는 액세스 토큰·리프레시 토큰 만료 시간 정하는 법에서 따로 다뤘어요.
허용 오차도 같은 계산에 들어가요. 수명이 5분이면 60초 오차는 노출 시간을 20% 늘려요. NTP 로 시계를 맞추고 있다면 JwtTimestampValidator(Duration.ofSeconds(30)) 정도로 줄여도 되지만, 두 서버의 시계 차가 허용 오차보다 크면 발급 직후 토큰이 nbf 에 걸려 401 이 나요. 토큰의 exp·iat 숫자는 타임스탬프 변환기에 넣으면 KST 로 바로 읽혀요.
서명 검증 비용, 디코드 한 번에 얼마나 드나
JWT 를 고르는 이유가 "인가 서버를 묻지 않아 빠르다"이니, 그 대신 내는 CPU 비용을 재 봤어요. 단일 스레드에서 워밍업 뒤 반복 측정한 값이에요.
디코드 전체(파싱·서명·클레임 검사)가 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 키 회전 순서, 새 키와 옛 키를 언제 넣고 빼나
실험에서 확인한 동작 세 가지가 회전 순서를 정해요.
- JWKS 에 새 키를 올리고 그 키로 서명한 토큰을 보내면, 디코더가 모르는
kid를 보고 즉시 JWK Set 을 다시 받아 통과시켰어요(추가 조회 1번). - JWKS 에서 옛 키를 빼도, 캐시가 남아 있는 동안은 옛 키 토큰이 계속 통과했어요.
- JWKS 에 없는
kid를 단 토큰을 10번 보내면 JWK Set 을 10번 다시 받았어요. 스프링이 Nimbus 의 조회 속도 제한(rateLimited)을 끄고 쓰기 때문이에요.
1번 덕분에 새 키를 미리 올려 두지 않아도 스프링 리소스 서버는 따라와요. 하지만 다른 언어의 서비스나 CDN 캐시는 그렇지 않을 수 있으니 새 키는 캐시 시간(5분) 이상 먼저 게시하는 게 안전해요. 옛 키는 마지막으로 그 키로 서명한 시각 + 토큰 수명 + 60초가 지난 뒤에 빼요. 그 전에 빼면 캐시가 갱신된 인스턴스부터 정상 토큰에 401 을 내요.
3번은 보안 쪽 숙제예요. 가짜 kid 를 단 토큰을 대량으로 보내면 리소스 서버가 그만큼 인가 서버의 JWKS 를 두드려요. 인가 서버 JWKS 엔드포인트 앞에 캐시나 속도 제한을 두고, 리소스 서버 쪽 조회 타임아웃을 짧게 잡아 두면 피해가 한 곳에 머물러요.
401·403 운영 문제 확인 순서
리소스 서버의 실패 응답은 WWW-Authenticate 헤더에 이유를 담아요. 오류 코드 이름과 상태 코드는 RFC 6750 의 3.1절을 따르니, 응답 헤더 한 줄을 먼저 보는 게 가장 빨라요. 상태 코드 뜻이 헷갈리면 HTTP 상태 코드 조회를 같이 쓰세요.
curl -si로 헤더 확인.error가 없는Bearer만 있으면 토큰이 서버에 닿지 않은 거예요. 프록시가Authorization헤더를 지웠거나,?access_token=쿼리로 보냈거나(기본으로 꺼져 있어요), 스킴이Bearer가 아닌 경우예요.invalid_token이면error_description을 읽기. 아래 표에서 문구로 원인을 찾아요.- 토큰 payload 를 열어 보기. JWT 디코더에 붙이면
typ·alg·kid·iss·aud·exp를 한눈에 봐요. 브라우저 안에서만 풀어서 운영 토큰을 넣어도 외부로 나가지 않아요. 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) found | ES256 등 허용 안 된 알고리즘, JWKS 에 없는 kid | jws-algorithms 추가, 키 회전 순서 점검 |
Jwt expired at … | 만료 + 60초 경과, 서버 시계 차 | 클라이언트 갱신 로직, NTP |
The iss claim is not valid | issuer-uri 끝 슬래시·대소문자 차이 | 토큰의 iss 와 글자 단위로 비교 |
The aud claim is not valid | audiences 와 토큰 aud 불일치 | 인가 서버의 대상(리소스) 설정 |
Invalid signature | 다른 환경의 인가 서버 토큰, 토큰 잘림 | 스테이징·운영 발급자 혼동 확인 |
표에 없는 경우도 있어요. issuer-uri 메타데이터는 기동 때가 아니라 첫 JWT 요청 때 조회해서, 리소스 서버가 인가 서버에 닿지 못해도(방화벽·내부 DNS) 기동은 성공하고 요청만 실패해요. 배포 직후 토큰을 실은 스모크 테스트 한 번을 넣어 두면 잡혀요. 토큰 보관 위치나 알고리즘 혼동 같은 클라이언트 쪽 점검은 JWT 보안 체크리스트에 모아 뒀어요.
도입 전에 따질 것, 어디서 검증할까
마지막으로 구조를 정할 때 물어볼 것들이에요.
- 검증을 게이트웨이에서 한 번만 할까, 서비스마다 할까. 게이트웨이만 검증하고 뒤 서비스는 헤더를 믿으면 CPU 는 아끼지만, 내부망에서 게이트웨이를 우회한 호출을 막을 수 없어요. 서비스마다 검증해도 앞의 계산대로 요청당 0.3ms 안팎이라, 보통은 각 서비스가
aud를 자기 이름으로 검증하는 쪽이 안전해요. - 토큰 크기. 권한·그룹을 클레임에 많이 담으면 헤더가 커져요. Base64URL 은 원래보다 약 33% 커지니 클레임 3KB 는 헤더 약 4KB 가 돼요. 웹 서버·프록시의 요청 헤더 상한에 먼저 걸리는 경우가 있어요.
- 즉시 차단 요구가 있는가. 있다면 짧은 수명(5분 안팎)으로 버틸지, 인트로스펙션으로 갈지를 위의 갱신 부하 차트로 정해요.
자주 묻는 질문
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분이에요. 인가 서버 용량과 차단 요구 수준을 같이 놓고 정하세요.
정리
- 키 설정은 OIDC 메타데이터가 있으면
issuer-uri, 없으면jwk-set-uri+issuer-uri, 즉시 차단이 필요하면 인트로스펙션이에요. 디코더 빈을 직접 만들면iss검사를 다시 붙여요. - 운영 전에
audiences를 넣고, JWKS 조회 타임아웃을 줄이고, 7.x 라면typ을 확인해요. 허용 오차 60초는 시계 차를 재 본 뒤에만 줄여요. - 최대 노출 시간은 수명 + 60초, 갱신 부하는 동시 사용자 ÷ 수명이에요. 디코드는 한 번에 약 0.26ms 라 서비스마다 검증해도 부담이 작아요.
- 키 회전은 새 키를 5분 이상 먼저 게시하고, 옛 키는 마지막 서명 + 수명 + 60초 뒤에 빼요. 401·403 은
WWW-Authenticate헤더 → JWT 디코더로 토큰 확인 순서로 좁혀요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 시큐리티 JWT 인증 원리 — 리소스 서버 검증 흐름에서 도식과 코드로 단계별로 풀었어요.