환경변수 보안 관리, .env 실수부터 Vault·Secrets Manager까지

목차

remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - GITHUB PUSH PROTECTION
remote:   —— AWS Access Key ID ————————————————————————————
remote:    locations:
remote:      - commit: 7c4a9e2f1b3d8a5c6e9f0a1b2c3d4e5f6a7b8c9d
remote:        path: services/worker/.env.local:4
remote:    (?) To push, remove secret from commit(s) or follow URL to allow secret.

코드 작성 중 PR을 올리다 막혔다. .gitignore.env는 분명히 있었는데, 누군가 .env.local을 새로 만들면서 패턴이 빠졌다. 환경변수 보안 관리는 도구만 깔면 되는 줄 알았는데, 실제로는 사람이 새 파일을 만드는 순간마다 다시 깨지는 문제다. 프론트엔드만 하던 시절에는 NEXT_PUBLIC_ 프리픽스만 신경 쓰면 됐지만, 백엔드로 넘어온 뒤로는 비밀의 무게가 완전히 달라졌다. 오늘 우연히 발견한 것 몇 개를 정리한다.

오늘 한 것 — .env 누수 경로 다시 그리기

이처럼, GitHub Push Protection이 막아준 덕분에 사고는 안 났다. 정작 무서운 건 막힌 지점이 아니라, 막히지 않고 통과했을 수도 있는 경로들이다. 프론트엔드에서는 .env가 빌드 타임에 번들에 박혀 브라우저로 나가는 문제가 컸다. 백엔드는 반대다. 파일은 서버에 남고, 그 서버에 누가 들어왔는지가 핵심이다. 같은 .env인데 위협 모델이 완전히 다르다.

.gitignore로 막을 수 없는 케이스

오늘 정리한 누수 경로는 7가지였다. Git 커밋이 가장 유명하지만, 실무에서 더 자주 새는 건 그 외 경로다.

경로 잘 알려진 정도 실제 빈도 1차 방어
Git 커밋 (.env add) 매우 높음 중간 gitignore + Push Protection
Docker 이미지 레이어 중간 높음 --secret 마운트
CI 로그 출력 낮음 매우 높음 ::add-mask::, set-output 금지
백업 파일 (.env.bak) 낮음 높음 gitignore 패턴 확장
에디터 임시 파일 매우 낮음 중간 .env* 패턴
컨테이너 inspect 낮음 중간 런타임 주입, 이미지 미포함
클립보드/스크린샷 의외로 많음 마스킹 도구, 검토 습관

특히 Docker 레이어는 프론트 시절에 한 번도 신경 쓴 적이 없었다. COPY .env . 한 줄이면 이미지 안에 영구히 박힌다. docker history로 누구나 볼 수 있다. 멀티스테이지 빌드를 써도 마지막 스테이지에 같은 짓을 하면 의미가 없다.

.gitignore 패턴 다시 짜기

따라서, 흔히 보는 .env만으로는 부족하다. 오늘 팀 레포에 적용한 패턴이다.

# secrets
.env
.env.*
!.env.example
!.env.template

# 백업/임시
*.env.bak
*.env.old
*.env.local
*.env.*.local

# 에디터 스왑
.env.swp
.env~

# 시크릿 디렉토리
/secrets/
/.secrets/

!.env.example로 예외를 두는 이유는, 신규 입사자가 어떤 키가 필요한지 알 수 있어야 하기 때문이다. 값은 xxxxx로 두되 키 이름은 노출한다. 키 이름 자체가 비밀인 경우는 거의 없다(다만 내부 서비스 도메인이 키 이름에 박혀 있다면 그건 별도로 마스킹한다).

새로 알게 된 것 — 환경변수 보안 관리의 단계별 현실

특히, 오늘 가장 크게 바뀐 인식은, "환경변수 보안 관리"가 단일한 한 가지 기법이 아니라는 점이다. 팀의 규모와 비밀의 민감도에 따라 단계가 있다. 프론트엔드 시절에는 그냥 Vercel 대시보드에 박아 넣으면 끝이었는데, 백엔드 멀티 서비스 환경에서는 그 한 단계로는 안 됐다.

