글 목록

왜 lodash-es를 사용할까

2026년 1월 20일 5분 읽기
JavaScript번들링

프론트엔드 실무 팁을 찾아보다 보면 lodash 대신 lodash-es를 쓰라는 이야기가 자주 등장합니다. 저도 처음에는 트리쉐이킹이 잘된다는 정도로 이해했습니다. 그런데 debounce 하나를 가져오는 코드가 비슷한데 왜 번들 크기가 달라지는지 설명하려니 막혔습니다.

이 차이는 라이브러리 이름보다 번들러가 모듈 사이의 관계를 얼마나 확실하게 알 수 있느냐에 있었습니다. CommonJS와 ES Modules의 차이부터, 실제 결과를 확인할 때 놓치기 쉬운 조건까지 살펴보겠습니다.

require는 함수를 호출하고, import는 관계를 선언한다

CommonJS는 require와 module.exports로 모듈을 사용합니다.

js
// math.cjs
const add = (a, b) => a + b;
module.exports = { add };

// app.cjs
const { add } = require("./math.cjs");

require는 조건문 안에서 호출할 수 있고 내보내는 객체도 실행 중 구성할 수 있습니다. 따라서 번들러는 모든 CommonJS 사용을 같은 방식으로 정적으로 분석하기 어렵습니다. 그렇다고 CommonJS를 하나 사용하면 모든 파일이 무조건 포함된다는 뜻은 아닙니다. 도구가 인식하는 패턴과 import 경로에 따라 최적화 범위가 달라집니다.

ES Modules의 정적 import·export는 모듈의 최상위 범위에 선언합니다. 반드시 파일의 첫 줄이어야 하는 것은 아니지만 함수나 조건문 안에 정적 import 선언을 넣을 수는 없습니다. 비동기 import() 표현식은 별도로 존재합니다.

js
// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// app.js
import { add } from "./math.js";
console.log(add(1, 2));

이 관계는 코드를 실행하지 않고도 확인할 수 있습니다. 번들러는 어떤 export를 참조하는지 추적해 사용하지 않는 코드를 제거할 근거를 얻습니다. 이것이 ESM이 트리쉐이킹에 유리한 이유입니다.

안 쓴 함수와 실행하면 효과가 있는 모듈은 다르다

사용하지 않는 export를 찾았다고 그 파일 전체를 무조건 지워도 되는 것은 아닙니다. 파일 최상위에서 이벤트를 등록하거나 전역 객체를 바꾸면, 반환값을 쓰지 않더라도 실행 자체에 의미가 있습니다.

모듈 관계에서 사용한 export를 추적하되 부수 효과가 있는 초기화 모듈은 남기는 과정

그림에서 subtract는 호출되지 않아 제거 후보가 됩니다. 반면 전역 등록을 수행하는 초기화 모듈은 사용한 export가 없어도 필요할 수 있습니다. 패키지의 sideEffects 정보는 이런 판단에 도움을 줍니다. 실제 부수 효과가 있는데 false라고 선언하면 필요한 초기화나 스타일이 빠지는 문제도 생깁니다. webpack의 트리쉐이킹 설명

따라서 ESM은 최적화하기 좋은 조건이지 결과 보증서가 아닙니다. 빌드 전에 ESM을 CommonJS로 변환해버리는 설정, 개발 모드 빌드, 동적으로 선택하는 export 등도 결과에 영향을 줍니다.

lodash-es는 번들러가 읽기 쉬운 모듈 경계를 제공한다

js
import { debounce } from "lodash-es";

const report = debounce(() => console.log("changed"), 200);
report();

사용한 debounce와 그 구현에 필요한 의존 코드가 남고 나머지는 제거될 수 있습니다. debounce 함수 본문만 딱 들어간다는 뜻은 아닙니다. 반대로 CommonJS인 lodash의 루트에서 가져오면 더 넓은 코드가 남을 수 있습니다.

