JavaScript

JS 비동기 프로그래밍 최종 정리

25G 2025. 2. 14. 13:56

비동기 프로그래밍

비동기 프로그래밍 탄생 배경

  1. CPU와 I/O 속도의 차이
    • CPU는 매우 빠르게 연산을 수행하지만, 디스크, 네트워크, DB 등 I/O 작업은 상대적으로 느림.
    • 동기(Synchronous) 방식으로 실행하면 I/O 대기 시간 동안 CPU가 낭비됨.
  2. 멀티태스킹과 병렬 처리의 필요성
    • 하나의 요청이 완료될 때까지 기다리면 다른 작업을 동시에 처리할 수 없음.
    • 여러 작업을 동시에 실행하기 위해 스레드 기반의 멀티태스킹이 등장.
    • 하지만 스레드 생성 비용이 높고, 컨텍스트 스위칭이 성능 저하를 초래함.
  3. 이벤트 기반 & 논블로킹 방식 등장
    • 효율적인 리소스 사용을 위해 이벤트 루프(Event Loop)와 콜백 기반의 비동기 처리가 개발됨.
    • 대표적으로 Node.js, JavaScript Promise, Java의 CompletableFuture, Python의 asyncio 등이 있음.
    • 이를 통해 CPU는 블로킹 없이 다른 작업을 수행하며 I/O 작업을 기다릴 수 있음.
  4. 현대 소프트웨어에서의 필수 요소
    • 네트워크 요청, 데이터베이스 조회, 파일 입출력 등 비효율적인 블로킹을 제거.
    • 마이크로서비스, 실시간 데이터 처리, 대규모 시스템(예: 웹 서버, 메시지 큐)에서 비동기 방식이 필수.

프로미스 패턴

  1. 비동기 코드에서 콜백 지옥(Callback Hell) 문제 해결
    • 기존에는 콜백 함수를 중첩하여 사용 → 코드 가독성이 떨어짐.
    • Promise는 비동기 작업을 더 구조적으로 관리할 수 있도록 설계됨.
  2. Promise의 특징
    • new Promise((resolve, reject) => { ... }) 형태로 생성.
    • 3가지 상태:
      • pending (대기 중) → 초기 상태.
      • fulfilled (성공) → resolve() 호출 시.
      • rejected (실패) → reject() 호출 시.
      • .then() → 성공 시 실행할 콜백을 등록.
      • .catch() → 실패 시 실행할 콜백을 등록.
      • .finally() → 성공/실패와 관계없이 실행할 콜백을 등록.
// 1. Promise 생성
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = Math.random() > 0.5; // 랜덤 성공/실패 시뮬레이션
      if (success) {
        resolve("✅ 데이터 로딩 성공!");
      } else {
        reject("❌ 데이터 로딩 실패!");
      }
    }, 1000); // 1초 후 실행
  });
}

// 2. Promise 사용
fetchData()
  .then((result) => {
    console.log(result); // 성공 시 실행
  })
  .then((result) => {
    console.log(result); // 이렇게 체이닝을 걸어서 처리를 할 수 있다.
  })
  .catch((error) => {
    console.error(error); // 실패 시 실행
  })
  .finally(() => {
    console.log("🎯 작업 완료!"); // 성공/실패 여부와 관계없이 실행
  });

프로미스 메모이제이션 예제 코드

// 📌 캐시 저장소 (키: URL, 값: Promise)
const cache = new Map();

// ✅ 메모이제이션된 fetch 함수
function fetchData(url) {
  // 1. 캐시에 존재하면 기존 Promise 반환 (중복 요청 방지)
  if (cache.has(url)) {
    console.log("📌 캐시에서 데이터 반환:", url);
    return cache.get(url);
  }

  // 2. 새로운 Promise 생성 및 저장
  console.log("🔄 새로운 데이터 요청:", url);
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = Math.random() > 0.3; // 70% 성공 확률
      if (success) {
        const data = `✅ 서버 응답: ${url}`;
        cache.set(url, Promise.resolve(data)); // 성공한 데이터 캐싱
        resolve(data);
      } else {
        reject("❌ 서버 요청 실패!");
      }
    }, 1000); // 1초 후 실행
  });

  cache.set(url, promise); // 요청 중인 Promise도 캐싱 (중복 요청 방지)
  return promise;
}

