헷갈리는 비동기 "잘" 정리하기

최씨·2025년 8월 17일
9

Frontend

목록 보기
12/12
post-thumbnail

⏰ 5분만 투자하면 아래 내용을 알 수 있어요!

☑️ 비동기가 왜 필요한지.

☑️ callback / Promise / async, await 차이

☑️ 상황별 Promise 메서드 활용법


🍀 이 글을 쓰게 된 이유

최근에 Promise 관련 문제를 봤는데, 제대로 답하지 못했습니다.
상당히 기초적인 문제였는데도 말이죠.

변명을 하자면… 최근에는 비동기 코드 작업이 필요하다면 async/await만 써서,
근본이 되는 Promise에 대해 기억이 흐릿해져서 생긴일인 것 같습니다.

그런데, 이런 식으로 동기/비동기 개념이 한때는 명확했다가도 시간이 지나면 흐려지고, 다시 찾아보고 또 잊는 과정을 반복해 온 것 같습니다. 그래서 이번 기회에 제 언어로 정리해 장기 기억으로 남기려 했습니다.

TMI: 이 글을 쓰면서 MDN에 소소하게 문서 기여도 하게 됐습니다 :)


🍀 동기/비동기

✏️ 동기/비동기 개념

  • 동기

    • 하나의 작업이 실행되는 동안은 다른 작업을 수행하지 않는 방식
    • 위 사진에서는 3개의 작업이 총 5초+3초+10초 = 18초간 수행됩니다.
  • 비동기

    • 작업이 종료되기를 기다리지 않고 다음 작업을 병렬적으로 수행하는 방식
    • 위 사진에서는 3개의 작업이 총 10초만에 수행됩니다.

이런 생각이 들 수도 있습니다.

"꼭 비동기가 필요할까?"
"여러개의 작업을 한곳에서 처리하지 않고 하나씩 스레드 여러개에 나누어 분배하면 되지 않을까?"
(스레드: 작업을 수행하는 어떠한 주체)

[ 답변 ]

자바스크립드는 하나의 스레드만 있는 싱글 스레드 언어로, 여러개의 스레드를 사용하는 멀티 스레드 방식 대신에 비동기로 작업을 처리해야 합니다.

정확하게는 제어권이 다른 프로미스 간에 이동하여 프로미스가 동시에 실행되는 것처럼 보이게하는 것입니다. JS에서 병렬실행은 worker threads를 통해서만 가능합니다.

코드 예시도 보겠습니다.

const workA = () => {
	setTimeout(() => {
		console.log('workA');
	}, 5000);
};
const workB = () => {
	setTimeout(() => {
		console.log('workB');
	}, 3000);
};
const workC = () => {
	setTimeout(() => {
		console.log('workC');
	}, 10000);
};

workA(); // 5초
workB(); // 3초
workC(); // 10초

// [결과]
// workB
// workA
// workC
  • 위 코드는 비동기로 실행되었기 때문에 실행에 총 10초가 걸립니다.

음… 근데 시간으로만 예시를 하면 비동기의 필요성이 잘 안와닿을 수 있을 것 같습니다.
실제 웹사이트를 예시로 들어보겠습니다. (처리되는 작업 순서: 이미지 로딩 → 텍스트 로딩)
밑의 사진이 최종적으로 모든 로딩이 완료됐을때의 화면입니다.

  1. 동기적으로 수행되는 웹 페이지

  • 이미지가 로딩될때까지 텍스트와 이미지 모두 나타나지 않습니다.
  • 아무것도 보이지 않는 시간이 길어지면 사용자는 오류라고 인식할 수 있어 UX가 저하됩니다.

  1. 비동기적으로 수행되는 웹 페이지

  • 이미지 로딩보다 시간이 적게 걸리는 작업인 텍스트 로딩이 먼저 수행되어 텍스트가 먼저 나타납니다.

✏️ callback

콜백 함수는 다른 함수의 인자로 전달되어, 특정 시점에 실행되는 함수입니다.

