ChunkLoadError, 새로고침하면 해결될까?

배포를 마쳤다고 이미 열린 페이지까지 새 코드로 바뀌는 것은 아니다. 사용자의 화면에서는 이전 코드가 계속 실행될 수 있고 그 코드가 필요한 파일을 한참 뒤에 처음 요청할 수도 있다.

React와 Webpack으로 만든 SPA를 WebView에서도 운영하면서 이 문제를 겪었다. 일부 세션에서 chunk 로딩이 실패했다. 기존 오류 처리 코드는 자동 새로고침으로 복구를 시도했지만 새로고침이 반복됐다.

ChunkLoadError란?

Webpack은 코드를 여러 chunk로 나누고 필요한 시점에 불러오도록 구성할 수 있다. ChunkLoadError는 필요한 chunk를 불러오지 못했을 때 발생할 수 있는 오류다.

예를 들어 사용자가 특정 화면에 진입하면서 처음 요청한 파일이 서버에서 사라졌거나 네트워크 문제로 내려받지 못한 경우다. 배포 전에 열어둔 페이지가 이미 삭제된 이전 chunk를 요청할 때도 발생할 수 있다. (Webpack — Deploying a new build)

다만 오류 이름만으로 원인을 확정할 수는 없다. 이번에는 이전 chunk를 요청하다 실패하는 상황을 재현하고 복구를 위한 새로고침이 왜 반복될 수 있는지 살펴봤다.

구체적인 서비스 구성과 배포 절차는 일반화했다. 코드와 파일명은 설명을 위한 예시다.

1. WebView에서 새로고침이 반복됐다

일부 WebView에서 화면이 나오지 않고 새로고침이 반복된다는 문의가 들어왔다. 콘솔에는 다음과 비슷한 오류가 남아 있었다.

ChunkLoadError: Loading chunk ... failed

당시에는 기존 배포물을 제거한 뒤 새 빌드 파일을 업로드하는 방식으로 수동 배포했다. 기존 정적 파일을 지우고 새 파일을 올리기까지 파일을 제공하지 못하는 시간이 있었다. 그 사이에 chunk를 요청하면 파일을 받지 못할 수 있었다.

업로드가 끝난 뒤에도 문제는 남았다. 이전 chunk는 이미 삭제됐으므로 이전 버전을 실행 중인 페이지가 해당 파일을 요청하면 실패할 수 있었다.

하지만 파일을 받지 못한다는 사실만으로는 새로고침이 계속되는 현상까지 설명되지 않았다. chunk 로딩이 실패하는 상황과 오류 처리 코드가 새로고침을 반복하는 상황을 나눠 확인했다.

2. 이전 페이지가 요청하던 파일

이미 열린 페이지는 어떤 파일을 요청할까?

코드 스플리팅을 사용하면 사용자가 처음 접속한 시점과 특정 코드가 필요한 시점이 달라질 수 있다. React에서는 다음처럼 컴포넌트를 지연 로딩할 수 있다.

const FeaturePage = React.lazy(() => import('./FeaturePage'));

동적 import()는 필요할 때 모듈을 불러오는 구문이다. Webpack은 이를 코드 스플리팅에 활용해 별도의 chunk를 만들 수 있다.

React는 위 컴포넌트를 처음 렌더링하려 할 때 로더 함수를 호출한다. 사전 로딩하지 않았다면 특정 화면에 진입하고 나서야 필요한 파일을 처음 요청할 수 있다. 최상위 컴포넌트가 정상적으로 mount됐더라도 아직 받지 않은 lazy chunk가 남아 있을 수 있는 것이다.

아래는 runtime이 app chunk에 포함되고 파일명에 콘텐츠 해시를 사용하는 구성을 단순화한 예시다. 파일명은 설명을 위해 임의로 정했다.

배포 전
├── app.a1b2.js
└── FeaturePage.c3d4.js

이후 FeaturePage의 코드를 변경해 다시 빌드하면 해당 chunk의 해시와 이를 참조하는 runtime이 달라질 수 있다.

배포 후
├── app.e5f6.js
└── FeaturePage.g7h8.js

