hyunsu blog

홈동향

사내 패키지 ESM-only 전환, 토스뱅크가 CJS 이중 배포를 접은 이유

목차 · 8
  1. 01두 벌을 낸 이유는 require() 가 ESM 을 못 읽어서였다
  2. 02버전이 하나여도 Context 는 둘로 갈린다
  3. 03타입은 require 갈래에 선언 파일이 없을 때 any 로 샌다
  4. 04Node.js 22.12부터 require() 가 ESM 을 기본으로 읽는다
  5. 05exports 를 한 갈래로 줄이면 import 와 require 가 같은 파일에 닿는다
  6. 06패키지는 문법을 낮추지 않고, 변환과 번들링은 쓰는 쪽이 맡는다
  7. 07사내 패키지라면 런타임 버전부터 확인하고 바꿀 만하다
  8. 08자주 묻는 질문

토스뱅크는 사내 공용 패키지를 ESM 과 CommonJS(CJS) 두 형식으로 내던 방식을 접고 ESM 만 배포하기로 했다. 두 형식을 같이 내면 설치된 버전이 하나여도 React Context 가 두 벌 생기고, require 쪽 갈래에 선언 파일이 비면 props 타입이 소리 없이 any 가 된다. Node.js 22.12부터 require() 가 플래그 없이 ESM 을 읽게 됐고 패키지를 쓰는 곳을 회사가 직접 정할 수 있었기에, 형식을 하나로 줄일 수 있었다.

01 두 벌을 낸 이유는 require() 가 ESM 을 못 읽어서였다

ESM(ECMAScript Modules)은 import·export 로 쓰는 JavaScript 표준 모듈 체계다. CJS 는 Node.js 가 오래 써 온 require()·module.exports 방식이다. 예전 Node.js 에서는 CJS 코드가 require() 로 ESM 파일을 불러올 길이 없었다. 그래서 같은 소스를 두 번 빌드하고, package.json 의 exports 에서 불러오는 방식마다 다른 파일을 걸어 두는 듀얼 패키지(dual package)가 관례가 됐다.

json
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Node.js 문서는 import 조건과 require 조건이 서로 배타적이라고 정의한다. import 나 import() 로 들어오면 .mjs 가, require() 로 들어오면 .cjs 가 열린다. 토스뱅크도 두 형식을 붙들고 있던 가장 큰 이유로 이 require() 제약을 꼽았다. 토스 기술 블로그는 ESM-only 결정을 2026년 10월 15일 연재 1편에, 그 뒤의 빌드 기준을 이튿날 2편에 실었다.

02 버전이 하나여도 Context 는 둘로 갈린다

듀얼 패키지의 첫 문제는 상태가 나뉘는 것이다. 흔히 dual package hazard 라고 부른다. 서비스 코드는 import 로 패키지를 읽어 ESM 빌드를 쓰고, 그 서비스가 쓰는 다른 패키지는 같은 패키지를 require 로 읽어 CJS 빌드를 쓴다고 하자. 두 파일이 따로 실행되니 createContext() 도 두 번 불린다.

text
서비스 코드  ─ import  → dist/index.mjs → createContext() → Context A
다른 패키지  ─ require → dist/index.cjs → createContext() → Context B

서비스의 <Provider> 는 A 에 값을 넣는다
다른 패키지의 useContext 는 B 를 읽는다 → 값을 못 찾는다

lockfile 에 버전이 하나만 있어도 이 일이 생긴다. 원인이 중복 설치가 아니라 한 버전 안의 진입 파일 두 개라서, 버전을 맞추는 것으로는 풀리지 않는다. 토스뱅크는 Context 말고도 모듈 단위 캐시나 내부 상태가 같은 식으로 갈릴 수 있다고 짚었다.

03 타입은 require 갈래에 선언 파일이 없을 때 any 로 샌다

두 번째 문제는 타입이다. 토스뱅크 글의 예에서 UI 패키지는 외부 라이브러리의 ExternalProps 를 가져와 버튼 props 를 만든다.

ts
import type { ExternalProps } from 'external-library';

type ButtonProps = {
  loading?: boolean;
} & ExternalProps;

