취소율이 99.97%로 나왔습니다: pandas 타입이 메모리와 정답을 함께 바꾸는 이유
영국 온라인몰의 실제 거래 54만 행을 타입 지정 없이 읽고 취소율을 계산했더니 99.97%가 나왔습니다. 실제 취소율은 1.71%입니다. 에러는 한 번도 나지 않았습니다. 같은 데이터는 기본 설정으로 134.9MB의 메모리를 차지했고, 컬럼마다 타입을 맞추자 16.7MB로 8분의 1이 됐습니다. 다만 범위를 보지 않고 줄였을 때는 수량 74,215가 8,679로 조용히 바뀌었습니다. 타입은 메모리와 정답을 함께 결정합니다.
총매출이 네 가지로 나온 이전 글과 같은 UCI Online Retail II 데이터의 Year 2010-2011 시트(541,910행)를 씁니다.
데이터는 RAM에 올라가야 다룰 수 있습니다
파일은 디스크에 저장되어 있지만, pandas로 다루려면 메모리(RAM)에 통째로 올려야 합니다. 메모리는 디스크보다 훨씬 빠른 대신 용량이 작아서, 데이터가 메모리보다 커지면 코드가 느려지거나 멈춥니다. 노트북을 바꾸기 전에 먼저 무엇이 메모리를 차지하는지 보는 게 순서이고, 대부분 원인은 컬럼 타입에 있습니다.
같은 숫자라도 어떤 그릇에 담느냐에 따라 크기가 다릅니다.
| 타입 | 한 칸 크기 | 담을 수 있는 값 |
|---|---|---|
| int8 | 1바이트 | -128 ~ 127 |
| int16 | 2바이트 | -32,768 ~ 32,767 |
| int32 | 4바이트 | 약 -21억 ~ 21억 |
| int64 (pandas 기본값) | 8바이트 | 약 ±922경 |
| float32 | 4바이트 | 유효숫자 약 7자리 |
| float64 (pandas 기본값) | 8바이트 | 유효숫자 약 15자리 |
| category | 사전 1회 + 코드값 | 반복되는 문자열 |
pandas의 기본값 int64·float64는 “일단 넘치지 않게” 크게 잡은 그릇입니다. 실제 값의 범위를 보고 더 작은 그릇으로 옮기는 것을 다운캐스팅이라고 합니다.
기본 설정으로 읽으면: 134.9MB
pd.read_excel에 아무 옵션도 주지 않고 읽은 결과입니다.
행 수: 541,910
기본 타입: {'Invoice': 'object', 'StockCode': 'object', 'Description': 'object', 'Quantity': 'int64', 'InvoiceDate': 'datetime64[ns]', 'Price': 'float64', 'Customer ID': 'float64', 'Country': 'object'}
전체 메모리: 134.9 MB
컬럼별 MB: {'Invoice': 18.9, 'StockCode': 20.0, 'Description': 43.2, 'Quantity': 4.1, 'InvoiceDate': 4.1, 'Price': 4.1, 'Customer ID': 4.1, 'Country': 36.4}
Invoice 값의 파이썬 타입: {<class 'int'>: 532619, <class 'str'>: 9291}
StockCode 값의 파이썬 타입: {<class 'int'>: 487036, <class 'str'>: 54874}
Customer ID 예시: [17850.0, 17850.0, 17850.0] | 평균(의미 없음): 15287.7
전체 134.9MB 중 문자열 컬럼 넷(Invoice, StockCode, Description, Country)이 118.5MB를 차지합니다. 숫자 컬럼은 각각 4.1MB뿐입니다. pandas는 문자열을 object 타입, 즉 값마다 따로 만든 파이썬 객체로 들고 있어서 무겁습니다. 38개 나라 이름이 54만 번 반복되는 Country 하나가 36.4MB입니다.
더 큰 문제는 메모리가 아니라 그 아래 두 줄입니다.
숫자처럼 보이는 값이 만든 99.97%
송장 번호(Invoice)는 536365처럼 숫자로만 된 값과 C536379처럼 문자가 붙은 취소 송장이 섞여 있습니다. 타입을 지정하지 않으면 pandas는 숫자로 읽을 수 있는 값은 숫자로, 아닌 값은 문자로 읽어서 한 컬럼 안에 정수 532,619개와 문자열 9,291개가 섞입니다. 상품 코드도 같습니다.
이 상태에서 취소 송장을 찾는 평범한 코드를 실행하면 이렇게 됩니다.
Invoice.str.startswith('C') 결과: {'nan': 532619, 'True': 9288, 'False': 3}
취소율(그대로): 99.97% | 문자열로 바꾼 뒤: 1.71%
숫자 5자리 상품 코드 행 수(그대로): 0 | 문자열로 바꾼 뒤: 487036
.str.startswith("C")는 문자열에만 동작합니다. 정수로 읽힌 53만 개 송장에는 True도 False도 아닌 NaN을 돌려주고, 평균을 낼 때 NaN은 조용히 빠집니다. 그래서 분모가 9,291개(문자 송장)로 줄어 취소율이 99.97%가 됩니다. 상품 코드를 5자리 숫자로 거르는 코드도 487,036행이 아니라 0행을 돌려줍니다. 둘 다 에러는 없습니다.
고객 번호도 비슷합니다. 빈 값이 섞여 있어서 float64로 읽혔고, 17850.0처럼 소수점이 붙습니다. 평균을 내면 15287.7이라는 숫자가 에러 없이 나오지만, 고객 번호의 평균은 아무 의미가 없습니다.
| 숫자로 둬도 되는 값 | 문자열로 둬야 하는 값 | |
|---|---|---|
| 예시 | 수량, 단가, 매출액 | 송장 번호, 상품 코드, 고객 번호, 우편번호 |
| 판단 기준 | 더하기·평균이 의미 있다 | 계산이 의미 없거나 앞자리 0·문자 접두사를 보존해야 한다 |
해결은 읽을 때 타입을 정해 주는 것입니다. pd.read_excel(..., dtype={"Invoice": str, "StockCode": str}) 한 줄이면 위 문제가 모두 사라집니다. 이전 글의 코드 맨 위에 이 옵션이 있었던 이유입니다.
범위를 보지 않고 줄이면: 74,215 → 8,679
메모리를 줄이려고 수량(Quantity)을 int16(2바이트)으로 바꿔 봤습니다.
Quantity 범위: -80995 ~ 80995
int16으로 줄였을 때 값이 바뀐 행: 4 | 예: [74215, -74215] → [8679, -8679]
수량의 범위는 -80,995 ~ 80,995인데 int16에는 32,767까지만 들어갑니다. 넘친 값은 에러 없이 한 바퀴 돌아 엉뚱한 수가 됩니다. 74,215가 8,679로 바뀐 행이 4개입니다. 54만 행 중 4행이라 합계를 대충 보면 눈치채기 어렵습니다. 그런데 이 4행은 int16 범위를 넘는 유일한 행, 즉 수량이 가장 큰 거래(74,215개, 80,995개)와 그 취소 기록입니다. 수량 기준 상위 거래를 뽑는 분석이라면 결과가 바로 틀어집니다.
범위를 보고 줄이면: 134.9MB → 16.7MB
값의 성격과 범위를 보고 컬럼마다 타입을 정했습니다.
| 컬럼 | 기본 타입 | 바꾼 타입 | 이유 |
|---|---|---|---|
| Invoice, StockCode | object(숫자·문자 섞임) | 문자열 → category | 계산하지 않는 코드, 같은 값 반복. StockCode는 이전 글에서 확인한 대소문자 문제 때문에 대문자로 통일 |
| Description, Country | object | category | 같은 문자열이 수만 번 반복 |
| Quantity | int64 | int32 | 범위 ±80,995라 int16은 부족 |
| Price | float64 | float32 | 메모리 절반, 대신 정밀도 확인 필요 |
| Customer ID | float64 | 문자열 → category | 계산하지 않는 ID, 소수점 제거 |
줄인 타입: {'Invoice': 'category', 'StockCode': 'category', 'Description': 'category', 'Quantity': 'int32', 'InvoiceDate': 'datetime64[ns]', 'Price': 'float32', 'Customer ID': 'category', 'Country': 'category'}
전체 메모리: 134.9 MB → 16.7 MB
int32는 pd.to_numeric(..., downcast="integer")가 값 범위를 보고 골라 준 타입입니다. 반복되는 문자열을 category로 바꾼 효과가 가장 큽니다. 값마다 문자열을 저장하는 대신 고유값 사전을 한 번 만들고, 각 행에는 사전 번호만 저장하기 때문입니다.
줄였다고 끝이 아닙니다: 줄인 뒤 검증
다운캐스팅은 값을 더 좁은 그릇에 옮겨 담는 일이라, 줄인 다음 반드시 값이 그대로인지 확인합니다.
행 수 같음: True
수량 합계 같음: True
금액 합계: 9,747,765.9340 vs 9,747,765.8869 (차이 -0.0471)
Price 최대 오차: 0.0009375000008731149
고유값 개수 비교: {'Invoice': (25900, 25900), 'Country': (38, 38)}
행 수와 수량 합계, 송장·나라의 고유값 개수는 그대로입니다. 단가를 float32로 줄인 영향은 숫자로 드러났습니다. 한 행의 단가 오차는 최대 0.0009파운드, 54만 행의 금액 합계 차이는 0.047파운드입니다.
이 정도 차이를 받아들일지는 목적에 따라 다릅니다. 월별 매출 추세처럼 큰 흐름을 보는 분석이라면 대체로 허용할 만한 수준입니다. 다만 상품 순위처럼 근소한 차이로 갈리는 결과는 경계에 있는 항목을 다시 확인해야 합니다. 회계 장부와 1페니(£0.01) 단위까지 맞춰야 하는 정산이라면 float32는 쓰면 안 되고, 금액은 float64나 정수(펜스 단위)로 둬야 합니다. 어느 쪽이든 차이를 재 보고 결정했다는 게 중요합니다.
AI에게 시킬 때는 검증까지 같이 요청합니다
타입을 일일이 고르는 건 번거로운 일이라 AI에게 맡기기 좋습니다. 다만 AI는 값이 깨져도 “메모리를 줄였습니다”라고 자신 있게 보고할 수 있어서, 요청할 때 검증을 함께 넣습니다.
이 데이터프레임의 메모리를 줄여 줘. 컬럼별로 더 작은 타입을 골라 바꾸고, 바꾸기 전과 후의 메모리를 비교해 줘. 바꾸는 과정에서 범위를 넘거나 값이 달라진 컬럼이 있는지도 합계와 고유값 개수로 확인해 줘.
그리고 결과를 받으면 세 가지를 직접 봅니다.
- ID처럼 생긴 숫자 컬럼을 숫자로 두지 않았는가 (송장·상품·고객 번호)
- 정수 컬럼의 최솟값·최댓값이 새 타입 범위 안에 있는가
- 줄이기 전후의 합계·고유값 개수가 같은가, 다르다면 얼마나 다른가
면접에서 나올 수 있는 질문
pandas에서 메모리를 줄이는 방법을 설명해 보세요. 먼저 memory_usage(deep=True)로 어느 컬럼이 무거운지 봅니다. 대개 반복되는 문자열이 가장 무거워서 category로 바꾸는 효과가 크고, 숫자는 값 범위를 확인한 뒤 int32·float32 등으로 다운캐스팅합니다. 필요한 컬럼만 읽는 것(usecols)도 방법입니다. 줄인 뒤에는 합계와 고유값 개수를 전후로 비교합니다.
우편번호나 회원 번호를 숫자로 읽으면 어떤 문제가 생기나요? 앞자리 0이 사라지고, 빈 값이 섞이면 float가 되어 소수점이 붙고, 문자가 섞인 값과 한 컬럼에 섞여 문자열 함수가 조용히 NaN을 돌려줍니다. 평균처럼 의미 없는 계산도 에러 없이 통과합니다. 읽을 때 dtype으로 문자열을 지정합니다.
float32로 줄이면 안 되는 경우는요? 유효숫자가 약 7자리라 큰 금액이나 정밀한 합계가 필요한 정산에는 맞지 않습니다. 이 데이터에서도 54만 행 합계가 0.047파운드 달라졌습니다.
전체 코드
pip install pandas openpyxl 후 online_retail_II.xlsx와 같은 폴더에서 실행합니다.
import os
import pandas as pd
XLSX = os.environ.get("RETAIL_XLSX", "online_retail_II.xlsx")
df = pd.read_excel(XLSX, sheet_name="Year 2010-2011") # 타입을 지정하지 않고 읽기
def mb(frame):
return frame.memory_usage(deep=True).sum() / 1024**2
# 1) 기본으로 읽었을 때
print(f"행 수: {len(df):,}")
print("기본 타입:", df.dtypes.astype(str).to_dict())
print(f"전체 메모리: {mb(df):.1f} MB")
print("컬럼별 MB:", (df.memory_usage(deep=True, index=False) / 1024**2).round(1).to_dict())
print("Invoice 값의 파이썬 타입:", df["Invoice"].map(type).value_counts().to_dict())
print("StockCode 값의 파이썬 타입:", df["StockCode"].map(type).value_counts().to_dict())
print("Customer ID 예시:", df["Customer ID"].dropna().head(3).tolist(),
"| 평균(의미 없음):", round(df["Customer ID"].mean(), 1))
# 1-1) 숫자와 문자가 섞인 컬럼에서 생기는 일
m = df["Invoice"].str.startswith("C")
print("Invoice.str.startswith('C') 결과:", {str(k): v for k, v in m.value_counts(dropna=False).items()})
print(f"취소율(그대로): {m.mean():.2%} | 문자열로 바꾼 뒤: {df['Invoice'].astype(str).str.startswith('C').mean():.2%}")
print("숫자 5자리 상품 코드 행 수(그대로):", df["StockCode"].str.fullmatch(r"\d{5}").sum(),
"| 문자열로 바꾼 뒤:", df["StockCode"].astype(str).str.fullmatch(r"\d{5}").sum())
# 2) 범위를 보지 않고 줄이면
print("Quantity 범위:", df["Quantity"].min(), "~", df["Quantity"].max())
q16 = df["Quantity"].astype("int16")
changed = (q16 != df["Quantity"])
print("int16으로 줄였을 때 값이 바뀐 행:", changed.sum(),
"| 예:", df.loc[changed, "Quantity"].head(2).tolist(), "→", q16[changed].head(2).tolist())
# 3) 범위를 보고 줄이기
slim = pd.DataFrame({
"Invoice": df["Invoice"].astype(str).astype("category"),
"StockCode": df["StockCode"].astype(str).str.upper().astype("category"),
"Description": df["Description"].astype("category"),
"Quantity": pd.to_numeric(df["Quantity"], downcast="integer"),
"InvoiceDate": df["InvoiceDate"],
"Price": df["Price"].astype("float32"),
"Customer ID": df["Customer ID"].astype("Int64").astype("string").astype("category"),
"Country": df["Country"].astype("category"),
})
print("줄인 타입:", slim.dtypes.astype(str).to_dict())
print(f"전체 메모리: {mb(df):.1f} MB → {mb(slim):.1f} MB")
# 4) 줄인 뒤 값이 멀쩡한지 검증
orig_amount = (df["Quantity"] * df["Price"]).sum()
slim_amount = (slim["Quantity"].astype("int64") * slim["Price"].astype("float64")).sum()
print("행 수 같음:", len(df) == len(slim))
print("수량 합계 같음:", df["Quantity"].sum() == slim["Quantity"].astype("int64").sum())
print(f"금액 합계: {orig_amount:,.4f} vs {slim_amount:,.4f} (차이 {slim_amount - orig_amount:,.4f})")
print("Price 최대 오차:", float((slim["Price"].astype("float64") - df["Price"]).abs().max()))
print("고유값 개수 비교:",
{c: (df[c].astype(str).nunique(), slim[c].astype(str).nunique()) for c in ["Invoice", "Country"]})
참고 자료
- UCI Online Retail II: Chen, D. (2012). UCI Machine Learning Repository. https://doi.org/10.24432/C5CG6D (CC BY 4.0)
- pandas: Scaling to large datasets: 메모리 사용량 줄이기 공식 가이드
메모리 수치는 macOS, Python 3.9.6, pandas 2.3.3에서 측정했으며 환경에 따라 조금씩 다를 수 있습니다. 파일을 어떤 형식으로 저장하느냐에 따라 이 타입 정보가 살아남기도, 사라지기도 합니다. 이건 다음 글에서 다룹니다.