[20240124 TIL] Node Web Server의 A to Z

Haizel·2024년 1월 24일
1
post-thumbnail

01. Node.js란?


Node.js는 Chrome V8 자바스크립트 엔진으로 빌드된 자바스크립트 런타임 환경(실행 환경)으로, 주로 서버 사이드 어플리케이션 개발에 사용되는 소프트웨어 플랫폼이다.

본래 자바스크립트는 웹 브라우저 내부에서만 동작하는 스크립트 언어이다.
하지만 웹 브라우저 없이도 자바스립트를 실행시키고자 하는 요구가 많아졌고, 이에 node.js는 chrome의 v8 자바스크립트 엔진을 활용해 자바스크립트를 웹 브라우저로부터 독립시켰다.

👉 결국 node.js를 통해 자바스크립트는 브라우저 외 다른 환경에서도 작동하고, 더불어 자바스크립트로 서버 구현도 가능해졌다.

👉 그 덕에 웹 어플리케이션은 더욱 발전할 수 있게 되어 실시간 온라인 채팅이나 유저 인증 관리 등 실시간 통신이나 대량의 데이터 처리와 같은 다양한 기능 개발이 가능해졌다.


📌 Node.js의 특징

Node.js는 V8과 더불어 libuv 라이브러리를 사용한다. libuv 라이브러리는 Node의 특성인 이벤트 기반, 논블로킹 I/O 모델을 구현하고 있다.

① Non-blocking I/O

  • Node.js 표준 라이브러리의 모든 I/O 메서드는 Non-blocking인 비동기 방식을 제공하고, 콜백 함수를 받는다.

  • Non-blocking 비동기 작업을 통해 모든 API를 비동기 방식으로 동작할 수 있다.

② 이벤트 기반

  • 이벤트 기반(Event-driven)이란 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 말한다.
  • 이벤트 기반 시스템에서는 특정 이벤트가 발생할 때 무엇을 할지 미리 등록해두고, 이를 이벤트 리스너에 콜백 함수로 등록한다.
    • 이벤트가 발생하면 리스너에 등록해둔 콜백 함수가 호출되며, 이벤트가 종료된 후 노드는 다음 이벤트가 발생할 때까지 대기 상태에 머문다.
  • 여러 이벤트가 등록되어 있다면, 어떤 순서로 콜백 함수를 호출 할지 이벤트 루프가 판단한다.

③ 싱글스레드

자바스크립트의 실행 환경(runtime)인 Node는 싱글 스레드 기반이다.

  • Node는 싱글 스레드 & 논 블로킹 모델로 하나의 스레드에서 작업을 처리하지만, 스레드에 들어오는 순서가 아닌 논 블로킹 방식을 따른다.
    • 따라서 이전 작업의 완료 여부와 상관 없이 다음 작업을 수행할 수 있다.
  • 싱글 스레드이지만 이벤트 루프 모델을 사용하는 덕분에 가벼운 환경에서도 높은 요청 처리 성능을 보여준다.
  • I/O 요청이 많이 발생하는 웹 애플리케이션이라면 논 블로킹 방식으로 동작하는 Node를 서버로 사용하는 것이 좋다.
    • 반대로 CPU 부하가 큰 작업에는 Node 서버는 적합하지 않다.

📌 Node.js Event loop (Server 관점)

👉 Client 관점에서 보기
👉 Server 관점으로 좀 더 자세히 살펴보기

✔️ Node.js가 각 요소들을 어떻게 비동기 Callback을 수행하는 걸까?

  1. 요청이 들어오면 → Event Loop가 해당 요청의 Blocking I/O 여부를 판별한다.

  2. 커널의 비동기 I/O의 지원을 받을 수 있는 Non-Blocking I/O 요청이면 → 커널의 Interface로 해당 요청을 처리한 후 → Event Queue에 Callback을 등록한다.

  3. Blocking I/O라면 Iibuv 내 별도의 Thread Pool에서 Worker Thread를 선택해 작업을 위임한다.
    - Worker Thread는 작업을 완료한 후 Event Queue로 callback을 등록한다.

  4. Event Loop는 주기적으로 Call Stack이 비어있는지 체크한 후, 비어있다면 → Event Queue에 대기 중인 Callback을 Call Stack으로 이동 시킨다.
    • 이를 통해 Main Thread에 의해 실행될 수 있게 만든다.