TypeScript 도 exports 를 따라 선언 파일을 찾는다. 공식 문서에 따르면 types 와 default 조건은 언제나 맞춰 보고, import·require 가운데 어느 갈래를 탈지는 moduleResolution 모드와 구문 모양이 정한다. 갈래가 정해지면 그 갈래의 실행 파일 확장자에 맞춰 선언 파일을 고른다.

갈래가 가리키는 실행 파일TypeScript 가 먼저 찾는 파일
.mjs.mts → .d.mts
.cjs.cts → .d.cts
.js.ts → .tsx → .d.ts

require 갈래로 갔는데 types 도 .d.cts 도 없으면, TypeScript 는 JS 파일을 타입 없이 받아들이고 그 import 는 any 가 된다. 토스뱅크 예에서는 ExternalProps 가 any 가 되면서 ButtonProps 전체가 any 로 무너졌고, 잘못된 props 를 넘겨도 타입 에러가 나지 않았다. 공식 문서는 noImplicitAny 를 켜 두면 이 지점에서 오류가 난다고 적는다. 하지만 갈래마다 선언 파일을 빠짐없이 맞춰 두는 일은 결국 패키지를 만드는 쪽에 남는다.

세 번째 비용은 사람이 치른다. 만드는 쪽은 의존성 하나만 올려도 ESM 빌드와 CJS 빌드, 각각의 선언 파일이 제대로 이어지는지 두 번씩 봐야 한다. 쓰는 쪽은 문제가 나면 어느 빌드가 로드됐고 어떤 선언을 읽었는지부터 거슬러 올라간다. 소스는 하나인데 실제로 도는 파일은 둘일 수 있으니 추적이 길어진다.

04 Node.js 22.12부터 require() 가 ESM 을 기본으로 읽는다

Node.js 문서의 변경 이력을 보면 require() 로 ESM 을 읽는 기능은 몇 단계에 걸쳐 기본값이 됐다.

Node.js 버전바뀐 것
22.0, 20.17기능 추가(--experimental-require-module 플래그 필요)
23.0, 22.12, 20.19플래그 없이 동작
23.5, 22.13, 20.19실험 경고를 기본으로 띄우지 않음
25.4실험 기능에서 벗어남

조건은 있다. 불러오는 ESM 과 그것이 가져오는 모듈 그래프 어디에도 최상위 await 가 없어야 한다. 하나라도 있으면 ERR_REQUIRE_ASYNC_MODULE 오류가 나고, 그 자리는 import() 로 바꿔야 한다. 또 require() 가 돌려주는 값은 모듈 네임스페이스 객체라서 default export 는 .default 에 들어 있다. CJS 빌드가 module.exports 에 기본값을 바로 실어 주던 패키지였다면, 그 값을 받던 코드는 손봐야 하는 셈이다. ESM 쪽에서 'module.exports' 라는 문자열 이름으로 export 하면 그 값을 그대로 돌려주는 길도 문서에 있다.

2026년 10월 Node.js 공식 릴리스 표에서 20은 지원이 끝났고, 22와 24가 LTS, 26이 Current 다. 지원 중인 줄기는 모두 이 기능이 기본으로 켜진 버전을 내고 있으니, 22 줄기만 22.12 이상인지 보면 된다.

플랫폼 조건 말고도 하나가 더 맞아야 했다. 공개 라이브러리는 구버전 Node.js 를 포함해 모르는 환경까지 받아야 하니 두 형식을 계속 내는 경우가 많다. 토스뱅크 패키지는 사내에서만 쓰이고, 어디서 쓰는지 알고 있으며 지원 환경도 직접 맞출 수 있다. 이렇게 사용처가 예측되는 곳이면 형식 하나로 충분하다고 봤고, 그 하나로는 표준이면서 트리 셰이킹에 유리한 ESM 을 골랐다. 전환은 도구 버전과 설정을 맞춘 뒤 패키지를 불러오는 코드와 타입 검사를 다시 확인하는 순서로 했다. 도구를 평소 최신으로 유지해 둔 덕에 수월했다는 것이 토스뱅크의 설명이다.

