비동기 프로그래밍

김수정·2020년 5월 6일
0

비동기 데이터 통신을 하는 경우, 동기가 아니기 때문에 해당 통신 후, 그 결과물을 가지고 하는 작업 혹은 동기적으로 이루어져야 하는 작업을 할 때 오류가 발생합니다. 이를 위해 비동기 데이터 통신을 동기적으로 처리하는 작업 방법들을 알아봅시다.

콜백기반 비동기 프로그래밍

비동기 통신을 하는 함수 안에 콜백함수를 매개변수로 받아 실행시킵니다.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}

loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  alert(`${script.src}가 로드되었습니다.`);
  alert( _ ); // 스크립트에 정의된 함수
});

에러 핸들링
error-first callback방식입니다.
첫 번째 매개변수를 에러를 위한 자리로 남겨두어 사용합니다.
이 스타일로 작성하면 단일 콜백 함수에서 에러 케이스와 성공

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`${src}를 불러오는 도중에 에러가 발생했습니다.`));

  document.head.append(script);
}

loadScript('/my/script.js', function(error, script) {
  if (error) {
    // 에러 처리
  } else {
    // 스크립트 로딩이 성공적으로 끝남
  }
});

콜백지옥

비동기 통신을 동기로 해야하는 작업이 여러 개인 경우, 콜백이 계속 겹치는 현상이 발생합니다.
겹치는 것이 많을 경우, 가독성 측면에서 매우 안좋습니다.

loadScript('1.js', function(error, script) {

  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', function(error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadScript('3.js', function(error, script) {
          if (error) {
            handleError(error);
          } else {
            // 모든 스크립트가 로딩된 후, 실행 흐름이 이어집니다. (*)
          }
        });

      }
    })
  }
});

해결
중첩적으로 들어가는 부분을 없애서 보기 편해졌습니다.
동작은 되지만 연속된 과정의 코드가 찢겨져 있어 코드를 해석하기 불편합니다.
또한 step*함수들은 재사용이 불가능합니다.

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 {
    // 모든 스크립트가 로딩되면 다른 동작을 수행합니다. (*)
  }
};

Promise

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

new Promise로 생성하여 executor함수를 매개변수로 전달합니다.
executor
promise는 executor를 바로 실행합니다.
resolve, reject 매개변수는 자바스크립트가 자체적으로 제공하는 콜백입니다.
둘 중에 하나는 무조건 호출해야 합니다.

resolve(value) - 일이 성공적으로 끝난 경우, 그 결과를 나타내는 value와 함께 호출
reject(error) - 에러 발생 시 에러 객체를 나타내는 error와 함께 호출

promise객체
state 프로퍼티: pending(executor) -> (1) resolved(resolve) (2) rejected(reject)
result 프로퍼티: undefined(executor) -> (1) value(resolve) (2) error(reject)

then
첫 번째 매개변수는 프라미스가 이행되었을 때 실행되는 함수, 두 번쨰 매개변수는 프라미스가 거부되었을 때 실행되는 함수입니다.

promise.then(
  function(result) { /* 결과(result)를 다룹니다 */ },
  function(error) { /* 에러(error)를 다룹니다 */ }
);

catch
then(null, f)와 같습니다.
에러가 발생한 경우만 다룹니다.

let promise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("에러 발생!")), 1000);
});

// .catch(f)는 promise.then(null, f)과 동일하게 작동합니다
promise.catch(alert); // 1초 뒤 "Error: 에러 발생!" 출력

finally
프라미스가 처리되면 무조건 호출되는 함수입니다.
그러나 프라미스 결과를 처리하는 함수는 아니므로, 맨 나중에 실행되는 것은 아닙니다.

new Promise((resolve, reject) => {
  /* 시간이 걸리는 어떤 일을 수행하고, 그 후 resolve·reject를 호출함 */
})
  // 성공·실패 여부와 상관없이 프라미스가 처리되면 실행됨
  .finally(() => 로딩 인디케이터 중지)
  .then(result => result와 err 보여줌 => error 보여줌)

프라미스 체이닝

콜백지옥을 해결하는 프라미스만의 방법입니다.
promise.then메서드가 프라미스를 반환하기 때문에 체이닝이 가능합니다.
.then(handler)에서 handler에 Promise객체를 생성하거나 반환하면, 핸들러 안에서 새로운 프라미스가 처리될 때까지 기다리다가 처리가 완료되면 결과를 받습니다.

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000); // (*)

}).then(function(result) { // (**)

  alert(result); // 1
  return result * 2;

}).then(function(result) { // (***)

  alert(result); // 2
  return result * 2;

}).then(function(result) {

  alert(result); // 4
  return result * 2;

});

