사툰사툰

Loading Chunk Failed : 청크 로드 에러 해결하기
REACT

Loading Chunk Failed : 청크 로드 에러 해결하기

개인적으로 정말 성가시다 생각하는 Chunk Load Error. 인턴 도중 갑자기 발생한 ChunkLoadError를 파악해보았습니다.

2023. 9. 15.

4781

🚀 Overview

평범한 평일 오후, 무난하게 기능 개발 PR을 올려놓고 배포되는 동안 점심을 먹으러 갔습니다. 테스트 코드도 넣었고, Production 환경으로 아직 올라가지 않는 단계라 안심했죠.

그런데 밥 먹다 불현듯 찾아온 여러 핸드폰 알람… 이게 뭔가 싶어서 봤더니 Sentry가 부르고 있었습니다. (슬랙 알람 소리 : 타타-닥-)

제목_없음.png

회사에서 운영하고 있는 고객센터 문의도 빗발치는 상황이었습니다. 먹다가 숨이 턱 막혔죠. 후딱 먹고 왜 발생했는지 파악해봤습니다.

Chunk 파헤치기

보통 ChunkLoadError가 발생하면 새로운 배포로 인한 문제가 많았습니다. 그 이유는 바로 Chunk의 고유 특성 때문이었습니다. SPA 프레임워크에선 번들 크기가 커지는 걸 대비하여 Webpack의 Code Splitting을 이용하여 Build파일을 Chunk 단위로 나누어 관리합니다. 페이지를 방문했을 때만 관련된 모듈들을 갖고 오도록 말이죠. 이 때 Chunk 파일 이름에 고유의 hash 값을 부여 받습니다.

원래는 기본적으로 모든 Chunk의 해시값을 다르게 생성해 왔었습니다. 하지만 Webpack 5로 넘어오면서 기존 hash 방법 대신 contentHash 방법 사용을 권장하고 있습니다. 이는 빌드할 때 수정한 파일만 새로운 hash값을 생성하고, 기존 파일은 그대로 두는 방식입니다. 이전보다 번거롭지 않고 수정되지 않은 파일을 캐싱하기 훨씬 더 용이하다는 것이 가장 큰 장점입니다.

// react-scripts v5.0.1의 webpack.config.js
// css와 js 모두 기본적으로 contenthash 설정으로 되어 있다.

...
output: {
      filename: isEnvProduction
        ? 'static/js/[name].[contenthash:8].js'
        : isEnvDevelopment && 'static/js/bundle.js',
      chunkFilename: isEnvProduction
        ? 'static/js/[name].[contenthash:8].chunk.js'
        : isEnvDevelopment && 'static/js/[name].chunk.js',
}
...
isEnvProduction &&
        new MiniCssExtractPlugin({
          filename: 'static/css/[name].[contenthash:8].css',
          chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
        }),
...

다음은 react-scriptswebpack config 중 일부를 발췌했습니다. outputfilename 부분을 살펴보면 [name].[contenthash:8].확장자의 형태를 가지고 있는 걸 확인할 수 있는데요. 이것은 빌드할 때 hash값이 따로 설정할 필요 없이 contenthash로 생성된다는 의미입니다.

ChunkLoadError가 발생하는 과정

관련 Chunk가 수정되어 배포된다면 Hash값 또한 바뀝니다. 당연하게도 이전에 배포된 Chunk 파일은 새로운 Chunk로 교체되어 사라집니다. 그러나 브라우저에 이전 Chunk 파일이 캐시되어 있다면 사라진 Chunk를 불러오게 되므로 에러가 발생합니다. 이를 대비하기 위한 방법은 여러가지가 있습니다. 저는 그 중 보편적인 선언적 에러 처리를 사용하여 새로 배포된 페이지를 바라보도록 사용자에게 새로고침을 유도하였습니다.

// ErrorBoundary 내에서 새로고침 버튼을 누르도록 했다.

class ErrorBoundary extends React.Component<Props, State> {
	public state: State = { hasError : false, error : null };
  public static getDerivedStateFromError(error: Error): State {
      return { hasError: true, error };
  }
  public render() {
      const { hasError } = this.state;
      if (hasError) {
          return (
		        <div className={styles.errorBoundaryRoot}>
		            <div className={styles.title}>오류가 발생했습니다.</div>
		            <button
		                onClick={() => {
		                    window.location.reload();
		                }}
		                className={styles.refreshBtn}
		            >
		                새로고침
		            </button>
		        </div>
		    );
      }

      return children;
  }
}

ErrorBoundary에서 ChunkLoadError 를 catch하고 화면에 window.location.reload()를 사용하도록 새로고침 버튼을 만들었죠.
(참고로 reload() 인수 안에 true 값을 줘서 강제로 캐시 없이 새로고침 하는 방법이 있는데 이 방법firefox 로만 현재 공식적으로 지원하고 chrome 같은 경우는 deprecated 되어 있습니다 😂)

