Profile image
Jinyoung
Dev

TIL-02: JavaScript 비동기 처리와 Promise

90% Human
10% AI
TIL-02: JavaScript 비동기 처리와 Promise
0 views
11 min read

왜 '비동기'가 필요한가?

JavaScript는 싱글 스레드 언어입니다. 즉, 한 번에 하나의 일만 할 수 있습니다.

그런데 현실 세계에서는 이런 일들이 생깁니다:

사용자가 버튼 클릭
  → 서버에 데이터 요청 (3초 걸림)
  → 그 3초 동안 화면이 완전히 멈춤?? ❌

식당에 비유하자면, 주방장이 한 명인데 손님 A의 스테이크가 구워지는 30분 동안 손님 B,C,D의 주문을 아예 받지 않는 것과 같습니다. 말이 안 되죠.

그래서 현실의 주방장은 스테이크를 오븐에 넣어두고, 그 사이에 다른 주문을 처리합니다. 오븐 타이머가 울리면 그때 꺼내면 되니까요. JavaScript도 마찬가지입니다. **이벤트 루프(Event Loop)**라는 메커니즘이 오래 걸리는 작업(네트워크 요청, 타이머 등)을 브라우저나 Node.js에 위임하고, 작업이 끝나면 콜백 큐를 통해 결과를 돌려받습니다.

비동기 처리 = "오래 걸리는 일은 맡겨두고, 그 사이에 다른 일을 한다"


비동기 처리 진화의 역사 (왜 Promise가 나왔는가)

시대1. 콜백 함수 (Callback) - 태초의 방법

// 데이터 가져오고 → 가공하고 → 저장하고 → 알림 보내기
fetchUser(userId, function(user) {
  fetchOrders(user.id, function(orders) {
    processOrders(orders, function(result) {
      saveResult(result, function(saved) {
        sendNotification(saved, function(notif) {
          // 콜백 지옥! (Callback Hell)
          // 여기까지 오는데 들여쓰기가 총 5단계
        })
      })
    })
  })
})

이것이 바로 악명 높은 "콜백 지옥 (Callback Hell) 또는 Pyramid of Doom" 입니다.

콜백의 문제점:

  • 가독성이 극도로 낮음 (오른쪽으로 계속 들여쓰기를 해야 됨)
  • 에러 처리가 각 콜백마다 따로따로
  • 코드 흐름을 추적하기 매우 어려움
  • 순서 보장이 어려움

시대2. Promise - ES6(2015) 등장

Promise는 "미래에 값을 줄게" 라는 약속 객체입니다.

// Promise로 같은 로직 작성
fetchUser(userId)
  .then(user => fetchOrders(user.id))
  .then(orders => processOrders(orders))
  .then(result => saveResult(result))
  .then(saved => sendNotification(saved))
  .catch(error => console.error("어디서든 에러나면 여기로"));

훨씬 읽기 좋아졌습니다. 위에서 아래로 흐르는 것처럼 보입니다.

Promise의 3가지 상태:

┌────────────────────────────────────────────────────────┐
│                                                        │
│   ⏳ Pending (대기)                                     │
│   → 초기 상태. 아직 결과가 없음                              │
│           ↙              ↘                             │
│   ✅ Fulfilled (이행)   ❌ Rejected (거부)                │
│   → 성공! 값이 생김      → 실패. 에러가 생김                  │
│                                                        │
│   * 한 번 Fulfilled 또는 Rejected되면 상태가 확정(Settled)됨  │
│   * Settled된 Promise는 다시 변하지 않음                    │
│                                                        │
└────────────────────────────────────────────────────────┘

Promise 기본 생성:

const myPromise = new Promise((resolve, reject) => {
  // 비동기 작업 수행
  const success = true;

  if (success) {
    resolve("성공 값!");        // Fulfilled 상태로 전환
  } else {
    reject(new Error("실패"));  // Rejected 상태로 전환
  }
});

// 사용
myPromise
  .then(value => console.log(value))      // "성공 값!"
  .catch(error => console.error(error));  // 에러 처리

실전 예제 - fetch API로 데이터 가져오기:

// 브라우저 콘솔에 복붙해서 바로 실행해볼 수 있습니다
fetch('https://jsonplaceholder.typicode.com/users/1')
  .then(response => response.json())
  .then(user => console.log(user.name))  // "Leanne Graham"
  .catch(error => console.error("요청 실패:", error));

fetch()는 Promise를 반환하는 대표적인 Web API입니다. 위 코드에서 .then()이 두 번 체이닝되는 것을 볼 수 있습니다 — 첫 번째는 응답을 JSON으로 변환하고, 두 번째는 그 데이터를 사용합니다.

시대3. async/await - ES2017

Promise의 단점을 가장 근본적으로 해결한 문법적 설탕(Syntax Sugar)입니다. Promise를 마치 동기 코드처럼 쓸 수 있게 해줍니다.

// Promise 버전 (단점: 변수 공유 어려움, 읽기 어려움)
function getOrdersForUser(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchOrders(user.id)
        .then(orders => ({ user, orders })); // user를 넘기는 꼼수
    })
    .then(({ user, orders}) => {
      return processOrders(user, orders);
    });
}

