평문을 지우며 — 시크릿 관리, 그리고 시리즈를 닫으며
2025-12-23
솔직히 고백할 게 있습니다. 지금껏 쿠버네티스 매니페스트에 이렇게 써 왔습니다.
# ⚠️ 데모용 평문. 실서비스에서는 볼트/External Secrets 로 주입한다.
stringData:
JWT_SECRET: "change-me-in-production-please-32B+"
DATABASE_URL: "postgres://shop:secret@postgres:5432/shop"
"⚠️ 데모용"이라는 주석 하나로 넘어갔죠. 이 시리즈의 마지막 편에서, 그 부채를 갚습니다. 시크릿을 제대로 관리하고 — 그리고 이 긴 시리즈를 닫습니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-36태그에 있습니다.
왜 평문이 문제인가
"어차피 데모인데"가 위험한 이유는, git 히스토리는 영원하기 때문입니다.
- 한 번 커밋된 비밀은
git log에 영구히 남습니다 — 나중에 지워도 히스토리엔 그대로입니다. - 리포가 유출되면 DB·서명 키가 통째로 넘어갑니다. 회전(rotate)하려면 모든 서비스를 다시 배포해야 하죠.
- PR 리뷰·CI 로그·포크마다 비밀이 평문으로 노출 됩니다.
그래서 원칙은 하나 — 비밀을 평문으로 git 에 커밋하지 않는다. 그런데 시크릿도 버전 관리·리뷰·롤백은 하고 싶습니다. 답은 "암호화된 채로 git 에" 입니다.
SOPS + age — 값만 암호화해서 커밋
SOPS는 YAML/JSON 의 값만 암호화합니다. 키는 age — KMS 없이도 쓸 수 있는 단순·현대적 암호화입니다.
핵심은 값만 암호화한다는 것입니다. .sops.yaml 에 규칙을 둡니다.
creation_rules:
- path_regex: deploy/k8s/secrets.*\.enc\.yaml$
encrypted_regex: ^(data|stringData)$ # 값만! kind·metadata 는 평문
age: age1pdvh2f5j8wq... # 공개 recipient(공개키는 안전하다)
암호화하면 이렇게 됩니다 — 구조는 평문이라 diff·리뷰가 되고, 값만 봉인 됩니다.
kind: Secret
metadata:
name: shop-secret # ← 평문(무엇에 대한 시크릿인지 보인다)
stringData:
JWT_SECRET: ENC[AES256_GCM,data:BRvbVw...,iv:4O9m...,tag:VYo8...,type:str] # ← 봉인
키 관리가 전부 입니다. 규칙은 둘입니다.
- 공개 recipient 는 커밋한다 — 공개키는 안전하고, 이걸로 누구나 암호화 할 수 있습니다.
- 개인키(
age.key)는 절대 커밋하지 않는다 —.gitignore에 넣고, 실서비스에선 파일이 아니라 볼트·키체인·CI 시크릿 에서 주입합니다.
복호화는 배포 시점에, 파이프로만 합니다 — 평문을 디스크에 남기지 않습니다.
# 평문은 파이프 안에서만 흐르고, 끝나면 사라진다
sops --decrypt deploy/k8s/secrets.enc.yaml | kubectl apply -f -
실제로 암호화→복호화 라운드트립을 검증 했습니다 — 봉인된 ENC[...] 가 원래 값으로 정확히 복원됩니다. 그리고 age recipient 를 AWS/GCP KMS 나 조직 키로 바꾸면, 같은 흐름이 그대로 실서비스로 확장됩니다.
암호화만으론 부족하다 — 심층 방어
여기서 멈추면 구멍이 하나 남습니다. 누군가 시크릿 주입을 깜빡하면? 서비스가 개발용 기본 키로 조용히 떠서, 모든 토큰이 dev-secret-change-me 로 서명될 수 있습니다. 암호화는 "새지 않게" 했지만, "잘못 뜨는 것"은 못 막습니다.
그래서 부팅 가드 를 넣습니다. 운영 모드(APP_ENV=production)인데 JWT_SECRET 이 없거나 개발용 기본값이면 — 뜨는 걸 거부 합니다.
func SecretFromEnv(logger *slog.Logger) string {
s := os.Getenv("JWT_SECRET")
insecure := s == "" || s == devSecret
if os.Getenv("APP_ENV") == "production" && insecure {
logger.Error("운영에서 JWT_SECRET 이 없거나 기본값 — 안전을 위해 부팅을 거부합니다")
exit(1) // 약한 키로 조용히 도느니, 시끄럽게 멈춘다
}
// 로컬·테스트면 기본값으로 떨어지고 경고만
...
}
약한 키로 조용히 도느니, 시끄럽게 멈추는 게 낫습니다. 잘못된 배포는 즉시 CrashLoopBackOff 로 드러나고, 아무도 취약한 서비스를 모른 채 운영하지 않습니다. 12편의 교훈 — "터지기 전엔 안 보인다" — 을 보안에도 적용한 셈입니다. 이 가드는 exit 훅을 두어 테스트로도 못박았습니다(운영·미설정 → exit 1, 운영·정상값 → 통과).
정직하게 — 시크릿 관리의 그림자
- SOPS 도 결국 키 관리 문제 로 귀결됩니다. 암호화를 아무리 잘 해도, age 개인키가 새면 끝입니다. 그래서 진짜 무게는 키를 어디에 어떻게 두느냐(KMS·HSM·짧은 수명 토큰)에 있습니다.
secrets.enc.yaml을 그냥kubectl apply하면 안 됩니다 — 암호화된ENC[...]문자열이 그대로 Secret 값이 되는 함정입니다. 반드시sops --decrypt | kubectl apply로만. (이건 문서·스크립트로 가드하지만, 사람은 실수하니 실서비스라면 admission webhook 으로도 막습니다.)- age 는 로테이션·감사가 약합니다. 누가 언제 어떤 시크릿을 봤는지 추적하고, 자동 회전이 필요한 규모라면 Vault·External Secrets Operator 로 갑니다 — SOPS 는 그 사이의 실용적인 중간값입니다.
- 이 리포의 데모 키 는 학습용입니다. 실제로는 개인키가 이 저장소 어디에도 없어야 합니다.
시리즈를 닫으며 — 우리가 지나온 길
이걸로 이 시리즈를 마칩니다. 1편에서 DDD 로 도메인을 설계 하며 시작해, 여기까지 왔습니다. 큰 궤적은 이랬습니다.
- 설계와 뼈대 — DDD 바운디드 컨텍스트, TDD 도메인 모델링, 이벤트로 컨텍스트 잇기(EDD), 컨테이너·쿠버네티스.
- 신뢰성 — 사가와 보상, 아웃박스·멱등성, JetStream 내구 소비자, 죽은 편지함, 스키마 진화.
- 관찰과 운영 — CQRS 읽기 모델, 분산 추적, 메트릭과 SLO.
- 프런트와 실시간 — BFF, 풀스택 컴포즈, 전 계층 테스트, 실시간(SSE).
- 그리고 마지막 다섯 편 — "정확히 1000번째 당첨"이라는 기획서 한 줄을, 정확성 → 견고함 → 분산 → 다중 인스턴스 → 운영 → 보안 으로 끝까지 밀어붙였습니다.
이 긴 여정에서 되풀이된 태도가 몇 개 있습니다.
- 정직함. 모든 편의 끝에 "정직하게 — 공짜는 없다"를 뒀습니다. 모든 선택엔 대가가 있고, 그 대가를 명시하는 게 설계입니다.
- 터지기 전엔 안 보인다. 동시성 레이스도, JetStream 스트림 누락도, 종료 후 DLQ 오염도, 약한 키도 — 스스로 질문하고, 재현하고, 재보기 전엔 드러나지 않았습니다.
- 한 대의 정답이 여러 대의 정답은 아니다. 인메모리 카운터·리밋·폴링은 확장하는 순간 공유 상태·분산 조율·푸시로 다시 풀어야 했습니다.
핵심 한 줄 — 소프트웨어를 만든다는 건, 만드는 것에서 끝나지 않습니다. 정확하게 짜고, 터지지 않게 다듬고, 여러 대로 나누고, 돌아가는 걸 보이게 하고, 마지막으로 안전하게 지키는 것 — 그 전부가 하나의 서비스입니다. 이 시리즈가 그 "전부"를 한 번 통째로 걸어 본 기록이었기를 바랍니다.
읽어 주셔서 감사합니다. 코드는 언제나 github.com/kahnco/go-ddd-shop 에, 편별 태그로 남아 있습니다.
이번 편 전체 코드는 리포의
part-36태그에 있습니다. — 완결.