4장. http 모듈로 서버 만들기

Doozuu·2023년 4월 28일
0

Node.js

목록 보기
4/6

4.1 요청과 응답 이해하기

클라이언트에서 서버로 요청을 보내고, 서버에서는 요청의 내용을 읽고 처리한 뒤 클라이언트에 응답을 보낸다.

따라서 서버에는 요청을 받는 부분응답을 보내는 부분이 있어야 한다.
클라이언트로부터 요청이 왔을 때 어떤 작업을 수행할지 이벤트 리스너를 미리 등록해둬야 한다.

http 서버가 있어야 웹 브라우저의 요청을 처리할 수 있으므로 http 모듈을 사용한다.

  • http 모듈에는 createServer 메서드가 있다.
  • 인수로 요청에 대한 콜백 함수를 넣을 수 있으며, 요청이 들어올 때마다 매번 콜백 함수가 실행된다.
  • req 객체는 요청에 관한 정보들을, res 객체는 응답에 관한 정보들을 담고 있다.

server1.js

const http = require('http');

http.createServer((req, res) => {
	// 여기에 어떻게 응답할지 적어준다.
})

응답을 보내는 부분과 서버 연결 부분 추가하기

  • 응답 보내는 부분 :
    res.writeHead : 응답에 대한 정보를 기록하는 메서드. (첫 번째 인수로 성공적인 요청임을 의미하는 200을, 두 번째 인수로 응답에 대한 정보를 보냄.) => Header 라고 부른다.
    res.write : 클라이언트로 보낼 데이터를 입력. => Body 라고 부른다.
    res.end : 응답을 종료하는 메서드. 만약 인수가 있다면 그 데이터도 클라이언트로 보내고 응답을 종료한다.
  • 서버 연결 부분 : listen 메서드를 붙이고 클라이언트에 공개할 포트 번호와 포트 연결 완료 후 실행될 콜백 함수 넣기

server1.js

const http = require("http");

http
  .createServer((req, res) => {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.write("<h1>Hello Node!</h1>");
    res.end("<p>Hello Server!</p>");
  })
  .listen(8080, () => {
    // 서버 연결
    console.log("8080번 포트에서 서버 대기 중입니다!");
  });

listen 메서드에 콜백 함수를 넣는 대신, 다음과 같이 서버에 listening 이벤트 리스너를 붙여도 된다. error 이벤트 리스너도 붙일 수 있다.
(참고 : 서버의 소스 코드를 변경할 때 서버는 자동으로 변경 사항을 반영하지 않는다. 따라서 서버를 종료했다가 다시 실행해야 만 변경 사항이 반영된다.)

server1.js

const http = require("http");

const server = http.createServer((req, res) => {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.write("<h1>Hello Node!</h1>");
    res.end("<p>Hello Server!</p>");
});
server.listen(8080);

server.on('listening', () => {
    console.log("8080번 포트에서 서버 대기 중입니다!");
});
server.on('error', (error) => {
 	console.log(error); 
});

서버를 실행해보면 콘솔창에 다음과 같이 출력된다.

$ node server1
8080번 포트에서 서버 대기 중입니다!

웹 브라우저를 열어 http://localhost:8080 에 접속한다.
그러면 다음과 같이 response로 작성한 부분이 나와있다.
(서버를 종료하려면 콘솔에서 Ctrl+C를 입력하면 된다.)

