4.Node.js/JavaScript&TypeScript

[JavaScript] 5편. 비동기 처리와 이벤트 루프 이해하기 : Promise, async/await, Event Loop

쿼드큐브 2025. 12. 6. 13:25
반응형
반응형

 

5편. 비동기 처리와 이벤트 루프 이해하기 : Promise, async/await, Event Loop

 

📚 목차
1. 콜백 기반 비동기 처리와 콜백 지옥(Callback Hell)
2. Promise의 기본 구조와 체이닝
3. async/await과 현대 비동기 처리 실무 패턴
4. AbortController / AbortSignal (ES2022)
5. Microtask / Task Queue와 Event Loop
6. Promise.withResolvers(ES2024) - 외부 제어형 Promise의 공식 해법
✔ 마무리

 

자바스크립트는 단일 스레드 기반의 언어이지만, 비동기 API·이벤트 루프·Task Queue·Promise Microtask를 적절히 활용함으로써 여러 작업을 동시에 수행하는 것처럼 보이게 설계되어 있습니다.
아래는 실무 개발자가 반드시 이해해야 하는 현대적 비동기 프로그래밍의 핵심 개념들입니다.

비동기 처리 이해하기 삽화 이미지
비동기 처리 이해하기 삽화 이미지

 

📂 [GitHub 실습 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /JavaScript

 

1. 콜백 기반 비동기 처리와 콜백 지옥(Callback Hell)

자바스크립트의 초기 비동기 프로그래밍 방식은 대부분 콜백(callback)에 의존하고 있었습니다.

콜백은 “특정 작업이 완료된 시점에 실행할 함수”를 미리 인자로 전달하는 방식이며, 당시의 브라우저 API와 Node.js 표준 라이브러리는 이러한 패턴을 기본으로 제공했습니다.


예를 들어, 네트워크 요청·파일 읽기·타이머 같은 작업은 실행에 시간이 걸리기 때문에 결과를 즉시 반환하는 대신, 완료 후 콜백 함수를 호출하는 방식으로 처리되었습니다.

 

🔷 기본적인 콜백 비동기 패턴

아래 예제는 1초 후 데이터를 반환하는 비동기 함수입니다

function fetchData(callback) {
  setTimeout(() => {
    callback(null, "데이터 로드 완료");
  }, 1000);
}

fetchData((err, data) => {
  if (err) {
    console.error("오류 발생:", err);
    return;
  }
  console.log(data);
});

위 코드는 자바스크립트 초기에 흔히 사용되던 구조로, 에러는 첫 번째 인자, 정상 값은 두 번째 인자로 전달하는 Node.js 콜백 관례(error-first callback)를 따릅니다.

이와 같은 패턴은 작은 함수에서는 충분히 사용 가능하지만, 여러 비동기 작업이 순차적으로 연결되기 시작하면 문제는 금방 드러납니다.

 

🔷 콜백 지옥(Callback Hell)의 실제 문제 예시

콜백 헬(Callback Hell)은 자바스크립트에서 비동기 작업을 콜백(callback)으로만 처리할 때 콜백 함수가 여러 번 중첩되면서 코드가 지나치게 복잡하고 읽기 어려워지는 현상을 말합니다.

 

아래 코드는 1초 간격으로 세 번의 비동기 작업을 순차적으로 실행합니다.

setTimeout(() => {
  console.log("1초 후");
  setTimeout(() => {
    console.log("2초 후");
    setTimeout(() => {
      console.log("3초 후");
    }, 1000);
  }, 1000);
}, 1000);

위와 같은 구조는 다음과 같은 문제를 갖습니다

비동기 작업이 많아질수록 들여쓰기가 깊어지고, 로직의 흐름을 위에서 아래로 자연스럽게 읽기 어렵습니다.

▸ 에러가 발생할 수 있는 위치가 많아지는 반면, 모든 중첩된 콜백에서 적절히 에러를 처리하고 전파하는 것은 쉽지 않습니다.

▸ 하나의 단계만 수정해도 상위·하위 콜백 흐름 전체를 다시 검토해야 합니다.
실무 프로젝트에서는 이 복잡성이 빠르게 누적됩니다.

 

2. Promise의 기본 구조와 체이닝

콜백 기반 비동기 처리의 구조적 한계를 보완하기 위해 ES6(2015)에서는 Promise가 공식 표준으로 도입되었습니다.
Promise는 “비동기 작업의 완료 또는 실패를 표현하는 객체”로, 비동기 코드의 흐름을 훨씬 명확하고 예측 가능하게 관리할 수 있게 합니다.


전통적인 콜백 패턴에서는 작업 순서를 읽기 어렵고, 여러 단계의 비동기 흐름을 제어하는 동안 깊은 중첩이 발생하는 문제가 있었습니다.
Promise는 이러한 중첩을 평평한 구조로 바꾸고, 에러 전파를 단일 경로로 통합하여 비동기 프로그래밍을 더욱 안정적이고 체계적으로 만들어 줍니다.

 

🔷 Promise의 기본 구조

new Promise(/* 실행자 함수 (Executor) */ (resolve, reject) => {
  // 비동기 작업을 수행하는 코드
});

Promise 객체는 new Promise() 생성자를 사용하여 생성합니다.

🔸실행자 함수 (Executor Function): 

▸ 이 함수는 resolve와 reject라는 두 개의 인수를 받으며, new Promise가 호출되는 즉시 실행됩니다. 

▸ 이 함수 내부에 실제로 시간이 걸리는 비동기 로직이 포함됩니다.


🔸resolve(value): 

▸ 비동기 작업이 성공적으로 완료되었을 때 호출하는 함수입니다. 

▸ 이 함수를 호출하면 Promise가 resolved (성공) 상태로 바뀌고, 

▸ 인수로 전달된 value는 나중에 .then() 메서드에서 사용할 수 있는 결과 값이 됩니다.


🔸 reject(reason): 

▸ 비동기 작업 중 오류가 발생하여 실패했을 때 호출하는 함수입니다. 

▸ 이 함수를 호출하면 Promise가 rejected (실패) 상태로 바뀌고, 

▸ 인수로 전달된 reason (일반적으로 Error 객체)는 나중에 .catch() 메서드에서 사용할 수 있는 실패 이유가 됩니다.

 

✔️ 예제 : 기본 Promise 생성 및 처리

// 비동기 작업을 Promise로 감싸서 반환하는 함수
function fetchData() {
  return new Promise((resolve, reject) => {
    // setTimeout으로 비동기 작업을 흉내 냄 (1초 후 실행)
    setTimeout(() => {
      // 정상적으로 작업을 마쳤을 때 resolve 호출
      resolve("데이터 로드 완료");

      // 오류 발생 시 reject를 호출하여 Promise를 실패 상태로 전환
      // reject(new Error("데이터 로드 실패"));
    }, 1000);
  });
}

// fetchData()는 Promise를 반환하므로 then/catch/finally를 연결할 수 있음
fetchData()
  .then(result => {
    // resolve가 호출된 경우 이 블록이 실행됨 (fulfilled 상태)
    console.log("성공:", result);

    // then() 내부에서 값을 반환하면 다음 then()으로 전달됨
    return "다음 처리 단계";
  })
  .catch(error => {
    // Promise가 reject되거나 then 내부에서 오류가 발생한 경우 실행됨
    console.error("오류:", error.message);
  })
  .finally(() => {
    // 성공/실패에 관계없이 항상 실행되는 마무리 블록
    console.log("작업 완료 (성공/실패 무관)");
  });

▸ resolve() → Promise를 "성공(fulfilled)" 상태로 전환
▸ reject() → Promise를 "실패(rejected)" 상태로 전환
▸ .then() → 성공 흐름 처리
▸ .catch() → 오류 처리
▸ .finally() → 결과와 상관없이 항상 실행되는 최종 처리

이 구조만으로도 콜백 기반의 error-first 패턴보다 로직이 명확하게 분리되고, 흐름을 읽기 쉬운 형태가 됩니다.

 

🔷 Promise 체이닝(Chaining)의 개념

Promise는 then()을 호출할 때마다 새로운 Promise를 반환합니다.
이 특성 덕분에 여러 비동기 작업을 자연스럽게 연결할 수 있습니다.

작업 1
  → 작업 2
  → 작업 3

이런 순서의 비동기 로직을 콜백처럼 들여쓰기 없이 직선적(Top → Bottom) 흐름으로 구성할 수 있습니다.

 

✔️ 예제: 단계적 Promise 체이닝

// 비동기 작업 1: 0.5초 후 완료되는 Promise 반환
function step1() {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log("1단계 완료");
      resolve("step1-result"); // 다음 단계로 전달할 값
    }, 500);
  });
}

