개발공부

[자바스크립트] 비동기 처리를 어떻게 할까?

떡볶이가 최고야 2024. 7. 21. 21:54

동기 처리와 비동기 처리란 무엇일까?

자바스크립트 엔진은 싱글 스레드 방식으로 동작합니다. 싱글 스레드 방식은 한 번에 하나의 작업만 처리할 수 있기 때문에 시간이 오래 걸리는 작업을 실행하는 동안에는 다음 작업을 시작할 수 없습니다. 이러한 처리 방식을 동기 처리라고 하는데, 동기 처리는 직관적이고 처리 순서가 보장된다는 장점이 있지만 시간이 걸리는 작업을 실행하는 경우 다음 작업이 블로킹된다는 단점이 있습니다.

 

비동기 처리는 동기 처리와 반대로 현재 실행 중인 작업이 종료되지 않은 상태라 하더라도 다음 작업을 실행하는 방식을 말합니다. 이 방식은 시간이 걸리는 작업이 실행 중이더라도 다음 작업이 블로킹되지 않습니다.

 

자바스크립트는 어떻게 비동기 처리가 가능할까?

자바스크립트는 한 번에 하나의 작업만 처리할 수 있는 싱글 스레드 기반의 언어라고 했는데 어떻게 비동기 처리가 가능한 것일까?

그것은 이벤트 루프와 관계가 있습니다.

 

이벤트 루프는 브라우저에 내장되어 있는 기능 중 하나입니다. 브라우저 환경을 그림으로 표현하면 아래와 같습니다.

 

 

자바스크립트 엔진은 단순히 작업이 요청되면 콜 스택을 통해 요청된 작업을 순차적(동기)으로 실행합니다. 여기서 주의해야 할 점은 싱글스레드 방식으로 동작하는 것은 브라우저가 아니라 브라우저에 내장된 자바스크립트 엔진이라는 점입니다. 비동기 처리에서 소스코드의 평가와 실행을 제외한 모든 처리는 자바스크립트 엔진을 구동하는 환경인 브라우저 또는 Node.js가 담당합니다. 예를 들어, 비동기 방식으로 동작하는 setTimeout의 콜백 함수의 평가와 실행은 자바스크립트 엔진이 담당하지만 타이머 설정과 콜백 함수의 등록은 브라우저 또는 Node.js가 담당합니다. 이를 위해 브라우저 환경은 태스크 큐와 이벤트 루프를 제공합니다.

 

브라우저 환경에서 비동기 처리가 어떻게 동작하는지 아래 코드로 살펴보겠습니다. foo함수와 bar함수 중에 어떤 함수가 먼저 실행될까요?

function foo(){
	console.log('foo 실행');
}

function bar(){
	console.log('bar 실행');
}

setTimeout(foo, 0);
bar();

 

1. 전역 코드가 평가되어 전역 실행 컨텍스트가 생성되고 콜 스택에 푸시됩니다.

2. 전역 코드가 실행되기 시작하여 setTimeout 함수가 호출됩니다. 이때 setTimeout 함수의 함수 실행 컨텍스트가 생성되고 콜 스택에 푸시되어 현재 실행 중인 실행 컨텍스트가 됩니다. 브라우저의 Web API인 타이머 함수도 함수이므로 함수 실행 컨텍스트를 생성합니다.

3. setTimeout 함수가 실행되면 콜백함수를 호출 스케줄링하고 종료되어 콜 스택에서 팝됩니다. 이때 호출 스케줄링, 즉 타이머 설정과 타이머가 만료되면 콜백 함수를 태스크 큐에 푸시하는 것은 브라우저의 역할입니다.

4. 4-1(브라우저 실행)과 4-2(자바스크립트 엔진 수행)은 병행 처리됩니다.

  4-1. 브라우저는 타이머를 설정하고 타이머의 만료를 기다립니다. 이후 타이머가 만료되면 콜백 함수 foo가 태스크 큐에 푸시됩니다. 위

  코드에서는 지연시간이 0이지만 최소 지연 시간 4ms로 지정됩니다. 따라서 4ms 후에 콜백 함수 foo가 태스크 큐에 푸시되어 대기합니      다. 지연 시간 이후에 콜백 함수가 태스크 큐에 푸시되어 대기하게 되지만 콜스택이 비어야 호출되므로 약간의 시간차가 발생할 수 있습니    다.

  4-2. bar 함수가 호출되어 bar 함수의 함수 실행 컨텍스트가 생성되고 콜 스택에 푸시되어 현재 실행 중인 실행 컨텍스트가 됩니다. 이후    bar 함수가 종료되어 콜 스택에서 팝됩니다.

5. 전역 코드 실행이 종료되고 전역 실행 컨텍스트가 콜 스택에서 팝되면 콜 스택이 비게 됩니다.

