엑셀 피벗을 코드 한 줄로 — pandas groupby 집계
부서와 월별 합계가 맞는지 확인하려면 집계 코드와 예상 표가 같은 입력을 가리켜야 합니다. 여기서는 매출 850인 가상 원본 5행을 네 그룹으로 묶습니다. 원본의 건수 열을 더한 값 12와 원본 행 수 5를 따로 출력하므로, 피벗의 ‘합계’와 ‘개수’를 혼동했는지도 확인할 수 있습니다.
영업 두 행이 같은 월에 들어간 원본
첨부 ZIP의 data/sales.csv는 다음 다섯 행입니다. 실제 회사·인물의 실적은 아닙니다.
| 부서 | 월 | 매출 | 건수 |
|---|---|---|---|
| 영업 | 2026-01 | 100 | 2 |
| 영업 | 2026-01 | 200 | 3 |
| 마케팅 | 2026-01 | 150 | 1 |
| 마케팅 | 2026-02 | 180 | 2 |
| 영업 | 2026-02 | 220 | 4 |

가상 입력 영업·2026-01에는 두 행이 있으므로 매출합계는 100+200=300, 건수합계는 2+3=5, 매출평균은 (100+200)/2=150입니다. 평균의 분모는 거래 건수합계 5가 아니라 매출값을 가진 원본 2행입니다. 건당 매출을 구하려면 다른 정의가 필요하므로 여기의 매출평균과 바꾸어 부르지 않습니다.
합계·평균·행 수를 한 번에 만드는 실행 파일
-
ZIP을 풀고
data/sales.csv를 열어 헤더 순서가 부서·월·매출·건수인지, 데이터가 5행인지 확인합니다. -
pandas 2.2.3이 있는 Python 환경을 선택합니다. 필요한 경우 별도 가상환경에
requirements.txt로 설치합니다. -
ZIP을 푼 폴더에서
python3 groupby_pivot.py를 실행합니다. -
output/groupby_result.csv와expected/groupby_result.csv의 4그룹을 대조합니다. 패키지 설치에는 네트워크가 필요할 수 있지만 아래 스크립트의 입력과 출력은 로컬 CSV뿐입니다.

from pathlib import Path
import math
import pandas as pd
ROOT = Path(__file__).resolve().parent
def aggregate(frame):
required = ["부서", "월", "매출", "건수"]
if list(frame.columns) != required:
raise ValueError("입력 열은 부서, 월, 매출, 건수 순서여야 합니다.")
frame = frame.copy()
for key in ["부서", "월"]:
if frame[key].isna().any() or frame[key].astype(str).str.strip().eq("").any():
raise ValueError(f"{key} 누락: 집계 전에 원본을 확인하세요.")
for key in ["매출", "건수"]:
if frame[key].map(pd.api.types.is_bool).any():
raise ValueError(f"{key}에 논리값이 있습니다. 숫자 0/1과 구분하세요.")
frame[key] = pd.to_numeric(frame[key], errors="raise")
if not frame[key].map(lambda x: pd.notna(x) and math.isfinite(x) and x >= 0).all():
raise ValueError(f"{key}은 0 이상의 유한한 수여야 합니다.")
if (frame["건수"] % 1 != 0).any():
raise ValueError("건수는 정수여야 합니다.")
return frame.groupby(["부서", "월"], as_index=False, sort=True, dropna=False).agg(
매출합계=("매출", "sum"), 건수합계=("건수", "sum"),
매출평균=("매출", "mean"), 원본행수=("매출", "size"),
)
def main():
frame = pd.read_csv(ROOT / "data" / "sales.csv")
grouped = aggregate(frame)
expected = pd.read_csv(ROOT / "expected" / "groupby_result.csv")
pd.testing.assert_frame_equal(grouped, expected, check_dtype=False)
assert len(grouped) == 4
assert grouped["매출합계"].sum() == 850
assert grouped["건수합계"].sum() == 12
assert grouped["원본행수"].sum() == 5
output = ROOT / "output"
output.mkdir(exist_ok=True)
grouped.to_csv(output / "groupby_result.csv", index=False, encoding="utf-8-sig")
print("검산 통과: 4그룹, 매출합계 850, 건수합계 12, 원본 5행")
if __name__ == "__main__":
main()
as_index=False는 그룹 키를 일반 열로 남깁니다. sort=True는 기대 CSV와 같은 키 순서를 만드는 용도이며 매출 순위를 뜻하지 않습니다. 이름 있는 집계로 매출합계, 건수합계, 매출평균, 원본행수를 명시했으므로 여러 수준의 열 이름을 임의로 이어 붙일 필요가 없습니다.
네 그룹의 합격 값