4단계 분류

지금까지 거쳐온 단계를 정리해보면 이렇다.

단계 저장 위치 적합한 규모 약점
1. 로컬 .env 파일 개발자 노트북 1인 사이드 프로젝트 동기화·유출
2. 플랫폼 환경변수 Vercel/Heroku 콘솔 5인 이하 단일 서비스 감사 추적 미흡
3. CI 시크릿 + 런타임 주입 GitHub Actions + 서버 env 10인 이하 멀티 서비스 회전 자동화 어려움
4. 전용 시크릿 매니저 AWS Secrets Manager, Vault 그 이상, 또는 규제 산업 운영 부담

또한, 오늘 발견한 것: 많은 팀이 2단계에 머무르면서 4단계가 필요하다고 착각한다. 반대도 많다. 5명 짜리 스타트업이 Vault를 깔아놓고 정작 키 회전은 1년에 한 번도 안 한다. 도구를 도입해도 운영 루틴이 없으면 1단계와 별 차이가 없다.

회전(Rotation)이 진짜 차이

게다가, 1단계와 4단계의 결정적 차이는 암호화도 접근 제어도 아니다. 회전이다. .env 파일에 박힌 API 키는 사실상 영원히 안 바뀐다. 누가 한 번 보면 그 키는 영원히 그 사람 손에 있다고 봐야 한다. Secrets Manager나 Vault는 회전 자체가 기능이다.

# AWS Secrets Manager — Python boto3 예시
import boto3
import json
from functools import lru_cache

# 캐시: 매 요청마다 호출하면 비용·레이턴시 모두 손해
@lru_cache(maxsize=32)
def get_secret(secret_name: str, region: str = "ap-northeast-2") -> dict:
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

# 사용
db_creds = get_secret("prod/db/main")
conn = psycopg2.connect(
    host=db_creds["host"],
    user=db_creds["username"],
    password=db_creds["password"],
)

게다가, 회전 주기를 30일로 설정하면 Secrets Manager가 자동으로 RDS 비밀번호를 바꿔준다. 애플리케이션은 캐시 TTL이 만료된 다음 호출에서 새 값을 가져온다. 처음 봤을 때 "이게 왜 기본이 아니지" 싶었다.

Vault는 언제 쓰나

예를 들어, AWS만 쓰는 팀이라면 Secrets Manager만으로 충분한 경우가 많다. Vault를 고려할 시점은 보통 세 가지다.

첫째, 멀티 클라우드 환경. AWS + GCP + 온프레미스가 섞이면 Vault가 단일 진실 공급원 역할을 한다. 둘째, 동적 시크릿이 필요한 경우. Vault는 요청 시점에 짧은 수명의 DB 자격증명을 생성한다. TTL이 1시간이면, 유출돼도 1시간 뒤 자동 무효화된다. 셋째, 규제. PCI-DSS, HIPAA 같은 감사가 들어오는 환경.

# Vault dynamic database secrets — 설정 예
path "database/creds/readonly" {
  capabilities = ["read"]
}

# 클라이언트가 호출하면 매번 새 계정 생성
# {"username": "v-token-readonly-x7k2", "password": "...", "lease_duration": 3600}

Vault는 운영 부담이 결코 작지 않다. HA 클러스터, unseal 절차, audit 로그 보관까지 손이 많이 간다. 5인 팀이 도입하면 Vault 운영자가 따로 필요할 정도다.

코드 — 실전 적용 3종 세트

예를 들어, 오늘 실제 레포에 머지한 변경 세 가지를 그대로 옮긴다. 모두 환경변수 보안 관리의 가장 흔한 구멍을 막는다.

1. pre-commit으로 시크릿 자동 차단

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']

