List의 공개 범위는 왜 타입만으로 지킬 수 없었을까

exports로 패키지의 공개 경로를 설계하고, JavaScript와 CSS가 줄어드는 단위를 가르기

타입은 조합을 막았지만 접근은 막지 않았다

공통 List를 설계하며 정한 경계는 이랬다. 새로운 조합을 만들 권한은 내부 Compound Primitive를 사용하는 디자인 시스템에 남기고, 제품 코드에는 승인된 Recipe만 공개한다.

여기까지 이 경계는 타입 안에서 지켜졌다. 승인되지 않은 조합은 컴파일되지 않았고, 스타일 탈출구도 ?: never로 막았다.

그런데 타입이 막은 것은 조합이지 접근이 아니었다.

ts
import { Root, Leading, Content } from '@design-system/list/internal';

이 줄이 해석되는 순간 제품 코드는 Recipe를 거치지 않고 Primitive를 직접 조합할 수 있다. Recipe의 타입이 아무리 잘 막고 있어도 내부 경로를 직접 import하면 그 계약을 우회할 수 있다.

제품 코드
조합
타입이 판정한다
<ListRowBasic label="전체 알림" count={12}/>
해석된다
<ListRowBasic label="전체 알림" count={12} description="..."/>
컴파일되지 않는다
접근
타입이 판정하지 않는다
import { ListRowMax } from '@design-system/list'
해석된다
import { Root, Leading } from '@design-system/list/internal'
해석된다
왼쪽은 하나가 걸리고 오른쪽은 둘 다 지나간다. 타입이 판정하는 것은 조합이지 접근이 아니다

예외 요구를 어떻게 처리할지 정할 때 같은 이야기를 했다. 내부 Compound를 제품 코드에 공개하면 한 번의 예외를 위해 열어둔 API가 모든 제품 코드의 범용 조합 도구가 된다. 그때는 공개하지 않겠다는 결정으로 끝냈다.

여기서 묻는 것은 그 결정이 옳은지가 아니다. 결정은 이미 내려져 있었다. 이 편의 질문은 그 결정이 어디에서 실행되는가다.

실행하는 자리는 패키지였다. 그리고 패키지가 답해야 할 질문은 두 가지였다. 내부 Primitive를 제품 코드에서 직접 import하지 못하게 할 수 있는가. 그리고 공개 Recipe 가운데 사용하지 않은 JavaScript와 CSS를 소비자의 번들에서 제외할 수 있는가.

첫 번째는 모듈을 해석할 수 있는 범위의 문제이고, 두 번째는 실제 번들에 포함되는 범위의 문제다. 서로 다른 문제지만 패키지의 진입점을 설계하는 과정에서 만난다. 이하에서는 프로덕션 빌드에서 사용하지 않은 ESM 코드를 제거하는 동작을 ’미사용 코드 제거’라고 부른다. 흔히 트리 셰이킹이라고 부르는 최적화다.

두 질문의 답이 같은 곳에 있을 줄은 몰랐다.

별도 패키지인가, 같은 패키지의 비공개 경로인가

경계를 만드는 방법은 두 가지였다.

별도 내부 패키지 같은 패키지의 비공개 경로
경계의 선명함 매우 분명하다 exports 설정에 의존한다
버전 관리 두 패키지를 따로 올려야 한다 하나로 움직인다
Primitive 단독 배포 가능 불가능
제품 코드의 소비 가능성 의존성으로 추가하면 독립적으로 import할 수 있다 exports에 공개하지 않으면 공식 경로로 import할 수 없다

마지막 줄이 결정적이었다.

별도 패키지로 분리하면 구조적 경계는 선명해진다. 하지만 제품 코드가 그 패키지를 의존성으로 추가하는 순간 Primitive를 직접 소비할 수 있는 공식 경로도 함께 생긴다.

