반응형

JavaScript 비동기 처리 정리, 결제 큐 사고 하나에서 드러난 함정들

JavaScript 비동기 처리 정리, 결제 큐 사고 하나에서 드러난 함정들

요즘 팀에서 결제 요청이 몰리면 처리 순서가 자꾸 꼬이더라고요. 로직이 복잡해서가 아니었어요. 비동기 처리 기초 개념을 대충 다뤘다는 게 원인이었습니다. 그날 밤의 사고를 계기로 다들 자바스크립트 비동기 동작을 다시 짚고 싶어서, 그때 원인별로 정리해 봤습니다.

비동기 처리 기초 개념, 어디서부터 이해할까

자바스크립트는 한 줄씩 순서대로 실행하지만, 네트워크 요청처럼 시간이 걸리는 작업은 답이 올 때까지 기다려주지 않습니다. 그래서 코드는 읽는 순서대로 끝나지 않을 수 있다는 뜻이에요.

MDN 웹 문서에 따르면 Promise는 비동기 연산의 최종 결과나 실패 사유를 나타내는 객체로 정의되어 있습니다. 즉, "언젠가 끝나는 작업"을 손에 쥐는 상자라고 생각하면 제일 편해요.

// 결과가 순서대로 안 나옵니다
console.log("첫째");
setTimeout(() => console.log("둘째"), 0);
console.log("셋째");
// 실제 출력: 첫째 → 셋째 → 둘째

되게 헷갈렸어요. 저도 처음엔 "왜 뒤에 쓴 코드가 먼저 나와요?" 했거든요.

여기서 출발점이 갈립니다. 비동기 작업을 다루는 방식은 크게 세 갈래예요.

방식 코드 길이 에러 처리 읽기 난이도 자주 쓰이는 곳
콜백 길다 콜백 안에서 분기 어려움 옛 코드, 일부 라이브러리
Promise 체이닝 중간 catch로 통합 보통 여러 요청 묶기
async와 await 짧다 try catch 쉬움 대부분 실무 코드

공식 문서를 확인하면 async와 await은 ES2017에서 표준으로 채택됐습니다. 그전까지는 Promise 체이닝만 주된 방식이었죠. 지금은 Node.js 18부터 fetch가 기본 제공되고, 공식 릴리스 노트에서도 안정 기능으로 안내하고 있기 때문에 외부 호출은 이렇게 들어가고 있습니다.

여기서부터가 헷갈림의 중심입니다. async를 붙인 함수는 언제나 Promise를 반환해요.

💡

async 함수는 본문이 끝나도 "값"이 아니라 "Promise"를 돌려줍니다. 그걸 기다리는 쪽에서 await로 받아 쓰는 구조예요. 이 한 줄이 순서 사고의 절반을 잡아줍니다.

조금 더 깊이 들여다보기

Await는 기다리는 동안 그 함수 안의 흐름만 멈춥니다. 이벤트 루프 전체가 멈추는 게 아니라는 점이 중요해요.

async function loadUser() {
  console.log("요청 시작");
  const res = await fetch("/api/user");
  console.log("응답 도착");
  return res.json();
}

fetch가 끝날 때까지 loadUser 안의 코드만 대기합니다. 그동안 다른 요청은 계속 처리되죠.

그리고 에러 처리입니다. async 함수에서 reject가 발생하면 예외가 throw된 것처럼 전파돼요.

async function loadUserSafe() {
  try {
    const res = await fetch("/api/user");
    return await res.json();
  } catch (err) {
    console.error("불러오기 실패", err);
    return null;
  } finally {
    console.log("정리 완료");
  }
}

try catch finally 조합이면 대부분의 케이스에서 깔끔해요. 스택오버플로우 답변에 따르면 forEach 안에서 await를 쓰는 패턴은 실행 제어를 보장하지 않아서, 순차 처리가 필요하면 반복문으로 바꿔야 한다고 정리돼 있습니다.

제가 겪은 사고도 이쪽이더라고요.

// 버그: 순차 대기라 느리고, 일부만 반영됨
const results = [];
items.forEach(async (item) => {
  results.push(await save(item));
});

forEach는 async 콜백의 완료를 기다려주지 않아요. save가 끝나기 전에 results를 읽어버리면 일부만 들어갑니다. 반대로 for...of로 바꾸면 순차 실행이 되고, Promise.all로 감싸면 병렬 실행이 됩니다.

작은 실수였죠. 근데 그 실수가 결제 큐에서 순서가 뒤집힌 사건으로 번졌어요.

⚠️

반복문 안에서 await를 쓸 때, 병렬이 필요한지 순차가 필요한지 먼저 정하세요. 순차라면 for...of, 병렬이라면 Promise.all을 씁니다. 이 선택 하나가 처리 시간을 몇 배로 갈립니다.

실전 적용 사례, 큐 처리 고치기

