Spring Boot with Vault

Spring Boot 서비스에서 HashiCorp Vault를 사용해 Secret을 안전하게 관리하는 세 가지 패턴을 다룹니다. Vault 토큰을 설정 파일에 하드코딩하거나 수명이 긴 토큰을 적절한 접근 통제와 교체 절차 없이 사용하는 일을 피해야 합니다.

이런 경우에 참고

  • 조직 표준이 HashiCorp Vault인 경우
  • Kubernetes 환경에서 Vault Agent Injector 사용이 가능한 경우
  • Vault Secrets Operator(VSO)가 클러스터에 배포된 경우
  • Spring Cloud Vault 라이브러리로 직접 연동하는 경우
  • application.yml에 Vault 토큰이 하드코딩된 경우

예시

Pattern A: Spring Cloud Vault (직접 연동)

앱이 Vault API를 직접 호출해 Secret을 로딩하는 방식입니다. Spring Cloud Vault 라이브러리가 application.yml의 spring.config.import: vault:// 설정을 읽어 Vault에서 값을 주입합니다.

언제 이 패턴을 선택하는가

  • Kubernetes 외 환경(VM, ECS 등)에서도 Vault를 사용하는 경우
  • Spring Boot 앱이 직접 Vault API를 호출해 Secret을 로딩해야 하는 경우

변경 전

yaml
# application.yml: 토큰 하드코딩
spring:
  config:
    import: vault://
  cloud:
    vault:
      host: vault.internal
      port: 8200
      scheme: https
      authentication: TOKEN
      token: hvs.xxxxxxxxxxxxxxxxxxxxxxxxxxxx   # 절대 커밋하면 안되는 부분
      kv:
        enabled: true
        backend: secret
        default-context: my-service

변경 후

yaml
# application.yml: Kubernetes Auth + role 기반 인증
spring:
  config:
    import: vault://
  cloud:
    vault:
      host: vault.internal
      port: 8200
      scheme: https
      authentication: KUBERNETES
      kubernetes:
        role: my-service-role
        service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
      kv:
        enabled: true
        backend: secret
        default-context: my-service

Kubernetes 환경에서는 KUBERNETES 인증 방식을 사용해 서비스 어카운트 토큰으로 Vault에 인증합니다. VM/ECS에서는 지원되는 플랫폼 인증이나 APPROLE을 검토하고, AppRole을 쓸 경우 role-id와 secret-id를 보호된 경로로 전달합니다. Projected Service Account Token을 별도 볼륨으로 마운트한 경우에는 실제 마운트 경로(예: /var/run/secrets/tokens/vault-token)를 service-account-token-file에 직접 지정해야 합니다.

필요 의존성 예시
groovy
// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-vault-config'
    // Dynamic Secrets (Database Secret Engine) 사용 시 추가
    implementation 'org.springframework.cloud:spring-cloud-vault-config-databases'
}
xml
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency>
<!-- Dynamic Secrets (Database Secret Engine) 사용 시 추가 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-vault-config-databases</artifactId>
</dependency>
Spring 코드 예시

Vault에 저장된 값은 키 이름 그대로 설정에 노출되므로 db.password, jwt.secret처럼 앱에서 참조하는 이름과 일치시켜야 합니다. 아래 @ConfigurationProperties 클래스는 검증 의존성과 @ConfigurationPropertiesScan 또는 @EnableConfigurationProperties 등록이 필요합니다.

java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class DatabaseConfig {

    // Vault 경로 secret/my-service의 db.password 키에 저장된 값
    @Value("${db.password}")
    private String dbPassword;

    @Value("${jwt.secret}")
    private String jwtSecret;
}
java
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import jakarta.validation.constraints.NotBlank;

@Validated
@ConfigurationProperties(prefix = "db")
public class DbProperties {

    @NotBlank
    private String password;

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

Pattern B: Vault Agent Injector (Kubernetes sidecar)

Vault Agent가 사이드카로 Pod에 주입되어 Secret을 파일로 제공하는 방식입니다. Injector와 Vault 인증이 구성된 환경에서 annotation으로 파일 주입을 요청하고, Spring의 spring.config.import로 파일을 읽습니다.

언제 이 패턴을 선택하는가

