[Python] 메모리/속도 최적화 방법

엘리자베스22호·2026년 2월 11일

데이터 사이언스 작업에서 메모리와 속도 최적화는 대용량 데이터 처리의 핵심입니다. Python의 Pandas와 NumPy를 중심으로 한 데이터 처리 라이브러리들은 편의성이 뛰어나지만, 최적화 없이 사용하면 메모리 부족이나 처리 속도 저하 문제가 발생할 수 있습니다.

+) 이 글로 사내에서 세미나를 했는데 아주 Hot했습니다..^^

1. 메모리 최적화 기법

메모리 최적화는 대용량 데이터 처리의 첫 번째 관문입니다. Pandas는 기본적으로 가장 큰 데이터 타입을 사용하기 때문에 메모리가 낭비되며, 적절한 최적화 없이는 메모리 부족 오류가 발생할 수 있습니다.

1.1 데이터 타입 최적화

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))

왜 int64 → int32, float64 → float32 로 바꾸면 메모리가 절반이 되는가?

이 현상은 Pandas의 특수한 동작 때문이 아니라, 컴퓨터가 숫자를 저장하는 방식에서 비롯됩니다.

컴퓨터에서 모든 숫자는 이진수(bit) 형태로 저장되며, 각 데이터 타입은 고정된 크기를 가집니다.

  • 1 byte = 8 bits

대표적인 타입의 메모리 크기는 다음과 같습니다.

타입비트바이트
int3232 bits4 bytes
int6464 bits8 bytes
float3232 bits4 bytes
float6464 bits8 bytes

즉, int64와 float64는 각각 8바이트,
int32와 float32는 4바이트를 사용합니다.

따라서 동일한 개수의 값이 있을 때,
64비트 타입을 32비트 타입으로 변경하면 각 원소가 차지하는 메모리가 정확히 절반이 됩니다.


Pandas의 Series와 DataFrame 컬럼은 내부적으로 NumPy 배열(ndarray) 위에 저장됩니다.
이는 곧, 컬럼 하나가 연속된 메모리 블록으로 구성되며 모든 값이 동일한 dtype을 사용한다는 의미입니다.

이 때문에 컬럼의 메모리 사용량은 다음과 같이 계산됩니다.

전체 메모리 = row 수 × dtype 크기

예를 들어 1,000,000개의 값이 있을 경우:

  • int64: 약 8MB
  • int32: 약 4MB

차이는 정확히 2배입니다.

그렇다면 Pandas는 왜 기본적으로 int64와 float64를 사용할까요?

첫 번째 이유는 표현 가능한 값의 범위입니다.

int32는 약 ±21억까지만 표현할 수 있지만,
int64는 훨씬 큰 값을 다룰 수 있습니다. 실무 데이터에서는 ID, timestamp, 누적 카운트 등이 쉽게 int32 범위를 초과할 수 있기 때문에, Pandas는 안전한 기본값으로 int64를 선택합니다.

두 번째 이유는 float64가 과학 계산의 표준이기 때문입니다.
float64는 약 15자리 십진 정밀도를 제공하지만, float32는 약 7자리 수준에 불과합니다. 머신러닝이나 통계 계산에서는 오차 누적을 방지하기 위해 float64가 기본으로 사용됩니다.

실제 환경에서는 이 차이가 더욱 크게 체감됩니다.

수백만 row와 수십 개 이상의 컬럼을 가진 데이터셋에서는,
float64 대신 float32를 사용하는 것만으로도 수백 MB에서 수 GB 단위의 메모리를 절약할 수 있습니다.

이 차이는 단순한 저장 공간뿐 아니라:

  • 데이터 로딩 속도
  • CPU 캐시 효율
  • 연산 성능
  • 메모리 부족(OOM) 발생 여부

에도 직접적인 영향을 줍니다.

정리하면, int64 → int32, float64 → float32 변환 시 메모리가 절반으로 줄어드는 이유는 각 값이 차지하는 비트 수가 64에서 32로 감소하기 때문입니다.

Pandas는 고정 폭 dtype을 사용하는 연속 메모리 구조를 가지므로, 이러한 차이가 전체 메모리 사용량에 그대로 반영됩니다.

데이터 타입 최적화는 단순한 미세 조정이 아니라, 대용량 데이터 처리에서 가장 먼저 고려해야 할 구조적 최적화입니다.


1.2 다운캐스팅 (Downcasting)

앞서 수동으로 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는 무엇을 하는가?

downcast 옵션은 단순히 int64를 무조건 int32로 바꾸는 기능이 아닙니다.

Pandas는 다음과 같은 순서로 동작합니다.

  1. 컬럼 전체 값을 스캔
  2. 최소값과 최대값 계산
  3. 해당 범위를 표현할 수 있는 가장 작은 dtype 선택
  4. 안전할 경우에만 변환 수행

예를 들어 downcast='integer'를 사용하면 내부적으로 다음 후보들을 검사합니다.

  • int8
  • int16
  • int32
  • int64

그리고 실제 값의 범위가 int8에 들어가면 int8,
int16에 들어가면 int16,
그보다 크면 int32 … 이런 식으로 자동 선택합니다.

