JavaScript 에서의 비동기

midohree·2020년 9월 6일
0
post-thumbnail
post-custom-banner

비동기 과제를 마치고 자바스크립트에서 비동기 처리를 진행하는 과정과 비동기에 대해 다시 한번 정리 할 필요성이 있다고 느껴졌다.

참고자료 | Philip Roberts : What the heck is event loop anyway?
참고자료 | How JavaScript works: an overview of the engine, the runtime, and the call stack

자바스크립트는 싱글(단일) 쓰레드(single-threaded) 언어이다. Thread는 CPU의 기본 단위를 말한다. 프로그램 내에서 프로세스가 실행되는 흐름의 단위라고 이해하면 쉽다. Thread는 한 프로세스 내에 여러개 존재할 수 있으며, 같은 프로세스에 존재하는 다른 Thread들과 서로 OS의 자원을 공유한다.

Single-thread 는 말 그대로 하나의 직렬로 처리하는 방식이다. Multy-thread는 여러개의 스레드를 사용하는 방식이다. 하지만 실제로 한번에 두가지의 일을 동시에 병렬로 처리하는 것이 아니라 번갈아가면서 처리하므로 동시에 작업이 진행되는 것 처럼 느끼게 하는 방식이다.

자바스크립트가 Single-thread 언어라는 말은 코드를 순서대로 실행하고 다음 코드로 이동 하기 전에 코드의 실행이 완료되어야 한다는 뜻이다. 즉 하나의 요청이 있으면 하나를 처리할 때 까지 다음 요청은 대기상태가 된다는 말인데 자바스크립트는 하나의 call stack과 하나의 memory heap만 갖고 있기 때문이다.

자바스크립트 엔진 중 유명한 구글의 V8 엔진(이 엔진은 싱글스레드로 작동한다.)은 크게 두 부분으로 구성된다.

  • 메모리힙(Memory Heap) : 메모리 할당이 이루어 지는 곳.
  • 콜스택(Call Stack) : 코드가 실행되며 스택 프레임이 쌓이는 곳. 작업은 스코프 단위로 수행한다.

하지만 엔진만으로 모든 일을 처리할 수 없다. 브라우저가 제공하는 WebAPI (DOM이나 AJAX, SetTimeout 같은..), Event Loop와 Callback Queue 또한 자바스크립트를 구성하는 중요한 요소이다.

  • WebAPIs : 비동기 작업을 Stack으로 부터 넘겨받아 코드를 실행한다.
  • Task Queue : WebAPI가 완료한 작업을 저장시켜두는 곳.
  • Event Loop : Stack이 비어있을 때 Task Queue에 있는 작업들을 Stack으로 올리는 역할.

즉, 코드를 실행 할 때마다 Call Stack에 쌓인 작업들이 실행되고, WebAPI가 있으면 이를 브라우저로 보내 실행을 시킨다. (Call Stack 에서는 다른 작업이 계속 이루어지고 있다.) WebAPI에서 완료된 작업들이 Callback Queue에 하나씩 쌓인다. Call Stack에서 작업이 완료되면 Callback Queue에 있던 작업들이 Event Loop 를 통해 Call Stack으로 올라가 작업이 수행된다.

자바스크립트에서의 동기와 비동기

실행 순서가 확실한 동기 (Synchronous).

  • 요청을 보낸 후 해당 요청의 응답을 받은 후, 다음 동작을 실행하는 방식.
  • 하나의 이벤트가 모두 끝날 때까지 다른 이벤트 처리를 하지 못하고 이벤트가 완료 된 후, 다음 이벤트가 동작하는 방식.
  • 장점: 설계가 매우 간단하고 직관적이다.
  • 단점: 결과가 주어질 때 까지 대기해야 한다.
console.log("1");
console.log("2");
console.log("3");

결과는

  • "1"
  • "2"
  • "3"