새 HTML과 runtime을 받은 페이지는 FeaturePage.g7h8.js를 요청한다. 반면 이전 코드를 실행 중인 페이지는 여전히 FeaturePage.c3d4.js를 요청할 수 있다. 서버에서 이전 파일을 삭제했다면 이 요청은 실패할 수 있다.

여기서 코드 스플리팅과 파일명 해시는 별개다. 코드 스플리팅은 코드를 여러 파일로 나누는 것이고 해시는 파일명을 구분하는 설정이다. 코드를 나눈다는 이유만으로 해시가 붙거나 모든 파일명이 매번 바뀌는 것은 아니다.

Webpack은 빌드 과정에서 모듈의 연결 정보를 만들고 실행 중에 이를 연결할 runtime을 생성한다. runtime은 이 정보를 이용해 필요한 모듈과 chunk를 불러온다. 이 연결 정보를 manifest라고 부르지만 별도 JSON 파일을 매번 요청한다는 뜻은 아니다. (Webpack — Runtime과 Manifest)

새 파일을 배포해도 이미 실행 중인 runtime의 참조 정보가 자동으로 바뀌지는 않는다. 그래서 같은 시점에도 이전 세션과 새 세션은 서로 다른 파일을 요청할 수 있다.

notion-5a96ebd6f4f8e2c9a377

이전 chunk를 계속 제공하면 이전 runtime이 뒤늦게 요청해도 파일이 없어 실패하는 일은 막을 수 있다. 콘텐츠 해시는 변경된 파일의 URL을 구분해 줄 뿐 이미 실행 중인 코드의 참조를 갱신하지는 않는다. 이전 파일을 언제까지 남겨둘지도 배포 정책에 포함해야 한다.

참고 — 다른 번들러에서는?

위 설명은 Webpack의 script 기반 chunk 로딩을 기준으로 한다. Vite나 Bun의 브라우저용 ESM 빌드에서는 브라우저의 모듈 로더를 활용하는 등 로딩 방식이 달라질 수 있다. 다만 실행 중인 이전 코드가 참조하는 파일을 더 이상 제공하지 않으면 로딩에 실패할 수 있다는 원리는 같다. 이때 오류 이름이 반드시 ChunkLoadError인 것은 아니다. (Vite — Load Error Handling, Bun — Bundler)

이전 파일을 요청한다면 브라우저 캐시 때문일까?

이전 chunk를 요청하는 데 HTTP 캐시가 반드시 필요한 것은 아니다. 배포 전부터 열려 있던 페이지는 실행 중인 이전 runtime의 참조를 계속 사용한다. 캐시에 저장된 응답을 다시 사용하는 것과는 다른 상황이다.

페이지를 다시 불러온 뒤에도 이전 HTML이 제공된다면 캐시 정책을 확인해야 한다. 이전 HTML이 이전 entry를 불러오고 그 코드가 이전 chunk를 요청할 수 있기 때문이다. 이때는 브라우저뿐 아니라 CDN 등에서 제공한 응답도 함께 살펴봐야 한다.

이전 chunk의 응답이 브라우저 캐시에 남아 있고 캐시 정책상 재사용할 수 있다면 서버에서 파일이 삭제된 뒤에도 로딩에 성공할 수 있다. 하지만 사용자가 아직 방문하지 않은 화면의 lazy chunk는 내려받은 적이 없어 캐시에도 없을 수 있다. 기존 페이지가 나중에 요청할 파일을 브라우저 캐시에만 맡길 수는 없다.

이번에 문제가 나타난 환경은 WebView였지만 일반 브라우저 탭에서도 같은 상황이 생길 수 있다. WebView 고유의 캐시 동작을 원인으로 확인한 것은 아니다.

ChunkLoadError만으로는 알 수 없는 것

ChunkLoadError가 발생했다고 해서 요청한 파일이 삭제됐다고 바로 판단할 수는 없다. 네트워크 오류로 파일을 받지 못했거나 응답은 받았지만 기대한 chunk를 사용할 수 없는 경우에도 로딩이 실패할 수 있다.

원인을 확인하려면 실패한 요청의 URL과 HTTP 상태, 응답 내용을 함께 살펴봐야 한다. 이번에는 배포 중 이전 파일이 삭제되는 상황을 의심하고 같은 오류가 나는지 재현해 봤다.

이전 chunk 요청 재현하기