Primitive는 독립적인 제품이 아니라 공개 Recipe를 만들기 위한 구현 도구였다. Recipe와 항상 같은 버전으로 움직였고 따로 배포할 이유가 없었다.

같은 패키지 안에 두되 공개 export에서 제외하는 구조를 골랐다.

경계를 실행하는 것은 폴더 이름이 아니라 exports였다

폴더 이름을 internal로 짓는 것만으로는 아무것도 막히지 않는다. 경계를 실제로 실행하는 것은 package.jsonexports다. exports는 패키지 소비자가 import할 수 있는 공식 진입점을 나열하는 필드다. "."은 패키지 루트이고, "./row/max"처럼 추가한 경로를 subpath export라고 부른다.

json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./row/max": {
      "types": "./dist/row/max/index.d.ts",
      "import": "./dist/row/max/index.js"
    },
    "./styles.css": "./dist/styles.css"
  }
}

여기에 없는 경로는 패키지 경계에서 해석되지 않는다.

ts
import { Root } from '@design-system/list/internal';
// 모듈을 찾을 수 없다
@design-system/list
공개 Recipe
ListRowBasicListRowRegularListRowMax
내부 Primitive
RootLeadingContentTrailingBottom
package.jsonexports
통과
import { ListRowMax } from '@design-system/list'
해석되지 않는다
import { Root } from '@design-system/list/internal'
이 띠를 지나지 않는 경로
tsconfig paths alias
두 층은 같은 패키지 안에 있다. 가르는 것은 폴더 이름이 아니라 그 아래의 exports 검사다

앞 절의 도해에서 그냥 지나가던 두 번째 import가 여기서 막힌다. 폴더를 옮긴 것이 아니라 exports에 그 경로가 없기 때문이다.

그 경계에는 조건 하나와 구멍 하나가 있었다

조건. 소비자 tsconfigmoduleResolutionbundlernode16 계열이어야 exports가 반영된다. 레거시 node 설정에서는 exports를 읽지 않아 subpath의 타입 해석이 어긋난다.

구멍. 모노레포에서 앱이 tsconfigpaths 별칭으로 패키지 소스를 직접 참조하면 exports는 개입하지 않는다. TypeScript 문서도 paths 별칭이 매치되면 exports 지원을 포함한 패키지 조회 기능이 동작하지 않는다고 명시한다.

이 구멍이 어디까지 뚫리는지는 소비 환경에 따라 다르다. paths는 TypeScript가 내보내는 코드를 바꾸지 않으므로, 번들러가 이 별칭을 모르면 타입 검사만 통과하고 빌드에서 터진다. 반면 Next.js처럼 번들러가 tsconfigpaths를 함께 읽는 환경에서는 같은 매핑이 모듈 해석에도 적용돼 런타임까지 그대로 지나간다. 어느 쪽이든 경계가 새는 것은 같지만, 언제 드러나는지가 다르다.

패키지 경계만으로 안심하지 않고 ESLint의 no-restricted-imports를 함께 걸어야 실제로 막힌다.

여기까지가 첫 번째 질문의 답이다. 무엇을 열 것인가는 exports가 정하고, 그것이 새는 자리는 ESLint가 한 겹 더 덮는다. 남은 것은 두 번째 질문이다. 공개했지만 사용하지 않은 Recipe가 소비자의 번들에서 실제로 빠지는가.

묶어서 공개하면 번들에 전부 남을까

경계를 정하고 나니 공개하는 Recipe의 이름 형태가 문제가 됐다.

tsx
<List.RowBasic />
<List.RowRegular />
<List.RowMax />

List.RowMax는 Figma의 List 계층과 비슷해 보이고 자동 완성에서 관련 Recipe를 한 번에 찾을 수 있다. 반면 평평한 ListRowMax는 이름 규칙에 의존한다.

여기까지는 취향의 문제로 보였다. 걸린 것은 하나였다. 셋을 List라는 하나의 이름 아래 묶으면, 하나만 써도 셋이 전부 번들에 남지 않을까.

