최신 트렌드

Secrets Management 실전 - Vault·AWS Secrets Manager로 런타임 크리덴셜 안전하게 다루기

백엔드 개발자 김승원 2026. 4. 23. 22:48

들어가며

지난 #132 SBOM과 Sigstore에서 공급망 공격을 빌드·서명·정책 레이어에서 막는 방법을 다뤘습니다. 그 위에 마지막으로 남는 구멍이 하나 있습니다. 서명된 이미지가 런타임에 들고 있는 크리덴셜입니다.

DB 비밀번호, API 키, OAuth 클라이언트 시크릿, 내부 시스템 토큰. 이것들이 application.yml·환경변수·Dockerfile ENV에 평문으로 박혀 있다면, 이미지 SBOM이 그대로 유출 경로가 됩니다. 2025년 Nx npm 토큰 유출도 결국은 CI 러너에 박혀 있던 장기 토큰이 원인이었습니다.

오늘은 "코드와 이미지에서 시크릿을 완전히 분리하고, 런타임에만 주입받는" 표준 패턴을 Spring Boot + Kubernetes + AWS/Vault 맥락에서 정리합니다.

  • 1부 - 시크릿 저장의 7가지 안티패턴
  • 2부 - HashiCorp Vault 실전 (dynamic secrets, transit)
  • 3부 - AWS Secrets Manager vs SSM Parameter Store
  • 4부 - IRSA/Workload Identity - 크리덴셜 없이 크리덴셜 받기
  • 5부 - External Secrets Operator와 CSI Secret Store
  • 6부 - Spring Boot 통합 + 로테이션 전략
  • 7부 - CI 시크릿 스캐닝과 사고 대응

1. 시크릿 저장의 7가지 안티패턴

실무에서 가장 많이 보는 잘못된 패턴입니다. 그대로 자기 프로젝트와 매칭해 보세요.

안티패턴 문제 대안
application.yml 평문 Git 히스토리 영구 유출 외부 시크릿 저장소
Dockerfile ENV 이미지 레이어·SBOM 노출 런타임 주입
git-crypt/Sealed Secrets만 사용 로테이션 불가, 키 분실 시 복구 불가 중앙 저장소 + Operator
Kubernetes Secret Base64 인코딩일 뿐 암호화 아님, etcd 평문 KMS envelope encryption + ESO
CI 환경변수 장기 토큰 러너 탈취 시 조직 전체 피해 OIDC 단기 토큰
공유 팀 1Password 감사 로그 부재, 접근 주체 식별 불가 기계 신원별 분리
로그·APM에 시크릿 출력 사고 시 2차 유출 마스킹 파이프라인

이 중 하나라도 해당되면 지금 공급망 공격의 현실적 표적입니다. "언제 바꿀 수 있는가"와 "누가 읽었는지 추적 가능한가"가 해결 여부의 기준입니다.

2. HashiCorp Vault - 동적 시크릿이 핵심

Vault의 진짜 가치는 "정적 시크릿 저장소"가 아니라 dynamic secrets입니다. 요청이 올 때마다 DB 계정을 즉석에서 생성하고, TTL이 지나면 자동으로 revoke합니다.

2-1. 정적 KV 저장소 (기본)

$ vault kv put secret/order-service/prod \
    db.username=app_prod \
    db.password=s3cret \
    api.stripe=sk_live_...

$ vault kv get -format=json secret/order-service/prod

KV는 가장 얕은 쓰임입니다. 그 자체로는 1Password와 큰 차이가 없습니다.

2-2. Dynamic Database Credentials

$ vault secrets enable database

$ vault write database/config/orders-mysql \
    plugin_name=mysql-database-plugin \
    connection_url="{{username}}:{{password}}@tcp(mysql.internal:3306)/" \
    allowed_roles="orders-app" \
    username="vaultadmin" \
    password="..."

$ vault write database/roles/orders-app \
    db_name=orders-mysql \
    creation_statements="CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
        GRANT SELECT, INSERT, UPDATE ON orders.* TO '{{name}}'@'%';" \
    default_ttl="1h" \
    max_ttl="24h"
$ vault read database/creds/orders-app
Key                Value
username           v-token-orders-app-AbCdEf-1713873600
password           A1B2-random-gen-C3D4
lease_duration     1h

애플리케이션이 부팅할 때 1시간짜리 DB 계정을 받고, 만료되기 전에 자동 renewal하는 구조입니다. 사고가 나도 최대 24시간 이후에는 공격자의 크리덴셜이 자동 소멸합니다.

