JavaScript의 비동기 동작은 어떻게 이해해야 할까

Frontend

JavaScript를 공부하다 보면 어느 시점부터 코드가 두 갈래로 나뉘어 보이기 시작합니다.

  • 위에서 아래로 바로 실행되는 코드
  • 나중에 결과가 돌아왔을 때 이어서 실행되는 코드

처음에는 이 차이가 단순히 문법 차이처럼 느껴집니다.

  • setTimeout은 비동기
  • fetch도 비동기
  • Promise도 비동기
  • async/await도 비동기

하지만 실제로는 이 모든 것이 같은 질문으로 연결됩니다.

"자바스크립트는 기다려야 하는 작업을 어떻게 다루고, 그 결과를 어떤 방식으로 이어서 실행할까?"

이 글에서는 JavaScript의 비동기 동작을 아래 흐름으로 정리해보겠습니다.

  1. 왜 비동기 처리가 필요한지
  2. callback, Promise, async/await는 각각 무엇을 해결하려고 나왔는지
  3. 순차 실행과 병렬 실행은 어떻게 다르게 읽어야 하는지
  4. 에러 처리는 어디서 자주 꼬이는지
  5. 실무에서 자주 하는 실수는 무엇인지

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • JavaScript는 한 번에 하나의 작업을 실행하지만, 시간이 걸리는 작업은 비동기 방식으로 다룰 수 있습니다.
  • 비동기의 핵심은 "즉시 결과가 없는 작업을 등록하고, 준비가 끝나면 이어서 처리하는 것"입니다.
  • callback은 가장 기본적인 방식이고, Promise는 비동기 결과를 더 구조적으로 다루기 위한 방식입니다.
  • async/awaitPromise를 동기 코드처럼 읽기 쉽게 표현한 문법입니다.
  • 중요한 것은 문법 이름보다 언제 기다리고, 언제 같이 실행하고, 어디서 에러를 잡는가를 정확히 구분하는 것입니다.

즉, 비동기 기본기의 핵심은 "나중에 실행된다"가 아니라 기다림과 이어짐을 코드로 어떻게 표현하는가입니다.

왜 비동기 처리가 필요할까?

웹 애플리케이션은 기다려야 하는 일이 많습니다.

  • 서버 응답을 기다려야 하고
  • 사용자의 입력을 기다려야 하고
  • 파일 읽기 결과를 기다려야 하고
  • 타이머가 끝나길 기다려야 합니다

만약 이런 작업을 전부 동기적으로 처리하려고 하면, 기다리는 동안 다른 코드가 멈춰 있게 됩니다.

예를 들어 네트워크 요청이 끝날 때까지 코드 전체가 멈춘다고 생각해보면:

  • 버튼 클릭도 바로 반응하지 못하고
  • 화면 갱신도 답답해지고
  • 사용자 경험도 급격히 나빠집니다

그래서 자바스크립트는 오래 걸리는 작업을 "지금 당장 끝내는 것"보다, 일단 등록해두고 결과가 준비되면 나중에 이어서 처리하는 방식으로 다룹니다.

이 점이 비동기 동작의 출발점입니다.

비동기란 정확히 무엇일까?

가장 단순하게 말하면 비동기는 결과가 지금 당장 없을 수 있는 작업을 다루는 방식입니다.

예를 들어:

const result = 1 + 2;
console.log(result);

이 코드는 동기적입니다. 1 + 2는 즉시 계산되고 바로 다음 줄에서 쓸 수 있습니다.

반면 서버 요청은 다릅니다.

const response = fetch('/api/user');

이 시점에 실제 데이터가 이미 다 와 있는 것이 아닙니다. 즉, 비동기는 "값" 자체보다 미래에 준비될 결과를 다루는 방식에 가깝습니다.

그래서 비동기 코드를 볼 때는 항상 아래 질문이 중요합니다.

  1. 이 작업의 결과는 지금 바로 있는가?
  2. 없다면 결과가 준비됐을 때 어떤 방식으로 이어서 처리하는가?