해당 과정을 한눈에 표현한다면 다음 그림과 같이 표현할 수 있다.


📌 Node.js Server 예시

Node.js는 HTTP 서버 모듈을 내장하고 있어 아파치같은 별도의 웹서버를 설치하지 않아도 된다.

// app.js
const http = require('http'); // ❶

http.createServer((request, response) => { // ❷
	response.stateCode = 200;
	response.setHeader('Content-type', 'text/plain');
	response.end('Hello World');
}).listen(3000); // ❸

console.log('Server running at http://127.0.0.1:3000/');

http 모듈을 로딩해 변수 http에 할당

  • Node.js는 파일과 1:1 대응 관계를 가지는 module 단위로, 각 기능을 분할한다.
    • 하나의 모듈은 자신만의 독립적인 스코프를 가지므로, 전역 변수의 중복 문제가 발생하지 않음.
    • 모듈은 module.exports 또는 exports 객체를 통해 정의하고 외부로 공개한다.
    • 공개된 모듈은 require 함수를 사용해 import한다.
    • 위 코드에서 http는 기존 선언된 모듈로, 이를 require 함수를 통해 import한 것이다.

❷ http 모듈의 createServer([requestListener])메서드를 사용해 HTTP 서버 객체 생성

  • HTTP 서버 객체는 EventEmitter 클래스를 상속한 것으로, request Listener 함수(request 이벤트가 발생하면 → 이를 처리하고 response를 반환)를 호출한다.
  • request Listener 함수는 request와 response 객체를 전달받으며, HTTP 요청 이벤트마다 한 번씩 호출된다.

❸ 서버 실행

  • createServer메소드는 HTTP 서버 객체를 반환하고, 이 객체를 listen 메소드에 포트 번호 3000을 전달하여 서버를 실행한다.

02. Node.js Module 비교


❶ 전역 기능

  • constfunction 같은 키워드 및 process 등의 전역 객체

❷ 코어 모듈(Core Module)

  • 파일 시스템 모듈("fs"), 경로 모듈("path"), Http 모듈("http")
  • Node.js가 기본으로 포함하는 모듈로, Path를 명시하지 않아도 무방하다.
const http = require('http');

❸ 제 3자 모듈(외부 패키지)

  • npm을 통해 설치한 외부 패키지도 Path를 명시하지 않아도 무방하다.
const mongoose = require('mongoose');

❹ 파일 모듈(File Module)

  • 코어 모듈과 외부 패키지를 제외한 나머지로, 반드시 path를 명시해야 한다.
const foo = require('./lib/foo');

03. Core Node.js Module


① 파일 시스템 모듈 ("fs")

  • 읽기와 쓰기와 같은 I/O 작업 기능 제공
// commonjs
const fs = require('fs'); // 파일 시스템 작업

// esm (after es6)
import fs from 'fs';

② 경로 모듈 ("path")

  • 다른 파일 및 디렉토리 경로 작업을 위한 유틸리티 기능 제공
// commonjs
const path = require('path'); // 파일 시스템 경로 유틸

// esm (after es6)
import path from 'path';

③ Http 모듈 ("http")

  • HTTP 서버 및 클라이언트 구축을 위한 기능 제공
// commonjs
const http = require('http'); // http 서버 및 관련 유틸
const https = require('https'); // https 서버 및 관련 유틸

// esm (after es6)
import http from 'http';
import https from 'https';

④ 그 외 코어 모듈

require('assert')         // 테스트 목적
require('child_process')  // 외부 프로그램을 실행할 때 필요
require('cluster')        // 다중 프로세스를 이용해 성능을 올릴 수 있게 한다.
require('crypto')         // 내장된 암호화 라이브러리
require('dns')            // 네트워크 이름 해석에 쓰이는 DNS 함수
require('domain')         // 에러를 고립시키기 위해 IO 비동기 작업을 묶는다.
require('events')         // 비동기 이벤트 지원
require('fs')             // 파일 시스템 작업
require('http')           // http 서버 및 관련 유틸
require('https')          // https 서버 및 관련 유틸
require('net')            // 비동기 소켓 기반 네트워크 API
require('os')             // 운영체제 유틸리티
require('path')           // 파일 시스템 경로 유틸
require('punycode')       // 유니코드 인코딩
require('querystring')    // URL 쿼리스트링 해석 및 생성
require('readline')       // 대화형 IO 유틸. CLI 프로그램에 사용
require('smalloc')        // 버퍼에 메모리를 명시적으로 할당
require('string_decoder') // 버퍼를 문자열로 변환
require('tls')            // 보안 전송 계층 통신 유틸
require('tty')            // 저수준 TTY 함수
require('dgram')          // 사용자 데이터그램 프로토콜(UDP) 네트워크 유틸
require('url')            // URL 파싱 유틸
require('util')           // 내부 노드 유틸
require('vm')             // 가상머신. 컨텍스트 생성에 사용.
require('zlib')           // 압축 유틸

