지난 12편에서는 자식 클래스가 반드시 구현해야 할 최소한의 메서드 규격을 강제하여 팀 협업 및 대규모 시스템에서 버그를 구조적으로 차단하는 추상 클래스(ABC)와 인터페이스 설계법을 다루었습니다.
파이썬으로 개발을 진행하다 보면 웹 크롤링 데이터 수집, 로그 파일 분석, 대용량 데이터베이스(DB) 조회 등 수백만 건에 달하는 대량의 데이터를 다루어야 하는 상황을 자주 맞닥뜨리게 됩니다. 이때 아무 생각 없이 데이터 전체를 리스트(list)나 딕셔너리(dict) 포맷으로 한꺼번에 메모리(RAM)에 읽어 들이면, 프로그램 실행 직후 MemoryError가 터지며 서버나 컴퓨터가 마비되는 현상을 겪게 됩니다.
저 역시 초보 시절, 수GB 크기의 로그 파일을 단순 open().readlines()로 한꺼번에 파이썬 메모리로 불러왔다가 RAM 점유율이 100%까지 치솟아 개발용 PC가 그대로 멈춰버렸던 뼈아픈 경험이 있습니다. 오늘은 데이터 전체를 미리 메모리에 올리지 않고 필요할 때마다 하나씩 꺼내어 처리하는 이터레이터(Iterator)의 원리와 파이썬 최강의 메모리 절약 무기인 제너레이터(Generator) 활용법을 정리해 드립니다.
1. 이터러블(Iterable)과 이터레이터(Iterator)의 차이점
파이썬에서 for 문으로 순회할 수 있는 모든 객체(리스트, 튜플, 문자열 등)를 이터러블(Iterable) 객체라고 부릅니다. 하지만 이터러블 객체가 곧바로 데이터를 하나씩 꺼내주는 것은 아닙니다.
이터러블 (Iterable):
__iter__()메서드를 가지고 있어서 이터레이터를 생성해 낼 수 있는 대상 (예: 붕어빵 틀)이터레이터 (Iterator):
__next__()메서드를 가지고 있어서 actual 데이터를 메모리에서 하나씩 순차적으로 꺼내주는 실체 (예: 하나씩 나오는 붕어빵)
리스트 전체를 메모리에 올려두고 포인터를 이동시키는 기존 방식과 달리, 이터레이터는 '다음 데이터가 무엇인지 알고 있는 상태'만 유지하므로 메모리를 극적으로 절약할 수 있습니다.
numbers = [10, 20, 30] # 이터러블 객체 (리스트)
# iter() 함수를 호출하여 이터레이터 객체 생성
num_iterator = iter(numbers)
# next() 함수를 호출할 때마다 메모리에서 데이터가 하나씩 꺼내어짐
print(next(num_iterator)) # 출력: 10
print(next(num_iterator)) # 출력: 20
print(next(num_iterator)) # 출력: 30
# 더 이상 꺼낼 데이터가 없으면 StopIteration 예외 발생
# next(num_iterator) -> StopIteration 에러 발생 (for문은 이 에러를 감지하여 구문을 자동 종료함)
2. 게으른 연산(Lazy Evaluation)의 핵심: 제너레이터(Generator)와 yield
이터레이터를 직접 클래스로 구현(__iter__, __next__ 매직 메서드 작성)하려면 코드가 다소 길어집니다. 이를 파이썬답고 훨씬 간결하게 만들 수 있도록 지원하는 기능이 바로 제너레이터(Generator)입니다.
제너레이터는 일반 함수와 똑같은 형태를 갖지만, 데이터를 반환할 때 return 대신 yield 키워드를 사용합니다.
return과 yield의 결정적 차이
return: 값을 반환하고 함수의 실행을 완전히 종료하며 메모리에서 함수를 제거합니다.yield: 값을 외부로 전달한 뒤, 함수의 현재 실행 상태(변수 값, 코드 위치)를 그 자리에 '일시 정지(Pause)' 형태로 메모리에 유지합니다. 이후 다음 데이터 요구(next())가 들어오면 정지했던 그 위치부터 다시 실행을 재개합니다.
이처럼 필요한 시점에만 계산을 수행하는 방식을 컴퓨터 과학에서는 '게으른 연산(Lazy Evaluation)'이라고 부릅니다.
3. 실전 성능 비교: 리스트 vs 제너레이터 메모리 사용량 측정
1,000만 개의 정수 데이터를 다룰 때 일반 리스트 방식과 제너레이터 방식이 사용하는 메모리 크기를 파이썬 sys.getsizeof() 함수로 직접 비교해 보겠습니다.
import sys
# 1. 일반 리스트 생성 (메모리에 1000만 개 데이터 전부를 일시 적재)
list_data = [i for i in range(10000000)]
print(f"리스트의 메모리 사용량: {sys.getsizeof(list_data)} 바이트")
# 출력: 약 80,000,000 바이트 (약 80MB 이상의 RAM 차지)
# 2. 제너레이터 표현식 생성 (괄호 () 사용)
generator_data = (i for i in range(10000000))
print(f"제너레이터의 메모리 사용량: {sys.getsizeof(generator_data)} 바이트")
# 출력: 약 200 바이트! (데이터 수에 상관없이 항상 최저 메모리 유지)
1,000만 개의 데이터를 다룸에 있어 리스트는 80MB 이상의 메모리를 즉시 점유하는 반면, 제너레이터는 단 200바이트 수준의 아주 작은 상태 정보 메모리만 소모합니다. 데이터가 1억 건, 10억 건으로 늘어나도 제너레이터의 메모리 사용량은 거의 늘어나지 않습니다.
4. 실전 예제: 대용량 텍스트 로그 파일 안전하게 읽기
서버 운영 중 발생하는 수십 GB 단위의 대용량 로그 파일을 한 줄씩 안전하게 처리하는 제너레이터 패턴입니다.
def read_large_log_file(file_path):
"""대용량 파일을 한 줄씩 게으르게(Lazy) 읽어오는 제너레이터 함수"""
with open(file_path, "r", encoding="utf-8") as file:
for line in file:
# 특정한 조건(예: ERROR 로그)을 만족하는 행만 선택적으로 yield
if "ERROR" in line:
yield line.strip()
# 제너레이터 함수 호출 (이 시점에는 파일 데이터가 메모리에 오르지 않음)
log_generator = read_large_log_file("server_big_log.txt")
# for문을 돌 때마다 파일에서 비로소 한 줄씩 읽어와 처리
for error_log in log_generator:
print(f"[비상 처리] {error_log}")
read_large_log_file 함수는 파일 전체를 한꺼번에 읽지 않고, for문이 한 회전 돌 때마다 한 줄씩만 메모리로 들고 와서 처리한 뒤 곧바로 메모리에서 비워버립니다. 따라서 서버 사양이 아무리 낮아도 테라바이트(TB)급 로그 파일을 다운 없이 처리할 수 있습니다.
핵심요약
이터레이터(Iterator)는
__next__()메서드를 통해 데이터를 한 번에 하나씩 순차적으로 꺼내어 처리하는 메모리 효율적 객체입니다.제너레이터(Generator)는
yield키워드를 활용해 함수 실행 상태를 유지하며 데이터를 필요한 시점에만 계산하는 게으른 연산(Lazy Evaluation)을 구현합니다.제너레이터 표현식이나 제너레이터 함수를 다루면 대용량 데이터 처리 시 RAM 메모리 점유율을 획기적으로 낮추어 메모리 부족 에러(
MemoryError)를 완벽히 예방할 수 있습니다.


0 댓글