[디자인 시스템 어떻게 만들었어요?(3)] Tree Shaking과 구형 브라우저 대응

5 hours ago 1

지난번 YDS(Yogiyo Design System) v2 컴포넌트 라이브러리 구축기에 이어, 이번 글에서는 YDS v2 컴포넌트 라이브러리와 같은 외부 라이브러리가 서비스 애플리케이션 번들 결과물에 미치는 영향을 살펴봅니다. 또한, 구형 브라우저에 대한 하위 호환성 확보를 위해 요기요 FE 팀이 거친 기술적인 고민과 그 해결 과정들을 공유하고자 합니다.

들어가며

현재 요기요 FE팀은 주요 FE 프로젝트의 기반 프레임워크로 Next.js를 채택하여 사용하고 있습니다. Next.js는 React 기반의 프레임워크로서 웹 개발에 필요한 도구들을 기본적으로 제공합니다. 이러한 편의성 덕분에 개발자는 인프라 설정보다 비즈니스 로직 구현에 더 집중할 수 있는 환경을 갖추게 됩니다. 반면, 라이브러리를 개발하는 과정에서는 다음과 같은 오해에 빠지기 쉽습니다.

오해 1. Next.js가 알아서 Tree-shaking 해줄 것이다.

Next.js automatically optimizes bundles by code splitting, tree-shaking, and other techniques. However, there are some cases where you may need to optimize your bundles manually.

Next.js는 Code Splitting과 Tree Shaking을 통해 번들 사이즈를 최적화합니다. 이런 특징 때문에 라이브러리 개발자는 사용하지 않는 코드가 빌드 과정에서 알아서 제거될 것이라고 기대하곤 합니다. 하지만 라이브러리가 Tree shaking이 가능한 요건들을 충족하지 못했다면, 프레임워크의 최적화 기능은 무용지물이 됩니다. 라이브러리가 어떤 모듈 시스템(ESM or CJS)으로 배포되는지, Side-Effect를 어떻게 관리하는지, 하나의 파일로 번들링되어 있는지 혹은 모듈 트리구조를 유지하고 있는지에 따라 Next.js는 불필요한 코드를 제거하지 못하고, 결국 사용되지 않는 코드까지 번들에 포함하는 상황이 발생할 수 있습니다.

오해 2. Next.js를 쓰면 구형 브라우저 호환성 문제에서 자유롭다.

Next.js 공식문서에 따르면 fetch, URL, Object.assign() 등 자주 사용되는 폴리필이 기본으로 제공됩니다. next-polyfill-nomodule을 통해 다양한 polyfill을 주입하지만, 이는 <script nomodule> 특성상 Chrome 64 미만의 구형 브라우저에서만 로드됩니다. 또한, ESM을 지원하는 브라우저용인 next-polyfill-module은 제한적인 기능만 지원하고 있습니다. 이 때문에 Promise.allSettled()처럼 Chrome 76부터 지원되는 메서드를 사용할 경우, 64~76 버전 사이의 Chrome 브라우저에서는 polyfill을 지원 받지 못하는 사각지대가 발생합니다. 따라서 프로젝트에서 Next.js 기본 제공 범위를 초과하는 API를 사용하고 있다면, 호환성 보장을 위해 개발자가 직접 누락된 폴리필을 확인하고 대응해야 합니다.

요기요 FE에서는 이러한 문제를 해결하기 위해 라이브러리 번들링 전략 개선과 Custom Polyfill 서비스를 구축하였습니다. 자세한 내용은 아래에서 이어서 설명하겠습니다.

Tree Shaking을 위한 여정

ESM

Tree shaking은 빌드 과정에서 사용되지 않는 코드를 제거하여 최종 번들 크기를 최적화하는 기법입니다. 정적인 import와 export 구문을 분석해 모듈 간의 참조 관계를 파악하고, 실제 실행에 필요 없는 코드를 선별해 삭제합니다. 결국, 코드의 정적 분석이 가능한 ESM을 채택해야만 완전한 Tree Shaking을 구현할 수 있습니다. 따라서 서비스 애플리케이션의 번들 크기를 최적화해 주기 위해서는 라이브러리 차원에서의 ESM 지원이 필수적입니다.

Side-Effect

