본문 바로가기
  • soldonii's devlog
Javascript 공부/TIL

200118(토) : 비동기 - 콜백과 프라미스

by soldonii 2020. 1. 18.

1. 비동기

  • 프로그램에서 '지금'에 해당되는 부분과 '나중'에 해당되는 부분 사이의 관계가 비동기 프로그램의 핵심이다.
  • '나중'은 '지금'의 직후를 의미하지 않는다!(즉, 코드에서 '지금' 요청을 보내자마자 바로 응답을 받아서 실행할 수 있지 않다!)
  • AJAX로 예를 들자면, AJAX는 비동기적으로 '지금' 요청하고, '나중'에 결과를 받는다.
  • '지금'부터 '나중'까지 "기다리는" 가장 간단한 방법은 '콜백 함수'라는 장치를 이용하는 것이다.

 

const data = ajax("http://some.url.1");
console.log(data); // data 변수에 원하는 값은 담겨있지 않다!

ajax("http://some.url.1", function cbFunc(data) {
  console.log(data); // 콜백 함수 내부에서야 비로소 data에 원하는 값이 담긴다!
});

 

1) 이벤트 루프

  • 사실 자바스크립트에는 '비동기'라는 개념은 존재하지 않는다! 단지 자바스크립트 엔진은 요청이 들어오면, 그에 맞춰 프로그램을 실행할 뿐이다!
  • 자바스크립트 엔진은 혼자서는 실행할 수 없고, 반드시 호스팅 환경(ex. 웹 브라우저, node.js 서버 등)에서만 실행이 가능하다.
  • 하지만 어떤 환경에서 실행되든 변하지 않는 것은 '스레드(thread)'는 공통이다. 일정 시간에 따라 한 번씩 엔진을 실행시키는 '이벤트 루프'라는 장치를 말한다.
  • 즉, 자바스크립트 엔진은 주어진 프로그램 덩이를, 이벤트 루프의 신호에 의거해서 타이밍이 됐을 때 처리하는 단순 실행기일 뿐이다.
  • "이벤트"(자바스크립트 코드 실행)를 스케쥴링하는 일은 언제나 자바스크립트 엔진을 감싼 '주위 환경'의 몫이다.
  • 다만 ES6(ES2015)부터는 Promise 도입을 기점으로 이벤트 루프 큐의 관리가 호스팅 환경이 아닌 자바스크립트 엔진의 관할이 되었다.

 

2) 완전-실행(Run-to-Completion)

  • 자바스크립트는 싱글 쓰레드이기 때문에, 절대로 한 번에 두 개의 작업이 실행될 수는 없다.
  • 따라서 foo()라는 함수가 일단 실행되면, 이 함수 코드 전체가 실행되고 나서야 비로소 bar() 함수가 실행을 시작할 수 있다.
  • 자바스크립트의 완전-실행 특성 때문에 함수의 실행 순서에 따라서 최종 결과값이 달라질 수 있는데, 이처럼 함수 순서에 따른 비결정성을 '경합 조건'이라고 한다.

 

3) 동시성

  • 동시성은 복수의 '프로세스'가 동일한 시간 동안 동시에 실행됨을 의미한다.
  • 각 프로세스 작업들이 병렬로(별개의 프로세서/코어에서 동일한 시점에) 처리되는지와는 전혀 관계가 없다. 즉, 프로세스(작업) 수준의 병행성을 의미한다.
  • 주의할 점은 여러 개의 프로세스는 동시에 실행될 수 있지만, 이들을 구성하는 이벤트들은 이벤트 루프 큐에서 차례대로 실행된다.
  • 동시성, 즉 복수의 프로세스가 동시에 진행될 때, 두 프로세스 간 상호작용이 없다면 코드의 경합조건은 발생하지 않는다.
  • 하지만 두 프로세스가 서로 상호작용을 할 경우(ex. 동일한 스코프를 공유하는 상황) 경합조건이 발생하기 때문에 상호작용 순서를 잘 조정해야 한다.
  • 글만 읽으면 뭔말인지 모르겠으니 코드로 보자.

 