// ✅ 메모이제이션된 API 호출
fetchData("https://api.example.com/data")
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

// 1초 후 다시 같은 URL 요청 → 캐시에서 반환됨
setTimeout(() => {
  fetchData("https://api.example.com/data")
    .then((data) => console.log(data))
    .catch((error) => console.error(error));
}, 1500);

// 2초 후 새로운 URL 요청 → 새로운 데이터 요청 발생
setTimeout(() => {
  fetchData("https://api.example.com/other")
    .then((data) => console.log(data))
    .catch((error) => console.error(error));
}, 2000);

async/await의 탄생

  • ES2017(ES8)에서 async/await가 도입되면서, 비동기 코드를 동기 코드처럼 작성할 수 있게 되었습니다.
  • async/await는 내부적으로 Promise를 사용하지만, 코드 가독성과 유지보수성을 크게 향상시키는 방식입니다.

async/await 기본 개념

  1. async 함수
  • 함수 선언 앞에 async 키워드를 붙이면, 해당 함수는 항상 Promise를 반환합니다.
  • 함수 내부에서 return문으로 반환한 값은 자동으로 resolve되고,
  • 함수 내부에서 예외(throw)가 발생하면 자동으로 reject됩니다.

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

  1. await 키워드
  • await는 Promise가 처리될 때까지(fulfill 또는 reject) 기다리는 역할을 합니다.
  • await 구문이 완료되면, 해당 Promise의 결과 값(resolve 값)을 반환받을 수 있습니다.
  • await는 async 함수 내부에서만 사용 가능합니다. (전역 스코프나 일반 함수에서는 사용 불가능)
  1. 프로세스 흐름
  • async 함수는 내부에 await가 있더라도 논블로킹(Non-blocking)으로 작동합니다.
  • 한 줄씩 동기 코드처럼 보이지만, 실제로는 이벤트 루프에서 Promise 상태를 확인하며,
  • 다른 작업(콜 스택)이 진행될 수 있게 해줍니다.

예시 코드

3.1 기본 사용 예시

// (1) Promise를 반환하는 함수
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = Math.random() > 0.5;
      if (success) {
        resolve("✅ 데이터 로딩 성공");
      } else {
        reject("❌ 데이터 로딩 실패");
      }
    }, 1000);
  });
}

// (2) async/await 사용
async function main() {
  try {
    console.log("데이터 요청 중...");
    const result = await fetchData();  // fetchData()의 resolve가 완료될 때까지 대기
    console.log("결과:", result);
  } catch (error) {
    console.error("에러:", error);
  } finally {
    console.log("비동기 작업 완료!");
  }
}

main();

동작 흐름

  1. main() 함수가 호출됨 → 내부에서 fetchData()를 호출.
  2. await fetchData() 구문에서 Promise가 이행(resolve)되거나 거부(reject)될 때까지 비동기로 기다림.
  3. try/catch 구문을 사용해 resolve 결과(result)를 받거나, reject 상황을 에러로 잡아냄.
  4. finally 블록은 성공/실패와 관계없이 마지막에 무조건 실행됨.

결과 예시:

데이터 요청 중...
결과: ✅ 데이터 로딩 성공
비동기 작업 완료!

또는

데이터 요청 중...
에러: ❌ 데이터 로딩 실패
비동기 작업 완료!

연속된 비동기 작업

async/await를 사용하면 서로 의존성이 있는 연속 비동기 작업을 마치 동기 코드처럼 순차적으로 표현할 수 있습니다.

async function processData() {
  try {
    const data1 = await fetchData1();
    console.log("data1:", data1);

    const data2 = await fetchData2(data1);
    console.log("data2:", data2);

    const finalResult = await fetchData3(data2);
    console.log("finalResult:", finalResult);
  } catch (error) {
    console.error("에러 발생:", error);
  }
}

processData();

fetchData2()는 data1의 결과를 이용해야 하므로,

await fetchData1()을 마친 후에야 호출할 수 있습니다.

async/await 덕분에 개발자가 동기 코드를 작성하듯 직관적으로 순서를 명시할 수 있습니다.

3.3 병렬 실행

  • await는 직렬로 코드가 실행되기 때문에, 서로 의존하지 않는 비동기 작업이라면 동시에(병렬) 실행하는 것이 성능적으로 유리합니다.

