본문으로 건너뛰기
기술스택 · stack/javascript/배열 메서드 2: reduce, 정렬, 원본을 바꾸지 않는 메서드

JavaScript · 5장 자료구조와 자료형

배열 메서드 2: reduce, 정렬, 원본을 바꾸지 않는 메서드

장바구니 합계를 내고, 가격순으로 줄 세우고, 카테고리별로 묶는 일은 앞 글의 map · filter만으로는 안 돼요. 이 글에서는 배열을 값 하나로 모으는 reduce와 정렬하는 sort를 보고, 원래 배열을 건드리지 않고 정렬하는 새 메서드들까지 정리해요.

#5.5

01. reduce: 배열을 값 하나로 모으기

reduce(함수, 초깃값)는 누적값을 하나 들고 배열을 처음부터 끝까지 돌아요. 항목마다 함수(누적값, 항목)을 부르고, 돌려받은 값이 다음 누적값이 돼요. 마지막 누적값이 결과예요. 합계, 개수 세기, 묶기처럼 "여러 개 → 하나"인 일에 써요.

const cart = [
  { name: '연필', price: 500, qty: 3 },
  { name: '공책', price: 2000, qty: 1 },
  { name: '형광펜', price: 1200, qty: 2 },
];

const total = cart.reduce((sum, item) => sum + item.price * item.qty, 0);
console.log(total); // 5900
①누적값 sum은 초깃값 0에서 시작해요. 연필(500 × 3)을 더해 1500, 공책(2000 × 1)을 더해 3500, 형광펜(1200 × 2)을 더해 5900이 되고, 마지막 누적값 5900이 reduce의 결과예요.

초깃값은 늘 넣어요. 빼면 첫 항목이 초깃값이 되는데, 배열이 비어 있으면 오류가 나고, 항목이 객체면 객체에 숫자를 더해 엉뚱한 문자열이 돼요. 누적값이 객체여도 돼서, 개수 세기나 묶기도 같은 모양으로 써요.

console.log([].reduce((a, b) => a + b, 0)); // 0 — 초깃값이 있으면 빈 배열도 괜찮아요

// 개수 세기: 누적값이 객체
const tags = ['js', 'css', 'js', 'html', 'js', 'css'];
const counts = tags.reduce((acc, tag) => {
  acc[tag] = (acc[tag] ?? 0) + 1;
  return acc;                     // 객체를 돌려주는 걸 잊지 마세요
}, {});
console.log(counts);              // { js: 3, css: 2, html: 1 }

console.log([].reduce((a, b) => a + b)); // 초깃값이 없으면?

카테고리별로 묶는 일은 따로 이름이 붙은 Object.groupBy(배열, 함수)가 있어요(2024년부터 주요 브라우저 지원). 함수가 돌려준 값을 키로 삼아 항목 배열을 모아 줘요. reduce는 강력하지만 읽기 어려워지기 쉬워서, 합계처럼 단순할 때나 맞는 메서드가 없을 때 써요.

const items = [
  { name: '연필', cat: '문구' },
  { name: '마우스', cat: '전자' },
  { name: '공책', cat: '문구' },
];
const byCat = Object.groupBy(items, it => it.cat);
console.log(byCat['문구'].length, byCat['전자'][0].name); // 2 마우스

02. sort: 정렬과 두 가지 함정

sort()에는 함정이 두 개 있어요. 첫째, 비교 함수 없이 부르면 모든 항목을 문자열로 바꿔 사전 순서로 정렬해요. 그래서 숫자 10이 9보다 앞에 와요. 숫자는 늘 비교 함수를 넣어요. 비교 함수는 a가 앞이면 음수, 뒤면 양수를 돌려주면 되니, 숫자 오름차순은 (a, b) => a - b예요.

console.log([10, 9, 1, 100].sort());                 // [1, 10, 100, 9] — 글자로 비교했어요
console.log([10, 9, 1, 100].sort((a, b) => a - b));  // [1, 9, 10, 100]

const products = [
  { name: '공책', price: 2000 },
  { name: '연필', price: 500 },
  { name: '지우개', price: 500 },
];
products.sort((a, b) => a.price - b.price);          // 가격 낮은 순
console.log(products.map(p => p.name));               // ['연필', '지우개', '공책']

const names = ['하늘', 'Zoe', '가람', 'amy'];
names.sort((a, b) => a.localeCompare(b, 'ko'));       // 한국어 사전 순서
console.log(names);                                   // ['가람', '하늘', 'amy', 'Zoe']

가격이 같은 연필과 지우개는 원래 순서 그대로예요. sort는 같다고 판단한 항목의 순서를 지켜요(안정 정렬). 그래서 "카테고리순, 같으면 가격순"처럼 기준을 겹칠 때 믿고 쓸 수 있어요. 문자열은 localeCompare를 써야 대소문자와 한글이 사람이 기대하는 순서로 정렬돼요.

둘째 함정은 sort가 원래 배열을 바꾼다는 거예요. 돌려주는 값도 새 배열이 아니라 원래 배열 그 자체예요. 화면에 보여 줄 정렬된 목록을 만들다가 원본 데이터의 순서까지 바뀌는 버그가 여기서 생겨요.

const nums = [3, 1, 2];
const sorted = nums.sort((a, b) => a - b);
console.log(sorted === nums); // true — 같은 배열이에요
console.log(nums);            // [1, 2, 3] — 원본이 바뀌었어요

