Node.js에서 동시성 다루기와 예제

kakasoo·2021년 9월 5일
4
post-thumbnail

이 글은 Node.js를 더 잘 다루기 위해 비동기 제어를 설명합니다.
일부러 돌아가는 길을 선택함으로써 일반적으로 사용하지 않았을 법한 것을 설명합니다.

구글 페이지 가져오기

import axios from "axios";

async function getWebContent(url: string) {
    const { data } = await axios.get(url);
    return data;
}

(async () => {
	await getWebContent("https://google.com");
})();

즉시 실행 함수 형태로 구글의 메인 페이지 문서를 읽는 코드를 작성했습니다.
간단합니다.
다음으로는, 이를 10번 반복하는 함수를 만들 것입니다.

비동기적으로 구글 페이지 10번 가져오기

import axios from "axios";

async function getWebContent(url: string) {
    const { data } = await axios.get(url);
    return data;
}

(async () => {
    const urls = new Array(10).fill("https://google.com");

    urls.forEach(async (el) => {
        const google = await getWebContent(el);
        console.log(google);
    });
})();

운좋게도 Node.js는 모든 게 비동기적으로 동작합니다.
그래서 10번을 호출함에도 불구하고 1번을 호출하는 것과 거의 비슷한 시간 내에 실행됩니다.

하지만 10번을 불러오는 것을 성공했음에도, 여기에는 큰 문제가 있습니다.
바로 비동기적으로 호출된다는 점으로 인해, 실제 실행 순서가 의도와 다를 수 있다는 점입니다.

forEach문을 아래처럼 고쳐서, index를 찍어보도록 할까요?

import axios from "axios";

async function getWebContent(url: string) {
    const { data } = await axios.get(url);
    return data;
}

(async () => {
    const urls = new Array(10).fill("https://google.com");

    urls.forEach(async (el, i) => {
        const google = await getWebContent(el);
        console.log(i);
    });
})();

저는 실행 결과 7, 6, 9, 3, 2, 0, 1, 4, 5, 8의 순서로 출력이 되었습니다.
일반적인 생각으로는 0부터 9까지 차례대로 나와야 하는데 말이죠.

한 번 순서가 제대로 나오도록 고쳐봅시다.

동기적으로 구글 페이지 10번 가져오기

import axios from "axios";

// This "possibly" works in one of the Threads in a pool
async function getWebContent(url: string) {
    const { data } = await axios.get(url);
    return data;
}

(async () => {
    const urls = new Array(10).fill("https://google.com");

    for await (const url of urls) {
        const google = await getWebContent(url);
        console.log(google);
    }
})();

for await를 사용함으로써 간단하게 고칠 수 있었습니다.
순서는 이제 보장이 될 것입니다.
하지만 동기적으로 고침으로써 오히려 성능은 10배 ( n번 만큼 ) 느려지고 말았습니다.

그러면 비동기의 성능을 가지면서 순서를 보장할 수는 없는 걸까요?

순서를 보장하면서 동시적으로 가져오기

import axios from "axios";

async function getWebContent(url: string) {
    const { data } = await axios.get(url);
    return data;
}

(async () => {
    const urls = new Array(10).fill("https://google.com");
    const result = await Promise.all(urls.map((url) => getWebContent(url)));

    result.forEach((el) => console.log(el));
})();

순서도 보장된 상태로 가져올 수 있습니다.
정확히 말하면, 동시에 보내기는 하되, 돌아온 것을 순서에 맞게 정렬해줬다고 할 수 있겠네요.

Promise.all이나 Promise.allSettled, 그 외에도 몇 가지 좋은 메서드들이 이미 존재합니다.

이로 인해서 성능과 순서를 보장할 수 있게 됐네요.

EventEmitter를 이용한 비동기 처리

import { EventEmitter } from "events";
import axios from "axios";

const urls = new Array(10).fill("https://google.com");
const baseEvent = new EventEmitter();
const responses = [];

baseEvent.on("request", async (url) => {
    const { data } = await axios(url);
    baseEvent.emit("response", data);
});

baseEvent.on("response", (html) => {
    responses.push(html);

    if (responses.length === urls.length) {
        baseEvent.emit("end", responses);
    }
});

baseEvent.once("end", () => {
    console.log(responses.length);
});

urls.map((url) => baseEvent.emit("request", url));

Node.js에서 제공하는 EventEmitter는 훌륭한 메서드를 가지고 있고,
우리는 이를 통해서 손쉽게 옵저버 패턴을 구현할 수 있습니다.

심지어 성능 면에서도 Promise보다 더 우수합니다.

import axios from "axios";
import { EventEmitter } from "events";

class MyEventEmitter extends EventEmitter {
    constructor() {
        super();
        this.urls = [];
        this.responses = [];
    }

    async getWebContent(url: string) {
        const { data } = await axios.get(url);
        this.responses.push(data);

        if (this.urls.length === this.responses.length) {
            this.emit("finish", this.responses);
        }
    }

    addUrl(url) {
        if (typeof url === "string") {
            this.urls.push(url);
            return this;
        }

        this.urls.push(...url);
        return this;
    }

    work() {
        this.urls.map((url, i) => {
            this.emit("work", url);
        });
    }
}

console.time("emitter");
const urls = new Array(10).fill("https://google.com");
const myEventEmitter = new MyEventEmitter();

myEventEmitter
    .addUrl(urls)
    .on("work", (url) => myEventEmitter.getWebContent(url))
    .on("finish", (responses) => console.timeEnd("emitter"))
    .work();

또한 상속을 통해 클래스 형태로 만들 수도 있습니다.

여기까지로, 동기 비동기를 다루는 Node.js ( 엄밀히 말하면 비동기를 다루는 ) 방식을 배웠습니다.
사실 Node.js는 비동기 또는 non-blocking이라는 이름에서 알 수 있듯이,
비동기에 특화된 언어 ( 또는 엔진 ) 이라고 볼 수 있습니다.

따라서 Promise나 EventEmitter를 이용하면 그 특성을 더 잘 살릴 수 있을 것 같습니다.

참고 자료

Node.js 디자인 패턴 바이블 이라는 책에서 키워드를 얻었고,
이 글에서 영감을 얻어 작성한 글입니다.

for awaitPromise.all을 포함한 Promise 메서드들을 함께 보시면 더 좋을 거 같습니다.

profile
자바스크립트를 좋아하는 "백엔드" 개발자

0개의 댓글