파이썬 제너레이터: 성능 향상과 코드 단순화

당신이 데이터 과학 프로젝트에 참여하고 있다고 상상해 보십시오. 당신의 작업은 메모리에 로드할 수 없을 정도로 큰 데이터 세트를 처리하는 것입니다. 또는 모든 것을 동시에 저장할 수 없는 무한 시퀀스, 즉 실시간 데이터 스트림을 다루고 있을 수도 있습니다. 이러한 문제는 데이터 과학자들이 커피 냄비를 찾게 만들고 때로는 리셋 버튼을 누르게 합니다. 

이 기사에서는 파이썬 제너레이터에 대해 배우고, 이를 사용하여 코드를 간소화하는 방법을 알아보겠습니다. 이 아이디어는 약간의 연습이 필요하므로, 파이썬이 처음이신 분들은 이 기사에서 약간 혼란스러울 경우, 우리의 파이썬 소개 코스를 수강하여 탄탄한 기초를 다지시기 바랍니다. 

파이썬 제너레이터란 무엇인가?

파이썬 제너레이터는 핵심적으로 값의 시퀀스를 게으르게 생성하는 특수 종류의 함수이거나 간결한 표현입니다. 제너레이터를 공장의 컨베이어 벨트로 생각해보세요. 제품을 한 곳에 쌓지 않고 공간이 부족해지는 것을 막고, 각 항목이 도착할 때마다 처리합니다. 이렇게 함으로써 제너레이터는 메모리를 효율적으로 사용하며 파이썬의 반복자(iterator) 프로토콜의 자연스러운 확장이며, for 루프와 내포(comprehension)와 같은 파이썬의 많은 내장 도구를 기반으로 합니다.  

제너레이터 뒤의 마법은 yield 키워드에 있습니다. 함수를 종료하는 return과는 달리, yield는 값을 생성하고 함수의 실행을 일시 중지하며 상태를 저장합니다. 제너레이터가 다시 호출될 때, 이전에 중단한 곳부터 진행합니다. 

예를 들어, 대용량 로그 파일을 한 줄씩 읽고 있는 상황을 상상해보세요. 제너레이터는 전체 파일을 메모리에로드하지 않고 각 줄을 읽는 방식으로 처리할 수 있습니다. 이 “게으른 평가(lazy evaluation)”는 제너레이터를 전통적인 함수와 구별 짓고 성능에 민감한 작업에 가장 적합한 도구로 만듭니다.  

기본 파이썬 제너레이터 예제

아이디어에 익숙해지기 위해 조금 연습해 봅시다. 다음은 처음 n개의 정수를 생성하는 제너레이터 함수입니다.

def generate_integers(n): for i in range(n): yield i # 여기서 멈추고 i를 반환합니다. # 제너레이터 사용하기 for num in generate_integers(5): print(num)
0 1 2 3 4

내부에서 무슨 일이 일어나는지 보여주는 시각적 자료를 만들었습니다:

Python Generator 구문과 패턴

제너레이터는 여러 방법으로 구현할 수 있습니다. 그렇지만 주요한 두 가지 방법이 있습니다: 제너레이터 함수와 제너레이터 표현식.

제너레이터 함수

제너레이터 함수는 일반 함수처럼 정의되지만 return 대신에 yield 키워드를 사용합니다. 호출되면 반복 가능한 제너레이터 객체를 반환합니다.

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # 제너레이터 사용하기 counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

위 예제에서 알 수 있듯이, count_up_to 함수가 호출되면 생성자 객체를 반환합니다. for 루프가 값을 요청할 때마다 함수는 yield에 도달할 때까지 실행되어 현재 count 값을 생성하고 반복 간에 상태를 유지하여 중단한 지점에서 정확히 재개할 수 있습니다.

생성자 표현식  

생성자 표현식은 생성자를 만들기 위한 간결한 방법입니다. 리스트 컴프리헨션과 유사하지만 대괄호 대신 괄호를 사용합니다.

# 리스트 컴프리헨션 (즉시 평가) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # 생성자 표현식 (지연 평가) squares_gen = (x**2 for x in range(5)) # 생성자 사용하기 for square in squares_gen: print(square)
0 1 4 9 16

