[Javascript] 비동기 프로그래밍

Sungjin·2022년 12월 12일
0

🌈 타이머 API

자바스크립트에는 함수를 특정 시간이 지난 뒤에 실행시키거나, 혹은 함수를 주기적으로 실행시키는 작업을 할 수 있게 해주는 함수가 내장되어 있다.

setTimeout(() => {
    console.log('2초가 지났습니다.');
}, 2000);

setInterval(() => {
    console.log('3초마다 실행된다.');
}, 3000);

setTimeoutsetInterval 은 각각 타이머 식별자를 반환한다. 또한, 이 식별자를 가지고 실행 중인 타이머를 취소할 수 있다.

const timeoutId = setTimeout(() => {
    console.log('2초가 지났습니다.');
}, 2000);

const intervalId = setInterval(() => {
    console.log('3초마다 실행된다.');
}, 3000);

clearTimeout(timeoutId);
clearInterval(intervalId);

하지만, setTimeoutsetInterval 은 정확한 시간 지연을 보장하지 않는다. 예를 들어, 다음 코드를 봐보자.

/**
 * 지연 시간이 0 초일 때
 */
setTimeout(() => {
    console.log("0초 가 지났다.");
}, 0);

console.log('Hello');

// Hello
// 0초가 지났다.

출력 결과를 보면 알다시피, 코드가 뒤늦게 실행된다. 이렇게 실행되는 이유는 자바스크립트의 비동기적인 특성 덕분이다. 더 자세히 알아보기 위하여 자바스크립트의 코드 실행 과정을 살펴 보도록하자.

🌈 자바스크립트 실행과정

💡 호출 스택

호출 스택은 스택 형태의 저장소로, 자바스크립트 엔진은 함수 호출과 관련된 정보를 이 곳에서 관리한다. 호출 스택에 저장 되는 각 항목을 실행 컨텍스트라고 부른다.

실행 컨텍스트에는 다음과 같은 정보들이 저장 된다.

  • 함수 내부에서 사용하는 변수

    	- var : 함수 범위 스코프 (오브젝트 환경 변수)
    
    	- let, const : 블록 범위 스코프 (선언적 환경 변수)
  • 렉시컬 스코프

브라우저 혹은 Node 엔진이 자바스크립트 코드를 실행할 때, 호출 스택을 다음과 같이 조작한다.

  • 전역 실행 컨텍스트를 호출 스택에 추가한다.

  • 전역 실행 컨텍스트 실행중, 함수가 호출되면, 해당 호출에 대한 실행 컨텍스트를 호출 스택에 추가한다.

  • 함수의 실행이 끝나면, 결과 값을 반환하고 호출 스택에서 실행 컨텍스트를 pop 한다.

  • 전역 실행 컨텍스트의 실행이 종료 되면, 전역 실행 컨텍스트를 호출 스택으로부터 pop 한다.

자바스크립트는 기본적으로 싱글 쓰레드 기반으로 동작한다. ( 물론, 이벤트 큐를 동작하는 워커 쓰레드의 경우는 멀티 쓰레드로 되어있다. )

따라서, 호출 스택에 있는 실행 컨텍스트를 실행하는 동안에는 다른 동작을 할 수 없다. (즉, 동기적으로 실행 되는 것이다.)

즉, 동작이 오래 걸리는 실행 컨텍스트가 존재 한다면, 시스템이 먹통이 되어버릴 수도 있다.

💡 작업 큐 (Task Queue)

필연적으로, 오랜 동작을 하는 실행 컨텍스트를 실행해야하는 순간이 존재한다. 이런 경우, 비동기 동작을 통해서 실행 컨텍스트를 처리할 수 있다.

  • 브라우저나 노드에서 직접 일을 처리하는 것이 아닌 웹 API (Node 에서는 Libuv 라이브러리) 를 통해 일을 위임한다. 이 때, 일이 끝나면 실행시킬 콜백을 같이 등록한다. API 로는 다음과 같은 것들이 있다.

    	- Dom
    
    	- Ajax
    
    	- Timer
  • 위임된 일이 끝나면, 그 결과와 콜백을 Task Queue 에 추가한다.

  • 호출 스택이 비워질 때마다 Task Queue 에서 가장 오래된 작업을 꺼내 해당 작업에 대한 콜백을 실행시킨다. 이러한 과정은 끊임없이 반복되는데, 이를 이벤트 루프라고 한다.