const result = [];

const addDataToArr = (data) => result.puh(data);

ajax("http://some.url.1", addDataToArr); // 요청1
ajax("http://some.url.2", addDataToArr); // 요청2
console.log(result); // 응답 1이 먼저, 응답 2가 이후에 배열에 담긴다고 보장할 수 없다.
const result = [];

const addDataToArr = (data) => {
  if (data.url === "http://some.url.1") result[0] = data;
  else if (data.url === "http://some.url.2") result[1] = data;
}

ajax("http://some.url.1", addDataToArr); // 요청1
ajax("http://some.url.2", addDataToArr); // 요청2
console.log(result); // 응답 1이 앞에, 응답 2가 뒤에 위치하게 된다.

 

  • 실생활 예로 보자면, a 함수는 DOM에서 <div> 태그의 내용을 변경하고, b 함수는 변경된 <div> 태그의 css 속성을 변경하길 원한다고 가정해보자.
  • 이 경우 위에서 언급한 두 프로세스 간 상호작용이 존재하는 경우이다. 따라서 경합조건이 발생하므로 순서를 면밀히, 즉 a 함수 실행 완료가 보장된 이후 b 함수가 실행되도록 조정해야 한다.

 

2. 콜백

  • 자바스크립트에서 콜백은 비동기를 처리하는 가장 단순하고 기본적인 방식이다.
  • 하지만 우리 두뇌는 순차적인 반면 비동기는 우리 두뇌의 흐름과 벗어난 실행 순서를 보이기 때문에 이해하기 어려워진다.
  • 인간이 어떤 작업을 계획할 때에는 동기적으로 계획한다. 예를 들면, 오늘은 일어나서 샤워를 한 후, 아침을 먹고, 카페에 가서 공부를 하고, 내용을 정리해서 블로깅을 하고... 이런 식으로.
  • 반면 해당 일들을 처리하는 과정에서 두뇌는 저 순서대로 곧이 곧대로 일하지 않고 수 없이 두뇌가 끊임없이 왔다갔다 하며 일을 처리한다.
  • 결론적으로, 인간의 두뇌는 단계별로(동기적으로) 생각하기 때문에, 두뇌의 구조에서 벗어난 비동기 작업, 특히 콜백으로 작성된 비동기 코드를 이해하기란 버거운 일일 수 밖에 없다.
  • 또한 콜백을 사용하는 과정에서 외부 써드파티 프로그램을 이용할 경우가 있을 텐데, 이 경우 비동기 흐름에 대한 제어권을 상실하게 된다. 내가 작성한 코드임에도 써드파티의 코드에 따라 실행 흐름이 의존되는 상황에 부딪힐 수 밖에 없다.
  • 이러한 상황을 타개하기 위해 각종 예측 가능한 상황에 대한 대비 로직을 짜넣을 경우, 프로그램이 비대해질 뿐 아니라 유지보수가 어려워지는 문제 또한 낳게 된다.

 

3. Promise

Promise를 이용하면 콜백이 다른 곳에 전달됐을 때 잃게되는 제어권을 다시 회복할 수 있다. 즉, 언제 작업이 끝날지 알 수 있고 따라서 이후 어떤 일을 해야할 지 스스로 결정할 수 있다.

 

  • 콜백으로 비동기를 처리할 때 항상 문제가 되는 것 중 하나는 '지금'과 '나중'의 간극으로 인한 것이다. 원하는 결과를 '나중'에 모두 얻은 뒤 최종 원하는 결과를 실행해야 하는데, '나중'이 언제일지 알기가 어렵다보니 시점에 따라서 원하지 않은 결과값을 토대로 코드가 실행되버리고 마는 것이다.
  • 하지만 Promise를 사용하면, 프라미스 속의 값은 '지금' 혹은 '나중'에 준비될 수 있지만, 그 결과물은 언제나 동일함을 보장받을 수 있다. 따라서 시점과 별개로 어떤 값이 들어올지 추론할 수 있고 이를 기반으로 추후 작업을 지시할 수 있다.
  • 또한 비동기 작업에 대한 error 핸들링 또한 native javascript의 에러 핸들링(try, catch)과 유사하여 더 우아한 에러처리가 가능하다.

 