라이브러리를 ESM으로 배포했다고 해서, Tree Shaking이 자동으로 완벽하게 작동하는 것은 아닙니다. Webpack이나 Rollup 같은 번들러는 애플리케이션의 안전한 동작을 보장하기 위해서 기본적으로 라이브러리 내 모든 모듈이 Side-Effect가 있다고 판단합니다. 결과적으로 모듈 간의 정적 참조 관계가 확인되더라도, 번들러는 보수적인 관점에서 사용되지 않는 코드까지 최종 번들에 남겨두게 됩니다.

번들러는 코드의 Side-Effect 여부를 예측할 수 없기에, 개발자가 명시적인 정보를 제공하여 최적화를 유도해야 합니다. 가장 대표적인 방법은 package.json 파일에 sideEffects 필드를 설정하는 것입니다.

모든 파일에 Side-Effect가 없다면 아래와 같이 설정합니다. 이 경우 번들러는 사용되지 않는 모든 파일을 안전하게 제거합니다.

// package.json
{
"sideEffects": false
}

만약 전역 스타일(CSS)이나 초기화 스크립트처럼 참조 관계와 상관없이 유지해야 할 코드가 있다면, 배열 형태로 해당 경로를 명시하여 보호할 수 있습니다. 결과적으로 필요한 코드는 보존하면서 불필요한 코드만 제거해 최적화할 수 있습니다.

// package.json
{
"sideEffects": ["*.css", "./src/global.js"]
}

모듈 경계 유지하기

라이브러리의 배포 형식으로 ESM을 채택하고 package.json에 sideEffects를 명시하는 것은 Tree Shaking을 위한 기초적인 요건입니다. 이는 서비스 번들러에게 최적화 가능성을 알리는 힌트일 뿐, 실제 효율은 라이브러리 배포 시 모듈 경계가 얼마나 엄격히 유지되고 있는지에 따라 결정됩니다. 특히 모든 소스 코드를 단일 파일로 병합하여 배포하는 방식은 서비스 번들러의 정적 분석 과정에서 불확실성을 유발하여 최종 번들 최적화의 큰 장애물이 됩니다.

라이브러리가 단일 파일로 배포되더라도, 서비스 번들러는 정적 분석을 통해 사용되지 않는 코드를 제거하는 특정 수준의 Tree Shaking을 수행합니다. 대표적으로 Named Export를 통한 함수 단위의 제거와 객체 리터럴의 최상위 속성에 대한 선별적 포함이 이에 해당합니다. 하지만 라이브러리 파일 최상위에 실행 코드가 존재하거나 즉시 실행 함수(IIFE)를 통한 초기화 로직이 포함된 경우 서비스 단계의 최적화는 실패합니다. 또한, 클래스의 static 블록이나 복잡한 모듈 간 의존성 구조 역시 서비스 번들러의 정적 분석에 모호함을 야기하며, 결과적으로 미사용 라이브러리 코드가 서비스 최종 번들에 포함되는 결과를 초래합니다.

이러한 구조적 한계는 Barrel File과 결합될 때 더욱 극대화됩니다. Barrel File은 index.ts와 같은 파일을 통해 여러 모듈을 한곳에 모아 re-export 함으로써 서비스 애플리케이션에서 라이브러리의 내부 구조를 몰라도 모듈에 쉽게 접근할 수 있게 돕는 유용한 패턴입니다. 그러나 라이브러리가 이미 단일 파일로 번들링된 상태라면, Barrel File은 서비스 애플리케이션의 번들러가 모든 모듈의 의존성 체인을 추적하게 만드는 거대한 진입점이 됩니다. 이 과정에서 특정 모듈 하나만 import 하더라도 번들러는 Barrel File에 엮인 모든 모듈의 Side-Effect 여부를 검증해야 하며, 만약 분석 과정에서 단 하나의 모듈이라도 정적 분석의 모호함이나 잠재적 부작용을 내포하고 있다면 서비스 번들러는 미사용 모듈까지 최종 번들에 포함하는 보수적인 선택을 하게 됩니다.

// library/index.ts

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

// app/page.tsx

import { module2 } from 'library'

