(번역) Next.js에서 패키지 가져오기를 최적화한 방법

강엽이·2023년 11월 8일
53
post-thumbnail

원문 : https://vercel.com/blog/how-we-optimized-package-imports-in-next-js

40% 더 빨라진 콜드 부팅 및 28% 더 빨라진 빌드 속도

최신 버전의 Next.js에서는 패키지 가져오기를 최적화했습니다. 이로 인해 수백 또는 수천 개의 모듈을 다시 내보내는 용량의 큰 아이콘이나 컴포넌트 라이브러리 또는 기타 종속성을 사용할 때의 로컬 개발 성능과 프로덕션 콜드 스타트 기능이 개선됐습니다.

이 글에서는 이러한 변경이 필요했던 이유와 현재 해결 방법을 찾기까지의 과정, 그리고 개선된 성능에 대해 설명합니다.

배럴(barrel) 파일이란 무엇인가요?

자바스크립트의 배럴 파일은 단일 파일에서 여러 모듈을 그룹화하여 내보내는 방법입니다. 그룹화된 모듈에 액세스할 수 있는 중앙화된 위치를 제공함으로써 그룹화된 모듈을 더 쉽게 가져올 수 있습니다.

예를 들어, utils/ 디렉터리 내에 3개의 모듈(module1.js, module2.js, module3.js)이 있다고 가정해 봅시다. 같은 디렉터리 내에 index.js라는 이름의 배럴 파일을 만들 수 있습니다.

export { default as module1 } from "./module1";
export { default as module2 } from "./module2";
export { default as module3 } from "./module3";

기존에는 애플리케이션에서 아래와 같이 각 모듈을 개별적으로 가져왔었습니다.

import module1 from "./utils/module1";
import module2 from "./utils/module2";
import module3 from "./utils/module3";

아래와 같이 배럴 파일을 사용하면, 내부 구조에 대해 알 필요 없이 모든 모듈을 일괄적으로 가져올 수 있습니다.

import { module1, module2, module3 } from "./utils";

배럴 파일은 관련 모듈에 쉽게 액세스할 수 있는 인터페이스를 제공하여 코드 구성과 유지보수성을 향상시킬 수 있습니다. 이 때문에 자바스크립트 패키지, 특히 아이콘 및 컴포넌트 라이브러리에서 널리 사용됩니다.

일부 인기 있는 아이콘 및 컴포넌트 라이브러리에는 엔트리 배럴 파일에 최대 10,000개의 다시 내보내기가 있습니다.

배럴 파일의 문제점은 무엇인가요?

모든 require(...)import '...'에는 자바스크립트 런타임에 숨겨진 비용이 있습니다. 수천 개의 다른 항목들을 가져오는 배럴 파일에서 단일 내보내기를 사용하려는 경우, 불필요한 다른 모듈을 가져오는 대가를 지불하고 있는 것입니다.

유명한 리액트 패키지의 경우, 가져오는 데만 200~800ms가 걸립니다. 극단적인 경우에는 몇 초가 걸릴 수도 있습니다.

이러한 속도 저하는 특히 서버리스 환경에서 로컬 개발 및 프로덕션 성능 모두에 영향cㅌㅇ을 미칩니다. 앱을 시작할 때마다 모든 것을 다시 가져와야 합니다.

트리 쉐이킹(tree-shake)을 하면 안되나요?

트리 쉐이킹은 웹팩, 롤업, 파셀, esbuild 등과 같은 번들러의 기능이며 자바스크립트 런타임 기능이 아닙니다. 라이브러리가 external로 표시되어 있으면 블랙 박스로 남아 있습니다. 번들러는 런타임에 종속성이 필요하기 때문에 이 라이브러리 내부에서 최적화를 수행할 수 없습니다.

라이브러리를 애플리케이션 코드와 함께 번들로 제공하기로 선택했을 때 가져오려는 라이브러리의 package.jsonsideEffect가 없는 경우, 트리 셰이킹이 작동합니다. 그러나 모든 모듈을 컴파일하고 전체 모듈 그래프를 분석한 다음 트리 셰이킹을 올바르게 수행하려면 시간이 더 걸립니다. 이로 인해 빌드 속도가 상당히 느려집니다.

첫 번째 시도: modularizeImports

Next.js에서 처음 시도한 접근 방식은 modularizeImports였습니다. 이 옵션을 사용하면 내보낸 이름과 패키지의 배럴 파일 진입점 뒤에 있는 원래 모듈 경로의 매핑 관계를 구성할 수 있습니다.

예를 들어 my-lib 패키지에 다음과 같은 index.js가 있습니다.

export { default as module1 } from "./module1";
export { default as module2 } from "./module2";
export { default as module3 } from "./module3";

my-lib/{{member}}의 컴파일러 트랜스폼을 구성하여 Next.js가 사용자의 import { module2 } from 'my-lib'import module2 from 'my-lib/module2'으로 변경하도록 지시할 수 있습니다. 즉, 배럴 파일을 건너뛰고 대상에서 직접 임포트하여 불필요한 모듈을 로드하지 않도록 할 수 있습니다.

이러한 변경으로 인해 빌드 시간과 런타임이 모두 빨라졌습니다.

그러나 이 방법은 라이브러리의 내부 디렉토리 구조를 기반으로 하며, 대부분 수작업으로 구성됩니다. 버전이 다른 수백만 개의 npm 패키지가 있으며, 이 솔루션을 사용하여 효율적으로 확장할 수 있는 방법이 없습니다.

