[js] 비동기 프로그래밍과 Error Handling

비동기 프로그래밍과 Error Handling

비동기 프로그래밍

JS는 기본적으로 싱글 스레드 기반으로 동작한다. 그렇기 때문에, 하나의 콜 스택(실행 컨텍스트 스택)을 가지는데, 이로 인해 멀티 쓰레드 프로그래밍이 불가능하다.

이는 자바스크립트 엔진에 대한 설명으로, 브라우저나 Node 환경은 멀티 쓰레드로 동작한다. 만약 모든 자바스크립트 코드가 자바스크립트 엔진을 통해서만 동작한다면, 자바스크립트 코드는 비동기적으로 작동할 수 없다. 왜냐하면, 자바스크립트 엔진은 싱글 스레드 기반의 엔진이기 때문이다.

자바스크립트 엔진은 일반적으로 heap영역콜 스택 영역으로 나뉘어진다.

  1. heap영역은 객체와 같은 자바스크립트 코드 평가 과정에서 동적으로 생성되는 것을 저장하는 메모리 공간이다.

  2. 콜 스택 영역은 자바 스크립트 코드 평가 과정에서 생성된 실행 컨텍스트들이 쌓이는 스택 자료구조를 가진 메모리 공간이다.

위는 자바스크립트의 엔진에 관한 설명인데, 실제 자바스크립트가 동작하는 환경인 브라우저나 노드에서는 이에 추가적인 장치들이 있다.

그 이외의 작업은 자바스크립트 실행 환경인 브라우저 혹은 Node가 담당하는데, 이 실행 환경에 태스크 큐이벤트 루프가 있다.

  1. 태스크 큐는 queue 자료구조로 비동기 함수, 이벤트 핸들러 등이 저장되는 공간이다. 예를들어, setTimeout(callback, time)에서 setTimeout은 콜 스택 영역에서 즉시 실행되지만, callback 함수는 태스크 큐에 들어가게 된다.

  2. 이벤트 루프는 태스크 큐에 대기 중인 함수를 콜 스택으로 보내서 실행해주는 역할을 해준다.

function foo() {
    console.log('foo');
}
function bar () {
    console.log('bar');
}

setTimeout(foo,0);
for (let i = 0; i< 10; i++){
    bar();
}
>>> bar 10 호출
>>> foo

setTimeout 함수는 실제로 2번째 인자의 delay 뒤에 콜 스택에 실행된다는 의미가 아니다. 정확히는 delay가 지난 후에, 태스크 큐에 push되어 대기된다. 만약, 해당 시간에 콜 스택이 비어있지 않다면 콜백함수는 실행되지 않는다.

아래의 예제는 동기적으로 delay를 주는 sleep 함수와 비동기 setTimeout을 사용한 예제이다.

시간상으로 foo 함수가 먼저 호출되어야 할 것처럼 보이지만, 실제 동작은 boo 호출 -> foo 호출이다. 그 이유는 sleep 함수가 동기적으로 동작하기 때문에, 해당 시간 동안 콜 스택에 계속 남아 있기 때문이다.

실제 내부 동작은 아래와 같고, 4번과 5번은 병렬로 처리된다. 4번은 실행환경에서 처리되고, 5번은 자바스크립트 엔진에 의해 처리된다.

  1. setTimeout 함수의 실행 컨텍스트가 생성, 콜 스택에 push되어 실행
  2. Web API 혹은 Node에서 타이머 함수의 실행 컨텍스트 생성
  3. setTimeout 실행되고 콜 스택에서 팝 된다.
  4. 실행환경에서 타이머가 만료되기를 기다리고, 만료되면 콜백 함수인 foo를 태스크 큐로 푸시해준다. (이는 자바스크립트 엔진이 하는 것이 아니라 실행환경에서 실행된다)
  5. sleep 함수의 실행 컨텍스트가 생성되고, 콜 스택에 푸시 된다. 그리고 실행되고, 이후에 팝된다.
  6. 전역 실행 컨텍스트가 종료되어 콜 스택에서 팝되면, 이벤트루프가 이를 감지하여, foo 함수를 콜 스택으로 밀어넣어준다.
// 동기적으로 delay를 주는 함수
function sleep(fn, delay) {
	const delayUntil = Date.now() + delay;

	while(Date.now() < delayUntil);

	fn();
}
function foo() {
	console.log('foo');
}
function boo() {
	console.log('boo');
}
// 비동기 함수 setTiemout
setTimeout(foo, 50);
// 동기 sleep함수 호출
sleep(boo, 3000);

>>> 3초뒤
>>> boo
>>> foo

마이크로 태스크큐

아래 예시의 결과는 1,2,3 이 아니라 2,3,1 이다.

setTimeout(()=> console.log(1), 0);
Promise.resolve()
	.then(() => console.log(2))
	.then(() => console.log(3));

>>> 2
>>> 3
>>> 1

그 이유는 프로미스의 후속 처리 메서드(then, catch)의 콜백함수는 태스크큐가 아닌 마이크로 태스크큐에 들어가기 때문이다.

마이크로 태스크큐는 태스크큐와는 별도의 메모리 공간이며, 마이크로 태스크큐가 모두 실행된 이후에 태스크큐가 실행이된다.

setTimeout의 콜백함수는 태스크큐에 있는데, 이는 Promise의 후속 메서드의 콜백함수보다 우선순위가 낮다. 따라서, 결과가 2, 3, 1이 되는 것이다.

Error

Error가 발생하는 경우에는 해당 에러는 호출자(caller) 방향으로 전파된다.

아래의 경우에는 전역 실행 컨텍스트 -> baz 실행 컨텍스트 -> bar 실행 컨텍스트 -> foo 실행 컨텍스트로 콜 스택이 구성되어 있는데, foo 실행 컨텍스트에서 에러가 발생하였기 때문에 위 화살표의 역방향으로 에러가 전파된다.

아래의 경우에는 에러 핸들링이 없는 코드이다. 그렇기 때문에 에러가 전역 실행 컨텍스트까지 도달하고, 결과적으로 아래의 console.log('hihi')의 코드가 실행되지 않고, 프로그램이 종료된다.

const foo = () => {
	throw new Error('foo error');
};
const bar = () => {
	foo();
}
const baz = () => {
	bar();
}
baz();

console.log("hihi");

>>> Uncaught Error: foo error

반면에 아래의 코드는 try ~ catch 문으로 에러 핸들링을 해주었다.

참고로 try ~ catch는 함수가 아니기 때문에, 별도의 실행 컨텍스트가 있는 것이 아니라, 전역 컨텍스트이다. 즉 여기서 에러가 캐치된 곳은 전역 컨텍스트이다.

이 경우에는 에러를 try ~ catch 문으로 핸들링 해주었기 때문에, 프로그램이 강제로 종료되지 않고, 아래의 코드를 계속 실행하게 된다.

const foo = () => {
	throw new Error('foo error');
};
const bar = () => {
	foo();
}
const baz = () => {
	bar();
}

try {
	baz();
}catch(err){
	console.error(err);
}
console.log('hihi');

>>> Error: foo error
>>> hihi

비동기 함수와 Error handling

비동기 함수의 경우에 콜백의 호출자는 이미 실행 컨텍스트에서 제거된다. (호출자 뿐만 아니라, 전역 컨텍스트도 실행 컨텍스트에서 제거된 상태이다)

그렇기 때문에 아래의 코드는 try~catch가 동작하지 않는다.

try {
	setTimeout(() => { 
		throw new Error('setTimeout Error'), 1000);
} catch(e) {
	console.error(e);
}

#javascript/비동기함수-ErrorHandling