Javascript 비동기 프로그래밍의 핵심 개념인 Promise, async/await 에 대해 정리해보고자 합니다. 설명에 앞서 먼저 동기와 비동기에 대해 간단히 정리하자면, 동기 처리(Synchronous)란 코드가 작성된 순서로 실행되며, 각 작업이 완료될 때까지 다음 작업이 기다리는 방식을 의미합니다. 반대로 비동기 처리(Asynchronous)란 현재의 작업 완료를 기다리지 않고 다음 작업을 실행하는 방식입니다.
좀 더 쉽게 표현하면, 동기 처리는 줄 서서 기다리기, 비동기 처리는 주문하고 진동벨 받기에 비유할 수 있습니다.
그렇다면, Javascript 에서 비동기 처리를 돕는 Promise, async/await는 왜 만들어진 것일까요?
복잡한 비동기 코드를 더 쉽고 깔끔하게
비동기 처리는 대기 시간동안 다른 작업이 가능하다는 점에서 사용자의 대기 시간을 줄이고 사용자 경험을 향상시킵니다.
하지만, 코드 실행 순서를 예측하기 어려울 수 있고, 콜백 지옥 등과 같은 복잡한 코드가 생길 수 있다는 단점이 있죠.
예시 코드를 들어 설명해보겠습니다.
일단 대표적인 비동기 처리 메서드인 setTimeout 을 실행하는 workA, workB, workC 함수를 정의하고,
const workA = (value, callback) => {
setTimeout(() => {
callback(value + 5);
}, 5000); // 5초 설정
};
const workB = (value, callback) => {
setTimeout(() => {
callback(value - 3);
}, 3000); // 3초 설정
};
const workC = (value, callback) => {
setTimeout(() => {
callback(value + 10);
}, 10000); // 10초 설정
};
이 코드들을 A,B,C 순으로 순차적으로 실행하기 위해 아래와 같이 코드를 작성해보겠습니다.
workA(10, (resA) => {
console.log(`1. ${resA}`);
workB(resA, (resB) => {
console.log(`2. ${resB}`);
workC(resB, (resC) => {
console.log(`3. ${resC}`);
});
});
});
결과
workA, workB, workC를 순차적으로 계산하기 위해, CallBack 함수 내부에 또 다른 CallBack 함수를 넘기며 실행되도록 한 코드입니다. 이 코드는 각 함수의 실행 순서를 알기엔 쉽지만, 각각 어떻게 실행되는지 보기에는 가독성이 매우 떨어집니다. (이렇게 꺽쇠 모양으로 복잡하게 생긴 코드를 흔히 콜백 지옥이라고 합니다.)
이러한 코드를 더 깔끔하고 가독성 좋게 작성할 수 있도록 해주는 것이 Promise, 더 나아가 async/await 입니다.
비동기 작업을 더 편리하게 해주는 자바스크립트 내장 객체, Promise
그럼 먼저 Promise 객체부터 자세히 알아보겠습니다. Promise 객체는 자바스크립트의 비동기 작업을 더 편리하게 처리할 수 있도록 해주는 자바스크립트의 내장객체로 아래와 같은 방법으로 생성자를 사용해 생성 가능합니다.
const executor = (resolve, reject) => {
// 실행 내용
}
const promise = new Promise(executor);
promise 객체를 생성할 때는 위 코드와 같은 executor(실행자) 함수가 반드시 인수로 들어가야 합니다.
executor의 인수로 들어가는 resolve와 reject는 자바스크립트에서 자체적으로 제공하는 콜백함수로, executor의 비동기 처리 성공시에는 resolve가, 실패시에는 reject가 호출됩니다. 비동기 처리는 항상 성공 or 실패 둘 중 하나이기 때문에, resolve와 reject 중 둘 중 하나는 반드시 호출 되어야 합니다.
또한, Promise 객체는 아래 사진처럼 state와 result라는 2가지 프로퍼티를 가지고, resolve와 reject의 호출에 따라 내부 프로퍼티의 상태가 변화합니다. 한 번 변경 처리가 끝난 Promise 객체에 resolve나 reject를 호출할 경우에는 무시됩니다.
resolve와 reject의 동작 방식
일단 resolve가 실행되면 promise의 프로퍼티가 변화하는데, state는 pending -> fulfilled로, result는 undefined에서 넘겨받는 value 값으로 변화합니다. reject가 실행될 경우도 위의 그림에서 본 값과 같이 변화됩니다.
또한, resolve와 reject냐에 따라 then, catch, finally 메서드를 통해 다음 실행할 코드를 설정할 수 있습니다.
아래 코드를 통해 resolve와 reject의 동작 방식을 살펴보겠습니다. 먼저 아래와 같은 코드에서 resolve에 "성공"이라는 인수를 넣어 그 아래에서 실행해보겠습니다. 콘솔 결과를 보면, resolve에 넣은 인수는 then, reject에 넣은 인수는 catch를 통해 실행되며, finally는 resolve냐 reject냐에 무관하게 무조건 실행되는 것을 확인할 수 있습니다.
const executor = (resolve, reject) => {
setTimeout(() => {
resolve("성공");
}, 3000);
};
const promise = new Promise(executor);
promise
.then((res) => {
console.log("then -> ", res);
})
.catch((rej) => {
console.log("catch -> ", rej);
})
.finally(() => {
console.log("무조건 실행");
});
const executor = (resolve, reject) => {
setTimeout(() => {
reject("실패");
}, 3000);
};
const promise = new Promise(executor);
promise
.then((res) => {
console.log("then -> ", res);
})
.catch((rej) => {
console.log("catch -> ", rej);
})
.finally(() => {
console.log("무조건 실행");
});
Promise 적용하기
그럼 지금 까지 배운 Promise를 처음에 보았던 workA, workB, workC 코드에 적용해보겠습니다.
적용 전
const workA = (value, callback) => {
setTimeout(() => {
callback(value + 5);
}, 5000); // 5초 설정
};
const workB = (value, callback) => {
setTimeout(() => {
callback(value - 3);
}, 3000); // 3초 설정
};
const workC = (value, callback) => {
setTimeout(() => {
callback(value + 10);
}, 10000); // 10초 설정
};
workA(10, (resA) => {
console.log(`1. ${resA}`);
workB(resA, (resB) => {
console.log(`2. ${resB}`);
workC(resB, (resC) => {
console.log(`3. ${resC}`);
});
});
});
결과
적용 후 (Promise Chaining)
const workA = (value) => {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value + 5);
}, 5000);
});
return promise;
};
const workB = (value) => {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value - 3);
}, 3000);
});
return promise;
};
const workC = (value) => {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value + 10);
}, 10000);
});
return promise;
};
workA(10)
.then((resA) => {
console.log(`1. ${resA}`);
return workB(resA);
})
.then((resB) => {
console.log(`2. ${resB}`);
return workC(resB);
})
.then((resC) => {
console.log(`3. ${resC}`);
});
결과
각각의 함수는 promise 객체를 반환하기 때문에 .then 메서드를 마치 체인을 엮듯 연속적으로 사용할 수 있습니다. 이러한 사용 방법을 '프로미스 체이닝'이라고 부릅니다.
실행 순서도 한눈에 보이고, 적용 전 코드의 콜백 지옥 모양과 다르게 코드의 가독성이 개선된 것을 확인할 수 있습니다.
Promise 객체를 더 쉽게 사용할 수 있게 해주는 async/await
여기서 멈추지 않고, Promise 객체를 더 쉽고 직관적으로 사용하게 해주는 것이 async/await 입니다.
async와 await를 적용해 아래 Promise와 then이 적용된 코드를 좀 더 개선해보겠습니다.
const delay = (ms) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
};
const start = () => {
delay(2000).then(() => {
console.log("대기");
});
};
start();
결과
프로미스를 반환해, async
start 함수에 먼저 async를 설정해주겠습니다. async 키워드를 특정 함수의 = 오른편에 입력해준 뒤, start 함수 위에 커서를 올리면 아래와 같이 Promise를 반환하는 함수라는 설명이 뜨는 것을 확인할 수 있습니다.
이렇게 async를 설정해준 함수는 자동으로 promise를 반환하는 비동기처리 함수가 됩니다.
따라서 이렇게 async를 설정한 start 함수는 아래와 같이 설정해줄 수 있고, 이 때 start 함수에서 return 해주는 값은 프로미스 객체의 resolve의 결과값으로 전달되는 것이기 때문에 아래 사진의 코드처럼 then으로 처리가 가능합니다.
끝날 때까지 기다려, await
await는 미리 정의된 특정 함수의 이름 바로 앞에 작성될 수 있는데, async 로 설정된 함수의 내부에서만 사용 가능합니다. await는 기다리다라는 의미인 만큼, 설정된 함수(promise)가 종료될 때까지 그 다음 코드들을 실행되지 않습니다.
따라서, 아래 코드에서는 await 설정 전에는 console에 바로 "대기" 가 출력되었지만, await 설정 후에는 2초 후 출력됩니다.
async/await 적용된 코드
아래가 최종 수정된 코드 입니다. 가독성이 개선되고, 코드가 좀 더 심플해진 것을 확인하실 수 있습니다.
const delay = (ms) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
};
const start = async () => {
await delay(2000); // delay 실행 완료 후, 아래 코드 실행
console.log("대기");
};
start();
그럼, 코드 실행 실패(오류) 시에는?
그렇다면 Promise에서 처리하던 then과 catch와 같은 오류 처리를 async, await에서는 어떻게 할까요?
바로 아래와 같이 try-catch 문을 이용하면 됩니다. try에는 실행할 코드를, catch에는 실행한 코드 오류 시 실행할 코드를 작성해주면 됩니다.
const delay = (ms) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
};
const start = async () => {
try { // 실행할 코드 작성
await delay(2000);
console.log("대기");
} catch (error) { // 실행 코드에서 오류 발생 시 실행할 코드 작성
console.log(error);
}
};
start();
정리
Promise
- 생성 시 실행자 함수가 반드시 인수로 전달되어야 하며, state와 result라는 프로퍼티를 가진다.
- 프로퍼티는 resolve 또는 reject에 의해 각각 다르게 상태 변화하며, resolve 값은 then, reject 값은 catch에 의해 다음 처리 될 수 있다.
async
- 함수 표현식에서 '=' 오른쪽에 기입되며, async가 설정된 함수는 Promise 객체를 반환한다.
await
- 함수명 왼쪽에 기입되며, async가 정의된 함수 내부에서 사용되며, 적용된 메서드가 끝날 때까지 다음 코드가 실행되지 않는다.