  • Kubernetes 환경이며 Vault Agent Injector가 클러스터에 배포되어 있는 경우
  • 앱 코드 변경 없이 Vault Secret을 파일로 주입하고 싶은 경우

변경 전

yaml
# Vault 토큰 하드코딩 및 DB 비밀번호와의 혼동
env:
  - name: VAULT_TOKEN
    value: "hvs.xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: my-secret        # DB 비밀번호 대신 Vault 토큰을 참조하는 잘못된 예시
        key: vault-token

변경 후

Pod annotation 예시

Deployment의 일부이며 selector, label 등은 생략했습니다. Pod의 서비스 어카운트와 Vault role을 연결하고 필요한 경로의 읽기 권한을 구성하세요.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-service
spec:
  template:
    metadata:
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "my-service-role"
        vault.hashicorp.com/agent-inject-secret-application.properties: "secret/data/my-service"
        vault.hashicorp.com/agent-inject-template-application.properties: |
          {{- with secret "secret/data/my-service" -}}
          db.password={{ .Data.data.db_password }}
          jwt.secret={{ .Data.data.jwt_secret }}
          {{- end }}
    spec:
      containers:
        - name: my-service
          image: my-service:latest

Vault Agent Injector는 위 annotation을 읽어 /vault/secrets/application.properties 파일을 생성합니다. KV v2의 db_password, jwt_secret을 Spring 속성 이름으로 매핑합니다. 실제 값에 줄바꿈이나 특수 문자가 있다면 properties 형식에 맞게 이스케이프하세요.

Spring이 읽는 방식
yaml
# application.yml: 주입된 파일을 Spring 설정으로 로딩
spring:
  config:
    import: file:/vault/secrets/application.properties

필수 Secret 파일이므로 optional:을 사용하지 않습니다.

Pattern C: Vault Secrets Operator

VSO가 VaultStaticSecret / VaultDynamicSecret CRD를 감시해 Vault 시크릿을 Kubernetes Secret으로 동기화합니다. 앱은 Vault를 전혀 모르고 일반 Kubernetes Secret만 읽습니다. 단, Vault의 암호화 계층을 벗어나 etcd에 저장되므로 etcd 암호화-at-rest 및 RBAC 설정이 선행되어야 합니다.

언제 이 패턴을 선택하는가

  • Kubernetes 환경이며 Vault Secrets Operator가 클러스터에 배포되어 있는 경우
  • Vault Agent Injector(sidecar) 없이 Kubernetes Secret 방식으로 시크릿을 관리하고 싶은 경우
  • 여러 네임스페이스에 동일 시크릿을 복제해야 하는 경우

변경 전

yaml
# Kubernetes Secret에 값을 직접 하드코딩하지 않는다
apiVersion: v1
kind: Secret
metadata:
  name: my-service-secret
type: Opaque
stringData:
  db-password: "s3cr3t-password"   # 절대 커밋하면 안되는 부분
  jwt-secret: "my-jwt-secret-key"

변경 후

yaml
# VaultStaticSecret CRD: VSO가 Vault에서 값을 읽어 Kubernetes Secret으로 동기화
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
  name: my-service-secret
  namespace: my-namespace
spec:
  vaultAuthRef: my-vault-auth
  mount: secret
  type: kv-v2
  path: my-service
  refreshAfter: 60s
  destination:
    name: my-service-secret   # 생성될 Kubernetes Secret 이름
    create: true
Spring이 읽는 방식

위 VaultStaticSecret은 같은 네임스페이스에 구성된 my-vault-auth를 참조합니다. Vault의 키가 db_password, jwt_secret인 경우 아래처럼 환경 변수와 앱 속성에 명시적으로 연결합니다.

yaml
# application.yml
db:
  password: ${DB_PASSWORD}
jwt:
  secret: ${JWT_SECRET}
yaml
# 컨테이너 환경 변수 설정의 일부
env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: my-service-secret
        key: db_password
  - name: JWT_SECRET
    valueFrom:
      secretKeyRef:
        name: my-service-secret
        key: jwt_secret

환경 변수는 실행 중인 프로세스에서 자동 갱신되지 않습니다. Secret 동기화 후 앱 재시작 또는 지원되는 재로딩 절차를 구성하세요.

Dynamic Secrets

Vault는 DB 계정이나 AWS 키 등을 임대 기간이 있는 자격 증명으로 발급할 수 있습니다. 갱신과 폐기 동작을 모니터링하고, 앱이 새 자격 증명을 적용하도록 구성해야 합니다.

Pattern A에서 사용 (Spring Cloud Vault)

yaml
# application.yml: DB Dynamic Secrets
spring:
  config:
    import: vault://
  cloud:
    vault:
      host: vault.internal
      port: 8200
      scheme: https
      authentication: KUBERNETES
      kubernetes:
        role: my-service-role
        service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
      database:
        enabled: true
        role: my-service-db-role   # Vault Database Secret Engine에 등록된 role
        backend: database

Spring Cloud Vault가 앱 기동 시 Vault Database Secret Engine에서 단기 DB 계정을 발급받아 DataSource에 주입합니다. 갱신 가능한 lease를 관리하지만, max_ttl에 도달했을 때 새 자격 증명을 받아 DataSource를 다시 구성하지는 않습니다. 만료 전에 앱 재시작이나 별도의 자격 증명 갱신·연결 풀 재구성 절차를 마련하세요.

Pattern C에서 사용 (VSO)

yaml
# VaultDynamicSecret CRD
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
  name: my-service-db-secret
  namespace: my-namespace
spec:
  vaultAuthRef: my-vault-auth
  mount: database
  path: creds/my-service-db-role
  destination:
    name: my-service-db-secret
    create: true

개발자 해야 할 일

