자바스크립트의 약속(Promise): 2부 비교

1부에서 ES 6의 Promise 도입 배경에 대해 의문을 제기했습니다. 이미 웹브라우저나 node.js에서 콜백 스타일을 많이 쓰고 있음에도 불구하고 자바스크립트 표준화 단체인 Ecma는 Promise라는 새로운 비동기 프로그래밍 방식을 표준으로 채택했기 때문입니다.

ES 6 Promise를 설명하는 대부분의 글들은 기존 콜백 스타일에 비해 ES 6가 가지는 장점을 설명하고 있습니다. 일례로, ECMAScript 6 promises (1/2): foundations라는 글은 콜백의 단점을 다음과 같이 정리하고 있습니다.

* Error handling becomes more complicated: There are now two ways in which errors are reported – via callbacks and via exceptions. You have to be careful to combine both properly.

* Less elegant signatures: In synchronous functions, there is a clear separation of concerns between input (parameters) and output (function result). In asynchronous functions that use callbacks, these concerns are mixed: the function result doesn’t matter and some parameters are used for input, others for output.

* Composition is more complicated: Because the concern “output” shows up in the parameters, it is more complicated to compose code via combinators.

  • 에러 처리가 더 복잡해진다. 에러가 보고되는 방식이 콜백과 예외 두 가지가 된다. 두 가지를 모두 적절하게 처리하기 위해서 주의를 기울여야 한다.
  • 시그너처가 아름답지 못하다. 동기 함수에서는 입력(인자)과 출력(함수 결과) 사이의 명확한 구분이 존재한다. 콜백을 쓰는 함수에서는 입력과 출력이 섞여 있다. 함수의 결과는 중요하지 않고, 일부 인자는 입력에 나머지 인자는 출력에 사용된다.
  • 합성이 더 어렵다. 출력이 인자로 표현되기 때문에, 컴비네이터를 통해 합성하는 게 더 복잡해진다.

물론 다 일리가 있는 설명이긴 하지만, 이런 몇 가지 장점만으로 이미 JavaScript 개발자들에게 익숙한 콜백 방식을 버리고 Promise 방식을 채택했다는 사실은 여전히 쉽게 납득하긴 어렵습니다.

사실 이해가 어려운 이유는 비교 대상에 있습니다. 콜백과 Promise를 비교할 것이 아니라 동기 프로그래밍 모델과 Promise를 비교해보면 콜백 대비 Promise의 장점이 명확히 보이기 때문입니다.

이해를 돕기 위해 간단한 예제로 시작하겠습니다. 아래 node.js 프로그램은 config.json 파일을 읽어 domain 키의 값을 domain.txt 파일에 쓰는 간단한 프로그램입니다.

var fs = require('fs');

try {
  var text = fs.readFileSync('config.json');
  try {
    var obj = JSON.parse(text);
    try {
      fs.writeFileSync('domain.txt', obj.domain);
      console.log("Done");
    } catch (e) {
      console.error('Error while writing domain text file');
    }
  } catch (e) {
    console.error('Invalid JSON in file');
  }
} catch (e) {
  console.error('Error while reading config file');
}

위 프로그램은 fs 모듈이 제공하는 readFile, writeFile 함수 대신에 readFileSync, writeFileSync 함수를 써서 우리게에 익숙한 동기 프로그래밍 방식으로 작성했습니다. 함수의 입력과 출력이 명확히 분리되어 있고, 모든 예외 처리는 try/catch 블록을 통해 하기 때문에 정상적인 실행 경로와 예외 처리 경로도 명확히 구분되어 있습니다. var obj = JSON.parse(text)에서 볼 수 있는 것처럼 한 함수의 출력을 다른 함수의 입력으로 넘기는 것도 간단합니다.

이 프로그램을 node.js의 콜백 방식으로 다시 작성해보면 다음과 같습니다.

var fs = require('fs');

fs.readFile('config.json',
  function (error, text) {
    if (error) {
      console.error('Error while reading config file');
    } else {
      try {
        var obj = JSON.parse(text);
        fs.writeFile('domain.txt', obj.domain, function (err) {
          if (err)    
            console.error('Error while writing domain text file');
          else
            console.log("Done");
        });
      } catch (e) {
        console.error('Invalid JSON in file');
      }
    }
  });

이 프로그램은 앞서 이야기한 장점이 상당 부분 사라졌습니다. 함수 인자에 입력과 출력이 모두 표현되고 있어, 더 이상 한 함수의 출력을 다른 함수의 입력으로 넘기는 것이 쉽지 않고, 예외 처리도 에러 콜백과 try/catch 블록 양쪽에서 모두 이루어지기 때문에 복잡해졌습니다.

자 그럼 콜백 예제는 잊고 이번에는 Promise로 짠 프로그램을 살펴보겠습니다. 참고로 node.js의 콜백 함수를 Promise 스타일로 바꾸기 위해 node의 promise 모듈이 제공하는 denodeify 함수를 사용하였습니다.

var fs = require('fs');
var Promise = require('promise');

