프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

Spring Authorization Server로 OAuth2 인증 서버 구현하기

매운할라피뇨 2026. 4. 23. 10:10
반응형

목차

  1. 개요
  2. Spring Authorization Server 아키텍처 이해
  3. 인증 서버 기본 구현
  4. JWT 토큰 커스터마이징과 클레임 설계
  5. 성능 특성과 대안 기술 비교
  6. 운영 환경 적용 시 고려사항
  7. 맺음말

개요

문제 배경

현대 분산 시스템 환경에서 OAuth2 인증 서버는 더 이상 선택이 아닌 필수 인프라로 자리잡고 있습니다. 마이크로서비스 아키텍처가 보편화되면서 각 서비스가 독립적으로 사용자 인증을 처리하던 방식은 보안 취약점과 운영 복잡도를 함께 키워 왔습니다. Spring Authorization Server는 이 문제를 해결하기 위해 Spring 생태계 내에서 공식적으로 지원하는 OAuth2 및 OpenID Connect 1.0 인증 서버 구현체입니다.
Spring 팀은 오랫동안 커뮤니티에서 광범위하게 사용되던 Spring Security OAuth 프로젝트를 2022년 말에 공식 종료(EOL)하였습니다. 그 후계자로 등장한 Spring Authorization Server는 단순한 대체재가 아니라, 최신 보안 표준과 Spring Security 6.x 아키텍처를 기반으로 완전히 새롭게 설계된 프레임워크입니다. Authorization Code Flow, Client Credentials Flow, Device Authorization Grant 등 다양한 OAuth2 표준 흐름을 지원하며, OpenID Connect 1.0의 UserInfo 엔드포인트와 Dynamic Client Registration도 포함합니다.
이 글에서는 Spring Authorization Server 1.x 버전을 기준으로 인증 서버의 전체 구현 과정을 다룹니다. 핵심 아키텍처 개념부터 JWT 토큰 커스터마이징, 운영 환경에서 마주치는 실제 문제까지 단계적으로 살펴봅니다.

기존 방식의 한계

Spring Security OAuth2(구버전)는 약 10년 이상 Java 생태계에서 OAuth2 인증 서버의 사실상 표준으로 사용되었습니다. 그러나 이 프로젝트는 설계 당시 OAuth2 명세가 완전히 성숙하기 전에 시작되었기 때문에 몇 가지 구조적인 한계를 안고 있었습니다. 첫째, Spring Security의 핵심 필터 체인과 강하게 결합되어 있어 커스터마이징이 어렵고 내부 동작을 파악하기 위해 많은 시간이 필요했습니다. 둘째, @EnableAuthorizationServer 애너테이션 기반의 설정 방식은 보일러플레이트 코드를 양산하고, 고급 기능을 추가할수록 설정 코드가 기하급수적으로 복잡해졌습니다. 셋째, PKCE(Proof Key for Code Exchange)나 Rich Authorization Requests 같은 최신 OAuth2 확장 스펙을 지원하지 않았습니다.

Keycloak, Okta, Auth0 같은 외부 IdP(Identity Provider) 솔루션이 대안이 될 수 있지만, 이들은 자체 인프라 운영 비용이나 SaaS 비용이 수반되고, Spring 애플리케이션과의 세밀한 통합에는 여전히 추가적인 작업이 필요합니다. Spring Authorization Server는 Java 개발팀이 완전한 제어권을 가지면서 Spring Boot 프로젝트에 자연스럽게 통합할 수 있는 중간 지점을 제공합니다.


Spring Authorization Server 아키텍처 이해

핵심 구성 요소

Spring Authorization Server의 아키텍처는 Spring Security의 필터 체인 위에 명확하게 레이어드된 구조를 가집니다. 내부를 이해하면 커스터마이징이 훨씬 수월해지므로, 주요 컴포넌트를 짚어볼 필요가 있습니다.

AuthorizationServerSecurityFilterChain 은 인증 서버 전용 엔드포인트(/oauth2/authorize, /oauth2/token, /oauth2/jwks 등)를 처리하는 별도의 보안 필터 체인입니다. 일반 애플리케이션의 SecurityFilterChain과 독립적으로 동작하므로, 두 체인의 보안 정책을 각자 설정할 수 있습니다. 이 분리는 Spring Security OAuth 구버전에서 자주 발생하던 필터 체인 충돌 문제를 원천적으로 제거합니다.