  • application.yml에서 Vault 토큰을 제거합니다.
  • Pattern A: 환경에 맞는 지원 인증 방식을 선택하고 인증 정보 전달 경로를 보호합니다.
  • Pattern B: Pod annotation을 추가하고 spring.config.import로 주입된 파일을 참조합니다.
  • Pattern C: VSO VaultStaticSecret / VaultDynamicSecret CRD를 정의합니다.
  • @ConfigurationProperties에서 필수 값 누락 시 fail closed 하도록 @NotBlank 등을 적용합니다.
  • 로그, exception, actuator endpoint에 Secret 값이 노출되지 않도록 점검합니다.

인프라/플랫폼 해야 할 일

  • Vault에 서비스별 policy와 role을 생성하고 최소 권한 원칙을 적용합니다.
  • Kubernetes Auth Method를 활성화하고 각 서비스 어카운트에 적절한 role을 바인딩합니다.
  • Pattern B: Vault Agent Injector(vault-agent-injector)가 클러스터에 배포되어 있는지 확인합니다.
  • Pattern C: VSO가 클러스터에 배포되어 있는지 확인합니다.
  • Database Secret Engine에 role과 connection을 구성합니다.
  • Secret rotation 시 재시작 또는 reload 절차를 운영 문서에 포함합니다.
  • Helm values, ConfigMap, Docker image에 Vault 토큰이 포함되지 않도록 배포 표준을 강제합니다.

검증 방법

  • application.yml, Helm values, ConfigMap에 Vault 토큰이 없는지 확인합니다.
  • Pattern A: 앱 기동 시 Vault에서 값을 정상적으로 로딩하는지 로그로 확인합니다.
  • Pattern B: /vault/secrets/ 경로에 파일이 생성되었는지 확인합니다 (kubectl exec).
  • Pattern C: 동기화된 Kubernetes Secret이 생성되었는지 확인합니다 (kubectl get secret).
  • /actuator/env, 디버그 로그, 예외 응답에 Secret 값이 노출되지 않는지 확인합니다.
  • 필수 Secret 누락 시 애플리케이션이 정상적으로 실패하는지 확인합니다.

자주 하는 실수

  • application.yml에 Vault 토큰 하드코딩
  • 장기 Vault 토큰을 접근 통제나 교체 절차 없이 사용
  • Vault 경로를 코드에 하드코딩 (설정 파일로 분리해야 함)
  • Secret rotation 후 재시작 절차 미비
  • Dynamic Secrets의 lease 갱신, 최대 TTL, 앱의 새 자격 증명 적용 시점을 함께 관리하지 않음

담당자에게 안내할 문구 예시

  • "application.yml에 저장된 Vault 토큰은 제거하고, Kubernetes 환경이라면 KUBERNETES 인증 방식으로 전환하세요."
  • "Vault 인증과 Injector를 구성한 뒤 annotation으로 파일 주입을 요청하고, Spring의 spring.config.import에서 필수 파일을 참조하세요."
  • "환경에 맞는 Vault 인증 방식과 최소 권한을 적용하고, Secret 교체 후 앱에 새 값을 반영하는 절차를 문서화하세요."
  • "Kubernetes 환경에서 VSO가 배포되어 있다면 VaultStaticSecret / VaultDynamicSecret CRD를 정의해 Vault 시크릿을 Kubernetes Secret으로 동기화하세요. 앱 코드 변경 없이 일반 Kubernetes Secret처럼 사용할 수 있습니다."
  • "Dynamic Secrets를 사용할 때는 lease 갱신과 최대 TTL을 확인하고, 만료 전에 앱이 새 자격 증명을 사용하도록 구성하세요."

관련 문서