자바스크립트의 비동기 로직은 실행 순서를 예측하기 어렵기 때문에, 순서를 보장해야 하는 작업은 콜백을 사용해 “앞의 작업이 끝난 뒤 다음 작업을 실행”하도록 제어할 수 있습니다.

비동기+논블로킹 함수인 setTimeout를 사용한 간단한 콜백함수 예시를 보겠습니다.

setTimeout(() => {
	console.log('1초 후: 비동기1');
}, 1000);

console.log('종료');

// [결과]
// 종료
// 1초 후: 비동기1
  1. setTimeout 함수가 호출되고, 안의 콜백 함수가 Web APIs에 타이머로 등록되고, “약 1초 뒤에 실행 요청”이 맡겨집니다. (기다리지 않고 다음 줄 진행)

  2. console.log('종료'); 가 바로 실행되어 종료가 출력됩니다

  3. 1초 뒤 타이머가 만료되면, 등록된 콜백이 콜백 큐 로 이동합니다. 콜 스택이 비었으므로 이벤트 루프가 그 콜백을 스택으로 전달해 실행하고, 그 결과로 1초 후: 비동기1이 출력됩니다.


이제 콜백함수 3개가 중첩된 구조를 보겠습니다.

setTimeout(() => {
  console.log('1초 후: 비동기1');

  setTimeout(() => {
    console.log('2초 후: 비동기2');

    setTimeout(() => {
      console.log('3초 후: 비동기3');
    }, 1000);

  }, 1000);

}, 1000);

console.log('종료');

// [결과]
// 종료
// 1초 후: 비동기1
// 2초 후: 비동기2
// 3초 후: 비동기3

순서를 지켜야 하는 로직이 여러 개 겹치면 위와 같은 중첩 구조가 생깁니다.
콜백의 깊이가 깊어질수록 코드는 오른쪽으로 계속 밀려 가독성이 떨어지고, 유지보수도 점점 어려워집니다.

그리고 위 예시는 동일하고 간단한 함수 3개가 중첩된 정직한 구조여서 괜찮아 보일 수 있지만, 실제 업무 속 복잡한 로직에서 콜백 뎁스가 깊어진다면, 이해하기 정말 어려울 것입니다.

이처럼 콜백의 중첩이 심해지는 현상을 흔히 “콜백 지옥(callback hell)”이라고 합니다.
유명한 콜백지옥 짤도 있죠.

“근데, 저정도로 순서가 보장되어야 한다면 그냥 동기로 짜면 되는거 아닌가?” 라는 의문이 들 수도 있습니다.

맞는 말입니다. 불필요하다면 가독성 측면에서 동기 코드로 작성하는 것이 더 낫습니다


하지만 fetch 같은 네트워크 요청은 응답이 올 때까지 동기로 대기하면 UI 렌더링이 멈추기 때문에, 설계상 반드시 비동기로 동작해야 합니다.

또한 여러 API 호출이 있을 때, A의 결과를 받아야만 B를 호출할 수 있는 순차적 상황이라면 콜백을 통해 흐름을 제어해야 합니다.

[ 예시: 내 위치의 날씨 정보 가져오기 ]

  • A: 위치 정보 API 호출 → 위도/경도 좌표 획득
  • B: A를 통해 얻은 좌표를 이용해 날씨 API 호출 → 날씨 정보 획득

이처럼 순차적 로직은 필요하지만, 단순 콜백만 사용하면 “콜백 지옥” 문제가 생깁니다.
이를 개선하기 위해 나온 것이 Promise이며, then() 체인을 통해 콜백 지옥을 완화할 수 있습니다.


✏️ Promise

then() 체인을 통한 개선을 보기 전 Promise의 간단한 개념과 상태부터 보겠습니다.

Promise 객체는 비동기 작업의 완료(fulfilled) 또는 실패(rejected)를 나타내는 객체입니다.
즉, 미래에 완료될 비동기 작업의 결과를 표현하는 일종의 약속입니다.