특히 라이브러리 빌드 시 외부 의존성을 external로 분리하지 않고 라이브러리의 단일 번들 결과물 내부에 직접 병합하는 구조는 서비스 애플리케이션의 Tree Shaking 실효성을 저하시킵니다. 서드파티 코드가 라이브러리 소스 코드와 하나의 파일로 물리적으로 결합되면 모듈 간의 경계가 불투명해지며, 서비스 번들러는 라이브러리 내의 어떤 컴포넌트가 어떤 서드파티 기능을 사용하는지 명확히 파악할 수 없는 상태에 놓이게 됩니다. 이 과정에서 서비스 번들러는 결국 안전을 위해 사용되지 않는 서드파티 구현체까지 모두 서비스 최종 번들에 포함하는 보수적인 선택을 내리게 됩니다.

// vite.config.ts

import pkg from './package.json';

export default defineConfig({
build: {
// 생략...
rollupOptions: {
external: [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies),
/^@yogiyo\/yds2-icons($|\/)/,
/^@yogiyo\/yds2-styled-system\/.*/,
'react/jsx-runtime',
],
},
},
});

이러한 한계를 극복하기 위한 근본적인 해결책은 라이브러리 빌드 시 모듈 트리를 온전히 유지하는 것입니다. 모듈이 파일 단위로 명확히 분리되어 배포된다면 서비스 번들러는 사용되지 않는 파일 자체를 빌드 대상에서 제외하는 방식으로 최적화를 수행할 수 있습니다. 이를 위해 Rollup의 preserveModules: true 설정을 활용하면 원본의 모듈 구조를 최대한 유지하며 모든 소스 파일을 개별 단위의 결과물로 생성할 수 있습니다.

// vite.config.ts

export default defineConfig({
build: {
// 생략...
rollupOptions: {
output: {
preserveModules: true,
preserveModulesRoot: 'src',
},
},
},
});

또한 개발자가 공개할 주요 진입점들을 직접 지정하는 Multi-entry 방식을 활용하는 것도 훌륭한 대안입니다. 이 방식은 필요한 기능들을 명시적으로 분리하여 빌드함으로써 라이브러리 사용자가 특정 모듈에만 접근하도록 제어하면서도 물리적인 파일 격리를 달성할 수 있게 해줍니다.

// vite.config.ts

import { globbySync } from 'globby';

export default defineConfig({
build: {
lib: {
entry: globbySync(['src/**/index.ts']),
},
// 생략...
},
});

결국 preserveModules를 통해 구조를 자동 복제하거나 Multi-entry로 진입점을 관리하는 방식 모두 package.json에서 Subpath Exports를 설정할 수 있는 물리적 기반이 됩니다. 서비스 애플리케이션에서 이를 활용해 필요한 모듈만 Direct Import 하면 개발 환경에서 미사용 모듈까지 불필요하게 분석하던 Barrel Import의 고질적인 문제인 Startup 및 Rebuild 속도 저하를 방지할 수 있습니다.

만약 서비스 애플리케이션에서 Next.js를 사용한다면, Barrel Import 방식을 유지하면서도 Direct Import의 효과를 낼 수 있는 실험적 기능인 optimizePackageImports를 활용할 수 있습니다. 이 옵션은 라이브러리가 모듈 트리 구조를 유지하고 있을 때, 개발자가 작성한 Barrel Import 구문을 분석하여, 실제 모듈 경로로 매핑해줍니다.

결론적으로 Tree Shaking의 실효성은 단순한 설정이 아닌 모듈 간의 물리적 독립성에서 기인합니다. sideEffects 설정, external을 통한 외부 의존성 분리, 모듈 구조 유지는 정밀한 Tree Shaking을 보장하고 쾌적한 개발 경험을 제공하기 위한 핵심 설계 전략입니다.

번들 결과 확인

지금까지 논의한 Tree Shaking 전략들이 실제 서비스 환경에서 어느 정도의 실효성을 갖는지 Next.js 프로젝트를 통해 단계별로 측정해 보았습니다. 실험은 Next.js 기본 세팅 상태에서 yds2-react를 설치한 뒤, 오직 SingleBadge 컴포넌트 하나만을 사용하여 빌드했을 때 메인 페이지 First Load JS 크기와 Next Bundle Analyzer가 측정한 yds2-react Total Size를 비교했습니다.