05 exports 를 한 갈래로 줄이면 import 와 require 가 같은 파일에 닿는다

토스뱅크는 전환 뒤의 package.json 을 공개하지 않았다. 아래는 Node.js·TypeScript 문서의 규칙대로 짠 전후 비교다. 전환 전에는 갈래마다 실행 파일과 선언 파일을 따로 둔다.

json
{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

ESM-only 로 바꾸면 갈래가 사라진다.

json
{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

"type": "module" 이 있으면 .js 파일은 ESM 으로 읽히고, Node.js 문서상 require() 도 이런 파일을 불러올 수 있다. default 는 Node.js 와 TypeScript 양쪽에서 늘 맞는 조건이라 import 든 require 든 같은 파일에 도착한다. 실행 파일과 선언 파일이 한 벌뿐이니 Context 가 갈릴 자리도, 한쪽 갈래만 타입이 빠질 자리도 없다. 조건은 적힌 순서대로 처음 맞는 것이 이기므로, Node.js 문서는 default 를 마지막에 두라고 하고 TypeScript 문서 예제는 types 를 첫 키에 둔다.

06 패키지는 문법을 낮추지 않고, 변환과 번들링은 쓰는 쪽이 맡는다

2편은 ESM-only 다음 단계로 패키지가 지킬 기준 세 가지를 내놓는다. 토스뱅크는 패키지를 가져다 쓰는 서비스를 호스트라고 부른다. 역할은 이렇게 나눴다.

맡는 쪽하는 일
패키지 빌드TypeScript 를 JavaScript 로만 바꾼다, 원본 문법과 파일 구조를 그대로 둔다, 선언 소스맵을 낸다
호스트자기 실행 환경에 맞춰 문법을 낮춘다, 파일별 지시문과 모듈 경계를 해석한다, 최종 번들을 만든다

안정성: target 을 낮춰도 도구가 못 낮추는 문법은 남는다

패키지는 호스트가 지원하는 환경에서 돌아야 한다. 토스뱅크는 호스트가 지원하는 API 범위를 타입 검사에 넣었지만, TypeScript 설정만으로 호환성을 다 잡을 수는 없어서 실제 API 지원과 문법 변환 가능 여부를 따로 확인한다. 2편이 든 예는 rolldown 이다. 당시 rolldown 이 쓰는 oxc 는 ES2025 정규식 문법 일부를 구형 환경용으로 낮추지 못했다. 호스트가 target 을 낮게 잡아도 도구가 모르는 변환은 일어나지 않는다는 뜻이다. Proxy 처럼 엔진이 지원하지 않으면 폴리필로 메울 수 없는 API 도 있다.

예측 가능성: 미리 묶으면 'use client' 경계가 무너진다

패키지 빌드에서 문법을 미리 낮추면 Optional Chaining 을 지원하는 호스트도 user?.name 대신 null·void 0 비교로 풀린 코드를 받는다. 보조 함수가 끼어들고, 원본과 배포본의 거리도 벌어진다. 그래서 문법 변환은 호스트 몫으로 넘겼다.

파일 구조도 손대지 않는다. React 문서에 따르면 'use client' 는 파일 단위로 서버와 클라이언트의 경계를 긋는다. 서버에서 평가되는 모듈이 'use client' 모듈에서 가져온 값은 컴포넌트이거나 직렬화할 수 있는 prop 값이어야 하고, 다른 쓰임은 예외를 던진다. 패키지를 한 파일로 묶어 맨 위에 지시문을 붙이면, 원래 따로 있던 순수 함수까지 클라이언트 쪽으로 넘어가 Server Component 에서 부를 수 없게 된다. 파일이 나뉘어 있으면 지시문이 붙은 파일만 경계가 된다.

디버깅 편의성: 선언 소스맵으로 구현까지 따라간다

타입 선언과 문서만으로는 어떤 조건에서 오류가 나는지, 옵션을 빼면 어떻게 동작하는지 알기 어렵다. 토스뱅크는 .d.ts 의 선언을 원본 소스 위치에 잇는 declaration sourcemap 을 패키지에 넣고 원본 파일도 함께 실었다. 그러면 호스트의 IDE 에서 정의로 이동했을 때 .d.ts 가 아니라 실제 구현이 열린다. 2편은 설정 이름을 밝히지 않았는데, TypeScript 에서는 declarationMap 옵션(2.9부터)이 이 .d.ts.map 을 만든다.

json
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true
  }
}