List.RowMax를 만드는 방법이 둘이고, 둘의 결과가 달랐다.

흐린 칸이 번들에서 빠진 것
ListRowMax 하나만 import 했다
평평한 named export
export { ListRowBasic, ListRowRegular, ListRowMax, }
RowBasicRowRegularRowMax
미사용 제거됨107 bytes
runtime object
export const List = { RowBasic, RowRegular, RowMax, }
RowBasicRowRegularRowMax
전부 남음356 bytes
ESM namespace
export * as List from './rows'
RowBasicRowRegularRowMax
미사용 제거됨96 bytes
rollup 4.62 · ES 출력 · react는 external
가운데 줄만 셋이 다 진하다. 묶는 것 자체가 아니라 묶는 방식 하나가 문제였다

List를 런타임 객체로 만들면 객체를 구성하는 순간 세 컴포넌트를 전부 참조한다.

ts
export const List = {
  RowBasic: ListRowBasic,
  RowRegular: ListRowRegular,
  RowMax: ListRowMax,
};

ListRowMax만 가져와도 나머지가 번들에 남았다. rollup 4.62, ES 모듈 출력, react를 번들에 포함하지 않고 외부 의존성으로 둔(external) 조건에서 107바이트 대 356바이트였다.

반면 ESM namespace로 만든 List.RowMax는 그대로 제거됐다.

ts
export * as List from './rows';

namespace 문법 자체가 미사용 코드 제거를 막는다고 단정할 수는 없다. 문제는 문법이 아니라 객체를 만드는 코드였다.

의심은 반쯤만 맞았다. 하나의 이름 아래 묶는 것은 문제가 아니었고, 묶는 방식 중 하나만 문제였다.

형태를 가른 것은 번들 크기가 아니라 진입점이었다

그럼에도 named export를 공개 API의 기본 형태로 골랐다. ESM namespace를 쓰면 List.RowMax라는 표기와 미사용 코드 제거를 함께 가질 수 있었는데도 그랬다. 이유는 방금 잰 숫자에 없었다.

List.RowMax ListRowMax
관련 API 탐색 쉽다. 한 namespace에 모인다 이름 규칙에 의존한다
Figma 계층과의 유사성 높다 보통
구현 방식 런타임 객체 또는 ESM namespace 정적 named export
미사용 코드 제거 구현에 따라 달라져 검증이 필요하다 번들러가 다루기 가장 단순한 형태
subpath로 분리 어렵다 쉽다

namespace로 묶으면 ./row/max 같은 Recipe별 진입점을 만들기 어렵다. List라는 하나의 이름 아래 모여 있는 구조를 여러 진입점으로 쪼개려면 결국 평평한 모듈로 되돌아가야 한다.

그리고 이 진입점이 없으면 뒤에 나올 CSS 문제를 풀 방법이 없었다.

그래서 공개 형태는 평평한 named export로 정해졌다.

ts
import {
  ListRowBasic,
  ListRowRegular,
  ListRowMax,
} from '@design-system/list';

측정한 숫자가 결정을 뒤집지는 않았다. 다만 그 측정을 하는 동안 알게 된 것이 하나 있었다. 이름 형태를 어떻게 고르든, 무엇이 실제로 남는지는 다른 층에서 정해지고 있었다.

이름을 정해도 빌드 출력이 결과를 다시 갈랐다

ListRowMax라는 이름을 골랐다고 미사용 코드가 사라지지는 않는다. 실제로 무엇이 남는지를 가르는 것은 이름이 아니라 빌드 출력이었다.

결정 변수 왜 결정적인가
최상위에 분석 불가능한 호출이 있는가 memoforwardRef의 결과를 export하면 번들러는 그 호출의 부수효과를 알 수 없다
그 결과에 다시 프로퍼티를 할당하는가 displayName 할당이 남아 있으면 변수가 참조되므로 제거되지 않는다
sideEffects 선언 번들러가 모듈 실행 여부를 판단할 때 쓰는 패키지 힌트다. 없어도 순수한 export는 제거되지만, 사용하지 않는 모듈 전체와 그 의존성을 통째로 건너뛸 근거는 사라진다
subpath export가 있는가 Recipe별 진입점이 있어야 CSS를 나눠 담을 수 있다