위 실험 결과가 시사하듯, 라이브러리의 설계와 서비스측 최적화가 맞물리며 1.58 MB에 달하던 점유 크기를 29 kB까지 줄여, 사용자에게 필요한 코드만 전달하는 가벼운 프로덕트를 완성할 수 있습니다.

Next.js 환경에서 구형 브라우저 대응하기

Compile vs Transpile vs Polyfill

서비스 애플리케이션에서 하위 호환성을 지원하기 위해서는 아래 개념들을 구분해야 합니다.

  • Compile : 한 프로그래밍 언어로 작성된 소스 코드를 다른 타겟 언어로 변환하는 과정을 의미합니다. 흔히 C언어와 같은 고수준 언어를 컴퓨터가 이해할 수 있는 기계어로 번역하는 작업을 떠올리지만, Java 소스 코드를 가상 머신이 실행할 수 있는 Bytecode로 변환하는 것 역시 Compile의 대표적인 사례입니다. 뒤에 언급할 Typescript를 Javascript로 변환하는 과정 또한 엄밀히 따지면 Transpile에 해당하지만, 큰 틀에서는 한 언어를 다른 언어로 재구성한다는 점에서 Compile의 범주 안에 포함된다고 할 수 있습니다.
  • Transpile : 한 언어로 작성된 소스 코드를 비슷한 수준의 추상화를 가진 다른 언어로 변환하는 과정을 의미합니다. 대표적인 사례로는 Typescript 코드를 Javascript로 변환하는 과정이 있으며, 최신 문법으로 작성된 Javascript 코드를 구형 브라우저와의 호환성을 위해 구 문법으로 변환하는 작업 역시 이 범주에 속합니다. 즉, 개발자는 Transpile을 통해 생산성 높은 최신 언어를 사용하면서도 실행 환경의 제약을 동시에 해결할 수 있습니다.
  • Polyfill : 특정 실행 환경에서 제공되지 않는 표준 기능을 사용할 수 있도록, 해당 기능의 동작을 직접 구현해 제공하는 보조 스크립트를 의미합니다. ECMAScript 표준에 정의된 객체나 메서드(Promise, Array.prototype.at 등), 그리고 브라우저가 제공하는 Web API(fetch, IntersectionObserver등)를 대상으로 합니다. 트랜스파일이 문법 수준의 호환성을 해결하는 데 초점을 둔다면, 폴리필은 런타임에 실제로 존재하지 않는 기능을 보완함으로써 실행 환경 간의 기능 차이를 해결합니다. 이를 통해 개발자는 최신 표준을 기준으로 코드를 작성하면서도, 구형 브라우저나 제한된 환경에서도 동일한 동작을 보장할 수 있습니다.

하위 호환성의 책임 주체

라이브러리를 최적화하여 번들 크기를 줄이는 것만큼 중요한 것은, 그 코드가 유저의 다양한 실행 환경에서 문제없이 동작하도록 보장하는 것입니다. 이를 위해 라이브러리 개발자는 하위 호환성 확보의 책임을 라이브러리 자체에서 질 것인지, 아니면 서비스 애플리케이션에 맡길 것인지 선택해야합니다.

만약 라이브러리가 사용될 서비스들의 브라우저 지원 범위가 동일하고, 서비스 측면에서 별도의 Polyfill 설정을 관리해야하는 포인트를 줄여주고 싶다면, babel-plugin-polyfill-corejs3과 같은 플러그인의 usage-pure 모드를 활용해서 Polyfill을 라이브러리 코드 내에 직접 포함해 배포할 수 있습니다. 이 방식은 전역 환경을 오염시키지 않으면서도 라이브러리 자체적으로 하위 호환성을 완결 지을 수 있어 서비스 개발자가 추가적인 설정에 신경 쓰지 않아도 된다는 장점이 있습니다.

하지만 이러한 접근은 서비스마다 타겟 브라우저 범위가 제각각이라는 점을 고려할 때 한계가 명확합니다. 또한, 라이브러리가 폴리필을 내장하면 최신 브라우저를 사용하는 서비스의 유저에게도 불필요한 코드 로딩 비용을 강제하게 되며, 이는 결국 서비스별 최적화 유연성을 저해하는 결과로 이어집니다. 따라서 개발 효율을 위한 관리 편의성을 우선할 것인지, 혹은 각 서비스 애플리케이션이 자신의 지원 환경에 맞춰 최적화할 수 있도록 자율성을 부여할 것인지에 대한 전략적 판단이 필요합니다.

