[JS] 비동기, 이벤트 루프, Promise, async-await 정리

js43o·2023년 9월 29일
1
post-thumbnail

자바스크립트를 처음 배울 때 가장 어렵다고 느낀 부분 중 하나는 '비동기'에 관한 것이었다. 비동기의 개념과 동작 원리, Promise, async-await 등에 관하여 나만의 언어로 정리해 본 글이다.

1. '비동기'란?

비동기(Asynchronous): 동기(Synchronous)와 상반되는 개념. 코드를 순차적으로, 차례차례, 맞물려서 실행하는 것이 아니라 독립적으로, 따로따로, 기다리지 않고 실행시키는 것이 비동기 프로그래밍이다.

자바스크립트는 싱글 스레드 언어이다. 즉, 물리적으로 동시에 하나의 일밖에 하지 못한다.
단순 사칙연산 수준이 아닌 DB 접근, API 요청 등 시간이 오래 걸리는 작업을 해야 한다면 해당 작업이 완전히 끝날 때까지 다음 코드를 실행할 수 없게 된다. (브라우저의 경우 UI 조작, 렌더링 역시 블로킹된다!)
이를 해결하기 위해 비동기 개념이 도입된 것이다.

2. 콜 스택, 태스크 큐, 그리고 이벤트 루프

자바스크립트 자체는 하나의 콜 스택만을 가진 싱글 스레드 언어이지만 브라우저에서 제공하는 Web APIs, 태스크 큐, 이벤트 루프를 통해 비동기 프로그래밍을 구현할 수 있다고 한다.

자바스크립트 명세서엔 setTimeout과 setInterval가 명시되어 있지 않습니다. 하지만 시중에 나와 있는 모든 브라우저, Node.js를 포함한 자바스크립트 호스트 환경 대부분이 이와 유사한 메서드와 내부 스케줄러를 지원합니다. - 모던 Javascript 튜토리얼

이미지 출처: https://medium.com/@Rahulx1/understanding-event-loop-call-stack-event-job-queue-in-javascript-63dcd2c71ecd
  • 콜 스택: 현재 실행 중인 코드에 대한 정보(실행 컨텍스트)가 저장되는 공간. 함수가 호출될 때마다 해당 함수에 대한 정보가 삽입되며, 실행을 마치고 반환될 때 꺼내짐.
  • Web APIs: setTimeout(), xhr(), DOM 렌더링 등 여러 가지 비동기 기능을 처리하며, 처리가 끝난 작업을 태스크 큐에 삽입함.
  • 태스크 큐: 콜 스택에 들어가 실행되기를 기다리는 '태스크'들의 큐.
  • 이벤트 루프: 콜 스택과 태스크 큐를 감시하다가, 콜 스택이 비워졌을 때 태스크 큐 가장 앞의 태스크 하나를 꺼내어 콜 스택에 삽입하는 동작을 반복적으로 수행함.

위 4가지 요소들이 서로 상호작용하는 과정을 설명한 매우 유명한 영상이 있다. 비동기 흐름을 이해하는 데에 많은 도움이 되었다.

매크로태스크 큐 & 마이크로태스크 큐

태스크 큐에 삽입되는 태스크는 '매크로태스크'와 '마이크로태스크' 큐로 다시 나뉘고, 이를 담는 큐 역시 각각 존재한다.

  • 매크로태스크: setTimeout과 같은 스케줄링 함수의 콜백 함수, 이벤트 핸들러 함수, 외부 스크립트 파일(<script>) 실행
  • 마이크로태스크: Promise에 등록된 then, catch, finally 핸들러 함수

자바스크립트 엔진은 매크로태스크 하나를 처리할 때마다 또 다른 매크로태스크나 렌더링 작업을 하기 전, 마이크로태스크 큐에 쌓인 마이크로태스크 전부를 처리합니다. - 모던 Javascript 튜토리얼

비동기라고 다 같은 비동기 태스크가 아니었다. JS에서는 다른 비동기 작업들보다도 Promise에 대한 작업을 가장 우선적으로 처리하게 되어 있는 것이다. (UI 변화, 네트워크 요청, 이벤트 발생 등으로 발생하는 사이드 이펙트와 무관하게 처리하기 위해서!)

코드 예시

function sync() {
  console.log('Sync');
}

function timer() {
  setTimeout(() => console.log('setTimeout'), 0);
}

function promise() {
  new Promise((resolve) => resolve('Promise')).then(console.log);
}

