코루틴 (Coroutine)

코루틴이란, 함수의 실행을 잠시 일시 정지시켰다가 재개할 수 있는 기능이다. Python을 쓰던 사람이라면, generator의 개념에 익숙할 텐데, 제너레이터가 바로 코루틴의 일종이다. Godot 3에서는 yield 키워드를 통해 코루틴을 사용할 수 있다. 하지만, 그 행동이 비직관적인 데다가 매뉴얼도 제대로 작성되어있지 않아 이해하는 데 어려움이 많다. 이 글에서는 어떻게 yield 키워드를 잘 사용할 수 있는지 이야기해보려고 한다.

yield()

GDScript의 yield() 함수는 기본적으로 다음과 같이 정의된다.

yield(Object object = null, String signal = "")

따라서, yield()는 고도에서 두 가지 용법으로 사용된다. 첫째는 아무런 인자를 주지 않았을 때이다. 다음 코드를 보자.

func _ready():
    my_func_one()

func my_func_two():
    print("Three")
    yield()
    print("Four")

func my_func_one():
    print("One")
    var y = my_func_two()
    print("Two")
    y.resume()

var y = my_func_two()라인에서 my_func_two()가 호출되는 것까지는 그냥 return과 동일하다. 하지만 my_func_two() 블럭 안에서 yield()를 만났을 때, my_func_two()의 상태는 GDScriptFunctionState로 저장되어 반환된다. (혹은, 주도권이 caller에게 넘어간다.) 이 상태는 resume() 함수를 통해 재개시킬 수 있다. 따라서, 위 코드의 출력은

One
Three
Two
Four

가 된다.

yield(object, signal)

yieldsignal을 결합시킬 때 이 키워드의 진정한 힘이 나타난다. (signal에 대해서는 언젠가 다룰 기회가 있을 것이다.) 이 경우, yieldobject에 주도권을 넘겨준 뒤, signal 시그널이 감지될 때까지 함수의 실행을 일시 정지시킨다. 이를 활용한 대표적인 예제가 타이머이다.

func _ready():
    yield(get_tree().create_timer(1.0), "timeout")
    print("One")

위 코드에서 get_tree().create_timer(1.0)은 최상위 트리에다가 1.0초짜리 타이머를 추가하는 함수(=오브젝트)이다. 이 함수는 지정된 시간이 끝나면 timeout 시그널을 방출한다. 따라서, 위 코드를 실행하면 1초 뒤 One이 출력된다. 만약 yield가 없었더라면, 1초의 타이머는 _ready() 함수의 실행과 병렬적으로 일어나므로, 즉시 One이 출력된 후 타이머가 종료되었을 것이다.

또한, 모든 yield 키워드는 함수의 상태, GDScriptFunctionState가 파괴되었을 때 자동으로 completed 시그널을 방출한다. 공식 도큐먼테이션에 있는 코드를 살펴보자면

func _ready():
    yield(countdown(), "completed")
    print('Ready')

func countdown():
    print(3)
    yield(get_tree().create_timer(1.0), "timeout")
    print(2)
    yield(get_tree().create_timer(1.0), "timeout")
    print(1)
    yield(get_tree().create_timer(1.0), "timeout")

_ready()의 첫 줄은 countdown()을 코루틴으로 호출하고 있다. countdown() 함수는 우리가 배운 대로 3, 2, 1을 각각 1초의 간격을 두고 출력한다. countdown()의 마지막 yield문이 종료되면, countdown()은 값을 리턴하고 파괴되며, 이 때 completed 시그널을 방출한다. 그 다음에야 Ready가 출력된다.

여기서 헷갈릴 만한 지점은, yield의 기본 행동은 여기서도 유지된다는 점이다. 즉, countdown()의 각 yield(object, singal) 함수는 object에 주도권을 넘겨줌과 동시에 _ready()GDScriptFunctionState를 반환한다. 만약 countdown()을 코루틴을 사용하지 않고 그냥 호출한다고 하자.

func _ready():
    countdown()
    print('Ready')

func countdown():
    print(3)
    yield(get_tree().create_timer(1.0), "timeout")
    print(2)
    yield(get_tree().create_timer(1.0), "timeout")
    print(1)
    yield(get_tree().create_timer(1.0), "timeout")

_ready()는 먼저 countdown()을 호출하고, countdown()은 3을 출력한 뒤, 첫 번째 yield문을 만난다. 이 때 countdown()은 자신의 상태를 _ready()에 반환함과 동시에 get_tree().create_timer(1.0)에게 주도권을 넘겨준다. 따라서, _ready()countdown()은 이 시점 이후부터는 동시에 병렬적으로 실행되게 된다. 따라서 출력은 다음과 같다.

3
Ready
2
1

Godot 4

올해 안에 나올 Godot 4에서는 yield 키워드가 없어지고 await 키워드가 추가된다고 한다. 이는 C# 등의 다른 언어와 통일성을 갖는 동시에 많은 혼란을 줄여줄 것으로 예상되는 환영할 만한 변화 같다.