피벗테이블 엔진을 직접 만들며 내린 결정들 — 단일 패스 집계와 셀 병합
피벗 엔진을 직접 만들며 내린 결정 다섯 개. 평균은 저장하지 않고, 순회는 한 번만 하고, 병합은 1차원 비교로 끝냅니다. 3,100줄 순수 함수 엔진의 설계 노트예요.

피벗테이블 엔진을 직접 만들었어요. 상용 그리드를 걷어낸 뒤(1편·7편)의 이야기입니다. 집계 엔진은 약 3,100줄의 순수 함수 모듈 하나에 모여 있고, 전용 테스트가 1,200줄 넘게 붙어요.
오늘은 그 엔진을 만들며 내린 결정 다섯 개를 풀어볼게요.
피벗이 생각보다 어려운 이유
표를 그리는 것과 피벗을 만드는 건 다른 일이에요. 어디가 어려운지 먼저 적어 둘게요.
| 요구 | 쉬워 보이는 이유 | 실제로 어려운 지점 |
|---|---|---|
| 행·열 다중 그룹 | 그룹핑 두 번 하면 되잖아요 | 조상 노드마다 소계가 필요해서 그룹이 트리가 됨 |
| 소계와 총계 | 합계 한 줄 더 그리면 되죠 | 소계 개수가 그룹 깊이에 따라 폭발함 |
| 다섯 가지 집계 | 각각 계산하면 됨 | 같은 데이터를 다섯 번 순회하게 됨 |
| 다단계 헤더 병합 | colspan 계산 | 같은 그룹이 연속인지 판정해야 함 |
| 사용자 정의 수식 | 수식 파서 만들면 됨 | 계산 시점에 따라 답이 달라짐 |
마지막 줄이 이 글에서 제일 하고 싶은 이야기예요. 나머지는 성능 문제인데, 저건 정답이 갈리는 문제거든요.
그리고 이 전부를 대용량에서 해야 했어요. 상용 그리드를 걷어낸 뒤라 물러설 데도 없었고요.
다섯 가지 결정
1 · 대수적 집계 상태
피벗은 셀마다 sum/avg/count/min/max 다섯 집계를 보여줘야 해요. 순진하게 하면 집계 함수별로 원본을 다시 순회하거나, 셀마다 원본 레코드 목록을 들고 있게 되죠. 대신 결합법칙이 성립하는 최소 상태 4개(sum, count, min, max)만 유지하고, avg는 표시 시점에 sum/count로 파생했어요.
(평균은 저장하지 않아요. 그때그때 나눕니다.)

