파이썬 – 제너레이터 (Generator)

14 분 소요

안녕하세요. 스쿨오브웹의 이상희입니다.

지난 파이썬 강좌인 파이썬 – 데코레이터 (Decorator)에 이어서 이번 강좌에서는 파이썬의 제너레이터에 대해서 알아보고자 합니다.

프로그램을 조금이라도 접해본 사람이라면 파이썬이 배우기 쉬운 프로그래밍 언어라는 정도는 알 겁니다. 하지만 쉬운 파이썬에도 대부분의 파이썬 초보자들이 이해하기 어렵다고 공통적으로 말하는 몇가지가 있습니다. 그 중의 하나가 제너레이터와 yield에 대한 개념입니다.

제너레이터의 사전적 의미는 “발전기” 또는 “뭔가를 만드는 사람이나 물건”을 뜻합니다. 위키피디아에는 컴퓨터 공학적 의미로써 다음과 같은 설명이 있습니다.

제너레이터는 반복자(iterator)와 같은 루프의 작용을 컨트롤하기 위해 쓰여지는 특별한 함수 또는 루틴이다. 사실 모든 제너레이터는 반복자이다. 제너레이터는 배열이나 리스트를 리턴하는 함수와 비슷하며, 호출을 할 수 있는 파라메터를 가지고 있고, 연속적인 값들을 만들어 낸다. 하지만 한번에 모든 값을 포함한 배열을 만들어서 리턴하는 대신에 yield 구문을 이용해 한 번 호출될 때마다 하나의 값만을 리턴하고, 이런 이유로 일반 반복자에 비해 아주 작은 메모리를 필요로 한다. 간단히 얘기하면 제너레이터는 반복자와 같은 역할을 하는 함수이다.

일반함수가 호출되면 코드의 첫 번째행 부터 시작하여 리턴(return) 구문이나, 예외(exception) 또는 (리턴을 하지않는 함수이면) 마지막 구문을 만날때까지 실행된 후, 호출자(caller)에게 모든 컨트롤을 리턴합니다. 그리고 함수가 가지고 있던 모든 내부 함수나 모든 로컬 변수는 메모리상에서 사라집니다. 같은 함수가 다시 호출되면 모든 것은 처음부터 다시 새롭게 시작됩니다.

그런데 어느날 부터 프로그래머들은 한번에 일을 다하고 영원히 사라져버리는 함수가 아닌 하나의 일을 마치면 자기가 했던 일을 기억하면서 대기하고 있다가 다시 호출되면 전의 일을 계속 이어서 하는 똑똑한 함수를 필요로 하기 시작했습니다. 그래서 만들어진 것이 제너레이터입니다. 제너레이터를 사용하면 일반 함수보다 훨씬 좋은 퍼포먼스를 낼 수가 있고, 메모리 리소스도 절약할 수 있습니다. 서론이 너무 길었네요. 예제를 보면서 제너레이터가 어떤 것인지 살펴볼까요?

원하시는 디렉터리에 generator.py란 이름의 파일을 만들고 다음의 코드를 저장하여 주십시오.

generator.py
def square_numbers(nums):
result = []
for i in nums:
result.append(i * i)
return result
 
my_nums = square_numbers([1, 2, 3, 4, 5])
 
print(my_nums)

아주 간단한 함수를 정의하고 호출하는 코드입니다. 정의된 함수는 인자로 받은 리스트를 for 루프로 돌면서 i * i의 결과값으로 새로운 리스트를 만들고 리턴하는 함수입니다.

터미널이나 커맨드창을 여신 후, generator.py 파일이 저장된 위치로 이동하여 주십시오. 이동을 하셨으면 다음의 명령어로 프로그램을 실행하여 주십시오.

$ python generator.py
[1, 4, 9, 16, 25]

새로운 리스트가 결과값으로 리턴되었습니다.

이 코드를 제너레이터로 만들어 보겠습니다.

def square_numbers(nums):
for i in nums:
yield i * i
 
my_nums = square_numbers([1, 2, 3, 4, 5])  #1
 
print(my_nums)
$ python generator.py
<generator object square_numbers at 0x0000016B17E19EB0>

제너레이터라는 오브젝트가 리턴 됐습니다. 제너레이터는 자신이 리턴할 모든 값을 메모리에 저장하지 않기 때문에 조금 전 일반 함수의 결과와 같이 한번에 리스트로 보이지 않는 것입니다. 제너레이터는 한 번 호출될때마다 하나의 값만을 전달(yield)합니다. 즉, 위의 #1까지는 아직 아무런 계산을 하지 않고 누군가가 다음 값에 대해서 물어보기를 기다리고 있는 상태입니다. 확인해 볼까요?

