Skip to content
Published on

Keycloak + Spring Security 6 통합 — Resource Server와 OAuth2 Client 실전

Authors

들어가며

한때 표준이었던 Keycloak Spring 어댑터(keycloak-spring-boot-starter)는 이미 수년 전 deprecated 되어 더 이상 선택지가 아닙니다. 2026년 현재의 정답은 명확합니다. Spring Security 6의 표준 OAuth2 스택으로 Keycloak을 평범한 OIDC Provider로 다루는 것입니다. 표준 스택은 Keycloak 버전 업그레이드에 영향을 받지 않고, Spring Boot 3.x의 자동 설정과 자연스럽게 맞물립니다.

Keycloak 26.6은 FAPI 2.0 Security Profile Final, EdDSA 서명, JWT Authorization Grant 등 토큰 발급 측의 표준 준수를 한층 강화했고, OAuth 2.1 draft가 사실상의 베스트 프랙티스로 자리 잡으면서 PKCE 의무화·Implicit 제거 같은 원칙이 Spring 기본값과도 일치하게 되었습니다. 이 글은 Spring Boot 3.4 + Spring Security 6.4 기준으로, Keycloak 통합의 전 과정을 코드 중심으로 정리합니다.

Resource Server vs OAuth2 Client — 무엇을 쓸 것인가

가장 먼저 정리할 개념은 두 스타터의 역할 차이입니다.

구분oauth2-resource-serveroauth2-client
역할API 서버 (토큰 검증)웹 앱 (로그인 주체)
인증 흐름Bearer 토큰 수신·검증Authorization Code + PKCE
세션무상태(stateless) 권장세션 기반
토큰 보관보관 안 함OAuth2AuthorizedClientService
대표 사용처REST API, 마이크로서비스서버 렌더링 웹, BFF

판단 기준은 간단합니다. 브라우저를 Keycloak 로그인 페이지로 리다이렉트해야 하면 client, Authorization 헤더의 Bearer 토큰을 검증만 하면 resource server입니다. BFF(Backend for Frontend) 패턴이라면 한 애플리케이션이 둘 다일 수도 있습니다. 프론트 요청은 oauth2Login으로 받고, 다운스트림 API 호출에는 저장된 access token을 전파하는 구조입니다.

의존성은 다음과 같습니다.

dependencies {
    // API 서버라면
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    // 로그인 주체 웹 앱이라면
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

issuer-uri 기반 자동 설정

Spring Boot의 자동 설정은 issuer-uri 하나로 시작됩니다.

# Resource Server
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://kc.example.com/realms/myrealm

기동 시 Spring은 issuer-uri 뒤에 /.well-known/openid-configuration을 붙여 OIDC discovery 문서를 가져오고, 그 안의 jwks_uri로 서명 검증 키를 확보합니다. 이때 자동으로 구성되는 JwtDecoder에는 두 가지 검증기가 기본 장착됩니다.

  • 타임스탬프 검증: exp, nbf (기본 60초 clock skew 허용)
  • issuer 검증: iss 클레임이 issuer-uri와 정확히 일치하는지

여기서 실무 사고 1호가 나옵니다. 컨테이너 내부에서는 Keycloak을 내부 DNS로, 토큰의 iss는 외부 도메인으로 발급되는 환경이라면 iss 불일치로 모든 요청이 401이 됩니다. 해결은 issuer-uri와 jwk-set-uri를 분리하는 것입니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # iss 클레임 검증 기준 (토큰에 박힌 외부 도메인)
          issuer-uri: https://kc.example.com/realms/myrealm
          # 실제 키 조회는 내부 네트워크로
          jwk-set-uri: http://keycloak.internal:8080/realms/myrealm/protocol/openid-connect/certs

OAuth2 Client 쪽 설정은 registration과 provider 두 블록으로 구성됩니다.

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: web-app
            client-secret: WEB_APP_SECRET
            authorization-grant-type: authorization_code
            scope: openid, profile, email
        provider:
          keycloak:
            issuer-uri: https://kc.example.com/realms/myrealm

Spring Security 6는 public client에 대해 PKCE를 자동 적용하고, confidential client도 client-authentication-method 설정으로 PKCE를 병행할 수 있습니다. OAuth 2.1 방향과 일치하는 기본값입니다.

JwtDecoder 커스터마이징 — audience 검증 추가

자동 구성 JwtDecoder는 aud를 검증하지 않습니다. 이전 글에서 다룬 Audience mapper로 토큰에 aud를 넣었다면, 검증도 직접 추가해야 의미가 있습니다.

@Configuration
public class JwtDecoderConfig {

    @Bean
    JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
        String issuer = props.getJwt().getIssuerUri();
        NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);

        OAuth2TokenValidator<Jwt> issuerValidator =
                JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
                JwtClaimNames.AUD,
                aud -> aud != null && aud.contains("order-api"));

        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                issuerValidator, audienceValidator));
        return decoder;
    }
}

