자바스크립트에서의 비동기 처리

동기와 비동기

“동기와 비동기”… 개발을 하며 자주 들어봤을 단어들이다.
정확히 개발에서 사용되는 동기와 비동기의 의미는 뭘까?

우선 단어의 의미만 놓고 본다면, 동기와 비동기는 아래와 같은 뜻을 가진다.

동기 (synchronous) 비동기(asynchronous)
동시에 일어나다 동시에 일어나지 않다

물론, 이런 단어적 의미만 봐서는 프로그래밍에서의 동기와 비동기가 무엇을 의미하는지 명확히 파악하기가 어렵다. 그럼 프로그래밍적 측면에서 동기와 비동기는 뭘 의미할까?

내가 그려본 동기와 비동기

동기

코드가 한 줄, 한 줄 차례대로 실행되는 것이 동기 프로그래밍의 특징이다.

위 사진에 나타나 있듯이, 코드들이 순서대로 대기를 하고 있다가, 자신의 차례가 오면 실행된다. 즉, 나보다 앞에 있는 코드들의 실행이 모두 끝날 때 까지 기다려야 내가 실행될 차례가 온다는 뜻이다.

알다시피 자바스크립트는 동기적 프로그래밍 언어다. 이 때문에 아래와 같이 코드가 순차적으로 실행되는 것이다.

심부름 리스트

더 나아가, 동기와 비동기를 예를 들어 설명해보도록 하겠다.

만약 오늘 엄마가 설거지, 빨래, 재활용 버리기 심부름을 시키셨다고 가정해보자. 대부분은 아마 설거지를 하고, 세탁기를 돌려놓은 뒤에 재활용을 버리고 돌아올 것이다. 이러한 방식이 바로 비동기적 행위다. 하지만 만약… 세탁기가 다 돌아가기 전까지 앞에서 기다리고 있다가, 세탁이 완료되고 그제서야 재활용을 버리러 간다면 이는 동기적 행위다. 이러한 동기적 방식은 프로그래밍적 측면에서 봤을 때 굉장히 단순하고 설계하기가 쉽지만, 때로는 심각한 비효율성을 초래할 수 있기 때문에, 동기와 비동기의 적재적소에의 활용이 필요하다.

비동기

코드가 각자 멀티태스킹과 같이 독립적으로 실행되는 것이 비동기 프로그래밍의 특징이다.
즉, 굳이 기다려주지 않는다!

비동기 프로그래밍이란, 각 코드들이 다른 코드들의 실행 완료 여부에 종속되지 않고 독립적이고 자율적으로 실행될 수 있는 형식을 뜻한다. 내가 중간에 위치한 코드라고 하더라도, 앞의 코드들이 종료될 때까지 기다리지 않아도 된다는 뜻이다.

상술했듯, 굳이 세탁기가 다 돌아갈 때 까지 앞에서 멀뚱멀뚱 기다리고 있지 않아도 된다는 것이다.

개념은 와닿을 수 있어도, 프로그래밍적으로 어떤 의미인지 헷갈릴 수도 있기에 코드로 이를 설명해보도록 하겠다.

비동기적 실행의 예

위 코드에서 필자는 setTimeout을 통해 비동기적 실행을 구현했다. 이를 풀어 설명해보자면, 설거지를 끝내고 → 세탁기를 돌린 뒤 → 재활용을 버리러 간 것이다. 그리고 20분 뒤에 “세탁 완료”가 된다. (1200000ms = 20분). 이처럼, 우리는 동기적 프로그래밍 일변도의 코드 뿐만이 아닌, 비동기와 동기 모두의 적절한 조화를 구현할 수 있다.

하지만, 이러한 비동기성에 의해 발생할 수 있는 문제점들 또한 존재한다.
아래 예시를 봐보도록 하자.

이처럼 setTimeout을 통해 비동기적 실행을 해야 할 경우가 있다고 가정할 때, 비동기적 실행 때문에 console.log(step2)undefined로 출력되는 문제가 발생할 수 있다.

이런 비동기 처리에 의해 발생하는 문제점을 어떻게 해결할 수 있을까?

여기서 처음 등장한 것이 콜백 함수다.


콜백 함수

콜백함수는 다른 함수의 인자로써 활용되는 함수, 또는 어떤 이벤트나 메소드에 의해 호출되는 함수라고 할 수 있다.

즉, 내부함수이자 피동적인 성격을 갖는 함수라고 볼 수 있는 것이다. 바로 코드를 통해 설명해보도록 하겠다.

우선 콜백함수의 생김새부터 살펴보자.

1
2
3
4
5
// 여기서 handleClick은 콜백 함수다 (addEventListener라는 함수에 의해 호출되어지기 때문)
document.querySelector('div').addEventListener('click', handleClick);

// 아래의 화살표 함수 또한 콜백함수라고 할 수 있다. setTimeout에 의해 호출되어지기 때문이다.
setTimeout(() => {}, 1000);

