Menu

자바스크립트 async/await 완벽 정리: 비동기 코드를 동기처럼

자바스크립트 async/await의 동작 원리를 제대로 파헤쳐 봅니다. async 함수, await 키워드, try/catch 에러 처리, 그리고 Promise.all로 병렬 실행까지 한 번에 정리했습니다.

async/await는 결국 Promise를 예쁘게 포장한 것

async/await는 새로운 비동기 모델이 아니다. 이미 있던 Promise 위에 얹은 문법 설탕(syntactic sugar)일 뿐이고, 덕분에 비동기 코드를 마치 위에서 아래로 쭉 읽히는 동기 코드처럼 쓸 수 있다. 동작하는 엔진은 똑같고, 겉모습만 훨씬 부드러워진 셈이다.

같은 작업을 두 가지 방식으로 써보면 이렇다:

index.js
Output
Click Run to see the output here.

두 함수 모두 Promise를 반환하고, 하는 일도 완전히 똑같습니다. 다만 async 버전은 .then 체인 없이 위에서 아래로 순서대로 읽을 수 있다는 것 — 바로 이 점이 async/await의 핵심 매력이죠.

async 키워드는 함수가 Promise를 반환하도록 만든다

function이든 화살표 함수든 메서드든, 앞에 async를 붙이는 순간 두 가지 일이 일어납니다.

  1. 그 함수는 항상 Promise를 반환합니다. return으로 돌려준 값이 그대로 Promise의 resolve 값이 됩니다.
  2. 함수 내부에서 await 키워드를 사용할 수 있게 됩니다.
index.js
Output
Click Run to see the output here.

여기서 result는 문자열 자체가 아니라 문자열로 resolve되는 Promise라는 점에 주목하세요. greet 함수 안에 await도 없고 비동기 작업도 전혀 없지만, async 키워드가 붙으면 반환값은 자동으로 Promise로 감싸집니다. 함수 내부에서 예외가 던져지면 그 Promise는 reject 상태가 됩니다.

await 키워드: Promise가 끝날 때까지 기다리기

async 함수 안에서 await somePromise를 쓰면, 해당 Promise가 resolve될 때까지 함수 실행이 멈췄다가 resolve된 값을 돌려받습니다. 반대로 Promise가 reject되면 await는 에러를 던집니다.

index.js
Output
Click Run to see the output here.

출력 순서를 잘 보세요. "카운트다운 시작""2"보다 먼저 찍힙니다. 이유는 간단합니다. await는 해당 async 함수만 멈출 뿐, 프로그램 전체를 멈추지는 않기 때문이죠. 이벤트 루프는 계속 돌아가고, countdown은 각 wait 프로미스가 resolve될 때마다 나중에 이어서 실행됩니다.

await 뒤에는 프로미스처럼 동작하는 값이면 뭐든 올 수 있습니다. await 42도 문법적으로 문제없어요. 프로미스가 아닌 값은 Promise.resolve(42)로 감싸져서 바로 resolve됩니다.

try/catch로 async await 에러 처리하기

일반 프로미스에서는 .catch()를 체이닝해서 에러를 잡죠. async/await에서는 reject된 프로미스가 그냥 던져진 예외처럼 동작하기 때문에, 평소 쓰던 방식 그대로 try/catch로 잡을 수 있습니다:

index.js
Output
Click Run to see the output here.

하나의 try/catch로 그 안에 있는 모든 await를 한꺼번에 잡을 수 있습니다. 네트워크 오류든, JSON 파싱 에러든, 직접 던진 throw든 전부 같은 catch 블록으로 떨어지죠. 중첩된 .then/.catch 체인에 비하면 확실히 체감되는 개선입니다.

한 가지 주의할 점: fetch는 네트워크 오류일 때만 reject되고, HTTP 4xx/5xx 응답에는 reject하지 않습니다. 그래서 res.ok를 직접 확인한 뒤 에러를 던져야 하는데, 실무 코드에서 정말 자주 보게 되는 패턴입니다.

