[JS] 비동기(async) 프로그래밍 이해하기[1]

JS 비동기 패러다임 소개

이 글은 Ethan Brown의 Learning Javascript 3판을 참조한 글이다.
Photo by Bruce Clarke on Flickr

JS 애플리케이션은 단일 스레드에서 동작한다. 즉, 자바스크립트는 한 번에 한 가지 일만 할 수 있다. ‘그럼 안 좋은거 아니야?’라고 생각할 수 있지만 싱글 스레드는 멀티 스레드가 겪어야 하는 골치 아픈 문제들을 신경 쓰지 않아도 된다는 장점이 있다.

싱글스레드만으로 멀티스레드 부럽지 않은 부드러운 소프트웨어를 만들기 위해서는 대가를 치뤄야 한다. 그 대가가 바로 비동기적(asynchronous) 프로그래밍이다.

자바스크립트의 비동기적 프로그래밍에는 뚜렷이 구분되는 세 가지 패러다임이 있다. 처음에는 콜백이 있었고, 프라미스가 뒤를 이었으며 마지막은 제너레이터다. 제너레이터가 콜백이나 프라미스보다 모든 면에서 더 좋다면 제너레이터에 대해서만 공부하고 나머지는 과거의 유산으로 치워 둘 수도 있겠지만, 그렇게 간단한 문제는 아니다. 제너레이터 자체는 비동기적 프로그래밍을 전혀 지원하지 않는다. 제너레이터를 비동기적으로 사용하려면 프라미스나 특수한 콜백과 함께 사용해야 한다. 프라미스 역시 콜백에 의존한다.

사용자 입력 외에, 비동기적 테크닉을 사용해야 하는 경우는 크게 세 가지가 있다.

  • Ajax 호출을 비롯한 네트워크 요청
  • 파일을 읽고 쓰는 등의 파일시스템 작업
  • 의도적으로 시간 지연을 사용하는 기능(알람 등)

먼저 간단한 예제로 setTimeout을 살펴보자. setTimeout은 지정된 시간 뒤에 원하는 함수를 실행시키는 콜백 함수다. 시간은 밀리초로 전달된다. (1s = 1,000ms)

console.log(“Before timeout”);
setTimeout(() => {
console.log("Executed!");
}, 5000);
console.log("After timeout");

위 코드를 실행시키면 다음과 같은 결과를 볼 수 있다.

// 결과 1
Before timeout
After timeout
Executed!

비동기적 프로그래밍을 처음 접하는 사람이라면 적잖이 당황했을 수도 있다. 코드작성된 순서를 보면 “Before timeout” -> “Executed” -> “After timeout” 순서대로 실행된다. 그런데 실제로는 Executed가 마지막으로 출력됐다.

만약 기대했던 대로

// 결과 2
Before timeout
Executed!
After timeout

이렇게 실행이 된다면 좋겠지만, 이것은 비동기적이지 않다. 비동기의 가장 큰 목적, 큰 요점은 어떤 것도 차단하지 않는다는 것이다. 앞서 말했듯이 JS는 싱글스레드이기 때문에 만약 setTimeout함수가 스레드를 점유하고 있게 되면 애플리케이션은 5초동안 아무 동작도 하지 않는 idle상태가 된다. 그리고 만약 사용자의 입력을 받아야 하는 경우라면 동기적 프로그래밍의 경우 사용자가 입력을 하기 전까지 애플리케이션이 아무 일도 할 수 없게 된다.


스코프와 비동기적 실행의 관계

비동기적 프로그래밍을 할 때 신경써야 할 것들 중 하나는 Scope 다. 콜백함수에서 사용하는 변수가 어느 스코프에서 선언됐느냐에 따라서 결과가 크게 달라질 수 있기 때문이다. 다음 예제를 보고 출력결과를 예상해보자.

function countdown() {
let i;
console.log("Countdown");
for (i = 5; i >= 0; i--) {
setTimeout(() => {
console.log(i === 0 ? "GO!" : i);
}, (5 - i) * 1000);
}
}
countdown();

아마 많은 분들이 5 4 3 2 1 “GO!” 가 출력됐을 거라고 예상했을 것이다. 안타깝지만 결과는 -1이 여섯 번 출력될 뿐이다. 그 이유는 변수 i가 선언된 위치에 있다.

setTimeout이 실행되는 타이밍은 for loop이 끝난 후다

