JavaScript: 비동기 처리, 콜백 함수, Promise, Async/Await

Chaewon Kang·2020년 10월 14일
0

비동기 처리, 콜백 함수, Promise, Async/Await에 대해서 공부해 봅시다.

비동기 처리, 콜백 함수, Promise, Asnyc/Await

비동기 처리는 무엇일까 (Asynchronous)

특정 코드의 연산이 끝날 때 까지 코드 실행을 멈추지 않고, 다음 코드를 먼저 실행하는 자바스크립트의 특성입니다.

동기 처리 (Synchronous)

작업이 끝날 때 까지 기다리는 동안 준비상태가 되기 때문에 다른 작업이 진행 될 수 없다. 즉 작업을 순차적으로 처리하는 것.

비동기 처리 사례(1)

비동기 처리의 가장 흔한 사례는 제이쿼리의 ajax이다. 제이쿼리로 실제 웹 서비스를 개발할 때, ajax 통신을 빼놓을 수 없다. 화면에 표시할 이미지나 데이터를 서버에서 불러와 표시할 때 ajax 통신으로 해당 데이터를 서버로부터 가져올 수 있다.

function getData() {
	var tableData;
	$.get('https://domain.com/products/1', function(response) {
		tableData = response;
	});
	return tableData;
}

console.log(getData());

위 코드에서 $.get()이 ajax 통신을 하는 부분이다.

httmps://domain.com에 HTTP GET 요청을 날려서, products 폴더 내의 1번 상품 정보를 요청하는 코드이다. 그렇게 받아온 데이터가 response 인자에 담긴다. 그리고 tableData라는 변수에 받아온 내용을 저장한다.

콘솔에 getData()를 호출하면, 어떤 값이든 찍히는 것이 타당해 보이지만 undefined라고 나올 것이다. 왜냐면, 데이터를 받아올 때 까지 기다리지 않고 다음 코드인 return tableData;를 실행했기 때문이다. 이렇게 특정 로직의 실행이 끝날 때 까지 기다리지 않고 나머지 코드를 먼저 처리하는 것이 비동기 처리이다.

비동기 처리 사례(2)

setTimeout()

setTimeout 또한 비동기 처리의 예시.

setTimeout(() => {
...}, 1000)

참고할 점 4ms가 브라우저에서 지정한 최소 시간 단위이기 때문에, setTimeout의 두번 째 인자를 0으로 딜레이 주더라도 4ms 이후에 실행됨.

setTimeout()은 Web API의 한 종류로, 코드를 바로 실행하지 않고 지정한 시간만큼 기다렸다가 실행한다. setTimeout()의 첫번 째 인자는 실행할 함수, 두번 째 인자는 밀리세컨 단위의 숫자이다. 두번 째 인자만큼의 시간만큼 기다렸다가 첫번 째 인자 함수를 실행한다.

그러나 setTimeout()역시 비동기 방식으로 실행되기 때문에, 뒤에 상대적으로 간단한(시간이 덜 소요되는) 코드가 있다면 그 코드를 먼저 실행하게 된다.

이러한 비동기 처리 방식의 문제점을 해결할 수 있는 것이 바로 콜백 함수이다.

콜백 함수

콜백 함수란, 함수 안에서 어떤 특정한 시점에 호출되는 함수를 말한다. 보통 콜백 함수는 함수의 매개변수로 전달하여, 특정 시점에서 콜백 함수를 호출한다.

그러니까 함수A의 파라미터로 함수B를 받아오고, 함수 A의 작업이 끝나고 나서 함수 B를 실행하는 것이다. 이렇게 하면 비동기 처리 방식의 문제를 해결할 수 있다.

function getData(callbackFunk) {
	$.get('https://domain.com/products/1', function(response) {
		callbackFunk(response);
	});
}

