본문으로 건너뛰기

안동민 개발노트

본문 시작

자바스크립트의 비동기 처리

동기 실행과 이벤트 기반 비동기 실행을 구분하고 콜백에서 Promise와 async·await로 발전한 제어 흐름을 익힙니다.

실제 웹 애플리케이션 개발에서 필수적인 요소인 비동기 처리(Asynchronous Processing)데이터 페칭(Data Fetching)을 다루겠습니다.

웹 애플리케이션은 서버에서 데이터를 가져오거나(API 호출), 실행 환경이 제공하는 파일 접근이나 타이머를 사용하는 등 기다림이 필요한 작업을 자주 수행합니다.

이 작업들을 동기적으로 처리하면 애플리케이션이 멈추거나 먹통이 될 수 있습니다.

이런 기다림 동안 다른 작업을 진행하려면 비동기 처리가 필요합니다. 다만 비동기 문법이 CPU 연산을 자동으로 다른 스레드에 보내지는 않습니다. 오래 실행되는 자바스크립트 계산은 여전히 현재 스레드를 막으므로 필요하면 Web Worker처럼 명시적인 실행 경계를 사용해야 합니다.

이 장에서는 자바스크립트의 비동기 처리 기본 메커니즘과 핵심 도구인 콜백, Promise, async/await를 정리합니다.

현재 task의 자바스크립트는 콜 스택에서 동기적으로 실행되고, 호스트가 맡은 작업의 완료 처리는 microtask 또는 다음 task에서 다시 이어집니다. 콜백, Promise, async/await는 이 결과와 오류를 표현하는 서로 다른 계약입니다.
현재 task의 동기 콜 스택, 호스트 API, Promise reaction microtask, timer task, React scheduler를 구분한 자바스크립트 비동기 실행 순서

Task → host → checkpoint → next task

비동기 API를 호출해도 현재 자바스크립트는 콜 스택에서 먼저 끝까지 실행됩니다. 호스트가 완료 작업을 큐에 넣으면 Promise reaction은 microtask checkpoint에서, timer callback은 선택된 다음 task에서 실행되며 React 업데이트는 별도 scheduler 경계를 거칩니다.

자바스크립트 task와 microtask, React scheduling 순서 현재 task에서 Promise executor와 일반 코드가 동기 실행되고 timer가 호스트에 등록됩니다. 콜 스택이 빈 뒤 Promise reaction microtask를 처리하고 React 업데이트를 예약하며, 최소 지연이 지난 timer callback은 이후 선택된 task에서 실행됩니다. setTimeout resolve → reaction 등록 microtask checkpoint setState(update) 최소 지연 뒤 task 가능 event loop가 task 선택 현재 task call stack Host API timer · network Microtask Promise reaction Task queue timer callback React scheduler 동기 실행 일반 코드 · executor stack 비움 reaction 실행 업데이트 예약 batch · priority render → commit Effect는 commit 이후 timer callback 새 task의 stack
  1. 현재 task를 동기 실행

    일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.

  2. 기다림은 Host API에 등록

    타이머와 네트워크 작업은 실행 환경이 맡을 수 있지만, 긴 CPU 계산은 자동으로 백그라운드로 가지 않습니다.

  3. stack이 비면 microtask checkpoint

    Promise reaction과 await continuation을 처리하며, 새로 생긴 microtask도 checkpoint가 끝날 때까지 이어서 처리합니다.

  4. React 업데이트는 scheduler로

    setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.

  5. 타이머는 최소 지연 뒤 실행 가능

    지연 시간이 지났다고 즉시 실행되는 것이 아니라 timer callback task가 선택될 자격을 얻습니다.

  6. 다음 task를 선택

    이벤트 루프가 timer task를 선택하면 콜백이 새 콜 스택에서 실행됩니다.

CPU boundary

비동기 문법은 연산 스레드를 바꾸지 않는다

Promise나 async/await 안의 긴 계산도 메인 스레드를 막습니다. 계산을 분리하려면 Web Worker 같은 별도 실행 주체가 필요합니다.

