이 글은 'Notes on structured concurrency, or: Go statement considered harmful'을 번역한 글입니다. 원작자의 허락하에 번역하였음을 분명히 밝힙니다. 좀 더 쉬운 표현을 위해 의역한 부분도 있으니, 원문이 궁금한 분들은 링크를 참고하시길 바랍니다. Thanks to Nathanial J. Smith for allowing the translation.
모든 동시성 API는 코드를 동시 실행할 방법이 필요하다. 다음은 몇 가지 예시다.
go myfunc(); // Golang
pthread_create(&thread_id, NULL, &myfunc); /* C with POSIX threads */
spawn(modulename, myfuncname, []) % Erlang
threading.Thread(target=myfunc).start() # Python with threads
asyncio.create_task(myfunc()) # Python with asyncio
표현 방식과 용어에는 많은 차이가 있지만, 의미는 동일하다. 이 코드들은 'myfunc'을 프로그램의 나머지 부분과 동시에 실행할 수 있도록 설정한다. 그리고 부모가 다른 작업을 수행할 수 있도록 즉시 반환한다.
또 다른 방법으로는 콜백을 사용하는 방법도 있다.
QObject::connect(&emitter, SIGNAL(event()), // C++ with Qt
&receiver, SLOT(myfunc()))
g_signal_connect(emitter, "event", myfunc, NULL) /* C with GObject */
document.getElementById("myid").onclick = myfunc; // Javascript
promise.then(myfunc, errorhandler) // Javascript with Promises
deferred.addCallback(myfunc) # Python with Twisted
future.add_done_callback(myfunc) # Python with asyncio
마찬가지로 표현 방식은 다양하지만, 동일한 작업을 수행한다. 특정 이벤트가 발생한다면 `myfunc`이 수행되도록 설정한다. 설정이 완료되면 즉시 반환하여 호출자가 다른 작업을 수행할 수 있도록 한다. (때로는 콜백이 promise combinators 또는 Twisted-style protocols/transports 같은 도우미로 표현되기도 하지만, 핵심 아이디어는 동일하다)
이게 전부다. 현실 세계에서 일반적인 목적을 가진 동시성 API를 살펴보면 위 둘 중 하나에 속한다. (혹은 asyncio 처럼 둘 다에 속할 수도 있다)
하지만 Trio 라이브러리는 다르다. Trio는 둘 중 어느 방식도 사용하지 않는다. 대신, `myfun`과 `anotherfunc`을 동시에 실행하고 싶다면 아래와 같이 쓸 수 있다.
async with trio.open_nursery() as nursery:
nursery.start_soon(myfunc)
nursery.start_soon(anotherfunc)
이 'nursery'를 사용한 구조를 처음 보면 혼란스러울 수 있다. 왜 들여쓰기 된 블록이 있지? 이 `nursery` 객체는 무엇이고 왜 작업을 생성하기 전에 필요한 거지? 다른 프레임워크에서 사용해 왔던, 익숙한 패턴을 사용할 수 없다는 것을 깨달으면 짜증 날 수도 있다. 이것은 굉장히 독특해 보이며, 기본적인 원시 개념으로 보기에는 너무 고수준처럼 느껴진다. 충분히 이해할 수 있는 반응이지만, 잠시 견뎌주길 바란다.
이 글에서는 nersery가 전혀 독특한 개념이 아니라, 루프나 함수 호출처럼 기본적인 제어 흐름이라는 것을 납득시키고자 한다. 더 나아가, 위에서 보았던 새로운 작업 흐름을 생성하여 실행하거나 콜백 같은 방식을 완전히 제거하고, nursery로 대체되어야 한다고 주장한다.
믿기 어려운 주장일까? 하지만 과거에 이미 비슷한 일이 일어난 적이 있다. `goto` 문은 한때 제어 흐름(control flow)의 왕이었다. 하지만 이제는 우스갯소리로나 쓴다. 몇몇 언어에서는 여전히 `goto`를 사용하고 있지만, 옛날의 `goto`보다는 훨씬 약화한 형태로 사용된다. 어떤 언어에서는 `goto`가 존재하지도 않는다. 무슨 일이 있었을까? 너무 옛날 일이라 대부분의 사람이 이 이야기를 잘 알지 못하지만, 놀랍게도 이 goto에 대한 이야기는 이 포스팅에서 다룰 내용과 관련이 깊다. 따라서 우선 goto가 정확히 무엇인지 되짚어보고, 이것이 동시성 API와 관련하여 어떤 교훈을 주는지 알아보자.
goto 문이란?
잠시 역사를 되짚어 보자. 초기 컴퓨터들은 어셈블리어나 더 원시적인 메커니즘을 이용해 프로그래밍 되었다. 이 방식은 매우 불편했다. 그래서 1950년대에, IBM의 John Backus나 Remington Rand의 Grace Hopper 같은 사람들은 Fortran이나 FLOW-MATIC(이를 전승한 COBOL이 더 잘 알려짐) 같은 언어를 개발하기 시작했다.
FLOW-MATIC은 당시에 매우 야심 찬 언어였다. 이를 Python의 증조 증조 증조부쯤 되는 언어, 대충 조상님 정도로 생각해도 된다. FLOW-MATIC은 컴퓨터가 아닌 인간이 먼저 이해할 수 있도록 설계된 최초의 언어였다. FLOW-MATIC 코드 예제를 간단히 살펴보자.
현대 언어에서 사용하는 if문이나 반복문도 보이지 않고 함수 호출도 없다. (번역자 첨언: 위 예제 코드에서 if가 존재하긴 하지만 현대의 if문과는 다른 개념이다. FLOW-MATIC의 if는 단순히 조건을 평가하고 특정 작업으로 제어를 이동시키기 위해 사용되는 키워드다) 블록을 구분하기 위한 기호나 들여쓰기도 보이지 않는다. 이건 단순히 나열된 문장들의 목록일 뿐이다. 이 프로그램이 제어문을 사용하기에는 너무 짧고 단순해서 이런 구조가 나온게 아니다. 블록 구문(block syntax)이 발명되기 전이라서 그렇다.
대신, FLOW-MATIC에는 두 가지 흐름 제어 방식이 있다. 첫 번째 방식은 순차적 실행이다. 맨 위에서 시작해 한 번에 한 명령문씩 아래로 내려가는 방식이다. 다른 방식은 JUMP TO같이 특별한 명령문을 사용하는 것이다. 이 경우에는 제어 흐름을 다른 곳으로 직접 전환할 수 있다. 예를 들어 명령문 (13)은 명령문 (2)로 점프한다.
처음에 다룬 동시성 원시 연산들(concurrency primitives)과 마찬가지로, 이 "한 방향으로 점프하기" 작업을 뭐라고 부를지 논의되었다. 이 코드에서는 `JUMP TO`라고 불렀지만, 결국 `goto`로 이름 지어졌다. ("go to", "어디로 가다"라는 의미) 따라서 이 글에서는 이런 흐름들을 goto라고 부르겠다.
다음은 예제에서 사용된 goto 의 전체 목록이다.
위 그림이 혼란스러워 보이는 게 당연하다. 이런 점프 기반 프로그래밍(jump-based programming) 스타일은 FLOW-MATIC이 어셈블리 언어로부터 거의 그대로 물려받은 스타일이다. 이는 매우 강력하고, 컴퓨터 하드웨어의 실제 동작하는 방식과 잘 맞아떨어지지만, 직접 다루기에는 매우 혼란스럽다. 이렇게 화살표가 엉키는 것 때문에 '스파게티 코드(spaghetti code)'라는 말이 생겼다. 우리에게 더 좋은 방법이 필요했다.
그런데 goto가 이런 문제를 일으키는 이유가 뭘까? 왜 어떤 제어 구조는 괜찮고, 어떤 건 별로일까? 좋은 제어 구조를 선택하는 기준은 무엇일까? 당시에는 이것이 불분명했다. 문제를 이해하지 못하면 문제를 해결하기도 어렵다.
go 문이란?
역사 이야기를 잠시 멈추고 다른 이야기를 해보겠다. goto가 나쁘다는 것은 모두가 알고 있다. 그런데 이게 동시성(concurrency)과 무슨 관련이 있는가? 여기서 Golang에서 유명한 go문에 대해서 생각해 보자. 이 go문은 새로운 `goroutine`(경량 스레드)를 만드는 데 사용된다.
// Golang
go myfunc();
위 코드의 제어 흐름을 다이어그램으로 그려보자. 앞서 본 goto와는 조금 다른 형태가 그려진다. 왜냐하면 제어가 분기(split) 되기 때문이다.
여기서 색은 두 경로가 모두 실행됨을 나타낸다. 부모 고루틴(초록색 화살표)의 제어 흐름은 순차적으로 진행되어, 위에서 들어와 바로 아래로 빠져나간다. 한편, 자식 고루틴(보라색 화살표)의 제어가 위로 들어와 myfun 본문으로 점프한다. 일반적인 함수 호출과는 달리, 이 점프는 단방향(one-way)이다. myfun을 실행할 때 우리는 완전히 새로운 스택으로 전환되며, 런타임은 호출 지점에 대한 정보를 더 이상 참조하지 않는다.
하지만 이건 Golang에만 해당하는 이야기가 아니다. 이는 이 글의 처음부터 나열해 온 모든 원시 연산자(primitives)의 제어 흐름 다이어그램과 동일하다.
- 스레딩 라이브러리(threading library)는 일반적으로 나중에 스레드에 join할 수 있게 해주는 핸들 객체(handle object)를 제공한다. 하지만 이는 언어 자체가 알지 못하는 독립적인 작업이다. 실제 스레드 생성하는 연산은 위에 표시된 제어 흐름을 따른다.
- 콜백(callback)을 등록하는 것은 의미론적으로 백그라운드 스레드를 시작하는 것과 유사하다. 이 스레드는 어떤 이벤트가 발생할 때까지 대기하다가, 이벤트가 발생하면 콜백을 실행한다. (물론 구현 방식은 다를 수 있다) 따라서 고수준 제어 흐름 관점에서 콜백을 등록하는 것은 본질적으로 go 문과 같다.
- Future와 Promise도 마찬가지다. 어떤 함수가 호출된 뒤 Promise를 반환한다면? 이는 해당 작업이 백그라운드에서 실행되도록 예약되었으며, 나중에 작업에 join할 수 있는 핸들 객체(handle object)를 제공받았다는 의미다. 제어 흐름 의미론(control flow semantics) 관점에서 이것은 스레드를 생성하는 것과 비슷하다. 이후 Promise에 콜백을 등록하므로, 이는 앞서 설명한 콜백과 동일하다.
이와 동일한 패턴은 정말 다양한 형태로 나타난다. 핵심적인 공통점은 모든 경우에 제어 흐름이 분기되며, 한쪽은 호출자(caller)에서 계속 실행되고, 다른 쪽은 단방향으로 점프한다는 점이다. 이 패턴을 알아차리는 법을 한번 배우면 어디서든 이 패턴을 발견할 수 있다.
하지만 불편하게도, 이 제어 흐름 구조(control flow construct) 카테고리를 지칭하는 표준적인 이름은 없다. 마치 'goto문'이 다양한 goto와 유사한 구조들을 포괄하는 용어가 된 것처럼, 나는 이런 패턴을 'go문'이라고 부르려 한다. 왜 하필 go일까? 첫 번째 이유는 Golang이 이 패턴의 가장 순수한 예시를 제공하기 때문이고, 두 번째 이유는 아마 이미 눈치챘을 수도 있다. 이 두 가지 다이어그램을 보면 유사점이 보이지 않는가?
그렇다. go문은 일종의 goto문이다.
동시성 프로그램(concurrent program)은 작성하기도 이해하기도 어렵다. goto 기반 프로그램도 마찬가지다. 이 두 가지가 어려운 이유가 서로 비슷하지 않을까? 현대 언어들에서는 이 goto로 발생하는 문제들이 대부분 해결되었다. 만약 우리가 goto 문제를 어떻게 해결했는지 연구해 본다면, 더 사용하기 쉬운 동시성 API를 설계하는 방법도 배울 수 있지 않을까?
goto문에서 무슨 일이 일어났나요?
왜 goto가 많은 문제를 만들어냈을까? 1960년대 후반, Edsger W. Dijkstra는 이 문제를 명확히 설명하는 'Go to statement considered harmful'과 'Notes on structured programming' 두 논문을 발표했다.
goto: 추상화의 파괴자
이 논문들에서 Dijkstra는 '복잡한 소프트웨어를 어떻게 올바르게 작성할 수 있을까'에 대한 문제에 주의를 기울였다. 여기서 그의 생각을 모두 살펴보기는 힘들지만, 몇 가지 흥미로운 인사이트들을 소개해보겠다. 예를 들어, 아래와 같은 유명한 문구가 있다.
이 문구는 'Notes on structured programming' 논문에서 나왔다. 하지만 Dijkstra의 주요 관심사는 추상화(abstraction)다. 그는 머릿속에 한 번에 담을 수 없는 큰 규모의 프로그램을 어떻게 작성할 것인지 고민했다. 이를 가능하게 하려면 프로그램의 일부를 블랙 박스(black box)처럼 다룰 수 있어야 했다. 예를 들어, 아래 Python 코드를 살펴보자.
print("Hello world!")
당신은 print 함수가 문자열 포맷팅, 버퍼링, 플랫폼 간 차이 등을 어떻게 구현하는지 전혀 알 필요 없다. 우리는 이 함수가 텍스트를 출력한다는 것만 알면 된다. Dijkstra는 언어가 이런 종류의 추상화를 지원하기를 원했다.
이 시점에서 블록 문법(block syntax)이 발명되었고, ALGOL 같은 언어들은 약 5가지의 고유한 제어 구조를 포함하게 되었다. 여기엔 아직 순차적 흐름(sequential)과 goto가 포함되어 있다.
그리고 if/else, 반복문(loop), 함수 호출(function call) 같은 다양한 변형도 추가되었다.
이러한 고수준 제어 구조들은 goto를 사용해 구현할 수 있다. 실제로 초기에는 사람들이 이 구조들을 편리한 단축어 정도로 생각했다. 그러나 Dijkstra가 다음과 같은 차이를 지적했다. goto를 제외한 모든 제어 구조에서 흐름이 위쪽 -> (어떤 일이 발생) -> 아래쪽으로 나간다. 이런 구조에서는 내부에서 어떤 일이 발생하는지 알 필요 없이, 하나의 블랙 박스처럼 다룰 수 있다. 즉, '(어떤 일이 발생)' 부분을 무시하고, 전체를 일반적인 순차적 흐름(sequential flow)으로 볼 수 있다. 더 나아가, 이런 구조로 작성된 코드 역시 동일한 규칙을 따르게 된다. 예를 들어 다음 코드를 살펴보자.
print("Hello world!")
우리는 제어 흐름의 동작 과정을 살펴보기 위해 print의 정의와 print와 연관된 모든 의존성을 읽을 필요가 없다. print 내부에 반복문(loop)이 있을 수도 있고, if/else나 또 다른 함수 호출이 있을 수도 있다. 하지만 이런 건 별로 중요하지 않다. 나는 제어 흐름이 print로 들어가고, 함수가 잘 동작한 후에, 내가 보고 있는 코드로 돌아오리라는 것을 알고 있다.
당연한 말이라고 생각할 수도 있지만, goto를 지원하는 언어라면 상황이 완전히 달라진다. 이런 언어에서는 제어 구조들이 더 이상 블랙 박스가 아니다. 예를 들어, 함수 하나를 상상해보자. 함수 내부에 loop가 있다. loop 안에는 if/else가 있고, if/else 안에서는 goto가 호출된다. goto는 내가 원하는 곳 어디로든지 제어를 보내버릴 수 있다. 다른 함수의 중간에서 튀어나올 수도 있고, 아예 호출한 적도 없는 함수로 갈 수도 있다. 결국 당신은 제어가 어디로 갈지 전혀 예상할 수 없게 된다.
모든 함수 호출이 잠재적으로 goto문일 수 있다. 이게 바로 추상화가 깨지는 이유다. 이를 미리 알 수 있는 유일한 방법은 전체 소스 코드를 머릿속에 전부 기억하는 것이다. 언어에 goto가 존재하는 순간, 코드의 일부만 보고는 제어 흐름을 예측할 수 없게 되었다. 이게 바로 왜 goto문이 스파게티 코드로 이어질 수밖에 없었는지에 대한 이유다.
Dijkstra는 이 문제를 이해했기 때문에 혁신적인 제안도 가능했다. 그는 if, loop, 함수 호출과 같은 구조들을 goto의 단축어로 생각하는 게 아니라, 그 자체가 기본적인 원시 연산자(primitive)로 받아들여야 한다고 제안했다. 또한, 언어에서 goto를 완전히 제거해야 한다고 제안했다.
현재 시점에서 보면 너무 당연한 말처럼 느껴진다. 혹시 프로그래머들이 자신들이 안전하게 사용할 능력이 부족하다는 이유로 도구를 빼앗기면 어떻게 반응하는지 본 적 있는가? 1969년, 이 제안은 엄청난 논란을 불러일으켰다. Donald Knuth는 goto를 옹호했다. goto를 사용해 코드를 작성하던 전문가들은 이 제약이 많은 새로운 구조를 사용함으로써 자신의 아이디어를 구현하기 위해 프로그래밍을 다시 배워야 한다는 사실에 불만을 가질 수밖에 없었다.
결국, 현대 프로그래밍 언어들은 Dijkstra의 원래 제안보다는 조금 덜 엄격하게 만들어졌다. 하지만 근본적으로 Dijkstra의 아이디어를 기반으로 설계되었다. break, continue, return과 같은 구문을 사용하면 여러 개의 중첩 구조를 한 번에 벗어날 수 있다. 이러한 구조들은 엄격하게 제한된 방식으로만 경계를 넘나든다. 함수는 제어 흐름을 블랙 박스처럼 감싸기 위한 핵심 도구이며, 절대 침범할 수 없는 경계(involate boundary)로 간주한다. return을 통해 현재 함수에서 빠져나갈 수는 있지만, 다른 함수로 뛰어넘을 수는 없다. 함수가 내부적으로 어떤 제어 흐름을 만들더라도 다른 함수를 신경 쓸 필요는 없다.
심지어는 goto 자체에도 적용된다. 여전히 goto와 비슷한 기능을 제공하는 현대 언어들이 존재한다. (C, C#, Golang 등등) 하지만 이 언어들의 goto는 엄격한 제한을 가진다. 최소한 한 함수 본문(body)에서 다른 함수로 점프하는 것이 불가능하다. 어셈블리로 작업하지 않는 한, 고전적이고 무제한적이던 goto는 이제 사라졌다. Dijkstra의 승리다.
뜻밖의 이점 - goto 제거가 가져온 새로운 기능
goto가 사라지자 흥미로운 일이 발생했다. 언어 설계자들이 제어 흐름이 구조적(structured)이라는 가정에 기반해 새로운 기능을 추가할 수 있게 되었다. 예를 들어 Python의 with 문은 자원을 정리하는 간결한 문법을 제공한다.
# Python
with open("my-file") as file_handle:
...
with 문은 파일이 해당 블록 (... 코드) 동안 열려 있다가, 블록이 끝나면 자동으로 닫힌다. 대부분의 현대 언어에서는 이와 비슷한 기능이 있다. 이 기능은 제어 흐름이 질서 있고 구조적으로 흐른다는 가정을 전제로 한다. 만약 goto문을 이용해 with 블록 중간으로 점프한다면? 파일이 열려 있을까, 닫혀있을까? 또는 블록 중간에서 goto를 이용해 다른 곳으로 점프한다면? 파일은 제대로 닫힐까? goto가 있다면 이 기능은 예상한대로 동작하지 않을 수 있다.
오류 처리에도 비슷한 문제가 있다. 어딘가 문제가 발생하면 코드가 무엇을 해야 하는가? 일반적으로는 호출 스택을 따라 문제를 코드의 호출자(caller)에 넘기고, 호출자가 그 문제를 어떻게 처리할지 결정한다. 현대 언어들은 이를 더 쉽게 만들기 위해 예외(exception)와 자동 예외 전파(automatic error propagation) 같은 구조를 제공한다. 하지만 이런 것들을 제공받으려면 언어가 반드시 스택(stack)과 신뢰할 수 있는 호출자의 개념을 가져야 한다. FLOW-MATIC 프로그램에서의 스파게티 같던 제어 흐름을 떠올려보고, 중간에서 예외가 발생했다고 상상해 보자. 어떻게 대응해야 할까?
결론: goto를 절대 사용하지 말자
함수의 경계를 무시하는, 전통적인 goto는 단순히 나쁜 기능, 올바르게 사용하기 어려운 기능을 의미하는게 아니다. 그런 수준의 문제였다면, 아마 여전히 살아남았을 수도 있다. 하지만 goto는 훨씬 더 나쁜 문제를 만든다.
당신이 goto를 사용하지 않더라도, 언어에 goto가 선택지로 존재하기만 해도 모든 것이 복잡해진다. 서드파티 라이브러리(third-party library)를 사용할 때도 그 라이브러리를 블랙 박스처럼 다룰 수 없다. 모든 함수를 읽어보고, 어떤 함수가 일반적인 함수인지 어떤 함수가 특이한 제어 흐름 구조로 위장되어 있는지를 전부 확인해야 한다. 그리고 리소스 정리나 자동 오류 전파와 같은 강력한 기능도 사용할 수 없게 된다. 따라서 블랙 박스 규칙을 따르는 제어 흐름 구조를 선호하여 goto를 완전히 제거하는 것이 좋다.
go 문이 해로운 이유
여기까지가 goto의 역사에 대한 역사였다. 그렇다면 이 이야기가 go와 얼마나 관련이 있을까? 놀랍게도 대부분의 이야기가 관련이 있으며, go 역시 goto가 만들었던 문제를 만든다.
go 문은 추상화를 깨뜨린다. 우리는 언어가 goto를 허용할 때 모든 함수가 잠재적인 goto가 될 수 있다는 점을 배웠다. 대부분의 동시성 프레임워크에서의 go 문에서도 똑같은 문제가 일어난다. 함수를 호출할 때마다 백그라운드 작업이 생성될 수도 있고, 아닐 수도 있다. 함수는 반환된 것처럼 보이지만, 백그라운드에서 계속 실행 중일 수도 있다. 모든 소스 코드와 의존성까지 모두 읽어야만 예상이 가능하다. 언제 작업이 끝날까? 명확하게 답하기 어렵다. 당신이 go문을 갖고 있다면 제어 흐름 관점에서 함수가 더 이상 블랙 박스가 될 수 없다. 저자의 첫 번째 concurrency API 글에서, 이를 인과성 위반(violating causuality)이라고 불렀고, 이는 asyncio나 Twisted를 사용하는 프로그램에서 발생하는 많은 일반적이고 현실적인 문제들의 근본적인 원인이다. 예를 들어, backpressure 문제나 improper shutdown 등이 여기에 해당한다.
go 문은 자동 자원 정리(automatic resource cleanup)를 방해한다. Python의 with문 예제를 다시 살펴보자.
# Python
with open("my-file") as file_handle:
...
우리는 with 문이 실행되는 동안 파일이 열려 있고, 끝난 후에는 닫히는 것이 '보장된다'고 말했다. 하지만 만약 ... 코드가 백그라운드 작업을 생성한다면 어떻게 될까? 그러면 그 보장은 깨진다. with 블록 내부에서 실행되는 것처럼 보이는 작업들이 실제로는 블록이 끝난 후에도 계속 실행될 수 있다. 그리고 백그라운드의 작업에서 파일이 이미 닫힌 상태에서 접근하려고 시도하면 충돌이 일어날 수도 있다. 이 문제는 코드의 일부만 보고는 알아낼 수 없다. 이 문제가 발생하는지 확인하려면, ... 코드 안에서 일어나는 모든 소스 코드를 직접 읽어봐야 한다.
이 코드가 제대로 동작하게 하려면, 모든 백그라운드 작업을 추적하고 모든 작업이 끝난 후에만 파일이 닫히도록 수동으로 관리해야 한다. 불가능한 일은 아니지만, 사용 중인 라이브러리가 작업 완료를 알리는 방법을 제공하지 않는다면 문제가 된다. 결국 우리는 옛날처럼 리소스 정리를 수작업으로 구현해야 하는 상황으로 되돌아가게 된다.
go 문은 오류 처리를 깨뜨린다. 위에서 이야기했듯, 현대 언어들은 예외와 같은 강력한 도구를 제공하여 오류를 감지하고 적절한 위치로 전파되게끔 만들었다. 하지만 이런 도구들은 '현재 코드의 호출자(caller)'를 신뢰할 수 있어야만 동작한다. 작업을 생성하거나 콜백을 등록하는 순간, 신뢰할 수 없게 된다. 대부분의 동시성 프레임워크는 이 문제를 그냥 포기해 버렸다. 백그라운드 작업에서 오류가 발생하고 수동으로 처리하지 않는다면, 런타임은 그 오류를 내버려둔다. 운이 좋다면 콘솔에 어떤 로그가 남을지도 모른다. Rust는 '스레딩 정확성에 가장 집착하는 언어'로 평가받고 있음에도 불구하고, 이런 문제에서 벗어나지 못했다. 백그라운드 스레드가 패닉을 일으키면, Rust는 그 오류를 그냥 버리고 최선의 결과가 나오길 기대한다.
물론 이런 시스템에서도 오류를 올바르게 처리할 수 있다. 모든 스레드를 신중하게 join하거나, Twisted의 errback, JavaScript의 Promise.catch 같이 오류 전파 메커니즘을 직접 구현할 수 있다. 하지만 언어에 이미 내장된 기능을 취약한 형태로 재구현한 임시방편에 불과하다. 이렇게 되면 'traceback'이나 'debugger' 같은 유용한 기능들도 잃게 된다. Promise.catch를 한 번만 깜빡해도, 중요한 오류가 감지되지 않고 사라질 수 있다. 설령 이 모든 문제를 어떻게든 해결한다 해도, 동일한 작업을 수행하는 두 개의 중복된 시스템을 갖게될 것이다.
결론: go 를 절대 사용하지 말자
goto가 기본적인 제어 흐름의 원시 개념이었던 것처럼, 초기 동시성 프레임워크에서는 go 문이 기본적인 동시성 원시 연산자(primitive)였다. go는 기본 스케줄러가 실제로 동작하는 방식을 잘 반영하며, 어떤 동시성 흐름 패턴이든 구현할 만큼 강력하다. 하지만 goto와 마찬가지로, go는 제어 흐름 추상화를 깨뜨리기 때문에 언어에 선택지로 존재하기만 해도 모든 것이 더 복잡해진다.
하지만 좋은 소식은, 이 문제들은 모두 해결될 수 있다. Dijkstra가 그 방법을 보여주었었다. 다음과 같은 작업을 해보자.
- go 문과 유사한 강력함을 가지면서도 '블랙 박스 규칙'을 따르는 대체 수단을 찾는다.
- 해당 새로운 구조를 동시성 프레임워크의 원시 연산자(primitive)로 통합하고, 어떠한 형태로든 go 문도 제외한다.
이것이 Trio가 해낸 일이다.
nursery: go 문을 대체하는 구조적 해결책
핵심 아이디어는 다음과 같다. 제어 흐름이 여러 개의 동시(concurrent) 경로로 분기될 때마다, 반드시 다시 합쳐지도록 만들어야 한다. 예를 들어, 세 가지 작업을 동시에 수행하고 싶다면 제어 흐름은 다음과 같이 나타나야 한다.
이 구조에는 위쪽으로 들어가는 화살표 하나, 아래쪽으로 나오는 화살표 하나만 있기 때문에 Dijkstra의 블랙 박스 규칙(black box rule)을 따른다. 이제 이 스케치를 어떻게 구체화할 수 있을까? 이미 이 제약을 만족하는 구조들이 몇 가지 존재하지만, 지금부터 소개하려는 구조와는 다르다. 우리는 위 구조를 독립적인 원시적 연산자(primitive)로 만들 것이다. 동시성 관련 글은 방대하고 복잡하므로 이 부분은 별도의 글로 작성한다. 여기서는 새로운 솔루션을 설명하는 데 집중하겠다. 분명히 해두고 싶은 점은, 내가 동시성이라는 개념을 발명했다고 주장하는 게 절대 아니다. 이 아이디어는 여러 출처에서 영감을 얻었고, 선구자들이 닦아놓은 길 위에 서 있을 뿐이다.
어쨌든, 이제 어떻게 해야 하는지 설명하겠다. 먼저, 부모 작업은 자식 작업을 위한 공간을 먼저 만들어야만 자식 작업을 시작할 수 있다. 이 공간을 `nursery`라고 부른다. 부모 작업은 nursery 블록을 열어 이 공간을 만든다. Trio에서는 이를 Python의 async with 을 사용해 수행한다.
nursery 블록을 열면 자동으로 이 nursery를 나타내는 객체가 생성된다. 이 객체는 as nursery 구문을 통해 nursery라는 변수에 할당된다. nursery 객체의 start_soon 메서드를 사용하면 동시 작업(concurrent task)을 시작할 수 있다. 위 예제에서는 하나는 myfunc을, 다른 하나는 anotherfunc을 호출한다. 개념적으로 이 작업들은 nursery 블록 내부에서 실행된다. nursery 블록 안에 작성된 코드는 블록이 생성될 때 자동으로 시작되는 초기 작업 정도로 생각해도 좋다.
핵심은 nursery 블록은 내부에 있는 모든 작업들이 종료될 때까지 종료하지 않는다. 만약 부모 작업이 블록의 끝에 도달하더라도, 그곳에 멈춰 모든 자식 작업이 끝나기를 기다린다.
다음 제어 흐름을 살펴보고, 이 섹션의 시작 부분에서 본 기본 패턴과 어떻게 일치하는지 확인해 보자.
이 설계는 몇 가지 결과를 가져오는데 그중 일부는 명확하지 않을 수 있다. 이런 결과들에 대해 생각해 보자.
nursery는 함수 추상화를 지켜준다.
go 문이 가진 근본적인 문제는, 함수 호출 시 그 함수가 완료된 후로도 백그라운드에서 작업을 계속 실행하는지를 알 수 없다는 점이다. 하지만 nursery를 사용하면 이런 걱정을 할 필요가 없다. 어떤 함수든 nursery를 열고 여러 동시 작업을 실행할 수 있지만, 이 모든 작업이 끝날 때까지 함수가 반환될 수 없다. 따라서 함수가 반환된다면, 그 함수가 진짜로 모든 작업을 마쳤다는 것을 의미한다.
nursery는 동적인 작업 생성을 지원한다.
위의 제어 흐름 다이어그램을 만족하는 더 단순한 원시 연산자도 있다. 이 연산자는 thunk(즉시 실행되지 않고 나중에 실행될 코드 블록) 목록을 받고, 이 모든 것을 동시에 실행한다.
run_concurrently([myfunc, anotherfunc])
하지만, 이 접근법의 문제는 미리 실행할 모든 작업의 목록을 미리 알고 있어야 한다. 이는 항상 가능한 일은 아니다. 예를 들어, 서버 프로그램은 일반적으로 accept 루프를 가진다. 이 루프는 접속하려는 커넥션을 받고, 각 커넥션을 처리하기 위한 새로운 작업을 시작한다. Trio에서의 기본적인 accept 루프 예제는 다음과 같다.
async with trio.open_nursery() as nursery:
while True:
incoming_connection = await server_socket.accept()
nursery.start_soon(connection_handler, incoming_connection)
nursery를 사용하면 이런 작업은 간단하다. 하지만 run_concurrently를 이용해 동일한 작업을 구현하려고 하면 훨씬 복잡해진다. 또한 원한다면 nursery를 기반으로 run_concurrently를 구현하는 것도 가능하지만, 굳이 그럴 필요는 없다. nursery 문법으로도 충분히 깔끔하고 이해하기 쉽기 때문이다.
탈출구 제공
nursery 객체는 탈출구(escape)도 제공한다. 만약 백그라운드 작업이 함수보다 더 오래 실행되어야 한다면 어떻게 해야 할까? 간단하다. 함수에 nursery 객체를 전달하면 된다. async with open_nursery() 블록 안의 코드만 nursery.start_soon을 호출할 수 있다는 규칙은 없다. nursery 블록이 열려 있는 한, nursery 객체 참조를 가진 누구든 해당 nursery에 작업을 생성할 수 있다. 이 nursery 객체를 함수 인자로 전달하거나, 큐(queue)를 통해 보내거나, 어떤 방식이든지 전달할 수 있다.
실제로 이는 '규칙을 어기는' 함수를 작성할 수 있다는 것을 의미하지만, 그 범위에 한계가 있다.
- nursery 객체는 명시적으로 전달되어야 한다. 따라서 호출 지점(call site)만 봐도 정상적인 흐름 제어를 위협하는 함수를 즉시 파악할 수 있다. 즉, 코드의 전체를 보지 않고도 추측할 수 있다. (지역적 추론, local reasoning이라고 표현함)
- 함수가 생성하는 모든 작업들은 여전히 전달된 nursery의 수명에 제한된다.
- 호출 코드는 자신이 접근할 수 있는 nursery 객체만 전달할 수 있습니다.
따라서 이건 전통적인 모델과는 매우 다르다. 전통적인 모델에서는 어떤 코드라도 언제든 수명에 제한이 없는 백그라운드 작업을 생성할 수 있었다. 이와 같은 특징이 nursery와 go가 같은 수준의 표현력을 가진다는 것을 증명해 낼 수 있지만, 이 글이 이미 충분히 길어졌으므로 나중으로 미룬다.
새로운 타입을 정의하여 nursery처럼 동작하게 할 수 있다.
표준 nursery 동작 원리(semantic)는 튼튼한 기초를 제공하지만 때로는 다른 것을 원할 수도 있다. 예를 들어, 자식 작업에서 예외가 발생하면 해당 작업을 재시작시키는 nursery와 유사한 클래스를 정의하고 싶을 수도 있다. 가능하다. 사용자 입장에서는 일반적인 nursery처럼 보이도록 할 수 있다.
async with my_supervisor_library.open_supervisor() as nursery_alike:
nursery_alike.start_soon(...)
만약 어떤 함수가 nursery를 인자로 받는다면, 대신 커스텀한 nursery를 전달하여 해당 함수에서 수행하는 작업들의 에러 핸들링 정책을 제어할 수 있다. 하지만 여기서 다른 라이브러리와는 미묘한 차이점이 보인다. start_soon은 코루틴 객체(coroutine object)나 Future가 아니라 함수를 받아야 한다. (함수는 여러 번 호출할 수 있지만, 코루틴 객체나 Future는 재시작할 수 없다) 함수를 받는 편이 여러 가지 이유로 더 나은 규칙이라고 생각한다. (특히 Trio는 아예 Future라는 개념이 없다) 하지만 여전히 언급할 가치가 있다.
nursery는 항상 내부 작업들이 종료될 때까지 항상 대기한다.
여기서는 취소(cancellation) 작업과 합류(join) 작업이 어떻게 상호작용을 하는지 이야기해야 한다. 이 부분에는 몇 가지 미묘함이 존재하며, 이를 잘못 처리하면 nursery의 불변 조건이 깨질 수 있다. Trio에서는 코드가 언제든지 취소 요청을 받을 수 있다. 취소가 요청되면 즉시 취소되는 것이 아니라, '체크포인트' 작업을 실행해야 Cancelled 예외가 발생한다. 이는 취소가 요청된 시점과 실제로 취소가 발생하는 시점 사이에 간격이 존재할 수 있음을 의미한다. 작업이 체크포인트에 도달하기까지 시간이 걸릴 수 있으며, 그 후에는 예외가 스택을 되돌리고 정리 핸들러(cleanup handler)를 실행하는 등의 작업이 필요하다. 이런 상황에서 nursery는 항상 정리 작업이 완료될 때까지 기다린다. 작업이 정리 핸들러를 실행할 기회를 주지 않은 채 강제 종료하지 않으며, 취소 중이더라도 nursery 밖에서 감독 없이 작업이 실행되도록 두지 않는다.
리소스 자동 정리 (automatic resource cleanup)
nursery는 블랙 박스 규칙(black box rule)을 따르기 때문에, with 블록이 정상적으로 동작할 수 있게끔 만든다. 예를 들어, with 블록 끝에서 파일을 닫는 것 때문에 해당 파일을 사용 중이던 백그라운드 작업에 영향이 가게 만들 가능성은 전혀 없다.
자동 예외 전파 (automated error propagation)
앞서 언급했듯, 대부분의 동시성 시스템에서는 백그라운드 작업에서 처리되지 않은 오류는 버려진다. 그 오류들을 처리할 다른 방법이 존재하지 않는다.
Trio에서는 모든 작업이 nursery 안에 존재하고, 모든 nursery는 부모 작업의 일부이며, 부모 작업은 반드시 nursery 안에 있는 작업들이 끝날 때까지 기다려야 한다. 그렇기 때문에 처리되지 않은 에러를 다룰 수 있게 된다. 백그라운드 작업이 예외(exception)와 함께 종료되면, 이 예외를 부모 작업에서 다시 던질 수 있다. 여기에서 주의 깊게 봐야 할 점은 nursery가 일종의 "동시 호출(concurrent call)" 원시 연산자(primitive)처럼 보인다는 점이다.
위의 예제의 호출 스택의 구조를 생각해 보자. 일반적인 함수 호출은 일직선 구조지만, myfunc와 anotherfunc은 동시에 호출되며 하나의 트리(tree) 구조가 된다. 그리고 예외는 이 호출 트리(call tree)를 따라 루트(root)를 향해 전파된다.
의문점이 있다. 부모 작업에서 예외가 전파되기 시작한다는 말은 일반적으로 부모 작업이 nursery 블록을 빠져나가게 된다는 의미다. 하지만 nursery 블록 내에서 아직 실행 중인 자식 작업들이 남아있는 한, 부모 작업은 nursery 블록을 떠날 수 없다. 어떻게 될까?
답은 자식 작업에서 처리되지 않은 예외가 발생하면 Trio는 즉시 같은 nursery에 있는 다른 모든 작업을 취소하고, 이 모든 작업이 종료될 때까지 기다린다. 여기에서 주목할 부분은 다음과 같다. 예외는 호출 스택(call stack)을 되감는(unwind) 과정을 거친다. 만약 우리가 호출 스택 트리(stack tree)의 한 분기(branch)를 넘어서 되감으려면, 다른 분기들도 취소 및 정리(cleanup)해야 한다.
이는 당신이 사용하는 언어에 nursery를 구현하려면 nursery 코드와 취소(cancellation) 시스템을 어떤 식으로든 통합이 필요함을 의미한다. 만약 C#이나 Golang처럼 취소가 주로 객체(object)를 수동으로 전달하고 정해진 컨벤션에 따라 관리되는 언어를 사용한다면 까다로운 작업이 될 수 있다. 혹은 일반적인 취소 메커니즘조차 제공되지 않는 언어라면, 더더욱 까다로울 것이다.
뜻밖의 이점 - go 제거가 가져온 새로운 기능
goto를 제거함으로써 이전 언어 설계자들은 프로그램의 구조에 대해 더 강력한 가정(stronger assumptions)을 할 수 있게 되었고, 이를 통해 with 블록이나 예외(Exception)와 같은 새로운 기능을 추가할 수 있었다. go를 제거하는 것도 비슷한 효과를 가진다.
- Trio의 취소는 다른 라이브러리보다 사용하기 쉽고, 더 신뢰할 수 있다. 이는 작업들이 트리 구조로 중첩(nested)되어 있다는 가정이 있기 때문이다. (Timeouts and cancellation for humans 참고)
- Trio는 Python의 동시성 라이브러리 중 유일하게 control-C가 개발자들이 예측할 수 있고 일관적인 방식으로 동작한다. nursery가 예외를 전파하기 위한 안정적인 메커니즘을 제공한 덕이다.
nursery 사용하기
지금까지는 이론적인 이야기였다. 그렇다면 실제로 사용해 보면 어떨까? 이는 직접 시도해 보며 확인해 보는 수밖에 없다. 솔직히 말해, 많은 사람들이Trio를 사용해 보고 테스트하기 전까지는 확실히 알 수 없다. 지금으로선 이 기반이 탄탄하다고 꽤 자신하지만, 초기의 구조적 프로그래밍 옹호자들이 결국 break와 continue를 완전히 제거하지 않은 것처럼, 나중에 약간의 조정이 필요할 수도 있다.
만약 당신이 Trio를 처음 배우는 동시성 프로그래밍의 숙련자라면, 때로는 Trio가 어렵다고 느껴질 수도 있다. 이는 1970년대의 프로그래머들이 goto 없이 코드를 작성하는 법을 배우기 어려워했던 것처럼, 당신도 새로운 방식을 배워야 한다.
하지만 그게 핵심이다. Donald Knuth는 다음과 같은 말을 남겼다. (위: 원문, 아래: 번역)
Probably the worst mistake any one can make with respect to the subject of go to statements is to assume that "structured programming" is achieved by writing programs as we always have and then eliminating the go to's. Most go to's shouldn't be there in the first place! What we really want is to conceive of our program in such a way that we rarely even think about go to statements, because the real need for them hardly ever arises. The language in which we express our ideas has a strong influence on our thought processes. Therefore, Dijkstra asks for more new language features – structures which encourage clear thinking – in order to avoid the go to's temptations towards complications. ㅡ (Knuth, 1974, p. 275)
goto 문으로 저지를 수 있는 가장 큰 실수는 아마도 "구조적 프로그래밍(structured programming)"이 기존의 방식으로 프로그램을 작성한 뒤, goto를 제거하는 것만으로 달성될 수 있다고 가정하는 것입니다. 대부분의 goto는 애초에 거기에 존재하지 말았어야 합니다! 우리가 진정으로 원하는 것은, 프로그램을 설계할 때 goto 문을 생각할 필요조차 없도록 프로그램을 구상하는 것입니다. 왜냐하면 실제로 goto가 정말로 필요한 경우는 거의 없기 때문입니다. 우리가 아이디어를 표현하는 언어는 우리의 사고 과정에 강력한 영향을 미칩니다. 따라서 Dijkstra는 goto가 불러오는 복잡성의 유혹을 피하기 위해, 더 명확한 사고를 장려하는 새로운 언어적 구조(language features)를 더 많이 요구합니다. ㅡ (Knuth, 1974, p. 275)
지금까지 nursery를 사용하며 경험한 바는 다음과 같다. nursery는 명확한 사고를 장려한다. 그 결과 더 견고하고, 사용하기 쉽고, 전반적으로 더 나은 설계가 만들어진다. 그리고 이러한 제약사항들이 불필요한 복잡성으로 이어질 가능성이 줄여준 덕에 문제를 더 쉽게 해결할 수 있게 만든다. Trio를 사용하며 더 나은 프로그래머가 되는 법을 배웠다.
예를 들어, Happy Eyeballs 알고리즘(RFC 8305)을 생각해자. 이 알고리즘은 TCP 연결을 더 빠르게 설정하기 위한 간단한 동시성 알고리즘이다. 개념적으로 이 알고리즘은 복잡하지 않다. 여러 커넥션 시도를 서로 경쟁하게 만들고, 네트워크 과부하를 방지하기 위해 시작 시각을 약간씩 조정(staggered start)하는 방식이다. 하지만 Twisted의 가장 뛰어난 구현을 살펴보면, 약 600줄의 코드로 작성되어 있으며, 하나 이상의 논리적 버그도 포함되어 있다. 반면, Trio를 사용해 구현하면 코드가 15배 이상 짧다. 중요한 점은 Trio를 사용하여 이 코드를 몇 분 만에, 첫 시도에 바로 올바르게 구현할 수 있었다는 점이다. 다른 어떤 프레임워크에서도 이런 성과를 낼 수 없었을 것이다. 자세한 내용은 Pyninsula에서 발표한 강연을 참고하길 바란다. 이것이 일반적인 사례인지는 시간이 지나야 알 수 있겠지만, 확실히 기대해 볼만하다고 생각된다.
결론
널리 사용되는 동시성 원시 연산자(go문, callback, future, promise 등)들은 모두 이론적으로나 실질적으로나 goto의 변형이다. 이 goto는 현대적으로 변형된 goto가 아니라, 함수 경계를 뛰어넘을 수 있을만큼 강력하고, 위험하며, 통제하기 어려운 기능의 goto이다. 이러한 원시 연산자들은 우리가 직접 사용하지 않더라도 위험하다. 왜냐하면 제어 흐름을 논리적으로 추론하기 어렵게 만들고, 추상화와 모듈화된 구조를 방해하며, 자동 리소스 정리(automatic resource cleanup)와 오류 전파(error propagation) 같은 유용한 기능에도 간섭하기 때문이다. 따라서 이러한 원시 연산자들은 현대적인 프로그래밍 언어에서는 더 이상 필요하지 않다.
nursery는 안전하고 편리한 대안을 제공한다. 이는 언어의 모든 기능을 온전히 유지하면서도, 강력한 새로운 기능을 만들어냈다. (ex: Trio의 취소 범위와 control-C 처리) 또한 코드의 가독성, 생산성, 그리고 정확성에서 좋은 효과를 볼 수 있다.
안타깝게도, 이러한 이점을 완전히 누리기 위해서는 기존의 오래된 원시 연산자(primitive)를 완전히 제거해야 한다. 그리고 이는 아마도 새로운 동시성 프레임워크를 처음부터 다시 설계해야 한다는 의미일 수 있다. 마치 goto를 제거하기 위해 새로운 언어들이 설계되어야 했던 것처럼 말이다. 하지만 FLOW-MATIC이 당시로서는 인상적이었지만, 대부분의 사람은 더 나은 언어로 업그레이드한 것에 만족하고 있다. nursery로 전환하는 것도 후회하지 않으리라 생각한다. Trio는 이것이 실용적이고 범용적인 동시성 프레임워크를 위한 실행 가능한 설계임을 보여주고 있다.
'Develop > etc' 카테고리의 다른 글
[Git] 머지 커밋 revert 하기 (5) | 2023.08.13 |
---|---|
Windows에서 8080 port 사용이 불가능한 현상 (1) | 2022.09.18 |
[IntelliJ] gradle project 생성 시 불필요한 파일들이 생기는 현상 (0) | 2022.09.02 |
OAuth vs OpenID (0) | 2022.08.31 |
[OAuth] 슬랙 로그인 구현하기 (5) | 2022.07.31 |