Promise는 아래의 3가지 상태 중 하나를 가집니다.

  • pending (대기): 아직 이행(fulfilled)하지도, 거부(rejected)하지도 않은 초기 상태
  • fulfilled (이행) : 연산이 성공적으로 완료된 상태 (resolve 호출)
  • rejected (거부) : 연산이 실패한 상태 (reject 호출)

Promise 기본 문법

  1. 생성자와 실행함수(executor)

    • Promise는 new Promise(executor) 형태로 생성합니다.

    • executor 함수는 자동으로 실행되며, resolvereject 콜백을 인수로 받습니다.

  2. then / catch / finally

    • .then(onFulfilled, onRejected)

      • .then() 메서드는 최대 두 개의 인수를 받습니다.
      • 첫 번째 인수 → 프로미스가 이행(fulfilled) 되었을 때 실행되는 콜백 함수
      • 두 번째 인수 → 프로미스가 거부(rejected) 되었을 때 실행되는 콜백 함수 (보통 catch 메서드를 따로 쓰는 경우가 많음)
      • then() 은 항상 새로운 Promise 객체를 반환하기 때문에, 연쇄(chaining)가 가능합니다. → 이 덕분에 비동기 로직을 순차적으로 연결할 수 있습니다
    • .catch(onRejected)

      • 실패만 따로 처리
    • .finally(onFinally)

      • 성공/실패와 상관없이 마지막에 항상 실행

간단한 프로미스 비동기 예시입니다.

const myFirstPromise = new Promise((resolve, reject) => {
  // 수행한 비동기 작업이 성공한 경우 resolve(...)를 호출.
  // 실패한 경우 reject(...)를 호출.
  setTimeout(function () {
    resolve("성공!");
  }, 1000);
});

myFirstPromise.then((successMessage) => {
  // successMessage는 위에서 resolve(...) 호출에 제공한 값.
  console.log("와! " + successMessage);
});
  • Promise를 생성하면서, 그 안에 실행함수(executor)를 같이 선언한 경우입니다.

밑은 실행함수(executor)를 따로 분리후 선언한 케이스입니다.

const successFn = (resolve, reject) => {
	// 수행한 비동기 작업이 성공한 경우 resolve(...)를 호출.
  // 실패한 경우 reject(...)를 호출.
  setTimeout(function () {
    resolve("성공!");
  }, 1000);
}

const myFirstPromise = new Promise(successFn);

myFirstPromise.then((successMessage) => {
  // successMessage는 위에서 resolve(...) 호출에 제공한 값.
  console.log("와! " + successMessage);
});

아래의 순서대로 피자를 시키는 예시에 콜백을 사용하면 다음과 같습니다.

  1. 토핑을 고른다.
  2. 피자를 주문한다
  3. 피자를 받아서 먹는다
chooseToppings(function (toppings) {
  placeOrder(
    toppings,
    function (order) {
      collectOrder(
        order,
        function (pizza) {
          eatPizza(pizza);
        },
        failureCallback,
      );
    },
    failureCallback,
  );
}, failureCallback);

그런데 위 피자 예시를 Promise와 then체인으로 개선하면, 콜백지옥 없이 더 간결해집니다.

chooseToppings()
  .then((toppings) => placeOrder(toppings))
  .then((order) => collectOrder(order))
  .then((pizza) => eatPizza(pizza))
  .catch(failureCallback);
  • then 블록은 이전 Promise의 결과를 전달받아 다음 Promise로 넘깁니다.
  • 가독성이 좋아지고, 단일 catch로 에러를 일괄 처리할 수 있습니다.

간단하게 catch로 전역 에러 핸들링을 하는 경우가 아니라,
특정 단계별로 다른 에러 처리 로직이 필요하다면 then()의 두번째 인자를 사용하면 됩니다.

chooseToppings()
  .then(placeOrder, handleToppingError)
  .then(collectOrder, handleOrderError)
  .then(eatPizza, handlePickupError);

프로미스 동시성

