Spring Boot SSL Bundle 인증서 관리

Spring Boot SSL Bundle이란?

Spring Boot 3.1에서 도입된 SSL Bundle은 TLS/SSL 인증서를 중앙에서 관리하는 기능입니다. 기존에는 RestTemplate, WebClient, 내장 서버, 데이터소스 등 각각 별도로 SSL을 설정해야 했지만, SSL Bundle을 사용하면 한 곳에서 인증서를 정의하고 여러 곳에서 참조할 수 있습니다. mTLS(상호 인증), PEM/JKS/PKCS12 형식 모두 지원하며, 런타임 인증서 갱신(Reload)까지 가능합니다.

기본 SSL Bundle 설정

# application.yml — PEM 형식 인증서
spring:
  ssl:
    bundle:
      pem:
        # 번들 이름: server-cert
        server-cert:
          keystore:
            certificate: classpath:certs/server.crt
            private-key: classpath:certs/server.key
          truststore:
            certificate: classpath:certs/ca.crt

        # 번들 이름: client-cert (외부 API 호출용)
        client-cert:
          keystore:
            certificate: classpath:certs/client.crt
            private-key: classpath:certs/client.key
            private-key-password: ${CLIENT_KEY_PASSWORD}
          truststore:
            certificate: classpath:certs/external-ca.crt

      # JKS/PKCS12 형식
      jks:
        legacy-cert:
          keystore:
            location: classpath:certs/keystore.p12
            password: ${KEYSTORE_PASSWORD}
            type: PKCS12
          truststore:
            location: classpath:certs/truststore.jks
            password: ${TRUSTSTORE_PASSWORD}

내장 서버 HTTPS 적용

# 내장 Tomcat/Netty에 SSL Bundle 적용
server:
  port: 8443
  ssl:
    bundle: server-cert    # 위에서 정의한 번들 이름 참조
// 기존 방식 (Spring Boot 3.0 이전):
server:
  ssl:
    key-store: classpath:keystore.p12
    key-store-password: secret
    key-store-type: PKCS12
    trust-store: classpath:truststore.jks
    trust-store-password: secret

// SSL Bundle 방식 (Spring Boot 3.1+):
// → 한 줄로 끝. 같은 인증서를 다른 곳에서도 재사용 가능

RestClient + SSL Bundle: 외부 API mTLS 호출

@Configuration
public class HttpClientConfig {

    // RestClient에 SSL Bundle 적용
    @Bean
    public RestClient secureRestClient(
            RestClient.Builder builder,
            SslBundles sslBundles) {

        return builder
            .baseUrl("https://external-api.example.com")
            .apply(b -> b.requestFactory(
                ClientHttpRequestFactories.get(
                    ClientHttpRequestFactorySettings.DEFAULTS
                        .withSslBundle(sslBundles.getBundle("client-cert"))
                        .withConnectTimeout(Duration.ofSeconds(5))
                        .withReadTimeout(Duration.ofSeconds(30))
                )
            ))
            .build();
    }

    // WebClient에 SSL Bundle 적용 (WebFlux)
    @Bean
    public WebClient secureWebClient(
            WebClient.Builder builder,
            SslBundles sslBundles) {

        SslBundle bundle = sslBundles.getBundle("client-cert");
        HttpClient httpClient = HttpClient.create()
            .secure(spec -> spec.sslContext(bundle.createSslContext()));

        return builder
            .baseUrl("https://external-api.example.com")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }
}

DataSource SSL: DB 커넥션 암호화

# PostgreSQL SSL 커넥션
spring:
  datasource:
    url: jdbc:postgresql://db.example.com:5432/mydb?sslmode=verify-full
    username: app_user
    password: ${DB_PASSWORD}
    ssl:
      bundle: db-cert      # DB 인증서 번들 참조

  ssl:
    bundle:
      pem:
        db-cert:
          truststore:
            certificate: classpath:certs/db-ca.crt
          keystore:
            certificate: classpath:certs/db-client.crt
            private-key: classpath:certs/db-client.key
