⏰ 5분만 투자하면 아래 내용을 알 수 있어요!
☑️ 비동기가 왜 필요한지.
☑️ callback / Promise / async, await 차이
☑️ 상황별 Promise 메서드 활용법
최근에 Promise 관련 문제를 봤는데, 제대로 답하지 못했습니다.
상당히 기초적인 문제였는데도 말이죠.
변명을 하자면… 최근에는 비동기 코드 작업이 필요하다면 async/await만 써서,
근본이 되는 Promise에 대해 기억이 흐릿해져서 생긴일인 것 같습니다.
그런데, 이런 식으로 동기/비동기 개념이 한때는 명확했다가도 시간이 지나면 흐려지고, 다시 찾아보고 또 잊는 과정을 반복해 온 것 같습니다. 그래서 이번 기회에 제 언어로 정리해 장기 기억으로 남기려 했습니다.
TMI: 이 글을 쓰면서 MDN에 소소하게 문서 기여도 하게 됐습니다 :)
동기
비동기
이런 생각이 들 수도 있습니다.
"꼭 비동기가 필요할까?"
"여러개의 작업을 한곳에서 처리하지 않고 하나씩 스레드 여러개에 나누어 분배하면 되지 않을까?"
(스레드: 작업을 수행하는 어떠한 주체)
[ 답변 ]
자바스크립드는 하나의 스레드만 있는 싱글 스레드 언어로, 여러개의 스레드를 사용하는 멀티 스레드 방식 대신에 비동기로 작업을 처리해야 합니다.
정확하게는 제어권이 다른 프로미스 간에 이동하여 프로미스가 동시에 실행되는 것처럼 보이게하는 것입니다. 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
음… 근데 시간으로만 예시를 하면 비동기의 필요성이 잘 안와닿을 수 있을 것 같습니다.
실제 웹사이트를 예시로 들어보겠습니다. (처리되는 작업 순서: 이미지 로딩 → 텍스트 로딩)
밑의 사진이 최종적으로 모든 로딩이 완료됐을때의 화면입니다.
콜백 함수는 다른 함수의 인자로 전달되어, 특정 시점에 실행되는 함수입니다.
자바스크립트의 비동기 로직은 실행 순서를 예측하기 어렵기 때문에, 순서를 보장해야 하는 작업은 콜백을 사용해 “앞의 작업이 끝난 뒤 다음 작업을 실행”하도록 제어할 수 있습니다.
비동기+논블로킹 함수인 setTimeout를 사용한 간단한 콜백함수 예시를 보겠습니다.
setTimeout(() => {
console.log('1초 후: 비동기1');
}, 1000);
console.log('종료');
// [결과]
// 종료
// 1초 후: 비동기1
setTimeout 함수가 호출되고, 안의 콜백 함수가 Web APIs에 타이머로 등록되고, “약 1초 뒤에 실행 요청”이 맡겨집니다. (기다리지 않고 다음 줄 진행)
console.log('종료'); 가 바로 실행되어 종료
가 출력됩니다
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를 호출할 수 있는 순차적 상황이라면 콜백을 통해 흐름을 제어해야 합니다.
[ 예시: 내 위치의 날씨 정보 가져오기 ]
이처럼 순차적 로직은 필요하지만, 단순 콜백만 사용하면 “콜백 지옥” 문제가 생깁니다.
이를 개선하기 위해 나온 것이 Promise이며, then()
체인을 통해 콜백 지옥을 완화할 수 있습니다.
then()
체인을 통한 개선을 보기 전 Promise의 간단한 개념과 상태부터 보겠습니다.
Promise 객체는 비동기 작업의 완료(fulfilled) 또는 실패(rejected)를 나타내는 객체입니다.
즉, 미래에 완료될 비동기 작업의 결과를 표현하는 일종의 약속입니다.
Promise는 아래의 3가지 상태 중 하나를 가집니다.
Promise 기본 문법
생성자와 실행함수(executor)
Promise는 new Promise(executor)
형태로 생성합니다.
executor 함수는 자동으로 실행되며, resolve
와 reject
콜백을 인수로 받습니다.
then / catch / finally
.then(onFulfilled, onRejected)
.then()
메서드는 최대 두 개의 인수를 받습니다.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);
});
밑은 실행함수(executor)를 따로 분리후 선언한 케이스입니다.
const successFn = (resolve, reject) => {
// 수행한 비동기 작업이 성공한 경우 resolve(...)를 호출.
// 실패한 경우 reject(...)를 호출.
setTimeout(function () {
resolve("성공!");
}, 1000);
}
const myFirstPromise = new Promise(successFn);
myFirstPromise.then((successMessage) => {
// successMessage는 위에서 resolve(...) 호출에 제공한 값.
console.log("와! " + successMessage);
});
아래의 순서대로 피자를 시키는 예시에 콜백을 사용하면 다음과 같습니다.
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가지 정적 메서드를 제공합니다.
Promise.all()
모든 프로미스가 이행되면 성공하고, 하나라도 거부되면 즉시 실패합니다.
ex) 독립적인 여러 API를 동시에 호출하고, 모두의 결과가 필요할 때
Promise.allSettled()
모든 프로미스가 이행 또는 거부로 완료될 때까지 기다린 뒤, 각각의 결과를 반환합니다.
ex) 실패 여부와 상관없이 전체 요청의 결과를 모니터링해야 할 때 (예: 로그 수집, 테스트 결과 등)
Promise.any()
프로미스 중 가장 먼저 이행된 하나로 성공하고, 모두 거부되면 실패합니다.
ex) 여러 서버/엔드포인트 중 하나만 응답해도 충분한 경우 (예: CDN 다중 요청)
Promise.race()
프로미스 중 가장 먼저 완료된 하나(이행이든 거부든)로 결과가 결정됩니다.
ex) 특정 요청에 타임아웃을 걸고 싶을 때
위의 4가지 외에도 다양한 정적 메서드들이 존재합니다. 다만 동시성 관련이 아닌것 뿐이죠.
Promise.resolve()
Promise.reject()
Promise.try()
Promise.withResolvers()
Q. API를 여러개 호출할 때, async await로 계속 map을 돌면서 실행하면 성능 이슈가 발생할 수 있습니다. 이 경우에는 어떻게 병렬로 호출할 수 있을까요?
A. API를 여러 개 호출할 때
async/await
를for
문이나map
안에서 순차적으로 처리하면, 각 요청이 이전 요청이 끝날 때까지 기다리게 되어 병목이 생길 수 있습니다.
이럴 때는Promise.all
을 사용해 여러 비동기 작업을 병렬로 실행할 수 있습니다.
예를 들어, 여러 URL에 대해 동시에 fetch를 하고 싶다면map
으로 Promise 배열을 만든 뒤Promise.all
에 넘겨주면 됩니다. 이 방식은 모든 요청이 동시에 시작되고, 모든 응답이 도착할 때까지 기다린 후 한 번에 결과를 받을 수 있어 효율적입니다.
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); // 프로미스 객체가 반환된다
프로미스 객체에만 then 핸들러를 붙일 수 있다고 오해하는 경우가 있는데,
async 함수의 리턴값은 프로미스 객체이기 때문에 async 함수 자체에 then 핸들러를 붙일 수 있습니다.
async function func1() {
return 1;
}
func1()
.then(data => console.log(data));
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));
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();
에러처리와 같은 경우는 .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를 불필요하게 남발한다면 성능 문제가 발생할 수 있습니다.
밑의 코드와 같은 경우는 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();
그런데 위 경우처럼 데이터의 연관이 있는 로직이 아니라, 상관없는 로직에도 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초 걸림
위 코드를 비동기로 데이터를 받아오고, 할당만 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초 걸림
Promise.all() 정적 메서드를 사용하는 방법도 있습니다.
async function getFood(){
// 구조 분해로 각 프로미스 리턴값들을 변수에 담는다.
let [ a, b ] = await Promise.all([getPizza(), getChicken()]);
console.log(`${a} and ${b}`);
}
getFood(); // 실행에 총 1초 걸림
비동기는 면접이나 실무에서 빠지지 않고 등장하는 단골 주제입니다.
얼추 알고 있다고 생각했는데,
이번에 여러 자료를 보면서, 생각보다 내부적으로 정확히 모르는 내용이 참 많다는 것을 느꼈습니다.
(위 글에 정말 기초이고, 언급 안한 부분들이 정말 많습니다.)
그럼 뭐 공부 해야겠죠 ㅎㅎ
다음에는 동기/비동기 & 블로킹/논블로킹 이것들의 개념차이와 조합별 차이를 다뤄보면 좋을 것 같습니다.
또한, 병렬 실행과 관련된 Worker Threads 주제도 함께 정리하려고 합니다.
긴 글 읽어주셔서 감사합니다 : )
🌐 참고 자료
참으로 함정에 빠지기 쉬운 개념이죠... JS가 '난해한' 언어인 이유 중 하나이기도 하고...
https://www.maeil-mail.kr/question/214
이런 promise 실행순서 문제도 풀고 하면, 조금 더 JS에 자신감이 붙지 않을까 싶습니다. 아마 bfe.dev(https://bigfrontend.dev) 에도 관련 문제가 있을거에요. 꾸준히 학습하는 모습이 매우 멋지십니다