| 부서 | 월 | 매출합계 | 건수합계 | 매출평균 | 원본행수 |
|---|---|---|---|---|---|
| 마케팅 | 2026-01 | 150 | 1 | 150 | 1 |
| 마케팅 | 2026-02 | 180 | 2 | 180 | 1 |
| 영업 | 2026-01 | 300 | 5 | 150 | 2 |
| 영업 | 2026-02 | 220 | 4 | 220 | 1 |
네 행의 매출합계는 850, 건수합계는 12, 원본행수는 5입니다. 출력 output/groupby_result.csv를 열고 expected/groupby_result.csv와 모든 열을 대조합니다. 코드도 assert_frame_equal로 이 비교를 합니다. 콘솔에 ‘검산 통과’가 나와도 그것은 제공한 가상 입력의 비교를 통과했다는 뜻이며 임의의 업무 데이터가 승인됐다는 뜻은 아닙니다.
같은 원본을 Excel 피벗과 비교할 때는 행 필드에 부서·월, 값 필드에 매출의 합계와 평균, 건수의 합계를 넣습니다. 빈 키·필터·데이터형·집계 함수가 다르면 결과도 달라질 수 있으므로 자동으로 같다고 가정하지 않습니다. 이번 검증은 Python과 CSV에서 수행했으며 Excel 피벗 실행을 수행했다는 의미는 아닙니다.
빈 키와 중복 행을 조용히 처리하지 않기
pandas groupby의 기본 dropna=True에서는 결측 키를 가진 행이 제외됩니다. ‘빈칸은 기본적으로 별도 그룹’이라는 설명은 맞지 않습니다. 이 예제는 집계 전에 부서·월의 결측값과 공백 문자열을 막고, 집계 옵션에도 dropna=False를 명시합니다. 누락 행을 미지정 그룹으로 받을지는 별도 업무 정책입니다.
원본 첫 행을 한 번 더 넣으면 매출합계가 950, 원본행수가 6이 됩니다. 코드가 같은 키를 가진 행을 자동 삭제하지 않기 때문입니다. 동일 거래를 두 번 수집한 것인지 서로 다른 거래인지 판단하려면 거래 ID 같은 별도 근거가 필요합니다. 단순히 그룹 키가 같다는 이유로 drop_duplicates를 적용하지 마세요.
재실행과 실패 복구
python3 -m unittest -v test_groupby_pivot.py로 ZIP에 제공한 10개 검산을 실행합니다. 정상 기대 표, 건수와 행 수 차이, 결측·공백 키, 음수·NaN·무한대, 소수 건수, 중복 행, 기본 dropna와의 차이, 논리값 거부와 숫자 0 허용을 다룹니다. 다운로드 ZIP에는 본문의 논리값 검사 보완 코드와 추가 테스트가 포함되어 있으므로 파일을 따로 교체할 필요가 없습니다. Linux의 Python 회귀 테스트 결과이며 Excel 앱 실행 검증은 아닙니다.
True를 매출 1로 받아들이지 않는 추가 시험
숫자 1과 논리값 True는 입력 의미가 다릅니다. 부서별 합계가 필요한 열에 참·거짓이 들어오면 해당 자료의 열 매핑부터 확인해야 합니다. 새 검사는 pd.to_numeric보다 먼저 pandas의 is_bool로 각 값을 확인합니다. 이미 True를 정수 1로 바꾼 뒤에는 이 검사만으로 원래 자료형을 복원할 수 없습니다.
ZIP 폴더에서 아래 내용을 별도 check_boolean.py로 저장하고 python3 check_boolean.py로 실행할 수 있습니다. 원본 CSV를 수정하지 않고 메모리의 사본에 네 가지 오류 입력을 넣습니다.
import pandas as pd
from groupby_pivot import aggregate, ROOT
original = pd.read_csv(ROOT / "data" / "sales.csv")
for key in ["매출", "건수"]:
for value in [True, False]:
changed = original.copy()
changed[key] = changed[key].astype(object)
changed.loc[0, key] = value
try:
aggregate(changed)
except ValueError as error:
if "논리값" not in str(error):
raise
print(key, value, "중단 확인")
else:
raise AssertionError("논리값이 집계를 통과했습니다")
zero = original.copy()
zero.loc[0, "매출"] = 0
zero.loc[0, "건수"] = 0
result = aggregate(zero)
actual = (result["매출합계"].sum(), result["건수합계"].sum(),
result["원본행수"].sum())
if actual != (750, 10, 5):
raise AssertionError(actual)
print("숫자 0 확인: 매출 750, 건수 10, 원본 5행")
정상 결과는 네 줄의 중단 확인과 마지막 숫자 0 확인입니다. 첫 매출 100과 건수 2를 0으로 바꿨으므로 합계는 각각 850−100=750, 12−2=10이지만 원본 행은 5개로 유지됩니다. False를 숫자 0으로 대신 승인하지 않는다는 점도 함께 확인하세요. 새 코드는 Python의 pandas 2.2.3 환경에서 기존 8개 테스트와 추가 2개 테스트를 합한 10개 테스트, 기본 4그룹 출력으로 검산했습니다. Excel 피벗을 실행한 결과는 아닙니다.
입력 오류는 결과 저장 전에 예외로 중단합니다. 예를 들어 첫 행 부서를 지우면 부서 누락: 집계 전에 원본을 확인하세요.로 실패합니다. 원본 첫 행을 영업,2026-01,100,2로 복구한 뒤 다시 실행하고 매출 합계 850과 원본 행 수 5를 확인합니다. 건수는 정수여야 합니다.라면 건수 열의 소수 입력을 원본과 대조하여 수정해야 하며 무조건 반올림해서는 안 됩니다. 이전 실행의 출력 파일은 남을 수 있으므로 오류가 났다면 오래된 CSV를 새 결과로 취급하지 않습니다. 재실행은 output/groupby_result.csv를 덮어쓰므로 실제 자료로 확장할 때는 새 폴더에 원본과 기대값을 분리하세요.
groupby CSV 집계의 범위와 Excel 피벗 비교 한계
CSV 데이터만 쓰며 Excel 파일 서식이나 수식을 보존하는 도구가 아닙니다. 빈 문자열과 실제 결측값은 사전 검사에서 모두 중단하며, 누락된 부서를 추측하여 채우지 않습니다. 제공된 기대 CSV는 가상 5행 전용이므로 업무 데이터를 바꾸면 원본 근거로 기대값을 새로 계산해야 합니다.
기본 옵션을 다시 확인할 자료
pandas DataFrame.groupby 공식 문서에서 API 범위를 확인할 수 있습니다. 첨부 requirements.txt는 재현 환경을 pandas==2.2.3으로 고정합니다. 다른 환경에서는 같은 원본과 기대 CSV를 먼저 대조하세요. 입력 파일을 합치는 단계는 pandas merge 글과 분리해 검산하세요.
groupby의 기본 dropna=True는 결측 키를 가진 행을 제외합니다.
출처 원문 수집일: S1 2026-09-23.