검증 실패 시 Spring은 401과 함께 WWW-Authenticate 헤더에 오류 상세를 담아 반환합니다. 디버깅할 때 응답 본문이 아니라 이 헤더를 봐야 한다는 점을 기억해 두면 시간을 아낄 수 있습니다.

Realm Role과 Client Role을 GrantedAuthority로 매핑

Keycloak 토큰의 롤 구조는 Spring이 기대하는 scope 기반 구조와 다릅니다. 토큰 페이로드를 보면 이렇습니다.

{
  "realm_access": {
    "roles": ["platform-admin", "offline_access"]
  },
  "resource_access": {
    "order-api": {
      "roles": ["order-viewer", "order-editor"]
    }
  },
  "scope": "openid profile email"
}

기본 JwtAuthenticationConverter는 scope 클레임만 읽어 SCOPE_openid 같은 권한을 만들 뿐, realm_access와 resource_access는 무시합니다. 그래서 hasRole 체크가 전부 403으로 떨어지는 것이 Keycloak + Spring 입문자의 단골 증상입니다. 전체 변환기 코드는 다음과 같습니다.

public class KeycloakJwtAuthenticationConverter
        implements Converter<Jwt, AbstractAuthenticationToken> {

    private static final String CLIENT_ID = "order-api";

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = Stream.concat(
                realmRoles(jwt), clientRoles(jwt, CLIENT_ID))
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .collect(Collectors.toSet());

        String principalName = jwt.getClaimAsString("preferred_username");
        return new JwtAuthenticationToken(jwt, authorities, principalName);
    }

    private Stream<String> realmRoles(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess == null) {
            return Stream.empty();
        }
        Object roles = realmAccess.get("roles");
        return roles instanceof Collection<?> c
                ? c.stream().map(Object::toString) : Stream.empty();
    }

    @SuppressWarnings("unchecked")
    private Stream<String> clientRoles(Jwt jwt, String clientId) {
        Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
        if (resourceAccess == null) {
            return Stream.empty();
        }
        Object client = resourceAccess.get(clientId);
        if (!(client instanceof Map)) {
            return Stream.empty();
        }
        Object roles = ((Map<String, Object>) client).get("roles");
        return roles instanceof Collection<?> c
                ? c.stream().map(Object::toString) : Stream.empty();
    }
}

SecurityFilterChain에 연결합니다.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("platform-admin")
                .anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(jwt ->
                jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter())));
        return http.build();
    }
}

한 가지 설계 결정이 필요합니다. realm role과 client role의 prefix를 어떻게 구분할 것인가입니다. 둘 다 ROLE_ prefix로 합치면 이름 충돌 가능성이 있으므로, 규모가 크다면 client role에 ROLE_CLIENT_ 같은 별도 prefix를 주거나 realm role만 사용하는 정책으로 단순화하는 것이 운영상 유리합니다.

Method Security

클래스 레벨 @EnableMethodSecurity를 켰다면 서비스 계층에서 선언적으로 인가를 표현할 수 있습니다.

@Service
public class OrderService {

    @PreAuthorize("hasRole('order-viewer')")
    public List<OrderSummary> list(String dept) { ... }

    @PreAuthorize("hasRole('order-editor') and #order.dept == authentication.token.claims['dept']")
    public Order create(Order order) { ... }

    @PostAuthorize("returnObject.ownerId == authentication.name")
    public Order findOne(Long id) { ... }
}

두 번째 예시처럼 SpEL에서 JWT 클레임을 직접 참조하면, 이전 글에서 설계한 dept 클레임이 곧바로 데이터 범위 인가(row-level authorization)의 입력이 됩니다. 다만 SpEL이 복잡해지면 테스트와 추적이 어려워지므로, 세 줄을 넘는 조건은 커스텀 AuthorizationManager 빈으로 추출하는 것을 권합니다.

세션 기반 oauth2Login과 OIDC 로그아웃

서버 렌더링 웹 앱이라면 oauth2Login과 RP-Initiated Logout을 함께 구성합니다.