직렬 실행 (비효율적)

// 서로 의존하지 않는 두 작업 dataA, dataB를 순차로 실행
async function getDataSerial() {
  const dataA = await fetchDataA(); // 1초 소요
  const dataB = await fetchDataB(); // 1초 소요
  return { dataA, dataB };         // 총 2초 소요
}

병렬 실행 (효율적)

// 동시에 Promise 생성 후, 둘 다 완료되길 기다림
async function getDataParallel() {
  const promiseA = fetchDataA(); // 1초
  const promiseB = fetchDataB(); // 1초
  // 두 Promise가 동시에 진행 (실제로는 1초 내에 모두 끝날 수 있음)

  const [dataA, dataB] = await Promise.all([promiseA, promiseB]);
  return { dataA, dataB };
}

Promise.all() 또는 Promise.allSettled() 등을 활용하면, 서로 의존 관계가 없는 비동기 작업을 동시에 시작하고, 그 결과를 한꺼번에 받아올 수 있습니다.

예외 처리

  1. try/catch
  • await로 받은 Promise가 reject되면, 예외 처리가 발생(throw)한 것과 동일하게 동작합니다.
  • try/catch 블록 안에서 .catch() 없이도 예외를 처리할 수 있습니다.
  1. 전역 에러 처리
  • async 함수 밖으로 예외가 전파되면, 전역에서 처리해야 할 수 있습니다.
  • Node.js 환경에선 process.on('unhandledRejection', callback)으로,
  • 브라우저 환경에선 window.addEventListener('unhandledrejection', callback) 등으로 처리할 수 있습니다.
  1. .catch() 체인
  • await 다음에 직접 .catch()를 사용할 수도 있지만, 주로 try/catch 구문이 많이 쓰입니다.

5. async/await와 이벤트 루프

  1. 이벤트 루프(Event Loop)
    • JavaScript의 비동기 모델을 지원하는 핵심 메커니즘.
    • 콜 스택(Call Stack), 태스크 큐(Task Queue), 마이크로태스크 큐(Microtask Queue) 등을 통해 동시성을 흉내냄.
  2. await 시 이벤트 루프 동작
    • await promise를 만나면, 해당 async 함수의 실행이 일시 중단(suspended)됨.
    • 그동안 이벤트 루프는 다른 태스크를 계속 처리할 수 있음.
    • promise가 resolve 또는 reject 되면, 마이크로태스크 큐에 콜백이 들어가고, 이후에 함수 실행이 재개됩니다.
  3. 비블로킹(Non-blocking)
    • await는 JS 메인 스레드를 전부 막는 게 아닌, 해당 함수의 실행 흐름만 중단하고,
    • 다른 이벤트/콜백은 처리 가능하도록 비동기적인 흐름을 유지합니다.

주의사항 & 팁

  1. async 함수는 자동으로 Promise를 반환

return 값은 resolve 값이 되며, throw는 reject 처리가 됩니다.

  1. 외부 API 병렬 호출 시, 가급적 Promise.all() 사용

의존 관계가 없는 요청을 await로 직렬화하면 불필요한 성능 손실이 발생할 수 있음.

  1. 오래 걸리는 동작 중 예외 처리

네트워크 요청, 파일 읽기/쓰기 중 예외가 발생할 경우,

try/catch 안에서 reject 처리하는 로직을 주의 깊게 작성해야 합니다.

  1. async/await 대신 .then()을 사용할 수 있음

async/await가 가독성을 높여주지만, 기존 Promise 체인 형태로도 동일한 기능을 구현할 수 있습니다.

규모가 큰 프로젝트에선 혼합 사용도 가능하나, 일관성 있게 작성하는 편이 좋습니다.

  1. 오류/예외가 나면 디버깅

async 함수 내부에서 throw된 에러는 호출부에서 await 시점에 받을 수 있습니다.

디버깅 시 console.log 혹은 브라우저 개발자도구/Node 디버거를 활용하세요.

  1. forEach 내부에서 await 사용 X

JavaScript의 Array.prototype.forEach()는 await를 제대로 지원하지 않습니다.

대신 for..of를 사용하면 순차적인 비동기 처리가 가능합니다.

JS의 비동기 실행 원리

