프로그램에 건네는 쪽지를 적어둔 파일이다. 비밀을 넣어두는 곳이지만 비밀을 지켜주지는 않는다. 코드에서 떼어놓을 뿐이고, 떼어놓은 비밀은 네 군데로 샌다.

시켜서 만든 파일들
AI에게 날씨 앱을 만들어달라고 하면 몇 분 뒤 폴더 하나가 생긴다. 열어보면 만들어달라고 한 적 없는 파일이 가득하다. 돌아가니까 넘어간다. 바이브 코딩은 대체로 그렇게 시작한다.
그 파일들이 하는 일은 생각보다 단순하다.

이 중 대부분은 몰라도 당장 탈이 없다. package.json을 한 번도 열어보지 않아도 앱은 돌아간다. .env는 다르다. 여기 적히는 API 키는 OpenAI 같은 외부 서비스가 발급하는 비밀번호이고, 그 키로 들어온 사용량은 누가 썼든 내 앞으로 청구된다. 키가 밖으로 새면 남이 쓴 요금을 내가 낸다.
프로그램에 건네는 쪽지
프로그램은 혼자 돌지 않는다. 어느 컴퓨터에서, 누구의 계정으로, 어떤 설정으로 실행되는지가 매번 다르다. 프로그램을 둘러싼 이런 조건을 통틀어 환경(environment)이라고 부른다.
운영체제는 프로그램을 실행할 때 환경에 관한 정보를 이름표가 붙은 값으로 건넨다. 지금 사용자가 누구인지, 홈 폴더가 어디인지 같은 것들이다. 프로그램은 실행되는 동안 이 쪽지를 꺼내 읽을 수 있다. 개발자들은 이 쪽지를 환경변수라고 부른다. 변수는 이름표가 붙은 값을 가리키는 프로그래밍 용어이니, 환경이 건네는 이름 붙은 값이라는 뜻이다.
.env라는 파일 이름은 environment의 앞 세 글자다. 그 쪽지를 파일에 미리 적어둔 것이다.
.env
OPENAI_API_KEY=sk-proj-abc123...
DATABASE_URL=postgres://localhost:5432/myapp한 줄에 하나, 이름=값. 형식은 이게 전부다. 앱을 실행하면 로더(dotenv 같은 라이브러리, 혹은 프레임워크에 내장된 기능)가 이 파일을 읽어 환경변수로 넣어주고, 코드는 이름으로 값을 꺼낸다. Node.js와 Next.js 서버 코드는 process.env.OPENAI_API_KEY로 꺼낸다. Vite는 다르다. Vite는 빌드 시점에 값을 import.meta.env.VITE_API_KEY 자리에 정적으로 심어 넣고, 여기에는 process.env가 아예 없다. 그러니 .env는 환경변수 그 자체가 아니다. 환경변수를 매번 손으로 입력하지 않으려고 만든 메모지다.
값을 코드에 직접 쓰지 않고 굳이 돌아가는 이유가 있다. 코드와 쪽지는 성격이 다르기 때문이다. 코드는 공유된다. GitHub에 올라가고, 동료에게 가고, 서버에 복사된다. 쪽지는 장소마다 다르다. 내 노트북에서는 연습용 DB에 붙어야 하고, 운영 서버에서는 진짜 고객 DB에 붙어야 한다.