우선 지금의 경우 변수 i는 for문 바깥에서 선언이 됐다. 이 경우 콜백함수가 실행될 때 참조하는 i의 값은 for문이 모두 끝난 후의 i값인 -1이다. 그렇기 때문에 -1이 6번 출력되고 프로그램이 종료된다.

이 문제는 블록스코프로 변수를 선언함으로 해결할 수 있다. 블록스코프 변수 선언에는 두 가지 방법이 있는데 첫 번째는 IIFE(즉시실행함수), 두 번째는 i를 for 문에서 선언하는 것이다. 여기서는 두 번째 방법을 사용했다.

function countdown() {
console.log("Countdown");
for (let i = 5; i >= 0; i--) {
setTimeout(() => {
console.log(i === 0 ? "GO!" : i);
}, (5 - i) * 1000);
}
}
countdown();
// 5
// 4
// 3
// 2
// 1
// "GO!"

예상했던 결과가 출력되는 것을 확인할 수 있다.

오류 우선 콜백

노드가 점점 인기를 얻어가던 시절에 오류 우선 콜백(error-first callback)이라는 패턴이 생겼다. 콜백과 관련된 에러를 처리할 방법의 표준으로 콜백의 첫 번째 매개변수에 에러 객체를 쓰자는 것이다. 간단히 말해 에러 매개변수를 체크하고 그에 맞게 반응하는 것이다.

오류 우선 콜백을 사용하면 다음과 같은 코드를 쓰게 된다.

const fs = require('fs');
const frame = 'may_or_may_not_exist.txt';
fs.readFile(fname, function(err, data) {
if(err) return console.error(`error reading file ${fname}:
${err.message}`;
console.log(`${fname} contents: ${data}`);
});

가장 먼저 하는 일은 err이 있는지 확인하는 것이다. err가 참이라고 나온다면 파일을 읽는 데 문제가 있다는 뜻으로 콘솔에 오류를 보고하고 빠져나온다.

콜백을 사용할 때 가장 많이 벌어지는 실수가 아마 이 부분일 것이다. 빠져나와야 한다는 사실을 잊는 것이다. 콜백을 사용하는 함수는 대개 콜백이 성공적이라고 가정하고 만들어진다. 그런데 콜백이 실패했으니, 빠져나가지 않으면 오류를 예약하는 것이나 다름없다.

그래서 콜백을 만들 때 실패를 염두에 두고 에러를 로그로 남기기만 하고 계속 진행하게 하는 등의 방법도 쓰인다.

프라미스를 쓰지 않는 경우 오류 우선 콜백은 노드 개발의 표준이나 다름없다.

콜백 헬

콜백으로 비동기적 프로그래밍을 할 수 있지만 한 번에 여러 개의 콜백을 사용하는 경우 관리하기가 매우 까다로워진다.

이 앱은 세 가지 파일을 불러 60초 뒤에 이들을 결합해 새로운 파일을 만든다.

const 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);
});
});
});

이런 코드를 ‘콜백 헬'이라고 부른다. 그런데 에러 처리를 하게 되면 문제가 복잡해진다. 예외 처리를 하는 경우가 가장 복잡한데 try…catch 블록으로 예외를 처리하는 다음 예제를 살펴보자.

const fs = require('fs');
function readSketchyFile() {
try {
fs.readFile('does_not_exist.txt', (err, data) => {
if(err) throw err;
});
} catch(err) {
console.log('warning: minor issue occurred');
}
}
readSketchyFile();

얼핏 보면 문제 없는 코드처럼 보인다. 예외도 적당히 잘 처리하는 것처럼 보인다. 하지만 직접 실행해 보면 프로그램은 멈춘다. 그 이유는 try…catch블록이 같은 함수 안에서만 동작하기 때문이다. try…catch블록은 readSketchyFile 함수 안에 있지만, 정작 예외는 fs.readFile이 콜백으로 호출하는 익명 함수 안에서 일어난다.

또한, 콜백이 우연히 두 번 호출되거나, 아예 호출되지 않는 경우를 방지하는 안전장치도 없다. 자바스크립트는 콜백이 정확히 한 번 호출될 것을 보장하지 않는다.

해결할 수 없는 문제는 아니지만 비동기적 코드가 늘어날수록 이를 관리하기는 매우 어려워진다. 그래서 등장한 것이 프라미스다.

프라미스