실행 순서가 확실하지 않은 비동기 (Asynchronous).

  • 호출이 시작 되고 끝날 때 까지 기다리지 않고, 나머지 코드를 먼저 실행하는 방식. 연속적으로 발생하는 이벤트를 담은 후 완료되는 순서대로 일을 처리하는 '실행 순서가 확실하지 않은 방식'.
  • 연속적으로 발생하는 이벤트를 담은 후 완료되는 순서대로 일을 처리하는 방식.
  • 장점: 결과가 주어지는데 시간이 걸리더라도 그 시간동안 다른 작업을 진행해 효율적이다.
  • 단점: 동기보다 복잡하다.
console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3")

결과는

  • "1"
  • "3"
  • "2"

하나의 요청이 끝난 후 다음 요청을 수행하는 동기적 실행과는 달리 비동기요청은 Web API 함수(위 코드에서는 setTimeout)가 있으면 이를 브라우저에 보내 실행시키고, 나머지 로직의 요청의 응답을 모두 받았을 때 Web API에서 완료되어 Call Stack에 저장되어 있는 결과가 EventLoop를 통해 "2"라고 출력 된다.

동기 vs 비동기

동기

function waitSync(ms) {
  const start = Date.now();
  let now = start;
 
  while(now - start < ms) {
    now = Date.now();
  }
}
function drink (person, coffee) {
  console.log(`${person} 은/는 ${coffee}를 마십니다.`);
}

function orderCoffeeSync (coffee) {
  console.log(`${coffee}가 접수되었습니다.`);
  waitSync(2000);
  
  return coffee;
}

let customers = [
  {
    name: 'Dohee',
    request: 'Cafe Latte'
  },
  {
    name: 'Zetton',
    request: 'Iced coffee'
  },
];

// call Synchronously
customers.forEach((customer) => {
  let coffee = orderCoffeeSync(customer.request);
  
  drink(customer.name, coffee);
});

실행 순서

1) Cafe Latte가 접수되었습니다.
2) (2초 후) Dohee 는 Cafe Latte를 마십니다.
3) (2초 후) Iced coffee가 접수되었습니다.
4) (2초 후) Zetton 은 Iced coffee를 마십니다.

비동기

function waitAsync (callback, ms) {
  setTimeout(callback, ms);
  // 특정 시간 이후에 callback이 실행되게끔..
}

function drink (person, coffee) {
  console.log(`${person} 은/는 ${coffee}를 마십니다.`);

function orderCoffeeSync(coffee) {
  console.log(`${coffee}가 접수되었습니다.`);
  
  waitSync(2000);
  return coffee;
}
  
let customers = [
  {
    name: 'Dohee',
    request: 'Cafe Latte'
  },
  {
    name: 'Zetton',
    request: 'Iced coffee'
  },
];
  
function orderCoffeeAsync (menu, callback) {
  console.log(`${menu}가 접수되었습니다.`);
  
  waitAsync(() => {
    callback(menu);},2000);
}
  
// call asynchronously
  customers.forEach((customer) => {
    orderCoffeeAsync(customer.request, (coffee) => {
      drink(customer.name, coffee);
    });
  });

실행 순서

1) Cafe Latte가 접수되었습니다. Dohee 는 Cafe Latte를 마십니다. (동시 출력)
2) (2초 후) Iced coffee가 접수되었습니다. Zetton 은 Iced coffee를 마십니다. (동시 출력)

비동기 처리의 대표적인 예

Timer 함수 (SetTimeout, setInterval)

console.log('Hello');

setTimeout(function () {
  console.log('Bye');
}, 3000);

console.log("Hello Again")

결과는 아래와 같은 순서대로 출력된다.

  • 'Hello'
  • 'Hello Again'
  • 3초 뒤 'Bye'

setTimeout()이 비동기 방식으로 실행되기 때문에 3초를 기다렸다가 다음 코드를 수행하는 것이 아닌, 일단 setTimeout()을 실행하고 나서 console.log('Hello Again')으로 넘어간다.