float 역시 동일하게:

  • float16
  • float32
  • float64

중에서 가능한 가장 작은 타입을 선택합니다.

즉, downcast는 “값을 잃지 않는 범위 내에서 최소 메모리 타입을 찾는 과정”입니다.

astype과 downcast의 차이

astype()은 개발자가 dtype을 직접 지정합니다.

df['id'] = df['id'].astype('int32')

이 방식은 빠르고 단순하지만,
값 범위를 벗어나면 overflow가 발생하거나 에러가 날 수 있습니다.

반면 downcast는 자동 검사 후 변환하기 때문에 상대적으로 안전합니다.

즉:

  • astype → 수동, 빠름, 위험 가능
  • downcast → 자동, 안전, 약간 느림

대량 데이터에서는 downcast가 실수 가능성을 크게 줄여줍니다.

왜 downcast가 실무에서 중요한가?

실제 데이터에서는 다음과 같은 경우가 많습니다.

  • 0~100 범위의 카운트 값
  • 작은 정수 범위의 코드 값
  • 소수점 두 자리 가격 정보

이런 컬럼들이 기본적으로 int64 / float64로 로딩되면 필요 이상의 메모리를 차지합니다.

downcast를 적용하면:

  • int64 → int8 / int16
  • float64 → float32

까지 자동으로 축소되는 경우가 많아,
수십 퍼센트 이상의 메모리 절감 효과를 얻을 수 있습니다.

주의할 점

float downcast는 정밀도 손실 가능성이 있습니다.

float32는 약 7자리 십진 정밀도만 유지되므로,
아주 작은 차이가 중요한 금융 계산이나 통계 분석에서는 주의가 필요합니다.

또한 pd.to_numeric()은 문자열 컬럼도 숫자로 변환하려 시도하므로,
혼합 타입 컬럼에서는 의도치 않은 NaN이 생길 수 있습니다.

정리하면, downcasting은 컬럼의 실제 값 범위를 기반으로 가장 작은 dtype을 자동 선택하는 메커니즘이며,
대용량 데이터에서 메모리 최적화를 위한 매우 효과적인 첫 단계입니다.

특히 여러 컬럼을 동시에 처리해야 할 경우, 수동 astype보다 downcast 방식이 훨씬 안정적입니다.


1.3 범주형(Category) 데이터 활용

pandas에서 문자열 컬럼은 기본적으로 object 타입으로 저장됩니다. 이 경우 각 행마다 개별 Python 문자열 객체가 생성되며, 동일한 문자열이라도 반복해서 메모리에 저장됩니다. 이 구조는 대량 데이터에서 불필요한 메모리 사용을 유발합니다.

category 타입은 이러한 문자열 컬럼을 고유값 테이블(categories)정수 인덱스 배열(codes) 로 분리하여 저장합니다. 고유 문자열은 한 번만 저장되고, 실제 데이터는 작은 정수로 참조됩니다.

즉 내부 구조는 다음과 같습니다.

  • 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"]

각 원소는 다음 구성 요소를 가집니다.

  • Python object pointer (약 8 bytes)
  • Python string 객체
  • 문자열 길이만큼의 문자 버퍼

즉 전체 메모리는 대략 다음 형태가 됩니다.

행 수 N × (포인터 + 문자열 객체 + 실제 문자 데이터)

같은 "OK" 값이라도:

  • 매 행마다 새로운 문자열 객체가 생성되고
  • 매번 별도의 메모리가 할당됩니다.

이 구조 때문에 데이터가 커질수록 메모리 사용량이 빠르게 증가하며, memory_usage(deep=True) 값도 크게 나타납니다.

category dtype 적용 후 구조

df['status'] = df['status'].astype('category')

이렇게 변환하면 pandas는 컬럼을 두 부분으로 분리합니다.

(1) Categories (고유값 테이블)

Index(['FAIL', 'OK'])

컬럼의 고유 문자열만 별도로 저장하며, 각 문자열은 단 한 번만 메모리에 올라갑니다.

(2) Codes (정수 인덱스 배열)

원본 데이터:

OK, FAIL, OK, OK, FAIL

은 다음과 같이 정수로 변환됩니다.

[1, 0, 1, 1, 0]

이 배열은 numpy 기반의 정수 배열이며,

  • int8 / int16 / int32 중 자동 선택
  • 연속 메모리 구조
  • Python object 없음

이라는 특징을 가집니다.

최종 메모리 구조

category 컬럼은 내부적으로 다음과 같이 구성됩니다.

  • categories
    ['FAIL', 'OK'] (고유 문자열만 저장)

  • codes
    [1, 0, 1, 1, 0] (순수 정수 배열)

정리하면:

변환 전:
N × string 객체

변환 후:
K × string  +  N × 작은 정수

여기서:

  • N: 전체 행 수
  • K: 고유값 개수 (cardinality)

입니다.

카디널리티가 낮을수록 효과가 큰 이유

예를 들어 1,000,000 rows가 있을 때,

