데이터 사이언스 작업에서 메모리와 속도 최적화는 대용량 데이터 처리의 핵심입니다. Python의 Pandas와 NumPy를 중심으로 한 데이터 처리 라이브러리들은 편의성이 뛰어나지만, 최적화 없이 사용하면 메모리 부족이나 처리 속도 저하 문제가 발생할 수 있습니다.
+) 이 글로 사내에서 세미나를 했는데 아주 Hot했습니다..^^
메모리 최적화는 대용량 데이터 처리의 첫 번째 관문입니다. Pandas는 기본적으로 가장 큰 데이터 타입을 사용하기 때문에 메모리가 낭비되며, 적절한 최적화 없이는 메모리 부족 오류가 발생할 수 있습니다.
Pandas에서 메모리 사용량을 줄이는 가장 효과적인 방법은 데이터 타입을 최적화하는 것입니다. 기본 int64를 int32로, float64를 float32로 변환하면 메모리 사용량을 절반으로 줄일 수 있습니다.
# 데이터 타입 확인
print(df.dtypes)
# 정수형 컬럼 최적화
df['id'] = df['id'].astype('int32') # int64 → int32
# 범주형 데이터 변환
df['category'] = df['category'].astype('category')
# 메모리 사용량 확인
print(df.memory_usage(deep=True))
이 현상은 Pandas의 특수한 동작 때문이 아니라, 컴퓨터가 숫자를 저장하는 방식에서 비롯됩니다.
컴퓨터에서 모든 숫자는 이진수(bit) 형태로 저장되며, 각 데이터 타입은 고정된 크기를 가집니다.
대표적인 타입의 메모리 크기는 다음과 같습니다.
| 타입 | 비트 | 바이트 |
|---|---|---|
| int32 | 32 bits | 4 bytes |
| int64 | 64 bits | 8 bytes |
| float32 | 32 bits | 4 bytes |
| float64 | 64 bits | 8 bytes |
즉, int64와 float64는 각각 8바이트,
int32와 float32는 4바이트를 사용합니다.
따라서 동일한 개수의 값이 있을 때,
64비트 타입을 32비트 타입으로 변경하면 각 원소가 차지하는 메모리가 정확히 절반이 됩니다.
Pandas의 Series와 DataFrame 컬럼은 내부적으로 NumPy 배열(ndarray) 위에 저장됩니다.
이는 곧, 컬럼 하나가 연속된 메모리 블록으로 구성되며 모든 값이 동일한 dtype을 사용한다는 의미입니다.
이 때문에 컬럼의 메모리 사용량은 다음과 같이 계산됩니다.
전체 메모리 = row 수 × dtype 크기
예를 들어 1,000,000개의 값이 있을 경우:
차이는 정확히 2배입니다.
첫 번째 이유는 표현 가능한 값의 범위입니다.
int32는 약 ±21억까지만 표현할 수 있지만,
int64는 훨씬 큰 값을 다룰 수 있습니다. 실무 데이터에서는 ID, timestamp, 누적 카운트 등이 쉽게 int32 범위를 초과할 수 있기 때문에, Pandas는 안전한 기본값으로 int64를 선택합니다.
두 번째 이유는 float64가 과학 계산의 표준이기 때문입니다.
float64는 약 15자리 십진 정밀도를 제공하지만, float32는 약 7자리 수준에 불과합니다. 머신러닝이나 통계 계산에서는 오차 누적을 방지하기 위해 float64가 기본으로 사용됩니다.
실제 환경에서는 이 차이가 더욱 크게 체감됩니다.
수백만 row와 수십 개 이상의 컬럼을 가진 데이터셋에서는,
float64 대신 float32를 사용하는 것만으로도 수백 MB에서 수 GB 단위의 메모리를 절약할 수 있습니다.
이 차이는 단순한 저장 공간뿐 아니라:
에도 직접적인 영향을 줍니다.
정리하면, int64 → int32, float64 → float32 변환 시 메모리가 절반으로 줄어드는 이유는 각 값이 차지하는 비트 수가 64에서 32로 감소하기 때문입니다.
Pandas는 고정 폭 dtype을 사용하는 연속 메모리 구조를 가지므로, 이러한 차이가 전체 메모리 사용량에 그대로 반영됩니다.
데이터 타입 최적화는 단순한 미세 조정이 아니라, 대용량 데이터 처리에서 가장 먼저 고려해야 할 구조적 최적화입니다.
앞서 수동으로 astype()을 사용해 데이터 타입을 변경할 수도 있지만,
Pandas는 보다 안전하게 타입을 축소할 수 있도록 pd.to_numeric()의 downcast 옵션을 제공합니다.
다운캐스팅은 현재 값들을 표현할 수 있는 가장 작은 숫자 타입으로 자동 변환하는 과정입니다.
df['value'] = pd.to_numeric(df['value'], downcast='integer')
df['price'] = pd.to_numeric(df['price'], downcast='float')
downcast 옵션은 단순히 int64를 무조건 int32로 바꾸는 기능이 아닙니다.
Pandas는 다음과 같은 순서로 동작합니다.
예를 들어 downcast='integer'를 사용하면 내부적으로 다음 후보들을 검사합니다.
그리고 실제 값의 범위가 int8에 들어가면 int8,
int16에 들어가면 int16,
그보다 크면 int32 … 이런 식으로 자동 선택합니다.
float 역시 동일하게:
중에서 가능한 가장 작은 타입을 선택합니다.
즉, downcast는 “값을 잃지 않는 범위 내에서 최소 메모리 타입을 찾는 과정”입니다.
astype()은 개발자가 dtype을 직접 지정합니다.
df['id'] = df['id'].astype('int32')
이 방식은 빠르고 단순하지만,
값 범위를 벗어나면 overflow가 발생하거나 에러가 날 수 있습니다.
반면 downcast는 자동 검사 후 변환하기 때문에 상대적으로 안전합니다.
즉:
대량 데이터에서는 downcast가 실수 가능성을 크게 줄여줍니다.
실제 데이터에서는 다음과 같은 경우가 많습니다.
이런 컬럼들이 기본적으로 int64 / float64로 로딩되면 필요 이상의 메모리를 차지합니다.
downcast를 적용하면:
까지 자동으로 축소되는 경우가 많아,
수십 퍼센트 이상의 메모리 절감 효과를 얻을 수 있습니다.
float downcast는 정밀도 손실 가능성이 있습니다.
float32는 약 7자리 십진 정밀도만 유지되므로,
아주 작은 차이가 중요한 금융 계산이나 통계 분석에서는 주의가 필요합니다.
또한 pd.to_numeric()은 문자열 컬럼도 숫자로 변환하려 시도하므로,
혼합 타입 컬럼에서는 의도치 않은 NaN이 생길 수 있습니다.
정리하면, downcasting은 컬럼의 실제 값 범위를 기반으로 가장 작은 dtype을 자동 선택하는 메커니즘이며,
대용량 데이터에서 메모리 최적화를 위한 매우 효과적인 첫 단계입니다.
특히 여러 컬럼을 동시에 처리해야 할 경우, 수동 astype보다 downcast 방식이 훨씬 안정적입니다.
pandas에서 문자열 컬럼은 기본적으로 object 타입으로 저장됩니다. 이 경우 각 행마다 개별 Python 문자열 객체가 생성되며, 동일한 문자열이라도 반복해서 메모리에 저장됩니다. 이 구조는 대량 데이터에서 불필요한 메모리 사용을 유발합니다.
category 타입은 이러한 문자열 컬럼을 고유값 테이블(categories) 과 정수 인덱스 배열(codes) 로 분리하여 저장합니다. 고유 문자열은 한 번만 저장되고, 실제 데이터는 작은 정수로 참조됩니다.
즉 내부 구조는 다음과 같습니다.
이 방식은 반복되는 문자열을 직접 저장하지 않기 때문에, 고유값 수가 적은 컬럼에서 메모리 절감 효과가 큽니다.
# 범주형 변환 전후 메모리 비교
print(df['status'].memory_usage(deep=True)) # 변환 전
df['status'] = df['status'].astype('category')
print(df['status'].memory_usage(deep=True)) # 변환 후
카디널리티가 낮을수록(categories의 크기가 작을수록) 효과가 크며, 고유값이 많은 컬럼에서는 오히려 이점이 줄어들 수 있습니다. 따라서 상태값, 타입 코드, 플래그와 같은 반복 패턴이 있는 컬럼에 주로 적용하는 것이 적절합니다.
문자열 컬럼은 기본적으로 object 타입으로 저장됩니다. 이 경우 각 행마다 독립적인 Python 문자열 객체가 생성되며, 동일한 문자열이라도 반복해서 메모리에 저장됩니다.
예를 들어 다음과 같은 컬럼이 있을 때:
status
------
OK
FAIL
OK
OK
FAIL
pandas 내부에서는 다음과 같이 처리됩니다.
["OK", "FAIL", "OK", "OK", "FAIL"]
각 원소는 다음 구성 요소를 가집니다.
즉 전체 메모리는 대략 다음 형태가 됩니다.
행 수 N × (포인터 + 문자열 객체 + 실제 문자 데이터)
같은 "OK" 값이라도:
이 구조 때문에 데이터가 커질수록 메모리 사용량이 빠르게 증가하며, memory_usage(deep=True) 값도 크게 나타납니다.
df['status'] = df['status'].astype('category')
이렇게 변환하면 pandas는 컬럼을 두 부분으로 분리합니다.
Index(['FAIL', 'OK'])
컬럼의 고유 문자열만 별도로 저장하며, 각 문자열은 단 한 번만 메모리에 올라갑니다.
원본 데이터:
OK, FAIL, OK, OK, FAIL
은 다음과 같이 정수로 변환됩니다.
[1, 0, 1, 1, 0]
이 배열은 numpy 기반의 정수 배열이며,
이라는 특징을 가집니다.
category 컬럼은 내부적으로 다음과 같이 구성됩니다.
categories
→ ['FAIL', 'OK'] (고유 문자열만 저장)
codes
→ [1, 0, 1, 1, 0] (순수 정수 배열)
정리하면:
변환 전:
N × string 객체
변환 후:
K × string + N × 작은 정수
여기서:
입니다.
예를 들어 1,000,000 rows가 있을 때,
고유값이 3개라면:
으로 매우 큰 메모리 절감 효과를 얻습니다.
반면 고유값이 900,000개라면:
가 되어 절감 효과가 거의 없으며, 오히려 성능이 나빠질 수도 있습니다.
따라서 category 타입은 상태값, 타입 코드, 플래그처럼 반복 패턴이 뚜렷한 low-cardinality 컬럼에 사용하는 것이 일반적입니다.
deep=True 옵션은 object 내부 문자열 메모리까지 모두 포함해 계산합니다.
category로 변환하면:
이 때문에 메모리 사용량이 눈에 띄게 감소하게 됩니다.
대용량 CSV 파일을 pd.read_csv()로 한 번에 로드하면, 파일 전체가 메모리에 올라가게 됩니다. 이때 DataFrame은 단순히 파일 크기만큼의 메모리를 사용하는 것이 아니라, 파싱 과정에서 생성되는 Python 객체와 내부 버퍼까지 포함하여 더 많은 메모리를 요구합니다.
즉 다음과 같은 호출은:
df = pd.read_csv("large_file.csv")
내부적으로:
파일 크기가 수 GB 이상이거나, 시스템 메모리가 제한적인 환경에서는 이 과정에서 쉽게 메모리 부족(OOM)이 발생할 수 있습니다.
chunksize 옵션을 사용하면 pandas는 파일을 한 번에 읽지 않고, 지정한 행 수 단위로 나누어 순차적으로 로드합니다.
chunk_size = 10000
for chunk in pd.read_csv('large_file.csv', chunksize=chunk_size):
process_chunk(chunk)
이 방식에서는:
결과적으로 동시에 메모리에 존재하는 데이터의 크기가 chunk_size에 의해 제한됩니다.
내부적으로는 다음과 같은 흐름으로 동작합니다.
즉:
파일 → 작은 DataFrame → 처리 → 버림 → 다음 DataFrame
이라는 패턴으로 동작합니다.
이 방식의 핵심 장점은 메모리 사용량이 데이터 전체 크기가 아니라, 청크 크기에 의해 결정된다는 점입니다.
예를 들어:
라면, 실제 메모리에는 항상 10,000행 정도만 존재하게 됩니다.
따라서 로그 데이터, 센서 데이터, 대규모 이벤트 로그처럼 파일 크기가 메모리를 초과하는 경우에도 안정적으로 처리가 가능합니다.
청크 처리는 특히 다음과 같은 상황에서 유용합니다.
정리하면, chunksize는 pandas가 제공하는 스트리밍 처리 방식이며, 대용량 데이터를 “로드”하는 대신 “순차 소비”하도록 구조를 바꿔주는 기능입니다. 메모리 제약 환경에서는 사실상 필수적인 접근 방식입니다.
pandas의 많은 연산은 기본적으로 새로운 DataFrame 또는 Series 객체를 생성합니다. 이는 함수형 스타일을 유지하기 위한 설계이지만, 대용량 데이터에서는 불필요한 메모리 사용으로 이어질 수 있습니다.
예를 들어 다음과 같은 코드에서:
subset = df[df['value'] > 100]
pandas는 조건에 맞는 행을 골라 완전히 새로운 DataFrame을 생성합니다. 이때 선택된 데이터는 원본과 메모리를 공유하지 않으며, 실제 값이 모두 복사됩니다.
즉 내부적으로는:
원본 DataFrame → 조건 필터 → 새 DataFrame 생성 → 데이터 복사
라는 흐름을 따릅니다.
행 수가 많을수록 이 복사 비용은 빠르게 증가합니다.
마찬가지로 다음과 같은 연산들도 대부분 새로운 객체를 반환합니다.
groupby()merge()concat()assign()drop() (기본값)이들은 모두 원본을 수정하지 않고 결과를 새로 만들어 돌려줍니다.
일부 메서드는 inplace=True 옵션을 제공합니다.
df.drop(columns=['unnecessary_col'], inplace=True)
이 경우 pandas는 기존 DataFrame 객체를 직접 수정하며, 별도의 결과 객체를 생성하지 않습니다. 따라서 불필요한 DataFrame 복사를 줄일 수 있습니다.
다만 내부 구현상 항상 완전한 zero-copy는 아니며, 컬럼 구조 변경이나 블록 재정렬이 필요한 경우에는 일부 메모리 재할당이 발생할 수 있습니다. 그럼에도 불구하고, 결과 객체를 하나 더 만드는 것보다는 메모리 사용이 훨씬 적습니다.
numpy와 pandas는 개념적으로 view(참조) 와 copy(복사) 를 구분합니다.
열 단위 접근은 종종 view를 반환합니다.
col = df['value']
이 경우 대부분 원본 데이터 블록을 공유합니다.
반면 조건 필터링은 항상 copy입니다.
subset = df.loc[df['value'] > 100]
여기서는 boolean mask가 적용되며, 결과 DataFrame은 새 메모리를 할당합니다.
즉 “필요할 때만 복사한다”는 의미는, 가능하면 컬럼 단위 참조를 활용하고, 대규모 행 필터링을 반복적으로 수행하지 않는 구조를 만드는 것을 의미합니다.
대용량 DataFrame을 다룰 때는 다음 원칙이 도움이 됩니다.
정리하면, pandas의 대부분 연산은 안전성을 위해 “복사 기반”으로 동작합니다. 작은 데이터에서는 문제가 되지 않지만, 대용량 환경에서는 이 복사 비용이 곧 메모리 병목이 됩니다. 따라서 연산 흐름을 설계할 때 객체 생성 횟수 자체를 줄이는 것이 중요합니다.
pandas 메모리 사용의 근본 원인은 Python 객체 자체의 구조에 있습니다. CPython에서 모든 객체는 단순한 값이 아니라, 메타데이터를 포함한 복합 구조로 관리됩니다.
예를 들어 하나의 Python 객체는 최소한 다음 정보를 포함합니다.
이 때문에 “값 하나”를 저장하더라도 상당한 고정 비용이 발생합니다.
실제로 일반적인 64bit CPython 환경에서의 대략적인 크기는 다음과 같습니다.
여기에 실제 데이터가 추가되면 크기는 더 증가합니다.
즉 Python에서 다음과 같은 코드가 있다고 할 때:
values = [1, 2, 3, 4]
메모리에는 단순히 4 × int 만 존재하는 것이 아니라,
가 모두 별도로 생성됩니다.
구조적으로는 다음과 같습니다.
list object
├─ pointer → int object
├─ pointer → int object
├─ pointer → int object
└─ pointer → int object
즉:
이 방식은 유연성과 안전성 측면에서는 장점이 있지만, 대량 데이터 처리에서는 매우 비효율적입니다.
반면 NumPy 배열은 Python 객체가 아니라, 연속된 C 메모리 블록 위에 순수 값만 저장합니다.
예를 들어 int64 배열의 경우:
입니다.
즉 다음과 같은 차이가 발생합니다.
Python list: N × (포인터 + Python int 객체)
NumPy array: N × 8 bytes
이 차이가 수백만 단위 데이터에서는 수십~수백 MB 이상의 격차로 이어집니다.
pandas에서 object 컬럼보다 numeric 컬럼이 훨씬 메모리 효율적인 이유도 동일한 원리입니다.
클래스 기반 구조에서도 기본 Python 객체는 __dict__ 를 사용하여 속성을 저장합니다.
class A:
pass
이 경우 각 인스턴스는 내부에 딕셔너리를 하나씩 가지게 됩니다.
__slots__ 를 사용하면 이 딕셔너리를 제거하고, 고정 슬롯 기반 구조로 바꿀 수 있습니다.
class A:
__slots__ = ("x", "y")
이 방식은:
효과가 있으며, 대량 객체를 생성하는 경우 유의미한 차이를 만듭니다.
pandas에서 메모리 문제가 발생하는 이유는 단순히 DataFrame이 커서가 아니라, 내부에 수많은 Python 객체가 생성되기 때문입니다.
따라서 메모리 최적화의 핵심은 다음과 같습니다.
__slots__ 등을 고려한다결국 pandas 메모리 사용량은 “데이터 크기”보다 “Python 객체 개수”에 더 크게 좌우됩니다.
처리 속도 최적화는 데이터 사이언스 워크플로우의 효율성을 결정합니다. Python은 편의성이 뛰어나지만 실행 속도가 느리다는 단점이 있으며, 특히 반복문 사용 시 성능 저하가 심각합니다.
Python에서 가장 큰 성능 병목은 반복문 자체가 아니라, Python 인터프리터가 개입하는 횟수입니다. for 루프를 사용한 순회 방식은 매 반복마다 Python 레벨의 연산과 타입 확인이 발생하며, 이 비용이 누적되면 전체 성능을 크게 저하시킵니다.
예를 들어 다음 코드를 보면:
result = []
for i in range(len(df)):
result.append(df.iloc[i]['value'] * 2)
이 한 줄의 연산 안에는 다음 과정이 반복됩니다.
for 루프 제어df.iloc[i] 인덱싱'value' 키 접근즉 단순한 곱셈 연산 하나를 위해, 여러 단계의 Python 객체 접근과 함수 호출이 매번 발생합니다.
반면 벡터화 연산은 완전히 다른 실행 경로를 가집니다.
result = df['value'].values * 2
이 코드에서 핵심은 .values 를 통해 NumPy ndarray 를 사용하는 것입니다.
NumPy 배열은 다음과 같은 특징을 가집니다.
이 상태에서 수행되는 * 2 연산은 Python 반복문이 아니라, C로 구현된 내부 루프에서 실행됩니다.
즉 실행 흐름은 다음과 같습니다.
Python 호출 1회 → C 레벨 루프 → 전체 배열 연산
반복문이 Python 레벨에서 실행되지 않기 때문에, 인터프리터 오버헤드가 거의 발생하지 않습니다.
두 방식의 차이는 연산 횟수가 아니라, 어디서 반복이 실행되느냐에 있습니다.
for loop:
Python → Python → Python → Python (N번)
Vectorization:
Python → C → C → C (N번)
Python은 동적 타입 언어이기 때문에, 각 연산마다 타입 확인과 참조 관리가 필요합니다. 반면 C 레벨에서는 타입이 고정되어 있으며, 연속 메모리 접근이 가능해 CPU 캐시 효율도 높습니다.
이 때문에 벡터화 연산은 일반적으로 Python 반복문 대비 수십 배 이상의 성능 차이를 보입니다.
pandas의 많은 연산은 내부적으로 NumPy를 기반으로 구현되어 있습니다. 따라서 다음과 같은 연산들은 대부분 벡터화되어 처리됩니다.
+, -, *, /)mean, sum, std 등)가능한 경우 pandas Series나 DataFrame 전체에 대해 연산을 적용하는 것이, 행 단위 접근보다 훨씬 효율적입니다.
벡터화 연산이 빠른 이유는 “연산을 한 번에 처리해서”가 아니라, 반복을 Python이 아닌 C 레벨로 내려보내기 때문입니다.
따라서 성능이 중요한 코드에서는:
기본적인 최적화 전략이 됩니다.
pandas에서 제공하는 집계 함수(sum, mean, min, max 등)는 단순한 편의 기능이 아니라, NumPy 기반의 저수준(low-level) 루틴 위에 구현된 고성능 연산입니다.
예를 들어 다음 두 코드는 기능적으로 동일합니다.
# Python 반복문
total = 0
for value in df['column']:
total += value
# Pandas 내장 함수
total = df['column'].sum()
하지만 내부 실행 방식은 완전히 다릅니다.
첫 번째 방식에서는 각 반복마다 다음 작업이 발생합니다.
즉 매 원소마다 Python 인터프리터가 개입합니다.
구조적으로는 다음과 같습니다.
Python → Python → Python → Python (N번 반복)
데이터가 커질수록 이 인터프리터 오버헤드가 누적되어 성능이 급격히 저하됩니다.
반면 df['column'].sum() 은 내부적으로 NumPy 배열을 직접 참조하여 C 레벨 루프에서 연산을 수행합니다.
실행 흐름은 다음과 같습니다.
즉:
Python 호출 1회 → C 레벨 루프 → 결과 반환
Python 인터프리터는 시작과 끝에만 관여하고, 실제 반복은 C에서 처리됩니다.
pandas/NumPy 내장 함수는 단순 반복 외에도 다음과 같은 최적화를 포함합니다.
이 때문에 같은 연산이라도 직접 작성한 Python 루프보다 수십 배 이상 빠른 경우가 일반적입니다.
pandas에서 성능을 확보하려면 기본 원칙은 단순합니다.
정리하면, pandas 내장 함수가 빠른 이유는 “잘 만들어져 있어서”가 아니라, 연산이 Python이 아닌 C 레벨에서 수행되기 때문입니다. 따라서 동일한 작업이라면 항상 pandas/NumPy가 제공하는 연산을 먼저 고려하는 것이 바람직합니다.
apply() 는 pandas에서 제공하는 함수이지만, 내부적으로는 각 원소 또는 각 행에 대해 Python 함수를 반복 호출하는 구조입니다. 즉 벡터화 연산이 아니라, Python 레벨 반복문에 가깝게 동작합니다.
다음 예제를 보면:
df['result'] = df['value'].apply(lambda x: x * 2 + 1)
겉보기에는 pandas 연산처럼 보이지만, 실제로는 다음 과정이 반복됩니다.
이를 구조적으로 표현하면:
Python → lambda → Python → lambda → Python (N번)
즉 각 원소마다 Python 함수 호출이 발생하며, 인터프리터 오버헤드가 누적됩니다.
반면 벡터화 방식은 완전히 다른 경로를 사용합니다.
df['result'] = df['value'] * 2 + 1
이 코드는 내부적으로 Series가 보유한 NumPy ndarray를 직접 참조하여 C 레벨 루프에서 연산을 수행합니다.
실행 흐름은 다음과 같습니다.
Python 호출 1회 → NumPy C 루프 → 전체 배열 연산
Python은 연산 시작과 종료에만 관여하고, 실제 반복은 모두 C 코드에서 처리됩니다.
apply() 는 단순 반복 외에도 다음과 같은 추가 비용을 가집니다.
특히 lambda 함수나 사용자 정의 함수(UDF)를 사용할 경우, JIT 컴파일이나 벡터화 최적화가 적용되지 않기 때문에 성능 차이가 더 커집니다.
가능하면 다음 우선순위를 따르는 것이 일반적입니다.
즉 apply() 는 편의 기능이지, 성능을 위한 도구는 아닙니다.
정리하면, apply() 가 느린 이유는 pandas 함수라서가 아니라, Python 함수가 원소 단위로 반복 호출되기 때문입니다. 동일한 연산이 벡터화로 표현 가능하다면, 항상 벡터화 방식을 우선 사용하는 것이 바람직합니다.
pandas는 독립적인 데이터 구조처럼 보이지만, 실제로는 대부분의 numeric 컬럼이 NumPy ndarray 위에 직접 구성되어 있습니다. 즉 Series와 DataFrame은 내부적으로 NumPy 배열을 감싸는 thin wrapper에 가깝습니다.
따라서 다음과 같은 변환은 기본적으로 데이터를 복사하지 않습니다.
array = df['column'].values
이 경우 array 는 새로운 배열이 아니라, 기존 Series가 사용 중이던 NumPy 메모리를 그대로 참조합니다.
이를 직접 확인하면:
import numpy as np
np.shares_memory(df['column'], df['column'].values) # True
True가 반환되며, 이는 pandas와 NumPy가 동일한 메모리 블록을 공유하고 있음을 의미합니다.
numeric Series는 대략 다음 구조를 가집니다.
Series
└─ NumPy ndarray (실제 데이터)
.values 또는 .to_numpy() 를 호출하면, 이 ndarray에 대한 참조만 반환됩니다.
즉:
복사 발생 ❌
포인터 전달 ✅
이기 때문에 변환 비용이 거의 없습니다.
NumPy 함수들은 연속 메모리 위에서 C 레벨 루프를 사용하여 동작합니다.
result = np.sqrt(array)
이 연산은:
이라는 특징을 가지며, pandas Series 메서드보다도 빠른 경우가 많습니다.
특히 복잡한 수치 계산이나 커스텀 연산이 필요한 경우, ndarray로 변환한 뒤 NumPy 연산을 적용하는 방식이 가장 효율적인 경로가 됩니다.
메모리 공유는 numeric dtype에서만 안전하게 적용됩니다.
object dtype 컬럼의 경우 .values 는 object 배열을 반환하며, 이 경우 각 원소는 여전히 Python 객체입니다. 따라서 기대하는 성능 향상이 발생하지 않을 수 있습니다.
또한 .to_numpy(copy=True) 를 명시하면 실제 복사가 발생하므로, 메모리 절약이 목적이라면 기본 옵션을 유지하는 것이 좋습니다.
pandas와 NumPy는 동일한 메모리를 공유하므로, .values 또는 .to_numpy() 를 사용하면 복사 없이 ndarray를 얻을 수 있습니다. 이를 활용하면 pandas 구조를 거치지 않고 NumPy의 고성능 연산을 직접 사용할 수 있으며, 대용량 데이터 처리 시 성능과 메모리 효율을 동시에 개선할 수 있습니다.
연산이 느린 이유가 반복 계산 때문이라면, 가장 효과적인 최적화는 동일한 계산을 다시 수행하지 않는 것입니다. functools.lru_cache 는 함수 호출 결과를 메모리에 저장해 두었다가, 동일한 입력이 들어오면 즉시 반환하는 방식의 캐싱 메커니즘을 제공합니다.
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_computation(n):
# 복잡한 계산
return result
이후 동일한 인자로 함수를 호출하면:
result = expensive_computation(100) # 첫 호출: 실제 계산 수행
result = expensive_computation(100) # 두 번째 호출: 캐시에서 즉시 반환
두 번째 호출부터는 함수 내부 코드가 실행되지 않습니다.
lru_cache 는 함수 입력을 key로, 반환값을 value로 하는 딕셔너리 기반 캐시를 유지합니다.
구조적으로는 다음과 같습니다.
(arguments) → result
이미 계산된 입력이 다시 들어오면:
이라는 흐름을 따릅니다.
maxsize 는 캐시에 저장할 최대 항목 수를 의미하며, 이를 초과하면 가장 오래 사용되지 않은 항목(LRU: Least Recently Used)부터 제거됩니다.
캐싱은 다음 조건을 만족할 때 가장 효과적입니다.
예를 들어:
같은 상황에서 유의미한 성능 개선을 얻을 수 있습니다.
캐싱은 메모리를 사용합니다. 따라서:
에는 오히려 메모리 낭비가 될 수 있습니다.
또한 pandas DataFrame이나 NumPy 배열처럼 mutable 객체는 lru_cache의 key로 사용할 수 없으므로, 스칼라 값이나 immutable 타입 위주로 사용하는 것이 일반적입니다.
lru_cache 는 계산을 빠르게 만드는 도구라기보다, 계산 자체를 생략하게 만드는 도구입니다. 반복 호출되는 고비용 함수가 있다면, 벡터화나 C 레벨 최적화보다 캐싱이 더 큰 효과를 주는 경우도 많습니다.
Pandas와 NumPy는 서로 다른 메모리 레이아웃을 가지며, 이를 이해하고 활용하면 성능을 크게 향상시킬 수 있습니다.
Pandas DataFrame은 컬럼 우선(column-major) 방식으로 데이터를 저장합니다. 따라서 컬럼 단위 연산이 행 단위 연산보다 훨씬 빠릅니다.
# 빠른 방법: 컬럼 단위 연산
df['new_col'] = df['col1'] + df['col2']
# 느린 방법: 행 단위 순회
for idx, row in df.iterrows():
df.at[idx, 'new_col'] = row['col1'] + row['col2']
컬럼 단위 연산은 메모리 지역성(locality)이 좋아 캐시 효율이 높습니다.
NumPy 배열은 기본적으로 행 우선(row-major, C-order) 방식으로 저장됩니다. 행 단위로 접근할 때 메모리 지역성이 좋습니다.
import numpy as np
# C-order (row-major): 행 단위 접근이 빠름
array_c = np.array([[1, 2, 3], [4, 5, 6]], order='C')
# F-order (column-major): 컬럼 단위 접근이 빠름
array_f = np.array([[1, 2, 3], [4, 5, 6]], order='F')
# 행 단위 연산 (C-order에서 빠름)
for row in array_c:
process(row)
이 차이는 단순히 라이브러리 구현 방식 때문이 아니라,
메모리에 데이터가 배치되는 방식과 CPU 캐시 동작 원리에서 비롯됩니다.
핵심은 다음 두 가지입니다.
이를 Pandas와 NumPy 관점에서 각각 살펴보겠습니다.
Pandas DataFrame은 내부적으로 여러 개의 Series로 구성되어 있습니다.
각 컬럼은 독립적인 NumPy 배열로 존재하며, 메모리 상에서 연속적으로 저장됩니다.
즉 구조적으로 보면 다음과 같습니다.
DataFrame
├── Series(col1)
├── Series(col2)
├── Series(col3)
따라서 다음과 같은 연산은
df['col1'] + df['col2']
실제로는 다음 과정을 거칩니다.
col1 전체 배열을 한 번에 읽고col2 전체 배열을 한 번에 읽은 뒤이 과정에서는
결과적으로
매우 이상적인 실행 경로입니다.
반면 다음과 같은 코드에서는
for idx, row in df.iterrows():
완전히 다른 접근 패턴이 발생합니다.
이 경우 각 row는 새로운 Series 객체로 생성되며, 컬럼마다 서로 다른 메모리 위치를 참조하게 됩니다. 또한 Python 레벨 loop와 attribute access가 반복됩니다.
즉 다음과 같은 비용이 동시에 발생합니다.
따라서 iterrows 기반 접근은 구조적으로 느릴 수밖에 없습니다.
정리하면 Pandas에서는 다음과 같습니다.
이 차이가 곧 성능 차이로 이어집니다.
NumPy 배열은 기본적으로 C-order, 즉 row-major 방식으로 저장됩니다.
예를 들어 다음 배열은
[[1,2,3],
[4,5,6]]
메모리에 다음과 같이 배치됩니다.
1 2 3 4 5 6
첫 번째 행이 끝까지 연속적으로 저장됩니다.
따라서 다음과 같은 행 단위 접근은
for row in array:
내부적으로 연속 메모리를 순차적으로 읽게 됩니다.
이때 CPU prefetch가 제대로 작동하고, cache hit 비율도 높아집니다.
반면 컬럼 단위 접근은 다음과 같은 패턴이 됩니다.
1 → 4 → 2 → 5 → 3 → 6
이처럼 stride jump가 발생하면
이 때문에 NumPy에서 컬럼 접근은 상대적으로 느립니다.
물론 order='F' 옵션으로 column-major 배열을 만들 수 있지만,
대부분의 NumPy 연산과 C-extension은 C-order 기준으로 최적화되어 있습니다.
중요한 점은 이 모든 차이가 연산 복잡도가 아니라
메모리를 어떻게 읽느냐에서 발생한다는 사실입니다.
CPU에서는 계산보다 메모리 접근이 훨씬 느립니다.
따라서 성능의 본질은 다음에 달려 있습니다.
이를 정리하면 다음과 같습니다.
이 구조를 거스르는 순간 성능은 급격히 저하됩니다.
때문에
이는 단순한 코딩 스타일 문제가 아니라,
CPU 아키텍처 레벨에서 결정되는 물리적인 제약에 가깝습니다.
NumPy와 Pandas는 각각 다른 강점을 가지므로 작업 특성에 맞게 선택해야 합니다.
NumPy 사용이 적합한 경우:
Pandas 사용이 적합한 경우:
이론적 지식을 실제 프로젝트에 적용하기 위한 단계별 전략입니다.
최적화 우선순위:
1. 프로파일링으로 병목 지점 식별
2. 가장 많은 시간/메모리를 소비하는 부분부터 최적화
3. 측정 → 최적화 → 재측정 사이클 반복
4. 가독성과 성능의 균형 유지
복잡한 최적화 기법보다는 간단하고 효과적인 방법부터 적용합니다.
1단계: 저비용 최적화
2단계: 알고리즘 개선
3단계: 구조적 변경
기본 최적화로 충분하지 않을 때 적용할 수 있는 고급 기법들입니다.
__slots__ 활용Python 클래스에서 __slots__를 사용하면 인스턴스의 메모리 사용량을 크게 줄일 수 있습니다.
# 일반 클래스 (딕셔너리 기반)
class RegularClass:
def __init__(self, x, y):
self.x = x
self.y = y
# __slots__ 사용 클래스
class SlottedClass:
__slots__ = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
# 메모리 사용량 비교
import sys
regular = RegularClass(1, 2)
slotted = SlottedClass(1, 2)
print(f"Regular: {sys.getsizeof(regular.__dict__)} bytes")
print(f"Slotted: {sys.getsizeof(slotted)} bytes")
딕셔너리와 집합은 해시 테이블 기반으로 O(1) 조회 성능을 제공합니다.
# 느린 방법: 리스트에서 검색 (O(n))
if item in my_list: # 선형 탐색
process(item)
# 빠른 방법: 집합에서 검색 (O(1))
my_set = set(my_list)
if item in my_set: # 해시 테이블 조회
process(item)
대량의 조회 작업이 필요한 경우 리스트를 집합이나 딕셔너리로 변환하는 것이 효과적입니다.
출처Pandas 고급 기능과 성능 최적화: 대용량 데이터 처리의 비밀
판다스 데이터 처리 과정에서 메모리 사용율이 높아지는 경우와 해결 방법
Pandas를 Numpy로! 최적화 시리즈(1) - ndarray 활용 - YA-Hwang 기술 블로그
NumPy 대 Pandas: 일반적인 용어로 차이점 설명