그렇다면 리스트 컴프리헨션과 제너레이터 표현식의 차이는 무엇일까요? 리스트 컴프리헨션은 전체 목록을 메모리에 생성하는 반면, 제너레이터 표현식은 값을 한 번에 하나씩 생성하여 메모리를 절약합니다. 리스트 컴프리헨션에 익숙하지 않다면, 당사의 Python List Comprehension Tutorial에서 읽어보실 수 있습니다.Python 제너레이터 vs. 이터레이터

Python의 전통적인 이터레이터는 명시적인 __iter__() 및 __next__() 메서드를 가진 클래스가 필요했는데, 이는 많은 보일러플레이트와 수동 상태 관리를 포함하였습니다. 반면, 제너레이터 함수는 각 숫자의 제곱을 생성하는 간단한 함수로 보여주며, 이러한 메서드가 필요 없이 상태를 자동으로 보존하는 프로세스를 단순화합니다.

Python 제너레이터를 사용하는 이유

파이썬 제너레이터가 무엇인지 설명하면서, 왜 그것들이 사용되는지도 전달했습니다. 이 섹션에서는 좀 더 자세히 설명하고자 합니다. 제너레이터는 단순한 파이썬의 멋진 기능이 아니라 실제로 문제를 해결하는 데 도움이 됩니다.

메모리 효율성  

리스트나 배열처럼 모든 요소를 동시에 메모리에 저장하지 않고, 제너레이터는 값을 즉석에서 생성하므로 한 번에 하나의 항목만 메모리에 보유합니다.

예를 들어, 파이썬 2의 range()xrange()의 차이를 생각해 보십시오:  

  •  range()는 메모리에 리스트를 생성하여 큰 범위에서는 문제가 될 수 있습니다.

  • xrange()는 값들을 게으르게 생성하는 생성기처럼 작동했습니다.

xrange()의 동작이 더 유용했기 때문에, 이제 Python 3에서 range()도 생성기처럼 동작하여 모든 값을 동시에 저장하는 메모리 오버헤드를 피합니다.

이 아이디어를 보여주기 위해 1000만 개 숫자 시퀀스를 생성할 때 메모리 사용량을 비교해보겠습니다.

import sys # 목록 사용 numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # 생성기 사용 numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

보시다시피, 생성기는 리스트에 비해 거의 메모리를 사용하지 않으며, 이 차이는 상당합니다.  

성능 향상  

지연 평가 덕분에 값은 필요할 때만 계산됩니다. 이는 전체 시퀀스가 생성될 때까지 기다리지 않고 즉시 데이터 처리를 시작할 수 있음을 의미합니다.  

예를 들어, 처음 100만 숫자의 제곱을 합산한다고 가정해 봅시다:

# 리스트 사용 (즉시 평가) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # 생성기 사용 (지연 평가) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

두 접근 방식 모두 동일한 결과를 제공하지만, 생성기 버전은 대규모 리스트 생성을 피하므로 결과를 더 빨리 얻을 수 있습니다. 

단순함과 가독성  

발전기는 보일러플레이트 코드를 제거하여 이터레이터의 구현을 간소화합니다. 클래스 기반 이터레이터와 발전기 함수를 비교해보세요:

여기에 클래스 기반 이터레이터가 있습니다:

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # 사용법 squares = SquaresIterator(5) for square in squares: print(square)

여기에 발전기 함수가 있습니다:

def squares_generator(n): for i in range(n): yield i ** 2 # 사용법 squares = squares_generator(5) for square in squares: print(square)

발전기 버전은 더 짧고 읽기 쉽며 보일러플레이트 코드가 필요하지 않습니다. 이는 파이썬의 철학을 완벽히 보여주는 예시입니다: 간단함이 더 좋습니다.

무한한 시퀀스 다루기

마지막으로, 발전기는 무한한 시퀀스를 나타내는 데 특히 적합하며, 이는 리스트로는 단순히 불가능합니다. 예를 들어, 피보나치 수열을 고려해보세요:

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # 사용법 fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