📌 서버 실습 코드

① app.js

const http = require('http'); // 코어 모듈

const routes = require('./routes'); //파일 모듈

const server = http.createServer(routes.handler);

server.listen(3000);

② routes.js

const fs = require('fs'); // 코어 모듈

const requestHandler = (req, res) => {
	const url = req.url;
	const method = req.method;

	if(url === '/') {
		res.write('<html>');
	    res.write('<head><title>Enter Message</title></head>');
	    res.write(
      '<body><form action="/message" method="POST"><input type="text" name="message"><button type="submit">Send</button></form></body>'
    );
	    res.write('</html>');

		return res.end(); // 해당 요청에 대한 응답 종료
	}


	if(url === '/message' && mehtod === 'POST') {
		const body = [];

		req.on('data', (chunk) => {
			body.push(chunk);
		});

		return req.on('end', () => {
			const parseBody = Buffer.concat(body).toString();
			const message = parseBody.split("=")[1];

			fs.writeFile('message.txt', (message, err) => {
			res.statusCode = 302;
			res.setHeader('Location', '/');
		
			return res.end(); // 해당 요청에 대한 응답 종료
			});
		});
	}

	res.setHaader('Content-Type', 'text/html');
	res.write('<html>');
	res.write('<head><title>My First Page</title></head>');
	res.write('<body><h1>Hello from my Node.js Server!</h1></body>');
	res.write('</html>');
	res.end();
};

// module.exports = requestHandler;

// module.exports = {
//     handler: requestHandler,
//     someText: 'Some hard coded text'
// };

// module.exports.handler = requestHandler;
// module.exports.someText = 'Some text';

exports.handler = requestHandler;
exports.someText = 'Some hard coded text';

❶ Another Server 예제

const http = require('http');

const server = http.createServer((req, res) => {
  const url = req.url;
  if (url === '/') {
    res.setHeader('Content-Type', 'text/html');
    res.write('<html>');
    res.write('<head><title>Assignment 1</title></head>');
    res.write(
      '<body><form action="/create-user" method="POST"><input type="text" name="username"><button type="submit">Send</button></form></body>'
    );
    res.write('</html>');
    return res.end();
  }
  if (url === '/users') {
    res.setHeader('Content-Type', 'text/html');
    res.write('<html>');
    res.write('<head><title>Assignment 1</title></head>');
    res.write('<body><ul><li>User 1</li><li>User 2</li></ul></body>');
    res.write('</html>');
    return res.end();
  }
  // Send a HTML response with some "Page not found text
  if (url === '/create-user') {
    const body = [];
    req.on('data', chunk => {
      body.push(chunk);
    });
    req.on('end', () => {
      const parsedBody = Buffer.concat(body).toString();
      console.log(parsedBody.split('=')[1]); // username=whatever-the-user-entered
    });
    res.statusCode = 302;
    res.setHeader('Location', '/');
    res.end();
  }
});

server.listen(3000);

📌 require

  • require 함수의 인수에는 파일뿐만 아니라 디렉토리를 지정할 수 있다.
  • 모듈을 명시하지 않는 경우에는 해당 디렉토리의 index.js를 로드한다.
project/
├── app.js
└── module/
    ├── index.js
    ├── calc.js
    └── print.js
// app.js
const myModule = require('./module');

// module/index.js
module.exports = {
  calc: require('./calc'),
  print: require('./print')
};

👉 app.js에서 한 번의 requiremodule/ 디렉토리 하위의 모든 모듈들을 사용할 수 있다.


04. Helper 함수


path 모듈를 통해 상위 디렉토리에 접근하고자 할 때, 파일 구조가 복잡할수록 경로 작성이 복잡해진다.