React boundary

microtask와 화면 갱신은 일대일이 아니다

React는 여러 업데이트를 batch할 수 있고 scheduler가 render를 미루거나 다시 시작할 수 있습니다. DOM 반영은 commit에서, Effect의 외부 동기화는 commit 이후에 일어납니다.


동기 vs 비동기

개념을 명확히 하기 위해 동기(Synchronous) 처리와 비동기(Asynchronous) 처리를 비교해 봅시다.

  • 동기(Synchronous) 처리
    • 코드가 위에서 아래로 순서대로 실행됩니다.
    • 한 작업이 완료될 때까지 다음 작업이 대기합니다.
    • 장점: 코드의 흐름을 파악하기 쉽고 예측 가능합니다.
    • 단점: 시간이 오래 걸리는 작업이 있으면 전체 애플리케이션이 블로킹(blocking) 되어 사용자 인터페이스가 멈추거나 응답하지 않게 됩니다.
    console.log("작업 1 시작");
    // 매우 오래 걸리는 동기 작업이라고 가정
    for (let i = 0; i < 1000000000; i++) {} // CPU를 많이 사용하는 작업
    console.log("작업 2 완료"); // 작업 1이 끝나야 실행
    console.log("작업 3 완료");

    위 코드에서 작업 2 완료작업 1 시작 뒤의 루프가 모두 실행된 후에야 출력됩니다.

  • 비동기(Asynchronous) 처리
    • 타이머나 네트워크 요청처럼 호스트에 맡길 수 있는 작업을 시작한 뒤, 결과를 기다리는 동안 다음 자바스크립트 코드를 실행할 수 있습니다.
    • 호스트 작업이 완료되면 콜백 task나 Promise reaction microtask가 실행 가능한 큐에 들어갑니다. 이벤트 루프는 현재 콜 스택이 끝난 뒤 알맞은 시점에 이를 실행합니다.
    • 장점: 기다림이 많은 작업에서 메인 스레드를 계속 점유하지 않아 사용자 경험을 부드럽게 유지할 수 있습니다. CPU 집약적인 자바스크립트 자체는 이 방식만으로 논블로킹이 되지 않습니다.
    • 단점: 코드의 흐름이 동기적이지 않아 때로는 이해하거나 디버깅하기 어려울 수 있습니다.
    console.log("작업 A 시작");
    setTimeout(() => { // 1초가 지난 뒤 실행 가능해질 콜백 task 등록
      console.log("작업 B 완료 (1초 지연)");
    }, 1000);
    console.log("작업 C 완료"); // 작업 B가 끝나기를 기다리지 않고 즉시 실행

    위 코드의 출력 순서는 작업 A 시작작업 C 완료작업 B 완료 (1초 지연)입니다. 1000은 정확한 실행 시각이 아니라 최소 지연 시간입니다. 시간이 지난 뒤에도 현재 task와 먼저 처리할 microtask가 남아 있으면 콜백은 더 늦게 시작할 수 있습니다.


콜백(Callback) 함수

자바스크립트에서 비동기 처리를 구현하는 가장 기본적인 방법은 콜백 함수를 사용하는 것입니다.

콜백 함수는 다른 함수의 인자로 전달되어 그 함수가 정한 시점에 호출되는 함수입니다. 콜백의 인자, 호출 횟수, 결과와 오류 전달 방식은 호출하는 API의 계약에 달려 있으며, 모든 콜백이 하나의 공통 오류 형식을 사용하는 것은 아닙니다.

예시: setTimeout
function greet(name, callback) {
  setTimeout(() => {
    const message = `Hello, ${name}!`;
    callback(message); // 작업 완료 후 콜백 함수 호출
  }, 1000);
}

function displayMessage(msg) {
  console.log(msg);
}

console.log("인사 시작...");
greet("Alice", displayMessage); // 콜백으로 displayMessage 함수 전달
console.log("인사 요청 완료.");

출력

인사 시작...
인사 요청 완료.
Hello, Alice!