고유값이 3개라면:

  • categories: 문자열 3개
  • codes: 정수 1,000,000개

으로 매우 큰 메모리 절감 효과를 얻습니다.

반면 고유값이 900,000개라면:

  • categories: 문자열 900,000개
  • codes: 정수 1,000,000개

가 되어 절감 효과가 거의 없으며, 오히려 성능이 나빠질 수도 있습니다.

따라서 category 타입은 상태값, 타입 코드, 플래그처럼 반복 패턴이 뚜렷한 low-cardinality 컬럼에 사용하는 것이 일반적입니다.

memory_usage(deep=True)가 줄어드는 이유

deep=True 옵션은 object 내부 문자열 메모리까지 모두 포함해 계산합니다.

category로 변환하면:

  • 행 단위 문자열 객체가 제거되고
  • 대신 numpy 정수 배열이 사용됩니다.

이 때문에 메모리 사용량이 눈에 띄게 감소하게 됩니다.


1.4 청크(Chunk) 단위 처리

대용량 CSV 파일을 pd.read_csv()로 한 번에 로드하면, 파일 전체가 메모리에 올라가게 됩니다. 이때 DataFrame은 단순히 파일 크기만큼의 메모리를 사용하는 것이 아니라, 파싱 과정에서 생성되는 Python 객체와 내부 버퍼까지 포함하여 더 많은 메모리를 요구합니다.

즉 다음과 같은 호출은:

df = pd.read_csv("large_file.csv")

내부적으로:

  • 전체 파일을 메모리로 읽고
  • 각 컬럼을 파싱하여 numpy 배열 또는 object 배열로 변환하며
  • DataFrame 구조를 구성하기 위한 추가 메모리를 할당합니다.

파일 크기가 수 GB 이상이거나, 시스템 메모리가 제한적인 환경에서는 이 과정에서 쉽게 메모리 부족(OOM)이 발생할 수 있습니다.

chunksize 옵션을 사용하면 pandas는 파일을 한 번에 읽지 않고, 지정한 행 수 단위로 나누어 순차적으로 로드합니다.

chunk_size = 10000
for chunk in pd.read_csv('large_file.csv', chunksize=chunk_size):
    process_chunk(chunk)

이 방식에서는:

  • 전체 파일을 메모리에 올리지 않고
  • 일정 크기의 DataFrame만 반복적으로 생성하며
  • 이전 청크는 처리 후 garbage collection 대상이 됩니다.

결과적으로 동시에 메모리에 존재하는 데이터의 크기가 chunk_size에 의해 제한됩니다.

내부적으로는 다음과 같은 흐름으로 동작합니다.

  1. CSV 파일 스트림을 열고
  2. 지정된 행 수만큼 읽어서 DataFrame으로 변환
  3. 해당 청크를 iterator 형태로 반환
  4. 다음 블록을 다시 읽는 과정을 반복

즉:

파일 → 작은 DataFrame → 처리 → 버림 → 다음 DataFrame

이라는 패턴으로 동작합니다.

이 방식의 핵심 장점은 메모리 사용량이 데이터 전체 크기가 아니라, 청크 크기에 의해 결정된다는 점입니다.

예를 들어:

  • 전체 파일: 10GB
  • chunksize: 10,000 rows

라면, 실제 메모리에는 항상 10,000행 정도만 존재하게 됩니다.

따라서 로그 데이터, 센서 데이터, 대규모 이벤트 로그처럼 파일 크기가 메모리를 초과하는 경우에도 안정적으로 처리가 가능합니다.

청크 처리는 특히 다음과 같은 상황에서 유용합니다.

  • 전체 데이터를 한 번에 올릴 필요가 없는 집계 작업
  • feature engineering을 단계적으로 수행하는 경우
  • 모델 학습 전 전처리를 스트리밍 방식으로 처리할 때
  • 메모리 제한이 있는 서버나 컨테이너 환경

정리하면, chunksize는 pandas가 제공하는 스트리밍 처리 방식이며, 대용량 데이터를 “로드”하는 대신 “순차 소비”하도록 구조를 바꿔주는 기능입니다. 메모리 제약 환경에서는 사실상 필수적인 접근 방식입니다.


1.5 불필요한 복사 최소화

pandas의 많은 연산은 기본적으로 새로운 DataFrame 또는 Series 객체를 생성합니다. 이는 함수형 스타일을 유지하기 위한 설계이지만, 대용량 데이터에서는 불필요한 메모리 사용으로 이어질 수 있습니다.

예를 들어 다음과 같은 코드에서:

subset = df[df['value'] > 100]

pandas는 조건에 맞는 행을 골라 완전히 새로운 DataFrame을 생성합니다. 이때 선택된 데이터는 원본과 메모리를 공유하지 않으며, 실제 값이 모두 복사됩니다.

즉 내부적으로는:

원본 DataFrame → 조건 필터 → 새 DataFrame 생성 → 데이터 복사

라는 흐름을 따릅니다.

행 수가 많을수록 이 복사 비용은 빠르게 증가합니다.

