환경변수는 어디까지 비밀인가: 빌드에 박히는 값과 서버에만 남는 값
.env에 넣어도 브라우저에 노출되는 이유를 빌드 치환 관점에서 설명하고, 공개해도 되는 키와 절대 안 되는 키, 유출됐을 때의 조치 순서를 정리했습니다.
.env에 넣었으니 안전한 건 아니다
API 키를 .env에 넣고 .gitignore에 추가했으면 됐다고 생각하기 쉽습니다. 그런데 배포하고 나서 브라우저 개발자 도구를 열어보면 그 키가 그대로 보이는 경우가 있습니다.
.env는 "저장소에 올리지 않는 장치"일 뿐, "브라우저에 노출되지 않는 장치"가 아닙니다. 두 가지는 완전히 다른 문제입니다.
갈리는 지점은 빌드다
프런트엔드 코드는 빌드를 거쳐 브라우저로 전송되는 파일이 됩니다. 이때 환경변수를 참조한 자리에는 값이 문자열로 치환되어 박힙니다. 빌드 결과물은 누구나 내려받아 열어볼 수 있으므로, 거기 박힌 값은 공개된 것과 같습니다.
그래서 프레임워크들은 접두사로 경계를 긋습니다.
| 프레임워크 | 브라우저에 노출되는 접두사 |
|---|---|
| Next.js | NEXT_PUBLIC_ |
| Vite | VITE_ |
| Create React App | REACT_APP_ |
접두사가 붙은 변수는 공개된다는 뜻입니다. 편의를 위한 표시가 아니라 경계선입니다. 접두사 없는 변수는 빌드 결과물에 들어가지 않고, 서버에서 실행되는 코드에서만 읽힙니다.
즉 규칙은 하나입니다. 비밀 키에는 절대 공개 접두사를 붙이지 않는다. 붙이는 순간 비밀이 아니게 됩니다.
값이 보이지 않아도 노출일 수 있다
여기서 한 단계 더 들어갑니다. 서버에서만 읽는 변수라도, 그 값을 응답에 담아 보내면 노출됩니다.
흔한 경로가 세 가지입니다.
서버에서 클라이언트로 넘기는 데이터. 서버에서 객체를 통째로 만들어 클라이언트 컴포넌트에 넘기면, 그 객체는 직렬화되어 HTML에 실려 갑니다. 필요한 필드만 골라 넘겨야 합니다.
오류 메시지. 예외를 그대로 응답에 담으면 접속 문자열이나 내부 경로가 섞여 나가는 경우가 있습니다. 프로덕션에서는 일반화된 메시지만 보내고, 상세 내용은 서버 로그에만 남기세요.
클라이언트가 직접 외부 API를 호출하는 구조. 브라우저가 외부 API를 부르려면 키가 브라우저에 있어야 합니다. 이 구조 자체가 노출입니다. 서버 라우트를 경유시켜야 합니다.
// app/api/weather/route.ts — 키는 서버에만 남는다
export async function GET(req) {
const res = await fetch(`https://api.example.com/v1/weather?key=${process.env.WEATHER_API_KEY}`);
return Response.json(await res.json());
}
공개돼도 괜찮은 키와 아닌 키
모든 키가 비밀은 아닙니다. 애초에 브라우저에서 쓰라고 만든 키가 있습니다. 지도 API의 클라이언트 키, 분석 도구의 추적 ID, 인증 서비스의 공개 키 같은 것들입니다.
이런 키는 노출을 전제로 설계되어 있고, 대신 사용처를 제한하는 장치가 따로 있습니다. 허용 도메인 목록, 참조자 제한, 호출량 상한 같은 것들입니다. 그래서 공개 키를 쓸 때 진짜 해야 할 일은 숨기는 게 아니라 그 제한을 설정하는 것입니다. 제한 없이 공개된 키는 남이 자기 서비스에 붙여 쓰는 순간 요금이 우리 앞으로 옵니다.
반대로 절대 브라우저에 두면 안 되는 것들은 분명합니다. 데이터베이스 접속 문자열, 결제 API의 비밀 키, 관리자 권한을 가진 서비스 토큰, 세션 서명 키, LLM API 키입니다.
특히 마지막이 실수가 잦습니다. 브라우저에서 직접 LLM API를 호출하는 예제가 많은데, 그대로 배포하면 키가 그대로 나갑니다.
어디에 저장하느냐
- 로컬:
.env.local..gitignore에 들어 있는지 확인하세요. - 저장소:
.env.example에 키 이름만 남깁니다. 값은 비워둡니다. - 배포 환경: 플랫폼의 환경변수 설정에 넣습니다. 값을 바꿨다면 재배포해야 반영됩니다.
- CI: 저장소 설정의 시크릿 기능을 씁니다. 워크플로 파일에 직접 쓰면 안 됩니다.
빌드 시점에 읽는 변수와 런타임에 읽는 변수도 구분해야 합니다. 공개 접두사가 붙은 변수는 빌드 때 박히므로, 값만 바꾸고 재배포하지 않으면 옛 값이 계속 나갑니다.
이미 올려버렸다면
실수로 저장소에 커밋했다면 순서가 있습니다.
- 먼저 키를 폐기하고 새로 발급합니다. 이게 1순위입니다.
- 코드에서 제거하고 환경변수로 옮깁니다.
- 그다음에 히스토리 정리를 고려합니다.
순서를 바꾸면 안 됩니다. 히스토리를 지워도 이미 복제된 사본이나 캐시에 남아 있을 수 있고, 공개 저장소라면 자동 수집 도구가 이미 가져갔다고 봐야 합니다. 노출된 키는 지우는 것이 아니라 무효화하는 것입니다.
서버 코드인지 클라이언트 코드인지 어떻게 확인하나요?
가장 확실한 방법은 빌드 결과물을 검색해보는 것입니다. 빌드한 뒤 결과 디렉터리에서 키 값의 일부를 grep으로 찾아보세요. 하나라도 걸리면 노출된 것입니다. 배포 전 점검 항목에 이 명령 하나를 넣어두면 사고를 크게 줄일 수 있습니다.
공개 키는 아무나 써도 상관없나요?
호출량이 우리 계정에 잡힌다면 상관있습니다. 도메인 제한과 사용량 상한을 설정해야 남이 가져다 써도 피해가 제한됩니다. 무료 한도가 있는 서비스라면 그 한도를 남이 소진시켜 정작 우리 사용자가 못 쓰게 되는 상황도 가능합니다.
환경변수 대신 설정 파일을 써도 되나요?
값이 코드 저장소에 들어가지 않는다는 조건만 지키면 방식은 자유입니다. 다만 파일로 관리하면 배포 환경마다 그 파일을 따로 배치해야 하고, 실수로 이미지에 포함되기도 쉽습니다. 대부분의 배포 플랫폼이 환경변수 주입을 기본으로 지원하므로 그쪽이 단순합니다.
한 번 찍어보면 끝난다
지금 배포된 사이트에서 개발자 도구를 열고 소스 탭에서 키의 앞 몇 글자를 검색해보세요. 몇 초면 노출 여부가 확인됩니다.
만든 프로젝트가 외부 API를 쓴다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 다른 개발자가 실제로 열어보는 과정에서, 혼자서는 지나쳤던 노출 지점을 짚어주는 경우가 있습니다.