별도의 환경에서 다음 순서로 확인했다.

  1. 이전 빌드로 페이지를 실행하고 lazy loading으로 분리된 화면에는 진입하지 않았다.
  2. 서버의 asset을 새 빌드로 교체하고 이전 chunk는 제공하지 않도록 했다.
  3. 기존 페이지를 유지한 채 아직 열지 않은 화면으로 이동했다.

기존 페이지는 이전 해시가 포함된 chunk를 요청했다. 서버에서 해당 파일을 제공하지 않자 로딩에 실패했다. 이전 페이지가 나중에 요청할 파일을 삭제하면 같은 오류가 발생할 수 있다는 점을 확인했다.

실제 배포 과정에는 파일을 교체하는 동안의 공백도 있었다. 다만 이번 재현은 이전 파일을 삭제한 상황을 확인한 것이므로 두 상황이 실제 고객 세션에서 각각 어떻게 작용했는지까지 설명하지는 못한다. 고객 세션에서 실패한 요청의 Network 로그와 정확한 빌드 버전도 확보하지 못해 장애의 직접 원인은 확정할 수 없었다.

이와 별개로 기존 복구 코드에는 실패가 이어지면 새로고침을 계속 실행할 수 있는 경로가 있었다.

새로고침은 왜 반복됐을까?

기존 fallback은 ChunkLoadError가 발생하면 window.location.reload()를 실행했다. 새 HTML과 entry 코드를 받아 필요한 chunk를 정상적으로 불러오면 복구되는 방식이었다.

하지만 새로고침 후에도 chunk 로딩에 실패하면 같은 오류 처리 코드가 다시 실행됐다. 재시도 횟수에 제한이 없어 실패가 이어지는 동안 새로고침도 반복될 수 있었다.

당시 고객 세션에서 새로고침 후에도 로딩이 실패한 구체적인 이유는 확인하지 못했다. 그래도 기존 코드에 반복 실패를 멈출 조건이 없다는 점은 확인했다.

3. 배포와 오류 처리를 함께 바꾸기

배포에서는 기존 페이지가 요청할 파일을 계속 제공해야 했다. 클라이언트에서는 로딩에 실패하더라도 새로고침이 반복되지 않도록 막아야 했다.

이전 파일을 남겨두는 배포

이전 페이지가 요청하는 URL에서 예전 파일을 계속 받을 수 있도록 해야 했다. 파일을 다른 곳에 보관해 두더라도 기존 URL에서 제공하지 못하면 해당 요청은 여전히 실패한다.

배포 중 파일이 없는 시간도 줄일 필요가 있었다. 기존 파일을 먼저 지우는 대신 새 asset을 업로드하고 제공 가능한 상태를 확인한 뒤 이를 참조하는 HTML을 전환하는 방식이다.

당시에도 새 파일을 업로드한 뒤 CloudFront 캐시를 무효화하고 있었다. 하지만 캐시 무효화는 이미 열린 페이지의 파일 참조를 바꾸거나 삭제된 chunk를 복원하지 않는다. 캐시 무효화와 별개로 파일을 남겨두고 배포 순서를 바꿔야 했다.

팀에는 이전 배포물 보존과 배포 절차 자동화를 제안했다. 자동화하더라도 기존 파일을 지운 뒤 새 파일을 올리는 순서를 그대로 옮기면 같은 문제가 생긴다. 파일 보존과 HTML 전환 순서도 함께 반영해야 했다.

자동 새로고침에 제한 두기

배포 방식을 개선해도 네트워크 오류 등으로 chunk 로딩이 실패할 수 있다. 이전 파일에 보존 기간을 둔다면 그보다 오래 열린 페이지의 요청도 실패할 수 있다.

클라이언트에서는 첫 실패에 문서를 다시 요청해 복구를 시도하되 다시 실패하면 자동 새로고침을 멈추도록 했다. 그다음에는 오류 화면에서 사용자가 직접 재시도할 수 있게 했다.

4. 재시도한 기록 남기기

처음에는 URL에 재시도 표시를 남기고 location.replace()로 문서를 다시 요청하도록 바꿨다. 이후 sessionStorage를 함께 사용했다. 어떤 오류를 복구 대상으로 볼지, 저장소에 접근할 수 없으면 어떻게 할지, 재시도 기록은 언제 지울지도 보완했다.

