요즘IT
위시켓
최근 검색어
전체 삭제
최근 검색어가 없습니다.

본문은 요즘IT와 번역가 David가 함께 아민 로나허(Armin Ronacher)의 글 <Playground Wisdom: Threads Beat Async/Await>을 번역한 글입니다. 오스트리아 출신의 개발자로, 파이썬 기반의 웹 프레임워크인 Flask의 창시자로 널리 알려져 있습니다. 현재 센트리(Sentry)의 엔지니어링 디렉터로, 소프트웨어 개발 및 팀 빌딩에 주력하고 있습니다. 이번 글에서는 비동기/대기(Async/await)는 대부분의 언어에서 잘못된 추상화 방식이므로, 스레드(Threads) 기반의 접근법이 더 나은 해결책이 될 수 있다고 설명합니다.

회원가입을 하면 원하는 문장을
저장할 수 있어요!

다음

회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!

확인

개발

실전 교훈: 비동기/대기보다 스레드가 유리한 이유

년차,
어떤 스킬
,
어떤 직무
독자들이 봤을까요?
어떤 독자들이 봤는지 궁금하다면?
로그인

본문은 요즘IT와 번역가 David가 함께 아민 로나허(Armin Ronacher)의 글 <Playground Wisdom: Threads Beat Async/Await>을 번역한 글입니다. 오스트리아 출신의 개발자로, 파이썬 기반의 웹 프레임워크인 Flask의 창시자로 널리 알려져 있습니다. 현재 센트리(Sentry)의 엔지니어링 디렉터로, 소프트웨어 개발 및 팀 빌딩에 주력하고 있습니다. 이번 글에서는 비동기/대기(Async/await)는 대부분의 언어에서 잘못된 추상화 방식이므로, 스레드(Threads) 기반의 접근법이 더 나은 해결책이 될 수 있다고 설명합니다.

 

필자에게 허락을 받고 번역했으며, 글에 포함된 각주(*표시)는 ‘번역자주’입니다. 글에 포함된 링크는 원문에 따라 표시했습니다.

 

비동기/대기(async/await) 기반 시스템의 어려움과 백프레셔*를 제대로 지원하지 못하는 문제에 대해 글을 쓴 지 몇 년이 지났습니다. 몇 년이 지난 지금도 이 문제가 크게 해소되지 않았지만, 제 생각과 이해는 조금 더 발전했습니다. 이제 저는 비동기/대기는 대부분의 프로그래밍 언어에서 좋지 않은 추상화라는 확신이 들었고, 대신 스레드라는 더 나은 방향을 목표로 해야 한다고 생각합니다.

*백프레셔: 소프트웨어에서 데이터의 흐름이 원하는 대로 진행되지 못하고 저항을 받는 상황

 

이 글에서는 이전에 많은 현명한 분들이 제시했던 여러 주장을 다시 한번 되짚어보려고 합니다. 여기에서 다루는 내용은 새로운 것이 아니며, 단지 새로운 독자들에게 전달하고자 하는 것뿐입니다. 특히 다음의 영향력 있는 글들을 읽어보시면 좋습니다.

 

 

 

요즘 아이들에게 익숙한 프로그래밍 방식

프로그래머로서 우리는 일반적인 작동 방식에 너무 익숙해져서, 자유로운 사고를 방해하는 암묵적인 가정을 하게 됩니다. 다음 코드를 보면서 이를 설명해 보겠습니다.

 

def move_mouse():
    while mouse.x < 200:
        mouse.x += 5
        sleep(10)

def move_cat():
    while cat.x < 200:
        cat.x += 10
        sleep(10)

move_mouse()
move_cat()

 

이 코드를 읽고 다음 질문에 답해보세요. 쥐와 고양이가 동시에 움직이나요, 아니면 하나씩 차례로 움직이나요? 10명의 프로그래머 중 10명은 틀림없이 하나씩 차례로 움직인다고 답할 것입니다. 우리가 파이썬과 스레드, 스케줄링 등의 개념을 알고 있기 때문에 당연히 그렇게 생각하는 것이죠. 하지만 스크래치*에 익숙한 아이들에게 물어보면, 아마도 쥐와 고양이가 동시에 움직인다고 대답할 것입니다.

*스크래치(Scratch): 아이들을 대상으로 만든 교육용, 블록형 프로그래밍 언어

 

이는 스크래치를 통해 프로그래밍을 접한 경우, 일종의 기초적인 액터* 프로그래밍 방식에 노출되기 때문입니다. 고양이와 쥐는 모두 독립적인 액터입니다. 실제로 스크래치 UI에서는 이것들을 ‘스프라이트’라고 부르며, 이 점을 매우 명확하게 보여줍니다. 화면에 있는 스프라이트에 로직을 부여하면, 이러한 모든 로직이 동시에 실행됩니다. 놀랍지 않나요? 심지어 스프라이트 간에 메시지를 주고받을 수도 있습니다.

*액터(Actor Framework): 동시성 프로그래밍을 위한 모델

 

이걸 잠시 생각해 보길 바란 이유는 이것이 꽤 의미심장하다고 느껴지기 때문입니다. 스크래치는 매우 단순한 시스템이며, 어린아이들에게 프로그래밍을 가르치기 위해 만들어졌습니다. 그런데도 이 시스템이 채택한 모델이 바로 액터 시스템입니다. 만약 여러분이 파이썬, C# 또는 다른 언어의 전통적인 교재로 프로그래밍을 시작했다면, 아마도 스레드에 대해서는 맨 마지막에 배우게 될 것입니다. 그것뿐만 아니라, 스레드를 매우 복잡하고 두려운 것처럼 설명할 가능성이 높습니다. 더 나쁜 것은, 액터 패턴에 대해서는 아마도 대규모 애플리케이션의 복잡성을 다루는 고급 서적에서나 배우게 될 것이라는 점입니다.

 