값을 코드에서 빼두면 코드는 한 글자도 고치지 않고 어디서든 돌릴 수 있다. 그리고 코드를 공개해도 값은 따라가지 않는다.
메모장으로 열리는 비밀
여기까지 오면 자연스럽게 이렇게 정리하게 된다. 비밀은 .env에 넣는다, 그러면 안전하다. 앞 문장은 맞고 뒤 문장은 틀렸다.
.env를 메모장으로 열어보자. 키가 그대로 읽힌다. 암호화도 없고 잠금도 없다. 파일 이름이 점으로 시작해서 탐색기에서 잘 안 보일 뿐, 파일 자체는 숨겨지지 않는다. 리눅스나 macOS라면 chmod 600 .env로 소유자만 읽고 쓰게 권한을 좁힐 수 있다. 그래도 이 파일에 접근할 수 있는 계정과 그 계정으로 도는 프로그램은 여전히 전부 읽을 수 있다.
.env가 하는 일은 보관이 아니라 분리다. 비밀을 코드 밖으로 꺼내서, 코드가 가는 곳에 비밀이 따라가지 않게 한다. 안전은 파일에서 나오지 않는다. 이 파일이 어디로 가지 않느냐에서 나온다. 그리고 떼어놓은 비밀이 바깥으로 나가는 길이 네 군데 있다.
새는 길 1
git 저장소로
git은 코드의 변경 기록을 저장하는 도구이고, GitHub는 그 기록을 올려두는 온라인 저장소다. 기록을 남기는 것을 커밋, 저장소에 올리는 것을 푸시라고 한다.
git은 폴더 안의 모든 파일을 기록 대상으로 본다. .env도 예외가 아니다. 예외로 만드는 것은 .gitignore에 적힌 한 줄이다.
.gitignore
node_modules/
.env
.env.*
!.env.examplenode_modules와 .env가 나란히 적혀 있지만 이유는 다르다. 하나는 너무 커서 빠지고, 하나는 비밀이라서 빠진다.
이 한 줄이 없는 상태에서 git add .와 git push를 실행하면 키가 저장소에 올라간다. 공개 저장소를 훑으며 키를 수집하는 봇은 상시 돌고 있다.
올라간 뒤에 알아챘다면 두 가지를 기억해두자. 첫째, 파일을 지우고 다시 커밋해도 소용없다. git은 과거 기록을 전부 보관하고, 키는 이전 커밋에 그대로 남는다. 둘째, 이미 올라간 파일은 .gitignore에 뒤늦게 추가해도 계속 추적된다. git rm --cached .env로 추적을 끊어야 한다.
유출된 키의 해법은 삭제가 아니라 폐기다. 키를 발급한 서비스의 관리 화면에서 그 키를 무효화하고 새로 발급받는다. 기록을 지우는 일은 그다음이다.
새는 길 2
브라우저로
웹 앱의 코드는 두 곳에서 돈다. 일부는 서버에서, 일부는 방문자의 브라우저에서. 브라우저에서 도는 코드는 방문자에게 통째로 전송되고, 개발자 도구를 열면 누구나 읽을 수 있다.
그래서 프레임워크는 환경변수를 기본적으로 서버에서만 읽히게 막아둔다. 그리고 브라우저에서도 써야 하는 값을 위해 통로를 하나 열어뒀다. 이름 앞에 붙이는 접두사다. Next.js는 NEXT_PUBLIC_, Vite는 VITE_.

