yield

개념

  • 함수를 대상으로 하는 키워드
  • 일반적인 함수는 한 번에 결과를 반환(return) 하고 끝내는 데 반해
  • yield 키워드가 적용된 함수는 제너레이터(generator) 로 변환되어, 호출될 때마다 값을 하나씩 생성해내게 된다.

기본 사용법

1
2
3
4
5
6
7
8
9
10
11
def sample_func():
    yield "Hello"
    yield "world"
    yield "it's"
    yield "yield"
    
gen = sample_func()

for it in gen:
    print(it)
    print("-----")
1
2
3
4
5
6
7
8
Hello
-----
world
-----
it's
-----
yield
-----

핵심 개념

상태 유지

  • 일반적인 함수는 return 키워드를 만나면 값을 반환하고 함수의 모든 상태(지역 변수 등)가 소멸된다.
  • 반면 yield 를 사용하는 함수는 값을 반환한 뒤, 함수의 실행을 잠시 일시 정지하고
  • 이때 함수 내부 상태(지역변수, 실행위치)를 그대로 기억한다.

지연 평가 Lazy Evaluation

  • 모든 결과값을 한꺼번에 메모리에 올리지 않고, 필요할 때마다 하나씩 생성한다.
  • 이러한 지연 평가는 제너레이터의 특징이다.

반환 타입 : 제너레이터 객체

  • yield가 포함된 함수를 호출하면 함수 내부 코드가 즉시 실행되는 게 아니라, 제너레이터 객체를 반환한다.
  • 제너레이터는 yield를 사용해 만든 특별한 종류의 이터레이터이다.
  • 따라서 for 문에 넣거나 next() 함수의 인자로 사용할 수 있다.

일반 함수와의 작동 비교

구분 return(일반 함수) yield(제너레이터)
종료 여부 값을 반환하고 함수가 완전히 종료됨 값을 반환하고 그 자리에 일시 정지됨
메모리 모든 결과값을 한 번에 메모리에 담음 한 번에 하나의 값만 생성해 메모리에 담음
재호출시 처음부터 다시 시작 정지했던 시점부터 다시 실행

작동 방식 뜯어보기

반환값은 generator

yield가 적용된 함수의 반환값은 generator

1
2
3
4
5
6
def sample_func():
    yield "Hello"
    yield "world"
    
gen = sample_func()
print(gen)
1
<generator object sample_func at 0x000001BE7105DFE0>

이터레이터를 순회할 때만 실행된다

단순 호출 시에는 실행되지 않고, 이터레이터를 순회할 때만 실행된다.

  • 단순 호출 시
1
2
3
4
5
6
def sample_func():
    yield "Hello"
    yield "world"
    
gen = sample_func()
print(gen)
1
<generator object sample_func at 0x000001BE7105DFE0>


  • 이터레이터를 순회할 때 값을 반환함
1
2
3
4
5
6
7
8
def sample_func():
    yield "Hello"
    yield "world"
    
gen = sample_func()

for it in gen:
    print(it)
1
2
Hello
world


  • 또는 next
1
2
3
4
5
6
7
8
def sample_func():
    yield "Hello"
    yield "world"
    
gen = sample_func()

print(next(gen))
print(next(gen))
1
2
Hello
world

yield와 return의 공존

그렇다면 yield와 return은 공존할 수 있을까? 결론적으로는 하나의 함수 안에 return과 yield를 모두 사용할 수 있다. 둘의 역할은 다르기 때문에 둘을 배타적인 개념으로 보면 옳지 않다.

  • yield가 하나라도 있으면 그 함수는 무조건 제너레이터가 된다.
  • 제너레이터 안의 return은, 제너레이터가 종료된다는 종료신호를 의미한다.
1
2
3
4
5
6
7
8
9
10
def mixed_generator():
    yield 1
    yield 2
    return "끝났습니다"  # 제너레이터 종료 신호
    yield 3              # 이 코드는 절대 실행되지 않음

gen = mixed_generator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # StopIteration: 끝났습니다 (에러 발생하며 종료)
1
2
3
4
5
6
7
8
9
10
1
2
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[12], line 10
      8 print(next(gen)) # 1
      9 print(next(gen)) # 2
---> 10 print(next(gen))

StopIteration: 끝났습니다

뜻과 어원

뜻과 어원

  • yield는 양보하다, 제품 등을 산출하다, 이익을 낳다, 굴복하다 등의 뜻을 가진다.
  • 이는 중세 영어 yelden 으로부터 비롯되었다. : 비자발적으로 포기하다, 지배나 지시에 복종하다.
  • 더 거슬러 올라가면 고대 영어 gieldan/geldan : 지불하다, 보상하다, 숭배하다, 희생하다.

왜 yield 라는 키워드명이 선택되었나?

  • 프로그래밍을 하면서 가장 묘한 의미를 가진 키워드로 생각된다.
  • 대체로 “양보하다”라는 의미로 사용되었다고 생각한다.
  • 함수가 실행되다가 yield를 만나면, CPU 실행 권한(제어권)을 함수 외부의 호출자에게 양보한다.