가장 기본적인 방식: callback

초기 비동기 코드는 보통 콜백으로 많이 다뤘습니다.

setTimeout(() => {
  console.log('done');
}, 1000);

여기서 핵심은:

  • 타이머 작업을 등록하고
  • 시간이 지난 뒤 실행할 함수를 같이 넘긴다

는 점입니다.

즉, 콜백은 "나중에 실행할 동작을 함수로 전달하는 방식" 입니다.

이 패턴은 꽤 직관적입니다. 하지만 단계가 많아질수록 읽기 어려워집니다.

login(user, password, (userInfo) => {
  fetchProfile(userInfo.id, (profile) => {
    fetchPosts(profile.id, (posts) => {
      render(posts);
    });
  });
});

이런 구조가 깊어질수록:

  • 흐름을 위에서 아래로 읽기 어렵고
  • 에러 처리가 흩어지고
  • 중첩이 늘어나며
  • 유지보수가 어려워집니다

즉, 콜백은 비동기 자체를 가능하게 해주지만, 복잡한 비동기 흐름을 구조적으로 표현하기에는 한계가 있는 방식입니다.

Promise는 무엇을 해결하려고 나왔을까?

Promise는 비동기 작업의 결과를 더 일관된 형태로 다루기 위한 모델입니다.

예를 들어:

fetch('/api/user')
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error(error);
  });

이 코드는 콜백을 쓰긴 하지만, 단순 중첩이 아니라 비동기 결과를 이어 붙이는 체인 형태를 가집니다.

Promise를 이해할 때 중요한 점은 이것이 단순 "비동기 함수"가 아니라, 아직 끝나지 않았거나, 끝났거나, 실패한 비동기 결과를 표현하는 객체라는 점입니다.

즉, Promise는 보통 아래 세 가지 상태로 이해합니다.

  • 대기 중인 상태
  • 성공적으로 완료된 상태
  • 실패한 상태

이 구조 덕분에 비동기 코드는 "콜백을 어디에 전달할까"보다 결과를 어떤 흐름으로 연결할까에 더 집중해서 읽을 수 있게 됩니다.

then, catch, finally는 어떻게 읽어야 할까?

이 부분도 자주 헷갈립니다.

then

성공했을 때 다음 작업을 이어 붙입니다.

fetchUser()
  .then((user) => fetchPosts(user.id))
  .then((posts) => {
    console.log(posts);
  });

여기서 중요한 것은 then 안에서 다시 Promise를 반환하면, 그 다음 then이 그 결과를 이어받는다는 점입니다.

즉, Promise 체인은 단순히 줄을 길게 쓴 것이 아니라, 비동기 결과가 순서대로 연결되는 구조입니다.

catch

실패를 한 곳에서 모아서 다루게 해줍니다.

fetchUser()
  .then((user) => fetchPosts(user.id))
  .then((posts) => render(posts))
  .catch((error) => {
    console.error(error);
  });

콜백 기반 코드보다 에러 흐름을 읽기 쉬운 이유가 여기 있습니다.

finally

성공 여부와 상관없이 마지막에 공통 작업을 넣을 때 씁니다.

setLoading(true);
 
fetchUser()
  .then((user) => render(user))
  .catch((error) => showError(error))
  .finally(() => {
    setLoading(false);
  });

즉, finally는 결과를 바꾸는 자리라기보다 정리 작업을 넣는 자리에 가깝습니다.

async/await는 무엇이 달라질까?

async/await는 비동기를 없애는 기능이 아닙니다. Promise 기반 비동기 흐름을 더 읽기 쉽게 표현하는 문법입니다.

예를 들어:

async function loadUser() {
  try {
    const response = await fetch('/api/user');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

이 코드는 위에서 아래로 읽히기 때문에 직관적입니다.

하지만 중요한 점은:

  • await가 있는 함수는 보통 Promise를 반환하고
  • awaitPromise 결과가 준비될 때까지 함수의 이후 흐름을 잠시 멈췄다가
  • 준비되면 이어서 실행하게 만듭니다

는 점입니다.

즉, async/await는 동기 코드처럼 보이지만 실제로는 Promise 위에서 동작하는 비동기 표현 방식입니다.

Promise 체인과 async/await 중 무엇이 더 좋을까?

둘 중 하나가 무조건 더 좋다고 보기는 어렵습니다. 중요한 것은 흐름에 맞게 선택하는 것입니다.

Promise 체인이 잘 맞는 경우

  • 짧은 변환 흐름을 이어 붙일 때
  • 함수형 스타일로 결과를 합성할 때
  • 간단한 후속 처리만 필요한 경우

async/await가 잘 맞는 경우

  • 순차 실행 흐름이 긴 경우
  • 중간 변수를 이름 붙여 읽고 싶을 때
  • try/catch로 에러 흐름을 명확히 보고 싶을 때

실무에서는 대체로 async/await가 더 읽기 쉽다고 느껴지는 경우가 많습니다. 하지만 Promise 체인을 완전히 대체하는 것이 아니라, 같은 기반을 다른 형태로 쓰는 것에 가깝습니다.

순차 실행과 병렬 실행은 완전히 다르다

비동기 코드를 읽을 때 가장 자주 놓치는 부분 중 하나입니다.

아래 코드는 순차 실행입니다.

const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(user.id);

여기서는:

  1. fetchUser()를 기다리고
  2. 그 결과가 와야 fetchPosts()를 시작하고
  3. 다시 그 다음 줄로 넘어갑니다

이 흐름이 필요할 때도 많습니다. 앞 결과가 뒤 작업의 입력이기 때문입니다.

하지만 아래는 다릅니다.

const [user, notifications, settings] = await Promise.all([
  fetchUser(),
  fetchNotifications(),
  fetchSettings(),
]);

이 경우는 서로 독립적인 비동기 작업을 같이 시작하고 같이 기다리는 방식입니다.

즉, 비동기 코드를 빠르게 읽으려면 항상:

  • 앞 작업이 뒤 작업의 입력인가?
  • 아니면 서로 독립적인가?

를 먼저 봐야 합니다.

독립적인 작업을 무조건 await로 한 줄씩 세우면 불필요하게 느려질 수 있습니다.

Promise.all만 알면 충분할까?

실무에서는 여러 비동기 작업을 묶어 다루는 경우가 많습니다. 이때 대표적으로 아래 메서드들이 자주 보입니다.

Promise.all

모든 작업이 성공해야 전체가 성공으로 끝납니다.

const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);

하나라도 실패하면 전체가 실패합니다.

Promise.allSettled

성공과 실패를 모두 개별 결과로 받고 싶을 때 씁니다.

const results = await Promise.allSettled([fetchUser(), fetchPosts(), fetchNotifications()]);

즉, 일부 실패를 허용하면서 전체 상태를 보고 싶을 때 유용합니다.

Promise.race

가장 먼저 끝나는 결과 하나를 기준으로 처리하고 싶을 때 씁니다.

const result = await Promise.race([fetchUser(), timeoutPromise()]);

예를 들어 타임아웃 처리 감각과 자주 연결됩니다.

즉, 여러 Promise를 묶는 메서드는 전부 "같이 기다린다"가 아니라, 어떤 성공 조건과 종료 조건을 원하느냐에 따라 골라야 합니다.

에러 처리는 어디서 자주 꼬일까?

비동기에서 흔한 실수는 성공 흐름은 보는데 실패 흐름을 놓치는 것입니다.

예를 들어:

async function loadData() {
  const user = await fetchUser();
  const posts = await fetchPosts(user.id);
  return posts;
}

이 코드는 겉보기엔 문제없어 보이지만, 실패 시 어떻게 처리할지 호출하는 쪽에서 명확히 정하지 않으면 에러 흐름이 흐릿해질 수 있습니다.

아래처럼 명시하는 편이 더 좋을 때가 많습니다.

async function loadData() {
  try {
    const user = await fetchUser();
    const posts = await fetchPosts(user.id);
    return posts;
  } catch (error) {
    logError(error);
    throw error;
  }
}

여기서 중요한 것은 "잡았는가"보다 잡고 끝낼지, 다시 던질지를 의식하는 것입니다.

즉:

  • 에러를 여기서 사용자 메시지로 바꿀 것인가
  • 상위 호출자까지 전달할 것인가
  • 로그만 남기고 삼킬 것인가

를 명확히 해야 비동기 흐름이 덜 꼬입니다.

await를 썼다고 항상 좋은 것은 아니다

async/await는 읽기 쉽지만, 무심코 쓰면 흐름을 느리게 만들거나 구조를 흐리게 할 수 있습니다.

예를 들어:

const user = await fetchUser();
const notifications = await fetchNotifications();
const settings = await fetchSettings();

이 세 작업이 서로 독립적이라면, 위 코드는 필요 이상으로 순차적입니다.

이럴 때는:

const [user, notifications, settings] = await Promise.all([
  fetchUser(),
  fetchNotifications(),
  fetchSettings(),
]);

처럼 쓰는 편이 더 자연스럽습니다.

즉, await는 "읽기 편한 비동기"를 만들어주지만, 실행 전략까지 자동으로 최적화해주지는 않습니다.

자주 하는 실수

정리하면 아래 실수가 정말 자주 나옵니다.

  • async/await가 비동기를 동기로 바꾼다고 생각한다
  • 독립적인 작업까지 순차적으로 await한다
  • Promise를 반환했는데 호출하는 쪽에서 기다리지 않는다
  • catchtry/catch 없이 실패 흐름을 놓친다
  • Promise.all은 무조건 빠르고 좋다고 생각한다
  • 콜백, Promise, async/await를 섞어 쓰면서 흐름을 더 복잡하게 만든다

즉, 비동기 코드는 문법이 어려운 것이 아니라, 실행 순서와 실패 흐름을 명시적으로 설계해야 해서 어렵게 느껴지는 경우가 많습니다.

비동기 코드를 읽을 때 체크리스트

실제로 코드를 읽을 때는 아래 질문으로 빠르게 점검하면 도움이 됩니다.

  1. 이 작업은 결과가 지금 바로 있는가, 아니면 나중에 오는가?
  2. 이 코드는 callback, Promise, async/await 중 어떤 방식으로 이어지는가?
  3. 앞 작업 결과가 뒤 작업의 입력인가, 아니면 병렬로 실행 가능한가?
  4. 에러는 어디서 잡고, 어디까지 전파되는가?
  5. 공통 정리 작업은 finally 같은 곳에 분리돼 있는가?

이 기준으로 보면 비동기 코드는 단순히 "복잡한 문법"이 아니라, 기다림과 이어짐을 구조화하는 코드로 보이기 시작합니다.

같이 보면 좋은 글

결론

JavaScript의 비동기 동작은 단순히 "나중에 실행되는 코드"가 아니라, 지금 바로 끝나지 않는 작업을 어떤 방식으로 표현하고, 어떤 순서로 이어 붙이며, 실패를 어디서 처리할 것인가를 설계하는 문제입니다.

짧게 정리하면:

  • callback은 가장 기본적인 비동기 표현 방식이고
  • Promise는 비동기 결과를 구조적으로 연결하게 해주며
  • async/await는 그 흐름을 더 읽기 쉽게 만들어줍니다

결국 비동기 기본기를 잘 이해한다는 것은 문법 몇 개를 아는 것이 아니라, 순차 실행과 병렬 실행을 구분하고, 성공과 실패 흐름을 함께 설계할 수 있는 상태에 가까습니다.