앞에서는 프로미스를 생성하고, then을 통해 순차적으로 작업을 이어가는 방법을 살펴봤습니다.
하지만 실제 상황에서는 여러 비동기 작업을 동시에 실행하고 싶거나, 그 중 일부만 먼저 처리하고 싶을 때가 자주 있습니다.

예를 들어,

  • 상품 상세 페이지에 들어갈 때 상품 정보 / 리뷰 / 추천 상품을 동시에 가져와야 한다면?
  • 여러 이미지 업로드 중 가장 먼저 완료된 한 장만 미리 보여주고 싶다면?

이럴 때 Promise 클래스는 비동기 작업 동시성을 용이하게 하기 위해 4가지 정적 메서드를 제공합니다.

  1. Promise.all()

    • 모든 프로미스가 이행되면 성공하고, 하나라도 거부되면 즉시 실패합니다.

    • ex) 독립적인 여러 API를 동시에 호출하고, 모두의 결과가 필요할 때

  2. Promise.allSettled()

    • 모든 프로미스가 이행 또는 거부로 완료될 때까지 기다린 뒤, 각각의 결과를 반환합니다.

    • ex) 실패 여부와 상관없이 전체 요청의 결과를 모니터링해야 할 때 (예: 로그 수집, 테스트 결과 등)

  3. Promise.any()

    • 프로미스 중 가장 먼저 이행된 하나로 성공하고, 모두 거부되면 실패합니다.

    • ex) 여러 서버/엔드포인트 중 하나만 응답해도 충분한 경우 (예: CDN 다중 요청)

  4. Promise.race()

    • 프로미스 중 가장 먼저 완료된 하나(이행이든 거부든)로 결과가 결정됩니다.

    • ex) 특정 요청에 타임아웃을 걸고 싶을 때


위의 4가지 외에도 다양한 정적 메서드들이 존재합니다. 다만 동시성 관련이 아닌것 뿐이죠.

  • Promise.resolve()

    • 주어진 값으로 이행하는 Promise 객체를 반환합니다. 이때 지정한 값이 then 가능한(then 메서드를 가지는) 값인 경우,
  • Promise.reject()

    • 주어진 사유로 거부하는 Promise 객체를 반환합니다.
  • Promise.try()

    • 반환값이나 예외 발생, 동기나 비동기에 관계 없이 모든 종류의 콜백을 가지고, 그 결과를 Promise 내부에서 감싸는 정적 메서드입니다.
  • Promise.withResolvers()

    • 새로운 Promise 객체와, 그 객체를 이행하거나 거부할 수 있는 두 함수를 포함하는 객체를 반환합니다. 이 두 함수들은 Promise() 생성자의 실행자 함수에 전달되는 매개변수에 해당합니다.

Q. API를 여러개 호출할 때, async await로 계속 map을 돌면서 실행하면 성능 이슈가 발생할 수 있습니다. 이 경우에는 어떻게 병렬로 호출할 수 있을까요?

A. API를 여러 개 호출할 때 async/awaitfor문이나 map 안에서 순차적으로 처리하면, 각 요청이 이전 요청이 끝날 때까지 기다리게 되어 병목이 생길 수 있습니다.
이럴 때는 Promise.all을 사용해 여러 비동기 작업을 병렬로 실행할 수 있습니다.
예를 들어, 여러 URL에 대해 동시에 fetch를 하고 싶다면 map으로 Promise 배열을 만든 뒤 Promise.all에 넘겨주면 됩니다. 이 방식은 모든 요청이 동시에 시작되고, 모든 응답이 도착할 때까지 기다린 후 한 번에 결과를 받을 수 있어 효율적입니다.


✏️ async/await

async/await는 ES2017에 도입된 문법으로, Promise 로직을 더 쉽고 간결하게 사용할 수 있게 해줍니다.

그런데, async/await는 Promise와 별개의 개념이 아니라, 내부적으로는 Promise를 사용하고, 보이는 문법만 더 편하게 만든 것입니다.