fetch와 프라미스 체이닝

fetch는 네트워크 요청 시 사용하는 것입니다. 이는 다른 챕터에서 다루도록 하고, 브라우저와 서버 통신은 비동기로 이루어지므로 비동기 프로그래밍과 밀접하게 사용됩니다.
fetch도 프라미스를 반환하므로 프라미스 체이닝을 할 수 있습니다.

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise(function(resolve, reject) { // (*)
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser); // (**)
    }, 3000);
  }))
  // 3초 후 동작함
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));

리팩토링

function loadJson(url) {
  return fetch(url)
    .then(response => response.json());
}

function loadGithubUser(name) {
  return fetch(`https://api.github.com/users/${name}`)
    .then(response => response.json());
}

function showAvatar(githubUser) {
  return new Promise(function(resolve, reject) {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  });
}

// 함수를 이용하여 다시 동일 작업 수행
loadJson('/article/promise-chaining/user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));
  // ...

오류 다루기

프라미스 체인에서 해당 then핸들러가 오류를 일으키면 다음 핸들러 중에서 가장 가까운 rejection핸들러로 실행이 넘어갑니다.
다음 핸들러 중에 rejection핸들러가 없다면 오류를 잡을 수 없겠죠.

catch메서드는 에러를 잡습니다. 맨 마지막에 사용하면 앞에 나열된 then메서드들의 모든 에러를 감시합니다.

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise((resolve, reject) => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  }))
  .catch(error => alert(error.message));

에러를 다시 던질 경우, 즉, catch메서드 안에서 throw를 사용한 경우, 해당 catch메서드에서 가장 가까운 곳에 있는 에러 핸들러로 넘어갑니다. 여기서 에러가 성공적으로 처리되면 다음 실행 메서드(then)으로 가고, 아닐경우 다음 에러 헨들러를 찾아갑니다.

// 실행 순서: catch -> catch -> then
new Promise((resolve, reject) => {

  throw new Error("에러 발생!");

}).catch(function(error) { // (*)

  if (error instanceof URIError) {
    // 에러 처리
  } else {
    alert("처리할 수 없는 에러");

    throw error; // 에러 다시 던지기
  }

}).then(function() {
  /* 여기는 실행되지 않습니다. */
}).catch(error => { // (**)

  alert(`알 수 없는 에러가 발생함: ${error}`);
  // 반환값이 없음 => 실행이 계속됨

});

계속 에러 핸들러를 타고가다가 최종적으로도 에러가 해결되지 않으면 전역에러로 바뀌어 자바스크립트가 멈춥니다. 브라우저 환경에선 unhandledrejection이벤트에 잡을 수 있습니다.

window.addEventListener('unhandledrejection', function(event) {
  // 이벤트엔 두 개의 특별 프로퍼티가 있습니다.
  alert(event.promise); // [object Promise] - 에러를 생성하는 프라미스
  alert(event.reason); // Error: 에러 발생! - 처리하지 못한 에러 객체
});

new Promise(function() {
  throw new Error("에러 발생!");
}); // 에러 처리 핸들러, catch가 없음

프라미스 API

5가지 정적 메서드가 있습니다.

Promise.all
여러 개의 프라미스를 동시에 실행시키고 모든 프라미스가 준비될 때까지 기다릴 때.
복수의 url에 동시에 요청을 보내고, 다운로드가 모두 완료된 후에 콘텐츠를 처리할 때 사용합니다.

Promise.all([
  new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
  new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
  new Promise(resolve => setTimeout(() => resolve(3), 1000))  // 3
]).then(alert); // 프라미스 전체가 처리되면 1, 2, 3이 반환됩니다. 각 프라미스는 배열을 구성하는 요소가 됩니다.

all의 인수로 이터러블 객체를 넣어주면 그 안에 있는 프라미스들이 모두 실행된 후에 .then이 실행되며, 이 때 then에 들어오는 result는 배열형태로 각 프라미스의 결과값이 들어있습니다.
이 중 하나라도 에러가 나면 에러를 리턴하고 전체 값이 거부됩니다.
이터러블 객체가 아닌 원시값을 넣어주면 그 값은 결과배열에 그대로 전달됩니다. 값을 아는 것은 그냥 값으로 넣어줍니다!

Promise.allSettled
all처럼 모든 프라미스가 처리될 때까지 기다리는 것은 동일하지만, 성공과 실패 결과를 모두 전달하는 점이 다릅니다.

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://no-such-url'
];