2-3. Transit 엔진 - 키를 노출하지 않고 암복호화

$ vault secrets enable transit
$ vault write -f transit/keys/orders-pii

$ vault write transit/encrypt/orders-pii plaintext=$(echo -n "010-1234-5678" | base64)
# -> vault:v1:abcdEncryptedCiphertext...

$ vault write transit/decrypt/orders-pii ciphertext="vault:v1:abcd..."
# -> base64(010-1234-5678)

"Encryption as a Service" 패턴입니다. 애플리케이션은 키를 가지지 않고 암복호화만 요청합니다. 애플리케이션 DB가 털려도 Vault 토큰이 없으면 PII 복호화가 불가능합니다.

2-4. AppRole 인증

애플리케이션이 Vault에 로그인하는 방식입니다. 가장 기본적인 AppRole 예시입니다.

$ vault auth enable approle
$ vault write auth/approle/role/orders-app \
    secret_id_ttl=10m \
    token_ttl=1h \
    token_max_ttl=4h \
    token_policies="orders-policy"

$ vault read -field=role_id auth/approle/role/orders-app/role-id
$ vault write -f -field=secret_id auth/approle/role/orders-app/secret-id

문제는 role_id·secret_id를 누가 애플리케이션에 주입하느냐입니다. 이 부분을 잘못 설계하면 바로 안티패턴이 됩니다. 해결책이 뒤에서 다룰 IRSA/Workload Identity입니다.

3. AWS Secrets Manager vs SSM Parameter Store

AWS 환경이라면 관리형 서비스 두 가지 중 선택해야 합니다. 비슷해 보이지만 쓰임이 다릅니다.

항목 Secrets Manager SSM Parameter Store
주 용도 로테이션 대상 시크릿 설정값·비로테이션 시크릿
자동 로테이션 Lambda 기반 내장 없음
복제(Multi-region) 원클릭 수동
비용 시크릿당 $0.40/월 + API 호출 Standard 무료, Advanced 유료
최대 크기 64KB Standard 4KB, Advanced 8KB
버전 관리 VersionId, VersionStage Parameter history

현실적 선택 기준입니다.

  • RDS/Redshift/DocumentDB 크리덴셜 → Secrets Manager (자동 로테이션 통합)
  • 3rd-party API 키 → Secrets Manager (수동 로테이션 스케줄)
  • 엔드포인트 URL·피처 플래그 → SSM Parameter Store (무료, 히스토리 충분)
  • 비용 민감·저로테이션 → SSM Advanced + SecureString + KMS

3-1. Secrets Manager 자동 로테이션 구조

aws secretsmanager rotate-secret \
  --secret-id prod/orders/db \
  --rotation-lambda-arn arn:aws:lambda:ap-northeast-2:123:function:SecretsManagerRDSMySQLRotationSingleUser \
  --rotation-rules AutomaticallyAfterDays=30

Lambda가 4단계를 수행합니다.

  1. createSecret - 새 비밀번호를 pending 단계(AWSPENDING)에 저장
  2. setSecret - RDS에 실제 비밀번호 변경 적용
  3. testSecret - 새 크리덴셜로 접속 검증
  4. finishSecret - pending을 current(AWSCURRENT)로 승격, 구버전은 previous

애플리케이션은 항상 AWSCURRENT를 읽기 때문에 로테이션 중에도 끊김이 없습니다.

4. 크리덴셜 없이 크리덴셜 받기 - IRSA/Workload Identity

"Vault에 로그인하려면 Vault 토큰이 필요하다"는 부트스트랩 문제는 모든 시크릿 저장소의 근본 난관입니다. 클라우드 네이티브 해결책이 Workload Identity입니다.

4-1. EKS IRSA (IAM Roles for Service Accounts)

apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-app
  namespace: production
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123:role/orders-app-role

이 ServiceAccount로 뜬 Pod은 AWS STS로부터 자동으로 IAM 역할의 단기 크리덴셜을 받습니다. SDK가 AWS_WEB_IDENTITY_TOKEN_FILE·AWS_ROLE_ARN을 감지해 알아서 처리합니다.

// build.gradle.kts
implementation("software.amazon.awssdk:secretsmanager:2.25.0")

// Java
SecretsManagerClient client = SecretsManagerClient.builder()
    .region(Region.AP_NORTHEAST_2)
    .build();  // 크리덴셜 설정 없음!

GetSecretValueResponse resp = client.getSecretValue(
    GetSecretValueRequest.builder().secretId("prod/orders/db").build());