소스맵이 가리킬 원본도 설치본에 있어야 한다. npm 문서상 package.json 의 files 를 적으면 거기 든 것만 배포 tarball 에 들어가니, 원본 디렉터리를 빼먹지 않았는지 본다. 원본이 src 에 있다면 "files": ["dist", "src"] 처럼 둔다.

07 사내 패키지라면 런타임 버전부터 확인하고 바꿀 만하다

이 사례가 가장 가깝게 닿는 곳은 사설 레지스트리나 모노레포 워크스페이스로 공용 패키지·디자인 시스템을 내는 국내 프론트엔드 팀이다. 특히 Context 기반 Provider 를 가진 디자인 시스템이나 설정·인증 SDK 처럼 모듈이 상태를 쥔 패키지는 dual package hazard 가 가장 아프게 드러나는 자리라 먼저 볼 만하다. Next.js App Router 로 서비스를 만드는 팀이라면 2편의 '미리 묶지 않는다' 기준도 같이 걸린다.

전환 전에 확인할 것은 아래 순서면 된다.

  1. 패키지를 쓰는 서버·CI·빌드 이미지의 Node.js 가 22.12 이상인지 본다. 20 줄기도 20.19부터 되지만 공식 표에서 이미 지원이 끝났다.
  2. 패키지와 그 의존 모듈에 최상위 await 가 없는지 본다.
  3. require() 로 패키지를 읽는 사용처가 default export 를 어떻게 받는지 본다(.default).
  4. exports 를 types 먼저, default 마지막으로 한 갈래만 남기고 "type": "module" 을 넣는다.
  5. 2편 기준까지 가져온다면 패키지 빌드에서 문법을 낮추거나 한 파일로 묶는 단계를 걷어 내고, declarationMap 과 원본 파일을 함께 싣는다.

모두에게 맞는 결론은 아니다. 공개 라이브러리로 22.12 미만 Node.js 나 사용처를 모르는 환경까지 받아야 한다면 이중 배포가 여전히 필요할 수 있고, 토스뱅크도 자기 판단을 사용처를 통제할 수 있는 경우로 한정했다. 그때는 갈래마다 .d.mts·.d.cts 를 따로 내 타입 누락부터 막고, 상태를 쥔 모듈은 두 벌이 동시에 실릴 수 있다는 점을 염두에 둔다.

패키지를 만들지 않고 가져다 쓰기만 하는 서비스 팀은 지금 바꿀 것이 없다. 다만 Provider 로 값을 넣었는데 다른 패키지의 Hook 이 기본값만 돌려준다면, 같은 패키지의 ESM 빌드와 CJS 빌드가 함께 실려 있는지부터 확인해 볼 만하다.

08 자주 묻는 질문

공개 npm 라이브러리도 ESM-only 로 바꿔도 되나?

지원 범위에 달렸다. 22.12 미만 Node.js 나 사용처를 모르는 환경까지 받아야 한다면 이중 배포가 여전히 필요할 수 있고, 토스뱅크도 판단을 사내 패키지라는 조건에 묶었다.

CJS 코드에서 ESM-only 패키지를 require 하면 default export 는 어디 있나?

.default 속성에 있다. require() 가 모듈 네임스페이스 객체를 돌려주기 때문이고, 패키지가 'module.exports' 이름으로 export 하면 그 값을 바로 받는다.

패키지에 최상위 await 가 있으면 어떻게 되나?

require() 로 불러올 때 ERR_REQUIRE_ASYNC_MODULE 오류가 난다. 그 사용처는 import() 로 바꿔야 한다.

이중 배포를 유지하면서 타입 누락만 막을 수 있나?

예. import·require 갈래마다 types 를 두고 .d.mts·.d.cts 를 각각 내면 된다. 다만 Context 가 두 벌 생기는 상태 분리는 이것으로 풀리지 않는다.