하지만 해결은 얼어 죽을

만만하게 본 탓일까요? 이번엔 새로고침을 해도 ChunkLoadError가 사라지지 않았습니다.

errorlog.png

해당 Chunk 경로를 따라가면 Chunk 파일의 값이 없기 때문에 예상대로 빈 화면이 보여졌습니다. 그런데 앞서 말했던 배포로서 생긴 오류와는 다른 양상을 보였습니다. 첫 번째로, 현재 ChunkLoadError가 뜨는 곳은 rewrite된 페이지입니다.

image

원인 파악

이게 무슨 소리냐구요? 먼저 상황 파악을 해봅시다. ChunkLoadError가 발생한 원인을 분석하기 위해 build 파일이 어떻게 생성되고 불러오는지가 중요합니다. 그래서 현재 아키텍처를 우선 살펴보았습니다.

현재 운영 중인 서비스 페이지는 Next.js 기반으로 이루어져 있습니다. 기존에는 React를 사용하고 있었지만 SEO 및 서버 측 데이터 패칭 등 여러 이유를 고려하여 Next.js를 도입하였습니다. 페이지 단위로 마이그레이션 진행 중이며 아직 마이그레이션이 완료되지 않은 페이지는 기존 React 프로젝트에서 갖고 오도록 rewrite를 사용하였습니다. 마이그레이션이 된 페이지라면 Next.js에서 화면이 그려지고 그렇지 않으면 기존 React를 rewrite하여 사용하는 방식이죠.

Untitled.png

그렇다면 이 아키텍처 중 어느 부분에서 ChunkLoadError가 발생할까요?

  1. React 배포 도중 Chunk Hash값 차이로 인한 문제다?
  2. React를 배포하고 있는 nginx에서 문제가 발생했다?
  3. Next.js를 배포하고 있는 Vercel에서 문제가 발생했다?

이렇게 세 가지를 생각해보았습니다. 하지만 주어진 상황을 고려할 때, 첫 번째 경우는 배포가 일어나지 않았기 때문에 발생할 수 없습니다. 따라서 두 번째와 세 번째 경우를 고려해야 합니다. 이 중에서도 먼저 nginx 서버에서 문제가 발생했을 가능성을 확인하기 위해 확인 작업을 시작했습니다. 이 과정에서 당연하게도 rewrite한 React 프로젝트의 Chunk가 문제였으므로 nginx가 문제라고 자연스럽게 판단했죠 😂 (하지만 이 판단은 추후에 큰 착오를…)

두 번째 상황이라 판단하고 확인에 들어섰습니다.

Refused to execute script from 'http://.../static/js/648.58fea13d.chunk.js' because its MIME type ('') is not executable, and strict MIME type checking is enabled.

ChunkLoadError 가 발생한 파일을 보면 다음과 같은 오류도 같이 발생합니다. MIME type 이 없다는 에러 내용이네요. js 파일인데 확인할 수 없다니 header 가 수상해서 확인해보았습니다.

Content-Type이 누락된 응답 헤더
Content-Type이 누락된 응답 헤더

Response Header 를 확인해보니 Content-Type 이 없어서 오류가 난 것입니다. 제대로 응답이 왔다면 Content-Typeapplication/javascript로 명시되어야 합니다. 이제 여기서 저는 더 확신했습니다. “웹서버에서 Chunk를 잘못 Serving 하고 있구나!

(어느 정돈 맞지만 방향이 달라버렸다)

현재 ReactAWS EC2 에서 Nginx를 이용하여 Serving하고 있습니다. 그래서 EC2에 접속하여 /etc/nginx 경로에 있는 파일들을 다 파헤쳤습니다. 그러던 중 관련 config이 담긴 파일을 발견하였습니다.

location ~* \.(?:css|js)$ {
	expires 1y;
	access_log off;
	add_header Cache-Control "public";
}

static한 이미지, 음악, 아이콘 등은 1개월, css와 js는 1년의 만료 기간을 두고 있었습니다. 아까 말씀드렸던 기존 Chunk를 참조하는 상황에 대비하여 배포와 상관없이 길게 잡아둔 걸로 보입니다. 이 부분을 약간 수정해보았습니다.

location ~* \.(?:css|js)$ {
	expires 0; // 1y에서 0으로 수정
	access_log off;
	add_header Cache-Control "public";
}

이렇게 만료시간을 0으로 두어서 nginx단에 캐시하지 않고 build된 파일을 무조건 확인하도록 설정하였습니다. 이렇게 한다면 캐시를 참조하지 않고, 직접 확인하기 때문에 에러가 없어지겠다는 생각이었죠.

add_header Content-Type $custom_content_type;

혹시나 nginx에서 제대로 Content-type을 적용하지 않는 오류가 있다고 생각하여 css와 js에 add_header 를 통해 Content-Type를 추가하였습니다.