이러한 이유로 요기요 FE팀은 하위 호환성의 책임 주체를 라이브러리가 아닌 서비스 애플리케이션에서 가져가기로 결정했습니다. 구체적으로 어떤 기술적 방법으로 이러한 전략을 실현했는지 아래에서 자세히 살펴보겠습니다.

Next.js의 Browser Support

Next.js는 별도의 설정없이도 Modern Browser를 지원하며, Next.js v15 기준으로 지원하는 브라우저 사양은 다음과 같습니다.

  • Chrome 64+
  • Edge 79+
  • Firefox 67+
  • Opera 51+
  • Safari 12+

만약 서비스의 비즈니스 타겟에 따라 특정 브라우저를 지원해야 한다면, package.json에 browserslist 설정을 추가하면 됩니다. 별도의 설정을 하지 않을 경우 Next.js는 내부적으로 아래 지원 범위를 기본값으로 빌드를 진행합니다. 이 설정은 Next.js가 코드를 얼마나 낮은 버전으로 Transpile 할지 결정하는 기준이 됩니다. 다만, 여기서 유의할 점은 browserslist와 위에서 살펴보았던 next-polyfill-module이 모든 하위 호환성 문제를 해결해주지 않는다는 것입니다.

// package.json

{
"browserslist": [
"chrome 64",
"edge 79",
"firefox 67",
"opera 51",
"safari 12"
]
}

외부 라이브러리까지 꼼꼼하게 트랜스파일하기

Next.js는 프로젝트 소스 코드를 browserslist 설정에 맞춰 Transpile 합니다. 하지만 node_modules에 포함된 외부 라이브러리들은 기본적으로 Transpile 대상에서 제외된 채 빌드됩니다. 이 때문에 서비스의 타겟 브라우저 사양이 라이브러리의 지원 버전보다 낮을 경우, 구형 브라우저에서 예기치 못한 런타임 에러가 발생하게 됩니다.

이때 활용할 수 있는 옵션이 Next.js의 transpilePackages입니다. 해당 옵션에 특정 패키지 명을 명시하면, Next.js는 빌드 타임에 해당 라이브러리까지 browserslist 기준에 맞춰 다시 Transpile을 수행합니다. 이를 통해 라이브러리 자체의 지원 범위에 구애받지 않고, 서비스 프로젝트가 목표로 하는 환경에 최적화된 최종 번들을 생성할 수 있습니다.

Polyfill 사각지대 메우기

transpilePackages 옵션을 통해 문법적인 호환성 문제를 해결했더라도, 런타임에 존재하지 않는 API를 메꿔주는 Polyfill 문제는 여전히 남아있습니다. Next.js는 next-polyfill-module를 통해 일부 Polyfill을 자동으로 주입하지만, 모든 ECMAScript와 브라우저 Web API를 커버하지는 못합니다. 이러한 사각지대를 해결하기 위해, 서비스 애플리케이션은 프로젝트의 상황에 맞는 Polyfill 전략을 선택해야합니다.

Case 1. 필요한 Polyfill 리스트를 명확히 아는 경우

프로젝트 소스 코드와 외부 라이브러리에서 사용하는 API 스펙을 이미 파악하고 있다면, 필요한 폴리필을 직접 로드하는 방식이 효율적입니다. Next.js App Router 환경에서는 instrumentation-client.js 파일을 활용하는 것이 권장됩니다.

import './lib/polyfills'

if (!window.ResizeObserver) {
import('./lib/polyfills/resize-observer').then((mod) => {
window.ResizeObserver = mod.default
})
}

Case 2. 필요한 Polyfill 리스트를 일일이 파악하기 어려운 경우

대규모 프로젝트이거나 의존성 구조가 복잡하여 모든 Polyfill 리스트를 수동으로 관리하기 어려운 상황이라면 다른 대응 전략을 수립해야 합니다. 이때는 프로젝트의 빌드 환경과 유저의 실행 환경 중 어디에 최적화의 방점을 찍을지에 따라 크게 두 가지 전략으로 나뉩니다.

