- 다운로드 버튼 하나 때문에 패키지 전체를 가져오던 문제를 해결하기까지 -
1. 들어가며
이번 3분기에 사내 메신저 서비스인 Loopin의 7.2 업데이트를 맡았습니다. 그중 하나가 "채팅에서 받은 파일도 내 파일함에 남아야 한다"는 요구사항이었습니다.
같은 기능이 이미 있었습니다. 파일 저장소 도메인인 vault의 FileDownloadItem 컴포넌트는 파일명과 용량을 보여주고, 다운로드 버튼을 누르면 파일을 내려받은 뒤 사용자의 Stash 폴더에 자동으로 등록하고, 이미 받은 파일이면 아이콘을 다르게 보여주는 것까지 하고 있었습니다. 소스가 150줄 정도 되는 작은 컴포넌트입니다. 채팅에 같은 걸 다시 만들 이유가 없어 보여서 가져다 쓰기로 했습니다.
그런데 import 한 줄을 추가했더니 pnpm-lock.yaml에 828줄이 추가되었고, 빌드 산출물에 채팅에서 쓰지도 않는 PDF 뷰어 워커 파일 1MB짜리가 들어와 있었습니다. 이 글은 150줄짜리 컴포넌트 하나를 쓰려고 패키지 전체를 가져오던 문제를, 필요한 부분만 떼어서 가져오도록 바꾼 과정에 대한 기록입니다.
2. 배경
저희 프론트엔드는 pnpm workspace와 Turborepo 기반 모노레포이고, 패키지는 다음 계층을 따릅니다.
episodes/* 실제 실행 앱 (엔트리)
└─ collages/* 앱 레이아웃과 도메인 조합 계층
└─ dramas/*-view 화면 컴포넌트 (React/MUI)
└─ dramas/*-state → dramas/*-stub
Loopin과 vault는 각각 loopin-view, vault-view라는 별도 패키지로 배포됩니다. 채팅에서 vault의 컴포넌트를 쓰려면 사내 레지스트리에 올라간 @vizendjs/vault-view를 의존성으로 추가하면 됩니다. 처음에 제가 쓴 코드는 이것뿐이었습니다.
import { FileDownloadItem } from '@vizendjs/vault-view';
3. 원인 찾기 — 배럴이 만든 모듈 그래프
필요한 컴포넌트 하나만 import 했으니 나머지는 트리 셰이킹으로 걸러질 거라고 생각했는데 아니었습니다.
무엇부터 봐야 할지 몰라서 눈에 보이는 것부터 확인했습니다. pnpm-lock.yaml의 diff에서 새로 추가된 패키지를 훑어보니 pdfjs-dist, react-syntax-highlighter처럼 채팅과 관계없는 것들이 있었고, vault-view의 dist에는 pdf.worker.min.mjs 1MB짜리 파일이 들어 있었습니다. 이 라이브러리들을 누가 import 하는지 거꾸로 따라가 봤더니, 원인은 배럴 파일(barrel file)이었습니다.
vault-view의 공개 엔트리부터 실제 무거운 라이브러리까지 이렇게 이어져 있었습니다.
src/index.ts
export * from './components'
└─ components/index.ts
├─ export * from './common'
│ ├─ CodeBlock.tsx → react-syntax-highlighter
│ ├─ AgGridCheck.tsx → ag-grid
│ └─ FileDownloadItem.tsx ← 내가 필요한 건 이것 하나
├─ export * from './stash'
│ └─ mydisk/modal/LightboxPage.tsx → pdfjs-dist, react-zoom-pan-pinch
│ └─ mydisk/MyDiskViewList.tsx → ag-grid-react
├─ export * from './minipix'
├─ export * from './policy'
├─ export * from './mydisk'
└─ export * from './cabinet'
export * from './utils'
├─ date-utils.ts → dayjs
├─ stash-utils.ts → @vizendjs/vault-stub
└─ file-utils.ts → (의존성 없음)
export *는 그 모듈 전체를 평가 대상으로 만들기 때문에, 컴포넌트 하나만 가져와도 이 트리 전체가 모듈 그래프에 올라옵니다. 예를 들어 LightboxPage.tsx는 이렇게 시작합니다.
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
마지막 줄처럼 모듈 최상위에서 전역 설정을 건드리는 코드가 있으면 번들러는 이 모듈을 부수 효과가 있다고 보고 함부로 지우지 못합니다. 여기에 더해 vault-view는 무거운 라이브러리들을 peerDependencies로 두고 빌드에서 external 처리하고 있었습니다. external은 번들에 포함하지 않고 import 구문만 남긴다는 뜻이라 vault-view의 dist 자체는 작아 보이지만, 그 dist를 가져다 쓰는 앱은 남아 있는 import 구문을 자기 번들에서 해석해야 합니다. 결국 비용은 소비하는 쪽인 Loopin이 떠안게 되는 구조였습니다.
4. 해결 1 — 필요한 것만 모은 서브패스 엔트리
기존 엔트리는 vault 앱이 그대로 쓰고 있어서 손댈 수 없었습니다. 그래서 기존 문은 그대로 두고, 일부만 필요한 소비자를 위한 두 번째 문을 따로 만드는 방향으로 접근했습니다.
// dramas/vault-view/src/common.ts
// @vizendjs/vault-view/common — 채팅용 경량 엔트리.
// components/common/index.ts 배럴(CodeBlock·AgGridCheck 포함)을 거치지 않고
// 필요한 것만 직접 재export하여 pdfjs-dist / react-syntax-highlighter 가
// 이 청크로 유입되지 않게 한다.
export { FileDownloadItem } from './components/common/FileDownloadItem';
export type { FileDownloadItemProps } from './components/common/FileDownloadItem';
// 배럴(~/utils)이 아닌 파일 직접 경로 → dayjs/vault-stub 유입 차단
export { formatFileSize } from './utils/file-utils';
여기서 제일 중요했던 건 파일 경로를 끝까지 직접 쓰는 것이었습니다. 처음에는 './components/common'까지만 적었는데 청크 크기가 거의 그대로였습니다. 확인해보니 그 배럴이 CodeBlock과 AgGridCheck를 함께 export 하고 있어서, 한 단계만 거쳐도 형제 모듈이 전부 따라오고 있었습니다.
FileDownloadItem 자체가 무엇을 쓰는지도 같이 확인했습니다. 이 컴포넌트의 import는 네 줄뿐이었습니다.
// dramas/vault-view/src/components/common/FileDownloadItem.tsx
import { useState } from 'react';
import styled from '@emotion/styled';
import { useClipFileDownload, useStash } from '@vizendjs/vault-state';
import { formatFileSize } from '~/utils/file-utils';
무거운 라이브러리는 하나도 없고, vault의 다운로드·Stash 훅이 있는 vault-state에만 의존하고 있었습니다. 이 정도면 떼어내도 되겠다는 확신이 들어서 작업을 진행했습니다.
formatFileSize도 같은 이유로 './utils'가 아니라 './utils/file-utils'로 적었습니다. utils/index.ts 배럴은 date-utils(dayjs)와 stash-utils(vault-stub)를 함께 내보내는데, 정작 제가 필요한 file-utils.ts는 import 문이 하나도 없는 순수 함수 파일이었습니다. 배럴을 거치면 아무 관계도 없는 dayjs가 딸려오고, 파일을 직접 가리키면 아무것도 딸려오지 않습니다. 같은 함수를 가져오는데 경로 한 단계 차이로 결과가 달라진다는 게 이때 가장 인상 깊었습니다.
5. 해결 2 — 빌드에 두 번째 엔트리 등록하기
파일만 만든다고 @vizendjs/vault-view/common으로 import 할 수 있는 건 아니었습니다. 빌드 산출물에 그 엔트리가 별도 파일로 나와야 했습니다.
// dramas/vault-view/vite.config.mts
build: {
lib: {
entry: {
index: './src/index.ts',
common: './src/common.ts',
},
name: packageJson.name,
formats: ['es'],
fileName: (format, entryName) => `${entryName}.${format}.js`,
},
rollupOptions: {
external: [
...Object.keys(packageJson.devDependencies),
...Object.keys(packageJson.peerDependencies),
],
},
}
entry를 문자열에서 객체로 바꾸는 순간 빌드가 실패해서 한참 헤맸습니다. 원인은 UMD였습니다. UMD는 모든 export를 하나의 전역 변수에 매달아야 하는 형식이라 엔트리가 둘이면 그 전역 이름을 정할 수 없어서 다중 엔트리를 지원하지 않았습니다. 소비하는 앱이 모두 ESM 번들러를 쓰고 있어서 formats를 ['es']로 고정하고, package.json의 main 필드도 ES 산출물을 가리키도록 같이 정리했습니다.
6. 해결 3 — 소비자가 그 문으로 들어올 수 있게
빌드 결과에 파일이 생겨도 패키지 바깥에서 @vizendjs/vault-view/common이라는 경로로 접근할 수 있어야 합니다. exports 필드에 서브패스를 추가했습니다.
{
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"exports": {
".": {
"types": "./dist/@types/src/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.es.js"
},
"./common": {
"types": "./dist/@types/src/common.d.ts",
"import": "./dist/common.es.js"
},
"./styles/all.css": "./dist/vault-view.css"
},
"typesVersions": {
"*": {
"common": ["./dist/@types/src/common.d.ts"]
}
}
}
여기까지 하니 빌드는 통과하는데 에디터에서는 계속 모듈을 못 찾는다고 나왔습니다. 한참 찾다가 exports의 types 조건은 moduleResolution이 bundler 이상일 때만 해석된다는 걸 알게 되었습니다. 저희 패키지 중 아직 레거시 해석을 쓰는 것이 있어서, 같은 내용을 typesVersions로 한 번 더 선언하고 나서야 오류가 사라졌습니다. 두 필드가 중복처럼 보이는데 서로 다른 소비자를 위한 것이었습니다.
7. 검증 — 정말 안 딸려오는지 확인하기
작업을 마치고 나서 실제로 효과가 있는지 dist를 직접 열어 확인했습니다. 두 엔트리가 서로 다른 청크를 참조하고 있었습니다.
dramas/vault-view/dist/
├─ index.es.js 1.5 KB → index-D-3TlXGH.mjs 참조
├─ index-D-3TlXGH.mjs 3.7 MB
├─ pdf.worker.min.mjs 1.0 MB
├─ common.es.js 120 B → FileDownloadItem-B17vE2-_.mjs 참조
└─ FileDownloadItem-B17vE2-_.mjs 49 KB
common.es.js는 내용이 네 줄뿐입니다.
import { F as a, f } from "./FileDownloadItem-B17vE2-_.mjs";
export {
a as FileDownloadItem,
f as formatFileSize
};
크기보다 확실한 근거는 각 청크가 바깥에 요구하는 라이브러리 목록이었습니다. 앞에서 적었듯이 무거운 라이브러리는 external로 빠져 있어서 import 구문으로만 남는데, 그 목록이 곧 소비하는 앱이 떠안아야 할 짐입니다.
[배럴 엔트리] index-D-3TlXGH.mjs 가 요구하는 것 (15개)
react, react-dom, @emotion/styled, @emotion/react, @vizendjs/accent,
@tanstack/react-query, axios, notistack, jotai, lodash, dayjs,
ag-grid-react, react-zoom-pan-pinch, pdfjs-dist, react-syntax-highlighter
[경량 엔트리] FileDownloadItem-B17vE2-_.mjs 가 요구하는 것 (5개)
react, @emotion/styled, @tanstack/react-query, axios, @vizendjs/accent
목록에서 pdfjs-dist, react-syntax-highlighter, react-zoom-pan-pinch, ag-grid-react, dayjs, lodash, jotai가 사라졌습니다. 컴포넌트 소스가 150줄인데 청크가 49KB인 게 이상해 보였는데, 목록에 @vizendjs/vault-state가 없는 걸 보고 이해했습니다. external은 devDependencies와 peerDependencies만 대상으로 하는데 vault-state는 dependencies라서, 다운로드와 Stash 등록에 쓰이는 훅 코드가 청크 안에 함께 들어간 것이었습니다. 남은 다섯 개는 채팅 화면이 이미 쓰고 있는 것들이라 실질적인 추가 비용은 거의 없었습니다. 숫자가 아니라 목록으로 확인하니 무엇이 왜 빠졌는지가 분명해서, 이후에는 번들을 볼 때 크기보다 이 목록을 먼저 보게 되었습니다.
8. 쓰는 쪽
소비하는 쪽에서는 import 경로에 /common만 붙이면 됩니다.
import { FileDownloadItem } from '@vizendjs/vault-view/common';
다만 이 import는 채팅 화면 컴포넌트가 아니라 상위 계층인 collage에 두었습니다. loopin-view에서 바로 import 하면 서로 다른 도메인의 같은 계층 패키지끼리 의존하게 되어 모노레포 규칙에 어긋나기 때문입니다. collage에서 구현체를 받아 Context로 주입하도록 바꾼 덕분에, vault-view 버전이 7.2.6에서 7.2.21까지 올라가는 동안 loopin-view는 한 번도 재배포하지 않아도 되었습니다.
주의할 점도 하나 있었습니다. 경량 엔트리의 청크는 안에서 axios를 직접 import 하는데, 이 axios는 반드시 호스트 앱과 같은 인스턴스여야 합니다. 저희는 인증 토큰과 행위자 식별자를 axios 전역 인스턴스의 request 인터셉터에서 붙이고 있어서, 패키지가 자기 복사본을 쓰면 그 요청만 Authorization과 Actorid 없이 나가 401이 납니다. 그래서 axios는 dependencies가 아니라 peerDependencies로 선언했습니다.
9. 결과와 배운 것
|
항목 |
배럴 엔트리 |
경량 엔트리 |
|---|---|---|
|
import 경로 |
@vizendjs/vault-view |
@vizendjs/vault-view/common |
|
참조 청크 크기 |
약 3.7MB |
약 49KB |
|
소비 앱이 떠안는 외부 라이브러리 |
15개 |
5개 |
|
pdfjs-dist / pdf.worker |
포함 |
없음 |
기능 쪽에서도 "채팅에서 받은 파일이 내 파일함에 남는다"가 채팅에 별도 구현 없이 충족되었습니다. 150줄짜리 컴포넌트를 그대로 재사용하면서 비용만 덜어낸 셈입니다.
패키지의 공개 API는 export 목록이 아니라 모듈 그래프인 것 같습니다. export * 한 줄은 패키지를 만드는 쪽에는 편하지만, 가져다 쓰는 쪽에는 그 아래 달린 것 전부가 비용으로 돌아옵니다. 앞으로 공용 패키지에서 배럴을 만들 때는 "이걸 통해 무엇이 같이 딸려오는지" 먼저 확인하려고 합니다.
공용 패키지에는 문이 여러 개 있어도 된다는 걸 배웠습니다. 처음에는 기존 엔트리를 어떻게든 가볍게 만들어야 한다고 생각했는데, vault 앱은 그 무거운 컴포넌트들이 전부 필요한 정상적인 소비자였습니다. 소비자마다 필요한 크기가 다르면 엔트리를 나누는 게 서로에게 나은 선택이었습니다.
아직 정리하지 못한 것도 있습니다. 지금은 경량 엔트리에 무엇을 넣을지가 제 판단에 달려 있어서, 나중에 누군가 common.ts에서 무심코 배럴을 import 하면 다시 무거워집니다. 청크의 외부 import 목록을 빌드 때 확인해서 정해둔 개수를 넘으면 실패시키는 검사를 넣어보고 싶은데, 어떻게 구성하는 게 좋을지는 더 알아봐야 할 것 같습니다.
messi