마찬가지로 다음과 같은 연산들도 대부분 새로운 객체를 반환합니다.

  • groupby()
  • merge()
  • concat()
  • assign()
  • drop() (기본값)

이들은 모두 원본을 수정하지 않고 결과를 새로 만들어 돌려줍니다.

inplace 옵션

일부 메서드는 inplace=True 옵션을 제공합니다.

df.drop(columns=['unnecessary_col'], inplace=True)

이 경우 pandas는 기존 DataFrame 객체를 직접 수정하며, 별도의 결과 객체를 생성하지 않습니다. 따라서 불필요한 DataFrame 복사를 줄일 수 있습니다.

다만 내부 구현상 항상 완전한 zero-copy는 아니며, 컬럼 구조 변경이나 블록 재정렬이 필요한 경우에는 일부 메모리 재할당이 발생할 수 있습니다. 그럼에도 불구하고, 결과 객체를 하나 더 만드는 것보다는 메모리 사용이 훨씬 적습니다.

View와 Copy의 차이

numpy와 pandas는 개념적으로 view(참조)copy(복사) 를 구분합니다.

  • view: 원본 메모리를 그대로 참조
  • copy: 새로운 메모리를 할당

열 단위 접근은 종종 view를 반환합니다.

col = df['value']

이 경우 대부분 원본 데이터 블록을 공유합니다.

반면 조건 필터링은 항상 copy입니다.

subset = df.loc[df['value'] > 100]

여기서는 boolean mask가 적용되며, 결과 DataFrame은 새 메모리를 할당합니다.

즉 “필요할 때만 복사한다”는 의미는, 가능하면 컬럼 단위 참조를 활용하고, 대규모 행 필터링을 반복적으로 수행하지 않는 구조를 만드는 것을 의미합니다.

실무 관점에서의 정리

대용량 DataFrame을 다룰 때는 다음 원칙이 도움이 됩니다.

  • 불필요한 중간 DataFrame 생성을 피한다
  • 가능한 경우 inplace 연산을 사용한다
  • 컬럼 단위 접근을 우선한다
  • 반복적인 조건 슬라이싱을 최소화한다
  • 파이프라인 중간 결과를 무분별하게 변수로 저장하지 않는다

정리하면, pandas의 대부분 연산은 안전성을 위해 “복사 기반”으로 동작합니다. 작은 데이터에서는 문제가 되지 않지만, 대용량 환경에서는 이 복사 비용이 곧 메모리 병목이 됩니다. 따라서 연산 흐름을 설계할 때 객체 생성 횟수 자체를 줄이는 것이 중요합니다.


1.6 Python 객체의 메모리 오버헤드 이해

pandas 메모리 사용의 근본 원인은 Python 객체 자체의 구조에 있습니다. CPython에서 모든 객체는 단순한 값이 아니라, 메타데이터를 포함한 복합 구조로 관리됩니다.

예를 들어 하나의 Python 객체는 최소한 다음 정보를 포함합니다.

  • 참조 카운트 (reference count)
  • 타입 포인터 (type pointer)
  • 객체 헤더

이 때문에 “값 하나”를 저장하더라도 상당한 고정 비용이 발생합니다.

실제로 일반적인 64bit CPython 환경에서의 대략적인 크기는 다음과 같습니다.

  • 빈 문자열: 약 41 bytes
  • 정수(int): 약 28 bytes
  • 빈 리스트: 약 56 bytes
  • 빈 딕셔너리: 약 64 bytes

여기에 실제 데이터가 추가되면 크기는 더 증가합니다.

즉 Python에서 다음과 같은 코드가 있다고 할 때:

values = [1, 2, 3, 4]

메모리에는 단순히 4 × int 만 존재하는 것이 아니라,

  • 리스트 객체
  • 포인터 배열
  • 각 정수 객체

가 모두 별도로 생성됩니다.

구조적으로는 다음과 같습니다.

list object
 ├─ pointer → int object
 ├─ pointer → int object
 ├─ pointer → int object
 └─ pointer → int object

즉:

  • 리스트는 포인터 배열을 들고 있고
  • 각 숫자는 독립적인 Python 객체입니다.

이 방식은 유연성과 안전성 측면에서는 장점이 있지만, 대량 데이터 처리에서는 매우 비효율적입니다.

NumPy 배열이 메모리를 적게 사용하는 이유

반면 NumPy 배열은 Python 객체가 아니라, 연속된 C 메모리 블록 위에 순수 값만 저장합니다.

예를 들어 int64 배열의 경우:

  • 각 원소는 정확히 8 bytes
  • 추가 객체 헤더 없음
  • 포인터 없음
  • 연속 메모리 구조

입니다.

즉 다음과 같은 차이가 발생합니다.

Python list: N × (포인터 + Python int 객체)
NumPy array: N × 8 bytes

이 차이가 수백만 단위 데이터에서는 수십~수백 MB 이상의 격차로 이어집니다.

pandas에서 object 컬럼보다 numeric 컬럼이 훨씬 메모리 효율적인 이유도 동일한 원리입니다.

slots를 통한 객체 경량화

클래스 기반 구조에서도 기본 Python 객체는 __dict__ 를 사용하여 속성을 저장합니다.

