모듈 평가 순서 이해하기
새로고침할 때만 React Router loader가 실패했던 이유
특정 페이지에 진입하면 loader에서 데이터를 정상적으로 불러오지만, 해당 페이지에서 직접 새로고침하면 errorElement가 렌더링되는 문제가 있었다.
처음에는 MSW의 초기화 시점이나 요청 핸들러를 의심했다. 하지만 핸들러 자체는 정상이었고, 문제는 요청이 발생하는 시점에 있었다.
이 문제를 해결하는 과정에서 React의 렌더링 시점과 JavaScript 모듈의 평가 시점을 비교하며 헷깔리던 모듈 평가를 순서대로 정리해볼 수 있었다.
1. 같은 페이지인데 왜 새로고침할 때만 실패하는 이유
문제는 두 가지 진입 경로에서 다르게 나타났다.
| 진입 |
경로결과 |
| 메인 페이지에서 다른 페이지로 이동 | 정상 동작 |
| 해당 페이지 URL에서 직접 새로고침 | errorElement 렌더링 |
두 경우 모두 같은 라우트에 진입하지만, 애플리케이션이 시작되는 과정은 다르다.
페이지 내부에서 이동하면 이미 애플리케이션이 초기화된 상태다. 반면 특정 페이지에서 새로고침하면 애플리케이션이 처음부터 실행되고, 현재 URL에 매칭되는 라우트의 loader가 초기 진입 과정에서 실행될 수 있다.
당시 개발 환경에서는 다음과 같은 초기화 코드를 사용하고 있었다.
import App from './App';
prepare().then(() => {
createRoot(document.getElementById('root')!).render(<App />);
});
코드만 보면 prepare()가 완료된 뒤에 App을 렌더링하므로 문제가 없어 보인다. 실제로 React 렌더링은 prepare()가 완료된 뒤에 실행된다.
하지만 여기에는 중요한 차이가 있다.
렌더링을 늦추는 것과 모듈 평가를 늦추는 것은 서로 다른 문제다.
App을 정적으로 import하는 순간, main.tsx의 본문이 실행되기 전에 App.tsx와 그 정적 의존성들이 평가될 수 있다.
따라서 prepare()가 실행되기 전에 앱 초기화 코드가 동작했을 가능성을 확인해야 했다.
2. 응답을 확인하면서 요청이 어디로 갔는지 추적하기
먼저 실패한 요청의 응답을 확인했다.
| 진입 경로 | HTTP | StatusContent-Type |
| 페이지 내부에서 이동 | 200 | application/json |
| URL 직접 새로고침 | 200 | text/html |
같은 API 요청인데 응답의 Content-Type이 달랐다.
정상적인 경우에는 MSW가 요청을 가로채고 JSON 응답을 반환했다. 반면 새로고침에서는 상태 코드가 200이었지만 HTML이 반환됐다.
Vite 개발 서버는 SPA 환경에서 알 수 없는 경로에 대해 index.html을 반환할 수 있다. 따라서 이 결과는 API 요청이 의도한 MSW 핸들러에서 처리되지 않고 개발 서버까지 전달됐다는 가설을 뒷받침했다.
여기서 중요한 것은 HTTP 상태 코드만으로 요청의 성공 여부를 판단하지 않는 것이다.
- 200 + application/json: 기대한 API 응답인지 본문까지 확인한다.
- 200 + text/html: API 응답을 기대했는데 HTML이 왔다면 라우팅이나 SPA fallback 여부를 확인한다.
- 404: 요청 URL, 서버 라우팅, 핸들러 매칭 등을 확인한다.
물론 Content-Type만으로 원인을 확정할 수는 없다. 하지만 다른 페이지를 거쳐 진입하면 동일한 요청이 정상적으로 처리됐고, MSW 핸들러도 유효했기 때문에 초기화 시점에 초점을 맞출 수 있었다.
3. createBrowserRouter의 초기화 시점
원인을 찾기 위해 라우터가 생성되는 시점과 loader가 실행되는 시점을 확인했다.
// App.tsx
const router = createBrowserRouter([
{
path: '/items/:id',
loader: itemLoader,
element: <DetailPage />,
errorElement: <ErrorPage />,
},
]);
createBrowserRouter()는 라우터를 생성하면서 초기화를 수행한다. 초기화 과정에서 현재 URL에 매칭되는 라우트의 loader가 실행될 수 있다. 따라서 loader가 실행되는 시점은 반드시 createRoot().render() 이후라고 볼 수 없다.
기존 코드의 실행 흐름을 정리하면 다음과 같다.
main.tsx의 정적 import
↓
App.tsx 평가
↓
createBrowserRouter() 실행
↓
라우터 초기화
↓
현재 URL에 매칭되는 loader 실행
↓
API 요청 발생
↓
main.tsx 본문 실행
↓
prepare() 실행 및 MSW 초기화
↓
React 렌더링
문제가 되는 부분은 API 요청이 MSW 초기화보다 먼저 발생할 수 있다는 점이다.
main.tsx의 첫 줄에 import App from './App'이 있다고 해서 main.tsx의 나머지 코드가 먼저 실행되는 것은 아니다. 정적 import로 선언된 의존 모듈은 해당 모듈의 본문이 실행되기 전에 평가될 수 있기 때문이다.
이때 App.tsx의 모듈 최상위에서 createBrowserRouter()를 호출하면, 라우터 초기화 과정에서 loader가 실행될 수 있다.
MSW를 설치하기 전에 API 요청이 발생하는 것이다.
왜 다른 페이지를 거쳐 이동하면 정상적으로 동작했을까?
홈 페이지에는 문제가 되는 API 요청을 발생시키는 loader가 없었다.
따라서 홈 페이지로 직접 진입했을 때는 라우터 초기화 과정에서 문제가 되는 요청이 발생하지 않았다. 이후 사용자가 상세 페이지로 이동할 때는 이미 MSW 초기화가 완료된 상태였으므로 요청이 정상적으로 처리됐다.
반대로 상세 페이지 URL에서 직접 새로고침하면 초기 라우터 설정 과정에서 해당 loader가 실행되고, MSW 초기화보다 먼저 요청이 발생할 수 있었다.
4. 해결 방법: 초기화 이후에 App 모듈 불러오기
해결 방법은 App.tsx의 정적 import를 제거하고, MSW 초기화가 완료된 뒤 동적 import를 실행하는 것이었다.
// main.tsx
prepare().then(async () => {
const { default: App } = await import('./App');
createRoot(document.getElementById('root')!).render(<App />);
});
동적 import()는 해당 표현식이 실행될 때 모듈을 불러온다. 따라서 prepare()가 완료된 다음 import('./App')을 호출하면, App.tsx의 평가와 라우터 초기화를 그 이후로 미룰 수 있다.
여기서 중요한 것은 단순히 render()를 늦추는 것이 아니라, loader를 실행하는 라우터가 생성되는 시점 자체를 MSW 초기화 이후로 옮겼다는 점이다.
이 방식은 MSW뿐 아니라 앱을 초기화하기 전에 완료되어야 하는 다른 비동기 작업이 있을 때도 활용할 수 있다. 다만 초기화 전에 실행되면 안 되는 코드가 다른 정적 의존성에 남아 있지 않은지도 함께 확인해야 한다.
5. 모듈 평가 순서
이번 문제를 해결하고 나니, JavaScript 모듈이 처리되는 과정을 좀 더 명확하게 이해할 필요가 있었다.
모듈의 동작은 크게 구성, 연결, 평가라는 세 단계로 나누어 설명할 수 있다.
| 구성 | 모듈을 가져오고 파싱하며 의존성 그래프를 구성 |
| 연결 | import와 export의 바인딩을 연결 |
| 평가 | 모듈의 최상위 코드를 실행 |
구성과 연결 단계에서 모든 최상위 코드가 실행되는 것은 아니다. 실제 코드 실행은 평가 단계에서 이루어진다.
예를 들어 main.tsx가 App.tsx를 정적으로 import하고, App.tsx가 다시 여러 페이지와 API 모듈을 import한다면 다음과 같은 의존성 그래프를 생각할 수 있다.
main.tsx
│
▼
App.tsx
├── router
│ ├── HomePage.tsx
│ └── DetailPage.tsx
│ ├── remote/item.ts
│ └── domain/item.ts
└── queryClient 관련 모듈
브라우저는 이 그래프에 포함된 모듈을 가져오고 연결한 뒤 평가한다. 일반적으로 의존 모듈이 먼저 평가되고, 해당 모듈을 import하는 모듈이 나중에 평가된다.
다만 다운로드 순서와 평가 순서는 다르다. 브라우저는 여러 모듈의 로딩을 병렬로 진행할 수 있으며, 실제 평가 순서는 의존성 관계와 비동기 모듈의 동작에 따라 결정된다.
또한 같은 모듈을 여러 파일에서 import하더라도 동일한 모듈 인스턴스의 최상위 코드는 매번 실행되지 않는다. 따라서 모듈 수준에 선언한 변수나 객체는 여러 import 지점에서 공유될 수 있다.
이러한 특성은 모듈 평가 순서를 이해하는 데서 끝나지 않는다. 싱글턴 객체의 생명주기, 초기화 순서, 순환 의존성 문제를 분석할 때도 중요하다.
[모듈 평가]
main.tsx 평가
(App import 없음. 그래서 App.tsx 안 건드림)
prepare() 호출 → 안에서 await 만나고 양보
평가 끝. 콜스택 비었음
[마이크로태스크 큐]
prepare()의 await 재개
enableMocking() → worker.start() 완료
→ MSW 설치 완료
.then 콜백 시작
await import('./App')
← 여기서 App.tsx 평가 (평가 구간이 여기로 옮겨짐)
createBrowserRouter 실행
→ 주소 읽음 → loader 실행 → fetch
→ MSW가 잡음. json 옴
createRoot().render(<App />)
[React 렌더]
App() 호출
QueryClientProvider() 호출
RouterProvider() 호출
loader 결과 아직 안 왔으면 여기서 기다림
loader 결과 도착 → SeatSelectPage() 호출
useSuspenseQuery → 캐시에 이미 있음 → 바로 반환
[커밋]
DOM에 삽입. 이게 마운트
ref 연결
useEffect 실행
[페인트]
브라우저가 화면에 그림. 눈에 보임
[그 이후]
이벤트만 남음. 클릭, 타이머, 네트워크 응답
특정 페이지에서 새로고침할 때만 발생하는 API 오류의 원인은 loader 내부의 API 호출이나 MSW 핸들러가 아니라, 라우터가 생성되고 초기화되는 시점에 있었다.
이번 문제에서 가장 중요한 차이는 다음과 같다.
- prepare().then(render)는 렌더링 시점을 늦춘다.
- 정적 import는 의존 모듈의 평가를 그보다 앞서 발생시킬 수 있다.
- createBrowserRouter()의 초기화 과정에서 loader가 실행될 수 있다.
- 동적 import()를 활용하면 초기화가 완료된 뒤 앱 모듈을 평가하도록 구성할 수 있다.
비동기 초기화가 필요한 앱에서는 화면을 언제 렌더링하는지만 볼 것이 아니라, 초기화 전에 어떤 모듈이 평가되고 그 과정에서 어떤 부수 효과가 발생하는지도 확인해야 한다.
모듈 평가 순서는 언뜻 언어 차원의 세부 사항처럼 보이지만, 실제로는 API 요청의 타이밍이나 앱 초기화의 안정성까지 좌우할 수 있다.