집 나간 네트워크는 돌아왔는데 React.lazy는 왜 안 돌아올까

2026. 09. 15. 홍영민

Frontend

들어가며

파트너(업주)님이 영업 내내 주문을 받는 배민주문접수 웹뷰를 개발하던 중 QA 과정에서 네트워크 OFF -> ON으로 변경 후 다른 화면으로 이동 시 오류 화면이 노출된다는 티켓을 전달받았습니다.

원인을 파악해 보니 브라우저가 모듈 로드 실패를 기억하고 있었고, 이는 버그가 아니라 HTML 스펙에 명시된 동작이었습니다. 더 곤란한 것은 프런트엔드 빌드 도구인 Vite 공식 문서가 안내하는 해결책 중 하나가 새로고침이었다는 점입니다. 배민주문접수 웹뷰는 새로고침을 하면 앱 상태가 초기화되는 SPA(single page application)라 그 방법을 사용할 수 없었습니다. React.lazy 사용을 통째로 포기했다가 되살린 과정을 공유합니다.

실패를 기억하는 곳을 찾아가기

네트워크는 복구됐는데 화면은 그대로였다

배포일이 이틀 앞으로 다가온 시점이었습니다. 지체할 시간 없이 곧바로 티켓의 증상 재현에 나섰습니다.

  1. 앱을 켠 상태에서 네트워크 연결을 해제합니다.
  2. 다른 화면으로 이동합니다.
  3. 네트워크를 다시 연결합니다.

"오류가 발생했습니다." 페이지가 보입니다. 어느 화면으로 이동해도, 몇 번을 다시 시도해도 같습니다. 네트워크는 이미 정상인데 왜 계속 실패하는 것인지 궁금했습니다.

Sentry를 확인해 보니 모듈을 받아오지 못했다는 에러가 여러 화면에 걸쳐 쌓여 있었습니다.

당시 배민주문접수 웹뷰는 화면(라우트) 단위로 React.lazy와 dynamic import를 사용하여 코드 스플리팅을 하고 있었습니다.

// pages/Foo/index.ts
export default lazy(() => import('./ui/Foo'));

네트워크 연결이 끊어진 동안 아직 로드되지 않은 화면으로 이동하면 dynamic import가 실패했습니다. 여기까지는 예측할 수 있는 실패였습니다. 문제는 네트워크가 돌아온 뒤에도 계속 실패한다는 점이었습니다.

React.lazy 재시도가 통하지 않았다

처음 의심한 것은 React.lazy였습니다. React 공식 문서에는 Promise와 그 결과가 캐시되어 load 함수가 중복 호출되지 않으며, 실패 시 가장 가까운 ErrorBoundary로 에러를 전달한다고 안내합니다. 한 번 실패한 뒤에 다시 시도할 방법이 있는지 문서만으로 알 수 없어서 React 리포지토리의 ReactLazy.js를 열어 봤습니다.

React.lazy 컴포넌트는 내부에 상태 하나를 들고 있습니다. Uninitialized에서 시작해 Pending을 거쳐 ResolvedRejected로 가는데, 한 번 Rejected 상태가 되면 다른 상태로 전환될 수 없습니다. 그 뒤로는 렌더링될 때마다 캐시된 에러를 던질 뿐이고, React.lazy에 넘긴 함수는 다시 호출되지 않습니다.

이 증상을 검색했을 때 가장 먼저 접하게 되는 해법은 React.lazy에 전달한 함수가 실패하면 import()를 다시 호출하는 재시도 패턴입니다. AI가 가장 먼저 제안해 준 해결책 역시 이 재시도 패턴이었습니다. 배민주문접수 웹뷰에도 그대로 적용해 봤습니다.

그러나 동작하지 않았습니다. 더 이상했던 점은 실패하는 양상이었습니다. 재시도가 분명히 새 import()를 호출하는데도 네트워크 탭에 요청이 기록되지 않았습니다. 브라우저가 요청을 시도하지도 않고 즉시 같은 에러를 돌려주고 있었던 것입니다. 네트워크 요청조차 보내지 않고 즉시 에러를 반환한다면, 브라우저 어딘가에 실패 상태가 기록되어 있다는 뜻이었습니다. React의 상태를 새로 만들어 처음부터 다시 시작해도 새로 호출한 import()가 즉시 거부되는 것을 보면, 실패를 기억하고 있는 것은 React 바깥이겠다는 생각이 들었습니다.

모듈 맵은 실패도 기억한다

ES 모듈에는 모듈 맵(module map)이라는 document 단위 캐시가 있습니다. 브라우저는 모듈을 URL 단위로 모듈 맵에 캐시하고, 같은 URL을 다시 import하면 네트워크 대신 모듈 맵에 저장된 결과를 반환합니다. 같은 모듈이 두 번 평가되지 않게 하는 장치입니다.