반복문 안에서 불필요하게 await 하지 않기

이건 async/await를 쓸 때 가장 흔히 빠지는 함정입니다. 반복문 안에서 await를 순차적으로 걸면, 매 반복마다 앞 작업이 끝날 때까지 기다리게 됩니다:

index.js
Output
Click Run to see the output here.

sequential은 대략 900ms, parallel은 대략 300ms 정도 걸린다. 경험상 규칙은 이렇다. 작업들이 서로의 결과에 의존하지 않는다면 전부 먼저 시작해 두고 Promise.all로 한꺼번에 await하자. 하나씩 await해야 하는 경우는 다음 호출이 이전 결과를 실제로 필요로 할 때뿐이다.

배열이나 리스트를 다룰 때는 Promise.all(items.map(async (x) => ...)) 패턴이 정석이다. for...of 안에서 await를 쓰면 순차 실행이 되는데, 순서 보장이나 요청 제한(rate limit)처럼 의도적으로 그렇게 하고 싶을 때도 있지만 대부분은 원하지 않는 동작이다.

async/await와 일반 Promise 섞어 쓰기

둘 중 하나만 골라야 하는 건 아니다. async 함수는 결국 Promise를 반환하고, await는 어떤 Promise에도 동작하기 때문에 자유롭게 섞어 쓸 수 있다.

index.js
Output
Click Run to see the output here.

두 스타일은 서로 바꿔 써도 무방합니다. 코드를 위에서 아래로 읽는 흐름이 자연스러울 땐 await를, 간단하게 한 번만 처리하거나 async 컨텍스트 밖에서 작업할 땐 .then을 쓰면 됩니다.

최상위 레벨 await (ES 모듈에서)

예전에는 await를 스크립트 최상위에서 바로 쓸 수 없어서, 반드시 async 함수로 감싸야 했습니다. 하지만 이제는 달라졌습니다. ES 모듈 환경(.mjs 파일이나 <script type="module">) 안에서는 최상위 레벨에서 곧바로 await를 사용할 수 있습니다.

// ES 모듈 내부
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user = await res.json();
console.log(user.name);

최상위 await를 사용하면 기다리는 프로미스가 완료될 때까지 모듈의 실행이 미뤄지고, 그 모듈을 import하는 쪽도 덩달아 기다리게 됩니다. 설정 파일을 불러오거나 동적 import를 할 때 유용하지만 남용은 금물입니다. 최상위 await가 느리면 그 모듈을 import하는 모든 곳이 함께 멈춰버리거든요.

CommonJS 파일이나 일반 인라인 스크립트에서는 여전히 SyntaxError가 납니다. 이럴 때 자주 쓰는 방법은 즉시 실행 async 함수(IIFE)입니다:

index.js
Output
Click Run to see the output here.

자주 하는 실수들

async/await를 쓰다 보면 누구나 한 번쯤 걸려 넘어지는 함정들이 있습니다. 대표적인 것들만 정리해봤어요.

  • async를 빼먹는 경우. 일반 함수 안에서 await를 쓰면 바로 문법 에러가 납니다. 해결 방법은 함수 앞에 async를 붙이거나, 비동기 함수를 .then으로 호출하는 거예요.
  • 결과에 await를 안 붙이는 경우. const data = getJSON(url);처럼 쓰면 data에는 실제 값이 아니라 프로미스 객체가 들어갑니다. 이걸 값인 양 쓰면 출력에 [object Promise]가 찍히는 걸 보게 되죠.
  • 처리되지 않은 rejection. doWork();처럼 비동기 함수를 던져놓고 잊어버리면, 에러가 발생해도 조용히 묻혀버립니다. .catch를 붙이거나 try/catch 안에서 await로 호출하세요.
  • forEach에 async 콜백 쓰기. array.forEach(async (x) => await something(x))는 아무것도 기다려주지 않습니다. forEach는 콜백이 반환하는 프로미스를 그냥 무시하거든요. 대신 for...of + await 조합을 쓰거나 Promise.all(array.map(...))을 활용하세요.