RegisteredClientRepository 는 OAuth2 클라이언트 정보를 저장하고 조회하는 인터페이스입니다. 인메모리(InMemoryRegisteredClientRepository)와 JDBC(JdbcRegisteredClientRepository) 두 가지 구현체가 기본 제공됩니다. 운영 환경에서는 당연히 JDBC 구현체를 사용하지만, 개발 초기에는 인메모리 구현체로 빠르게 시작할 수 있습니다. RegisteredClient 객체는 클라이언트 ID, 시크릿, 허용된 그랜트 타입, 리다이렉트 URI, 스코프 등을 캡슐화합니다.

OAuth2AuthorizationService 는 발급된 권한 부여(Authorization) 정보를 저장합니다. 인가 코드, 액세스 토큰, 리프레시 토큰의 생명주기를 관리하며, 토큰 유효성 검증 시 이 서비스를 통해 저장된 정보와 대조합니다. 마찬가지로 인메모리 및 JDBC 구현체가 제공됩니다.

OAuth2 프로토콜 흐름

Authorization Code Flow는 Spring Authorization Server가 처리하는 가장 일반적인 흐름이며, 보안 측면에서도 가장 권장되는 방식입니다. 흐름을 단계별로 살펴보면 다음과 같습니다. 사용자가 클라이언트 애플리케이션에 접근하면, 클라이언트는 code_challengecode_challenge_method를 포함한 PKCE 파라미터와 함께 인증 서버의 /oauth2/authorize 엔드포인트로 사용자를 리다이렉트합니다. 인증 서버는 사용자를 로그인 폼으로 보내고, 인증 성공 후 동의 화면을 표시합니다. 사용자가 동의하면 인증 서버는 단기 유효한 인가 코드를 생성하여 클라이언트의 리다이렉트 URI로 전달합니다. 클라이언트는 이 코드와 code_verifier/oauth2/token 엔드포인트에 제출하여 액세스 토큰을 교환합니다.

이 흐름에서 PKCE는 인가 코드 가로채기 공격(Authorization Code Interception Attack)을 방지하는 핵심 메커니즘입니다. Spring Authorization Server 1.0부터 퍼블릭 클라이언트(모바일 앱, SPA 등)에 대해 PKCE가 기본적으로 요구되며, 컨피덴셜 클라이언트에도 선택적으로 강제할 수 있습니다.

Spring Security와의 통합

Spring Authorization Server는 Spring Security의 SecurityFilterChain 빈 두 개를 병렬로 등록하는 방식으로 동작합니다. 첫 번째는 인증 서버 자체의 엔드포인트를 보호하는 체인이고, 두 번째는 나머지 애플리케이션 경로(로그인 폼, 사용자 정보 페이지 등)를 처리하는 체인입니다. @Order 애너테이션을 통해 두 체인의 우선순위를 명시적으로 지정해야 하며, 일반적으로 인증 서버 체인에 더 높은 우선순위(낮은 숫자)를 부여합니다. 이 설계 덕분에 인증 서버 기능과 리소스 서버 기능을 동일한 애플리케이션 내에서 함께 제공하는 "All-in-One" 구성도 가능합니다.


인증 서버 기본 구현

기본 설정 및 의존성

Spring Authorization Server 프로젝트를 시작하려면 spring-boot-starter-oauth2-authorization-server 의존성 하나로 필요한 대부분의 구성이 자동 처리됩니다. Spring Boot 3.1 이상과 Java 17 이상 환경을 기준으로 합니다. Gradle을 사용한다면 implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'를 추가하고, Maven이라면 해당 <dependency> 블록을 추가합니다. 이 스타터는 spring-security-oauth2-authorization-server 코어 라이브러리, Spring Security, Spring Web 등 필수 의존성을 함께 가져옵니다.

