[모던JS: Core] 비동기 - 프라미스(Promise) 및 async/await (1)

KG·2021년 5월 27일
1

모던JS

목록 보기
19/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

콜백 (Callback)

자바스크립트는 요청을 보낼 때 크게 동기(sync) 방식과 비동기(async) 방식으로 구분할 수 있다. 특히 웹 브라우저에서 통신 간 발생하는 요청은 대부분 비동기로 처리되는 경우가 많다. 비동기는 요청을 발송하고 이에 대한 응답이 반환될 때 까지 CPU 및 기타 자원을 놀게 두지 않고 다른 일을 할 수 있도록 효율적인 동작이 가능하다.

자바스크립트 호스트 환경이 제공하는 여러 함수를 사용하면 비동기 동작 스케줄링이 가능하다. 이전 챕터에서 살펴보았던 setTimeout 함수가 대표적인 스케줄링 함수이다.

비동기 요청은 강력한 만큼 그 설계와 구조가 동기 방식에 비해 직관적이지 않고 복잡하다. 특히 지금 다룰 콜백(callback)을 이용한 비동기 요청은 그 구조가 매우 깊거나 복잡할 수 있다. 그러나 본래 자바스크립트는 ES6(ES2015) Promise가 도입되기 전까지는 콜백방식을 이용해 비동기를 처리했다. 따라서 콜백 방식을 어떻게 사용했고 그에 따른 단점이 어땠는지 살펴보도록 하자.

1) 콜백 기본 개념

보통 스크립트 또는 모듈을 로딩하는 것 역시 비동기로 처리하는 경우가 많다. 관련 파일의 용량이 큰 경우, 모두 완료되기 까지 기다리고 다음 작업을 처리한다면 정체현상이 일어날 수 있기 때문이다. 다음과 같이 외부의 파일을 읽어 스크립트로 추가하는 함수가 있다고 하자.

function loadScript (src) {
  // <script> 태그를 만들고
  let script = document.createElement('script');
  // 스크립트 파일 속성을 지정 (외부 링크)
  script.src = src;
  // 해당 스크립트 태그를 도큐멘트 헤더에 추가
  document.head.append(script);
}

해당 함수를 이용하여 스크립트 로딩을 수행할 때, 이는 비동기적으로 실행된다. 로딩은 지금 당장 시작되더라도 실행 자체는 함수가 끝나야 되기 때문이다. 따라서 loadScript 아래에 위치하는 코드들은 스크립트 로딩이 종료되는 것을 기다리지 않는다.

// 해당 파일에 newFunctionInScript() 함수가 있다고 할때
loadScript('/my/script.js');

newFunctionInScript();	// 함수가 존재하지 않는 에러 발생

앞서 이야기 했듯 스크립트를 읽어오기 까지 일정 시간이 소요될 수 있다. 즉 충분히 스크립트를 읽을 시간을 확보하지 못했기 때문에 위와 같은 에러가 발생한다. 따라서 우리는 스크립트를 다 읽어왔는지 체크하기 위한 방법이 필요한데, 현재로서는 이 지점을 체크할 수가 없다. 이를 위해 우리가 활용할 수 있는 것이 loadScript의 인수로 함수를 던져주는 것이다. 이때 전달하는 함수를 콜백(callback)이라고 한다. 그 의미는 영단어에서 말하듯이 나중에 호출할 함수를 의미한다. 즉 콜백은 자바스크립트에서 어떤 특별한 동작을 수행하는 함수가 아닌 일반 함수이다.

function loadScript (src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  // 스크립트의 로드가 완료되었을 때 호출되는 함수
  script.onload = () => callback(script);
  
  document.head.append(script);
}

이처럼 콜백함수를 통해 스크립트가 완료되었을 때 이후의 동작을 지정해준다면, 완료 시점 이후에 우리가 원하는 동작을 진행할 수 있다. 앞서 에러가 있던 코드를 다음과 같이 콜백을 통해 전달하도록 수정한다면 원했던 대로 동작하는 것을 보장할 수 있다.

loadScript('/my/script.js', function() {
  // 함수 자체가 callback 으로 전달된다.
  // 전달된 callback은 모든 로딩이 완료되고서 실행된다
  // 따라서 실행시점에서 외부 파일에 있던 함수를 사용 가능하다
  newFunctionInScript();
});

이 같은 방식을 콜백 기반 비동기 프로그래밍이라고 한다. 앞서 이야기한 것과 같이 ES6(ES2015) 이전에는 이와 같은 방식으로 모든 비동기 요청을 처리했다.

2) 콜백 속 콜백