// async/await 버전
async function getOrdersForUser(userId) {
  const user = await fetchUser(userId);       // user 변수 자유롭게 사용
  const orders = await fetchOrders(user.id);  // orders 변수 자유롭게 사용
  return processOrders(user, orders);         // 둘 다 그냥 쓰면 됨!
}

에러 처리도 자연스러워집니다:

async function getOrdersForUser(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    return processOrders(user, orders);
  } catch (error) {
    // 어디서 에러가 발생했는지 스택 트레이스가 명확함
    console.error("에러 발생:", error);
  }
}

중요: async/await은 Promise를 대체하는 것이 아니라 Promise 위에서 동작하는 더 편한 문법입니다.


Promise의 장단점 분석

✅ 장점:

장점설명콜백 대비
체이닝.then().then()으로 순차적 표현콜백 지옥 탈출
통합 에러 처리.catch() 하나로 전체 에러 처리각 콜백마다 에러 처리 불필요
상태 확정(Settled)한 번 결정된 상태는 변하지 않음예측 가능한 동작
병렬 처리Promise.all()로 여러 작업 동시 처리순차 처리보다 빠름

❌ 단점:

단점1. 여전히 읽기 어려운 체이닝

// 각 then 안에서 변수를 공유하기 어려움
fetchUser()
  .then(user => {
    return fetchOrders(user.id); // user를 다음 then에서도 쓰고 싶다면?
  })
  .then(orders => {
    // 여기서 user에 접근하려면?
    // user를 쓰려면 외부 변수에 저장하는 꼼수를 써야 함
  });

단점2. 디버깅의 어려움

// 에러가 어느 then에서 발생한건지 스택 트레이스가 불명확
fetchUser()
    .then(processData)    // 여기서 났나?
    .then(saveData)       // 아니면 여기?
    .then(notify)         // 아니면 여기?
    .catch(e => console.log(e)); // 에러 위치 추적이 어려움

단점3. 취소(Cancel)가 불가능함

const promise = fetchHugeData(); // 시작

// 사용자가 페이지를 떠남.. 근데 멈출 수가 없음
// promise.cancel() ← 이런 메서드가 없음

단점4. then 체이닝의 복잡한 분기 처리

// 조건에 따라 다른 처리가 필요할 때 가독성이 나빠짐
fetchData()
  .then(data => {
    if (data.type === 'A') {
      return processA(data).then(result => ({result, type: 'A'}));
    } else {
      return processB(data).then(result => ({result, type: 'B'}));
    }
  })
  .then(({result, type}) => {
    // type을 억지로 넘겨줘야 함. 어색한 코드
  })

실전에서 유용한 도구들

지금까지 콜백 → Promise → async/await으로 이어지는 비동기 처리의 진화를 살펴보았습니다. 이제 Promise 생태계 안에서 실전에 유용한 보조 도구들을 알아보겠습니다.

Promise.all/Promise.allSettled/Promise.race - 병렬 처리

Promise.all - "모두 성공해야 진행"

// 3개를 순차적으로 하면 3초씩 = 9초
// Promise.all로 동시에 하면 3초 (가장 오래 걸리는 것만큼)
const [user, posts, comments] = await Promise.all([
  fetchUser(userId),
  fetchPosts(userId),
  fetchComments(userId)
]);
// 단, 하나라도 실패하면 전체 실패

Promise.allSettled - "결과에 상관없이 모두 기다림" (ES2020)

// Promise.all의 특성: 하나라도 실패하면 나머지도 무시됨
// Promise.allSettled: 성공이든 실패든 모두의 결과를 돌려줌
const results = await Promise.allSettled([
  fetchUser(userId),
  fetchPosts(userId),     // ← 이게 실패해도
  fetchComments(userId)   // 이건 결과를 돌려줌
]);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log(result.value);
  }
  if (result.status === 'rejected') {
    console.log(result.reason);
  }
})

Promise.race - "가장 빠른 녀석만"

// 타임아웃 구현에 유용
const result = await Promise.race([
  fetchData(),                    // 실제 요청
  new Promise((_, reject) => {    // 타임아웃
    setTimeout(() => reject(new Error("Timeout")), 5000);
  })
]);

AbortController - Promise 취소 문제 해결

const controller = new AbortController();

// 요청 시작
fetch('/api/huge-data', { signal: controller.signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('요청이 취소되었습니다');  // 취소 감지
    }
  });

// 사용자가 페이지를 떠날 때
controller.abort(); // 요청 취소

참고: Promise는 단발성(one-shot)으로 하나의 값만 반환합니다. 실시간 데이터 스트림(WebSocket, 연속 이벤트 등)을 다뤄야 한다면 RxJS의 Observable 같은 별도의 스트림 처리 라이브러리를 살펴보세요.

전체 비교 요약

콜백Promiseasync/awaitObservable
가독성❌ 낮음⚠️ 중간✅ 높음⚠️ 중간
에러 처리❌ 분산⚠️ .catch✅ try/catch.catch
취소 가능⚠️ (AbortController)⚠️ (동일)
다중 값
디버깅❌ 어려움⚠️ 중간✅ 쉬움⚠️ 중간
학습 난이도✅ 쉬움⚠️ 중간✅ 쉬움❌ 어려움

Comments (0)

Checking login status...

No comments yet. Be the first to comment!