Pandas 기초: DataFrame 만들기, 선택·필터링·집계
이 글의 핵심
Pandas를 처음 쓸 때 여러 조건으로 필터링하면서 and를 써서 에러를 만나는 일이 잦습니다. 불리언 마스크에는 &와 괄호를 써야 하는 이유, 결측치와 중복을 버릴지 채울지 먼저 정하고 일괄 처리하는 짧은 패턴, 큰 CSV를 읽을 때 dtype을 지정해 메모리를 줄이는 방법까지 함께 봅니다.
들어가며
Pandas는 데이터 분석과 조작을 위한 Python의 핵심 라이브러리입니다. 엑셀로 하던 필터, 정렬, 피벗, VLOOKUP 같은 작업을 코드로 옮겨 놓으면 같은 분석을 다음 달 데이터에 그대로 다시 돌릴 수 있고, 수십만 행이 넘어 엑셀이 느려지는 데이터도 몇 초 만에 처리됩니다.
Pandas를 처음 배울 때 어려운 부분은 함수 이름보다 인덱스와 “벡터화” 사고방식입니다. 반복문으로 한 행씩 처리하는 대신 열 전체에 연산을 한 번에 적용하고, 행은 위치가 아니라 인덱스 라벨로 맞춰진다는 규칙이 곳곳에 영향을 줍니다. 아래 예제마다 이 규칙이 어떻게 작동하는지 함께 설명합니다.
설치와 Series·DataFrame
설치
pip install pandas
Series와 DataFrame
Series는 한 줄짜리 표(열 하나)이고, DataFrame은 행·열이 있는 표 전체입니다. 엑셀 시트를 떠올리면 DataFrame이 한 장, 그중 한 열만 떼면 Series입니다.
import pandas as pd
# Series (1차원)
s = pd.Series([1, 2, 3, 4, 5])
print(s)
# 0 1
# 1 2
# 2 3
# 3 4
# 4 5
# DataFrame (2차원)
df = pd.DataFrame({
'name': ['철수', '영희', '민수'],
'age': [25, 30, 28],
'city': ['서울', '부산', '대구']
})
print(df)
# name age city
# 0 철수 25 서울
# 1 영희 30 부산
# 2 민수 28 대구
출력 왼쪽의 0, 1, 2가 인덱스입니다. 행 번호처럼 보이지만 실제로는 각 행에 붙은 라벨이라, 필터링해서 일부 행만 남겨도 원래 번호가 유지됩니다(0, 2만 남는 식). 두 Series를 더하거나 비교할 때도 Pandas는 위치가 아니라 이 라벨이 같은 것끼리 맞춰서 계산합니다. 인덱스가 어긋난 두 Series를 더하면 짝이 없는 라벨은 NaN이 되는데, 초보자가 “값이 왜 비어 있지?”라고 당황하는 흔한 이유입니다.
DataFrame의 각 열은 하나의 타입(dtype)을 가집니다. age는 int64, name과 city는 문자열이라 object로 잡힙니다. 한 열에 숫자와 문자열이 섞이면 열 전체가 object가 되어 숫자 연산이 느려지거나 오류가 나므로, 데이터를 읽은 직후 df.dtypes를 확인하는 습관이 도움이 됩니다.
CSV·Excel 읽고 쓰기
CSV 파일
# CSV 읽기
df = pd.read_csv('data.csv')
# CSV 쓰기
df.to_csv('output.csv', index=False, encoding='utf-8-sig')
# 특정 열만 읽기
df = pd.read_csv('data.csv', usecols=['name', 'age'])
# 구분자 지정
df = pd.read_csv('data.tsv', sep='\t')
CSV를 쓸 때 index=False를 주는 이유는, 기본값으로는 인덱스가 첫 열로 함께 저장되기 때문입니다. 이 파일을 다시 읽으면 Unnamed: 0이라는 의미 없는 열이 생깁니다. encoding='utf-8-sig'는 파일 앞에 BOM을 붙여 Windows 엑셀에서 열었을 때 한글이 깨지지 않게 합니다. 반대로 엑셀에서 저장한 오래된 한글 CSV를 읽을 때 UnicodeDecodeError: 'utf-8' codec can't decode byte가 나면 encoding='cp949'로 읽어 보세요. 한국어 환경에서 CSV를 다루다 보면 거의 반드시 한 번은 겪는 오류입니다.
usecols로 필요한 열만 읽으면 메모리와 읽기 시간이 모두 줄어듭니다. 열이 수십 개인 로그 파일에서 두세 개만 분석한다면 효과가 큽니다.
Excel 파일
# Excel 읽기
df = pd.read_excel('data.xlsx', sheet_name='Sheet1')
# Excel 쓰기
df.to_excel('output.xlsx', index=False)
Excel 입출력은 Pandas가 직접 하지 않고 외부 엔진을 사용하므로 pip install openpyxl이 따로 필요합니다. 설치하지 않으면 “Missing optional dependency ‘openpyxl’” 오류가 납니다. sheet_name=None을 주면 모든 시트를 {시트이름: DataFrame} 딕셔너리로 한 번에 읽습니다. Excel은 CSV보다 읽기가 훨씬 느리므로, 같은 파일을 반복해서 분석한다면 한 번 읽은 뒤 CSV나 Parquet으로 저장해 두는 편이 좋습니다.
info, describe로 데이터 훑어보기
기본 정보
# 처음 5행
print(df.head())
# 마지막 5행
print(df.tail())
# 기본 정보
print(df.info())
# 통계 요약
print(df.describe())
# 크기
print(df.shape) # (행, 열)
# 열 이름
print(df.columns)
데이터를 받으면 분석 전에 이 몇 줄로 구조를 먼저 확인합니다. info()는 열별 dtype과 결측이 아닌 값의 개수를 보여 줘서, 전체 행 수보다 작은 열이 결측이 있는 열입니다. info()는 결과를 직접 출력하고 None을 반환하므로 print(df.info())로 쓰면 마지막 줄에 None이 하나 더 찍힙니다. df.info()만 써도 됩니다.
describe()는 수치형 열의 개수, 평균, 표준편차, 최솟값, 사분위수, 최댓값을 보여 줍니다. 나이의 최솟값이 -1이거나 금액의 최댓값이 99999999라면 입력 오류나 결측 표시용 값이 섞여 있다는 신호입니다. 문자열 열의 요약이 필요하면 describe(include='object')로 고유값 개수와 최빈값을 볼 수 있습니다.
열과 행 선택
열 선택
# 단일 열
ages = df['age']
# 여러 열
subset = df[['name', 'age']]
# 조건 필터링
adults = df[df['age'] >= 30]
seoul_users = df[df['city'] == '서울']
# 복합 조건
result = df[(df['age'] >= 25) & (df['city'] == '서울')]
대괄호 하나(df['age'])는 Series를, 대괄호 두 개(df[['name', 'age']])는 열 목록을 받아 DataFrame을 돌려줍니다. 안쪽 대괄호는 파이썬 리스트입니다. 열 이름을 잘못 쓰면 KeyError: 'agee'가 나는데, 공백이 섞인 열 이름('age ')도 같은 오류를 내므로 df.columns로 실제 이름을 확인하세요.
df['age'] >= 30은 행마다 True/False가 들어 있는 Series(불리언 마스크)를 만들고, df[마스크]는 True인 행만 남깁니다. 여러 조건을 묶을 때 and/or 대신 &/|를 쓰고 조건마다 괄호를 쳐야 하는 이유는 FAQ에서 설명합니다. 괄호를 빠뜨리면 &가 비교 연산자보다 먼저 계산되어 df['age'] >= (25 & df['city']) == '서울'처럼 엉뚱하게 해석되고 타입 오류가 납니다.
행 선택
# 인덱스로 선택 (iloc)
first_row = df.iloc[0]
first_three = df.iloc[:3]
# 라벨로 선택 (loc)
df_indexed = df.set_index('name')
chul = df_indexed.loc['철수']
# 조건 선택
young = df[df['age'] < 30]
iloc은 위치(0부터 시작하는 정수), loc은 라벨(인덱스 값)로 선택합니다. 기본 인덱스가 0, 1, 2일 때는 둘이 같아 보여서 차이를 놓치기 쉽지만, 필터링 후에는 달라집니다. young.iloc[0]은 남은 행 중 첫 번째이고, young.loc[0]은 인덱스 라벨이 0인 행이라 필터에서 빠졌다면 KeyError가 납니다. 슬라이싱 규칙도 다릅니다. iloc[:3]은 파이썬처럼 끝을 제외하지만 loc['a':'c']는 끝 라벨 'c'를 포함합니다.
set_index('name')으로 이름을 인덱스로 만들면 loc['철수']로 바로 찾을 수 있습니다. 이름이 중복되면 한 행이 아니라 여러 행짜리 DataFrame이 돌아오므로, 인덱스로 쓸 열은 값이 유일한지 먼저 확인해야 합니다. set_index는 원본을 바꾸지 않고 새 DataFrame을 반환하기 때문에 예제처럼 다른 변수에 받아야 합니다.
열 추가·삭제와 값 변경
열 추가/삭제
# 열 추가
df['country'] = '한국'
df['birth_year'] = 2026 - df['age']
# 열 삭제
df = df.drop('country', axis=1)
# 여러 열 삭제
df = df.drop(['col1', 'col2'], axis=1)
df['birth_year'] = 2026 - df['age']는 반복문 없이 모든 행에 한 번에 계산이 적용되는 벡터화 연산입니다. 내부적으로 C로 구현된 NumPy 연산이라 파이썬 for 문으로 한 행씩 계산하는 것보다 수십 배 이상 빠릅니다. '한국'처럼 스칼라 값 하나를 대입하면 모든 행에 같은 값이 들어갑니다.
drop은 기본적으로 새 DataFrame을 반환하므로 결과를 다시 df에 대입해야 삭제가 반영됩니다. axis=1은 열, axis=0(기본값)은 행을 뜻하며, columns=['country']처럼 쓰면 축을 헷갈릴 일이 없습니다. 마지막 줄의 col1, col2는 형식 예시라서 이 DataFrame에서 실행하면 KeyError: "['col1', 'col2'] not found in axis"가 납니다. 없을 수도 있는 열을 지울 때는 errors='ignore'를 줍니다.
값 변경
# 특정 값 변경
df.loc[df['name'] == '철수', 'age'] = 26
# 함수 적용
df['age_group'] = df['age'].apply(
lambda x: '청년' if x < 30 else '중년'
)
# 여러 열의 타입 변환
df[['age', 'birth_year']] = df[['age', 'birth_year']].astype(int)
df.loc[조건, '열'] = 값은 조건에 맞는 행의 특정 열만 바꾸는 표준 방법입니다. 같은 일을 df[df['name'] == '철수']['age'] = 26처럼 대괄호를 이어 쓰면 값이 바뀌지 않거나 SettingWithCopyWarning 경고가 납니다. 첫 번째 대괄호가 원본의 복사본을 만들 수 있고, 두 번째 대입은 그 복사본에 적용되기 때문입니다. pandas 3.0부터는 Copy-on-Write가 기본이 되어 이런 연쇄 대입은 원본을 절대 바꾸지 않으므로, 처음부터 loc 한 번으로 행과 열을 함께 지정하는 습관을 들이는 것이 좋습니다.
apply는 값마다 파이썬 함수를 호출하므로 편하지만 벡터화 연산보다 느립니다. 이 예제처럼 조건 하나로 두 값을 나누는 경우는 np.where(df['age'] < 30, '청년', '중년')이, 구간이 여러 개라면 pd.cut(df['age'], bins=[0, 30, 60, 120], labels=['청년', '중년', '장년'])이 더 빠르고 의도도 분명합니다. 여러 열의 타입 변환에는 원소마다 함수를 적용하는 대신 astype을 씁니다. 원소별 함수 적용이 꼭 필요하면 DataFrame.map을 쓰는데, 예전 이름인 applymap은 pandas 2.1부터 폐기 예정 경고가 나옵니다.
groupby로 그룹화와 집계
groupby
# 도시별 평균 나이
city_avg = df.groupby('city')['age'].mean()
print(city_avg)
# 여러 집계 함수
result = df.groupby('city').agg({
'age': ['mean', 'min', 'max'],
'name': 'count'
})
print(result)
groupby는 “분할 → 적용 → 결합” 세 단계로 동작합니다. city 값이 같은 행끼리 그룹으로 나누고, 각 그룹의 age에 mean()을 적용한 뒤, 결과를 도시를 인덱스로 하는 Series로 합칩니다. SQL의 SELECT city, AVG(age) FROM df GROUP BY city와 같습니다.
agg에 딕셔너리를 넘기면 열마다 다른 집계를 할 수 있는데, 결과 열 이름이 ('age', 'mean') 같은 2단 구조(MultiIndex)가 되어 이후 다루기 번거롭습니다. df.groupby('city').agg(avg_age=('age', 'mean'), n=('name', 'count'))처럼 이름 있는 집계를 쓰면 평평한 열 이름을 바로 얻을 수 있습니다. count는 결측이 아닌 값의 개수라 결측이 있으면 행 수와 달라지고, 행 수 자체가 필요하면 size()를 씁니다. 또 그룹 키 열에 결측이 있는 행은 기본적으로 결과에서 빠지므로(dropna=True), 결측 그룹도 보고 싶다면 groupby('city', dropna=False)를 줍니다.
merge와 concat으로 병합
merge (조인)
# 두 DataFrame 병합
df1 = pd.DataFrame({
'id': [1, 2, 3],
'name': ['철수', '영희', '민수']
})
df2 = pd.DataFrame({
'id': [1, 2, 4],
'score': [85, 90, 88]
})
# Inner Join
merged = pd.merge(df1, df2, on='id', how='inner')
print(merged)
# id name score
# 0 1 철수 85
# 1 2 영희 90
# Left Join
merged = pd.merge(df1, df2, on='id', how='left')
merge는 SQL 조인과 같습니다. inner는 양쪽에 모두 있는 id(1, 2)만 남기고, left는 왼쪽 df1의 모든 행을 유지하면서 짝이 없는 민수(id 3)의 score를 NaN으로 채웁니다. 이때 score 열은 정수였어도 NaN을 담기 위해 float64로 바뀌어 85가 85.0으로 보입니다.
조인에서 가장 흔한 사고는 키 중복으로 행 수가 불어나는 것입니다. df2에 id=1인 행이 두 개 있으면 결과에 철수가 두 번 나오고, 이후 합계를 구하면 값이 부풀려집니다. 오류 없이 조용히 일어나서 보고서 숫자가 맞지 않은 뒤에야 알게 되는 경우가 많습니다. pd.merge(..., validate='one_to_one')이나 'many_to_one'을 주면 기대와 다른 중복이 있을 때 바로 예외를 냅니다. 조인 전후로 len()을 비교해 보는 것도 간단한 확인 방법입니다.
concat (연결)
# 세로로 연결
df_concat = pd.concat([df1, df2], ignore_index=True)
# 가로로 연결
df_concat = pd.concat([df1, df2], axis=1)
concat은 키를 보지 않고 이어 붙이기만 합니다. 세로 연결은 같은 구조의 표(예: 월별로 나뉜 파일)를 합칠 때 쓰며, 열 이름이 다르면 없는 열은 NaN으로 채워집니다. 위 예제의 df1과 df2는 열이 달라서 name과 score 양쪽에 NaN이 섞인 6행짜리 표가 됩니다. ignore_index=True를 주지 않으면 원래 인덱스 0, 1, 2가 두 번 반복되어 이후 loc 선택이 헷갈립니다.
가로 연결은 인덱스 기준으로 붙으므로, 두 표의 행 순서가 같아도 인덱스가 다르면 어긋납니다. 키 값으로 맞춰 붙여야 한다면 concat(axis=1) 대신 merge를 쓰세요. 반복문 안에서 df = pd.concat([df, new_row])로 한 행씩 붙이면 매번 전체를 복사해 매우 느려지므로, 리스트에 모아 두었다가 마지막에 한 번 concat하는 것이 정석입니다.
판매 데이터 분석 예제
import pandas as pd
# 데이터 로드
sales = pd.read_csv('sales.csv')
# 기본 정보
print(f"총 {len(sales)}건의 판매")
print(f"총 매출: {sales['amount'].sum():,}원")
# 월별 매출
sales['date'] = pd.to_datetime(sales['date'])
sales['month'] = sales['date'].dt.month
monthly_sales = sales.groupby('month')['amount'].sum()
print(monthly_sales)
# 상위 10개 상품
top_products = sales.groupby('product')['amount'].sum().sort_values(ascending=False).head(10)
print(top_products)
# 결과 저장
monthly_sales.to_csv('monthly_report.csv')
pd.to_datetime으로 문자열 날짜를 날짜 타입으로 바꿔야 .dt.month 같은 날짜 속성을 쓸 수 있습니다. read_csv('sales.csv', parse_dates=['date'])로 읽는 시점에 변환해도 됩니다. 형식이 섞여 변환에 실패하는 값이 있으면 오류가 나는데, errors='coerce'를 주면 그 값만 NaT(날짜 결측)로 바꾸고 계속 진행합니다.
이 예제에는 알아 둘 함정이 있습니다. dt.month는 1~12만 돌려주므로 데이터가 여러 해에 걸쳐 있으면 2024년 3월과 2025년 3월이 같은 그룹으로 합쳐집니다. 연도별 월 매출을 보려면 sales['date'].dt.to_period('M')로 “2025-03” 형태의 키를 만들거나 sales.resample('MS', on='date')['amount'].sum()을 씁니다. 또 amount 열에 "1,200"처럼 쉼표가 든 문자열이 섞여 있으면 sum()이 숫자를 더하는 대신 문자열을 이어 붙이므로, 합계가 이상하게 길게 나온다면 dtype부터 확인하세요. 이런 정리 작업은 데이터 전처리에서 자세히 다룹니다.
메모리·결측·중복을 다루는 짧은 패턴
DataFrame은 엑셀 시트를 코드로 다루는 표와 비슷합니다. 큰 CSV는 읽을 때부터 dtype을 줄여 메모리를 아끼고, 결측과 중복은 분석 목적에 맞게 버릴지·채울지 먼저 정한 뒤 dropna·fillna·drop_duplicates로 일괄 처리합니다.
# ✅ 메모리 최적화
df = pd.read_csv('large.csv', dtype={'id': 'int32'})
# ✅ 결측치 처리
df = df.dropna() # 결측치 제거
df = df.fillna(0) # 0으로 채우기
# ✅ 중복 제거
df = df.drop_duplicates()
# ✅ 체이닝
result = (df
.query('age >= 25')
.groupby('city')['age']
.mean()
.sort_values(ascending=False)
)
dtype={'id': 'int32'}는 기본 int64 대신 절반 크기의 정수를 쓰게 합니다. 효과가 더 큰 것은 반복되는 문자열 열입니다. 도시, 상품 분류처럼 종류가 적은 문자열 열을 'category'로 지정하면 각 값을 정수 코드로 저장해 메모리가 크게 줄고 groupby도 빨라집니다. df.memory_usage(deep=True)로 열별 메모리 사용량을 확인할 수 있습니다. 메모리에 다 들어가지 않는 파일은 chunksize=100_000으로 나눠 읽으며 조각마다 집계한 뒤 합칩니다.
결측 처리 두 줄은 선택지를 보여 주는 예시이고, 둘을 연달아 실행하면 dropna()가 이미 결측 행을 지웠으므로 fillna(0)은 아무 일도 하지 않습니다. dropna()는 어느 열이든 결측이 하나라도 있는 행을 지우기 때문에 열이 많은 데이터에서는 대부분의 행이 사라질 수 있습니다. subset=['amount']로 기준 열을 좁히세요. drop_duplicates()도 기본적으로 모든 열이 같은 행만 중복으로 보므로, 주문 번호가 같으면 중복으로 볼 때는 subset=['order_id']를 지정합니다.
메서드 체이닝은 중간 변수 없이 처리 과정을 위에서 아래로 읽히게 해 줍니다. 괄호로 전체를 감싸면 줄바꿈을 자유롭게 할 수 있습니다. query('age >= 25')는 문자열로 조건을 쓰는 방식이라 짧고 읽기 쉽지만, 열 이름에 공백이 있으면 백틱(`열 이름`)으로 감싸야 하고 파이썬 변수는 @변수명으로 참조합니다.
Pandas 요약
- Pandas: 데이터 분석 라이브러리
- DataFrame: 2차원 테이블
- 읽기/쓰기: CSV, Excel, JSON
- 조작: 필터링, 그룹화, 병합
- 집계: sum, mean, count
다음 단계
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 여러 조건으로 필터링할 때 and를 쓰면 왜 에러가 나나요?
A. df['age'] >= 25의 결과는 True/False 하나가 아니라 행마다 값이 들어 있는 Series이기 때문에, Python의 and로 묶으면 참·거짓을 하나로 판단할 수 없다는 ValueError가 발생합니다. Pandas에서는 &(그리고), |(또는), ~(부정)을 쓰고, 연산자 우선순위 때문에 각 조건을 괄호로 감싸야 합니다. 예를 들어 df[(df['age'] >= 25) & (df['city'] == '서울')]처럼 작성하며, 조건이 길어지면 df.query('age >= 25')를 쓰는 것도 방법입니다.