timer();
promise();
sync();
promise();
console.log('hello, world!');

위의 코드를 실행한 결과는 이렇다.

Sync
hello, world!
Promise
Promise
setTimeout

이때 코드에서 timer()가 먼저 호출되었음에도 sync()의 결과가 우선적으로 출력되는 것을 볼 수 있다. 그런데 왜지?

setTimeout()의 딜레이가 0초니까 호출되자마자 곧장 태스크 큐에 콜백이 들어갈 테고, 그때는 콜 스택도 비어있지 않나?
콜 스택이 비어있으면 이벤트 루프가 태스크 큐에서 태스크를 꺼내서 콜 스택에 삽입한다 했는데... 왜 가장 먼저 실행되지 않았을까?

  • 그 이유는 전역 코드 역시 하나의 실행 컨텍스트를 가지는 커다란 익명 함수와 같기 때문이다. 즉, 스크립트가 처음 실행되는 시점에 콜 스택은 비어있지 않다. 가장 먼저 전역 컨텍스트가 밑바닥에 깔리게 되며, 전체 스크립트 코드가 종료(반환)된 뒤에야 스택에서 빠지게 된다. (C++의 main 함수를 생각하면 이해가 쉽다)
  • 그러므로 비동기 코드는 먼저 처리할 수 있는 모든 동기 코드가 실행된 뒤에 실행된다!

또한 먼저 실행한 (딜레이 0초의) setTimeout()보다 우선순위가 더 높은 Promise(= 마이크로태스크)의 처리가 먼저 이루어지는 것도 볼 수 있다.

3. Promise

Promise(프로미스)는 내부적으로 상태(state)결과(result)를 갖는 자바스크립트 객체이다.

  • 상태는 pending(대기), fulfilled(이행), rejected(실패) 3가지로 나뉜다.
  • 결과는 fulfilled일 때에는 어떤 작업에 대한 유효한 결괏값, rejected일 때에는 에러에 대한 값이 담긴다.
  • 프로미스는 선언과 동시에 실행된다. 이때 초기 상태는 pending이다.

new Promise(executor) 키워드로 프로미스를 생성할 수 있으며, 인자로 넘기는 콜백 함수 executor는 반드시 (resolve, reject) => { ... }과 같은 형태로 작성해야 한다.

  • resolvereject 역시 일종의 콜백 함수이며, 둘 중 하나를 반드시 내부에서 인자와 함께 호출해야 한다.
  • resolve(value)를 호출하면 프로미스가 fulfilled 상태로 바뀌며 result에 value가 들어간다.
  • reject(error)를 호출하면 프로미스가 rejected 상태로 바뀌며 result에 error가 들어간다.

여기까지만 보면 프로미스는 평범한 JS 객체와 다를 게 없어 보인다. 한 가지 차이점은, 프로미스의 결괏값에 접근하려면 반드시 '소비자' 역할을 하는 then(), catch(), finally() 메서드를 사용해야 한다는 것이다.

  • .then((result) => { ... }): 프로미스가 이행(fulfilled)되었을 때의 결과를 받아 내부에서 다룬다.
  • .catch((error) => { ... }): 프로미스가 거부(rejected)되었을 때의 에러를 받아 내부에서 다룬다.
  • .finally(() => { ... }): 프로미스가 '처리'(이행 또는 거부)되었을 때 콜백을 실행한다. 결과를 인자로 받지 않는다.

왜 프로미스를 사용할까? (콜백 방식과의 비교)

비동기 작업이 각각 독립적으로만 실행되어도 상관없다면 프로미스는 거의 필요가 없을 것이다. 하지만 그게 아니라 각 비동기 작업이 '순차적으로' 이루어져야 하는 상황이 있을 수 있다. 예를 들면 데이터를 서버에서 받아온 후 그 결괏값을 JSON 형태로 파싱한다던지...

프로미스가 생기기 이전에는 콜백 방식으로 이러한 순차적인 비동기 로직을 처리했었다. 어떤 비동기 작업이 끝난 뒤에 수행할 작업을 '콜백 함수' 형태로 처음 비동기 작업을 수행하는 함수에게 넘겨서 맡기는 식이다.

하지만 만약 연쇄적으로 일어나야 하는 비동기 작업의 수가 늘어난다면 콜백을 넘기고 또 넘기는 코드가 중첩될 것이고, 들여쓰기(indent) 단계가 높아짐에 따라 전체적인 코드가 가로 방향으로 커지게 된다. (= 콜백 지옥)