gitleaks는 정규식 기반으로 AWS 키, Stripe 키, GitHub 토큰 같은 알려진 패턴을 잡는다. detect-secrets는 엔트로피 기반이라 무작위 문자열도 탐지한다. 둘 다 깔면 false positive가 좀 늘지만, baseline 파일로 한 번 정리하면 그 뒤로는 새 시크릿만 잡힌다. (출처: gitleaks v8.18.4 release)

2. Docker 빌드에서 --secret 마운트

# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder

# BuildKit secret mount — 이미지 레이어에 남지 않는다
RUN --mount=type=secret,id=pip_token \
    PIP_TOKEN=$(cat /run/secrets/pip_token) \
    pip install --index-url https://__token__:${PIP_TOKEN}@pypi.internal/simple/ -r requirements.txt

FROM python:3.12-slim
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# 런타임 시크릿은 ENV로 박지 않는다. 컨테이너 실행 시 주입.

그런데, 빌드 명령은 이렇게 호출한다.

# 빌드 시 시크릿을 마운트로만 전달
DOCKER_BUILDKIT=1 docker build \
  --secret id=pip_token,src=$HOME/.pypi_token \
  -t myapp:latest .

# 이미지 안에 시크릿이 없는지 확인
docker history myapp:latest --no-trunc | grep -i token
# (아무것도 안 나와야 정상)

처음에 ARG로 토큰을 받았다가 이미지 안에 그대로 박혀버린 적이 있다. ARG는 빌드 인자라서 안전한 줄 알았는데, RUN echo $ARG가 한 줄이라도 있으면 그 레이어에 평문으로 남는다. --secret은 마운트 형태라서 빌드가 끝나면 사라진다.

3. GitHub Actions에서 OIDC로 키 없이 AWS 접근

# .github/workflows/deploy.yml
permissions:
  id-token: write   # OIDC 토큰 발급에 필요
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: ap-northeast-2
          # access key 없음. OIDC로 일시 자격증명 받는다.

      - name: 시크릿 조회 후 배포
        run: |
          # GitHub Secrets에 AWS 키를 안 박아도 된다
          aws secretsmanager get-secret-value \
            --secret-id prod/api/keys \
            --query SecretString --output text > /tmp/secrets.json

또한, 이 패턴을 적용한 뒤로 GitHub Secrets에서 AWS_ACCESS_KEY_ID를 완전히 지웠다. 키 회전 작업이 통째로 사라졌다. (출처: aws-actions/configure-aws-credentials v4 README)

메모 — 새로 추가한 검토 체크리스트

오늘 정리하면서 팀 위키에 박은 항목들이다. PR 리뷰할 때 한 번씩 보는 용도.

  • .env* 패턴이 .gitignore에 있는지, 예외(!.env.example)가 명시적인지
  • 새로 추가된 Dockerfile에 COPY .envENV SECRET= 라인이 없는지
  • docker history <image> --no-trunc로 마지막 빌드 검사
  • CI 워크플로우의 run: 블록에서 시크릿을 echo하거나 cat하지 않는지
  • 운영 비밀번호가 90일 이상 회전되지 않았는지 (Secrets Manager 콘솔 확인)
  • 신규 입사자에게 .env.example만 공유하고 실제 값은 Secrets Manager 경로를 알려주는지

반면, 이전 글에서 본 사람도 있을 텐데, Docker BuildKit 공식 문서HashiCorp Vault Dynamic Secrets 가이드는 한 번씩 읽어볼 만하다. 2026년 6월 기준으로 둘 다 안정 버전 문서가 최신화되어 있다.

그러나, 오늘의 액션 아이템 세 개만 남긴다. 첫째, 지금 당장 gitleaks를 pre-commit에 추가해라. 5분이면 된다. 둘째, 운영 환경에서 마지막으로 회전한 시크릿이 언제인지 한 번 확인해라. 셋째, Dockerfile에 COPY .envARG 기반 토큰 전달이 있는지 grep 한 번 돌려라.

예를 들어, 개인적으로는 작은 팀일수록 Vault보다 AWS Secrets Manager + OIDC 조합이 비용 대비 더 나은 것 같다.

관련 글