그래서 async/await의 상태 역시 Promise와 동일하게 pending/fulfilled/rejected 이 3가지입니다.


async

async는 await를 사용하기 위한 선언문입니다.

즉, 함수 앞에 async를 붙여줘서 함수 내에서 await를 사용할 수 있게 하는것입니다.

async function func1() {
  return 1;
}

const data = func1();
console.log(data); // 프로미스 객체가 반환된다
  • 이렇게 async 키워드를 붙인 함수에서 값을 리턴하면 프로미스 객체형태로 반환됩니다.
  • async function에서는 어떤 값을 리턴하든 무조건 프로미스 객체로 감싸져 반환되는 것입니다.

프로미스 객체에만 then 핸들러를 붙일 수 있다고 오해하는 경우가 있는데,

async 함수의 리턴값은 프로미스 객체이기 때문에 async 함수 자체에 then 핸들러를 붙일 수 있습니다.

async function func1() {
  return 1;
}

func1()
	.then(data => console.log(data));
  • 근데 가능하다는것이지… 이렇게 쓸거면 굳이 async 안쓰겠죠..?

await

await 키워드는 .then() 보다 좀 더 세련되게 비동기 처리의 결과값을 얻을 수 있게 해주는 문법입니다.

밑에서 then이 남발된 코드가 async/await로 개선된 것을 확인할 수 있습니다.

/* Promise Hell */
fetch('https://example.com/api')
  .then(response => response.json())
  .then(data => fetch(`https://example.com/api/${data.id}`))
  .then(response => response.json())
  .then(data => fetch(`https://example.com/api/${data2.id}/details`))
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));
  • 서버에 리소스를 요청하는 fetch() 비동기 함수를 사용했습니다.
  • 매 줄마다 then이 쓰여서 가독성이 좋지 않습니다.
async function getData() {
    const response = await fetch('https://example.com/api');
    const data = await response.json();
    
    const response2 = await fetch(`https://example.com/api/${data.id}`);
    const data2 = await response2.json();
    
    const response3 = await fetch(`https://example.com/api/${data2.id}/details`);
    const data3 = await response3.json();
    
    console.log(data3);
}

getData();
  • await는 Promise 비동기 처리가 완료될때까지 코드 실행을 일시 중지하고 wait 합니다.
  • 비동기 코드를 마치 동기 코드처럼 읽히게 해줘서, 가독성이 좋습니다.

에러처리와 같은 경우는 .catch() 로 처리하지 않고, try/catch 문을 사용합니다

async function func() {
    try {
				...
    } catch (err) {
        // 에러 처리
        console.error(err);
    }
}
  
func();

Q. 밑의 async/await 버전의 코드를 then 사용 버전으로 바꿔보세요.

async function getUserData() {
  const res = await getUser();
  return res.data;
}

A. 답변

function getUserData () { 
  return getData().then(res => res.data);
)
  • await는 프로미스가 끝나면 콜백을 실행하는 then으로 교체
  • await로 받은 결과 res에서 .data 꺼내는 부분은 then 버전의 콜백 res => res.data로 교체
  • async 함수가 자동으로 프로미스를 감싸주듯, then 체인도 프로미스를 반환하므로 async function -> function 교체

[ 주의할 점 ]

비동기 프로그래밍은 웹에서 메인 스레드를 차단하지 않고 시간 소모적인 작업을 병렬적으로 수행할 수 있도록 한 웹 개발의 필수적인 부분입니다.

그러나, await를 불필요하게 남발한다면 성능 문제가 발생할 수 있습니다.

밑의 코드와 같은 경우는 await가 두 번 쓰이는데, 필수적입니다.
왜냐하면, res로 데이터를 다 받아야지만, 그 다음 줄인 data를 초기화할때 res를 사용할 수 있기 때문입니다.

// async/await 방식
async function func() {
    try {
        const res = await fetch(url); // 요청을 기다림
        const data = await res.json(); // 응답을 JSON으로 파싱
        console.log(data);
    } catch (err) {
        console.error(err);
    }

}

