본문 바로가기
JavaScript/Node.js

Node) http 모듈로 서버 만들기 - 쿠키와 세션, https와 http2, cluster

by 박채니 2022. 12. 28.
안녕하세요, 코린이의 코딩 학습기 채니 입니다.
[Node.js 교과서]의 책을 참고하여 포스팅한 개인 공부 내용입니다.

 

http 모듈로 서버 만들기

 

쿠키와 세션 이해하기

클라이언트에서 보내는 요청에는 누가 요청을 보내는지 모른다는 단점이 있습니다. 

이를 해결하기 위해 로그인을 구현하게 되는데, 이 때 쿠키와 세션을 이용합니다.

 

누가 컴퓨터를 사용하는지 알아내기 위해 서버는 요청에 대한 응답을 할 때 '쿠키'를 같이 보냅니다.

'쿠키'는 유효기간이 있으며, key-value로 이루어져있습니다.

서버로부터 쿠키가 오면, 브라우저는 이를 저장해뒀다가 다음 요청 시 쿠키를 같이 서버에 보내주어 서버는 요청에 들어있는 쿠키를 읽어 누군지 파악하게 됩니다.

쿠키는 요청의 헤더(Cookie)에 담겨 전송되고 브라우저는 응답의 헤더(Set-Cookie)에 따라 쿠키를 저장하게 됩니다.

 

서버에서 직접 쿠키를 만들어 요청자 브라우저에 전달해보겠습니다.

cookie.js

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 포트에서 대기중입니다.");
  });

@콘솔출력값
$ node part4/cookie    

8083 포트에서 대기중입니다.
/ undefined
/favicon.ico mycookie=test

쿠키는 문자열 형식으로 존재하고, 쿠키 간에는 세미콜론을 넣어 구분하게 됩니다.

req.headers.cookie에 쿠키가 들어있고, 응답 헤더에 쿠키를 기록하기 위해 'Set-Cookie'에 쿠키를 넣어 응답해주었습니다.

 

콘솔 출력 값을 확인해보면, 총 두 개가 기록되어 있는 것을 확인할 수 있습니다.

'/' 에는 undefined가 출력되고

'/favicon.ico'에는 mycooki=test가 출력되었습니다.

(favicon는 웹 사이트 탭에 보이는 이미지를 뜻하고, 파비콘 정보를 넣지 않고 보내 브라우저가 요청한 것)

 

첫 번째 요청 (/)시에는 브라우저가 쿠키 정보를 가지고 있지 않기 때문에 undefined가 출력되었고, 서버는 쿠키를 만들어 헤더에 응답하여 브라우저는 이를 받아 쿠키에 저장하였습니다.

따라서 두 번째 요청 (/favicon.ico)에는 헤더에 쿠키가 들어있음을 확인할 수 있습니다.

 

단순히 쿠키만 심었기 때문에 쿠키가 나인지 구별을 못하고 있으므로, 사용자를 식별하는 방법을 알아보겠습니다.

cookie2.html

<!DOCTYPE html>
<html lang="ko">
  <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>

 

cookie2.js

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]) => {
      console.log(acc);
      acc[k.trim()] = decodeURIComponent(v);
      return acc;
    }, {});

