OAuth2 Resource Server란?
Spring Security의 OAuth2 Resource Server는 JWT 또는 Opaque Token으로 보호되는 API 서버를 구축하는 모듈입니다. 별도의 인가 서버(Keycloak, Auth0, AWS Cognito 등)가 발급한 토큰을 검증만 담당하며, 토큰 발급 로직은 포함하지 않습니다.
이 글에서는 JWT 기반 Resource Server 설정부터 커스텀 권한 매핑, 멀티 테넌트 지원, Opaque Token 전략, 테스트 기법까지 실전에서 필요한 모든 것을 다룹니다.
기본 설정: JWT Resource Server
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/myapp
# 또는 직접 JWKS 엔드포인트 지정
# jwk-set-uri: https://auth.example.com/.well-known/jwks.json
issuer-uri를 설정하면 Spring이 자동으로 .well-known/openid-configuration을 조회해서 JWKS URI, 지원 알고리즘 등을 가져옵니다. 이것만으로 기본 JWT 검증이 동작합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()) // Stateless API → CSRF 불필요
.build();
}
}
기본적으로 JWT의 scope 클레임이 SCOPE_ 접두사가 붙은 GrantedAuthority로 매핑됩니다.
커스텀 권한 매핑: JwtAuthenticationConverter
실무에서는 Keycloak의 realm_access.roles처럼 비표준 클레임에 역할 정보가 담기는 경우가 많습니다. Method Security와 연동하려면 커스텀 컨버터가 필수입니다.
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthorities =
new JwtGrantedAuthoritiesConverter();
grantedAuthorities.setAuthorityPrefix("SCOPE_"); // 기본 scope 유지
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
// 1) 기본 scope 권한
Collection<GrantedAuthority> authorities =
new ArrayList<>(grantedAuthorities.convert(jwt));
// 2) Keycloak realm_access.roles 매핑
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null) {
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
}
}
// 3) resource_access (client-level roles)
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess != null) {
Map<String, Object> client =
(Map<String, Object>) resourceAccess.get("my-api");
if (client != null) {
List<String> clientRoles = (List<String>) client.get("roles");
clientRoles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase()))
.forEach(authorities::add);
}
}
return authorities;
});
return converter;
}
이제 @PreAuthorize("hasRole('ADMIN')")으로 Keycloak 역할 기반 접근 제어가 가능합니다.
JWT Decoder 커스터마이징
토큰 검증 로직을 세밀하게 제어해야 할 때 JwtDecoder 빈을 직접 정의합니다.
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
// 커스텀 검증기 추가
OAuth2TokenValidator<Jwt> withIssuer =
JwtValidators.createDefaultWithIssuer("https://auth.example.com");
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
if (token.getAudience().contains("my-api")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_audience", "my-api audience 필수", null));
};
// 복합 검증기: issuer + audience
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
return decoder;
}
audience 검증은 기본으로 안 되기 때문에, 프로덕션에서는 반드시 추가해야 합니다. 이것이 없으면 같은 인가 서버를 쓰는 다른 서비스의 토큰으로도 접근이 가능해집니다.
멀티 테넌트: 여러 인가 서버 지원
SaaS 환경에서 테넌트별로 다른 인가 서버를 사용하는 경우, AuthenticationManagerResolver로 동적 라우팅합니다.
@Bean
public AuthenticationManagerResolver<HttpServletRequest>
multiTenantResolver(TenantRepository tenantRepo) {
Map<String, JwtDecoder> decoders = new ConcurrentHashMap<>();
return request -> {
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId == null) throw new AuthenticationServiceException("테넌트 헤더 누락");
JwtDecoder decoder = decoders.computeIfAbsent(tenantId, id -> {
Tenant tenant = tenantRepo.findById(id)
.orElseThrow(() -> new AuthenticationServiceException("미등록 테넌트"));
return NimbusJwtDecoder
.withJwkSetUri(tenant.getJwksUri())
.build();
});
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder);
provider.setJwtAuthenticationConverter(jwtAuthenticationConverter());
return provider::authenticate;
};
}
// SecurityFilterChain에서 사용
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(multiTenantResolver(tenantRepo))
)
Opaque Token: 인트로스펙션 방식
JWT 대신 Opaque Token(참조 토큰)을 사용하면, Resource Server가 매 요청마다 인가 서버에 토큰 유효성을 물어봅니다.
# application.yml
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth.example.com/oauth2/introspect
client-id: my-api
client-secret: ${INTROSPECTION_SECRET}
- 장점: 즉시 토큰 폐기(revocation) 가능, 토큰에 민감 정보 미포함
- 단점: 매 요청마다 네트워크 호출 → 지연 증가. 캐싱으로 완화 가능
- 선택 기준: 토큰 즉시 폐기가 중요하면 Opaque, 성능 우선이면 JWT + 짧은 만료 시간
테스트 전략
spring-security-test는 JWT 모킹을 위한 유틸리티를 제공합니다.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mockMvc;
@Test
void 인증_없이_접근하면_401() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
void JWT_있으면_정상_응답() throws Exception {
mockMvc.perform(get("/api/users/me")
.with(jwt()
.jwt(j -> j
.subject("user-123")
.claim("realm_access", Map.of(
"roles", List.of("USER")))
)
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("user-123"));
}
@Test
void ADMIN_아니면_403() throws Exception {
mockMvc.perform(delete("/api/admin/users/999")
.with(jwt().authorities(
new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
}
에러 응답 커스터마이징
기본 401/403 응답은 정보가 부족합니다. AOP나 AuthenticationEntryPoint를 커스터마이징하여 RFC 6750 형식으로 응답합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint((request, response, ex) -> {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("""
{"error":"unauthorized","message":"%s"}
""".formatted(ex.getMessage()));
})
.accessDeniedHandler((request, response, ex) -> {
response.setStatus(403);
response.setContentType("application/json");
response.getWriter().write("""
{"error":"forbidden","message":"권한이 부족합니다"}
""");
})
)
.build();
}
프로덕션 체크리스트
- audience 검증 추가 — 같은 IdP의 다른 서비스 토큰 차단
- JWKS 캐싱 — Spring이 자동 캐싱하지만, NimbusJwtDecoder의 캐시 TTL 확인 (기본 5분)
- Clock Skew —
JwtTimestampValidator의 clockSkew 기본 60초, 서버 시간 동기화 필수 - 토큰 만료 시간 — Access Token 5~15분, Refresh Token 수 시간 권장
- CORS — SPA에서 API 호출 시
Authorization헤더 허용 설정 필수 - Rate Limiting — 토큰 검증은 CPU 집약적이므로 요청 제한 설정
마무리
Spring OAuth2 Resource Server는 토큰 검증에 집중한 모듈로, 인가 서버를 직접 구현하지 않고 Keycloak/Auth0/Cognito 등과 조합하여 사용합니다. 핵심 포인트: audience 검증 필수, 커스텀 클레임 매핑으로 실제 역할 체계 반영, 멀티 테넌트는 AuthenticationManagerResolver, 테스트는 spring-security-test의 jwt() 활용. Actuator 메트릭으로 토큰 검증 실패율을 모니터링하면 보안 이슈를 조기에 감지할 수 있습니다.