결론적으로, Promise를 이용하면 1) 시간 의존적('지금'과 '나중' 사이의 간극)인 상태를 외부로부터 캡슐화하기 때문에 Promise 자체는 '시간 독립적'이므로 시점이나 결과값과 관계 없이 예측 가능한 방향으로 코드를 구성할 수 있다. 2) 또한 Promise는 상태가 귀결된 Promise 객체를 리턴하기 때문에, 한 번 귀결되면 그 상태가 그대로 유지(Promise는 불변적이다!) 될 뿐 아니라 언제, 어디에나 넘겨서 사용할 수 있다.

 

1) 완료 이벤트

  • 콜백에서는 작업부에서 우리가 넘겨준 콜백을 호출(ex. foo(..))하면 알림이 성립된다. 즉, 실행하면 => 알림을 준다.
  • 하지만 Promise는 관계가 역전되어, foo(..)에 서 이벤트를 리스닝하다가 알림을 받으면 다음으로 진행한다. 즉, 알림을 주면 => 실행한다. 콜백에서의 실행-알림 관계가 역전된 것이다.
  • 구체적으로는 foo(..)를 호출한 뒤 나올 수 있는 결과는 성공 또는 에러 뿐인데, Promise는 이 성공(resolve) 또는 에러(reject) 알림을 반환하게 된다.
  • 위에서 콜백의 가장 큰 단점으로 제어권의 상실을 이야기했는데, Promise는 콜백에서의 실행-알림 관계를 역전시킴으로써 되려 제어권을 Promise 자신이 가져오게 되는 이점을 가지게 된다.
  • 뿐만 아니라, foo(..) 호출 완료 후 실행될 작업(ex. 성공시에는 bar(..) 함수를, 에러 시에는 baz(..) 함수를 호출한다고 가정)은 foo(..)의 호출에 끼어들 필요가 없이 결과에 대한 알림만 기다리면 되고, foo(..) 입장에서도 어떤 작업들이 foo 자기 자신의 완료 또는 성공 알림을 기다리고 있는지 신경쓰지 않아도 된다. Promise를 사용하면 자연스럽게 서로 관심사가 분리(seperation of concerns)된다.
  • 또 Promise는 한 번 인스턴스가 생성되면 언제나 동일한 값을 가지기 때문에 프로그램의 여러 곳에서 같은 결과값으로 사용할 수 있다.
  • 어떤 값의 타입을 그 형태(어떠한 프로퍼티가 있는가)를 보고 확인하는 type 체크 방법을 일반적으로 '덕 타이핑(duck typing)'이라고 한다.
  • 프라미스는 then 메소드를 가진, 'thenable'이라는 객체 또는 함수를 정의하여 판별할 수 있다. 데너블에 해당하면 무조건 프라미스 규격에 맞는 것이다.

 

2) 프라미스의 믿음성 문제

  • 콜백만을 사용하여 비동기를 처리할 때에는 다음과 같은 문제가 야기될 수 있었다. 1) 너무 빨리 콜백을 호출, 2) 너무 늦게 콜백을 호출, 3) 너무 적게 혹은 많이 콜백을 호출, 4) 필요한 환경/파라미터를 정상적으로 콜백에 전달 못함, 5) 발생 가능한 에러/예외 무시.
  • 그러나 프라미스는 이 모든 문제들에 대해 해결책을 제시한다.

 