반면, 스크래치는 이러한 전통적인 방식과 다릅니다. 스크래치에서는 스레드나 모나드*, 비동기/대기, 스케줄러에 대해 전혀 언급하지 않습니다. 그런 개념 없이도 작동합니다. 프로그래머 입장에서 볼 땐 단순히 메시지 전달을 위한 기본적인 ‘문법’만 있는 명령형(비록 화려하고 시각적이지만) 언어일 뿐입니다. 그러나 중요한 점은 스크래치에선 동시성이 자연스럽게 이루어집니다. 어린아이들도 쉽게 프로그래밍할 수 있습니다. 동시성을 두려워할 필요가 없다는 걸 몸소 보여주죠.

*모나드(monad): 값을 안전하게 다루기 위해 포장하고 체인처럼 연결해서 처리하는 방식

 

 

명령형 프로그래밍에 대한 오해

두 번째로 강조하고 싶은 점은, 명령형 프로그래밍이 함수형 프로그래밍에 비해 전혀 부족함이 없다는 것입니다. 비록 우리 대부분이 문제 해결을 위해 명령형 프로그래밍 언어를 사용하고 있지만, 어느 순간 이것이 열등하고 순수하지 않다는 관념에 노출되어 왔다고 생각합니다. 함수형 프로그래밍이라는 세계에는 모나드(monad) 같은 개념과 조합성, 논리, 수학 등 멋진 정리들이 자리 잡고 있습니다. 이러한 방식으로 프로그래밍하면, 마치 더 높은 차원으로 올라가서 if문과 for 루프를 이어 붙이고, 이곳저곳에서 부작용(side effects)을 만들어내며, IO를 남발하는 사람들을 내려다보는 것 같은 느낌을 받습니다. 함수형 프로그래밍은 그야말로 순수하고 고상한 코딩의 정점처럼 묘사되곤 하죠. 

 

물론 제가 좀 과장했을 수도 있지만, 완전히 틀린 이야기는 아닐 것 같습니다. 솔직히 저도 이해합니다. 실제로 러스트나 자바스크립트에서 람다*를 연결할 때 기분이 짜릿해지죠. 하지만 우리는 이러한 구조들이 많은 언어에서 단순히 덧붙여진 기능이라는 점을 인식해야 합니다. 예를 들어, Go는 대부분의 함수형 기능 없이도 잘 작동하는데, 그렇다고 해서 Go가 프로그래밍 언어로서 부족한 것은 아닙니다.

*람다(lambda): 임시 함수

 

여기서 중요한 점은, 각 프로그래밍 패러다임이 서로 다른 방식으로 문제를 해결한다는 것입니다. 따라서 함수형 프로그래밍이 모든 문제를 완벽히 해결했다고 생각하거나, 명령형 프로그래밍이 부족하다고 생각하는 것을 잠시 멈춰 보세요. 

 

대신 저는 함수형 언어와 명령형 언어가 ‘대기’를 어떻게 다루는지에 대해 이야기하고 싶습니다. 먼저 위의 예시로 돌아가 보겠습니다. 고양이와 쥐를 위한 두 함수는 각각 별개의 실행 스레드로 볼 수 있습니다. 코드에서 sleep(10)을 호출할 때, 프로그래머는 분명히 컴퓨터가 실행을 일시적으로 중단하고 나중에 계속할 것이라고 예상합니다. 모나드에 대해 지루한 이야기를 하고 싶지 않으니, ‘함수형’ 프로그래밍 언어로 자바스크립트와 프로미스*를 사용하겠습니다. 대부분의 독자들이 충분히 익숙할 것이라 생각되는 추상화입니다.

*프로미스(Promise): 미래에 받게 될 값을 나타내는 객체

 

function moveMouseBlocking() {
  while (mouse.x < 200) {
    mouse.x += 5;
    sleep(10);  // 블로킹 sleep
  }
}

function moveMouseAsync() {
  return new Promise((resolve) => {
    function iterate() {
      if (mouse.x < 200) {
        mouse.x += 5;
        sleep(10).then(iterate);  // 논블로킹 sleep
      } else {
        resolve();
      }
    }
    iterate();
  });
}

 

여기서 바로 문제점을 발견할 수 있습니다. 블로킹 예제를 논블로킹 예제로 변환하는 것이 매우 어렵다는 점입니다. 갑자기 우리의 루프(또는 실제로 모든 제어 흐름)를 표현할 방법을 찾아야 하기 때문입니다. 대기를 수행하기 위해 스케줄러와 실행기의 도움을 받아, 수동으로 재귀 함수 호출 형태로 분해해야 합니다.

 

이러한 스타일은 결국 다루기가 번거로워 비동기/대기가 도입되었고, 이를 통해 이전 코드의 가독성을 대부분 되찾을 수 있게 되었습니다. 이제 다음과 같이 작성할 수 있습니다.

 

async function moveMouseAsync() {
  while (mouse.x < 200) {
    mouse.x += 5;
    await sleep(10);
  }
}

 

하지만 내부적으로는 실제로 아무것도 변한 것이 없습니다. 특히 이 함수를 호출할 때, 단지 “연산의 합성”을 포함하는 객체만 얻게 됩니다. 그 객체는 결괏값을 최종적으로 가지게 될 프로미스입니다. 실제로 C#과 같은 일부 언어에서는 컴파일러가 이를 연쇄적인 함수 호출로 변환합니다. 프로미스를 얻고 나면, 결과를 기다리거나 이 작업이 완료될 때 호출될 then과 함께 콜백을 등록할 수 있습니다.

 

프로그래머에게 비동기/대기는 깔끔하게 정리된 추상화 방식이라는 걸 직관적으로 알 수 있습니다. 프로미스와 콜백에 대한 추상화죠. 하지만 엄밀히 말하면, 우리가 시작했던 지점보다 더 나빠졌습니다. 표현력 측면에서 중요한 기능을 잃었기 때문입니다. 자유롭게 중단할 수 없게 되었습니다.

 

원래의 블로킹 코드에서는 sleep을 호출할 때, 암묵적으로 0.01초 동안 중단되었습니다. 하지만 비동기 호출에서는 같은 일을 할 수 없습니다. 여기서는 sleep 작업을 “대기”해야만 합니다. 이것이 바로 우리가 ‘컬러 함수’를 가지게 된 핵심적인 이유입니다. 동기 함수에서는 대기를 할 수 없으므로, 비동기 함수만이 다른 비동기 함수를 호출할 수 있습니다.