application.yml에서 기본적인 포트와 로깅 설정을 마친 후, 실제 인증 서버 동작은 Java 설정 클래스에서 모두 이루어집니다. Spring Boot의 자동 구성은 최소한의 기본값을 제공하지만, 프로덕션 수준의 인증 서버를 구성하려면 명시적인 빈 정의가 필요합니다. 특히 RegisteredClientRepository, JWKSource, AuthorizationServerSettings는 반드시 직접 정의해야 하는 핵심 빈입니다.

아래 코드 예제는 Authorization Code Flow와 Client Credentials Flow를 지원하는 기본 인증 서버 설정입니다. SecurityFilterChain 빈 두 개를 각각 @Order로 구분하여 등록하며, 클라이언트 정보는 개발 편의를 위해 인메모리로 설정합니다.

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    // 1. 인증 서버 전용 보안 필터 체인 (높은 우선순위)
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {

        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            // OIDC 활성화: /userinfo 엔드포인트 노출
            .oidc(Customizer.withDefaults());

        http
            // 미인증 접근 시 로그인 페이지로 리다이렉트
            .exceptionHandling(exceptions -> exceptions
                .defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                )
            )
            // 리소스 서버로 동작: /userinfo 엔드포인트 보호
            .oauth2ResourceServer(resourceServer ->
                resourceServer.jwt(Customizer.withDefaults())
            );

        return http.build();
    }

    // 2. 일반 애플리케이션 보안 필터 체인 (낮은 우선순위)
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults()); // 기본 로그인 폼 활성화

        return http.build();
    }

    // 3. 클라이언트 등록 정보 (개발/테스트용 인메모리)
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient webClient = RegisteredClient
            .withId(UUID.randomUUID().toString())
            .clientId("my-web-client")
            .clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("secret"))
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("http://localhost:8080/login/oauth2/code/my-web-client")
            .postLogoutRedirectUri("http://localhost:8080/")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("read")
            .clientSettings(ClientSettings.builder()
                .requireAuthorizationConsent(true)   // 동의 화면 표시
                .requireProofKey(true)               // PKCE 강제
                .build())
            .build();

        return new InMemoryRegisteredClientRepository(webClient);
    }

    // 4. 사용자 저장소 (개발용 인메모리)
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.builder()
            .username("user")
            .password("{bcrypt}" + new BCryptPasswordEncoder().encode("password"))
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(user);
    }

    // 5. 인증 서버 메타데이터 설정
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("http://localhost:9000") // 토큰 발급자 URI
            .build();
    }
}

애플리케이션을 기동한 후 http://localhost:9000/.well-known/oauth-authorization-server에 접근하면 다음과 유사한 메타데이터 응답을 확인할 수 있습니다.

{
  "issuer": "http://localhost:9000",
  "authorization_endpoint": "http://localhost:9000/oauth2/authorize",
  "token_endpoint": "http://localhost:9000/oauth2/token",
  "jwks_uri": "http://localhost:9000/oauth2/jwks",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]
}

/.well-known 엔드포인트가 정상적으로 응답하고, jwks_uri가 포함되어 있다면 기본 설정이 완료된 것입니다. 이 메타데이터는 리소스 서버가 자동으로 인증 서버를 발견(Discovery)하는 데 사용되므로, issuer 값을 정확히 설정하는 것이 매우 중요합니다.

핵심 구현 포인트

320x100

applyDefaultSecurity(http) 메서드 한 줄이 /oauth2/authorize, /oauth2/token, /oauth2/jwks, /oauth2/revoke, /oauth2/introspect 등 모든 표준 엔드포인트를 한 번에 등록합니다. 이 편의 메서드는 내부적으로 OAuth2AuthorizationServerConfigurerHttpSecurity에 적용하는데, 각 엔드포인트는 개별적으로 비활성화하거나 커스터마이징할 수 있습니다. requireProofKey(true) 설정은 해당 클라이언트가 반드시 PKCE를 사용하도록 강제하며, 퍼블릭 클라이언트(SPA, 모바일 앱)에는 항상 활성화하는 것을 권장합니다.


JWT 토큰 커스터마이징과 클레임 설계

JWK 소스 설정