// 비동기 작업 2: 이전 단계의 결과(prev)를 받아 처리
function step2(prev) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log("2단계 완료, 이전 결과:", prev);
      resolve("step2-result"); // 다음 단계로 전달할 값
    }, 500);
  });
}

// 비동기 작업 3: step2의 결과(prev)를 전달받아 처리
function step3(prev) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log("3단계 완료, 이전 결과:", prev);
      resolve("step3-result"); // 전체 흐름의 최종 결과
    }, 500);
  });
}

// Promise 체이닝을 통해 순차적으로 비동기 작업 실행
step1()
  .then(result1 => step2(result1)) // step1의 결과를 step2로 전달
  .then(result2 => step3(result2)) // step2의 결과를 step3으로 전달
  .then(finalResult => {
    // 모든 단계가 정상적으로 완료된 경우
    console.log("최종 결과:", finalResult);
  })
  .catch(err => {
    // 어느 단계에서든 오류가 발생하면 이 블록으로 이동
    console.error("오류 발생:", err);
  });

 

✔️ Promise 체이닝의 장점
▸ 들여쓰기 없이 “위에서 아래로” 흐름 작성
▸ 각 단계는 이전 단계의 결과를 자연스럽게 전달
▸ 하나의 .catch()로 모든 단계의 오류 처리
▸ 실무에서 API 호출, DB 요청, 파일 처리 등 다양한 순차 비동기 작업에 사용

 

 

🔷 예외 처리(에러 전파)의 일관성

Promise의 가장 강력한 기능 중 하나는 에러 전파(error propagation)입니다.
Promise 체이닝에서 어느 단계에서든 오류가 발생하면 그 이후의 then()은 건너뛰고, 가장 가까운 catch()로 이동합니다.

