
판다스(pandas)로 큰 CSV나 DB 테이블을 읽었을 때, 왜 유독 문자열 컬럼이 메모리 절반을 차지해 버리는 걸까요? 정수나 실수 컬럼은 고정 크기 배열로 저장되지만, 기본 object dtype 문자열 컬럼은 셀 하나마다 별도의 파이썬 문자열 객체를 만들고 그 주소를 포인터 배열로 연결하기 때문입니다. 이 글에서는 pandas 2.x와 pyarrow 환경을 기준으로 object, category, Arrow 기반 문자열(string[pyarrow] / pd.ArrowDtype) 세 방식이 실제로 메모리를 어떻게 다르게 쓰는지 계산 근거와 함께 비교해 보겠습니다.
object 컬럼이 메모리를 많이 먹는 근본적인 이유
object dtype 컬럼은 내부적으로 8바이트짜리 포인터 배열 하나와, 그 포인터가 가리키는 개별 파이썬 str 객체들로 구성됩니다. CPython 3.11 64비트 기준으로 빈 문자열 하나의 sys.getsizeof() 값이 이미 49바이트이고, ASCII 문자 하나당 1바이트씩 늘어납니다. 즉 8글자짜리 문자열 하나가 대략 57바이트를 차지하는 셈인데, 이게 행마다 따로 생성된다는 점이 문제입니다.
여기서 많은 분이 놓치는 부분이 있습니다. df.memory_usage()를 기본 옵션으로 호출하면 문자열 컬럼도 포인터 배열 크기(행당 8바이트)만 보여주기 때문에 실제보다 훨씬 작아 보입니다. 진짜 크기를 보려면 deep=True 옵션이 필요합니다.
import pandas as pd
import numpy as np
n = 1_000_000
statuses = ['pending', 'shipped', 'delivered', 'cancelled', 'returned']
df = pd.DataFrame({
'id': np.arange(n, dtype='int64'),
'amount': np.random.rand(n),
'status': np.random.choice(statuses, size=n),
})
print(df.memory_usage())
print(df.memory_usage(deep=True))
deep=True 없이 실행하면 id, amount, status 세 컬럼 모두 8,000,000바이트로 똑같이 나옵니다. 하지만 deep=True를 붙이면 status 컬럼만 포인터 8MB에 더해 100만 개의 개별 str 객체 오버헤드(대략 57MB 안팎)가 추가로 잡혀서 전체 데이터프레임 메모리의 절반 이상을 이 컬럼 하나가 차지하는 그림이 나옵니다. 이 수치는 실행 환경(파이썬·pandas 버전, OS)에 따라 조금씩 달라질 수 있는 근사치라는 점은 감안해야 합니다.
category로 바꾸면 정말 메모리가 줄어드나요?
위 예시처럼 ‘status’ 컬럼이 5종류 값만 반복된다면 category dtype이 확실한 해법입니다. category는 실제 문자열을 딱 한 번씩만 저장해 두고, 각 행에는 그 값을 가리키는 정수 코드만 넣습니다. 유일값이 127개 이하면 코드 배열은 int8로, 32,767개 이하면 int16으로 자동 선택됩니다.