http
  .createServer(async (req, res) => {
    console.log(req.headers.cookie);
    const cookies = parseCookies(req.headers.cookie);

    // 주소가 /login으로 시작하는 경우
    if (req.url.startsWith("/login")) {
      const url = new URL(req.url, "http://localhost:8084");
      console.log("url: ", url);
      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}님, 안녕하세요!`);
    }
    // 주소가 /이면서 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 (error) {
        console.error(error);
        res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
        res.end(err.message);
      }
    }
  })
  .listen(8084, () => {
    console.log("8084 포트에서 기다리는 중");
  });

새로 고침을 해도 로그인이 유지되지만, 해당 방식은 쿠키가 노출되어 있기 때문에 보안 상 위험합니다.

 

서버가 사용자 정보를 관리하도록 하겠습니다.

session.js

const http = require("http");
const fs = require("fs");
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()) {
      console.log("session:", cookies.session);
      res.writeHead(200, { "Content-Type": "text/html; 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 (error) {
        res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
        res.end(error.message);
      }
    }
  })
  .listen(8085, () => {
    console.log("8085 포트에서 기다리는 중");
  });

쿠키에 이름을 담아 보내는 대신 uniqueInt 숫자 값을 보냈고, 사용자 이름과 만료 시간은 uniqueInt 속성명 아래에 있는 session 객체에 대신 저장하였습니다.

서버에 사용자 정보를 저장 (session[uniqueInt])하고 클라이언트와는 세션 아이디로만 소통하는 것을 확인할 수 있습니다.

세션을 위해 쿠키를 사용하였고, 이를 '세션 쿠키'라고 합니다.

또한 실제 배포용 서버에는 세션을 변수에 저장하지 않고 레디스 혹은 멤캐시드와 같은 데이터베이스에 저장합니다.

 


https와 http2

 

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

https는 아무나 사용할 수 없기 때문에 인증서를 발급 받아 처리해야 합니다.

 

server1-3.js

const https = require("http");
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 포트에서 기다리는 중");
  });

createServer 메소드가 두 개의 인수를 받아 처리하는 것을 확인할 수 있습니다.

첫 번째 인수는 인증서 관련 옵션 객체이고,

두 번째 인수는 서버 로직 코드입니다.

인증서를 구입하면 pem, crt 또는 key 확장자를 가진 파일들을 제공하고, 파일들을 fs.readFileSync 메소드로 읽어서 cert, key, ca 옵션에 알맞게 넣어 처리합니다.

 

  • http2 모듈은 SSL 암호화와 더불어 최신 HTTP 프로토콜인 http/2를 사용할 수 있음
  • http/2는 요청 및 응답 방식이 기존 http/1.1보다 개선되어 효율적으로 요청을 보냄
    • 웹 속도 향상

server1-4.js (http2 적용)

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 서버에서 대기 중");
  });

 


cluster

  • 싱글 프로세스로 동작하는 노드가 CPU 코어를 모두 사용할 수 있게 해주는 모듈
  • 포트를 공유하는 노드 프로세스를 여러 개 둘 수 있어 요청이 많이 들어왔을 때 병렬로 실행된 서버 개수만큼 요청을 분산 처리
  • 예) 코어가 8개인 서버가 있을 때, 노드는 코어 하나만을 활용하지만 cluster 모듈을 설정해 코어 하나당 노드 프로세스 하나가 돌아갈 수 있게 할 수 있음
  • 장점 - 코어 하나만 사용했을 대보다 성능이 개선
  • 단점 - 메모리를 공유하지 못함 (세션을 메모리에 저장하는 경우 문제 될 수 있으며, 레디스 등을 통해 해결 가능)

cluster.js

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++) {
    cluster.fork();
  }

  // 워커 종료 시
  cluster.on("exit", (worker, code, singal) => {
    console.log(`${worker.process.pid}번 워커가 종료되었습니다.`);
    console.log("code", code, "singal", singal, "worker", worker);
  });
} 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 Server</p>");
    })
    .listen(8086);

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

클러스트에는 마스터 프로세스와 워커 프로세스가 존재합니다.

  • 마스터 프로세스
    • CPU 개수만큼 워커 프로세스 생성 후 8086 포트에서 대기
    • 요청이 들어오면 만들어진 워커 프로세스에 요청을 분배
  • 워커 프로세스
    • 실질적인 일을 하는 프로세스

실행한 컴퓨터의 CPU 코어는 8개인데, 실제로 8개가 생성되었는지 확인해보겠습니다.

 

cluster.js

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++) {
    cluster.fork();
  }

  // 워커 종료 시
  cluster.on("exit", (worker, code, singal) => {
    console.log(`${worker.process.pid}번 워커가 종료되었습니다.`);
    console.log("code", code, "singal", singal);
  });
} 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 Server</p>");
      // 워커가 존재하는지 확인하기 위해 1초마다 강제 종료
      setTimeout(() => {
        process.exit(1);
      }, 1000);
    })
    .listen(8086);

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

@콘솔출력값
$ node part4/cluster

마스터 프로세스 아이디 : 3164
3168번 워커 실행
3165번 워커 실행
3166번 워커 실행
3169번 워커 실행
3171번 워커 실행
3170번 워커 실행
3167번 워커 실행
3172번 워커 실행
3168번 워커가 종료되었습니다.
code 1 singal null
3165번 워커가 종료되었습니다.
code 1 singal null
3169번 워커가 종료되었습니다.
code 1 singal null
3171번 워커가 종료되었습니다.
code 1 singal null
3166번 워커가 종료되었습니다.
code 1 singal null
3170번 워커가 종료되었습니다.
code 1 singal null
3167번 워커가 종료되었습니다.
code 1 singal null
3172번 워커가 종료되었습니다.
code 1 singal null
  • code - process.exit의 인수로 넣어준 코드 출력
  • signal - 존재하는 경우 프로세스를 종료한 신호의 이름 출력

브라우저에 접속하면 1초 후 콘솔에 워커 종료되었다는 로그가 찍히고 여덟번 새로고침하면 모든 워커가 종료되어 서버가 응답하지 않는 것을 확인할 수 있습니다.

워커 프로세스가 존재하므로 8번까지는 오류가 발생해도 서버가 정상 작동합니다.

 

종료된 워커를 다시 켜면 오류가 발생해도 계속 버틸 수 있습니다.

cluster.js

...

// 워커 종료 시
  cluster.on("exit", (worker, code, singal) => {
    console.log(`${worker.process.pid}번 워커가 종료되었습니다.`);
    console.log("code", code, "singal", singal);
    cluster.fork();

...

@콘솔출력값
$ node part4/cluster

마스터 프로세스 아이디 : 3224
3225번 워커 실행
3228번 워커 실행
3227번 워커 실행
3226번 워커 실행
3231번 워커 실행
3232번 워커 실행
3229번 워커 실행
3230번 워커 실행
3225번 워커가 종료되었습니다.
code 1 singal null
3235번 워커 실행

하지만 해당 방식으로 오류를 해결하는 것은 옳지 못하기 때문에 사용을 지양합니다.