이와 같은 일관된 에러 흐름은 콜백 기반 패턴에서는 구현하기 매우 어려웠던 부분입니다.

Promise.resolve()
  .then(() => {
    throw new Error("중간 단계에서 오류 발생");
  })
  .then(() => {
    console.log("이 부분은 실행되지 않음");
  })
  .catch(err => {
    console.error("오류 처리:", err.message);
  });

 

✔️ Promise는 async/await의 기반

ES2017에서 async/await이 도입되면서 Promise의 내부 동작이 개발자를 대신해 자연스럽게 처리되지만, async/await도 결국 Promise 위에서 동작합니다.


즉, Promise의 기본 구조를 이해하는 것은 현대 자바스크립트 비동기 프로그래밍의 토대입니다.

 

3. async/await과 현대 비동기 처리 실무 패턴

Promise를 통해 비동기 흐름을 개선할 수 있었지만, 여전히 then() 체이닝이 길어지거나 여러 비동기 작업을 조합하는 과정에서
코드의 가독성과 의도 전달이 쉽지 않을 때가 있습니다.

 

이러한 문제를 해소하기 위해 ES2017에서는 async/await 문법이 도입되었습니다.

특히 서버 개발(Node.js)과 브라우저 개발 모두에서 폭넓게 사용되며, 2025년 기준 실무 비동기 처리의 기본기라 할 수 있습니다.

 

async/await은 Promise 기반으로 동작하면서도, 비동기 코드를 마치 동기 코드처럼 이해하고 작성할 수 있도록 설계된 문법입니다.

 

1. async 함수

▸ async 키워드는 함수 앞에 붙여 해당 함수가 항상 Promise를 반환하도록 만듭니다.

▸ 함수가 일반 값을 반환하면, 그 값은 이행된 (fulfilled) Promise로 감싸져 반환됩니다.

▸ 함수 내에서 예외가 발생하면, 해당 예외는 거부된 (rejected) Promise로 반환됩니다.

 

2. await 표현식

▸ await 키워드는 오직 async 함수 내부에서만 사용할 수 있습니다.

▸ Promise 앞에 붙여서 사용하며, 해당 Promise가 이행될 때까지 함수의 실행을 일시 중지합니다.

▸ Promise가 이행되면, await 표현식은 Promise의 결과 값을 반환합니다.


3. Promise와 async/await의 관계

🔸 await는 Promise를 기다린다

▸ await 키워드는 반드시 그 뒤에 Promise 객체가 와야 하며, 이 Promise가 이행될 때까지 기다립니다.

 

🔸 async 함수는 Promise를 반환한다

▸ async 함수 자체는 항상 Promise를 반환하므로, 다른 async 함수나 await를 사용하는 곳에서 연속적으로 사용(체이닝)될 수 있습니다.

 

🔸오류처리

▸ Promise에서는 .catch()를 사용하여 오류를 처리합니다.
▸ async/await에서는 마치 동기 코드처럼 try...catch 블록을 사용하여 await가 기다리는 Promise의 거부(rejection)를 처리할 수 있습니다.

 

🔷 async/await - 가장 자연스러운 비동기 코드 작성 방식

async 키워드를 함수 앞에 붙이면 해당 함수는 자동으로 Promise를 반환하며, await 키워드는 Promise가 resolve될 때까지 함수 실행을 잠시 중단합니다.

이 덕분에 비동기 흐름을 동기 코드처럼 읽고 작성할 수 있습니다.

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

async function process() {
  console.log("시작");
  await delay(1000);
  console.log("1초 경과");
  await delay(1000);
  console.log("2초 경과");
}

console.log("--- process 함수 실행 시작 ---");
process();
console.log("--- process 함수 호출 완료 ---");
// 참고: process()가 Promise를 반환했으므로, 이 줄은 즉시 실행됩니다.

1. await delay(1000); 라인에 도달하면, delay 함수는 1초 후에 해결될 (resolve) Promise를 반환합니다.
2. await 키워드는 Promise가 해결될 때까지 process 함수의 실행을 일시 중단하고, 컨트롤을 이벤트 루프에 반환합니다.
3. process 함수가 일시 중단된 후, 다음 줄인 console.log("--- process 함수 호출 완료 ---"); 가 즉시 실행되어 출력됩니다.

 

 

🔷 비동기 에러 처리 패턴 - try/catch와 async 조합

Promise 기반 코드의 오류는 catch()로 처리해야 했지만 async/await은 기존 구조와 동일하게 try/catch로 에러 처리가 가능합니다.

async function fetchUser() {
  throw new Error("사용자 정보를 가져오는 중 오류 발생");
}

async function main() {
  try {
    const user = await fetchUser();
    console.log(user);
  } catch (err) {
    console.error("에러 발생:", err.message);
  }
}

main();

 

 

🔷 병렬 실행을 위한 Promise 제어 메서드 - Promise.all / allSettled / race / any

여러 개의 비동기 작업을 효율적으로 처리하기 위해 JavaScript의 Promise 객체에는 네 가지 유용한 정적 메서드가 제공됩니다.

