개발/Developing

Python에서의 yield와 실행 순서

lazykuna 2023. 3. 28. 00:29

요즘 정신이 없어 무언가 기록을 쓰고 남길 시간도 정신도 도통 없지만, 그래도 시간이 나고 생각이 날 때마다 남겨 보려고 노력을 하고 있다. 이 글도 그러한 일환 중의 하나고 …

거두절미하고, 오늘은 회사에서 꽤 흥미로운 주제로 이야기를 하게 되어 글을 써 보았다. 파이썬이 주력은 아니지만…

Python에서는 yield 라는 키워드가 있다. 아는 사람들은 다 알겠지만, 함수에서 yield 를 사용할 경우 해당 함수의 리턴 타입은 `Iterator[T] = Generator[T, None, None]` 꼴이 된다. 사용법은 상당히 간단한데, 그냥 for loop을 쭉 돌리면 알아서 해당 Iterator이 값을 생성하고 이를 받아오는 구조이다.

def f1(n):
    """Yield integers from [0,n)."""
    i = 0
    while i < n:
        yield i
        i += 1

def scenario0():
    for i in f1(2):
        print(i)

"""
Result:
0
1
"""

일반적인 list와 비교하여 값을 그때그때마다 “만들어서” 전달하기에, 아주 큰 데이터*(큰 리스트라던가…)*를 전달하고 처리하는 데 있어 상당히 효율적이다… 가 일반적으로 알려진 이야기이다.

그런데, 문득 이러한 yield 구문이 동작할 때 어떤 식으로 동작하는지에 대해서 생각해 본 적이 있는가? 잘 알려진 이야기 중 하나가 javascript의 async 가 동작하는 방식에 대해서이다. 간단하게 언급하자면, javascript의 경우에는 Microtask queue라는 것을 이용하여 async call을 적재해 두고, FIFO 형태로 실행하는 것이었다. 아쉽게도, python의 yield 는 그것과는 다르다. 즉,

  1. next(g) 가 호출되고 나서야
  2. 실행 컨텍스트가 g 로 넘어가게 되어, 비로소 generator이 값을 생성하기 시작한다.
  3. Generator이 yield 를 마주치면,
  4. 그때는 상위 콜스택으로 다시 실행 컨텍스트가 넘어가서 리턴값을 받아 남은 명령어를 마저 실행한다.

이런 구조로 작동한다.

이러한 동작 방식에 대한 이해를 도울 좋은 그림이 있어서 퍼왔다.

https://dojang.io/mod/page/view.php?id=2412

하지만 이걸로 모든 궁금증이 해결된 것은 아니다. 이렇게 실행 콘텍스트가 왔다갔다하지만, 우리는 필연적으로 모든 함수가 끝을 맺을 것이라고 기대하고 있다. 왜냐하면 우리가 아는 프로그램은 모두 그렇게 작동했으니까. 과연 yield 상대로도 정말 그럴까? 아래 코드로 몇 가지 실험을 더 해 봤다.

def f1(n):
    """Yield integers from [0,n)."""
    i = 0
    while i < n:
        print('YieldStarts')
        yield i
        print('YieldEnds')
        i += 1
    print('Complete')

def scenario1():
    y = f1(2)
    print(y)
    print('next1Before')
    print(next(y))
    print('next1After')
    print('next2Before')
    print(next(y))
    print('next2After')

def scenario2():
    y = f1(2)
    print(y)
    print('next1Before')
    print(next(y))
    print('next1After')
    print('next2Before')
    print(next(y))
    print('next2After')
    print('next3Before')
    print(next(y))
    print('next3After')

def scenario3():
    y = f1(2)
    print(y)
    print('next1Before')
    print(next(y))
    print('next1After')
    print('next2Before')
    print(next(y))
    print('next2After')
    y.close()

def scenario4():
    y = f1(2)
    print(y)
    print('next1Before')
    print(next(y))
    print('next1After')
    print('next2Before')
    print(next(y))
    print('next2After')
    try:
        next(y)
    except StopIteration:
        pass

결과는 꽤 흥미롭다.

  • scenario1 의 경우: […, YieldStarts, …., YieldEnds, …, YieldStarts, next2After] 하고 프로그램이 끝남. ⇒ 함수 y 가 끝까지 돌지 않는다!!
  • scenario2 의 경우: […, YieldStarts, …., YieldEnds, …, YieldStarts, …, YieldEnds] 로 y 가 끝까지 돌지만, StopIteration exception 발생. 이건 의도된 거니까 적절히 처리 해주어야 한다.
  • scenario3 의 경우: scenario1 과 같다.
  • scenario4 의 경우: […, YieldStarts, …., YieldEnds, …, YieldStarts, …, YieldEnds] 로 y 도 프로그램도 모두 끝까지 돈다. 이게 보통 우리가 기대하는 프로그램 실행 순서일 거다.

즉, yield 를 이용해서 generator을 쓸 경우 마지막 “deferred” 로직에 대해서 신경 쓸 필요가 있다. 당연히 돌 거라고 생각했다가는 큰일나기 딱 좋은 구조다. throw가 발생하는 구조라면 더 골치가 아파질 지도. 😇

사담으로, 꽤 재미있는 점은 이러한 yield를 중첩해서 쓰면 무언가 chained 된 형태로 prepare ~ body ~ cleanup 디자인을 할 수 있더라. 이런 건 보통 인터페이스 써서 하지 않나 싶지만 하나의 기믹 정도로 알아두기는 괜찮은 듯.

사담2) Notion에서 티스토리로 글 옮겨오기 겁나 구리다. 요즘은 Github.io 같은데로 옮겨야 할까 싶다 🤔