Spring Authorization Server는 기본적으로 RSA 키 쌍을 사용하여 JWT를 서명합니다. 서명 키 관리는 보안의 핵심이므로 신중하게 설계해야 합니다. 개발 환경에서는 애플리케이션 기동 시마다 새로운 RSA 키 쌍을 생성하는 방식이 간편하지만, 운영 환경에서는 서버 재기동 후에도 동일한 키를 유지해야 합니다. 그렇지 않으면 재기동 전에 발급된 토큰이 무효화되어 사용자 세션이 강제로 종료되는 문제가 발생합니다.

운영 환경을 위한 권장 방식은 외부 Key Store(예: Java KeyStore 파일, AWS KMS, HashiCorp Vault)에서 키를 로드하는 것입니다. Spring Authorization Server는 JWKSource<SecurityContext> 인터페이스를 통해 키 공급 방식을 유연하게 교체할 수 있습니다. 아래 예제는 RSA 키 쌍을 생성하고 JWKSet으로 래핑하는 표준적인 방법을 보여줍니다.

@Configuration
public class JwkConfig {

    // JWK 소스: 서명 키 공급
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsaKey();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    // JWT 디코더: 리소스 서버가 토큰 검증에 사용 (All-in-One 구성 시)
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    private RSAKey generateRsaKey() {
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048); // 최소 2048비트 권장
            KeyPair keyPair = keyPairGenerator.generateKeyPair();
            RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
            RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

            return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString()) // 키 식별자
                .build();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("RSA 키 생성 실패", e);
        }
    }
}

/oauth2/jwks 엔드포인트에서 공개 키를 노출하면 다음과 같은 JSON 응답을 반환합니다.

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "a1b2c3d4-...",
      "n": "0vx7agoebGcQ...",
      "use": "sig",
      "alg": "RS256"
    }
  ]
}

kid(Key ID) 필드는 리소스 서버가 어떤 공개 키로 토큰을 검증할지 식별하는 데 사용됩니다. 키 교체(Key Rotation) 시에는 새로운 키 쌍을 JWKSet에 추가하고 이전 키는 일정 기간 유지한 뒤 제거하는 방식으로 무중단 키 교체를 구현할 수 있습니다.

토큰 클레임 커스터마이징

기본적으로 발급되는 JWT에는 sub, iss, iat, exp, scope 등의 표준 클레임만 포함됩니다. 현업 프로젝트에서는 리소스 서버가 별도의 DB 조회 없이 토큰만으로 사용자 정보를 파악할 수 있도록 커스텀 클레임을 추가하는 경우가 많습니다. Spring Authorization Server는 OAuth2TokenCustomizer<JwtEncodingContext> 인터페이스를 통해 이 기능을 제공합니다.

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
    return context -> {
        // 액세스 토큰에만 커스텀 클레임 추가
        if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
            Authentication principal = context.getPrincipal();

            // 사용자 정보 조회 (예: DB에서 추가 정보 로드)
            if (principal instanceof UsernamePasswordAuthenticationToken auth) {
                UserDetails userDetails = (UserDetails) auth.getPrincipal();

                // 커스텀 클레임 추가
                context.getClaims().claim("username", userDetails.getUsername());
                context.getClaims().claim("roles",
                    userDetails.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority)
                        .collect(Collectors.toSet())
                );
                // 예: 테넌트 ID, 조직 코드 등 비즈니스 클레임
                context.getClaims().claim("tenant_id", "tenant-001");
            }
        }
    };
}

커스터마이저가 적용된 후 디코딩한 JWT 페이로드 예시입니다.

{
  "sub": "user",
  "iss": "http://localhost:9000",
  "iat": 1745000000,
  "exp": 1745003600,
  "scope": "openid profile read",
  "username": "user",
  "roles": ["ROLE_USER"],
  "tenant_id": "tenant-001"
}

OAuth2TokenCustomizer는 토큰 타입(ACCESS_TOKEN, REFRESH_TOKEN, ID_TOKEN)별로 분기하여 각각 다른 클레임 집합을 설정할 수 있습니다. 단, ID 토큰에 민감한 정보를 포함하지 않도록 주의해야 합니다. ID 토큰은 클라이언트 측에서 직접 디코딩되어 사용자 정보를 표시하는 데 쓰이므로, 내부 시스템 식별자나 권한 정보를 ID 토큰에 담는 것은 보안상 위험할 수 있습니다.

토큰 유효 기간 설정

