frontend/JavaScript

[JavaScript] JS의 비동기처리.

findTheValue 2021. 8. 17. 01:48

JS의 비동기 처리방식


동기 / 비동기 처리


img

동기(Syncronous)는 먼저 시작한 작업이 끝날 때 까지 다른 작업을 할 수 없다.

비동기(Asyncronous)는 동시에 여러가지 작업을 처리하며 기다리는 과정에서 다른 함수 호출이 가능하다.




비동기 처리의 예

다음과 같은 작업들은 주로 비동기적으로 처리하게 된다.

  • Ajax Web API요청 : 화면을 렌더링해야하는데 서버쪽에서 응답을 해주기까지 마냥 대기할 수 없다. 비동기적으로 처리한다.

    Ajax

    function getData() {
      let data;
    
      $.ajax({
        type: 'post',
        url: 'https://devlibrary00108.tistory.com/',
        data: {
          seungjoo:zzang,
        },
        success: function (result) {
          // 성공시 결과값 할당
          data = result;
        },
      });
    
      return data;
    }
    
    console.log(getData()); // 값 안나옴(ajax처리를 기다려주지 않음.)

    https://devlibrary00108.tistory.com/로 data를 전송하고, 통신 성공시 data변수에 result를 할당하여 리턴하도록 되어있다.

    하지만 리턴값은 undefined가 출력되는데 이유는 Ajax요청 후 응답을 받기 전에 return이 실행됐기 때문이다.

    이렇게 특정 로직의 실행이 끝날 때까지 대기하지 않고, 나머지를 먼저 실행하는 것이 비동기다.

  • 파일읽기 : 서버 쪽에서 파일을 읽어 처리해야 하는 상황에도 비동기처리한다.

  • 암호화/복호화 : 암호화/복호화를 할 때에도 바시간이 어느정도 걸리는 경우가 있기 때문에 비동기처리한다.

  • 작업예약 : setTimeout 을 사용하여 비동기적으로 처리한다.
    - setTimeout(a,b) : Web API의 한 종류로, b ms 후에 코드를 실행한다는 뜻인데, 사실은 (b+4)ms 후에 실행된다.

  • setTimeout()

    function getData() {
      let data;
    
      setTimeout(function () {
        data = 'result';
      }, 1000);
    
      return data;
    }
    
    console.log(getData()); // undefined 얘도 안기다려준다.
  • setTimeout, setInterval, HTTP요청, 이벤트핸들러...etc


Single Thread로 비동기가 가능한 이유.

자바스크립트는 이벤트를 처리하는 Call Stack이 하나뿐인 언어이다.
따라서 여러가지 이벤트를 처리할 때에 동기적으로 처리하게 된다면 하나의 이벤트가 처리될 때까지 다른 어떤 업무도 수행하지 못한다.

때문에 자바스크립트는 즉시 처리하지 못하는 이벤트들을 Web API(호스트 객체)로 보내

먼저 처리된 이벤트들을 줄세워 다시 이벤트 큐에 줄을 세워놓게 된다.(큐에 푸시하는 것은 브라우저의 역할.)

Web API로 들어오는 순서는 중요하지 않고, 어떤 이벤트가 먼저 처리되느냐가 중요하다.


하지만 순서가 중요할 때가 있다. 선순위 데이터를 이용해 후순위 작업을 이어 나가야 할 때이다.


비동기 처리 방식의 문제점 해결

1. Callback 함수

콜백함수는 나중에 호출할 함수를 의미한다. 더 자세하게는, 함수 타입의 값을 파라미터로 넘겨줘서 파라미터로 받은 함수를 특정 작업이 끝나고 호출해주는 것을 의미한다.

