이 글은 발표한 내용을 정리한 것입니다.
저는 비동기에 대해서 이렇게 이해했어요. 지금 코드가 돌아가는 환경이 아니라, 외부 환경에서 특정 기능을 동작할 때, 외부 환경을 기다리는 대신 현 환경에서의 기능을 진행하는 것이라고요. 사실 이건 이해하고 말했다기보다, 그냥 그렇게 보였다고 말하는 게 더 정확한 것 같아요. 제가 현재 보고 있는 책 ( Node.js 디자인 패턴 바이블 ) 에서는 이렇게 말하네요.
비동기식 프로그래밍에서는 파일 읽기 또는 네트워크 요청 수행과 같은 일부 작업을 백그라운드 작업으로 실행
할 수 있습니다. 비동기 작업이 호출되면 이전 작업이 아직 완료되지 않은 경우에도 다음 작업이 즉시 실행됩니다. 이 상황에서는 비동기 작업이 끝났을 때 이를 통지받아 해당 작업의 결과를 사용하여 다음의 작업을 이어나가야 합니다.
Node.js에서 비동기 작업의 완료를 통지받는 가장 기본적인 메커니즘은 콜백입니다. 콜백은 비동기 작업의 결과를 가지고 런타임에 의해 호출되는 함수
일 뿐입니다.
콜백은 다른 모든 비동기 메커니즘을 기초로 하는 것들의 가장 기본적인 구성 요소입니다. 실제로 콜백 없이는 프라미스가 존재할 수 없으며 따라서 async/await 또한 존재할 수 없습니다. 또한 스트림이나 이벤트 또한 불가능합니다. 이것이 콜백이 어떻게 작동하는지 알아야 하는 이유입니다.
제가 이해하던 게 그저 경험적인 서술이었다면, 이 책에서 말해주는 건 좀 더 객관적인 사실인 것 같습니다. 이제 어떻게 비동기를 설명할지 안 것 같네요.
비동기 세계에서 콜백은 동기적으로 사용되는 return 명령의 사용을 대신합니다. JavaScript는 콜백에 이상적인 언어입니다.
미리 말씀드리겠습니다. 콜팩 패턴은 아래 세 가지로 요약 가능해요.
JavaScript에서 콜백은 다른 함수에 인자로 전달되는 함수이며, 작업이 완료되면 작업 결과를 가지고 호출됩니다. 함수형 프로그래밍에서 이런 식으로 결과를 전달하는 방식을 연속 전달 방식 ( CPS : Continuation-Passing Style ) 이라고 합니다. 이는 일반적인 개념이며 항상 비동기 작업과 관련이 있는 것은 아닙니다.
// 직접 스타일의 코드 ( Direct Style )
const add = (a, b) => a + b;
// 동기식 연속 전달 방식 ( 동기 CPS )
const add = (a, b, callback) => callback(a, b);
a,b를 뒤에 있을 callback에 맡기면 되고, 만약 callback2가 있다면 이후 계속 전달하면 됩니다. 동기적으로 작성된 코드는 실행 순서가 보장됩니다. 사실 동기식으로 작성한 경우에는 직접 스타일의 코드가 더 낫다고 말할 수 있습니다.
console.log('before');
addCps(1,2, result => console.log(result));
console.log('after');
// before, result, after 순으로 console.log 된다.
console.log('before');
addtionAsync = (a, b, callback) => setTimeout(() => callback(a + b), 100);
console.log('after');
동기와 비동기 연속 전달 방식의 차이를 아시겠나요? 사실 둘의 코드 면에서는 차이가 없고, 전달하려는 일급 객체 함수가 동기냐 비동기냐에 달렸습니다. 위 코드를 자세히 풀어 쓰면 아래처럼 됩니다.
우리의 자바스크립트는, 다음처럼 동작합니다. 코드를 따라가며 함수를 만날 때마다 스택에 쌓습니다. 스택에 쌓인 함수를 꺼내 하나 씩 실행합니다. 이 때, 비동기를 만나게 된다면 일단 이벤트 큐에 넣어서 해당 위치에서 실행되도록 합니다. 스택이 모두 비는 시점이 생기면, 이벤트 루프가 큐에서 하나 씩 이벤트 함수를 꺼냅니다. 이 이벤트 함수가 다시 새로운 스택의 시작점이 됩니다. "스택의 출발점이 된다."는 점은 몇 번 강조해도 모자를 것 같습니다.
이후, 새로운 스택에서 함수가 다시 실행되더라도 이전의 컨텍스트를 유지할 수 있습니다. 이는 클로저라는 특성 덕분입니다. 일급 객체와 클로저가 있기에 자바스크립트는 콜백에 친화적인 언어라고 할 수 있겠죠. 결론적으로, 동기 함수와 비동기 함수의 차이는, 조작을 완료할 때까지 블로킹하냐 안하냐로 볼 수 있겠습니다.
하지만 콜백이 있다고 해서 모두 연속적이거나 비동기적인 건 아닙니다.
[1, 5, 7].map(el => el - 1); // [0, 4, 6]
값을 계속 전달하기 위한 목적이 아니라 배열 내에서 순회하려는 목적으로 만든 것입니다. 연속 전달과 비 연속 전달 방식에는 문법적인 차이가 없습니다. 그저 콜백이 어떻게 만들어졌는가에서 비롯될 뿐이므로, 이는 각 콜백의 API 문서를 봐야 합니다.
예측할 수 없는 상황을 피해야 합니다.
import { readFile } from 'fs';
const cache = new Map();
function inconsistentRead(filename, cb) {
if (cache.has(filename)) {
cb(cache.get(filename))
} else {
readFile(filename, 'utf8', (err, data) => {
cache.set(filename, data);
cb(data);
})
}
}
이 함수는 readFile을 이용한 예제입니다. 이 함수의 문제점이 보이시나요?
이 함수는 if문에서는 동기적으로, else문에서는 비동기적으로 작동하게 만들어졌습니다.
function createFileReader (filename) {
const listeners = [];
inconsistentRead(filename, (value) => { // 위에서 만든 함수
listeners.forEach(listener => listener(value));
});
return { onDataReady : listener => listeners.push(listener) }
}
위 코드를 사용하면 에러가 발생합니다. 사실 에러가 발생한다는 게 중요한 게 아닙니다, 더 중요한 건 에러를 식별하기 힘들다는 거죠. 명백한 이유도 없이 어떠한 오류도, 로그도 처리되지 않는 요청이 발생할 수 있단 게 위험입니다.
아예 모든 함수를 비동기로 바꿔버려서 해결할 수도 있습니다.
import { readFile } from 'fs';
const cache = new Map();
function consistentReadAsync(filename, cb) {
if (cache.has(filename)) {
process.nextTick(() => cb(cache.get(filename)));
} else {
readFile(filename, 'utf8', (err, data) => {
cache.set(filename,data);
cb(data);
})
}
}
process.nextTick() 을 활용하여 실행을 연기해, 콜백의 비동기적 호출을 보장할 수 있습니다. 이는 동기 콜백 호출이 동일한 이벤트 루프 사이클에 즉시 실행되는 대신, 가까운 미래에 실행되도록 예약하는 것입니다. 즉, 다음 번 이벤트 루프로 넘어갈 때 즉시 콜백을 실행하도록 하는 것입니다. 다음은 setInterval에 대해서 말씀드릴 건데, 그 전에 Reactor 패턴을 보면 좋을 거 같아요.
대충 간략화한 그림이에요.
원래는 앞에 나온 그림이지만 한 번 짚어야 다음을 이해하는 데에 도움이 될 거 같습니다.
제가 이해한 대로 설명을 드릴게요.
process.nextTick()으로 지연된 콜백은 마이크로 태스크라고 불리며, 현재 작업이 종료되는 즉시 실행되게 됩니다. 즉, 다른 I/O 이벤트들보다 우선시됩니다. 반면 setImmediate()는 이벤트 큐의 이벤트 맨 뒤에 대기하게 됩니다.
process.nextTick()은 그래서, 재귀 함수에서는 매우 위험할 수 있어요. 계속 자기만 부를 거거든요. setImmediate()는 setTimeout(callback, 0)과 유사하다고 합니다. 하지만 위 정의 상, setImmediate()가 setTimeout(callback, 0)보다 더 빠르게 실행된다네요.
마지막으로 콜백에 대해서만 더 얘기해보도록 하겠습니다. 첫번째는 Node.js 콜백 규칙에 대해서입니다.
const callback = (err, data) => {
if (err) {
handleError(err);
} else {
processData(data);
}
}
readFile(filename, [options], callback)
// passport-local document example
passport.use(new LocalStrategy(
function(username, password, done) {
User.findOne({ username: username }, function (err, user) {
if (err) { return done(err); }
if (!user) { return done(null, false); }
if (!user.verifyPassword(password)) { return done(null, false); }
return done(null, user);
});
}
));
예제로 가져왔는데요, 여기서도 done이라는 건 콜백을 뜻하죠. 인증에 성공한 경우 로그인으로 보내야 하니 err는 null로, 실패한 경우에는 err를 전달하죠. 이제보니 이것도 Node.js 콜백 규칙을 따르고 있었군요.
import readJson(filename, callback) {
readFile(filename, 'utf8', (err, data) => {
let parsed;
if (err) {
// 에러를 전파하고 현재의 함수에서 빠져나오도록 처리하는 구간
return callback(err);
}
try {
parsed = JSON.parse(data);
}
catch(err) {
// 파싱 에러를 전파하고 현재 함수에서 빠져나오도록 처리하는 구간
return callback(err);
}
callback(null, parse);
})
}
주목할 점은, ( 책에서 말하기 전까지 이걸 주목해본 적은 없습니다만 )
fs 모듈의 readFile 함수는 err를 밖으로 return 하지 않고 콜백으로 전한다는 점입니다. 또한 try catch 문 밖에서 callback을 호출한다는 점도 흥미로운 부분입니다.
비동기 함수의 콜백 내에서 에러는 밖으로 전달될 수도 있습니다. ( 위 상황에서 JSON.parse() ) 따라서 에러를 발생할 수 있는 경우에는 try catch가 필수적입니다. 만약 try catch문이 없어진다면, 콜백 내의 오류는 스택으로 이동하여 마지막에 발견됩니다.
( Node.js의 마지막, 이벤트 루프의 콘솔에서 throw 됩니다. )
이러한 경우를 위해 어플리케이션 종료 전 자원을 정리하거나 로그를 남길 수 있는 방법도 있습니다.
process.on('uncaughtException', (err) => {
console.error(`This will catch at last the JSON parsing exception : ${err.message}`);
// 원하는 후속 조치를 작성
// 어플리케이션 종료, 아래 코드가 없을 시 애플리케이션을 종료하지 않는다.
process.exit(1);
})