class A:
    pass

이 경우 각 인스턴스는 내부에 딕셔너리를 하나씩 가지게 됩니다.

__slots__ 를 사용하면 이 딕셔너리를 제거하고, 고정 슬롯 기반 구조로 바꿀 수 있습니다.

class A:
    __slots__ = ("x", "y")

이 방식은:

  • 객체당 메모리 사용량 감소
  • 속성 접근 속도 개선

효과가 있으며, 대량 객체를 생성하는 경우 유의미한 차이를 만듭니다.

pandas에서 메모리 문제가 발생하는 이유는 단순히 DataFrame이 커서가 아니라, 내부에 수많은 Python 객체가 생성되기 때문입니다.

따라서 메모리 최적화의 핵심은 다음과 같습니다.

  • object dtype을 피하고 numeric / category 타입을 사용한다
  • 가능하면 NumPy 기반 구조를 활용한다
  • 불필요한 DataFrame 복사를 줄인다
  • 대량 객체 생성이 필요한 경우 __slots__ 등을 고려한다

결국 pandas 메모리 사용량은 “데이터 크기”보다 “Python 객체 개수”에 더 크게 좌우됩니다.


2. 속도 최적화 기법

처리 속도 최적화는 데이터 사이언스 워크플로우의 효율성을 결정합니다. Python은 편의성이 뛰어나지만 실행 속도가 느리다는 단점이 있으며, 특히 반복문 사용 시 성능 저하가 심각합니다.

2.1 벡터화(Vectorization) 연산 활용

Python에서 가장 큰 성능 병목은 반복문 자체가 아니라, Python 인터프리터가 개입하는 횟수입니다. for 루프를 사용한 순회 방식은 매 반복마다 Python 레벨의 연산과 타입 확인이 발생하며, 이 비용이 누적되면 전체 성능을 크게 저하시킵니다.

예를 들어 다음 코드를 보면:

result = []
for i in range(len(df)):
    result.append(df.iloc[i]['value'] * 2)

이 한 줄의 연산 안에는 다음 과정이 반복됩니다.

  • Python for 루프 제어
  • df.iloc[i] 인덱싱
  • Series 생성
  • 'value' 키 접근
  • Python 정수 연산
  • 리스트 append

즉 단순한 곱셈 연산 하나를 위해, 여러 단계의 Python 객체 접근과 함수 호출이 매번 발생합니다.

반면 벡터화 연산은 완전히 다른 실행 경로를 가집니다.

result = df['value'].values * 2

이 코드에서 핵심은 .values 를 통해 NumPy ndarray 를 사용하는 것입니다.

NumPy 배열은 다음과 같은 특징을 가집니다.

  • 동일한 dtype의 값만 저장
  • 연속된 메모리 구조
  • Python 객체 없음

이 상태에서 수행되는 * 2 연산은 Python 반복문이 아니라, C로 구현된 내부 루프에서 실행됩니다.

즉 실행 흐름은 다음과 같습니다.

Python 호출 1회 → C 레벨 루프 → 전체 배열 연산

반복문이 Python 레벨에서 실행되지 않기 때문에, 인터프리터 오버헤드가 거의 발생하지 않습니다.

Python for loop와 벡터화의 본질적인 차이

두 방식의 차이는 연산 횟수가 아니라, 어디서 반복이 실행되느냐에 있습니다.

for loop:
Python → Python → Python → Python (N번)

Vectorization:
Python → C → C → C (N번)

Python은 동적 타입 언어이기 때문에, 각 연산마다 타입 확인과 참조 관리가 필요합니다. 반면 C 레벨에서는 타입이 고정되어 있으며, 연속 메모리 접근이 가능해 CPU 캐시 효율도 높습니다.

이 때문에 벡터화 연산은 일반적으로 Python 반복문 대비 수십 배 이상의 성능 차이를 보입니다.

pandas에서도 동일하게 적용되는 이유

pandas의 많은 연산은 내부적으로 NumPy를 기반으로 구현되어 있습니다. 따라서 다음과 같은 연산들은 대부분 벡터화되어 처리됩니다.

  • 산술 연산 (+, -, *, /)
  • 비교 연산
  • boolean mask
  • 통계 연산 (mean, sum, std 등)

가능한 경우 pandas Series나 DataFrame 전체에 대해 연산을 적용하는 것이, 행 단위 접근보다 훨씬 효율적입니다.

벡터화 연산이 빠른 이유는 “연산을 한 번에 처리해서”가 아니라, 반복을 Python이 아닌 C 레벨로 내려보내기 때문입니다.

따라서 성능이 중요한 코드에서는:

  • Python for loop 기반 순회를 피하고
  • NumPy ndarray 또는 pandas의 벡터화 연산을 사용하며
  • 행 단위 처리보다는 컬럼 단위 연산을 우선하는 것이

기본적인 최적화 전략이 됩니다.

2.2 Pandas 내장 함수 우선 사용

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 iterator 제어
  • pandas Series에서 값 추출
  • Python 객체로 변환
  • Python 정수 연산
  • 결과 저장