localhost와 포트란?

  • localhost는 현재 컴퓨터의 내부 주소를 가리키며, 외부에서는 접근할 수 없고 자신의 컴퓨터에서만 접근할 수 있다.
    따라서 서버를 개발할 때 테스트용으로 많이 사용된다.
    localhost 대신 127.0.0.1을 주소로 사용해도 같다.
    이러한 숫자 주소를 IP(Internet Protocol)이라고 한다.
  • 포트는 서버 내에서 프로세스를 구분하는 번호이다.
    서버는 HTTP 요청을 대기하는 것 외에도 데이터베이스와 통신하는 등 다양한 작업을 한다. 따라서 서버는 프로세스에 포트를 다르게 할당해 들어오는 요청을 구분한다.
    유명한 포트 번호로는 21(FTP), 80(HTTP), 443(HTTPS), 3306(MYSQL)이 있다. 포트 번호는 IP 주소 뒤에 콜론;과 함게 붙여 사용한다.
  • 예제에서는 임의의 포트 번호 8080에 노드 서버를 연결했기 때문에 http://localhost:8080 으로 접근해야 한다. 그러나 깃허브 등과 같은 사이트들은 포트 번호를 따로 표시하지 않는다. 바로 443번 포트를 사용하기 때문이다. 443번 포트를 사용하면 주소에서 포트를 생략할 수 있다. (https://github.com:443 으로 요청해도 홈페이지에 접속된다.)
  • 리눅스와 맥에서는 1024번 이하의 포트에 연결할 때 관리자 권한이 필요하다. 따라서 명령어 앞에 sudo를 붙여야 한다. 예를 들어 node server1 대신 sudo node server1을 입력해야 한다.
  • 참고로 다른 서비스가 사용하고 있는 포트를 사용할 경우 Error: listen EADDRINUSE :::포트번호 같은 에러가 발생한다. 이런 경우, 그 서비스를 종료하거나 노드의 포트를 다른 번호로 바꾸면 된다.

HTML 파일을 읽어서 응답하기

res.write와 res.end에 일일이 HTML을 적는 것은 비효율적이므로 미리 HTML파일을 만들어 두는 것이 바람직하다.
HTML 파일은 fs 모듈로 읽어서 전송할 수 있다.

  • 요청이 들어오면 먼저 fs 모듈로 HTML 파일을 읽는다.
  • 에러가 발생한 경우에는 에러 메시지를 응답한다.

server2.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Node.js 웹 서버</title>
  </head>
  <body>
    <h1>Node.js 웹 서버</h1>
    <p>만들어보기!</p>
  </body>
</html>

server2.js

const http = require("http");
const fs = require("fs").promises;

http
  .createServer(async (req, res) => {
    try {
      const data = await fs.readFile("./server2.html");
      res.writeHead(200, { "Content-Type": "text/html;charset-utf-8" });
      res.end(data);
    } catch (err) {
      console.log(err);
      res.writeHead(500, { "Content-Type": "text/plain;charset-utf-8" });
      res.end(err.message);
    }
  })
  .listen(8081, () => {
    console.log("8081번 포트에서 서버 대기중입니다.");
  });

node server2로 서버를 실행해서 웹 브라우저에 8081 포트로 접속해보면 다음과 같이 html에 작성한 내용이 브라우저에 나와있다.



4.2 REST와 라우팅 사용하기

서버에 요청을 보낼 때는 주소를 통해 요청의 내용을 표현한다.
ex.주소가 /index.html 이면 서버의 index.html을 보내달라는 뜻이다.

요청의 내용이 주소를 통해 표현되므로 서버가 이해하기 쉬운 주소를 사용하는 것이 좋다. 여기서 REST가 등장한다.

REST는 REpresentational State Transfer의 줄임말로, 서버의 자원을 정의하고 자원에 대한 주소를 지정하는 방법을 가리킨다. 자원이라고 해서 꼭 파일일 필요는 없고, 서버가 행할 수 있는 것들을 통틀어 의미한다고 보면 된다.

REST API에는 많은 규칙이 있다.
주소는 의미를 명확히 전달하기 위해 명사로 구성된다. 단순히 명사만 있으면 무슨 동작을 행하라는 것인지 알기 어려우므로 REST에서는 주소 외에도 HTTP 요청 메서드라는 것을 사용한다.

  • GET : 서버 자원을 가져오고자 할 때 사용한다.
  • POST : 서버에 자원을 새로 등록하고자 할 때 사용한다. 요청의 본문에 새로 등록할 데이터를 넣어 보낸다.
  • PUT : 서버의 자원을 요청에 들어 있는 자원으로 치환하고자 할 때 사용한다. 요청의 본문에 치환할 데이터를 넣어 보낸다.
  • PATCH : 서버 자원의 일부만 수정하고자 할 때 사용한다. 요청의 본문에 일부 수정할 데이터를 넣어 보낸다.
  • DELETE : 서버의 자원을 삭제하고자 할 때 사용한다.
  • OPTION : 요청을 하기 전에 통신 옵션을 설명하기 위해 사용한다.