여기서 displayMessagegreet 함수의 콜백으로 전달되어, setTimeout 내부의 비동기 작업이 완료된 후에 실행됩니다.

콜백 헬(Callback Hell) 문제: 여러 개의 비동기 작업이 순차적으로 실행되어야 할 때, 콜백 함수들이 중첩되어 코드의 가독성이 떨어지고 유지보수가 어려워지는 현상을 콜백 헬(Callback Hell) 또는 피라미드 오브 둠(Pyramid of Doom)이라고 부릅니다.

// 콜백 헬 예시
getData(function(a) {
  processData(a, function(b) {
    displayResult(b, function(c) {
      // ... 계속 중첩
    });
  });
});

Promise (프로미스)

콜백 헬의 단점을 극복하고 비동기 코드를 보다 깔끔하게 작성하기 위해 ES6에서 Promise(프로미스)가 도입되었습니다.

Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다.

new Promise(executor)executor는 Promise를 만드는 순간 동기적으로 실행됩니다. Promise가 fulfilled 또는 rejected로 결정된 뒤 등록된 reaction이 실행되는 단계가 microtask이며, executor 자체가 자동으로 백그라운드에서 실행되는 것은 아닙니다.

Promise의 세 가지 상태

Pending (대기): 아직 결과가 결정되지 않은 초기 상태.

Fulfilled (이행/성공): 비동기 작업이 성공적으로 완료된 상태.

Rejected (거부/실패): 이유와 함께 작업이 실패한 상태. Pending은 Fulfilled 또는 Rejected 중 하나로 한 번만 전이하며, 결정된 상태는 다시 바뀌지 않습니다.

Promise 사용법
  • Promise 생성: new Promise((resolve, reject) => { ... }). executor는 동기적으로 호출되고, 그 안에서 타이머나 네트워크 작업을 등록할 수 있습니다.
    • resolve: 값이나 thenable로 Promise를 해결하는 함수. thenable을 넘기면 그 최종 상태를 동화하므로 반드시 fulfilled만 뜻하지는 않습니다.
    • resolvedsettled와 같은 말이 아닙니다. 아직 pending인 thenable로 resolve하면 그 결과를 따르도록 고정되지만, 그 thenable이 settle할 때까지 Promise 자체는 Pending일 수 있습니다.
    • reject: 비동기 작업이 실패했을 때 호출하는 함수.
  • Promise 체이닝: .then(), .catch(), .finally()
    • .then(onFulfilled, onRejected): Promise가 성공(fulfilled) 또는 실패(rejected)했을 때 실행할 콜백 함수를 등록합니다. 일반적으로 성공 시만 처리합니다.
    • .catch(onRejected): Promise가 실패(rejected)했을 때만 실행되는 콜백을 등록하여 에러를 처리합니다. .then(undefined, onRejected)의 축약형입니다.
    • .finally(): Promise의 성공/실패 여부와 상관없이 정리 콜백을 등록합니다. 결과값이나 실패 이유를 인자로 받지 않으며, 콜백이 throw하거나 rejected Promise를 반환하지 않으면 기존 결과를 그대로 통과시킵니다.
예시: Promise 사용
function fetchDataPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true; // 가상으로 성공/실패 여부 결정
      if (success) {
        resolve("데이터를 성공적으로 가져왔습니다!"); // 성공 시 resolve 호출
      } else {
        reject("데이터를 가져오는 데 실패했습니다."); // 실패 시 reject 호출
      }
    }, 2000);
  });
}

console.log("데이터 요청 시작...");

fetchDataPromise()
  .then(response => { // 성공적으로 데이터를 가져왔을 때
    console.log("성공:", response);
    return "다음 작업으로 전달할 값"; // 다음 .then()으로 값 전달
  })
  .then(nextValue => { // 이전 .then()에서 반환된 값 받음
    console.log("다음 체인:", nextValue);
  })
  .catch(error => { // Promise가 실패했을 때
    console.error("실패:", error);
  })
  .finally(() => { // 성공/실패와 상관없이 항상 실행
    console.log("데이터 요청 작업 완료.");
  });