기존에 비교했던 gzip 크기는 다음과 같았습니다. 당시 버전과 번들러 설정을 함께 남기지 않아, 이 숫자는 경향을 보여주는 과거 기록으로만 보아야 합니다. 현재 프로젝트에서 같은 크기가 나온다고 보장하지 않습니다.

가져오는 방식기존 기록의 gzip 크기해석
import _ from "lodash"약 24kB루트 전체를 가져온 경우
import { debounce } from "lodash"약 24kB이름을 골라도 큰 번들이 남았던 경우
import { debounce } from "lodash-es"약 2kBESM에서 사용한 코드가 남은 경우
import debounce from "lodash/debounce"약 2kB직접 경로로 범위를 좁힌 경우

여기서 배운 점은 중괄호 문법을 썼다는 사실만으로 번들이 작아지지 않는다는 것입니다. 패키지가 어떻게 구성됐는지와 빌드 도구가 어떻게 해석하는지를 함께 봐야 합니다.

비교 실험에서는 바꾸는 조건을 하나로 좁힌다

직접 확인하려면 같은 프로젝트의 production 빌드에서 import 방식만 바꾸어 비교하는 편이 좋습니다. 패키지·번들러 버전, lockfile, 압축 옵션과 출력 대상을 고정합니다. 함수가 전혀 사용되지 않으면 전체 코드가 지워질 수 있으므로, 위 예제처럼 실제로 호출되는 경로를 둡니다.

  1. lodash 루트 import로 빌드하고 결과와 모듈 분석 파일을 보관합니다.
  2. 같은 사용 코드를 lodash-es import로 바꿔 다시 빌드합니다.
  3. 원본 크기와 gzip 크기를 나눠 비교하고, 어떤 모듈이 빠졌는지 확인합니다.
  4. 다른 화면이나 공통 청크에서 lodash를 계속 가져오고 있는지도 검색합니다.

출력 파일 하나가 줄어도 공통 청크에 옮겨갔을 수 있습니다. 반대로 앱 전체 크기가 같아도 첫 화면에 필요한 JavaScript가 줄었을 수 있습니다. 실제 사용자가 처음 받는 청크를 기준으로 판단해야 합니다. 전송 크기 감소가 파싱 시간과 상호작용 성능의 같은 비율 개선을 뜻하지도 않습니다.

옮기기 어렵다면 import 경로부터 줄일 수 있다

CommonJS 환경을 유지해야 한다면 직접 경로로 가져오는 방법도 있습니다.

js
import debounce from "lodash/debounce";

이 경우에도 해당 함수가 의존하는 내부 모듈은 함께 들어옵니다. 경로 하나만 지정했다고 파일 하나만 포함되는 것은 아닙니다. 빌드 도구의 패키지 최적화나 import 변환 기능을 이미 사용 중이라면 루트 import와 차이가 작을 수도 있습니다. 변경 전후 산출물을 보는 이유입니다.

내장 API로 바꾸는 방법도 있지만 동작이 같은지부터 확인해야 합니다. structuredClone은 모든 값을 복제할 수 없고 함수가 포함된 값 등에서 실패할 수 있습니다. flat, Set, Object.keys도 lodash의 대응 함수와 모든 입력에서 동일하지는 않습니다.

js
// Array의 한 단계 평탄화가 필요한 경우
const flattened = nested.flat();

// 원시값 배열에서 중복을 제거하려는 경우
const unique = [...new Set(values)];

예를 들어 Map과 Set의 비어 있음은 .size로 확인해야 합니다. Object.keys(new Map([["a", 1]])).length는 0이므로 객체 키 검사로 대체하면 데이터가 있는데도 비었다고 판단합니다. 라이브러리를 줄이면서 의미를 바꾸면 번들 최적화가 기능 회귀로 이어집니다.

다음에 lodash-es 전환을 검토한다면 import 한 줄을 바꾸는 데서 멈추지 않고, 같은 동작을 유지하면서 실제로 어떤 코드가 줄었는지 확인해보면 좋겠습니다. 모듈 형식을 이해한 뒤에야 번들 분석 결과도 이유와 함께 읽을 수 있었습니다.