이 메서드들은 병렬 제어에 필수적이며, 각기 다른 사용 시나리오에 맞춰 최적화되어 있습니다.

 

1. Promise.all - 모두 성공해야 완료 (Fail-Fast)

이 메서드는 인수로 받은 모든 Promise가 성공(fulfilled)했을 때만 결과를 반환합니다.
▸ 동작 방식: 인수로 받은 모든 Promise가 성공하면, 그 결과 값들을 모아 순서대로 배열에 담아 반환합니다.
▸ 실패: 단 하나라도 Promise가 실패(rejected)하면, 즉시 전체 Promise.all을 실패 처리하고 그 실패(rejection) 이유를 반환합니다. (Fail-Fast 전략)
▸ 주요 용도: API 병렬 요청, DB 다중 질의 등 모든 작업이 성공해야만 다음 단계로 진행할 수 있는 상황에서 가장 많이 사용됩니다.

async function executeAll() {
  console.log("--- 1. Promise.all 실행 ---");
  try {
    const results = await Promise.all([
      // 1초 후 성공
      new Promise(resolve => setTimeout(() => resolve("A 결과"), 1000)),
      // 0.5초 후 성공
      new Promise(resolve => setTimeout(() => resolve("B 결과"), 500)),
      // 2초 후 성공
      new Promise(resolve => setTimeout(() => resolve("C 결과"), 2000))
    ]);

    console.log("✅ Promise.all 성공 (모두 완료):", results);
    // Promise.all 성공 (모두 완료): [ 'A 결과', 'B 결과', 'C 결과' ]
  } catch (error) {
    console.log("❌ Promise.all 실패 (하나라도 실패 시):", error);
  }

  // 실패 예시 (가장 먼저 실패하는 'Reject'가 반환됨)
  try {
    await Promise.all([
      new Promise((_, reject) => setTimeout(() => reject("첫 번째 실패"), 100)),
      new Promise(resolve => setTimeout(() => resolve("두 번째 성공"), 2000))
    ]);
  } catch (error) {
    console.log("❌ Promise.all 실패 (단 하나 실패):", error); // "첫 번째 실패" 출력
    //❌ Promise.all 실패 (단 하나 실패): 첫 번째 실패
  }
}

// executeAll();

 

2. Promise.allSettled - 성공/실패 상관없이 모두 결과 반환

이 메서드는 인수로 받은 모든 Promise가 처리(settled, 성공 또는 실패)될 때까지 기다립니다.
▸ 동작 방식: 모든 Promise가 완료되면, 각 Promise의 결과를 나타내는 객체들의 배열을 반환합니다.
성공한 Promise: { status: 'fulfilled', value: 값 }
실패한 Promise: { status: 'rejected', reason: 이유 }
▸ 실패: Promise.allSettled 자체는 절대 실패하지 않습니다. (언제나 성공적으로 배열을 반환)
▸ 주요 용도: 외부 연동, 독립적인 배치 작업처럼 모든 작업의 결과를 개별적으로 확인해야 하는 상황에 적합합니다.

async function executeAllSettled() {
  console.log("\n--- 2. Promise.allSettled 실행 ---");
  const result = await Promise.allSettled([
    Promise.resolve("성공 결과"),
    Promise.reject(new Error("네트워크 오류")),
    new Promise(resolve => setTimeout(() => resolve(123), 100))
  ]);

  console.log("✅ Promise.allSettled 결과 (성공/실패 모두 포함):");
//Promise.allSettled 결과 (성공/실패 모두 포함):
//[
//  { status: 'fulfilled', value: '성공 결과' },
//  { status: 'rejected', reason: Error: 네트워크 오류 },
//  { status: 'fulfilled', value: 123 }
//]
  console.log(result);
}

// executeAllSettled();

 

3. Promise.race - 가장 먼저 끝난 Promise의 결과 사용

이 메서드는 인수로 받은 Promise 중 가장 먼저 처리(settled, 성공 또는 실패)된 Promise의 결과를 반환합니다.
▸ 동작 방식: Promise 중 가장 먼저 완료된 것(성공이든 실패든 상관없음)의 결과 값 또는 실패 이유를 반환하며, 나머지 Promise의 결과는 무시합니다.
▸ 주요 용도: 타임아웃 처리나 가장 빠른 리소스 선택과 같이, 여러 비동기 작업 중 가장 먼저 끝나는 하나의 결과만 필요한 경우에 사용됩니다.

async function executeRace() {
  console.log("\n--- 3. Promise.race 실행 ---");

  const fastSuccess = new Promise(resolve => setTimeout(() => resolve("1. 🚀 가장 빠른 성공"), 100));
  const slowReject = new Promise((_, reject) => setTimeout(() => reject("2. 🐢 느린 실패"), 500));
  const slowSuccess = new Promise(resolve => setTimeout(() => resolve("3. 🐌 느린 성공"), 2000));

  try {
    const result = await Promise.race([fastSuccess, slowReject, slowSuccess]);
    console.log("✅ Promise.race 결과:", result); 
    // Promise.race 결과: 1. 🚀 가장 빠른 성공
  } catch (error) {
    console.log("❌ Promise.race 실패:", error); // 가장 먼저 끝난 게 실패면 실패가 반환됨
  }
}