6. 이벤트 루프에 의해 콜 스택이 비어 있음이 감지되고 태스크 큐에서 대기 중인 콜백 함수 foo가 이벤트 루프에 의해 콜 스택에 푸시됩니다. 즉, 콜백 함수 foo의 함수 실행 컨텍스트가 생성되고 콜 스택에 푸시되어 현재 실행 중인 실행 컨텍스트가 됩니다. 이후 foo 함수가 종료되어 콜 스택에서 팝됩니다.

 

 

결론은, bar 함수가 먼저 실행되고 콜 스택에서 팝되면 이벤트 루프에 의해 태스크 큐에서 대기하고 있던 foo 함수가 콜 스택으로 푸시되어 실행됩니다. 따라서 bar 함수가 먼저 실행됩니다.

 

 

정리하면, 자바스크립트는 싱글 스레드 방식으로 동작하지만 브라우저 환경의 Web API, 태스크 큐, 이벤트 루프 등을 의해 비동기 처리가 가능해집니다.

 

 

자바스크립트에서 비동기 처리를 하기 위한 패턴 3가지

1. 콜백 패턴

자바스크립트에서 비동기 처리할 때 사용하는 패턴으로 크게 3가지를 들 수 있습니다.

첫 번째는 콜백 함수를 사용하는 것입니다. 콜백 함수란 함수의 매개변수로 전달되는 함수를 말합니다.

아래 예시코드와 같이 콜백함수가 여러번 중첩되었을 때 코드의 복잡도가 높아져 가독성이 나빠지는 현상을 콜백 지옥(헬)이라고 하며, 콜백 패턴의 단점입니다.

function task1(callback) {
  setTimeout(() => {
    console.log("task1 시작");
    callback();
  }, 1000);
}
function task2(callback) {
  console.log("task2 시작");
  callback();
}

//...

function task6(callback) {
  console.log("task6 시작");
  callback();
}

task1(() => {
  task2(() => {
    task3(() => {
      task4(() => {
        task5(() => {
          task6(() => {
            console.log("끝");
          });
        });
      });
    });
  });
});

 

 

2. Promise

콜백 패턴의 단점을 보완하여 ES6에 도입된 다른 패턴이 Promise입니다.

Promise는 ECMAScript 사양에 정의된 표준 빌트인 객체입니다. new 연산자와 함께 호출하면 프로미스 객체를 생성할 수 있습니다.

 

const promise = new Promise((resolve,reject) => {
	if(/*비동기 처리 성공*/){
    	resolve('result');
    } else { /*비동기 처리 실패*/
    	reject('failure reason');
    }
});

 

Promise 생성자 함수가 인수로 전달받은 콜백 함수 내부에서 비동기 처리를 수행합니다. 이때 비동기 처리가 성공하면 resolve 함수를 호출하고, 비동기 처리가 실패하면 reject 함수를 호출합니다.

 

프로미스는 현재 비동기 처리가 어떻게 진행되고 있는지를 나타내는 상태정보를 갖습니다.

프로미스의 상태 정보 의미 상태 변경 조건
pending 비동기 처리가 아직 수행되지 않은 상태 프로미스가 생성된 직후 기본 상태
fulfilled 비동기 처리가 수행된 상태(성공) resolve 함수 호출
rejected 비동기 처리가 수행된 상태(실패) reject 함수 호출

 

프로미스의 후속 처리 메서드

비동기 처리 상태가 변화하면 이에 따른 후속 처리를 해야합니다.

예를 들어 resolve 함수를 호출하여 fulfilled 상태가 되면 프로미스의 처리 결과를 resolve함수의 인자로 받아 무언가를 해야하고,

reject 함수를 호출하여 rejected 상태가 되면 프로미스의 처리 결과(에러)를 reject함수의 인자로 받아 에러 처리를 해야합니다.

 

이를 위해 프로미스는 후속 처리 메서드 then, catch, finally를 제공합니다.

 

Promise.prototype.then

첫 번째 then은 fulfilled상태(resolve 함수가 호출된 상태)가 되면 호출됩니다.

 

Promise.prototype.catch

두 번째 catch는 rejected상태(reject 함수가 호출된 상태)가 되면 호출됩니다.

 

Promise.prototype.finally

세 번째 finally는 상태와 관계없이 무조건 한 번 호출됩니다.

 

이 세가지 메서드는 언제나 프로미스를 반환하기 때문에 연속적으로 이어서 호출할 수 있습니다.

이것을 프로미스 체이닝이라고 하며, 프로미스 체이닝을 통해 콜백 지옥 현상을 피할 수 있습니다.