generator.py
def square_numbers(nums):
for i in nums:
yield i * i
 
my_nums = square_numbers([1, 2, 3, 4, 5])
 
print(next(my_nums))
$ python generator.py
1

next()함수를 이용하여 다음 값이 무엇인지 물어봤습니다. 다음 값은 1이라고 하네요. 이번에는 몇번 더 물어보겠습니다.

generator.py
def square_numbers(nums):
for i in nums:
yield i * i
 
my_nums = square_numbers([1, 2, 3, 4, 5])
 
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
$ python generator.py
1
4
9
16
25

위의 첫 번째 예제의 일반 함수가 리턴한 리스트의 값이 모두 출력되었습니다. 그런데 여기서 한번 더 next() 함수를 호출하면 어떻게 될까요? 한번 해보죠.

generator.py
def square_numbers(nums):
for i in nums:
yield i * i
 
my_nums = square_numbers([1, 2, 3, 4, 5])
 
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
$ python generator.py
1
4
9
16
25
Traceback (most recent call last):
File "generator.py", line 12, in <module>
print(next(my_nums))
StopIteration

StopIteration 예외가 발생하였습니다. 더 이상 전달할 값이 없다는 뜻입니다.

제너레이터는 일반적으로 for 루프를 통해서 호출하여 사용하는데 그 예를 한번 보시죠.

generator.py
def square_numbers(nums):
for i in nums:
yield i * i
 
my_nums = square_numbers([1, 2, 3, 4, 5])
 
for num in my_nums:
print(num)
$ python generator.py
1
4
9
16
25

이번에는 모든 값이 출력되고 StopIteration 예외는 발생하지 않았습니다. for 루프는 자신이 어디서 멈춰야 하는지를 알고 있기 때문입니다.

여기서 한 가지 제너레이터가 일반함수 보다 좋은점을 지적할 수가 있습니다. 그건 코드가 더 단순하다는 겁니다. 파이썬의 철학이 담긴 “The Zen of Python”의 3번째 항목에 이런 글이 적혀있습니다. “복잡한것 보다는 단순한 것이 좋다.” 그렇습니다. 이왕이면 복잡한 코드 보다는 단순한 코드가 더 좋은겁니다.

그런데 "list comprehension"을 사용하면 위의 코드보다 더 간단한 코드를 만들 수가 있습니다. 예제를 보겠습니다.

generator.py
my_nums = [x*x for x in [1, 2, 3, 4, 5]]
 
print(my_nums)
 
for num in my_nums:
print(num)
$ python generator.py
[1, 4, 9, 16, 25]
1
4
9
16
25

첫 번째 예제의 일반 함수와 같은 리스트를 리턴하는군요. 같은 구문을 조그만 바꾸면 제너레이터를 만들 수가 있습니다.

generator.py
my_nums = (x*x for x in [1, 2, 3, 4, 5])  #1
 
print(my_nums)
 
for num in my_nums:
print(num)
$ python generator.py
<generator object <genexpr> at 0x1007c8f50>
1
4
9
16
25

#1의 []()로 바꾸니 제너레이터가 생성됐습니다. 간단하네요. ㅎ

그런데 for 루프를 사용하지 않고 한번에 제너레이터 데이터를 보고 싶으면 어떻게 할까요? 그럴때는 제너레이터를 간단히 리스트로 변환하면 됩니다.

generator.py
my_nums = (x*x for x in [1, 2, 3, 4, 5])  # 제너레이터 생성
 
print(my_nums)
print(list(my_nums))  # 제너레이터를 리스트로 변형
$ python generator.py
<generator object <genexpr> at 0x0000026FD7A99EB0>
[1, 4, 9, 16, 25]

간단히 리스트로 변형되어 출력 되었습니다. 여기서 한 가지 주의하셔야 하는 점은 한 번 리스트로 변형하면 제너레이터가 가지고 있던 장점을 모두 잃게 된다는 점입니다. 이 장점 중 가장 중요한 것은 퍼포먼스 입니다. 위에서도 설명하였듯이 제너레이터는 모든 결과값을 메모리에 저장하지 않기 때문에 더 좋은 퍼포먼스를 냅니다. 예제를 보면서 확인을 해보죠.

generator.py
from __future__ import division
import os
import psutil
import random
import time
 
names = ['최용호', '지길정', '진영욱', '김세훈', '오세훈', '김민우']
majors = ['컴퓨터 공학', '국문학', '영문학', '수학', '정치']
 
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
 
 
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
 
 
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
 