*컬러 함수(Colored functions): 함수에 특정 속성이나 효과가 있어 이를 호출하는 함수에도 동일한 속성을 요구하는 함수들을 지칭하는 용어

 

 

정지 문제 

위의 예제는 비동기/대기가 일으키는 또 다른 문제를 보여줍니다. 만약 resolve가 절대 호출되지 않는다면 어떻게 될까요? 일반적인 함수 호출은 결국 반환하고, 스택이 해제되며, 결과를 받을 준비가 됩니다. 하지만 비동기 세계에서는 누군가가 마지막에 resolve를 호출해야 합니다. 

 

만약 그것이 절대 호출되지 않는다면 어떻게 될까요? 이론적으로는 이게 엄청나게 긴 시간 동안 대기하도록 sleep()을 호출하거나, 데이터가 전혀 들어오지 않는 파이프를 기다리는 것과 비슷해 보일 수 있습니다. 하지만 실제로는 완전히 다릅니다. 한쪽은 콜 스택과 관련된 모든 것을 계속 메모리에 유지해야 하지만, 다른 쪽은 프로미스 객체만 남아있고 나머지는 다 정리된 상태에서 가비지 컬렉션*이 알아서 처리하기를 기다리고 있죠.

*가비지 컬렉션: 프로그램에서 더 이상 사용하지 않는 메모리를 자동으로 회수하는 메모리 관리 기능

 

계약상으로는 resolve를 반드시 호출해야 한다는 규칙이 없습니다. 우리가 이론적으로 알고 있듯이 정지 문제*는 결정 불가능하므로, 누군가가 resolve를 호출할지 안 할지 알 수 있는 방법이 실제로 없습니다.

*정지 문제(halting problem): 프로그램이 유한한 시간 내에 종료될지 또는 무한히 실행될지를 결정하는 문제

 

너무 이론적으로 보일 수도 있지만, 이는 매우 중요한 문제입니다. 프로미스/퓨처와 비동기/대기는 이들이 없을 때보다 상황을 더 악화시키고 있기 때문이죠. 자바스크립트의 프로미스를 가장 대표적인 예로 살펴보겠습니다. 프로미스는 결국 resolve를 호출하게 될 익명 함수에 의해 생성됩니다.

 

let neverSettle = new Promise((resolve) => {
  // 이 함수는 끝나지만, resolve를 절대 호출하지 않습니다
});

 

먼저 명확히 하자면, 이는 자바스크립트의 문제가 아닙니다만, 위 코드를 보면 이해하기 쉽죠. 이것은 완전히 합법적인 코드입니다. 절대 resolve되지 않는 프로미스죠. 이건 버그가 아닙니다. 프로미스 안의 익명 함수는 반환되고, 스택은 정리되며, 우리에게는 결국 가비지 컬렉션될 ‘대기 중인’ 프로미스만 남게 됩니다. 이게 문제인 이유는 절대 resolve되지 않기 때문에 대기도 할 수 없다는 점입니다.

 

이 문제를 좀 더 잘 보여주는 예제를 살펴보겠습니다. 실제로 동시에 작업할 수 있는 것들의 수를 줄이고 싶을 때가 있습니다. 예를 들어 최대 10개의 작업만 동시에 실행되도록 하는 시스템을 생각해 봅시다. 이를 위해 세마포어를 사용하여 10개의 토큰을 발급하고, 그렇지 않으면 백프레셔를 적용할 수 있습니다. 코드는 다음과 같습니다.

 

const semaphore = new Semaphore(10);

async function execute(f) {
  let token = await semaphore.acquire();
  try {
    await f();
  } finally {
    await semaphore.release(token);
  }
}

 

하지만 여기서 문제가 생깁니다. 만약 execute 함수에 전달된 함수가 neverSettle을 반환한다면 어떻게 될까요? 분명히 우리는 세마포어 토큰을 해제하지 못할 것입니다. 이는 블로킹 함수들과 비교했을 때 확실히 더 나쁜 상황입니다. 가장 비슷한 상황은 아주 긴 시간 동안 실행되는 sleep을 호출하는 어리석은 함수일 것입니다. 하지만 둘은 다릅니다. 

 

한 경우에는 콜 스택과 관련된 모든 것을 살아있게 유지하고, 다른 경우에는 결국 가비지 컬렉션될 프로미스만 남고 다시는 볼 수 없게 됩니다. 프로미스의 경우, 우리는 사실상 스택이 유용하지 않다고 결정한 셈입니다. 이를 해결할 방법도 있습니다. 예를 들어, 프로미스가 가비지 컬렉션될 때 알림을 받을 수 있게 하는 등의 방법이 있죠. 하지만 한 가지 짚고 넘어가야 할 점이 있습니다. 규칙상으로는 이러한 프로미스의 동작이 전혀 문제가 되지 않지만, 이로 인해 우리는 이전에는 없었던 새로운 문제에 직면하게 되었다는 것입니다.

 

파이썬도 이와 같은 문제에서 자유롭지 않습니다. Future 객체를 await 했을 때, 프로그램을 강제 종료하기 전까지는 무한정 대기 상태에 빠질 수 있기 때문입니다. 해결되지 않은 채로 남아있는 프로미스는 콜 스택을 가지고 있지 않습니다. 하지만 이 문제는 다른 방식으로도 나타나며, 올바르게 사용하더라도 발생합니다. 스케줄러를 통해 함수들이 분해되어 호출되는 흐름은 이제 이러한 비동기 호출들을 완전한 콜 스택으로 연결하기 위한 추가적인 기능이 필요하다는 것을 의미합니다. 이는 이전에는 존재하지 않았던 추가적인 문제들을 만들어냅니다. 콜 스택은 정말로 매우 중요합니다. 디버깅에 도움이 되며 프로파일링에도 매우 중요합니다.

 

 

블로킹은 하나의 추상화 방식입니다