또는, 아래와 같이 즉각적으로 실행되는 콜백 함수 또한 좋은 예시가 될 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
// sayHello의 인자로써 사용된 callback()이라는 함수도 콜백 함수다.

function sayHello(callback) {
console.log('안녕하세요 반갑습니다.');
// #2 콜백이 실행된다.
callback();
}

// #1 sayHello 안에 console.log("저보 반갑습니다.")를 실행시키는 콜백함수를 담아 보낸다.
sayHello(() => {
console.log('저도 반갑습니다.');
});

그리고 아래와 같은 동기적 대응을 위한 콜백함수의 활용법 또한 존재한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Ramyun(callback) {
let percentage = 0;
// setInterval은 비동기적으로 실행된다.
const boil = setInterval(() => {
percentage += 25;
if (percentage === 100) {
console.log(`물이 ${percentage}% 완전히 끓었습니다`);
clearInterval(boil);
// 비동기 함수 setInterval()이 클리어링 될 때 콜백함수를 실행시킨다.
callback();
return;
}
console.log(`물이 ${percentage}% 끓었습니다`);
}, 1000);
}
Ramyun(() => {
console.log('면이랑 스프 넣기');
console.log('맛있게 먹기');
});

⇒ 이처럼, 콜백함수를 사용하면 비동기적 특성에 의해 발생하는 문제점을 해결할 수 있다.

콜백지옥 밈

하지만, 딱 봐도 알 수 있듯이 굉장히 가독성이 떨어진다…
그리고 결정적으로 “콜백 지옥“이라는 문제가 발생될 수 있다.

콜백지옥이란, 위 사진과 같이 콜백 함수가 겹겹이 쌓여 있는 경우를 의미한다. (최악의 가독성이다…)

1
2
3
4
5
6
7
8
9
10
11
12
// 에러 핸들링이 어려운 이유 (예시 코드)
try {
// throw new Error가 1초 뒤에 콜백으로써 실행된다.
// 하지만, setTimeout은 비동기로 동작하기에 호출 스택에서 즉시 제거된다.
// 따라서 아래의 콜백 함수는 테스트 큐에서 대기 후 -> 호출 스택으로 이동되어 실행되지만, 호출자인 setTimeout은 이미 제거된 뒤이기에 에러를 캐치하지 못한다.
setTimeout(() => {
throw new Error('에러 발생!');
}, 1000);
} catch (e) {
console.log('위와 같은 이유로 에러를 캐치하지 못한다..');
console.log(e);
}

이처럼… 콜백은 굉장히 가독성이 떨어지고 유지보수성 또한 저하되며, 위와 같이 에러 핸들링이 굉장히 제한적이다. 따라서 우리는 콜백 함수에서 더 나아가 “**프로미스(Promise)**”라는 대안을 활용할 수 있다.


프로미스

프로미스 또한 비동기 처리를 위한 하나의 객체다.
new Promise()를 통해 인스턴스화 된다. (IE 지원 X)

주요 형태는 아래와 같다.

1
2
3
4
5
6
7
8
9
// Promise 객체를 생성 (인스턴스화)
const promise = new Promise((resolve, reject) => {
if (/* 비동기 작업 성공적으로 완료 */) {
resolve('result');
}
else { /* 비동기 작업 실패 */
reject('failure reason');
}
});

위에서 유추할 수 있듯, 프로미스에는 세 가지의 주요 상태값이 존재한다.

상태 설명
pending 비동기 처리가 아직 실행되지 않음
fulfilled 비동기 처리가 성공적으로 수행 완료 (resolve함수가 호출됨)
rejected 비동기 처리의 수행의 실패 (reject 함수가 호출됨)

다시, 라면 주문하기의 예를 통해 프로미스가 어떻게 활용되는지 확인해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 김밥천국에 준비된 라면 재고들
const Ramyuns = ['신라면', '진라면', '불닭'];

function getRamyun() {
// 프로미스 객체를 반환하겠다는 뜻
return new Promise((resolve, reject) => {
// 비동기 setTimeout의 실행
setTimeout(() => {
// 라면의 재고가 있으면? resolve
if (Ramyuns.length) {
resolve(Ramyuns[0]);
} else {
// 없으면 reject를 반환
reject('라면이 다 떨어졌습니다 죄송합니다...');
}
}, 1000);
});
}

const whichRamyun = getRamyun();

// 만약 fulfilled라면, resolve에 의해 promise object가 반환됐을 것이다.
// 이를 result라 임의로 이름 짓고, console.log()를 통해 찍어낸 것!
const onFulfilled = (result) => {
console.log(`${result} 잘 먹겠습니다 :)`);
};
const onRejected = (error) => {
console.log(error);
};
// whichRamyun이 fulfilled던, rejected던 getRamyun의 실행이 완료되어 값이 할당 됐을 때,
// then을 통해 기다렸다가 동기적으로 둘 중 하나를 실행한다.
whichRamyun.then(onFulfilled, onRejected);