(1) 너무 빨리 호출

  • 프라미스의 정의 상, .then을 호출했을 때 이미 프라미스가 귀결된 상태라 할 지라도, .then에 건네진 콜백은 항상 비동기적으로만 부르기 때문에 문제가 되지 않는다.

(2) 너무 늦게 호출

  • 마찬가지로, 프라미스는 resolve 혹은 reject 둘 중 하나를 호출하도록 스케쥴링이 되고, 이들이 호출되면 .then에 등록된 콜백들이 비동기 기회가 있을 때 순서대로 실행되기 때문에 다른 콜백이 또 다른 콜백에 영향을 줄 수 없다.

(3) 한번도 콜백을 호출하지 않음

  • 이 또한 프라미스는 귀결이 되면 resolve, reject 둘 중 하나는 반드시 호출하기 때문에 콜백이 호출되지 않을 일은 없다.

(4) 너무 가끔 또는 종종 호출

  • 프라미스는 정의 상 단 한번말 귀결되기 때문에, resolvereject를 여러 차례 호출하려고 시도할 경우, 프라미스는 최초의 귀결만을 취급하고 그 이후는 조용히 무시한다.

(5) 파라미터/환경 전달 실패

  • 프라미스는 resolve reject 중 하나로 귀결되며, 만약 귀결되지 않으면 undefined로 세팅되지만, 그 때에도 이후 콜백으로 프라미스는 반드시 전달이 된다.
  • 주의할 점은, resolvereject 시 여러 개의 파라미터를 넘겨도 첫번째 파라미터 이외의 것들은 모두 무시된다. 값을 여러 개 넘기고 싶다면 반드시 배열 또는 객체로 감싸줘야 한다.

(6) 에러/예외 무시

  • 프라미스의 생성 또는 귀결 중에 에러가 발생하면 reject로 귀결 시 실행될 콜백에 해당 에러 내용이 전달된다.
  • 중요한 점은 프라미스는 에러가 아닐 경우에는 비동기적으로 작동하지만, 에러 발생 시 동기적으로 바로 에러를 처리하기 때문에 경합 조건을 무척 줄일 수 있다.

 

3) 프라미스의 흐름 제어 특징

  • .then(..)을 호출하면 그 결과 자동으로 새 프라미스를 생성해서 반환한다.
  • resolve, reject 내에서 특정 값을 반환 혹은 예외를 던지면 그 결과로 연쇄 가능한 새 프라미스가 귀결된다.

 

4) 프라미스 용어 정리

  • 보통 Promise의 사용 예제를 보면 아래와 같은 코드로 이루어져 있다.
const promise = new Promise((resolve, reject) => {
  resolve(Promise.reject('rejected')); // Promise의 귀결을 정의하는 부분
});

promise.then(
  function fulfilled() {
      //...
  },
  function rejected(err) {
    console.log(err);
  }
);

 

  • 위 코드는 프라미스가 어떻게 귀결될지를 정의하고 있는데, Promise.reject('rejected'), 즉 버림의 상태로 귀결시킴을 알 수 있다.
  • 즉, promise가 이룸(fulfilled)으로 귀결될 때를 정의하는 것이 아니라, 귀결(resolve)될 때 어떻게 귀결될지를 정의하는 부분이기 때문에 resolve로 쓰는 것이 맞다.
  • 반면 promise가 귀결된 프라미스를 전달받는 콜백함수들은, 반드시 이뤄졌을 때와 반드시 버려졌을 때의 행동을 나눠서 작성하기 때문에 .then 내부에서는 fulfilledrejected와 같은 함수명이 적절하다.

'Javascript 공부 > TIL' 카테고리의 다른 글

200125(토) : Merge Sort, Quick Sort  (0) 2020.01.25
200124(금) : Bubble Sort, Insertion Sort  (0) 2020.01.24
200117(금) : this  (0) 2020.01.17
200116(목) : Property Descriptor  (0) 2020.01.16
200115(수) : import, export  (0) 2020.01.15

댓글