1-1 Node.js 철학
1-1-1 경량 코어
최소한의 기능 세트를 가지고 코어의 바깥부분에 유저랜드(userland) 혹은 유저스페이스(userspace)라 불리는 사용자 전용 모듈 생태계를 둠.
그 영향으로 엄격하게 관리되어 안정적이지만 느리게 진화하는 해결책을 갖는 대산 커뮤니티가 사용자 관점에서의 폭 넓은 해결책을 실험해볼 수 있는 자유를 줌.
1-1-2 경량 모듈
Node.js는 프로그랢 코드를 구성하는 기본적인 수단으ㅗㄹ서 모듈 개념을 사용한다. 이것은 애플리케이션과 재사용 가능한 라이브러리를 만들기 위한 구성요소다. Node.js에서 가장 널리 통용되는 원칙 중 하나는 코드의 양 뿐 아니라 범위의 측면에서도 작은 모듈을 디자인 하는 것이다.
Node.js는 패키지 관리자(npm, yarn)의 도움을 받아 각 패키지가 자신이 피룡로 하는 버전의 종속성 패키지들을 갖도록 함으로써 종속성 지옥에서 벗어나게 해준다. 이러한 측은 패키지가 충돌의 위험 없이 잘 집중화되고 많은 수의 작은 종속성을 가질 수 있도록 해준다.
작은 모듈은 재사용성이라는 장점 외에도 다음의 장점이 있다.
이해하기 쉽고 사용하기 쉽다.
테스트 및 유지보수가 쉽다.
사이즈가 작아 브라우저에서 사용하기에 완벽하다.
더 작고 집중화된 모듈을 갖는 것은 모두에게 공유와 재사용을 가능하게 해준다.
1-1-3 작은 외부 인터페이스
Node.js의 모듈들이 갖는 장점은 작은 사이즈와 작은 범위 그리고 최소한의 기능 노출이다. 이러한 것들이 명확하게 사용될 수 있고 잘못된 사용에 덜 노출되도록 하는 API 생산 효과를 갖는다.
Node.js에서 모듈을 정의하는 가장 일반적인 패턴은 명백한 단일 진입점을 제공하기 위해서 단 하나의 함수나 클래스를 노출시킨다.
Node.js의 많은 모듈들의 특징 중 또 다른 하나는 그들이 확장보다는 사용되기 위해서 만들어졌다는 것이다. 확장의 가능성을 금지하기 위해 모듈 내부 접근을 제한한다는 것이 덜 유연하다고 생각되지만 사실은 유스케이스를 줄이고, 구현을 단순화하며, 유지관리를 용이하게 하고, 가용성을 높인다는 장점을 가지고 있다. 실제로 이는 내부를 외부에 노출시키지 않기 위해 클래스보다 함수를 노출시키는 것을 선호한다는 것을 의미
1-1-4 간결함과 실용주의
완벽하고 모든 기능을 갖춘 소프트웨어와는 반대로 단순하게 설계하는 것이 좋은 실천이다. 구현을 위해서 적은 노력이 들고, 가벼워서 빨리 보급 가능하며, 유지보수가 쉽고 빠른 이해가 가능하다. 이러한 요인들잉 커뮤니티의 기여를 보다 용이하게 하고 소프트웨어 자체의 성장과 향상을 돕는다.
Node.js에서 이 원칙이 채택되는 것에 가장 큰 영향을 준 것은 매우 실용적인 언어인 JavaScript이다. 실제로 복잡한 클래스 계층을 대체하기 위해서 간단한 클래스나 함수 그리고 클로저를 사용하는 것을 쉽게 볼 수 있다.
1-2 Node.js는 어떻게 작동하는가
1-2-1 I/O는 느리다
I/O는 컴퓨터의 기본적인 동작들 중에서 가장 느리다. RAM에 접근하는 데에는 나노초(10^-9초)인 반면, 디스크와 네트워크에 접근하는 데에는 밀리초(10^-3초)가 걸린다. 대역폭도 마찬가지로 RAM의 전송률은 GB/s 단위로 일관되게 유지되는 반면, 디스크나 네트워크의 전송률은 MB/s에서 GB/s까지 다양하다. CPU의 측변에서는 I/O가 많은 비용을 요구하지 않지만 요청과 작업이 완료되는 순간 사이의 지연이 발생하게 된다.
1-2-2 블로킹 I/O
전통적인 블로킹 I/O 프로그래밍에서는 I/O를 요청하는 함수의 호출은 작업이 완료될 때까지 스레드의 실행을 차단한다. 차단 시간은 디스크 접근의 경우 몇 밀리초부터 사용자가 키를 누르는 것과 같은 사용자 액션에 의해서 데이터가 생성되는 경우 몇 분까지 소요되기도 한다. 다음 의사코드는 소켓을 가지고 작ㅇ업이 수행되는 일반적인 블로킹 스레드를 보여준다.
// data가 사용가능해질 때까지 스레드를 블로킹
data = socket.read();
// data 사용 가능
print(data)
블로킹 I/O를 사용하여 구현된 웹 서버가 같은 스레드 내에서 여러 연결을 처리하지 못하는 것은 자명한 일이다. 소켓의 각각의 I/O 작업이 다른 연결의 처리를 차단하기 때문이다. 이 문제를 해결하기 위한 전통적인 접근 방법은 각각의 동시 연결을 처리하기 위해서 개별의 스레드 또는 프로세스를 사용하는 것이다.
이 방법은 I/O 작업이 각각의 스레드에서 처리되기 때문에 I/O 작업으로 블로킹된 스레드가 다른 연결들의 가용성에 영향을 미치지 않는다.
하지만 I/O 작업을 통해 스레드가 꽤 많이 블로킹 된다. 그리고 스레드는 리소스 측면에서 비용이 저렴하지 않다. 메모리를 소모하고 컨텍스트 전환ㄴ을 유발하여 대부분의 시간 동안 사용하지 않는 장시간 실행 스레드를 가지게 됨으로써 귀중한 메모리와 CPU 사이클을 낭비하게 된다.
1-2-3 논 블로킹 I/O
대부분의 최신 운영체제는 리소스에 접근하기 위해서 블로킹 I/O 외에도 논 블로킹 I/O라고 불리는 다른 메커니즘을 지원한다. 이 운영모드에서 시스템 호출은 데이터가 읽혀지거나 쓰여지기를 기다리지 않고 항상 즉시 반환된다. 호출 순간에 사용 가능한 결과가 없는 경우, 함수는 단순히 미리 정의된 상수를 반환하여 그 순간에 사용 가능한 데이터가 없다는 것을 알린다.
이러한 종류의 논 블로킹 I/O를 다루는 가장 기본적인 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 적극적을 폴링(poll)하는 것이다. 이것을 바쁜 대기(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)
} else {
// 데이터를 받고 처리
consumeData(data)
}
}
}
보다시피 간단한 기법으로 서로 다른 리소스를 같은 스레드 내에서 처리할 수 있지만 여전히 효율적이지 않다. 실제로 앞의 예제에서 루프는 사용할 수 없는 리소스를 반복하는 데에 소중한 CPU를 사용한다. 폴링 알고리즘은 엄청난 CPU 시간의 낭비를 초래한다.
1-2-4 이벤트 디멀티플렉싱
바쁜 대기(Busy-waiting)는 논 블로킹 리소스 처리를 위한 이상적인 기법이 아니다. 이를 위해 신호가 원래의 구성요소로 다시 분할되는 작업을 뜻하는 '디멀티플렉싱' 기법을 이용한다. 우리가 말하는 동기 이벤트 디멀티플렉서는 여러 리소스를 관찰하고 이 리소스들 중에 읽기 또는 쓰기 연산의 실행이 완료되었을 때 새로운 이벤트를 반환한다. 여기서 찾을 수 있는 차이점은 동기 이벤트 디멀티플렉서가 처리하기 위한 새로운 이벤트가 있을 때까지 블로킹된다는 것이다.
watchedList.add(socketA, FOR_READ) // (1)
watchedList.add(fileB, FOR_READ)
while (events = demultiplexer.watch(watchedList)) { // (2)
// 이벤트 루프
for (event of events) { // (3)
// 블로킹하지 않으며 항상 데이터를 반환
data = event.resource.read()
if (data === RESOURCE_CLOSED) {
// 리소스가 닫히고 관찰되는 리스트에서 삭제
demultiplexer.unwatch(event.resource)
} else {
// 실제 데이터를 받으면 처리
consoumeData(data)
}
}
}
1. 각 리소스가 데이터 구조(List)에 추가된다.
2. 디멀티플렉서가 관찰될 리소스 그룹과 함께 설정된다. demultiplexer.watch()는 동기식으로 관찰되는 리소스들 중에서 읽을 준비가 된 리소스가 있을 때까지 블로킹된다. 준비된 리소스가 생기면, 이벤트 디멀티플렉서는 처리를 위한 새로운 이벤트 세트를 반환한다.
3. 이벤트 디멀티플렉서에서 반환된 각 이벤트가 처리된다. 이 시점에서 각 이벤트와 관련된 리소스는 읽을 준비 및 차단되지 않는 것이 보장된다. 모든 이벤트가 처리되고 나면, 이 흐름은 다시 이벤트 디멀티플렉서가 처리 가능한 이벤트를 반환하기 전까지 블로킹된다. 이를 이벤트 루프(event loop)라고 한다.
여기서 흥미로운 점은 우리가 이 패턴을 이용하면 바쁜 대기(Busy-waiting) 기술을 이용하지 않고도 여러 I/O 작업을 단일 스레드 내에서 다룰 수 있다는 것이다. 그림 1.2는 동시에 다중 연결들을 다루기 위해 동기 이벤트 디멀티플렉서와 단일 스레드를 사용하는 웹 서버 안에서 어떤 일이 일어나는지 시각화하여 보여준다.
전체적인 유휴시간을 최소화 시키는 데에 확실한 이점이 있다는 것을 볼 수 있다.
하지만 이것이 이 I/O 모델을 선택한 이유는 아니며, 실제로 하나의 스레드만 가지는 것은 일반적으로 프로그래머가 동시성에 접근하는 방식에 이로운 영향을 미치게 된다. 이 책을 통해서 경쟁 상태(Race condition)의 발생 문제와 다중 스레드 동기화 문제가 없다는 것이 어떻게 우리에게 더 간단한 동시성 전략을 사용하게 해 줄 수 있는지 보게 될 것이다.
1-2-5 리액터(Reactor) 패턴
리액터 패턴의 주된 아이디어는 각 I/O 작업에 연관된 핸들러를 갖는다는 것이다. Node.js에서의 핸들러는 콜백 함수에 해당한다. 이 핸들러는 이벤트가 생성되고 이벤트 루프에 의해 처리되는 즉시 호출되게 된다. 리액터 패턴의 구조는 다음과 같다.
위 그림은 리액터 패턴을 사용하느 ㄴ애플리케이션에서 어떤 일이 발생하는지를 보여준다.
1. 애플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 새로운 I/O 작업을 생성한다. 또한, 애플리케이션은 작업이 완료되었을 때 호출될 핸들러를 명시한다. 이벤트 디멀티플렉서에 새 요청을 전다랗는 것은 논 블로킹 호출이며, 제어권은 애플리케이션으로 즉시 반환된다.
2. 일련의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 대응하는 이벤트 작업들을 이벤트 큐에 집어 넣는다.
3. 이 시점에서 이벤트 루프가 이벤트 큐의 항목들을 순환한다.
4. 각 이벤트와 관련된 핸들러가 호출된다.
5. 애플레이션 코드의 일부인 핸들러의 실행이 완료되면 제어권을 이벤트 루프에 되돌려준다(5a). 핸들러 실행 중에 다른 비동기 작업을 요청할 수 있으며(5b). 이는 이벤트 디멀티플렉서에 새로운 항목을 추가하는 것이다(1).
6. 이벤트 큐의 모든 항목이 처리되고 나면 이벤트 루프는 이벤트 디멀티플렉서에서 블로킹되며 처리 가능한 새 이벤트가 있을 경우 이 과정이 다시 트리거 된다.
이제 비 동기적 동작이 명확해졌다. 애플리케이션은 특정에 리소스로(블로킹 없이) 접근하고 싶다느 ㄴ요청과 동시에 작업이 완료되었을 때 호출될 핸들러를 제공한다.
참고. Node.js 애플리케이션은 이벤트 디멀티플렉서에 더 이상 보류중인 작업이 없고 이벤트 큐에 더 이상 처리 중인 작업이 없을 경우 종료된다.
우리는 이제 Node.js의 핵심에 있는 패턴을 정의할 수 있다.
참고. Reactor 패턴
Reactor 패턴은 일련의 관찰 대상 리소스에서 새 이벤트를 사용할 수 있을 때까지 블로킹하여 I/O를 처리하고, 각 이벤트를 관련된 핸들러에 전달함으로써 반응한다.
1-2-6 Libuv, Node.js의 I/O 엔진
서로 다른 운영체제 간의 디멀티플렉서를 위한 자체 인터페이스의 불일치성은 이벤트 디멀티플렉서를 위해 보다 높은 레벨의 추상화를 필요로하게 되었다. 이러한 이유로 Node.js 코어 팀이 Node.js를 주요 운영체제에서 호환되게 해주며 서로 다른 리소스 유형의 논 블로킹 동작을 표준화하기 위해 libuv라고 불리는 C 라이브러리를 만들었다. Libuv는 Node.js의 하위 수준의 I/O 엔진을 대표하며 아마도 Node.js의 구성요소 중에서 가장 중요하다고 말할 수 있다.
Libuv는 기본 시스템 호출을 추상화하는 것 외에도 리액터 패턴을 구현하고 있으므로 이벤트 루프의 생성, 이벤트 큐의 관리, 비동기 I/O 작업의 실행 및 다른 유형의 작업을 큐에 담기 위한 API들을 제공한다.
1-2-7 Node.js를 위한 구성
리액터 패턴과 libuv는 Node.js의 기본 구성 요소지만 전체 플랫폼의 구축을 위해서는 3개의 구성이 더 필요하다.
- libuv와 다른 저수준 기능들을 랩핑하고 표출시키기 위한 바인딩 세트
- V8, 크롬 브라우저를 위해 구글이 개발한 JavaScript 엔진으로 Node.js가 매우 빠르고 효율적인 이유 중 하나이기도 하다. V8은 혁신적인 설계와 속도 그리고 효율적인 메모리 관리로 높은 평가를 받고 있다.
- 고수준 Node.js API를 구현하고 있는 코어 JavaScript 라이브러리
이것들이 Node.js의 구성요서이며, 그림 1.4에서 최종 아키텍처를 묘사하고 있다.
1-3 Node.js에서의 JavaScript
Node.js에서 사용하는 JavaScript는 브라우저에서 사용하는 JavaScript와는 다소 다른데,
가장 눈에 띄는 차이점은 Node.js는 DOM을 가지고 있지 않으며, window와 document 또한 없다는 것이다. 반면에 브라우저에서는 불가능하지만 Node.js는 운영체제에서 기본적으로 제공하는 서비스들에 접근이 가능하다. 사실 브라우저는 악성 웹 애플리케이션에 의해서 기본 시스템이 손상되지 않도록 안전 조치가 적용되어 있다. 브라우저는 운영체제 리소스에 대해 높은 수준의 추상화를 제공하여 브라우저 안에서 실행되는 코드를 보유하고 조작하기 쉽게 하기 때문에 불가피하게도 사용에 제한이 있다. Node.js에서는 사실상 운영체제가 표출하는 거의 모든 서비스에 접근할 수 있다.
1-3-1 최신 JavaScript를 실행시켜라
브라우저에서 JavaScript를 사용할 때 주된 고충 중에 하나는 우리의 코드가 다양한 장치와 브라우저에서 실행된다는 경향이 있따. 다른 브라우저를 사용한다는 것은 JavaScript 런타임이 프로그램 언어와 웹 플랫폼의 최신 특성들 중 몇가지를 간과할 수 있다는 것을 의미한다. 다행이도 오늘날에는 트랜스파일러와 폴리필의 사용으로 이러한 문제들이 어느 정도 줄어들었다. 그럼에도 불구하고 이러한 것들은 여러 단점들을 가지고 있으며 모든 것들이 플러그인으로 대체 가능하지 않다.
Node.js에서 애플리케이션을 개발할 때에는 이러한 모든 애로사항들이 적용되지 않는다. 실제로 Node.js 애플리케이션이 이미 잘 알려진 시스템이나 Node.js 런타임 위에서 동작한다. 이것이 만들어내는 엄청난 차이점은 우리가 JavaScript나 Node.js 런타임 위에서 동작하는 코드를 사용할 수 있다는 것이다.
이러한 요인과 함께 Node.js가 가장 최신버전의 V8을 가지고 있다는 사실은 우리가 추가적인 소스 변환단계 없이 확신을 가지고 최신 ECMAScript(ES는 줄임 표현으로 JavaScript 언어는 이 표준에 기반을 두고 있다) 사양의 특성들 대부분을 사용 가능하다는 것이다.
명심해야 할 것이 있는데, 서드파티에 사용되기 위한 라이브러리를 개발한다면 우리는 여전히 우리의 코드가 다양한 Node.js 버전에서 실행될 수 있음을 고려해야 한다. 이 경우에 일반적인 패턴은 LTS(long-term support) 지원 버전 중에서 가장 오래된 것을 기준으로 삼고 package.json에 engines 섹션을 명시하는 것이다. 패키지 매니저는 사용자가 해당 Node.js 버전에 호환되지 않는 패키지를 설치하려고 할 때 경고를 한다.
1-3-2 모듈 시스템
JavaScript가 여전히 어떠한 형식에 대한 공식적인 지원이 없었을 때 Node.js는 모듈 시스템과 함께 시작이 되었다. 본래의 모듈 시스템은 CommonJS로 불렸으며 내장모듈 또는 장치의 파일시스템에 위치한 모듈로부터 외부에 표출된 함수와 변수 그리고 클래스를 임포트 하기 위해서 require 키워드를 사용한다.
오늘날에 JavaScript는 (import 키워드라고 하면 더욱 친숙한) 소위 ES 모듈 문법이라고 불리는 것을 가지고 있다ㅏ. 이것은 브라우저에서와는 기본적 구현이 다른 것으로 Node.js에서는 문법만 상속받는다. 실제로 브라우저가 원격에 있는 모듈을 주로 다루는 반면, Node.js는 현재로느 오직 로컬 파일 시스템에 있는 모듈만 다룰 수 있다.
1-3-3 운영체제 기능에 대한 모든 접근
이미 언급했듯이 Node.js는 JavaScript를 사용하지만 브라우저 영역 안에서 실행되지 않는다. 이것은 Node.js가 운영체제에서 기본적으로 제공하는 주된 서비스들에 바인딩 할 수 있게 해준다.
예를 들어 우리는 fs 모듈의 도움으로 파일시스템에 있는 (운영체제 레벨의 허가를 조건으로) 파일에 접근 가능하며 net과 dgram 모듈로 애플리케이션이 저수준의 TCP 또는 UDP 소켓을 사용하게 할 수 있다. 우리는 HTTP(S) 서버를 만들 수 있고 표준 암호화와 OpenSSL의(crypto 모듈을 사용하여) 해시 알고리즘을 사용할 수 있다. 또한 우리는 (v8 모듈을 사용하여) V8 내부 여러 곳에 접근이 가능하며 (vm 모듈을 사용하여) V8의 다른 문맥 상에서 코들르 실행시킬 수 있다.
우리는 (child_process 모듈을 사용하여) 다른 프로세스들을 실행시키거나 전역 변수 process를 사용하여 우리의 애플리케이션이 돌고 있는 프로세스의 정보를 가져올 수 있다. 특히 process 전역변수로부터 (process,env를 사용하여) 프로세스에 할당된 환경변수 목록과 애플리케이션 실행 시 할당된 커맨드라인 인자들을 (process.argv를 사용하여) 가져올 수 있다.
1-3-4 네이티브 코드 실행
Node.js가 제공하는 가장 강력한 능력 중 하나는 네이티브 코드에 바인드할 수 있는 사용자측 모듈을 만들어내는 것이 가능하다. 이것은 C/C++로 이ㅣ미 만들어져 있거나 새로 만들어진 컴포넌트를 사용할 수 있다는 엄청난 이점을 플랫폼에 부여하게 된다. Node.js는 N-API 인터페이스의 도움으로 네이티브 모듈을 구현하는 데에 있어서 강력한 지원을 하고 있다.
작은 노력으로도 이미 존재하는 막대한 양의 오픈 소스를 재사용할 수 있으며 더 중요한 점은 기업이 그것을 바꾸어 적용할 필요 없이 C/C++ 레거시들을 재사용할 수 있게 해준다.
또 고려앻봐야 하는 중요한 점 하나는 하드웨어 드라이브나 하드웨어 포트(usb나 시리얼과 같은)의 저수준 특성들에 접근이 여전히 필요하다는 것이다. Node.js는 네이티브 코드와 연결될 수 있는 능력 덕분에 사물인터넷(IoT: Internet of things)이나 홈메이드 로보틱스 세계에서 점차 인기를 얻고 있다.
마지막으로, V8은 JavaScript 실행에 있어서 매우 빠르게 동작하지만 네이티브 코드와 비교했을 때 여전히 수행능력에 손실이 따른다. 일상에서 컴퓨터를 사용할 때에는 드문 문제일 수 있지만 엄청난 양의 데이터를 처리하고 조작하여 네이티브 코드에 위임하는 CPU 집약적 애플리케이션의 경우 충분히 마주칠 수있는 문제이다.
현재 Node.js를 포함한 대부분의 JavaScript 가상머신(VM: Virtual Machine)은 JavaScript 이외의 언어(C++또는 Rust)를 JavaScript VM들이 "이해가능"한 형식으로 컴파일 할 수 있게 해주는 저수준 명령 형식인 웹어셈블리(WASM)을 지원한다.
요약
이번 챕터에서는 우리가 사용하는 코드와 내부 아키텍처를 규정하는 몇 가지 중요 원칙들에 근거하여 Node.js가 어떻게 만들어졌는지 살펴보았다. Node.js가 최소한의 코어를 가지고 있고 "Node way"는 더 작고 간단하며 최소한의 필요기능만을 노출한다는 의미를 갖고 있음을 배웠다..
다음으로 Node.js의 심장인 리액터 패턴을 살펴보았고 플랫폼 런타임의 내부 아키텍처를 해부하여 V8, libuv, Core JavaScript 라이브러리를 좀 더 가까이 들여다 보았다. 마지막으로 Node.js와 브라우저에서 사용되는 JavaScript의 주요 특징들을 분석해 보았다.
'Node.js > Node.js 디자인 패턴 바이블' 카테고리의 다른 글
[Node.js 디자인 패턴 바이블] CHAPTER 02 모듈 시스템 (0) | 2023.07.06 |
---|