Spring OAuth2 Resource Server란?
Spring Security OAuth2 Resource Server는 JWT 또는 Opaque Token을 검증하여 API를 보호하는 핵심 모듈입니다. Authorization Server가 발급한 토큰을 검증하고, 토큰에 담긴 클레임을 기반으로 인가 처리까지 수행합니다. 마이크로서비스 아키텍처에서 각 서비스가 독립적으로 토큰을 검증할 수 있어 무상태(stateless) 인증의 핵심 구성 요소입니다.
이 글에서는 JWT 검증 설정, 커스텀 클레임 매핑, 다중 인증 서버 지원, 메서드 레벨 인가, Opaque Token 인트로스펙션까지 실무에서 필요한 심화 패턴을 다룹니다.
JWT Resource Server 기본 설정
Spring Boot의 자동 설정으로 최소한의 코드로 JWT 검증을 시작할 수 있습니다.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
# 또는 직접 JWK Set URI 지정
# jwk-set-uri: https://auth.example.com/.well-known/jwks.json
@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)
)
.build();
}
}
issuer-uri를 설정하면 Spring은 자동으로 /.well-known/openid-configuration을 호출하여 JWK Set URI, 지원 알고리즘 등을 가져옵니다. 이 과정에서 issuer 검증도 자동 수행됩니다.
커스텀 클레임을 권한으로 매핑
기본적으로 Spring은 JWT의 scope 클레임을 SCOPE_ 접두사 권한으로 변환합니다. 실무에서는 roles, permissions 등 커스텀 클레임을 매핑해야 하는 경우가 대부분입니다.
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// scope 대신 roles 클레임 사용
authoritiesConverter.setAuthoritiesClaimName("roles");
// SCOPE_ 대신 ROLE_ 접두사
authoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
// JWT subject를 principal name으로
converter.setPrincipalClaimName("preferred_username");
return converter;
}
// SecurityFilterChain에 적용
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
복합 클레임 매핑 (roles + permissions 통합)
@Bean
public JwtAuthenticationConverter combinedAuthConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>();
// roles 클레임 → ROLE_ 접두사
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
}
// permissions 클레임 → 접두사 없이
List<String> permissions = jwt.getClaimAsStringList("permissions");
if (permissions != null) {
permissions.stream()
.map(SimpleGrantedAuthority::new)
.forEach(authorities::add);
}
// scope 클레임도 유지
List<String> scopes = jwt.getClaimAsStringList("scope");
if (scopes != null) {
scopes.stream()
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.forEach(authorities::add);
}
return authorities;
});
return converter;
}
이렇게 하면 @PreAuthorize("hasRole('ADMIN')"), @PreAuthorize("hasAuthority('order:write')"), hasAuthority('SCOPE_read')를 모두 사용할 수 있습니다.
다중 인증 서버 (Multi-Tenancy)
마이크로서비스가 여러 IdP(Keycloak, Auth0, 내부 인증 서버 등)의 토큰을 동시에 처리해야 하는 경우, JwtIssuerAuthenticationManagerResolver를 사용합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// issuer URI 기반 자동 라우팅
JwtIssuerAuthenticationManagerResolver resolver =
JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
"https://auth.example.com",
"https://keycloak.internal/realms/myapp",
"https://accounts.google.com"
);
return http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(resolver)
)
.build();
}
// 또는 issuer별 커스텀 AuthenticationManager
@Bean
public SecurityFilterChain customMultiTenant(HttpSecurity http) throws Exception {
Map<String, AuthenticationManager> managers = new HashMap<>();
// 내부 서버: 커스텀 클레임 매핑
JwtDecoder internalDecoder = JwtDecoders.fromIssuerLocation(
"https://auth.example.com");
JwtAuthenticationProvider internalProvider =
new JwtAuthenticationProvider(internalDecoder);
internalProvider.setJwtAuthenticationConverter(combinedAuthConverter());
managers.put("https://auth.example.com", internalProvider::authenticate);
// Keycloak: realm_access.roles 매핑
JwtDecoder keycloakDecoder = JwtDecoders.fromIssuerLocation(
"https://keycloak.internal/realms/myapp");
JwtAuthenticationProvider keycloakProvider =
new JwtAuthenticationProvider(keycloakDecoder);
keycloakProvider.setJwtAuthenticationConverter(keycloakAuthConverter());
managers.put("https://keycloak.internal/realms/myapp",
keycloakProvider::authenticate);
AuthenticationManagerResolver<HttpServletRequest> resolver = request -> {
String token = extractToken(request);
String issuer = parseIssuer(token);
return managers.getOrDefault(issuer,
auth -> { throw new AuthenticationException("Unknown issuer"); });
};
return http
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(resolver))
.build();
}
JWT의 iss 클레임을 파싱하여 해당 issuer의 AuthenticationManager로 라우팅합니다. 각 issuer별로 다른 클레임 구조, 다른 권한 매핑을 적용할 수 있어 멀티테넌트 환경에 유용합니다.
JWT 커스텀 검증 로직
기본 검증(서명, 만료, issuer) 외에 비즈니스 규칙 기반 추가 검증이 필요한 경우 커스텀 Validator를 구현합니다.
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(
"https://auth.example.com");
// 기본 검증 + 커스텀 검증 결합
OAuth2TokenValidator<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer("https://auth.example.com");
// audience 검증
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
List<String> audiences = token.getAudience();
if (audiences == null || !audiences.contains("my-api")) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_audience", "Missing required audience", null));
}
return OAuth2TokenValidatorResult.success();
};
// 커스텀 클레임 검증: tenant_id 필수
OAuth2TokenValidator<Jwt> tenantValidator = token -> {
String tenantId = token.getClaimAsString("tenant_id");
if (tenantId == null || tenantId.isBlank()) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("missing_tenant", "tenant_id claim required", null));
}
return OAuth2TokenValidatorResult.success();
};
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
defaultValidators, audienceValidator, tenantValidator
));
return decoder;
}
Spring Resilience4j 서킷브레이커와 함께 사용하면, 인증 서버 장애 시에도 캐시된 JWK로 토큰 검증을 계속할 수 있습니다.
메서드 레벨 인가 심화
@Configuration
@EnableMethodSecurity // Spring 6.x
public class MethodSecurityConfig {
// 커스텀 권한 평가자
@Bean("authz")
public CustomAuthorizationService authorizationService() {
return new CustomAuthorizationService();
}
}
@Component("authz")
public class CustomAuthorizationService {
public boolean isResourceOwner(Authentication auth, Long resourceId) {
Jwt jwt = (Jwt) auth.getPrincipal();
String userId = jwt.getClaimAsString("sub");
// DB 조회 등으로 소유권 확인
return resourceService.isOwner(resourceId, userId);
}
public boolean hasTenantAccess(Authentication auth, String tenantId) {
Jwt jwt = (Jwt) auth.getPrincipal();
String tokenTenant = jwt.getClaimAsString("tenant_id");
return tenantId.equals(tokenTenant);
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// 역할 기반
@GetMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('order:read')")
public List<OrderDto> listOrders() { ... }
// 소유권 기반
@GetMapping("/{id}")
@PreAuthorize("@authz.isResourceOwner(authentication, #id)")
public OrderDto getOrder(@PathVariable Long id) { ... }
// 복합 조건: 테넌트 + 권한
@PostMapping
@PreAuthorize("@authz.hasTenantAccess(authentication, #dto.tenantId) " +
"and hasAuthority('order:write')")
public OrderDto createOrder(@RequestBody CreateOrderDto dto) { ... }
// 응답 필터링
@GetMapping("/all")
@PostAuthorize("returnObject.tenantId == authentication.token.claims['tenant_id']")
public OrderDto getOrderFiltered(@RequestParam Long id) { ... }
}
Opaque Token 인트로스펙션
JWT 대신 Opaque Token(참조 토큰)을 사용하면 토큰 폐기(revocation)가 즉시 반영됩니다. 대신 매 요청마다 인증 서버에 인트로스펙션 호출이 발생하므로, 캐싱 전략이 필수입니다.
# application.yml
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth.example.com/oauth2/introspect
client-id: my-resource-server
client-secret: ${INTROSPECTION_SECRET}
// 캐싱 적용된 커스텀 인트로스펙터
@Bean
public OpaqueTokenIntrospector cachingIntrospector(
@Value("${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}")
String introspectionUri) {
OpaqueTokenIntrospector delegate = new SpringOpaqueTokenIntrospector(
introspectionUri, "my-resource-server", introspectionSecret);
Cache tokenCache = new ConcurrentMapCache("token-introspection");
return token -> {
Cache.ValueWrapper cached = tokenCache.get(token);
if (cached != null) {
return (OAuth2AuthenticatedPrincipal) cached.get();
}
OAuth2AuthenticatedPrincipal principal = delegate.introspect(token);
// active 토큰만 캐시, TTL은 토큰 만료 시간 기준
Instant exp = principal.getAttribute("exp");
if (exp != null && exp.isAfter(Instant.now())) {
tokenCache.put(token, principal);
}
return principal;
};
}
// SecurityFilterChain에서 opaque token 모드
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspector(cachingIntrospector(introspectionUri))
)
)
에러 응답 커스터마이징
인증/인가 실패 시 RFC 6750 규격에 맞는 일관된 에러 응답을 반환하는 것이 API 품질의 핵심입니다.
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
.authenticationEntryPoint((request, response, exception) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("""
{"error":"unauthorized","message":"%s","timestamp":"%s"}
""".formatted(exception.getMessage(), Instant.now()));
})
.accessDeniedHandler((request, response, exception) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("""
{"error":"forbidden","message":"Insufficient permissions","timestamp":"%s"}
""".formatted(Instant.now()));
})
)
운영 베스트 프랙티스
| 항목 | 권장 | 이유 |
|---|---|---|
| audience 검증 | 필수 | 다른 서비스용 토큰 오용 방지 |
| JWK 캐싱 | 기본 5분 | 인증 서버 부하 감소, 키 로테이션 대응 |
| 토큰 만료 시간 | 15분 이하 | 탈취 시 피해 최소화 |
| Refresh Token | Rotation 적용 | 재사용 탐지 가능 |
| 메서드 보안 | @EnableMethodSecurity | URL 패턴보다 정밀한 인가 |
| 에러 응답 | RFC 6750 준수 | 클라이언트 디버깅 편의 |
마무리
Spring OAuth2 Resource Server는 JWT/Opaque Token 검증, 커스텀 클레임 매핑, 다중 issuer 지원, 메서드 레벨 인가까지 유연하게 확장할 수 있습니다. Spring @Async 비동기 처리와 함께 사용할 때는 SecurityContext 전파 설정도 잊지 마세요. 핵심은 audience 검증과 최소 권한 원칙을 기본으로, 비즈니스에 맞는 커스텀 검증 로직을 추가하는 것입니다.