promise
	.then((value)=>{console.log(value})
	.catch((error)=>{console.error(error})
	.finally(); //마지막에 무조건 실행

//then의 두 번째 인자로 error처리 콜백함수를 전달하면 catch 안 써도 됨.
promise
    .then(
        (value)=>{console.log(value},
        (error)=>{console.error(error}
        )

 

 

 

프로미스의 정적 메서드

Promise.all

Promise.all은 여러 개의 비동기 처리를 모두 병렬 처리할 때 사용합니다. 아래 예시 코드를 실행하면 then메서드의 콜백함수가 차례로 실행되어 약 6초가 걸립니다. 

const requestData1 = () =>
  new Promise((resolve) => setTimeout(() => resolve(1), 3000));
const requestData2 = () =>
  new Promise((resolve) => setTimeout(() => resolve(2), 2000));
const requestData3 = () =>
  new Promise((resolve) => setTimeout(() => resolve(3), 1000));

const res = [];
requestData1()
  .then((data) => {
    res.push(data);
    return requestData2();
  })
  .then((data) => {
    res.push(data);
    return requestData3();
  })
  .then((data) => {
    res.push(data);
    console.log(res); //[1, 2, 3] => 약 6초 소요
  })
  .catch(console.error);

 

 

이때 Promise.all을 사용하면 비동기 처리를 병렬적으로 처리할 수 있습니다. 배열의 형태로 인수로 전달받고 전달 받은 모든 프로미스가 모두 fulfilled 상태가 되면 모든 처리 결과를 배열에 저장해 새로운 프로미스를 반환합니다.

const requestData1 = () =>
  new Promise((resolve) => setTimeout(() => resolve(1), 3000));
const requestData2 = () =>
  new Promise((resolve) => setTimeout(() => resolve(2), 2000));
const requestData3 = () =>
  new Promise((resolve) => setTimeout(() => resolve(3), 1000));

Promise.all([requestData1(), requestData2(), requestData3()])
  .then(console.log) //[ 1, 2, 3 ] => 약 3초 소요
  .catch(console.error);

 

Promise.allSettled

Promise.allSettled는 Promise.all과 마찬가지로 여러 비동기 처리를 병럴 처리할 수 있습니다. 다만, Promise.all과 다르게 전달 받은 프로미스가 모두 settled 상태(fulfilled 또는 rejected 상태)가 되면 처리 결과를 배열의 형태로 반환합니다. 처리 결과를 나타내는 객체는 fulfilled 인 경우 비동기 처리 상태(status)와 처리 결과를 나타내는 value로 구성되고, rejected인 경우 staus와 reason 프로퍼티로 구성된다.

 

 

3. async await

사용 방법이 어려운 Promise Then의 문법적 설탕(Syntactic sugar)으로 나온 패턴이 async await 입니다.

async와 await를 사용하면 비동기 코드를 동기 코드처럼 작성할 수 있어, 가독성이 좋아지고 에러 처리가 간단해집니다.

 

async는 함수의 앞에 붙여서 해당 함수가 비동기 함수임을 나타내며, await는 비동기 함수의 실행 결과를 기다리는 키워드입니다.

async 함수 안에서 await 키워드를 사용하면, 해당 비동기 작업이 완료될 때까지 코드 실행을 일시 중지하고 결과를 기다린 다음, 해당 결과를 반환한다.

 

 

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const getName = async () => {
  await delay(1000);
  console.log("name");
};

getName();

 

 


 

자바스크립트에서 비동기처리가 가능한 이유와 처리 방식에 대해서 정리해 보았습니다.

저는 개인적으로 async/await 패턴이 가독성 면에서 좋다고 생각해서 주로 사용하고 있습니다. 하지만 이번에 Promise에 대해 공부하면서 알게된 Promise.all이나 Promise.allSetteld은 async/await과 조합해서 사용하면 성능면에서 도움이 될 것 같습니다.

 

아래 코드는 async/await과 Promise.all을 조합해서 작성해 본 코드입니다.

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const getName = async (user) => {
  await delay(1000);
  return user.name;
};

const users = [
  { name: "su", gender: "male" },
  { name: "min", gender: "female" },
];

const getMaleNames = async (users) => {
  // 여러 개의 비동기 작업을 병렬로 실행
  const maleUserNames = await Promise.all(
    users
      .filter((user) => user.gender === "male")
      .map(async (user) => {
        const name = await getName(user);
        return name;
      })
  );

  // 작업이 완료되면 각각의 결과에 접근할 수 있다.
  console.log("maleUserNames:", maleUserNames); //['su']
};

getMaleNames(users);

 

 

해당 글은 [유데미x스나이퍼팩토리] 프로젝트 캠프 : Next.js 2기 수업과 모던 자바스크립트 Deep Dive의 내용을 바탕으로 작성되었습니다.