전략 A. Babel의 useBuiltIns 설정을 통한 주입

서비스 애플리케이션의 .babelrc 설정에서 useBuiltIns: “usage” 옵션을 활성화하면, 프로젝트 내 소스 코드와 외부 라이브러리를 정적으로 분석하여 사용된 API에 대응하는 Polyfill을 알아서 채워 넣습니다.

// .babelrc

{
"presets": [
[
"next/babel",
{
"preset-env": {
"useBuiltIns": "usage",
"corejs": "3.46"
}
}
]
]
}

하지만 이러한 편의성 이면에는 한계점이 존재합니다. Next.js는 v12부터 Rust 기반의 고성능 컴파일러인 SWC를 기본으로 사용하지만, Polyfill 자동 주입을 위해 Custom .babelrc를 설정하는 순간 Next.js는 SWC 대신 Babel로 Compile 방식을 전환하게 됩니다. 이는 빌드 속도를 저하시키고, 해당 Polyfill을 Native로 지원하는 최신 브라우저 유저들조차 불필요한 Polyfill 코드가 담긴 번들을 내려받아야 하는 성능상의 오버헤드가 발생합니다.

전략 B. User-Agent 기반의 동적 Polyfill 서비스 사용하기

Babel 설정의 한계를 극복하기 위해 검토할 수 있는 대안은 유저 브라우저의 User-Agent를 분석해 동적으로 Polyfill 스크립트를 생성하는 방식입니다. 예를 들어, 최신 버전의 Chrome 환경에서는 빈 스크립트가 내려가고, 구 버전에서는 그 버전에서 필요한 Polyfill 스크립트가 내려가게 됩니다.

외부 솔루션으로는 cdnjs.cloudflare.com이나 polyfill-fastly.io처럼 동적 Polyfill 스크립트를 제공하는 서비스들이 존재하며, 아래와 같이 스크립트 태그 삽입 만으로 간단히 구현할 수 있다는 장점이 있습니다.

<script src="https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?version=4.8.0"></script>

이러한 외부 서비스들은 별도의 인프라 구축 없이 바로 사용할 수 있다는 장점이 있지만, 요기요의 환경에서는 한계가 있었습니다. 요기요는 Native 앱에서 User-Agent를 커스텀하여 사용하고 있는데, 외부 서비스의 표준 파싱 로직이 이 특수한 UA를 제대로 해석하지 못하는 문제가 발생했기 때문입니다. 이 경우 외부 서비스는 브라우저 식별에 실패하여 보수적인 Polyfill 리스트를 응답하게 되고, 결과적으로 최신 환경의 유저들도 불필요하게 무거운 스크립트를 전송받는 비효율이 발생했습니다.

이 문제를 해결하기 위해 요기요 FE 팀은 Custom Polyfill 서비스를 개발하였습니다. ua-parser-js와 core-js-compat으로 유저 환경에 꼭 필요한 폴리필 목록을 추출한 뒤, 이를 esbuild로 번들링하여 응답하는 구조입니다. 이를 통해 요기요 서비스 환경에 최적화된 호환성 레이어를 제공할 예정입니다.

마치며

지금까지 YDS v2 라이브러리를 구축하며 직면했던 번들 최적화와 하위 호환성 대응에 대한 고민과 해결 과정을 살펴보았습니다. 라이브러리가 소비되는 서비스 애플리케이션의 성능과 안정성에 어떤 영향을 미칠지 깊게 고민해야 하는 과정이었습니다. 디자인 시스템은 한 번의 구축으로 끝나는 프로젝트가 아니라, 서비스와 함께 호흡하며 계속해서 발전해 나가는 생태계와 같습니다. 앞으로도 요기요 FE 팀은 사용자에게는 더 빠른 경험을, 개발자에게는 더 쾌적한 환경을 제공하기 위한 여정을 지속해 나가겠습니다.


[디자인 시스템 어떻게 만들었어요?(3)] Tree Shaking과 구형 브라우저 대응 was originally published in YOGIYO Tech Blog - 요기요 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

Read Entire Article