// executeRace();

 

4. Promise.any - 가장 먼저 성공한 Promise 반환

이 메서드는 인수로 받은 Promise 중 가장 먼저 성공(fulfilled)한 Promise의 결과를 반환합니다.
▸ 동작 방식: Promise 중 가장 먼저 성공한 것의 결과 값만을 반환합니다. 나머지 Promise의 성공/실패는 무시됩니다.
▸ 실패: 모든 Promise가 실패(rejected)했을 때만 실패 처리되며, 이때 모든 실패 이유를 포함하는 AggregateError 객체를 반환합니다.
▸ 주요 용도: 다수의 미러 서버 중 가장 빠른 정상 응답을 채택하거나, 여러 데이터 소스 중 하나라도 성공적인 결과를 제공하면 되는 상황에 적합합니다.

async function executeAny() {
  console.log("\n--- 4. Promise.any 실행 ---");

  // 성공 예시: 'OK'가 가장 먼저 성공합니다.
  const successResult = await Promise.any([
    Promise.reject("X"), // 1. 실패
    Promise.resolve("OK"), // 2. 성공 (가장 먼저 성공)
    Promise.reject("Y") // 3. 실패
  ]);
  console.log("✅ Promise.any 성공 (가장 빠른 성공):", successResult); // "OK"

  // 모두 실패 예시: AggregateError 발생
  const reject1 = new Promise((_, reject) => setTimeout(() => reject("첫 번째 실패"), 200));
  // 가장 먼저 종료되지만, 실패이므로 무시
  const reject2 = new Promise((_, reject) => setTimeout(() => reject("두 번째 실패"), 100)); 

  try {
    await Promise.any([reject1, reject2]);
  } catch (error) {
    console.log("❌ Promise.any 실패 (모두 실패 시):");
    // AggregateError는 Array.prototype.errors에 실패 이유들을 담습니다.
    console.log("오류 타입:", error.name);
    console.log("모든 실패 이유:", error.errors);
  }
}

// executeAny();
반응형

 

4. AbortController / AbortSignal (ES2022)

AbortController와 AbortSignal은 진행 중인 비동기 작업(Asynchronous Operations)을 중간에 취소(Cancellation)하기 위한 공식 표준 API입니다.

이전에는 비동기 작업을 취소하는 표준화된 방법이 없었으나, 이 API가 도입되면서 일관된 취소 메커니즘을 제공하게 되었습니다. (ES2022 표준)

 

✔️ 예제: fetch 요청 취소하기 (Node.js 18+)

기본 메커니즘
1. AbortController 객체를 생성합니다. 이 객체가 취소 명령을 내리는 주체입니다.
2. controller.signal 속성을 통해 AbortSignal 객체를 얻습니다. 이 Signal은 취소하려는 비동기 작업에 연결됩니다.
3. 비동기 작업을 시작할 때, 해당 작업의 옵션 객체(예: fetch의 두 번째 인자)에 이 signal을 전달합니다.
4. 작업을 취소하고 싶을 때 controller.abort() 메서드를 호출합니다.

 

fetch() 메서드와 결합하여 네트워크 요청을 중단하는 데 가장 자주 사용됩니다.

signal을 받은 fetch가 취소되면 해당 Promise는 AbortError와 함께 거부(reject)됩니다.

const controller = new AbortController();
const fetchURL = "https://httpbin.org/delay/2"; // 응답까지 2초가 걸리는 URL

// 0.5초(500ms) 후에 취소 명령을 내립니다.
const timeoutId = setTimeout(() => {
  controller.abort();
  console.log("🚩 요청 취소 명령어 발동 (Timeout: 500ms)");
}, 500);

async function runFetchWithCancellation() {
  console.log(`⏳ ${fetchURL} 에 요청 시작...`);
  try {
    const response = await fetch(fetchURL, {
      // AbortSignal을 fetch 요청에 연결합니다.
      signal: controller.signal
    });

    // 요청이 취소되지 않고 성공했을 때만 timeout을 클리어합니다.
    clearTimeout(timeoutId);

    console.log("✅ 응답 성공!");
    const text = await response.text();
    //console.log(text); // 긴 응답 내용이므로 주석 처리
  } catch (err) {
    // 취소되면 catch 블록으로 들어오며 'AbortError'가 발생합니다.
    if (err.name === 'AbortError') {
      console.error("❌ 에러:", err.name, "- 요청이 취소되었습니다.");
    } else {
      console.error("❌ 에러:", err.name, err.message);
    }
  }
}

// runFetchWithCancellation();

 

✔️ 주요 활용 상황 및 시나리오

1. 특정 시간이 지나면 API 요청 중단 (Timeout 구현)

가장 고전적인 문제입니다. 서버 응답이 너무 늦어지는 것을 방지하고 사용자 경험을 개선합니다.
▸ 시나리오: API 요청 후 5초 이내에 응답이 오지 않으면 사용자에게 실패를 알리고 요청을 중단해야 할 때. (위의 예시 코드가 이에 해당)

 

2. 사용자가 화면을 이동했을 때 요청 취소 (Race Condition 방지)