문제를 겪은 2026년 6월 당시 HTML 스펙의 모듈 맵 정의에는 모듈 맵에 담기는 값의 종류에 이런 항목이 있었습니다.

null (used to represent failed fetches)

즉 불러오기에 실패한 것을 표현하는 값이 따로 정의되어 있습니다. 모듈 맵이 실패까지 캐시하는 것은 브라우저나 라이브러리의 버그가 아니라 스펙에 명시된 동작이었고, 브라우저는 스펙을 그대로 따르고 있었습니다. 이 문구는 최근 스펙 개정으로 현재는 빠져 있으며, 스펙 변경 내역의 삭제된 줄에서 확인할 수 있습니다.

한 번 실패한 모듈 URL은 document가 존재하는 동안 실패로 고정되고, 이후의 import()는 네트워크 요청 없이 캐시된 에러를 즉시 반환합니다. 오프라인에서 한 번 실패한 주소는 네트워크를 복구한 뒤 같은 주소로 다시 import()해도 결과가 같습니다. 재시도가 요청조차 하지 않던 이유가 여기 있었습니다.

2021년 웹 표준을 관리하는 WHATWG에 올라온 Failed dynamic import should not always be cached 이슈에서도 이 동작의 부작용을 지적한 바 있습니다.

Vite 공식 문서의 해답은 새로고침이었다

Vite는 이 문제를 이미 알고 있었습니다. 청크 로드 실패 시 window에 vite:preloadError 이벤트를 발생시키고, Vite 공식 문서의 Load Error Handling 절이 안내하는 처리 예시는 다음과 같습니다.

window.addEventListener('vite:preloadError', (event) => {
  window.location.reload() // for example, refresh the page
})

예시가 새로고침인 데에는 이유가 있습니다. 모듈 맵은 document 단위 캐시라 document를 새로 만들지 않고는 실패 기록을 지울 수 없기 때문입니다. Vite 공식 문서의 트러블슈팅 절에서도 브라우저 제약 때문에 dynamic import를 재시도할 수 없다고 명시하면서 그 근거로 앞에서 본 WHATWG 이슈를 안내하고 있습니다.

새로고침이 해결책이 될 수 없었던 이유

배민주문접수 웹뷰는 브라우저가 아닌 네이티브 앱 안에서 동작하는 SPA이자, 파트너님이 영업 내내 주문을 받는 프로그램입니다. 잠깐 들렀다 떠나는 일반 웹페이지와 달리 영업 시작부터 마감까지 온종일 켜두고 사용하며, 그동안 주문은 끊임없이 들어옵니다.

SPA에서 새로고침은 화면 하나를 다시 그리는 일이 아닙니다. document를 통째로 버리고 앱을 처음부터 다시 로드하는 일입니다. 메모리에 들고 있던 상태가 전부 사라지므로, 파트너님이 보고 있던 화면과 진행 중이던 작업이 모두 처음으로 돌아갑니다. 화면 하나를 되살리자고 영업 중인 프로그램을 방금 켠 상태로 되돌리는 셈입니다. Vite 공식 문서가 권한 새로고침이 배민주문접수 웹뷰에서는 선택지가 아니었습니다.

오픈 전날, 일단 코드 스플리팅을 포기했다

마지막으로 시도해 볼 것이 하나 남아 있었습니다. 실패한 주소 뒤에 쿼리스트링을 붙여 다른 주소처럼 다시 받아오는 방식입니다. 모듈 맵은 URL 전체를 키로 쓰기 때문에 쿼리스트링이 붙으면 새 모듈로 인식해 다시 불러옵니다.

await import(/* @vite-ignore */ `${failedChunkUrl}?retry=1`);

하지만 이 방법도 소용없었습니다. dist 디렉터리의 자바스크립트 파일에는 각 화면 청크의 참조 경로가 문자열 배열로 적혀 있었고, 청크 내부에서 다른 청크를 가리키는 정적 import 주소 역시 빌드 시점에 이미 고정되어 있었기 때문입니다.

실행 중에 주소를 바꿀 수 있는 것은 가장 바깥의 import() 하나뿐이고, 그 아래로 이어지는 청크들은 손댈 수 없습니다. 화면 청크를 새 주소로 다시 받아 와도 그 안에서 참조하는 공유 청크는 원래 주소를 그대로 가리킵니다. 실패가 기록된 것이 공유 청크라면 쿼리스트링을 붙여도 소용이 없고, 배민주문접수 웹뷰가 정확히 그런 경우였습니다.

