본문 바로가기

Prog. Langs & Tools/JavaScript

JS #10. 자바스크립트 프로미스(Promise)

이전 포스팅 - 자바스크립트 콜백(Callback)

프로미스란

이번 시간에는 자바스크립트 프로미스에 대해서 알아보고자 한다. 이전 포스팅에서 자바스크립트가 비동기 처리를 하는 방법에 대해서 설명하면서 콜백을 소개했고, 콜백의 장단점을 설명하면서 콜백의 단점을 보완하기 위해 프로미스가 생겼다고 이야기 했었다. 조금 더 구체적으로 말하자면, 콜백으로 비동기성을 표현할 때 순차성(Sequentiality)과 믿음성(Trustability)이 결여되어 문제가 생긴다. 하나씩 살펴보자.

먼저 제어의 역전을 생각해 보자. 콜백 함수를 다른 곳으로 전달하게 되면 해당 프로그램의 진행은 그 곳에서 이루어지기 때문에 우리가 할 수 있는 일은 없고, 그저 무사히 잘 되기만을 바라는 방법밖에 없다. 이렇게 불안불안하게 코드를 짤 바에, 프로그램의 진행을 다른 파트에 넘겨주지 않고도 개발자가 작업이 언제 끝나는지 알 수 있다면 얼마나 좋을까? 그리고 그 비동기 처리 이후에 무엇을 할 지도 개발자가 스스로 결정할 수 있다면 제어의 역전이라는 문제를 되역전 함으로써 해결할 수 있을 것 같다.

이를 위해 나타난 체계가 프로미스이다. 프로미스(Promise)는 약속이라는 뜻이다. 아직 어떤 값을 받지는 못했지만, 어떤 값을 받기로 이미 약속을 했고 그 값이 올 때 까지 약속한 것을 기다리는 행위로 이해를 하면 된다. 마치 우리가 까페에서 커피를 주문했는데, 번호표를 받고 커피가 나올 때 까지 기다렸다가 번호가 불리면 가서 커피를 받는 것처럼 말이다. 여기서 번호표는 내가 돈을 지불했고, 그 커피가 완성되면 받을 것을 약속한 증표이다. 프로미스는 마치 그러한 번호표와 같은 것이다.

더하기를 하는 프로미스 예제를 보도록 하자.

여기에는 두 계층의 프로미스가 있다. 먼저 fetchX()와 fetchY()를 직접 호출하여 이들의 반환 값(프로미스)을 add()에 전달한다. 두 프로미스 속의 원래 값은 지금 또는 나중에 준비되겠지만 시점에 상관없이 각 프로미스가 같은 결과를 내게끔 정규화한다. 그 덕에 X,Y는 시간 독립적인 추론이 가능하다.

두 번째 계층은 add()가 Promise.all([ ])을 거쳐 만들어 반환한 프로미스로 then()을 호출하고 대기한다. 프로미스는 이룸 함수(resolve)와 버림 함수(reject)로 성공한 경우와 실패한 경우 두 가지 가능성에 대해서 사후처리를 진행한다. 프로미스 then() 함수는 이룸 함수를 첫 번째 인자로, 버림 함수를 두 번째 인자로 각각 넘겨받는다.

프로미스는 시간 의존적인(time-dependent) 상태를 외부로부터 캡슐화(원래 값을 이룰지 버릴지 기다림) 하기 때문에 프로미스 자체는 시간 독립적(time-independent)이고 그래서 타이밍 또는 내부 결괏값에 상관없이 예측 가능한 방향으로 구성할 수 있다. 또한, 프로미스는 귀결된 후 상태가 그대로 유지되어(불변성, immutable) 몇 번이든 필요할 때마다 꺼내 쓸 수 있다. 즉, 프로미스는 미랫값을 캡슐화하고 조합할 수 있게 해주는 손쉬운 반복 장치이다.

 

프로미스 믿음

이번에는 프로미스가 콜백에서 무너졌던 비동기 코딩의 신뢰를 회복시킬 안전 장치라는 점에서 바라보고자 한다.

먼저 콜백을 사용했을 때 생길 수 있는 믿음성 문제에 대해서 짚어보자. 콜백을 넘긴 이후 다음과 같은 일들을 예상해 볼 수 있다.

  • 너무 일찍 콜백을 호출
  • 너무 늦게 콜백을 호출
  • 너무 적게, 아니면 너무 많이 콜백을 호출
  • 필요한 환경/인자를 정상적으로 콜백에 전달 못함
  • 발생 가능한 에러/예외를 무시함