SPA(Single Page Application)에서 다음 페이지로 이동했지만, 이전 페이지의 비동기 작업이 계속 실행되는 것을 방지합니다.

▸ 문제점: 이전 페이지 요청의 응답이 늦게 도착하여 현재 페이지의 상태를 의도치 않게 업데이트하는 Race Condition을 유발할 수 있습니다.
▸ 해결: 컴포넌트가 언마운트(Unmount)될 때 controller.abort()를 호출하여 미해결된 모든 요청을 정리합니다.

 

✔️ setTimeout 및 Promise와 결합하여 취소 가능한 작업 만들기

fetch 외에도 setTimeout 기반의 Promise나 Node.js의 stream 처리 등 다양한 비동기 작업에 AbortSignal을 연결할 수 있습니다.

// 주어진 Signal이 abort될 때까지 기다리거나, 지정된 시간(ms)만큼 지연하는 함수
function delayWithSignal(ms, signal) {
  return new Promise((resolve, reject) => {
    // 1. Abort 되었을 때의 핸들러 등록
    const onAbort = () => {
      clearTimeout(timerId); // 지연 타이머를 클리어
      reject(new DOMException("Delay aborted", "AbortError"));
    };

    // Signal이 이미 취소된 상태라면 즉시 reject
    if (signal.aborted) {
      onAbort();
      return;
    }

    // 2. 'abort' 이벤트 리스너 등록
    signal.addEventListener('abort', onAbort, { once: true });

    // 3. 지연 타이머 설정
    const timerId = setTimeout(() => {
      signal.removeEventListener('abort', onAbort); // 성공 시 리스너 제거
      resolve(`✅ ${ms}ms 지연 완료`);
    }, ms);
  });
}

async function testDelayCancellation() {
    console.log("\n--- 취소 가능한 지연 테스트 ---");
    const controller = new AbortController();

    // 100ms 후 취소 명령
    setTimeout(() => controller.abort(), 100);

    try {
        // 500ms 지연 요청
        const result = await delayWithSignal(500, controller.signal);
        console.log(result);
    } catch (err) {
        console.error("❌ 취소 에러:", err.name, "-", err.message); // AbortError
    }
}

// testDelayCancellation();

 

5. Microtask / Task Queue와 Event Loop

자바스크립트는 한 번에 하나의 작업만 처리할 수 있는 단일 스레드(Single-Threaded) 언어입니다. 그런데도 동시에 여러 작업을 처리하는 것처럼 보이는 것은 바로 이벤트 루프(Event Loop) 메커니즘 덕분입니다.


이벤트 루프는 실행해야 할 비동기 작업을 두 개의 우선순위가 다른 큐(Queue, 대기열)에 담아 관리하며, 이 순서대로 작업을 스케줄링합니다.

 

1. Call Stack (호출 스택) - 모든 작업의 시작점

이벤트 루프가 작업을 시작하기 전에, 동기적으로 작성된 코드는 Call Stack이라는 곳에서 즉시 실행됩니다.

자바스크립트는 Call Stack에 있는 작업이 모두 비워질 때까지 다른 큐의 작업을 들여다보지 않습니다

즉, 동기 코드가 항상 최우선적으로 실행됩니다.

 

2. Microtask Queue (마이크로태스크 큐) - 가장 높은 우선순위

Microtask Queue는 가장 높은 우선순위를 가진 작업들의 대기열입니다.

비교적 짧고 즉각적인 처리가 필요한 미세 작업들이 이곳에 등록됩니다.

작업 설명
Promise.then / catch / finally Promise의 완료(Fulfilled, Rejected) 이후 실행될 콜백
queueMicrotask() 개발자가 직접 Microtask를 등록할 수 있는 API
MutationObserver (브라우저) DOM 변경 감지에 사용되는 비동기 작업

▸ Microtask는 Call Stack이 비워진 후, 다음 Task(매크로태스크)가 시작되기 전에 큐가 완전히 빌 때까지 연속적으로 처리됩니다.

 

3. Task Queue - Microtask보다 낮은 우선순위

Task Queue는 Microtask보다 한 단계 낮은 우선순위를 가진 작업들을 담습니다.
상대적으로 오래 걸리거나 외부 시스템과 상호작용하는 작업들이 이 큐에 들어갑니다.

작업 설명
setTimeout / setInterval 지정된 시간이 지난 후 실행될 콜백
I/O 이벤트 (Node.js) 파일 읽기/쓰기, 네트워크 요청 응답 등
DOM 이벤트 (브라우저) 클릭, 키보드 입력 등 사용자 인터랙션
setImmediate (Node.js) Node.js 전용 매크로태스크

이벤트 루프는 Microtask Queue가 완전히 비워진 후에야 Task Queue에서 오직 하나의 작업(Task)만을 꺼내 실행합니다.

 

✔️ Event Loop 우선순위 규칙 (실행 순서)

이벤트 루프는 무한히 반복하면서 다음의 명확한 규칙에 따라 작업을 수행합니다.

1. 동기 코드 실행: Call Stack이 완전히 비워질 때까지 모든 코드를 실행합니다.

2. Microtask 처리: Call Stack이 비워지면, Microtask Queue에 있는 작업을 모두 비워질 때까지 연속적으로 실행합니다.