자, 이제 우리는 프로미스 모델에 적어도 몇 가지 과제가 있다는 것을 알게 되었습니다. 그렇다면 다른 추상화 방식에는 어떤 것들이 있을까요? 제가 주장하고 싶은 것은, 함수가 실행 스레드를 일시 중단할 수 있다는 능력이 정말로 뛰어난 기능이자 추상화라는 점입니다. 잠시 생각해 보세요.

 

어디에 있든 상관없이, 무언가를 기다려야 한다고 말하고 나중에 중단된 지점부터 다시 계속할 수 있다는 거죠. 이는 특히 나중에 백프레셔가 필요하다고 판단될 때 이를 적용하는 데 매우 중요합니다. 파이썬 asyncio에서 가장 큰 문제점은 write가 논블로킹이라는 점입니다. 이 함수는 영원히 문제가 될 것이고, 버퍼 블로트*를 피하기 위해서는 반드시 await s.drain()을 따로 호출해야 합니다.

*버퍼 블로트(Buffer Bloat): 시스템의 버퍼가 과도하게 차는 현상

*블로킹: 프로그래밍에서 특정 작업이 완료될 때까지 프로그램의 실행을 멈추고 기다리는 상태

 

이러한 추상화가 특히 중요한 이유는 현실 세계에서 모든 것이 항상 비동기적이지 않으며, 블로킹*되지 않을 것이라 생각했던 것들이 실제로는 블로킹될 수 있기 때문입니다. 파이썬이 설계될 당시 write가 블로킹될 수 있다고 생각하지 않았던 것처럼 말입니다. 이와 관련하여 한 가지 흥미로운 예시를 들어보고자 합니다. 다음 코드에서 무엇이 차단되며, 그 이유는 무엇일까요?

 

def decode_object(idx):
   header = indexes[idx]
   object_buf = buffer[header.start:header.start + header.size]
   return brotli.decompress(object_buf)

 

이는 일종의 속임수 같은 질문이지만, 사실 그렇지 않습니다. 이 코드가 블로킹되는 이유는 메모리 접근 자체가 블로킹될 수 있기 때문입니다. 이렇게 생각하지 않으셨을 수도 있지만, 메모리 영역을 접근하는 데 시간이 걸리는 여러 가지 이유가 있습니다. 가장 명백한 것은 메모리 매핑된 파일입니다. 아직 로드되지 않은 페이지에 접근하면, 운영체제는 이를 메모리로 가져오기 전까지 기다려야 합니다. "await touching this memory"와 같은 표현은 없습니다. 만약 있다면, 우리는 모든 곳에서 대기를 해야 할 것이기 때문입니다. 이는 사소해 보일 수 있지만, 센트리(Sentry)에서 발생한 여러 사고의 원인이 바로 블로킹되는 메모리 읽기였습니다.

*센트리(Sentry): 소프트웨어 에러 모니터링 및 성능 추적 플랫폼

 

비동기/대기가 오늘날 취하는 타협점은 모든 것이 블로킹되거나 일시 중단될 필요는 없다는 생각에 기반합니다. 하지만 현실에서 제가 발견한 바로는, 실제로는 더 많은 것들이 일시 중단되기를 원하고, 만약 임의의 메모리 접근도 일시 중단이 필요한 경우라면, 과연 이런 추상화가 의미가 있을까요?

 

그래서 어쩌면 처음부터 모든 함수 호출이 블로킹되고 일시 중단될 수 있도록 하는 것이 올바른 추상화였을지도 모릅니다. 하지만 그러면 이제 스레드 생성에 관해 이야기해야 합니다. 단일 스레드만으로는 큰 의미가 없기 때문이죠. 비동기/대기 시스템이 제공하는 특별한 장점은 두 가지 작업을 동시에 실행하도록 지시할 수 있다는 점입니다. 

 

비동기 작업을 시작하고 나중에 await하는 것을 미룸으로써 이를 달성할 수 있죠. 이 점에서는 비동기/대기의 장점을 인정할 수밖에 없습니다. 동시 실행이라는 현실을 언어 자체에 녹여냈기 때문입니다. Scratch 프로그래머에게 동시성이 그토록 자연스러운 이유는 바로 거기에 있기 때문이고, 비동기/대기도 여기서 매우 비슷한 목적을 해결하고 있습니다.

 

전통적인 스레드 기반의 명령형 언어에서는, 스레드를 생성하는 행위가 보통 (종종 복잡한) 표준 라이브러리 함수 뒤에 숨겨져 있습니다. 더 성가신 점은 스레드가 매우 어색하게 붙어있는 것처럼 느껴지고 가장 기본적인 작업에도 완전히 부적절하다는 것입니다. 우리는 단순히 스레드를 생성하는 것뿐만 아니라, 스레드를 조인하고, 스레드 경계를 넘어 값을 전달하며(에러도 포함해서), 작업이 완료되기를 기다리거나 키보드 입력, 메시지 전달 등을 기다리고 싶어 하기 때문입니다.

 

 

클래식 스레딩 

잠시 스레드에 집중해 보겠습니다. 앞서 말했듯이, 우리가 찾고 있는 것은 모든 함수가 값을 반환하거나 중단할 수 있는 능력입니다. 스레드가 바로 그것을 가능하게 합니다.

 

여기서 스레드를 이야기할 때, 반드시 특정한 종류의 스레드 구현을 의미하는 것은 아닙니다. 위의 프로미스 예제를 잠시 생각해 보세요. “sleep”이라는 개념이 있었지만, 그것이 어떻게 구현되는지는 실제로 말하지 않았습니다. 분명히 이를 가능하게 하는 기본 스케줄러가 있지만, 그것이 어떻게 이루어지는지는 언어의 범위를 벗어납니다. 스레드도 그럴 수 있습니다. 실제 OS 스레드일 수도 있고, 가상일 수도 있으며 파이버*나 코루틴*으로 구현될 수도 있습니다. 결국 언어가 제대로 구현한다면 개발자로서 우리는 그것에 대해 신경 쓸 필요가 없습니다.

*파이버(Fiber): 경량 스레드

*코루틴(Coroutine): 중단/재개가 가능한 함수

 