애플리케이션 코드에 액세스 키가 한 줄도 없습니다. Pod의 ServiceAccount가 곧 신원이 되고, IAM 정책이 권한을 결정합니다.

4-2. GKE Workload Identity / Azure Pod Identity

원리는 같습니다. Kubernetes ServiceAccount와 클라우드 IAM을 OIDC 트러스트로 바인딩하여, 애플리케이션은 환경에서 제공된 토큰으로 단기 크리덴셜을 받습니다.

4-3. Vault에도 같은 원리

$ vault auth enable kubernetes
$ vault write auth/kubernetes/config \
    kubernetes_host="https://kubernetes.default.svc" \
    token_reviewer_jwt="$(cat /var/run/secrets/.../token)" \
    kubernetes_ca_cert=@ca.crt

$ vault write auth/kubernetes/role/orders-app \
    bound_service_account_names=orders-app \
    bound_service_account_namespaces=production \
    policies=orders-policy ttl=1h

Pod은 /var/run/secrets/kubernetes.io/serviceaccount/token을 Vault에 제시하여 토큰을 받습니다. AppRole의 secret_id 주입 문제가 사라집니다.

5. External Secrets Operator (ESO)

애플리케이션 코드에서 Vault·Secrets Manager SDK를 직접 부르는 방식은 유연하지만, 언어별 SDK 의존·캐싱·재시도를 각자 구현해야 합니다. ESO는 이것을 Kubernetes Secret으로 동기화해 주는 오퍼레이터입니다.

5-1. SecretStore 정의

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secretsmanager
  namespace: production
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-northeast-2
      auth:
        jwt:
          serviceAccountRef:
            name: eso-sa  # IRSA 적용된 SA

5-2. ExternalSecret 선언

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: orders-db
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secretsmanager
    kind: SecretStore
  target:
    name: orders-db-secret
    template:
      type: Opaque
      data:
        SPRING_DATASOURCE_USERNAME: "{{ .username }}"
        SPRING_DATASOURCE_PASSWORD: "{{ .password }}"
  data:
    - secretKey: username
      remoteRef: {key: prod/orders/db, property: username}
    - secretKey: password
      remoteRef: {key: prod/orders/db, property: password}

ESO가 1시간마다 AWS에서 값을 가져와 orders-db-secret Kubernetes Secret을 갱신합니다. 애플리케이션은 평범한 Kubernetes Secret만 알면 됩니다.

5-3. CSI Secret Store와의 비교

항목 ESO CSI Secret Store
저장 위치 Kubernetes Secret tmpfs(볼륨)만, Secret은 선택
갱신 주기 폴링 rotation-poller sidecar
etcd 보관 O(암호화 권장) X (더 안전)
도입 난이도 낮음 CSI 드라이버 설치 필요

etcd에 Secret이 있는 것 자체가 불편하다면 CSI, 기존 패턴 호환성이 우선이면 ESO가 현실적입니다. 둘 다 쓰는 조직도 흔합니다.

6. Spring Boot 통합

Spring Cloud는 세 가지 경로를 제공합니다.

6-1. Spring Cloud Vault (Dynamic DB credentials)

# application.yml
spring:
  cloud:
    vault:
      uri: https://vault.internal:8200
      authentication: kubernetes
      kubernetes:
        role: orders-app
        service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
      database:
        enabled: true
        role: orders-app
        backend: database
      config.lifecycle:
        enabled: true
        lease-endpoints: legacy

Spring Cloud Vault가 부팅 시 DB 역할을 요청하고, lease 만료 전에 자동 renew합니다. 애플리케이션 코드에는 spring.datasource.username/password가 필요 없습니다.

6-2. AWS Secrets Manager + Spring Cloud AWS

# bootstrap.yml (또는 spring.config.import)
spring:
  config.import: "aws-secretsmanager:prod/orders/db"
  datasource:
    url: jdbc:mysql://mysql.internal:3306/orders
    username: ${username}
    password: ${password}

Spring Cloud AWS 3.x부터 spring.config.import 한 줄로 해결됩니다. ESO나 CSI 없이도 SDK가 직접 호출합니다. 부팅 속도가 중요하다면 이 방식이 가장 단순합니다.

6-3. @RefreshScope와 런타임 갱신

@RestController
@RefreshScope
public class StripeClient {
    @Value("${stripe.secret-key}")
    private String apiKey;
    // /actuator/refresh 호출 시 재주입
}

로테이션 후 재배포 없이 갱신하려면 Spring Actuator /actuator/refresh + @RefreshScope 조합을 씁니다. #94 Spring Boot Actuator에서 다룬 기능 그대로입니다.

