← 블로그 목록
스터디

동기와 비동기, 그리고 async/await를 쓰는 두 가지 방식

동기와 비동기가 실제로 무엇을 막고 막지 않는지부터 콜백-Promise-async/await로 이어진 이유, 그리고 async/await를 순차로 쓸 때와 병렬로 쓸 때 성능과 에러 처리가 어떻게 달라지는지 코드 예제와 함께 정리합니다.

동기와 비동기는 정확히 무엇을 가리키는가

동기(synchronous)와 비동기(asynchronous)는 "작업이 끝날 때까지 다음 코드를 기다리게 하는가"의 차이다. 동기 코드는 한 줄이 끝나야 다음 줄로 넘어간다. 비동기 코드는 시간이 걸리는 작업(네트워크 요청, 파일 읽기, 타이머)을 실행만 시켜두고, 결과를 기다리지 않은 채 다음 줄로 바로 넘어간다.

1// 동기: 각 줄이 끝나야 다음 줄이 실행된다
2console.log("1");
3const data = readFileSync("data.txt"); // 파일을 다 읽을 때까지 여기서 멈춘다
4console.log("2");
5 
6// 비동기: 요청만 걸어두고 바로 다음 줄로 넘어간다
7console.log("1");
8readFile("data.txt", (err, data) => console.log(data)); // 콜백은 나중에 실행
9console.log("2");

JavaScript는 싱글 스레드다. 동기 코드가 오래 걸리면 그동안 브라우저는 화면을 다시 그리지 못하고, Node.js 서버는 다른 요청을 처리하지 못한다. 비동기는 이 하나뿐인 스레드를 오래 걸리는 작업이 독점하지 못하게 막는 방법이다. RabbitMQ가 서비스 간 동기 호출을 큐로 끊어내는 이유와 본질적으로 같은 문제의식이다 — 동기 호출은 "기다리는 쪽"을 상대방의 속도에 강하게 묶어버린다.

다이어그램 렌더링 중...

콜백 → Promise → async/await, 왜 이렇게 바뀌었나

비동기 코드를 표현하는 방식은 세 단계를 거쳐 진화했다. 문제는 항상 같았다 — "비동기 결과를 어떻게 순서대로, 에러 처리까지 포함해서 읽기 쉽게 이어붙이는가."

방식예시한계
콜백(callback)readFile(path, (err, data) => {...})비동기가 중첩될수록 콜백 안에 콜백이 쌓이는 "콜백 지옥", 에러 처리가 각 콜백마다 따로 필요
PromisereadFile(path).then(data => ...).catch(err => ...)중첩은 풀리지만 .then() 체이닝이 길어지면 여전히 읽기 번거로움
async/awaitconst data = await readFile(path)Promise를 동기 코드처럼 순서대로 읽고 쓸 수 있게 하는 문법

async/await는 새로운 비동기 모델이 아니라 Promise 위에 얹은 문법(syntactic sugar)이다. await는 Promise가 처리(resolve/reject)될 때까지 해당 async 함수의 실행만 일시 정지시키고, 그 사이 다른 코드(이벤트 루프의 다른 작업)는 계속 실행된다. 스레드를 막는 동기의 blocking과는 다르다.

1// Promise 체이닝
2function getUser(id) {
3 return fetchUser(id)
4 .then((user) => fetchPosts(user.id))
5 .then((posts) => console.log(posts))
6 .catch((err) => console.error(err));
7}
8 
9// 같은 로직을 async/await
10async function getUser(id) {
11 try {
12 const user = await fetchUser(id);
13 const posts = await fetchPosts(user.id);
14 console.log(posts);
15 } catch (err) {
16 console.error(err);
17 }
18}

async/await를 쓰는 두 가지 방식: 순차 실행 vs 병렬 실행

async/await를 배우고 나서 가장 흔하게 하는 실수가 이거다 — 서로 의존하지 않는 비동기 작업까지 습관적으로 한 줄씩 await를 걸어 순차 실행해버리는 것. 두 방식은 코드 생김새는 비슷해도 실행 시간과 에러 처리 방식이 다르다.

1) 순차 실행 (sequential)