t1 = time.time()
people = people_list(1000000)  # 1 people_list를 호출
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
 
print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))
$ python generator.py
시작 메모리 사용량: 13.76171875 MB
종료 메모리 사용량: 284.30078125 MB
 소요된 시간: 1.215000

먼저 #1에서 people_list(1000000)를 호출하여 백만명의 학생의 정보가 들어가는 리스트를 만들어 봤습니다. 메모리 사용량이 13 MB에서 284 MB으로 늘었으며, 시간은 1.2초가 걸렸습니다. #1의 people_list(1000000)people_generator(1000000)로 변경하여 제너레이터의 퍼포먼스를 테스트하여 보겠습니다.

generator.py
from __future__ import division
import os
import psutil
import random
import time
 
names = ['최용호', '지길정', '진영욱', '김세훈', '오세훈', '김민우']
majors = ['컴퓨터 공학', '국문학', '영문학', '수학', '정치']
 
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
 
 
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
 
 
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
 
t1 = time.time()
people = people_generator(1000000)  # 1 people_generator를 호출
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
 
print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))
$ python generator.py
시작 메모리 사용량: 13.75390625 MB
종료 메모리 사용량: 13.7578125 MB
 소요된 시간: 0.000000

메모리 사용량의 변화는 없었고 시간은 0.1초도 걸리지 않았습니다. 제너레이터를 사용하면 메모리 사용량이 적으면서 제너레이터 오브젝트를 만드는 것이 리스트 오브젝트를 만드는 것 보다 빠르다는 것은 확인이 되었습니다.

하지만 이 생성된 오브젝트를 사용해서 데이터 처리를 할 때는 어떨까요? 먼저 생성된 리스트를 사용하여 for loop 처리를 해보겠습니다.

generator.py
from __future__ import division
import os
import psutil
import random
import time
 
names = ['최용호', '지길정', '진영욱', '김세훈', '오세훈', '김민우']
majors = ['컴퓨터 공학', '국문학', '영문학', '수학', '정치']
 
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
 
 
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
 
 
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
 
t1 = time.time()
 
people = people_list(1000000)
 
# 리스트를 사용하여 for loop 실행
for p in people:
print(p)
 
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
 
print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))
$ python generator.py
{'id': 999998, 'name': '진영욱', 'major': '영문학'}
{'id': 999999, 'name': '진영욱', 'major': '컴퓨터 공학'}
{'id': 999999, 'name': '진영욱', 'major': '컴퓨터 공학'}
시작 메모리 사용량: 13.7578125 MB
종료 메모리 사용량: 285.84765625 MB
 소요된 시간: 97.907999

메모리의 사용량에는 변화가 없고 시간은 97.9 초 소요됐습니다.

이번에는 생성된 제너레이터를 사용하여 for loop 처리를 해보겠습니다.

generator.py
from __future__ import division
import os
import psutil
import random
import time
 
names = ['최용호', '지길정', '진영욱', '김세훈', '오세훈', '김민우']
majors = ['컴퓨터 공학', '국문학', '영문학', '수학', '정치']
 
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
 
 
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
 
 
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
 
t1 = time.time()
 
people = people_generator(1000000)  # 1 people_generator를 호출
 
# 제너레이터를 사용하여 for loop 실행
for p in people:
print(p)
 
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
 
print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))
$ python generator.py
{'id': 999997, 'name': '진영욱', 'major': '컴퓨터 공학'}
{'id': 999998, 'name': '오세훈', 'major': '컴퓨터 공학'}
{'id': 999999, 'name': '지길정', 'major': '영문학'}
시작 메모리 사용량: 13.76171875 MB
종료 메모리 사용량: 13.75390625 MB
 소요된 시간: 102.774121

역시 메모리의 소비는 없었지만 소요시간이 102.7 초로 리스트 보다 약 5초 가량 느린 것을 확인했습니다.

이로써 알 수 있는 사실은 실행 시간 보다 메모리 소비를 줄여야 하는 경우라면 제너레이터를 사용해야하고, 리소스 보다 실행 시간을 줄여야하는 경우라면 리스트를 사용해야한다고 볼 수 있겠습니다.

하지만 위의 데이터 보다 훨씬 더 많은 양의 데이터를 병행으로 동시에 처리해야 한다고 했을 때 약간의 시간을 줄이기 보다는 한정적인 리소스를 효율적으로 사용해야 하는 경우가 대부분이라고 생각합니다.

다음 강좌에서는 OOP(Object Oriented Programming)에 대해서 알아보겠습니다.

수고하셨습니다~! 🥂