토큰 유효 기간은 보안과 사용성 사이의 균형을 신중하게 조율해야 하는 설정입니다. 액세스 토큰의 유효 기간이 너무 길면 탈취된 토큰이 오랫동안 악용될 수 있고, 너무 짧으면 잦은 토큰 재발급으로 사용자 경험이 저하됩니다. Spring Authorization Server에서는 클라이언트 등록 시 TokenSettings를 통해 유효 기간을 세밀하게 조정할 수 있습니다. 일반적으로 액세스 토큰은 5

15분, 리프레시 토큰은 7

30일로 설정하며, 리프레시 토큰은 reuseRefreshTokens(false) 옵션을 통해 재사용을 방지하는 것이 보안상 권장됩니다.


성능 특성과 대안 기술 비교

성능 특성

Spring Authorization Server를 단독으로 운영하는 인증 서버로 구성할 경우, 일반적인 Spring Boot 애플리케이션과 동일한 성능 특성을 보입니다. /oauth2/token 엔드포인트 처리 시간은 주로 두 가지 요소에 의해 결정됩니다. 첫째는 비밀번호 해시 알고리즘 비용입니다. BCryptPasswordEncoder의 기본 strength 값인 10은 단일 검증에 약 100ms가 소요됩니다. 이는 브루트포스 공격을 방어하기 위한 의도적인 지연이지만, 트래픽이 집중되는 경우 토큰 엔드포인트의 처리량을 제한하는 요인이 됩니다. strength를 낮추면 처리량은 개선되지만 보안이 약화되므로, 높은 처리량이 필요한 시스템에서는 Argon2PasswordEncoder처럼 메모리 하드 알고리즘을 검토하거나 Client Credentials Flow에서는 API 키 방식으로 전환하는 것도 고려할 수 있습니다.

둘째는 OAuth2AuthorizationService의 저장소 성능입니다. 인메모리 구현체는 초당 수천 건의 요청을 처리할 수 있지만, JDBC 구현체는 매 토큰 발급 및 검증 요청마다 DB를 조회합니다. 토큰 검증 빈도가 높은 환경에서는 Redis 기반의 커스텀 OAuth2AuthorizationService 구현체를 도입하여 응답 시간을 수 밀리초 수준으로 줄일 수 있습니다. Spring Authorization Server는 이 인터페이스를 직접 구현하여 빈으로 등록하는 것을 지원하므로, 저장소 교체가 비교적 간단합니다.

대안 기술과 비교

인증 서버를 자체 구축할지, 외부 솔루션을 도입할지는 팀의 역량과 비즈니스 요구사항에 따라 달라집니다. Keycloak은 가장 널리 사용되는 오픈소스 IdP로, 강력한 관리자 UI, 소셜 로그인, MFA, LDAP/AD 연동 등 풍부한 기능을 즉시 사용할 수 있습니다. 그러나 Keycloak은 JBoss(WildFly) 기반의 독립적인 런타임을 갖추고 있어 운영 복잡도가 높고, Spring 애플리케이션과의 세밀한 통합에는 추가 어댑터 개발이 필요합니다. Okta, Auth0 같은 SaaS 솔루션은 운영 부담을 완전히 외부에 위임할 수 있지만, 요청량 기반의 과금 구조는 트래픽이 많은 시스템에서 상당한 비용이 됩니다.
Spring Authorization Server는 이 스펙트럼에서 "개발자가 완전히 제어하는 경량 인증 서버" 포지션을 차지합니다. 관리자 UI는 없지만 코드 레벨에서 모든 동작을 커스터마이징할 수 있고, 기존 Spring Boot 애플리케이션과 동일한 배포 파이프라인을 사용할 수 있습니다. 팀이 이미 Spring 생태계에 숙련되어 있고, 복잡한 LDAP 연동이나 소셜 로그인이 초기 요구사항이 아니라면 Spring Authorization Server가 가장 빠르게 생산성을 발휘할 수 있는 선택입니다.

어떤 상황에서 선택할 것인가