이미 실패한 뒤에 되살리는 방법은 세 가지 모두 막혔습니다. React.lazy 재시도는 모듈 맵에 가로막혔고, 쿼리스트링은 그 아래로 이어지는 청크까지 바꾸지 못했으며, 새로고침은 서비스 특성상 사용할 수 없었습니다.

결국 화면 단위의 React.lazy를 전부 걷어내고 정적 import로 되돌렸습니다. 이로 인해 메인 번들 파일의 크기가 커졌지만, Rollup의 manualChunks 옵션으로 벤더 라이브러리를 분리해 완화했습니다. 덕분에 당장의 이슈를 해결하고 무사히 오픈까지 마쳤습니다. 다만 화면 수가 늘어날수록 메인 번들 파일이 무거워지는 문제는 여전히 해결해야 할 숙제로 남았습니다.

React.lazy가 실패하지 않게 만들기

서비스 오픈 이후 다시 React.lazy 복구를 시도했습니다. 원인을 파헤치며 알게 된 것을 나란히 놓고 보니 한쪽만 규칙이 달랐습니다.

  • import()의 실패는 document가 존재하는 동안 모듈 맵에 기록됩니다. 사후 복구가 불가능합니다.
  • fetch()의 실패는 어디에도 기록되지 않습니다. 몇 번이든 다시 시도할 수 있습니다.
  • import()의 성공 역시 document가 존재하는 동안 모듈 맵에 기록됩니다. 한 번 성공한 모듈은 이후 오프라인이어도 항상 로드됩니다.

같은 네트워크 요청인데 import()의 실패만 지울 수 없는 기록으로 남습니다. 그렇다면 실패한 뒤에 복구할 방법을 찾을 것이 아니라, 실패가 기록되는 상황을 처음부터 만들지 않아야 했습니다.

먼저 떠오른 것은 Service Workermodulepreload였는데요. Service Worker는 코드 스플리팅된 파일의 크기가 대부분 10KB 이하로 매우 작았기 때문에 캐시의 이득 보다는 관리 비용이 더 컸고, modulepreload는 Chrome 66이 지원 하한이므로 Chrome 51이 지원 하한인 배민주문접수 웹뷰의 환경에는 적절하지 않아 제외했습니다.

결국 남은 방법은 애플리케이션 코드에서 import()가 실행되는 순서를 직접 통제하는 것이었습니다. 재요청이 자유로운 fetch()로 연결 상태를 먼저 확인하고, 정상 연결이 확보된 시점에만 재시도가 불가능한 import()를 호출합니다. 그리고 앱 부팅 직후 주요 화면을 미리 import()해서 모듈 맵을 성공 기록으로 채워 둡니다.

네트워크가 살아 있을 때만 불러오기

React.lazy에 넘기는 함수를 감싸서 import() 전에 정적 웹 서버로 fetch()를 사용해 HEAD 요청을 보내고 응답이 올 때까지 기다립니다. 연결 확인에 실패하면 잠시 후에 다시 확인하고, 연결이 확인된 뒤에만 import()를 호출합니다. 모듈 맵에 기록이 남는 요청은 연결이 확인된 뒤에만 보내는 것입니다.

부팅 직후 주요 화면 미리 불러오기

연결 확인은 import() 실패를 막아주지만, 네트워크 연결이 끊어진 상황에서는 아직 한 번도 열지 않은 화면을 띄우지 못하고 로딩 상태로 기다리게 합니다. 그래서 부팅 직후 주요 화면을 미리 받아 모듈 맵을 성공 기록으로 채워 둡니다. 모듈 맵에 한 번 등록된 모듈은 오프라인이어도 모듈 맵에서 그대로 꺼내 쓰므로, 미리 받아 둔 화면만큼은 네트워크 상태와 무관하게 안전하게 열리기 때문입니다.

어차피 전부 받아올 것이라면 React.lazy를 왜 사용하는지 궁금하실 수 있습니다. 여기서 포기한 것은 코드 스플리팅이 아니라 지연 로딩입니다. 화면을 청크로 나누는 것은 그대로 두고, 받는 시점만 ‘필요할 때’에서 ‘부팅 직후’로 바꿨습니다.

‘사용하지 않는 화면은 받지 않는다’는 지연 로딩의 이점은 내려놓은 대신 코드 스플리팅을 유지하여 첫 화면을 띄우기까지 내려받고 파싱할 메인 번들 파일의 크기를 절반 가까이 줄였고, 네트워크가 끊겨도 안전하게 화면을 이동할 수 있는 안정성을 얻었습니다.

전체 동작 흐름을 정리하면 아래와 같습니다.

동작은 부팅 직후 사전 로딩과 이후 화면 이동 단계로 나뉩니다.