Ajax 요청

비동기 로직의 가장 핵심적인 부분이 아닐까 싶다. 서버에 데이터를 요청하고 받아오는 것은 웹 페이지 개발에 있어서 아주 중요하다.

Ajax는 자바스크립트를 이용해 비동기적으로 서버와 브라우저가 데이터를 교환할 수 있는 통신 방식을 의미한다. 서버로부터 웹페이지가 반환되면 화면 전체를 갱신해야 하는데 페이지 일부만을 갱신하고도 동일한 효과를 볼 수 있도록 하는 것이 Ajax의 강점이다.

console.log('1');

fetch('https://api.google.com/places')
      .then(res => res.json())
      .then(() => console.log('데이터 완료'))
      
console.log('2');
  

ajax요청(fetch)을 하는 부분이 브라우저로 보내지기 때문에 숫자 1과 2가 먼저 찍히고 서버에서 응답이 올바르게 왔다면 '데이터 완료'가 출력된다.

call back 함수

콜백함수는 다른 함수의 전달인자로 넘겨주는 함수이다.

parameter을 넘겨받은 함수(A)callback 함수(B)를 필요에 따라 즉시 실행할수도, 나중에 실행 할 수도 있다.

callback 함수는 일종의 식당 자리 예약과 같다. 웨이팅을 해야 하는 시점에 대기자 명단을 쓰고 자리가 날 때 까지 주변을 돌아니고 식당에 자리가 생기면 전화로 연락이 오는 시스템이다. 전화를 받는 시점이 콜백함수가 호출되는 시점과 같은건데 즉, 자리가 준비된 시점, 데이터가 준비된 시점에서만 원하는 동작(자리에 앉는다, 특정 값을 출력한다 등)을 수행할 수 있다.

function B () {
  console.log('called at the back!')
}

function A (callback) {
  callback(); // callback === B
}

A(B);

1) callback in action : 반복 실행하는 함수(iterator)


[1, 2, 3].map((num, index) => {
  return num * index;
});

2) callback in action : 이벤트에 따른 함수 (event handler)


document.querySelector('#btn').addEventListener('click', (e) => {
  console.log('button clicked')
});

비동기 프로그래밍을 하는데 있어서, 가장 기본적인 방법은 콜백함수를 이용하는 방법이다. 콜백함수는 비동기 처리가 끝났을 때 실행해서 비동기 함수의 결과값을 처리하도록 하거나 혹은 에러핸들링이 가능하게끔 할 수 있다.

fetch('https://api.google.com/places')
      .then(res => res.json())
      .then(() => console.log('데이터 완료'))
      .catch(err => console.log(`에러발생 : ${err}`))

콜백지옥

하지만 비동기 처리 로직을 위해 콜백 함수를 연속으로 사용할 때 콜백함수가 체이닝되는 경우가 발생할 수 있다. 예를 들어, 데이터를 받은 후 그 데이터를 처리하는 또 다른 비동기 로직이 있어야 하고, 그 안에 또 다른 결과값을 만들어내기 위한 로직이 필요하게 되면 코드를 보는 사람은 물론이거니와 코드를 작성한 나 스스로도 코드를 알아보지 못하고 디버깅의 늪에 빠지게 되는.. 그런 상황이 발생 할 수 있다.
또한 콜백 방식이 비동기 처리가 갖는 가장 큰 문제점은 에러 처리가 어렵다는 것이다.
이러한 불편함을 해결하기 위해 비동기 흐름을 컨트롤 하는 방법으로PromiseAsync를 사용 할 수 있다.

Promise 객체

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다. (MDN)

Promise를 사용하면 비동기 작업이 완료된 후의 결과 값을 받을 수 있다. Promise 생성자 함수는 비동기 작업을 수행할 콜백 함수를 인자로 전달받는데, 이 콜백함수는 resolve와 reject 함수를 인자로 전달받는다.

Promise의 상태 값