자바스크립트 코드를 작성할 때는, 호출 스택과 작업 큐의 성질을 잘 고려해야한다.

  • 각 Task 는 Task Queue 에 쌓인 순서대로 실행된다.

  • 호출 스택이 비워지지 않는다면, Task Queue 에 쌓여있는 작업을 처리할 수 없다.

앞서 setTimeout 의 지연시간으로 0을 넘겨준 예제를 다시 살펴보자. 지연시간을 0으로 주면, setTimeout 에 넘겨진 콜백을 바로 실행하는 것이 아닌 Task Queue 에 콜백을 등록한다.

즉, 호출 스택이 비워지고 나서야 Task Queue 에 등록된 콜백을 실행시킨다. 이 때문에 ‘0초가 지났다.’ 가 더 늦게 출력된 것이다.

🌈 비동기 프로그래밍

이처럼 어떤 일이 완료되기를 기다리지 않고 다음 코드를 실행해 나가며 (non-blocking), 호출된 함수가 등록된 함수 (콜백) 을 처리하는 (asyncronous) 프로그래밍 방식의 예제를 볼 수 있었다.

비동기 프로그래밍은 주로 통신, I/O 와 같이 오래 걸리는 작업들을 라이브러리 혹은 웹 API 에 위임할 때 이루어진다.

대개 프로그램의 성능과 응답성을 높이는 데에 도움을 준다. 하지만, 장점만 존재하는 것은 아니다. 코드가 실제로 실행되는 순서가 뒤죽박죽이 되므로, 코드의 가독성을 해치고 디버깅을 어렵게 만든다는 비판이 존재한다.

이런 문제를 해결하기 위해 자바스크립트 생태계 에서는 다음과 같은 기법들을 제안했다.

  • Callback

  • Promise

  • async / await

기법들을 살펴보도록 하자.

💡 Callback

콜백은 다른 함수의 인수로 넘기는 함수를 말한다. 이 콜백을 가지고 비동기 프로그래밍을 할 수 있다.

유의할 점은 콜백을 인수로 받는 함수가 항상 비동기 식으로 동작하는 것은 아니라는 것이다. 즉, 콜백은 동기식으로 호출될 수 있고 콜백의 실행이 끝날때까지 코드의 실행 흐름이 다음으로 넘어가지 않는다.

콜백은 자바스크립트가 고차함수를 잘 지원한다는 특징 때문에 가장 많이 사용되는 비동기 프로그래밍 양식이었다. 하지만 콜백만으로 복잡한 비동기 데이터 흐름을 표현하기 어려웠고, 콜백 지옥 이라는 용어를 낳게 된다.

var fs = require(`fs`);
fs.readFile(`a.txt`, (err, dataA) => {
    if(err)
        console.error(err);
    fs.readFile(`b.txt`, (err, dataB) => {
        if(err)
            console.error(err);
        fs.readFile(`c.txt`, (err, dataC) => {
            if(err)
                console.error(err);
            setTimeout(() => {
                fs.writeFile(`d.txt`, dataA + dataB + dataC, (err) => {
                    if(err)
                        console.error(err);
                }, 60 * 1000);
            })
        })
    })
})

위의 코드와 같이 함수의 depth 가 깊어짐에 따라, 코드의 가독성을 해칠 수 있고 디버깅이 어려울 수 있다.

또한, 콜백에서의 문제점 중 하나는 예외 처리이다.

var fs = require(`fs`);
function readSketchFile() {
    try {
        fs.readFile(`does_not_exist.txt`, (err, data) => {
            if(err)
                throw err;
        })
    } catch(err) {
        console.log(`warning: minor issue occured, program continuing`);
    }
}

readSketchFile();

위의 코드를 봤을 때, 얼핏 보면 타당해보이고 예외처리 또한 훌륭히 수행한 것으로 보일 수 있다.