이게 중요한 이유는, 제가 “일시 중단”이나 “다른 곳에서 이어서 실행”을 언급할 때 바로 코루틴과 파이버가 떠오르기 때문입니다. 많은 프로그래밍 언어들이 이러한 기능을 제공하고 있죠. 하지만 잠시 뒤로 물러서서 그것들이 어떻게 구현되는지는 생각하는 것이 아니라, 우리가 원하는 일반적인 기능에 대해 생각해 보는 것이 좋습니다.

 

우리에게는 이렇게 말할 수 있는 방법이 필요합니다. 동시에 실행하되, 반환을 기다리지 말고 나중에(또는 절대) 기다리자. 기본적으로 이는 일부 언어에서 비동기 함수를 호출하되 대기하지 않는 것과 동일합니다. 다시 말해서 함수 호출을 예약하는 것입니다. 그리고 이것이 본질적으로 스레드를 생성하는 것입니다. 스크래치를 생각해 보면, 동시성이 자연스럽게 느껴지는 이유 중 하나는 그것이 정말 잘 통합되어 있고 언어의 핵심 기능이기 때문입니다. 이러한 방식으로 작동하는 실제 프로그래밍 언어가 있습니다. 고루틴을 가진 Go이며, 이를 위한 문법이 있죠.

 

이제 우리는 생성할 수 있고, 그것이 실행됩니다. 하지만 이제 더 많은 문제를 해결해야 합니다. 동기화, 대기, 메시지 전달 등 모든 것들이 해결되지 않았습니다. 스크래치조차도 이에 대한 답을 가지고 있습니다. 그러니 분명히 이것이 작동하게 하기 위해서는 뭔가가 더 필요합니다. 그리고 그 생성 호출은 도대체 무엇을 반환하는 걸까요?

 

 

비동기란 무엇인가?

비동기/대기에는 하나의 아이러니가 있습니다. 그것은 여러 언어에 존재하고, 표면적으로는 완전히 동일해 보이지만, 내부적으로는 완전히 다르게 작동한다는 점입니다. 게다가 각 언어에서 비동기/대기의 도입 배경도 서로 다릅니다.

 

앞서 제가 언급했듯이, 임의로 블로킹할 수 있는 코드는 일종의 추상화입니다. 이 추상화가 많은 애플리케이션에서 의미가 있으려면, 블로킹하는 동안의 CPU 시간을 다른 유용한 작업에 활용할 수 있어야 합니다. 한편으로는 컴퓨터가 순차적으로만 일을 처리하면 지루할 테고, 다른 한편으로는 작업을 병렬로 실행해야 할 수도 있기 때문입니다. 프로그래머로서 우리는 때때로 계속 진행하기 전에 두 가지 일을 동시에 처리해야 합니다. 여기서 더 많은 스레드를 만드는 방법이 등장하죠. 하지만 스레드가 그렇게 좋다면, 왜 여러 언어의 비동기/대기의 기반이 되는 코루틴과 프로미스에 대해 그토록 이야기하는 걸까요?

 

제가 생각하기에 이 지점에서 이야기가 빠르게 복잡해지기 시작합니다. 예를 들어 자바스크립트는 파이썬, C#, 러스트와는 완전히 다른 과제들을 가지고 있습니다. 그런데도 어쩐지 이 모든 언어들이 결국 비동기/대기 형태를 갖게 되었죠.

 

자바스크립트

자바스크립트부터 시작해 보겠습니다. 자바스크립트는 함수 스코프가 반환할 수 없는 단일 스레드 언어입니다. 언어에는 그런 기능이 없으며 스레드도 존재하지 않습니다. 따라서 비동기/대기 이전에는 콜백 지옥이 최선의 방법이었습니다. 이 경험을 개선하기 위한 첫 번째 시도는 프로미스를 추가하는 것이었습니다. 비동기/대기는 그 후에 그것을 위한 문법적 설탕이 되었습니다. 자바스크립트가 다른 선택권이 많지 않았던 이유는 프로미스가 언어 변경 없이 달성할 수 있는 유일한 것이었고, 비동기/대기는 변환 단계로 구현될 수 있는 것이었기 때문입니다. 

 

그래서 실제로 자바스크립트에는 스레드가 없습니다. 하지만 여기서 재미있는 일이 발생합니다. 자바스크립트는 언어 수준에서 동시성 개념을 가지고 있습니다. setTimeout을 호출하면, 런타임에게 나중에 함수를 호출하도록 스케줄링하라고 말하는 것입니다. 이는 매우 중요합니다. 특히 생성된 프로미스는 자동으로 스케줄링 된다는 것을 의미합니다. 잊어버리더라도 실행될 것입니다.

 

파이썬

반면에 파이썬은 완전히 다른 도입 배경을 가지고 있습니다. 비동기/대기 이전 시대에, 파이썬은 이미 스레드를 가지고 있었습니다 - 실제 운영체제 수준의 스레드였죠. 하지만 GIL*(Global Interpreter Lock) 때문에 이러한 스레드들이 병렬로 실행되는 것은 불가능했습니다. 물론 이는 단지 하나의 코어 이상으로 확장되지 않는다는 것을 의미할 뿐이므로, 잠시 그것은 무시하도록 하겠습니다. 파이썬은 스레드를 가지고 있었기 때문에, 꽤 일찍부터 파이썬에서 가상 스레드를 구현하는 실험이 있었습니다. 당시에는(그리고 어느 정도 지금도) OS 수준 스레드의 비용이 꽤 높았기 때문에, 가상 스레드는 이러한 동시 실행 개체들을 더 많이 생성하는 빠른 방법으로 여겨졌습니다.

*GIL: 한 번에 하나의 스레드만 파이썬 코드를 실행할 수 있도록 하는 잠금장치

 

파이썬에서 가상 스레드를 구현하는 방법은 두 가지가 있었습니다. 하나는 스택레스* 파이썬 프로젝트였는데, 이는 파이썬의 대체 구현(정확히는 c파이썬에 대한 많은 패치들)으로 “스택리스 가상머신(기본적으로 C 스택을 유지하지 않는 가상머신)”을 구현했습니다. 간단히 말해서, 이를 통해 스택레스가 “tasklet”이라고 부르는 것을 구현할 수 있었는데, 이는 중단되고 재개될 수 있는 함수들이었습니다. 스택레스는 밝은 미래를 갖지 못했는데, 스택리스 특성 때문에 파이썬 > C > 파이썬 호출이 교차되면서 스택에서 중단될 수 없었기 때문입니다.

