웹 백엔드

Node.js - 비동기 처리

토리쟁이 2024. 1. 27. 21:31

 

 

이번 포스팅에서는 자바스크립트의 동기와 비동기에 대해 공부하고 node.js에서의 비동기 처리 방법에 대해 다뤄보고자 한다.


 

 

동기와 비동기가 무엇인지 설명하기 전에, 그것을 왜 공부해야 되는지부터 알아보자.

 

일단, 자바스크립트는 동기식 언어이다.

하지만, 동기적으로 처리된다면 웹을 실행하는데 많은 시간이 소요될 수 있다는 단점이 있다.

그렇기 때문에, 동기적으로 처리되는 자바스크립트에서 필요에 따라 비동기적으로 처리할 수 있는 역량은 필수적이다.

 

 

이제, 동기와 비동기가 무엇인지 정확히 짚고 넘어가보자.

동기와 비동기가 무엇이길래 동기적 언어인 자바스크립트에서 비동기적으로 특정 코드를 처리해야하는 것일까?

 

 

 

동기와 비동기

 

  • 동기(synchronous)
    • 어떤 코드가 있을 때, 순서대로 진행되는 것
    • 한 작업이 끝날 때까지 다른 작업은 수행되지 않고 기다린 후, 끝나면 그 다음 차례의 작업이 실행됨
  • 비동기(asynchronous)
    • 코드가 순서대로 실행되지 않음
    • 먼저 읽힌 코드가 먼저 실행됨
    • 순서대로 실행되는 것이 아니라, 작업이 끝난 것부터 실행됨 

 

 

자바스크립트에 대해 처음 공부할 때, 자바스크립트는 단일 스레드(싱글 스레드) & 동기적 언어라고 했었다. 그렇기에, 자바스크립트는 한 번에 하나의 작업만 수행하며, 작업이 실행되는 동안 다른 작업은 멈추고 자신의 차례를 기다리게 된다.
만약 서버로 데이터를 요청했지만 응답이 매우 느릴 경우, 동기적이라면 다른 코드 작업도 밀리게 되므로 웹 실행이 늦어지는 치명적인 문제가 생길 수 있다. 이러한 문제점을 해결하기 위해 비동기적 처리가 반드시 필요하게 되는 것이다.

 

 

만약 동기 및 비동기처리를 적절한 경우에 사용하지 못 한다면 어떻게 될까?

 

웹 개발을 하는데 있어서 수 많은 API들이 사용되는데, 그 API를 불러오기 위해서는 특정 서버와 통신을 해야 한다. 하지만 동기와 비동기를 적절하게 사용하지 못 할 경우, 데이터를 다 불러오기도 전에 실행되어 해당 데이터를 조회할 때에는 정작 undefined가 뜰 수 있다.

 

/*
    setTimeout(()=>{}, 시간초)
    시간의 단위는 ms
    설정한 시간 이후에 함수 내부에 있는 코드가 동작함
*/

// 편의점에 들어가서 음료수를 사서 나오는 상황
// setTimeout(() => {
//   console.log("setTimeout 사용해보기");
// }, 3000);

let product, price;

function goMart() {
  console.log("마트에 들어가서 어떤 음료를 살지 고민...");
}

function pickDrink() {
  // 3초동안 고민
  setTimeout(() => {
    console.log("고민 끝");
    product = "콜라";
    price = 2000;
  }, 3000);
}

function pay() {
  console.log(`상품명 ${product}, 가격 ${price}`);
}

goMart();
pickDrink();
pay();

 

실행 결과

 

이런 경우에는 반드시 데이터를 다 불러온 후 그 데이터를 사용해야하므로 반드시 동기화를 시켜주어야 한다. 이러한 문제점을 해결하기 위해, 비동기 코드를 처리하기 위한 3가지 방법이 있다.

 

 

 

 

먼저,  callback 함수에 대해 공부해보자.

JavaScript는 함수를 인자로 받고 다른 함수를 통해 반환될 수 있는데 이 때, 인자(매개변수)로 대입되는 함수가 콜백함수이다.

 

 

 