즉 매 원소마다 Python 인터프리터가 개입합니다.

구조적으로는 다음과 같습니다.

Python → Python → Python → Python (N번 반복)

데이터가 커질수록 이 인터프리터 오버헤드가 누적되어 성능이 급격히 저하됩니다.

Pandas 내장 함수의 실행 경로

반면 df['column'].sum() 은 내부적으로 NumPy 배열을 직접 참조하여 C 레벨 루프에서 연산을 수행합니다.

실행 흐름은 다음과 같습니다.

  1. Series 내부의 ndarray 추출
  2. NumPy reduce 함수 호출
  3. C 코드에서 연속 메모리 순회
  4. 결과 반환

즉:

Python 호출 1회 → C 레벨 루프 → 결과 반환

Python 인터프리터는 시작과 끝에만 관여하고, 실제 반복은 C에서 처리됩니다.

추가적인 최적화 요소

pandas/NumPy 내장 함수는 단순 반복 외에도 다음과 같은 최적화를 포함합니다.

  • 연속 메모리 접근으로 CPU cache 효율 극대화
  • SIMD(Vectorized instruction) 활용 가능
  • dtype 기반 분기 제거
  • 불필요한 Python 객체 생성 없음

이 때문에 같은 연산이라도 직접 작성한 Python 루프보다 수십 배 이상 빠른 경우가 일반적입니다.

pandas에서 성능을 확보하려면 기본 원칙은 단순합니다.

  • 직접 반복문을 작성하지 않는다
  • 내장 집계 함수와 벡터화 연산을 우선 사용한다
  • row 단위 접근보다 column 단위 연산을 사용한다

정리하면, pandas 내장 함수가 빠른 이유는 “잘 만들어져 있어서”가 아니라, 연산이 Python이 아닌 C 레벨에서 수행되기 때문입니다. 따라서 동일한 작업이라면 항상 pandas/NumPy가 제공하는 연산을 먼저 고려하는 것이 바람직합니다.

2.3 apply() 대신 벡터화 연산 사용

apply() 는 pandas에서 제공하는 함수이지만, 내부적으로는 각 원소 또는 각 행에 대해 Python 함수를 반복 호출하는 구조입니다. 즉 벡터화 연산이 아니라, Python 레벨 반복문에 가깝게 동작합니다.

다음 예제를 보면:

df['result'] = df['value'].apply(lambda x: x * 2 + 1)

겉보기에는 pandas 연산처럼 보이지만, 실제로는 다음 과정이 반복됩니다.

  • Series에서 값 하나 추출
  • Python 객체로 변환
  • lambda 함수 호출
  • Python 산술 연산
  • 결과 저장

이를 구조적으로 표현하면:

Python → lambda → Python → lambda → Python (N번)

즉 각 원소마다 Python 함수 호출이 발생하며, 인터프리터 오버헤드가 누적됩니다.

반면 벡터화 방식은 완전히 다른 경로를 사용합니다.

df['result'] = df['value'] * 2 + 1

이 코드는 내부적으로 Series가 보유한 NumPy ndarray를 직접 참조하여 C 레벨 루프에서 연산을 수행합니다.

실행 흐름은 다음과 같습니다.

Python 호출 1회 → NumPy C 루프 → 전체 배열 연산

Python은 연산 시작과 종료에만 관여하고, 실제 반복은 모두 C 코드에서 처리됩니다.

apply()가 특히 느려지는 이유

apply() 는 단순 반복 외에도 다음과 같은 추가 비용을 가집니다.

  • 각 호출마다 Python 함수 객체 실행
  • dtype 추론
  • 결과를 다시 Series로 재구성

특히 lambda 함수나 사용자 정의 함수(UDF)를 사용할 경우, JIT 컴파일이나 벡터화 최적화가 적용되지 않기 때문에 성능 차이가 더 커집니다.

가능하면 다음 우선순위를 따르는 것이 일반적입니다.

  1. pandas / NumPy 내장 벡터화 연산
  2. pandas 집계 함수
  3. NumPy 함수
  4. 최후의 수단으로 apply()

apply() 는 편의 기능이지, 성능을 위한 도구는 아닙니다.

정리하면, apply() 가 느린 이유는 pandas 함수라서가 아니라, Python 함수가 원소 단위로 반복 호출되기 때문입니다. 동일한 연산이 벡터화로 표현 가능하다면, 항상 벡터화 방식을 우선 사용하는 것이 바람직합니다.

2.4 NumPy와 Pandas의 메모리 공유 활용

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 연산을 직접 사용하는 이유

NumPy 함수들은 연속 메모리 위에서 C 레벨 루프를 사용하여 동작합니다.

result = np.sqrt(array)

이 연산은:

  • Python 반복문 없음
  • pandas 오버헤드 없음
  • dtype 고정
  • SIMD 최적화 가능

이라는 특징을 가지며, pandas Series 메서드보다도 빠른 경우가 많습니다.

특히 복잡한 수치 계산이나 커스텀 연산이 필요한 경우, ndarray로 변환한 뒤 NumPy 연산을 적용하는 방식이 가장 효율적인 경로가 됩니다.