Spring Authorization Server 도입을 적극 권장할 수 있는 상황은 명확합니다. 첫째, 모든 서비스가 Java/Spring으로 구성된 마이크로서비스 환경에서 통합 인증 서버가 필요한 경우입니다. 둘째, 토큰 클레임, 동의 화면, 인가 정책 등을 비즈니스 로직에 맞게 세밀하게 제어해야 하는 경우입니다. 셋째, 외부 IdP에 대한 의존성을 최소화하고 인증 서버를 애플리케이션 코드베이스 내에서 관리하고 싶은 경우입니다. 반면, 엔터프라이즈 SSO, SAML 2.0 연동, 복잡한 MFA 플로우가 첫 날부터 필요하다면 Keycloak이나 클라우드 IdP가 더 현실적인 선택일 수 있습니다.


운영 환경 적용 시 고려사항

흔한 실수와 함정

Spring Authorization Server를 처음 도입하는 팀이 가장 많이 겪는 문제는 CORS 설정 누락입니다. SPA(Single Page Application)에서 토큰 엔드포인트를 직접 호출하거나, /oauth2/authorize 리다이렉트 과정에서 CORS 오류가 발생하는 경우가 빈번합니다. 인증 서버 전용 SecurityFilterChain은 기본적으로 CORS 처리를 하지 않으므로, CorsConfigurationSource 빈을 등록하고 해당 체인에 .cors(Customizer.withDefaults())를 명시적으로 추가해야 합니다.

두 번째로 흔한 실수는 issuer URI 불일치입니다. AuthorizationServerSettings에 설정한 issuer 값과 리소스 서버의 spring.security.oauth2.resourceserver.jwt.issuer-uri 값이 정확히 일치해야 합니다. 개발 환경과 운영 환경에서 호스트명이 다를 경우 환경 변수로 분리하지 않으면 리소스 서버의 토큰 검증이 실패하는 문제가 발생합니다. 특히 리버스 프록시(Nginx, API Gateway) 뒤에 인증 서버를 배치할 때 외부에서 접근하는 URL과 내부 URL이 달라지는 상황을 반드시 고려해야 합니다.

세 번째 함정은 클라이언트 시크릿 저장 방식입니다. RegisteredClientclientSecret은 반드시 {bcrypt} 프리픽스와 함께 암호화된 형태로 저장해야 합니다. 평문으로 저장하면 Spring Security가 인증 시 매칭에 실패합니다. JDBC 구현체를 사용할 경우 DB에도 암호화된 값이 저장되어야 하며, 마이그레이션 시 기존 클라이언트의 시크릿을 재설정해야 하는 경우 클라이언트 애플리케이션과의 사전 협의가 필요합니다.

모니터링과 디버깅

운영 환경에서 인증 서버의 건강 상태를 파악하기 위해 모니터링해야 할 핵심 지표가 있습니다. 토큰 발급 실패율은 클라이언트 설정 오류나 사용자 인증 실패를 나타내며, management.endpoints.web.exposure.include=health,metrics로 Actuator를 활성화하면 Spring Boot의 표준 메트릭으로 수집할 수 있습니다. Micrometer를 통해 Prometheus/Grafana로 시각화하면 실시간으로 이상 징후를 탐지할 수 있습니다.

토큰 저장소 크기도 중요한 지표입니다. OAuth2AuthorizationService의 JDBC 구현체를 사용하는 경우, oauth2_authorization 테이블이 만료된 토큰 레코드로 지속적으로 증가합니다. Spring Authorization Server는 자동 만료 레코드 정리 기능을 내장하지 않으므로, 별도의 배치 작업이나 DB 스케줄러를 통해 주기적으로 만료된 레코드를 정리해야 합니다. 이를 방치하면 테이블이 수천만 건 이상으로 증가하여 토큰 조회 성능이 저하될 수 있습니다.

인증 서버의 디버깅을 위해서는 logging.level.org.springframework.security=TRACE 설정이 강력한 도구가 됩니다. 그러나 이 설정은 토큰 값과 같은 민감한 정보를 로그에 노출할 수 있으므로, 운영 환경에서는 DEBUG 레벨 이상으로 유지하고 개발/스테이징 환경에서만 TRACE를 활성화해야 합니다.

확장 및 마이그레이션