하지만 안타깝게도 오류는 없어지지 않았습니다. 왜 그럴까요? 아차차 잠시 잊고 있었습니다. 우리는 Next.js에서 rewrite한다는 사실을 말이죠! vercel에서 rewrite할 때 해당 React의 Build 파일들은 vercel단에서 하나의 요청 파일로 불러옵니다. 실제로 배포된 React 에서 /static 경로로 build된 파일을 읽을 수 있습니다. 이 파일을 불러오면 Vercel CDN에 우선 캐싱하기 때문에 처음 접속 이후에는 nginx 단이 아닌 Vercel CDN 에서 build 파일을 불러오게 됩니다.

해결

이제 원인을 파악했으니 해결해봅시다. Vercel에서 불러오는 build 파일이 잘못 불러온다면 어떻게 잘못 불러올까요? 경로도 똑같고 E-Tag도 같은 상황입니다. 게다가 Serverless 형태라 더 파헤치기엔 제약이 많았습니다.

(Log단에서 확인할 순 있지만 최대 하루까지고, 파일을 불러올 때 요청 빈도를 확인하려면 돈을 지불해야 했습니다 😒)

빠른 해결 방법은 RedeployVercel CDN Cache 삭제였습니다. nginx에 조치를 취했던 것처럼 같은 방법으로 Vercel CDN Cache를 삭제해주었습니다.

깔끔하게 다시 기능이 동작합니다!

Untitled.png
청크 파일의 일부분
청크 파일의 일부분

Chunk File도 성공적으로 불러옵니다. (일단 해-결)

남은 숙제

Vercel에선 프리뷰 기능과 더불어 이전에 배포했던 branch들을 특정 난수의 URL로 확인할 수 있다는 장점이 있습니다. 이 장점을 활용해 이전 오류가 발생했던 URL를 확인해봤지만 여전히 ChunkLoadError가 발생했습니다. Vercel Deployment에 쌓인 배포 commit마다 다른 값을 갖고 있다는 것을 파악했습니다.

게다가 캐시를 지운 이후에도 청크 로드 에러가 주기적으로 발생했습니다. 확실하게 해결하지 않으면 골칫거리로 남을 게 뻔했죠 😂. 그렇게 계속 분석하던 도중 뜻밖의 부분에서 실마리를 발견했습니다. 바로 Vercel의 Build 방식입니다.

Vercel은 하나의 레포지토리에서 특정 branch를 선택하여 배포를 수행합니다. 이 때, 어떤 branch든 내용이 업데이트 된다면, preview branch를 통해 build를 진행합니다. 이 부분에서 에러가 발생했습니다. 현재 하나의 레포지토리 안에 React와 Next.js 프로젝트를 Branch로만 구분하고 같이 사용하고 있었기 때문입니다.

ReactJenkins로 따로 배포 중이고 Vercel은 React가 배포될 때마다 React용 branch의 업데이트를 감지하여 Build를 진행한 것입니다. 아예 내용이 다르니 Build Error가 발생하였고 이 과정에서 Chunk 파일이 깨진 것입니다.

이제 왜 Vercel 내 Chunk 파일이 깨지는지 확실하게 알았습니다. 이 문제를 해결하기 위해 Vercel 설정을 약간 수정했습니다. 즉, VercelReact를 Build하지 않도록 만들고, 혹시나 깨진 Chunk를 바라보지 않도록 정상적인 React 빌드 파일로 Redirect해야 합니다.

다행히 vercel.json을 통해 프로젝트 설정을 커스텀마이징 할 수 있습니다. 여러 가지 설정을 수정할 수 있으며 직접 Build해야만 결과를 알 수 있다는 게 단점입니다.

// vercel.json
{
  "redirects": [
    {
      "source": "/static/:path*",
      "destination": "${reactPage}/static/:path*",
      "permanent": true
    }
  ],
   "git": {
    "deploymentEnabled": {
      "main": false,
      "develop": false,
      "production": false
    }
  }
}

Vercel에서 Static File을 불러올 때 redirect를 이용하고, 308 status로 다른 주소를 바라보지 않도록 수정하였습니다. 보통 301을 Get, 308을 Post 요청에서 사용하는 것이 좋지만 이 점은 크게 고려하지 않았습니다. 그리고 React와 관련된 대표적인 Branch만 우선 Build되지 않도록 추가하였습니다.

생각보다 단순한 방법으로 해결했지만 데 많은 시간이 걸렸고 무엇보다 어떻게 에러가 발생했는지 빠르게 파악하는 훈련이 필요하다고 느꼈습니다.

참고

[React] 청크 로드 에러 해결하기 (Loading chunk failed) (velog.io)

[React] ChunkLoadError: Loading chunk N failed. (tistory.com)

#ChunkLoadError#Loading Chunk ... Failed#청크에러#배포#Build#Sentry