logo
Nostrss
Published on

reduce로 데이터를 하나로 모으는 마법

Authors
reduce로 데이터를 하나로 모으는 마법

들어가며

이번에는 자바스크립트 내장 메소드 중 꽃이라 할 수 있는 reduce 함수를 알아보자. reducemap이나 filter보다 더 강력하다. 왜냐하면 여러 값을 하나의 값으로 축약하는 범용적인 함수이기 때문이다.

범용적인 reduce 함수를 만들어보고, 다양한 상황에서 어떻게 활용할 수 있는지 알아보자.

배열의 reduce 메서드 복습

배열의 reduce 메서드부터 다시 보자.

const numbers = [1, 2, 3, 4, 5]
const sum = numbers.reduce((acc, cur) => acc + cur, 0)

console.log(sum) // 15

reduce는 배열의 각 요소에 함수를 적용하면서 하나의 값으로 축약 한다. 첫 번째 인자는 콜백 함수이고, 두 번째 인자는 초기값(initialValue) 이다.

accumulator(누산기) 개념

reduce의 핵심은 누산기(accumulator) 다. 누산기는 각 단계의 계산 결과를 누적해서 보관하는 변수다.

위 예제를 단계별로 보면 이렇다.

// 0 + 1 = 1
// 1 + 2 = 3
// 3 + 3 = 6
// 6 + 4 = 10
// 10 + 5 = 15

함수 호출 중첩으로 표현하면 더 명확하다.

const add = (a, b) => a + b

add(add(add(add(add(0, 1), 2), 3), 4), 5) // 15

가장 안쪽의 add(0, 1)부터 시작해서 바깥으로 나오면서 계속 누적된다.

콜백 함수의 인자

reduce의 콜백 함수는 최대 4개의 인자를 받을 수 있다.

const arr = [10, 20, 30]
arr.reduce((accumulator, currentValue, index, array) => {
  console.log(accumulator, currentValue, index, array)
  return accumulator + currentValue
}, 0)
// 0 10 0 [10, 20, 30]
// 10 20 1 [10, 20, 30]
// 30 30 2 [10, 20, 30]
  • accumulator: 누산기 (이전 단계의 결과)
  • currentValue: 현재 처리 중인 요소
  • index: 현재 인덱스
  • array: 원본 배열

실무에서는 대부분 accumulatorcurrentValue만 사용한다.

initialValue의 역할

초기값을 생략할 수도 있다. 이 경우 배열의 첫 번째 요소가 초기값이 되고, 두 번째 요소부터 순회한다.

const numbers = [1, 2, 3, 4, 5]

// 초기값이 있는 경우
console.log(numbers.reduce((acc, cur) => acc + cur, 0)) // 15

// 초기값이 없는 경우 - 첫 번째 요소가 초기값
console.log(numbers.reduce((acc, cur) => acc + cur)) // 15

하지만 빈 배열에서 초기값 없이 reduce를 호출하면 에러가 발생한다.

// 에러 발생!
// [].reduce((acc, cur) => acc + cur)
// TypeError: Reduce of empty array with no initial value

그래서 일반적으로는 초기값을 명시하는 것이 안전하다.

커스텀 reduce 함수 구현하기 ⭐

배열의 reduce는 편리하지만, 배열에만 사용할 수 있다. 모든 Iterable에서 동작하는 범용적인 reduce 함수를 만들어보자.

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]()
    acc = iter.next().value
  }
  for (const a of iter) {
    acc = f(acc, a)
  }
  return acc
}

이 코드는 간결하지만, 중요한 로직이 많이 담겨 있다. 특히 if (!iter) 블록을 자세히 살펴보자.

Symbol.iterator()와 next().value 이해하기 ⭐⭐⭐

이 부분이 reduce 함수의 핵심이다. 왜 Symbol.iterator()를 호출하고 next().value를 사용하는지 단계별로 알아보자.

1. if (!iter) 체크: 초기값 생략 처리

if (!iter) {
  // 세 번째 인자가 없으면 (초기값 생략)
  iter = acc[Symbol.iterator]()
  acc = iter.next().value
}

reduce 함수는 3개의 인자를 받을 수 있다.

  • f: 콜백 함수
  • acc: 초기값 (또는 초기값이 생략된 경우 Iterable)
  • iter: Iterable (선택적)

만약 iter가 없으면 (undefined면), 사용자가 초기값을 생략한 것이다.

// 초기값이 있는 경우: reduce(f, 초기값, Iterable)
reduce(add, 0, [1, 2, 3, 4, 5])

// 초기값이 없는 경우: reduce(f, Iterable)
reduce(add, [1, 2, 3, 4, 5])