파트너님이 앱을 실행하면 부팅 직후 서버 연결을 확인한 뒤 주문접수·주문내역·설정 등 주요 화면을 미리 불러와 모듈 맵에 성공 기록을 저장해 둡니다. 이후 파트너님이 설정 화면 등을 열 때 새로 네트워크 통신을 거치지 않고, 모듈 맵에 미리 채워 둔 모듈을 바로 꺼내어 렌더링하므로 네트워크 환경과 무관하게 화면을 보여줄 수 있습니다.

마지막으로, React.lazy를 사용하지 않아야 한다는 규칙이 사람의 기억에 의존하지 않도록 린트 규칙으로 설정했습니다. 몇 달 뒤 새 화면을 만드는 누군가가 무심코 React.lazy 한 줄을 추가하는 순간 이 글의 이슈가 조용히 되살아날 수 있기 때문입니다. 코드 어디서든 React.lazy를 직접 사용하면 린트가 차단하고 대체 함수를 안내합니다.

적용 결과

"네트워크 OFF → ON으로 변경 후 다른 화면 이동 시 ‘오류가 발생했습니다.’ 페이지 노출되는 현상"은 더 이상 재현되지 않습니다. 네트워크 연결을 해제하고 화면을 이동하면 로딩 상태로 대기하다가, 연결이 복구되면 화면이 렌더링됩니다. 미리 불러오기가 완료된 화면은 네트워크 연결을 해제한 채 이동하더라도 정상적으로 렌더링됩니다.

모든 화면을 정적 import로 두었을 때와 비교하면 첫 화면을 띄우기까지 다운로드한 메인 번들 파일의 크기가 gzip 기준 176.9kB에서 84.7kB로 절반 이상 줄었습니다.

마침내 스펙은 개정되었다

2021년의 문제 제기는 2024년 5월 Don’t cache HTTP errors in the module map이라는 스펙 개정 PR로 이어졌고, 이 PR은 2026년 7월 15일에 머지되었습니다. 문제 제기로부터 5년, 배민주문접수 웹뷰에서 이 이슈를 겪은 시점으로부터는 한 달 뒤였습니다. 개정된 스펙에서는 HTTP 에러로 생긴 모듈 불러오기 실패를 모듈 맵에 기록하지 않으므로, 같은 URL을 다시 import()하면 재시도됩니다.

이 이슈를 추적하면서 알게 된 것이 하나 있습니다. 이런 표준 개정 논의를 GitHub에서 공개적으로 진행하고, 지금도 표준은 계속 개정되고 있습니다. HTML 스펙 문서 제목 아래에는 "Living Standard"라는 부제와 마지막 갱신 날짜가 함께 적혀 있는데, 이 글을 쓰는 동안에도 그 날짜가 여러 번 바뀌었습니다. 웹 표준은 완성된 문서가 아니라 이름 그대로 살아 있는 문서였습니다.

다만 개정된 스펙이 브라우저에 반영되기까지는 시간이 걸립니다. 이 글을 작성하면서 최신 크롬 브라우저로 확인해 보니 여전히 같은 이슈가 재현되었습니다. 미래의 브라우저에서 이 글이 다루는 문제는 사라지겠지만, 그동안은 애플리케이션 차원의 대응이 필요합니다.

마치며

이번 일을 겪기 전까지 모듈 맵이 실패까지 기억한다는 사실을 몰랐습니다. 일반적인 브라우저 환경에서는 네트워크 단절 상황을 직접 다룰 일이 드물었고, 문제가 생기더라도 새로고침 한 번이면 넘어갈 수 있었습니다. 웹뷰를 개발하면서 브라우저 기반 서비스에서는 겪지 못했던 동작을 마주하게 됐고, 그것이 이슈로 이어졌습니다.

정작 원인은 멀리 있지 않았습니다. 공식 문서와 소스 코드 안에 이미 적혀 있었습니다. 재시도가 요청조차 하지 않던 이유는 스펙의 괄호 한 줄에 있었습니다. 쿼리스트링이 소용없던 이유는 빌드 결과물에 적힌 경로에, 공식 해법이 새로고침일 수밖에 없던 이유는 Vite 문서에 있었습니다.

요즘은 문제가 생기면 AI의 답을 먼저 보고 거기서 해결책을 찾는 일이 잦아졌습니다. 그만큼 문서를 직접 읽는 일도, 문제를 스스로 파고드는 시간도 줄었습니다. 그런데 이번 일을 겪으며 다시 확인한 것은, 정보를 찾는 방식이 검색에서 AI로 바뀌었어도 문제를 해결하는 본질은 달라지지 않았다는 것입니다. 가설을 세우고 원인에 다가가고 공식 문서와 표준에서 근거를 찾는 일은 그대로 남습니다.

  • 배달의민족 주문~! 주문접수채널팀에서 배민주문접수 제품을 개발하고 있습니다.