본문으로 건너뛰기

.env에서 Secret Manager로, 미루던 이관을 시작하게 된 일주일

.env 파일은 오래 쓴 만큼 손에 익어 있었습니다. 로컬 개발부터 배포 자동화까지 전부 그 위에 얹어 두었습니다. 언젠가는 Secret Manager로 옮겨야겠다는 생각만 몇 달째 하고 있었습니다. 그 “언젠가”를 앞당긴 건 gcloud 로그 파일이었습니다. 그 뒤로 시크릿 저장소를 옮기고 VM의 서비스 계정을 갈아 끼우기까지 일주일이 걸렸습니다.

.env 방식과 Secret Manager 방식은 무엇이 다른가

.env 방식: 값을 파일로 복사한다

.env 방식에서 시크릿은 KEY=value 줄이 들어 있는 텍스트 파일입니다. 앱은 기동할 때 dotenv 같은 로더로 이 파일을 읽어 process.env에 넣습니다. 코드는 process.env.KEY로 값을 꺼냅니다. Next.js는 이 로더가 내장돼 있어 .env.local, .env.production 같은 파일을 환경에 따라 알아서 읽습니다.

.env.production (로컬)  →  배포 스크립트가 복사  →  .env (서버)  →  앱이 process.env로 읽음

저장소가 파일이라 전달 방법도 파일 복사입니다. 개발자 PC에 운영용 .env가 있고 배포 스크립트가 그 파일을 서버에 올리거나 내용을 배포 명령의 인자에 실어 보냅니다. 그래서 값의 사본이 최소 세 군데에 생깁니다. 개발자 PC, 서버 디스크, 그리고 그 사이를 오간 배포 명령과 로그에까지 사본이 남습니다. 사본마다 접근 통제가 다릅니다. 누가 언제 읽었는지는 어디에도 남지 않습니다.

장점은 단순하다는 것입니다. 의존하는 외부 서비스가 없고 네트워크가 끊겨도 앱이 작동을 합니다. 로컬 개발과 운영이 같은 코드 경로를 쓰는 것도 장점입니다. 이 단순함 때문에 대부분 여기서 시작하고 한참 동안 문제없이 굴러갑니다.

Secret Manager 방식: 값을 API로 꺼내 간다

Secret Manager는 GCP가 제공하는 시크릿 저장 서비스입니다. (AWS에도 Secrets Manager라는 거의 같은 이름의 비슷한 서비스가 있습니다.) 시크릿 하나는 이름이 붙은 컨테이너이고 그 안에 값이 버전(version) 으로 쌓입니다. 값을 바꾸면 새 버전이 생기고 옛 버전은 비활성화하거나 파기할 수 있어서 교체 이력이 그대로 남습니다.

값은 API를 호출해서 읽습니다. 호출하는 쪽은 자기 신원, 보통은 서비스 계정(service account) 으로 인증합니다. 신원에 secretmanager.secretAccessor 역할이 있어야만 값을 받을 수 있습니다. 이 역할은 프로젝트 전체가 아니라 시크릿 하나 단위로 줄 수 있습니다. 값을 읽을 때마다 감사 로그를 남기게 설정할 수도 있습니다.

Secret Manager  →  (IAM 확인)  →  런타임이 API로 받음  →  process.env 또는 메모리

앱까지 전달하는 경로는 런타임에 따라 다릅니다. Cloud Functions와 Cloud Run은 배포 옵션 --set-secrets로 시크릿 이름을 env 변수에 연결하면 플랫폼이 기동 시점에 값을 넣어 줍니다. 값은 배포 명령에도, 배포 설정에도 들어가지 않고 이름만 들어갑니다. 반면 Compute Engine VM처럼 플랫폼이 대신 넣어 주지 않는 곳에서는 앱이나 기동 스크립트가 직접 API를 호출해야 합니다. Cloud Functions, Cloud Run, Compute Engine을 모두 쓰고 있었기에 이 방법을 모두 고려해야 했습니다.

대신 앱 기동이 외부 API에 의존하게 됩니다. Secret Manager가 응답하지 않으면 앱이 못 뜨고 콜드 스타트에도 조회 시간이 더해집니다. 런타임에 신원과 권한을 붙이는 설정이 필요하고 로컬 개발에서는 여전히 다른 수단이 있어야 합니다.

한 줄로 비교하면

