on
[Node] 03. 콜백과 이벤트
[Node] 03. 콜백과 이벤트
본 문서는 Node.js 디자인 패턴 바이블을 읽고 리뷰를 남기고 있습니다. 문고들은 이 책의 일부분을 인용한 것임을 밝힙니다. 비동기식 프로그래밍에서는 파일 읽기 또는 네트워크 요청 수행과 같은 일부 작업을 백그라운드 작업으로 실행할 수 있습니다. 비동기 작업이 호출되면 이전 작업이 아직 완료되지 않은 경우에도 다음 작업이 즉시 실행됩니다. Node.js에서 비동기 작업의 완료를 통지받는 가장 기본적인 메커니즘은 콜백입니다. 콜백은 비동기 작업의 결과를 가지고 런타임에 의해 호출되는 함수일 뿐입니다.
비동기식 프로그래밍인 Node.js에 대해 배워보도록 하겠습니다. 비동기 코드를 작성하기 위해서 그것이 무엇을 의미하는지도 이해해 볼 것입니다. 또한 콜백 패턴과 연관이 있는 관찰자 패턴을 배워볼 것입니다.
# Contents
콜백 패턴
관찰자 패턴
# 콜백 패턴
콜백은 작업의 결과를 전달하기 위해서 호출되는 함수이며, 우리가 비동기 작업을 처리할 때 필요한 것입니다. 비동기 세계에서 콜백은 동기적으로 사용되는 return 명령의 사용을 대신합니다. JavaScript는 콜백에 이상적인 언어입니다. 그 이유는 함수가 일급 클래스 객체이면서 변수에 할당하거나 인자로 전달되거나 다른 함수 호출에서 반환되거나 자료구조에 저장될 수 있기 때문입니다.
이 섹션에서는 return 명령을 대신하여 콜백으로 이루어진 프로그래밍 스타일들을 분석해 볼 것입니다.
1. 연속 전달 방식
JavaScript에서 콜백은 다른 함수에 인자로 전달되는 함수이며, 작업이 완료되면 작업 결과를 가지고 호출합니다. 함수형 프로그래밍에서는 이런 식으로 결과를 전달하는 방식을 연속 전달 방식이라고 합니다.
개념을 명확히 하기 위해 간단한 동기 함수를 살펴보겠습니다. 아래는 동기식 연속 전달 방식입니다.
function add(a,b) { return a + b }
여기에 특별한 것은 없습니다. 결과를 return 문을 통해 호출자에게 전달됩니다. 이것을 직접 스타일이라고 하며, 동기식 프로그래밍에서 일반적으로 결과를 반환하는 방식입니다.
앞에 함수와 동일한 처리를 CPS로 바꾼 코드는 다음과 같습니다.
function addCps(a,b,callback) { callback(a + b) }
addCps() 함수는 동기 CPS 함수로 콜백 또한 작업이 완료되었을 때 작업을 완료합니다. 다음의 코드는 이를 증명합니다.
console.log('before') addCps(1, 2, result => console.log(`Result: ${result}`)) console.log('after')
이제 비동기 CPS 작업을 살펴보겠습니다.
function additionAsync(a, b, callback) { setTimeout(() => { callback(a + b) }, 100); }
앞의 코드에서 setTimeout()을 사용하여 콜백의 비동기 호출을 가정해보았습니다. setTimout()은 이벤트 큐에 주어진 밀리초 후에 실행되는 작업을 추가합니다. 이제 작업의 순서가 어떻게 변경되는지 살펴보겠습니다.
console.log("before"); additionAsync(1, 2, (result) => console.log(`Result: ${result}`)); console.log("after");
앞의 코드는 다음과 같은 결과를 출력합니다.
setTimeout()은 비동기 작업을 실행시키기 때문에 콜백의 실행이 끝날 때까지 기다리지 않는 대신, 즉시 반환되어 additionAsync()로 제어를 돌려주어 제어가 호출자에게 반환됩니다. 정리해보면, 동기 함수는 조작을 완료할 때까지 블로킹하지만 비동기 함수는 제어를 즉시 반환하고 결관느 이벤트 루프의 다음 사이클에서 핸들러로 전달됩니다.
콜백 인자가 있는 경우들에는 함수가 비동기식이거나 연속 전달 스타일을 사용한다고 생각하게 할 수 있는 상황들이 있습니다. 그러나 항상 그런 것은 아닙니다. 아래 예제를 보시죠.
var result = [1, 2, 3, 4, 5].map((val) => val * 2); console.log(result); //[2, 4, 6, 8, 10]
콜백은 배열 내의 요소를 반복하는데 사용될 뿐 연산 결과를 전달하지 않습니다. 실제로 여기서 결과는 직접적인 방식으로 동기적으로 반환됩니다.
2. 동기? 비동기
우리는 함수가 동기식인지 비동기식인지에 따라 실행 순서가 어떻게 변화되는지 살펴보았습니다. 비동기와 동기가 공존하는 함수에서의 처리는 어떻게 해야하는지 알아보도록 합시다.
아래는 예측할 수 없는 함수입니다.
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) => { if (err) throw err; cache.set(filename, data); cb(data); }); } } function createFileReader(filename) { const listners = []; inconsistentRead(filename, (value) => { listners.forEach((listner) => listner(value)); }); return { onDataReady(listner) { listners.push(listner); }, }; } const reader1 = createFileReader("hello.txt"); reader1.onDataReady(function (data) { console.log(`data : ${data}`); const reader2 = createFileReader("hello.txt"); reader2.onDataReady((data) => { console.log(`second data : ${data}`); }); });
이 코드는 다음과 같은 결과를 출력합니다.
출력에서 보면 두 번째 콜백이 호출되지 않습니다.
reader1이 생성되는 동안 비동기로 작업을 하여 리스너를 호출하지만, reader2는 동기로 작업을 하여 리스너를 호출하고 콜백함수를 푸쉬하게 됩니다. 따라서 리스너를 호출하지만 콜백함수가 등록되지 않아 아무 작업이 없는 것입니다.
그러면 이러한 작업을 해결법을 알아보겠습니다. 책에서는 2가지 방안을 제시하고 있습니다.
첫 번째는 동기 API의 사용입니다.
import { readFileSync } from "fs"; const cache = new Map(); function inconsistentRead(filename, cb) { if (cache.has(filename)) { cb(cache.get(filename)); } else { readFileSync(filename, "utf8", (err, data) => { if (err) throw err; cache.set(filename, data); cb(data); }); } }
두 번째는 지연 실행으로 비동시성을 보장하는 것 입니다.
import { readFile } from "fs"; const cache = new Map(); function inconsistentRead(filename, cb) { if (cache.has(filename)) { process.nextTick(() => cb(cache.get(filename))); } else { readFile(filename, "utf8", (err, data) => { if (err) throw err; cache.set(filename, data); cb(data); }); } }
3. Node.js 콜백 규칙
Node.js 에서 CPS API 및 콜백은 일련의 특정한 규칙을 따릅니다. 이 규칙은 Node.js 코어 API에 적용되지만 대다수의 사용자 영역 모듈과 애플리케이션에도 적용됩니다. 따라서 콜백이 사용되는 비동기 API를 설계할 때마다 이를 이해하고 반드시 준수해야 합니다.
콜백은 맨 마지막에
오류는 맨 처음에
오류 전파
캐치되지 않는 예외 - try, catch
process.on("uncaughtException", (err) => { console.error(`This is uncaughtException : ${err}`); process.exit(1); });
캐치되지 않는 예외가 애플리케이션의 일관성을 보장할 수 없는 상태로 만듭니다. 이로 인해 예기치 않는 문제가 발생할 수 있음을 이해하는 것이 중요합니다.
# 관찰자 패턴
Node.js 에서 기본적으로 사용되고 중요한 또 다른 패턴은 관찰자 패턴입니다. 리액터 그리고 콜백과 함께 관찰자 패턴은 비동기적인 Node.js 세계를 숙달하는 데 필수적인 조건입니다.
관찰자 패턴은 Node.js의 반응적 특성을 모델링하고 콜백을 완벼갛게 보완하는 이상적인 해결책입니다.
EventEmitter의 필수 메소드는 다음과 같습니다.
1. EventEmitter 클래스
전통적인 객체지향 프로그래밍에서는 관찰자 패턴에 인터페이스, 구체적인 클래스 그리고 계층구조를 요구하지만 Node.js에서는 훨씬 더 간단합니다. 관찰자 패턴은 이미 코어에 내장되어 있으며 EventEmitter 클래스를 통해 사용할 수 있습니다.
function log() { console.log("Hello world"); } module.exports.run = () => { log(); }
기억해야 할 개념은 module.exports 변수에 할당되지 않는 이상, 모듈 안의 모든 것이 비공개라는 것입니다. require()를 사용하여 모듈을 로드할 때 변수의 내용은 캐시되고 리턴됩니다.
2. module.exports 대 exports
exports.hello = () => { console.log("hello"); }
exports = () => { console.log("hello"); }
module.exports = () => { console.log("hello"); }
직접 확인해보세요!!
3. 모듈 정의 패턴
모듈 시스템은 종속성을 로드하는 메커니즘이 되는 것 외에 API를 정의하기 위한 도구이기도 합니다. API 디자인과 관련된 문제들의 경우 고려해야 할 주요 요소는 pirvate 함수와 public 함수 간의 균형입니다. 이것의 목표는 확장성과 코드 재사용 같은 소프트웨어 품질과의 균형을 유지하면서 정보 은닉 및 API 유용성을 극대화하는 것입니다.
이 섹션에서는 Node.js 에서 모듈을 정의할 때 export 지정, 함수, 클래스 그리고 인스턴스 내보내기, 몽키 패치와 같은 가장 많이 사용되는 몇가지 패턴을 분석합니다.
4. exports 지정하기
public API를 공개하는 가장 기본적인 방법은 exports 에 할당하는 것입니다. 이렇게 하면 exports에서 참조하는 객체의 속성에 공개할 모든 값을 할당합니다. 그 결과 외부에 공개된 객체는 일련의 관련 기능에 대한 컨테이너 또는 네임스페이스가 됩니다.
다음 코드는 이 패턴을 구현하는 모듈을 보여줍니다.
// logger.js 파일 exports.info = (msg) => { console.log(`info : ${msg}`); } exports.verbose = (msg) => { console.log(`verbose : ${msg}`); }
5. 함수 내보내기
가장 일반적인 모듈 정의 패턴 중 하나가 module.exports 변수 전체를 함수로 재할당하는 것입니다. 주요 장점은 모듈에 대한 명확한 진입점을 제공하는 단일 기능을 제공하여 그것에 대한 이해와 사용을 단순화 하는 것입니다. 또한 최소한의 노출이라는 원리에 잘 맞아 떨어집니다. 해당 방법은 서브스택 패턴으로 알려져 있습니다.
다음 코드는 이 패턴을 구현하는 모듈을 보여줍니다.
// logger.js 파일 module.exports = (msg) => { console.log(`info : ${msg}`); } module.exports.verbose = (msg) => { console.log(`info : ${msg}`); }
6. 클래스 내보내기
클래스를 내보내는 모듈은 함수를 내보내는 모듈이 특화된 것입니다. 차이점은 이 새로운 패턴을 통해 사용자에게 생성자를 사용하여 새 인스턴스를 만들 수 있게 하면서, 프로토타입을 확장하고 새로운 클래스를 만들 수 있는 기능을 제공할 수 있다는 것입니다.
다음 코드는 이 패턴을 구현하는 모듈을 보여줍니다.
// logger.js 파일 class Logger { constructor(name) { this.name = name; } log(msg) { console.log(`[${this.name}`] ${msg}`); } } module.exports = Logger
7. 인스턴스 내보내기
우리는 require()의 캐싱 메커니즘 도움을 통해 생성자나 팩토리로부터 서로 다른 모듈 간에 공유할 수 있는 상태 저장 인스턴스를 쉽게 정의할 수 있습니다.
다음 코드는 이 패턴을 구현하는 모듈을 보여줍니다.
// logger.js 파일 class Logger { constructor(name) { this.name = name; } log(msg) { console.log(`[${this.name}`] ${msg}`); } } module.exports = new Logger('DEFAULT');
# ESM 모듈
ESM 모듈은 ECMAScript 2015 명세의 일부분으로 JavaScript에 서로 다른 실행 환경에서도 적합한 공식 모듈 시스템을 부여하기 위해 도입되었습니다. ESM 명세는 CommonJS나 AMD와 같은 기존의 모듈 시스템에 있는 좋은 방안들은 유지하려고 애썼습니다. 문법은 매우 간단하면서 짜임새를 갖추고 있습니다. 순환 종속성에 대핝 ㅣ원과 비동기적 모듈을 로드할 수 있는 방법을 제공합니다.
ESM과 CommonJS 사이의 가장 큰 차이점은 ES 모듈은 static이라는 것입니다. 즉 임포트가 모든 모듈의 가장 상위 레벨과 제어 흐름 구문의 바깥쪽에 기술됩니다. 또한, 임포트할 모듈의 이름을 코드를 이용하여 실행 시에 동적으로 생성할 수 없으며, 상수 문자열만이 허용됩니다. 예를 들면, 다음의 코드는 ES 모듈 사용시에 적합하지 않습니다.
if (condition) { import module1 from 'module1' else { import module2 from 'module2' }
반면, CommonJS에서는 다음과 같이 작성하는 것이 전혀 문제되지 않습니다.
let module = null; if (condition) { module = require('module1') else { module = require('module2') }
1. Node.js에서 ESM 사용
Node.js는 모든 .js 파일이 CommonJS 문법을 기본으로 사용한다고 합니다. 따라서 .js 파일에 ESM 문법을 사용한다면 인터프리터는 에러를 낼 것입니다. Node.js 인터프리터가 CommonJS 모듈 대신 ES 모듈을 받아들일 수 있는 몇 가지 방법이 있습니다.
모듈 파일의 확장자를 .mjs 로 합니다.
모듈과 가장 근접한 package.json의 "type" 필드에 "module" 을 기재합니다.
2. exports와 imports 지정하기
ESM모듈에서는 기본적으로 모든 것이 private이며 export된 개체들만 다른 모듈에서 접근 가능합니다. export 키워드는 우리가 모듈 사용자에게 접근을 허용하는 개체 앞에 사용합니다. 예제를 보겠습니다.
// logger.js // 'log'로서 함수를 익스포트 export function log(message) { console.log(message) } // 'DEFAULT_LEVEL'로서 상수를 익스포트 export const DEFAULT_LEVEL = 'info' // 'LEVELS'로서 객체를 익스포트 export const LEVELS = { error: 0, debug: 1, warn: 2, data: 3, info: 4, verbose: 5, } // 'Logger'로서 클래스를 익스포트 export class Logger { constructor(name) { this.name = name; } log(message) { console.log(message); } }
우리가 모듈로부터 원하는 개체를 임포트하고 싶다면 import 키워드를 사용합니다. 문법은 꽤나 유연하고 하나 이상의 개체를 임포트할 수 있으며 다른 이름으로도 지정할 수 있습니다. 다음 예제를 보겠습니다.
import * as loggerModule from './logger.js' console.log(loggerModule)
이번 예제에서는 모듈의 모든 멤버를 임포트하고 loggerModule 변수에 할당하기 위해서 * 문법을 사용하였습니다. 예제의 출력은 다음과 같습니다.
만약 규모가 큰 모듈을 사용하고자 할 때, 모듈의 모든 기능ㄹ들을 원하지 않고 하나 혹은 몇개의 개체만을 사용하고 싶을 때 다음과 같은 방법이 있습니다.
import { log } from './logger.js' log('hello world')
하나 이상의 개체를 임포트하고 싶을 때에는 다음과 같이 합니다.
import { log, Logger } from './logger.js' log('hello world') const logger = new Logger('DEFAULT') logger.log('hello world')
임포트되는 개체의 이름을 as 키워드로 바꾸어줄 수 있습니다.
import { log as log2 } from './logger.js' log2('hello world')
3. export와 import 기본 값 설정하기
CommonJS에서 가장 많이 사용되는 특성은 이름이 없는 하나의 개체를 module.exports에 할당하여 익스포트 할 수 있다는 것이었습니다. ESM에서도 비슷한 동작을 할 수 있는데, export default 키워드를 사용하여 처리 할 수 있습니다. 아래 예제를 보겠습니다.
// logger.js export default class Logger { constructor(name) { this.name = name; } log(msg) { console.log(msg); } }
이 경우에 Logger라는 이름이 무시되며, 익스포트되는 개체는 default 라는 이름 아래 등록됩니다. 다음 예제에서는 임포트 처리를 구현하고 있습니다.
// main.js import MyLogger from './logger.js' constr logger = new MyLogger('DEFAULT') logger.log('hello world')
4. 혼합된 export
ESM 모듈에서는 이름이 지정된 export와 default export를 혼합하여 사용 가능합니다. 예제를 살펴봅시다.
// logger.js export default function log(msg) { console.log(msg); } export function info(msg) { log(`info : ${msg}`) }
우리가 가진 default export와 이름을 가진 export를 임포트 하기를 원한다면 다음과 같은 형식을 사용합니다.
// main.js import MyLogger, { info } from './logger.js'
이 예제에서는 logger.js로부터 default export를 MyLogger라는 이름으로, 그리고 info를 임포트하였습니다.
책에 있는 내용을 요약하자면, 하나의 기능을 익스포트 하고 싶을 경우에는 default export를 사용하되, 이름을 사용한 export 사용에 습관을 들이는 것이 일반적으로 좋은 방법이라고 합니다.
# 모듈의 수정
우리는 읽기 전용 라이브 바인딩인 ESM 모듈을 통해 개체들을 임포트하였고, 외부 모듈에서 그것을 재 할당하는 것이 불가능하다는 판단을 이미 알고 있을 것입니다. default export나 이름을 갖는 export의 바인딩을 바꿀 수 없는 것은 사실이지만, 바인딩이 객체라면 우리는 여전히 객체의 특정 속성을 변경하는 것이 가능합니다. 예제를 보시는 편이 빠를 것입니다.
// mock-read-file.js import fs from "fs" const originalReadFile = fs.readFile let mockedResponse = null function mockedReadFile(path, cb) { setImmediate(() => { cb(null, mockedResponse) }) } export function mockEnable(respondWith) { mockedResponse = respondWith fs.readFile = mockedReadFile } export function mockDisable() { fs.readFile = originalReadFile }
간단하게 설명하도록 하겠습니다.
처음으로 fs 모듈을 임포트하여 fs.readFile함수를 저장합니다. fs.readFile의 함수를 변경하는 로직이 있기 때문에 원래의 함수 로직을 저장하는 것입니다.
mockedReadFile에서는 콜백함수를 받아서 처리하는 부분입니다. mockedResponse 객체를 콜백 함수에 넘겨주는 역할 밖에 하지 않습니다.
다음 mockEnable은 fs.readFile의 함수를 변경하는 역할입니다. 그 반대는 mockDisable 함수이겠군요.
간단한 로직을 통해 모듈을 수정할 수 있었습니다. 이러한 수정된 모듈을 사용하는 간단한 예제를 살펴보시죠.
// main.js import fs from 'fs' import { mockEnable, mockDisable } from './mock-read-file.js' mockEnable(buffer.from('hello world')) fs.readFile('fake-path', (err, data) { if (err) console.error(err) console.log(data.toString()) }) mockDisable()
간단한 예제로 모듈의 수정을 마무리하도록 하겠습니다. 사실 상 모듈을 수정하는 일은 별로 좋은 판단은 아니라고 생각합니다. 모듈의 구조를 변경하거나 기능을 추가하려고 할 때 proxy 패턴을 사용하기 때문에 이러한 형태를 더 깊게 배운다는 것은 불필요한 행동이라고 생각합니다. 책의 내용에서는 더 깊게 배우고 판단하지만, 저희는 여기까지만 배우도록 하겠습니다.
# 마무리
현재까지는 이론적인 측면과 어떠한 자체적인 기능을 배운다는 측면이 더욱 강하였습니다. 그 다음부터는 JavaScript의 핵심 내용인 콜백과 이벤트를 배우며 Node.js에서는 프로그래밍을 어떻게 해야하는 지 알려드리도록 하겠습니다.
from http://junhokims.tistory.com/41 by ccl(A) rewrite - 2021-10-05 17:27:41