index.js
Output
Click Run to see the output here.

실행해 보면 broken은 기다리지 않고 바로 반환되기 때문에 "완료"이 찍히기 전에 "끝났나요?"가 먼저 출력됩니다. 반면 fixed는 모든 작업이 끝날 때까지 기다렸다가 마지막에 "끝났습니다!"를 출력하죠.

async/await는 언제 쓰면 좋을까

비동기 작업을 여러 단계로 순서대로 처리해야 하거나 try/catch로 에러를 잡아야 하는 코드라면, 기본값은 async/await라고 생각해도 됩니다. 반대로 한 줄짜리 간단한 처리, 내부에서 await할 필요 없이 Promise를 그대로 반환하는 라이브러리 코드, 또는 Promise.race나 체이닝 끝의 .finally() 같은 조합자가 꼭 필요한 경우에는 그냥 Promise를 쓰는 편이 낫습니다.

잘만 쓰면 async/await로 작성한 비동기 코드는 요리 레시피처럼 읽힙니다. 이거 하고, 저거 하고, 그다음 이거. 이벤트 루프는 여전히 뒤에서 제 할 일을 하지만, 적어도 개발자가 콜백 구조에 머리를 쥐어짤 일은 없어지는 거죠.

다음 주제: fetch API

지금까지 예제에서는 "뭔가 비동기 작업"을 대신할 소재로 fetch를 계속 써왔습니다. 이제 한 번 제대로 짚어볼 차례입니다. 요청과 응답이 어떻게 동작하는지, JSON은 어떻게 다루는지, 헤더는 어떻게 설정하는지, 그리고 왜 fetch가 HTTP 에러 상태 코드에서는 reject하지 않는지 — 다음 페이지에서 자세히 살펴봅니다.

자주 묻는 질문

자바스크립트에서 async/await는 어떤 역할을 하나요?

async/await는 프로미스를 좀 더 읽기 쉽게 다루기 위한 문법입니다. 비동기 코드를 마치 동기 코드처럼 위에서 아래로 쭉 읽히도록 써줍니다. 함수 앞에 async를 붙이면 그 함수는 자동으로 프로미스를 반환하고, 함수 안에서 await를 쓰면 해당 프로미스가 처리될 때까지 기다렸다가 결과값을 돌려줍니다. 내부적으로는 여전히 프로미스가 돌아가고 있는 거고, 겉모습만 깔끔해진 셈이죠.

async 함수 밖에서도 await를 쓸 수 있나요?

ES 모듈의 최상위 스코프에서는 가능합니다. 이걸 top-level await라고 부릅니다. 하지만 일반 함수 내부나 CommonJS 스크립트에서는 async 함수 밖에서 await를 쓰면 문법 오류가 납니다. 해결 방법은 두 가지인데요. 코드를 async 함수로 감싸서 호출하거나, 파일을 ES 모듈로 바꾸면 됩니다.

async/await에서 에러 처리는 어떻게 하나요?

await로 기다리는 호출을 try/catch로 감싸면 됩니다. await한 프로미스가 reject되면 곧바로 예외가 던져지기 때문에 catch 블록에서 잡아낼 수 있습니다. 반대로 await 없이 백그라운드에서 실행하는 작업이라면, 반환되는 프로미스에 .catch()를 꼭 붙여주세요. 안 그러면 처리되지 않은 rejection이 발생합니다.

await를 걸면 프로그램 전체가 멈추나요?

아닙니다. await는 해당 async 함수의 실행만 잠시 멈출 뿐, 이벤트 루프는 계속 돌아갑니다. 타이머도 동작하고, 다른 비동기 작업도 진행되고, UI도 멈추지 않습니다. 호출한 쪽에서는 pending 상태의 프로미스를 바로 돌려받고 자기 할 일을 계속하는 거죠.

Coddy로 코딩 배우기

시작하기