만약 스크립트가 두 개 있는 경우라면, 어떻게 순차적으로 두 스크립트를 불러올 수 있을까? 이때 두 번째 스크립트 로딩은 당연히 첫 번째 스크립트의 로딩이 끝난 이후가 되어야 한다.

앞서 구현한 바와 같이 콜백을 사용하여 해결할 수 있다. 가장 간단하게 떠올릴 수 있는 방법으로는, 콜백 함수 안에서 두 번째 loadScript를 호출하는 방법이다.

loadScript('/my/script.js', function(script) {
  console.log(`${script.src}를 로딩했습니다.`);
  
  loadScript('/my/script2.js', function(script) {
    console.log(`${script.src}를 로딩했습니다.`);
  });
});

이를 콜백 속의 콜백, 또는 중첩콜백이라고 한다. 중첩 콜백에서는 바깥에 위치한 콜백이 먼저 실행되고 나서 안쪽에 있는 콜백이 실행된다. 이러한 콜백에 개수에는 제한이 없다. 몇개의 콜백이 있던간에 외부에서 안쪽으로의 실행 흐름은 보장된다.

loadScript('...', function(script) {
  loadScript('...', function(script) {
    loadScript('...', function(script) {
      loadScript('...', function(script) {
        ...
      });
    });
  });
});

이와 같이 작성하더라도 새로운 동작이 콜백 안에 위치하고 있다면 순서 흐름은 외부에서 안쪽으로 일정하다. 하지만 이러한 방식으로 작성할 때 콜백의 개수가 두세개라면 괜찮지만 동작이 많아지는 경우에는 가독성이 굉장히 떨어진다. 이와 같이 콜백에 콜백이 꼬리를 물고 늘어지는 것을 콜백 지옥 또는 멸망의 피라미드라고 부른다.

호출이 중첩되면서 코드의 depth가 깊어지는 것은 좋은 일이 아니다. 그만큼 가독성이 떨어지고 코드 관리가 힘들어진다. 만약 중간중간 다른 로직을 처리하는 반복문과 조건문 등이 들어가게 되면 더욱 더 알아보기 힘들어 질 것이다.

위 그림과 같이 비동기 동작이 하나씩 추가될 때 마다 중첩 호출이 만들어내는 피라미드는 오른쪽으로 계속 쌓여갈 것이다. 때문에 이러한 방식은 결코 좋지 않다.

각 동작을 독립적인 함수로 만들어 관리하면 depth가 계속 깊어지는 것을 방지할 수 있다.

loadScript('1.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    loadScript('2.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    loadScript('3.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
  }
}

이처럼 작성한다면 무한정 depth가 깊어지는 것을 어느정도 방지할 수 있다. 그리고 여전히 콜백 기반으로 동작하는 것 역시 보장한다. 그렇다면 문제가 완벽하게 해결된 것일까?

동작상의 문제는 없지만 이 경우 각 함수가 찢겨진 종이조각처럼 산발적으로 존재한다는 문제가 있다. 때문에 여전히 가독성 측면에서 좋지 않다. 코드의 흐름에 따라 계속해서 컨텍스트의 위치가 바뀌기 때문이다.

또한 이처럼 함수를 독립적으로 분할하며 명명한 step... 함수들은 오직 콜백지옥을 회피하기 위한 목적으로 만들었기 때문에 재사용이 불가하다는 단점 역시 존재한다. 연쇄 동작이 일어나는 코드 외부에서는 이러한 함수들을 재활용 할 수 없기에 네임 스페이스가 다소 복잡해지게 된다.

이러한 방식은 좋은 코드를 작성하는데에 문제가 된다. 다행히 이러한 콜백지옥을 회피할 수 있는 방법이 존재한다. 가장 좋은 방법 중에 하나는 다음 챕터에서 이야기할 프라미스(Promise)를 활용하는 것이다.

3) 에러 핸들링

위에서 잠깐 살펴보았지만 콜백 함수는 항상 에러를 핸들링할 수 있어야 한다. 위의 예시로 보자면 스크립트 로딩이 어떤 사정에 의해 실패할 가능성이 존재하기 때문이다. 따라서 loadScript 함수를 로딩 에러가 추적 가능하도록 개선해보자.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error('에러발생'));
  
  document.head.append(script);
}

loadScript('/my/script.js', function(error, scirpt) {
  if (error) {
    // 에러핸들링
  } else {
    // 스크립트 로딩이 성공적으로 완료
  }
});

