FrontEnd/JavaScript

NodeJS 디자인 패턴 1편

devdubu 2025. 4. 9. 20:41

머리말

정말 오랜만의 복귀 입니다.
울산에서 출장 덕분에 조금 많이 바빳고, 바쁜 와중에도 조금이라도 더 지식을 쌓으려고 노력했던 흔적들을 이제는 다듬을 때가 되어서 돌아왔습니다.

이제는 개인적으로 진행하는 프로젝트 및 제가 프로젝트를 진행하면서 나왔던 트러블들에 대한 이슈 해결 등등을 정리해서 하나씩 올려보려고 합니다.

많관부입니다!!

위 시리즈는 NodeJS 디자인 패턴 책을 읽고 후기 및 정리 글로 올리는 것입니다.

개인적인 생각이지만,
하나의 프로그램을 잘 사용하기 위해서는 그 프로그램이 개발자의 의도를 잘 최대한 활용하는 것이 제일 잘 사용하는 방법 인것 같습니다.

 

이를 토대로 생각했을 때, NodeJS로 주력으로 개발하는 개발자로서, NodeJS를 만든 저자의 철학은 당연히 알아야한다고 생각이 들었습니다.

아래의 NodeJS 디자인 패턴의 저서를 정리하여 만든 문서 입니다.
혹여라도 틀린 내용이 있다면, 댓글로 부탁 드리겠습니다.

NodeJS 철학

경량 코어

NodeJS는 프로그램 코드를 구성하는 기본 수단으로서 모듈 개념을 사용합니다.

그 중 한가지는 최소한의 기능 세트를 가지고 코어의 바깥 부분에 유저랜드(userland) 혹은 유저스페이스(userspace)라 불리는 사용자 전용 모듈 생태계를 두는 것 입니다.

 

엄격하게 관리되어 안정적이지만, 느리게 진화하는 해결책을 갖는 대신
커뮤니티가 사용자 관점에서 폭 넓은 해결책을 실험 해볼 수 있는 자유를 주었습니다.

 

코어를 최소한의 기능 세트로 관리하는것은 관리의 관점에서 편리할 뿐 아니라
전체 생태계 진화에 있어 긍정적인 문화적 영향을 미칠 수 있습니다.

코어는 최소한의 기능 세트

  • 코어를 개발하는 개발자의 입장으로서,,최근에 뼈져리게 느끼고 있는 부분입니다.

 

NodeJS에서 가장 널리 통용되는 원칙 중 하나는 코드의 양 뿐만 아니라
범위의 측면에서도 작은 모듈을 디자인 하는 것입니다.

이 원칙은 Unix 철학에 근거하는데, 특히 다음 두가지 수칙이 있습니다.

  • 작은 것이 아름답다
  • 각 프로그램은 한 가지 역할만 잘 하도록 만들어라

사실상 저 위의 철학은 어디서 많이 보던 철학이긴 합니다.
사실 다른 분들도 잘은 기억안나도, 어디선가 많이 본 철학일 것입니다.

 

NodeJS 는 이 개념을 완전히 새로운 차원으로 끌어올렸습니다.

 

NodeJS 는 패키지 관리의 도움을 받아, 각 패키지가 자신이 필요로하는 버전의 종속성 패키지 들을 갖도록 함으로서 종속성 지옥에서 벗어나게 해줍니다.

 

이러한 측면은 패키지가 충돌의 위험 없이 잘 집중화 되고 많은 수의 작은 종속성을 가질 수 있도록 해줍니다.


다른 플랫폼에서는 비 실용적이고, 적용 불가능한 반면, NodeJS에서는 이러한 관행이 표준입니다.

이러한 것이 재 사용성 측면을 향상시켜 줍니다.

쉬운 해석

  • 사실상, NodeJs코어를 가볍게 하고, 나머지의 부가 기능은 커뮤니티 즉, 개발자들의 손에 맡겼다고 이해하시면 쉬울 겁니다.
  • 코어를 개발하다보면,,필연적으로 많은 커스터마이징을 요청 받기에,,
  • 많은 기능을 때려넣어서, 많은 수의 요구사항을 대응 하는 것보다, 적은 기능으로 개발자들에게 자유도를 높여주는 것이 좋다 라는 예시죠

작은 외부 인터페이스

NodeJS의 모듈들이 갖는 장점은 작은 사이즈와 작은 범위 그리고 최소한의 기능 노출 입니다.

대부분의 경우 컴포넌트 사용자는 기능의 확장을 필요로 하거나 부가적인 고급 기능들의 활용 없이 제한되고 집중화 된 기능에만 관심이 있습니다.

