의존성 트리에서 반복적으로 나타나는 JavaScript 비대화의 세 가지 주요 원인과 그것이 생기는 이유, 그리고 이를 줄이기 위한 실질적인 방법을 살펴봅니다.
소프트웨어 엔지니어이자 오픈 소스 개발자.
2026년 3월 12일
지난 몇 년 동안 우리는 e18e 커뮤니티의 큰 성장과, 그에 따른 성능 중심 기여의 증가를 보아 왔습니다. 그중 큰 부분은 “cleanup” 이니셔티브로, 커뮤니티가 중복되었거나, 오래되었거나, 유지보수되지 않는 패키지들을 쳐내고 있습니다.
이 과정에서 가장 자주 나오는 주제 중 하나가 바로 “dependency bloat”입니다. 이는 npm 의존성 트리가 시간이 지날수록 더 커지고 있으며, 플랫폼이 이제는 기본 제공하는 기능을 위해 오래전에 중복이 된 코드가 여전히 많이 들어 있다는 생각입니다.
이 글에서는 제가 의존성 트리에서 비대화의 세 가지 주요 유형이라고 생각하는 것, 왜 그것들이 존재하는지, 그리고 우리가 어떻게 이를 해결하기 시작할 수 있는지를 간단히 살펴보려 합니다.

위 그래프는 많은 npm 의존성 트리에서 흔히 볼 수 있는 모습입니다. 기본으로 제공되어야 할 것처럼 보이는 무언가를 위한 작은 유틸리티 함수 하나가 있고, 그 뒤로 비슷하게 작은 깊은 의존성들이 많이 이어집니다.
그렇다면 왜 이런 일이 생길까요? 왜 우리는
is-string
대신
typeof
검사를 쓰지 않을까요? 왜 우리는
hasown
대신
Object.hasOwn
(또는
Object.prototype.hasOwnProperty
)를 쓰지 않을까요? 이유는 세 가지입니다.
세상 어딘가에는 ES3를 지원해야 하는 사람들이 분명 있는 듯합니다. IE6/7이나, 극초기 버전의 Node.js를 떠올려 보세요.1
이런 사람들에게는 오늘날 우리가 당연하게 여기는 많은 것들이 존재하지 않습니다. 예를 들어, 그들에게는 다음 중 어느 것도 없습니다.
Array.prototype.forEach
* ```plaintext
Array.prototype.reduce
Object.keys
* ```plaintext
Object.defineProperty
이것들은 모두 ES5 기능이므로, ES3 엔진에는 아예 존재하지 않습니다.
여전히 오래된 엔진을 실행하는 이 불운한 사람들은 모든 것을 직접 재구현하거나, polyfill을 제공받아야 합니다.
또는, 정말 좋을 대안은 그들이 업그레이드하는 것입니다.
이런 패키지들이 존재하는 두 번째 이유는 “안전성”입니다.
기본적으로 Node 내부에는 “primordials”라는 개념이 있습니다. 이것들은 본질적으로 시작 시점에 감싸 둔 전역 객체들이고, 이후에는 Node가 그것들을 가져다 씁니다. 목적은 누군가 전역 네임스페이스를 변경해서 Node 자체가 망가지는 일을 막는 것입니다.
예를 들어 Node 자체가
Map
을 사용하고 있는데 우리가
Map
이 무엇인지 다시 정의해 버리면, Node를 망가뜨릴 수 있습니다. 이를 피하기 위해 Node는 원래의
Map
에 대한 참조를 보관하고, 전역에 접근하는 대신 그것을 가져와 사용합니다.
이에 대해서는 Node 저장소의 여기에서 더 읽을 수 있습니다.
이것은 엔진 에는 매우 타당합니다. 스크립트가 전역 네임스페이스를 망쳐 놓았다고 해서 엔진이 그대로 쓰러져서는 안 되기 때문입니다.
일부 메인테이너들은 이것이 패키지 를 만드는 올바른 방식이라고도 믿습니다. 그래서 위 그래프에는
math-intrinsics
같은 의존성이 있는데, 이는 기본적으로 변경을 피하기 위해 여러
Math.*
함수를 다시 내보냅니다.
마지막으로 cross-realm 값이 있습니다. 이는 기본적으로 한 realm에서 다른 realm으로 전달한 값입니다. 예를 들어 웹 페이지에서 자식
<iframe>
으로 전달하거나, 그 반대의 경우입니다.
이 상황에서는 iframe 안의
new RegExp(pattern)
이 부모 페이지의
RegExp
클래스와 같지 않습니다. 즉
window.RegExp !== iframeWindow.RegExp
이고, 당연히 값이 iframe 같은 다른 realm에서 왔다면
val instanceof RegExp
는
false
가 됩니다.
예를 들어 저는 chai의 메인테이너인데, 우리에게는 정확히 이 문제가 있습니다. 우리는 assertion이 realm을 가로질러 일어나는 것을 지원해야 합니다. 테스트 러너가 VM이나 iframe 안에서 테스트를 실행할 수 있기 때문입니다. 그래서
instanceof
검사에 의존할 수 없습니다. 그 이유로 우리는 어떤 값이 정규식인지 확인하기 위해
Object.prototype.toString.call(val) === '[object RegExp]'
를 사용합니다. 이것은 생성자에 의존하지 않기 때문에 realm을 넘어도 동작합니다.
위 그래프에서
is-string
은 기본적으로 같은 일을 하고 있습니다. 한 realm에서 다른 realm으로
new String(val)
을 전달했을 경우를 대비하는 것입니다.
이 모든 것은 매우 작은 일부 사람들에게는 타당합니다. 아주 오래된 엔진을 지원하거나, realm 사이로 값을 전달하거나, 누군가 환경을 변경하는 것에서 보호받고 싶다면 이런 패키지들은 정확히 필요한 것들입니다.
문제는 우리 대다수는 이런 것들이 전혀 필요하지 않다는 점입니다. 우리는 지난 10년 이내의 Node 버전을 실행하거나, evergreen 브라우저를 사용합니다. 우리는 ES5 이전 환경을 지원할 필요도 없고, 프레임 사이로 값을 전달하지도 않으며, 환경을 망가뜨리는 패키지는 제거합니다.2
이런 틈새 호환성 계층이 어쩌다가 일상적인 패키지들의 “hot path” 안으로 들어와 버렸습니다. 실제로 이것이 필요한 아주 작은 집단이야말로 이런 목적의 특수 패키지를 찾아야 하는 쪽이어야 합니다. 그런데 현실은 반대로 되어 있고, 우리 모두가 그 비용을 치르고 있습니다.
어떤 사람들은 패키지가 거의 원자 수준까지 잘게 쪼개져야 하며, 나중에 다른 더 높은 수준의 것을 만들기 위해 재사용할 수 있는 작은 빌딩 블록들의 모음을 만들어야 한다고 믿습니다.
이런 종류의 아키텍처는 결국 다음과 같은 그래프를 만들어 냅니다.