핵심은 셀이 원본 레코드를 안 들고 있는 거예요. 대신 합칠 수 있는 최소 상태만 유지합니다.
// 셀 하나가 들고 있는 전부
type AggState = {
sum: number;
count: number;
min: number;
max: number;
};
// 두 상태를 합치는 연산이 결합법칙을 만족합니다
const merge = (a: AggState, b: AggState): AggState => ({
sum: a.sum + b.sum,
count: a.count + b.count,
min: Math.min(a.min, b.min),
max: Math.max(a.max, b.max),
});
// 평균은 저장하지 않고 표시할 때 나눕니다
const avg = (s: AggState) => (s.count === 0 ? null : s.sum / s.count);결합법칙이 성립한다는 게 왜 중요하냐면, 소계를 따로 계산할 필요가 없어지거든요. 자식들의 상태를 그냥 합치면 부모의 상태가 됩니다.
| 저장하는 것 | 소계 계산 | 문제 |
|---|---|---|
| 셀마다 원본 레코드 목록 | 자식 목록을 이어붙여 다시 집계 | 메모리가 원본 크기의 배수로 늘어남 |
| 셀마다 최종 집계값(avg 포함) | avg끼리 평균? | 틀린 답. 가중치가 사라짐 |
| 합칠 수 있는 최소 상태 | 자식 상태를 merge | 메모리 고정, 답 정확 |
두 번째 줄이 실제로 흔한 버그예요. 평균의 평균은 평균이 아니거든요. 그래서 avg를 아예 저장하지 않기로 했습니다. 저장할 수 없으면 잘못 쓸 수도 없어요.
2 · 단일 패스
레코드 1건을 처리할 때 그 레코드의 행 경로·열 경로에 있는 모든 조상 노드의 셀/행합계/열합계/총합계 4종 AggMap을 동시에 누적해요. 원본 순회는 정확히 한 번. 합계 행을 위해 다시 돌지 않습니다.
// 뼈대 (일반화)for (const record of rows) {
const rPath = rowPathOf(record), cPath = colPathOf(record);
for (const r of ancestors(rPath))
for (const c of ancestors(cPath)) {
accumulate(cellAgg, key(r, c), record.value);
}
// rowAgg · colAgg · grandAgg도 같은 루프에서 누적
}단일 패스가 왜 필요했는지는 계산해 보면 바로 나와요.
| 방식 | 원본 순회 횟수 | 행 100만 · 집계 5종 · 깊이 3일 때 |
|---|---|---|
| 집계별로 따로 순회 | 집계 종류 수만큼 | 500만 회 |
| 소계를 나중에 다시 계산 | 집계 × 깊이 | 1,500만 회 |
| 한 번 순회하며 조상까지 누적 | 1회 | 100만 회 |
세 번째 줄이 가능한 이유가 앞의 결합법칙이에요. 레코드 하나를 처리할 때 그 레코드가 속한 모든 조상 노드에 같은 상태를 더해 두면, 나중에 소계를 다시 계산할 일이 없습니다.
대신 코드가 조금 낯설어져요. "이 레코드가 영향을 주는 모든 셀"을 먼저 구하고 한꺼번에 갱신하는 모양이라, 익숙한 groupBy 코드와 안 닮았거든요. 그래서 주석과 테스트에 공을 많이 들였습니다.
3 · 헤더 병합은 run-length
다단계 열 헤더의 colspan 병합은 leaf 컬럼들의 경로를 level별로 훑으면서, 같은 그룹 경로가 연속되는 동안 현재 셀의 endIndex만 늘리는 run-length 방식이에요. 말단 노드는 남은 header depth를 rowSpan으로 흡수하고요. 정렬된 순회에서 병합은 "이전과 같은가"라는 질문 하나로 끝나요. 2차원 문제를 1차원 비교로 바꾸는 것 — 이게 핵심이었습니다.
병합 판정도 같은 태도예요. 이전 것과 같은지만 봅니다.
// 정렬된 leaf 컬럼들을 level별로 훑습니다
let current = null;
for (const leaf of leafColumns) {
const groupKey = leaf.path[level];
if (current && current.key === groupKey) {
current.endIndex += 1; // 연속이면 늘리기만
} else {
current = { key: groupKey, startIndex: i, endIndex: i };
cells.push(current);
}
}
// colSpan = endIndex - startIndex + 1
// 말단 노드는 남은 헤더 깊이를 rowSpan으로 흡수합니다정렬된 순회에서 병합은 "이전과 같은가"라는 지역 판정으로 끝나요. 전역에서 그룹 크기를 미리 세거나 인덱스 맵을 만들 필요가 없습니다.
이런 게 만족스러운 순간이에요. 어려워 보이던 문제가 정렬 하나로 지역 문제가 되는 거요.
4 · 서버 집계와 클라이언트 집계의 수렴
데이터가 작으면 브라우저에서 집계하고(위의 create 경로), 크면 백엔드가 집계 모델(행 노드/열 노드/셀)을 내려줘요. 두 경로는 입구가 다르지만 동일한 뷰 모델로 수렴해서, 렌더러는 데이터가 어디서 집계됐는지 모릅니다.
서버 모델의 평탄한 대량 행은 Proxy로 감싸서 접근 시점에만 노드 객체로 변환해요. 초기 변환 비용을 뷰포트에 보이는 만큼으로 줄이는 지연 평가입니다.
두 경로가 만나는 지점을 코드로 보면 이렇습니다.
// 입구는 둘
const model = source === "client"
? aggregateInBrowser(rows, spec) // 작을 때
: normalizeServerModel(response); // 클 때
// 출구는 하나
renderPivot(model);
// 렌더러는 어디서 집계됐는지 모릅니다이 경계를 지키면 좋은 게 하나 더 있어요. 같은 입력으로 두 경로를 돌려서 결과가 같은지 테스트할 수 있습니다. 서버 집계와 브라우저 집계가 갈라지는 사고를 테스트가 잡아요.
그리고 서버가 내려주는 평탄한 대량 행은 통째로 객체로 만들지 않습니다. 접근할 때만 변환해요. 화면에 보이는 건 수십 행인데 수만 행을 미리 변환할 이유가 없거든요.
5 · 커스텀 수식의 두 가지 compute mode
사용자 정의 수식 필드([필드A] / [필드B] 같은)는 컴파일해서 쓰는데, 계산 시점을 두 모드로 나눴어요. 행 단위 계산 후 집계와 집계값끼리 계산은 비율 지표에서 완전히 다른 답을 내거든요. 이익률 = profit/revenue를 행마다 계산해 평균 내면 왜곡되고, 집계된 profit 합을 revenue 합으로 나눠야 맞는 경우가 많아요. 어느 쪽이 맞는지는 지표의 의미에 달렸으니, 엔진이 정하지 않고 모드로 노출했습니다.
(이익률을 행마다 평균 내면 사고가 납니다. 진짜로요.)
말로만 하면 안 와닿으니 숫자로 볼게요. 이익률을 두 방식으로 계산해 봅니다.
| 지점 | 매출 | 이익 | 행 단위 이익률 |
|---|---|---|---|
| A | 100 | 10 | 10% |
| B | 900 | 180 | 20% |
| 합계 | 1,000 | 190 | — |
행마다 계산한 뒤 평균 내면 15%가 나와요. 집계된 값끼리 계산하면 190 ÷ 1,000 = 19%고요. 4%p 차이입니다.
어느 쪽이 맞냐고요? 둘 다 맞아요. 다른 질문에 대한 답이거든요. 앞쪽은 "지점들의 평균적인 이익률", 뒤쪽은 "전체 이익률"입니다.
// 그래서 수식 필드에 계산 시점을 명시하게 했습니다
{
name: "이익률",
expression: "[이익] / [매출]",
computeMode: "afterAggregate", // 집계된 값끼리 계산
// computeMode: "perRow" // 행마다 계산 후 집계
}기본값을 뭘로 할지가 제일 고민이었어요. 결국 집계 후 계산을 기본으로 뒀습니다. 비율 지표는 그쪽이 맞는 경우가 훨씬 많거든요.
그리고 화면에 어느 모드인지 표시했어요. 숫자가 다르게 보일 때 "왜 다르지"를 사용자가 스스로 풀 수 있어야 하니까요. 이익률을 행마다 평균 내면 사고가 납니다. 진짜로요.
경계도 테스트로
이 엔진이 UI 레이어에 있으면서도 제품/런타임을 모르게 유지되는 건, 소스를 파일 시스템으로 스캔하는 경계 테스트 덕이에요. UI 모듈 소스에 상위 레이어 import 문자열이 등장하면 실패합니다. 4편에서 말한 원칙 그대로 — 순수성은 선언이 아니라 검사예요.
3,100줄에 테스트가 1,200줄인 이유
집계 엔진은 눈으로 검증이 안 돼요. 화면에 숫자가 뜨는데, 그 숫자가 맞는지는 아무도 모릅니다. 그럴듯하게 틀린 숫자가 제일 무섭죠.
| 테스트 종류 | 무엇을 고정하나 |
|---|---|
| 속성 테스트 | 자식 상태를 merge한 값 = 부모를 직접 집계한 값 |
| 경로 수렴 테스트 | 같은 입력에서 서버 경로와 브라우저 경로의 결과가 같음 |
| 경계 케이스 | 빈 그룹 · null 값 · 0으로 나누기 · 단일 행 |
| 수식 모드 | 같은 데이터에서 두 모드가 의도한 대로 다른 값을 냄 |
첫 줄이 이 엔진의 뼈대예요. 결합법칙이 성립한다는 전제 위에 전부가 서 있어서, 그 전제가 깨지면 나머지가 조용히 다 틀립니다. 그래서 그것만 따로 검사해요.