Promise.allSettled(urls.map(url => fetch(url)))
  .then(results => { // (*)
    results.forEach((result, num) => {
      if (result.status == "fulfilled") {
        alert(`${urls[num]}: ${result.value.status}`);
      }
      if (result.status == "rejected") {
        alert(`${urls[num]}: ${result.reason}`);
      }
    });
  });

result의 결과는 아래와 같이 나와서 모든 결과를 받아볼 수 있습니다.

[
  {status: 'fulfilled', value: ...응답...},
  {status: 'fulfilled', value: ...응답...},
  {status: 'rejected', reason: ...에러 객체...}
]

Promise.race
복수의 프라미스를 받아 가장 먼저 처리되는 프라미스의 결과를 반환합니다.

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("에러 발생!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert); // 1

Promise.resolve/reject
async await가 생긴 뒤에 쓸모가 없어졌습니다. 그러나 async-await를 쓸 수 없는 상황도 있겠지요.
resolve는 프라미스가 성공한 상태를 생성하고 reject는 거절한 상태를 생성합니다.
함수가 프라미스를 반환해야할 때 사용합니다.

let cache = new Map();

function loadCached(url) {
  if (cache.has(url)) {
    return Promise.resolve(cache.get(url)); // (*)
  }

  return fetch(url)
    .then(response => response.text())
    .then(text => {
      cache.set(url,text);
      return text;
    });
}

(*) let promise = new Promise(resolve => resolve(value)); 이런 느낌으로 보내집니다.

프라미스화

콜백대신 사용하기 좋은 프라미스로 바꿔주는 헬퍼 함수입니다.
콜백을 단 한 번 호출하는 함수에만 적용할 수 있습니다. 여러 번 호출해도 두 번째부터는 무시되기 때문입니다.

// 콜백의 성공 결과를 담은 배열을 얻게 해주는 promisify(f, true)
function promisify(f, manyArgs = false) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      function callback(err, ...results) { // f에 사용할 커스텀 콜백
        if (err) {
          reject(err);
        } else {
          // manyArgs가 구체적으로 명시되었다면, 콜백의 성공 케이스와 함께 이행 상태가 됩니다.
          resolve(manyArgs ? results : results[0]);
        }
      }

      args.push(callback);

      f.call(this, ...args);
    });
  };
};

// 사용법:
f = promisify(f, true);
f(...).then(arrayOfResults => ..., err => ...)

마이크로태스크 = promise job queue

프라미스의 핸들러들이 동작하는 원리를 설명합니다.
프라미스의 핸들러는 전부 비동기로 동작합니다. 따라서 일반적인 동기방식과 처리과정에 차이가 있습니다.

엔진은 프라미스의 핸들러들은 큐에 저장하고, 현재 진행되던 코드가 완료된 후에야 큐에서 핸들러를 가져와 처리합니다.
unhandledrejection 이벤트는 마이크로태스크 큐에 있는 모든 작업이 끝난 이후에 호출됩니다.

asnyc, await

async

function 앞에 붙이면 return값이 프라미스가 아니어도 항상 프라미스를 반환해줍니다.

async function f() {
  return 1;
}

f().then(alert); // 1

await

async 안에서만 동작합니다.
await 키워드는 프라미스가 처리될 때까지 기다렸다가 결과를 반환합니다.
프라미스가 처리되길 기다리는 동안엔 엔진이 다른 일(다른 스크립트를 실행, 이벤트 처리 등)을 할 수 있기 때문에, CPU 리소스가 낭비되지 않습니다.
promise.then의 다른 표현법이 됩니다.

async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });

  let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)
  alert(result); // "완료!"
}

f();

async class method

class Waiter {
  async wait() {
    return await Promise.resolve(1);
  }
}

new Waiter()
  .wait()
  .then(alert); // 1

에러 핸들링

try..catch로 작업합니다. 혹은 async함수에 .catch를 추가합니다.

// (1)
async function f() {

  try {
    let response = await fetch('http://유효하지-않은-url');
    let user = await response.json();
  } catch(err) {
    // fetch와 response.json에서 발행한 에러 모두를 여기서 잡습니다.
    alert(err);
  }
}

f();

// (2)
async function f() {
  let response = await fetch('http://유효하지-않은-url');
}

// f()는 거부 상태의 프라미스가 됩니다.
f().catch(alert); // TypeError: failed to fetch // (*)
profile
정리하는 개발자

0개의 댓글