console.log("Promise 요청은 즉시 반환되었습니다.");

출력

데이터 요청 시작...
Promise 요청은 즉시 반환되었습니다.
(2초 후)
성공: 데이터를 성공적으로 가져왔습니다!
다음 체인: 다음 작업으로 전달할 값
데이터 요청 작업 완료.

Promise는 콜백 헬을 벗어나 비동기 결과와 오류를 체인으로 연결해 가독성을 높여줍니다. 각 .then()이 반환한 값이나 Promise는 다음 단계가 동화하고, handler가 던진 오류는 다음 rejection 경로로 전달됩니다.

비동기 문법은 콜백에서 Promise, async/await로 발전했지만 해결하려는 문제는 같습니다.

중첩을 줄이고, 성공/실패 흐름을 읽기 쉬운 형태로 펼치는 것입니다.


async/await (어싱크/어웨이트)

ES8(ECMAScript 2017)에서 도입된 async/await 문법은 Promise를 기반으로 하며, 비동기 코드를 마치 동기 코드처럼 보이게 작성할 수 있도록 도와주어 가독성을 극대화합니다.

  • async 키워드
    • 함수 앞에 async를 붙이면, 해당 함수는 항상 Promise를 반환합니다.
    • 함수 내에서 await 키워드를 사용할 수 있게 됩니다.
    • 함수 본문은 호출 시 첫 await를 만나 일시 중지하거나 함수가 끝날 때까지 동기적으로 실행됩니다.
  • await 키워드
    • Promise뿐 아니라 어떤 표현식에도 사용할 수 있습니다. 표현식의 결과는 Promise resolution 규칙을 거쳐 일반 값은 fulfilled 값처럼 처리되고 thenable은 그 상태를 동화합니다.
    • 해당 값이 이행될 때까지 현재 async 함수만 일시 중지하며, continuation은 microtask에서 이어집니다.
    • 이행되면 결과값을 반환하고, 거부되면 해당 await 지점에서 에러를 던집니다.
예시: async/await 사용
function fetchDataAsync() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("비동기 데이터 획득!");
    }, 1500);
  });
}

async function processData() { // async 함수로 정의
  console.log("데이터 처리 시작 (async/await)...");
  try {
    // await은 Promise가 완료될 때까지 기다립니다.
    const data = await fetchDataAsync(); // fetchDataAsync Promise가 이행될 때까지 대기
    console.log("받은 데이터:", data);

    const processedData = data + " -> 처리 완료!";
    console.log("처리된 데이터:", processedData);

    // 다음 비동기 작업도 await으로 순차적으로 처리 가능
    const anotherResult = await new Promise(resolve => setTimeout(() => resolve("추가 작업 완료!"), 500));
    console.log(anotherResult);

  } catch (error) { // Promise가 rejected되면 catch 블록으로 이동
    console.error("오류 발생:", error);
  } finally {
    console.log("데이터 처리 종료.");
  }
}

// 첫 await 전의 본문은 동기적으로 실행되고, async 함수는 Promise를 반환합니다.
processData().then(() => {
  console.log("processData 함수 전체 실행 완료.");
});

console.log("async 함수 호출에서 Promise를 받았습니다.");

출력

데이터 처리 시작 (async/await)...
async 함수 호출에서 Promise를 받았습니다.
(1.5초 후)
받은 데이터: 비동기 데이터 획득!
처리된 데이터: 비동기 데이터 획득! -> 처리 완료!
(0.5초 후)
추가 작업 완료!
데이터 처리 종료.
processData 함수 전체 실행 완료.

async/await는 비동기 코드의 가독성을 비약적으로 향상시켜줍니다.

특히 여러 비동기 작업이 실제로 앞 단계 결과에 의존할 때 Promise Chain보다 순서를 직관적으로 표현할 수 있습니다. 반대로 각 await 표현식에서 서로 독립적인 작업을 새로 시작하면 시작 시점도 직렬화됩니다. 병렬로 기다리려면 작업들을 먼저 시작한 뒤 await Promise.all(...)처럼 함께 결합합니다.