콜백을 이용한 에러처리에서 위와 같은 방식은 흔히 사용되는 패턴이다. 이러한 패턴을 오류 우선 콜백(error-first-callback)이라고 부른다. 이는 다음의 순서로 처리되며 에러를 핸들링한다.

  1. callback의 첫 번째 인수는 에러를 위해 남겨둔다. 에러가 발생하면 이 인수를 이용해 callback(err)이 호출된다.
  2. 두 번째 인수(필요시 더 추가가능)는 에러가 발생하지 않았을 때를 위해 남겨둔다. 원하는 동작이 성공한 경우에는 callback(null, result1, result2, ...)이 호출된다.

이처럼 오류 우선 콜백 패턴에서는 단일 콜백 함수에서 에러 케이스와 성공 케이스를 모두 처리할 수 있다.

프라미스 (Promise)

프라미스(Promise)는 ES6에 추가되었다. 요즈음 자바스크립트에서 비동기 요청을 처리할 때는 기본적으로 프라미스를 기반으로 처리한다. 약속이란 뜻의 프라미스는 비동기 요청에 맞게 그 결과를 바로 반환하지 않고 일종의 '약속값'을 반환한다. 개발자는 프라미스가 반환한 약속값을 가진 객체를 가지고 추후 반환되는 결과 또는 에러 등에 접근할 수 있다.

본격적으로 프라미스를 다루기 전에 다음의 개념을 익히고 들어가자.

  1. 제작코드(producing code) : 원격에서 스크립트를 불러오는 등의 시간이 걸리는 일을 수행한다.
  2. 소비코드(consuming code) : 제작코드의 결과를 기다리고, 결과가 반환되면 이를 소비(사용)한다.
  3. 프라미스(Promise) : 제작코드소비코드를 연결해 주는 특별한 자바스크립트 객체이다. 프라미스는 시간이 얼마나 걸리든 상관없이 약속한 결과를 만들어 내는 제작코드가 준비되었을 때, 모든 소비코드가 결과를 사용할 수 있도록 한다.

1) 프라미스 (Promise) 기본 개념

promise 객체는 다음과 같은 문법으로 만들 수 있다.

let promise = new Promise(function(resolve, reject) {
  // executor (제작코드)
});

new Promise에 전달되는 함수는 executor(실행자)라고 부른다. executornew Promise가 만들어질 때 자동으로 실행되는데, 결과를 최종적으로 만들어내는 제작코드를 포함한다.

executor의 인수 resolvereject는 자바스크립트에서 자체 제공하는 일종의 콜백이다. 개발자는 resolvereject를 신경 쓰지 않고 executor 내부 코드만 작성할 수 있다. 대신 executor에선 결과를 즉시 얻든 늦게 얻든 상관없이 상황에 따라 인수로 넘겨준 콜백 중 하나를 반드시 호출해야 한다.

  • resolve(value) : 일이 성공적으로 끝난 경우 그 결과를 나타내는 value와 함께 호출

  • reject(error) : 에러 발생 시 에러 객체를 나타내는 error와 함께 호출

new Promise 생성자는 내부적으로 executor를 거쳐promise 객체를 반환하는데 해당 객체는 다음과 같은 내부 프로퍼티를 가지고 있다.

  • state : 처음엔 pending(대기) 상태를 유지하다가 resolve 호출 시 fullfilled, reject 호출 시 rejected로 갱신
  • result : 처음엔 undefined 였다가 resolve(value)가 호출되면 value로, reject(error)가 호출되면 error로 갱신

promise 생성자와 간단한 executor 함수로 만든 예시를 살펴보자. setTimeout 함수를 이용해 executor 함수는 1초의 시간이 소요된 후 실행된다.

let promise = new Promise(function (resolve, reject) {
  // 프라미스가 만들어짐과 동시에 자동으로 executor 함수 실행
  
  // 1초 뒤에 resolve에 'done'이 전달되며 result는 'done'으로 갱신
  setTimeout(() => resolve('done'), 1000);
});

위에 예시로 든 코드는 다음과 같은 흐름을 가진다.

  1. executor 함수는 new Promise에 의해 자동으로 그리고 즉각적으로 호출
  2. executor는 인자로 resolvereject 함수를 전달받아 둘 중 하나는 반드시 호출 (위에서는 resolve 호출)

반환되는 promise의 객체는 초기 좌측의 상태에서 1초후에 우측의 상태로 전환된다. 이처럼 성공적으로 처리된 경우의 프라미스는 fullfilled promise라고 부른다.

이번에는 executor가 에러와 함께 약속한 작업을 거부하는 경우에 대해 살펴보자. 1초 후에 reject(...)가 호출되면서 promise의 상태가 rejected로 갱신될 것이다.