getData(function(tableData) {
	console.log(tableData);
}

위와 같이 콜백 함수를 사용하면, 특정 로직이 끝났을 때에 원하는 동작을 실행시킬 수 있다. 즉, 위처럼 하면 데이터가 준비된 시점에서만 동작을 수행할 수 있게 된다. response가 있을 때만 하게 되니까.

예를 들어서,

function work(callback) {
	setTimeout(() => {
		const start = Date.now();
		for (let i = 0; i < 1000000000; i++) {}
		const end = Date.now();
		console.log(end - start + 'ms');
		callback(end - start)
	}, 0)
}

console.log('작업 시작!');
work((ms) => {
	console.log('작업이 끝났어요!');
	console.log(ms + 'ms 걸렸어요.');
});
console.log('다음 작업');

라는 코드가 있다고 하자. work 함수에 콜백 함수를 인자로 넘겨 주었다.

실행 결과는, 아래와 같다.

// Console
작업 시작!
다음 작업
518ms
작업이 끝났어요!
518ms 걸렸어요.

맨 먼저 콘솔에 '작업 시작!'이 출력되고, work 함수를 실행한다. 실행되면서 for문 내에서 소요되는 시간 지연이 일어나는 동안, 콘솔에 '다음 작업'이 출력된다. work 함수에서 for문 실행이 끝나면, 콘솔에 end 변수의 값에서 start 변수의 값을 뺀 값이 출력된다. 이후 그 값을 콜백 함수에 인자로 전달하고, 그 인자 값이 ms라는 변수에 담겨서, '작업이 끝났어요!'와 '518ms 걸렸어요.'가 출력된다. 실제로는 Ajax Web API 요청, 서버에서 파일 읽어오기, 암호화/복호화, 작업 예약 등을 비동기 작업으로 처리한다.

Promise

Promise는 비동기 작업을 좀 더 편하게 처리할 수 있도록 ES6 명세에 도입된 기능이다. 이전에는 비동기 작업은 콜백 함수로 처리를 했어야 했다. 이럴 경우 비동기 작업이 많아질 경우에 코드의 복잡도가 올라가기 때문에, Promise가 생겼다. 원래는 라이브러리로 있다가, 자바스크립트 스펙에 추가되었다.

콜백 지옥의 예.

function IncreaseAndPrint(n, callback) {
	setTimeout(() => {
		const increased = n + 1;
		console.log(increased);
		if (callback) {
			callback(increased)
		}
	}, 1000)
}

IncreaseAndPrint(0, n => {
	IncreaseAndPrint(n, n => {
		IncreaseAndPrint(n, n => {
			IncreaseAndPrint(n, n => {
				IncreaseAndPrint(n, n => {
					console.log("Finished!");
				})
			})
		})
	})
})

Promise를 사용하면, 콜백 지옥에서 탈출할 수 있다. Promise는, 특정 작업이 성공할 때는 resolve를 호출하고, 실패할 때는 reject를 호출한다. 위의 콜백 지옥을 Promise를 사용하면 아래와 같이 표현할 수 있다.

function increaseAndPrint(n) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			const value = n + 1;
			if (value === 5) {
				const error = new Error();
				error.name = 'ValueIsFiveError';
				reject(error);
				return;
			}
			console.log(value);
			resolve(value);
		}, 1000);
	})
}

increaseAndPrint(0).then(n => {
	return increaseAndPrint(n);
}).then(n => {
	return increaseAndPrint(n);
}).then(n => {
	return increaseAndPrint(n);
}).then(n => {
	return increaseAndPrint(n);
}).then(n => {
	return increaseAndPrint(n);
}).catch(e => {
	console.error(e);
})

짜잔. 그런데 사실 이렇게 하지 않아도 됨.

function increaseAndPrint(n) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			const value = n + 1;
			if (value === 5) {
				const error = new Error();
				error.name = 'ValueIsFiveError';
				reject(error);
				return;
			}
			console.log(value);
			resolve(value);
		}, 1000);
	})
}

increaseAndPrint(0).then(increaseAndPrint)
.then(increaseAndPrint)
.then(increaseAndPrint)
.then(increaseAndPrint)
.then(increaseAndPrint)
.catch(e => {
	console.error(e);
})

이렇게 해도 결과는 같다. 하지만 이럴 경우에 단점이 있다. 에러 발생 위치를 찾기 어렵고, 조건에 따라 분기하기도 어렵고... 그래서 이런 단점을 극복하기 위해 Async/Await 문법을 사용한다.

Async/Await

Promise를 더욱 쉽게 사용하기 위해 쓴다.

// ms라는 파라미터를 받고, 새로운 프로미스를 만든다. 만든 프로미스가 몇 ms 이후 끝날 것인지.
function sleep(ms) {
	return new Promise(resolve => setTimeout(resolve, ms));
}

async function process() {
	console.log("안녕하세요!");
	await sleep(1000);
	console.log("반갑습니다.");
}

process();