콜백, Promise, async/await는 결과와 오류가 전달되는 위치가 다릅니다. 아래 비교는 문법의 모양과 실행 계약을 분리해 보여줍니다.

Callback, Promise, async await의 시작과 반환, 결과와 오류 전달, 순차 실행 계약 및 Promise의 pending, fulfilled, rejected 상태 비교

Start · return · result · error

세 형식은 작업을 더 빠르게 만드는 단계가 아니라 결과와 오류를 읽는 위치를 바꾸는 계약입니다. API가 콜백을 호출하거나 Promise가 결정되면 후속 로직이 이어지며, 의존 관계가 있는 작업과 서로 독립적인 작업의 시작 시점은 따로 설계합니다.

Callback · API가 호출 규약 소유

시작 · 반환

함수를 넘기면 API가 정한 시점과 횟수로 호출합니다. 동기 호출인지 나중 호출인지, 취소 handle을 반환하는지도 API 계약입니다.

결과

결과는 callback 인자로 받습니다. 브라우저의 event/value 형식과 Node에서 흔한 (error, value) 형식은 서로 다른 규약입니다.

오류 · 순서

나중 callback의 throw는 호출 바깥 try/catch가 잡지 못합니다. 의존 작업을 그대로 중첩하면 callback hell이 생길 수 있습니다.

Promise · 상태와 reaction 체인

시작 · 반환

new Promise(executor)의 executor는 동기 실행되고, 생성자가 반환한 Promise가 나중 결과를 나타냅니다.

결과

resolve는 값이나 thenable로 Promise를 해결합니다. pending thenable이면 그 결과를 따르도록 고정된 뒤에도 settlement 전까지 Pending일 수 있고, fulfillment reaction은 microtask에서 실행됩니다.

오류 · 순서

reject와 handler의 throw는 Rejected 경로로 이어지고 catch가 처리합니다. 체인은 앞 결과에 의존하는 순서를 표현합니다.

async · await · Promise를 평평하게 읽기

시작 · 반환

async 본문은 첫 await 전까지 동기 실행되고 호출 결과로 Promise를 반환합니다.

결과

await는 임의 표현식을 Promise resolution 규칙으로 처리합니다. 이행 값은 continuation에서 읽고, continuation은 microtask에서 이어집니다.

오류 · 순서

거부는 await 지점에서 throw되어 try/catch로 갑니다. 잡지 않은 throw는 async 함수의 Rejected 결과가 되며 각 await에서 작업을 시작하면 시작 시점도 직렬화됩니다.

Pending

결정 전

아직 fulfillment 값이나 rejection 이유가 정해지지 않은 초기 상태입니다.

Fulfilled

값으로 이행

Pending에서 한 번 전이한 최종 상태이며 등록된 fulfillment reaction이 실행 대상이 됩니다.

Rejected

이유와 함께 거부

Pending에서 한 번 전이한 최종 상태이며 rejection handler가 없으면 미처리 오류로 보고될 수 있습니다.

Promise state: PendingFulfilled 또는 Rejected로 한 번만 결정됩니다. finally는 결과 인자를 받지 않는 정리 단계이며, 스스로 throw하거나 Rejected Promise를 반환하지 않으면 기존 settlement를 통과시킵니다. 서로 독립적인 작업은 먼저 함께 시작한 뒤 await Promise.all(...)처럼 결합해야 불필요한 직렬화를 피할 수 있습니다. 구현 형식과 별개로 UI는 시작·결과·오류를 명시적 상태로 해석해야 합니다.

비동기 문법을 UI 상태로 옮길 때는 성공 경로만이 아니라 대기, 실패, 정리 단계를 함께 모델링해야 합니다.

비동기 UI가 idle에서 loading으로 전이한 뒤 fulfilled 결과의 유무에 따라 success 또는 empty가 되고 rejected 결과는 error가 되는 상태 머신

Idle → loading → one outcome