주의할 점

메모리 공유는 numeric dtype에서만 안전하게 적용됩니다.

object dtype 컬럼의 경우 .values 는 object 배열을 반환하며, 이 경우 각 원소는 여전히 Python 객체입니다. 따라서 기대하는 성능 향상이 발생하지 않을 수 있습니다.

또한 .to_numpy(copy=True) 를 명시하면 실제 복사가 발생하므로, 메모리 절약이 목적이라면 기본 옵션을 유지하는 것이 좋습니다.

pandas와 NumPy는 동일한 메모리를 공유하므로, .values 또는 .to_numpy() 를 사용하면 복사 없이 ndarray를 얻을 수 있습니다. 이를 활용하면 pandas 구조를 거치지 않고 NumPy의 고성능 연산을 직접 사용할 수 있으며, 대용량 데이터 처리 시 성능과 메모리 효율을 동시에 개선할 수 있습니다.


2.5 캐싱(Caching) 활용

연산이 느린 이유가 반복 계산 때문이라면, 가장 효과적인 최적화는 동일한 계산을 다시 수행하지 않는 것입니다. 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의 내부 동작

lru_cache 는 함수 입력을 key로, 반환값을 value로 하는 딕셔너리 기반 캐시를 유지합니다.

구조적으로는 다음과 같습니다.

(arguments) → result

이미 계산된 입력이 다시 들어오면:

  • 함수 본문 실행 ❌
  • 캐시 lookup ✅
  • 저장된 결과 반환

이라는 흐름을 따릅니다.

maxsize 는 캐시에 저장할 최대 항목 수를 의미하며, 이를 초과하면 가장 오래 사용되지 않은 항목(LRU: Least Recently Used)부터 제거됩니다.

언제 효과적인가

캐싱은 다음 조건을 만족할 때 가장 효과적입니다.

  • 동일한 입력으로 함수가 반복 호출되는 경우
  • 계산 비용이 lookup 비용보다 훨씬 큰 경우
  • 함수가 순수 함수(pure function)에 가까운 경우
    (같은 입력 → 항상 같은 출력)

예를 들어:

  • feature engineering 중 반복되는 변환
  • 복잡한 규칙 기반 계산
  • 동일 파라미터로 여러 번 호출되는 통계 함수

같은 상황에서 유의미한 성능 개선을 얻을 수 있습니다.

주의할 점

캐싱은 메모리를 사용합니다. 따라서:

  • 입력 종류가 매우 많은 경우
  • 결과 객체가 큰 경우
  • 한 번만 호출되는 함수인 경우

에는 오히려 메모리 낭비가 될 수 있습니다.

또한 pandas DataFrame이나 NumPy 배열처럼 mutable 객체는 lru_cache의 key로 사용할 수 없으므로, 스칼라 값이나 immutable 타입 위주로 사용하는 것이 일반적입니다.

lru_cache 는 계산을 빠르게 만드는 도구라기보다, 계산 자체를 생략하게 만드는 도구입니다. 반복 호출되는 고비용 함수가 있다면, 벡터화나 C 레벨 최적화보다 캐싱이 더 큰 효과를 주는 경우도 많습니다.

3. 데이터 구조별 접근 패턴 최적화

Pandas와 NumPy는 서로 다른 메모리 레이아웃을 가지며, 이를 이해하고 활용하면 성능을 크게 향상시킬 수 있습니다.

3.1 Pandas: Column-major 접근

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)이 좋아 캐시 효율이 높습니다.

3.2 NumPy: Row-major 접근 (C-order)

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)

왜 Pandas는 컬럼 단위가 빠르고, NumPy는 행 단위가 빠른가?

이 차이는 단순히 라이브러리 구현 방식 때문이 아니라,
메모리에 데이터가 배치되는 방식과 CPU 캐시 동작 원리에서 비롯됩니다.

핵심은 다음 두 가지입니다.

  • 메모리 레이아웃 (Memory Layout)
  • 메모리 지역성 (Memory Locality)

이를 Pandas와 NumPy 관점에서 각각 살펴보겠습니다.

Pandas: Series 기반 Column-major 구조

Pandas DataFrame은 내부적으로 여러 개의 Series로 구성되어 있습니다.
각 컬럼은 독립적인 NumPy 배열로 존재하며, 메모리 상에서 연속적으로 저장됩니다.

즉 구조적으로 보면 다음과 같습니다.

DataFrame
 ├── Series(col1)
 ├── Series(col2)
 ├── Series(col3)

따라서 다음과 같은 연산은

df['col1'] + df['col2']

실제로는 다음 과정을 거칩니다.

  • col1 전체 배열을 한 번에 읽고
  • col2 전체 배열을 한 번에 읽은 뒤
  • 벡터화 연산으로 처리합니다.

이 과정에서는

  • 연속 메모리 접근이 이루어지고
  • CPU cache line에 자연스럽게 적재되며
  • Python loop 없이 C 레벨에서 계산됩니다.

결과적으로

  • 분기(branch)가 없고
  • cache miss가 최소화되며
  • SIMD 벡터 연산이 활용됩니다.