1. callback 함수

  • parameter(인자, 매개변수)로 다른 함수에 전달되는 함수
  • 즉, 다른 함수가 실행을 끝낸 뒤에 실행되는 함수로, 어떤 함수가 다른 함수의 실행을 끝낸 뒤 실행되는 것을 보장하기 위해 사용됨
  • 보통 함수를 선언한 뒤에, 함수 타입 파라미터를 맨 마지막에 하나 더 선언해주는 방식으로 정의
  • 콜백 함수가 반복되면, 코드의 들여쓰기가 너무 깊어져 가독성이 떨어지고 코드 수정 난이도가 높아진다는 단점이 존재해서 콜백 함수 사용을 지양함

 

 

 

위의 예제에서 상품명과 가격에 값이 할당이 안되는 것을 확인할 수 있는데, callback함수를 사용해 해결해보자.

let product, price;

function goMart() {
  console.log("마트에 들어가서 어떤 음료를 살지 고민...");
}

function pickDrink(callback) { // 콜백함수
  // 3초동안 고민
  setTimeout(() => {
    console.log("고민 끝");
    product = "콜라";
    price = 2000;
    callback(); // pay 함수가 실행
  }, 3000);
}

function pay() {
  console.log(`상품명 ${product}, 가격 ${price}`);
}

goMart();
pickDrink(pay); // pay함수 호출시 인자가 필요하더라도 그냥 pay만 쓸 것-

 

실행 결과

 

위의 코드를 살펴보면, pickDrink 함수의 인자로 콜백함수인 pay가 들어갔다는 것을 알 수 있다.

pickDrink함수에서 product와 price 변수에 값이 할당이 된 후, callback 함수인 pay함수가 실행되기 때문에 undefined가 아닌 값이 제대로 출력되는 것이다.

 

 

 

 

 

 

두 번째로 살펴볼 것은 Promise이다.

 

 

 

 

2. Promise

  • 비동기 작업이 맞이할 미래의 완료 or 실패와 그 결과 값을 나타냄
  • 성공과 실패를 분리하여 반환
  • 비동기 작업이 완료된 이후에 다음 작업을 연결시켜 진행할 수 있는 기능을 가짐
  • ES6에서 추가된 JS 문법
  • new Promise로 만들어서 사용(클래스), 만들어질 때 executor(실행함수)가 자동으로 실행됨
  • Promise는 2가지 callback 함수를 가짐 : reject, resolve → executor의 인수 
    • resolve: 비동기 작업이 성공(fullfilled)한 경우, 그 결과를 value와 함께 호출
    • reject: 비동기 작업이 실패(rejected)한 경우, 에러 객체(error)와 함께 호출
    • promise가 생성되면 작업을 실행하고, 작업의 완료 여부를 executor의 매개변수를 통해서 전달
  • Promise의 상태
    • Pending(대기): Promise를 수행 중인 상태
    • Fulfilled(이행): Promise가 Resolve된 상태(성공)
    • Rejected(거부): Promise가 지켜지지 못한 상태, Reject된 상태(실패)
    • Settled: fulfilled 또는 rejected 로 결론이 난 상태
  • resolve() → then 메서드 실행
  • reject() catch 메서드 실행
  • 성공/실패 상관X → finally 메서드 실
  • Promise chaining: 메서드를 연속 사용함으로서 간결한 코드로 순차적인 작업 처리가 가능
    • .then().then().then()... 지나치게 길어질 경우엔, 가독성이 떨어진다.

 

 

이제, 위의 예제를 callback이 아닌 promise를 사용해 나타내보자.

let product, price;

function goMart() {
  console.log("마트에 들어가서 어떤 음료를 살지 고민...");
}

function pickDrink() {
  // 3초동안 고민
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("고민 끝");
      product = "콜라";
      price = 2000;
      resolve("구매완료");
      // reject("실패");
    }, 3000);
  });
}

function pay() {
  console.log(`상품명 ${product}, 가격 ${price}`);
}

goMart();
pickDrink()
  .then(() => { // pickDrink가 성공적으로 수행된다면
    pay();
  })
  .catch((err) => { // pickDrink가 실패한다면
    console.log(err);
  })
  .finally(() => { // 성공 or 실패 여부 상관없이 실행됨
    console.log("마트에서 나왔어요");
  });

 

실행 결과

 

Promise 객체를 만들어 사용하며 성공 결과를 resolve에 넘겨줘서 순차적으로 작업이 처리될 수 있도록 하였다. 그 결과, 변수에 알맞은 값이 할당된 후 변수가 출력될 수 있었다.

 

 

 