*스택레스(Stackless): 파이썬의 스택 처리 방식을 변경한 구현체

 

파이썬에서의 두 번째 시도는 “greenlet”이라고 불렸습니다. greenlet의 작동 방식은 사용자 정의 확장 모듈에서 코루틴을 구현하는 것이었습니다. 구현이 꽤 까다로웠지만, 협력적 멀티태스킹을 가능하게 했습니다. 하지만  스택레스처럼, 이것도 승리하지 못했습니다. 대신 실제로 일어난 일은 파이썬이 수년간 가지고 있던 제너레이터 시스템이 점진적으로 코루틴 시스템으로 업그레이드되었고, 문법 지원과 함께 비동기 시스템이 그 위에 구축되었습니다.

 

이로 인한 결과 중 하나는 코루틴에서 중단하기 위해 문법적 지원이 필요하다는 것입니다. 이는 호출되었을 때, 스케줄러에 양보하는 sleep과 같은 함수를 구현할 수 없다는 것을 의미했습니다. await를 해야만 했죠(또는 초기에는 yield from을 사용할 수 있었습니다). 그래서 우리는 파이썬에서 코루틴이 내부적으로 작동하는 방식 때문에 비동기/대기를 갖게 되었습니다. 이에 대한 동기는 무언가가 중단될 때 알 수 있다는 것이 긍정적인 것으로 여겨졌기 때문입니다.

 

파이썬 코루틴 모델의 한 가지 흥미로운 결과는 적어도 코루틴 모델에서는 OS 수준의 스레드를 넘어설 수 있다는 것입니다. 한 스레드에서 코루틴을 만들어 다른 스레드로 보내서 거기서 계속 실행할 수 있습니다. 실제로는 IO 시스템과 연결되면 다른 스레드의 이벤트 루프로 더 이상 이동할 수 없기 때문에 작동하지 않습니다. 하지만 이미 기본적으로 자바스크립트와는 완전히 다른 일을 한다는 것을 알 수 있습니다. 적어도 이론적으로는 스레드 간에 이동할 수 있고, 스레드가 있으며, yield를 위한 문법이 있습니다. 파이썬의 코루틴은 자바스크립트와는 달리 실행되지 않은 상태로 시작합니다. 이는 부분적으로 파이썬의 스케줄러를 교체할 수 있고 서로 호환되지 않는 구현이 있기 때문이기도 합니다.

 

C#

마지막으로 C#에 대해 이야기해 볼게요. 여기서도 도입 배경이 완전히 다릅니다. C#에는 실제 스레드가 있습니다. 스레드가 있을 뿐만 아니라, 객체별 잠금도 있고 여러 스레드가 병렬로 실행되는 것과 관련된 문제도 전혀 없습니다. 하지만 그렇다고 해서 다른 문제가 없다는 뜻은 아닙니다. 현실적으로 스레드만으로는 충분하지 않습니다. 스레드 간에 동기화하고 통신해야 하는 경우가 많으며, 때로는 그냥 기다려야 할 때도 있습니다. 예를 들어, 사용자 입력을 기다려야 합니다. 그 입력을 처리하는 동안에도 다른 작업을 하고 싶을 것입니다.

 

그래서 시간이 지나면서 .NET은 비동기 작업에 대한 추상화인 task*를 도입했습니다. 이것들은 .NET 스레딩 시스템의 일부이며, 이와 상호작용하는 방식은 코드를 작성하고 문법을 사용해 task에서 중단할 수 있다는 것입니다. .NET은 현재 스레드에서 task를 실행하며, 블로킹하는 경우 계속 블로킹된 상태로 유지됩니다. 

 

이는 자바스크립트와는 꽤 다른데, 자바스크립트에서는 새로운 스레드가 생성되지 않지만 스케줄러에서 실행이 보류됩니다. .NET에서 이렇게 작동하는 이유는 이 시스템의 일부 동기가 메인 UI 스레드를 블로킹하지 않고, 접근할 수 있게 하는 것이었기 때문입니다. 하지만 그 결과로, 실제로 블로킹하면 무언가를 망치게 됩니다. 그러나 이것이 바로 적어도 한때 C#이 await를 만날 때마다 함수를 연쇄적인 클로저로 분할했던 이유이기도 합니다. 단순히 하나의 논리적 코드 조각을 여러 개의 별도 함수로 분해하는 것이죠.

*task: .NET에서 비동기 작업을 나타내는 객체

 

러스트에 대해 깊이 들어가고 싶지는 않지만, 러스트의 비동기 시스템은 아마도 그들 중 가장 이상한 것일 겁니다. 왜냐하면 폴링 기반이기 때문입니다. 간단히 말해서: 작업이 완료되기를 적극적으로 "기다리지" 않는 한, 진행되지 않습니다. 따라서 스케줄러의 목적은 작업이 실제로 진행될 수 있도록 하는 것입니다. 러스트가 왜 비동기/대기를 채택했을까요? 주로 런타임과 스케줄러 없이도 작동하는 것을 원했고, 소유권 검사기와 메모리 모델의 제한 때문이었습니다.

 

이 모든 언어 중에서, 제가 생각하기에 비동기/대기에 대한 논거는 러스트와 자바스크립트에서 가장 강력합니다. 러스트는 시스템 언어이고 제한된 런타임으로 작동하는 디자인을 원했기 때문이고, 자바스크립트도 실제 스레드가 없어서 비동기/대기의 대안은 콜백뿐이었기 때문에 이해가 됩니다.

 