NodeJS 에서 모듈을 정의하는 가장 일반적인 패턴은 ==명백한 단일 진입점==을 제공하기 위해서 단 하나의 함수나 클래스를 노출 시키는 것 입니다.

개인적인 해석

  • 개인적으로 해당 구절을 보면서는 index.js가 먼저 떠올랐습니다.
  • NodeJS에서 가장 흔한 entry point 영역이죠, 다른 해석이 있다면 댓글 부탁드립니다.

 

NodeJS의 많은 모듈 들의 특징 중또 다른 하나는 그들이 확장보다는 사용 되기 위해서 만들어 졌다는 것입니다.

 

확장의 가능성을 금지하기 위해 모듈 내부 접근을 제한한다는 것이 덜 유연 하다고 생각되지만,
사실은 usecase를 줄이고, 구현을 단순화 하며, 유지관리를 용이하게 하고, 가용성을 높인다는 장점이 존재합니다.

장점이자 단점

  • 사실 Java 처럼 접근 제한자가, protected, public, private 처럼 다양한걸 원했지만,
  • NodeJS 는 그러지 못합니다. 하지만, 위에 말했듯이,,,장점이자 단점으로 작용이 되긴 합니다.

실제로 이는 내부를 외부에 노출시키지 않기 위해 클래스보다 함수를 노출시키는 것을 선호한다는 것을 의미합니다.
어쩌면 의도된 NodeJs의 철학일지도 모르겠습니다.

간결함과 실용주의

Keep It Simple, Stupid(KISS)

  • 단순함이야말로 궁극의 정교함이다.

리차드가브리엘이 그의 에세이에 다음과 같이 말했습니다.

디자인은 구현과 인터페이스 모두에서 단순해야 한다.

  • 구현이 인터페이스보다 더 단순해야한다는 것은 더욱 중요하다.
  • 단순함이 디자인에서 가장 중요한 고려사항이다.

개발을 하게 된다면, 정말 잘 느껴지는 구절이라고 생각이 듭니다.

NodeJS 는 어떻게 작동하는가

위에서 NodeJS의 철학에 대해서 공부해봤으니, 이제는 NodeJS 의 작동 원리에 대해서 알아볼 시간 입니다.

I/O 는 느리다.

I/O는 컴퓨터의 기본적인 동작들 중에서 가장 느리다.

RAM에 접근하는데에는 ns(나노 sec) 인 반면에, 디스크와 네트워크 접근하는데에는 ms 가 걸린다.

대역폭도 마찬가지다.


RAM의 전송률은 GB/s 단위로 일관되게 유지되는 반면, 디스크나 네트워크 전송률은 MB/s에서 GB/s까지 다양합니다.

CPU 측면에서는 I/O가 많은 비용을 요구하지 않지만, 보내지는 요청과 작업이 완료되는 순간 사이의 지연이 발생하게 됩니다.

 

게다가 인간이라는 요소를 고려해봐야합니다.

 

실제로 사람이 하는 마우스 클릭처럼 애플리케이션의 입력이 일어나느 많은 상황에서 I/O의 속도와 빈도는 기술적인 측면에만 의존하지 않으며, 디스크나 네트워크 보다 느릴 수 있습니다.

간단 요약

  • 사실 컴공 혹은 컴퓨터 공부를 하신 분들이랑 익히 아는 내용이지만, CPU, RAM, 디스크, 마우스 키보드 이벤트 등등을 모든 것을 고려하면
  • I/O 자체는 매우 느린 이벤트라는 것을 인지하고 계실겁니다.

블로킹 I/O

전통적인 Blocking I/O 프로그래밍에서는 I/O를 요청하는 함수의 호출은 ==작업이 완료될 때까지== 스레드의 실행을 차단합니다.
차단 시간은 ms 부터 사용자 입력 시 까지 고려하면 분 단위로 올라갑니다.

다음 의사코드는 소켓을 가지고 작업이 수행되는 일반적인 Blocking Thread를 보여줍니다.

// data가 사용가능해질 때까지 스레드를 블로킹
data = socket.read();

// data 사용 가능
print(data)

 

소켓의 각각의 I/O 작업이 다른 연결의 처리를 차단하기때문에,
Blocking I/O를 사용하여 구현된 웹 서버 같은 층 스레드 내에서 여러 연결 처리를 하지 못하는 것은 자명한 일입니다.

 