매우 이상적인 실행 경로입니다.

반면 다음과 같은 코드에서는

for idx, row in df.iterrows():

완전히 다른 접근 패턴이 발생합니다.

이 경우 각 row는 새로운 Series 객체로 생성되며, 컬럼마다 서로 다른 메모리 위치를 참조하게 됩니다. 또한 Python 레벨 loop와 attribute access가 반복됩니다.

즉 다음과 같은 비용이 동시에 발생합니다.

  • 객체 생성 비용
  • 포인터 점프 증가
  • cache locality 붕괴

따라서 iterrows 기반 접근은 구조적으로 느릴 수밖에 없습니다.

정리하면 Pandas에서는 다음과 같습니다.

  • 컬럼 단위 연산: 연속 메모리 + 벡터화
  • 행 단위 순회: 분산 메모리 + Python loop

이 차이가 곧 성능 차이로 이어집니다.

NumPy: 기본 Row-major (C-order) 메모리 배치

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가 발생하면

  • cache line 활용도가 떨어지고
  • TLB miss가 증가하며
  • prefetch가 실패합니다.

이 때문에 NumPy에서 컬럼 접근은 상대적으로 느립니다.

물론 order='F' 옵션으로 column-major 배열을 만들 수 있지만,
대부분의 NumPy 연산과 C-extension은 C-order 기준으로 최적화되어 있습니다.

중요한 점은 이 모든 차이가 연산 복잡도가 아니라
메모리를 어떻게 읽느냐에서 발생한다는 사실입니다.

CPU에서는 계산보다 메모리 접근이 훨씬 느립니다.

따라서 성능의 본질은 다음에 달려 있습니다.

  • 얼마나 연속적으로 읽을 수 있는가
  • cache line을 얼마나 효율적으로 사용하는가

이를 정리하면 다음과 같습니다.

  • Pandas는 컬럼이 연속 메모리이므로 컬럼 단위 연산이 빠릅니다.
  • NumPy는 행이 연속 메모리이므로 행 단위 접근이 빠릅니다.

이 구조를 거스르는 순간 성능은 급격히 저하됩니다.

때문에

  • Pandas에서는 항상 컬럼 벡터 연산을 사용합니다.
  • NumPy에서는 메모리 order에 맞는 접근 방향을 유지합니다.
  • Python loop는 최후의 수단으로 사용합니다.

이는 단순한 코딩 스타일 문제가 아니라,
CPU 아키텍처 레벨에서 결정되는 물리적인 제약에 가깝습니다.

3.3 데이터 구조 선택 가이드

NumPy와 Pandas는 각각 다른 강점을 가지므로 작업 특성에 맞게 선택해야 합니다.

NumPy 사용이 적합한 경우:

  • 수치 연산 및 시뮬레이션
  • 선형 대수, 푸리에 변환
  • 동일한 타입의 대량 데이터 처리

Pandas 사용이 적합한 경우:

  • 테이블 형식 데이터 분석
  • 데이터 로딩, 재구성, 피벗, 병합
  • 누락된 데이터 처리
  • 다양한 타입의 컬럼 관리

4. 최적화 전략

이론적 지식을 실제 프로젝트에 적용하기 위한 단계별 전략입니다.

4.1 최적화 우선순위 결정

최적화 우선순위:
1. 프로파일링으로 병목 지점 식별
2. 가장 많은 시간/메모리를 소비하는 부분부터 최적화
3. 측정 → 최적화 → 재측정 사이클 반복
4. 가독성과 성능의 균형 유지

4.2 단계별 최적화 접근법

복잡한 최적화 기법보다는 간단하고 효과적인 방법부터 적용합니다.

1단계: 저비용 최적화

  • 데이터 타입 최적화
  • 범주형 데이터 변환
  • 불필요한 컬럼 제거

2단계: 알고리즘 개선

  • 반복문을 벡터화 연산으로 변경
  • Pandas 내장 함수 활용
  • NumPy 연산 활용

3단계: 구조적 변경

  • 청크 단위 처리 도입
  • 병렬 처리 적용
  • 데이터 파이프라인 재설계

5. 고급 최적화 기법

기본 최적화로 충분하지 않을 때 적용할 수 있는 고급 기법들입니다.

5.1 __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")

5.2 딕셔너리와 집합의 효율적 사용

딕셔너리와 집합은 해시 테이블 기반으로 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 기술 블로그

(python) pandas 처리 속도 개선에 대하여

NumPy 대 Pandas: 일반적인 용어로 차이점 설명

고성능 ML 백엔드를 위한 10가지 Python 성능 최적화 팁

44

파이썬 프로그래머가 알아야 할 주요 성능 수치

AI 개발자를 위한 단계별 Python 최적화 가이드라인 세션의 코드를 실행하여 최적화 가능성을 확인합니다. 딥러닝 모델 학습의 데이터로더 CPU 병목을 줄이고 모델 추론 과정에서 전처리 속도를 개선하는데 도움이 됩니다

profile
2026년 화이팅!!!

0개의 댓글