그러나 위의 코드는 동작하지 않는다. 예외 처리가 의도대로 동작하지 않는 이유는 try ...catch 블록은 같은 함수 안에서만 동작하기 때문이다.

위의 코드에서 예외는 readFile 의 콜백 함수에서 발생하였지만, catch 구문은 예외가 발생한 함수의 밖에 정의 되어 있다.

💡 Promise

위에서 설명한 콜백의 문제를 해결하기 위해 여러 라이브러리들이 등장했고, 그 중에서 개발자들에게 널리 선택 받은 것은 Promise 패턴이었다.

Promise 는 언젠가 끝나는 작업의 결과값을 담는 통과 같은 객체다. Promise 객체가 만들어지는 시점에는 무엇이 들어갈지 모를 수 있다. 대신 then 메서드를 통해 콜백을 등록해서, 작업이 끝났을 때 결과 값을 가지고 추가 작업을 할 수 있다.

const p = new Promise((resolve, reject) => {
    setTimeout(() => {
        console.log('2초가 지났다.');
        resolve('hello');
    }, 2000);
});

Promise 생성자는 콜백을 인수로 받는다. resolve 를 호출하면 인수로 준 값이 곧 Promisereturn 값이 된다.

reject 함수는 비동기 작업에서 에러가 발생했을 때 호출하는 함수이다.

위 예제에서는 setTimeout 을 이용해 2초가 지난 뒤에 콜백이 실행되도록 했다. 즉, p 변수에 저장된 Promise 객체는 2초 동안은 return 값이 없는 상태가 된다. 그리고 2초가 지나면 return 값을 갖게 된다.

Promise 객체의 결과값을 사용해 추가 작업을 하려면 then 메소드를 호출해야 한다. then 메소드에 콜백을 넘겨서, 첫 번째 인수로 들어온 결과 값을 가지고 추가 작업을 할 수 있다.

p.then(val => {
    console.log(val);
});

then 메서드 자체도 Promise 객체를 반환한다. 즉, 콜백에서 반환한 값 또한 Promise 객체라는 것이다.

p.then(val1 => {
    return `${val1} First`
})
.then(val2 => {
    return `${val2} Second`
});

이제, HTTP 통신을 할 때 Promise 가 어떻게 사용되는지 살펴보자. axios 는 직접 요청을 보내기 위한 라이브러리인데. 호출 결과 값으로 Promise 객체가 반환된다.

axios.get(`${URI}`)
.then(res => {
    console.log(res);
})

지금까지 살펴본바로는 Promise 또한 콜백을 등록하고 사용한다. 하지만 Promise 는 예측 가능한 패턴으로 사용할 수 있게 하며, Promise 없이 콜백만 사용했을 때 나타날 수 있는 상당수의 버그를 해결한다.

또한, Promise 는 객체이므로 어디든 전달할 수있다는 장점도 존재하며, 콜백만 사용했을 때 보다 코드를 훨씬 깔끔하게 작성할 수 있다.

다음은 위의 콜백지옥 예제를 Promise 로 작성한 코드이다.

const readFileEx = new Promise((resolve, reject) => {
    fs.readFile('a.txt', (err, dataA) => {
        if(err)
            reject(err);
        resolve(dataA);
    })
});

readFileEx.then((dataA) => {
    return new Promise((resolve, reject) => {
        fs.readFile('b.txt', (err, dataB) => {
            if(err)
                reject(err);
            resolve(dataA + dataB);
        });
    });
})
.then((dataAB) => {
    return new Promise((resolve, reject) => {
        fs.readFile('c.txt', (err, dataC) => {
            if(err)
                reject(err);
            resolve(dataAB + dataC);
        });
    });
})
.then((dataABC) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(dataAbc);
        }, 60 * 1000);
    });
})
.then((dataABC) => {
    return new Promise((resolve, reject) => {
        fs.writeFile('d.txt', dataABC, (err) => {
            if(err)
                reject(err);
        })
    });
})
.catch((err) => {
    console.error(err);
});

💡 async & await

[Javascript] 자바스크립트 async & await

profile
WEB STUDY & etc.. HELLO!

0개의 댓글