CSV 파일 하나를 받았을 때 가장 먼저 할 일은 모델을 돌리는 게 아니라 이 데이터를 믿어도 되는가 를 확인하는 것이다. 행이 몇 개인지, 어느 열에 결측치가 있는지, 값 하나가 나머지를 왜곡하고 있지는 않은지부터 본다. Pandas는 이 탐색·정제·집계 과정을 표 하나(DataFrame)로 처리하는 표준 라이브러리다.
한 줄 정의
DataFrame 은 행과 열에 이름(레이블)이 있고 열마다 다른 타입을 가질 수 있는 2차원 표다. 내부는 NumPy 배열이며, 한 열만 떼어내면 1차원인 Series 가 된다.
언제 Pandas인가
수백만 행 이하의 데이터를 Jupyter나 스크립트에서 탐색하고, Matplotlib·Seaborn 같은 시각화 라이브러리와 바로 연동해야 할 때 Pandas가 기본 선택이다. 데이터가 그보다 훨씬 크거나 여러 파일에 걸쳐 있다면 27편에서 다룰 Polars·DuckDB를 함께 고려한다.
예제 데이터
결측치와 이상치를 일부러 섞은 20행짜리 판매 기록으로 전체 흐름을 따라간다.
order_id,date,region,category,amount,customer_id
1,2026-07-01,서울,의류,9096.0,109
2,2026-07-02,인천,의류,38146.0,107
3,2026-07-03,대구,가전,,103
4,2026-07-04,부산,식품,980000.0,109
5,2026-07-05,부산,,4328.0,104
...(총 20행, amount 3곳·category 2곳 결측, amount 1곳 이상치)
기초 EDA — 처음 마주칠 때
데이터를 로딩한 직후에는 크기·타입·결측 여부를 먼저 확인한다.
import pandas as pd
df = pd.read_csv("sales.csv")
df.shape
df.info()
(20, 6)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20 entries, 0 to 19
Data columns (total 6 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 order_id 20 non-null int64
1 date 20 non-null object
2 region 20 non-null object
3 category 18 non-null object
4 amount 17 non-null float64
5 customer_id 20 non-null int64
dtypes: float64(1), int64(2), object(3)
memory usage: 1.1+ KB
Non-Null Count 가 전체 행 수(20)보다 적은 열이 결측치를 가진 열이다. category 와 amount 가 여기 해당한다. 날짜·범주형 열은 문자열(object)로 읽히므로 쓰기 전에 타입을 명확히 바꿔둔다.
df["date"] = pd.to_datetime(df["date"])
df["region"] = df["region"].astype("category")
결측치 파악하고 처리하기
결측치는 개수와 비율을 함께 본다. 몇 개인지보다 전체에서 차지하는 비중이 처리 방법을 결정한다.
df.isna().sum()
(df.isna().sum() / len(df) * 100).round(1)
order_id 0
category 2
amount 3
...
category 10.0
amount 15.0
... (단위: %)
숫자 열은 평균·중앙값으로, 범주형 열은 최빈값으로 채우는 것이 흔한 시작점이다. 채울 근거가 마땅치 않은 행은 아예 제거한다.
df["category"] = df["category"].fillna(df["category"].mode()[0])
df = df.dropna(subset=["amount"]).copy()
# 결측 처리 후 행 수: 17 (제거 전 20)
mode()는 최빈값이 여러 개면 그 값을 모두 담은Series를 반환한다.mode()[0]로 첫 값 하나만 꺼내 쓰는 이유다.
이상치를 IQR로 찾아내기
describe() 로 수치 열의 분포를 보면 이상한 값이 평균을 얼마나 왜곡하는지 드러난다.
df["amount"].describe()
count 17.000000
mean 82436.941176
std 231649.759649
min 4328.000000
25% 18831.000000
50% 27781.000000
75% 38146.000000
max 980000.000000
평균(82,437)이 중앙값(27,781)의 3배에 가깝다. 값 하나가 평균을 크게 끌어올리고 있다는 신호다. 사분위 범위(IQR)를 기준으로 정상 범위를 벗어난 값을 찾는다.
정상 범위는 다음과 같이 정의한다.
사분위수 (하위 25%)과 (하위 75%) 사이 거리가 IQR이며, 그 1.5배를 넘어서는 값을 이상치로 본다.
Q1 = df["amount"].quantile(0.25)
Q3 = df["amount"].quantile(0.75)
IQR = Q3 - Q1
lo, hi = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
df_clean = df[df["amount"].between(lo, hi)]
Q1=18831.0, Q3=38146.0, IQR=19315.0
정상 범위: -10142 ~ 67118
이상치 행:
order_id region amount
3 4 부산 980000.0
이상치 1건 제거, 17 -> 16
제거 후 평균: 26339 (제거 전 평균 82437)
행 하나를 제거했을 뿐인데 평균이 82,437에서 26,339로, 중앙값에 훨씬 가까운 값으로 돌아왔다.
groupby·pivot_table·merge로 집계·결합하기
지역별 매출 합계·건수·평균을 한 번에 구할 때는 agg() 에 결과 열 이름을 직접 지정하는 named aggregation 을 쓴다.
by_region = df.groupby("region", observed=True).agg(
revenue=("amount", "sum"),
cnt=("amount", "count"),
avg=("amount", "mean"),
).reset_index()
region revenue cnt avg
0 대구 110238.0 4 27559.5
1 부산 38797.0 2 19398.5
2 서울 113852.0 5 22770.4
3 인천 158541.0 5 31708.2
revenue=("amount", "sum") 형태로 쓰지 않고 agg({"amount": "sum"}) 처럼 쓰면 결과 열 이름이 그대로 amount 로 남아 어떤 집계인지 알아보기 어렵다. 지역과 카테고리를 동시에 펼쳐 보려면 pivot_table 이 엑셀 피벗 테이블과 같은 역할을 한다.
pivot = df.pivot_table(
values="amount", index="region", columns="category",
aggfunc="sum", fill_value=0,
)
category 가전 식품 의류
region
대구 18831.0 53172.0 38235.0
부산 0.0 0.0 38797.0
서울 0.0 8560.0 105292.0
인천 37425.0 0.0 121116.0
다른 표에 있는 정보를 붙이려면 merge 로 SQL의 JOIN과 같은 작업을 한다. 왼쪽 표의 행은 모두 남기고 오른쪽 표에서 값을 채워 넣는 LEFT JOIN 이 가장 흔하다.
result = pd.merge(df, customers, on="customer_id", how="left")
merge 는 지정한 열 값을 기준으로 결합하고, join 은 인덱스 를 기준으로 결합하는 간편 버전이다. 기준 값이 없는 행은 결측치로 남으므로 merge 뒤에는 항상 isna().sum() 으로 매칭이 안 된 행이 있는지 확인한다.
Pandas 2.x의 SettingWithCopyWarning과 Copy-on-Write
필터링한 결과에 값을 대입하면 경고가 뜨는 경우가 있다.
df_seoul = df[df["region"] == "서울"]
df_seoul["amount"] = df_seoul["amount"] * 1.1
SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer, col_indexer] = value instead
df_seoul 이 원본의 뷰(view)인지 복사본인지 애매해서 나는 경고다. 아래 두 방법 중 하나로 애매함을 없앤다.
# 방법 1: 필터링 시점에 명시적으로 복사
df_seoul = df[df["region"] == "서울"].copy()
df_seoul["amount"] *= 1.1
# 방법 2: 원본을 .loc으로 직접 수정
df.loc[df["region"] == "서울", "amount"] *= 1.1
Pandas 2.x는 이 애매함을 근본적으로 없애는 Copy-on-Write(CoW) 모드를 옵션으로 제공한다. pd.options.mode.copy_on_write = True 로 켜면 필터링 결과는 항상 복사본처럼 동작해, 두 단계로 나눠 대입해도 원본이 조용히 바뀌는 일이 없다. 다만 한 줄에서 인덱싱을 두 번 연달아 쓰는 진짜 체인 대입은 여전히 막힌다.
pd.options.mode.copy_on_write = True
df["amount"][df["region"] == "서울"] = 0 # 체인 대입
ChainedAssignmentError: A value is trying to be set on a copy of a
DataFrame or Series through chained assignment. ... Try using
'.loc[row_indexer, col_indexer] = value' instead
CoW는 현재 버전에서는 옵션으로 켜야 하는 기능이다. pandas 공식 문서 기준으로 기본값으로 강제되는 시점은 3.0 이다. 지금 당장은
df_seoul = df[...].copy()처럼 명시적으로 복사하는 습관이 버전에 관계없이 안전하다.
apply보다 벡터화 연산이 빠른 이유
apply() 는 행마다 Python 함수를 하나씩 호출한다. 벡터화 연산은 그 반복을 NumPy의 C 레벨 코드로 통째로 처리한다. 100만 행에 조건부 계산을 적용해 비교해 본다.
# 느린 방법: apply (Python 루프)
tax_apply = amount.apply(lambda x: x * 1.1 if x < 30000 else x * 1.05)
# 빠른 방법: 벡터화 (np.where)
tax_vec = np.where(amount < 30000, amount * 1.1, amount * 1.05)
apply: 0.078초
벡터화(np.where): 0.0019초 (41배 빠름)
문자열·날짜 열에도 같은 원리가 적용된다. .str 접근자(accessor) 는 문자열 메서드를, .dt 접근자는 날짜 속성을 열 전체에 벡터화해서 적용한다.
df["date"] = pd.to_datetime(df["date"])
df["year"] = df["date"].dt.year
df["weekday"] = df["date"].dt.day_name()
df["region"].str.contains("서") # 지역명에 '서'가 들어간 행
apply 가 필요한 순간은 열 단위 연산으로 표현할 수 없는, 행마다 다른 복잡한 로직을 써야 할 때로 좁혀서 쓴다.
장점과 한계
구분 | 내용 |
|---|---|
장점 | 결측·이상치 처리, 집계, 결합까지 표 하나로 이어지는 API가 일관되어 있고, 시각화·통계·모델 라이브러리 대부분이 DataFrame을 그대로 받는다. |
한계 | 싱글스레드로 동작해 수백만 행을 넘어서면 속도가 눈에 띄게 느려지고, 처리 도중 메모리를 원본의 여러 배까지 쓰기도 한다. 이 한계를 넘어서는 시점의 대안은 다음 편에서 다룬다. |
자주 발생하는 문제
결측치를 확인하지 않고 바로 평균을 낸다. describe() 의 count 가 전체 행 수보다 작으면 그 통계는 결측을 제외한 값만으로 계산된 것이다. 결측이 특정 조건에 몰려 있으면 평균 자체가 왜곡될 수 있다.
이상치를 지우기 전에 원인을 확인하지 않는다. 980,000처럼 튀는 값이 오타인지, 실제로 있었던 대형 거래인지는 IQR 계산만으로 알 수 없다. 제거하기 전에 그 행이 왜 그런 값을 가졌는지 먼저 본다.
체인 대입 경고를 무시한다. SettingWithCopyWarning 이 떴는데 값이 바뀐 것처럼 보여도, 그 변경이 원본에 반영됐는지 아닌지는 상황마다 다르다. 경고가 뜨면 위 두 가지 방법 중 하나로 코드를 명확하게 고친다.
관련 문서
이 문서에서 다룬 IQR 이상치 탐지, 결측치 처리 전략은 실습: 리스트와 딕셔너리로 데이터 필터링·집계하기 에서 다룬 필터링·집계 감각을 DataFrame으로 확장한 것이다. 다음 편에서는 이 워크플로가 감당하기 어려운 규모의 데이터를 Polars·DuckDB로 처리하는 방법을 다룬다.
참고 자료
Pandas 공식 문서 — User Guide: Essential basic functionality (2026-08-10 확인)
Pandas 공식 문서 — Copy-on-Write (2026-08-10 확인)
Pandas 공식 문서 — groupby (2026-08-10 확인)
댓글 0
댓글을 불러오는 중…