df['status'] = df['status'].astype('category')
print(df['status'].memory_usage(deep=True))
print(df['status'].cat.codes.dtype)
이 경우 코드 배열은 100만 행 × 1바이트로 약 0.95MB, 여기에 유일값 5개를 저장하는 카테고리 목록이 몇백 바이트 더 붙는 정도입니다. 앞서 object 상태에서 60MB 안팎이었던 것과 비교하면 60분의 1 수준으로 줄어드는 셈인데, 이건 유일값 개수가 행 수 대비 극히 적을 때 성립하는 이야기입니다. pandas 공식 문서의 카테고리형 설명에도 이 dtype이 ‘반복되는 유한한 값 집합’을 전제로 설계됐다는 점이 명시돼 있습니다.
카디널리티가 높은 컬럼엔 Arrow 문자열이 유리합니다
이메일 주소나 UUID처럼 유일값 개수가 행 수에 거의 맞먹는 컬럼이라면 category는 별 도움이 안 됩니다. 유일값이 90만 개인데 행이 100만 개면, 코드 배열도 커야 하고(유일값이 32,767개를 넘는 순간 int16에서 int32로 올라갑니다) 카테고리 목록 자체도 거의 원본만큼 큰 문자열 모음이 되어 버리기 때문입니다.
이럴 때 pandas 2.0부터 쓸 수 있는 pd.ArrowDtype이 대안이 됩니다. Apache Arrow 포맷은 문자열을 하나의 연속된 바이트 버퍼에 이어 붙이고, 각 문자열의 시작 위치만 오프셋 배열(보통 int32, 4바이트)로 따로 관리합니다. 셀마다 별도 파이썬 객체를 만들지 않기 때문에 카디널리티와 무관하게 object보다 항상 가볍습니다.
import pyarrow as pa
df['status_arrow'] = df['status'].astype(pd.ArrowDtype(pa.string()))
print(df['status_arrow'].dtype)
print(df['status_arrow'].memory_usage(deep=True))
8글자 평균 문자열 100만 개 기준으로 어림잡으면 데이터 버퍼 약 8MB, 오프셋 배열 약 4MB, 결측치 표시용 validity bitmap 0.1MB 남짓을 더해 12MB 안팎이 나옵니다. category(약 1MB)보다는 크지만 object(약 60MB 이상)보다는 훨씬 작은, 딱 중간 지점입니다. 이 기능을 쓰려면 pyarrow가 별도로 설치돼 있어야 하고, 벡터 연산까지 온전히 활용하려면 pyarrow 10 이상을 권장합니다.
메모리·속도·호환성 기준으로 세 방식 정리
| 기준 | object | category | Arrow string(ArrowDtype) |
|---|---|---|---|
| 저장 구조 | 포인터 배열 + 개별 str 객체 | 정수 코드 배열 + 유일값 목록 | 연속 버퍼 + 오프셋 배열 |
| 유리한 경우 | 호환성만 필요할 때 | 카디널리티 낮음(수십~수백 종류) | 카디널리티 높음(고유값 많음) |
| 문자열 비교·정렬 | 파이썬 객체 비교라 느림 | 코드값 비교라 빠름 | Arrow 연산 커널로 빠름 |
| 결측치 처리 | None/NaN 혼재 가능 | 코드 -1로 별도 관리 | validity bitmap으로 명확 |
| 필요 조건 | 없음(기본값) | pandas 기본 기능 | pandas 2.0+, pyarrow 설치 |
표에서 보듯 세 방식 중 절대 우위는 없습니다. 컬럼의 유일값 비율을 먼저 확인하고 고르는 게 순서입니다.

category로 바꿨더니 groupby가 오히려 느려졌어요, 왜 그럴까요?
category 컬럼 두 개 이상을 기준으로 groupby를 돌릴 때 종종 나오는 문제입니다. pandas의 groupby는 observed 옵션이 꺼져 있으면(과거 기본값) 실제 데이터에 등장하지 않는 카테고리 조합까지 전부 그룹으로 만들어 버립니다. 카테고리가 A 컬럼 100종, B 컬럼 100종이면 실제 조합이 200개뿐이어도 10,000개 그룹을 순회하게 되는 식입니다.
이 문제를 인지한 pandas 팀은 최신 버전 문서에서 observed 기본값이 앞으로 바뀔 예정이라는 안내를 하고 있으니, category로 groupby를 쓸 때는 df.groupby('col', observed=True)처럼 명시적으로 지정해 두는 편이 안전합니다.
이 외에도 실무에서 자주 걸리는 함정이 몇 가지 있습니다.
- 서로 다른 카테고리 목록을 가진 두 데이터프레임을
concat하면 카테고리가 통합되거나 조용히 object로 되돌아가면서 애써 줄인 메모리가 원상복구될 수 있습니다. - category에 없던 새 값을 그냥 대입하면 에러가 나거나 무시되므로,
cat.add_categories()로 먼저 카테고리를 넓혀줘야 합니다. - ArrowDtype 컬럼은 일부 구버전 라이브러리(예전 버전의 scikit-learn, matplotlib 등)가 바로 못 받아들여서
.astype(object)로 되돌려야 하는 경우가 있는데, 이 변환 자체에도 시간이 듭니다.
즉 category와 Arrow 모두 ‘메모리를 아끼는 대신 어딘가에서 변환 비용이나 호환성 비용을 치른다’는 트레이드오프가 있습니다.
메모리가 빠듯하면 카디널리티부터 확인하고 dtype을 고르세요
문자열 컬럼이 메모리 절반을 먹는 원인은 단순합니다. object dtype이 행마다 파이썬 객체를 새로 만들기 때문입니다. 해법은 컬럼 성격에 따라 갈립니다. 반복되는 값이 적으면 category, 유일값이 많으면 Arrow 문자열이 각각 확실한 메모리 절감을 줍니다.
지금 바로 할 수 있는 점검은 이렇습니다. df[col].nunique() / len(df) 비율을 구해서 이 값이 낮으면(경험적으로 몇 퍼센트 이하) category를, 높으면 ArrowDtype을 시도해 보고, 두 경우 모두 반드시 memory_usage(deep=True)로 전후를 직접 비교해서 본인 데이터와 환경에서 실제로 줄어드는지 확인해 보시기 바랍니다.
