콜백과 이벤트

kakasoo·2021년 9월 25일
0
post-thumbnail

이 글은 발표한 내용을 정리한 것입니다.

비동기


저는 비동기에 대해서 이렇게 이해했어요. 지금 코드가 돌아가는 환경이 아니라, 외부 환경에서 특정 기능을 동작할 때, 외부 환경을 기다리는 대신 현 환경에서의 기능을 진행하는 것이라고요. 사실 이건 이해하고 말했다기보다, 그냥 그렇게 보였다고 말하는 게 더 정확한 것 같아요. 제가 현재 보고 있는 책 ( Node.js 디자인 패턴 바이블 ) 에서는 이렇게 말하네요.

비동기식 프로그래밍에서는 파일 읽기 또는 네트워크 요청 수행과 같은 일부 작업을 백그라운드 작업으로 실행할 수 있습니다. 비동기 작업이 호출되면 이전 작업이 아직 완료되지 않은 경우에도 다음 작업이 즉시 실행됩니다. 이 상황에서는 비동기 작업이 끝났을 때 이를 통지받아 해당 작업의 결과를 사용하여 다음의 작업을 이어나가야 합니다.

Node.js에서 비동기 작업의 완료를 통지받는 가장 기본적인 메커니즘은 콜백입니다. 콜백은 비동기 작업의 결과를 가지고 런타임에 의해 호출되는 함수일 뿐입니다.

콜백은 다른 모든 비동기 메커니즘을 기초로 하는 것들의 가장 기본적인 구성 요소입니다. 실제로 콜백 없이는 프라미스가 존재할 수 없으며 따라서 async/await 또한 존재할 수 없습니다. 또한 스트림이나 이벤트 또한 불가능합니다. 이것이 콜백이 어떻게 작동하는지 알아야 하는 이유입니다.

제가 이해하던 게 그저 경험적인 서술이었다면, 이 책에서 말해주는 건 좀 더 객관적인 사실인 것 같습니다. 이제 어떻게 비동기를 설명할지 안 것 같네요.


콜백 패턴


비동기 세계에서 콜백은 동기적으로 사용되는 return 명령의 사용을 대신합니다. JavaScript는 콜백에 이상적인 언어입니다.

미리 말씀드리겠습니다. 콜팩 패턴은 아래 세 가지로 요약 가능해요.

  • 연속 전달 방식
    1. 동기 연속 전달 방식
    2. 비동기 연속 전달 방식
  1. 비 연속 전달 방식

연속 전달 방식

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');

동기와 비동기 연속 전달 방식의 차이를 아시겠나요? 사실 둘의 코드 면에서는 차이가 없고, 전달하려는 일급 객체 함수가 동기냐 비동기냐에 달렸습니다. 위 코드를 자세히 풀어 쓰면 아래처럼 됩니다.

우리의 자바스크립트는, 다음처럼 동작합니다. 코드를 따라가며 함수를 만날 때마다 스택에 쌓습니다. 스택에 쌓인 함수를 꺼내 하나 씩 실행합니다. 이 때, 비동기를 만나게 된다면 일단 이벤트 큐에 넣어서 해당 위치에서 실행되도록 합니다. 스택이 모두 비는 시점이 생기면, 이벤트 루프가 큐에서 하나 씩 이벤트 함수를 꺼냅니다. 이 이벤트 함수가 다시 새로운 스택의 시작점이 됩니다. "스택의 출발점이 된다."는 점은 몇 번 강조해도 모자를 것 같습니다.

이후, 새로운 스택에서 함수가 다시 실행되더라도 이전의 컨텍스트를 유지할 수 있습니다. 이는 클로저라는 특성 덕분입니다. 일급 객체와 클로저가 있기에 자바스크립트는 콜백에 친화적인 언어라고 할 수 있겠죠. 결론적으로, 동기 함수와 비동기 함수의 차이는, 조작을 완료할 때까지 블로킹하냐 안하냐로 볼 수 있겠습니다.

비 연속 전달 ( Non-CPS ) 콜백

하지만 콜백이 있다고 해서 모두 연속적이거나 비동기적인 건 아닙니다.

[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) }
}