사고는 이런 식으로 난다. 브라우저 쪽 코드에서 키를 읽으려 하니 undefined(값이 없다는 뜻)가 나온다. AI에게 물으면 변수 이름에 NEXT_PUBLIC_을 붙이라고 답한다. 붙이니까 된다. 그 순간부터 OpenAI 키가 모든 방문자에게 배달된다.
접두사는 "이 값은 공개해도 된다"는 선언이다. 지도 API의 공개용 키, 분석 도구의 사이트 ID처럼 원래 노출을 전제로 만든 값에만 붙인다. 결제 키, AI API 키, DB 주소는 서버에서만 읽혀야 한다. 브라우저에서 그 기능이 필요하면 브라우저는 내 서버에 요청하고, 내 서버가 키를 들고 외부 서비스를 호출하는 구조로 만든다.
새는 길 3
배포와 공유 과정에서
배포는 내 컴퓨터에서 만든 앱을 인터넷의 서버에 올려 누구나 접속할 수 있게 하는 일이다. .gitignore는 git 저장소로 가는 길목만 막는다. git을 거치지 않고 폴더를 그대로 서버에 올리거나 복사하면 .env도 함께 간다. 실제로 안전한 배포는 서버에 .env 파일 자체를 두지 않고, 플랫폼의 설정 화면에서 값을 넣는 방식이다. Vercel, Netlify, Railway 모두 프로젝트 설정에 환경변수 입력란이 있다. 거기에 이름과 값을 적어두면 플랫폼이 앱을 실행할 때 쪽지로 건넨다. 운영체제가 프로그램에 쪽지를 건네던 원래 방식 그대로다. 운영 환경에는 .env 파일이 아예 없는 것이 정상이다.
로컬에서는 되는데 배포하면 안 될 때 먼저 의심할 곳이 이 입력란이다.
새는 길은 이 과정의 지름길에서 생긴다. 동료에게 .env를 메신저로 보내면 대화방에 키가 계속 남는다. 서버에 폴더를 통째로 복사하면 .env도 같이 간다. Docker 이미지를 만들 때도 마찬가지다(.dockerignore가 .gitignore와 같은 역할을 한다). 막힌 부분을 질문하려고 화면을 캡처했는데 에디터에 .env가 열려 있는 경우도 흔하다.
파일 대신 공유할 것은 .env.example이다.
.env.example
OPENAI_API_KEY=
DATABASE_URL=값은 비우고 이름만 남긴 목록이다. 비밀이 없으니 git에 올린다. 받는 사람은 이 파일을 복사해 .env로 이름을 바꾸고 자기 값을 채운다. 실제 값이 꼭 오가야 한다면 비밀번호 관리자의 공유 기능처럼 대화 기록에 남지 않는 통로를 쓴다.
새는 길 4
AI와의 대화로
바이브 코딩에는 기존 개발에 없던 경로가 하나 더 있다. 작업 상대가 AI라는 점이다.
에이전트는 폴더를 읽는다
Claude Code나 Cursor 같은 코딩 에이전트는 프로젝트 폴더의 파일을 읽으며 일한다. .env도 그 안에 있다. 읽힌 내용은 대화 맥락에 들어가 서비스 제공사의 서버로 전송된다. 에이전트 설정에서 읽기를 막아둘 수 있다. Claude Code라면 권한 설정에 거부 규칙을 넣는다.
.claude/settings.json
{
"permissions": {
"deny": ["Read(./.env)", "Read(./.env.*)"]
}
}키를 대화창에 붙여넣는다
에러 메시지를 통째로 복사하다 보면 키가 섞여 들어간다. 키가 왜 안 먹히는지 물으려고 키를 직접 붙여넣기도 한다. 붙여넣은 키는 유출된 키로 취급하고 재발급한다.
AI가 키를 코드에 직접 적자고 한다
환경변수가 안 읽혀서 디버깅이 길어지면 AI는 확인용으로 값을 코드에 직접 적어보자고 하기도 한다. 확인한 뒤 되돌리는 것을 잊으면 그 코드가 그대로 커밋된다.
AI가 git을 대신 실행한다
커밋하고 푸시해달라고 하면 에이전트는 흔히 git add .로 폴더 전체를 올린다. .gitignore가 제대로 되어 있는지까지 확인해준다는 보장은 없다. 첫 푸시 전에 git status로 올라갈 파일 목록을 직접 확인한다.
공통점은 속도다. 확인 없이 넘어가는 단계가 많을수록 작업은 빨라지고, 위의 네 가지는 모두 확인 없이 넘어간 단계에서 열린다.
첫 푸시 전에 확인할 다섯 가지
- .gitignore에 .env가 적혀 있는가.
git status결과에 .env가 보이지 않는가. 이미 커밋된 적이 있다면git rm --cached로 추적을 끊었는가. NEXT_PUBLIC_,VITE_가 붙은 변수 중 공개되면 안 되는 값이 없는가. Vite라면 이 값이import.meta.env를 거쳐 빌드 결과물에 그대로 박힌다는 점을 기억한다.- 코드 안에 키가 직접 적힌 곳이 없는가.
sk-,key같은 문자열로 검색해본다. - 배포 플랫폼의 환경변수 화면에 .env.example의 모든 이름이 입력되어 있는가.
- 대화창, 메신저, 스크린샷으로 키가 나간 적이 있는가. 있다면 재발급한다.
.env는 금고가 아니라 잠기지 않는 서랍이다. 비밀을 코드와 섞이지 않게 따로 넣어둘 뿐이다. 그 서랍이 집 밖으로 나가지 않게 하는 일은 여전히 직접 챙겨야 한다.