Promise 객체는 new 키워드로 생성할 수 있으며 총 4개의 상태 값을 가진다.

  • Pending: 아직 결과 값이 반환되지 않은 진행 중인 상태
 const promise = new Promise((resolve, reject) => {
   // 데이터를 호출하는 resolve()
   // 에러를 호출하는 reject()
 });
  • Fulfilled: 성공
const promise = new Promise((resolve, reject) => {
  if(resolve) resolve('result');
});

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

비동기 처리가 성공하면 콜백함수의 인자로 전달받은 resolve() 함수를 호출한다. 호출되는 값은 then() 메소드의 인자로 넘어가 호출한 resolve()를 콜백함수처럼 사용 할 수 있다.

  • Rejected: 실패
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject('error');
    , 1000)
  });
});

promise
  .then(res => console.log(res))
  .catch(err => console.error(err));

비동기 처리가 실패하면 reject() 함수를 호출한다. reject되는 값은 catch() 메소드의 인자로 넘어가 에러핸들링이 가능하다.

  • Settled: 결과 값이 성공 혹은 실패로 반환된 상태
    상태 값은 크게 Pending과 Settled로 나눌 수 있으며,
    Settled은 다시 fulfilled와 Rejected로 나누어 진다.
    한번 Setteld된 값은 재실행 할 수 없다.

Promise Chaning

Promise의 then() 메소드는 다시 Promise를 반환한다. Promise 객체를 반환한다는 것은 then, catch 메소드를 연속적으로 체이닝시켜 사용 가능하다는 것을 의미한다. return값은 다음 then() 메소드의 콜백함수의 결과로 전달이 가능하다.

doSomething() // 반환 값이 다음 then으로 전달된다.
  .then(res => doSomethingElse(result)) // 반환값이 다음 then으로 전달된다.
  .then(res => doThirdThing(newResult)) // 이하동문
  .then(res => console.log(`Got the final result: ${finalResult}`)) // 이하동문
  .catch(err => console.error(err));
// 위의 Promise 객체들 중 하나라도 에러가 발생하면 catch 메소드로 바로 넘어온다. 

하지만 생각해보면 콜백 지옥에서 벗어나기 위해 Promise를 사용했지만, Promise역시 콜백 함수에서 처리해야 할 로직이 많아지면 이 또한 가독성이 떨어지게 된다.

async / await

async / await 구문은 Promise를 기반으로 사용되는데, 비동기 코드를 좀 더 동기적인 코드처럼 보이게끔 작성 할 수 있다.

Promise를 사용하지만 then, catch를 사용해 결과값을 컨트롤 하는 것이 아닌, 동기적 코드처럼 반환 값을 변수에 할당하여 작성할 수 있게 도와준다. async / await 에서 에러 핸들링을 하고 싶으면 try..catch.. 문을 사용 하면 된다.

아래는 프로젝트에서 사용했던 async / await 구문이다.

const getNearestPlaces = async landmarkList => {
  if(!landmarkList) return;

  const { coordinates, id } = landmarkList[0];

  try {
    const result = await axios.get(ROUTES.DIRECTIONS, {
      params: {
        lat: coordinates.lat,
        lng: coordinates.lng,
        id: id,
      },
    });

    return result.data;
  } catch (err) {
    const { response } = err;

    if (response) alert(MESSAGES.RECOMMENDS_FAIL);
  }
};

get요청으로 서버에 qurey를 전달하고, 서버에서는 요청에 따른 결과 값을 프론트 단으로 전송시킨다. result에 결과 값을 받고, 동기적으로 result에서 필요한 부분만 반환시킨다. 비동기 상황에서는 어떤 이벤트가 먼저 완료될 지 순서가 불명확한데, async / await 를 사용하면 코드가 동기적으로 실행되는 것처럼 작성 할 수 있다. async await는 위와 같이 ajax를 사용하여 응답을 받는 경우에 유용하게 사용 할 수 있다.

post-custom-banner

0개의 댓글