시크릿은 매번 같은 길로 도망친다
환경변수와 시크릿이 유출되는 가장 흔한 세 가지 경로인 클라이언트 번들 포함, 로그 출력, 실수로 인한 커밋을 분석한다. 각 경로별 사고 패턴과 조기 탐지를 위한 도구 설정, 그리고 유출 확인 시 즉시 실행해야 할 키 롤링과 로그 감사까지 실전 대응 루틴을 제시한다. NEXT PUBLIC API KEY=sk live ...
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
NEXT_PUBLIC_API_KEY=sk-live-... 이 문자열이 클라이언트 번들에 포함되는 순간, 그 키는 이미 세상에 공개된 것이다. 브라우저 개발자 도구를 열고 Sources 탭에서 main.js를 검색하는 데는 3초면 충분하다. 시크릿 유출 사고의 상당수는 정교한 공격 기법보다, 이처럼 일상적인 실수와 설계 오판에서 비롯된다. 문제는 사고가 발생하는 경로가 놀라울 정도로 한정적이라는 점이다. 번들, 로그, 커밋. 시크릿은 언제나 같은 세 길로 도망친다.
접두사에 속지 않는 법
프레임워크 생태계는 환경변수를 클라이언트에 노출할지 결정하는 규칙을 접두사로 구분한다. Next.js의 NEXT_PUBLIC_, Vite의 VITE_, Create React App의 REACT_APP_ 같은 접두사가 붙은 변수는 빌드 시점에 문자열로 치환되어 번들에 박힌다.
여기서 첫 번째 함정. NEXT_PUBLIC_API_URL 같은 변수만 노출된다고 생각하기 쉽다. 실제로는 NEXT_PUBLIC_ 접두사를 붙인 모든 변수가 무차별적으로 클라이언트 번들에 포함된다. NEXT_PUBLIC_SENTRY_DSN이야 공개되어도 괜찮다 치자. NEXT_PUBLIC_DATABASE_URL을 실수로 정의했다면? 빌드가 성공하고 앱은 멀쩡히 동작한다. 아무 경고도 뜨지 않는다. 데이터베이스 접속 문자열이 브라우저에서 평문으로 노출된 채 배포되고 나서야 누군가 문제를 발견한다. 이미 늦었다.
두 번째 함정은 서버 컴포넌트와 클라이언트 컴포넌트의 경계가 흐릿해지면서 발생한다. Next.js App Router에서 "use client" 지시문을 실수로 빼먹거나, 서버 전용 유틸리티 함수를 클라이언트 컴포넌트에서 import 하는 순간, 트리셰이킹 되리라 믿었던 시크릿이 번들에 포함된다. 번들러는 의존성 그래프를 따라가면서 필요한 모든 코드를 포함시키기 때문이다. process.env.SECRET을 참조하는 유틸 함수가 클라이언트 진입점에서 한 번이라도 import 체인에 걸리면, 그 SECRET 값은 빌드 결과물에 하드코딩된다.
검증은 간단하다. 프로덕션 빌드를 로컬에서 생성한 뒤 grep 한 줄이면 끝난다. Next.js 기준으로 .next/static/chunks 디렉터리에서 의심되는 문자열을 검색한다. grep -r "sk-" .next/static/chunks 같은 명령어로 Stripe 시크릿 키 패턴을 잡아낼 수 있다. AWS 키는 AKIA 접두사를, GitHub 토큰은 ghp_를 찾는다. 이 검사를 CI에 넣는 걸 권장한다. 빌드마다 자동으로 실행되게 하면 배포 전에 걸러진다.
로그는 가장 무방비한 출구다
에러가 발생했을 때 개발자가 가장 먼저 하는 일은 console.log(error)다. 이 error 객체 안에 API 키가 들어있을 가능성은 의외로 높다. Axios 인터셉터에서 요청 객체 전체를 로깅하도록 설정한 경우가 대표적이다.
axios.interceptors.request.use(config => { console.log('Request:', config) return config })
config 객체에는 headers.Authorization이 포함되어 있다. 토큰이 로그에 찍힌다. 로그 수집기로 Datadog, CloudWatch, Sentry를 쓰고 있다면 그대로 원격 저장소에 기록된다. 로그 보존 기간이 90일이라면 90일 동안 어디서든 조회 가능한 상태가 된다.
JSON.stringify(error)도 위험하다. error.config 같은 내부 프로퍼티까지 재귀적으로 직렬화하면서 요청 헤더를 포함시키기 때문이다. Sentry SDK는 기본적으로 Authorization, Cookie, X-Api-Key 헤더를 필터링하지만, 커스텀 헤더(X-Internal-Token 같은)는 걸러지지 않는다. 직접 beforeSend 훅에서 필터링 규칙을 설정하지 않으면 그대로 수집된다.
스택 트레이스도 예외는 아니다. throw new Error("Invalid API key: sk-live-abc123") 같은 코드는 메시지 자체에 시크릿을 포함시킨다. 에러 메시지는 보통 전역 에러 핸들러를 타고 로깅되고, 알림 채널(Slack, PagerDuty)로 전파된다. 한 번의 실수로 시크릿이 사내 메신저 전체에 뿌려질 수 있다는 얘기다.
로그에서 시크릿을 차단하는 가장 확실한 방법은 출력 자체를 막는 것이다. Winston이나 Pino 같은 로거를 쓴다면 커스텀 serializer를 등록해서 특정 키(password, secret, token, key, authorization)를 마스킹한다. 이때 정규식 기반 필터링은 우회될 여지가 크다. { "api_key": "..." }는 마스킹했는데 { "apiKey": "..." }는 통과하는 식이다. 화이트리스트 방식이 낫다. 로그에 포함시킬 필드를 명시적으로 지정하고 나머지는 모두 제거한다. Sentry에서는 denyUrls 옵션으로 민감한 정보를 포함할 수 있는 특정 URL 패턴의 에러를 아예 수집 대상에서 제외하는 방법도 있다.
깃은 절대 잊지 않는다
.env 파일을 실수로 커밋하는 사고는 이제 고전적인 축에 속한다. 그런데도 수년째 끊이지 않는다. .gitignore에 .env를 추가했지만 .env.local이나 .env.production을 빼먹는 경우다. .env.example을 편집하다가 실수로 실제 값을 넣어 올리는 경우도 있다.
더 까다로운 건 커밋을 삭제해도 흔적이 남는다는 사실이다. git filter-branch나 BFG Repo-Cleaner로 히스토리를 정리해도 이미 누군가 포크했거나 클론해둔 리포지토리의 로컬 복사본에는 그대로 남아 있다. 퍼블릭 리포지토리였다면 GitHub의 이벤트 API나 BigQuery 공개 데이터셋에 해당 커밋 내용이 이미 아카이빙되어 있을 가능성도 배제할 수 없다.
GitHub가 2021년부터 시크릿 스캐닝을 기본 제공하면서 상황이 달라졌다. 커밋이 푸시되는 순간 GitHub이 패턴 매칭으로 시크릿을 탐지하고, 해당 파트너 서비스(AWS, Stripe, GitHub 자체 토큰 등)에 자동으로 통보한다. 통보를 받은 서비스 제공자는 해당 키를 즉시 폐기하고 저장소 관리자에게 알림을 보낸다. 이걸 믿고 느긋하게 있을 순 없다. 파트너십을 맺지 않은 서비스의 키는 탐지되지 않는다. 또 MongoDB Atlas 연결 문자열이나 Firebase 서비스 계정 JSON 같은 건 패턴이 다양해서 탐지율이 낮다.
여기서 한 가지 덜 알려진 위험 요소가 있다. CI/CD 파이프라인 로그에 시크릿이 노출되는 것이다. GitHub Actions에서 echo ${{ secrets.DATABASE_URL }} 같은 코드를 돌리면 값이 마스킹되어 ***로 표시된다. 하지만 base64로 인코딩해서 출력하거나, JSON 구조 안에 시크릿을 담아서 한 줄이 아닌 여러 줄로 나눠 출력하면 마스킹을 우회할 수 있다. GitHub Actions의 시크릿 마스킹은 단일 토큰 값을 공백이나 줄바꿈 없이 그대로 노출할 때만 동작한다. 값의 일부분만 출력되거나 변형된 형태로 나가면 감지하지 못한다. 로그를 외부 서비스로 전송하는 워크플로가 있다면 그 시점부터는 GitHub의 통제를 벗어난다.
푸시 전에 막는다는 착각
pre-commit 훅은 인력에 의존한다. --no-verify 플래그 하나로 우회할 수 있고, 급한 핫픽스 상황에서는 실제로 그렇게 한다는 게 함정이다. pre-commit 단계의 시크릿 검사는 '있으면 좋은' 정도로 생각하는 편이 오히려 안전하다. 진짜 차단은 CI 레벨에서 이뤄져야 한다.
gitleaks, truffleHog, detect-secrets 같은 도구를 CI 파이프라인에 통합하면 효과적이다. 이 도구들의 장점은 정규식 기반 패턴 매칭에 더해 엔트로피 기반 탐지까지 한다는 점이다. 16진수 문자열이 무작위로 보이는지, base64 인코딩 문자열이 충분히 높은 무작위성을 띠는지를 측정해서 시크릿일 가능성을 추정한다. 오탐이 발생할 여지가 있으니 .gitleaks.toml이나 trufflehog의 제외 규칙을 세심하게 튜닝할 필요는 있다. 오탐을 무시하는 문화가 생기면 진짜 양성도 무시하게 된다.
CI에서 차단하지 못했을 경우를 대비한 마지막 방어선은 런타임이다. 애플리케이션 기동 시점에 process.env에 등록된 변수 중 특정 패턴을 가진 값이 있는지 검사하는 미들웨어를 두는 방법이다. 가령 AWS 액세스 키 패턴(AKIA[0-9A-Z]{16})과 매칭되는 문자열이 환경변수 값에 존재하면 앱을 아예 기동하지 못하게 한다. 물론 이 방식은 키 패턴을 알고 있는 시크릿에만 적용 가능하다. 커스텀 토큰이나 패스워드 같은 건 걸러내지 못한다.
유출을 확인했을 때
시계는 그때부터다. 할 일은 둘이다. 키를 돌리고, 누가 썼는지 확인한다.
키 롤링부터 시작한다. 문제의 키를 즉시 폐기하고 새 키를 발급받는다. 서비스가 잠시 중단되더라도 키를 먼저 죽이는 게 원칙이다. 유출된 키로 발생한 피해는 나중에 복구할 수 있지만, 살아있는 키로 발생하는 피해는 계속 누적된다.
새 키를 발급받은 후에는 감사 로그를 확인한다. AWS CloudTrail, GCP Audit Logs, Stripe Dashboard의 API 활동 로그에서 유출된 키로 수행된 모든 호출을 추적한다. 시크릿이 유출된 시점(커밋 시각, 로그 기록 시각, 번들 배포 시각)을 기준으로 그 이후의 호출을 전수 조사한다. 이상한 리전에서 EC2 인스턴스가 생성되었는지, Stripe에서 비정상적인 환불이 발생했는지, GitHub 이슈에 스팸 댓글이 달렸는지를 체크한다.
마지막으로 유출 경로를 문서화한다. 어떤 파일이었는지, 누가 어떤 상황에서 올렸는지, 왜 CI가 못 막았는지 기록으로 남긴다. 같은 실수가 반복되는 조직은 대개 원인 분석 없이 '당장 지운다'로 끝내버린 조직이다. git blame에 찍힌 사람을 찾는 게 목적이 아니다. 왜 막지 못했는지, 어떤 도구를 어디에 추가하면 되는지를 결정하는 근거를 만드는 게 목적이다.
시크릿은 항상 같은 길로 도망친다. 번들, 로그, 커밋. 이 길목을 미리 알고 있다는 건 막을 기회가 세 번 주어진다는 뜻이기도 하다. 빌드 직후 번들에서 시크릿을 찾고, 로거가 요청 객체를 통째로 삼키지 않게 하고, CI가 푸시를 거부하게 하는 것. 셋 중 하나만 제대로 해도 대부분의 사고는 막힌다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
취약점 목록을 읽는 방식이 바뀌는 순간
공식 CVE 목록이 단순 조회용 페이지에서 Git과 JSON 중심의 배포 모델로 이동하면서, 보안 데이터 소비 방식도 함께 달라지고 있다. `cvelistV5`는 속도와 구조화라는 장점을 주지만, 버전 차이·컨테이너 분리·동기화 전략까지 함께 이해해야 진짜 운영 자산이 된다.
나의 첫 JWT 도입은 참사였다
JWT의 stateless함에 매료돼 도입했다가 로그아웃이 안 되는 사실을 배포 직전에 깨달은 적이 있는가. access token 만료 시간을 15분으로 설정하면 UX가 나빠지고, 길게 잡으면 보안이 걱정된다. refresh token을 회전시키면 탈취 위험은 줄지만 구현 복잡도가 급상승한다. 세션 기반 인증으로 회귀할 수도 없는 노릇. 이 글은 실전에서 부딪힌 이 모든 트레이드오프를 사례별로 풀어낸다.
이전 글
데이터는 반드시 낡는다. 그리고 그 사실을 받아들일 때 진짜 설계가 시작된다.
다음 글
앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.