자바스크립트의 약속(Promise)

의문점

앞서 게임 서버: node.js의 장점과 단점이라는 글에서 node.js의 문제점으로 불편한 비동기 프로그래밍 모델을 꼽았습니다. 글의 말미에 비동기 프로그래밍의 불편함을 극복하기 위한 노력으로 ES6의 Promise와 (아직 논의 중인) ES7의 async 함수에 대한 이야기를 잠깐 언급했었습니다. 이 시리즈에서는 ES6 Promise에 대해 좀 더 자세히 설명하겠습니다.

일단 한 가지 의문점으로 시작하겠습니다. 이미 웹브라우저나 node.js에서 모두 콜백 방식으로 API를 제공하고 있고, 자바스크립트 개발자라면 당연히 이런 방식에 익숙할 수밖에 없는데, 왜 ES6에서는 이와는 다른 Promise라는 스타일을 표준으로 만든 걸까요? 물론 PromiseJS, Q, RSVP, Bluebird, when.js 등의 자바스크립트 Promise 라이브러리가 많이 나와있지만, 일부 개발자만이 사용할뿐 대세와는 거리가 멀었던 게 사실입니다.

ES6 Promise를 소개하고 있는 글들은 대부분 ES6 Promise가 새로운 스타일의 비동기 프로그래밍 모델을 제공하고 있고, 기존의 콜백 방식에 비해서 더 좋다고 주장하고 있습니다. 하지만 ES6 Promise를 실제로 사용해 보신 분들은 이 지점에서 고개를 갸웃거릴 수밖에 없습니다. 기존 콜백 방식에서도 async 모듈 등을 사용해서 나름대로 불편함을 해결해왔기 때문에 말 그대로 “스타일의 차이” 외에 ES6 Promise 확실히 더 좋은 비동기 프로그래밍 모델이라고 주장할 근거가 약하기 때문입니다.

특히, HTML5 Rocks에 올라온 JavaScript Promises There and back again와 같은 글은 비교 방식에 문제가 있습니다. ES6 Promise에는 then(), map(), foreach() 등 sequencing과 parallelism을 표현하는 함수가 존재하고 콜백 방식에는 없는 것처럼 설명하고 있는데, 콜백 방식에서도 이미 async 모듈의 series, parallel, map 등을 사용해 같은 수준의 추상화를 하고 있기 때문입니다.

물론 ES6 Promise의 장점은 분명히 존재합니다. 특히, err 인자를 모든 함수에 넘기는 방식에 비해 ES6 Promise의 에러 처리 방식은 분명히 개선된 점이 있습니다. 하지만 ES6 Promise를 단순히 기존 콜백 방식의 문제점을 약간 개선한 새로운 비동기 프로그래밍 스타일 정도로 설명해서는 안 됩니다. ES6 Promise 꿈은 더 원대합니다. ES6 Promise는 불편한 “비동기 프로그래밍” 세상을 떠나 다시 우리가 살던 낙원인 “동기 프로그래밍”으로 돌아가기 위한 노력이기 때문입니다.

비교

앞에서 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는 단순히 더 나은 비동기 프로그래밍 스타일이 아니라 불편한 비동기 프로그래밍 방식을 버리고, 다시 편리한 동기 프로그래밍 방식으로 돌아가기 위한 중간 과정으로 이해할 수 있습니다.

관심사의 분리

앞에서 JSON 파일을 읽어 domain key 값을 다시 파일에 쓰는 코드를 sync, callback, promise, async function 총 4가지 버전으로 작성해 보았습니다. 이 과정에서 알게 된 사실은 callback을 제외한 sync, promise, async function은 코드의 구조가 거의 똑같다는 사실입니다. 그래서 promise는 단순히 스타일이 다른 비동기 프로그래밍 방식이 아니라 코드를 좀 더 sync하게 작성하기 위한 방법이고, async function으로 넘어가기 위한 중간 단계라고 설명했습니다.

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) {
    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');
  });

이 코드는 sync 코드를 그대로 옮겼기 때문에 try/catch 블록과 catch() 함수가 같이 사용되어 조금 복잡해 보입니다. 코드를 좀 더 간결하게 만들기 위해 다음과 같이 에러 처리를 한 곳으로 모으고, then()을 중첩해서 부르는 대신 chaining하도록 바꾸겠습니다.

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) {
    if (reason.code === 'EACCES')
      console.error('Error while writing domain text file');
    else if (reason.code === 'ENOENT')
      console.error('Error while reading config file');
    else
      console.error('Invalid JSON in file');
  });

이 코드를 다시 sync 코드로 바꾸면 다음과 같습니다.

var fs = require('fs');

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

이 두 코드를 비교하면 then의 의미가 좀 더 명확해집니다. sync 코드에서는 암묵적으로 코드가 한줄씩 실행된다고 가정하고, 다음줄을 실행할 때 이전 계산값이 이미 존재한다고 가정합니다. async 코드에서는 이런 보장이 없기 때문에 then()을 명시적으로 사용해 이전 줄의 실행 결과가 다음 줄을 실행할 때 필요하다고 표시합니다.

sync와 async의 차이점은 ‘a’가 있냐 없냐이고, 사실 종이 한 장 차이입니다. 코드의 로직은 전혀 차이가 없고, 단순히 동기냐 비동기냐의 차이만 있기 때문에 실제 코드도 거의 차이가 없어야 당연합니다. 하지만 node.js의 콜백 스타일은 ‘비동기’라는 단 하나의 걱정거리 때문에 코드 스타일이 완전히 달라졌기 때문에 좋지 않은 프로그래밍 스타일이라고 이야기할 수 있습니다.

then()함수의 의미는 동기/비동기의 차이를 하나의 함수로 캡슐화(encapsulation)했다는데 있습니다. promise를 이용해 작성한 코드는 then()의 구현체만 바꾸면 코드 변경 없이 같은 코드를 비동기가 아닌 동기로 구동할 수도 있습니다. 즉, 관심사의 분리(Separation of Concerns)라는 측면에서 promise는 “비동기 프로그래밍”이라는 관심사를 분리해냈다고 이해할 수도 있습니다.

One thought on “자바스크립트의 약속(Promise)

  1. Pingback: 자바스크립트의 약속(Promise) | 서광열의 코딩 스쿨

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s