.env 방식Secret Manager 방식
값이 있는 곳파일 (PC, 서버 디스크)중앙 저장소 하나
앱에 전달파일 복사API 호출 (플랫폼 주입 또는 직접 호출)
접근 통제파일 권한IAM, 시크릿 단위
누가 읽었는지기록 없음감사 로그
교체사본 전부 찾아서 수정새 버전 추가, 옛 버전 파기
외부 의존없음기동 시 API 필요
로컬 개발그대로별도 폴백 필요

핵심 차이는 “값을 복사해서 나눠 주는가, 이름을 나눠 주고 값은 한 곳에서 꺼내 가는가”입니다. 복사본이 없으면 유출 지점도 줄어들고 교체도 한 번으로 끝납니다. 대신 꺼내 가는 쪽에 신원과 권한이 있어야 합니다.


미루던 이관을 시작하게 된 계기

출발점은 배포 알림이었습니다. 운영 배포가 끝나면 메일로 알림을 받고 싶었습니다. 기왕이면 배포 시각과 소요 시간, 배포된 커밋 같은 정보도 함께 남기고 싶었습니다. 그래서 배포 스크립트를 손보다가 Cloud Functions 배포 명령을 오랜만에 다시 읽게 됐습니다.

gcloud functions deploy crawler \
  --set-env-vars KEY_A=...,KEY_B=...

이 명령은 env 값을 명령줄 인자(argv)에 그대로 싣습니다. 그런데 gcloud는 자신이 실행한 명령을 ~/.config/gcloud/logs/ 아래에 로그로 남깁니다. 확인해 보니 그 로그 파일에 env 키 몇 개의 값이 평문으로 들어 있었습니다. 로컬 파일이고 외부로 나간 정황도 없었습니다. 그래도 값이 지나가는 경로를 코드와 설정 파일 안에서만 생각하고 있었다는 걸 알게 된 순간이었습니다. 값을 명령줄에 실어 보내면 그 값이 어디에 기록될지는 도구가 정합니다.

당장은 --env-vars-file로 바꾸고 출력을 막아 같은 경로로 값이 새지 않게 했습니다. 그런데 이건 경로 하나를 막은 것일 뿐입니다. 값이 로컬 파일에 평문으로 있어야 배포가 되는 구조는 그대로였습니다. 미루던 이관을 지금 하기로 했습니다.


결정 1 - 교체만 할 것인가, 이관까지 할 것인가

로그에 남은 값은 교체하기로 했습니다. 문제는 교체하는 김에 저장 방식까지 바꿀 것인가였습니다.

같은 값을 참조하는 곳을 세어 보니 DB 연결 문자열 하나가 여러 저장소의 env 파일에 흩어져 있었습니다. 교체만 하면 그 파일을 전부 손으로 고쳐야 합니다. 하나라도 놓치면 그 서비스가 DB에 못 붙습니다. 다음 교체 때도 똑같은 일을 반복하게 됩니다.

그래서 자격증명 교체(credential rotation) 와 이관을 한 번에 하기로 했습니다. 어차피 모든 참조 지점을 한 번은 건드려야 하니 그 김에 값 대신 Secret Manager의 시크릿 이름을 참조하도록 바꾸는 편이 총비용이 적었습니다. 이미 모니터링용 배치 잡 하나가 Secret Manager를 쓰고 있어서 선례도 있었습니다.


결정 2 - 무엇을 옮길 것인가

env 파일 전체의 키를 전수 조사했습니다. 값은 보지 않고 키 이름과 성격만 뽑았습니다. 키는 세 부류로 나뉘었습니다.

옮길 것은 유출 시 피해가 직접 발생하는 값입니다. DB 연결 문자열, 세션 서명 키, 외부 API 키, 메일 계정 비밀번호, 그리고 개인정보 암호화 키가 여기 들어갑니다.

옮겨도 소용없는 것도 있었습니다. NEXT_PUBLIC_* 접두사가 붙은 값은 빌드 시점에 클라이언트 번들로 인라인되므로 Secret Manager에 넣어도 결국 브라우저에 그대로 내려갑니다. 애초에 공개 식별자로 다뤄야 하는 값입니다.

옮기지 않을 것은 설정값입니다. 로그 레벨, 호스트 이름, 버킷 이름, 모델 이름 같은 값은 공개돼도 피해가 없습니다. Secret Manager에 넣으면 조회 비용과 콜드 스타트만 늘어납니다.

