개발

번들을 실행해 보기로 했다

JonghwanWon 2026. 8. 3. 23:19

프로덕션 빌드가 성공했다는 사실은 꽤 많은 것을 보장한다.

문법 오류가 없고, TypeScript 변환이 끝났고, 의존성이 묶였고, 배포할 JavaScript 파일도 만들어졌다는 의미이다.

CI가 초록불을 띄우면 안심하게 되는 이유다.

하지만 모든 과정을 통과하고도, 사용자가 특정 메뉴에 들어가는 순간 화면이 깨질 수 있다.

missingProductionReference();

export const pageName = "Page";

 

이 코드는 번들 생성 자체는 통과할 수 있다. 번들러는 코드를 읽고 파일을 만들지만 실제 브라우저 환경에서 존재하는지까지 실행해서 확인하지는 않기 때문이다.

 

브라우저가 특정 모듈을 불러와 평가하는 순간에 ReferenceError가 발생할 수 있다.


첫 번째 오류가 발생했을 때엔, 배포 직후 첫 페이지를 열어 보는 E2E 스모크 테스트를 만들었다.

적어도 앱이 시작조차 되지 않는 문제를 배포 직후 빠르게 발견할 수 있었다.

 

두 번째 오류가 발생했을 때엔, 빌드 결과를 대상으로 실행하여 테스트를 진행한다는 규칙이 만들어졌다.

개발 서버에서는 잘 동작하지만, 프로덕션 빌드 결과에서 발생하는 문제를 발견할 수 있었다.

 

그리고, 세 번째 오류가 발생했다.

첫 페이지도 멀쩡하고, 주요 화면도 문제없이 열린다. 하지만 깊은 route에 진입하고, 여러 데이터 설정이 동시에 갖추어졌을 때 오류가 발생한다.

 

정상 경로 편향(Happy Path Bias)

우리는 늘 익숙한 경로를 검증한다. 자주 사용하는 메뉴, 대표적인 권한, 가장 단순한 데이터 상태. 앱이 정상적으로 동작한다는 확신을 주는 경로들이다.

사용자가 가장 자연스럽게 밟을 것 같은 경로, 개발자가 가장 익숙한 경로에 검증이 집중되어 조건이 복잡한 경로는 뒤로 밀린 것이다.

 

이번 오류는 첫 페이지를 여는 테스트로 발견할 수 없었다. 여러 단계의 route 이동과 데이터가 필요했고, 그 상태를 E2E 테스트로 구성하는 일도 쉽지 않았다.

 

질문이 달라져야했다.

"모든 사용자의 경로를 재현하지 않고도, 아직 한 번도 열리지 않은 lazy chunk가 최소한 실행 가능한지 어떻게 확인할 수 있을까?"

Vite Manifest는 이미 lazy entry 목록을 알고 있다.

Vite는 빌드 과정에서 manifest를 만든다. 

{
  "index.html": {
    "file": "assets/index.js",
    "isEntry": true
  },
  "src/routes/GoalListPage.tsx": {
    "file": "assets/chunk.goal-list.js",
    "isDynamicEntry": true
  }
}

isDynamicEntry는 dynamic import로 만들어진 실행 진입점이라는 의미이다.

즉, manifest는 "어떤 URL에서 이 페이지에 도달하는가?"는 몰라도, "최종 산출물 중 어떤 chunk가 lazy entry인가?"는 알고 있다.

 

검증기는 headless Chromium에서 이 목록을 모두 가져와 하나씩 직접 import 한다.

for (const lazyFile of dynamicEntryFiles.sort()) {
  await import(lazyFile);
}

사용자 권한을 만든 필요도 없고, 특정 메뉴를 클릭할 필요도 없다. 브라우저에게 해당 모듈을 직접 불러오라고 요청하면 된다.

 

import는 파일과 의존성을 로드하고, 모듈 최상위 코드를 평가한다.

따라서 lazy chunk 최상위에서 오류가 발생하는 경우, import Promise가 reject 된다.

 

lazy chunk가 공유 모듈을 import 하고 있고, 그 공유 모듈이 누락된 경우도 마찬가지다. 브라우저는 dependency graph를 따라가며 파일을 요청하고, 문제를 발견하면 실패한다.

 

entry와 dynamic entry라는 실행 root를 열고, 그 의존성은 브라우저가 실제 실행 방식대로 따라가게 한다. 공유 모듈은 한 번 평가한 뒤 cache에 보관되므로, 같은 페이지 안에서는 실제 브라우저와 비슷한 의미론을 유지할 수 있었다.

Chromium에서 실행하는 이유

프론트엔드 번들은 Node.js에서 실행할 코드가 아니다.

DOM, 브라우저 전역 객체, CORS, ESM loader 등의 동작이 모두 결과에 영향을 미친다. 검증은 로컬 HTTP 서버와 headless Chromium을 사용한 이유다.

dist를 제공하는 로컬 서버
    │
    ▼
headless Chromium
    │
    ├─ index.html 로드
    ├─ entry module 평가
    ├─ 모든 lazy entry import
    └─ pageerror / request 실패 수집

정적 규칙으로 위험한 코드를 추측하는 대신, 목표 브라우저가 직접 판정하게 만든 셈이다.

이 방식은 단순하다. 배포할 것은 TypeScript 소스가 아니라, 브라우저가 일게 될 JavaScript 번들이기 때문이다.

이 검증은 무엇을 잡고, 무엇을 놓칠까

잡을 수 있는 문제는 다음과 같다.

 - entry 또는 lazy chunk 최상위 코드의 예외

 - 누락된 JavaScript dependency

 - import와 export 연결 실패

 - 끝나지 않는 top-level await

 - 모듈 초기화 중 발생하는 비동기 오류

 

잡기 어려운 문제도 있다.

 - 버튼을 클릭해야 실행되는 코드의 오류

 - 실제 React 렌더링에서만 발생하는 오류

 - 검증이 끝난 한참 뒤 발생하는 비동기 오류

 - 특정 API 응답에서만 발생하는 오류

 

따라서, 이 검증방식은 E2E 테스트의 대체가 아니다.

production build: 결과를 만들 수 있는가?

build verify: 최종 번들이 브라우저에서 최소한 평가될 수 있는가?

E2E / Component test: 사용자가 실제 기능을 사용할 수 있는가?

각각 다른 실패를 막는다.

세 번의 오류가 남긴 것

 

첫 번째 오류 뒤에는 배포 직후 첫 페이지를 여는 스모크 테스트가 생겼다.

두 번째 오류 뒤에는 프로덕션 빌드 결과로 검증하는 규칙과 배포 체크리스트가 생겼다.

세 번째 오류 뒤에는 아직 사용자가 방문하지 않은 lazy chunk까지 직접 실행해 보는 검증이 생겼다.

 

오류가 발생할 때마다 안전망이 하나씩 늘어났다. 중요한 것은 "다시는 오류가 나지 않게 하자"가 아니라,

이번 오류가 기존 검증의 어느 빈틈을 통과했는지 이해하고 그 빈틈을 메꾸어나가는 것이었다.

 

빌드 성공은 좋은 신호지만, 충분한 신호는 아니다.

이제는 한 단계 더 묻게 되었다.

 

이 번들은 빌드되었는가?

실제 브라우저에서 실행될 수 있는가?

 

두 질문 사이에는 생각하는 것보다 큰 간격이 있었다.

세번째 오류를 통해 이제 그 간격을 조금 좁혀줄 장치를 마련할 수 있었다.