var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);

readFile('config.json')
  .then(function (text) {
    try {
      var obj = JSON.parse(text);
      writeFile('domain.txt', obj.domain).then(function ()
      {
        console.log("Done");
      }).catch(function (reason) {
        console.error('Error while writing domain text file');
      });
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  }).catch(function (reason) {
    console.error('Error while reading config file');
  });

이 프로그램을 콜백 방식이 아닌 동기 프로그래밍 예제와 비교해보시기 바랍니다. 뭔가 느껴지시나요? 네. 이 프로그램의 전체적인 구조는 동기 프로그래밍 예제와 상당히 닮아 있습니다. 물론 try/catch 블록 대신 catch 함수를 사용하거나 함수의 결과가 리턴값이 아닌 then 함수를 통해 넘어오긴 하지만 콜백 방식에 비해 동기 프로그래밍에 훨씬 가깝다는 사실을 느낄 수 있습니다.

이번에는 이 프로그램을 다시 ES 7 async 함수를 사용하도록 바꿔보겠습니다. (참고로 이 코드는 async 함수 지원이 필요하므로, 아직 node.js에서 동작하지 않습니다.)

async function () {
  try {
    var text = await readFile('config.json');
    try {
      var obj = JSON.parse(text);
      try {
        await writeFile('domain.txt', obj.domain);
        console.log("Done");
      } catch (e) {
        console.error('Error while writing domain text file');
      }
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  } catch(e) {
    console.error('Error while reading config file');
  }
}();

async, await 키워드만 제외하면, 이 프로그램은 사실상 동기 프로그래밍과 똑같은 걸 확인할 수 있습니다. 앞서 디슈가링(desugaring)이라는 글에서 프로그래밍 언어의 새로운 기능을 이해하기 위해서는 새로운 기능이 기존 언어로 어떻게 표현되는지를 알면 된다고 말씀드렸는데, ES 7 async 함수는 디슈가링을 통해 ES 6 Promise로 표현됩니다. async 함수는 then 함수를 통해 넘어가던 함수의 출력을 await 키워드를 이용해 함수의 출력으로 바꿔주는 것입니다.

정리하면, ES 6 Promise는 기존 콜백 스타일에 비해 더 나은 비동기 프로그래밍을 제공할 뿐만 아니라 ES 7에서 준비하고 있는 async 함수를 제공하기 위한 준비 단계이기도 합니다. 다시 말해, Promise는 단순히 더 나은 비동기 프로그래밍 스타일이 아니라 불편한 비동기 프로그래밍 방식을 버리고, 다시 편리한 동기 프로그래밍 방식으로 돌아가기 위한 중간 과정으로 이해할 수 있습니다.

Advertisements

6 thoughts on “자바스크립트의 약속(Promise): 2부 비교

  1. ES6에 Generator도 추가가 되는데 왜 promise와 함께 두가지 종류의 비동기 프로그래밍 방식을 제공하는지 궁금합니다. ES7의 async가 generator와 promise로 구현되서 그런가요?

    Liked by 2 people

  2. 칼럼 잘 보고 있습니다. 감사합니다.
    Promise의 일반적인 코딩 패턴은 아래와 같지 않을까요?

    var fs = require(‘fs’);
    var Promise = require(‘promise’);

    var readFile= Promise.denodeify(fs.readFile);
    var writeFile= Promise.denodeify(fs.writeFile);

    readFile(‘config.json’)
    .then(function (text) {
    var obj = JSON.parse(text);
    return writeFile(‘domain.txt’, obj.domain);
    })
    .then(function () {
    console.log(“Done”);
    })
    .catch(function (reason) {
    // 에러 처리
    })
    .done();

    개인적으로는 Promise를 사용해서 많이 간결해 진다고 느끼고 있습니다.

    좋아요

    • 네 말씀하신 대로 짜는 게 좀 더 functional한 스타일입니다.

      제 글에서는 sync -> promise -> async로 차례대로 바꾸기 위해 promise 코드를 다소 부자연스럽게 표현하였습니다.

      재미있는 건 sync한 코드와 async한 코드 사이에 dualism이 존재하기 때문에 truwater이 말씀하신 코드가 더 좋은 코드라면, 이 코드를 그대로 sync코드로 옮기면 sync 코드도 더 좋은 코드가 나오게 됩니다. 이 부분에 대해서는 다음 글에서 좀 더 자세히 설명하겠습니다.

      Liked by 1명

  3. Promise를 봐도 이해가 안되는 점이 말씀하신 부분입니다.
    C# 5도, ES7도 채택하는 async / await 모델은 기존 흐름과 큰 차이 없이 읽을 수 있는데,
    Promise를 쓸 경우 조건에 따른 분기등을 처리할 때 이상해지는 느낌입니다.
    (조건이 달라지면 catch로 빼야하나 뭐 이런 생각이)

    개인적으로는 synchronize.js 같이 fiber를 통해서 async await를 미리 쓰는 쪽을 선호합니다.

    좋아요

댓글이 닫혀있습니다.