2026.06.02·6

JavaScript 비동기 흐름 다시 보기 (02) - async/await는 비동기를 동기로 바꾸지 않습니다

async 함수가 Promise를 반환하고 await가 함수 내부 흐름만 기다린다는 점을 기준으로 async/await의 실제 역할을 정리합니다.

JavaScript 비동기 흐름 다시 보기 (02) - async/await는 비동기를 동기로 바꾸지 않습니다 대표 이미지

async/await는 Promise를 없애는 문법이 아닙니다. Promise 흐름을 더 순서대로 읽히게 쓰는 문법입니다.

들어가며

앞선 글에서는 Promise를 "미래의 값"보다 상태를 가진 비동기 작업으로 보는 기준을 세웠습니다. Promise는 pending에서 시작해 fulfilled 또는 rejected로 한 번 결정됩니다. then, catch, finally는 그 상태 변화에 반응합니다.

이제 자연스럽게 다음 질문으로 넘어갑니다. 그렇다면 async/await는 이 흐름을 어떻게 바꿀까요.

처음 async/await를 보면 비동기 코드가 동기 코드처럼 보입니다. then을 길게 이어 붙이지 않아도 되고, 성공 값을 변수에 담아 다음 줄에서 바로 쓰는 것처럼 읽힙니다.

async function printUserName() {
  const name = await loadUserName();
 
  console.log(name);
}

이런 코드 때문에 async/await를 "비동기를 동기로 바꿔 주는 문법"처럼 이해하기 쉽습니다. 하지만 이 설명은 절반만 맞습니다. 코드가 읽히는 모양은 동기 코드에 가까워지지만, 작업의 성격이 동기로 바뀌는 것은 아닙니다.

Keypoint

async/await는 비동기 작업을 동기 작업으로 바꾸지 않습니다. Promise가 끝난 뒤 이어질 흐름을 더 읽기 쉬운 형태로 쓰게 해 줄 뿐 입니다.

이 차이를 놓치면 async 함수의 반환값, 에러 처리, UI 상태 전환을 잘못 이해하기 쉽습니다.

async 함수는 항상 Promise를 반환합니다

가장 먼저 확인해야 할 점은 async가 붙은 함수의 반환값입니다.

아래 코드는 문자열을 return하는 것처럼 보입니다.

async function getUserName() {
  return "Ada";
}
 
const name = getUserName();
 
console.log(name);

하지만 name"Ada"가 아닙니다. getUserName()의 호출 결과는 Promise<string>입니다. async 함수 안에서 일반 값을 return해도 JavaScript는 그 값을 fulfilled 상태의 Promise로 감싸서 반환합니다.

즉 위 코드는 의미상 아래와 가깝습니다.

function getUserName() {
  return Promise.resolve("Ada");
}

그래서 async 함수는 겉으로 값을 직접 돌려주는 것처럼 보여도, 호출하는 쪽에서는 항상 Promise로 다뤄야 합니다.

일부러 잘못 쓴 예를 보겠습니다.

async function getUserName() {
  return "Ada";
}
 
const name = getUserName();
 
// name은 string이 아니라 Promise<string>입니다.
console.log(name.toUpperCase());

이 코드는 name을 문자열처럼 다루고 있습니다. 하지만 getUserName()은 Promise를 반환하므로, 성공 값을 쓰려면 Promise가 fulfilled가 된 뒤의 흐름으로 들어가야 합니다.

getUserName().then((name) => {
  console.log(name.toUpperCase());
});

또는 다른 async 함수 안에서 await해야 합니다.

async function printUpperName() {
  const name = await getUserName();
 
  console.log(name.toUpperCase());
}

여기서 핵심은 간단합니다. async 함수는 함수 안의 코드를 동기처럼 보이게 만들 수는 있지만, 함수 밖으로 나가는 값은 여전히 Promise입니다.

await는 값을 꺼내는 문법처럼 보입니다

await는 Promise가 fulfilled가 되었을 때 성공 값을 꺼내는 것처럼 동작합니다.

async function loadProfile() {
  const profile = await fetchProfile();
 
  return profile.name;
}

