Native ES2026으로 Lodash 대체하기: groupBy, fromAsync, toReversed 및 5가지 기능 더
요약
ES2026에 도입되는 Object.groupBy와 Map.groupBy 같은 네이티브 JavaScript 기능들이 Lodash의 핵심 유틸리티 기능을 대체하고 있습니다. 특히, Object.groupBy는 콜백 함수를 사용하여 계산된 값을 기준으로 그룹화할 수 있으며, Map.groupBy는 객체나 Date 등 다양한 타입을 키로 사용할 때 강력합니다. 또한, Array.fromAsync와 같은 새로운 API들은 비동기 반복 가능 객체를 처리하는 효율적인 방법을 제공하여, 개발자들이 Lodash 의존성을 줄이고 번들 크기를 최적화할 수 있게 합니다.
핵심 포인트
- Object.groupBy 및 Map.groupBy는 기존의 _.groupBy를 대체하며, 계산된 값이나 다양한 타입(객체, Date 등)을 키로 사용할 때 유연합니다.
- Array.fromAsync와 같은 네이티브 API들은 스트림, 페이지네이션 API 등 비동기 반복 가능 객체를 처리하는 효율적인 방법을 제공합니다.
- Lodash는 여전히 깊은 비교(deep equality), 디바운스(debounce), 쓰로틀(throttle) 등 일부 영역에서 우위를 점하고 있지만, 대부분의 유틸리티 기능은 네이티브 JS로 대체 가능해지고 있습니다.
- 새로운 네이티브 API들은 V8, Bun, Node 22와 같은 최신 런타임에 이미 탑재되어 있어 즉시 활용할 수 있습니다.
Lodash는 압축(minified) 시 약 70KB를 추가하지만, ES2026은 의존성 없이 대부분의 유틸리티 용도를 커버합니다. Object.groupBy와 Map.groupBy는 _.groupBy를 대체하며 모든 현대적인 런타임(runtime)에 포함되어 있습니다. Array.fromAsync, toReversed, toSorted, toSpliced는 비동기 반복(async iteration)과 불변 변환(immutable transforms)을 처리합니다. structuredClone, Object.hasOwn, Array#findLast, Promise.withResolvers, Iterator.from이 교체를 마무리합니다. Lodash는 여전히 깊은 비교(deep equality), 디바운스(debounce), 쓰로틀(throttle), 그리고 iteratee 단축 표기법(shorthand) 측면에서 우위에 있습니다.
지난달 저는 Next.js 프로젝트에서 Lodash를 제거하여 클라이언트 번들(client bundle)에서 71KB를 절약했습니다. 대체 기능들은 이미 V8, Bun, 그리고 Node 22에 탑재되어 있었습니다. 단지 제가 알아차리지 못했을 뿐입니다. 이 글은 "Lodash가 죽었다"는 내용이 아닙니다. Lodash는 여전히 네이티브 JS가 하지 못하는 몇 가지 기능을 수행하며, 저는 어떤 것인지 솔직하게 밝힐 것입니다. 하지만 대부분의 팀에게 이 의존성(dependency)은 과거에 수행하던 작업의 5% 정도만 수행하고 있습니다. 다음은 나란히 비교한 내용입니다.
Object.groupBy와 Map.groupBy
모두가 찾는 기능입니다. 키(key)를 기준으로 배열을 그룹화하여 배열들의 객체를 반환합니다.
// Lodash
import groupBy from 'lodash/groupBy'
const users = [
{ id: 1, role: 'admin' },
{ id: 2, role: 'user' },
{ id: 3, role: 'admin' },
]
const grouped = groupBy(users, 'role') // { admin: [...], user: [...] }
// Native (ES2026이지만, Node 21+ 및 모든 현대적 브라우저에 도입됨)
const grouped = Object.groupBy(users, (u) => u.role) // { admin: [...], user: [...] }
Object.groupBy는 문자열 키 대신 콜백(callback)을 받으므로 더 유연합니다. 별도의 헬퍼(helper) 없이도 계산된 값(computed values)을 기준으로 그룹화할 수 있습니다. 문자열이 아닌 키(객체, 날짜, 숫자 등 무엇이든)를 사용하는 실제 Map이 필요한 경우 Map.groupBy를 사용하세요. Lodash는 이에 대해 깔끔한 해답을 내놓지 못했습니다.
const byDay = Map.groupBy(orders, (o) => o.createdAt.toDateString())
// Map { 'Mon May 05 2026' => [...], ... }
저는 관리자 대시보드의 네 곳의 호출 지점(call sites)을 이런 방식으로 마이그레이션했습니다. 번들 크기는 줄어들었고, 코드는 더 짧아졌습니다. 의미론적 변화(semantic shift)는 없었습니다.
한 가지 알아두어야 할 점은, Object.groupBy는 null-prototype 객체를 반환하므로 상속된 메서드가 없다는 것입니다. 즉, .hasOwnProperty가 없으며, 프로토타입 오염 (prototype pollution)으로 인한 예기치 않은 상황도 발생하지 않습니다. 만약 기존 Lodash 버전을 사용하면서 이 점이 불안했다면, 이제는 안심해도 됩니다. Lodash는 일반 객체를 반환했기에 어쨌든 Object.hasOwn을 사용해야 했습니다. 네이티브 (Native) 방식은 이미 여러분을 위해 더 안전한 방식을 택했습니다. TC39 명세에 따르면 콜백 함수는 객체 키를 위해 문자열로 강제 변환 (coerced)되는 모든 원시 값 (primitive)을 반환할 수 있습니다. Map.groupBy의 경우 키는 객체, 날짜 (Date), NaN, 심볼 (Symbol) 등 무엇이든 될 수 있습니다. 저는 이전에 주문 데이터를 리터럴 Date 객체로 그룹화한 적이 있는데, 문자열로 변환하여 날짜 정밀도를 잃고 싶지 않을 때 이는 진정으로 유용합니다.
비동기 반복 가능 객체 (async iterables)를 위한 Array.fromAsync
이 기능은 조용한 주인공입니다. 스트림 (streams), 페이지네이션 API (paginated APIs), 서버 전송 이벤트 (server-sent events), 비동기 제너레이터 (async generators) 등 비동기 반복 가능 객체는 이제 어디에나 존재합니다. Lodash는 이 영역에 제대로 도달하지 못했습니다.
// Lodash와 수동 루프를 사용한 기존 패턴
import map from 'lodash/map'
async function collectStream(stream) {
const items = []
for await (const chunk of stream) {
items.push(chunk)
}
return map(items, (i) => i.toString())
}
// 네이티브 (Native) 방식
const items = await Array.fromAsync(stream, (chunk) => chunk.toString())
Array.fromAsync는 await가 내장된 Array.from입니다. 비동기 반복 가능 객체, 동기 반복 가능 객체, 그리고 배열 유사 객체 (array-likes)를 모두 수용하며, 매핑 함수 (mapping function)를 무료로 제공합니다. 저는 각 페이지가 비동기 제너레이터 yield로 구성된 페이지네이션 GraphQL에서 이를 사용합니다. 12줄에 달하던 수집 코드가 단 한 줄이 되었습니다. 50페이지 분량의 페이지네이션 쿼리에 처음 테스트했을 때, 너무 빨리 결과가 나와서 무언가 잘못된 줄 알았습니다. 하지만 잘못된 것이 아니었습니다. 그냥 제대로 작동한 것이었습니다. 페이지네이션 API를 수집하는 Vercel 크론 잡 (cron jobs)에 대한 더 깊은 맥락은 The 5 Vercel Cron Jobs That Keep My Studio Running을 참조하세요.
불변 변환 (Immutable transforms): toReversed, toSorted, toSpliced
수년 동안 불변 업데이트 (immutable updates)는 [...arr].sort() 또는 [...arr].reverse()를 의미했습니다. 스프레드 복사 (spread copy)는 작은 배열에서는 괜찮습니다.
큰 배열의 경우에는 낭비이며, 코드가 번거로운 절차(ceremony)처럼 느껴집니다. // 기존의 불변 패턴 (Old immutable pattern)
const sorted = [... users ]. sort (( a , b ) => a . name . localeCompare ( b . name ))
const reversed = [... users ]. reverse ()
const removed = [... users . slice ( 0 , i ), ... users . slice ( i + 1 )]
// Native ES2023+ (현재 모든 런타임에서 지원)
const sorted = users . toSorted (( a , b ) => a . name . localeCompare ( b . name ))
const reversed = users . toReversed ()
const removed = users . toSpliced ( i , 1 )
이 기능들에서 제가 좋아하는 세 가지는 다음과 같습니다: 첫째, 절대 원본을 변경(mutate)하지 않습니다. 실수할 여지가 없습니다. 둘째, 명령형(imperative) 버전처럼 읽히면서도 훨씬 안전합니다. 셋째, toSpliced는 거대한 filter나 두 번의 slice를 사용하는 복잡한 방식이 아닌, 불변 삭제(immutable deletion)를 드디어 제공합니다.
Lodash에는 변형을 일으키는 _.reverse와 불변을 유지하는 _.sortBy가 있었습니다. 네이티브 버전은 의도가 더 명확합니다. sort는 원본을 변경하지만, toSorted는 그렇지 않습니다. 끝입니다. 만약 당신의 React useState 리듀서가 setItems([...items].sort(fn))와 같이 작성되어 있다면, setItems(items.toSorted(fn))로 교체하세요. 결과는 같으면서 노이즈는 절반으로 줄어듭니다. 참고로 성능은 스프레드 후 변형(spread-then-mutate) 패턴과 실제로 동일합니다. 엔진은 소스(source)가 다른 곳에서 별칭(alias)으로 사용되지 않음을 증명할 수 있을 때 중간 복사 과정을 건너뛸 만큼 충분히 똑똑합니다. 10,000개의 아이템이 있는 배열로 벤치마크를 수행했을 때, 차이는 오차 범위 내에 있었습니다.
깊은 복사 (deep copies)를 위한 structuredClone
_.cloneDeep은 아마도 모든 React 코드베이스에서 가장 많이 사용되는 Lodash 함수였을 것입니다. 순환 참조(circular references), 날짜(Date), Map, Set, 정규 표현식(RegExp) 등 무엇이든 처리했습니다. structuredClone은 이 모든 것을 수행하며 Node 17+, Deno, Bun, 그리고 2022년 이후의 모든 브라우저를 포함한 모든 현대적인 런타임에 탑재되어 있습니다.
// Lodash
import cloneDeep from ' lodash/cloneDeep '
const copy = cloneDeep ( state )
// Native
const copy = structuredClone ( state )
structuredClone은 당신이 예상하는 것들(Map, Set, Date, RegExp, typed arrays, binary blobs)은 물론, 당신이 잊기 쉬운 것들(순환 참조, 희소 배열 (sparse arrays))까지 처리합니다. 함수(functions), DOM 노드, 그리고 직렬화할 수 없는 내부 요소를 가진 클래스 인스턴스(class instances)에 대해서는 에러를 발생시키는데, 이는 타당한 동작입니다.
주의 사항: 일반적인 JSON의 경우 structuredClone은 직접 구현한 JSON.parse(JSON.stringify(x))보다 느립니다. 데이터가 일반적인 JSON임을 알고 있다면, JSON 라운드트립(round-trip) 방식이 더 빠릅니다. 만약 데이터의 형식을 확신할 수 없다면, structuredClone을 기본값으로 사용하는 것이 옳습니다.
더 작은 승리: Lodash 임포트(import)를 각각 하나씩 제거할 수 있는 5가지 교체 방법:
// Object.hasOwn (고유 속성에 대한 _.has 대체)
if (Object.hasOwn(obj, 'key')) { ... }
// Array#findLast (_.findLast 대체)
const lastError = logs.findLast((l) => l.level === 'error')
// Promise.withResolvers ("deferred" 패턴 대체, Lodash 대응 기능은 없으나 공유 코드베이스에서 흔히 쓰이는 유틸리티임)
const { promise, resolve, reject } = Promise.withResolvers()
// Iterator.from + 헬퍼 함수들 (_.chain을 이용한 지연 파이프라인(lazy pipelines) 대체)
const result = Iterator.from(largeArray)
.filter((x) => x.active)
.map((x) => x.id)
.take(10)
.toArray()
// 리터럴 내 숫자 구분자 (대체 대상은 없으며, 단지 편리함)
const TIMEOUT_MS = 30_000
Iterator.from은 대부분의 팀이 가장 과소평가하고 있는 기능이라고 생각합니다. 이는 유연한 API(fluent API)를 가진 지연 반복(lazy iteration)을 제공합니다. 만약 _.chain을 사용한 적이 있다면, 이것이 현대적인 해답입니다. 이는 중간 배열을 할당하지 않습니다. 제가 지난주에 벤치마크한 10만 행의 데이터셋에서, 이터레이터 파이프라인은 take(10) 이후에 단락 평가(short-circuit)를 수행하기 때문에 배열 방식보다 약 4배 더 빨랐습니다.
데코레이터가 이러한 기능들과 어떻게 조합되는지 확인하고 싶다면, 관련 불변 패턴을 다룬 "TypeScript Decorators Finally Shipped: What Changed in 2026"를 참조하세요.
Lodash가 여전히 우세한 점
공정하게 말하자면, Lodash는 그 명성을 얻을 만한 자격이 있습니다. 네이티브 JS가 아직 따라잡지 못한 몇 가지 사항이 있습니다:
깊은 비교 (Deep equality): _.isEqual은 순환 구조(cyclic structures), NaN 비교, 타입 변환(type coercion)의 예외 케이스, 그리고 프로토타입 순회(prototype walking)를 처리합니다. 네이티브의 해답은 "라이브러리를 사용하거나 50줄의 코드를 직접 작성하라"는 것입니다. 깊은 비교가 필요하다면, Lodash에 투자하는 6KB는 충분히 가치 있는 비용입니다. 또는 더 작은 대안으로 fast-deep-equal을 사용할 수도 있습니다.
디바운스(Debounce) 및 쓰로틀(throttle).
_.debounce와 _.throttle은 누구나 처음 시도할 때 실수하는 종류의 코드입니다. 취소(Cancellation), leading/trailing edges, max wait 등 고려할 사항이 많습니다. 네이티브(Native)에는 관련 기능이 없습니다. 웹 플랫폼(Web Platform)에는 아직 구현되지 않은 Scheduler.postTask 제안(proposal)이 있습니다. 당분간은 Lodash의 두 함수를 그대로 배포하거나 독립형 모듈로 복사해서 사용하세요. (just-debounce-it은 600바이트이며 제 역할을 다합니다.)
Iteratee shorthand(반복자 약어). _.map(users, 'name')은 Lodash가 발명한 '문자열을 콜백으로 사용하는 패턴'입니다. 네이티브에서는 users.map((u) => u.name)이 필요합니다. 화살표 함수 형태도 괜찮습니다. 단지 5글자 차이일 뿐입니다. 어떤 팀들은 진심으로 이 약어를 그리워하기도 합니다. 저는 그렇지 않지만, 사람들이 왜 그러는지 이해는 갑니다.
함수형 합성(Functional composition). _.flow, _.curry, _.partial. 만약 함수형 JavaScript를 작성한다면, Lodash/fp는 여전히 가장 깔끔한 선택지입니다. Ramda가 대안이 될 수 있습니다. 네이티브 파이프 연산자(pipe operator)는 아직 제안(proposal) 단계에 있습니다.
결론(Bottom Line)
실제 교체 작업은 38개의 Lodash 임포트(import)가 있는 프로젝트에서 약 2시간 정도 걸렸습니다. 그중 31개는 즉시 삭제했습니다. 6개는 3줄의 네이티브 코드로 바뀌었습니다. 하나(검색 입력창을 위한 _.debounce)는 남겨두었습니다. 번들(Bundle) 크기는 minified 기준 71KB, gzipped 기준 24KB 감소했습니다. Webpack이 수행해야 할 트리 쉐이킹(tree-shaking) 작업이 줄어들면서 빌드 시간도 단축되었습니다. 깨진 테스트는 없었습니다. 런타임 에러(runtime errors)도 없었습니다.
만약 팀이 Node 21+를 사용하거나 최신 브라우저(evergreen browsers)를 대상으로 서비스한다면, Lodash를 제거하는 것이 수학적으로 유리합니다. 아마도 더 이상 Lodash의 대부분은 필요하지 않을 것입니다. _.isEqual과 _.debounce를 사용 중이라면 그대로 유지하거나, 이 두 가지만 아주 작은 단일 목적 모듈로 교체하세요. Lodash가 나쁘다고 말하려는 것은 아닙니다. Lodash는 표준 라이브러리가 부실했던 암흑기 동안 JavaScript를 지탱해 왔습니다. ES2026은 마침내 언어가 그 격차를 따라잡고 있는 단계입니다.
스택에서 무엇을 더 교체하거나 줄일지 고민 중이라면, [Neon Database Branching Saved Me 200 EUR Every Month]가 데이터베이스 측면에서 동일한 아이디어를 다루고 있습니다. 그리고 studio playbook 페이지에는 제가 사용하는 나머지 스택이 정리되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기