클라이언트 번들에 박힌 API 키를 CloudFront Edge 로 옮기기
정적 배포 사이트에서 NASA API 키가 클라이언트 번들에 그대로 노출되던 상황을 점검·발견하고, AWS CloudFront Function 을 사용해 엣지에서 키를 주입하는 구조로 전환한 실제 작업 기록. 로컬·프로덕션 환경별 흐름과 AWS 설정 단계까지.
시작 — 잘못 살아있던 것
정적 사이트에서 외부 API 를 호출할 때 떠올리게 되는 전통적 선택지는 두 가지입니다.
- 클라이언트에 키를
NEXT_PUBLIC_*으로 넣고 브라우저에서 직접 호출 - 서버 사이드 프록시를 두어 서버에서 키를 주입
1번은 편리하지만 키가 공개됩니다. 2번은 서버가 필요합니다. output: 'export' 같은 정적 배포에서는 2번이 불가능 — 여기까지가 흔한 생각이었습니다. 하지만 CloudFront 가 edge function 을 제공하면서 "정적이지만 엣지에서 키 주입" 이라는 제3의 옵션이 자리잡았습니다.
이 포스트는 그 3번째 선택지를 실제로 적용한 기록입니다.
기존 상태 — 키가 번들에 박혀 있던 구조
NASA 의 APOD 와 NeoWs API 를 세 곳에서 호출하고 있었습니다.
/apod— 오늘의 천문사진/neo— 근지구 소행성 모니터- 메인 홈 — APOD 썸네일
각 페이지 코드는 이런 식이었습니다.
const API_KEY = process.env.NEXT_PUBLIC_NASA_API_KEY ?? 'DEMO_KEY';
const APOD_URL = 'https://api.nasa.gov/planetary/apod';
fetch(`${APOD_URL}?api_key=${API_KEY}&date=${today}`);
NEXT_PUBLIC_* 접두어가 붙은 환경변수는 빌드 시점에 클라이언트 번들에 인라인 됩니다. 즉, 빌드 결과물 .js 파일을 열어보면 api_key=xxxxxx 가 문자열로 그대로 박혀 있다는 의미입니다.
점검 — 보안 문제 확인
아래 절차로 누구나 재현할 수 있는 수준이었습니다.
- 프로덕션에 배포된
_next/static/chunks/*.js를 브라우저 Sources 탭에서 열기 api.nasa.gov로 검색 → 해당 URL 패턴 출현- URL 주변을 살펴보면
api_key=쿼리 파라미터로 40자 키가 노출
이 키는 제 NASA 계정에 귀속된 자산입니다. 악의적인 이용이 발생하면:
- Rate limit 소진 — 정상 방문자가 APOD/NEO 페이지에서 실패 응답을 받게 됩니다
- 정책 위반 시 키 차단 — 키가 죽으면 해당 기능 전체 다운입니다
NASA 는 개인 키에 금전적 과금은 없지만, rate limit 과 차단 리스크는 명확합니다. 즉, 비용은 없어도 서비스 가용성 이 공격자의 손에 달리게 되는 구조였습니다.
로컬 dev · 프로덕션 환경에서의 기존 동작
같은 코드가 두 환경에서 어떻게 다르게 동작했는지 정리했습니다.
로컬 개발
.env.local의NEXT_PUBLIC_NASA_API_KEY를 읽어 빌드- 브라우저가 직접
api.nasa.gov호출 - 문제: DevTools 에서 바로 키가 보임. 단, 로컬은 본인만 쓰니 실질 피해는 없음
프로덕션 (S3 + CloudFront)
- GitLab CI 가
NEXT_PUBLIC_NASA_API_KEY를 주입해 빌드 - 결과 번들에 키가 인라인된 상태로 S3 업로드
- 방문자 브라우저가
api.nasa.gov직접 호출 - 문제: 전 세계 누구나 DevTools 로 키 추출 가능
어느 환경이든 동일한 설계 결함이 있었지만, 실제 노출 범위와 리스크는 prod 쪽이 비교가 안 되게 컸습니다.
해결 설계 — 엣지에서 키 주입
핵심 아이디어 3가지.
- 클라이언트는 절대 키를 모른다 — 코드에서
api_key흔적을 전부 제거 - 호출 흐름의 "NASA 로 나가기 직전" 지점에서만 주입 — 그 이전까지는 키가 존재조차 하지 않는다
- 주입 지점은 CloudFront Function — 엣지에서 실행되는 경량 JS 런타임
새 흐름:
[브라우저]
GET https://js2devlog.com/api/nasa/apod?date=2026-04-19
↓
[CloudFront — Behavior: /api/nasa/*]
↓
[CloudFront Function: nasa-api-key-inject]
- URI rewrite: /api/nasa/apod → /planetary/apod
- Querystring 추가: api_key=<NASA_KEY>
↓
[Origin: api.nasa.gov]
GET /planetary/apod?date=2026-04-19&api_key=<NASA_KEY>
↓
[응답이 역방향으로 브라우저까지]
클라이언트 코드는 이렇게 짧아집니다.
const APOD_URL = '/api/nasa/apod'; // 상대 경로 — 도메인 없음
fetch(`${APOD_URL}?date=${today}`); // api_key 완전히 없음
/api/nasa/* 라는 상대 경로 자체가 곧 "CloudFront 가 프록시해주는 영역" 이라는 약속이 됩니다.
로컬 dev 에서는?
정적 배포는 CloudFront 가 있지만, 로컬 dev 서버에는 없습니다. 해결책은 Next.js 의 rewrites():
// next.config.ts
async rewrites() {
return [{
source: '/api/nasa/apod',
destination: `https://api.nasa.gov/planetary/apod?api_key=${process.env.NASA_API_KEY}`,
}];
}
여기서 몇 가지 포인트.
rewrites()는 dev 서버 런타임 전용 —output: 'export'빌드 산출물에는 포함되지 않음- 따라서 prod 에서는 이 설정이 작동하지 않지만, 그 역할을 CloudFront 가 대신
- dev 환경에서만
.env.local의NASA_API_KEY가 실제로 사용됨 NEXT_PUBLIC_접두어는 절대 쓰지 않음 — 그 순간 빌드 시점 인라인이 부활
환경별 최종 동작 정리
| 환경 | 키 주입 위치 | 키 출처 | 클라이언트 노출 |
|---|---|---|---|
| 로컬 dev | Next.js dev 서버 (rewrites) | .env.local (서버 전용 변수) | 없음 |
| GitLab CI 빌드 | (이 구조에선 빌드 시 키 불필요) | — | — |
| Prod (S3 + CloudFront) | CloudFront Function (edge) | Function 코드 내 하드코딩 | 없음 |
AWS 작업 — 단계별 상세
AWS Console 한국어 UI 기준으로 작성했습니다.
1. Origin 추가
CloudFront → Distributions → (대상 배포 선택) → Origins 탭 → 원본 생성
| 항목 | 값 |
|---|---|
| 원본 도메인 | api.nasa.gov |
| 프로토콜 | HTTPS만 |
| 최소 원본 SSL 프로토콜 | TLSv1.2 |
| 원본 경로 | (비워둠) |
| 이름 | nasa-api |
저장.
2. CloudFront Function 생성 + 게시
CloudFront → 함수(Functions) → 함수 생성
| 항목 | 값 |
|---|---|
| 이름 | nasa-api-key-inject |
| 런타임 | cloudfront-js-2.0 |
코드 (Development 탭):
function handler(event) {
var req = event.request;
var key = 'YOUR_NASA_API_KEY_HERE';
if (req.uri.startsWith('/api/nasa/neo/')) {
req.uri = '/neo/rest/v1/' + req.uri.substring('/api/nasa/neo/'.length);
} else if (req.uri === '/api/nasa/apod') {
req.uri = '/planetary/apod';
}
req.querystring['api_key'] = { value: key };
return req;
}
변경 사항 저장 → 게시(Publish) 탭 → 함수 게시 필수.
⚠️ 이 단계가 핵심입니다. Publish 하지 않으면 Behavior 드롭다운에 함수가 노출되지 않습니다.
3. Behavior 추가
Distribution → 동작(Behaviors) 탭 → 동작 생성
| 항목 | 값 |
|---|---|
| 경로 패턴 | /api/nasa/* |
| 원본 및 원본 그룹 | nasa-api (방금 만든 origin) |
| 뷰어 프로토콜 정책 | HTTP를 HTTPS로 리디렉션 |
| 허용된 HTTP 방법 | GET, HEAD |
| 뷰어 액세스 제한 | No |
| 압축 | Yes |
| 캐시 정책 | CachingOptimized — APOD/NEO 는 일 단위 데이터라 적합 |
| 원본 요청 정책 | AllViewerExceptHostHeader ← 필수 |
| 함수 연결 — 뷰어 요청 | CloudFront Functions → nasa-api-key-inject |
저장. 동작 목록에서 우선순위(Precedence) 는 구체적 경로가 와일드카드보다 위에 있으면 됩니다.
원본 요청 정책을
None으로 두면 query string 이 origin 으로 전달되지 않아 NASA 가API_KEY_MISSING을 돌려줍니다. 가장 자주 나는 실수입니다.
4. 기존 키 revoke + 신규 발급
기존 키는 이미 공개 상태이므로 폐기합니다.
- https://api.nasa.gov/ 접속 → 상단 "Generate API Key" 폼 작성 → 이메일로 40자 키 수신
- Function 코드의
'YOUR_NASA_API_KEY_HERE'를 신규 키로 교체 → 저장 → 게시 다시 수행
5. GitLab CI/CD Variables 정리
Settings → CI/CD → Variables
| Variable | 조치 |
|---|---|
NEXT_PUBLIC_NASA_API_KEY | 삭제 — 이 접두어는 클라이언트 번들에 인라인되기 때문 |
NASA_API_KEY | 신규 등록, Protected + Masked 체크 |
- Protected: 보호된 브랜치/태그에서만 사용 가능
- Masked: CI 로그에
***로 가려짐
6. 로컬 .env.local 갱신
# 삭제
NEXT_PUBLIC_NASA_API_KEY=xxx
# 추가 (변수명만 바뀜, 같은 키 값)
NASA_API_KEY=신규_키
검증
CloudFront 배포 상태가 "배포 완료(Deployed)" 로 바뀐 뒤 (수 분 소요):
curl -I "https://www.js2devlog.com/api/nasa/apod?date=2026-04-19"
성공: HTTP/2 200 + JSON 응답 본문 ({"copyright":"...","title":"...",...})
실패 시 힌트:
| 증상 | 원인 |
|---|---|
{"error_message":"API_KEY_MISSING"} | Function 미게시 / Behavior 연결 누락 |
{"error":{"code":"API_KEY_INVALID"}} | Function 코드의 키 문자열 오타 |
| 403 / 404 | 원본 요청 정책이 AllViewerExceptHostHeader 아님 |
브라우저 DevTools 에서는 Network 탭의 Request URL 에 api_key 쿼리가 보이지 않아야 합니다 — 엣지에서 주입하므로 클라이언트에는 존재하지 않는 게 정상입니다.
CloudFront Function 의 한계와 트레이드오프
이 구조의 가장 큰 제약: 키가 Function 코드에 문자열로 들어간다는 것입니다. CloudFront Function 은 런타임 제약이 엄격해서 AWS Secrets Manager / 환경변수를 참조할 수 없습니다.
이 설계에서 키 노출 범위는 이렇습니다.
- 일반 사용자: 조회 불가 (CloudFront 응답에 포함 안 됨)
- AWS Console 접근자: 코드를 열면 조회 가능
- Git repo: 전혀 없음
팀 규모가 크거나 compliance 요구가 있다면 Lambda@Edge + Secrets Manager 로 승격할 수 있습니다. Lambda@Edge 는 Node.js 런타임이라 Secrets Manager 호출이 가능합니다. 다만 콜드 스타트 지연 수백 ms 와 추가 과금이 동반됩니다. 개인 블로그 규모에서는 CloudFront Function 으로 충분하다고 판단했습니다.
동일 패턴을 재활용
이번 작업으로 /api/nasa/* 를 완성했지만, 같은 구조가 다른 외부 API 에도 그대로 적용됩니다.
/api/exoplanet→ NASA Exoplanet Archive (키 불필요)/api/mars-rss→ NASA Mars RSS (키 불필요, URI rewrite 만)
각각 별도 Origin + Behavior + (필요 시) Function 을 만들면, 클라이언트 코드에서는 전부 상대 경로 + 도메인 없음 으로 통일됩니다. CORS 우회도 덤으로 해결됩니다.
회고
세 가지를 남기고 싶습니다.
1. NEXT_PUBLIC_* 는 "공개됨" 의 뜻입니다. 이름만 보면 그저 접두어처럼 느껴지지만, 실제 의미는 "이 값은 누구나 꺼낼 수 있다" 입니다. 민감한 무엇도 이 접두어로 넣으면 안 됩니다.
2. 정적 배포에서도 엣지 기능으로 대부분의 백엔드 필요를 대체할 수 있습니다. CloudFront Functions / Lambda@Edge / Vercel Edge Functions 등 선택지가 풍부해졌습니다. "정적이라 못 한다" 는 이제 잘못된 전제입니다.
3. 보안 설정은 가장 먼저 해야 합니다. 이 프로젝트는 꽤 오랫동안 공개 키를 달고 돌아갔습니다. 악의적 이용이 없었던 건 그냥 운이 좋았기 때문입니다. 이런 류의 "키를 옮기는" 작업은 실제론 30분~1시간이면 끝나므로, 발견했을 때 바로 처리하는 게 기술 부채를 가장 덜 쌓는 방법입니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...