이 코드에서 fetchProfile()이 fulfilled 상태가 되면 profile에는 성공 값이 들어옵니다. 그래서 then 안으로 들어가지 않아도 다음 줄에서 profile.name을 읽을 수 있습니다.

then으로 쓰면 이런 흐름입니다.

function loadProfile() {
  return fetchProfile().then((profile) => {
    return profile.name;
  });
}

async/await는 이 흐름을 더 위에서 아래로 읽히게 바꿉니다. 하지만 Promise를 없앤 것은 아닙니다. await fetchProfile()fetchProfile()이 반환한 Promise가 결정된 뒤, 그 결과를 기준으로 다음 줄을 이어 가게 합니다.

await는 "비동기 작업을 동기 작업으로 바꾼다"보다, "Promise가 끝난 뒤 이 async 함수의 다음 흐름을 이어 간다"에 가깝습니다.

await가 멈추는 것은 전체 프로그램이 아닙니다

async/await를 오해하기 쉬운 가장 큰 이유는 await라는 이름입니다. 기다린다고 하니 JavaScript 전체가 멈추는 것처럼 느껴집니다.

하지만 await가 멈추는 것은 프로그램 전체가 아닙니다. 더 정확히는 해당 async 함수 안에서 다음 줄로 넘어가는 흐름입니다.

async function loadAndRender() {
  showLoading();
 
  const profile = await fetchProfile();
 
  renderProfile(profile);
  hideLoading();
}
 
const task = loadAndRender();
 
console.log(task);

loadAndRender()를 호출하면 task에는 Promise가 들어갑니다. 함수 안에서는 fetchProfile()이 끝날 때까지 renderProfile(profile)로 넘어가지 않습니다. 하지만 함수 밖에서는 호출 결과로 Promise를 받고, 그 Promise를 기준으로 다음 처리를 이어 갈 수 있습니다.

이 지점이 중요합니다. async/await는 코드의 모양을 위에서 아래로 읽히게 만들지만, 호출 경계 밖에서는 여전히 Promise 기반 비동기 흐름입니다.

그래서 async 함수를 호출만 하고 기다리지 않으면, 바깥 코드는 그 작업이 끝났다고 보장할 수 없습니다.

async function savePost() {
  await requestSave();
  console.log("저장이 끝났습니다.");
}
 
savePost();
 
console.log("다음 작업을 시작합니다.");

이 예시에서 정확한 실행 순서까지 깊게 들어가지는 않겠습니다. 그 이야기는 이벤트 루프와 마이크로태스크 큐를 다룰 때 따로 봐야 합니다. 여기서 잡을 기준은 하나입니다. savePost()를 호출했다고 해서 저장이 끝난 값을 바로 손에 쥔 것은 아닙니다. 호출 결과는 Promise이고, 저장 완료 이후의 흐름은 그 Promise가 결정된 뒤 이어집니다.

rejected Promise는 await 지점에서 throw처럼 보입니다

1편에서 Promise는 fulfilled 또는 rejected로 끝난다고 했습니다. await도 이 상태를 그대로 따릅니다.

fulfilled가 되면 성공 값을 꺼냅니다.

async function loadName() {
  const name = await Promise.resolve("Ada");
 
  return name;
}

rejected가 되면 실패 이유를 값처럼 돌려주지 않습니다. 그 지점에서 예외처럼 흐름을 던집니다.

async function loadName() {
  const name = await Promise.reject(
    new Error("사용자 이름을 불러오지 못했습니다."),
  );
 
  return name;
}

이 코드에서 return name은 실행되지 않습니다. await한 Promise가 rejected 상태이기 때문입니다.

그래서 async/await 코드에서는 try/catch가 자주 함께 등장합니다.

async function loadName() {
  try {
    const name = await fetchUserName();
 
    return name;
  } catch (error) {
    return "이름 없음";
  }
}

이 코드는 rejected Promise를 catch에서 처리합니다. 다만 try/catch가 정확히 무엇을 잡고 무엇을 놓치는지는 별도의 주제입니다. 지금은 await가 rejected 상태를 예외처럼 이어 준다는 점만 잡으면 충분합니다.

UI 상태로 보면 then/catch와 같은 흐름입니다

async/await는 UI 상태를 다룰 때 특히 읽기 좋습니다. 하지만 상태 전환 자체는 then/catch/finally와 다르지 않습니다.