03. 원본을 바꾸지 않는 메서드

그래서 원래 배열을 바꾸는 메서드에는 새 배열을 돌려주는 짝이 생겼어요(2023년부터 주요 브라우저 지원). 이름 앞에 to가 붙어요. 화면 상태처럼 원본을 바꾸면 안 되는 곳에서는 이쪽을 써요.

원본을 바꿔요새 배열을 돌려줘요하는 일
sort(f)toSorted(f)정렬
reverse()toReversed()순서 뒤집기
splice(i, n, …)toSpliced(i, n, …)가운데 지우기 · 넣기
arr[i] = 값with(i, 값)한 칸 바꾸기
const nums = [3, 1, 2];

console.log(nums.toSorted((a, b) => a - b)); // [1, 2, 3]
console.log(nums.toReversed());              // [2, 1, 3]
console.log(nums.toSpliced(0, 1));           // [1, 2] — 0번 자리 1개 지우기
console.log(nums.with(1, 9));                // [3, 9, 2] — 1번 자리를 9로
console.log(nums);                           // [3, 1, 2] — 원본은 그대로

예전 코드에서는 같은 일을 [...arr].sort(f)처럼 먼저 복사한 뒤 정렬하는 방식으로 했어요. 지금도 동작하지만, toSorted가 의도를 더 잘 보여 줘요. 앞 글의 map · filter도 원본을 바꾸지 않는 쪽이라 이 메서드들과 함께 이어 쓰기 좋아요.

주문 목록에서 금액이 큰 상품 두 개와 결제 완료 금액의 합계를 구하는 코드예요. 결과가 이상하고, 원래 주문 순서까지 바뀌었어요. 아래처럼 나오게 고쳐 보세요.

['모니터', '키보드']
357000
[101, 102, 103, 104]
const orders = [
  { id: 101, item: '키보드', status: 'paid', amount: 59000 },
  { id: 102, item: '마우스', status: 'refund', amount: 25000 },
  { id: 103, item: '모니터', status: 'paid', amount: 289000 },
  { id: 104, item: '케이블', status: 'paid', amount: 9000 },
];

const top2 = orders.sort((a, b) => b.amount - a.amount).slice(0, 2).map(o => o.item);
const paidTotal = orders.filter(o => o.status === 'paid').reduce((sum, o) => sum + o.amount);

console.log(top2);
console.log(paidTotal);
console.log(orders.map(o => o.id)); // 원래 순서여야 해요
해답 보기

버그가 두 개예요. sort가 원본 orders의 순서를 바꿨으니 toSorted로 바꿔요. reduce에 초깃값이 없어서 첫 주문 객체가 누적값이 됐고, 객체에 숫자를 더해 [object Object]… 글자가 됐어요. 초깃값 0을 넣어요.

const top2 = orders.toSorted((a, b) => b.amount - a.amount).slice(0, 2).map(o => o.item);
const paidTotal = orders.filter(o => o.status === 'paid').reduce((sum, o) => sum + o.amount, 0);

04. 실전 포인트

  • 언제 쓰나요? 합계 · 평균 · 최댓값은 reduce(초깃값과 함께), 카테고리별 묶기는 Object.groupBy, 화면에 보여 줄 정렬은 toSorted, 한글 이름 정렬은 localeCompare를 써요.
  • 대신 쓰는 것: reduce 안에서 조건 · 변환을 다 하면 읽기 어려워요. filter → map으로 다듬은 뒤 마지막에만 reduce로 모으거나, 그냥 for...of로 써도 돼요. 오래된 브라우저를 지원해야 하면 toSorted 대신 [...arr].sort()를 써요.
  • 흔한 실수: reduce에 초깃값을 빼는 것, 객체 누적값을 return하지 않는 것, 숫자 배열을 비교 함수 없이 sort()하는 것, sort가 원본을 바꾸는 걸 모르고 상태 배열에 쓰는 것이에요.

정리

  • reduce(함수, 초깃값)는 누적값을 들고 돌면서 배열을 값 하나로 모아요. 초깃값은 늘 넣어요.
  • sort()는 기본이 문자열 순서예요. 숫자는 (a, b) => a - b, 한글은 localeCompare로 비교해요.
  • sort · reverse · splice는 원본을 바꿔요. 새 배열이 필요하면 toSorted · toReversed · toSpliced · with를 써요.
[10, 9, 1].sort()의 결과가 [1, 10, 9]인 이유는?

비교 함수가 없으면 항목을 문자열로 바꿔 사전 순서로 비교해요. 글자로는 '10'이 '9'보다 앞이에요. 숫자는 sort((a, b) => a - b)로 정렬해요.

const sorted = list.sort(…) 뒤에 list는 어떻게 될까요?

list도 정렬돼 있어요. sort는 원래 배열을 바꾸고 그 배열을 그대로 돌려줘서 sorted와 list는 같은 배열이에요. 원본을 지키려면 list.toSorted(…)를 써요.

reduce로 합계를 낼 때 초깃값 0을 넣어야 하는 이유 두 가지는?

빈 배열이면 초깃값 없이 TypeError가 나고, 항목이 객체면 첫 객체가 누적값이 돼서 숫자 대신 [object Object]… 같은 글자가 만들어져요.

SIMPLE · 기술스택 / JavaScript — 5장 자료구조와 자료형