내가 여기까지 계산했고, 일단 이 값을 네게 줄게. 네가 할 일(루프 등)을 다 하고 나서 다시 나를 불러줘

yield를 사용하는 이유(장단점)

장점

  • 메모리 효율성 : 예로, 100만개 정수를 리스트로 만들면 약 8MB -> yield 적용시 192 바이트 가량
  • 응답성 : 데이터가 전부 준비(로딩)될 때까지 기다릴 필요 없이, 첫 번째 데이터가 생성되는 즉시 처리 시작 가능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 메모리 비교  
import sys

# 1. 리스트 방식 (모든 데이터를 메모리에 즉시 할당)
list_data = [i for i in range(1000000)]

# 2. 제너레이터 방식 (데이터 생성 규칙만 저장)
generator_data = (i for i in range(1000000))

# 결과 출력
print(f"리스트의 메모리 크기: {sys.getsizeof(list_data)} 바이트")
print(f"제너레이터의 메모리 크기: {sys.getsizeof(generator_data)} 바이트")

# 리스트 크기를 MB 단위로 환산하면 약 8MB 정도 됩니다.
# 반면 제너레이터는 데이터 양에 상관없이 항상 일정한 크기를 유지합니다.
1
2
리스트의 메모리 크기: 8448728 바이트
제너레이터의 메모리 크기: 192 바이트

제너레이터 표현식 : 소괄호로 반복객체를 감싸면 제너레이터 객체가 됨

단점

  • 제너레이터 객체 유지 오버헤드

제너레이터 객체는 단순히 다음 값 외로 제너레이터 객체 자체의 속성정보, 로컬 변수의 스택, 포인터 등을 담기 때문에 소량의 메모리가 필요하다. 따라서 소수의 요소가 있는 경우, 제너레이터가 메모리를 더 사용할 수도 있다. 물론 그 차이는 미미하다.

1
2
3
4
5
6
7
8
9
10
11
import sys

# 1. 소수의 리스트
few_list = [1, 2, 3]

# 2. 제너레이터 방식
generator_data = (i for i in range(1))

# 결과 출력
print(f"정수형 한개의 메모리 크기: {sys.getsizeof(few_list)} 바이트")
print(f"제너레이터의 메모리 크기: {sys.getsizeof(generator_data)} 바이트")
1
2
정수형 한개의 메모리 크기: 88 바이트
제너레이터의 메모리 크기: 192 바이트


  • 함수 호출 오버헤드 - 연산 속도 저하

이터레이터의 연산 관점에서, 리스트가 제너레이터보다 빠르다. 그 이유는 리스트는 이미 메모리(RAM)에 연속적으로 배치된 상태이며, CPU는 이들을 일련적으로 처리만 하면 된다. 반면, 제너레이터는 next()를 호출할 때마다 함수 상태 복구, 코드 실행, 값 계산, 상태 저장의 작업이 동반되기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import sys
import time

# 이터레이터 내의 모든 값을 더하는 함수
def sum_func(iter):
    start = time.time()
    result = 0
    for it in iter:
        result += it
    print(f"결과값 : {result}")
    print(f"소요시간 : {time.time() - start}\n")

# 반복 횟수
num = 50000000

# 1. 리스트 방식 (모든 데이터를 메모리에 즉시 할당)
list_data = [i for i in range(num)]
sum_func(list_data)

# 2. 제너레이터 방식 (데이터 생성 규칙만 저장)
generator_data = (i for i in range(num))
sum_func(generator_data)
1
2
3
4
5
결과값 : 1249999975000000
소요시간 : 1.740260124206543

결과값 : 1249999975000000
소요시간 : 3.548307418823242

전체 연산을 내는 속도는 리스트가 빠르지만, 첫 값을 내는 작업은 제너레이터가 빠르다.


  • 일회성(exhaustion)

제너레이터의 특징 중 하나로, 한 번 순회하면 다시 사용할 수 없다는 한계가 있다. 리스트는 바로 재사용이 가능한 반면, 제너레이터는 다시 쓰려면 객체를 새로 생성해야 한다는 한계가 있다.


  • 인덱싱 불가

마찬가지로 제너레이터의 특징 중 하나로, gen[0] 처럼 특정 위치의 값을 바로 가져올 수 없고, 반드시 순차적으로 접근해야 한다.

yield를 사용하는 경우

  • 대용량 파일이나 데이터의 처리 : 메모리의 효율성 확보 가능
  • 끝을 알 수 없는 시퀀스를 처리할 때 : 챗봇의 stream 형식 출력, 피보나치 수열, 소수값 찾기 등 정확히 몇개의 요소를 가진 이터레이터인지 모를 때
  • 네트워크 스트리밍 및 API 데이터 : 모든 데이터를 다 받고 처리하는 것보다, 먼저 도착한 데이터부터 처리해 보여주면 사용자 경험 향상

Reference

https://en.dict.naver.com/#/entry/enko/0d1cc99a97c14dc981ba2af2b1a58e73
https://www.etymonline.com/search?q=yield
https://www.w3schools.com/python/ref_keyword_yield.asp

Comments