위험도로 나눠놓고 보니 예상과 달리 DB 연결 문자열보다 개인정보 암호화 키가 위에 있었습니다. DB는 IP 허용 목록이라는 2차 방어선이 있습니다. 하지만 암호화 키에는 그런 방어선이 없어서 키가 새면 DB 덤프 하나로 저장된 전화번호와 이메일이 전부 복호화됩니다. 게다가 이 키는 바로 교체할 수 없습니다. 바꾸면 기존 암호문을 읽지 못하므로 전체 데이터를 먼저 재암호화해야 합니다. 결국 교체 없이 이관만 할 수 있는 값이었습니다. 그래서 평문으로 남아 있는 상태가 더 부담스러웠습니다.

이관 단위는 키 하나당 Secret 하나로 잡았습니다. JSON으로 묶어 하나에 넣는 방식도 있었습니다. 하지만 그러면 한 값만 바꿔도 전체 버전이 올라가고 권한도 모든 값을 주거나 하나도 안 주거나 둘 중 하나밖에 할 수 없습니다. 개별로 두면 크롤러에는 DB 문자열만, 웹 앱에는 그보다 많이, 이렇게 리소스 단위로 권한을 좁힐 수 있습니다. 비용은 버전당 월 몇십 원 수준이라 부담이 되진 않았습니다.


결정 3 - VM에서 시크릿을 어떻게 읽을 것인가

Cloud Functions 쪽은 쉬웠습니다. --set-secrets 옵션이 있어서 배포 명령 몇 줄로 끝났습니다. 값이 argv에 실리지 않으니 처음 발견한 경로로도 더는 새지 않았습니다.

문제는 Compute Engine VM 위에서 PM2로 도는 Next.js 앱이었습니다. 이쪽은 배포 스크립트가 .env 파일을 만들어 VM에 복사하는 구조라 시크릿을 주입해 주는 수단이 없습니다. 선택지는 셋이었습니다.

방식로컬 평문VM 디스크 평문비용
① 그대로 둔다남음남음없음
② 배포 스크립트가 Secret을 받아 .env를 만든다없어짐남음스크립트 수정
③ 앱이 부팅 시 직접 읽는다없어짐없어짐앱 기동 경로 수정, VM 권한

②가 비용 대비 이득이 가장 커 보였습니다. 로컬에서 운영 값이 든 파일이 사라지고 앱 코드를 건드리지 않아도 됩니다. 그런데 VM 디스크에 여전히 평문이 남는다는 점이 마음에 걸려서 ③을 시도해 보기로 했습니다. dev 환경부터 해 보고 막히면 ②로 물러나기로 했습니다.

③의 구현 자체는 깔끔했습니다. DB 연결 모듈이 로드 시점에 top-level await로 연결을 시도하므로 앱 코드 안에서 읽으면 너무 늦었습니다. 그래서 Node의 --import 옵션으로 프리로드(preload) 모듈을 하나 두었습니다. 프리로드 모듈은 앱 코드가 로드되기 전에 먼저 실행되는 모듈입니다.

// Secret을 읽는 preload 모듈
// 이미 process.env에 값이 있으면 건너뛴다 (로컬 개발은 .env 그대로)
// metadata server가 없으면 조용히 통과한다 (로컬 빌드도 안 깨진다)
const token = await fetchMetadataToken();
if (token) {
  for (const key of SECRET_KEYS) {
    if (!process.env[key]) process.env[key] = await accessSecret(key, token);
  }
}

메타데이터 서버에서 토큰을 받아 Secret Manager REST API를 fetch로 호출하므로 추가 패키지가 필요 없습니다. 값이 이미 env에 있으면 건너뛰는 폴백 덕에 로컬 개발과 로컬 빌드는 기존 .env로 그대로 돌릴 수 있습니다.


결정 4 - 스코프를 넓힐 것인가, 권한을 좁힐 것인가

그런데 dev VM에서부터 바로 막혔습니다. VM의 서비스 계정에 secretAccessor 역할을 줬는데도 403이 났습니다.