ex. GET 메서드의 /user 주소로 요청을 보내면 사용자 정보를 가져오는 요청이라는 것을 알 수 있고, POST 메서드의 /user 주소로 요청을 보내면 새로운 사용자를 등록하려 한다는 것을 알 수 있다.

이렇게 주소와 메서드만 보고 요청의 내용을 알아볼 수 있다는 것이 장점이다. 또한, GET 메서드 같은 경우에는 브라우저에서 캐싱(기억)할 수도 있어 같은 주소로 GET 요청을 할 때 서버에서 가져오는 것이 아니라 캐시에서 가져올 수도 있다. 이렇게 캐싱이 되면 성능이 좋아진다.

그리고 HTTP 통신을 사용하면 클라이언트가 누구든 상관없이 같은 방식으로 서버와 소통할 수 있다. iOS, 안드로이드, 웹, 다른 서버가 모두 같은 주소로 요청을 보낼 수 있다. 즉, 서버와 클라이언트가 분리되어 있다는 뜻이다. 이렇게 서버와 클라이언트를 분리하면 추후에 서버를 확장할 때 클라이언트에 구애되지 않아 좋다.

REST를 따르는 서버를 RESTful하다고 표현한다.
코드를 작성하기 전에 아래처럼 대략적인 주소를 먼저 설계하는 것이 좋다.

예제

req.method : HTTP 요청 메서드 구분
req.url: 요청 주소 구분

const http = require("http");
const fs = require("fs").promises;
const path = require("path");

const users = {}; // 데이터 저장용

http
  .createServer(async (req, res) => {
    try {
      if (req.method === "GET") {
        if (req.url === "/") {
          const data = await fs.readFile(
            path.join(__dirname, "restFront.html")
          );
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        } else if (req.url === "/about") {
          const data = await fs.readFile(path.join(__dirname, "about.html"));
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        } else if (req.url === "/users") {
          res.writeHead(200, {
            "Content-Type": "application/json; charset=utf-8",
          });
          return res.end(JSON.stringify(users));
        }
        // /도 /about도 /users도 아니면
        try {
          const data = await fs.readFile(path.join(__dirname, req.url));
          return res.end(data);
        } catch (err) {
          // 주소에 해당하는 라우트를 못 찾았다는 404 Not Found error 발생
        }
      } else if (req.method === "POST") {
        if (req.url === "/user") {
          let body = "";
          // 요청의 body를 stream 형식으로 받음
          req.on("data", (data) => {
            body += data;
          });
          // 요청의 body를 다 받은 후 실행됨
          return req.on("end", () => {
            console.log("POST 본문(Body):", body);
            const { name } = JSON.parse(body);
            const id = Date.now();
            users[id] = name;
            res.writeHead(201, { "Content-Type": "text/plain; charset=utf-8" });
            res.end("등록 성공");
          });
        }
      } else if (req.method === "PUT") {
        if (req.url.startsWith("/user/")) {
          const key = req.url.split("/")[2];
          let body = "";
          req.on("data", (data) => {
            body += data;
          });
          return req.on("end", () => {
            console.log("PUT 본문(Body):", body);
            users[key] = JSON.parse(body).name;
            res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
            return res.end(JSON.stringify(users));
          });
        }
      } else if (req.method === "DELETE") {
        if (req.url.startsWith("/user/")) {
          const key = req.url.split("/")[2];
          delete users[key];
          res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
          return res.end(JSON.stringify(users));
        }
      }
      res.writeHead(404);
      return res.end("NOT FOUND");
    } catch (err) {
      console.error(err);
      res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(err.message);
    }
  })
  .listen(8082, () => {
    console.log("8082번 포트에서 서버 대기 중입니다");
  });

