DEV/FE

[FE] 자바스크립트 비동기 개념 정리 하기

krokerdile 2025. 1. 30. 19:56
728x90

들어가며

스터디에서 동기, 비동기 개념을 다루는 김에 전에 작성해둔 글들과 여러 참고 자료들을 보면서 자바스크립에서의 비동기 개념에 대해서 정리해보는 시간을 가져 봤습니다. 조금 더 추가해야 되는 내용들이 많지만 작성해두고 개선을 해나가보려고 합니다.

자바스크립트에 동기, 비동기 개념이 있는 이유

출처: https://poiemaweb.com/js-async

자바스크립트는 싱글 스레드 언어이기 때문에 한번에 하나의 작업만 수행 할 수 있다. 즉 이전 작업이 완료 되어야 다음 작업을 수행 할 수 있게 된다.우리가 프로그래밍을 하면서 일반적으로 위에서 아래로 차례대로 실행 되는 방식, 이러한 코드 순차 실행을 동기라고 부른다.동기 방식은 직관적이지만 다수의 작업이 이루어진다면, 특정 작업이 마무리 될 때 까지 다음 작업이 이루어 질 수 없기 때문에 성능과 사용자 경험에 영향을 미칠 수 있다.그래서 자바스크립트는 여러 작업을 동시에 처리하기 위해서 비동기라는 개념을 도입해서 특정 작업의 완료를 기다리지 않고 다른 작업을 동시에 수행할 수 있도록 했다.

자바스크립트와 싱글 스레드

싱글스레드의 특징

  • 한번에 하나의 일만 수행 가능
  • 문맥 교환(context switching)이 필요하지 않음
  • 프로그래밍 난이도가 쉽고, CPU 및 메모리 사용이 적음
  • 단순 CPU만을 사용하는 계산 작업은 오히려 멀티 스레드보다 효율적일 수 있음
  • 연산량이 많은 작업을 할 경우 그 작업이 완료되어야 다른 작업을 할 수 있어 멀티 스레드에 비해 효율이 급격히 떨어짐
  • 에러 처리를 못 할 경우 동작을 멈춤

자바스크립트가 싱글 스레드 일까?

자바스크립트의 메인 스레드인 이벤트 루프가 싱글 스레드이기 때문에 자바스크립트는 싱글 스레드 언어라고 부른다. 하지만 이벤트 루프만 독립적으로 실행되지 않고 웹 브라우저나 NodeJs와 같은 멀티 스레드 환경에서 실행된다. 즉, 자바스크립트 자체는 싱글 스레드가 맞지만, 자바스크립트 런타임은 싱글 스레드가 아니라고 할 수 있다.
이는 Node.js와 브라우저 환경 모두에서 확인할 수 있는데, Node.js의 경우 Worker Threads를, 브라우저의 경우 Web Workers를 통해 추가적인 스레드를 생성하고 병렬 처리를 수행할 수 있다. 하지만 이러한 워커 스레드들은 메인 스레드와 직접적인 메모리 공유나 변수 접근이 불가능하며, 오직 메시지 통신(postMessage)을 통해서만 데이터를 주고받을 수 있다. 이는 자바스크립트의 핵심 실행 모델인 '이벤트 루프 + 메인 스레드'가 여전히 하나만 존재하기 때문에 "개념적으로는 싱글 스레드"라고 불린다. 반면, 실제로 여러 개의 스레드가 동시에 실행될 수 있고 OS 레벨에서 멀티 스레드로 동작하기 때문에 "이론적으로는 멀티 스레드"라고 할 수 있다. 브라우저의 경우에는 추가적으로 UI 렌더링, DOM 조작, 네트워크 요청 등을 처리하는 별도의 스레드들이 존재하여 더욱 복잡한 멀티 스레드 구조를 가지고 있다. 결과적으로 자바스크립트는 언어로서는 싱글 스레드지만, 실행 환경은 멀티 스레드 기능을 제공하는 독특한 구조를 가지고 있다고 정리할 수 있다.

프로세스와 스레드

프로세스의 내부 구조는 크게 4가지 섹션으로 구성됩니다. 코드(Text) 섹션에는 실행할 프로그램의 기계어 코드가 저장됩니다. 데이터(Data) 섹션에는 전역 변수와 정적 변수가 저장됩니다. 힙(Heap) 영역은 동적으로 할당되는 메모리를 관리하며, 스택(Stack) 영역은 함수의 호출 정보와 지역 변수를 저장합니다.

프로세스는 운영체제로부터 Process Control Block(PCB)이라는 자료구조를 할당받습니다.

PCB 내부 요소

  • Process ID(PID): 프로세스의 고유 식별자
  • Program Counter: 다음에 실행할 명령어의 주소
  • CPU 레지스터: 프로세스가 CPU를 사용할 때의 레지스터 상태
  • CPU 스케줄링 정보: 프로세스의 우선순위, CPU 사용 시간 등
  • 메모리 관리 정보: 페이지 테이블, 세그먼트 테이블 등
  • 입출력 상태 정보: 할당된 입출력 장치, 열린 파일 목록 등

스레드는 이러한 프로세스 내에서 실행되는 작업 단위로, Light Weight Process(LWP)라고도 불립니다.

스레드 내부 요소

  • 스레드 ID
  • 프로그램 카운터
  • 레지스터 집합
  • 스택

반면에 다음 자원들은 프로세스 내의 모든 스레드가 공유하게 된다.

  • 코드 섹션
  • 데이터 섹션
  • 힙 영역
  • 열린 파일이나 신호와 같은 운영체제 자원

스레드의 동기화를 위해서는 다양한 메커니즘이 사용된다.

  1. 뮤텍스(Mutex): 공유 자원에 대한 접근을 직렬화하는 기본적인 동기화 도구입니다.
  2. 세마포어(Semaphore): 여러 개의 스레드가 공유 자원에 접근할 수 있도록 하는 카운팅 메커니즘입니다.
  3. 모니터(Monitor): 상호배제와 조건 동기화를 위한 고수준의 동기화 메커니즘입니다.