7. 로테이션 전략

"로테이션 주기"만 설계하면 반쪽짜리입니다. 실제로 무중단으로 돌아가려면 dual secret 전략이 필요합니다.

유형 전략 주기
DB 비밀번호 Dynamic (매 요청 발급) or 30일 자동 분/일
3rd-party API 키(Stripe 등) Dual keys - 신/구 공존 후 전환 분기
JWT 서명 키 kid 기반 keyset, 신규 서명 + 기존 검증 분기
OAuth 클라이언트 시크릿 사전 알림 + dual 활성 반기
루트/관리자 계정 사용 시마다 Break-glass 로그 수동

Stripe·PayPal 등 상당수 서비스가 "두 개의 활성 키"를 공식 지원합니다. 이걸 쓰지 않고 단일 키만 운영하면 로테이션 = 다운타임이 됩니다.

8. CI에서 시크릿 다루기

배포 파이프라인이 가진 시크릿이 가장 위험합니다. 러너 하나가 털리면 조직 전체가 털립니다.

8-1. OIDC 단기 토큰 (GitHub → AWS)

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123:role/github-deployer
      aws-region: ap-northeast-2
  - run: aws ecr get-login-password | docker login ...

GitHub이 IAM에 OIDC 트러스트를 맺으면, 워크플로우는 장기 액세스 키 없이 15분~1시간 토큰을 받습니다. #132 Sigstore keyless 서명과 같은 원리입니다.

8-2. Secret scanning 게이트

  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: {fetch-depth: 0}
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}

gitleaks·trufflehog 같은 도구가 PR 단계에서 하드코딩된 시크릿을 잡습니다. 이미 푸시된 경우에는 git 히스토리에서 제거해도 이미 노출된 것으로 간주하고 로테이션해야 합니다.

8-3. pre-commit 훅

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks: [{id: gitleaks}]

개발자 로컬 단계에서 차단하는 것이 가장 저렴합니다. CI 단계에서 찾으면 이미 원격에 올라간 뒤이기 때문에 비용이 큽니다.

9. 사고 대응 시나리오

"시크릿이 유출됐다"고 가정합시다. 어떤 질문에 몇 분 안에 답할 수 있어야 하는지가 성숙도의 기준입니다.

  1. 5분 - 유출된 시크릿이 무엇에 쓰이는가? (Vault 경로·Secrets Manager ID로 용도 식별)
  2. 10분 - 누가 어제부터 읽었나? (CloudTrail·Vault audit log 조회)
  3. 15분 - 즉시 revoke. Dynamic secret이면 lease revoke, 정적이면 로테이션 트리거
  4. 30분 - 영향받은 서비스 재기동 (@RefreshScope 또는 롤링 배포)
  5. 1시간 - 사고 확산 봉쇄 완료, post-mortem 준비
  6. 사후 - 왜 평문으로 있었는지, 스캐닝이 왜 못 잡았는지 원인 분석

여기서 핵심은 감사 로그입니다. 감사 로그가 없으면 "누가 언제 읽었는지"를 영원히 알 수 없어, 피해 범위 추정이 불가능합니다.

10. 도입 로드맵

기존 코드베이스가 있다면 한 번에 다 못 바꿉니다. 현실적 순서입니다.

단계 목표 소요
1 Git·이미지에서 평문 시크릿 제거, gitleaks 게이트 1~2주
2 중앙 저장소 선정 (Vault or Secrets Manager) 1주
3 IRSA/Workload Identity 전환으로 부트스트랩 문제 해결 2주
4 ESO 또는 Spring Config 임포트로 신규 서비스 전환 2주
5 DB 크리덴셜 dynamic secret 전환 1개월
6 Dual secret·자동 로테이션 정착 분기
7 감사 로그·알림 연동 (SIEM·Slack) 분기