보시다시피 가장 미세한 코드 조각들조차 각각 자기 패키지를 갖습니다. 예를 들어
shebang-regex
는 이 글을 쓰는 시점에서 다음과 같습니다.
const shebangRegex = /^#!(.*)/;
export default shebangRegex;
코드를 이 원자 수준까지 쪼개면, 이론상으로는 나중에 점들을 이어 붙이기만 해서 더 높은 수준의 패키지를 만들 수 있습니다.
세분화 정도를 감 잡을 수 있도록 이런 원자 패키지의 예를 몇 가지 들어 보겠습니다.
arrify
- 값을 배열로 변환 (
```plaintext
Array.isArray(val) ? val : [val]
)
slash
- 파일 시스템 경로의 백슬래시를
```plaintext
/
로 치환
cli-boxes
- 박스의 가장자리 정보를 담은 JSON 파일
* ```plaintext
path-key
PATH
환경 변수 키 가져오기 (Unix에서는
PATH
, Windows에서는
Path
)
onetime
- 함수가 한 번만 호출되도록 보장
* ```plaintext
is-wsl
process.platform
이
linux
이고
os.release()
에
microsoft
가 포함되는지 확인
is-windows
-
```plaintext
process.platform
이
win32
인지 확인
예를 들어 새로운 CLI를 만들고 싶다면 이런 것들 몇 개를 가져와 구현을 걱정하지 않을 수 있습니다.
env['PATH'] || env['Path']
를 직접 작성할 필요 없이, 그걸 위한 패키지를 가져오면 됩니다.
현실에서는 이런 패키지들의 대부분, 혹은 전부가 원래 의도했던 재사용 가능한 빌딩 블록이 되지 못했습니다. 더 큰 트리 안에서 여러 버전으로 상당 부분 중복되거나, 다른 패키지 하나만 사용하는 단일 용도 패키지인 경우가 많습니다.
가장 미세한 패키지 몇 개를 살펴봅시다.
shebang-regex
는 거의 전적으로 같은 메인테이너의
```plaintext
shebang-command
에 의해 사용됩니다
cli-boxes
는 거의 전적으로 같은 메인테이너의
```plaintext
boxen
과
ink
에 의해 사용됩니다
onetime
는 거의 전적으로 같은 메인테이너의
```plaintext
restore-cursor
에 의해 사용됩니다
이들 각각이 소비자 하나만 가진다는 것은, 사실상 인라인 코드와 다를 바 없지만 획득 비용은 더 많이 든다는 뜻입니다. npm 요청, tar 압축 해제, 대역폭 등이 그렇습니다.
nuxt의 의존성 트리를 보면 이런 빌딩 블록 중 몇 가지가 중복되어 있는 것을 볼 수 있습니다.
is-docker
(2개 버전)
* ```plaintext
is-stream
(2개 버전)
is-wsl
(2개 버전)
* ```plaintext
isexe
(2개 버전)
npm-run-path
(2개 버전)
* ```plaintext
path-key
(2개 버전)
path-scurry
(2개 버전)
이들을 인라인한다고 해서 코드 중복이 사라지는 것은 아니지만, 버전 해석, 충돌, 획득 비용 같은 것의 대가를 치르지 않게 되기는 합니다.
인라인은 중복을 거의 공짜로 만들고, 패키징은 그것을 비싸게 만듭니다.
### 더 커지는 공급망 표면적
패키지가 많아질수록 공급망 표면적은 더 커집니다. 모든 패키지는 유지보수, 보안 등에서 잠재적인 실패 지점입니다.
예를 들어 이런 패키지들 다수의 메인테이너가 지난해 계정 탈취를 당했습니다. 그 결과 수백 개의 작은 빌딩 블록이 손상되었고, 결국 우리가 실제로 설치하는 더 높은 수준의 패키지들도 함께 손상되었습니다.
```plaintext
Array.isArray(val) ? val : [val]
처럼 단순한 로직은 아마도 별도의 패키지, 보안 관리, 유지보수 등을 필요로 하지 않을 것입니다. 그냥 인라인하면 되고, 손상될 위험도 피할 수 있습니다.
첫 번째 기둥과 비슷하게, 이 철학 역시 “hot path” 안으로 들어와 버렸고 아마 그러지 말았어야 했습니다. 다시 말해, 실질적인 이익도 없이 우리 모두가 비용을 치르고 있습니다.
앱을 만들고 있다면, 선택한 엔진이 아직 지원하지 않는 어떤 “미래” 기능을 쓰고 싶을 수 있습니다. 이런 상황에서는 polyfill 이 유용할 수 있습니다. 기능이 있어야 할 자리에 대체 구현을 제공하므로, 마치 기본 지원되는 것처럼 사용할 수 있습니다.
예를 들어 temporal-polyfill은 새로운 Temporal API를 polyfill해서, 엔진이 지원하는지 여부와 관계없이
Temporal
을 사용할 수 있게 해 줍니다.
그렇다면 앱이 아니라 라이브러리를 만들고 있다면 어떻게 해야 할까요?
일반적으로 라이브러리는 polyfill을 로드해서는 안 됩니다. 그것은 소비자의 책임이며, 라이브러리가 주변 환경을 변경해서도 안 되기 때문입니다. 대안으로 일부 메인테이너는 ponyfill 이라고 불리는 것을 사용하기로 합니다. 이름도 유니콘, 반짝임, 무지개라는 주제를 그대로 따릅니다.
ponyfill은 기본적으로 환경을 변경하는 polyfill이 아니라 import해서 쓰는 polyfill입니다.
이 방식은 어느 정도 동작합니다. 라이브러리가 미래 기술을 사용할 때, 존재하면 네이티브 구현으로 통과시키고 그렇지 않으면 대체 구현을 쓰는 구현체를 import할 수 있기 때문입니다. 이 과정에서 환경은 전혀 변경되지 않으므로 라이브러리에서 사용하기에 안전합니다.
예를 들어 fastly는 @fastly/performance-observer-polyfill 을 제공하는데, 여기에는
PerformanceObserver
를 위한 polyfill과 ponyfill이 모두 들어 있습니다.
이런 ponyfill들은 당시에는 자기 역할을 했습니다. 환경을 변경하지 않으면서도, 그리고 소비자에게 어떤 polyfill을 설치해야 하는지 알도록 강요하지 않으면서도, 라이브러리 작성자가 미래 기술을 사용할 수 있게 해 주었기 때문입니다.
문제는 이 ponyfill들이 너무 오래 남아 버릴 때 생깁니다. 그것들이 메워 주던 기능이 이제 우리가 신경 쓰는 모든 엔진에서 지원될 때는 ponyfill을 제거해야 합니다. 하지만 실제로는 이런 일이 자주 일어나지 않고, 필요가 사라진 뒤에도 ponyfill이 계속 남아 있습니다.
그 결과 우리는 이제, 이미 10년 전부터 모두가 갖고 있는 기능을 위해 ponyfill에 의존하는 수많은 패키지를 떠안게 되었습니다.
예를 들면 다음과 같습니다.
globalthis
-
```plaintext
globalThis
를 위한 ponyfill (2019년에 널리 지원, 주간 4,900만 다운로드)
indexof
-
```plaintext
Array.prototype.indexOf
를 위한 ponyfill (2010년에 널리 지원, 주간 230만 다운로드)
object.entries
-
```plaintext
Object.entries
를 위한 ponyfill (2017년에 널리 지원, 주간 3,500만 다운로드)
이 패키지들이 기둥 1 때문에 유지되고 있는 것이 아니라면, 대개는 그냥 아무도 제거할 생각을 하지 않았기 때문에 아직도 사용되고 있을 뿐입니다.
엔진의 모든 장기 지원 버전이 해당 기능을 갖추게 되면, ponyfill은 제거되어야 합니다.4
이 비대화의 상당 부분은 오늘날 의존성 트리 깊숙이 박혀 있어서, 전부를 풀어내고 좋은 상태에 이르기까지는 꽤 큰 작업이 필요합니다. 시간도 걸리고, 메인테이너와 소비자 양쪽의 많은 노력도 필요할 것입니다.
그렇다고 해도, 모두가 함께 노력한다면 이 문제에서 상당한 진전을 이룰 수 있다고 저는 생각합니다.
스스로에게 “왜 내가 이 패키지를 갖고 있지?” 그리고 “정말로 이게 필요한가?”라고 묻기 시작해 보세요.
중복으로 보이는 것이 있다면, 메인테이너에게 제거할 수 있는지 묻는 이슈를 올리세요.
이런 문제를 많이 가진 직접 의존성을 만난다면, 그렇지 않은 대안을 찾아보세요. 시작점으로는 module-replacements 프로젝트가 좋습니다.
knip은 사용하지 않는 의존성, 죽은 코드 등 훨씬 더 많은 것을 찾아 제거하는 데 도움을 주는 훌륭한 프로젝트입니다. 이 맥락에서는, 더 이상 사용하지 않는 의존성을 찾아 제거하는 데 매우 좋은 도구가 될 수 있습니다.
이것이 위 문제들을 직접 해결해 주는 것은 아니지만, 더 본격적인 작업에 들어가기 전에 의존성 트리를 정리하는 좋은 출발점이 됩니다.
knip이 사용하지 않는 의존성을 어떻게 다루는지는 그들의 문서에서 더 읽어볼 수 있습니다.
e18e CLI에는 어떤 의존성이 더 이상 필요하지 않거나, 커뮤니티가 추천하는 대체제가 있는지를 판단해 주는 아주 유용한
analyze
모드가 있습니다.
예를 들어 다음과 같은 결과를 얻는다고 해 봅시다.
$ npx @e18e/cli analyze
...
│ Warnings:
│ • Module "chalk" can be replaced with native functionality. You can read more at
│ https://nodejs.org/docs/latest/api/util.html#utilstyletextformat-text-options. See more at
│ https://github.com/es-tooling/module-replacements/blob/main/docs/modules/chalk.md.
...
이를 사용하면 어떤 직접 의존성을 정리할 수 있는지 빠르게 파악할 수 있습니다. 또
migrate
명령을 이용해 이런 의존성 중 일부를 자동으로 마이그레이션할 수도 있습니다.
$ npx @e18e/cli migrate --all
e18e (cli v0.0.1)
┌ Migrating packages...
│
│ Targets: chalk
│
◆ /code/main.js (1 migrated)
│
└ Migration complete - 1 files migrated.
이 경우
chalk
에서
picocolors
로 마이그레이션하는데, 훨씬 더 작은 패키지이면서 같은 기능을 제공합니다.
앞으로는 이 CLI가 심지어 여러분의 환경을 바탕으로 추천도 해 줄 것입니다. 예를 들어 충분히 새로운 Node를 사용 중이라면 색상 라이브러리 대신 네이티브 plaintext styleText 를 제안할 수 있습니다.
npmgraph는 의존성 트리를 시각화하고 비대화가 어디서 오는지 조사하는 데 훌륭한 도구입니다.
예를 들어 이 글을 쓰는 시점 기준 ESLint 의존성 그래프의 아래쪽 절반을 살펴봅시다.

이 그래프를 보면
find-up
브랜치가 고립되어 있고, 다른 어떤 것도 그 깊은 의존성을 사용하지 않는다는 것을 알 수 있습니다. 위로 올라가는 파일 시스템 탐색처럼 단순한 것에 6개의 패키지가 꼭 필요한 것은 아닐 수도 있습니다. 그런 다음 plaintext empathic 같은 대안을 찾아볼 수 있는데, 이것은 훨씬 더 작은 의존성 그래프를 가지면서도 같은 일을 해냅니다.
module replacements 프로젝트는 어떤 패키지가 네이티브 기능이나 더 성능 좋은 대안으로 교체 가능한지 문서화하기 위한 중앙 데이터셋으로, 더 넓은 커뮤니티에서 사용되고 있습니다.
대안이 필요하거나 단지 의존성을 점검하고 싶을 때, 이 데이터셋은 매우 유용합니다.
마찬가지로 여러분의 트리에서 네이티브 기능으로 인해 불필요해진 패키지나, 더 나은 검증된 대안이 있는 패키지를 발견한다면, 다른 사람들도 그 혜택을 볼 수 있도록 이 프로젝트에 기여하기에 정말 좋은 곳입니다.
이 데이터와 함께, 제안된 대체제로 일부 패키지를 자동 마이그레이션해 주는 codemods 프로젝트도 있습니다.
매우 작은 집단의 사람들이 선호하는 특이한 아키텍처나, 그들에게 필요한 수준의 하위 호환성을 위해 우리 모두가 비용을 치르고 있습니다.
이 패키지들을 만든 사람들의 잘못이라고만 볼 수는 없습니다. 각자는 자신이 원하는 방식으로 만들 수 있어야 하기 때문입니다. 그들 중 많은 사람들은 영향력 있는 JavaScript 개발자들의 이전 세대이며, 오늘날 우리가 가진 많은 좋은 API와 상호 호환성이 존재하지 않던 더 어두운 시대에 패키지를 만들었습니다. 그들이 그렇게 만든 것은 당시로서는 아마 최선의 방식이었기 때문입니다.
문제는 우리가 거기서 앞으로 나아가지 못했다는 점입니다. 우리는 이미 수년 동안 이런 기능들을 가지고 있었는데도, 오늘날 여전히 이 모든 비대화를 다운로드하고 있습니다.
저는 이것을 뒤집는 방식으로 해결할 수 있다고 생각합니다. 이 작은 집단이 비용을 치러야 합니다. 거의 그들만 사용하는 자신들만의 특수 스택을 가져야 합니다. 나머지 모두는 현대적이고, 가볍고, 널리 지원되는 코드를 쓰면 됩니다.
바라건대 e18e와 npmx 같은 것들이 문서화, 도구 등으로 이를 돕기를 바랍니다. 여러분도 의존성을 더 자세히 들여다보고 “왜?”라고 묻는 방식으로 도움을 줄 수 있습니다. 여러분의 의존성에 이 패키지들이 정말 아직도 필요한지, 그리고 왜 필요한지 묻는 이슈를 올리세요.
우리는 이 문제를 고칠 수 있습니다.
저는 그렇게 오래된 엔진이 필요한 사람들이 있다고 믿지만, 몇 가지 예시는 정말 보고 싶습니다↩
이 비대화의 대부분은 당시에는 아마 필요했던 시절의 산물입니다. 그때는 플랫폼이 지금처럼 기능이 풍부하지 않았기 때문입니다. 당시로서는 올바른 결정이자 아키텍처였을 가능성이 높다고 생각합니다.↩
언급된 지원 연도 대부분은 MDN에서 왔고, MDN보다 이전인 경우에는 compat data에서 가져왔습니다↩
일반적으로 “Ponyfill” 관련 주제는 아직 정리되지 않은 문제입니다. 저는 LTS에 도달하면 제거해야 한다고 생각하지만, 이에 동의하지 않고 “영원히” 남겨야 한다고 보는 사람들도 있습니다.↩