// HikariCP에 직접 SSL Bundle 적용
@Configuration
public class DataSourceSslConfig {

    @Bean
    public DataSource dataSource(
            DataSourceProperties properties,
            SslBundles sslBundles) {

        SslBundle bundle = sslBundles.getBundle("db-cert");
        SSLContext sslContext = bundle.createSslContext();

        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(properties.getUrl());
        config.setUsername(properties.getUsername());
        config.setPassword(properties.getPassword());

        // PostgreSQL JDBC SSL 설정
        config.addDataSourceProperty("ssl", "true");
        config.addDataSourceProperty("sslmode", "verify-full");
        config.addDataSourceProperty("sslfactory",
            "org.postgresql.ssl.DefaultJavaSSLFactory");

        return new HikariDataSource(config);
    }
}

Redis SSL 커넥션

# Redis TLS 커넥션에 SSL Bundle 적용
spring:
  data:
    redis:
      host: redis.example.com
      port: 6380
      ssl:
        enabled: true
        bundle: redis-cert

  ssl:
    bundle:
      pem:
        redis-cert:
          truststore:
            certificate: classpath:certs/redis-ca.crt
          keystore:
            certificate: classpath:certs/redis-client.crt
            private-key: classpath:certs/redis-client.key

런타임 인증서 갱신 (Reload)

Spring Boot 3.2+에서는 인증서 파일이 변경되면 자동으로 Reload할 수 있습니다. Let’s Encrypt 등 자동 갱신 인증서에 필수적입니다:

# 파일 시스템 인증서 + 자동 Reload
spring:
  ssl:
    bundle:
      pem:
        server-cert:
          reload-on-update: true     # 파일 변경 감지 → 자동 Reload
          keystore:
            certificate: /etc/tls/server.crt   # 파일 경로 (classpath 아님)
            private-key: /etc/tls/server.key
          truststore:
            certificate: /etc/tls/ca.crt

server:
  ssl:
    bundle: server-cert
// 프로그래밍 방식으로 Reload 감지
@Component
@Slf4j
public class SslBundleReloadListener {

    public SslBundleReloadListener(SslBundles sslBundles) {
        // 번들 업데이트 이벤트 리스너
        sslBundles.addBundleRegisteredHandler("server-cert", bundle -> {
            log.info("SSL bundle 'server-cert' registered");
        });
    }
}

// K8s cert-manager와 연동:
// cert-manager가 /etc/tls/ 시크릿을 갱신
// → Spring Boot가 파일 변경 감지
// → SSL 컨텍스트 자동 Reload
// → 무중단 인증서 갱신!

K8s cert-manager + SSL Bundle 연동

# K8s Certificate 리소스
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: app-tls
spec:
  secretName: app-tls-secret
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
  - api.example.com
  duration: 2160h     # 90일
  renewBefore: 720h   # 만료 30일 전 갱신

---
# Deployment: 시크릿을 파일로 마운트
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  template:
    spec:
      containers:
      - name: app
        image: my-app:latest
        volumeMounts:
        - name: tls-certs
          mountPath: /etc/tls
          readOnly: true
        env:
        - name: SERVER_SSL_BUNDLE
          value: server-cert
      volumes:
      - name: tls-certs
        secret:
          secretName: app-tls-secret
          items:
          - key: tls.crt
            path: server.crt
          - key: tls.key
            path: server.key
          - key: ca.crt
            path: ca.crt

mTLS 상호 인증 구현

# 서버 측: 클라이언트 인증서 요구
spring:
  ssl:
    bundle:
      pem:
        mtls-server:
          keystore:
            certificate: /etc/tls/server.crt
            private-key: /etc/tls/server.key
          truststore:
            certificate: /etc/tls/client-ca.crt  # 클라이언트 CA

server:
  ssl:
    bundle: mtls-server
    client-auth: need     # need: 필수, want: 선택적
// 클라이언트 인증서에서 사용자 정보 추출
@RestController
public class SecureController {