앞의 둘이 함께 걸릴 때 특히 눈에 띄지 않는다.

번들러가 지우지 못한 줄
함수 선언에 붙인 displayName제거됨
export function ListRowBasic(...) { ... }ListRowBasic.displayName = 'ListRowBasic'
memo의 결과에 할당남음
export const ListRowBasic = memo(...)ListRowBasic.displayName = 'ListRowBasic'
PURE 주석을 붙인 뒤남음
const ListRowBasic = /*#__PURE__*/ memo(...)ListRowBasic.displayName = 'ListRowBasic'
할당을 없앤 뒤제거됨
export const ListRowBasic = /*#__PURE__*/ memo( function ListRowBasic(...) { ... },)
두 번째와 세 번째는 결과가 같지만 붙잡는 줄이 다르다. 그래서 한쪽만 고치면 떨어지지 않는다

일반 함수 선언에 붙인 displayName은 문제가 되지 않았다. 번들러가 함수와 그 함수에 대한 프로퍼티 할당을 함께 추적해서 통째로 떨어뜨린다.

ts
export function ListRowBasic(p) { ... }
ListRowBasic.displayName = 'ListRowBasic';   // 제거된다

문제는 memoforwardRef처럼 외부 호출의 결과에 할당할 때다.

ts
import { memo } from 'react';
export const ListRowBasic = memo(function B(p) { ... });
ListRowBasic.displayName = 'ListRowBasic';   // 제거되지 않는다

reactexternal이면 번들러는 memo()가 무슨 일을 하는지 알 수 없다. 호출을 지우지 못하고, 결과적으로 사용하지 않는 Recipe가 전부 남는다.

여기서 /*#__PURE__*/를 떠올리게 된다. 이 호출의 반환값이 쓰이지 않을 때 호출 자체도 제거해도 된다는 힌트를 번들러에 전달하는 주석이라, 호출식 앞에 붙일 수 있으니 memo(...)에 정확히 적용된다. 그런데 붙여도 제거되지 않았다.

ts
const ListRowBasic = /*#__PURE__*/ memo(function B(p) { ... });
ListRowBasic.displayName = 'ListRowBasic';   // 이 줄이 변수를 붙잡는다

/*#__PURE__*/는 호출을 제거 가능하다고 표시한다. 그런데 바로 다음 줄의 할당문이 그 변수를 참조하고 있어서 변수를 지울 수 없다. 주석을 붙일 자리가 없어서가 아니라 붙여도 무력화되는 것이다.

둘을 함께 해야 떨어졌다.

ts
export const ListRowBasic = /*#__PURE__*/ memo(
  function ListRowBasic(p) { ... },
);

내부 함수에 이름을 주면 React DevTools가 그 이름을 사용하므로 displayName 할당 자체가 필요 없어진다. /*#__PURE__*/로 호출을 표시하고 할당을 없애는 두 가지를 함께 했을 때만 사용하지 않는 Recipe가 완전히 사라졌다.

모듈을 잘게 나누는 것만으로는 해결되지 않았다.

배포 형태 결과
한 모듈에 순수 함수 세 개 나머지 둘 제거됨
한 모듈에 memo + displayName 세 개 전부 남음
모듈 세 개로 나눈 memo + displayName 전부 남음

떨어지느냐를 가른 것은 모듈의 개수가 아니라 최상위에 부수효과로 보이는 문장이 있느냐였다. 한 파일에 있어도 순수하면 떨어지고, 파일을 나눠도 부수효과가 있으면 남는다.

