토스뱅크가 패키지를 관리하는 방식 #1: 패키지를 ESM으로만 제공하기로 했어요
요약
토스뱅크가 내부 기술 그룹을 통해 패키지 관리 환경 개선 방안을 제시했습니다. 기존의 ESM과 CJS를 모두 지원하는 Dual package 방식은 상태 분리, 타입 불일치 등 여러 문제를 야기합니다. 이에 따라 앞으로는 ESM만을 제공하는 단일화된 방식으로 전환할 계획입니다.
핵심 포인트
- Dual package 방식은 상태와 객체를 둘로 나누어 공유 오류를 발생시킵니다.
- CJS 경로 선택 시 타입 파일 누락으로 인해 TypeScript가 타입을 찾지 못할 수 있습니다.
- 패키지 관리의 복잡성을 줄이고 안정적인 개발 환경을 구축하는 것이 목표입니다.
안녕하세요. 토스뱅크에서 패키지 개발 환경을 개선하고 있는 기술그룹입니다.
토스뱅크에서는 여러 서비스가 함께 쓰는 코드를 패키지로 관리해요. 그동안 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에서 불러오는 방식에 따라 서로 다른 파일을 연결했기 때문이에요.
이 구성에서는 import와 require()가 서로 다른 구현을 읽어요. 두 모듈이 각각 실행되면서 만든 객체는 내용이 같아도 다른 객체예요.
패키지를 한 버전만 설치했어도 생길 수 있는 일이에요. 이렇게 두 모듈이 함께 로드되면서 공유해야 할 객체나 상태가 나뉘는 위험을 Dual package hazard라고 해요. 컨텍스트뿐 아니라 모듈이 보관하는 캐시나 상태도 영향을 받을 수 있어요.
실행 코드는 찾았는데 타입은 찾지 못해요
타입도 두 경로에서 확인해야 해요. 우리 패키지의 타입이 외부 라이브러리를 참조한다면, 그 라이브러리의 타입까지 올바르게 연결돼야 하고요.
예를 들어 UI 패키지가 외부 라이브러리의 ExternalProps를 가져와 버튼의 props를 정의한다고 해볼게요.
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)](https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require)을 지원해요. CJS 코드에서도 ESM을 불러올 수 있게 된 거예요. 이때 불러오는 ESM과 의존 모듈에는 최상위 await가 없어야 해요.
Node.js의 변화에 맞춰 JavaScript 생태계에서도 ESM-only를 지원하는 흐름이 이어지고 있었어요.
그런데도 왜 여전히 dual package로 제공하는 라이브러리가 많을까요? 공개 라이브러리는 구버전 Node.js를 포함한 다양한 환경에서 사용돼요. 이런 사용처까지 지원하려면 ESM과 CJS를 함께 제공해야 할 수 있어요.
저희 패키지는 사내에서만 사용하기 때문에 사용처를 파악하고 지원 환경도 함께 조정할 수 있었어요. 이처럼 예측 가능한 환경에서는 하나의 형식만 제공해도 충분하다고 판단했어요.
ESM을 고른 가장 큰 이유는 JavaScript의 표준 모듈 시스템이기 때문이에요. 트리 셰이킹에도 유리하고요.
저희는 도구 버전과 설정을 맞추고, 패키지를 불러오는 코드와 타입 검사를 확인하며 ESM-only로 전환했어요. 평소 도구를 꾸준히 최신화해온 것이 도움이 됐어요.

마치며
ESM-only 전환으로 패키지를 만드는 쪽은 두 구현의 코드와 타입을 함께 관리할 부담을 덜었어요. 사용하는 쪽에서도 어느 구현이 로드됐는지 추적할 일이 줄었고요.
저희 기술그룹은 ESM-only 전환을 포함해 패키지를 더 단순하고 안정적으로 개발하기 위한 기준을 정리했어요. 다음 글에서는 이 기준을 어떻게 정했는지, 타입과 원본 소스를 어떻게 제공하고 빌드를 구성했는지 이어서 소개할게요.
AI 자동 생성 콘텐츠
본 콘텐츠는 RSS: Toss Tech Blog의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기