← Go Back
Computer Science/Programming Language Async

함수의 색

밥 니스트롬(Bob Nystrom)의 2015년 What Color is Your Function?을 바탕으로 비동기 프로그래밍 시 발생하는 함수의 색 문제를 이해해보자.

비유 설정

JS와 비슷한 가상의 언어를 상상한다. 그런데, 괴상한 규칙이 있다:

  • 모든 함수는 색을 가진다. 빨강 or 파랑.
  • 색이 없는 함수는 만들 수 없다.

그리고 추가되는 규칙 5개:

  1. 함수를 정의할 때 색을 골라야한다. (red_function / blue_function)
  2. 호출 방식이 색마다 다르다. 빨강은 빨강 호출, 파랑은 파랑 호출. 틀리면 큰일남.
  3. 빨강 함수는 빨강 함수 안에서만 호출할 수 있다. 파랑 안에서 빨강 호출은 금지 (반대는 OK)
  4. 빨강 함수는 호출하기가 더 고통스럽다(뭔가 번거로운 절차가 붙음).
  5. 핵심 라이브러리 함수 일부가 빨강이다 - 직접 못 만들고, 반드시 써야 하는데 빨강만 있음.

정체 공개

빨강 함수의 정체는 비동기(async) 함수, 파랑은 동기(sync) 함수이다. 규칙을 매핑하면:

  1. 동기는 값을 return, 비동기는 콜백을 호출한다.
  2. 동기는 그냥 호출, 비동기는 콜백/await가 필요하다.
  3. 동기 함수에서 비동기를 호출할 수 없다 - 결과가 나중에 오니까
  4. 비동기는 try/catch나 일반 제어문 안에서 못 쓰고, 표현식 합성이 안된다.
  5. Node.js는 코어 라이브러리가 죄다 비동기(=빨강)이다.\

콜백 지옥이 바로 이 빨강 함수의 고통이고, 수만 개의 async 라이브러리는 언어가 떠넘긴 문제를 라이브러리 차원에서 때우려는 발버둥이라는게 글쓴이의 일침이다.

글이 업데이트된 2021년 12월 3일 기준 비동기 라이브러리만 15,118개라고 한다.

전염성

이게 왜 지독하냐? 위의 규칙 3번이 핵심인데, 빨강은 위로 전염된다. 어떤 함수가 빨강 코어 함수를 하나라도 쓰면 그 함수도 빨강이 되고, 그걸 호출하는 함수도 빨강이 되고… main()까지 빨강이 번진다. 고차 함수든 단순 리팩토링이든, 코드를 함수로 쪼개 재사용하려는 순간 색을 끊임없이 신경 써야 한다. 글쓴이는 이걸 “개발이라는 여름 휴가 내내 수영복 속에 들어간 모래” 같다고 표현한다.

부분 해결책

  • Promise/Future: 합성이 조금 나아져서 규칙 4를 살짝 완화시켜준다. 하지만 여전히 클로저 무더기를 손으로 만들어서 .then()에 넘길 뿐, 본질은 그대로다. (배를 맞느냐 급소를 맞느냐.. 라고 함)
  • async-await: 규칙 4를 제대로 고쳐준다. 비동기 호출이 동기만큼 쓰기 쉬워진다. 하지만, 나머지 규칙은 그대로 남는다. 세상은 여전히 async/sync 둘로 쪼개져 있고, async 함수는 여전히 async 함수다. 고차 함수를 쓰거나 코드를 재사용하려는 순간 색이 코드 전체로 다시 번진다.

색이 없는 언어

그럼 색이 없는 언어가 있을까? 글쓴이가 꼽은 색 없는 언어는 Go, Lua, Ruby, 그리고 당시 기준의 Java 이다.

왜 “당시”냐면, 그때 자바의 표준 동시성 모델은 플랫폼 스레드(1:1) 기반 블로킹이었다. 모든 코드가 동기식이었고, 동시성이라 하면 “스레드를 여러 개 띄워서 얻는 것”이었다. 동시성은 “이 함수를 어느 스레드에서 굴리느냐”의 문제지, 함수 시그니처에 빨강/파랑으로 박힌게 아니었다.

니스트롬은 자바가 futures와 async I/O 쪽으로 옮겨가면서 이런 장점을 스스로 망치는 중이고, 그게 “바닥을 향한 경주”같다고 표현한다. 이게 자바의 무색 상태를 깨뜨리고 있었고, 그 색을 들여온 장본인이 CompletableFuture이다. CompletableFuture<T>를 반환하는 순간 그게 빨강 함수가 된다. RxJava, WebFlux 같은 리액티브로 가면 더 빨개지고.

근데 지금, 자바는 다시 무색으로 돌아왔다. 결국 버추얼 스레드(Java 21) 로 무색 진영(Go 고루틴 방식)으로 복귀했다. 평범한 블로킹 코드로 고동시성을 뽑게되니, 색이 다시 사라진 것.

그래서 나머지 3가지 언어의 공통점이 뭐냐면, 스레드, 더 정확히는 “서로 전환 가능한 독립 콜스택”이다. OS 스레드일 필요도 없다. GO의 고루틴, Lua의 코루틴, Ruby의 파이버면 충분하다.

근본 원인

문제의 본질은 “작업이 끝났을 때 어떻게 하던 자리로 돌아오는가”다. 언어가 “지금 어디였는지” 기억하는 수단이 콜스택인데, async I/O를 하려면 OS 이벤트 루프로 제어를 넘기기 위해 콜스택을 통째로 풀어 버려야(unwind) 한다. 그래서 결과를 받아도 이어갈 자리가 사라진다.

콜백, async-await는 이걸 CPS 변환(continuation-passing style) 으로 푼다. 콜스택의 각 단계를 클로저로 만들어 힙에 올리는 것. 컴파일러가 await 지점에서 함수를 반으로 쪼개 뒷부분을 새 함수로 빼낸다. 이렇게 콜스택 전체를 main()까지 클로저화해야 하는 것, 그게 바로 “빨강은 빨강만 호출 가능” 규칙의 정체다.

진짜 해결

스레드(그린이든 OS든)가 있으면 콜스택을 풀 필요가 없다. 스레드 전체를 통째로 멈췄다가(suspend) 나중에 그 자리에서 재개하면 되기 때문이다. Go는 I/O를 만나면 그 고루틴을 파킹하고 블로킹 안 된 다른 고루틴을 실행한다. 표준 라이브러리 I/O가 동기처럼 보이지만 그동안 다른 코드가 돈다.

동시성은 함수에 박힌 색이 아니라, 프로그램을 어떻게 모델링하느냐의 문제가 된다.

결론은 굳이 쓸데없는 비동기 라이브러리를 사용해서 async/sync 색을 나누고 함수 간 전염을 만들지 말고, 언어 모델링 레벨에서 스레드 코루틴 or 버추얼 스레드를 적용하는 방식으로 콜스택을 보존하여 이러한 함수의 색을 없애자는 것이다.

Reference

Bob Nystrom - What Color is your function

← Go Back