그렇다면 모듈 구조를 유지한 채 배포할 이유는 무엇인가. JavaScript를 줄이기 위해서가 아니었다. sideEffects 판단의 단위가 모듈이라는 점, 그리고 Recipe별 CSS 진입점을 만들 수 있다는 점이었다. 두 번째가 다음 절의 주제다.

JavaScript는 심볼 단위로, CSS는 파일 단위로 줄어든다

당시 디자인 시스템은 React와 Tailwind CSS를 사용했고 v4 마이그레이션도 계획돼 있었다.

패키지는 컴파일된 CSS를 함께 배포했다. 소비 앱이 자기 Tailwind 설정으로 패키지 소스를 스캔하는 방식이 아니었다. 이 선택으로 얻은 것부터 적으면 이렇다.

  • 소비 앱의 Tailwind 버전과 theme 설정에 의존하지 않는다.
  • v3에서 v4로 넘어가는 작업이 패키지 안에서 끝난다. 소비 앱은 CSS 파일을 그대로 가져다 쓴다.
  • 앱마다 스캔 경로 설정이 누락돼 스타일이 통째로 빠지는 사고가 없다.

대신 새로운 문제가 생겼다.

import { ListRowMax } from '@design-system/list'
JavaScript
소비 앱의 번들러가 고른다
ListRowBasic제거
ListRowRegular제거
ListRowMax남음
줄어드는 단위 · 심볼 단위
배포된 CSS
패키지 빌드 시점에 확정된다
.listRowBasic남음
.listRowRegular남음
.listRowMax남음
줄어드는 단위 · 파일 단위
왼쪽은 소비 앱에서 갈리고 오른쪽은 패키지 빌드에서 이미 확정돼 있다. 같은 한 줄인데 줄어드는 단위가 다르다

ListRowMax만 import하면 나머지 Recipe의 JavaScript는 번들에서 빠진다. 이건 루트 진입점으로 가져와도 마찬가지였다. 굳이 @design-system/list/row/max로 쓰지 않아도 JavaScript는 줄어든다.

CSS는 다르다. 파일 하나를 import하면 세 Recipe의 규칙이 모두 들어온다. 여기에는 번들러의 미사용 코드 제거가 개입하지 않는다.

그래서 exports가 두 번째 일을 하게 된다. 경계를 정하려고 만든 subpath 맵이 CSS를 나눠 담는 진입점이기도 했다.

json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./row/max": {
      "types": "./dist/row/max/index.d.ts",
      "import": "./dist/row/max/index.js"
    },
    "./row/max/styles.css": "./dist/row/max/styles.css",
    "./styles.css": "./dist/styles.css"
  }
}

앞에서 보여준 맵에는 ./styles.css 하나뿐이었다. 그 상태가 곧 문제 상태다. JavaScript만 놓고 보면 아쉬울 것이 없어 보이지만, CSS는 Recipe를 하나만 써도 전부 들어온다.

여기에 실제로 걸리는 실수가 하나 더 있다. "sideEffects": false로 선언하면 번들러가 CSS import를 부수효과 없는 문장으로 보고 지운다. JavaScript는 남고 스타일만 사라진다.

json
{
  "sideEffects": ["**/*.css"]
}

컴파일된 CSS를 배포하려면 토큰 오버라이드 방식, Cascade Layer 순서, 패키지 고유 클래스의 이름 충돌도 별도 계약으로 정해야 했다. 다만 이 글에서는 CSS를 어떤 단위로 가져오는지에만 집중한다.

최종적으로 소비 앱이 쓰는 계약은 다음과 같다.

ts
import { ListRowMax } from '@design-system/list';
import '@design-system/list/row/max/styles.css';

JavaScript는 루트 진입점에서도 심볼 단위로 제거할 수 있지만, CSS를 Recipe 단위로 줄이려면 Recipe별 스타일 진입점을 명시적으로 가져와야 했다.

무엇을 확인했고 무엇을 확인하지 못했는가