아니면 에러 처리 및 후처리를 catchfinally를 활용해 구현하는 것 또한 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
// 이번에는 라면이 없는 경우를 가정했다.
const Ramyuns = [];
function getRamyun() {(...생략)}

const whichRamyun = getRamyun();
whichRamyun
.then((result) => console.log(result))
// 에러를 캐치한다!
.catch((error) => console.log(error))
// 프로미스가 fulfilled던, rejected던, 프로미스 반환 후 최종적으로 실행시키고싶은 함수가 있을 때 사용한다.
// 라면을 잘 먹었던, 없어서 나가야되던, 인사는 해야하니까...
.finally(() => console.log("안녕히 계세요!"));

에러 처리의 효율성은 catch가 더 좋다고 한다!

만약 catch를 사용하지 않은 첫 번째 에러처리 예시의 경우, resolve가 됐더라도, fulfilled에 대한 콜백에서 오류가 난다면 이를 제대로 잡아내지 못하기 때문이다. (Uncaught Error).

1
2
3
4
5
whichRamyun.then((result) => {
console.log(result);
// 이 경우의 에러를 잡아내지 못함...
throw new Error('then에서의 에러가 발생했습니다!');
});

async/await

마지막으로… async/await는 ES8에 추가된 신상 문법이다.
비동기 핸들링 코드 작성을 하는 데 있어 코드의 가독성이 굉~장히 좋다는 특징을 갖는다.

가독성이 좋은 만큼, 사용법도 굉장히 심플하고 간편하다.

1
2
3
4
5
6
7
8
9
10
11
// 일반 함수에서의 사용
async function something() {
const ramyun = await getRamyun();
return ramyun;
}

// 화살표 함수에서의 사용
const something = async () => {
const ramyun = await getRamyun();
return ramyun;
};

이를 다시 라면 주문하기의 예시로 설명해보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Ramyuns = ["신라면"];
function getRamyun() {(/* ...생략 */)}

(async function ramyun() {
// then()과 흐름은 동일하다.
try {
// 직관적으로 알 수 있듯, await! 기다리라는 뜻이다.
const ramyunName = await getRamyun();
// 그러면 위의 getRamyun의 실행이 종료될 때까지 기다린다.
console.log(`${ramyunName} 잘 먹겠습니다 :)`);
} catch (error) {
// 에러와 finally도 동일!
console.log(error);
} finally {
console.log("안녕히 계세요!");
}
})() // 즉시 실행시키기 위해 괄호로 감싸준 모습

대충 봐도 알겠지만, 프로미스의 장점에 더해 (에러처리, 비동기 처리의 효율성) 가독성까지 겸비한 모습이다.

따라서 요즘 대부분의 비동기 핸들링에는 async/await가 사용된다.


정리 및 요약

자바스크립트는 동기적 실행을 기반으로 하는 언어이기 때문에, 코드들이 순차적이고 직렬적으로 실행된다. 하지만 web API에 의해 setTimeout, setInterval 등의 병렬 및 비동기 처리가 가능해졌고, 이러한 비동기 프로세스를 핸들링 하기 위해 사용되는 것이 callback(), Promise, async/await다. 콜백은 특정 함수 안에서 호출되어지는 또 다른 함수로 요약할 수 있는데, 특정 함수가 실행된 뒤 원하는 후속 함수가 실행되도록 코딩을 하는 방법을 따른다. 하지만, 이러한 콜백 함수는 가독성이 굉장히 떨어지고 에러 핸들링에 치명적 단점을 갖고 있기에, Promise를 대안으로 사용하기 시작했다. 프로미스는 then() 을 사용하며 조금 더 가독성이 좋아졌고, catch를 활용할 수 있게되며 에러 핸들링 또한 콜백에 비해 더 나아졌다. 하지만 ES8에서 async/await라는 신문법이 나왔고, 훨씬 더 가독성이 좋고 보일러 플레이트가 간소화된 비동기 핸들링이 가능해졌다.

세 줄 요약

  1. 자바스크립트는 기본적으로 동기적 언어인데, 종종 비동기적으로 작동하는 코드 때문에 오류가 발생하는 경우가 생긴다. (setTimeout, fetch, setInterval 등등)
  2. 이런 비동기 핸들링을 위해 callback, Promise, async/await가 사용된다.
  3. 하지만 에러 핸들링과 가독성 측면 등 모든 부분에서 async/await가 우월하기에 이를 가장 많이 활용한다.

참조한 자료

JavaScript Promises

자바스크립트 async와 await

Promise | PoiemaWeb


자바스크립트에서의 비동기 처리

https://hoonjoo-park.github.io/javascript/base/asyncawait/

Author

Hoonjoo

Posted on

2022-03-16

Updated on

2022-05-29

Licensed under

Comments