3. Task 하나 실행: Microtask Queue가 비워지면, Task Queue에서 작업 딱 1개를 꺼내 Call Stack으로 옮겨 실행합니다.

4. Microtask 재처리: Task 1개가 완료되면, 다시 Microtask Queue를 비우는 작업을 수행합니다.

5. 반복: 3번과 4번을 반복합니다.

이 규칙 때문에 Promise 기반 비동기 작업(Microtask)은 일반적인 타이머 기반 작업(Task)보다 훨씬 더 빠른 실행 시점을 갖게 됩니다.

 

✔️ 예제: Microtask 우선순위 확인

console.log("시작"); // 1. 동기 코드

setTimeout(() => {
  console.log("setTimeout 실행 (Task)"); // 4. Task Queue에 등록됨
}, 0);

Promise.resolve().then(() => {
  console.log("Promise then 실행 (Microtask)"); // 3. Microtask Queue에 등록됨
});

console.log("끝"); // 2. 동기 코드
시작
끝
Promise then 실행 (Microtask)
setTimeout 실행 (Task)

1. 시작과 끝이 동기 코드로서 Call Stack에서 즉시 실행됩니다. (Call Stack Clear)
2. Call Stack이 비워지면, 이벤트 루프가 Microtask Queue를 확인합니다.
3. Promise.resolve().then(...) 콜백이 Microtask Queue에 있으므로 이것이 실행됩니다.
4. Microtask Queue가 비워지면, 이벤트 루프가 Task Queue를 확인합니다.
5. setTimeout(...) 콜백이 Task Queue에 있으므로 이것이 실행됩니다.

 

✔️ 왜 Microtask와 Task의 구분이 중요한가?

현대 자바스크립트 개발에서 이 우선순위를 이해하는 것은 비동기 로직의 디버깅과 예측 가능성 확보에 결정적입니다.

 

▸ 실행 순서 예측:

await를 사용하는 async/await도 내부적으로 Promise를 사용하므로, await 다음의 코드는 Microtask로 취급됩니다.

따라서 일반적인 setTimeout이나 I/O 작업보다 먼저 실행됩니다.


▸ UI 업데이트 타이밍 제어 (브라우저):

브라우저는 일반적으로 Task(매크로태스크) 하나가 끝난 후 화면을 업데이트(렌더링)합니다.

만약 Task가 너무 길면 화면이 멈춘 것처럼 보입니다.

Task가 아닌 Microtask를 연속적으로 실행해도 화면은 업데이트되지 않기 때문에, 반드시 Task를 사용해야 다음 렌더링 기회를 줄 수 있습니다.


▸ 일관성 유지:

예를 들어, DOM을 조작하는 작업을 Microtask로 처리하면 현재 Call Stack이 끝난 직후에 DOM이 일관된 상태로 업데이트되는 것을 보장할 수 있습니다.

 

✔️ 요약

▸ Microtask는 Promise 기반 비동기 흐름의 핵심이며 Task보다 우선순위가 높음
▸ 이벤트 루프는 “동기 → Microtask → Task → Microtask → …” 순으로 실행됨
▸ 이 원리를 이해하면 복잡한 비동기 흐름을 정확히 예측하고 안정적인 코드를 작성할 수 있음

 

6. Promise.withResolvers(ES2024) - 외부 제어형 Promise의 공식 해법

Promise.withResolvers()는 2024년(ES2024)에 공식 도입된 새로운 Promise 생성 API입니다.

이는 Deferred 패턴이라고 불렸던, Promise를 외부에서 성공(resolve) 또는 실패(reject) 상태로 만들 필요가 있을 때 사용하는 가장 현대적이고 안전한 방법입니다.

 

🔷 탄생 배경: 구식 Deferred 패턴의 문제점

이전까지 Promise를 외부에서 제어하려면, 개발자는 다음과 같이 resolve와 reject 함수를 Promise 생성자 함수(Executor) 바깥의 변수에 저장하는 복잡한 방법을 사용했습니다.

// ❌ 구식 Deferred 패턴
let resolve, reject; // 1. 외부 변수 선언 (스코프 오염)

const p = new Promise((res, rej) => {
  resolve = res; // 2. Executor 내부에서 함수를 외부에 할당
  reject = rej;
});
// 이후 resolve(값) 또는 reject(이유)를 호출하여 p를 제어

이 방식은 작동은 하지만, 다음과 같은 단점이 있어 구조적 문제가 많았습니다.

▸ 변수 초기화 불명확: resolve와 reject가 Promise가 생성된 후에야 초기화되므로, 정확히 언제 호출 가능한지 코드를 전부 읽어야 알 수 있었습니다.
▸ 스코프 오염: resolve와 reject 변수가 불필요하게 넓은 스코프(범위)에 노출됩니다.
▸ 타입 안정성 부족: TypeScript와 같은 정적 타입 언어에서 타입 추론이 어려워 단언(!) 같은 코드가 필요했습니다.

 

 

🔷 Promise.withResolvers()의 해법

Promise.withResolvers()는 이런 문제를 해결하기 위해 Promise 객체와 그 제어 함수들을 한 번에 명확하게 반환하는 공식적인 표준을 제공합니다.

 

