데이터 분석을 하다 보면 단순히 데이터를 조회하는 것보다 새로운 열을 만들고, 기존 값을 수정하고, 필요 없는 행이나 열을 제거하고, 외부 API 데이터를 DataFrame으로 변환하는 작업을 훨씬 자주 하게 된다.
Pandas는 이런 작업을 자연스럽게 처리할 수 있도록 DataFrame을 중심으로 다양한 기능을 제공한다.
이번 글에서는 다음 내용을 한 흐름으로 정리한다.
set_index(), reset_index()NaN이 생기는 이유drop()을 이용한 행·열 삭제inplace가 원본 데이터에 미치는 영향개발에서는 데이터를 다루는 기본 동작을 흔히 CRUD라고 부른다.
| 구분 | 의미 | Pandas에서의 예 |
|---|---|---|
| Create | 생성 | 컬럼 추가, 새로운 DataFrame 생성 |
| Read | 조회 | 인덱싱, 필터링, loc, iloc |
| Update | 수정 | 특정 값 변경, 컬럼 덮어쓰기 |
| Delete | 삭제 | drop()으로 행·열 제거 |
Pandas는 데이터베이스 자체는 아니지만, 분석용 데이터를 다룰 때 CRUD와 비슷한 흐름을 자주 사용한다.
원본 데이터
↓
필요한 컬럼 추가
↓
값 수정
↓
인덱스 재구성
↓
불필요한 행/열 제거
↓
분석 가능한 형태로 정리
먼저 간단한 사용자 행동 데이터를 만들어보자.
import pandas as pd
df = pd.DataFrame({
"user_id": [101, 102, 103, 104, 105],
"plan": ["free", "pro", "free", "pro", "free"],
"purchase_count": [0, 3, 1, 5, 2]
})
df
새로운 컬럼을 추가하는 가장 단순한 방법은 존재하지 않는 컬럼 이름에 값을 대입하는 것이다.
df["status"] = "active"
이 경우 모든 행에 동일한 값이 들어간다.
user_id plan purchase_count status
0 101 free 0 active
1 102 pro 3 active
2 103 free 1 active
3 104 pro 5 active
4 105 free 2 active
즉,
df["새컬럼"] = 값
형태는 컬럼 생성과 값 할당을 동시에 수행한다.
리스트, NumPy 배열, Series 등 여러 값을 넣을 수도 있다.
df["grade"] = ["D", "B", "C", "A", "B"]
중요한 점은 할당하는 데이터의 길이가 DataFrame의 행 개수와 맞아야 한다는 것이다.
이미 존재하는 컬럼에 값을 다시 대입하면 기존 데이터가 변경된다.
df["grade"] = ["C", "A", "B", "A", "B"]
조건에 따라 값을 바꾸는 경우에는 loc가 유용하다.
df.loc[df["purchase_count"] >= 3, "grade"] = "VIP"
의미는 다음과 같다.
purchase_count가 3 이상인 행 선택
↓
grade 컬럼 선택
↓
"VIP"로 변경
Pandas에서 처음 헷갈리기 쉬운 부분 중 하나가 Series를 DataFrame 컬럼으로 추가할 때 단순히 위치 순서대로 들어가지 않을 수 있다는 점이다.
예를 들어 다음 DataFrame이 있다고 하자.
df = pd.DataFrame({
"user_id": [101, 102, 103]
})
df.index = ["u1", "u2", "u3"]
그리고 별도의 Series를 만든다.
segment = pd.Series(["new", "regular", "vip"])
segment의 인덱스는 기본적으로 다음과 같다.
0, 1, 2
반면 df의 인덱스는 다음과 같다.
u1, u2, u3
이 상태에서 바로 할당하면:
df["segment"] = segment
Pandas는 위치가 아니라 인덱스 라벨을 기준으로 정렬(alignment) 하려고 한다.
두 객체의 인덱스가 서로 일치하지 않기 때문에 값이 정상적으로 매칭되지 않고 NaN이 생길 수 있다.
직접 실행한 과정에서도 DataFrame 인덱스를 문자열 라벨로 바꾼 뒤, 기본 RangeIndex를 가진 Series를 컬럼으로 할당했을 때 값 대신 NaN이 채워지는 현상을 확인할 수 있었다.
segment.index = df.index
df["segment"] = segment
이제 각 인덱스가 정확히 대응되기 때문에 정상적으로 값이 들어간다.
Pandas는 단순한 위치 기반 배열이 아니라 인덱스 라벨을 기준으로 데이터를 정렬하는 자료구조다.
DataFrame을 만들면 기본적으로 다음과 같은 인덱스가 생긴다.
df.index
예:
RangeIndex(start=0, stop=5, step=1)
인덱스는 각 행을 식별하는 라벨 역할을 한다.
기본 숫자 인덱스를 직접 바꿀 수도 있다.
df.index = ["user-a", "user-b", "user-c", "user-d", "user-e"]
이제 loc를 통해 라벨 기반으로 접근할 수 있다.
df.loc["user-c"]
기존 컬럼을 인덱스로 올리고 싶다면 set_index()를 사용한다.
df = pd.DataFrame({
"user_id": [101, 102, 103],
"plan": ["free", "pro", "free"],
"purchase_count": [0, 3, 1]
})
user_id를 인덱스로 설정:
df.set_index("user_id", inplace=True)
이제 user_id는 일반 컬럼이 아니라 행을 식별하는 인덱스가 된다.
df.loc[102]
인덱스에는 보통 사용자 ID, 주문 번호, 상품 ID처럼 행을 식별할 수 있는 값을 사용하는 편이 이해하기 쉽다.
현재 인덱스를 다시 컬럼으로 내리고 기본 숫자 인덱스를 복구하려면 reset_index()를 사용한다.
df.reset_index(inplace=True)
기존 인덱스는 일반 컬럼으로 돌아오고 새로운 RangeIndex가 생성된다.
기존 인덱스를 컬럼으로 남길 필요가 없다면:
df.reset_index(drop=True, inplace=True)
drop=True를 사용하면 기존 인덱스를 버리고 기본 숫자 인덱스만 만든다.
Pandas 메서드를 사용할 때 자주 등장하는 옵션이 inplace다.
df.reset_index(inplace=True)
inplace=True는 현재 DataFrame 객체 자체를 수정하겠다는 의미다.
반대로 기본값인 inplace=False에서는 변경된 결과를 새로운 객체로 반환하고 원본은 그대로 유지된다.
특정 컬럼을 제거하려면 drop()을 사용한다.
df.drop("grade", axis=1)
axis=1은 컬럼 방향을 의미한다.
좀 더 읽기 쉽게 다음처럼 작성할 수도 있다.
df.drop("grade", axis="columns")
하지만 다음 코드만 실행하면:
df.drop("grade", axis=1)
삭제된 DataFrame이 반환될 뿐 원본 df는 그대로 유지된다.
직접 실행 결과에서도 drop()의 반환 결과에서는 컬럼이 사라졌지만, 원본 DataFrame을 다시 확인했을 때 해당 컬럼이 그대로 존재하는 것을 확인할 수 있었다.
df2 = df.drop("grade", axis=1)
df.drop("grade", axis=1, inplace=True)
행을 삭제할 때는 axis=0을 사용한다.
df.drop(102, axis=0)
여기서 전달하는 값은 행의 위치가 아니라 인덱스 라벨이다.
웹 API에서 데이터를 받아오면 응답 형식으로 JSON을 자주 만나게 된다.
{
"user_id": 101,
"name": "Kim",
"plan": "pro"
}
JSON은 key: value 구조를 사용하며 Python 딕셔너리와 매우 비슷하다.
일반적인 처리 흐름은 다음과 같다.
API 서버
↓
JSON 문자열 응답
↓
Python dict / list로 변환
↓
필요한 데이터 추출
↓
Pandas DataFrame 생성
Python에서 HTTP 요청을 보낼 때 requests 라이브러리를 많이 사용한다.
import requests
직접 실행 환경에서는 처음 requests를 import했을 때 다음 오류가 발생했다.
ModuleNotFoundError: No module named 'requests'
현재 Jupyter Kernel이 사용하는 Python 환경에 requests가 설치되어 있지 않았기 때문이다.
Notebook에서는 다음처럼 설치할 수 있다.
%pip install requests
설치 이후 requests import가 정상적으로 동작하는 것을 확인할 수 있었다.
%pip를 사용하는 이유터미널에서 사용하는 pip와 Jupyter Kernel이 서로 다른 Python 환경을 가리키면 패키지를 설치했는데도 import가 되지 않을 수 있다.
Notebook 안에서 다음 명령을 사용하면 현재 Kernel 환경을 기준으로 설치하기 때문에 환경 혼동을 줄일 수 있다.
%pip install requests
실행 과정에서는 의존성 버전을 맞추기 위해 urllib3 버전을 다시 조정한 기록도 있었다.
%pip install "urllib3<2" --force-reinstall
환경마다 필요한 버전은 다를 수 있으므로 이런 명령은 오류 메시지나 패키지 호환성을 확인한 뒤 필요한 경우에만 사용하는 것이 좋다.
API를 호출할 때 인증 키가 필요한 경우가 많다.
공개할 코드에서는 실제 키를 직접 작성하지 않는 것이 안전하다.
import os
api_key = os.environ.get("API_KEY")
Windows PowerShell:
$env:API_KEY="your-api-key"
macOS / Linux:
export API_KEY="your-api-key"
블로그나 Git 저장소에는 실제 인증 정보를 남기지 않는 것이 중요하다.
다음은 재현 가능한 형태로 단순화한 예시다.
import os
import requests
api_key = os.environ.get("API_KEY")
url = "https://api.example.com/resources"
params = {
"serviceKey": api_key,
"pageNo": 1,
"numOfRows": 100,
"type": "json"
}
response = requests.get(url, params=params)
URL을 직접 문자열로 이어 붙일 수도 있지만 params를 사용하면 요청 파라미터를 분리해서 관리하기 쉽다.
JSON 파싱을 하기 전에 HTTP 요청이 성공했는지 먼저 확인하는 습관이 좋다.
print(response.status_code)
오류 응답이라면 예외를 발생시키도록 할 수도 있다.
response.raise_for_status()
응답 전체가 매우 길다면 일부만 확인한다.
print(response.text[:300])
직접 실행한 API 요청에서는 정상 처리 상태를 나타내는 응답과 함께 실제 데이터가 반환되는 것을 확인할 수 있었다.
JSON 문자열은 json.loads()로 Python 객체로 바꿀 수 있다.
import json
data = json.loads(response.text)
변환 후에는 먼저 최상위 키를 확인해보는 것이 좋다.
data.keys()
직접 실행한 응답에서는 최상위 구조가 다음 두 키로 구성되어 있었다.
header
body
API마다 JSON 구조가 다르기 때문에 실제 응답의 키를 한 단계씩 확인하는 습관이 중요하다.
중첩 JSON을 다루다 보면 다음과 같은 오류를 자주 만나게 된다.
KeyError: 'response'
직접 실행한 과정에서도 실제 최상위 키에 존재하지 않는 "response"를 사용해 접근하면서 KeyError가 발생했다.
예를 들어 실제 구조가 다음과 같다고 하자.
{
"header": {...},
"body": {...}
}
그런데 다음처럼 접근하면 오류가 발생한다.
data["response"]["body"]
"response"라는 키가 존재하지 않기 때문이다.
이럴 때는 다음처럼 한 단계씩 확인한다.
print(data.keys())
print(data["body"].keys())
중첩 JSON을 다룰 때 가장 기본적이고 효과적인 디버깅 방법이다.
API 응답은 종종 다음처럼 여러 단계로 중첩되어 있다.
body
└── items
└── item
├── record 1
├── record 2
└── record 3
실제 데이터 목록까지 접근하는 코드는 다음 형태가 된다.
items = data["body"]["items"]["item"]
첫 번째 레코드를 확인하면 어떤 키가 들어 있는지 파악하기 쉽다.
print(items[0])
API는 많은 필드를 반환하지만 실제 분석에는 일부만 필요할 수 있다.
예를 들어 위치 관련 데이터에서 다음 네 값만 사용한다고 하자.
독립적인 형태로 다시 작성하면:
rows = []
for item in items:
rows.append({
"address": item.get("address"),
"location": item.get("location"),
"latitude": item.get("latitude"),
"longitude": item.get("longitude")
})
item["address"] 대신 item.get("address")를 사용하면 해당 키가 없는 경우 즉시 KeyError가 발생하는 것을 줄일 수 있다.
리스트 안에 딕셔너리가 있는 형태라면 바로 DataFrame으로 만들 수 있다.
df = pd.DataFrame(rows)
직접 실행한 API 처리 과정에서는 필요한 필드 네 개를 추출해 최종적으로 100행 × 4열 형태의 DataFrame을 생성할 수 있었다.
전체 흐름은 다음과 같다.
API 요청
↓
JSON 문자열 응답
↓
dict 변환
↓
중첩 구조 확인
↓
필요한 필드 추출
↓
DataFrame 생성
각 레코드가 딕셔너리라면 리스트 컴프리헨션을 사용할 수도 있다.
rows = [
{
"address": item.get("address"),
"location": item.get("location"),
"latitude": item.get("latitude"),
"longitude": item.get("longitude")
}
for item in items
]
df = pd.DataFrame(rows)
이 방식은 같은 목적을 조금 더 간결하게 표현한다.
변환 직후에는 기본 상태부터 확인하는 것이 좋다.
print(df.shape)
print(df.head())
print(df.dtypes)
print(df.isna().sum())
API 데이터에서는 다음 문제가 자주 발생한다.
빈 문자열
null
누락된 key
숫자가 문자열로 전달됨
예상과 다른 중첩 구조
페이지별 응답 개수 차이
직접 확인한 API 데이터에도 일부 주소 값이 빈 문자열인 레코드가 있었다.
따라서 API 수집 이후에는 거의 항상 전처리 단계가 필요하다.
API에서는 숫자처럼 보이는 값이 문자열로 전달되는 경우가 많다.
{
"latitude": "37.1234",
"longitude": "127.5678"
}
분석이나 시각화에 사용하려면 숫자형으로 변환하는 편이 좋다.
df["latitude"] = pd.to_numeric(
df["latitude"],
errors="coerce"
)
df["longitude"] = pd.to_numeric(
df["longitude"],
errors="coerce"
)
errors="coerce"를 사용하면 숫자로 변환할 수 없는 값은 NaN으로 처리된다.
| 상황 | 원인 | 확인 방법 |
|---|---|---|
ModuleNotFoundError | 현재 환경에 패키지가 없음 | %pip install ... 후 import 재확인 |
KeyError | 예상한 JSON key가 실제 구조에 없음 | dict.keys()로 단계별 구조 확인 |
Series 할당 후 NaN | Series와 DataFrame 인덱스 불일치 | 양쪽 .index 비교 |
이 문제들은 모두 문법 자체보다 현재 실행 환경과 데이터 구조를 먼저 확인해야 해결되는 문제라는 공통점이 있다.
웹 개발 관점에서도 API 데이터 처리 흐름은 익숙하다.
Backend / Open API
↓
JSON Response
↓
Python dict / list
↓
Pandas DataFrame
↓
필터링 / 수정 / 집계
↓
분석 결과
프론트엔드에서는 다음과 같이 데이터를 받을 수 있다.
const response = await fetch("/api/users");
const data = await response.json();
Python에서는 비슷하게:
response = requests.get(url)
data = response.json()
처럼 사용할 수 있다.
이후 데이터를 Pandas의 DataFrame으로 바꾸면 열 단위 연산, 필터링, 집계, 전처리를 편리하게 수행할 수 있다.
df["새컬럼"] = 값 형태로 컬럼을 생성하거나 수정할 수 있다.set_index()는 특정 컬럼을 인덱스로 만들고, reset_index()는 인덱스를 다시 일반 컬럼으로 되돌릴 수 있다.drop()은 기본적으로 변경된 DataFrame을 반환하며 원본을 바로 수정하지 않는다.inplace=True를 사용하면 원본 객체에 변경 사항을 반영한다.keys()를 사용해 실제 구조를 확인하는 것이 중요하다.KeyError가 발생하므로 한 단계씩 구조를 탐색하는 습관이 좋다.Pandas에서 데이터를 변경하는 작업은 단순한 문법 암기보다 DataFrame이 인덱스를 기준으로 데이터를 정렬하고, 메서드에 따라 원본 변경 여부가 달라진다는 점을 이해하는 것이 중요하다.
또한 API 데이터를 분석할 때는 바로 DataFrame을 만드는 것보다 먼저 JSON 구조를 확인하고, 필요한 데이터가 어느 경로에 있는지 단계적으로 탐색하는 습관이 필요하다.
결국 데이터 분석에서 자주 반복되는 흐름은 다음처럼 정리할 수 있다.
데이터 수집
↓
구조 확인
↓
필요한 값 선택
↓
DataFrame 변환
↓
생성 / 수정 / 삭제
↓
분석 가능한 형태로 정리
이 흐름에 익숙해지면 이후 데이터 전처리, 병합, 그룹화, 시각화 작업도 훨씬 자연스럽게 이어갈 수 있다.