// 서버로부터 사용자 목록을 받아온 후
fetchUsers((users) => {
  // 객체 형태로 파싱한 후
  parseUsers(users, (users) => { 
    // 현재 사용자를 찾은 후
    findCurrentUser(users, (currentUser) => {
      // 콘솔에 출력하라
      console.log(currentUser);
    }); 
  });
});

하지만 프로미스의 then() 체이닝을 이용하면 중첩 단계를 높이지 않고도 여러 개의 비동기 로직을 연달아서 순차적으로 수행할 수 있다.

fetchUsers()
  .then(users => parseUsers(users))
  .then(users => findCurrentUser(users))
  .then(currentUser => console.log(currentUser));
  • 이것이 가능한 이유는 then()프로미스를 받아 프로미스를 반환하기 때문이다. then()의 인자로 넘겨주는 콜백 함수는 프로미스의 결괏값을 인자로 받으며, return문의 대상이 프로미스가 아니라면 해당 값을 결괏값으로 하는 이행된 프로미스를 반환하도록 한다.
  • 물론 new Promise()문을 통해 직접 프로미스를 생성하여 반환시킬 수도 있다. 보통 아래 코드처럼 명시적으로 값을 반환하기 어려운 경우 resolve()를 호출하여 결괏값을 직접 프로미스에 지정해 주는 것이다.
.then(user => new Promise(function(resolve, reject) {
    setTimeout(() => resolve(user), 3000);
}

4. async-await

ES2017에서 새로 추가된 문법인 async, await을 이용하면 Promise, then 문법을 사용하지 않고도 비동기 코드를 작성할 수 있다.

  • async: 함수 선언문 앞에 붙여서 해당 함수를 'async 함수'로 만들 수 있다. async 함수는 항상 프로미스를 반환한다. (프로미스가 아닌 값을 return 한다면 해당 값을 결괏값으로 하는 이행된 프로미스를 반환)
  • await: 프로미스 객체 앞에 붙여 쓰며, 해당 프로미스가 처리될 때까지 기다린 후 결괏값을 반환한다. async 함수 내부에서만 사용할 수 있다.

아래 코드는 위쪽의 프로미스 코드를 async-await 문법으로 변경한 코드이다. 비동기 코드를 마치 평범한 동기 코드처럼 작성할 수 있다!

async function foo() {
  const users = await fetchUsers();
  const currentUser = await findCurrentUser(users);
  console.log(currentUser);
}
  • 에러 처리는 async 함수 안에서 일반적인 try-catch문 방식을 이용한다.
  • async 함수 역시 프로미스를 반환하므로 await 키워드를 통해 평가할 수 있다.
  • Promise.all() 등의 메서드 역시 await이 가능하다.

이렇게 보면 Promise.then().catch() 문법을 완벽히 대체할 수 있을 것 같지만 꼭 그렇진 않다.
전역 스코프에서는 await을 쓸 수 없으므로 기존 프로미스 방식을 사용해야 하고, Node.js의 Express 등 일부 라이브러리는 async-await 문법을 아직 완벽히 지원하지 않는다. 또한 전통적인 콜백 방식 역시 단순한 코드에서는 구현이 간단하므로 여전히 사용된다.

5. 결론

여기까지 JS에서 비동기와 관련된 내용들을 하나씩 정리해 보았다.

예전에 비동기 코드를 작성할 때 자주 혼란스러웠던 부분은 '동기 코드와 비동기 코드가 어떤 방식으로 서로 상호작용할까?' 였는데, 이 글을 쓰기 위해 다시금 비동기에 관해 학습하고 정리하면서 확신이 들게 되었다.

'비동기 코드는 동기 코드와 완전히 분리된 맥락에서 돌아간다! 둘은 혼재될 수 없다'가 내가 생각한 결론이다.

  • async-await 문법도 결국은 '동기 코드처럼 보이도록' 비동기 코드를 작성할 수 있게 해줄 뿐, 실제로는 프로미스 개념을 벗어나는 완전히 새로운 기능이 아니다. async로 선언한 함수 자체가 곧 프로미스가 되기 때문이다.
  • 이벤트 루프 관련 내용 중에서 전역 스코프 자체가 하나의 익명 함수처럼 태스크 큐에 가장 먼저 추가된다는 사실도 이해에 큰 도움이 되었다. 결국 모든 비동기 코드는 모든 동기 코드가 실행을 마친 뒤에야 실행된다는 것!
profile
공부용 블로그

0개의 댓글