효과 시스템과 코루틴의 관계를 사용자 관점에서 비교한다. 효과 처리기의 “정적 타입·동적 스코프” 성질을 출발점으로, Rust의 async/await와 Result, 체크드 예외, 모나드 등과 대비하며 각 접근의 장단과 설계 상의 트레이드오프를 논의하고, 코루틴이 정적 타입·렉시컬 스코프·비레이어드라는 점에서 효과 모델링의 달콤한 지점임을 주장한다.
지난 몇 달 동안 Russell Johnston이 효과 시스템과 코루틴의 관계에 관해 내게 깨닫게 해 준 몇 가지를 곱씹고 있었다. 이 주제에 대한 그의 생각은 여기에서 더 읽을 수 있는데, 그가 내게 일깨워준 것은 효과 시스템(예: Koka에 있는 것)과 코루틴(예: Rust의 async 함수나 제너레이터)이 어떤 면에서는 서로 동형(isomorphic)이라는 점이다. 나는 둘의 차이를 곰곰이 생각하며 각자의 장단점을 파악하려고 해 왔다.
몇 주 전, Will Crichton이 Twitter에 올린 글이 이 대비를 내게 더 선명하게 만들어 주었다:
요즘 PL 전체 분야: 만약 동적 스코프였다면… 하지만 정적으로 타입이 붙었다면…………..? (effects, capabilities, contexts, metavariables…)
나는 그저 소박한 언어 설계자(무엇보다도 특히 PL의 이론가는 아니다)에 불과하므로, 내 관심사는 사용자 경험과 어포던스의 차이다. 하지만 이것은 예리한 통찰처럼 보이고, 효과 처리기의 이 속성 — 정적으로 타입이 붙지만 스코프는 동적이라는 점 — 은 사용자 관점에서 효과 처리기와 코루틴의 차이를 이해하기 위한 좋은 출발점 같다.
코루틴은 끝나기 전에 호출자에게 제어를 넘겨줄 수 있는 함수다. 호출자는 그 시점의 코루틴 상태에 대한 참조를 가지게 되어, 원하면 다시 그 코루틴을 재개(resume)할 수 있다. 코루틴이 yield(양보)하는 방식을 통해 많은 의미 있는 “효과”를 모델링할 수 있다. 예를 들어:
Pending 같은 값을 내보내(yield) “비동기적으로” IO를 수행할 수 있다.예컨대 Rust는 비동기성과 반복을 모두 코루틴으로 모델링한다. 반면 예외는 그렇게 모델링하지 않고, Result 타입으로 모델링한다. 엄밀히 말하면 Rust는 “스택리스(stackless)” 코루틴을 사용하지만, 이 구분은 이 글의 개념을 이해하는 데 아주 중요하지는 않다.
용어가 러스트 커뮤니티에서 혼탁해졌기 때문에 분명히 해야 한다: 이 글은 러스트 프로젝트가 개념화하는 “effects generics”와는 특별히 관련이 없다. 그것은 러스트가 효과를 다루는 방식과 관련된 또 다른 의미론적 기능 집합이며, 문헌에 나타나는 “효과 시스템(effect systems)”과는 다르다. 이 글에서는 Koka나 Effekt 같은 언어에서 제안된 효과 시스템에 초점을 맞춘다. 그리고 내 이해가 불완전하거나 틀릴 수도 있지만, 내가 이해하는 대로 그들은 이렇게 동작한다.
효과 시스템에서는, 타입에 더해 표현식(expression)에 효과(effect) 가 있다. 효과는 형식 체계에서 말하는 추가적인 “카인드(kind)”다(마치 Rust에서 라이프타임이 또 다른 “카인드”인 것처럼). 일반적으로 표현식은 자신이 포함하는 다른 표현식의 효과를 상속한다. 함수는 본문(body)의 효과를 상속한다.
예를 들어, Koka에는 “발산(diverging)” 효과가 있어 어떤 표현식이 발산할 수 있음을 의미한다(즉, 평가를 끝내지 못할 수 있다). 발산하는 표현식을 포함하는 표현식 역시 발산한다. 따라서 타입 시스템에서 반드시 종료가 보장되는 함수와 종료하지 않을 수도 있는 함수를 구분할 수 있다(물론 이것은 정지 문제의 결정 불가능성 때문에 불완전하다; 실제로는 발산하지 않지만 발산한다고 표시되는 함수도 생긴다).
그러나 이러한 언어들에는 효과 처리기(effect handler) 라는 개념도 있는데, 이는 어떤 효과를 가진 표현식을 받아 그 효과를 “처리(handle)”하여 해당 효과가 없는 표현식을 산출한다. 모든 효과를 처리할 수 있는 것은 아니다(내가 이해하기로는 발산 효과를 의미 있게 처리하는 것은 불가능하다). 하지만 일부는 가능하다. 효과를 처리한다는 의미론이 코루틴과 유사해지는 지점이다.
처리 가능한 효과가 발생하면, 효과를 가진 표현식은 가장 가까운 효과 처리기에게 제어를 넘기며, 처리기는 제어를 다시 넘겨줄 수도 있고 넘겨주지 않을 수도 있다. 이는 코루틴과 동일한 유형의 효과를 모두 모델링하는 데 사용될 수 있다. 예를 들어:
코루틴과 효과 처리기의 핵심 차이는, 코루틴은 호출자에게 제어를 양보하지만, 효과가 있는 표현식은 처리기에게 제어를 양보한다는 점이다. 이로부터 파생되는 어포던스의 차이가 효과 처리기보다 코루틴이 가지는 실질적으로 중요한 이점이다.
다음과 같은 가상의 언어를 생각해 보자. 이 언어에서는 모든 함수가 코루틴이며, IO와 예외를 모델링하기 위해 Pending 또는 예외를 내보낼 수 있고, “효과를 처리”하는 방식이 서로 다른 여러 종류의 호출 연산자가 있다:
Pending을 내보내는 코루틴에 사용할 수 있는 비동기 ().await 호출 연산자가 있다. 이 연산자는 Pending을 내보낸 다음 다시 호출한다(따라서 Pending을 내보내는 또 다른 코루틴 내부에서만 호출될 수 있다).()? 호출 연산자가 있다. 이 연산자는 바깥쪽으로 예외를 내보낸다.Pending과 예외를 모두 내보내는 코루틴에 사용할 수 있는 결합형 ().await? 호출 연산자가 있다. 두 효과를 모두 전달(forward)한다.이 언어에서 HTTP 요청을 하고 싶다고 해 보자(IO를 수행하고, 어떤 IO 오류를 나타내는 예외를 발생시킬 수도 있다):
fn get_blog() -> HttpResponse yields (Pending | Exception) {
http::get("https://without.boats").await?
}
이제 IO와 예외를 효과로 모델링하는 비슷한 언어와 비교해 보자. 이 언어에서는 특별한 호출 연산자가 필요 없다. IO나 예외 효과가 있는 함수는 IO나 예외 효과가 있는 다른 함수를 그냥 호출할 수 있다:
fn get_blog() -> HttpResponse effect (IO | Exception) {
http::get("https://without.boats")
}
이 두 기능의 차이는, http::get 호출이 그 효과를 전달하기 위해 await와 ? 구문으로 주석(표시)되어야 한다는 점이고, 반면 효과 처리기에서는 피호출자가 효과를 전달하는 것이 암묵적이라는 점이다.
한 걸음만 더 물러섰다가 곧 결론에 도달하겠다. 내가 제시한 것과는 다른, 특히 더 오래된 코루틴의 정의가 있다. Wikipedia를 자세히 읽어 보면 약간 다른 정의를 찾을 수 있으며, 내가 코루틴이라고 부른 것은 오히려 “제너레이터(generator)”나 “세미코루틴(semicoroutines)”으로 다뤄진다. 오래된 정의에서 코루틴은 제어를 어디로 넘길지 지정할 수 있다. 이는 프로그램 스택이 없는 실행 모델에 기반해 있다. 대신 각 코루틴은 전역 싱글턴이고, 그 코루틴에 제어를 넘긴다는 것은 마지막으로 멈춘 지점에서 계속한다는 뜻이다. 반면 세미코루틴에서는 코루틴을 호출하면 새로운 스택 프레임이 만들어지고, 그 스택 프레임에 대한 참조를 가진 코드만이 다시 재개할 수 있다.
프로그램 스택은 오늘날 프로그램 실행 모델에서 너무도 보편적이어서 우리는 그것을 필연적인 것으로 취급하지만, 다른 모든 것처럼 그것 역시 발명된 것이다. 스택은 재귀적 함수 정의를 지원하기 위해 고안되었지만, 로컬 추론을 가능하게 한다는 점에서 또 다른 장점이 있다. 함수 본문 안에서 동적으로 점프할 지점은 정확히 하나, 곧 반환하는 곳뿐이다. 호출자의 관점에서는 이것이 정적으로 알려진다. 함수는 호출된 그 지점으로 되돌아올 것이다.
이 규칙은 언어가 추가적인 동적 점프 지점을 허용하는 새로운 기능을 도입할 때에만 깨진다. 오래전부터 인기 있는 예가 예외다. 예외는 처리되는 지점까지 콜 스택을 되감는다(unwind). 예외에는 두 종류가 있다. 완전히 타입이 없는 언체크드(unchecked) 예외와, 발생할 수 있는 모든 함수에 주석(애노테이션)을 요구하는 체크드(checked) 예외다.
효과 처리기는 체크드 예외의 일반화다. 이들은 효과가 있는 함수를 주석 처리하도록 요구하지만, 그 효과가 발생할 수 있는 호출 지점 을 주석 처리하도록 요구하지는 않는다. 따라서 함수의 본문을 살펴볼 때 효과가 언제 발생하는지를 이해하려면 호출되는 모든 함수의 타입 시그니처를 검사해야 한다. 이것은 의미 있는 제어 흐름이므로, 각 함수 호출의 시그니처를 일일이 보지 않고도 오류가 발생할 수 있는 지점을 식별할 수 있으면 매우 유용하다. 이것이 Rust가 체크드 예외 대신 Result와 ? 연산자를 제공하는 이유다.
여기에는 세 점으로 이루어진 평가 축이 있다. 언어는 어떤 효과를 컴파일 타임에 전혀 검사하지 않는 방식으로 모델링할 수 있다. 곧 동적 타입이면서 동적 스코프인 경우다. 언체크드 예외(패닉 포함)가 그 예이고, 블로킹 IO도 마찬가지다. 또 언어는 타입 시스템에 효과를 모델링하되 스코프는 동적으로 할 수 있다. 체크드 예외와 효과 처리기가 그 예다. 그리고 언어는 효과를 정적 타입이면서 렉시컬(정적) 스코프인 방식으로 모델링할 수 있다. Rust가 Result와 async/await로 이러한 효과를 처리하는 방식이 바로 그렇다.
(여기서 다루지 않은 네 번째 지점이 있다는 것을 눈치챌 수도 있다: 동적 타입이면서 렉시컬 스코프인 경우다. 이 범주의 예로는 Python과 JavaScript 같은 동적 타입 언어의 async/await이 있다. 비동기 호출의 결과를 얻으려면 반드시 await로 주석을 달아야 하지만, 그렇게 하지 않더라도 컴파일 타임 오류는 아니다.)
│ 예외 │ IO
──────────────────────────────────────────┼───────────────────────┼───────────────
│ │
동적 타입 & 동적 스코프 │ 패닉(unchecked) │ 블로킹 IO
│ │
정적 타입 & 동적 스코프 │ 체크드 예외 │ IO 효과
│ │
정적 타입 & 렉시컬 스코프 │ Result │ async/await
│ │
이것들이 효과를 모델링하는 유일한 언어 기능은 아니며, 다른 기능들도 이 버킷들 중 하나에 들어간다. 예를 들어 모나드도 정적 타입이고 렉시컬 스코프다. 그러나 모나드에 대한 큰 이의는 효과를 특정한 “레이어” 방식으로 모델링한다는 점이다. 예컨대 IO<Result<T, E>>와 Result<IO<T>, E> 사이에 구분이 생긴다. 반면 코루틴은 순서에 독립적이다. Pending과 Exception을 내보내는 모든 코루틴은 같은 타입이며, 순서의 구분이 없다. 효과 처리기도 마찬가지다.
이 점은 Rust에서 비동기 반복자(async iterators) 설계를 둘러싼 논쟁에서 나타났다. async fn next에 기반한 설계는 여러 코루틴을 도입하면서 임의의 순서(레이어링)를 만들어내지만, 단일 코루틴에 기반한 설계는 구분을 만들지 않는다. 코루틴은 그저 항목들을 내보내는 동시에 Pending도 내보낸다. 이것이 앞서 링크한 글에서 Russell Johnston이 주장한 요지였다. 이런 비레이어드(unlayered) 성질은 코루틴과 효과 처리기가 공유하는 장점이다.
한편, Rust는 어떤 종류의 코루틴이 아니라 Result로 오류를 모델링함으로써 순서를 도입 한다. 이는 “예외를 던지는” 함수(즉 Err로 평가되는 함수)는 재개되어서는 안 된다는 사실 때문에 대체로 문제가 없다. 다만 약간의 특이점이 생긴다. 예를 들어 Result의 반복자(하나가 Err여도 계속될 수 있음)와 오류를 “던지는” 반복자(계속되지 않음)를 구분할 방법이 없다. 만약 제너레이터가 “예외를 내보낼” 수 있었다면, 그것은 Result의 시퀀스를 내보내는 제너레이터와는 다른 타입이 되었을 것이다. 하지만 내 경험상 이 불충분함이 실제로 자주 문제 되지는 않으므로, 이것은 미래의 어떤 언어가 풀 이론적 과제로 남겨도 괜찮다고 생각한다.
전체적으로, 코루틴은 많은 종류의 효과가 있는 함수들을 다루는 가장 유망한 방법처럼 보인다. 설계의 달콤한 지점(sweet spot)에 있는 듯하기 때문이다. 즉, 코루틴은 정적으로 타입이 붙고, 렉시컬 스코프이며, 레이어링되지 않는다. 그래서 어떤 언어에서든 효과를 다루기 위한 출발점으로 나는 코루틴 기능을 선택할 것이다(다만 언어가 Rust의 제약을 받지 않는다면, 재귀가 가능하도록 스택풀(stackful) 코루틴 기능을 선호할 것이다).