Python Generators: How To Efficiently Fetch Data From Databases
Two practical use cases for Data Engineers.
levelup.gitconnected.com
소개
위 문서에서는 매우 큰 데이터베이스에서 데이터를 어떻게 효율적으로 가져올 수 있는지에 대해 설명하고 있다.
필자는 데이터베이스 엔지니어로 근무하면서 실제 운영되고 있는 데이터베이스에서 많은 데이터셋을 가져와 분석을 위한 다른 데이터베이스나 클라우드 스토리지에 적재해야 하는 상황이 많다고 예시를 들어주었는데
실제로 백엔드 엔지니어로 근무하고 있는 나 또한 모놀리식 구조의 서버를 MSA로 분리하면서 실제 운영되고 있는 매우 방대한 메인 DB의 데이터를 서로 다른 여러 DB에 나누어 적재해야 하는 상황이 있었다.
이 글에서 소개하고 있듯이 많은 회사나 엔지니어가 이 이슈를 해결하기 위해 Batch로 나누어 처리하는 방식을 사용한다고 하는데 당시 나도 Batch로 처리하는 스크립트를 만들어 데이터를 단계적으로 옮기는 방식으로 처리해 공감이 갔다.
하지만 이 글에서는 대규모 데이터베이스에서 데이터를 가져오기 위해 Batch 방식을 사용하는 대신
Python Generator를 사용하면 더 효율적으로 데이터를 가져올 수 있다고 소개하고 있다.
그럼 먼저 Python Generator가 무엇인지부터 알아야 할 필요가 있다.
Python Generator란?
Python Generator는 iterator 객체를 생성해 주는 특수 함수이다.
일반 함수는 단일 값을 계산하고 반환한 다음 즉시 종료되는 반면 Generator는 일련의 값을 생성하고 필요에 따라 일시 중지하거나 재개할 수 있다는 특징을 가지고 있다.
이해를 돕기 위해, 아래와 같이 0과 입력 변수 사이의 숫자의 제곱을 산출하는 함수를 Generator로 만들어보자.
def squares_generator(n):
num = 0
while num < n:
yield num * num
num += 1
squares_generator() 함수는 n을 매개변수로 받고 0부터 n까지 제곱수를 반환하는 함수이다.
일반 함수는 return으로 반환하는 것과 달리 generator는 yield를 사용해 반환 후 제어권을 넘기는 것을 볼 수 있다.
이 함수를 호출하고 출력해 보면 아래와 같이 iterator 객체가 출력되는 것을 확인할 수 있다.
squares_generator(2)
# 출력 :
# <0x106382die9에 있는 생성기 객체 squares_generator>
iterator 객체는 반복문을 통해 출력할 수 있다.
for num in squares_generator(5):
print(num)
# 출력
# 0
# 1
# 4
# 9
# 16
Python 문법에서는 Generator를 list comprehension과 유사하게 생성할 수 있도록
Generator Expression을 제공하는데 아래는 이를 사용하여 위 코드를 다시 작성한 예시이다.
generator_exp = (num * num for num in range(5))
print(next(generator_exp)) # 0
print(next(generator_exp)) # 1
print(next(generator_exp)) # 4
print(next(generator_exp)) # 9
print(next(generator_exp)) # 16
여기서 list를 사용하면 되는데 왜 generator를 사용해야 하는지에 대한 의문이 들 수 있다.
list 문법은 코드를 직관적으로 짤 수 있다는 장점이 있다.
이에 반해 Generater가 가지 장점은 아래와 같다.
1. 메모리 효율성
list의 경우 list 안에 속한 모든 데이터를 메모리에 먼저 적재하기 때문에 list 크기만큼 메모리 사이즈가 늘어나게 된다.
하지만 generator의 경우 필요한 시점에 값을 생성하여 반환하는 방식이기 때문에 고정된 메모리만 사용한다.
이는 큰 데이터셋을 다룰 때 메모리를 매우 효율적으로 사용할 수 있다는 장점을 가진다.
2. 지연 평가 (Lazy Evaluation)
generator는 필요할 때만 값을 생성하므로, 불필요한 계산을 피할 수 있다.
이 또한 큰 데이터셋이나 무한 시퀀스를 다룰 때 유용하다.
따라서 list를 사용했을 때보다 generator를 사용했을 때 메모리 사용에서 큰 효율을 가져온다.
이를 대용량 데이터베이스에서 데이터를 가져오는 경우에도 동일하게 작용한다.
이 글에서는 실제 Usecase를 보여주기 위해 Git Repository를 제공하고 있다.
해당 레포를 clone 받아 실제 데이터를 효율적으로 가져오는 것을 실습해 볼 수 있다.
대규모 데이터베이스에서 데이터를 효율적으로 가져오는 방법
clone을 받았으면 fetch_data_with_python_generator 폴더에서 docker-compose up -d 를 실행해 컨테이너를 띄운다
※ 많은 데이터 환경을 시험하기 위해 미리 준비된 sql 파일을 실행해 데이터를 미리 적재해 놓기 때문에 컨테이너를 올리기까지 시간이 조금 걸린다.
컨테이너들이 정상적으로 올라갔다면 설정해 둔 포트로 Jupyter와 MinIO에 접속할 수 있다.
환경 구축이 완료되었으면 실제 Usecase를 살펴보자.
Usecase 1 : 대규모 데이터베이스에서 데이터 가져오기
보통 많은 데이터를 가져와야 할 때 다음 2가지 사이에서 적절한 균형을 찾아야 한다.
1. Memory
: 전체 데이터셋을 한 번에 가져오면 Out Of Memory 오류가 발생하거나 전체 인스턴스 또는 클러스터 성능에 이슈가 발생할 수 있다.
2. Speed
: 행을 하나씩 가져오면 I/O 네트워크 비용이 너무 많이 발생한다.
주로 이를 해결하기 위해 Batch를 사용한다.
Batch 방식은 데이터를 일괄적으로 가져오는 방법을 말한다.
이때 일괄 처리의 크기는 사용 가능한 메모리와 데이터 파이프라인의 속도 요구 사항에 따라 달라진다.
Jupyter의 generators.ipynb의 코드를 살펴보면 아래와 같이 Batch 방식으로 데이터를 가져오는 부분이 있다.
# 1.1. CREATE DF USING BATCHES
def create_df_batch(cursor, batch_size):
print('Creating pandas DF using generator...')
colnames = ['transaction_id', 'user_id', 'product_name', 'transaction_date', 'amount_gbp']
df = pd.DataFrame(columns=colnames)
cursor.execute(query)
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
# some tramsformation
batch_df = pd.DataFrame(data = rows, columns=colnames)
df = pd.concat([df, batch_df], ignore_index=True)
print('DF successfully created!\n')
return df
코드를 살펴보면 먼저 pandas를 사용해 dataframe을 만들어 둔 후 데이터베이스에서 모든 데이터를 가져온 결과를 cursor에 캐싱한다. 이후 캐싱된 데이터에서 제한된 batch_size 만큼 데이터를 가져와 만들어 둔 dataframe에 적재하는 작업을 모든 데이터를 가져올 때까지 반복한 후 반환한다.
이 함수를 실제로 호출하면 아래와 같은 결과가 나온다
df _batch = create_ df _batch(cursor, atch_size)
df _batch.head()
# 출력:
# 생성기를 사용하여 pandas DF 생성 중...
# DF가 성공적으로 생성되었습니다!
# CPU 시간: 사용자 9.97초, 시스템: 13.7초, 총: 23.7초
# 벽 시간: 25초
하지만 이 같은 batch 방식은 모든 데이터를 가져와 먼저 메모리에 적재하기 때문에
데이터베이스에 있는 row의 수만큼 메모리를 사용하게 된다.
만약 데이터베이스에 매우 많은 row 수가 있다면 그 수만큼 메모리를 사용하는 것이다.
이를 개선하기 위해 Generator를 사용할 수 있는데
마찬가지로 generators.ipynb 파일의 아래 함수를 살펴보자
# AUXILIARY FUNCTION
def generate_dataset(cursor):
cursor.execute(query)
for row in cursor.fetchall():
# some tramsformation
yield row
# 2.1. CREATE DF USING GENERATORS
def create_df_gen(cursor):
print('Creating pandas DF using generator...')
colnames = ['transaction_id', 'user_id', 'product_name', 'transaction_date', 'amount_gbp']
df = pd.DataFrame(data = generate_dataset(cursor), columns=colnames)
print('DF successfully created!\n')
return df
먼저 결과를 살펴보면 아래와 같다.
df_gen = create_df_gen(cursor)
df_gen.head()
# 생성기를 사용하여 pandas DF 생성 중...
# DF가 성공적으로 생성되었습니다!
# CPU 시간: 사용자 9.04초, 시스템: 2.1초, 총: 11.1초
# 벽 시간: 14.4초
결과는 동일하지만 Generator를 사용하면 반복될 때마다 필요한 row를 반환하기 때문에 메모리 사용은 Batch 방식에 비해 훨씬 적다.
위 글에서는 Cloud Object Storage에 데이터를 적재하는 2번째 Usecase도 소개하지만
메모리를 효율적으로 사용한다는 점은 두 Usecase 모두 동일하기 때문에 이 글에서 따로 다루진 않는다.
핵심은 Generator를 사용하면 메모리를 효율적으로 사용할 수 있고 이를 대규모 데이터베이스에서 데이터를 fetch 하는 데 사용할 수 있다는 점이다.
후기
실제로 현업에서 Batch 방식을 사용해 대량의 데이터를 옮겨본 경험이 있어 Generator를 사용한 방식이 굉장히 신기했다.
Python을 사용하면서 서버 프로그래밍을 하고 있지만 이런 식으로 Generator를 활용할 생각은 못했어서 역시 아직 많이 부족하구나를 느꼈다.
이후 대규모 데이터를 처리할 일이 있거나 메모리 효율이 중요한 업무를 맡게 된다면 Generator를 활용할 방법이 없을지 먼저 고민해 볼 것 같다.
'기술문서 읽기' 카테고리의 다른 글
ASGI에 대해서 (1) | 2024.02.08 |
---|---|
Rest API 디자인 모범 사례 (1) | 2024.01.28 |
데이터베이스 최적화하는 방법 11가지 (0) | 2024.01.14 |
Netflix에서 API를 탄력적으로 만드는 방법 (2) | 2023.12.31 |
PostgreSQL 공식문서 - Index (1) | 2023.12.21 |