Network 탭에서 네트워크 요청 내용을 실시간으로 볼 수 있다.
Name은 요청 주소를, Method는 요청 메서드를, Status는 HTTP 응답 코드를, Protocol은 통신 프로토콜을, Type은 요청의 종류를 의미한다.(xhr은 AJAX를 뜻한다.)

요청을 누르면 상세 페이지가 나오는데, General은 공통된 헤더이고, Request Headers는 요청의 헤더, Response Headers는 응답의 헤더이다. 응답의 본문은 Preview나 Response 탭에서 확인할 수 있다.

서버를 종료하면 데이터가 소실되기 때문에 데이터를 영구적으로 저장하려면 데이터베이스를 사용해야 한다.

📌 코드가 길어져서 보기가 어렵고 관리하기도 어렵기 때문에 이를 편리하게 만들어주는 Express 모듈을 활용하는 것이 좋다.
-> npm install express로 설치해서 사용.



4.3 쿠키와 세션 이해하기⭐️

클라이언트에서 보내는 요청에는 한 가지 큰 단점이 있다. 바로 누가 요청을 보내는지 모른다는 것이다. 요청을 보내는 IP 주소나 브라우저의 정보를 받아올 수는 있지만, 여러 컴퓨터가 공통으로 IP 주소를 갖거나 한 컴퓨터를 여러 사람이 사용할 수도 있다.

이를 해결하기 위해 로그인을 구현하면 되는데, 로그인을 구현하려면 쿠키와 세션을 알고 있어야 한다.

쿠키

사용자가 누구인지 기억하기 위해 서버는 요청에 대한 응답을 할 때 쿠키라는 것을 같이 보낸다. 쿠키는 유효 기간이 있으며 name=doozuu 와 같이 단순한 key-value 쌍이다.

서버로부터 쿠키가 오면, 웹 브라우저는 쿠키를 저장해뒀다가 다음에 요청할 때마다 쿠키를 동봉해서 보낸다. 서버는 요청에 들어 있는 쿠키를 읽어서 사용자가 누구인지 파악한다. 브라우저는 쿠키가 있다면 자동으로 동봉해서 보내주므로 따로 처리할 필요가 없다. 서버에서 브라우저로 쿠키를 보낼 때만 코드를 작성해 처리하면 된다.

즉, 서버는 미리 클라이언트에 요청자를 추정할 만한 정보를 쿠키로 만들어 보내고, 그다음부터는 클라이언트로부터 쿠키를 받아 요청자를 파악한다.

쿠키는 요청의 헤더에 담겨 전송된다. 브라우저는 응답의 헤더에 따라 쿠키를 저장한다.

서버에서 쿠키를 만들어 요청자의 브라우저에 넣어보기

const http = require("http");

http
  .createServer((req, res) => {
    console.log(req.url, req.headers.cookie);
    res.writeHead(200, { "Set-Cookie": "mycookie=test" });
    res.end("Hello Cookie");
  })
  .listen(8083, () => {
    console.log("8083번 포트에서 서버 대기 중입니다.");
  });

쿠키는 name=doozuu;year=2021처럼 문자열 형식으로 존재한다.
쿠키 간에는 세미콜론을 넣어 각각을 구분한다.

쿠키는 req.headers.cookie에 들어있다.
응답의 헤더에 쿠키를 기록해야 하므로 res.writeHead 메서드를 사용한다. Set-Cookie는 브라우저에게 다음과 같은 값의 쿠키를 저장하라는 의미이다. 실제로 응답을 받은 브라우저는 mycookie=test라는 쿠키를 저장한다.