JS는 콜스택, 마이크로태스크 큐, 매크로태스크큐 라는 3가지 주요구조를 사용해서 실행순서를 제어한다.

콜스택

  • JavaScript의 동기 코드(synchronous code)를 위에서 아래로 순차적으로 실행하는 공간입니다.
  • LIFO(Last In, First Out) 구조를 가집니다.
  • 함수가 호출되면 스택에 push되고, 실행이 끝나면 pop됩니다.

마이크로태스크 큐

  • Promise의 .then(), .catch(), .finally()와 process.nextTick() 등의 비동기 작업이 대기하는 공간입니다.
  • 콜 스택이 비워지면, 이벤트 루프는 마이크로태스크 큐를 먼저 확인하고 실행합니다.
  • FIFO(First In, First Out) 구조를 가집니다.

특징

  1. 마이크로태스크는 콜 스택이 비워질 때 실행됩니다.
  2. Promise.then(), queueMicrotask(), process.nextTick()이 여기에 등록됩니다.
  3. 마이크로태스크 큐는 매크로태스크 큐보다 항상 먼저 실행됩니다.

매크로태스크 큐

  • setTimeout(), setInterval(), setImmediate(), I/O 이벤트비동기 타이머 및 이벤트 핸들러가 대기하는 공간입니다.
  • 콜 스택이 비어있고 마이크로태스크 큐의 모든 작업이 끝난 후에 실행됩니다.
  • FIFO(First In, First Out) 구조를 가집니다.

특징

  1. 매크로태스크 큐는 마이크로태스크 큐 이후에 실행됩니다.
  2. setTimeout(fn, 0)도 최소한 1ms 이후에 실행됩니다.
  3. 이벤트 루프는 마이크로태스크 큐 → 매크로태스크 큐 순서로 작업을 실행합니다.

이벤트 루프(Event Loop) 동작 흐름

  1. 콜 스택 실행 (동기 코드 처리)
  2. 마이크로태스크 큐 실행 (Promise 콜백 등)
  3. 매크로태스크 큐 실행 (setTimeout, setInterval 등)
  4. 다시 1단계로 돌아감
  • 실행 우선순위
    • 콜 스택 > 마이크로태스크 큐 > 매크로태스크 큐

비동기 처리 문제

console.log('A');

async function foo() {
  console.log('B');
  await Promise.resolve('C').then(value => {
    console.log(value);
  });
  console.log('D');
}

foo().then(() => {
  console.log('E');
});

new Promise((resolve, reject) => {
  console.log('F');
  setTimeout(() => {
    console.log('G');
    resolve('H');
  }, 0);
}).then(val => {
  console.log(val);
});

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

console.log('J');
  • 정답
    • A → B → F → J → C → I → D → E → G → H
  • 해석
    1. 메인 스레드(동기 코드): Call Stack
      1. console.log('A') → A 출력
      2. foo() 호출 → 함수 실행 시작
        • console.log('B') → B 출력
        • await Promise.resolve('C') → Promise는 바로 이행(resolve)되지만, .then() 콜백은 마이크로태스크 큐로 이동
      3. 다음 코드로 진행
      4. new Promise 생성 → console.log('F') 출력
      5. setTimeout 등록 → setTimeout의 콜백(G, H)은 매크로태스크 큐에 등록
      6. Promise.resolve().then() 실행 → .then() 콜백은 마이크로태스크 큐로 이동
      7. console.log('J') → J 출력
      8. 이시점 출력
        1. A B F J
    2. 마이크로태스크 큐(Microtask Queue) 실행
      • Promise.resolve('C').then()
      • Promise.resolve().then(() => console.log('I'))
      • await 구문 이후 코드(console.log('D'))
      • foo()의 .then(() => console.log('E'))
      • 실행 순서:
      1. console.log('C') → C 출력
      2. console.log('I') → I 출력
      3. console.log('D') (await 뒤의 코드) → D 출력
      4. **console.log('E') (foo의 .then) → E 출력
      5. 이시점 출력
        1. A B F J C I D E
    3. 매크로태스크 큐(MacroTask Queue) 실행
      1. console.log('G') → G 출력
      2. resolve('H') 호출 → then() 콜백이 마이크로태스크 큐로 이동
      3. 마이크로태스크 큐에서 console.log('H') 실행 → H 출력