@Configuration
@EnableWebSecurity
public class WebAppSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
            ClientRegistrationRepository registrations) throws Exception {

        OidcClientInitiatedLogoutSuccessHandler logoutHandler =
                new OidcClientInitiatedLogoutSuccessHandler(registrations);
        logoutHandler.setPostLogoutRedirectUri("{baseUrl}/logged-out");

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/logged-out").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(Customizer.withDefaults())
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));
        return http.build();
    }
}

여기서 자주 발생하는 운영 이슈 두 가지입니다.

  • 로컬 로그아웃만 되는 문제: logoutSuccessHandler 없이 기본 logout만 쓰면 Spring 세션만 끊기고 Keycloak SSO 세션은 살아 있어, 다음 요청에서 즉시 자동 재로그인됩니다. 반드시 OidcClientInitiatedLogoutSuccessHandler로 Keycloak의 end_session_endpoint까지 흐름을 이어야 합니다.
  • post logout redirect 거부: Keycloak 클라이언트 설정의 Valid post logout redirect URIs에 리다이렉트 대상이 등록되지 않으면 Keycloak이 오류 페이지를 띄웁니다. 배포 도메인 변경 시 빠뜨리기 쉬운 항목입니다.

역방향 로그아웃도 고려해야 합니다. Keycloak 어드민이 세션을 강제 종료했을 때 Spring 세션을 함께 정리하려면 OIDC Back-Channel Logout을 사용합니다. Spring Security 6.2부터 oidcLogout DSL로 지원되며, Keycloak 클라이언트 설정의 Backchannel logout URL에 Spring이 노출하는 엔드포인트를 등록하면 됩니다.

http.oidcLogout(logout -> logout.backChannel(Customizer.withDefaults()));

멀티테넌트 — 여러 Realm의 토큰을 한 API에서 받기

B2B SaaS에서 테넌트마다 realm을 분리했다면, 리소스 서버는 여러 issuer의 토큰을 동시에 검증해야 합니다. Spring Security가 제공하는 JwtIssuerAuthenticationManagerResolver가 정석입니다.

@Configuration
public class MultiTenantSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 허용된 issuer 목록 기반 — 신뢰 목록 방식
        JwtIssuerAuthenticationManagerResolver resolver =
                JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
                        "https://kc.example.com/realms/tenant-a",
                        "https://kc.example.com/realms/tenant-b");

        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.authenticationManagerResolver(resolver));
        return http.build();
    }
}

issuer별 AuthenticationManager는 첫 요청 시 lazy 하게 만들어져 캐시됩니다. 테넌트가 동적으로 늘어나는 환경이라면 trusted issuer 목록을 DB에서 읽는 커스텀 resolver를 구현하되, iss 클레임 값을 그대로 신뢰해 discovery를 수행하면 안 됩니다. 공격자가 자신의 가짜 issuer를 iss에 넣은 토큰을 보내는 SSRF·토큰 위조 벡터가 되기 때문에, 반드시 사전 등록된 허용 목록과 대조해야 합니다.

WebFlux 대응

리액티브 스택에서는 타입만 바뀌고 개념은 동일합니다. 변환기는 Reactive 어댑터로 감쌉니다.

@Configuration
@EnableWebFluxSecurity
public class ReactiveSecurityConfig {

    @Bean
    SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
        ReactiveJwtAuthenticationConverterAdapter converter =
                new ReactiveJwtAuthenticationConverterAdapter(
                        new KeycloakJwtAuthenticationConverter());

        http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .authorizeExchange(ex -> ex
                .pathMatchers("/actuator/health/**").permitAll()
                .anyExchange().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(jwt ->
                jwt.jwtAuthenticationConverter(converter)));
        return http.build();
    }
}

Spring Cloud Gateway를 BFF로 쓰는 경우에는 oauth2-client + TokenRelay 필터 조합으로 세션의 access token을 다운스트림에 전파하는 패턴이 표준입니다.