예제

  • parseCookie 함수
    쿠키는 mycookie=test 같은 문자열이다. parseCookie 함수는 쿠키 문자열을 쉽게 사용하기 위해 자바스크립트 객체 형식으로 바꾸는 함수이다. 이 함수를 거치면 {mycookie : 'test'} 가 된다.
    (문자열 -> 객체로 바꿔줌.)

  • 로그인 요청 처리 부분
    form은 GET 요청인 경우 데이터를 쿼리스트링으로 보내기에 URL 객체로 쿼리스트링 부분을 분석한다. 쿠키의 만료시간은 지금으로부터 5분 뒤로 설정한다. 302 응답 코드, 리다이렉트 주소와 함께 쿠키를 헤더에 넣는다. 브라우저는 이 응답 코드를 보고 페이지를 해당 주소로 리다이렉트한다. 헤더에는 한글을 설정할 수 없으므로 name 변수를 encodeURIComponent 메서드로 인코딩한다. 또한, Set-Cookie 값으로는 제한된 ASCII 코드만 들어가야 하므로 줄바꿈을 넣어서는 안된다.

  • 그 외의 경우
    먼저 쿠키가 있는지 없는지를 확인한다. 쿠키가 없다면 로그인할 수 있는 페이지를 보낸다. 처음 방문한 경우에는 쿠키가 없으므로 cookie2.html이 전송된다. 쿠키가 있다면 로그인한 상태로 간주해 인사말을 보낸다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>쿠키&세션 이해하기</title>
  </head>
  <body>
    <form action="/login">
      <input id="name" name="name" placeholder="이름을 입력하세요" />
      <button id="login">로그인</button>
    </form>
  </body>
</html>
const http = require("http");
const fs = require("fs").promises;
const path = require("path");

const parseCookies = (cookie = "") =>
  cookie
    .split(";")
    .map((v) => v.split("="))
    .reduce((acc, [k, v]) => {
      acc[k.trim()] = decodeURIComponent(v);
      return acc;
    }, {});

http
  .createServer(async (req, res) => {
    const cookies = parseCookies(req.headers.cookie); // { mycookie: 'test' }
    // 주소가 /login으로 시작하는 경우
    if (req.url.startsWith("/login")) {
      const url = new URL(req.url, "http://localhost:8084");
      const name = url.searchParams.get("name");
      const expires = new Date();
      // 쿠키 유효 시간을 현재시간 + 5분으로 설정
      expires.setMinutes(expires.getMinutes() + 5);
      res.writeHead(302, {
        Location: "/",
        "Set-Cookie": `name=${encodeURIComponent(
          name
        )}; Expires=${expires.toGMTString()}; HttpOnly; Path=/`,
      });
      res.end();
      // name이라는 쿠키가 있는 경우
    } else if (cookies.name) {
      res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(`${cookies.name}님 안녕하세요`);
    } else {
      try {
        const data = await fs.readFile(path.join(__dirname, "cookie2.html"));
        res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
        res.end(data);
      } catch (err) {
        res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
        res.end(err.message);
      }
    }
  })
  .listen(8084, () => {
    console.log("8084번 포트에서 서버 대기 중입니다!");
  });

쿠키에는 들어가면 안 되는 글자들이 있는데, 대표적으로 한글과 줄바꿈이 있다. 한글은 encodeURIComponent로 감싸서 넣는다.

  • 쿠키명=쿠키값 : 기본적인 쿠키의 값이다. mycookie=test와 같이 설정한다.
  • Expires=날짜 : 만료 기한이다. 이 기한이 지나면 쿠키가 제거된다. 기본값은 클라이언트가 종료될 때까지이다.
  • Max-age=초 : Expires와 비슷하지만 날짜 대신 초를 입력할 수 있다. 해당 초가 지나면 쿠키가 제거된다.
  • Domain=도메인명 : 쿠키가 전송될 도메인을 특정할 수 있다. 기본값은 현재 도메인이다.
  • Path=URL : 쿠키가 전송될 URL을 특정할 수 있다. 기본값은 '/'이고, 이 경우 모든 URL에서 쿠키를 전송할 수 있다.
  • Secure : HTTPS일 경우에만 쿠키가 전송된다.
  • HttpOnly : 설정시 자바스크립트에서 쿠키에 접근할 수 없다. 쿠키 조작을 방지하기 위해 설정하는 것이 좋다.