이 생성기는 메모리 부족 없이 피보나치 수를 무한히 생성할 수 있습니다. 다른 예시로는 실시간 데이터 스트림 처리나 시계열 데이터 작업이 있습니다.

고급 Python 생성기 개념

이제 좀 더 어려운 아이디어를 살펴보겠습니다. 이 섹션에서는 생성기를 구성하고 .send(), .throw(), .close()와 같은 고유한 생성기 메소드를 사용하는 방법을 탐구할 것입니다.

생성기를 연결하는

생성기는 결합될 수 있습니다. 생성기를 연결하여 데이터를 변환, 필터링 및 모듈식으로 처리할 수 있습니다.

무한한 숫자 시퀀스가 있다고 가정하고 각 숫자를 제곱하고 홀수 결과를 필터링하고 싶다고 가정해 봅시다.

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # 생성기를 조합합니다 numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # 처음 10개의 짝수 제곱을 출력합니다 for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

프로세스는 infinite_sequence 함수가 숫자를 무한히 생성하는 동안, square_numbers는 각 숫자의 제곱을 생성하고, 그런 다음 filter_evens는 홀수를 걸러내어 짝수의 제곱만 생성합니다. 저희 Associate Python Developer 직무 궤적은 이러한 종류의 작업에 진입하여, 제너레이터를 사용하여 복잡한 파이프라인을 구축하고 디버그하는 방법을 볼 수 있습니다, 그리고 이터레이터 그리고 리스트 컴프리헨션을 사용합니다. 

특별한 제너레이터 메서드들  

제너레이터는 양방향 통신과 제어된 종료를 가능하게 하는 고급 메서드를 제공합니다.

send()

.send() 메서드를 사용하면 제너레이터로 값을 다시 전달하여 코루틴으로 변환할 수 있습니다. 이는 대화형이거나 상태를 유지하는 제너레이터를 생성하는 데 유용합니다.  

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # 생성기 사용하기 acc = accumulator() next(acc) # 생성기 시작하기 print(acc.send(10)) # 출력: 10 print(acc.send(5)) # 출력: 15 print(acc.send(20)) # 출력: 35

작동 방법:

  • 생성기는 next(acc)로 초기화됩니다.

  • .send(value)를 호출할 때마다 값이 생성기로 전달되고, yield 문에서 value에 할당됩니다.

  • 제너레이터는 상태(total)를 업데이트하고 새로운 결과를 생성합니다.

throw()

.throw() 메서드는 제너레이터 내에서 예외를 발생시킬 수 있게 해주며, 에러 처리나 특정 조건 신호 전달에 유용합니다.  

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # 제너레이터 사용하기 gen = resilient_generator() print(next(gen)) # 출력: 0 print(next(gen)) # 출력: 1 print(gen.throw(ValueError)) # 출력: "에러 발생!"

여기에 대해 작동 방식이 있습니다:

  • 일반적으로 발생기는 .throw()이 호출될 때까지 실행됩니다.

  • 예외는 발생기 내에서 발생하며 try-except 블록을 사용하여 처리할 수 있습니다.

close()

.close() 메서드는 GeneratorExit 예외를 발생시켜 생성기를 중지시킵니다. 이는 리소스를 정리하거나 무한 생성기를 중지하는 데 유용합니다.  

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # 생성기 사용하기 counter = infinite_counter() print(next(counter)) # 출력: 0 print(next(counter)) # 출력: 1 counter.close() # 출력: "생성기가 닫혔습니다!"

그리고 이것이 작동하는 방식입니다: 

  • 생성기는 .close()가 호출될 때까지 실행됩니다.  

  • GeneratorExit 예외가 발생하여 생성기가 종료되기 전에 정리하거나 메시지를 기록할 수 있도록 합니다.

데이터 과학에서의 실제 응용  

생성기가 유용하다는 것을 점점 더 이해하고 계시리라 생각합니다. 이 섹션에서는 사용 사례를 부각시켜 일상에서 어떻게 실제로 작동하는지 상상할 수 있도록 하겠습니다.

대용량 데이터셋 처리  