요청 상태는 여러 boolean이 아니라 한 번에 하나의 명시적 상태로 표현합니다. 시작하면 Loading, 이행하면 데이터 유무에 따라 Success 또는 Empty, 거부되면 Error로 전이하며 재시도와 오래된 응답 처리도 상태 계약에 포함합니다.

비동기 요청 UI의 idle, loading, success, empty, error 상태 Idle에서 요청을 시작하면 Loading이 되고, fulfilled 결과에 항목이 있으면 Success, 없으면 Empty가 됩니다. Rejected 결과는 Error가 되며 Error에서 재시도하면 Loading으로 돌아갑니다. 요청 항목 있음 항목 없음 rejected 재시도 Idle 아직 요청 전 Loading 진행 중 · 중복 정책 Success 데이터 화면 Empty 빈 결과 안내 Error 실패 안내 · retry
Idle

아직 요청 전

초기 안내나 요청 버튼을 보여주며 데이터와 오류를 요청 결과처럼 가장하지 않습니다.

Loading

요청 진행 중

진행 피드백과 중복 요청 정책을 적용합니다. 이전 데이터 유지 여부도 제품 계약으로 정합니다.

Success

데이터가 있는 이행 결과

필요한 형태로 저장한 데이터를 화면에 표시합니다.

Empty

성공했지만 항목 없음

오류가 아닌 정상적인 빈 결과로 안내와 다음 행동을 제공합니다.

Error

거부 또는 처리 실패

실패 정보를 표시하고 재시도하면 Loading으로 돌아갑니다.

불가능한 조합을 만들지 않는다

statusidle, loading, success, empty, error 중 하나로 모델링하면 loading과 error가 동시에 true인 모순을 막기 쉽습니다.

정리와 경쟁 상태는 별도 계약

finally는 자원 정리 지점이지 결과 상태를 고르는 대신이 아닙니다. 취소와 새 요청이 겹치면 AbortController나 request id로 오래된 응답을 무시합니다.

Callback, Promise, async/await 중 무엇으로 구현해도 UI는 요청 시작, 이행 값의 유무, 거부 이유를 같은 상태 전이로 해석할 수 있어야 합니다.


비동기 처리의 주요 개념 요약

  • 이벤트 루프(Event Loop): 브라우저 같은 실행 환경이 task를 하나씩 실행하고, task가 끝나 콜 스택이 비면 microtask checkpoint에서 Promise reaction과 await continuation을 처리한 뒤 다음 task와 렌더 기회로 진행하는 실행 모델입니다.
  • Non-blocking I/O: 네트워크처럼 실행 환경에 맡길 수 있는 입력·출력의 완료를 기다리는 동안 자바스크립트가 다른 작업을 진행하는 방식입니다. 긴 CPU 계산을 자동으로 분리한다는 뜻은 아닙니다.
  • React scheduling: Promise나 타이머 콜백의 setState는 업데이트를 요청합니다. React는 업데이트를 batching하고 우선순위에 따라 render와 commit을 예약하므로, microtask 하나와 화면 갱신 하나가 항상 일대일로 대응하지 않습니다. Effect는 render 중이 아니라 commit 이후 외부 시스템과 동기화하는 단계에서 실행됩니다.

자바스크립트의 비동기 처리는 여기까지입니다.

이 장에서는 동기 처리와 비동기 처리의 차이를 이해하고, 자바스크립트 비동기 처리의 발전 과정인 콜백, Promise, 그리고 async/await의 기본적인 사용법과 특징을 알아보았습니다.

특히 async/await는 현대 리액트 애플리케이션에서 서버와의 통신 등 데이터 페칭 시 가장 널리 사용되는 방식이므로 개념을 확실히 익히는 것이 중요합니다.

이제 자바스크립트가 비동기 작업을 어떻게 다루는지 이해했으므로, 다음 절에서는 이러한 비동기 처리 기술을 활용하여 리액트 컴포넌트 내부에서 실제로 데이터를 가져오는 방법인 데이터 페칭(Data Fetching)에 대해 구체적으로 알아보겠습니다.