2. acc[Symbol.iterator](): Iterable에서 Iterator 얻기

초기값이 생략되었다면, 두 번째 인자 acc가 실제로는 Iterable이다.

iter = acc[Symbol.iterator]()

이 코드는 Iterable의 Symbol.iterator 메서드를 호출해서 Iterator 객체를 얻는다.

const arr = [1, 2, 3, 4, 5]
const iterator = arr[Symbol.iterator]()

console.log(iterator) // Array Iterator {}

Iterator는 상태를 가진 객체다. next() 메서드를 호출할 때마다 내부 포인터가 이동한다.

3. acc = iter.next().value: 첫 번째 값을 초기값으로 설정

acc = iter.next().value

Iterator의 next() 메서드를 호출하면 { value, done } 형태의 객체가 반환된다.

const arr = [1, 2, 3, 4, 5]
const iterator = arr[Symbol.iterator]()

console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: 3, done: false }

iterator.next().value는 첫 번째 값인 1을 반환한다. 이 값을 acc에 할당하여 초기값으로 사용한다.

중요한 점은 next()를 호출하면 Iterator의 내부 포인터가 이동한다는 것이다.

4. for...of로 같은 Iterator를 계속 순회

for (const a of iter) {
  acc = f(acc, a)
}

이제 for...of로 같은 Iterator를 순회한다. 이미 next()를 한 번 호출했으므로, for...of는 두 번째 요소부터 순회한다.

이것이 핵심이다! Iterator는 상태를 가지므로, next()로 첫 번째 값을 가져온 후 for...of를 실행하면 나머지 값만 처리된다.

단계별 실행 예제

초기값이 없는 경우를 단계별로 보자.

const add = (a, b) => a + b
reduce(add, [1, 2, 3, 4, 5])

Step 1: 초기화

// 인자 확인
f = add
acc = [1, 2, 3, 4, 5]
iter = undefined

Step 2: if (!iter) 블록 실행

// iter가 없으므로 if 블록 진입
iter = [1, 2, 3, 4, 5][Symbol.iterator]() // Iterator 얻기
acc = iter.next().value // 1 (첫 번째 값)

이 시점에서 Iterator의 내부 포인터는 두 번째 요소를 가리키고 있다.

Step 3: for...of 순회

// for...of는 같은 iterator를 계속 순회
// 이미 next()를 한 번 호출했으므로, [2, 3, 4, 5]만 순회

for (const a of iter) {
  acc = f(acc, a)
}

// 1번째 루프: acc = add(1, 2) = 3
// 2번째 루프: acc = add(3, 3) = 6
// 3번째 루프: acc = add(6, 4) = 10
// 4번째 루프: acc = add(10, 5) = 15

Step 4: 결과 반환

return acc // 15

Iterator 상태 시각화

Iterator의 상태 변화를 좀 더 명확히 보자.

const arr = [1, 2, 3, 4, 5]
const iterator = arr[Symbol.iterator]()

// 첫 번째 값을 초기값으로 사용
const firstValue = iterator.next().value // 1
console.log(firstValue) // 1

// 이제 iterator는 두 번째 요소를 가리킴
// for...of로 같은 iterator를 순회하면 [2, 3, 4, 5]만 순회
let acc = firstValue
for (const a of iterator) {
  console.log(a) // 2, 3, 4, 5
  acc = acc + a
}

console.log(acc) // 15

이것이 바로 reduce 함수가 초기값을 생략할 수 있는 원리다!

  • Symbol.iterator()로 Iterator를 얻고
  • next().value로 첫 번째 값을 초기값으로 사용하고
  • for...of로 같은 Iterator를 계속 순회하면 나머지 값만 처리된다

기본 예제: 합계와 곱셈

이제 우리가 만든 reduce 함수를 사용해보자.

const log = console.log
const add = (a, b) => a + b
const multiply = (a, b) => a * b

log(reduce(add, 0, [1, 2, 3, 4, 5])) // 15
log(reduce(add, [1, 2, 3, 4, 5])) // 15

두 경우 모두 같은 결과를 반환한다. 차이점은 첫 번째는 초기값 0을 명시했고, 두 번째는 배열의 첫 번째 요소 1을 초기값으로 사용했다는 점이다.

함수 호출 중첩으로 표현하면 이렇다.

log(add(add(add(add(add(0, 1), 2), 3), 4), 5)) // 15

곱셈도 해보자.

log(reduce(multiply, 1, [1, 2, 3, 4, 5])) // 120

Before: for of 사용

let sum = 0
for (const n of [1, 2, 3, 4, 5]) {
  sum = sum + n
}
console.log(sum) // 15

After: reduce 함수 사용

