Computer >> 컴퓨터 >  >> 프로그래밍 >> JavaScript

JavaScript 객체 배열을 하나 또는 여러 속성 기준으로 효율적으로 그룹화하는 방법

실무에서 데이터를 다루다 보면 객체 배열을 특정 속성을 기준으로 그룹화하고, 각 그룹의 값을 집계해야 하는 경우가 자주 발생합니다. 이번 글에서는 Mapreduce를 활용해 하나 또는 여러 개의 속성을 기준으로 객체 배열을 유연하게 그룹화하는 방법을 알아보겠습니다.

문제 상황

다음과 같이 Phase, Step, Task, Value 속성을 가진 객체 배열이 있다고 가정해 보겠습니다.

const arr = [
  { Phase: "Phase 1", Step: "Step 1", Task: "Task 1", Value: "5" },
  { Phase: "Phase 1", Step: "Step 1", Task: "Task 2", Value: "10" },
  { Phase: "Phase 1", Step: "Step 2", Task: "Task 1", Value: "15" },
  { Phase: "Phase 1", Step: "Step 2", Task: "Task 2", Value: "20" },
  { Phase: "Phase 2", Step: "Step 1", Task: "Task 1", Value: "25" },
  { Phase: "Phase 2", Step: "Step 1", Task: "Task 2", Value: "30" },
  { Phase: "Phase 2", Step: "Step 2", Task: "Task 1", Value: "35" },
  { Phase: "Phase 2", Step: "Step 2", Task: "Task 2", Value: "40" }
];

여기서 우리가 작성할 함수는 이 배열을 첫 번째 인수로 받고, 두 번째 인수로 그룹화 기준이 될 속성(하나 또는 여러 개), 세 번째 인수로 합산 대상이 되는 값의 키를 전달받습니다. 즉, 지정된 속성 조합별로 객체들을 묶고 해당 그룹의 Value 값을 모두 더하는 것이 목표입니다.

기대 결과

예를 들어 Phase만 기준으로 그룹화하면 다음과 같은 결과를 얻어야 합니다.

const output = [
  { Phase: "Phase 1", Value: 50 },
  { Phase: "Phase 2", Value: 130 }
];

Phase와 Step 두 속성을 기준으로 그룹화하면 결과는 다음과 같습니다.

const output = [
  { Phase: "Phase 1", Step: "Step 1", Value: 15 },
  { Phase: "Phase 1", Step: "Step 2", Value: 35 },
  { Phase: "Phase 2", Step: "Step 1", Value: 55 },
  { Phase: "Phase 2", Step: "Step 2", Value: 75 }
];

구현 코드

이를 구현한 전체 코드는 다음과 같습니다.

const arr = [
  { Phase: "Phase 1", Step: "Step 1", Task: "Task 1", Value: "5" },
  { Phase: "Phase 1", Step: "Step 1", Task: "Task 2", Value: "10" },
  { Phase: "Phase 1", Step: "Step 2", Task: "Task 1", Value: "15" },
  { Phase: "Phase 1", Step: "Step 2", Task: "Task 2", Value: "20" },
  { Phase: "Phase 2", Step: "Step 1", Task: "Task 1", Value: "25" },
  { Phase: "Phase 2", Step: "Step 1", Task: "Task 2", Value: "30" },
  { Phase: "Phase 2", Step: "Step 2", Task: "Task 1", Value: "35" },
  { Phase: "Phase 2", Step: "Step 2", Task: "Task 2", Value: "40" }
];

const groupBy = (array, groups, valueKey) => {
  const map = new Map;
  groups = [].concat(groups);
  return array.reduce((acc, val) => {
    groups.reduce((accu, value, ind, { length }) => {
      let child;
      if (accu.has(val[value])) {
        return accu.get(val[value]);
      };
      if (ind + 1 === length) {
        child = Object
          .assign(...groups.map(value => ({ [value]: val[value] })), { [valueKey]: 0 });
        acc.push(child);
      }
      else {
        child = new Map;
      };
      accu.set(val[value], child);
      return child;
    }, map)[valueKey] += +val[valueKey];
    return acc;
  }, []);
};

console.log(groupBy(arr, 'Phase', 'Value'));
console.log(groupBy(arr, ['Phase', 'Step'], 'Value'));

실행 결과

콘솔에 출력되는 결과는 다음과 같습니다.

[ { Phase: 'Phase 1', Value: 50 }, { Phase: 'Phase 2', Value: 130 } ]
[
  { Phase: 'Phase 1', Step: 'Step 1', Value: 15 },
  { Phase: 'Phase 1', Step: 'Step 2', Value: 35 },
  { Phase: 'Phase 2', Step: 'Step 1', Value: 55 },
  { Phase: 'Phase 2', Step: 'Step 2', Value: 75 }
]

코드 동작 원리

이 함수가 효율적으로 동작하는 핵심은 중첩된 Map 구조입니다. 각 부분을 살펴보겠습니다.

1. 유연한 인수 처리
[].concat(groups)를 사용하면 두 번째 인수로 단일 문자열('Phase')을 넘기든, 배열(['Phase', 'Step'])을 넘기든 항상 배열 형태로 통일됩니다. 덕분에 하나의 함수로 단일 키 그룹화와 복합 키 그룹화를 모두 지원할 수 있습니다.

2. 바깥쪽 reduce — 결과 배열 생성
바깥쪽 reduce는 원본 배열의 각 요소를 순회하며 최종 결과로 반환될 배열(acc)을 누적합니다.

3. 안쪽 reduce — 그룹 경로 탐색 및 생성
안쪽 reduce는 그룹화 기준 속성들을 순서대로 따라가며 중첩 Map 구조를 탐색합니다. 이미 존재하는 그룹이면 get()으로 재사용하고, 없으면 새로 생성합니다. 마지막 레벨에 도달하면 그룹 속성들과 valueKey: 0으로 초기화된 객체를 만들어 결과 배열에 push합니다.

4. 값 누적
마지막 줄의 [valueKey] += +val[valueKey]에서 단항 연산자 +가 문자열 값을 숫자로 변환한 뒤 기존 값에 더해 누적합을 계산합니다.

이러한 구조 덕분에 그룹 조회가 Map의 해시 기반 접근으로 이루어져, 배열 전체를 반복적으로 탐색하는 방식보다 성능 면에서 유리합니다. 데이터 양이 많은 환경에서도 O(n × k)(n은 배열 길이, k는 그룹 키 개수) 수준의 시간 복잡도로 처리할 수 있습니다.

마무리

Map과 reduce를 조합하면 외부 라이브러리 없이도 순수 JavaScript만으로 강력하고 유연한 그룹화 로직을 구현할 수 있습니다. 참고로 ES2024부터 도입된 Object.groupBy()Map.groupBy()를 사용하면 더욱 간결하게 그룹화할 수 있으니, 프로젝트의 런타임 환경에 따라 적절한 방식을 선택하시기 바랍니다.