spring:
  cloud:
    gateway:
      default-filters:
        - TokenRelay=
      routes:
        - id: order-api
          uri: http://order-api.internal:8080
          predicates:
            - Path=/api/orders/**

테스트 — spring-security-test와 Mock JWT

통합 인가 로직은 Keycloak 없이 테스트할 수 있어야 CI가 빠르고 안정적입니다. spring-security-test의 jwt() 포스트 프로세서가 핵심 도구입니다.

@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;

    @Test
    void adminEndpointRequiresPlatformAdmin() throws Exception {
        mockMvc.perform(get("/api/admin/stats")
                .with(jwt()
                    .jwt(j -> j.claim("preferred_username", "alice")
                               .claim("dept", "platform"))
                    .authorities(new SimpleGrantedAuthority("ROLE_platform-admin"))))
            .andExpect(status().isOk());
    }

    @Test
    void viewerCannotCreateOrder() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_order-viewer"))))
            .andExpect(status().isForbidden());
    }
}

여기서 주의할 점은 jwt() 포스트 프로세서의 authorities를 직접 지정하면 커스텀 변환기를 우회한다는 것입니다. 변환기 자체의 회귀를 잡으려면 raw 클레임만 넣고 변환기를 통과시키는 테스트를 별도로 둡니다.

@Test
void converterMapsRealmAndClientRoles() {
    Jwt jwt = Jwt.withTokenValue("t")
        .header("alg", "RS256")
        .claim("realm_access", Map.of("roles", List.of("platform-admin")))
        .claim("resource_access", Map.of("order-api",
                Map.of("roles", List.of("order-editor"))))
        .claim("preferred_username", "alice")
        .build();

    var token = new KeycloakJwtAuthenticationConverter().convert(jwt);

    assertThat(token.getAuthorities())
        .extracting(GrantedAuthority::getAuthority)
        .containsExactlyInAnyOrder("ROLE_platform-admin", "ROLE_order-editor");
}

진짜 Keycloak과의 계약을 검증하고 싶다면 Testcontainers의 Keycloak 모듈로 realm export JSON을 로드해 E2E 단계에서 돌리는 구성을 추천합니다. 단위·슬라이스 테스트는 mock JWT, 야간 파이프라인은 Testcontainers로 역할을 나누는 것입니다.

트러블슈팅 — 401/403 디버깅 체크리스트

마지막으로 현장에서 가장 많이 만나는 증상별 원인을 정리합니다.

증상가장 흔한 원인확인 방법
모든 요청 401iss 불일치 (내부/외부 URL 혼용)토큰 iss와 issuer-uri 비교
모든 요청 401aud 검증 실패 (mapper 누락)WWW-Authenticate 헤더 확인
간헐적 401서버 시계 드리프트, 키 롤테이션 직후exp/iat와 서버 시각 비교
인증은 되는데 403롤 변환기 미적용, prefix 불일치authorities 로그 출력
403 + CSRF 오류stateless API에 CSRF 활성POST만 403인지 확인
로그아웃 후 자동 재로그인RP-Initiated Logout 미구성Keycloak 세션 잔존 확인

디버깅의 첫 단계는 언제나 같습니다. 보안 로그를 켜는 것입니다.

logging:
  level:
    org.springframework.security: TRACE
    org.springframework.security.oauth2: TRACE

TRACE 레벨에서는 어떤 필터가 요청을 거부했는지, JwtDecoder가 어떤 검증기에서 실패했는지, 변환된 authorities가 무엇인지 전부 출력됩니다. 401은 인증(토큰 자체)의 문제, 403은 인가(권한 매핑)의 문제라는 구분만 확실히 해도 원인 탐색 범위가 절반으로 줄어듭니다. 401이면 토큰을 디코드해 iss·aud·exp를 보고, 403이면 변환기와 hasRole 표현식의 대소문자·prefix를 보는 것이 정석 순서입니다.

마치며

Spring Security 6 표준 스택으로 Keycloak을 다루면 어댑터 시절의 버전 종속 문제에서 완전히 벗어날 수 있습니다. 핵심을 요약합니다.

  • 토큰을 검증만 하면 resource server, 로그인을 주도하면 oauth2-client. BFF는 둘의 조합입니다.
  • issuer-uri 자동 설정은 iss 검증까지만 해 줍니다. aud 검증은 JwtDecoder 커스터마이징으로 직접 추가합니다.
  • realm_access / resource_access 롤은 커스텀 JwtAuthenticationConverter로 GrantedAuthority에 매핑해야 hasRole이 동작합니다.
  • 로그아웃은 RP-Initiated Logout과 Back-Channel Logout을 양방향으로 구성합니다.
  • 멀티테넌트는 trusted issuer 허용 목록 기반 resolver로, 테스트는 mock JWT와 Testcontainers의 이중 구조로 가져갑니다.

다음 글에서는 Keycloak의 Identity Brokering으로 소셜 로그인과 외부 IdP 연동을 구축하는 방법을 다룹니다.

참고 자료