새로 고침을 해도 로그인이 잘 유지된다. 그러나 쿠키가 노출되기 때문에 이름 같은 민감한 개인정보를 쿠키에 넣어두는 것은 적절하지 않다.

서버가 사용자 정보를 관리하도록 만들기

쿠키에 이름을 담아서 보내는 대신, uniqueInt라는 숫자 값을 보낸다.
사용자의 이름과 만료 시간은 session이라는 객체에 대신 저장한다.

const http = require("http");
const fs = require("fs").promises;
const path = require("path");

const parseCookies = (cookie = "") =>
  cookie
    .split(";")
    .map((v) => v.split("="))
    .reduce((acc, [k, v]) => {
      acc[k.trim()] = decodeURIComponent(v);
      return acc;
    }, {});

const session = {};

http
  .createServer(async (req, res) => {
    const cookies = parseCookies(req.headers.cookie);
    if (req.url.startsWith("/login")) {
      const url = new URL(req.url, "http://localhost:8085");
      const name = url.searchParams.get("name");
      const expires = new Date();
      expires.setMinutes(expires.getMinutes() + 5);
      const uniqueInt = Date.now();
      session[uniqueInt] = {
        name,
        expires,
      };
      res.writeHead(302, {
        Location: "/",
        "Set-Cookie": `session=${uniqueInt}; Expires=${expires.toGMTString()}; HttpOnly; Path=/`,
      });
      res.end();
      // 세션쿠키가 존재하고, 만료 기간이 지나지 않았다면
    } else if (
      cookies.session &&
      session[cookies.session].expires > new Date()
    ) {
      res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(`${session[cookies.session].name}님 안녕하세요`);
    } else {
      try {
        const data = await fs.readFile(path.join(__dirname, "cookie2.html"));
        res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
        res.end(data);
      } catch (err) {
        res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
        res.end(err.message);
      }
    }
  })
  .listen(8085, () => {
    console.log("8085번 포트에서 서버 대기 중입니다!");
  });

이러한 방식이 세션이다. 서버에 사용자 정보를 저장하고 클라이언트와는 세션 아이디로만 소통한다.

세션 아이디는 꼭 쿠키를 사용해서 주고받지 않아도 된다. 하지만 많은 웹 사이트가 쿠키를 사용하는데, 쿠키를 사용하는 방법이 제일 간단하기 때문이다. 세션을 위해 사용하는 쿠키를 세션 쿠키라고 한다.

실제 배포용 서버에서는 세션을 위와 같이 변수에 저장하지 않는다. 서버가 멈추거나 재시작되면 메모리에 저장된 변수가 초기화되기 때문이다. 또한, 메모리가 부족하면 세션을 저장하지 못하는 문제도 생긴다. 그래서 보통은 Redis 나 Memcached 같은 데이터베이스에 넣어둔다.

위의 코드는 보안상 매우 취약하기 때문에 안전하게 사용하려면 다른 사람들이 만든 검증된 코드를 사용하는 것이 좋다.



4.4 https 와 http2

https 모듈은 웹 서버에 SSL 암호화를 추가한다. GET 이나 POST 요청을 할 때 오가는 데이터를 암호화해서 중간에 다른 사람이 요청을 가로채더라도 내용을 확인할 수 없게 한다. 요즘은 로그인이나 결제가 필요한 창에서 https 적용이 필수가 되는 추세이다.

https는 아무나 사용할 수 있는 것이 아니다. 암호화를 적용하는 만큼, 그것을 인증해줄 수 있는 기관도 필요하다. 인증서는 인증 기관에서 구입해야 하는데, Let's Encrypt 같은 기관에서 무료로 발급해주기도 한다.

