
프로덕션 JS 번들에 남은 mock 데이터와 dead-code elimination의 함정
아래 사례의 서비스명·도메인·계정 정보는 모두 가상으로 변경했습니다.
외부 보안점검 과정에서 예상하지 못한 문제가 발견됐다. 로그인이나 특정 화면 진입 없이, 서비스의 정적 JavaScript 파일 하나를 내려받는 것만으로 실제 계정 정보처럼 보이는 데이터가 노출된 것이다.
GET /js/app.f7d11f3b.js HTTP/1.1
Host: example-service.com
문제의 번들에는 도메인명, 사용자 ID, 구독 상태, 가격, 서비스 할당량 등의 값이 포함돼 있었다. 처음에는 오래전에 사용하던 체크아웃 페이지의 잔재를 의심했다. 관련 라우트는 이미 신규 서비스로 리다이렉트되도록 정리돼 있었기 때문이다.
하지만 조사 결과, 원인은 과거 기능이 아니라 현재도 사용 중인 기능의 로컬 개발용 mock 데이터였다.
사건의 배경: 사내망 API와 로컬 mock
호출하는 백엔드 API는 사내망 또는 VPN 환경에서만 접근 가능한 주소를 사용하고 있었다. 따라서 개발자가 로컬에서 화면을 확인하려면 실제 API 대신 응답을 흉내 내는 장치가 필요했다. 이때 axios-mock-adapter 기반의 mock이 추가됐다.
문제는 mock에 들어간 데이터였다.
// 개념 재현
mock.onGet('/migration/customer').reply(200, {
domain: 'customer-company.example',
user_id: 'realistic-user-id',
seq: 123456,
});
mock 자체가 위험한 것은 아니다. 로컬 개발 환경에서만 동작하고, 데이터가 완전히 가상이라면 유용한 개발 도구다.
하지만 다음 두 가지가 동시에 발생하면 문제가 된다.
- mock 데이터가 실제 응답을 복사한 것처럼 현실적인 값을 포함하고 있었고
- 로컬 환경에서만 실행되도록 감싼 코드가 프로덕션 번들에서 제거되지 않았다
즉, 운영 사용자가 해당 mock을 실행할 수는 없었지만, 번들 파일 안에는 데이터가 그대로 남아 있었다.
핵심 원인: 조건문으로 감쌌는데 왜 번들에는 남았을까?
문제의 구조는 다음과 비슷했다.
const isLocal = ['LOCAL'].includes(String(process.env.APP_MODE));
if (isLocal) {
attachMockAdapter(instance);
}
의도는 명확했다. APP_MODE가 LOCAL일 때만 mock을 붙이겠다는 것이다.
프로덕션 빌드 시 환경변수는 대체로 실제 값으로 치환된다. 예를 들어 운영 환경의 값이 PROD라면 결과는 아래처럼 된다.
const isLocal = ['LOCAL'].includes(String('PROD'));
if (isLocal) {
attachMockAdapter(instance);
}
사람이 보면 isLocal은 명백히 false다. 하지만 번들 압축기와 dead-code elimination은 사람이 아니라 도구의 규칙에 따라 동작한다.
Dead-code elimination은 “확실히 증명 가능한 코드”만 제거한다
Terser, esbuild, SWC 같은 번들 압축기는 파일 크기를 줄이기 위해 실행될 수 없는 코드를 제거한다. 이를 흔히 dead-code elimination이라고 부른다.
다만 중요한 전제가 있다.
압축기는 해당 코드가 절대 실행될 수 없다는 사실을 안전하게 증명할 수 있을 때만 제거한다.
예를 들어 아래 코드는 판단이 쉽다.
if (process.env.NODE_ENV === 'development') {
attachOtherMock(instance);
}
프로덕션 빌드에서 환경변수가 치환되면 다음과 같다.
if ('production' === 'development') {
attachOtherMock(instance);
}
문자열 리터럴 두 개의 === 비교는 결과가 명확하다.
if (false) {
attachOtherMock(instance);
}
따라서 압축기는 해당 블록을 제거할 수 있다. 이 블록에서만 사용하던 import도 사용되지 않게 되면, 최종 번들에서 관련 파일과 데이터까지 함께 빠질 수 있다.
반면 아래 코드는 다르다.
['LOCAL'].includes('PROD')
사람에게는 결과가 자명해도, 압축기가 일반적인 메서드 호출 결과까지 적극적으로 계산해 제거한다고 기대하면 안 된다. Array.prototype.includes() 같은 호출은 런타임 동작, 폴리필, 객체 변경 가능성 등 여러 맥락과 연결될 수 있다.
결과적으로 압축기는 다음과 같이 판단할 수 있다.
- 이 조건이 거짓인지 확실히 알 수 없다.
- 따라서
if블록이 실행되지 않는다고 증명할 수 없다. - 그러므로 블록을 제거하지 않는다.
- 블록이 남으니 내부 함수 호출과 import도 남는다.
- import가 남으니 mock 데이터도 번들에 포함된다.
이번 사례는 바로 이 지점에서 발생했다.
“실행되지 않음”과 “배포되지 않음”은 다르다
이번 일을 통해 가장 명확하게 정리된 문장은 이것이다.
조건문으로 막았다고 해서, 그 코드와 데이터가 배포 산출물에서 사라지는 것은 아니다.
운영 환경에서 mock이 실행되지 않는 것과, mock 데이터가 JS 번들에 존재하지 않는 것은 완전히 다른 문제다.
전자는 기능 동작의 문제이고, 후자는 노출 면적과 정보보호의 문제다.
정적 JavaScript 파일은 보통 인증 없이 배포된다. 브라우저가 서비스를 렌더링하기 위해 내려받아야 하기 때문이다. 따라서 번들에 포함된 문자열, 객체, API 경로, 설정값은 누구나 확인할 수 있다고 보는 편이 안전하다.
실제로 운영 번들을 내려받아 검증해 보니 결과도 명확했다.
- 단순
===비교로 게이팅된 다른 mock 데이터: 검색 결과 없음 .includes()기반 조건문으로 게이팅된 문제 mock 데이터: 번들에서 그대로 검출
조건문 표현 방식 하나가 최종 배포물의 포함 여부를 갈랐다.
다른 압축기를 썼다면 해결됐을까?
가능성은 낮다.
이 문제는 특정 압축기의 단순한 버그라기보다, dead-code elimination이 안전성을 우선하는 방식에서 비롯된 문제에 가깝다. 압축기는 임의의 함수나 메서드 호출을 무조건 실행해 결과를 미리 계산하지 않는다.
물론 도구·버전·설정·코드 형태에 따라 최적화 결과는 달라질 수 있다. 일부 도구가 특정 패턴을 더 공격적으로 접어 줄 수도 있다. 하지만 보안 관점에서 “압축기가 알아서 제거해 줄 것”을 전제로 설계하는 것은 위험하다.
핵심은 압축기를 바꾸는 것이 아니다.
어떤 번들러를 사용하더라도, 프로덕션 산출물에 포함되면 안 되는 데이터는 애초에 포함되지 않도록 구조를 설계해야 한다.
더 안전한 대응 방식
이번 건은 조건문만 수정하는 방향으로 끝내지 않았다. 문제가 된 mock 자체가 현재도 필요한지 재검토했고, 최종적으로는 해당 mock을 완전히 제거하는 방향을 선택했다.
그 과정에서 정리한 대응 원칙은 다음과 같다.
1. mock과 fixture에는 실제 데이터를 넣지 않는다
개발 편의성을 위해 실제 API 응답을 복사하는 일은 피해야 한다.
const mockCustomer = {
domain: 'example.com',
user_id: 'user1',
seq: 1,
plan: 'Sample Plan',
price: 10000,
quota: 10
};
처음부터 명백한 가상 데이터만 사용하면, 실수로 번들에 포함되더라도 피해 가능성을 크게 줄일 수 있다.
2. 환경 분기는 단순하고 정적으로 판별 가능한 형태를 사용한다
가능하면 번들러가 명확하게 판단할 수 있는 리터럴 비교를 사용한다.
if (process.env.NODE_ENV === 'development') {
attachMockAdapter(instance);
}
반대로 아래처럼 함수 호출이나 컬렉션 탐색 결과에 의존하는 조건은 dead-code elimination 결과를 장담하기 어렵다.
['LOCAL'].includes(process.env.APP_MODE);
modes.some((mode) => mode === process.env.APP_MODE);
isDevelopmentEnvironment();
단, 단순 비교로 바꾸는 것만으로 충분하다고 생각해서는 안 된다. 이는 최적화에 유리한 표현일 뿐, 민감 데이터 차단의 최후 방어선이 되어서는 안 된다.
3. mock을 프로덕션 코드 경로에서 분리한다
가장 좋은 방법은 mock을 애플리케이션의 일반적인 프로덕션 import 경로에 두지 않는 것이다.
예를 들어 개발 전용 엔트리, 별도 개발 서버 설정, 테스트 전용 모듈 등으로 분리하면 프로덕션 빌드에 섞일 가능성을 줄일 수 있다.
핵심은 다음과 같다.
- 프로덕션 엔트리에서 mock 모듈을 import하지 않기
- mock 데이터 파일을 운영 소스 트리에 무심코 두지 않기
- 필요한 경우 개발 전용 빌드 설정에서만 mock을 연결하기
4. 배포 산출물을 직접 검사한다
소스코드 검토만으로는 충분하지 않다. 실제 배포되는 결과물을 확인해야 한다.
예를 들어 CI 단계에서 아래 항목을 점검할 수 있다.
- 고객 도메인 패턴
- 사내 도메인 및 내부 API 주소
- 이메일 주소
- 특정 사용자 ID 또는 내부 식별자 형식
- 접근 토큰·API 키 형식
mock,fixture,axios-mock-adapter등 금지 키워드- 알려진 민감 문자열 목록
중요한 것은 “소스에 없었는지”가 아니라 “최종 번들에 없는지”다.
체크리스트
배포 전 다음 항목을 확인해 볼 수 있다.
- mock·fixture 데이터에 실제 사용자 또는 고객 데이터를 사용하지 않았는가?
- mock 모듈이 프로덕션 엔트리에서 import되지 않는가?
- 환경 분기가 단순한 정적 비교로 작성돼 있는가?
- “실행되지 않는다”와 “번들에 존재하지 않는다”를 구분하고 있는가?
- 운영 빌드 결과물에 대해 문자열·정규식 기반 스캔을 수행하는가?
- 소스맵, 정적 JS, 설정 파일 등 공개 가능한 산출물을 함께 점검하는가?
- 외부 보안점검에서 발견된 유형을 CI/CD 검증 항목으로 되돌렸는가?
마무리
이번 이슈는 복잡한 취약점이 아니었다. 인증 우회도, 서버 침해도, 특수한 공격 기법도 필요하지 않았다. 공개된 정적 JavaScript 파일 하나를 내려받아 검색하는 것만으로 충분했다.
문제의 본질은 단순하다.
- 로컬 전용이라고 생각한 mock이 운영 번들에 포함됐고
- mock 데이터가 실제 데이터처럼 현실적이었으며
- “실행되지 않는다”는 사실을 “노출되지 않는다”로 잘못 해석했다
결국 보안은 코드가 실제로 실행되는 경로만 보는 일이 아니다. 사용자에게 전달되는 모든 산출물에 무엇이 들어 있는지 확인하는 일이기도 하다.
그리고 이번 사례가 남긴 가장 중요한 교훈은 한 문장으로 정리할 수 있다.
프로덕션에서 실행되지 않는다고 안심하지 말고, 프로덕션 번들에 실제로 포함되지 않았는지 확인하자.
'웹' 카테고리의 다른 글
| 브라우저 쿠키 저장소 제약 (0) | 2026.08.19 |
|---|---|
| 크롬 익스텐션 배포할 때마다 데이터가 사라졌던 이유 (0) | 2026.08.12 |
| Tailwind v4 + 사내 GNB 충돌 디버깅기 (0) | 2026.05.28 |
| Google 소셜 로그인 뿌시기 (0) | 2025.06.10 |
| 한글 입력 시 중복 요청 이슈와 해결 방법 (1) | 2025.04.15 |