원인은 액세스 스코프(access scope) 였습니다. Compute Engine VM에는 IAM 역할과 별개로 인스턴스 생성 시 정해지는 레거시 스코프가 있습니다. 이 스코프는 IAM보다 먼저 걸리는 상한으로 작동합니다. VM에는 로깅·모니터링·읽기 전용 스토리지 정도의 좁은 스코프만 걸려 있었습니다. 그런데 Secret Manager에는 전용 스코프가 없어서 스코프가 cloud-platform이어야만 접근할 수 있습니다.

그렇다고 스코프를 cloud-platform으로 넓히기만 하면 되는 일도 아니었습니다. VM이 프로젝트 기본 컴퓨트 서비스 계정을 쓰고 있었는데 그 계정에는 roles/editor가 붙어 있었습니다. 지금까지는 좁은 스코프가 그 넓은 IAM을 눌러 주고 있었던 셈입니다. 스코프를 여는 순간 VM 위의 앱이 침해되면 프로젝트의 거의 모든 리소스를 만들고 지울 수 있게 됩니다.

여기서 좀 더 깊게 작업을 진행해야 했습니다. 시크릿을 이관하려면 스코프를 열어야 하고 스코프를 열려면 서비스 계정부터 바꿔야 했습니다. 바뀌는 설정은 다음과 같습니다.

이전이후
스코프좁음cloud-platform
서비스 계정기본 컴퓨트 SA전용 SA
IAMroles/editor로그 쓰기, 메트릭 쓰기, 트레이스, 특정 Secret 읽기

결과적으로 스코프는 넓어지고 실제 권한은 좁아졌습니다. 레거시 스코프는 IAM 이전 시대의 유물이라 Google 문서도 스코프는 cloud-platform으로 두고 권한은 IAM으로 제어하라고 안내합니다. 기본 SA에 editor가 붙어 있던 문제는 시크릿 이관과 무관하게 언젠가 정리해야 했으므로 이번에 같이 정리했습니다.

SA를 바꾸기 전에 앱이 VM의 SA로 실제로 무엇을 하는지 전부 확인해야 했습니다. 문서에는 “앱은 별도 키 파일을 쓰고 VM SA는 안 쓴다”고 적혀 있었습니다. 하지만 문서와 현실이 어긋난 경우를 이 작업 중에 여러 번 봤기 때문에 코드로 다시 확인했습니다. GCS 업로드, Cloud Logging, 트레이스 중 하나라도 빠뜨리면 무언가 해당 서비스를 사용하는 기능이 죽어버릴 것입니다. 다행히 문서가 맞았습니다.


결정 5 - 평문은 어디까지 없앨 것인가

이쯤에서 “어디든 평문으로 남는 건 문제 아닌가”라는 생각이 들었습니다. 맞는 말이지만 완전히 없앨 수 있는 층과 그렇지 않은 층을 구분하지 않으면 과잉 투자가 됩니다. 실제로 값이 어디에 남는지 층별로 나눴습니다.

지점이관 전③ 이관 후
로컬 개발 머신의 .env.production평문없어짐
배포 명령과 gcloud 로그평문없어짐
VM 디스크의 .env평문없어짐
/proc/<pid>/environ노출그대로
프로세스 메모리노출그대로

/proc/<pid>/environ과 프로세스 메모리의 노출은 process.env를 쓰는 한 없어지지 않습니다. 없애려면 앱 코드가 process.env를 거치지 않고 모듈 안에서 직접 값을 받아 쓰도록 바꿔야 합니다.

이 층까지 막아서 줄어드는 위협을 따져 보면 그럴 이유가 약합니다. /proc/<pid>/environ을 읽으려면 이미 그 VM에 같은 사용자 권한으로 들어와 있어야 합니다. 그 상태라면 공격자는 메타데이터 서버에서 SA 토큰을 받아 Secret Manager를 직접 호출할 수도 있습니다. 이 층을 막아도 방어선이 실질적으로 올라가지 않습니다.

반면 디스크 파일 제거는 다른 위협을 막습니다. 디스크 스냅샷과 백업 유출, 실수로 인한 복사, 퇴사자의 로컬 사본, 그리고 이번처럼 로그에 딸려 나가는 경우를 막을 수 있습니다.

그래서 목표를 “영구 저장 매체에서 제거”로 잡았습니다. 메모리까지 포함한 완전 제거는 HSM 같은 다른 급의 아키텍처가 필요해서 이 규모에는 맞지 않습니다. 선을 어디까지 그을지를 이 작업에서 가장 오래 고민했습니다.