앞부분에 async, await 키워드를 붙여서 사용해요. 점 접근자와 then 키워드로 promise 실행을 기다리지 않아도, await으로 분기점을 쉽게 만들 수 있고, 변수를 공유하거나 로직을 작성하기에 용이합니다.

이 함수의 결과물은 promise를 반환하게 된다. 그래서, 만약에 코드를 아래와 같이 바꾸면

function sleep(ms) {
	return new Promise(resolve => setTimeout(resolve, ms));
}

async function process() {
	console.log("안녕하세요!");
	await sleep(1000);
	console.log("반갑습니다.");
	return true;
}

process().then(value => {
	console.log(value);
});

출력 값은

// Console

안녕하세요!
반갑습니다!
true

처럼 된다. Async/Await 문법을 사용한 결과로 새로운 Promise가 반환되기 때문에, 거기에다 대고 점 접근법과 then 키워드를 사용할 수 있다. process의 리턴값이 then 함수의 value에 담겨서, value가 출력된다.

Asnyc/Await 에서 에러를 잡아내려고 할 땐, try/catch 문을 사용한다.

function sleep(ms) {
	return new Promise(resolve => setTimeout(resolve, ms));
}

async function makeError() {
	await sleep(1000);
	const error = new Error();
	throw error;
}

async function process() {
	try {
		await makeError();
	} catch (e) {
		console.error(e);
	}
}

process();

Promise all

function sleep(ms) {
	return new Promise(resolve => setTimeout(resolve, ms));
}

/* 화살표 함수로 async 함수 만들기 */
const getDog = async () => {
	await sleep(1000);
	return '멍멍이';
}

const getRabbit = async () => {
	await sleep(500);
	return '토끼';
}

const getTurtle = async () => {
	await sleep(3000);
	return '거부기';
}

async function process() {
	const dog = await getDog();
	console.log(dog);
	const rabbit = await getRabbit();
	console.log(rabbit);
	const turtle = await getTurtle();
	console.log(turtle);
}

이렇게 하면, 각각 1초, 0.5초, 3초의 딜레이가 있다. 이는 한꺼번에 시작하는 게 아니라 하나씩 다르게 처리하고 있다. 하지만, 여러 개의 Promise를 동시에 처리하고 싶을 떄가 있을 것이다. 이 때 Promise all을 사용한다.

function sleep(ms) {
	return new Promise(resolve => setTimeout(resolve, ms));
}

/* 화살표 함수로 async 함수 만들기 */
const getDog = async () => {
	await sleep(1000);
	return '멍멍이';
}

const getRabbit = async () => {
	await sleep(500);
	return '토끼';
}

const getTurtle = async () => {
	await sleep(3000);
	return '거부기';
}

async function process() {
	const results = await Promise.all([getDog(), getRabbit(), getTurtle()]);
	console.log(results);
}

위처럼 만든 3개의 프로미스를 배열에 담고, 그 배열을 Promise.all로 감싼다. 이것을 또 await 키워드로 래핑해 준다. 이렇게 하면, 우리가 만든 각각의 Promise들을 다 시작하고 나서, 각각의 배열 원소 프로미스들이 다 끝나면, 각각의 결과값이 담긴 배열이 반환된다. 콘솔 출력의 결과값은 멍멍이, 토끼, 거부기가 배열 하나에 들어가 있게 된다. getTurtle이 가장 오래 걸리니까 전체 실행 시간은 3초이다.

const dog = results[0];
const rabbit = results[1];
const turtle = results[2];

이런 식으로 각각의 변수에 담아줄 수도 있고, 비구조화 할당을 통해 아래처럼 할 수도 있다.

async function process() {
	const [dog, rabbit, turtle] = await Promise.all([getDog(), getRabbit(), getTurtle()]);
	console.log(results);
}

Promise.race

등록한 Promise 중에 가장 빨리 끝난 Promise만 나타나게 된다.

async function process() {
	const first = await Promise.race([getDog(), getRabbit(), getTurtle()]);
	console.log(first); // rabbit. 0.3초
}

그리고, Promise all로 묶었을 때 개별 Promise 중 하나라도 에러가 발생하게 되면, 전체 프로그램에 에러가 발생한다. 이 경우에는 try/catch 문으로 에러를 잡아낼 수 있다. 그런데 Promise.race로 하면, 가장 빨리 끝난 Promise가 에러일 때만 에러이다.

profile
문학적 상상력과 기술적 가능성

0개의 댓글