이 문제를 해결하기 위한 전통적인 접근 방법은 각각의 동시 연결을 처리하기 위해서 개별의 스레드 또는 프로세스를 사용하는 것입니다.

이 방법은 I/O 작업이 각각의 스레드에서 처리되기 때문에 I/O작업으로 인해 블로킹 된 스레드가 다른 연결 들의 가용성에 영향을 미치지 않습니다.

해석

  • 결국은 Blocking I/O의 핵심은 작업이 완료될 때까지 다른 요청은 ==차단== 하는 것입니다.
  • 쓰레드라는 말이 어렵지만, 결국 Blocking I/O의 방식에서 병렬로 처리하고 싶다면,
  • 각 I/O는 독립적으로 실행되어야한다는 것입니다.
    • 이에 전통적인 방식이 쓰레드 Or 프로세스를 여러개를 병렬로 수행하는 것이죠

 

위 그림은 관련 연결 로부터 새로운 데이터를 받기 위해 유휴 상태에 있는 각 스레드의 처리 시간을 강조하고 있습니다.

 

예를 들어, 데이터베이스 File System 과 상호작용할 때와 같이 모든 유형의 I/O가 요청 처리를 차단할 수 있다는 것을 생각해보면 I/O 작업의 결과를 위해서 스레드가 꽤 많이 블로킹 되는 것을 알 수 있습니다.

 

안타깝게도 스레드는 시스템 리소스 측면에서 비용이 저렴하지 않습니다.

메모리는 소모하고 컨텍스트 전환을 유발하여 대부분의 시간도안 사용하지 않는 장시간 실행 스레드를 가지게 됨으로써 귀중한 메모리와 CPU 사이클을 낭비하게 됩니다.

그래서 쓰레드는 무적?

  • 그럴리가 없다는 건 다들 아실듯 합니다.
  • 쓰레드 자체적으로는 위의 이유로, 굉장한 자원 소모를 요하고,
  • 또한 완벽한 독립성을 보장해서 동시 다발적으로 요청을 처리할 수 있는가? 에 대한 답은 사진에 나와있듯이
  • 유휴 시간이 또한 필요합니다.
    • 쉽게 말하자면 F1의 피트스탑이죠
    • F1 자동차가 아무리 튼튼해도 미친듯이 계속 달리기엔 소모성 부품들은 갈아줘야하니까..
    • 야매 설명이지만, 실제론 Busy Waiting 기술 때문에 그렇습니다.

Busy Waiting에 대한 기술 설명은 추후에 링크로 수정해서 업로드하겠습니다!

Non Blocking I/O

대부분의 최신 운영체제는 리소스에 접근하기 위해서 Blocking I/O 외에도 ==Non Blocking I/O== 라고 불리는 다른 메커니즘을 지원합니다.

 

이 운영모드에서 시스템 호출은 데이터가 읽혀지거나 쓰여지기를 기다리지 않고, 항상 즉시 반환됩니다.

위 부분이 Non Blocking을 설명하는데 핵심이 됩니다.

 

Blocking에서의 설명에서는 다른 요청을 차단하고, 기다린다고 되어있습니다.

하지만, Non Blocking은 반대로 요청을 차단하지 않고, 기다리지 않습니다.

 

즉, 하던거 마저 한다고 생각하시면 됩니다.
요청이 오던 말던~ 난 내 갈길 가련다~ 이런 느낌이죠

 

그렇다고 요청이 온걸 실행하지 않는 다는 것은 아닙니다.

위 그림이 그나마 설명하는데 제일 쉬운 그림인 듯 해보입니다.

 

B를 실행하지 않는 건 아니지만, 실행은 하지만, 메인으로 실행하는 건 아니란 말이죠
굳이 설명하자면, Background 작업 이라는 것이고, Main으로 보여지는 건 A라는 것이죠

 

호출 순간에 사용 가능한 결과가 없는 경우, 함수는 단순히 미리 정의된 상수를 반환하여 그 순간에 사용 가능한 데이터가 없다는 것을 알립니다.

 

예를 들어,Unix 운영체제에서 운영 모드를 Non Blocking 으로 변경하기 위해서 기존 파일 디스크립터를 조작하는 fcntl() 함수가 사용됩니다.

 

우선 리소스가 Non Blocking모드에 있고, 리소스가 읽힐 준비가 된 데이터를 가지고 있지 않다면,
모든 읽기 작업은 실패함과 동시에, 코드 EAGAIN 을 반환합니다.

 

이러한 종류의 Non Blocking I/O 를 다루는 가장 기본적인 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 적극적으로 polling 하는 것입니다.

 

