본문 바로가기

오픈소스/노드

[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의 필수 메소드는 다음과 같습니다.

 

  • on(event, listener): 이 메소드를 사용하면 주어진 이벤트 유형에 대해 새로운 리스너를 등록할 수 있습니다.
  • once(event, listener): 첫 이벤트가 전달된 후 제거되는 새로운 리스너를 등록합니다.
  • emit(event, [arg1], [...]): 새 이벤트를 생성하고 리스너에게 전달할 추가적인 인자들을 제공합니다.
  • removeListener(event, listener): 지정된 이벤트 유형에 대한 리스너를 제거합니다.

 

1. EventEmitter 생성 및 사용

 전통적인 객체지향 프로그래밍에서는 관찰자 패턴에 인터페이스, 구체적인 클래스 그리고 계층구조를 요구하지만 Node.js에서는 훨씬 더 간단합니다. 관찰자 패턴은 이미 코어에 내장되어 있으며 EventEmitter 클래스를 통해 사용할 수 있습니다. 다음은 관찰 가능한 객체 만들어 처리하는 예제 입니다.

 

import { EventEmitter } from "events";
import { readFile } from "fs";

class FindRegex extends EventEmitter {
    constructor(regex) {
        super();
        this.regex = regex;
        this.files = [];
    }

    addFile(file) {
        this.files.push(file);
        return this;
    }

    find() {
        for (const file of this.files) {
            readFile(file, "utf-8", (err, data) => {
                if (err) {
                    return this.emit("error", err);
                }

                this.emit("fileread", file);

                const match = data.match(this.regex);

                if (match) {
                    match.map((value) => {
                        this.emit("found", file, value);
                    });
                }
            });
        }
        return this;
    }
}

const findRegexInstance = new FindRegex(/hello \w+/);
findRegexInstance
    .addFile("hello.txt")
    .addFile("hello2.txt")
    .find()
    .on("found", (file, match) => {
        console.log(`Matched in ${file} : ${match}`);
    })
    .on("error", (err) => {
        console.error(`Error emitted ${err.message}`);
    });

 

 FindRegex 객체가 EventEmitter 로부터 상속받은 on() 메소드를 어떻게 제공하는지 확인할 수 있습니다. 이것은 Node.js 생태계에서 꽤나 일반적인 패턴입니다. 

 

2. EventEmitter 메모리 누수

 관찰 가능 주체들에 대해서 오랜 시간 동안 구독을 하고 있을 때 더이상 그것들이 필요하지 않게 되면 구독을 해지하는 것이 매우 중요합니다. 구독 해지는 메모리 누수를 예방하고 리스너의 스코프에 있는 객체에 의해 더 이상 사용되지 않는 메모리 점유를 풀게 해줍니다. Node.js에서는 EventEmitter 리스너의 등록을 해지하지 않는 것이 메모리 누수의 주된 원인이 됩니다. 

 메모리 누수는 메모리가 더 이상 필요하지 않지만 해제되지 않아 애플리케이션의 메모리 사용을 무기한으로 증가시키는 원인을 제공하는 소프트웨어 결함 입니다. 모든 리스터들이 참조하는 메모리와 함께 도달 가능한 상태를 유지하는 것을 의미합니다. 애플리케이션에 의해 사용되는 메모리는 무한정 증가할 것이며, 떄로는 천천히 때로는 일찍이 그러나 결국에는 애플리케이션을 망가뜨리게 됩니다. 이러한 상황을 예방하기 위해서 EventEmitter의 removeListener() 메소드 로 리스너를 해제할 수 있습니다.

 

emitter.removeListener('an_event', listener)

 

findRegexInstance
    .addFile("hello.txt")
    .addFile("hello2.txt")
    .addFile("hello.txt")
    .find()
    .on("found", matchedFunc)
    .on("error", (err) => {
        console.error(`Error emitted ${err.message}`);
    });

function matchedFunc(file, match) {
    console.log(`Matched in ${file} : ${match}`);
    findRegexInstance.removeListener("found", matchedFunc);
}

 

 

 

# 마무리


  이 장에서는 처음으로 실용적 측면의 비동기적 코드를 접해보았습니다. 전체 Node.js에서 비동기적 기반의 큰 두갈래를 알아보았습니다. 그리고 그것의 사용에 관한 세부사항과 규약, 패턴을 탐구해보았습니다. 또한 비동기적 코드를 다룰 때 몇 가지 위험한 것들을 살펴보고 피하는 방법에 대해 배웠습니다. 다음 장에서는 콜백을 이용하여 복합적인 비동기 흐름 제어를 다루는지 배워보도록 하겠습니다.