1async function loadDashboard() {
2 const user = await fetchUser(); // 1초 대기
3 const orders = await fetchOrders(); // 1초 대기
4 const stats = await fetchStats(); // 1초 대기
5 return { user, orders, stats };
6 // 총 소요 시간 ≈ 3
7}

await가 앞 작업이 끝날 때까지 다음 줄 실행을 멈춘다. ordersuser 값을 필요로 하는 것도 아닌데 굳이 순서대로 기다리기 때문에, 세 요청의 대기 시간이 그대로 합산된다.

2) 병렬 실행 (concurrent, Promise.all)

1async function loadDashboard() {
2 const [user, orders, stats] = await Promise.all([
3 fetchUser(),
4 fetchOrders(),
5 fetchStats(),
6 ]);
7 return { user, orders, stats };
8 // 총 소요 시간 ≈ 1초 (가장 오래 걸리는 요청 기준)
9}

Promise.all은 세 요청을 동시에 시작시켜 놓고, 모두 끝날 때까지 한 번만 기다린다. 서로 의존 관계가 없는 작업이라면 이 방식이 세 배 가까이 빠르다. "작업 B가 작업 A의 결과값을 필요로 하는가"가 순차/병렬을 가르는 기준이다. fetchOrders(user.id)처럼 앞 결과가 필요하면 순차로 갈 수밖에 없고, 그렇지 않으면 병렬로 묶는 게 맞다.

에러 처리도 달라진다

1// Promise.all: 하나라도 reject되면 즉시 전체가 reject (fail-fast)
2try {
3 const [user, orders] = await Promise.all([fetchUser(), fetchOrders()]);
4} catch (err) {
5 // userorders든 먼저 실패한 쪽의 에러만 잡힌다
6}
7 
8// Promise.allSettled: 각각의 성공/실패를 개별적으로 확인
9const results = await Promise.allSettled([fetchUser(), fetchOrders()]);
10results.forEach((r) => {
11 if (r.status === "fulfilled") console.log(r.value);
12 else console.error(r.reason);
13});

Promise.all은 하나만 실패해도 나머지가 성공했는지와 무관하게 즉시 reject된다. 일부가 실패해도 나머지 결과는 살려서 써야 한다면 Promise.allSettled를 써야 한다. 순차 실행에서는 try/catch를 각 await 단위로 세밀하게 걸 수도, 전체를 하나로 묶을 수도 있어서 실패 지점을 더 좁게 특정할 수 있다는 차이도 있다.

반복문 안에서 자주 나오는 함정

1// 잘못된 예: for...of + await → 배열 요소 수만큼 순차 대기
2for (const id of userIds) {
3 const user = await fetchUser(id); // 하나씩 끝나야 다음으로
4 console.log(user);
5}
6 
7// 개선: map으로 Promise 배열을 만들고 한 번에 대기
8const users = await Promise.all(userIds.map((id) => fetchUser(id)));

forEachfor...of 안에 await를 그대로 넣으면 배열 길이만큼 요청이 순차로 쌓인다. 각 요청이 독립적이라면 map + Promise.all 조합으로 동시에 날리는 게 대부분 맞는 선택이다.

정리

  • 동기는 코드가 끝날 때까지 다음 줄을 막고, 비동기는 시간이 걸리는 작업을 걸어두기만 하고 스레드를 계속 다른 일에 쓴다. JavaScript가 싱글 스레드이기 때문에 이 차이가 특히 중요하다.
  • 콜백 → Promise → async/await는 같은 문제("비동기를 순서대로 읽기 쉽게 표현하기")를 풀어온 진화의 흐름이고, async/await는 Promise 위의 문법이다.
  • 같은 async/await라도 의존 관계가 없는 작업을 한 줄씩 await로 순차 실행하면 대기 시간이 합산되고, Promise.all로 묶어 병렬 실행하면 가장 오래 걸리는 작업 시간만큼만 걸린다.
  • 병렬 실행 시 하나라도 실패하면 즉시 전체가 실패하는 Promise.all과, 개별 성공/실패를 모두 보존하는 Promise.allSettled 중 상황에 맞는 쪽을 선택해야 한다.
  • 반복문 안에서 await를 무심코 순차로 쓰고 있지 않은지는 성능 리뷰에서 가장 먼저 확인해볼 만한 지점이다.