하지만 C#의 경우 논거가 훨씬 약해 보입니다. UI 스레드에서 코드를 실행하도록 강제해야 하는 문제도 가상 스레드에 대한 스케줄링 정책을 가짐으로써 해결될 수 있었을 것입니다. 제 생각에 가장 나쁜 사례는 파이썬입니다. 비동기/대기는 결과적으로 매우 복잡한 시스템이 되었고, 이제 언어는 코루틴과 실제 스레드, 각각에 대한 서로 다른 동기화 기본 요소들, 그리고 하나의 OS 스레드에 고정된 비동기 작업들을 가지게 되었습니다. 심지어 언어는 스레드와 비동기 작업을 위한 서로 다른 퓨처들을 표준 라이브러리에 가지고 있습니다.

 

제가 여러분이 이 모든 것을 이해하기를 원했던 이유는 이 모든 다른 언어들이 같은 문법을 공유하지만, 그것으로 할 수 있는 일은 완전히 다르기 때문입니다. 이들이 공통적으로 가지고 있는 것은 비동기 함수는 비동기 함수에 의해서만 호출될 수 있다는 것입니다(또는 스케줄러에 의해).

 

 

비동기가 아닌 것

지난 몇 년 동안 저는 파이썬이 왜 비동기/대기를 채택하게 되었는지에 대한 많은 주장을 들었고, 제 관점에서 볼 때 제시된 일부 주장들은 면밀한 검토를 견디지 못합니다. 제가 반복적으로 들은 한 가지 주장은 중단 시점을 제어할 수 있다면, 잠금이나 동기화를 처리할 필요가 없다는 것입니다. 여기에는 일부 진실이 있지만(임의로 중단되지 않음), 여전히 잠금을 해야 합니다. 여전히 동시성이 있기 때문에 모든 것을 보호해야 합니다. 파이썬에서는 이것이 특히 불만스러운데, 색이 있는 함수뿐만 아니라 색이 있는 잠금도 있기 때문입니다. 스레드를 위한 잠금과 비동기 코드를 위한 잠금이 있으며, 이들은 서로 다릅니다.

 

제가 위에서 세마포어 예제를 보여준 데는 그럴만한 이유가 있습니다. 세마포어는 비동기 프로그래밍에서 실제로 존재합니다. 시스템이 너무 많은 작업을 받아들이지 않도록 보호하기 위해 자주 필요합니다. 실제로 많은 비동기/대기 기반 프로그램들이 겪는 핵심적인 문제 중 하나는 백프레셔를 행사할 수 없어서 버퍼가 부풀어 오르는 것이죠(이에 대해 다룬 ). 왜 그럴까요? API가 비동기가 아니라면 버퍼링하거나, 실패하도록 강제되기 때문입니다. 블로킹할 수는 없습니다.

 

비동기는 또한 파이썬의 GIL 문제를 마법처럼 해결하지도 않습니다. 자바스크립트에 실제 스레드가 마법처럼 생기게 하지도 않고, 임의의 코드가 블로킹을 시작할 때의 문제(그리고 기억하세요, 메모리 접근조차도 블로킹될 수 있습니다)나 매우 느리게 큰 피보나치 수를 계산할 때의 문제를 해결하지도 않습니다.

 

 

스레드가 답?

앞서 여러 번 언급했듯이, 우리가 “임의의 시점에서 중단”할 수 있는 것에 대해 생각할 때, 프로그래머들은 종종 바로 코루틴을 떠올립니다. 그럴 만한 이유가 있죠: 코루틴은 놀랍고, 재미있으며, 모든 프로그래밍 언어가 가져야 할 기능입니다.

 

코루틴은 중요한 구성 요소이며, 미래의 언어 설계자가 이 글을 보고 있다면 꼭 포함하세요. 하지만 코루틴은 매우 가벼워야 하며, 무슨 일이 일어나고 있는지 파악하기 매우 어렵게 만드는 방식으로 남용될 수 있습니다. 예를 들어, 루아*는 코루틴을 제공하지만, 그것으로 뭔가를 쉽게 할 수 있는 필요한 구조를 제공하지 않습니다. 결국 자신만의 스케줄러, 자신만의 스레딩 시스템 등을 만들게 될 것입니다.

*루아(Lua): 경량의 스크립트 프로그래밍 언어

 

그래서 우리가 정말로 원하는 것은 처음에 시작했던 것입니다: 좋은 옛 스레드입니다. 이 모든 것의 아이러니는 제가 생각하기에 이것을 제대로 구현한 언어가 현대 자바라는 점입니다. 자바의 Project Loom은 내부적으로 코루틴과 모든 멋진 기능들을 가지고 있지만, 개발자에게는 좋은 옛 스레드를 제공합니다. 캐리어 OS 스레드에 마운트되는 가상 스레드가 있고, 이 가상 스레드는 스레드 간에 이동할 수 있습니다. 가상 스레드에서 블로킹 호출을 하면 스케줄러에 양보합니다.

 

이제 저는 스레드만으로는 충분하지 않다고 생각합니다. 스레드에는 동기화가 필요하고, 통신 기본 요소 등이 필요합니다. 스크래치에는 메시지 전달이 있습니다. 그래서 이것들이 잘 작동하게 만들기 위해서는 더 많은 것이 필요합니다.

 

스레드를 더 쉽게 다룰 수 있게 만드는 데 필요한 것에 대해 다른 블로그 포스트에서 후속 설명을 하고 싶습니다. 비동기/대기가 분명히 혁신한 것은 이러한 핵심 기능들을 언어 사용자에게 더 가깝게 가져왔다는 것이고, 종종 현대의 비동기/대기 코드가 전통적인 스레드를 사용하는 코드보다 읽기 쉬워 보입니다.

 

 

구조화된 동시성과 채널 

마지막으로 async/await의 장점과 그것이 가져온 혁신에 관해 이야기하고 싶습니다. 이 언어 기능은 동시성 프로그래밍을 널리 접근 가능하게 만듦으로써, 혼자서도 중요한 혁신을 이끌어냈다고 생각합니다. 특히 파이썬같은 언어에서도 많은 개발자들이 기본적인 “요청당 단일 스레드” 모델에서 벗어나 작업을 더 작은 단위로 나누게 되었죠. 제게 가장 큰 혁신은 Trio*가 nursery*를 통해 도입한 구조적 동시성 개념입니다. 이 개념은 결국 asyncio의 TaskGroup API를 통해 자리를 잡았고, 자바에도 도입되고 있습니다.