log(reduce(add, 0, [1, 2, 3, 4, 5])) // 15

코드가 훨씬 간결하고 의도가 명확하다. "이 배열을 더해서 하나의 합계로 만들겠다"는 의미가 분명하다.

실전 예제: 상품 가격 계산

실무에서 자주 보는 패턴이다. 상품 목록의 가격을 모두 합산하는 경우를 보자.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
]

Before: for of 사용

let totalPrice = 0
for (const product of products) {
  totalPrice = totalPrice + product.price
}

console.log(totalPrice) // 105000

After: reduce 함수 사용

log(reduce((total_price, product) => total_price + product.price, 0, products)) // 105000

한 줄로 표현할 수 있다. 콜백 함수는 누산기 total_price에 현재 상품의 price를 계속 더한다.

filter와 조합하여 조건부 합계

20000원 이상 상품의 가격만 합산해보자.

const filter = (f, iter) => {
  let res = []
  for (const a of iter) {
    if (f(a)) res.push(a)
  }
  return res
}

log(
  reduce(
    (total_price, product) => total_price + product.price,
    0,
    filter((p) => p.price >= 20000, products)
  )
) // 75000

filter로 먼저 조건에 맞는 상품을 걸러낸 뒤, reduce로 합산한다.

다양한 Iterable에서 사용하기

mapfilter처럼 reduce도 모든 Iterable에서 동작한다.

Set

log(reduce(add, 0, new Set([1, 2, 3, 4, 5]))) // 15

Set도 Iterable이므로 바로 사용할 수 있다.

문자열

문자열의 문자 개수를 세어보자.

log(reduce((count, char) => count + 1, 0, 'hello')) // 5

특정 문자의 개수를 세는 것도 가능하다.

log(reduce((count, char) => (char === 'l' ? count + 1 : count), 0, 'hello')) // 2

Generator

제네레이터를 사용해서 시작 숫자부터 끝 숫자까지 증가하는 함수를 만들어보자.

function* range(start, end) {
  while (start <= end) {
    yield start++
  }
}

log(reduce(add, 0, range(1, 10))) // 55

1부터 10까지의 합을 간단히 계산할 수 있다.

Map 객체

Map 객체의 값들을 합산해보자.

let m = new Map()
m.set('a', 10)
m.set('b', 20)
m.set('c', 30)

// 값만 합산
log(reduce((acc, [k, v]) => acc + v, 0, m)) // 60

키를 배열로 모을 수도 있다.

// 키를 배열로 수집
log(reduce((acc, [k, v]) => [...acc, k], [], m)) // ['a', 'b', 'c']

복잡한 데이터 구조 생성하기

reduce는 단순 합계뿐만 아니라 복잡한 데이터 구조를 생성할 수 있다.

배열을 객체로 변환

const pairs = [
  ['a', 1],
  ['b', 2],
  ['c', 3],
]

const obj = reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}, pairs)
log(obj) // { a: 1, b: 2, c: 3 }

초기값으로 빈 객체 {}를 주고, 각 [key, value] 쌍을 객체에 추가한다.

데이터 그룹화

상품을 가격 범위별로 그룹화해보자.

const groupedByPrice = reduce(
  (acc, product) => {
    const key = product.price >= 20000 ? 'expensive' : 'cheap'
    if (!acc[key]) acc[key] = []
    acc[key].push(product)
    return acc
  },
  {},
  products
)

log(groupedByPrice)
// {
//   cheap: [
//     { name: '반팔티', price: 15000 },
//     { name: '핸드폰케이스', price: 15000 }
//   ],
//   expensive: [
//     { name: '긴팔티', price: 20000 },
//     { name: '후드티', price: 30000 },
//     { name: '바지', price: 25000 }
//   ]
// }

가격 범위별 상품 개수 집계

const priceRangeCount = reduce(
  (acc, product) => {
    const range =
      product.price < 20000 ? 'under20k' : product.price < 30000 ? 'under30k' : 'over30k'
    acc[range] = (acc[range] || 0) + 1
    return acc
  },
  {},
  products
)

log(priceRangeCount)
// { under20k: 2, under30k: 2, over30k: 1 }

중첩 배열 평탄화 (flatten)

const nested = [
  [1, 2],
  [3, 4],
  [5, 6],
]

const flattened = reduce((acc, arr) => [...acc, ...arr], [], nested)
log(flattened) // [1, 2, 3, 4, 5, 6]

초기값으로 빈 배열 []을 주고, 각 내부 배열을 spread operator로 펼쳐서 누적한다.

map과 filter를 reduce로 구현하기

reduce는 너무 강력해서 mapfilter도 구현할 수 있다.

mapWithReduce