11. 흔한 도입 실패 패턴

  • 중앙 저장소만 도입하고 주입 방식은 안 바꿈 - 여전히 개발자가 값을 복사해서 CI 변수에 붙임. ESO/CSI/SDK 자동 주입 없이는 절반의 해법.
  • Kubernetes Secret에 Base64만 보고 암호화라고 믿음 - etcd 평문 그대로. KMS envelope encryption(EncryptionConfiguration)은 명시적으로 켜야 적용.
  • Vault Root 토큰 장기 사용 - Vault 부트스트랩 후 반드시 제거. unseal 키 분산 관리가 없으면 Vault 자체가 SPOF.
  • 로테이션 후 서비스 다운 - dual secret 설계 없이 단일 키 로테이션. @RefreshScope만으로 해결 안 되는 커넥션 풀 갱신을 고려해야 함.
  • 시크릿을 로그에 출력 - Feign/WebClient 디버그 로그, APM 파라미터 캡처에 그대로 노출. 마스킹 필터가 기본 설정이어야 함.
  • 감사 로그 보관만 하고 안 봄 - 이상 접근 알림 없이 CloudTrail 자료만 쌓기. 새 주체 접근·심야 대량 조회 정도는 최소 알림.
  • 외부 SaaS 키에만 집중 - 사내 DB·Redis·Elastic 크리덴셜이 더 위험한 경우가 많음. 내부 트래픽이라는 이유로 방어선이 약한 곳이 실제 침투 경로.

12. 체크리스트

  • [ ] application.yml·Dockerfile에 평문 시크릿이 없다
  • [ ] gitleaks 또는 동등 도구가 pre-commit·CI에서 강제된다
  • [ ] 중앙 저장소(Vault or Secrets Manager)가 단일 진실원이다
  • [ ] 모든 Pod이 IRSA/Workload Identity 또는 Vault K8s Auth로 부트스트랩된다
  • [ ] DB 크리덴셜은 dynamic 또는 30일 이내 자동 로테이션된다
  • [ ] 3rd-party 키는 dual 활성 전환 절차가 문서화돼 있다
  • [ ] Kubernetes Secret etcd 암호화(KMS envelope)가 적용됐다
  • [ ] 감사 로그가 SIEM으로 수집되고 이상 접근 알림이 있다
  • [ ] CI는 장기 토큰 없이 OIDC로 클라우드 권한을 얻는다
  • [ ] 유출 시 revoke·로테이션·재기동까지 30분 안에 가능하다

마치며

Secrets Management 실전의 핵심을 정리합니다.

  • 시크릿은 코드와 이미지에서 완전히 분리되어 런타임에만 주입돼야 합니다. application.yml·Dockerfile·CI 장기 토큰 모두 공급망 공격의 우선 표적이고, SBOM·이미지 서명을 강화할수록 시크릿 주입 경로가 최종 방어선이 됩니다.
  • 부트스트랩 문제는 Workload Identity로 푸는 것이 2026년 표준입니다. IRSA/GKE Workload Identity/Vault Kubernetes Auth 모두 같은 원리로, Pod의 ServiceAccount가 곧 신원이 되어 "크리덴셜을 받기 위한 크리덴셜"이 필요 없어집니다.
  • Dynamic secrets는 정적 KV보다 차원이 다른 안전성을 제공합니다. Vault DB 엔진으로 1시간짜리 DB 계정을 발급하면, 사고가 나도 피해가 TTL 이내로 자동 봉쇄됩니다. 단, 커넥션 풀 재사용·renewal 실패 대응 설계가 필수입니다.
  • ESO와 Spring Config 임포트 중 하나를 표준으로 삼아야 합니다. 서비스마다 Vault SDK를 직접 부르면 장애 포인트가 늘어나고 감사가 어려워집니다. 중앙 방식을 정하고 나머지는 따르게 하는 것이 운영 비용을 낮춥니다.
  • 로테이션은 dual secret·@RefreshScope·커넥션 풀 갱신까지가 한 세트입니다. 단일 키 로테이션은 다운타임으로 이어지고, 단순히 값을 바꾸기만 하는 건 "로테이션했다는 착각"만 줍니다. Stripe·PayPal 같은 서비스의 dual key 기능을 적극 활용하세요.
  • 감사 로그는 수집이 아니라 알림까지가 기능입니다. CloudTrail·Vault audit log를 SIEM으로 보내고 이상 패턴 알림(새 주체 접근·심야 대량 조회)을 설정해야, 사고 시 "누가 언제 읽었는가"에 10분 안에 답할 수 있습니다.

OWASP(#131) · SBOM/Sigstore(#132) · Secrets Management까지로 애플리케이션 공급망 전체 방어선이 구성됩니다. 다음 편에서는 이 위에 제로 트러스트 네트워킹 - mTLS·서비스 메시(Istio/Linkerd)·SPIFFE/SPIRE를 얹어, 서비스 간 통신 자체에 신원을 부여하는 방법을 다룰 예정입니다. 크리덴셜 관리가 "무엇을 주느냐"였다면, 제로 트러스트는 "누가 누구에게 말할 수 있느냐"의 레이어입니다.