배포 실패와 Lambda 권한 오류의 원인이 공개 번들에 인라인된 AWS 키였다
NEXT_PUBLIC_ 접두사는 브라우저에 노출해도 되는 값이라는 선언입니다. 브라우저에서 S3 를 직접 호출하는 구조가 그 접두사를 강제하고 있었습니다
서로 무관해 보이는 세 가지가 같은 날 발생했습니다.
- 배포 전면 실패: CodePipeline 의 Build 스테이지가 8초 만에 실패합니다. 재시도해도 같은 오류가 발생합니다.
Error calling startBuild: Cannot have more than 0 builds in queue for the account (AccountLimitExceededException) - 외부 시스템 프록시 Lambda 가 IAM 권한 오류로 동작 불능이 되면서, 그 Lambda 에 의존하는 통계 화면들도 같이 동작하지 않았습니다.
- 외부 경로로 "탈취된 키로 결제가 시도됐다" 는 통보가 접수됐습니다.
1번을 파이프라인이나 프로젝트 설정 문제로 판단하면 원인을 발견하지 못합니다. 실행 중인 빌드는 0개였고 프로젝트의 concurrentBuildLimit 도 설정돼 있지 않았습니다. 프로젝트가 아니라 계정 단위 제한이었습니다.
원인
어드민의 공개 JS 번들에 AWS 액세스키와 시크릿키가 평문으로 포함돼 있었습니다. 로그인 없이 URL 하나로 획득할 수 있는 상태였고, 노출된 키를 탐지한 AWS 측 계정 제한이 1번과 2번 증상으로 나타난 것이었습니다.
세 요소가 중첩돼 있었습니다.
(1) 브라우저에서 S3 를 직접 호출하는 구조: 게시판 계열 화면이 클라이언트 컴포넌트에서 new AWS.S3() 를 생성해 listObjects 와 putObject 를 호출했습니다. 이 구조는 자격증명이 브라우저에서 실행되는 코드 안에 존재할 것을 요구합니다.
(2) NEXT_PUBLIC_ 접두사: Next.js 에서 이 접두사는 "브라우저에 노출해도 되는 값" 이라는 선언이고, 빌드할 때 클라이언트 번들에 문자열로 치환됩니다. (1) 을 동작시키려면 이 접두사를 사용할 수밖에 없으니 (2) 는 (1) 의 필연적 결과입니다. 시크릿에 사용하면 안 되는 접두사라는 점이 코드만 봐서는 확인되지 않습니다.
(3) 정적 자산 경로는 인증 대상이 아님: middleware.ts 의 인증 검사에서 /_next 가 제외돼 있습니다. 통상적인 설정이고, 문제는 그 정적 자산 안에 시크릿이 있는 쪽입니다.
청크를 수신하는 데 파일명 해시를 알 필요도 없습니다. /_next/static/<buildId>/_buildManifest.js 가 전체 청크 목록을 공개하므로 자동 수집을 방지하는 게 없습니다.
조치
S3 호출 경로를 브라우저 → 백엔드 → S3 로 변경했습니다.
- 클라이언트 5곳의
aws-sdk직접 호출을 제거하고 신규GET /api/image/list,POST /api/image/upload를 경유하게 했습니다. 인증은 기존과 같은 검증을 사용합니다. - 서버 라우트 16곳의
NEXT_PUBLIC_AWS_*를AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_S3_BUCKET/AWS_REGION으로 변경했습니다. 서버에서만 참조되는 값은 클라이언트 번들에 포함되지 않습니다. - 노출된 키를 폐기하고 새 키를 발급했습니다.
ECS task role 로 키 자체를 제거하는 방안은 이 환경에서 사용할 수 없어서 채택하지 않았습니다. 서버는 계속 env 기반 장기 키를 사용합니다.
다음 날, 인라인 메커니즘 자체를 제거했습니다
위 조치까지는 "클라이언트가 참조하지 않으니 안전하다" 는 사람 판단에 의존하는 상태였습니다. next.config.js 의 env: 블록에 38개 키가 그대로 있었기 때문입니다.
블록을 통째로 삭제했습니다. 이후로는 NEXT_PUBLIC_ 이 아닌 변수를 클라이언트 코드가 참조해도 undefined 가 됩니다. 잘못 참조해도 키가 유출되지 않고 값이 비어 있는 버그로만 나타나는, Next.js 의 기본 동작으로 복원한 상태입니다.
env 블록이 원래 필요했던 이유는 standalone 서버가 .env 를 런타임에 로드하지 않기 때문이었는데, Dockerfile 에서 node --env-file=.env.local server.js 로 주입하는 방식이 그 역할을 대체합니다.
삭제해도 되는지는 전수 검색으로 확인했습니다. 38개 키를 클라이언트 도달 가능 코드에서 값으로 참조하는 곳은 0건이었고, 유일한 히트는 import type 이라 컴파일 시 제거됩니다.
노출 여부를 확인하는 방법
공개 번들 전수 확인: 실제 배포본의 청크를 모두 다운로드해 키 문자열(AKIA)을 검색합니다. 빌드 ID → 청크 목록 → grep 순서이고, 인증 없이 실행할 수 있습니다.
BASE=https://admin.example.com
BUILD=$(curl -s $BASE/login | grep -o '/_next/static/[A-Za-z0-9_-]*/_buildManifest.js' | head -1)
curl -s "$BASE$BUILD" | grep -o 'static/chunks/[A-Za-z0-9._/-]*\.js' | sort -u > chunks.txt
while read -r c; do
curl -s "$BASE/_next/$c" | grep -q "AKIA" && echo "노출: $c"
done < chunks.txt
카나리 빌드: 배포 전에 노출이 재발하는지 확인하는 방법입니다. 자격증명 자리에 가짜 값을 설정하고 빌드해서, 클라이언트 산출물에 그 값이 포함되는지 확인합니다. 실제 키를 사용하지 않고 확인할 수 있습니다.
AWS_ACCESS_KEY_ID=AKIATESTLEAKCANARY01 AWS_SECRET_ACCESS_KEY=canary-secret-value \
npx dotenv -e .env.local -- next build
grep -r "AKIATESTLEAKCANARY01" .next/static/ && echo "클라이언트 노출" || echo "미노출"
grep -rl "AKIATESTLEAKCANARY01" .next/server/ | head # 서버엔 주입돼야 정상
.next/static 에 없고 .next/server 에만 있으면 정상입니다.
정리하면서 남긴 것들
NEXT_PUBLIC_이 포함된 값은 전부 공개입니다. private 레포든 인증이 적용된 화면이든 무관합니다. 빌드 산출물이 공개되는 순간 노출됩니다.next.config.js의env블록에 있어도 클라이언트 참조가 없으면 번들에 포함되지 않습니다. 실제로 DB 비밀번호, JWT 시크릿, 외부 API 키, 슬랙 웹훅이 같은 블록에 있었지만 서버 코드에서만 참조돼 노출되지 않았습니다. 위험 판정 기준은 "블록에 등재됐는가" 가 아니라 "클라이언트 코드에서 참조하는가" 입니다. 다만 그 판정을 사람이 계속 해야 하는 상태였고, 그래서 블록 자체를 삭제했습니다.- dev 와 prod 의
.env가 같은 키를 사용하고 있어서 개발 환경 번들에도 같은 키가 노출됐습니다. 환경별 키 분리가 안 돼 있으면 노출 범위가 두 배가 됩니다. - 계정 제한이 적용되면 배포나 Lambda 처럼 무관해 보이는 기능이 먼저 실패합니다. 여러 AWS 서비스가 동시에 권한이나 쿼터 오류를 발생시키면 계정 레벨 조치를 의심하는 편이 빨랐습니다.