function findUser(id, callback) {
  setTimeout(function() {
    console.log("waited 1 sec");
    const user = {
      id : id,
      name : "User" + id,
      email : id + "@abc.com"
    }
    callback(user); // user 객체를 callback함수에 넘겨준다
  }, 1000)
// callback인자로 함수를 넘겨줘 setTimeout이 끝난 후에 callback함수가 실행되도록 설계했다.
findUser(1, function(user) {
  console.log("user:" , user)
})
// 출력
// waited 1 sec
// user: {id: 1, name: "User1", email: "1@abc.com"}

findUser 함수 안에 id와 callback함수를 인자로 넘겨주고, callback함수에 대한 설명을 따로 정의해준다.


Callback hell (콜백지옥)

콜백 기반 비동기 처리는 언뜻 봤을 때 꽤 쓸만해 보이고, 실제로도 그렇다. 한 개 혹은 두 개의 중첩 호출이 있는 경우는 보기에도 나쁘지 않다.

하지만 비동기 동작이 많아진다면 치명적인 단점들이 나타난다.

$.get('url', function(response) {
  parseValue(response, function(id) {
    auth(id, function(result) {
      display(result, function(text) {
        console.log(text);
      });
    });
  });
});
  1. 코드의 가독성이 떨어진다
    • 코드의 중첩으로 인해 '멸망의 피라미드'가 만들어진다.
  2. 콜백함수에 에러처리를 한다면, 모든 콜백에서 각각 에러핸들링을 해주어야한다.
  3. 로직 변경에도 어려움이 있다.

Callback hell을 해결하는 방법

코딩 패턴으로 해결

function parseValueDone(id) {
  auth(id, authDone)
}
function authDone(result) {
  display(result, displayDone)
}
function displayDone(text) {
  console.log(text);
}
$.get('url', function(response) {
  parseValue(response, parseValueDone);
});

Promise?

  • Promise 객체는 비동기 작업이 미래에 완료 또는 실패되는 것의 여부와 그 결과 값을 나타낸다.

Promise 활용하기

  • Promise 생성
const myPromise = new Promise((resolve, reject) => {
  //구현
})

new Promise로 생성된 인스턴스 객체는 '객체'이기 때문에 변수로 할당하거나 함수의 인자로 사용할 수 있다.

인수 resolvereject는 자바스크립트가 자체적으로 제공하는 콜백이다.

  • resolve(value) : 일이 성공적으로 끝난 경우, 결과인 value와 함께 호출
  • reject(error) : 에러 발생 시 에러 객체인 error와 함께 호출
callback 함수
function getData(callbackFunc) {
  $.get('url 주소/products/1', function(response) {
    callbackFunc(response);
  });
}

getData(function(tableData) {
  console.log(tableData);
Promise 적용
function getData(callback) {
    // data를 받는데 성공하면 resolve() 호출
  return new Promise(function(resolve, reject) {
      $.get('url 주소/products/1', function(response) {
      resolve(response);
    });
  });
}
//getData()의 실행이 끝나면 호출되는 then()
getData().then(function(tableData) {
  //resolve()의 결과 값이 여기로 전달됨
  console.log(tableData); //$.get()의 response 값이 tableData에 전달됨
});

Promise의 상태(States)

Promise로직은 3가지 상태 중 하나를 갖는다.

  • pending(대기) => 비동기 로직이 완료되지 않은 상태
  • fulfilled(이행) => 비동기 처리가 완료되어 프로미스가 result를 return해준 상태
  • rejected(실패) => 실패하거나 오류가 발생한 상태

인스턴스 호출

thencatch를 사용한다.

function getData(callback) {
  // new Promise() 추가
  return new Promise(function(resolve, reject) {
      $.get('url 주소/products/1', function(response) {
      reject("데이터를 받아오는 데에 실패했습니다.");
    }); // promise가 reject되었을때, error을 나타내줌
  });
}
//getData()의 실행이 끝나면 호출되는 then()
getData()
    .then()
    .catch(e => console.log("error: ", e)) 
});
  • resolve 되는 값은 then 메소드의 인자로 넘어간다.

  • reject 되는 값은 catch 메소드의 인자로 넘어가서 에러 핸들링을 할 수 있다.

여기서 중요한 점은 then메소드는 다시 Promise를 반환한다는 것이다.
다시 Promise 객체를 반환하게 되면 then,catch 메소드를 사용할 수 있게 되며,

이를 통해 연속적으로 then을 사용하여 Promise Chaining이 가능하게 된다.
img


Promise Chaining

콜백에서 나타났던 콜백 지옥처럼, Promise에서도 여러개의 비동기 작업을 나타내는 Promise Chaining이 나타난다.

new Promise(function(resolve, reject) {
    // 1초 후 최초 promise 이행
  setTimeout(() => resolve(1), 1000);
    //.then 핸들러가 호출
}).then(function(result) {

  alert(result); 
  return result * 2;
    //위의 then 반환값은 여기 result에 저장.
}).then(function(result) {

  alert(result); 
  return result * 2;

}).then(function(result) {

  alert(result);
  return result * 2;

});

Fetch와 Promise 같이 사용하기

Promise 객체를 직접 생성해서 리턴하는 것 보다는 라이브러리의 함수를 호출해서 리턴받은 Promise 객체를 사용하는 경우가 더 많다.

대표적으로 REST API를 호출할 때 사용되는 브라우저 내장 함수인 fetch()가 있다.

fetch()함수는 API의 URL을 인자로 받고, 미래 시점에 얻게될 API 호출 결과를 Promise 객체로 리턴한다.

fetch("url주소/products/1")
    .then((response) => console.log("response:", response))
    .catch((error) => console.log("error: ", error))
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) { //1
  let img = document.createElement('img');
  img.src = githubUser.avatar_url;
  img.className = "promise-avatar-example";
  document.body.append(img);

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

.then(githubUser => alert(`Finished showing ${githubUser.name}`));

1로 표시한 곳의 .then 핸들러는 이제 setTimeout안의 resolve(githubUser)를 호출했을 때 2만 처리상태가 되는 new Promise를 반환한다. 체인의 다음 .then은 이를 기다린다.

비동기는 항상 프로미스를 반환하는 것이 좋다. 지금은 체인을 확장할 계획이 없더라도

이렇게 구현해 놓으면 나중에 필요한 경우 손쉽게 체인을 확장할 수 있다.

코드를 재사용 가능한 함수 단위로 분리한다면 다음과 같다.

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}`));

Async/Await

ES8에서 추가된 자바스크립트 비동기 처리 패턴이다. 코드의 모습과 동작을 좀 더 동기 코드와 유사하게 작성할수 있어 기존의 콜백 패턴이나 프로미스 패턴보다 가독성이 높다.

async function getData() {

  const result = await new Promise((resolve) => {

    // 1초뒤 data에 값 담음
    setTimeout(() => {

      const data = "my-data";
      resolve(data);

    }, 1000);
  });

  // data 값 출력
  console.log(result);
}

getData();

기본적인 형태는 위와 같으며 아래에서 각 키워드를 알아본다.


async

async 키워드를 function앞에 붙이면 async Function 함수가 되며 이 함수는 항상 프라미스를 반환한다.

async function func() {
  return "my-data";
}
console.log(func()); // Promise { 'my-data' }

위 코드를 보면 func()my-data 값을 리턴하고 있지만 실제 리턴값은 프라미스 객체를 리턴하고 있다.

async function func() {
  return "my-data";
}

func().then((data) => {
  console.log(data); // my-data
});

위 코드는 반환된 프라미스 객체를 then 함수를 통해 data값을 받아 출력하고 있다.


await

또 다른 키워드 awaitasync 함수 안에서만 동작한다. await는 프로미스 객체를 만나면 처리될 때까지 기다리고 결과는 그 이후에 반환되며 이 키워드로 비동기 패턴을 동기식으로 작성할 수 있다.

async function getData() {

  // promise 변수에 프로미스 객체를 담음
  const promise = new Promise((resolve) => {

    // 1초뒤 resolve 함수를 호출하여 프로미스가 이행상태가 됨
    setTimeout(() => {
      resolve("my-data");
    }, 1000);
  });

  const result = await promise; // 프로미스가 이행될 때 까지 기다림
  console.log(result); // my-data
}

getData();

awaitasync 함수 외부에서 사용될 수 없다. 만약 사용된다면 문법 에러가 발생한다.

위 코드를 아래처럼 프로미스를 선언할 때 await 키워드를 사용할 수도 있다.

async function getData() {

  // 프로미스가 이행되었을 때 결과값을 담음
  const result = await new Promise((resolve) => {

    // 1초뒤 resolve 함수를 호출하여 프로미스가 이행상태가 됨
    setTimeout(() => {
      resolve("my-data");
    }, 1000);
  });

  console.log(result); // my-data
}

getData();

기존 비동기 처리 방식과 비교

기존의 비동기 방식과 비교해보자, 서버와 통신하여 1초뒤 my-data 값을 리턴하는 getData() 함수가 있다고 가정한다.(여기서는 비동기 통신을 setTimeout()로 대체한다.)

function getData() {
  let data = "";
  setTimeout(() => {
    data = "my-data";
  }, 1000);
  return data;
}

console.log(getData()); // 출력 안됨

함수를 실행하면 리턴값은 아무것도 안나온다. 비동기 방식으로 통신되기 때문에 응답(1초)을 기다리지 않고 바로 값을 리턴하기 때문이다.


콜백함수 방식

function getData(callBack) {
  let data = "";
  setTimeout(() => {
    data = "my-data";
    return callBack(data);
  }, 1000);
}

getData(function(result) {
  console.log(result); // my-data
});

콜백 방식을 사용하면 위와 같다. getData() 함수에 콜백함수를 인자로 넣고 통신이 끝난 후 리턴하여 그 값을 출력하는데, 코드의 가독성이 좋지 않다.


프로미스의 then 방식

function getData() {
  let data = "";
  return new Promise((resolve) => {
    setTimeout(() => {
      data = "my-data";
      resolve(data);
    }, 1000);
  });
}

getData().then(function(result) {
  console.log(result); // my-data
});

반환된 프로미스 객체를 then() 함수에 전달받는 방식이다. 콜백 지옥을 해결하고, 예외처리가 가능한 방식이지만, 여전히 가독성이 안좋은건 사실이다.


Async/Await 방식

function getData() {
  let data = "";
  return new Promise((resolve) => {
    setTimeout(() => {
      data = "my-data";
      resolve(data);
    }, 1000);
  });
}

async function getResult() {
  const result = await getData();
  console.log(result); // my-data
}

getResult();

getResult() 함수에 async 키워드를 사용해 async Function으로 만들었다. getData()의 리턴값은 프로미스기 때문에 await 키워드를 만나 프로미스 실행이 완료되면 결과값이 result 변수에 할당된다.


예외처리

예외처리는 try, catch로 한다. 프로미스에서 catch()를 사용한 것처럼 async & await에서는 catch{}를 사용한다.

function getData() {
  let data = "";
  return new Promise((resolve) => {
    setTimeout(() => {
      data = "my-data";
      resolve(data);
    }, 1000);
  });
}

async function getResult() {

  // 통신 성공
  try {
    const result = await getData();
    console.log(result); // my-data

  // 통신 실패
  } catch (error) {
    console.log(error); // error
  }
}

getResult();

catch로 통신 오류 및 타입오류 등이 error 객체에 담긴다. 에러 유형에 맞게 코드를 작성하자.