위 코드를 사용하면 에러가 발생합니다. 사실 에러가 발생한다는 게 중요한 게 아닙니다, 더 중요한 건 에러를 식별하기 힘들다는 거죠. 명백한 이유도 없이 어떠한 오류도, 로그도 처리되지 않는 요청이 발생할 수 있단 게 위험입니다.

개선 방법 : 동기 API의 사용

  1. 동기함수를 씁니다. 예컨대, readFile 대신 readFileSync를 쓰면 됩니다.
    • 단, 이런 동기식 함수가 항상 존재하는 것은 아니며, 성능에도 영향을 줍니다.
  2. 동기함수인 경우, 동기 연속 전달 방식 대신에 직접 스타일의 코드를 작성합니다.
  3. 동기함수를 쓰는 경우 캐시를 해둡니다.
    • 단, 캐시를 하더라도 readFileSync과 같은 함수에서 하나의 큰 파일을 읽는 경우는?

지연 실행 (deferred execution)

아예 모든 함수를 비동기로 바꿔버려서 해결할 수도 있습니다.

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 패턴을 보면 좋을 거 같아요.

리액터 패턴과 이벤트 루프

대충 간략화한 그림이에요.

원래는 앞에 나온 그림이지만 한 번 짚어야 다음을 이해하는 데에 도움이 될 거 같습니다.

제가 이해한 대로 설명을 드릴게요.

  1. 애플리케이션이 실행됩니다. 분명 여기에는 비동기로 호출되는 함수가 있을 거에요.
  2. 그런 함수들에 일단은 약속된 상수를 주고, 이벤트 디멀티 플렉서로 보냅니다.
  3. 이벤트 디멀티 플렉서는, 이름과 같이 디멀티 플렉서하는 구간이에요.
    • 여러 전선이 얽혀서 동시에 신호를 받을 수 있는 걸 멀티 플렉서라고 하죠.
    • 디멀티 플렉서는 반대로 이벤트를 하나 하나 풀어내는 곳이라고 보면 될 거 같아요.
  4. 이벤트가 완성되면 바로 이벤트 큐로 보냅니다.
  5. 애플리케이션의 코드 중 일부가 완료될 때마다 제어권은 이벤트 루프로 갑니다.
  6. 이벤트 루프는 이벤트 큐의 함수를 꺼내서 사용합니다.
    • 이걸 스택에 다시 쌓는다고 생각하면 될 거 같아요.
  7. 애플리케이션의 코드가 다 사라지더라도, 결국 이벤트가 새로운 스택으로 이어지게 됩니다.
  8. 이벤트 큐의 모든 항목이 처리되고, 디멀티 플렉서에 보류중인 작업도 없으면 끝난 것입니다.

setImmediate()

process.nextTick()으로 지연된 콜백은 마이크로 태스크라고 불리며, 현재 작업이 종료되는 즉시 실행되게 됩니다. 즉, 다른 I/O 이벤트들보다 우선시됩니다. 반면 setImmediate()는 이벤트 큐의 이벤트 맨 뒤에 대기하게 됩니다.

process.nextTick()은 그래서, 재귀 함수에서는 매우 위험할 수 있어요. 계속 자기만 부를 거거든요. setImmediate()는 setTimeout(callback, 0)과 유사하다고 합니다. 하지만 위 정의 상, setImmediate()가 setTimeout(callback, 0)보다 더 빠르게 실행된다네요.

알면 좋을 것들

마지막으로 콜백에 대해서만 더 얘기해보도록 하겠습니다. 첫번째는 Node.js 콜백 규칙에 대해서입니다.

Node.js 콜백 규칙

const callback = (err, data) => {
	if (err) {
		handleError(err);
	} else {
		processData(data);
	}
}

readFile(filename, [options], callback)
  1. 콜백은 맨 마지막의 인자로 전합니다.
  2. 콜백 함수의 첫 인자는 오류를 담아야 한다.
  3. 콜백 함수의 두번째 인자는 결과를 담아야 한다.
  4. 콜백에서 오류가 발생하지 않은 경우, callback의 첫 인자는 null을 전해야 한다.
// 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);
})
profile
자바스크립트를 좋아하는 "백엔드" 개발자

0개의 댓글