const mapWithReduce = (f, iter) => {
  return reduce((acc, a) => [...acc, f(a)], [], iter)
}

log(mapWithReduce((n) => n * 2, [1, 2, 3, 4])) // [2, 4, 6, 8]

초기값으로 빈 배열을 주고, 각 요소를 변환한 값을 누적한다.

filterWithReduce

const filterWithReduce = (f, iter) => {
  return reduce((acc, a) => (f(a) ? [...acc, a] : acc), [], iter)
}

log(filterWithReduce((n) => n % 2, [1, 2, 3, 4])) // [1, 3]

조건이 true인 경우에만 배열에 추가한다.

성능 노트

위 구현은 이해하기 쉽지만, spread operator(...)를 매번 사용하면 성능이 떨어질 수 있다. 왜냐하면 spread operator는 새 배열을 만들기 때문이다.

실제 사용할 때는 push를 사용하는 것이 더 효율적이다.

const mapOptimized = (f, iter) => {
  return reduce(
    (acc, a) => {
      acc.push(f(a))
      return acc
    },
    [],
    iter
  )
}

const filterOptimized = (f, iter) => {
  return reduce(
    (acc, a) => {
      if (f(a)) acc.push(a)
      return acc
    },
    [],
    iter
  )
}

실전 조합: map, filter, reduce 함께 사용하기

세 함수를 조합하면 강력한 데이터 처리 파이프라인을 만들 수 있다.

상품 데이터 처리 파이프라인

20000원 이상 상품의 이름을 추출한 뒤 문자열로 합치기.

const map = (f, iter) => {
  let res = []
  for (const a of iter) {
    res.push(f(a))
  }
  return res
}

log(
  reduce(
    (acc, name) => acc + name + ', ',
    '',
    map(
      (p) => p.name,
      filter((p) => p.price >= 20000, products)
    )
  )
) // '긴팔티, 후드티, 바지, '

안쪽부터 실행된다.

  1. filter: 20000원 이상 상품만 선택
  2. map: 상품의 이름만 추출
  3. reduce: 이름들을 문자열로 합침

통계 정보 계산

상품 가격의 합계, 개수, 평균을 한 번에 계산해보자.

const stats = reduce(
  (acc, product) => ({
    total: acc.total + product.price,
    count: acc.count + 1,
    avg: (acc.total + product.price) / (acc.count + 1),
  }),
  { total: 0, count: 0, avg: 0 },
  products
)

log(stats)
// { total: 105000, count: 5, avg: 21000 }

초기값으로 통계 객체를 주고, 각 상품을 순회하며 통계를 업데이트한다.

Map 객체 변환 및 필터링

Map 객체에서 값이 15 이상인 항목만 골라서 값을 2배로 만든 뒤 다시 Map으로 변환.

let m = new Map()
m.set('a', 10)
m.set('b', 20)
m.set('c', 30)

const result = new Map(
  map(
    ([k, v]) => [k, v * 2],
    filter(([k, v]) => v >= 15, m)
  )
)

log(result)
// Map(2) { 'b' => 40, 'c' => 60 }

filter로 조건에 맞는 항목만 선택하고, map으로 값을 변환한 뒤, Map 생성자로 다시 Map 객체를 만든다.

Iterable을 사용하여 직접 reduce를 구현하면 무엇이 좋은가?

타입마다 별도의 함수를 만들어야 한다. 코드 중복이 심하고 유지보수가 어렵다.

다형적인 reduce 함수

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]()
    acc = iter.next().value
  }
  for (const a of iter) {
    acc = f(acc, a)
  }
  return acc
}

// 모든 Iterable에 동일한 함수 사용
reduce(add, 0, [1, 2, 3]) // 배열
reduce(add, 0, new Set([1, 2, 3])) // Set
reduce(add, 0, range(1, 5)) // Generator
reduce((acc, [k, v]) => acc + v, 0, m) // Map

하나의 함수로 모든 Iterable을 처리할 수 있다.

커스텀 Iterable 예제: 피보나치

커스텀 Iterable을 만들어서 reduce를 사용할 수도 있다.

function* fibonacci(limit) {
  let [a, b] = [0, 1]
  let count = 0
  while (count < limit) {
    yield a
    ;[a, b] = [b, a + b]
    count++
  }
}

// 처음 10개의 피보나치 수 합계
log(reduce(add, 0, fibonacci(10))) // 88

fibonacci는 Generator이므로 Iterable이다. 별도의 변환 없이 바로 reduce를 사용할 수 있다.

참고 자료

출처

인프런 함수형 프로그래밍과 JavaScript ES6+ 강의를 학습하고 정리한 내용입니다.

함수형 프로그래밍과 JavaScript ES6+