프로미스는 다음과 같은 문제들에 대해 해결할 수 있도록 설계가 되었다. 먼저 너무 빨리 호출하고 늦게 호출하는 문제는 프로미스 설계 상으로 then() 콜백 이후 resolve(), reject() 중 하나로 스케줄링 되어 자동으로 호출되므로 해결된다. 어떤 동기적인 작업의 연쇄가 실제로 예정된 다른 콜백의 실행을 지연시키는 방향으로 움직이지는 않는다. 프로미스가 귀결되면 then()에 등록된 콜백들이 그 다음 비동기 기회가 찾아왔을 때 순서대로 실행되며 어느 한 콜백 내부에서 다른 콜백의 호출에 영향을 주거나 지연시킬 일은 있을 수 없다.

위의 예제에서 c가 b를 앞지를 가능성은 없다. 

반면에 별개의 두 프로미스에서 연쇄된 콜백 사이의 상대적인 실행 순서는 장담할 수 없다. 예를 들어 두 프로미스 p1, p2가 모두 귀결된 상태라면 p1.then(); p2.then()에서 p1 콜백이 p2 콜백보다 당연히 먼저 실행되어야 할 것 같지만, 꼭 그렇지 않은 경우도 있다. 여러 프로미스에 걸친 콜백의 순서/스케줄링에 의존하면 문제가 발생할 수 있으므로 피하는 것이 좋다.

한 번도 콜백을 호출하지 않는 경우도 프로미스로 해결할 수 있다. 프로미스 스스로 귀결된 이후 귀결 사실을 알리지 못하게 막을 방도는 없다. 이룸/버림 콜백이 모두 프로미스에 등록된 상태면 프로미스 귀결 시점에 둘 중 하나는 반드시 부른다. 에러가 나게 되면 에러를 알림처리하고 프로미스가 어느 한 쪽으로 스스로 귀결되지 않으면 경합(race)이라는 상위 수준의 추상화를 이용하여 프로미스로 해결할 수 있다. 아래는 프로미스 타임아웃 패턴이다.

프로미스는 정의상 단 한번만 호출된다. 아예 호출이 되지 않는 경우는 위처럼 해결하고, 여러 차례 호출하려고 하는 경우는 최초의 귀결만 취하고 이후의 시도는 조용히 무시한다.

프로미스의 믿음을 쌓는 문제에서 마지막으로 설명할 내용은 Promise.resolve() 함수이다. 이 함수는 ES6 프로미스 구현체에 추가되었으며 콜백만 사용하는 경우보다 더 믿을 수 있는 프로미스를 만들 수 있다.

 

연쇄 흐름

프로미스는 단일 단계 작업만을 대상으로 만들어진 체계가 아니다. 프로미스는 여러개를 길게 늘어놓으면 일련의 비동기 단계를 나타낼 수 있다. 이렇게 할 수 있는 이유는 프로미스의 다음 두 가지 작동 방식 때문이다.

  • 프로미스에 then()을 부를 때 마다 생성하여 반환하는 새 프로미스를 계속 연쇄할 수 있다.
  • then()의 이름 콜백 함수(첫 번째 인자)가 반환한 값은 어떤 값이든 자동으로 첫 번째 지점에서 연쇄된 프로미스의 이름으로 세팅된다.

프로미스를 연쇄적으로 세팅하면 임시적으로 사용할 변수를 계속 선언할 필요가 없게 된다. 그리고 비동기적으로 2단계가 1단계를 기다렸다가 실행되게 하고 싶다면 return 값을 Promise 객체로 변환하여 주는 방법이 있다. 여기에 setTimeout 같은 함수를 통해 시간차를 줄 수도 있다. 이러한 프로미스 연쇄의 특징을 나타낸 예제는 다음과 같다.

 

에러 처리

동기적인 try ... catch 구문은 개발자들이 대부분 가장 익숙한 일반적인 에러 처리 형태이다. 하지만 이는 비동기 패턴에서는 사용할 수 없다. 콜백에 대한 에러 처리 패턴에는 몇 가지가 있는데 먼저 에러-우선 콜백(error first callbacks) 스타일에 대해서 설명하려고 한다.

foo() 함수에 전달한 콜백은 첫 번째 인자 err를 통해 에러 신호를 감지할 것이다. err가 있으면 에러가 난 것이고, 없으면 문제가 없었다는 것이다. 하지만 이 방법에도 한계가 있는데, 먼저 baz.bar() 호출 결과가 동기적으로 성공/실패 한다는 전제 하에서만 작동한다. baz.bar() 함수 자체가 비동기로 작동하면 그 내부에서 발생한 에러는 잡을 수 없다. 또한 이러한 비동기적 에러 처리가 여러개 조합되면 if 문이 여기저기서 꼬인 상태로 뒤엉키고 콜백 지옥이 발생할 확률이 높다.

then()에 넘긴 버림 처리기로 프로미스 에러를 처리할 수도 있다. 프로미스는 '분산-콜백(split-callback)' 스타일로 이룸/버림 각각이 콜백을 지정해서 에러 처리를 한다. 하지만 이 경우 이룸 부분에서 에러가 발생할 경우 캐치하지 못할 수 있다는 한계가 있다.

프로미스 연쇄 끝에 .catch() 를 붙이는 방법도 있다. then()에서 발생된 에러는 catch()의 파라미터로 전달된 함수에 들어오게 된다. 하지만 이 역시 완벽한 해결책이 아니다. 왜냐하면 이 catch() 파라미터의 함수가 에러를 낼 수도 있기 때문이다.

그렇다면 더 나은 방법은 무엇일까? 여기서는 브라우저의 특징을 좀 더 자세하게 살펴볼 필요가 있다. 브라우저는 어떤 객체가 휴지통으로 직행하여 가비지 콜렉션(Garbage Collection)될지 정확하게 알고 추적할 수 있다. 따라서 브라우저는 프로미스 객체를 추적하면서 언제 가비지를 수거하면 될지 분명히 알고 있으며, 프로미스가 버려지면 그 사유가 논리적인, 잡히지 않은 에러이므로 개발자 콘솔창에 표시해야 할지 여부를 확실하게 결정할 수 있다.

그래서 이론적으로 가장 완성도가 높은 에러 처리 방법은 다음과 같다. 그 전에 ES6 이후에 프로미스 에러 처리에 대해 개선될 부분이 생기기를 바란다.

  • 기본적으로 프로미스는 그 다음 잡/이벤트 루프 틱 시점에 에러 처리기가 등록되어 있지 않을 경우 모든 버림을 콘솔에 알리도록 되어 있다.
  • 감지되기 전까지 버림 프로미스의 버림 상태를 계속해서 유지하려면 defer()를 호출해서 해당 프로미스에 관한 자동 에러 알림 기능을 끈다.

아래의 코드에서 p를 생성할 때 버림 상태를 사용/감지 하려면 잠시 대기해야 하므로 defer()를 호출하는데, 이렇게 하면 전역 범위로 알림이 발생하지 않는다. defer()는 계속 연쇄할 목적으로 같은 프로미스를 단순 반환한다.

 

프로미스 패턴

앞에서 프로미스 연쇄(이것-저것-이것 등의 흐름 제어)의 시퀀스 패턴을 계속 봐 왔는데, 이외에도 프로미스에 기반을 두고 좀 더 추상화한 형태로 구축 가능한 비동기 패턴의 변형이 많다.

먼저 복수의 병렬/동시 작업이 끝날 때 까지 진행하지 않고 대기하는 패턴은 Promise.all([ ])에서 처리한다. 어느 쪽이 먼저 끝나든지, 모든 작업이 끝나야 게이트가 열리고 다음으로 넘어간다. Promise.all([ ])이 반환한 메인 프로미스는 자신의 하위 프로미스들이 모두 이루어져야 이루어질 수 있다. 단 한 개의 프로미스라도 버려지면 Promise.all([ ]) 프로미스 역시 곧바로 버려지며 다른 프로미스 결과도 덩달아 무효가 된다.

반면 결승선을 통과한 최초의 프로미스만 인정하고 나머지는 무시해야 할 때도 있다. 이러한 개념을 프로미스에서는 경합(race)이라고 한다. Promise.race([ ]) 역시 하나라도 이루어진 프로미스가 있을 경우 이루어지고 하나라도 버려지는 프로미스가 있으면 버려진다. 위에서 살펴본 것 처럼 Promise.race([ ])를 이용하면 프로미스 타임아웃 패턴을 구현할 수 있다.  

 

정리하면 프로미스는 정말 훌륭하므로 마음껏 써도 좋다. 콜백 코드에서 '제어의 역전' 문제를 프로미스는 해결했다. 프로미스가 콜백을 완전히 없애는 것은 아니다. 다만, 기존 콜백 코드를 믿을 만한 중계자 역할을 수행하는 유틸리티를 통해 잘 조정하여 서로 조화롭게 작동할 수 있도록 유도한 것이다. 프로미스 연쇄는 비동기 흐름을 순차적으로 표현하는 더 나은 방법이다. 덕분에 우리가 자바스크립트 코드를 좀 더 효율적으로 계획/관리할 수 있다.

 

참고 자료

  1. <YOU DON'T KNOW JS (this와 객체 프로토타입, 비동기와 성능)> 카일 심슨 저