데이터 과학에서 가장 흔한 도전 중 하나는 메모리에 들어갈 수 없을 정도로 큰 데이터셋을 다루는 것입니다. 생성기는 이러한 데이터를 한 줄씩 처리할 수 있는 방법을 제공합니다.

10 GB의 CSV 파일에 판매 데이터가 포함되어 있고 특정 지역에 대한 레코드를 필터링해야 한다고 가정해 보겠습니다. 이를 달성하기 위해 생성기 파이프라인을 사용하는 방법은 다음과 같습니다:  

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # 생성기 파이프라인 file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # 필터링된 데이터 처리 for record in filtered_data: print(record)

다음과 같은 일이 발생합니다: 

  1. read_large_csv는 파일을 한 줄씩 읽어 각 행을 사전으로 반환합니다.  

  2. filter_by_region는 지정된 지역에 따라 행을 필터링합니다.  

  3. 이 파이프라인은 데이터를 점진적으로 처리하여 메모리 과부하를 피합니다.

이 접근 방식은 데이터를 분석하기 전에 정리하고 변환해야 하는 추출, 변환 및 로드 워크플로우에 이점을 제공합니다. 당사의 파이썬에서 ETL 및 ELT 코스에서 이러한 유형의 작업을 볼 수 있습니다.

스트리밍 및 파이프라인

데이터는 때로 연속적인 스트림으로 도착합니다. 센서 데이터, 실시간 피드 또는 소셜 미디어를 생각해보세요.

당신이 매초 온도 데이터를 생성하는 IoT 장치와 작업하고 있다고 가정해 봅시다. 10개의 측정값에 대한 슬라이딩 윈도우를 사용하여 평균 온도를 계산하고 싶습니다:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # 센서 데이터 시뮬레이션 def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # 제너레이터 파이프라인 sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # 매초 평균 출력 for avg in averages: print(f"Average temperature: {avg:.2f}")

여기 설명이 있습니다:

  1. sensor_data_stream은 무한한 센서 데이터 스트림을 시뮬레이션합니다.

  2. sliding_window_average은 마지막 10개의 측정치를 유지하고 그들의 평균을 생성합니다.

  3. 이 파이프라인은 실시간으로 데이터를 처리하여 모니터링 및 분석에 이상적입니다.

추가적인 사용 사례

데이터 크기가 예측 불가능하거나 계속해서 증가하는 경우와 같이 상황에서 생성자가 사용됩니다.

웹 스크래핑

웹사이트를 스크래핑할 때, 처리해야 할 페이지나 항목의 수를 모르는 경우가 많습니다. 생성자는 이러한 예측 불가능성을 우아하게 처리할 수 있게 해줍니다:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # 페이지 가져오기 및 파싱 시뮬레이션 print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # 다음 페이지를 가져오는 가상의 함수 # 사용법 scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

시뮬레이션 작업

몬테카를로 방법(Monte Carlo methods)이나 게임 개발과 같은 시뮬레이션에서 생성자는 무한하거나 동적인 시퀀스를 나타낼 수 있습니다:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # 사용법 simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

메모리 및 속도 벤치마크

작동 방식 덕분에 생성자는 메모리 효율성이 중요한 시나리오에서 뛰어난 성능을 발휘하지만 (놀라울 수 있겠지만) 항상 가장 빠른 옵션은 아닐 수 있습니다. 생성자와 리스트를 비교하여 그 상충점을 이해해 봅시다.

이전에 우리는 생성기가 메모리 측면에서 목록보다 우수하다는 것을 보였습니다. 이는 1000만 개의 숫자 시퀀스를 생성할 때 메모리 사용량을 비교한 부분이었습니다. 이제 다른 것을 해보겠습니다, 속도 비교:

import time # 리스트 내포 start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # 생성기 표현식 start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

생성기는 메모리를 절약하지만, 이 경우에는 실제로 목록보다 느립니다. 이 작은 데이터 집합의 경우, 실행을 일시 중단하고 다시 시작하는 오버헤드가 있기 때문입니다.

