Loopin의 vault 경량 엔트리 적용기

Loopin의 vault 경량 엔트리 적용기

- 다운로드 버튼 하나 때문에 패키지 전체를 가져오던 문제를 해결하기까지 -

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

Site footer