딕셔너리 캐싱을 통한 성능 향상
Intro
이전 포스팅에서는 반복문의 횟수를 줄임으로써 pandas 의 Dataframe 을 이용한 연산의 성능을 소폭 향상시켜봤다. 이번 포스팅에서는 딕셔너리 캐싱을 통해 더욱 큰 성능 향상을 노려본다.
라이브러리
1
2
3
import pandas as pd
import random
import time
실험 데이터셋
df_a : user_id, some_data 두 컬럼으로 이루어져 있다.
df_a의 some_data 는 약 20% 가량이 NaN 값을 가진다.
df_b : user_id, alt_data 두 컬럼으로 이루어져 있다.
df_a와 df_b의 user_id 집합은 동일하다.
df_b 의 alt_data 에는 결측치가 없다.
1
2
3
4
5
6
7
8
9
10
qnt = 10000 # 데이터 수
df_a_origin = pd . DataFrame ({
"user_id" : [ str ( x ) for x in range ( 1 , qnt + 1 )],
"some_data" : [ random . randint ( 0 , qnt ) if random . randint ( 0 , qnt ) < qnt * 4 / 5 else None
for _ in range ( qnt )]
})
df_b = pd . DataFrame ({
"user_id" : [ str ( x ) for x in range ( 1 , qnt + 1 )],
"alt_data" : [ random . randint ( 0 , qnt ) for _ in range ( qnt )]
})
실험의 목표
df_a 의 NaN 인 some_data 값을 찾아, df_b 의 동일 user_id 의 alt_data 로 대체한다.
대조군
작동 방식 : df_a 의 각 row 를 순회하며 NaN값 여부를 확인하고, alt_data로 대체한다.
순서
작동
시간복잡도
1
df_a의 각 row를 순회하며, some_data가 NaN인지 확인한다.
O(N_a)
2
NaN값인 경우, df_b에서 동일한 user_id 에 해당하는 alt_data를 찾는다.
O(N_b)
3
row의 some_data 를 찾은 alt_data로 대체한다.
O(1)
전체 시간복잡도 : O(N_a * N_b)
또한 매 반복마다 boolean mask 를 생성하므로 메모리 비용이 발생
1
2
3
4
5
6
df_a = df_a_origin . copy ()
start = time . time ()
for i , row in df_a . iterrows (): #-------- (1)
if pd . isna ( row [ "some_data" ]): #----- (1)
df_a . at [ i , "some_data" ] = df_b . loc [ df_b [ "user_id" ] == row [ "user_id" ], "alt_data" ]. iloc [ 0 ] #----(2)(3)
print ( f "time check 1 ::: { time . time () - start } " )
1
time check 1 ::: 0.7818460464477539
문제 포착
매 반복마다 수행되는 df_b에서 user_id 에 해당되는 row를 찾는 연산은 위 코드에서 가장 큰 시간복잡도를 일으킨다.
실험군 1
작동 방식 : df_b 를 dictionary로 만든 뒤, 반복문에서 이 dictionary에서 user_id 를 lookup 하도록 대체한다.
순서
작동
시간복잡도
1
df_b를 dictionary로 변환해 캐싱한다. > b_dict
O(N_b)
2
df_a를 순환하면서 some_data 가 NaN인지 여부를 검사한다.
O(N_a)
3
b_dict 에서 user_id 를 찾는다.
O(1)
4
해당 row의 some_data를 b_dict의 동일 user_id의 alt_data로 대체한다.
O(1)
전체 시간복잡도 : O(N_b) + O(N_a * 1)
= O(N_b + N_a)
1
2
3
4
5
6
7
df_a = df_a_origin . copy ()
start = time . time ()
b_dict = { uid : row for uid , row in df_b . set_index ( "user_id" ). iterrows ()}
for i , row in df_a . iterrows ():
if ( pd . isna ( row [ "some_data" ])):
df_a . at [ i , "some_data" ] = b_dict [ row [ "user_id" ]][ "alt_data" ]
print ( f "time check 2 ::: { time . time () - start } " )
1
time check 2 ::: 0.21245098114013672
실험군 2
작동 방식 : 이전 포스팅에서 살펴본 “반복 횟수 감소” 방법과 이번 포스팅의 “딕셔너리 캐싱”을 함께 사용한다.
순서
작동
시간복잡도
1
df_a 에서 some_data가 NaN인 인덱스들을 미리 뽑는다.
O(N_a)
2
df_b를 dictionary로 변환해 캐싱한다. > b_dict
O(N_b)
3
미리 뽑은 인덱스에 해당하는 row 들만 순환한다.
O(K), K≈0.2*N_a
4
b_dict 에서 user_id 를 찾는다.
O(1)
5
해당 row의 some_data를 b_dict의 동일 user_id의 alt_data로 대체한다.
O(1)
전체 시간복잡도 : O(N_a) + O(N_b) + O(K * 1)
= O(N_b + N_a)
1
2
3
4
5
6
7
df_a = df_a_origin . copy ()
start = time . time ()
target_idx = df_a [ df_a [ "some_data" ]. isna ()]. index
b_dict = { uid : row for uid , row in df_b . set_index ( "user_id" ). iterrows ()}
for i , row in df_a . loc [ target_idx ]. iterrows ():
df_a . at [ i , "some_data" ] = b_dict [ row [ "user_id" ]][ "alt_data" ]
print ( f "time check 3 ::: { time . time () - start } " )
1
time check 3 ::: 0.11246085166931152
결과 리뷰
dictionary caching 을 통해 큰 폭의 성능 향상을 얻을 수 있었다.
반복문 내부의 로직 성능 향상이므로, 반복 횟수가 커질수록 그 이득도 커질 것이다.
반복문 감소 + dictionary caching 을 조합할 경우 추가적인 성능 향상이 가능하다.
시간복잡도는 실험군2와 실험군1이 같지만, 실행속도는 실험군 2가 더 빠르다. 이에 대해서는 아래 원리에서 살펴본다.
원리
캐싱
접근 빈도가 높거나 계산 비용이 큰 데이터의 결과를 임시 저장소에 보관하고, 이후 동일한 요청이 들어왔을 때 다시 계산하거나 조회하지 않고, 임시 저장한 값을 즉시 반환하는 성능 최적화 기법
응답 속도를 개선하고, 시스템의 IO / 연산 / 네트워크 부하를 줄이는 게 핵심 목표이다.
캐싱은 무조건 “메모리”에 올리는 게 아니며, 보조기억장치에 저장할 수도 있다. 다만, 현실적으로 가장 빠른 캐시는 메모리 캐시이기 때문에 “캐시 == 메모리 캐시”로 말하는 경우가 많다.
딕셔너리 해시
Dictionary / Hash Table
딕셔너리 해시는 key-value 쌍으로 데이터를 저장하며, 평균적으로 O(1)의 시간복잡도로 값을 조회(lookup)할 수 있는 자료구조이다.
내부적으로는 해시 함수(hash function)을 사용해 key를 정수값(=해시값)으로 변환하고, 이 해시값을 인덱스 삼아 배열 내의 저장 위치에 직접 접근한다.
때문에 딕셔너리는 캐시 구현 시 가장 많이 사용되는 자료 구조이다.
1
2
3
4
print ( hash ( "some_data" ))
print ( hash ( "alt_data" ))
# >> -2155841253351233556
# >> 701232581478664499
해시 함수는 다음 성질을 가진 함수이다.
입력: 문자열, 숫자, 객체 등 임의의 데이터
출력: 정해진 범위의 정수값(해시값)
해시 함수가 없으면, 딕셔너리(맵)에서 값을 찾기 위해 처음부터 끝까지 비교해야 한다.
하지만 해시를 쓰면:
key → 해시 함수 → 숫자
숫자 → 배열 인덱스 → 즉시 접근
→ 평균 시간복잡도 O(1)
실험군2가 더 빠른 이유
시간복잡도는 실험군2와 실험군1가 같지만, 실행속도는 실험군 2가 더 빠르다.
이는 시간복잡도(Big-O)가 아니라 실제 실행 비용(constant factor)과 반복 횟수 차이 때문이다.
즉, Big-O 계산법에서는 다루지 않는 실행 비용 때문인 것이다.
실험군1, 2에서는 df_a에 대해 아래와 같이 숨겨진 연산이 존재한다.
• iterrows() → 매 row마다 pandas Series 생성 (비쌈)
• 모든 row에서 isna 검사 수행
• NaN이 아닌 row에서도 if 판단 수행
실험군 2는, 감소된 반복횟수(K≈0.2*N_a)만큼만 순회하기 때문에, 위 연산들이 더 적게 수행된다.
따라서 실제 작업량이 줄어들며, 실행속도가 개선되는 것잉다.
시간복잡도는 “상한선”만 알려준다는 것을 명심하자
Outro
다음에는 가장 효율적인 방식으로, 해시 기반 join 을 이용해 반복문을 아예 제거하는 방법을 살펴본다.
Tags:
best ,
caching ,
dictionary ,
for ,
loop ,
pandas ,
practice ,
typography ,
감소 ,
딕셔너리 ,
반복문 ,
성능 ,
캐싱 ,
파이썬 ,
판다스 ,
향상
Categories:
Python
Updated: 2025-12-d
Comments