    @GetMapping("/api/secure")
    public Map<String, String> secureEndpoint(
            HttpServletRequest request) {

        X509Certificate[] certs = (X509Certificate[])
            request.getAttribute("jakarta.servlet.request.X509Certificate");

        if (certs != null && certs.length > 0) {
            X509Certificate clientCert = certs[0];
            String subject = clientCert.getSubjectX500Principal().getName();
            String issuer = clientCert.getIssuerX500Principal().getName();

            return Map.of(
                "subject", subject,
                "issuer", issuer,
                "serialNumber", clientCert.getSerialNumber().toString(),
                "validUntil", clientCert.getNotAfter().toString()
            );
        }
        throw new RuntimeException("No client certificate");
    }
}

// Spring Security와 연동: X.509 인증
@Configuration
@EnableWebSecurity
public class MtlsSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .x509(x509 -> x509
                .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
                .userDetailsService(username ->
                    User.withUsername(username)
                        .password("")
                        .roles("CLIENT")
                        .build()
                )
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/secure/**").hasRole("CLIENT")
                .anyRequest().permitAll()
            )
            .build();
    }
}

커스텀 SSL Bundle 프로그래밍

// Vault에서 동적으로 인증서를 가져와 SSL Bundle 등록
@Component
public class VaultSslBundleRegistrar implements ApplicationRunner {

    private final SslBundleRegistry registry;
    private final VaultTemplate vault;

    @Override
    public void run(ApplicationArguments args) {
        // Vault PKI에서 인증서 발급
        VaultCertificateResponse cert = vault.opsForPki()
            .issueCertificate("my-role",
                VaultCertificateRequest.create("api.example.com"));

        // PEM 문자열로 SSL Bundle 생성
        PemSslStoreBundle storeBundle = PemSslStoreBundle.of(
            new PemSslStoreDetails(
                null,
                cert.getCertificate(),
                cert.getPrivateKey()
            ),
            new PemSslStoreDetails(
                null,
                cert.getCaCertificate(),
                null
            )
        );

        SslBundle bundle = SslBundle.of(
            storeBundle,
            SslBundleKey.NONE,
            SslOptions.DEFAULTS,
            "TLS",
            null
        );

        // 런타임에 번들 등록
        registry.registerBundle("vault-cert", bundle);
    }
}

테스트에서 SSL Bundle 사용

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class MtlsIntegrationTest {

    @Autowired
    private SslBundles sslBundles;

    @LocalServerPort
    private int port;

    @Test
    void shouldConnectWithClientCert() {
        SslBundle clientBundle = sslBundles.getBundle("client-cert");

        RestClient client = RestClient.builder()
            .baseUrl("https://localhost:" + port)
            .requestFactory(ClientHttpRequestFactories.get(
                ClientHttpRequestFactorySettings.DEFAULTS
                    .withSslBundle(clientBundle)
            ))
            .build();

        String result = client.get()
            .uri("/api/secure")
            .retrieve()
            .body(String.class);

        assertThat(result).contains("CN=test-client");
    }
}

// 테스트용 자체 서명 인증서 생성
// keytool -genkeypair -alias test -keyalg RSA -keysize 2048 
//   -validity 365 -keystore test.p12 -storetype PKCS12 
//   -storepass changeit -dname "CN=localhost"

핵심 정리

기능 설정
PEM 인증서 번들 spring.ssl.bundle.pem.{name}
JKS/PKCS12 번들 spring.ssl.bundle.jks.{name}
서버 HTTPS server.ssl.bundle: {name}
자동 갱신 reload-on-update: true
mTLS server.ssl.client-auth: need

SSL Bundle은 Spring Boot의 TLS 설정을 한 곳에서 관리하고 여러 곳에서 재사용하는 핵심 추상화입니다. cert-manager와 reload-on-update를 결합하면 무중단 인증서 갱신이 가능합니다. K8s cert-manager TLS 자동화Spring OAuth2 Resource Server도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux