자바스크립트의 비동기성을 표현하는 기본 단위로써 콜백이 있는데, 콜백은 순차성과 믿음성이 결여되는 문제점이 있었다. 특히 콜백이 가진 제어의 역전은 실행 흐름을 서드 파티에 의존해야 하기 때문에 요청을 보내면 그 요청이 잘되기를 바라는 방법이 최선이었다. 이런 문제점을 보완하고 좀 더 나은 방법으로 비동기 처리를 할 수 있는 방법으로 프로미스
가 등장하게 되는데, 제어의 되역전
을 아이디어로 삼는다. 즉 실행 흐름을 서드 파티와 같은 다른 파트에 넘겨주지 않고도 개발자가 작업의 실행 결과와 다음 task에 대해 제어할 수 있는 것이다.
프로미스의 미랫값, 지금값, 나중값
미랫값
은 시간 독립적인 값(Time Independent)
이다. 시간의 흐름과 상관없이 미래에 성공 또는 실패로 귀결되는 값이다. 이 미랫값은 원래 내가 가지고 있던 값 자체와 교환된다. 미랫값이 성공이면 나는 언제가 되었든 반드시 원래 가지고 있던 값을 받게 되고, 실패이면 값을 받지 못한다. 그리고 이러한 미랫값을 다루는 방법이 콜백이다.
1 | //1️⃣ |
위의 코드가 비동기 패턴이 아니라 일반 함수라고 가정해보자면, x,y의 값은 지금
존재하는 구체적인 값이라는 가정 하에 연산이 진행될 것이다. 아래 예시를 통해 지금값에 대해 보충해 보겠다.
1 | //2️⃣ |
이 2️⃣번 예시는 비동기 패턴이 아닌 일반적인 계산 로직을 담고 있는 코드이다. 이 로직에서 x + y 가 연산되는 시점에서 코드는 x, y가 지금
존재하고 있는, 귀결(Resolved)된 값이라고 생각하고 연산이 이루어진다. 그렇기 때문에 x는 아직 할당 전이어서 실질적인 값은 undefined인데 지금 값이 존재한다고 가정되어 undefined + 2로 계산이 되어 NaN이라는 결과를 얻은 것이다.
만약 이 함수에서 x, y의 값을 연산할 때 지금
둘 중 하나라도 준비가 덜 됐으면 될 때까지 기다렸다가 나중
에 값을 얻었을 때 연산을 진행한다고 가정해보자. 즉 코드에서 x, y를 지금
존재하는 귀결된 값이라고 가정하는 것이 아니라 미래에 실패 또는 성공할 값이라고 가정하고 연산을 진행하는 것이다. 그렇다면 결과값은 두 가지 값 모두 순조롭게 할당이 완료되어 성공적으로 정상적인 값을 출력하거나, 나중
까지 기다렸는데도 정상적인 값이 할당되지 않아 undefined + 2로 계산된 것처럼 정상적인 결과를 출력하는데 실패하게 될 것이다. 그래서 x, y의 구체적인 연산 결과는 당장 예측할 수 없지만 그 값이 미래에 반드시 성공 또는 실패될 것이라는 사실을 예측할 수 있게 된다.
다시 돌아와서 1️⃣번 예제의 비동기 패턴을 살펴보자. 비동기 프로그래밍의 핵심은 지금
에 해당하는 부분과 나중
에 해당하는 부분 사이의 관계라고 하였다. 1️⃣번 예제에서 지금
부터 나중
까지 기다리는 최적의 방법으로 콜백 함수
를 사용한다. 위에 설명했던 것처럼 지금 값이 귀결되어 있는지와 상관없이 일단 add() 함수를 호출하여 작성된 로직을 수행한다. add() 함수 내부의 x, y는 add()입장에서 실행되는 지금 시점에서 값이 준비되어 있는지 아닌지는 관심 밖이다. 여기서 x,y는 미랫값으로 취급된다. 따라서 그 값이 어떤 것인지와 상관없이 add()의 결과는 성공 또는 실패 일 것이라고 예측할 수 있게 된다.즉 x,y의 값이 지금
존재하는 값일 수도 있고, 나중에 값을 얻게 될 수도 있는데, 여기서는 지금
과 나중
의 어떤 때라도 결과론적으로 똑같이 일관적으로 동작할 수 있게 하기 위해서 이 두 가지 모두를 나중
으로 만들어서 모든 작업을 나중에 얻게 될 성공 또는 실패의 값으로 비동기화시킨 것이다.
정리하자면 어떤 로직을 수행할 때, 값이 지금 존재하는지를 알기 여려운 경우, 해당 값을 지금 값이 존재하는지, 미래에 어느 시점에 존재하는 값으로 바뀌는지는 알 수 없지만 값 자체를 나중
에 언젠가는 존재할 값이라는 가정 하에 로직을 수행하고, 나중
에 그 값을 얻게 되면, 그 값은 구체적으로 로직의 연산 결과를 반환하는 값은 아니지만 반드시 성공 또는 실패한 값이다.
이 개념을 기억하며 프로미스 함수를 좀 더 자세히 살펴보자. 위 예제의 x + y를 프로미스 함수로 나타낸 것이다.
1 | function add(xPromise, yPromise) { |
Promise.all([])은 프로미스 배열을 인자로 받아서 프로미스가 모두 귀결될 때까지 기다렸다가 새 프로미스를 만들어 반환한다. 그리고 프로미스가 귀결되면 X와 Y 값을 받아 더하고, then에서 파라미터로 전달받은 values는 앞에서 귀결된 프로미스가 전달해 준 메시지 배열이다. 그리고 fetchX()와 fetchY()는 제각기 값을 가진 프로미스를 반환하는데, 지금 또는 나중에 준비된다. add()의 결과로 두 숫자의 합이 담긴 프로미스를 받으면 이제 반환된 프로미스가 귀결될 때까지 대기하기 위해 then()을 연쇄 호출한다.
위 예제에는 두 개의 프로미스 계층이 있다. 먼저 두 프로미스 fetchX()와 fetchY()를 호출하여 반환된 값은 add()에 전달된다. 프로미스 속의 원래 값은 지금 또는 나중에 준비되는데 그 시점과는 상관없이 각 프로미스가 같은 결과를 내게끔 정규화하고, 미랫값 X,Y는 시간 독립적으로 추론이 가능하다. 그리고 그 다음 프로미스 계층은 add()가 Promise.all을 거쳐 반환한 프로미스인데, then()을 호출하고 대기한다. add()가 끝나면 덧셈을 마친 미랫값이 콘솔에 출력된다.
이렇게 프로미스는 Fulfillment
또는 Rejection
으로 귀결될 수 있다. 여기서 Fulfillment는 항상 프로그램이 귀결값을 결정짓고, Rejection은 프로그램 로직에 따라 직접 세팅되거나 런타임 예외에 의해 암시적으로 생겨난다.
그리고 프로미스 then() 함수는 Fulfillment 함수를 첫 번째 인자로, Rejection 함수를 두 번째 인자로 넘겨받는다. 그렇기 때문에 X나 Y의 조회 시 문제가 있거나 연산에 실패하면 add()가 반환하는 프로미스는 버려지고, then()의 두 번째 에러 처리 콜백이 이 프로미스에서 Rejection을 받는다.
요약해보자면 프로미스는 시간 의존적인 상태를 외부로부터 캡슐화하기 때문에 타이밍 또는 내부 결과값에 상관없이 예측 가능한 방향으로 조합할 수 있다. 또 프로미스는 일단 귀결되면 그 상태가 그대로 유지되는 불변값이다.
프로미스
프로미스 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값이다.
위에서 보았던 여러 예제를 통해 프로미스의 성격에 대해 알아보았다. 프로미스에서는 미랫값이라는 개념이 중요했다. 미랫값은 지금 당장은 값은 알 수 없고, 어느 시점에 그 값이 확정되는지 알 수 없지만 성공 또는 실패일 것으로 추론할 수 있는 값이었다. 프로미스는 이처럼 지금 당장 실행하는 시점에서 아직 알려지지 않을 수도 있는 값을 위한 대리자이고, 미랫값 개념을 콜백으로 다루는데, 비동기 연산이 종료된 이후에 결과 값과 실패 사유를 처리하기 위한 처리기를 연결할 수 있다. 그래서 프로미스를 사용하면 비동기 메서드에서 마치 동기 메서드처럼 값을 반환할 수 있다. 다만 최종 결과를 반환하는 것이 아니고, 미래의 어떤 시점에 결과를 제공하겠다는 '약속'(프로미스)을 반환한다.
그래서 프로미스는 다음 중 하나의 상태를 가진다.
- 대기(pending): 이행하지도, 거부하지도 않은 초기 상태.
- 이행(Fulfillment): 연산이 성공적으로 완료됨.
- 거부(Rejection): 연산이 실패함.
Promise가 생성된 시점에 대기 중인 프로미스 값은 성공적으로 이행(Fulfillment)이 되어서 위 예제에서 처럼 X + Y의 값을 얻을 수도 있고, 오류와 같은 이유로 인해 이행이 불가하여 거부(Rejection)될 수도 있다. 그리고 프로미스가 이행이나 거부로 귀결되면 then 메서드에 의해서 처리기들이 호출된다. 이 프로미스는 시간 의존적인 상태를 외부로부터 캡슐화하기 때문에 프로미스 자체는 시간 독립적이고, 따라서 언젠가 이행(Fulfillment)되거나 거부(Rejection)될 것을 예측하여 로직을 구성할 수 있다.
프로미스의 완료 이벤트
프로미스는 각각 미래값으로서 작동하지만 프로미스의 귀결은 비동기 작업의 여러 단계를 흐름 제어하기 위한 체계이다.
어떤 일을 하는 foo()라는 함수를 호출한다고 했을 때,
- 전통적인 자바스크립트 사고 방식 :알림 자체를 하나의 이벤트로 보고 리스닝하고, foo()의 완료 이벤트를 리스닝함으로써 알림 요건을 재구성한다.
- 콜백에 : foo()에서 넘겨준 콜백을 호출하면 성립된다.
- 프로미스에서 : foo()에서 이벤트를 리스닝하고 있다가 알림을 받게 되면 다음으로 진행한다.
아래는 프로미스의 예제이다.
1 | foo(x) { |
foo()를 호출한 뒤에 완료, 에러 이벤트를 각각 리스닝하는 이벤트 리스너를 설정했다. foo()를 호출하면 foo()에서 이벤트를 받아 어떻게 처리하는지를 신경쓰지 않아도 되고, foo()의 결과는 완료 아니면 에러인 관심사의 분리
가 된다.
이러한 코드는 사실상 일반적으로 콜백이 지향하는 코드와 정반대이다. 아래 예제를 보면 foo()에 콜백 함수를 넘겨주는 대신 foo()가 이벤트 구독기를 반환하고 여기에 콜백 함수를 넣는다. 이처럼 콜백 패턴을 뒤집는다는 것은 사실상 제어의 되역전
으로 서드파티와 같은 다른 파트에 주었던 실행 흐름의 제어권을 호출부에 되돌려 둔 것이다.
1 | function foo(x) { |
이 제어의 되역전
으로 인해 이제 관심사를 분리할 수 있게 된다. 아래와 같은 코드가 있으면 bar()나 baz()는 foo()가 호출되어 처리되는 것에 신경 쓸 필요 없고, foo()도 마찬가지로 누군가 자신을 기다리고 있다는 사실을 몰라도 된다. 이렇게 분리된 관심사 간에 중재자 역할을 evt가 하는 것이고, 이것이 프로미스와 매우 유사하다. 프로미스식으로 아래 코드를 다시 작성한다면 foo()는 프로미스 인스턴스를 생성해서 반환하고, 이 프로미스를 bar()와 baz()에 전달할 것이다.
1 | var evt = foo(42); |
References
Promise
[YOU DON’T KNOW JS] 카일 심슨 지음