그룹화 후 집계 리포트 만들기
집계는 필터링(filter), 그룹화(group), 합산(sum), 정렬(sort), 상위 N개 선택(take)의 다섯 단계로 분해해 각 단계를 드러낸다. 누적은 맵으로, 상위 선택은 정렬 뒤 자르기로 분리한다. 금액은 부동소수가 아니라 정수 minor unit 또는 decimal 계열로 다루고, 그룹 키(group key)는 정규화한 뒤 묶는다.
연습 메모 · 14 min read · Easy
그룹화 후 집계 리포트 만들기
주문 목록에서 고객별 총액을 계산하고 상위 고객만 뽑는다.
핵심은 filter, group, sum, sort, take를 분리해서 읽히게 만드는 것이다.
집계 리포트는 경험상 항상 같은 다섯 단계로 분해된다.
거른다(filter)
묶는다(group)
더한다(sum)
정렬한다(sort)
자른다(take).
초급 코드는 이 다섯을 한 반복문에 욱여넣어서 수정하기 힘들게 만들고, 좋은 코드는 각 단계를 파이프라인으로 쉽게 보여준다. 단계가 보이면 어디서 무엇이 틀어졌는지도 그 마디에서 바로 짚을 수 있다.
이 패턴의 목적은 짧게 쓰는 것이 아니라 데이터 변환의 의도를 단계로 노출하는것 이다.
C# LINQ는 이 다섯 단계를 체인으로 표현하고,
Rust는 HashMap::entry한 번으로 vacant/occupied를 함께 다루며,
Python은 defaultdict로 누적 키 초기화를 생략한다.
여기서 entry는 키가 이미 있으면 기존 값을 수정하고, 없으면 새 값을 삽입하는 분기를 한 API 안에 묶는다. 언어는 달라도 골격은 같다. 누적은 맵으로, 정렬은 키 함수로, 상위 선택은 자르기로 분리한다.
개인메모: 누적 과정의 맵은 함수 내부 mutable 상태로 두되 외부에는 불변 결과만 반환하고, 정렬된 리스트도 생성 후에는 후속 단계에서만 소비하게 해 외부에서 수정하지 못하게 한다. 금액은 부동소수가 아니라 정수 minor unit이나 decimal 계열로 다룬다. 이
문서의 코드에서 C#은 decimal, Python은 int, Rust는 i64로 이미 이 원칙을 지키고, TypeScript만 number가 부동소수를 허용하므로 amountMinor를 bigint(정수 minor unit)로 바꿨다. number를 굳이 쓴다면 "정수 minor unit이며 safe integer 범위 안에서만 쓴다"고 못박아야 한다. 또 limit가 0 이하이면 계산 전에 빈 컬렉션을 반환하도록 모든 언어에서 통일한다. 음수 limit에서 slice나 take 동작이 언어마다 달라(끝에서부터 자르거나 전체에서 하나를 빼는 식)
결과가 제각각이기 때문이다.
표현
이 카드는 언어별 idiomatic 표기를 따른다. C#은 PascalCase, TypeScript는 camelCase, Python과 Rust는 snake_case다. 같은 개념이 CustomerId,customerId, customer_id로 다르게 보이는 것이 정상이며, 그 표기 매핑 자체가 이 카드의 학습 포인트다.
C#
public sealed record OrderRow(int CustomerId, decimal Amount, bool IsPaid);
public sealed record CustomerTotal(int CustomerId, decimal TotalAmount);
public static IReadOnlyList<CustomerTotal> CalculateTopCustomers(
IReadOnlyList<OrderRow> orders,
int limit)
{
if (limit <= 0)
{
return Array.Empty<CustomerTotal>();
}
return orders
.Where(order => order.IsPaid)
.GroupBy(order => order.CustomerId)
.Select(group => new CustomerTotal(
group.Key,
group.Sum(order => order.Amount)))
.OrderByDescending(row => row.TotalAmount)
.ThenBy(row => row.CustomerId)
.Take(limit)
.ToArray();
}TypeScript
type OrderRow = Readonly<{
customerId: number;
amountMinor: bigint;
isPaid: boolean;
}>;
type CustomerTotal = Readonly<{
customerId: number;
totalAmountMinor: bigint;
}>;
function calculateTopCustomers(
orders: readonly OrderRow[],
limit: number,
): readonly CustomerTotal[] {
if (limit <= 0) {
return [];
}
const totals = new Map<number, bigint>();
for (const order of orders) {
if (!order.isPaid) {
continue;
}
const currentTotal = totals.get(order.customerId) ?? 0n;
totals.set(order.customerId, currentTotal + order.amountMinor);
}
return [...totals.entries()]
.map(([customerId, totalAmountMinor]) => ({ customerId, totalAmountMinor }))
.sort((left, right) => {
if (left.totalAmountMinor !== right.totalAmountMinor) {
return left.totalAmountMinor < right.totalAmountMinor ? 1 : -1;
}
return left.customerId - right.customerId;
})
.slice(0, limit);
}Python
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class OrderRow:
customer_id: int
amount: int
is_paid: bool
@dataclass(frozen=True, slots=True)
class CustomerTotal:
customer_id: int
total_amount: int
def calculate_top_customers(
orders: Iterable[OrderRow],
limit: int,
) -> tuple[CustomerTotal, ...]:
if limit <= 0:
return ()
totals: defaultdict[int, int] = defaultdict(int)
for order in orders:
if not order.is_paid:
continue
totals[order.customer_id] += order.amount
rows = (
CustomerTotal(customer_id, total_amount)
for customer_id, total_amount in totals.items()
)
ordered = sorted(rows, key=lambda row: (-row.total_amount, row.customer_id))
return tuple(ordered[:limit])
Rust
use std::cmp::Ordering;
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OrderRow {
customer_id: u32,
amount: i64,
is_paid: bool,
}
impl OrderRow {
pub fn new(
customer_id: u32,
amount: i64,
is_paid: bool,
) -> Result<Self, OrderError> {
if amount < 0 {
return Err(OrderError::NegativeAmount { amount });
}
Ok(Self {
customer_id,
amount,
is_paid,
})
}
pub fn customer_id(&self) -> u32 {
self.customer_id
}
pub fn amount(&self) -> i64 {
self.amount
}
pub fn is_paid(&self) -> bool {
self.is_paid
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomerTotal {
customer_id: u32,
total_amount: i64,
}
impl CustomerTotal {
pub fn new(
customer_id: u32,
total_amount: i64,
) -> Result<Self, OrderError> {
if total_amount < 0 {
return Err(OrderError::NegativeTotalAmount { total_amount });
}
Ok(Self {
customer_id,
total_amount,
})
}
pub fn customer_id(&self) -> u32 {
self.customer_id
}
pub fn total_amount(&self) -> i64 {
self.total_amount
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrderError {
NegativeAmount { amount: i64 },
NegativeTotalAmount { total_amount: i64 },
AmountOverflow { customer_id: u32 },
}
impl fmt::Display for OrderError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NegativeAmount { amount } => {
write!(formatter, "order amount must be non-negative: {amount}")
}
Self::NegativeTotalAmount { total_amount } => {
write!(
formatter,
"total amount must be non-negative: {total_amount}"
)
}
Self::AmountOverflow { customer_id } => {
write!(formatter, "amount overflow for customer_id={customer_id}")
}
}
}
}
impl Error for OrderError {}
pub fn calculate_top_customers(
orders: &[OrderRow],
limit: usize,
) -> Result<Vec<CustomerTotal>, OrderError> {
if limit == 0 {
return Ok(Vec::new());
}
let totals = accumulate_paid_totals(orders)?;
let mut rows = to_customer_totals(totals)?;
rows.sort_by(compare_customer_totals);
rows.truncate(limit);
Ok(rows)
}
fn accumulate_paid_totals(
orders: &[OrderRow],
) -> Result<HashMap<u32, i64>, OrderError> {
let mut totals = HashMap::<u32, i64>::new();
for order in orders {
if !order.is_paid() {
continue;
}
let total = totals.entry(order.customer_id()).or_insert(0);
*total = total
.checked_add(order.amount())
.ok_or(OrderError::AmountOverflow {
customer_id: order.customer_id(),
})?;
}
Ok(totals)
}
fn to_customer_totals(
totals: HashMap<u32, i64>,
) -> Result<Vec<CustomerTotal>, OrderError> {
totals
.into_iter()
.map(|(customer_id, total_amount)| {
CustomerTotal::new(customer_id, total_amount)
})
.collect()
}
fn compare_customer_totals(
left: &CustomerTotal,
right: &CustomerTotal,
) -> Ordering {
right
.total_amount()
.cmp(&left.total_amount())
.then_with(|| left.customer_id().cmp(&right.customer_id()))
}호출부
각 언어 모두 세 케이스를 보여준다.
정상(합산과 상위 N), 동률 tie-break(입력 순서와 무관하게 customerId 오름차순), limit 가드(0 이하면 빈 결과).
C#
// 1) 정상 케이스: 고객 1은 100 + 25 = 125, 고객 2는 50
var orders = new[]
{
new OrderRow(1, 100m, true),
new OrderRow(2, 50m, true),
new OrderRow(1, 25m, true),
new OrderRow(3, 80m, false), // 미결제 -> filter에서 제외
};
CalculateTopCustomers(orders, 2);
// [CustomerTotal(1, 125), CustomerTotal(2, 50)]
// 2) 동률 tie-break: 두 고객 총액이 100으로 같다.
// 입력은 고객 2가 먼저지만, 동률이면 CustomerId 오름차순이라 고객 1이 앞에 온다.
var tied = new[]
{
new OrderRow(2, 100m, true),
new OrderRow(1, 100m, true),
};
CalculateTopCustomers(tied, 2);
// [CustomerTotal(1, 100), CustomerTotal(2, 100)]
// 3) limit 가드: 0 이하면 계산 없이 빈 결과
CalculateTopCustomers(orders, 0); // []
CalculateTopCustomers(orders, -1); // []
TypeScript
// 1) 정상 케이스: 고객 1은 100 + 25 = 125, 고객 2는 50
const orders: readonly OrderRow[] = [
{ customerId: 1, amountMinor: 100n, isPaid: true },
{ customerId: 2, amountMinor: 50n, isPaid: true },
{ customerId: 1, amountMinor: 25n, isPaid: true },
{ customerId: 3, amountMinor: 80n, isPaid: false }, // 미결제 -> filter에서 제외
];
calculateTopCustomers(orders, 2);
// [{ customerId: 1, totalAmountMinor: 125n }, { customerId: 2, totalAmountMinor: 50n }]
// 2) 동률 tie-break: 두 고객 총액이 100n으로 같다.
// 입력은 고객 2가 먼저지만, 동률이면 customerId 오름차순이라 고객 1이 앞에 온다.
const tied: readonly OrderRow[] = [
{ customerId: 2, amountMinor: 100n, isPaid: true },
{ customerId: 1, amountMinor: 100n, isPaid: true },
];
calculateTopCustomers(tied, 2);
// [{ customerId: 1, totalAmountMinor: 100n }, { customerId: 2, totalAmountMinor: 100n }]
// 3) limit 가드: 0 이하면 계산 없이 빈 결과
calculateTopCustomers(orders, 0); // []
calculateTopCustomers(orders, -1); // []
Python
# 1) 정상 케이스: 고객 1은 100 + 25 = 125, 고객 2는 50
orders = (
OrderRow(1, 100, True),
OrderRow(2, 50, True),
OrderRow(1, 25, True),
OrderRow(3, 80, False), # 미결제 -> filter에서 제외
)
calculate_top_customers(orders, 2)
# (CustomerTotal(customer_id=1, total_amount=125),
# CustomerTotal(customer_id=2, total_amount=50))
# 2) 동률 tie-break: 두 고객 총액이 100으로 같다.
# 입력은 고객 2가 먼저지만, 동률이면 customer_id 오름차순이라 고객 1이 앞에 온다.
tied = (
OrderRow(2, 100, True),
OrderRow(1, 100, True),
)
calculate_top_customers(tied, 2)
# (CustomerTotal(customer_id=1, total_amount=100),
# CustomerTotal(customer_id=2, total_amount=100))
# 3) limit 가드: 0 이하면 계산 없이 빈 결과
calculate_top_customers(orders, 0) # ()
calculate_top_customers(orders, -1) # ()
Rust
// 1) 정상 케이스: 고객 1은 100 + 25 = 125, 고객 2는 50
let orders = vec![
OrderRow::new(1, 100, true).expect("valid order"),
OrderRow::new(2, 50, true).expect("valid order"),
OrderRow::new(1, 25, true).expect("valid order"),
OrderRow::new(3, 80, false).expect("valid order"), // 미결제 -> filter에서 제외
];
let result = calculate_top_customers(&orders, 2).expect("valid aggregation");
// [
// CustomerTotal { customer_id: 1, total_amount: 125 },
// CustomerTotal { customer_id: 2, total_amount: 50 },
// ]
// 2) 동률 tie-break: 두 고객 총액이 100으로 같다.
// 입력은 고객 2가 먼저지만, 동률이면 customer_id 오름차순이라 고객 1이 앞에 온다.
let tied = vec![
OrderRow::new(2, 100, true).expect("valid order"),
OrderRow::new(1, 100, true).expect("valid order"),
];
let tied_result = calculate_top_customers(&tied, 2).expect("valid aggregation");
// [
// CustomerTotal { customer_id: 1, total_amount: 100 },
// CustomerTotal { customer_id: 2, total_amount: 100 },
// ]
// 3) limit 가드: 0이면 계산 없이 빈 결과 (usize라 음수는 들어올 수 없음)
let empty_result = calculate_top_customers(&orders, 0).expect("valid aggregation");
// []읽는 순서
limit가 유효한가 (0 이하면 계산 전에 빈 결과를 반환하는 guard clause)
어떤 행을 집계 대상으로 볼 것인가 (filter)
무엇을 기준으로 묶을 것인가 (group key)
각 그룹에서 무엇을 더할 것인가 (sum)
어떤 순서로 줄 세울 것인가 (sort, 동률은 customerId 오름차순으로 tie-break)
몇 개를 선택할 것인가 (take)
중요한 건 순서를 외우는 것이다.
어차피 필터(filter), 그룹 키(group key), 합(sum),집계(sort), 상위 N개 선택 (take)은 각각 구현 방식이 다르다.
하지만 이걸 조합하는 건 대개 저 형태를 따라온다.
정렬을 자르기 뒤에 두면 임의의 N개를 정렬할 뿐 상위 N개가 아니게 된다는 점에 유의한다.
경계 - 맵 누적 vs GroupBy 체인
값이 적고 가독성이 중요하면 LINQ의 GroupBy 체인이 의도를 가장 잘 드러
낸다.
반복이 매우 많거나 중간 그룹 객체 할당을 피해야 하는 hot path라면
TypeScript/Rust처럼 맵에 직접 누적하는 편이 할당을 줄인다.
같은 다섯 단계라도 한쪽은 선언적 표현, 다른 쪽은 할당 최소화를 택한 것이다.
경험상 체이닝 방식이 대체로 편하다. 순서대로 따라가기 편하기때문이다.
언제 쓰나
집계 리포트, 랭킹, 대시보드 상위 N, 카테고리별 합계처럼 묶어서 더하고
줄 세우는 모든 곳. 입력이 스트림으로 들어오거나 무한히 크면, 전체를
메모리에 모으는 대신 누적 맵만 유지하는 형태로 바꾼다.
잘못된 예제
// 아래는 의도적으로 나쁜 코드라, 위의 정상 OrderRow 타입과 다르다.
// amount를 float로 두고, group key를 정규화하지 않고, 반복문 안에서 DB를 조회한다.
type BadOrderRow = Readonly<{
customerId: string;
amount: number; // float money bug
isPaid: boolean;
}>;
const totals: Record<string, number> = {};
for (const order of badOrders) {
if (!order.isPaid) {
continue;
}
const customer = db.findCustomer(order.customerId); // hot loop 안 I/O -> N+1
const key = customer.email; // 정규화 안 한 group key
totals[key] = (totals[key] || 0) + order.amount; // float 누적
}
세 가지가 동시에 잘못됐다. 반복문 안 DB 조회로 N+1이 생기고, 금액을
부동소수로 더해 반올림 오차가 쌓이며, group key를 정규화하지 않아 같은
고객이 다른 키로 갈라진다.
정규화 누락은 위 예제처럼 키가 숫자(customerId)면 잘 드러나지 않는다.
문제가 실제로 터지는 건 키가 문자열일 때다. 같은 고객이 표기 차이만으로
서로 다른 키가 되어 집계가 쪼개진다.
// 같은 고객인데 서로 다른 키로 갈라지는 경우
"1" vs " 1 " // 앞뒤 공백
"[email protected]" vs "[email protected]" // 대소문자
// 누적 전에 키를 한 형태로 정규화한다
const key = rawCustomerKey.trim().toLowerCase();
totals.set(key, (totals.get(key) ?? 0n) + order.amountMinor);
핵심은 group key를 만드는 시점에 정규화를 한 곳으로 모으는 것이다. 공백
trim, 대소문자 통일, 유니코드 정규화(NFC 등)를 누적 직전에 끝내고, 그 뒤로는
정규화된 키만 흐르게 한다. 정규화를 여러 곳에 흩뿌리면 한 곳만 빠져도 같은
버그가 되살아난다.
추가로 생각해보기
집계 단계를 함수로 잘게 나누면 테스트 단위가 작아진다.
필터, 누적,정렬, 자르기를 각각 따로 검증할 수 있다. 정렬 동률 규칙(총액이 같으면 customerId 오름차순)은 위 네 언어 코드에 모두 반영했다. tie-break를 코드로 못박지 않으면 입력 순서나 정렬 안정성에 따라 결과가 흔들려, 같은 입력에도 출력 순서가 결정적으로 유지되지 않는다.
현재 예제는 입력 데이터를 애플리케이션 메모리에 올린 뒤 집계한다. 데이터가 작거나 이미 메모리에 로드된 배치라면 이 방식이 가장 단순하다. 하지만 원본이 DB에 있고 행 수가 크다면 filter, group, sum, sort, take는 가능하면 SQL의 WHERE, GROUP BY, SUM, ORDER BY, LIMIT/OFFSET으로 밀어 넣는 편이 낫다. DB는 인덱스와 실행 계획을 이용해 불필요한 I/O를 줄일 수 있고, 애플리케이션은 최종 결과인 상위 N개만 받으면 된다.
병렬화도 가능하지만 공유 맵 하나를 여러 스레드가 동시에 수정하는 방식은 피하는 것이 좋다. 락 경합 때문에 오히려 느려질 수 있다. 더 안전한 방식은 입력을 파티션으로 나누고, 각 파티션에서 로컬 맵으로 부분 합계를 만든 뒤, 마지막에 부분 맵들을 병합하는 것이다. 즉 병렬 집계는 “공유 상태에 동시에 쓰기”가 아니라 “독립 누적 후 reduce”로 생각하는 편이 좋다.
상위 N개만 필요할 때 전체 결과를 모두 정렬할 필요가 없는 경우도 있다. 고객 수가 매우 많고 limit가 작다면 전체 sort 대신 크기 N짜리 min-heap 또는 priority queue를 유지해 top-K만 남기는 방식이 더 효율적이다.
다만 이 카드는 의도 매핑 훈련이 목적이므로,
기본형은 filter → group → sum → sort → take의 읽기 쉬운 형태를 먼저 익히고, 성능 문제가 확인될 때 DB 위임, 병렬 reduce, top-K heap으로 확장한다.
요약
집계는 필터링(filter), 그룹화(group), 합산(sum), 정렬(sort), 상위 N개 선택(take)의 다섯 단계로 분해해 각 단계를 드러낸다. 누적은 맵으로, 상위 선택은 정렬 뒤 자르기로 분리한다. 금액은 부동소수가 아니라 정수 minor unit 또는 decimal 계열로 다루고, 그룹 키(group key)는 정규화한 뒤 묶는다.
잘못된 사용: 반복문 안 DB 조회, 그룹 키(group key) 정규화 누락, 금액을 float로 처리,정렬 방향 오류, limit 검증 누락.