저희 팀 상황은 이랬습니다. 주문 확정 요청이 들어오면, 재고 차감과 결제 승인, 알림 발송을 순서대로 처리해야 했어요.

원래 코드는 이렇게 생겼습니다.

async function processOrder(order) {
  await reserveStock(order);
  await chargePayment(order);
  await sendNotification(order);
}

reserveStock이 실패하면 뒤는 안 돌아가는 게 의도였어요. 그런데 실제 장애 때는 chargePayment가 이미 성공한 뒤에 reserveStock이 타임아웃되는 경우가 생겼고, 그때마다 재고가 중복 차감됐습니다. 순서가 조작된 건 아니고, 각 단계 내부에서 비동기 호출이 겹쳤던 거예요.

그래서 단계마다 상태를 명시하고 실패 지점을 남기도록 바꿨습니다.

async function processOrder(order) {
  const steps = [
    ["재고 차감", () => reserveStock(order)],
    ["결제 승인", () => chargePayment(order)],
    ["알림 발송", () => sendNotification(order)],
  ];

  for (const [name, run] of steps) {
    try {
      await run();
    } catch (err) {
      await markStepFailed(order, name, err);
      throw err;
    }
  }
}

바뀐 건 두 가지예요. 실패한 단계 이름을 남기고, 전체를 중단한다는 걸 코드에 박아뒀습니다. 그리고 알림 발송처럼 결과가 프로세스 흐름에 영향을 안 주는 단계는 Promise.all로 빼서 지연 시간을 줄였습니다.

결과는 이랬습니다. 중복 차감 건이 사라졌고, 알림 지연도 눈에 띄게 줄었어요. 그리고 무엇보다 새 사람이 들어오면 어디를 고쳐야 하는지 바로 보이더라고요.

일곱 시간은 걸렸어요. 결국 await 한 줄이 순서를 보장하는 건 아니고, 단계 사이에 상태 경계가 필요하다는 걸 배웠습니다.

오늘 배운 것 핵심 정리

돌이켜보면, 비동기 사고는 대부분 세 가지 중 하나였어요.

첫째, 순서를 착각한 경우. setTimeout이나 Promise의 실행 순서를 잘못 짐작해서 생깁니다. 의도가 있다면 await로 흐름을 드러내세요.

둘째, 병렬을 순차로 돌린 경우. forEach 안의 await는 완료를 보장하지 않습니다. for...of와 Promise.all 중 하나를 고르면 돼요.

셋째, 실패 지점을 남기지 않은 경우. try catch finally로 단계별 상태를 기록해 두면, 장애 때 원인이 바로 보입니다.

💡

비동기 처리 정리의 핵심은 결국 "순서"와 "실패 지점" 두 마리입니다. 코드를 좀 더 길게 쓰더라도, 그 둘이 코드에 보이게 만들면 나중에 그 코드를 읽는 사람이 감사합니다.

직접 해볼 게 하나만 있다면, 작은 스크립트에서 setTimeout과 await를 나란히 두고 실행 순서를 비교해 보세요. 순서를 눈으로 본 사람은 대부분 잊지 못합니다.

자주 묻는 질문

Q. Promise.all을 쓰면 무조건 빨라지나요? A. 병렬 요청에는 확실히 유리해요. 다만 하나라도 reject되면 전체가 reject되고, 개별 실패를 구분하려면 Promise.allSettled를 쓰는 게 안전합니다.

Q. forEach 안에서 await를 쓰면 어떻게 고치나요? A. 순차 처리가 필요하면 for...of로 바꿉니다. 병렬이 필요하면 map으로 Promise를 만들어 Promise.all로 감쌉니다. 공통점은 forEach 자체가 await의 완료를 기다려주지 않는다는 겁니다.

Q. async 함수가 항상 값을 바로 반환하나요? A. 아니요. async 함수는 항상 Promise를 반환합니다. 내부에서 return 값을 주면 그 값이 담긴 Promise가, throw하면 reject가 된 Promise가 나가요.

Q. try catch로 async 함수의 에러를 다 잡을 수 있나요? A. 같은 async 함수 안에서 await로 부른 부분은 잡힙니다. 하지만 콜백 안에서 발생한 에러나 await 없이 별도로 호출한 함수의 reject는 밖으로 새어나가므로, 그쪽도 catch를 붙여 주세요.


끝까지 읽어주셔서 고맙습니다. 궁금한 점은 댓글로 알려주세요.

오늘은 여기까지. 도움이 되셨으면 좋겠네요. 다음 글에서는 큐 처리에서 다뤘던 상태 관리 쪽을 더 깊이 파보려고요. 이 글이 조금이라도 도움이 됐다면 다음 글도 기대해 주세요.

#자바스크립트 #비동기처리 #프로미스 #asyncawait #자바스크립트공부

반응형
Posted by no_name
: