개인 모노레포에 앱이 하나씩 늘면서 .env 파일도 앱마다 늘었어요. .env 는 git 에 올리지 않으니, 이 파일들은 맥북 한 대에만 있었습니다. 맥북을 잃어버리거나 다른 PC 에서 작업하려고 하면, 저장소를 clone 해도 앱을 띄울 수가 없는 상태였죠.
그래서 .env 를 S3 버킷에 보관하고 받아오는 작은 도구를 만들었어요. bash 스크립트 하나(약 200줄)에 pnpm vault 라는 이름을 붙였습니다.
pnpm vault status # 전체 동기화 상태
pnpm vault diff lol-watch # 키 단위 차이 (값은 안 찍음)
pnpm vault pull lol-watch # 금고 → 로컬
pnpm vault push lol-watch production # 로컬 → 금고 (차이를 보여 주고 확인)
pnpm vault pull --all # 새 PC: 전부 받기
다만 도구보다 먼저 한 일이 있어요. .env 에 뭘 넣을지부터 정리했습니다.
1. 먼저 값을 나눈다 — 비밀인가, 아닌가
예전 .env 엔 API 키 옆에 포트, 폴링 간격, 리전 같은 값이 같이 들어 있었어요. 이걸 그대로 금고에 올리면 금고가 "설정 전부"를 들고 있게 되고, 무엇이 진짜 비밀인지 흐려져요.
기준은 하나예요. 유출되면 남이 쓸 수 있는가.
| 분류 | 두는 곳 | 예 |
|---|---|---|
| 비밀 | .env → S3 금고 | API 키, 토큰, 웹훅 URL, 비밀번호 든 DB 주소 |
| 비밀 아님 | 코드 기본값 (커밋) | 포트, 경로, 리전, 폴링 간격 |
| 비밀 아님 + 운영만 다름 | 배포 정의 (compose.prod.yml 등) | APP_ENV, 컨테이너 안 DB 경로 |
비밀 아닌 값을 .env 에 넣지 않는 데엔 이유가 하나 더 있어요. .env 에 POLL_INTERVAL_SEC=300 이 있으면, 나중에 코드 기본값을 120 으로 바꿔도 .env 의 300 이 계속 이겨요. 코드를 고쳤는데 동작이 안 바뀌는 거죠. 그래서 비밀 아닌 값은 코드에 기본값으로 두고, 그게 정본이 되게 했어요.
class Config:
riot_api_key: str | None = os.getenv("RIOT_API_KEY") # 비밀 — 기본값 없음
riot_platform: str = os.getenv("RIOT_PLATFORM", "kr") # 비밀 아님 — 코드가 정본
poll_interval_sec: int = int(os.getenv("POLL_INTERVAL_SEC", "300"))
port: int = int(os.getenv("PORT", "4080"))
이렇게 나누고 나니 처음 적용한 앱(lol-watch)의 .env 에는 비밀값 두 개만 남았어요.
비밀값엔 기본값을 두지 않는다
비밀값은 없을 때 어떻게 할지를 정해요. 운영에서 꼭 필요한 값이면 시작을 실패시키고, 선택 기능이면 그 기능만 꺼요.
if not config.riot_api_key:
if config.app_env == "production":
raise RuntimeError("RIOT_API_KEY 미설정 — 운영에선 폴링이 전부 멈춘다")
logger.warning("RIOT_API_KEY 미설정 — 폴링 없이 API 만 뜬다")
2. 우선순위 — 오른쪽이 이긴다
코드 기본값 < .env (금고 사본) < .env.local (내 PC 전용) < 실제 환경변수
.env.local 은 "잠깐 바꿔 보기"용이에요. 금고에 올라가지 않아서, 여기에 임시 값을 넣어도 다른 곳에 퍼지지 않아요.
python-dotenv 의 load_dotenv 는 이미 있는 키를 덮지 않아요. 그래서 이긴 쪽을 먼저 읽으면 돼요. 경로는 __file__ 기준으로 잡아서, 어느 폴더에서 실행하든 같은 파일을 읽게 했고요.
_APP_ROOT = Path(__file__).resolve().parents[2]
# load_dotenv 는 이미 있는 키를 덮지 않는다 → 먼저 읽은 쪽이 이긴다.
load_dotenv(_APP_ROOT / ".env.local")
load_dotenv(_APP_ROOT / ".env")
계층이 넷이면 "이 값이 어디서 왔지?"가 헷갈려요. 그래서 앱이 시작할 때 키마다 출처를 로그로 남겨요(값은 찍지 않아요). .env.local 이 무언가를 덮고 있으면 경고도 하고요.
config 출처: {'RIOT_API_KEY': '.env', 'DISCORD_WEBHOOK_URL': '.env'}
중요한 점 하나 — 앱은 S3 를 읽지 않아요. 실행할 때 S3 를 읽게 하면 모든 환경에 AWS 자격증명과 네트워크가 필요해져요. S3 는 실행 계층이 아니라 .env 의 원본 보관소일 뿐이에요.
3. 금고 — S3 버킷 하나
버킷은 이 용도로만 하나 만들었고, 설정은 이렇게 했어요.
| 설정 | 왜 |
|---|---|
| 퍼블릭 액세스 차단 | 당연히 |
| 버전 관리 켜기 | 잘못 덮어써도 이전 버전으로 되살린다 |
| 서버 측 암호화(SSE) | 저장된 파일 암호화 |
| HTTPS 강제(버킷 정책) | 평문 전송 거부 |
파일은 앱과 단계로 나눠 둬요.
apps/<앱>/local.env ↔ apps/<앱>/.env
apps/<앱>/production.env ↔ apps/<앱>/.env.production
4. diff 는 키 이름만 보여 준다
push 하기 전에 "뭐가 바뀌는지"는 봐야 하는데, 터미널에 비밀값이 찍히면 안 돼요(화면 공유, 터미널 기록, AI 도구 로그…). 그래서 diff 는 키 이름과 변화 종류만 보여 줘요.
── lol-watch/production 올릴 변경
+ SLACK_WEBHOOK_URL (로컬에만)
~ RIOT_API_KEY (값 다름)
구현은 awk 로 두 파일을 키=값 으로 읽어 키 집합을 비교하는 정도예요. export 로 시작하는 줄과 주석·빈 줄은 건너뛰고요.
5. 덮어쓰기 막기 — 낙관적 잠금
맥북과 다른 PC 두 곳에서 쓰면 이런 일이 생길 수 있어요.
- A 에서 키를 바꾸고 push → B 에서 예전 파일로 push 하면 A 의 변경이 사라진다
- 로컬에서 고치고 push 를 잊은 채 pull 하면 내 변경이 사라진다
그래서 마지막으로 동기화했을 때의 S3 버전 ID 와 파일 해시 를 앱 폴더의 .env.vault-state(gitignore)에 적어 둬요.
# <stage> <S3 versionId> <sha256> (값은 예시)
production 3HL4kqtJlcpXroDTDmJ.rmSpXd3dIbrHY 9f2c…
| 명령 | 확인하는 것 | 걸리면 |
|---|---|---|
pull | 로컬 파일 해시가 마지막 동기화 때와 같은가 | "push 안 한 변경이 있어요" — 거부 |
push | 금고의 현재 버전 ID 가 마지막 동기화 때와 같은가 | "그 사이 금고가 바뀌었어요" — 거부 |
거부될 때도 키 단위 diff 를 같이 보여 줘서 뭘 합쳐야 하는지 알 수 있어요. 정말 덮어써야 하면 --force 를 붙이고, 그래도 S3 버전 관리 덕분에 덮인 값은 되살릴 수 있어요.
status 는 이 두 값을 조합해 상태를 알려 줘요.
lol-watch local 동기화됨
lol-watch production 로컬 수정됨 — push 필요
6. 흐름은 한 방향 — 로컬 → 금고 → 서버
로컬 .env.production ──push──▶ S3 금고 ──(make setup-env-prod 가 pull)──▶ EC2 .env
EC2 에 올리는 명령은 올리기 전에 금고에서 먼저 pull 해요. 로컬에 push 안 한 변경이 있으면 pull 이 거부하니, 서버엔 항상 금고와 같은 값만 올라가요.
setup-env-prod: ## S3 금고 → .env.production → EC2 의 .env
@bash ../../scripts/env-vault.sh pull lol-watch production
@scp -i $(KEY_FILE) .env.production $(EC2_USER)@$(EC2_IP):$(RELEASE_ROOT)/.env
그래서 규칙도 하나 생겼어요. EC2 의 .env 를 직접 고치지 않는다 — 다음에 올릴 때 금고 값으로 덮이니까요.
새 PC 에선 이렇게 끝나요.
git clone …
aws configure --profile <프로필>
pnpm vault pull --all
다른 방법과 비교
비밀값을 보관하는 방법은 많아요. 지금 규모(혼자 쓰는 개인 모노레포, 서버 한 대)에서 고른 이유를 표로 정리하면 이래요.
| 방법 | 장점 | 이 경우에 안 맞았던 점 |
|---|---|---|
| 비밀번호 관리자에 메모로 | 따로 만들 게 없다 | 앱·단계가 늘면 복붙 실수, 차이 비교가 안 된다 |
| git 에 암호화해서 커밋 (sops, git-crypt) | 코드와 같이 버전 관리 | 암호화 키를 또 어딘가 보관해야 하고, 실수로 평문 커밋하면 기록에 남는다 |
| AWS SSM Parameter Store · Secrets Manager | 앱이 실행 때 직접 읽을 수 있다 | 앱마다 SDK·권한이 필요해진다 — 로컬 개발까지 AWS 에 묶인다 |
| 비밀값 관리 서비스 (Doppler 등) | 팀 공유·감사 기록까지 | 혼자 쓰기엔 서비스가 하나 더 늘어난다 |
| S3 + bash 스크립트 | 앱은 그냥 .env 를 읽는다, 버전 관리 공짜 | 직접 만든 도구라 기능이 딱 필요한 만큼뿐 |
결국 "앱 코드는 .env 만 알고, 보관과 동기화는 밖에서"가 제일 단순했어요.
대가
- AWS 자격증명이 있어야 pull/push 할 수 있어요. 새 PC 에선
aws configure가 첫 단계예요. - 혼자 쓰는 걸 전제로 만들었어요. 팀이라면 권한 분리나 감사 기록이 필요할 텐데, 이 스크립트엔 없어요.
- 잠금은 "마지막 동기화 이후 바뀌었나"만 봐요. 두 곳에서 정말 동시에 push 하는 경우까지 막지는 않아요(혼자 쓰니 감수).
- 값 분류를 앱마다 한 번씩 손봐야 해요. 그래서 적용 순서를 정해 뒀어요 — 정리 전 원본을 먼저 금고에 push 해 첫 버전으로 남기고, 정리본은 그다음에 올린다.
.env 가 맥북 한 대에만 있던 상태에서, 지금은 clone 하고 명령 하나면 어느 PC 에서든 앱을 띄울 수 있어요.