이것을 바쁜 대기(busy-waiting)이라고 합니다.
아래의 의사코드는 Non Blocking 와 polling loop를 사용하여 여러 리소스로부터 읽어 들이는 것이 어떻게 가능한지 보여줍니다.

resouces = [socketA, socketB, fileA]
while(!resources.isEmpty()){
    for(resource of resources){
        // 읽기를 시도
        data = resource.read()
        if(data === NO_DATA_AVAILIABLE){
            // 이 순간에는 읽을 데이터가 없음
            continue
        }
        if(data === RESOURCE_CLOSE){
            // 리소스가 닫히고 리스트에서 삭제
            resouces.remove(i)
        }else{
            // 데이터를 받고 처리
            consumData(data)
        }
    }
}

보시시피 간단한 기법으로 서로 다른 리소스를 같은 스레드 내에서 처리 할 수 있지만, 여전히 효율적이지 않습니다.
실제로 앞의 예제에서 루프는 사용할 수 없는 리소스를 반복하는 데에 소중한 CPU 를 사용합니다.

 

저도 사실 위 책의 설명을 보고 이해 못합니다 ㅋㅋ 그러니 다들 화이팅입니다.

이벤트 디멀티플렉싱

Busy waiting은 Non Blocking 리소스 처리를 위한 이상적인 기법이 아닙니다.
다행히도, 대부분의 운영체제는 Non Blocking 리소스를 효율적인 방법으로 처리하기 위한 기본적인 메커니즘을 제공합니다.

이 메커니즘을 동기 이벤트 디멀티플렉서 또는 이벤트 통지 인터페이스라고 합니다.

멀티플렉싱?

  • 전기통신 용어로서 여러신호들을 하나로 합성하여 제한된 수요범위 내에서 매개체를 통하여 쉽게 전달하는 방법을 나타냅니다.

디멀티플렉싱?

  • 신호가 원래의 구성요소로 다시 분할되는 작업입니다.
  • 두 용어는 비디오 처리를 포함한 여러 분야에서 서로 다른 것들을 합성과 분할하는 일반적인 작업을 설명하기 위해서 사용됩니다.

 

우리가 말하고 있는 동기 이벤트 디멀티플렉서는 여러 리소스를 관찰하고 이 리소스들 중에 읽기 또는 쓰기 연산의 실행이 완료 되었을 때 새로운 이벤트를 반환합니다.

 

여기서 찾을 수 있는 이점은 동기 이벤트 디멀티플렉서가 처리하기 위한 새로운 이벤트가 있을 때 까지 블로킹 된다는 것입니다.

watchedList.add(socketA, FOR_READ)
watchedList.add(fileB, FOR_READ)
while(events = demultiplexer.watch(watchedList)){
    // 이벤트 루프
    for(event of events){
        // 블로킹하지 않으며 항상 데이터를 반환
        data = events.resource.read()
        if(data === RESOURCE_CLOSE){
            // 리소스가 닫히고 관찰되는 리스트에서 삭제
            demultiplexer.unwatch(event.resource)
        }else{
            // 실제 데이터를 받으면 처리
            consumeData(data)
        }
    }
}
  1. 각 리소스가 데이터 구조(List)에 추가됩니다.
    1. 각 리소스를 특정 연산과 연결합니다.
  2. 디멀티플렉서가 관찰될 리소스 그룹과 함께 설정됩니다.
    1. demultiplexer.watch()는 동기식으로 관찰되는 리소스들 중에서 읽을 준비가 된 리소스가 있을 때까지 블로킹 됩니다.
    2. 준비된 리소스가 생기면, 이벤트 디멀티플렉서는 처리를 위한 새로운 이벤트 세트를 반환합니다.
  3. 이벤트 디멀티플렉서에서 반환된 각 이벤트가 처리됩니다.
    1. 이 시점에서 각 이벤트와 관련된 리소스는 읽을 준비 및 차단되지 않는 것이 보장됩니다.
    2. 모든 이번트가 처리되고 나면, 이 흐를은 다시 이벤트 디멀티플렉서가 처리 가능한 이벤트를 반환하기 전까지 블로킹 됩니다.
    3. 이를 이벤트 루프라고 합니다.

여기서 흥미로운점은 우리가 이 패턴을 이용하면 Busy Waiting 기술을 이용하지 않고도, 여러 I/O 작업을 단일 스레드 내에서 다룰 수 있다는 것입니다.

이로서 우리가 디멀티 플렉싱에 대해 논하는 이유가 명확 해졌습니다.
우리는 단일 스레드를 사용하여 여러 리소스를 다룰 수 있게 된 것입니다.

 