컷오버

스코프를 바꾸려면 운영 VM을 반드시 정지해야 했습니다. 스케줄러 같은 크론잡이 주기적으로 이 앱을 호출하고 있어서 아무 때나 내릴 수는 없었습니다. 사용자 접속 추이와 잡의 실행 시각을 전부 뽑아 가장 긴 빈 시간대를 찾았고 그 시간 안에서 순서를 정했습니다.

처음에는 시크릿을 먼저 주입하고 스코프를 바꾸려 했습니다. 하지만 스코프가 없으면 VM은 Secret Manager에 읽기도 쓰기도 못 합니다. 스코프 변경이 먼저여야 했습니다. 다운타임이 앞으로 오는 게 오히려 안전하기도 합니다. 스코프 부여에 실패해도 바뀐 건 없을 거니까요.

실제 다운타임은 VM 스코프를 바꾸는 동안뿐이라 잡아 둔 시간 안에서 여유 있게 끝났습니다. 시크릿을 주입해 지문을 대조했습니다. 재배포 후 VM의 .env에서 평문 시크릿은 0개가 됐습니다. 감사 로그로 앱이 빌드와 기동 시점에 실제로 Secret Manager를 병렬로 읽는 것까지 확인했습니다.

배포 직후 최신 버전을 DB에 기록하는 자동화 스크립트를 한 번 놓치긴 했습니다. 앱은 프리로드로 시크릿을 받지만 이 스크립트는 여전히 .env에서 DB 문자열을 읽고 있어서 .env를 걷어내자마자 실패했습니다. 이 스크립트에도 같은 폴백을 넣어 해결했습니다.


옮긴 뒤에 알게 된 것

이관 직후에 Secret Manager에서 권한 거부 오류 알림이 왔습니다. 확인해 보니 VM의 SA가 자기 권한이 없는 시크릿을 읽으려다 거부된 건이었습니다. 검증 과정에서 제가 낸 요청이었고 설계대로 거부된 게 맞았습니다. 그 시크릿의 secretAccessor는 해당 함수의 전용 SA에만 줬고 VM에는 주지 않았기 때문입니다. 시크릿 단위 권한으로 접근이 실제로 격리되고 함께 켠 감사 로그가 비인가 접근을 잡아낸다는 것을 이 로그로 확인했습니다. 프로젝트 단위로 권한을 줬다면 그냥 읽혔을 겁니다.

로컬에서 앱이 뜨지 않는 문제도 있었습니다. 프리로드 모듈을 기존 OpenTelemetry 등록 모듈과 함께 --import 두 개로 걸어 뒀습니다. 그런데 Next.js가 워커를 띄울 때 NODE_OPTIONS를 파싱해서 다시 직렬화하는 과정에서 두 경로가 하나로 붙어 버렸습니다. --import= 형식으로 바꾸면 앞의 모듈이 무시되어 버립니다. 시크릿 프리로드가 없어진 줄도 모른 채 DB 연결을 하려고 했던 것입니다. preload.mjs 하나로 합쳐 해결했습니다.


돌아보며

배포 알림 하나 붙이려던 작업이 여기까지 온 이유를 생각해 보면 각 단계가 다음 단계의 전제였습니다. 로그에 남는 경로를 막으려니 저장 방식을 바꿔야 했습니다. 저장 방식을 바꾸려니 VM 권한을 열어야 했고 권한을 열려니 서비스 계정부터 정리해야 했습니다. 어느 하나만 하고 멈출 수도 있었습니다. 하지만 그러면 “나머지는 나중에”가 또 남았을 겁니다. 몇 달을 미룬 이관이 결국 일주일 안에 끝난 건 그 “나중에”를 더 두고 싶지 않아서였습니다.

살짝 남은 것도 있습니다. 로컬 개발용 .env는 아직 파일입니다. 스케줄러 인증 토큰도 있었는데, 이건 나중에 아예 OIDC로 전환해 값 자체를 없앴습니다.

보안 감사를 진행할 땐 코드와 설정 파일만 보면 안 됩니다. 도구가 남기는 로그, 배포 스크립트 옆의 작은 유틸리티까지 값이 지나가는 모든 경로를 체크해야 합니다. 이번에도 역시 예상치 못한 곳에 있어선 안 될 값이 발견된 것에서 시작되었습니다.

참고 자료