*Trio:  파이썬의 비동기 프로그래밍 라이브러리

*Nursery:  Trio에서 제공하는 구조적 동시성 패턴의 핵심 개념

 

구조적 동시성에 대한 더 자세한 설명은 나다니엘 스미스(Nathaniel J. Smith)의 “Notes on structured concurrency, or: Go statement considered harmful”를 읽어보는 것을 추천합니다. 하지만 이에 대해 아직 잘 모르겠다면, 제가 간단히 설명하겠습니다.

 

  • 작업의 시작과 끝이 명확합니다: 모든 스레드나 태스크는 명확한 시작과 끝이 있어서, 각 스레드가 무엇을 하는지 파악하기 쉽습니다. 스레드 컨텍스트에서 생성된 모든 스레드는 해당 스레드가 알고 있죠. 마치 작업을 위해 작은 팀을 만드는 것처럼 생각하세요: 함께 시작하고, 함께 끝내고, 그리고 보고합니다.
  • 스레드는 부모보다 오래 살지 않습니다: 어떤 이유로든 자식 스레드보다 부모가 먼저 끝나면, 자동으로 기다린 후에 반환합니다.
  • 에러는 전파되고 취소를 유발합니다: 한 스레드에서 문제가 발생하면, 에러가 부모에게 전달됩니다. 더 중요한 것은, 다른 자식 스레드들도 자동으로 취소된다는 점입니다. 취소는 시스템의 핵심입니다.

 

저는 구조적 동시성이 스레드 세계에서도 필수가 되어야 한다고 생각합니다. 스레드는 자신의 부모와 자식을 알아야 하고, 성공 값을 돌려주는 편리한 방법도 찾아야 합니다. 마지막으로 컨텍스트는 컨텍스트 로컬을 통해 암묵적으로 스레드 간에 흐를 수 있어야 합니다.

 

두 번째로, 비동기/대기는 태스크/스레드가 서로 대화해야 한다는 것을 더욱 분명하게 만들었습니다. 특히 채널의 개념과 채널 선택이 더 널리 퍼졌죠. 이는 필수적인 구성 요소이며 더 개선될 수 있다고 생각합니다. 생각해 볼만한 점은 구조적 동시성이 있다면, 원칙적으로 각 스레드의 반환 값은 스레드에 연결된 버퍼드 채널로 표현될 수 있으며, 이는 최대 하나의 값(성공한 반환 값 또는 에러)을 보유하고 선택할 수 있습니다. 

 

오늘날 어떤 언어도 이 모델을 완벽하게 구현하지는 못했지만, 수년간의 실험 덕분에 구조적 동시성을 핵심으로 하는 해결책이 그 어느 때보다 명확해 보입니다.

 

<출처: freepik>

 

결론 

이 글을 통해 비동기/대기가 양날의 검이었다는 점이 잘 설명됐길 바랍니다. 콜백 지옥에서는 벗어났지만, 대신 컬러 함수와 같은 새로운 문제들, 백프레셔 관련 과제들, 그리고 영원히 resolve되지 않고 남아있을 수 있는 프로미스와 같은 전혀 새로운 문제들을 안게 되었죠. 또한 디버깅이나 프로파일링에 특히 유용했던 콜 스택의 많은 이점들도 잃게 되었습니다. 이러한 문제들은 사소한 것이 아닙니다. 우리가 목표로 해야 할 직관적이고 명확한 동시성 프로그래밍을 가로막는 실제적인 장애물들이죠.

 

한발 물러서서 보면, 실제 스레드를 가진 언어들에서 비동기/대기를 도입한 것은 잘못된 방향이었던 것 같습니다. 자바의 Project Loom 같은 혁신이 여기에 더 적합해 보이네요. 가상 스레드는 필요할 때 양보할 수 있고, 블로킹될 때 컨텍스트를 전환할 수 있으며, 동시성을 자연스럽게 만드는 메시지 전달 시스템과도 잘 작동합니다. 함수형 프로그래밍과 프로미스 시스템이 모든 문제를 해결했다는 생각에서 벗어난다면, 스레드를 다시 제대로 볼 수 있을 것입니다.

 

하지만 동시에 비동기/대기는 동시성 프로그래밍을 전면에 내세웠고, 실제로 혁신을 이끌어냈습니다. 문법적으로도 동시성을 언어의 핵심 기능으로 만든 것은 좋은 변화였죠. 아마도 이렇게 널리 퍼진 사용법과 사람들의 고민이 파이썬의 비동기/대기 세계에서, 구조적 동시성이라는 실제적인 해결책을 만들어내게 했을 겁니다.

 

앞으로의 언어 설계는 동시성을 다시 한번 고민해야 합니다. 비동기/대기를 도입하는 대신, 새로운 언어들은 더 사용자 친화적인 기본 요소와 함께 자바의 ‘Project Loom’ 같은 모델을 따라야 합니다. 그리고 스크래치처럼 프로그래머들에게 동시성을 자연스럽게 다룰 수 있는 좋은 API를 제공해야 합니다. 액터 프레임워크가 정답은 아니라고 생각하지만, 구조적 동시성, 채널, 그리고 생성/조인/선택을 위한 문법적 지원의 조합이 큰 도움이 될 것입니다.


<원문>

Playground Wisdom: Threads Beat Async/Await

 

위 번역글의 원 저작권은 Armin Ronacher에게 있으며, 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다

좋아요

댓글

공유

공유

작가
486
명 알림 받는 중

작가 홈

작가
486
명 알림 받는 중
요즘 해외 개발자들은 어떻게 일할까요? 기획자나 디자이너는요? 그래서 준비했습니다. 읽어볼만한 해외 소식들을 번역해 전합니다. "We are the world."

좋아요

댓글

스크랩

공유

공유

요즘IT가 PICK한 뉴스레터를 매주 목요일에 만나보세요

요즘IT가 PICK한 뉴스레터를
매주 목요일에 만나보세요

뉴스레터를 구독하려면 동의가 필요합니다.
https://auth.wishket.com/login