이때 Helper 함수를 통해 보다 쉽게 파일 모듈 경로에 도달할 수 있다.

project/
	├── app.js 
	├── routes/ 
	├	├── admin.js 
	├   └── shop.js
	└── util/ 
	    └── path.js

❶ app.js

const path = require('path');

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

const adminRoutes = require('./routes/admin');
const shopRoutes = require('./routes/shop');

app.use(bodyParser.urlencoded({extended: false}));

app.use('/admin', adminRoutes);
app.use(shopRoutes);

app.use((req, res, next) => {
    res.status(404).sendFile(path.join(__dirname, 'views', '404.html'));
});

app.listen(3000);

❷ routes/admin.js

const path = require('path');

const express = require('express');

const rootDir = require('../util/path');

const router = express.Router();

// /admin/add-product => GET
router.get('/add-product', (req, res, next) => {
  res.sendFile(path.join(rootDir, 'views', 'add-product.html'));
});

// /admin/add-product => POST
router.post('/add-product', (req, res, next) => {
  console.log(req.body);
  res.redirect('/');
});

module.exports = router;

❸ routes/shop.js

const path = require('path');

const express = require('express');

const rootDir = require('../util/path');

const router = express.Router();

router.get('/', (req, res, next) => {
  res.sendFile(path.join(rootDir, 'views', 'shop.html'));
});

module.exports = router;

❹ util/path.js 👈 Helper 함수!

const path = require('path');

module.exports = path.dirname(process.mainModule.filename);

05. Node Module Loading System


🗣️ Module : 모듈이란 어플리케이션을 구성하는 개별적 요소를 말한다.

브라우저 상에서 동작하는 JS는 script tag로 로드하며 복수의 JS파일을 로드할 경우, 하나의 파일로 Merge되고 해당 파일들은 동일한 유효 범위를 갖게 된다.

Node.js는 모듈 단위로 각 기능을 분할한다.

  1. 모듈과 파일은 1:1 대응 관계를 가지며
  2. 하나의 모듈은 자신만의 독립적인 스코프를 가진다.
  3. 전역변수 중복 문제가 발생하지 않는다.

모듈은 module.exports또는 expots 객체를 통해 정의하고 외부로 공개된다. 공개된 모듈은 require 함수를 통해 import된다.

① exports

모듈 안에 선언한 모든 것들은 기본적으로 해당 모듈 내부에서만 참조 가능하다.

  • 이를 외부에 공개하여 다른 모듈에서 사용할 수 있도록 해주는 것이 export 객체이다.
  • 전역 함수 require() 로 추출한다.
// circle.js
const { PI } = Math;
exports.area = (r) => PI * r * r;
exports.circumference = (r) => 2 * PI * r;

// app.js
const circle = require('./circle.js'); // == require('./circle');

console.log(`지름이 4인 원의 면적: ${circle.area(4)}`);
console.log(`지름이 4인 원의 둘레: ${circle.circumference(4)}`);

② module.exports

  • module.exports는 하나의 값(원시 타입, 함수, 객체)을 할당할 수 있다.
    • exports 객체 : 프로퍼티/메소드를 여러 개 정의 할 수 있다.
  • require()로 할당받은 변수는 module.exports에 할당한 값 자체이다.

exportsmodule.exports의 참조이며 module.exports의 alias이다.
👉 즉 exports == module.exports와 같다 보아도 무방하다.


📌 exports vs module.exports

구분모듈 정의 방식require 함수의 호출 결과
exportsexports 객체에는 값을 할당할 수 없고, 공개할 대상을 exports 객체에 프로퍼티 또는 메소드로 추가한다.exports 객체에 추가한 프로퍼티와 메소드가 담긴 객체가 전달된다.
module.exportsmodule.exports 객체에 하나의 값(원시 타입, 함수, 객체)만을 할당한다.module.exports 객체에 할당한 값이 전달된다.

✔️ module.exports에 함수를 할당하는 방식

  • module.exports는 1개의 값만을 할당할 수 있기 때문에, 다음과 같이 객체를 사용해 복수의 기능을 하나로 묶어 공개하는 방식을 사용한다.
// foo.js
module.exports = function(a, b) {
  return a+b;
};

// app.js
const add = require('./foo');

const result = add(1, 2);
console.log(result);

✔️ exports에 객체를 할당하는 방식

  • 프로퍼티/메소드를 여러 개 정의해 외부로 공개할 수 있다.
