총매출 한 줄 코드는 에러 없이 틀립니다: AI 시대에 분석가가 할 일
영국 온라인몰의 실제 거래 기록 54만 행에 “총매출 구해줘”를 코드 한 줄로 실행하면 £9,747,765.93이 에러 없이 나옵니다. 그런데 같은 데이터로 총매출을 다시 정의하면 약 975만~1,066만 파운드 사이의 네 가지 숫자가 나오고, 주문 수는 541,910건부터 19,773건까지 네 가지로 갈립니다. 코드가 틀린 게 아니라, 무엇을 세는지 확인하지 않은 채 숫자를 믿었기 때문입니다. AI가 코드를 거의 공짜로 써주는 지금, 분석가에게 남는 일은 바로 이 확인입니다.
데이터 핸들링은 분석 전에 데이터를 쓸 수 있게 만드는 일입니다
데이터 핸들링은 분석 전에 데이터를 쓸 수 있는 상태로 만드는 모든 작업을 말합니다. 파일을 불러오고, 컬럼 타입을 정하고, 빠진 값과 겹친 값을 확인하고, 원하는 단위로 묶어 집계하는 일이죠. 실무에서 분석가의 시간은 그래프를 그리는 것보다 이쪽에 훨씬 많이 들어갑니다.
요즘은 이 코드를 AI가 몇 초 만에 써 줍니다. 코드를 쓰는 비용은 거의 사라졌지만, 결과가 맞는지 판단하는 일은 그대로 남았습니다. 오히려 코드가 빨리 나올수록 확인 없이 넘어가기 쉬워졌습니다.
까다로운 건 틀린 결과가 겉보기에 멀쩡하다는 점입니다. 코드는 실행되고, 숫자는 그럴듯하고, 경고도 없습니다. 이 글에서는 그런 상황을 실제 데이터로 재현해 봅니다.
실습 데이터: UCI Online Retail II
UCI 머신러닝 저장소의 Online Retail II는 영국에 등록된 무점포 온라인 소매업체의 2009년 12월부터 2011년 12월까지 실제 거래 기록입니다. 주로 선물용 잡화를 팔고, 고객 중 상당수가 도매상입니다. CC BY 4.0 라이선스라 출처를 밝히면 자유롭게 쓸 수 있습니다.
online_retail_II.xlsx에는 시트가 두 개 있습니다. 이 글은 주로 Year 2010-2011 시트(541,910행)를 씁니다. 실행 환경은 Python 3.9.6, pandas 2.3.3, openpyxl입니다.
총매출 한 줄: £9,747,765.93
가장 먼저 떠오르는 코드는 이것입니다.
df["amount"] = df["Quantity"] * df["Price"]
df["amount"].sum() # £9,747,765.93
사람이 쓰든 AI가 쓰든 가장 단순한 답은 이 한 줄입니다. 에러 없이 숫자가 나오니 보고서에 그대로 옮기고 싶어집니다. 그래서 AI에게 코드를 맡길 때는 이 숫자가 틀릴 수 있는 경우를 함께 묻는 습관이 필요합니다. 예를 들면 이렇게요.
총매출 집계 코드를 짜 줘. 그리고 이 결과가 틀릴 수 있는 원인을 세 가지 꼽고, 각각을 확인할 수 있는 코드도 같이 보여 줘. 중복된 행, 빈 값, 잘못 읽힌 타입은 꼭 점검해 줘.
이렇게 되물으면 확인할 거리가 생깁니다. 아래는 그 확인을 직접 해 본 결과입니다. 전체 코드는 글 끝에 있습니다.
확인 1: 한 행은 무엇인가
행 수: 541910 | 송장 수: 25900 | 송장당 행 수 중앙값/평균/최대: 10.0 20.9 1114
행은 54만 개인데 송장(Invoice)은 2만 6천 개입니다. 이 데이터의 **한 행은 주문 한 건이 아니라 “송장 한 장 안의 상품 한 줄”**입니다. 이렇게 한 행이 무엇을 뜻하는지를 데이터의 grain(그레인, 기록 단위)이라고 부릅니다.
이 한 문장을 모르면 계산이 크게 어긋납니다. 행 단위 평균 금액은 £17.99지만, 송장 단위로 계산한 주문당 평균 금액은 £519.45입니다. 같은 “평균 주문 금액”이 29배 차이 납니다.
확인 2: 설명서와 실제 파일은 다릅니다
UCI 페이지의 변수 설명과 실제 파일을 대조했습니다.
| 항목 | 설명서 | 실제 파일 |
|---|---|---|
| 컬럼 이름 | InvoiceNo, UnitPrice, CustomerID | Invoice, Price, Customer ID |
| 송장 번호 | 거래마다 고유한 6자리 숫자, c로 시작하면 취소 | 한 송장이 여러 행. 취소 C 9,288행 외에 설명에 없는 A 3행 |
| 상품 코드 | 상품마다 고유한 5자리 숫자 | 숫자 5자리 487,036행, 85123A처럼 문자가 붙은 코드 51,878행, 그 밖의 코드 2,996행 |
| 고객 번호 | 고객마다 고유한 5자리 숫자 | 135,080행(24.9%)이 비어 있음 |
| 수량 | 상품 수량 | 취소가 아닌데 수량이 음수인 행 1,336개 |
하나씩 열어 보면 성격이 다 다릅니다.
A송장 3행은 설명이 모두Adjust bad debt(부실채권 조정)입니다. 판매가 아니라 회계 조정입니다.- 그 밖의 코드 2,996행은
POST(배송비),DOT(닷컴 배송비),M(수동 입력),C2(운송비),D(할인),BANK CHARGES(은행 수수료),AMAZONFEE(아마존 수수료), 기프트 바우처 같은 비상품 항목입니다. - 문자가 붙은 코드는 같은 제품군 안의 디자인·색상 구분입니다(
85099B빨간 도트 점보백,85099F딸기 무늬 점보백). 그런데85123A와85123a처럼 대소문자만 다른 코드가 같은 상품을 가리켜서, 상품 수가 그대로 세면 4,037개, 대문자로 통일하면 3,926개입니다. - 음수 수량 1,336행은 모두 단가 0, 고객 번호 없음이고 설명란에
check,damaged,?같은 값이 있습니다. 판매가 아니라 재고 조정으로 보입니다.
확인 3: 시트 두 개는 기간이 겹칩니다
2년 치를 보려고 두 시트를 이어 붙이기 전에 기간을 확인했습니다.
겹치는 기간 행 수: 22523 | 그중 두 시트에 모두 있는 행: 22523
2010년 12월 1일부터 9일까지의 22,523행이 두 시트에 똑같이 들어 있습니다. 그냥 합치면 이 9일 치가 두 번 집계됩니다. UCI 페이지의 전체 행 수 1,067,371도 두 시트를 단순히 더한 값이라, ‘실제 거래 행 수’로 인용하면 안 됩니다.
그래서 총매출은 얼마인가: 네 가지 답
같은 시트에서 정의를 하나씩 바꿔 가며 다시 계산했습니다.
A. 모든 행: £9,747,765.93
B. 취소·조정 제외: £10,655,640.48
C. 상품만: £10,271,034.61
D. 같은 행 제거: £10,246,820.87
주문 수: 행 541910 | 송장 25900 | 구매 송장 22061 | 상품 판매가 있는 송장 19773
구매 송장 중 상품 판매가 없는 송장: {'재고 조정만': 2102, '비상품만': 177, '기타': 9}
A에서 B로 가면 금액이 오히려 늘어납니다. 취소 송장은 수량이 음수라서, 모든 행을 더하면 취소 금액 약 90만 파운드가 자동으로 빠지기 때문입니다. 그래서 A는 “취소를 뺀 금액”에 가깝지만 배송비·수수료·회계 조정이 섞여 있어 매출이라고 부르기 어렵습니다.
| 정의 | 금액 | 맞는 질문 |
|---|---|---|
| A. 모든 행 | £9,747,766 | 성격이 다른 금액이 섞여 있어 쓸 일이 거의 없음 |
| B. 취소·조정 제외 | £10,655,640 | 취소(C)·조정(A) 송장만 뺀 금액. 배송비·수수료 같은 비상품이 섞여 있음 |
| C. 상품만 | £10,271,035 | 취소 전 상품 판매 금액 |
| D. 같은 행 제거 | £10,246,821 | C와 같되 중복 입력을 의심해 뺀 경우 |
주문 수도 마찬가지입니다. 여기서 ‘구매 송장’은 취소(C)·조정(A) 송장을 뺀 나머지입니다. 구매 송장 22,061건과 상품 판매가 있는 송장 19,773건의 차이 2,288건은 재고 조정만 있는 송장 2,102건, 배송비 같은 비상품만 있는 송장 177건, 나머지 9건입니다. 객단가나 전환율의 분모로 어느 쪽을 쓰느냐에 따라 결과가 10% 넘게 달라집니다.
어느 숫자도 계산은 맞습니다. 중요한 건 숫자 옆에 정의를 적는 것입니다.
확인 4: 중복과 결측은 지우기 전에 무게를 잽니다
AI에게 점검을 요청한 세 가지 중 중복과 결측은 이렇게 나왔습니다.
- 완전히 같은 행 5,268개. 송장 536409에는 같은 상품·수량·시각·단가의 행이 두 번씩 있습니다. 입력 중복일 수도, 실제로 두 번 스캔한 것일 수도 있어서 파일만으로는 원인을 확정할 수 없습니다. 제거하면 매출이 £24,213.74(0.24%) 줄어든다는 사실을 함께 보고합니다.
- 고객 번호 결측. 행 기준으로는 24.9%지만 상품 매출 기준으로는 14.7%입니다. 재구매율이나 RFM 같은 고객 분석은 결국 매출의 약 85%에 대한 결과라는 뜻입니다. 빈 번호를 “비회원”이라는 고객 한 명으로 묶으면, 가장 큰 고객이 새로 하나 생기는 왜곡이 생깁니다.
세 번째 점검 항목인 타입 오류는 이 데이터에서 가장 조용하고 가장 위험한 문제였습니다. 이건 다음 글에서 다룹니다.
포트폴리오에는 숫자와 정의를 함께 남기세요
분석 포트폴리오의 흔한 약점은 그래프는 화려한데 그 숫자가 무엇을 센 것인지 알 수 없다는 점입니다. 이 글의 확인 과정을 표 하나로 남기면 면접에서 “왜 주문이 19,773건인가요?”라는 질문에 바로 답할 수 있습니다.
| 항목 | 이 데이터에서 적은 내용 |
|---|---|
| 한 행의 의미 | 송장 한 장 안의 상품 한 줄 (송장당 평균 21행) |
| 사용 범위 | Year 2010-2011 시트. 다른 시트와 겹치는 9일 치는 한 번만 사용 |
| 설명서와 다른 점 | 컬럼 이름, A 송장, 문자 붙은 상품 코드(대소문자 혼용), 비상품 코드, 재고 조정 행 |
| 지표 정의 | 주문 = 상품 판매가 있는 송장 19,773건 / 매출 = 취소 전 상품 판매 금액 £10,271,035 |
| 확정하지 못한 것 | 완전히 같은 행 5,268개의 원인 (제거 시 매출 -0.24%) |
| 결과의 범위 | 고객 분석은 상품 매출의 85.3%에 해당하는 거래 기준 |
전체 코드
pip install pandas openpyxl 후, UCI에서 받은 online_retail_II.xlsx와 같은 폴더에서 실행하면 위 결과가 그대로 나옵니다.
import os
import pandas as pd
XLSX = os.environ.get("RETAIL_XLSX", "online_retail_II.xlsx")
kw = dict(dtype={"Invoice": str, "StockCode": str})
y1 = pd.read_excel(XLSX, sheet_name="Year 2009-2010", **kw)
df = pd.read_excel(XLSX, sheet_name="Year 2010-2011", **kw)
df["amount"] = df["Quantity"] * df["Price"]
# 0) 가장 먼저 떠오르는 한 줄
print(f"총매출(한 줄 코드): £{df['amount'].sum():,.2f}")
# 1) 한 행은 무엇인가
per_invoice = df.groupby("Invoice").size()
print("행 수:", len(df), "| 송장 수:", df["Invoice"].nunique(),
"| 송장당 행 수 중앙값/평균/최대:", per_invoice.median(), round(per_invoice.mean(), 1), per_invoice.max())
# 2) 설명서에 없는 값
print("송장 접두사:", df["Invoice"].str.extract(r"^([A-Za-z]*)")[0].replace("", "(없음)").value_counts().to_dict())
kind = pd.Series("기타", index=df.index)
kind[df["StockCode"].str.fullmatch(r"\d{5}")] = "숫자 5자리"
kind[df["StockCode"].str.fullmatch(r"\d{5}[A-Za-z]+")] = "숫자 5자리+문자"
print("상품 코드 형태:", kind.value_counts().to_dict())
prod_codes = df.loc[df["StockCode"].str.match(r"^\d{5}"), "StockCode"]
print("상품 코드 수: 그대로", prod_codes.nunique(), "| 대문자로 통일", prod_codes.str.upper().nunique())
print("취소 아닌데 수량 음수:", ((df["Quantity"] < 0) & ~df["Invoice"].str.startswith("C")).sum())
print("고객 ID 결측:", df["Customer ID"].isna().sum(), "| 완전히 같은 행:", df.duplicated().sum())
# 3) 시트 두 개는 기간이 겹친다
overlap = y1[y1["InvoiceDate"] >= df["InvoiceDate"].min()]
key = ["Invoice", "StockCode", "Quantity", "InvoiceDate", "Price"]
found = overlap[key].merge(df[key].drop_duplicates(), how="left", indicator=True)
print("겹치는 기간 행 수:", len(overlap), "| 그중 두 시트에 모두 있는 행:", (found["_merge"] == "both").sum())
# 4) '총매출'과 '주문 수'를 정의에 따라 계산
cancel = df["Invoice"].str.startswith("C")
adjust = df["Invoice"].str.startswith("A")
product = df["StockCode"].str.match(r"^\d{5}")
sale = ~cancel & ~adjust & (df["Quantity"] > 0) & (df["Price"] > 0)
for name, value in {
"A. 모든 행": df["amount"].sum(),
"B. 취소·조정 제외": df.loc[~cancel & ~adjust, "amount"].sum(),
"C. 상품만": df.loc[sale & product, "amount"].sum(),
"D. 같은 행 제거": df.loc[sale & product].drop_duplicates()["amount"].sum(),
}.items():
print(f"{name}: £{value:,.2f}")
orders = df.loc[sale & product, "Invoice"].nunique()
print("주문 수: 행", len(df), "| 송장", df["Invoice"].nunique(),
"| 구매 송장", df.loc[~cancel & ~adjust, "Invoice"].nunique(), "| 상품 판매가 있는 송장", orders)
gap = df[df["Invoice"].isin(set(df.loc[~cancel & ~adjust, "Invoice"]) - set(df.loc[sale & product, "Invoice"]))]
gap_kind = gap.groupby("Invoice").apply(
lambda g: "재고 조정만" if ((g["Quantity"] < 0) | (g["Price"] == 0)).all()
else ("비상품만" if (~g["StockCode"].str.match(r"^\d{5}")).all() else "기타"))
print("구매 송장 중 상품 판매가 없는 송장:", gap_kind.value_counts().to_dict())
print(f"주문당 평균 금액: 행 평균 £{df['amount'].mean():,.2f} vs 송장 기준 £{df.loc[sale & product, 'amount'].sum() / orders:,.2f}")
sales = df.loc[sale & product]
print(f"고객 ID 결측: 행 기준 {sales['Customer ID'].isna().mean():.1%}, 매출 기준 "
f"{sales.loc[sales['Customer ID'].isna(), 'amount'].sum() / sales['amount'].sum():.1%}")
코드 첫 줄의 dtype={"Invoice": str, "StockCode": str}는 생략하면 안 됩니다. 이 한 줄이 없으면 어떤 일이 생기는지는 다음 글에서 이어집니다.
참고 자료
- UCI Online Retail II: 데이터 설명, 변수 정의, CC BY 4.0 라이선스
- Chen, D. (2012). Online Retail II [Dataset]. UCI Machine Learning Repository. https://doi.org/10.24432/C5CG6D
이 글의 수치는 2026년 9월 29일 UCI에서 내려받은 파일 기준입니다.