
토스뱅크가 패키지를 관리하는 방식 #1: 패키지를 ESM으로만 제공하기로 했어요
안녕하세요. 토스뱅크에서 패키지 개발 환경을 개선하고 있는 기술그룹입니다.
토스뱅크에서는 여러 서비스가 함께 쓰는 코드를 패키지로 관리해요. 그동안 ECMAScript Modules(이하 ESM)와 CommonJS(이하 CJS) 두 형식으로 빌드하고, package.json의 exports로 각 환경에 맞는 코드와 타입을 제공해왔어요. 이러한 Dual package 방식의 배경과 설정은 CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field에서 자세히 볼 수 있어요.
그런데 Dual package 방식은 두 형식으로 빌드하는 것 외에도 신경 쓸 일이 많았어요. 같은 패키지의 상태가 둘로 나뉘거나, 한쪽 경로에서 타입을 찾지 못할 수 있었거든요. 어떤 문제인지부터 살펴볼게요.
같은 패키지인데 상태가 둘로 나뉘어요
React 컨텍스트를 제공하는 패키지를 ESM과 CJS로 각각 빌드했다고 해볼게요. 서비스는 ESM 구현을, 서비스가 사용하는 다른 패키지는 CJS 구현을 읽는 상황이에요.
두 구현에서 각각 컨텍스트를 만들면 객체도 둘이 돼요. 서비스의 Provider는 컨텍스트 ①에 값을 넣는데, 다른 패키지의 Hook은 컨텍스트 ②에서 값을 찾을 수 있어요. Provider로 감쌌는데도 값을 읽지 못하는 거예요.

같은 패키지 이름으로 불렀는데 왜 이런 일이 생길까요? exports에서 불러오는 방식에 따라 서로 다른 파일을 연결했기 때문이에요.
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}이 구성에서는 import와 require()가 서로 다른 구현을 읽어요. 두 모듈이 각각 실행되면서 만든 객체는 내용이 같아도 다른 객체예요.
패키지를 한 버전만 설치했어도 생길 수 있는 일이에요. 이렇게 두 모듈이 함께 로드되면서 공유해야 할 객체나 상태가 나뉘는 위험을 Dual package hazard라고 해요. 컨텍스트뿐 아니라 모듈이 보관하는 캐시나 상태도 영향을 받을 수 있어요.
실행 코드는 찾았는데 타입은 찾지 못해요
타입도 두 경로에서 확인해야 해요. 우리 패키지의 타입이 외부 라이브러리를 참조한다면, 그 라이브러리의 타입까지 올바르게 연결돼야 하고요.
예를 들어 UI 패키지가 외부 라이브러리의 ExternalProps를 가져와 버튼의 props를 정의한다고 해볼게요.
import type { ExternalProps } from 'external-library';
type ButtonProps = {
loading?: boolean;
} & ExternalProps;TypeScript는 외부 라이브러리의 package.json에 명시된 exports를 따라 타입을 찾아요. 이때 TypeScript 설정에 따라 import(ESM) 또는 require(CJS) 경로를 선택할 수 있어요.
CJS 경로를 선택했는데 타입 파일이 명시돼 있지 않고, 대응하는 파일도 없다면 타입을 찾지 못할 수 있어요.

타입을 찾지 못해 ExternalProps가 any로 처리되면, ButtonProps도 any가 돼요. 사용처에서 잘못된 props를 넘겨도 타입 에러가 나지 않아 문제를 놓칠 수 있어요. (TypeScript 문서)
개발과 디버깅이 복잡해져요
만드는 쪽에서는 패키지나 의존성이 바뀔 때마다 두 경로의 실행 코드와 타입 연결을 확인해야 해요.
사용하는 쪽에서도 문제가 생기면 어느 구현이 로드됐고 어떤 타입을 읽었는지부터 추적해야 해요. 코드가 같아 보여도 실행 경로가 달라질 수 있어 디버깅이 복잡해져요.
패키지를 만들고 쓰는 일을 단순하게 하려면, 두 형식을 계속 제공해야 하는지부터 살펴봐야 했어요.
ESM-only를 선택했어요
두 형식을 제공했던 가장 큰 이유는 CJS 사용처를 지원하기 위해서였어요. 과거 Node.js에서는 CJS의 require()로 ESM을 불러올 수 없었거든요.
그런데 Node.js 22.12.0부터는 별도 플래그 없이 require(esm)을 지원해요. CJS 코드에서도 ESM을 불러올 수 있게 된 거예요. 이때 불러오는 ESM과 의존 모듈에는 최상위 await가 없어야 해요.
Node.js의 변화에 맞춰 JavaScript 생태계에서도 ESM-only를 지원하는 흐름이 이어지고 있었어요.
그런데도 왜 여전히 dual package로 제공하는 라이브러리가 많을까요? 공개 라이브러리는 구버전 Node.js를 포함한 다양한 환경에서 사용돼요. 이런 사용처까지 지원하려면 ESM과 CJS를 함께 제공해야 할 수 있어요.
저희 패키지는 사내에서만 사용하기 때문에 사용처를 파악하고 지원 환경도 함께 조정할 수 있었어요. 이처럼 예측 가능한 환경에서는 하나의 형식만 제공해도 충분하다고 판단했어요.
ESM을 고른 가장 큰 이유는 JavaScript의 표준 모듈 시스템이기 때문이에요. 트리 셰이킹에도 유리하고요.
저희는 도구 버전과 설정을 맞추고, 패키지를 불러오는 코드와 타입 검사를 확인하며 ESM-only로 전환했어요. 평소 도구를 꾸준히 최신화해온 것이 도움이 됐어요.

마치며
ESM-only 전환으로 패키지를 만드는 쪽은 두 구현의 코드와 타입을 함께 관리할 부담을 덜었어요. 사용하는 쪽에서도 어느 구현이 로드됐는지 추적할 일이 줄었고요.
저희 기술그룹은 ESM-only 전환을 포함해 패키지를 더 단순하고 안정적으로 개발하기 위한 기준을 정리했어요. 다음 글에서는 이 기준을 어떻게 정했는지, 타입과 원본 소스를 어떻게 제공하고 빌드를 구성했는지 이어서 소개할게요.