type Profile = {
  name: string;
};
 
type ProfileState =
  | { status: "pending" }
  | { status: "fulfilled"; data: Profile }
  | { status: "rejected"; error: Error };
 
let state: ProfileState = { status: "pending" };
 
async function loadProfile() {
  state = { status: "pending" };
 
  try {
    const profile = await fetchProfile();
 
    state = { status: "fulfilled", data: profile };
  } catch (error) {
    state = {
      status: "rejected",
      error: error instanceof Error ? error : new Error("알 수 없는 오류"),
    };
  }
}

이 코드는 async/await를 쓰지만, 실제로는 Promise 상태를 UI 상태로 번역합니다.

  • await fetchProfile()이 아직 끝나지 않았으면 pending 상태를 보여 줍니다.
  • fulfilled가 되면 데이터를 가진 상태로 바꿉니다.
  • rejected가 되면 에러 상태로 바꿉니다.

즉 async/await를 써도 UI가 해야 할 일은 그대로 남습니다. 로딩을 언제 보여 줄지, 성공 데이터를 어디에 둘지, 실패를 어떻게 보여 줄지 결정해야 합니다.

React Query 같은 라이브러리는 이 번역을 더 체계적으로 대신합니다. 하지만 직접 async/await를 쓸 때는 우리가 이 상태 전환을 명시해야 합니다.

순서대로 읽히는 코드가 항상 좋은 코드는 아닙니다

async/await는 코드를 순서대로 읽기 좋게 만듭니다. 하지만 그 장점 때문에 모든 작업을 순서대로 기다리게 만들 수도 있습니다.

예를 들어 두 작업이 서로 의존하지 않는다고 가정해 보겠습니다.

async function loadDashboard() {
  const profile = await fetchProfile();
  const notifications = await fetchNotifications();
 
  return { profile, notifications };
}

이 코드는 읽기 쉽습니다. 하지만 fetchNotifications()profile 결과를 필요로 하지 않는다면, 두 요청을 반드시 순서대로 기다릴 이유가 없습니다.

이럴 때는 두 작업을 먼저 시작하고, 둘 다 끝나기를 기다리는 편이 더 자연스러울 수 있습니다.

async function loadDashboard() {
  const profileTask = fetchProfile();
  const notificationsTask = fetchNotifications();
 
  const [profile, notifications] = await Promise.all([
    profileTask,
    notificationsTask,
  ]);
 
  return { profile, notifications };
}

이 예시는 Promise.all을 깊게 설명하려는 것이 아닙니다. 그건 이 시리즈 뒤에서 별도로 다룰 주제입니다. 여기서는 async/await가 "순서대로 보이게 쓰는 문법"이기 때문에, 실제 작업도 무조건 순서대로 만들어 버릴 수 있다는 점만 보면 됩니다.

Keypoint

async/await는 읽기 쉬운 순서를 만들어 줍니다. 하지만 작업 사이에 의존성이 없다면 코드의 읽기 순서와 작업 시작 순서를 따로 생각해야 합니다.

마치며

async/await는 비동기 작업을 동기 작업으로 바꾸지 않습니다. async 함수는 항상 Promise를 반환하고, await는 그 Promise가 결정된 뒤 async 함수 안의 다음 흐름을 이어 가게 합니다.

fulfilled Promise는 값처럼 이어지고, rejected Promise는 예외처럼 이동합니다. 그래서 async/await 코드는 동기 코드처럼 읽히지만, 실제로는 Promise의 상태 전환 위에서 움직입니다.

이 기준을 잡아 두면 async/await 코드를 볼 때 더 중요한 질문을 할 수 있습니다.

  • 이 async 함수는 어디에서 await되고 있는가?
  • await가 기다리는 Promise는 무엇인가?
  • 실패하면 rejected 흐름이 어디에서 처리되는가?
  • 순서대로 기다릴 필요가 있는 작업인가?

다음 글에서는 이 질문 중 하나를 더 깊게 보겠습니다. Promise는 왜 나중에 이어지는 것처럼 보일까요. 이벤트 루프와 마이크로태스크 큐를 통해, Promise의 다음 흐름이 언제 실행되는지 정리하겠습니다.