let promise = new Promise(function (resolve, reject) {
  // 1초 뒤 에러와 함께 실행 종료 신호를 전달
  setTimeout(() => reject(new Error('에러발생')), 1000);
});

fullfilled 또는 rejected의 상태를 가지고 있는 promise를 처리된(settled) 프라미스라고 부른다. 반대의 경우는 앞서 살펴본 pending 상태를 가진 프라미스이다.

프라미스는 항상 성공(fullfilled) 또는 실패(rejected)만 하는 것을 보장한다. 이때 변경된 상태는 더 이상 변하지 않는다. 처리가 끝난 프라미스에 추가적으로 resolvereject를 호출하더라도 이는 무시된다.

let promise = new Promise(function (resolve, reject) {
  resolve('done');	// 결과가 정해짐
  
  reject(new Error('error'));		// 무시됨
  setTimeout(() => resolve('...'));	// 무시됨
});

또한 resolvereject는 자바스크립트 엔진이 미리 정의한 함수로 개발자가 따로 만들 필요가 없는 네이티브 함수이다. 이들은 인수를 최대 하나만 받을 수 있다. 이를 초과한 경우 나머지 인수는 무시한다. 또는 아무런 인수를 전달받지 않는 것 역시 가능하다.

프라미스는 주로 비동기 요청에 많이 응용되어, 내부의 executor 함수가 특정 시간이 걸리는 작업을 수행하고 resolve 또는 reject를 호출하는 패턴을 가지는데, 이는 문법적으로 강제되는 요소는 아니다. 즉 아래와 같이 resolve 또는 reject를 즉시 호출해도 상관없다.

let promise = new Promise(function(resolve, reject) {
  // 즉시 호출
  resolve(123);
});

이와 같이 선언한다면 프라미스는 즉시 이행 상태가 된다.

프라미스 객체가 가지고 있는 내부 프로퍼티인 stateresult는 개발자가 직접 접근할 수 없다. 단 .then/.catch/.finally 메서드를 사용하면 접근이 가능하다.

2) 소비자: then / catch / finally

앞서 에러 핸들링 챕터에서 try...catch를 다루면서 이미 catchfinally에 대해서 다룬 바 있다. 이와 유사한 쓰임새를 가지면서 추가적으로 then이 추가되었다고 볼 수 있다.

프라미스 객체는 executor와 결과 또는 에러를 받을 소비 함수를 이어주는 역할을 한다고 했는데, 이때 소비 함수는 then/catch/finally 메서드를 사용해 등록(구독)할 수 있다.

1. then

then은 프라미스에서 가장 중요하고 기본이 되는 메서드이다. 문법은 다음과 같다.

promise.then(
  function(result) { /* 결과 핸들링 */ }
  function(error) { /* 에러 핸들링 */ }
);

then 메서드의 첫 번째 인수는 프라미스가 이행(fullfilled)되었을 때 실행되는 함수이고, 여기서 그 실행결과를 받을 수 있다.

then 메서드의 두 번째 인수는 프라미스가 거부(rejected)되었을 때 실행되는 함수이고, 여기서 그 에러를 받을 수 있다.

let promise = new Promise(function (resolve, reject) {
  /*(1)*/ setTimeout(() => resolve('done'), 1000);
  /*(2)*/ setTimeout(() => reject(new Error('에러')), 1000);
});


promise.then(
  result => console.log(result)
  error => console.log(error)
);

위 코드는 예시를 위해 하나의 executor에서 resolvereject를 동시에 호출하고 있다. 그러나 문법적으로 하나의 상태가 처리되면 나머지는 무시된다. 여기서는 분기별 상황을 보기 위해 그냥 편의상 한 곳에 모아두었다는 점을 감안하고 살펴보자.

만약 (1)에서 선언된 resolve가 호출된다면 then 메서드는 1초후 done을 호출한다. 즉 then 메서드의 첫 번째 인수인 result => console.log(reulst)가 실행된다.

만약 (2)에서 선언된 reject가 호출된다면 then 메서드는 1초후 에러를 발생한다. 즉 then 메서드의 두 번째 인수인 error => console.log(error)가 실행 된다.

따라서 만일 작업이 성공적으로 처리가 된 경우만 다루고 싶다면 then 메서드에 인수를 하나만 전달하면 된다.

promise.then(console.log);
// 또는 promise.them(result => console.log(result));

2. catch

