홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

프론트 컨테이너가 DB 비밀번호를 들고 있었습니다 — compose 리소스의 env 경계

정기창·2026년 8월 24일

배포된 컨테이너의 환경변수를 별생각 없이 한 번 출력해봤습니다. 프론트엔드 컨테이너에 키가 65개 들어 있었고, 그중에는 MySQL 비밀번호와 토큰 암호화 키가 있었습니다. 그 앱의 package.json에는 mysql 드라이버가 한 줄도 없습니다.

쓰지도 않는 자격증명을 들고 있었습니다

문제의 앱은 순수 프론트엔드입니다. 의존성을 세어보면 mysql2, drizzle, mongoose, ioredis가 전부 0개입니다. 그런데 컨테이너 안에는 이런 것들이 들어 있었습니다.

  • MYSQL_* 일체와 MONGODB_URI, REDIS_URL
  • 오브젝트 스토리지 액세스 키와 시크릿
  • 토큰 암호화 키
  • 외부 AI API 키, 슬랙 웹훅, 메일 발송 키

브라우저로 나가는 값이 아니니 괜찮지 않냐고 생각할 수 있습니다. 저도 잠깐 그렇게 넘어갈 뻔했습니다. 다만 프론트 컨테이너는 정적 파일 서버가 아니라 SSR 프로세스라, 그 안에서 도는 서버 코드는 process.env 전체를 읽을 수 있습니다. 공격 표면의 경계는 브라우저가 아니라 컨테이너입니다. 그 컨테이너에서 임의 코드 실행이 한 번 성립하면 DB 자격증명까지 한 번에 넘어갑니다.

compose의 environment 블록으로는 좁혀지지 않습니다

원인을 찾는 데 시간이 걸린 이유가 여기 있습니다. compose 파일만 보면 각 서비스가 자기 environment: 블록만 받는 것처럼 보입니다. 그런데 실제로는 그렇지 않았습니다.

이 배포 플랫폼은 compose 리소스에 등록된 환경변수를 그 안의 모든 서비스 컨테이너에 주입합니다.

즉 주입은 compose 파일 층이 아니라 그 위의 플랫폼 층에서 일어납니다. 워커가 필요해서 리소스에 등록한 DB 자격증명이, 같은 리소스에 들어 있다는 이유만으로 프론트에도 똑같이 들어갑니다. compose 파일 안에서 서비스별로 아무리 정성껏 나눠 적어도 이미 주입된 것을 빼는 수단이 없습니다.

그래서 범위를 실제로 좁히는 방법은 하나뿐이었습니다. 프론트를 그 compose에서 떼어내 독립 애플리케이션 리소스로 분리하는 것입니다. 설정으로 푸는 문제가 아니라 배치로 푸는 문제였습니다.

분리 후 실측

구간컨테이너 env 키자격증명 계열
분리 전65개14종
분리 후22개0개

남은 22개는 플랫폼 메타 5개, Node·Docker 베이스 이미지가 원래 넣는 9개, 그리고 프론트가 실제로 쓰는 공개 변수 7개입니다. 하나하나 세어보고 "이건 왜 여기 있나"를 답할 수 있게 되었습니다.

다만 숫자가 0이 됐다고 사건이 끝나는 것은 아닙니다. 노출됐던 이력 자체는 지워지지 않습니다. 그래서 14종을 회전 대상으로 따로 판단해야 했습니다. 값을 옮긴 것과 값을 무효화한 것은 다른 일입니다.

도메인을 옮기면서 다운타임을 0으로 두기

리소스를 분리한다는 것은 도메인이 붙는 대상을 바꾼다는 뜻이라, 그 순간이 가장 겁이 났습니다. 순서를 이렇게 잡았습니다.

  1. 새 앱을 먼저 띄우고 도메인을 새 앱에도 부여합니다. 잠시 라우터에 같은 도메인이 겹칩니다.
  2. 겹친 상태에서 응답을 확인합니다.
  3. 확인이 끝나면 구 컨테이너를 정지합니다.

겹치는 구간이 위험해 보이지만, 이 경우에는 안전합니다. 양쪽이 바이트 단위로 같은 번들을 서빙하고 있어서 어느 쪽이 응답하든 결과가 같기 때문입니다. 2초 간격으로 200회 프로브를 돌려 전부 200을 받은 것을 확인하고 넘겼습니다.

빠진 빌드 인자가 없다는 증거

프론트를 새로 빌드해 옮길 때 가장 조용하게 망가지는 것이 빌드 타임 주입값입니다. 빌드 인자 하나가 빠지면 번들 안의 API 주소가 비어버리는데, 배포는 성공하고 헬스체크도 통과합니다. 나중에 사용자가 발견합니다.

그래서 새 앱과 운영의 번들 크기를 라우트별로 비교했습니다.

/        870,001 bytes
/login   861,695 bytes
/signup  859,809 bytes   ← 세 라우트 모두 운영과 바이트 단위 동일

빌드 인자가 하나라도 빠졌다면 인라인된 문자열 길이가 달라져 이 숫자가 어긋납니다. 같다는 것은 7종이 전부 들어갔다는 뜻입니다. 눈으로 페이지를 열어보는 것보다 이쪽이 훨씬 결정적이었습니다.

이름은 일부러 바꾸지 않았습니다

프론트가 빠졌으니 남은 compose 리소스는 이제 워커 하나뿐입니다. 이름이 어색해졌지만 그대로 뒀습니다. 플랫폼에서 리소스를 실제로 개명하지 않은 채 코드 쪽 매트릭스만 고치면, 그 순간부터 그것이 드리프트이기 때문입니다. 이름의 어색함은 읽는 사람이 감수하면 되지만, 실물과 문서의 불일치는 다음 사람이 잘못된 대상을 배포하게 만듭니다.

대신 compose 파일에 경고를 한 줄 남겼습니다. 이 리소스에 서비스를 다시 추가하면 같은 유출이 그대로 재발합니다. 구조가 아니라 규율로만 막히는 자리는 이렇게 적어두는 것 말고 할 수 있는 게 없습니다.

남는 교훈

환경변수의 경계를 저는 제가 정한다고 생각했습니다. 실제로는 그것을 주입하는 층이 정합니다. 플랫폼이 리소스 단위로 주입한다면 제 경계도 리소스 단위이고, 그보다 잘게 나누고 싶으면 리소스를 나누는 수밖에 없습니다.

그리고 "필요한 것만 준다"는 원칙은 선언으로 지켜지지 않는다는 생각이 들었습니다. 이번 건도 문서에는 필요한 값만 적혀 있었습니다. 실제로 무엇이 들어 있는지는 컨테이너에 들어가서 세어봐야만 알 수 있었습니다. 배포된 컨테이너의 env를 한 번 출력해보는 데는 1분이 걸립니다. 저는 그걸 몇 달 동안 해보지 않았습니다.

CoolifyDocker Compose보안환경변수배포 자동화시크릿 관리

관련 글

삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다

서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.

관련도 93%

배포 실패 로그를 추적하다 크립토마이너를 발견한 이야기

Coolify에서 NestJS 배포가 타임아웃으로 실패했습니다. 서버 리소스를 확인하려고 docker stats를 열었는데, Next.js 프론트엔드 컨테이너가 CPU 123%, 메모리 2.41GB를 점유하고 있었습니다. 컨테이너 안에서 발견한 정체불명의 바이너리와 마이닝 풀 연결까지, 배포 실패에서 크립토마이너를 발견하기까지의 추적 기록입니다.

관련도 93%

SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들

글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.

관련도 93%