작은 데이터 집합의 경우 성능 차이는 미미하지만, 대규모 데이터 집합의 경우 생성기의 메모리 절약이 가벼운 속도 패널티를 상쇄시키는 경우가 많습니다.

발생하는 문제

마지막으로, 일반적인 실수나 문제점을 살펴보겠습니다:

생성기는 소모될 수 있습니다.

제너레이터가 소진되면 다시 사용할 수 없습니다. 다시 반복하고 싶다면 재생성해야 합니다.

gen = (x for x in range(5)) print(list(gen)) # 출력: [0, 1, 2, 3, 4] print(list(gen)) # 출력: [] (제너레이터가 소진됨)

지연 평가(Lazy evaluation)는 복잡할 수 있습니다

제너레이터가 필요할 때 값을 생성하기 때문에, 오류나 부작용은 제너레이터를 반복할 때까지 나타나지 않을 수 있습니다.

제너레이터를 과도하게 사용할 수 있습니다

작은 데이터셋이나 간단한 작업의 경우, 제너레이터를 사용하는 오버헤드가 메모리 절약의 가치가 없을 수 있습니다. 여러 번 반복하기 위해 데이터를 구체화하는 이 예를 고려해 보세요.

# 제너레이터 표현식 gen = (x**2 for x in range(10)) # 리스트로 구체화하기 squares = list(gen) # 리스트 재사용하기 print(sum(squares)) # 출력: 285 print(max(squares)) # 출력: 81

제너레이터를 사용할 때 선택하기

요약하자면, 제너레이터를 사용해야 하는 경우에 대한 매우 일반적인 규칙을 제공하겠습니다. 다음과 같은 경우에 사용하세요:

  • 대규모 데이터셋: 메모리에 맞지 않는 대규모 데이터셋을 다룰 때 제너레이터를 사용하세요.
  • 무한 시퀀스: 라이브 데이터 스트림이나 시뮬레이션과 같은 무한 시퀀스를 표현할 때 제너레이터를 사용하세요.
  • 파이프라인: 데이터를 점진적으로 변환하고 필터링하는 모듈식 데이터 처리 파이프라인을 구축할 때 제너레이터를 사용하세요.

데이터를 구체화할 때(목록으로 변환)

  • 소규모 데이터 세트: 메모리가 문제가 되지 않고 모든 요소에 빠르게 액세스해야 하는 경우 제너레이터를 사용하지 마십시오. 대신 목록을 사용하십시오.
  • 여러 번 반복: 동일한 데이터를 여러 번 반복해야 하는 경우 제너레이터를 사용하지 마십시오. 대신 목록으로 구체화하여 제너레이터를 다시 만들지 않도록 합니다.

결론 및 주요 포인트

이 기사에서는 제너레이터가 대규모 데이터셋을 처리하거나 실시간 데이터 파이프라인을 구축하는 등 데이터 과학의 실제 문제에 도움이 되는 방법을 살펴보았습니다. 계속 연습해보세요. 제너레이터를 마스터하는 가장 좋은 방법은 여러분의 작업에서 사용하는 것입니다. 먼저, 리스트 컴프리헨션을 제너레이터 표현식으로 대체하거나 루프를 제너레이터 함수로 리팩토링해보세요. 

기본을 마스터하면, 제너레이터 개념을 기반으로 하는 새로운 고급 주제를 탐구할 수 있습니다:  

  • 코루틴: .send().throw()를 사용하여 데이터를 받아들이고 처리할 수 있는 제너레이터를 만들어, 양방향 통신을 가능하게 합니다.  

  • 비동기 프로그래밍: 생성기를 Python의 asyncio 라이브러리와 결합하여 효율적이고 비차단 애플리케이션을 구축하십시오.  

  • 동시성: 생성기가 협력적 멀티태스킹 및 경량 동시성을 구현하는 방법을 배우십시오.  

지속적인 학습을 유지하고 전문가가 되세요. 오늘 저희의 파이썬 개발자 커리어 트랙이나 파이썬 프로그래밍 스킬 트랙을 선택하세요. 시작하려면 아래 링크를 클릭하세요.

Source:
https://www.datacamp.com/tutorial/python-generators