발급받은 인증서가 있다면 다음과 같이 하면 된다.
다른 것은 거의 비슷하지만, createServer 메서드가 인수를 두 개 받는다. 첫 번째 인수는 인증서에 관련된 옵션 객체이고, 두 번째 인수는 http 모듈과 같이 서버 로직이다.

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      cert: fs.readFileSync("도메인 인증서 경로"),
      key: fs.readFileSync("도메인 비밀키 경로"),
      ca: [
        fs.readFileSync("상위 인증서 경로"),
        fs.readFileSync("상위 인증서 경로"),
      ],
    },
    (req, res) => {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.write("<h1>Hello Node!</h1>");
      res.end("<p>Hello Server!</p>");
    }
  )
  .listen(443, () => {
    console.log("443번 포트에서 서버 대기 중입니다!");
  });

http2

노드의 http2 모듈은 SSL 암호화와 더불어 최신 HTTP 프로토콜인 http/2를 사용할 수 있게 한다. http/2는 요청 및 응답 방식이 기존 http/1.1보다 개선되어 훨씬 효율적으로 요청을 보낸다. 웹 속도도 많이 개선된다.

http2를 적용한 예제

https 모듈과 거의 유사하다. https 모듈을 http2로, createServer 메서드를 createSecureServer 메서드로 바꾸면 된다.

const http2 = require('http2');
const fs = require('fs');

http2.createSecureServer({
  cert: fs.readFileSync('도메인 인증서 경로'),
  key: fs.readFileSync('도메인 비밀키 경로'),
  ca: [
    fs.readFileSync('상위 인증서 경로'),
    fs.readFileSync('상위 인증서 경로'),
  ],
}, (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.write('<h1>Hello Node!</h1>');
  res.end('<p>Hello Server!</p>');
})
  .listen(443, () => {
    console.log('443번 포트에서 서버 대기 중입니다!');
  });



4.5 cluster

cluster 모듈은 기본적으로 싱글 프로세스로 동작하는 노드가 CPU 코어를 모두 사용할 수 있게 해주는 모듈이다. 포트를 공유하는 노드 프로세스를 여러 개 둘 수도 있어, 요청이 많이 들어왔을 때 병렬로 실행된 서버의 개수만큼 요청이 분산되게 할 수 있다.

예를 들어 코어가 8개인 서버가 있을 때, 노드는 보통 코어를 하나만 활용한다. 하지만 cluster 모듈을 설정해 코어 하나당 노드 프로세스 하나가 돌아가게 할 수 있다. 성능이 꼭 8배가 되는 것은 아니지만, 하나만 사용할 때에 비해 성능이 개선된다. 하지만 장점만 있는 것은 아니며, 메모리를 공유하지 못하는 등의 단점도 있다. 따라서 세션을 메모리에 저장하는 경우 문제가 될 수 있으며, 이는 Redis 등의 서버를 도입해 해결할 수 있다.

예제

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`마스터 프로세스 아이디: ${process.pid}`);
  // CPU 개수만큼 워커를 생산
  for (let i = 0; i < numCPUs; i += 1) {
    cluster.fork();
  }
  // 워커가 종료되었을 때
  cluster.on('exit', (worker, code, signal) => {
    console.log(`${worker.process.pid}번 워커가 종료되었습니다.`);
    console.log('code', code, 'signal', signal);
    cluster.fork();
  });
} else {
  // 워커들이 포트에서 대기
  http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.write('<h1>Hello Node!</h1>');
    res.end('<p>Hello Cluster!</p>');
    setTimeout(() => { // 워커 존재를 확인하기 위해 1초마다 강제 종료
      process.exit(1);
    }, 1000);
  }).listen(8086);

  console.log(`${process.pid}번 워커 실행`);
}

클러스터에는 마스터 프로세스와 워커 프로세스가 있다.
마스터 프로세스는 CPU 개수만큼 워커 프로세스를 만들고, 8086번 포트에서 대기한다. 요청이 들어오면 만들어진 워커 프로세스에 요청을 분배한다. 워커 프로세스는 실질적인 일을 하는 프로세스이다.

직접 cluster 모듈로 클러스터링을 구현할 수도 있지만, 실무에서는 pm2 등의 모듈로 cluster 기능을 사용하곤 한다.

profile
모든게 새롭고 재밌는 프론트엔드 새싹

0개의 댓글