인증 서버를 단일 인스턴스에서 다중 인스턴스로 수평 확장할 때는 세션 상태와 키 관리에 특별한 주의가 필요합니다. Authorization Code Flow에서 인가 코드는 서버 세션에 저장되므로, 다중 인스턴스 환경에서는 Redis나 DB 기반의 세션 저장소를 사용하거나 스티키 세션(Sticky Session)을 구성해야 합니다. Spring Session을 함께 도입하면 Redis 기반 세션 공유를 간편하게 구성할 수 있습니다.
서명 키는 모든 인스턴스가 동일한 키를 사용해야 합니다. 인스턴스마다 독립적으로 키를 생성하면 한 인스턴스가 발급한 토큰을 다른 인스턴스에서 검증하지 못하는 문제가 발생합니다. 이를 해결하는 가장 일반적인 방법은 키를 외부 시크릿 저장소(AWS Secrets Manager, HashiCorp Vault)에 저장하고 모든 인스턴스가 기동 시 동일한 키를 로드하도록 구성하는 것입니다. Spring Cloud Vault나 AWS SDK를 통해 이 연동을 구현할 수 있습니다.
Spring Security OAuth(구버전)에서 Spring Authorization Server로 마이그레이션할 때는 가장 먼저 클라이언트 등록 데이터 스키마를 변환해야 합니다. 두 버전의 DB 스키마가 다르므로, 공식 문서에서 제공하는 DDL 스크립트를 기준으로 새 테이블을 생성하고 기존 데이터를 매핑하는 마이그레이션 스크립트를 작성해야 합니다. Flyway나 Liquibase를 사용한다면 마이그레이션 버전 관리가 용이합니다.


맺음말

핵심 요약

이 글에서는 Spring Authorization Server를 활용하여 OAuth2 인증 서버를 구현하는 전체 과정을 다루었습니다. 핵심 아키텍처인 AuthorizationServerSecurityFilterChain, RegisteredClientRepository, OAuth2AuthorizationService의 역할과 관계를 이해하는 것이 효과적인 커스터마이징의 출발점입니다. 기본 설정에서는 두 개의 SecurityFilterChain@Order로 구분하고, 클라이언트 등록 시 PKCE를 강제하는 것이 현대적인 보안 요구사항을 충족하는 방법입니다. JWT 토큰 커스터마이징에서는 OAuth2TokenCustomizer를 통해 비즈니스에 필요한 클레임을 추가하되, ID 토큰에는 민감한 내부 정보를 포함하지 않는 원칙을 지켜야 합니다.

적용 판단 기준

Spring Authorization Server는 모든 상황에 최적인 솔루션이 아닙니다. Java/Spring 기반 팀이 자체 제어와 커스터마이징 유연성을 우선시하는 환경에서 가장 빛을 발합니다. 팀 내에 Spring Security에 대한 깊은 이해가 있고, 인증 서버의 동작을 코드 수준에서 직접 관리하고자 한다면 강력히 권장할 수 있는 선택입니다. 반면, 빠른 도입과 풍부한 기능이 우선이라면 Keycloak을 검토하고, 운영 부담 최소화가 중요하다면 관리형 SaaS 솔루션이 더 적합할 수 있습니다.

다음 단계

Spring Authorization Server를 더 깊이 활용하려면 몇 가지 심화 주제를 탐구할 것을 권장합니다. 첫째, Device Authorization Grant는 TV, IoT 디바이스 등 브라우저 입력이 어려운 환경에서 OAuth2 인증을 처리하는 흐름으로, Spring Authorization Server 1.1부터 정식 지원됩니다. 둘째, Token IntrospectionToken Revocation 엔드포인트를 활용하면 불투명 토큰(Opaque Token) 기반의 아키텍처를 구성할 수 있으며, 이는 토큰의 즉시 폐기가 필요한 금융, 의료 분야의 규정 준수 요구사항을 충족하는 데 중요합니다. 셋째, Federation을 통해 Google, Kakao 같은 외부 OAuth2 공급자와 연동하면 소셜 로그인을 자체 인증 서버 내에 통합할 수 있습니다. 공식 문서인 Spring Authorization Server Reference와 GitHub의 공식 샘플 저장소는 이 모든 기능의 예제 코드를 제공하는 신뢰할 수 있는 출발점입니다.

반응형