프라미스는 콜백의 단점을 해결하려는 시도로 만들어졌다. 하지만 앞서 말했듯이 프라미스가 콜백을 대체하는 것이 아니다. 프라미스는 콜백을 예측 가능한 패턴으로 사용할 수 있게 하며, 프라미스 없이 콜백만 사용했을 때 나타날 수 있는 엉뚱한 현상이나 찾기 힘든 버그를 상당수 해결한다.

프라미스의 기본 개념은 간단하다. 프라미스 기반 비동기적 함수를 호출하면 함수는 Promise 인스턴스를 반환한다. 프라미스는 성공(fullfilled) 그리고 실패(rejected), 둘 중 하나의 이벤트만 일어나는 것을 보장한다.또한 성공이든 실패든 단 한 번만 일어난다. 프라미스가 성공하거나 실패하면 그 프라미스를 결정됐다(settled)고 한다.

프라미스는 객체이기 때문에 어디든 전달이 가능하다. 비동기 처리를 다른 함수에서 하고 싶다면 프라미스 객체를 넘기기만 하면 된다.

아까 보았던 countdown예제를 프라미스로 작성해보자.

function countdown(seconds) {
return new Promise((resolve, reject) => {
for (let i = seconds; i >= 0; i--) {
setTimeout(() => {
if (i > 0) console.log(i + '...');
else resolve(console.log("GO!"));
}, (seconds - i) * 1000);
}
});
}

그다지 좋은 함수는 아니지만 프라미스를 어떻게 만드는지를 잘 보여주고 있다. reslove와 reject는 함수다. 그런데 resolve나 reject를 여러 번 호출하든, 섞어서 호출하든 결과는 같다. 첫 번째로 호출만 것만 의미있다는 말이다. 프라미스는 성공 또는 실패를 나타낼 뿐이다.

이번엔 반환된 프라미스를 사용하는 방법을 알아보자.

countdown(5).then(
function() {
console.log("completed successfully");
},
function(err) {
console.log("experienced an error: " + err.message);
}
);

반환된 프라미스를 변수에 할당하지 않고 then 핸들러를 바로 호출했다. then 핸들러는 성공 콜백과 에러 콜백을 받는다. 경우는 단 두 가지, 성공 콜백이 실행되거나, 에러 콜백이 실행된다. 프라미스는 catch 핸들러도 지원하므로 핸들러를 둘로 나눠 써도 된다.

const p = countdown(5);
p.then(function() {
console.log("completed successfully");
});
p.catch(function(err) {
console.log("experienced an error: " + err.message);
});

count 함수가 에러를 내도록 수정해보자! 카운트 다운을 하다가 4를 만나면 에러를 낸다.

function countdown(seconds) {
return new Promise((resolve, reject) => {
for (let i = seconds; i >= 0; i--) {
setTimeout(() => {
if (i === 4) return reject(new Error("OMG"));
if (i > 0) console.log(i + "...");
else resolve(console.log("GO!"));
}, (seconds - i) * 1000);
}
});
}

숫자를 바꿔 가면서 몇 번 테스트를 해보자. 뭔가 이상하단 것을 알아차릴 수 있다. 4보다 작은 숫자를 넣어서 실행을 하면 정상적으로 잘 수행될 것이다. 그런데 4보다 큰 숫자를 넣어보자. 4가 출력될 차례가 되면 프로그램은 예상대로 ‘OMG’를 출력할 것이다. 그런데 여기서 멈추지 않고 3부터 다시 카운트다운으로 들어간다.

reject나 resolver가 함수를 멈추지는 않기 때문이다. 이들은 그저 프라미스의 상태를 관리할 뿐이다. countdown함수는 개선해야 할 부분이 더 있다. 우리에게 필요한 것은 카운트다운을 컨트롤 할 수 있는 기능이다.

프라미스는 비동기적 작업이 성공 또는 실패하도록 확정하는, 매우 안전하고 잘 정의된 매커니즘을 제공하지만 현재 진행 상황은 전혀 알려주지 않는다. 그렇기 때문에 다른 방법을 찾아야 한다.

다음 글에서는 이벤트 에 대해서 알아보고 이 문제를 해결해 보려고 한다.

다음 글: Part2


이 글은 Ethan Brown의 Learning javascript 3판을 참조한 글이다. 대부분의 예제와 글은 책에서 발췌했으며 약간의 축약, 수정을 거쳤다.

글이 좋았다면 👏🏻를 쳐주세요