프로세스 간 통신(IPC)은 다음과 같은 방법들을 통해 이루어집니다.

  1. 파이프(Pipe): 부모-자식 프로세스 간의 단방향 통신
  2. 명명된 파이프(Named Pipe): 관련 없는 프로세스 간의 통신
  3. 메시지 큐: 구조화된 메시지의 비동기 교환
  4. 공유 메모리: 여러 프로세스가 동일한 메모리 영역을 공유
  5. 소켓: 네트워크를 통한 프로세스 간 통신

컨텍스트 스위칭(Context Switching)은 프로세스 간에 발생할 때와 스레드 간에 발생할 때의 오버헤드가 다릅니다. 프로세스 컨텍스트 스위칭은 전체 메모리 공간을 변경해야 하므로 비용이 큽니다. 반면, 스레드 컨텍스트 스위칭은 스택과 레지스터만 변경하면 되므로 상대적으로 가볍습니다.

멀티프로세싱과 멀티스레딩은 각각 장단점이 있습니다. 멀티프로세싱은 한 프로세스가 실패해도 다른 프로세스에 영향을 주지 않아 안정성이 높지만, 프로세스 생성과 컨텍스트 스위칭의 오버헤드가 큽니다. 멀티스레딩은 자원 공유가 효율적이고 컨텍스트 스위칭이 빠르지만, 한 스레드의 오류가 전체 프로세스에 영향을 미칠 수 있습니다.

자바스크립트가 싱글 스레드를 선택한 이유는?

자바스크립트가 싱글 스레드로 설계된 배경과 이유는 크게 언어적 관점과 런타임 환경 관점에서 이해할 수 있습니다. 먼저 자바스크립트는 웹 브라우저에서 사용자 인터페이스를 조작하고 웹 페이지의 상호작용을 처리하기 위해 설계되었습니다. 브라우저 환경에서는 사용자 입력을 신속하게 처리해야 하며, DOM 조작이 동시에 여러 스레드에서 발생할 경우 발생할 수 있는 복잡한 동시성 문제를 피하기 위해 단순한 싱글 스레드 모델을 채택했습니다.

claude로 정리한 자바스크립트 엔진과 환경

자바스크립트 엔진의 구조와 동작 방식을 살펴보면, 엔진은 메모리 힙과 콜 스택이라는 두 가지 주요 컴포넌트로 구성됩니다. 메모리 힙은 변수와 객체가 저장되는 공간이며, 콜 스택은 실행 중인 코드의 위치를 추적하는 공간입니다. 이 구조는 본질적으로 싱글 스레드로, 한 번에 하나의 작업만을 처리할 수 있습니다.

하지만 자바스크립트가 싱글 스레드임에도 불구하고 비동기 작업을 효율적으로 처리할 수 있는 것은 런타임 환경(브라우저 또는 Node.js)이 제공하는 이벤트 루프 메커니즘 때문입니다. 이벤트 루프는 콜 스택과 태스크 큐를 지속적으로 모니터링하면서, 콜 스택이 비어있을 때 태스크 큐에서 대기 중인 작업을 콜 스택으로 이동시켜 실행합니다.

이러한 구조를 통해 자바스크립트는 다음과 같은 장점을 얻을 수 있게 되었습니다.

  1. 동시성 문제(race condition, deadlock 등)를 피할 수 있습니다.
  2. 코드의 실행 순서가 예측 가능하며, 디버깅이 상대적으로 쉽습니다.
  3. DOM 조작과 같은 UI 작업을 더 안전하게 처리할 수 있습니다.
  4. 메모리 사용이 효율적이며, 컨텍스트 스위칭 비용이 없습니다.

이러한 싱글 스레드 기반의 자바스크립트가 실제로 비동기 작업을 처리하는 방식을 이해하기 위해서는 먼저 콜백 함수의 개념과 활용에 대해 이해할 필요가 있습니다.

콜백 함수의 이해와 활용

1. 콜백 함수의 기본 개념

콜백 함수는 다른 함수의 매개변수로 전달되어 특정 시점에 실행되는 함수입니다. JavaScript에서 함수는 일급 객체이기 때문에 변수에 할당하거나 다른 함수의 인자로 전달할 수 있습니다. 이러한 특성을 활용하여 비동기 작업을 처리하거나 특정 이벤트가 발생했을 때 실행될 코드를 정의할 수 있습니다.

// 기본적인 콜백 함수 예시
function fetchData(callback) {
  setTimeout(() => {
    const data = { id: 1, name: "John" };
    callback(data);
  }, 1000);
}

fetchData(result => {
  console.log("데이터 수신:", result);
});

2. 콜백 지옥(Callback Hell)

중첩된 콜백 함수는 코드의 가독성을 해치고 유지보수를 어렵게 만듭니다. 이를 콜백 지옥이라고 합니다.

getData(function(a) {
  getMoreData(a, function(b) {
    getMoreData(b, function(c) {
      getMoreData(c, function(d) {
        getMoreData(d, function(e) {
          console.log(e); // 매우 깊은 중첩
        });
      });
    });
  });
});

3. 콜백 지옥을 해결하는 현대적 방법들

3.1 Promise를 활용한 방법

Promise는 비동기 작업의 최종 완료나 실패를 나타내는 객체입니다. Promise를 사용하면 비동기 작업을 체이닝 형태로 처리할 수 있어 가독성이 향상됩니다.