에러가 발생한 경우만 다루고 싶다면 .then(null, errorHandler)와 같은 형태로 nullthen 메서드에 첫 번째 인수로 전달할 수 있다. 위에서 살펴보았듯이 then 메서드의 두 번째 인수가 에러를 처리하기 때문이다. 그러나 간단하게 catch 메서드를 사용하여 catch(errorHandler) 형태로 선언할 수 있다. 이때 catch는 앞서 언급한 thennull을 전달하는 것과 완벽히 동일하게 작동한다.

let promise = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('에러')), 1000);
});

promise.catch(console.log);
// 또는 promise.catch(error => console.log(error));

3. finally

try...catch 절에도 finally가 있는 것 처럼 프라미스에도 이와 유사하게 finally가 존재한다. 프라미스가 처리되면 항상 이행이나 거부 상태를 만드는 함수 f가 실행된다는 점에서 .finally(f) 호출은 .then(f, f)와 유사하다.

예를 들어 비동기 요청 시 더 이상 쓸모가 없어진 로딩 인디케이터(Loading Indicator)를 멈추는 경우와 같이, 결과가 어떻게 되든간 상관없이 마무리가 필요하다면 finally가 유용하다.

new Promise(function (resolve, reject) {
  // 작업 후 resolve 또는 reject 호출
}).finally(() => 로딩 인디케이터 중지)
  .then(result => console.log(result)

그러나 .then(f, f).finally(f)와 완벽하게 동일하지는 않다. 주요 차이점은 다음과 같다.

  1. finally 핸들러에는 인수가 없다. 따라서 finally에서는 프라미스가 이행되었는지, 아니면 거부되었는지 알 수가 없다. 보통 finally에서는 절차를 마무리하는 보편적인 동작을 수행하기 때문에 이처럼 성공 또는 실패 여부를 몰라도 되는 경우가 많다.
  2. finally 핸들러는 자동으로 다음 핸들러에 결과와 에러를 전달한다. finally는 프라미스 결과를 처리하기 위해 만들어진 것이 아니기 때문에 프라미스 결과는 finally를 통과해 계속 전달된다.
new Promise((resovle, reject) => {
  setTimeout(() => resolve('done'), 1000);
}).finally(() => console.log('ready'))
  .then(result => console.log(result))
  // finally이후 실행되지만 then에서 result를 다룰 수 있다.

new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('에러')), 1000);
}).finally(() => console.log('ready'))
  .catch(err => console.log(err));
  // finally이후 실행되지만 catch에서 err를 다룰 수 있다.
  1. finally(f)는 함수 f를 중복해서 쓸 필요가 없기 때문에 .then(f, f) 보다 문법적인 측면에서 더 편리하다.

프라미스가 대기 상태일때는 소비 함수 then/catch/finally는 모두 프라미스가 처리될 때 까지 기다린다. 그러나 프라미스가 이미 처리된 상태라면 핸들러가 즉각 실행된다. 앞에서 프라미스는 항상 일정 소요시간을 가지는 것이 아닌 즉시 이행 상태 역시 가능하다고 했다.

let promise = new Promise(resolve => resolve('done'));

promise.then(console.log);	// 대기없이 바로 출력

3) 콜백 to 프라미스

앞서 콜백으로 구현한 loadScript 함수를 프라미스 방식으로 바꾸어보자. 콜백 함수 대신 스크립트 로딩이 완전히 끝났을 때 이행되는 프라미스 객체를 만들고, 이를 반환하도록 하자. 외부에서는 then 핸들러를 통해 결과값을 처리할 수 있다.

function loadScript(src) {
  return new Promise((resolve, reject) => {
    let script = document.createElement('script');
    script.src = src;
    
    script.onload = () => resolve(script);
    script.onerror = () => reject(new Error('에러'));
    
    document.head.append(script);
  });
}

let promise = loadScript(url);

promise.then(
  scirpt => console.log(script + '로드완료')
  error => console.log(error)
);

// 또는
promise.them(script => console.log(script));

프라미스를 사용한 코드가 콜백 기반보다 좋은 점을 정리하면 다음과 같다.

프라미스콜백
프라미스는 흐름이 자연스럽다. 어떤 동작을 수행하고 그 결과에 따라 다음에 무엇을 할지 자연스러운 순서로 코드 작성이 가능하다.콜백은 함께 호출할 callback 함수가 준비되어 있어야 한다. 또한 호출 이전에 호출 결과로 무엇을 할지 callback 함수에 미리 정의가 되어야 한다.
프라미스는 원하는 만큼 then을 추가할 수 있다. 이를 프라미스 체이닝이라고 한다.콜백은 하나만 가능하다.

그 외의 다른 장점들은 다음 챕터에서 자세히 다루어보자.

References

  1. https://ko.javascript.info/async
profile
개발잘하고싶다

0개의 댓글