URL과 sessionStorage에 시도 기록 남기기

자동 복구 처리는 공통 함수로 모았다. 처음에는 오류 이름을 기준으로 처리했고 이후 메시지 패턴도 확인하도록 보완했다. 로딩 실패로 보이는 오류를 추리는 용도이며 실패한 HTTP 응답이나 근본 원인을 직접 검증하는 코드는 아니다.

복구를 시도했는지는 sessionStorage와 URL의 쿼리 파라미터로 확인했다. 둘 중 하나라도 기록이 남아 있으면 자동 복구를 중단했다.

기록이 없을 때만 저장소에 시도 여부를 남겼다. URL에도 타임스탬프를 담은 표시를 추가하고 location.replace()로 문서를 다시 요청했다.

sessionStorage는 접근 자체나 읽기·쓰기가 제한될 수 있다. 저장소를 사용할 수 없더라도 URL의 표시로 재시도를 제한하도록 했다. 이 경우에는 리다이렉트나 라우팅 후에도 표시가 유지되어야 한다.

URL을 바꾼다고 모든 캐시를 우회하거나 반드시 최신 HTML을 받는 것은 아니다. 반복 새로고침을 막는 핵심은 시도 기록을 남기고 다음 실패에서 확인하는 데 있었다. location.replace()는 그 표시가 포함된 URL로 문서를 다시 요청하는 데 사용했다.

notion-39286876dd30b2bc4843

시도 기록이 유지되는 동안 자동 복구는 한 번으로 제한했다. 다시 실패하면 자동 이동을 멈추고 오류 UI에서 사용자가 재시도할 수 있게 했다.

재시도 기록은 언제 지울까?

기록을 남기더라도 문서를 다시 불러온 직후 지우면 제한이 풀릴 수 있다. 한때는 최상위 컴포넌트의 useEffect에서 복구 시도 기록을 초기화하고 있었다.

하지만 앱이 mount된 뒤에도 특정 화면에 필요한 chunk를 처음 요청할 수 있다. 이 요청이 실패했을 때 기록이 이미 지워져 있다면 복구 코드는 자동 재시도를 다시 허용한다.

그래서 mount 시점의 초기화를 제거했다. 대신 사용자가 오류 화면의 새로고침 버튼을 눌렀을 때 기록을 지우도록 바꿨다. 사용자가 직접 재시도한 경우에만 자동 복구를 다시 허용한 것이다.

기록이 남아 있는 동안에는 별개의 chunk 로딩 오류가 발생해도 자동 재시도가 제한된다. 이번에는 반복 새로고침을 막는 데 우선순위를 두고 사용자의 수동 재시도로 제한을 해제하도록 했다.

5. Sentry에서 확인한 변화

최초로 URL 기반 복구 처리를 적용한 뒤 해당 오류 처리 경로를 통해 Sentry에 추가 수집된 ChunkLoadError는 0건이었다.

이 경로에서는 첫 실패에 자동 복구를 시도하고 다시 실패했을 때 오류를 기록했다. 따라서 0건은 자동 복구 후 재실패해 보고된 오류의 수집 결과다. 전체 chunk 로딩 실패가 없었다는 뜻도, 모든 사용자의 복구 성공률을 직접 측정한 결과도 아니다.

이 결과를 확인한 뒤 이전 배포물을 보존하는 조치도 적용했다. 이미 열린 페이지가 이전 chunk를 요청하더라도 해당 파일을 받을 수 있도록 바꿨다.

Sentry에서 확인한 수치는 최초 URL 기반 복구 처리 이후의 결과다. 나중에 추가한 sessionStorage 병행이나 mount 시점의 초기화 제거가 각각 어떤 효과를 냈는지는 이 수치만으로 알 수 없다.

6. 마무리

새 파일을 올린 뒤에도 이전 코드를 실행하는 페이지는 남아 있었다. 배포할 때는 그 페이지가 나중에 요청할 파일까지 생각해야 했다.

복구 코드에는 다시 시도하는 방법뿐 아니라 실패가 이어질 때 멈추는 조건도 필요했다. 재시도 기록을 언제 지울지도 그 조건의 일부였다.

GitHub 계정으로 댓글과 반응을 남길 수 있어요.

댓글을 불러오는 중…