API 문법보다 실제 패키지 산출물과 소비 환경이 중요했다. 최적화 결과를 주장하려면 디자인 시스템 패키지를 npm pack으로 묶은 뒤, 제품과 같은 프로덕션 설정의 검증용 앱에 설치해 다음을 확인해야 한다.

  • 모듈 경계: ListRowMax만 가져왔을 때 공개하지 않은 subpath가 해석되지 않는가
  • JavaScript: 다른 List Recipe와 중복된 React가 모듈 그래프에 포함되지 않는가
  • CSS: 필요한 스타일은 남고, 생성된 JavaScript와 CSS를 비교했을 때 사용하지 않은 Recipe의 스타일은 분리되는가

이 확인을 사람 손에 맡기면 다음 릴리스에서 다시 깨진다. 번들 크기는 size-limit, 패키지 메타데이터와 타입 해석은 publint@arethetypeswrong/cli, 실제 포함 모듈은 번들 분석기로 확인하는 단계까지 파이프라인에 넣어야 구조가 유지된다.

이 글의 수치는 전부 rollup 단일 환경에서 재현한 것이다. webpack처럼 sideEffects를 다르게 다루는 번들러에서는 결과가 달라질 수 있다. 따라서 이 글은 실제 제품 번들이 몇 KB 줄었다고 주장하지 않는다. 확인한 것은 rollup 환경에서 각 공개 형태와 빌드 출력이 미사용 코드 제거에 어떤 차이를 만드는지다. 실제 제품 환경의 결과는 배포 tarball을 소비 앱에 설치해 별도로 검증해야 한다.

같은 필드가 두 개의 문을 맡고 있었다

이 시스템을 설계하며 세운 질문은 누가 새로운 UI 조합을 만들 수 있어야 하는가였다.

그 답을 타입으로 옮겼고, DOM으로 옮겼고, 목록 정책으로 옮겼고, 플랫폼마다 다른 실현으로 옮겼고, 이번에는 패키지 구조로 옮겼다. 매번 같은 일이 반복됐다. 한 층에서 막아도 제품 코드가 갈 수 있는 다른 경로가 남아 있었다.

타입은 조합을 막았지만 접근을 막지 않았다. exports는 접근을 막았지만 모노레포의 paths 별칭 앞에서는 개입하지 않았다. 그래서 ESLint 규칙이 한 겹 더 필요했다.

승인된 조합만 받는 API는 문 하나가 아니라 문이 몇 개인지 아는 일에 가까웠다.

그리고 이 편을 시작할 때 던진 두 질문, 내부 Primitive의 접근을 막을 수 있는가와 공개했지만 사용하지 않은 Recipe가 실제로 사라지는가는 서로 다른 문제처럼 보였다.

package.json exports
".""./row/max""./row/max/styles.css""./styles.css"
첫 번째 문
무엇이 해석되는가
목록에 없는 경로
"./internal"
접근이 막힌다
두 번째 문
무엇을 따로 가져오는가
목록에 있는 경로
"./row/max/styles.css"
CSS가 나뉜다
목록에 없는 경로가 접근을 막고, 목록에 있는 경로가 CSS를 나눈다. 두 일이 같은 맵에서 일어난다

두 문제의 답이 모두 exports 하나에 있었던 것은 아니다. 공개 경로는 exports와 ESLint 규칙이 지켰고, 사용하지 않은 JavaScript는 빌드 출력과 번들러가 제거했다. CSS는 Recipe별 subpath를 만들어야 필요한 파일만 가져올 수 있었다.

다만 이 서로 다른 규칙이 만나는 자리는 같았다. exports에 어떤 진입점을 적느냐가 제품 코드에 허용할 import 경로와 CSS를 나누어 가져올 단위를 함께 결정했다.

패키지에서 경계를 그리는 일은 공개 API만 정하는 일이 아니었다. 어떤 경로를 열고, 어떤 단위로 배송할지까지 함께 정하는 일이었다.