func();
  • 위 2개의 await 중 하나라도, 없다면 에러가 뜹니다.

그런데 위 경우처럼 데이터의 연관이 있는 로직이 아니라, 상관없는 로직에도 await를 사용한다면, 불필요한 사용이 됩니다.

예를 들어 getPizza()와 getChicken()이라는 함수가 서로 전혀 연관없는 경우일때 밑의 코드에서의 await는 불필요합니다.

async function getFood(){
  const a = await getPizza(); // 1초 걸리는 로직
  const b = await getChicken(); // 1초 걸리는 로직
  console.log(`${a} and ${b}`);
}

getFood(); // 실행에 총 1+1=2초 걸림
  • b 초기화에 a가 필요없는데, a가 초기화될때까지 b를 초기화하는 로직은 불필요하게 기다렸습니다.

위 코드를 비동기로 데이터를 받아오고, 할당만 await 로 하게 개선하면 밑과 같습니다.

async function getFood() {
  const getPizzaPromise = getPizza(); // 1초 걸리는 로직
  const getChickenPromise = getChicken(); // 1초 걸리는 로직

  const a = await getPizzaPromise ;
  const b = await getChickenPromise ;

  console.log(`${a} and ${b}`);
}

getFood(); // 실행에 총 1초 걸림
  • 데이터를 불러오는건 비동기로 동작하므로, 총 1초만 필요합니다.

Promise.all() 정적 메서드를 사용하는 방법도 있습니다.

async function getFood(){
  // 구조 분해로 각 프로미스 리턴값들을 변수에 담는다.
  let [ a, b ] = await Promise.all([getPizza(), getChicken()]); 
  console.log(`${a} and ${b}`);
}

getFood(); // 실행에 총 1초 걸림
  • Promise.all() 은 배열 인자의 각 프로미스 비동기 함수들이 resolve가 모두 되야 결과를 리턴 받습니다.
  • 배열인자의 각 프로미스 함수들은 제각각 비동기 논블록킹으로 실행되어 시간을 단축 할 수 있습니다.
  • 리턴 값은 각 프로미스 함수의 반환값이 배열로 담겨집니다.

🍀 결론

비동기는 면접이나 실무에서 빠지지 않고 등장하는 단골 주제입니다.

얼추 알고 있다고 생각했는데,
이번에 여러 자료를 보면서, 생각보다 내부적으로 정확히 모르는 내용이 참 많다는 것을 느꼈습니다.
(위 글에 정말 기초이고, 언급 안한 부분들이 정말 많습니다.)

그럼 뭐 공부 해야겠죠 ㅎㅎ

다음에는 동기/비동기 & 블로킹/논블로킹 이것들의 개념차이와 조합별 차이를 다뤄보면 좋을 것 같습니다.

  1. Sync Blocking (동기 + 블로킹)
  2. Async Blocking (비동기 + 블로킹)
  3. Sync Non-Blocking (동기 + 논블로킹)
  4. Async Non-Blocking (비동기 + 논블로킹)

또한, 병렬 실행과 관련된 Worker Threads 주제도 함께 정리하려고 합니다.

긴 글 읽어주셔서 감사합니다 : )


🌐 참고 자료

profile
정답은 없지만, 가까워지려고 노력하고 있습니다 :)

4개의 댓글

comment-user-thumbnail
2025년 8월 25일

참으로 함정에 빠지기 쉬운 개념이죠... JS가 '난해한' 언어인 이유 중 하나이기도 하고...

https://www.maeil-mail.kr/question/214

이런 promise 실행순서 문제도 풀고 하면, 조금 더 JS에 자신감이 붙지 않을까 싶습니다. 아마 bfe.dev(https://bigfrontend.dev) 에도 관련 문제가 있을거에요. 꾸준히 학습하는 모습이 매우 멋지십니다

1개의 답글
comment-user-thumbnail
2025년 8월 29일

비동기동비

1개의 답글