2022. 4. 16. 23:19ㆍ책/Node.js 디자인 패턴 바이블
1. Node.js 철학
모든 프로그래밍 플랫폼은 자신들만의 철학과 원칙, 이데올로기를 가지고 있다. 노드js 역시 마찬가지이며, 아래와 같은 원칙들을 가지고 있다.
※ https://en.wikipedia.org/wiki/List_of_software_development_philosophies
1.1 경량 코어
코어를 최소한의 기능 세트로 관리하되 코어의 바깥 부분에 유저스페이스(userspace)라 불리는 유저 전용 모듈 생태계를 두는 것을 원칙으로 함. 이는 커뮤니티가 유저 관점에서 폭넓은 해결책을 실험해볼 수 있는 자유를 부여함.
1.2 경량 모듈
Node.js는 프로그램 코드를 구성하는 기본적인 수단으로서 모듈 개념을 사용한다. 이것은 애플리케이션과 재사용 가능한 라이브러리를 만들기 위한 구성 요소가 된다. 또한 Unix 철학에 근거하여 작은 모듈을 원칙으로 삼았는데,
- 작은 것이 아름답다
- 각 프로그램은 한 가지 역할만 잘 하도록 만들어라
라는 개념을 받아들여 패키지 관리자(npm, yarn)를 활용하게 된다. 이 덕분에 종속성 지옥에서 벗어날 수 있었으며, 재사용성도 높아지게 된다.
이처럼 작은 모듈 덕분에 쉬운 이해와 사용성, 테스트 및 유지보수의 용이성, 브라우저와의 적합성 등 여러 이점들을 갖게 된다. 또한, 작고 집중화된 모듈 덕분에 공유와 재사용이 가능해지면서 더 높은 수준의 DRY(Don't Repeat Yourself) 원칙을 가능케 한다.
1.3 작은 외부 인터페이스
Node.js의 모듈은 작은 사이즈와 작은 범위, 최소한의 기능 노출을 특징으로 한다.
1.4 간결함과 실용주의
Node.js는 KISS(Keep It Simple, Stupid)원칙을 채택하여 단순한 구현 과정, 특유의 가벼움을 가진다. 특히, JavaScript 언어를 사용함으로써 매우 실용적이고 합리적인 복잡성과 빠른 구현이 가능해졌다.
2. Node.js 작동 원리
2.1 I/O는 느리다
Input/Output은 컴퓨터의 기본 동작들 중에서도 가장 느리다. CPU 측면에서 I/O가 비용이 큰 건 아니지만, 보내지는 요청과 작업이 완료되는 사이에서 지연이 발생한다. 특히, 사람이 클릭과 같은 입력을 발생시킨다는 점을 고려하면 I/O의 속도는 디스크나 네트워크보다도 느려질 수 있다.
컴퓨터 동작 | 소요시간 |
RAM 접근 | 나노초(10-9s) |
디스크/네트워크 접근 | 밀리초(10-3s) |
RAM 전송률 | GB/s 단위로 비교적 일정 |
디스크/네트워크 전송률 | MB/s ~ GB/s까지 다양 |
2.2 블로킹 I/O
I/O 작업이 완료될 때까지 스레드의 실행을 차단하는 방식. 블로킹 I/O로 구현된 웹 서버는 같은 스레드 내에서 여러 연결을 처리하지 못하는데, 이 문제를 해결하기 위한 전통적인 방법은 다중 스레드를 두는 것이었다.
어떠한 유형의 요청이 됐든 I/O 작업은 요청의 처리를 위해 스레드 실행을 차단할 수 있고, 이 때문에 스레드가 꽤 많이 블로킹된다는 것을 알 수 있다. 안타깝게도 스레드는 시스템 리소스 측면에서 비용이 저렴하지 않으며, 메모리를 소모하고 컨텍스트 전환을 유발하면서 귀중한 메모리와 CPU 사이클을 낭비하게 된다.
2.3 논 블로킹 I/O
물론 대부분의 최신 운영체제는 논 블로킹 I/O 메커니즘을 지원한다.이 운영모드에서 시스템 호출은 데이터가 읽히거나 쓰이기를 기다리지 않고 항상 즉시 반환된다. 만약 호출 순간에 사용 가능한 결과가 없다면 미리 정의된 상수를 반환해 사용 가능한 데이터가 없다는 것을 알린다.
이게 무슨소리냐면 원하는 결과가 준비됐건 안됐건 일단 무언가를 반환해서 스레드를 사용할 수는 있는 상태로 둔다는 뜻이다. (제어권이 호출한 함수 측으로 즉시 반환된다)
이러한 논 블로킹 I/O를 다루는 기본 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 *폴링(polling)하는 것인데, 이를 바쁜 대기(busy-waiting)라고 한다. 아래는 논 블로킹 I/O와 폴링 루프를 사용해 여러 리소스를 읽어 들이는 의사 코드이다.
resources = [socketA, socketB, fileA];
while (!resources.isEmpty()) {
for (resource of resources) {
data = resource.read() // 데이터 읽기 시도
if (data === NO_DATA_AVAILABLE) continue // 읽을 데이터가 없다
if (data === RESOURCE_CLOSED) {
resources.remove(i) // 리소스가 닫히고 리스트에서 삭제
// resource closed = 리소스를 반납한다는 의미
} else {
consumeData(data) // 데이터를 받고 처리
}
}
}
간단한 기법으로 서로 다른 리소스를 같은 스레드 내에서 처리할 수 있지만 루프 내에서 사용할 수 없는 리소스를 반복해서 돌리느라 CPU를 미친듯이 잡아먹게 된다. 즉, 폴링 알고리즘은 미칠듯한 CPU 낭비를 초래한다
* polling : 일정한 주기(특정한 시간)을 가지고 응답을 주고받는 방식
2.4 이벤트 디멀티플렉싱
다행히도 대부분의 운영체제는 바쁜 대기(busy-waiting) 말고도 논 블로킹 리소스를 효율적으로 처리할 수 있는 메커니즘을 제공하는데, 이를 동기 이벤트 디멀티플렉서(혹은 이벤트 통지 인터페이스)라고 한다.
동기 이벤트 디멀티플렉서는 여러 리소스를 감시(watch)하다가 리소스들 중 읽기/쓰기 연산의 실행이 완료되면 새로운 이벤트를 반환한다. 여기서 파생되는 이점은 동기 이벤트 디멀티플렉서가 처리하기 위한 새로운 이벤트가 있을 때까지 블로킹된다는 것이다.
즉, 바쁜 대기 없이도 여러 I/O 작업을 단일 스레드 내에서 다룰 수 있다는 것이다.
// 이벤트 디멀티플렉서
// 각 리소스가 데이터구조(List)에 추가되며, 각 리소스를 특정 연산과 연결
watchedList.add(socketA, FOR_READ)
watchedList.add(fileB, FOR_READ)
// 디멀티플렉서가 감시될 리소스 그룹과 함께 설정됨
// demutiplexer.watch()는 동기식으로 관찰되는 리소스들 중에서
// 읽을 준비가 된 리소스가 있을 때까지 블로킹됨.
// 준비된 리소스가 생기면, 이벤트 디멀티플렉서가 처리를 위한 새로운 이벤트 세트를 반환.
while (events = demultiplexer.watch(watchList)) {
// ** 이벤트 루프(event loop) **
// 이벤트 디멀티플렉서에서 반환된 각 이벤트가 처리된다.
// 이 시점에서 각 이벤트와 관련된 리소스는 읽을 준비가 끝난 상태.
// 모든 이벤트가 처리되고 나면
// 이벤트 디멀티플렉서가 처리 가능한 이벤트를 반환하기 전까지 다시 블로킹됨.
// 이를 **이벤트 루프(event loop)**라고 한다.
for (event of events) {
data = event.resource.read() // 리소스를 읽어서 데이터를 뽑아내는 과정에선 블로킹 발생X
if (data === RESOURCE_CLOSED) {
// 리소스가 닫히고 관찰되는 리스트에서 삭제
demultiplexer.unwatch(event.resource)
} else {
// 실제 데이터를 받으면 처리
cosumeData(data)
}
}
}
※ 전기통신용어
멀티플렉싱(입출력 다중화) : 여러 신호들을 하나로 합성해 다중화 시키는 작업. 하나로 합쳤기 때문에 하나의 통신 채널에서도 데이터(시그널)를 전송할 수 있게 된다.
디멀티플렉싱 : 합성된 신호들을 원래의 구성요소로 다시 분할하는 작업.
그림을 보면 알겠지만, 작업은 여러 스레드에 분산되는게 아니라 시간에 따라 분산되며, 전체적인 유휴시간(idle time)을 최소화시키는데 확실한 이점이 있음이 나타난다. 물론 Node.js가 이거 하나만 보고 동기 이벤트 디멀티플렉싱을 선택한 것은 아니다.
2.5 리액터 패턴(Reactor Pattern)
동기 이벤트 디멀티플렉서 메커니즘에 특화된 패턴으로, 위 알고리즘에다가 각 이벤트별 핸들러를 끼얹은 게 리액터 패턴이다.
각 I/O 작업에 연관된 핸들러를 갖는데, Node.js 기준으로 핸들러는 콜백 함수에 해당한다. 핸들러는 이벤트가 생성되고 이벤트 루프에 의해 처리되는 즉시 호출된다. 자세하게 그림으로 나타내자면 아래와 같다.
정리하면 '관찰하고 있는 리소스' 들로부터 발생하는 이벤트들이 발생할 때 까지 "Block" 하고, 이벤트 발생 시 해당 이벤트와 연결되어있는 handler에게 dispatch(전달) 함으로써 "반응(React)"하는 패턴이다.
2.6 Libuv, Node.js의 I/O 엔진
각 운영체제는 이벤트 디멀티플렉서를 위한 자체 인터페이스를 가지고 있으며(Linux : epoll, macOS : kqueue, Window : I/O completion port), 리소스 유형에 따라 여러 형태로 I/O 작업을 처리한다.
문제는 처리방식이 운영체제마다 다르다보니 이벤트 디멀티플렉서를 위해 고수준의 추상화가 필요하게 됐고, Node.js 코어팀은 Node.js를 주요 운영체제에서 호환되게 하면서 여러 리소스 유형의 논 블로킹 동작을 표준화하기 위해 libuv라는 C 라이브러리를 만들었다.
Libuv는 기본 시스템 호출을 추상화하며, 리액터 패턴을 구현하고 있다(즉, 이벤트 루프 생성, 이벤트 큐 관리, 비동기 I/O ㅏ작업의 실행 및 다른 유형의 작업을 큐에 담기 위한 API 제공 등등)
http://nikhilm.github.io/uvbook/
※ 추상화 : 다양한 속성 중에서 프로그램에 필요한 속성만 간추려 표현하는 것
ex) 고객 정보를 정의 및 분류하기 위해 여러 속성을 쓸 수 있을텐데, 그중 주민번호와 가입일 두 속성으로 간추려 정의/관리하는 것도 추상화의 일종
2.7 Node.js를 위한 구성
아래 그림은 Node.js의 구성요소이자 최종 아키텍처를 나타내고 있다.
- 바인딩 세트 : libuv와 다른 저수준 기능들을 랩핑하고 표출시킴
- 코어 JS API : 고수준 Node.js API를 구현
- V8 : 구글이 개발한 JS 엔진. 혁신적인 설계와 속도, 효율적인 메모리 관리로 매우 높은 평가를 받고 있다. Node.js가 매우 빠르고 효율적일 수 있는 이유 중 하나
3. Node.js에서의 JavaScript
Node.js에서의 JS는 브라우저에서의 JS와 다소 다르다. Node.js는 DOM을 가지지 않으며, window/document 또한 가지지 않는다. 반면 Node.js는 운영체제가 제공하는 거의 모든 서비스에 접근할 수 있다(브라우저는 기본 시스템 손상을 방지하기 위해 여러 안전 조치가 적용돼있고, 이 때문에 제한 사항이 제법 있는 편)
1) 최신 JavaScript 사용할 것
Node.js에서 애플리케이션 개발시 브라우저처럼 여러 웹 플랫폼에서 실행되지 않는다(크롬, IE, 파이어폭스, 사파리 기타 등등). 보통 이미 잘 알려진 시스템이나 Node.js 런타임 위에서 동작하기 때문에 바벨 같은 트랜스 파일러를 거쳐 소스 코드를 변환시켜야 할 필요가 없다. 대신 Node.js가 가장 최신 버전의 V8을 가지고 있고, 최신 ECMAScript 사양의 기능을 사용하기 위해 최신 자바스크립트를 사용할 필요가 있다.
2) 모듈 시스템
앞서 말했듯 Node.js는 모듈 시스템을 기반으로 돌아간다. 그리고 이를 위해 CommonJS라는 모듈 시스템을 채택해 애플리케이션을 만든다(require 키워드) 오늘날 JS가 사용하는 ESM 문법(import 키워드)과는 다소 차이점이 있는데, 뒤에서 더 자세히 알아보자.
3) 운영체제 기능에 대한 모든 접근
Node.js가 브라우저 영역 안에서 실행되지 않으므로, 운영체제에서 기본적으로 제공하는 주된 서비스들에 바인딩할 수 있다. 아래는 그 예시들인데 이 외에도 매우 많다.
- fs모듈로 파일시스템에 있는 파일에 접근 가능
- net, dgram 모듈로 저수준의 TCP / UDP 소켓 사용 가능
- HTTP(S) 서버 생성 가능, 표준 암호화와 OpenSSL의 해시 알고리즘 사용 가능
- child_process 모듈로 타 프로세스 실행
- process.env를 사용해 전역 변수 process로부터 프로세스에 할당된 환경변수 취득 가능
4) 네이티브 코드 실행
※ 네이티브 코드 : CPU와 운영체제(OS)가 직접 실행할 수 있는 코드
Node.js의 기능 중 하나는 네이티브 코드에 바인드 할 수 있는 모듈 생성이 가능하다는 것이다. 이 덕분에 C/C++로 이미 만들어진 컴포넌트를 사용할 수 있고, 사물인터넷이나 홈메이드 로보틱스에도 활용이 가능하다. (물론, V8이 JS실행에 있어 매우 빠르긴 하지만 네이티브 코드와 비교했을 땐 여전히 떨어지는 게 사실임)
추가로 Node.js를 포함한 대부분의 JS 가상머신(VM)들은 JS 이외의 언어를 이해가능한 형식으로 컴파일 해주는 저수준 명령 형식인 웹어셈블리(WASM)를 지원한다.
'책 > Node.js 디자인 패턴 바이블' 카테고리의 다른 글
[2장] 모듈 시스템(4) (0) | 2022.04.23 |
---|---|
[2장] 모듈 시스템(3) (0) | 2022.04.20 |
[2장] 모듈 시스템(2) (0) | 2022.04.20 |
[2장] 모듈 시스템 (0) | 2022.04.19 |