번들에 라이브러리 버전이 고정되지 않은 인기 있는 라이브러리에 대한 설정이 포함되어 있으면 나중에 해당 라이브러리의 내부 구조가 변경될 때 가져오기 변환이 무효화됩니다. 그렇기 때문에 더 좋은 솔루션이 필요했습니다.

새로운 솔루션: optimizePackageImports

modularizeImports 옵션 구성의 나머지 어려움을 해결하기 위해 Next.js 13.5에서 이 작업을 자동으로 수행하는 새로운 optimizePackageImports 옵션을 도입했습니다.

사용하려면 다음과 같이 선택할 패키지를 구성할 수 있습니다.

module.exports = {
  experimental: {
    optimizePackageImports: ["my-lib"],
  },
};

이 옵션을 활성화하면 Next.js가 my-lib의 진입 파일을 분석하여 배럴 파일인지 파악합니다. 만약 배럴 파일이라면 즉시 파일을 분석하여 모든 임포트를 자동으로 매핑하며, 이는 modularizeImports가 작동하는 방식과 유사합니다.

이 프로세스는 한 번에 진입점 배럴 파일만 확인하므로 트리 쉐이킹보다 비용이 저렴합니다. 또한 중첩된 배럴 파일과 와일드카드 내보내기(export * from)를 재귀적으로 처리하고, 배럴이 아닌 파일에 부딪히면 프로세스를 종료합니다.

이 새로운 옵션은 패키지의 내부 구현에 의존하지 않기 때문에 lucide-react@headlessui/react 같이 이 옵션의 이점을 즉시 누릴 수 있는 공통 라이브러리 목록을 미리 구성해 두었습니다.

향후에는 패키지의 옵트인 여부를 자동으로 알려주는 아이디어를 모색하고 있습니다. 현재로서는 커뮤니티와 저희 팀이 최적화할 새로운 패키지를 발견함에 따라 목록이 계속 확장될 수 있습니다.

성능 개선 측정

로컬 개발 속도, 프로덕션 빌드 속도는 물론 콜드 스타트 속도도 향상되었습니다.

로컬 개발

M2 맥북 에어에서 로컬 벤치마킹을 진행한 결과, 가장 인기 있는 아이콘 또는 컴포넌트 라이브러리 중 하나를 사용할 경우 실제 라이브러리에 따라 개발 시간이 15%~70% 단축되는 것을 확인할 수 있었습니다.

  • @mui/material: 7.1초(2225개 모듈) -> 2.9초(735개 모듈) (-4.2초)
  • recharts: 5.1초(1485개 모듈) -> 3.9초(1317개 모듈) (-1.2초)
  • @material-ui/core: 6.3초(1304개 모듈) -> 4.4초(596개 모듈) (-1.9초)
  • react-use: 5.3초(607개 모듈) -> 4.4초(337개 모듈) (-0.9초)
  • lucide-react: 5.8초(1583개 모듈) -> 3초(333개 모듈) (-2.8초)
  • @material-ui/icons: 10.2초(11738개 모듈) -> 2.9초(632개 모듈) (-7.3초)
  • @tabler/icons-react: 4.5초(4998개 모듈) -> 3.9초(349개 모듈) (-0.6초)
  • rxjs: 4.3초(770개 모듈) -> 3.3초(359개 모듈) (-1.0초)

이러한 시간 절약은 로컬 개발 환경에서 초기 부팅을 위한 것이지만, 핫 모듈 교체(HMR) 속도에도 영향을 미치므로 실시간 로컬 개발 환경에서 훨씬 더 빠르게 느껴질 수 있습니다. 많은 하위 모듈이 포함된 여러 라이브러리를 사용하는 경우 이 수치는 빠르게 증가합니다.

프로덕션 빌드

M2 MacBook Air에 빌드된 lucide-react@headlessui/react가 포함된 Next.js 앱 라우터 페이지의 벤치마크에서 next build는 모듈 해상도와 트리 쉐이킹을 더 이상 수행할 필요가 없기 때문에 약 28% 더 빠르게 실행됩니다.

더 빠른 콜드 부팅

로컬 환경에서는 lucide-react@headlessui/react를 사용하는 간단한 경로를 렌더링할 때 Node.js 서버가 ~10% 더 빠르게 시작되는 것을 확인했습니다.

Vercel과 같은 서버리스 환경에서는 배포된 코드 크기와 Node.js require 횟수가 모두 감소합니다. Next.js 13.5에 포함된 다른 개선 사항과 함께 최대 40% 더 빨라진 콜드 스타트를 측정했습니다.

재귀적인 배럴 파일

마지막으로 변경한 사항은 재귀적인 배럴 파일을 처리하여 단일 모듈로 최적화하는 것입니다. 테스트를 위해 4개의 레벨로 구성된 10개의 export * 표현식으로 이 모듈을 만들었으며, 총 10,000개의 모듈에 해당합니다.

이전에는 이 재귀 패키지를 컴파일하는 데 약 30초가 걸렸지만, 이제 7초면 충분합니다. 100,000개 이상의 모듈을 보유한 일부 고객의 경우 90% 더 빠른 리로드 속도를 경험했습니다.

결론

로컬 개발 성능과 프로덕션 콜드 스타트에서 상당한 성능 향상을 확인하려면 최신 버전의 Next.js로 업그레이드하는 것이 좋습니다. 배럴 파일 가져오기를 방지하기 위해 이 ESLint 규칙을 추가하는 것도 고려해 볼 수 있습니다.

profile
FE Engineer

1개의 댓글

comment-user-thumbnail
2023년 11월 16일

이렇게 최적화를 계속 해주시는 덕분에 많은 사람들이 혜택을 누리고 있는거였네요!

답글 달기