아래 사진은 동시에 다중 연결을 다루기 위해 동기 이벤트 디멀티플렉서와 단일 스레드를 사용하는 웹 서버 안에서 어떤 일이 일어나는지 시각화하여 보여 줍니다.

 

그림에서 보여주듯이 오직 하나의 스레드 만을 사용한 것이 동시적 다중 I/O 사용 작업에 나쁜 영향을 미치지 않습니다.
작업은 여러 스레드에 분산되는 대신에 시간에 따라 분산됩니다.

 

이것이 전체적인 유휴시간을 최소화시키는데 확실한 이점이 있다는 것이 위 그림을 통해서 명확히 나타납니다.
하지만 이것이 이 I/O 모델을 선택한 유일한 이유는 아닙니다.

 

실제로 스레드만 가지는 것은 일반적으로 프로그래머가 동시성에 접근하는 방식이 이로운 영향을 미치게 됩니다.
이책을 통해서 여러분은 경쟁 상태의 발생 문제와 다중 스레드의 동기화 문제가 없다는 것이 어떻게 우리에게 더 간단한 동시성 전략을 사용하게 해줄 수 있는지 보게 될 것입니다.

 

조금 이해하긴 난해하지만, 사진을 보면 금방 이해 될 것이라고 믿습니다.

 

사실 잘 사용한다면, 쓰레드는 정말 좋은 도구가 될 수도 있지만, 잘못 사용하면 사약 수준으로 엄청나게 프로젝트 난이도를 높여주는 아이 이기도 하죠


NodeJS 자체는 이를 배제 한채로 시작하다보니, 어쩌면 단점도 존재하지만, 저는 장점이라고 생각이 듭니다.

Java의 동시성 문제라던지..등등의 까다로운 문제들이 발생하진 않지만, 최대 단점이 속도라는 건... 어쩔 수 없다는 생각이 드네요 ㅎㅎ

리액터 패턴

이제 우리는 이전 섹션에서 제시된 알고리즘에 특화된 리액터(Reactor)패턴을 알아보겠습니다.

리액터 패턴의 이면에 있는 주된 아이디어어는 각 I/O 작업에 연관된 핸들러를 갖는다는 것이다.
NodeJS 에서의 핸들러는 콜백함수에 해당합니다.

이 핸들러는 이벤트가 생성되고 이벤트 루프에 의해 처리되는 즉시 호출되게 됩니다.
리액터 패턴의 구조는 다음과 같습니다.

위 그림은 리액터 패턴을 사용하는 애플리케이션에서 어떤 일이 발생하는지를 보여줍니다.

  1. 애플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 새로운 I/O 작업을 생성합니다.
    1. 또한 애플리케이션은 작업이 완료되었을 때 호출될 핸들러를 명시합니다.
    2. 이벤트 디멀티플렉서는 대응하는 이벤트 작업들을 이벤트 큐에 집어 넣습니다.
  2. 일련의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 대응하는 이벤트 작업들을 이벤트 큐에 집어 넣었습니다.
  3. 이 시점에서 이벤트 루프가 이벤트 큐의 항목 들을 순환합니다.
  4. 각 이벤트와 관련된 핸들러가 호출 됩니다.
  5. 애플리케이션 코드의 일부인 핸들러의 실행이 완료되면 제어권을 이벤트 루프에 되돌려줍니다.
  6. 이벤트 큐의 모든 항목이 처리되고 나면 이벤트 루프는 이벤트 디멀티플렉서에서 블로킹 처리 가능한 새 이벤트가 있을 경우 이 과정이 다시 트리거 됩니다.

이제 비 동기적 동작이 명확 해졌습니다.

애플리케이션은 특정 시점에 리소스로(블로킹 없이) 접근하고 싶다면,
요청과 동시에 작업이 완료 되었을 때 호출될 핸들러를 제공합니다.

NodeJS 애플리케이션은 이벤트 디멀티플렉서에 더 이상 보류중인 작업이 없고 이벤트 큐에 더 이상 처리 중인 작업이 없을 경우 종료됩니다.

우리는 이제 NodeJS 의 핵심에 있는 패턴을 정의 할 수 있습니다.

Reactor 패턴

  • Reactor 패턴은 일련의 관찰 대상 리소스에서 새 이벤트를 사용할 수 있을 때까지 블로킹하여 I/O를 처리하고, 각 이벤트를 관련된 핸들러에 전달함으로써 반응합니다.