Promise가 성공/실패한다면, 실행할 함수를 then/catch에 담아 사용하고 있는데, 만약 promise chaining(프로미스 체이닝)을 하게 된다면 then().then().then()~ 처럼 꼬리를 물게 되어 코드의 가독성이 떨어질 수 있다.

이러한 문제점을 해결하기 위해 보다 직관적인 코드인  Async와 Await가 등장한 것이다.

 

 

 

 

마지막으로, Promise를 더 편하게 사용할 수 있는 문법인 Async와 Await를 알아보자.

참고로, Async와 Await는 비동기 처리 패턴 중, 가장 최근에 나온 문법이다.

 

 

3. Async & Await

  • async
    • 비동기로 실행되는 것이 있음을 알림
    • 함수 앞에 붙여 항상 Promise를 반환
      • promise가 아닌 값을 반환해도 promise로 감싸서 반환됨
  • await
    • async 함수 안에서만 await 키워드 사용 가능
    • Promise 앞에 붙여 Promise가 다 처리될 때까지 기다림

 

이제, 위의 예제를 Promise가 아닌 async와 await를 사용해 다시 작성해보자

let product, price;

function goMart() {
  console.log("마트에 들어가서 어떤 음료를 살지 고민...");
}

function pickDrink() {
  // 3초동안 고민
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("고민 끝");
      product = "콜라";
      price = 2000;
      resolve("구매완료");
      // reject("실패");
    }, 3000);
  });
}

function pay() {
  console.log(`상품명 ${product}, 가격 ${price}`);
}

async function execute() { // async를 붙여 비동기 실행이 있음을 알림
  goMart();
  await pickDrink(); // pickDrink 함수의 실행이 끝날 때까지 기다림
  pay();
}

execute(); // 실행함수

 

실행 결과

 

이 예제는 Promise chaining이 많지 않아서 얼마나 코드를 간결하게 만들어 준 것인지 체감이 안될 수도 있는데, .then().then().then()~이 많다고 가정했을 때, 실행 함수를 작성한 후 그 안에서 그냥 함수 앞에 await만 븥여주면 되는거라 가독성이 굉장히 좋아진다

 

 

 

 

 


 

추가 예제

 

< Promise 문법을 사용>

//실습1 = then ~ catch
function call(name) {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      console.log(name);
      resolve(name);
    }, 1000);
  });
}

function back() {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      console.log("back");
      resolve("back");
    }, 1000);
  });
}

function hell() {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      resolve("call back hell");
    }, 1000);
  });
}


// call -> back -> hell 순서로 실행
call("kim")
  .then((name) => { // call 함수가 성공하면
    // resolove에서 넘긴 name
    console.log(name + "반가워");
    return back(); 
  })
  .then((txt) => {  // back 함수가 성공하면
    console.log(txt + "을 실행했구나");
    return hell();
  })
  .then((message) => { // hell 함수가 성공하면
    console.log("여기는 " + message);
  })
  .catch((error) => { // 만약 실패하면
    console.log(error);
  });

 

실행 결과

 

 

 

위 예제를 Promise가 아닌 async & await로 작성해보자.

 

< async & await 문법을 사용>

function call(name) {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      console.log(name);
      resolve(name);
    }, 1000);
  });
}

function back() {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      console.log("back");
      resolve("back");
    }, 1000);
  });
}

function hell() {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      resolve("call back hell");
    }, 1000);
  });
}


// call -> back -> hell 순서로 실행
async function execute() {
  let name = await call("kim");
  console.log(name + "반가워");
  let txt = await back();
  console.log(txt + "을 실행했구나");
  let message = await hell();
  console.log("여기는 " + message);
}

execute();

 

실행 결과

 

 

 

 

간결성을 비교해보자면,

 

 

두 코드를 비교해보면, 제법 차이가 난다는 것을 알 수 있다.

'웹 백엔드' 카테고리의 다른 글

Node.js - 동적 폼 전송  (1) 2024.01.29
Node.js - form  (0) 2024.01.28
Node.js - Express 모듈과 EJS 템플릿  (1) 2024.01.26
Node.js - API와 HTTP 모듈  (0) 2024.01.25
Node.js  (1) 2024.01.22