Computer >> 컴퓨터 >  >> 프로그래밍 >> JavaScript

자바스크립트 Async/Await 완벽 가이드: 비동기 코드를 동기 코드처럼 작성하는 방법

ES6(ES2015)에서 도입된 Promise는 콜백 지옥(callback hell)에 빠지지 않고도 비동기 코드를 깔끔하게 작성할 수 있게 해주는 기능입니다. Promise를 사용하면 여러 겹으로 중첩되어 작성과 유지보수가 어려운 콜백 함수를 더 이상 신경 쓰지 않아도 됩니다.

그런데 자바스크립트에는 Promise 기반 비동기 코드를 한층 더 쉽게 작성할 수 있는 기능이 있습니다. 바로 async/await입니다. async/await를 사용하면 실제로는 비동기 작업을 수행하면서도 동기 코드처럼 읽기 쉬운 코드를 만들 수 있습니다.

이 글에서는 async/await가 무엇인지, 그리고 실제 코드에서 어떻게 활용하는지 예제와 함께 살펴보겠습니다.

Promise 간단 복습

async/await를 본격적으로 다루기 전에 Promise를 먼저 짚고 넘어가겠습니다. Promise는 비동기 작업을 나타내는 객체로, "어떤 작업이 수행될 것이며, 성공하면 값을 반환하고 실패하면 에러를 반환한다"는 약속을 담고 있습니다.

Promise는 생성되는 시점에는 아직 확정되지 않은 값을 대표합니다. 미래에 값이 전달될 것이라는 '약속'일 뿐입니다. 또한 Promise는 객체이기 때문에 코드에서 항상 "Promise"로 표기해야 한다는 점도 기억하세요.

예를 들어 API에서 리소스를 가져오는 상황을 생각해 봅시다. 요청 처리에 1~2초가 걸린다면, 사용자가 그 시간을 그대로 기다리게 하는 대신 해당 코드를 Promise로 감싸서 프로그램의 나머지 부분이 계속 실행되도록 만들 수 있습니다.

이렇게 하면 데이터를 가져오는 동안에도 웹사이트 UI의 일부를 계속 렌더링할 수 있습니다. 값이 반환되는 즉시 Promise가 그 값을 메인 프로그램에 전달합니다.

다음은 Promise의 간단한 예제입니다.

let sendCookies = new Promise(resolve => {
	setTimeout(() => {
		resolve("쿠키가 발송되었습니다!");
	}, 1000);
});

sendCookies.then(data => {
	console.log(data);
});

이 코드를 실행하면 "쿠키가 발송되었습니다!"가 출력됩니다. sendCookies.then() 메서드가 호출되는 순간 Promise가 시작되고, 프로그램은 1,000밀리초(1초)를 기다린 뒤 그 값을 메인 프로그램으로 되돌려줍니다.

Async와 Await 사용 방법

async/await에서 await 문은 자신이 속한 async 함수 내부에서 코드 실행을 일시 중지하고, Promise가 반환될 때까지 기다립니다. 그래서 개발자들은 흔히 "async/await는 동기 코드처럼 보이지만 비동기 작업을 수행한다"고 말하는 것입니다.

다음 예제를 살펴보세요.

function sendCookies() {
	return new Promise(resolve => {
		setTimeout(() => {
			resolve("쿠키가 발송되었습니다!");
		}, 1000);
	});
}

async function main() {
	const sendMessage = await sendCookies();
	console.log(sendMessage);
}

main();

실행 결과는 역시 "쿠키가 발송되었습니다!"입니다. sendCookies() 함수가 값을 반환하는 데 1,000밀리초가 걸리며, main()을 async 함수로 선언했기 때문에 코드는 Promise가 이행(resolve)되거나 거부(reject)될 때까지 기다립니다.

async 키워드는 "이 함수 안에서 비동기 작업을 수행하겠다"는 의미이고, await 키워드는 "sendCookies() Promise가 반환될 때까지 프로그램 실행을 잠시 멈춰라"라는 의미입니다.

참고로 async 함수는 항상 Promise를 반환합니다.

여러 단계를 거치는 비동기 작업 처리

async/await가 가장 빛을 발하는 순간은 여러 개의 Promise를 연달아 처리해야 할 때입니다. 흔히 "프라미스 체이닝(Promise Chaining)"이라고 불리는 패턴으로, 코드는 각 단계의 Promise가 반환될 때까지 기다린 후에야 다음 단계로 넘어갑니다.

function processOrder() {
	return new Promise(resolve => {
		setTimeout(() => {
			resolve("쿠키 주문을 처리 중입니다...");
		}, 1000);
	});
}

function sendCookies() {
	return new Promise(resolve => {
		setTimeout(() => {
			resolve("쿠키가 발송되었습니다!");
		}, 1000);
	});
}

async function main() {
	const processingMessage = await processOrder();
	console.log(processingMessage);

	const sendMessage = await sendCookies();
	console.log(sendMessage);
}

main();

실행 결과:

쿠키 주문을 처리 중입니다...
쿠키가 발송되었습니다!

각 단계는 1,000밀리초씩 걸리며, processOrder()의 Promise가 반환된 후에야 sendCookies() 함수가 실행됩니다. 이렇게 await를 순서대로 배치하면 여러 비동기 작업의 실행 순서를 명확하게 제어할 수 있습니다.

Async 함수를 선언하는 세 가지 방법

async/await 함수는 세 가지 방식으로 작성할 수 있습니다.

첫 번째는 지금까지 예제에서 사용한 방식으로, function 키워드로 함수를 선언하고 그 안에서 async와 await 키워드를 사용하는 것입니다.

두 번째는 화살표 함수(arrow function)를 사용하는 방식입니다.

const main = async () => {
	const sendMessage = await sendCookies();
	console.log(sendMessage);
};

세 번째는 함수 표현식(function expression) 문법을 사용하는 방식입니다.

const main = async function() {
	const sendMessage = await sendCookies();
	console.log(sendMessage);
};

세 방식 모두 실행 결과는 동일하며, 차이는 함수를 선언하는 문법뿐입니다. 어느 방법이 정답이라고 정해져 있지는 않으며, 작성하는 프로그램의 성격과 개인적인 선호에 따라 선택하면 됩니다. 화살표 함수가 가장 간결하다는 장점이 있지만, 상황에 따라 다른 선언 방식이 더 적합할 수도 있습니다.

Fetch API와 함께 사용하기

async/await의 가장 흔한 용도 중 하나는 fetch()처럼 Promise 기반으로 동작하는 API를 사용해 웹 요청을 처리하는 것입니다.

async function retrieveComments() {
	const res = await fetch('https://jsonplaceholder.typicode.com/comments');
	const comments = await res.json();

	const names = comments.map(comment => comment.name);

	console.log(names);
}

retrieveComments();

retrieveComments() 함수가 실행되면 await 키워드가 fetch() 요청이 완료될 때까지 기다립니다. 즉, 웹 요청이 처리되기 전까지는 프로그램의 나머지 부분이 실행되지 않습니다. fetch() 함수가 Promise를 반환하기 때문입니다.

fetch()가 응답을 반환하면 res.json()을 호출해 응답을 JSON 형태로 변환한 뒤, map()을 통해 모든 댓글의 이름 목록을 추출하여 콘솔에 출력합니다.

에러 처리 방법

코드에서 에러는 언제나 발생할 수 있으며, 개발자라면 이에 대비해야 합니다. async 함수에서는 동기 코드에서와 같은 방식으로 try...catch 문을 사용해 에러를 처리합니다.

먼저 .catch()를 사용하는 방법부터 살펴보겠습니다.

async function retrieveCookies() {
	const res = await fetch('https://thisapidoesnotexist.app/cookies');
	const data = await res.json();

	return data.map(cookie => cookie.name);
}

async function printCookies() {
	const cookies = await retrieveCookies();
	console.log(cookies);
}

printCookies().catch(error => console.log(error.message));

이 코드를 실행하면 "NetworkError when attempting to fetch resource."와 같은 네트워크 에러가 출력됩니다. 코드 자체는 문제가 없지만 접근하려는 API가 존재하지 않기 때문에 Promise가 어떤 데이터도 반환하지 못하고 거부(reject) 상태가 됩니다.

이처럼 .catch() 문을 사용하면 함수에서 에러가 반환될 경우 이를 받아 적절히 처리할 수 있습니다. Promise가 거부되면 .catch() 블록이 실행되어 에러 내용을 출력합니다.

같은 로직을 try/catch 문으로도 처리할 수 있습니다.

async function printCookies() {
	try {
		const cookies = await retrieveCookies();
		console.log(cookies);
	} catch (error) {
		console.log(error);
	}
}

printCookies();

이번에는 try/catch 문으로 await 구문이 성공적인 Promise를 반환했는지 검사합니다. API가 유효하지 않아 Promise가 거부되면 try/catch 블록의 catch 절이 실행되어 에러를 콘솔에 기록합니다. 두 방식 모두 널리 사용되므로, 상황에 맞게 선택해서 사용하면 됩니다.

마무리

async/await를 사용하면 비동기 작업을 동기 코드처럼 읽기 쉽게 작성할 수 있습니다. async 함수를 선언한 뒤 await 키워드를 사용하면 작업이 완료될 때까지 기다릴 수 있으며, await는 반드시 Promise를 반환하는 함수와 함께 사용해야 합니다.

핵심 내용을 정리하면 다음과 같습니다.

  • async 키워드가 붙은 함수는 항상 Promise를 반환합니다.
  • await는 Promise가 이행되거나 거부될 때까지 실행을 일시 중지합니다.
  • 에러 처리는 .catch() 또는 try/catch 문으로 수행합니다.
  • fetch() 같은 Promise 기반 API와 함께 사용하면 특히 유용합니다.

이제 async/await를 활용해 자바스크립트 비동기 코드를 전문가처럼 작성할 준비가 되셨습니다!