steven

콜백 패턴이 어려운 이유

비동기는 지금과 나중 사이의 갭과 관련된 성질이다

이 글은 YDKJS의 비동기 파트와 개발자 Domenic Denicola의 블로그 글 을 많이 참고하였습니다.

Continuation Passing Style

Continuation Passing Style은 자바스크립트에서 비동기 프로그래밍을 할 때 주로 사용하는 스타일이다. 초기 NodeJS의 API는 모두 이 스타일로 설계가 되었고, 이 영향 탓인지 프론트엔드 라이브러리도 비슷한 패턴을 보였다. 이 스타일은 함수형 프로그래밍에서 유래한 개념으로, 프로그램의 제어 흐름을 연속 함수를 통해 전달하는 프로그래밍 스타일을 의미한다. 이 스타일이 가지는 모양새는 다음과 같다. 일반적인 프로그래밍에서는 함수가 값을 반환하면 프로그램이 그 다음 라인으로 자동 진행된다. 반면 CPS에서는 다음에 무엇을 할지를 명시적으로 함수로 전달한다.

function addCPS(a, b, next) {
  next(a, b);
}

addCPS(1, 2, function (result) {
  console.log(result); // 3
});

이 패턴이 비동기 프로그래밍을 할 때 헬이 되는 이유는 무엇일까? 이를 살펴보기 위해 제어의 역전이라는 개념부터 알아보자.

Inversion of control

제어의 역전은 프로그램의 프로그램의 제어 흐름을 외부에서 관리하는 것이다. 여기서 외부는 (아주 쉽게) 내부가 인터페이스 공급자인지 소비자인지 구분해서 바라본다. CPS를 적용할 때 외부는 엄연히 라이브러리나 서드파티 서비스가 되겠다. (외부가 사용자인 경우는 Array.prototype.filter와 같은 함수를 생각하면 된다.)

제어의 역전과 믿음성

비동기 함수로 프로그램을 제어할 때 문제점은 비동기 함수를 과연 얼마나 믿을 수 있냐는 것이다. 이 함수를 보자. 그리고 이 패턴에 대해 발생할 수 있는 문제를 읽어보자.

analytics.trackPurchase(purchaseData, () => {
  charge();
  displayThankyou();
});

무엇이든지 적당한 믿음은 필요하다고 생각하지만, 콜백 패턴으로 처리하는 로직이 mission critical 할수록 ad hoc logic을 짤 것이다. 근데 문제는 mission critical 하다는 정도가 사람마다 다르다. 결국 모든 연속 함수에다가 조금씩 ad hoc 로직을 붙이게 된다. 가독성은 둘째치더라도 어느정도까지 ad hoc을 걸어야 할지에 대한 생각은 결국 콜백 패턴 자체를 지옥처럼 생각하는 큰 문제가 있다.

promise

프로미스는 미래 특정 시점에 귀결되는 값에 대한 placeholder다. 프로미스의 상태는 미래의 어느 시점에 성공 / 실패로 정해지고 .then 이나 .catch, .finally 를 사용하는 흐름 구조 때문에 시간 독립적인 특성을 가진다.

어떤 값의 평가가 동기적으로 되는지 비동기적으로 되는지 상관없이 프로미스는 미래값으로 취급한다(YDKJS에서는 이 특성을 시간을 정규화했다고 표현한다). 모든 로직을 비동기 흐름으로 작성하기 때문에 믿음성에서 언급한 “비동기 함수가 비동기가 아닐 수 있다는” 문제는 쉽게 해결된다.

Promise/A를 만든 Domenic Denicola은 블로그에서 프로미스의 불변성에 대한 내용을 언급한다. 미래 어떤 시점에 프로미스가 성공 / 실패하면 그 프로미스는 결정된 상태를 계속 유지한다. 이 특성은 콜백 패턴에서 믿음성의 문제를 상당 부분 해결해준다.

const purchasedPromise = analytics.trackPurchasePromise(purchaseData);

purchasedPromise.then(() => {
  charge();
  displayThankyou();
});

위에서 예로 들었던 콜백 패턴의 비동기 프로그래밍은 analytics.trackPurchase 함수가 연속 함수를 몇 번 호출하는지에 대한 믿음성의 문제가 있다. 하지만 프로미스는 resolve 또는 reject 이 호출되면 오직 최초의 결정된 상태만 취하고 이후의 시도는 전부 무시한다.

믿음성 문제 말고, 코드를 바라보는 측면에서

콜백 패턴이 어려운 대표적인 이유는 비동기 흐름이 많아질수록 깊어지는 인덴팅으로 인해 가독성이 매우 떨어진다는 문제가 있다는 것이다. YDKJS에서는 이것 외에도 우리가 계획하는 건 일련의 순서에 따라서 계획하지만 실제로 콜백 패턴을 사용한 비동기 코드를 바라볼 때는 연속성이 쉽게 적용되지 않는다는 점을 지적한다.

우리가 어떤 기능을 만들거나 설계를 할 때에는 연속적인 흐름대로 생각하기 마련이다. 이건 이렇게 하고 저건 저렇게 해야지 와 같은 방식으로 생각한다. 그런데 비동기 코드는 직접 설계한 사람이 아니라면 직관적으로 이해하기가 어렵다.

// A
setTimeout(() => {
  // C
}, 1000);
// B

위 코드 스니펫이 어떤 흐름으로 작동하는지 대해서는 사람이 생각하는 방법과 실제 엔진이 실행하는 방법 간에 괴리가 있다. Domenic은 프로미스의 .then 하고 .catch 으로 작성하는 비동기 제어 흐름이 동기적인 계획 흐름처럼 보이게 한다는 것을 강조한다.

동기적인 흐름에서 작업 단위(함수)는 값을 리턴하거나 에러를 던진다. 프로미스를 이용한 비동기 흐름에서 값을 리턴하는 흐름은 .then 을 이용한 체이닝으로, 버블링 된 에러를 잡는 흐름은 .then 의 두 번째 함수 또는 .catch 를 이용한 체이닝으로 표현할 수 있다. 다른 코드에 대한 믿음 문제를 떠나서 내가 받아들이기에 쉬울 수록 이해심이라는게 더 생기지 않을까? 그런 의미에서 프로미스는 자바스크립트 비동기 환경에서 큰 도움을 주었다고 볼 수 있겠다.