// Promise 예시
function getData() {
  return new Promise((resolve, reject) => {
    fetch('<https://api.example.com/data>')
      .then(response => response.json())
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}

getData()
  .then(data => console.log(data))
  .catch(error => console.error(error));

3.2 Promise 병렬 처리 메서드들

Promise.all()

모든 Promise가 성공적으로 완료될 때까지 기다립니다. 하나라도 실패하면 전체가 실패로 처리됩니다.

const promises = [
  fetch('api/users'),
  fetch('api/posts'),
  fetch('api/comments')
];

Promise.all(promises)
  .then(responses => {
    const [users, posts, comments] = responses;
    // 모든 데이터가 준비된 후 처리
  })
  .catch(error => console.error('하나라도 실패하면 이곳으로:', error));

Promise.allSettled()

각 Promise의 성공/실패 여부와 관계없이 모든 Promise가 처리될 때까지 기다립니다.

const promises = [
  fetch('api/users'),
  fetch('api/posts'),
  fetch('api/comments')
];

Promise.allSettled(promises)
  .then(results => {
    results.forEach(result => {
      if (result.status === 'fulfilled') {
        console.log('성공:', result.value);
      } else {
        console.log('실패:', result.reason);
      }
    });
  });

Promise.race()

가장 먼저 완료되는 Promise의 결과만 반환합니다. 타임아웃 구현에 유용합니다.

const timeout = new Promise((_, reject) => {
  setTimeout(() => reject(new Error('시간 초과')), 5000);
});

Promise.race([
  fetch('api/data'),
  timeout
])
.then(response => response.json())
.catch(error => console.log('요청 실패:', error));

3.3 반복문을 활용한 비동기 처리

for await...of를 사용한 병렬 처리

async function processWithForAwait(urls) {
  // Promise 배열을 생성
  const promises = urls.map(url => fetch(url));
  const results = [];

  // 모든 요청이 동시에 시작되고, 완료되는 대로 처리됨
  for await (const response of promises) {
    const data = await response.json();
    results.push(data);
  }

  return results;
}

// 사용 예시
const urls = [
  '<https://api.example.com/data/1>',
  '<https://api.example.com/data/2>',
  '<https://api.example.com/data/3>'
];

const results = await processWithForAwait(urls);

3.4 async/await를 활용한 최신 방식

async/await를 사용하면 비동기 코드를 동기 코드처럼 작성할 수 있어 가독성이 크게 향상됩니다.

async function processData() {
  try {
    const response = await fetch('api/data');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
}

3.5 실전 활용 예시: 병렬 처리와 순차 처리의 조합

async function processInBatches(items, batchSize = 3) {
  const results = [];

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchPromises = batch.map(processItem);

    // 각 배치는 병렬로 처리하되, 배치 간에는 순차적으로 처리
    const batchResults = await Promise.all(batchPromises);
    results.push(...batchResults);
  }

  return results;
}

// 사용 예시
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const results = await processInBatches(items, 3);

이러한 다양한 방법들을 상황에 맞게 선택하여 사용하면 효율적인 비동기 처리가 가능합니다. Promise.all()은 모든 작업을 동시에 처리해야 할 때, Promise.race()는 가장 빨리 완료되는 작업만 필요할 때, for await...of는 병렬 처리와 순차적 결과 처리가 필요할 때 적절히 활용할 수 있습니다. 특히 최신 JavaScript의 기능들을 활용하면 콜백 지옥을 피하면서도 가독성 높은 코드를 작성할 수 있습니다.

콜백에서 이벤트 루프까지: 자바스크립트의 비동기 처리

앞서 살펴본 콜백 함수는 자바스크립트에서 비동기 처리를 구현하는 가장 기본적인 방식입니다. 하지만 실제로 자바스크립트가 어떻게 이러한 비동기 처리를 수행하는지 이해하기 위해서는 더 깊은 수준의 메커니즘을 살펴볼 필요가 있습니다.

비동기 처리의 필요성

자바스크립트가 싱글 스레드 언어임에도 불구하고 효율적인 비동기 처리가 필요한 이유는 다음과 같습니다:

  1. 사용자 경험 향상: 긴 작업을 동기적으로 처리하면 브라우저가 응답하지 않는 상태가 될 수 있습니다.
  2. 리소스 효율성: I/O 작업 같은 대기 시간이 긴 작업을 비동기로 처리하면 그 동안 다른 작업을 수행할 수 있습니다.
  3. 서버 통신: AJAX 요청이나 파일 작업 같은 네트워크 작업은 비동기적으로 처리되어야 합니다.

콜백에서 현대적 비동기 처리로의 진화

콜백 함수는 비동기 처리의 시작점이었지만, 다음과 같은 한계를 가지고 있었습니다:

  1. 에러 처리의 어려움: 콜백 체인에서 발생한 에러를 처리하기 어렵습니다.
  2. 코드 복잡성: 중첩된 콜백으로 인한 가독성 저하 (콜백 지옥)
  3. 비동기 흐름 제어의 어려움: 여러 비동기 작업의 순서나 병렬 처리가 복잡합니다.

이러한 한계를 극복하기 위해 Promise와 async/await가 도입되었지만, 이들도 결국은 자바스크립트 엔진의 이벤트 루프라는 핵심 메커니즘 위에서 동작합니다. 이벤트 루프는 다음과 같은 특징을 가집니다:

  1. 태스크 큐 관리: 비동기 작업의 완료 순서에 따라 콜백을 관리합니다.
  2. 실행 컨텍스트 조정: 동기/비동기 작업 간의 실행 순서를 조정합니다.
  3. 메모리 관리: 완료된 작업의 콜백과 관련 리소스를 적절히 해제합니다.

브라우저와 Node.js의 비동기 처리

브라우저와 Node.js는 서로 다른 환경에서 비동기 처리를 구현합니다:

  1. 브라우저 환경:
    • Web APIs를 통한 비동기 작업 처리
    • DOM 이벤트, AJAX, 타이머 등의 처리
    • 렌더링 엔진과의 상호작용
  2. Node.js 환경:
    • libuv 라이브러리를 통한 비동기 I/O
    • 이벤트 기반 아키텍처
    • 스레드 풀을 통한 CPU 집약적 작업 처리

현대적 비동기 처리의 특징

최신 자바스크립트의 비동기 처리는 다음과 같은 특징을 가집니다:

  1. 선언적 프로그래밍: async/await를 통한 직관적인 코드 작성
  2. 강력한 에러 처리: try/catch를 통한 통합된 에러 처리
  3. 비동기 제어 흐름: Promise 체이닝과 병렬 처리 기능
  4. 타입 안정성: TypeScript와의 통합을 통한 더 나은 개발 경험

이러한 배경을 이해하고 나면, 자바스크립트가 어떻게 비동기 작업을 처리하는지 더 깊이 있게 살펴볼 수 있습니다.

자바스크립트가 비동기를 처리하는 방법

자바스크립트가 싱글 스레드 언어임에도 멀티 작업을 비동기적으로 처리 가능한 이유를 정리해보려고 합니다.

실제로 싱글 스레드 언어임에도 웹 어플리케이션에서는 네트워크 요청이나 이벤트 처리, 타이머와 같은 작업을 멀티로 처리를 할 수 있습니다. 만약에 싱글 스레드여서 한번에 하나만 가능하다면, 실제로 어떤 작업을 할 때 앞 작업이 끝나지 않는 다면 계속 기다려야 할 것 입니다.

그래서 웹 브라우저 상에서는 네트워크 요청이나 타이머 애니메이션 같은 반복 작업이나 오래 걸리는 작업은 브라우저 내부의 멀티 스레드인 Web APIs에서 비동기 + 논 블로킹으로 처리가 됩니다.

그리고 이 처리된 작업들을 이벤트나 콜백함수를 받아서 이벤트 루프라는 친구가 받아서 마무리 처리를 하게 됩니다.

이벤트 루프의 역할

이벤트 루프에 대해 정리해 보자면, 싱글 스레드에서 일어나는 작업뿐만 아니라 손이 부족한 자바스크립트 언어를 위해서 브라우저 상에서 돌리는 멀티 스레드의 작업을 확인하고 모니터링해 주는 요소라고 볼 수 있습니다.

이벤트 루프는 브라우저 내부의 Call Stack, Callback Queue, Web APIs 등의 요소들을 모니터링하면서 비동기적으로 실행되는 작업들을 관리하고, 이를 순서대로 처리하여 프로그램의 실행 흐름을 제어합니다. 간단히 표현하면 브라우저의 동작 타이밍을 제어하는 관리자라고 볼 수 있습니다.

브라우저 상에서 일어나는 다양한 작업들에는 종류가 있으며, 작업의 종류에 따라서 브라우저 Web APIs에게 일을 맡기거나 싱글 스레드에서 처리하는 경우 등으로 일을 나누어서 작업을 합니다.
맡겨서 해야 되는 일(setTimeout, fetch)과 같은 비동기 작업을 브라우저 Web APIs에게 맡기고, 백그라운드

작업이 끝난 결과를 콜백 함수 형태로 큐(Callback Queue)에 넣고 준비가 되면 호출 스택(Call Stack)에 넣어서 마무리 작업을 진행합니다.

이렇게 이벤트를 기반으로 프로그래밍 하여 이벤트 루프를 관리합니다. 이벤트 기반 프로그래밍이라고도 하는데, 이는 이벤트에 의해서 프로그램의 흐름이 결정되는 방식입니다.

이걸 좀 더 알고리즘 처럼 간단화 해본다면 다음과 같습니다.

  1. 이벤트를 일종의 태스크(작업)으로 본다.
  2. 처리해야 되는 작업이 있다면? → 먼저 들어온 작업 부터 순차적으로 처리한다.
  3. 처리해야 되는 작업이 없다면? → 대기 모드로 있다가 새로운 작업이 추가되면 다시 작동한다.

작업은 일종의 일들의 집합이라고 볼 수 있고, 자바스크립트 엔진은 해당 작업들을 처리하게 되고 만약 작업이 없다면 컴퓨터의 절전모드 처럼 대기 하게 됩니다.

만약에 해야 되는 작업이 많을 때 새로운 작업이 추가 된다면 해당 작업이 바로 매크로 태스크 큐에 추가 됩니다.

매크로 태스크 큐(macrotask queue)

예를 들어 자바스크립트 엔진이 특정 스크립트에 대한 작업을 처리 하고 있는데 사용자가 몇 가지 이벤트를 던졌다고 했을 때, 매크로 태스크 큐에는 특정 스크립트에 대한 작업이 가장 위에 올려져 있고 그 뒤로 이벤트들이 마무리 된다고 했을 때 콜백 함수 형태로 해야 되는 결과가 담기게 됩니다.

이 때 태스크를 처리하는 과정이 만약에 계속 이어지게 된다면 어떤 일이 일어날까요?

  1. 엔진이 특정 태스크를 처리하는 동안엔 렌더링이 절대 일어나지 않습니다. 태스크를 처리하는 데 걸리는 시간이 길지 않으면 이는 전혀 문제가 되지 않습니다. 처리가 끝나는 대로 DOM 변경을 화면에 반영하면 되기 때문입니다.
  2. 태스크 처리에 긴 시간이 걸리면, 브라우저는 태스크를 처리하는 동안에 발생한 사용자 이벤트 등의 새로운 태스크들을 처리하지 못합니다. 인터넷 서핑을 하다 보면 '응답 없는 페이지(Page Unresponsive)'라는 얼럿 창을 만나게 되는 경우가 종종 있습니다. 이 얼럿 창은 아주 복잡한 계산이 필요하거나 프로그래밍 에러 때문에 무한 루프에 빠지게 될 때 나타나는데, 브라우저는 얼럿 창을 통해 사용자에게 페이지 전체와 함께 해당 태스크를 취소시킬지 말지를 선택하도록 유도합니다.

이런 경우를 막기 위해서 작업을 분리해서 여러 개의 작은 태스크로 나누어 처리해야 합니다. 이렇게 하면 태스크 사이사이에 브라우저가 이벤트를 처리하고 화면을 렌더링할 수 있는 틈이 생기게 됩니다.

이를 적용하는 방법은 아래와 같은 방법들이 있습니다.

  1. setTimeout/setInterval을 사용하여 작업을 청크(chunk) 단위로 나누기
  2. requestAnimationFrame을 활용하여 프레임 단위로 작업 분배하기
  3. Web Workers를 사용하여 무거운 계산을 별도의 스레드에서 처리하기

이러한 작업 분할 방법들은 긴 실행 시간을 가진 매크로태스크를 효율적으로 관리하는 데 도움을 줍니다. 하지만 이는 매크로태스크의 처리 방식에 대한 일부분일 뿐입니다. 자바스크립트 런타임에서 실제로 처리되는 태스크의 종류와 그 특성을 더 자세히 살펴보면, 매크로태스크는 다양한 형태로 존재하며 각각 특정한 목적을 가지고 있습니다.

매크로태스크 큐에서 처리되는 주요 작업들은 다음과 같습니다.

  • 스크립트 전체 실행
  • setTimeout, setInterval과 같은 타이머
  • 이벤트 핸들러 (click, keydown 등의 사용자 이벤트)
  • XMLHttpRequest나 fetch와 같은 네트워크 요청
  • FileReader와 같은 파일 작업
  • requestAnimationFrame을 통한 애니메이션 프레임 콜백

이러한 매크로태스크들은 큐에 들어온 순서대로 하나씩 처리됩니다. 그러나 현대 웹 애플리케이션에서는 Promise와 같은 비동기 작업의 즉각적인 처리가 필요한 경우가 많아졌습니다. 특히 여러 비동기 작업이 서로 연관되어 있거나, 데이터의 일관성을 유지해야 하는 경우에는 매크로태스크의 처리 방식만으로는 부족할 수 있습니다. 이러한 필요성에 의해 도입된 것이 바로 마이크로태스크 큐입니다.

Promise와 같은 비동기 작업은 이벤트 루프와 마이크로태스크 큐에서 특별한 방식으로 처리됩니다. Promise가 생성되면 그 실행자(executor)는 즉시 실행되지만, Promise의 결과를 처리하는 .then, .catch, .finally 핸들러들은 마이크로태스크 큐에 등록됩니다.

이벤트 루프는 이러한 마이크로태스크들을 매우 특별한 시점에 처리합니다. 현재 실행 중인 매크로태스크가 완료되면, 이벤트 루프는 다음 매크로태스크로 넘어가기 전에 마이크로태스크 큐를 확인합니다. 이때 마이크로태스크 큐에 작업이 있다면, 큐가 완전히 비워질 때까지 모든 마이크로태스크를 처리합니다.
이러한 처리 방식은 특히 setTimeout과 같은 타이머 기반의 비동기 작업과 비교할 때 큰 차이를 보입니다.

setTimeout은 매크로태스크 큐에서 처리되므로, 지정된 시간이 경과한 후에도 마이크로태스크 큐의 모든 작업이 완료될 때까지 대기해야 합니다. 브라우저는 이러한 매크로태스크와 마이크로태스크의 처리 순서를 통해 효율적인 비동기 작업 관리를 수행합니다.

이벤트 루프의 이러한 동작 방식은 실제 애플리케이션에서 매우 중요한 의미를 갖습니다. Promise 체인으로 연결된 비동기 작업들은 중간에 다른 매크로태스크의 방해 없이 연속적으로 실행될 수 있으며, 이는 데이터의 일관성을 유지하고 예측 가능한 실행 흐름을 만드는 데 도움이 됩니다. 특히 여러 상태 업데이트가 연속적으로 필요한 경우, 이러한 특성은 매우 유용하게 활용될 수 있습니다.

예를 들어, 다음과 같은 코드가 있다고 가정해봅시다.

console.log('시작');

setTimeout(() => {
    console.log('타임아웃');
}, 0);

Promise.resolve()
    .then(() => console.log('프로미스 1'))
    .then(() => console.log('프로미스 2'));

console.log('끝');

이 코드의 실행 순서는 "시작 -> 끝 -> 프로미스 1 -> 프로미스 2 -> 타임아웃" 이 됩니다. 이는 동기 코드가 먼저 실행되고, 이어서 마이크로태스크가 모두 처리된 후, 마지막으로 매크로태스크가 실행되기 때문입니다.

이러한 처리 방식은 Promise 체인에서 특히 중요한 의미를 갖습니다. Promise 체인의 각 .then 핸들러는 새로운 마이크로태스크를 생성하지만, 이들은 다음 매크로태스크나 렌더링 작업 전에 모두 처리됩니다. 이는 연관된 Promise 작업들이 중간에 다른 작업의 방해 없이 순차적으로 완료될 수 있도록 보장합니다.

async/await 구문도 내부적으로는 Promise를 기반으로 동작하기 때문에 같은 원리가 적용됩니다. await 표현식이 Promise를 만나면, 해당 함수의 나머지 부분은 마이크로태스크 큐에 등록되어 Promise가 이행된 후에 실행됩니다.

이러한 마이크로태스크의 특성은 데이터의 일관성이 중요한 상황에서 매우 유용합니다. 예를 들어, 여러 개의 상태 업데이트가 필요한 경우, 이를 Promise 체인으로 구성하면 모든 업데이트가 다음 렌더링 전에 완료되도록 보장할 수 있습니다. 이는 사용자에게 중간 상태가 표시되는 것을 방지하고, 최종 상태만 렌더링되도록 하는 데 도움이 됩니다.

마이크로 태스트 큐(micro task queue)

지금까지 살펴본 매크로태스크의 처리 방식은 자바스크립트의 비동기 처리에서 가장 기본이 되는 개념입니다. 하지만 현대 자바스크립트에서는 이보다 더 섬세한 비동기 처리가 필요한 경우가 많습니다. 특히 Promise와 같은 비동기 작업의 경우, 매크로태스크보다 더 높은 우선순위로 처리되어야 할 때가 있습니다. 이를 위해 자바스크립트는 마이크로태스크라는 또 다른 큐 시스템을 제공합니다.

마이크로태스크는 매크로태스크와는 다른 방식으로 동작하며, 현재 실행 중인 코드가 완료된 직후에 처리됩니다. 이는 다음 매크로태스크나 렌더링이 시작되기 전에 즉시 실행되어야 하는 작업들을 처리하는 데 매우 유용합니다.

마이크로태스크는 Promise의 .then/catch/finally 핸들러, async/await 구문, 그리고 queueMicrotask() API를 통해 생성할 수 있습니다. 이러한 마이크로태스크들은 현재 실행 중인 매크로태스크가 완료된 직후에 처리되며, 다음 매크로태스크나 렌더링 작업이 시작되기 전에 모두 실행됩니다.

자바스크립트 엔진의 이벤트 루프는 매우 체계적인 순서로 작업을 처리합니다. 먼저 매크로태스크 큐에서 하나의 태스크를 가져와 실행하고, 이어서 마이크로태스크 큐에 있는 모든 태스크를 처리한 후, 마지막으로 필요한 렌더링 작업을 수행합니다. 이러한 처리 순서는 애플리케이션의 일관성과 성능에 매우 중요한 역할을 합니다.

예를 들어, setTimeout으로 예약된 콜백 함수는 매크로태스크 큐에 들어가지만, Promise로 처리되는 비동기 작업은 마이크로태스크 큐에 들어갑니다. 따라서 Promise 체인으로 연결된 작업들은 다음 렌더링이나 다른 이벤트 처리 전에 모두 완료될 수 있습니다. 이는 상태 변경에 따른 UI 업데이트가 일관되게 이루어지도록 보장합니다.

이러한 마이크로태스크의 특성은 특히 데이터의 일관성이 중요한 상황에서 유용합니다. 마이크로태스크는 현재의 실행 컨텍스트가 완전히 완료되기 전에 처리되므로, 외부 이벤트나 상태 변경의 영향을 받지 않고 안정적으로 작업을 수행할 수 있습니다. 다만, 마이크로태스크 큐에 과도한 작업이 쌓이면 렌더링이 지연될 수 있으므로, 적절한 작업 분배가 필요합니다.

그래서 이 큐들이 어디에 있는 걸까?

앞서 설명한 마이크로태스크와 매크로태스크의 개념에 이어서, 이 두 가지는 실제로는 Callback Queue라는 더 큰 개념의 일부임을 이해할 필요가 있습니다.

Callback Queue는 자바스크립트의 비동기 처리에서 사용되는 큐 시스템을 총칭하는 개념입니다. 이는 Web APIs에서 여러 API들을 묶어서 부르는 것과 유사하게, 서로 다른 특성을 가진 두 종류의 큐 시스템을 포함합니다. 앞서 살펴본 매크로태스크 큐(Task Queue)와 마이크로태스크 큐(Microtask Queue)가 바로 그것입니다.

매크로태스크 큐는 일반적으로 Task Queue라고도 불리며, setTimeout, setInterval, fetch, addEventListener와 같은 일반적인 비동기 작업들의 콜백 함수가 여기에 포함됩니다. 이러한 작업들은 실행에 있어 상대적으로 낮은 우선순위를 가지며, 마이크로태스크 큐의 모든 작업이 처리된 후에야 실행됩니다.

반면 마이크로태스크 큐는 promise.then, process.nextTick, MutationObserver와 같이 보다 즉각적인 처리가 필요한 비동기 작업들의 콜백 함수가 들어갑니다. 이 큐는 처리 우선순위가 높아, 현재 실행 중인 매크로태스크가 완료되면 다음 매크로태스크로 넘어가기 전에 반드시 처리됩니다.

이러한 이중 큐 시스템은 자바스크립트의 비동기 처리를 더욱 섬세하고 효율적으로 만들어주며, 특히 Promise와 같은 모던 자바스크립트의 비동기 패턴을 더욱 예측 가능하고 안정적으로 만드는 데 큰 역할을 합니다. 이벤트 루프는 이러한 큐들의 우선순위를 고려하여 작업을 처리함으로써, 복잡한 비동기 작업들도 일관된 순서로 실행될 수 있도록 보장합니다.

Animation Queue는 왜 따로 있지?

앞서 살펴본 Callback Queue 시스템 외에도, 브라우저는 애니메이션 처리를 위한 별도의 큐 시스템인 AnimationFrame Queue를 제공합니다. 이 큐가 별도로 관리되는 이유는 애니메이션의 특수성과 밀접한 관련이 있습니다.

AnimationFrame Queue는 일반적인 비동기 작업들과는 다른 특별한 목적을 가지고 있습니다. 이 큐는 requestAnimationFrame 메소드를 통해 등록된 콜백들을 관리하며, 브라우저의 화면 갱신 주기에 맞춰 최적화된 방식으로 작동합니다. 브라우저는 화면을 다시 그리기(repaint) 직전에 이 큐에 있는 모든 작업들을 처리하는데, 이는 애니메이션의 부드러운 실행을 보장하기 위함입니다.

이러한 별도의 큐 시스템이 필요한 주된 이유는 다음과 같습니다.

  1. 성능 최적화: 브라우저는 화면 주사율(보통 60fps)에 맞춰 애니메이션을 처리할 수 있어, 불필요한 렌더링을 방지하고 최적의 성능을 제공합니다.
  2. 리소스 관리: 사용자가 다른 탭이나 창으로 이동했을 때 애니메이션을 자동으로 중지함으로써, 불필요한 리소스 소비를 방지합니다.
  3. 타이밍 정확성: 브라우저의 렌더링 사이클과 동기화되어 작동하므로, 보다 정확한 애니메이션 타이밍을 제공합니다.

이러한 특성 때문에 AnimationFrame Queue는 시각적 업데이트와 관련된 작업들을 처리하는 데 있어 매크로태스크 큐나 마이크로태스크 큐보다 더 적합합니다. 특히 스크롤 이벤트 처리, DOM 요소의 애니메이션, 캔버스 애니메이션과 같은 작업들을 처리할 때 이 큐를 활용하면 더 나은 성능과 사용자 경험을 제공할 수 있습니다.

예를 들어, setTimeout을 사용한 애니메이션과 비교했을 때, requestAnimationFrame을 사용한 애니메이션은 브라우저의 렌더링 주기와 완벽하게 동기화되어 더 부드럽고 효율적인 애니메이션을 구현할 수 있습니다. 이는 현대 웹 애플리케이션에서 복잡한 시각적 효과를 구현할 때 특히 중요한 장점이 됩니다.

그러면 Node.js와 브라우저의 차이는?

지금까지 살펴본 이벤트 루프와 콜스택의 개념은 주로 브라우저 환경에서의 작동 방식을 설명한 것입니다. 반면 백엔드 개발에서는 Node.js가 이러한 실행 환경을 제공하며, 브라우저와는 몇 가지 중요한 차이점을 가지고 있습니다.

Node.js는 브라우저와 유사한 이벤트 기반 비동기 처리 구조를 가지고 있지만, 내부적으로는 libuv라는 강력한 라이브러리를 사용하여 비동기 I/O를 처리합니다. 가장 큰 차이점은 API 환경에서 나타나는데, 브라우저가 DOM 조작, AJAX 요청, 애니메이션 등을 처리하기 위한 Web API를 제공하는 반면, Node.js는 파일 시스템 접근, 네트워크 작업, 암호화, 압축 등을 위한 고유한 Node.js API를 제공합니다. 다만 setTimeout이나 setInterval과 같은 일부 API는 양쪽 환경에서 모두 사용 가능합니다.

Node.js의 내부 아키텍처는 다음과 같은 핵심 컴포넌트들로 구성되어 있습니다:

  1. V8 엔진은 자바스크립트 코드를 실행하는 핵심 엔진으로, 구글이 개발한 고성능 자바스크립트 엔진입니다.
  2. Node API(Bindings)는 V8 엔진과 Node.js 시스템 간의 상호작용을 담당하는 중간 계층으로 작동합니다.
  3. libuv 라이브러리는 비동기 I/O 작업을 효율적으로 처리하기 위한 핵심 컴포넌트로, 이벤트 루프의 실제 구현을 담당합니다.
  4. Event Queue는 브라우저의 Task Queue와 유사한 역할을 하지만, 주로 I/O 작업의 결과를 저장하고 처리하는 데 특화되어 있습니다.
  5. Node.js의 이벤트 루프는 libuv를 통해 구현되며, I/O 작업의 결과를 처리하고 다음 작업을 스케줄링하는 역할을 담당합니다.

또한 Node.js 10 버전부터는 Worker Threads가 추가되어 CPU 집약적인 작업을 별도의 스레드에서 처리할 수 있게 되었습니다. 각 Worker Thread는 독립적인 V8 엔진 인스턴스를 가지고 있어, 진정한 의미의 병렬 처리가 가능합니다.

Callback Queue에 담는 방법에도 차이가 있다.

이러한 차이점들은 각 환경의 주요 용도와 밀접하게 연관되어 있습니다. 브라우저는 사용자 인터페이스와 관련된 작업에 최적화되어 있는 반면, Node.js는 서버 사이드 작업과 시스템 수준의 작업을 효율적으로 처리하도록 설계되어 있습니다.

앞선 Node.js와 브라우저의 차이점 설명에 이어서, API의 동작 방식에서도 주목할 만한 차이가 있습니다. 두 환경은 비슷한 API 구성을 가지고 있지만, 비동기 작업의 처리 방식에서 중요한 차이를 보입니다.

가장 큰 차이점은 콜백 함수가 큐에 추가되는 방식입니다. 브라우저 환경에서는 Web API가 비동기 작업 완료 후 직접 콜백 함수를 Callback Queue에 추가합니다. 반면 Node.js 환경에서는 API가 작업 완료 이벤트만 발생시키고, 이벤트 루프가 이를 감지하여 콜백 함수를 Task Queue에 추가하는 역할을 담당합니다.

이러한 차이는 타이머 API의 동작에서 명확하게 드러납니다. 예를 들어, setTimeout 함수가 실행되는 경우:

  • 브라우저에서는 Timer Web API가 지정된 시간이 경과한 후 직접 콜백 함수를 Task Queue에 추가합니다.
  • Node.js에서는 Timer API가 시간 경과 후 완료 이벤트를 발생시키고, 이벤트 루프가 이 이벤트를 감지하여 Task Queue에 콜백 함수를 추가합니다.

이러한 구조적 차이는 Node.js의 libuv 라이브러리가 이벤트 기반 비동기 I/O를 더 세밀하게 제어할 수 있게 해주며, 특히 서버 환경에서 필요한 다양한 종류의 I/O 작업을 효율적으로 처리할 수 있게 합니다.

이벤트 루프와 Async/Await

Async/await는 Promise를 기반으로 하는 문법적 편의 기능입니다. 겉으로 보기에는 단순히 비동기 코드를 동기적으로 작성할 수 있게 해주는 것처럼 보이지만, 내부적으로는 매우 정교한 동작 방식을 가지고 있습니다.

우선 async 함수의 특징부터 살펴보면, async 키워드가 붙은 함수는 항상 Promise를 반환합니다. 이는 함수 내부에서 명시적으로 Promise를 반환하지 않더라도 마찬가지입니다. 이러한 특성 때문에 async 함수는 then() 메서드를 통한 체이닝이 가능하며, 다른 async 함수 내에서 await와 함께 사용될 수 있습니다.

await 키워드의 동작 방식은 더욱 흥미롭습니다. await는 Promise가 처리될 때까지 함수의 실행을 일시적으로 중단시키는 것처럼 보이지만, 실제로는 Promise 체인을 자동으로 구성합니다. 예를 들어, await 키워드 다음에 오는 모든 코드는 자동으로 then() 핸들러의 콜백 함수로 변환됩니다. 이는 코드의 가독성을 크게 향상시키면서도 Promise의 강력한 기능을 모두 활용할 수 있게 해줍니다.

이벤트 루프의 관점에서 보면, await 표현식을 만났을 때 일어나는 일들이 더욱 명확해집니다. await를 만나면 해당 async 함수의 실행은 일시 중단되고, 함수의 나머지 부분은 마이크로태스크 큐에 등록됩니다. 이때 중요한 점은, await 이후의 코드가 모두 Promise의 then 핸들러로 감싸져서 처리된다는 것입니다. 이러한 동작 방식 덕분에 코드는 동기적으로 보이지만 실제로는 비동기적으로 처리되며, 이벤트 루프의 효율적인 작업 처리 방식을 그대로 활용할 수 있습니다.

이벤트 루프 관점에서 async/await의 동작을 살펴보면, 비동기 작업들은 마이크로태스크 큐와 매크로태스크 큐를 통해 처리됩니다. Promise의 then 핸들러와 await 표현식 이후의 코드는 마이크로태스크 큐에 등록되어 처리되는데, 이는 일반적인 비동기 콜백들이 매크로태스크 큐에서 처리되는 것과는 다른 특징입니다.

예를 들어, 다음과 같은 코드를 살펴보겠습니다.

console.log('Start');

// 매크로태스크 예시
setTimeout(() => {
    console.log('Timeout 1');
}, 0);

// Promise와 async/await (마이크로태스크)
async function asyncOperation() {
    console.log('Async Start');
    await Promise.resolve();
    console.log('Async End');
}

asyncOperation();

// 또 다른 매크로태스크
setTimeout(() => {
    console.log('Timeout 2');
}, 0);

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

console.log('End');

// 출력 순서:
// Start
// Async Start
// End
// Promise
// Async End
// Timeout 1
// Timeout 2

이 예시에서 볼 수 있듯이, 마이크로태스크 큐의 작업(Promise와 await 이후의 코드)이 매크로태스크 큐의 작업(setTimeout 콜백)보다 먼저 처리됩니다. 이는 이벤트 루프가 각 매크로태스크 사이에 마이크로태스크 큐를 완전히 비우기 때문입니다.

최상위 레벨 await의 도입으로 이러한 비동기 처리는 더욱 강력해졌습니다. 모듈의 최상위 레벨에서 await를 사용할 수 있게 되면서, 모듈의 초기화 자체를 비동기적으로 처리할 수 있게 되었습니다. 이는 특히 데이터베이스 연결, 설정 로딩, API 클라이언트 초기화 등의 작업에서 매우 유용합니다.

예를 들어, 다음과 같은 모듈 시스템에서의 사용을 살펴보겠습니다.

// config.js
export const config = await fetchConfiguration();
export const database = await initializeDatabase(config);

// 다른 모듈에서의 사용
import { config, database } from './config.js';

// 이 시점에서 설정과 데이터베이스는 이미 초기화되어 있음
console.log('Configuration:', config);
await database.query('SELECT * FROM users');

// 실제 애플리케이션에서의 활용 예시
class UserService {
    constructor() {
        this.db = database;  // 이미 초기화된 데이터베이스 인스턴스
    }

    async getUsers() {
        return this.db.query('SELECT * FROM users');
    }
}

이러한 최상위 레벨 await의 사용은 모듈 전체의 실행을 제어합니다. 특히 주목할 점은, await를 사용하는 모듈을 가져오는 다른 모듈들도 자동으로 비동기적으로 처리된다는 것입니다. 이는 의존성 트리 전체에 걸쳐 비동기 초기화를 자연스럽게 관리할 수 있게 해줍니다.

큐 시스템의 관점에서 보면, 최상위 레벨 await도 일반적인 await와 마찬가지로 마이크로태스크 큐를 통해 처리됩니다. 다만 모듈 시스템의 특성상, 모듈 평가 자체가 지연될 수 있으므로 성능에 미치는 영향을 고려해야 합니다.

마이크로태스크와 매크로태스크의 처리 순서는 다음과 같습니다.

  1. 현재 실행 중인 동기 코드 완료
  2. 마이크로태스크 큐의 모든 작업 처리
    • Promise 콜백
    • await 이후의 코드
    • process.nextTick (Node.js)
  3. 매크로태스크 큐의 다음 작업 처리
    • setTimeout/setInterval 콜백
    • I/O 이벤트
    • setImmediate (Node.js)

이러한 처리 순서는 비동기 작업의 우선순위와 실행 순서를 예측 가능하게 만들어, 복잡한 비동기 로직을 안정적으로 구현할 수 있게 해줍니다.

참고자료

https://d2.naver.com/helloworld/2922312

https://medium.com/@vdongbin/javascript-%EC%9E%91%EB%8F%99%EC%9B%90%EB%A6%AC-single-thread-event-loop-asynchronous-e47e07b24d1c

https://ko.javascript.info/event-loop

https://ko.javascript.info/microtask-queue

https://chanmi-lee.github.io/articles/2020-06/JavaScript-Visualized-Event-Loop#google_vignette

https://inpa.tistory.com/entry/%F0%9F%94%84-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EA%B5%AC%EC%A1%B0-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC#node.js%EC%9D%98_%EB%82%B4%EB%B6%80_%EA%B5%AC%EC%84%B1%EB%8F%84

http://latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7%21%21%21PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D

728x90
반응형