▸ promise - 작업 결과를 표현하는 Promise 객체
▸ resolve - 외부에서 해당 Promise를 성공 상태로 만드는 함수
▸ reject - 외부에서 실패 상태로 만드는 함수

 

아래 예시는 가장 기본적인 사용 형태입니다.

// 👍 최신 Promise.withResolvers() 패턴
const { promise, resolve, reject } = Promise.withResolvers(); // 한 번에 객체 분해 할당

// 0.5초 후 resolve 함수를 호출하여 promise를 성공시킵니다.
setTimeout(() => resolve("✅ 외부 제어 완료"), 500);

promise.then(console.log); // 0.5초 후 "✅ 외부 제어 완료" 출력
// promise.catch(console.error); // reject가 호출될 경우를 대비

 

✔️ Timeout 제어 Promise 구현

특정 시간이 지난 후 성공하는 Promise를 만들 때, resolve를 setTimeout 내부에 깔끔하게 전달할 수 있습니다.

function timeout(ms) {
  // Promise와 resolve 함수를 즉시 얻습니다.
  const { promise, resolve } = Promise.withResolvers();

  // 지정된 ms 후에 resolve를 호출하여 promise를 성공시킵니다.
  setTimeout(() => {
    resolve(`Timeout ${ms}ms 경과`);
  }, ms);

  return promise; // 외부에서 await 할 수 있는 promise를 반환
}

async function runTimeoutExample() {
  console.log("⏳ 1초 대기 시작");
  const msg = await timeout(1000); // 1초 대기
  console.log(msg); // Timeout 1000ms 경과
}

// runTimeoutExample();

 

✔️ 이벤트 기반 Deferred 패턴 (이벤트 발생 대기)

특정 DOM 이벤트나 네트워크 이벤트가 발생할 때까지 await 상태로 대기하는 Promise를 안전하게 만들 수 있습니다

// target 객체에서 eventName 이벤트가 발생할 때까지 대기
function waitForEvent(target, eventName) {
  const { promise, resolve } = Promise.withResolvers();

  function handler(e) {
    // 1. 이벤트가 발생하면 리스너를 제거하고
    target.removeEventListener(eventName, handler);
    // 2. resolve를 호출하여 promise를 성공시킵니다.
    resolve(e);
  }

  target.addEventListener(eventName, handler);

  return promise;
}

// 예시 (브라우저 환경)
// async function handleUserClick() {
//    console.log("다음 클릭을 기다립니다...");
//    const clickEvent = await waitForEvent(document, 'click');
//    console.log('클릭 감지!', clickEvent.target);
// }
// handleUserClick();

 

✔️ 외부에서 제어 가능한 작업 상태 관리

여러 비동기 흐름이 하나의 상태를 공유하고 특정 시점에만 다음 단계로 넘어가야 할 때 유용합니다.

// 초기화 작업이 완료될 때까지 기다릴 수 있는 Promise를 미리 생성
const initState = Promise.withResolvers();

async function initialize() {
  console.log("⏳ 초기화 중... (1초 소요)");
  await new Promise(r => setTimeout(r, 1000));
  // 초기화가 완료되는 시점에 resolve를 호출합니다.
  initState.resolve("✨ 초기화 완료 상태!");
}

async function requestHandler() {
  console.log("⚙️ 요청 대기 중...");
  // initState.promise가 resolve될 때까지 모든 요청은 대기합니다.
  const status = await initState.promise;
  console.log(`✅ 요청 처리 시작: ${status}`);
}

initialize(); // 초기화 작업 시작
requestHandler(); // 요청 처리 함수 호출 (초기화 완료될 때까지 대기)

 

✔️ 기존 방식과의 비교 - 가독성과 안정성 모두 개선

항목 new Promise 방식 withResolvers 방식
resolve/reject 선언 외부 변수에 저장해야 함 함수가 구조적으로 함께 제공됨
초기화 명확성 executor가 언제 실행되는지 모호 즉시 구조로 분리되어 명확
TypeScript 친화성 단언 필요, 타입 추론 불완전 자동 타입 추론, 안정성↑
코드 가독성 목적이 불명확 “Deferred” 의도가 즉시 드러남
실수 가능성 resolve/reject 할당 누락 위험 API가 안전하게 보장

 

✔ 마무리

이번 글에서는 자바스크립트 비동기 처리의 핵심 개념을 콜백에서 시작하여 Promise, async/await, 병렬 제어, 취소(AbortController), 그리고 이벤트 루프와 최신 문법(Promise.withResolvers)까지 순차적으로 살펴보았습니다.

 

단일 스레드라는 제약 속에서도 자바스크립트가 높은 동시성을 제공할 수 있는 이유와, 실무에서 안정적이고 예측 가능한 비동기 코드를 작성하기 위한 주요 패턴들을 함께 확인했습니다.

 

각 기술들은 서로를 대체하는 것이 아니라 상호 보완하며 작동하며, 이를 종합적으로 이해할수록 더 견고하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

 

비동기 처리는 현대 자바스크립트 개발의 핵심 기반이므로, 이번 내용을 바탕으로 실제 프로젝트에서 보다 명확하고 일관된 비동기 로직을 구성해 보시기 바랍니다.

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형