# 00. About Asynchronous
비동기란 무엇일까요?
MDN 에서 정의한 비동기는 다음과 같습니다.
비동기라는 용어는 둘 이상의 객체 또는 이벤트가 동시에 존재하지 않거나 발생하지 않는 경우(또는 이전 객체 또는 이벤트가 완료될 때까지 기다리지 않고 발생하는 여러 관련 작업)를 말합니다.
비동기의 개념을 처음 접하는 분들은 위의 정의만으로는 정확하게 이해하기 어려울 것입니다.
통상적으로는 비동기를 병렬적 실행 정도로 이해하고 설명하곤 합니다.
비동기의 경우 한 작업이 시작되면 그 작업이 끝나기를 기다린 후 다음 작업을 실행하는 동기와는 다르게 이전 작업이 끝나기를 기다리지 않고 다음 작업을 시작할 수 있습니다.
비동기 처리의 경우 오래 걸리는 작업을 효율적으로 처리할 수 있기 때문에 주로, 네트워크 요청이나 파일 I/O와 같은 작업에서 쓰이곤 합니다.
만약 위와 같은 작업을 동기로 처리할 경우, 하나의 네트워크 요청이 지연되면 전체 실행에 영향을 줄 수 있었을 것입니다.
# 01. Callback Hell
비동기 작업을 처리할 때 전통적으로 사용해 온 콜백 방식은 간단한 비동기 작업에는 적합하지만, 여러 비동기 작업이 순차적이거나 복잡하게 연결될 경우 콜백 지옥 현상을 야기할 수 있습니다.
콜백 지옥은 여러 비동기 작업을 중첩된 콜백 함수 형태로 작성할 때 발생합니다.
한 작업의 결과를 다음 작업의 인자로 넘겨주기 위해 콜백 함수가 중첩이 될수록 코드의 깊이가 점점 깊어지면서 가독성이 떨어지고 디버깅이나 예외 처리 과정도 복잡해질 수 있습니다.
doTask1(param, (result1) => {
doTask2(result1, (result2) => {
doTask3(result2, (result3) => {
doTask4(result3, (result4) => {
// ...
});
});
});
});
이와 같은 문제를 극복하기 위해 도입된 것이 Promise라는 인터페이스입니다.
# 02. About Promise
자바스크립트와 타입스크립트에서는 Promise 객체를 사용함으로 비동기 작업을 수행할 수 있습니다.
Promise는 비동기 작업의 완료 또는 실패와 그 결과 값을 나타내는 객체로, 미래에 결괏값이 제공되거나 에러가 발생할 수 있음을 약속(promise)합니다.
Promise 객체는 아래와 같이 3가지 상태를 가집니다.
- pending (대기) :
Promise가 생성된 직후의 초기 상태로, 아직 비동기 작업의 결과가 결정되지 않은 상태입니다. 이 상태에서는 작업이 진행 중이며, 성공이나 실패 여부가 정해지지 않은 상태입니다.
- fulfilled (이행) :
비동기 작업이 성공적으로 완료되어 결과값을 반환한 상태입니다. 이때 Promise는 이행(fulfilled) 상태로 전환되며, then() 메서드를 사용하여 결괏값을 처리할 수 있습니다.
- rejected (거부) :
비동기 작업이 실패하거나 예외가 발생한 상태입니다. Promise는 거부(rejected) 상태로 전환되며, 이 경우 catch() 메서드를 사용해 오류를 처리할 수 있습니다.
Promise의 구조
/**
* Represents the completion of an asynchronous operation
*/
interface Promise<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;
/**
* Attaches a callback for only the rejection of the Promise.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of the callback.
*/
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}
Promise는 then과 catch 메서드를 가지고 있습니다.
then 메서드의 경우 Promise 객체가 이행(fulfilled) 되거나 거부(rejected)될 때 호출할 콜백 함수를 매개변수로 받습니다.
이때 then 메서드의 반환은 새로운 Promise이며 이를 통해 비동기 작업을 순차적으로 체이닝(chaining)할 수 있습니다.
catch 메서드의 경우 Promise 객체가 거부(rejected) 되었을 때 에러를 처리하는 콜백 함수를 매개변수로 받습니다.
catch 메서드 역시 Promise를 반환하기 때문에 에러가 발생된 후에도 비동기 체인을 이어나갈 수 있게 해 줍니다.
Promise의 메서드 체이닝
doTask1(param)
.then(result1 => doTask2(result1))
.then(result2 => doTask3(result2))
.then(result3 => doTask4(result3))
.catch(error => {
// 통합된 에러 처리
console.error("에러 발생:", error);
});
# 03. async와 await
위와 같은 Promise의 메서드 체이닝 역시 작업이 복잡해지면 직관성이 떨어질 수 있습니다.
때문에 Promise를 보다 직관적으로 사용하기 위해 도입된 것이 async와 await이라는 키워드입니다.
async 키워드를 함수 앞에 붙이면 해당 함수는 항상 Promise를 반환합니다.
만약 함수 내부에서 명시적으로 Promise를 반환하지 않더라도 반환값은 자동으로 resolved 상태 ( fulfilled 되었거나 rejected 된 상태 ) 의 Promise로 감싸집니다.
await 키워드는 단어 뜻 그대로 Promise가 처리될 때까지 함수의 실행을 중단합니다.
또한 await은 반드시 async 함수 내부에서만 사용할 수 있습니다.
async function async_await() {
try {
const response = await fetch("https://.....");
const data = await response.json();
return data;
} catch (error) {
console.error("데이터 로드 실패:", error);
}
}
위와 같이 async와 await을 이용함으로써 비동기 기반의 복잡한 흐름의 코드를 마치 동기 코드처럼 작성할 수 있습니다.
또한 try/catch문을 사용해 에러를 일관되게 처리할 수도 있습니다.
처음 프론트를 접했던 게 급하게 프로젝트에서 프론트 작업을 맡게 되면서 시작하게 되었는데, 그때 이 비동기 부분이 잘 와닿지 않았습니다.
처음엔 일단 작업을 해야했기 때문에 이해는 뒷전이고 사용법만 익히기에 급급했는데, 중간중간 있었던 사수님의 가르침을 통해 프로젝트에서 await 키워드가 사용된 코드들을 이해할 수 있었고, 시간이 좀 지난 후 지금에서 다시 한번 공부해 보면서 async/await 키워드의 필요성을 알아볼 수 있었습니다.
백엔드 엔지니어로 커리어를 시작하고자 공부 중이지만, 비동기로 프론트도 꾸준히 공부하면서 '다 잘하는 개발자'가 되고자 부단히 노력해 가겠습니다.