// foo.js
module.exports = {
  add (v1, v2) { return v1 + v2 },
  minus (v1, v2) { return v1 - v2 }
};

// app.js
const calc = require('./foo');

const result1 = calc.add(1, 2);
console.log(result1); //3

const result2 = calc.minus(1, 2);
console.log(result2); //-1

06. Java의 I/O (Stream & Buffer)


I/OInput/Output으로 데이터를 입력하고 출력하는 것을 의미한다.

컴퓨터는 우리가 입력(input)데이터를 적절히 처리하여 화면에 출력(Output)해 보여준다.

그렇다면 이때 데이터는 어떤 방법으로 전달되고 있을까?


① Stream(스트림)

Stream(스트림)은 배열이나 문자열 같은 데이터를 운반하는데 사용되는 연결 통로로, 데이터가 들어온 순서대로 흐르는 단반향의 통로이다.

  • 쉽게 말해 자료를 입출력하기 위해 사용하며, 프로그램과 입출력 장치 사이에서 자료들을 중계하는 역할을 담당한다.

  • Stream은 동기적(blocking)으로 동작한다.

    • 데이터를 읽거나 쓰기 위해 스트림에 요청하면 → 스트림은 자신의 역할에 맞춰 다시 데이터를 읽거나 쓸 수 있을 때까지 무한정 기다린다.
  • Java에서는 모든 기본 I/O는 Stream을 기반으로 하고 있어 빈번하게 사용되는데, 사용 후 Stream을 닫아주지 않으면 심각한 메모리 누수가 발생할 수 있으므로 예외처리에 주의를 기울여 사용해야 한다.

  • 입구와 출구가 존재하며 입구는 InputStream, 출구는 OutStream이라고 한다.
  • Stream을 통해 데이터는 기본적으로 byte 또는 byte[]형태로 이동된다.
  • 하지만 "스트림"에서 알 수 있듯 단방향 통신으로 데이터가 전달되기 때문에, 입출력을 동시에 수행하려면 2개의 스트림이 필요하다.

② Buffer (버퍼)

Buffer는 데이터를 임시로 담아 둘 수 있는 일종의 큐 형태의 공간이다.
용량이 큰 데이터의 경우 한번에 전송하는 데에 어려움이 있기 때문에, Buffer라는 특정 단위만큼 데이터를 담아 전송한다.

  • byte 단위의 데이터가 입력될 때마다 Stream은 즉시 전송하게 되는데 → 이로 인해 디스크 접근/네트워크 접근 같은 오버헤드가 발생하기 때문에 이 방법은 매우 비 효율적이다.
    • 따라서 Buffer을 통해 중간에서 입력을 모아 한번에 출력함으로써 I/O의 성능을 향상시키는 역할을 한다.
    • 또한 입출력 속도 차이에 의한 성능 저하의 보완적 수단으로 사용한다.
  • 데이터가 크지 않은 경우 스트림되는 즉시 프로그램에서 데이터를 처리할 수 있지만, 대용량의 데이터를 서버로부터 가져오려면 많은 시간이 소요된다.
    • 이때 데이터를 버퍼로 전송하고, 버퍼가 가득차면 버퍼의 내용이 스트림이 된다.

✔️ Buffer 예제 코드

// ❶ Buffer 사용하지 않고 출력
public static void nonBufferIO {
	for (int i = 0; i < 100000; i++) {
		System.out.print(i); // 입력이 있을 때마다 출력
	}
	System.out.println();
}


// ❷ Buffer를 사용해 출력
public static void bufferIO {
	StringBuffer sb = new StringBuffer();
	for (int i = 0; i < 100000; i++) {
		sb.append(i); // 1) 버퍼에 모두 담는다.
	}
	sb.append("\n"); 
	System.out.print(sb); // 2. 출력
}


/*
* 수행 속도 비교
* nonBufferIO() : 281 (ms)
* bufferIO() : 15 (ms)
* → bufferIO > nonBufferIO
*/

❗️ Java는 Buffer의 장점을 Stream에 적용하여 BufferedInputStreamBufferedOutputStream을 제공한다.


📌 Stream > Buffer > Chunks



📂 참고자료

👀 [유튜브 강의] Node.js 서버 만들기

profile
한입 크기로 베어먹는 개발지식 🍰

0개의 댓글

관련 채용 정보