본문 바로가기
JavaScript/Node.js

Node) http 모듈로 서버 만들기 - 요청과 응답 이해하기, REST와 라우팅 이용하기

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

 

http 모듈로 서버 만들기

 

요청과 응답 이해하기

 

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

따라서 서버에는 요청을 받는 부분과 응답을 보내는 부분이 있어야 합니다. (이벤트 방식)

따라서 클라이언트로부터 요청이 왔을 때 어떤 작업을 수행할지 이벤트 리스너를 미리 등록해야 합니다.

 

createServer.js

const http = require("http");

http.createServer((req, res) => {

});

http 서버가 있어야 웹 브라우저 요청을 처리할 수 있기 때문에 http 모듈을 사용하였습니다.

인수로 요청에 대한 콜백 함수를 넣을 수 있으며, 요청이 들어올 때마다 매번 콜백 함수가 실행됩니다. 

  • req : 요청에 관한 정보들을 담은 객체
  • res : 응답에 관한 정보들을 담은 객체

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

@콘솔출력값
$ node part4/server1

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

첫 서버가 잘 실행되었습니다. 만일 서버를 종료하고 싶다면 콘솔에서 Ctrl + C를 입력합니다.

 

createServer 메소드 뒤에 listen 메소드를 붙이고 포트 번호와 포트 연결 완료 후 실행될 콜백 함수를 넣습니다.

파일을 실행하게 되면 서버는 8080 포트에서 요청이 오기를 기다립니다.

  • res.writeHead
    • 응답에 대한 정보를 기록
    • 첫 번째 인수로 성공적인 요청을 의미하는 200을, 두 번째 인수로는 응답에 대한 정보를 보냄 (HTML 형식, 한글 표시)
    • 헤더
  • res.write
    • 첫 번째 인수는 클라이언트로 보낼 데이터
    • HTML 모양의 문자열 뿐만 아니라 버퍼를 보낼 수도 있음
    • 여러 번 호출해서 데이터를 여러 개 보낼 수도 있음
    • 본문 (데이터 기록)
  • res.end
    • 응답을 종료하는 메소드
    • 인수가 있다면 해당 데이터도 클라이언트로 보내고 응답을 종료

listen 메소드에 콜백 함수를 넣는 대신, 서버에 listening 이벤트 리스너를 붙여도 됩니다.

server1-1.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.error(error);
});

@콘솔출력값
$ node part4/server1-1

8080 포트에서 기다리는 중

 

한 번에 여러 서버를 실행할 수도 있습니다.

server1-2.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에서 기다린당");
  });

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(8081, () => {
    console.log("8081 포트에서 기다릴게");
  });

@콘솔출력값
$ node part4/server1-2 

8080에서 기다린당
8081 포트에서 기다릴게

실무에서 서버를 여러 개 띄우는 일은 드물지만, 여러 개 띄울 수도 있습니다.

또한 하나하나 HTML을 적는 것은 매우 비효율적이므로 HTML파일을 만들어 사용하는 것이 바람직 합니다.

 

server2.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <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(__dirname + "/server2.html");
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(data);
    } catch (err) {
      console.error(err);
      res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(err.message);
    }
  })
  .listen(8081, () => {
    console.log("8081 포트에서 서버 대기 중입니다.");
  });
  
@콘솔출력값
$ node part4/server2  

8081 포트에서 서버 대기 중입니다.


REST와 라우팅 사용하기

  • 서버에 요청을 보낼 때는 주소를 통해 요청의 내용을 표현
  • REST (REpresentational State Transfer)
    • 서버의 자원을 정의하고 자원에 대한 주소를 지정하는 방법
    • 주소는 의미를 명확히 전달하기 위해 명사로 구성
    • 주소 외에도 HTTP 요청 메소드를 사용
      • GET
        : 서버 자원을 가져오고자 할 때 사용, 쿼리스트링 사용
        : 브라우저에서 캐싱(기억)할 수도 있어 같은 주소로 GET 요청을 할 때 서버에서 가져오는 것이 아니라 캐쉬에서 가져올 수 있음
      • POST
        : 서버에 자원을 새로 등록하고자 할 때 사용, 요청의 본문에 새로 등록할 데이터를 넣어 보냄
      • PUT
        : 서버의 자원을 요청에 들어 있는 자원으로 치환하고자 할 때 사용, 요청의 본문에 치환할 데이터를 넣어 보냄
      • PATCH
        : 서버 자원의 일부만 수정하고자 할 때 사용, 요청의 본문에 일부 수정할 데이터를 넣어 보냄
      • DELETE
        : 서버의 자원을 삭제하고자 할 때 사용, 요청의 본문에 데이터를 넣지 않음
      • OPTIONS
        : 요청을 하기 전에 통신 옵션을 설명하기 위해 사용
    • 주소와 메소드만 보고 요청의 내용을 알아볼 수 있다는 것이 장점
    • 클라이언트가 누구든 상관없이 같은 방식으로 서버와 소통할 수 있음 (서버와 클라이언트 분리)

restFront.css

a {
  color: blue;
  text-decoration: none;
}

 

restFront.html

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>RESTful SERVER</title>
    <link rel="stylesheet" href="./restFront.css" />
  </head>
  <body>
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
    </nav>
    <div>
      <form id="form">
        <input type="text" id="username" />
        <button type="submit">등록</button>
      </form>
    </div>
    <div id="list"></div>

    <script src="http://unpkg.com/axios/dist/axios.min.js"></script>
    <script src="./restFront.js"></script>
  </body>
</html>

 

restFront.js

// 로딩 시 사용자 정보를 가져오는 함수
async function getUser() {
  try {
    const res = await axios.get("/users");
    console.log(res);
    const users = res.data;
    const list = document.querySelector("#list");
    list.innerHTML = "";

    // 사용자마다 반복적으로 화면 표시 및 이벤트 연결
    Object.keys(users).map((key) => {
      const userDiv = document.createElement("div");
      const span = document.createElement("span");
      span.textContent = users[key];
      const edit = document.createElement("button");
      edit.textContent = "수정";

      edit.addEventListener("click", async () => {
        const name = prompt("바꿀 이름을 입력하세요.");
        if (!name) {
          return alert("이름을 반드시 입력해야 합니다.");
        }
        try {
          console.log(key, { name });
          await axios.put("/user/" + key, { name });
          getUser();
        } catch (error) {
          console.error(error);
        }
      });

      const remove = document.createElement("button");
      remove.textContent = "삭제";
      remove.addEventListener("click", async () => {
        try {
          await axios.delete("/user/" + key);
          getUser();
        } catch (error) {
          console.error(error);
        }
      });
      userDiv.appendChild(span);
      userDiv.appendChild(edit);
      list.appendChild(userDiv);
      console.log(res.data);
    });
  } catch (error) {
    console.error(error);
  }
}

// 화면 로딩 시 getUser 호출
window.onload = getUser;

// 폼 제출 시 실행
document.querySelector("#form").addEventListener("submit", async (e) => {
  e.preventDefault();
  const name = e.target.username.value;
  if (!name) {
    return alert("이름을 입력하세요.");
  }
  try {
    await axios.post("/user", { name });
    getUser();
  } catch (error) {
    console.error(error);
  }
  e.target.username.value = ""; // 초기화
});

 

about.html

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>RESTful SERVER</title>
    <link rel="stylesheet" href="./restFront.css" />
  </head>
  <body>
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
    </nav>
    <div>
      <h2>소개 페이지입니다.</h2>
      <p>사용자 이름을 등록하세요!</p>
    </div>
  </body>
</html>

 

restServer.js

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

http
  .createServer(async (req, res) => {
    try {
      console.log(req.method, req.url);
      if (req.method === "GET") {
        // 메인 페이지 요청이 들어왔을 때
        if (req.url === "/") {
          const data = fs.readFile(path.join(__dirname, "restFront.html"));
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        }
        // about 페이지 요청 왔을 때
        else if (req.url === "/about") {
          const data = fs.readFile(path.join(__dirname, "about.html"));
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        }
        // 주소가 /와 /about 둘 다 아닐 때
        try {
          const data = fs.readFile(path.join(__dirname, req.url));
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        } catch (error) {
          // 주소에 해당하는 라우터를 못 찾은 경우 404 에러 발생
        }
      }
      res.writeHead(404);
      return res.end("NOT FOUND");
    } catch (error) {
      console.error(error);
      res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(err.message);
    }
  })
  .listen(8082, () => {
    console.log("8082 포트에서 대기 중입니다.");
  });

req.method로 HTTP 요청 메소드를 구분하고, 해당 메소드가 GET이라면 req.url로 주소를 구분하여 처리합니다.

만일 존재하지 않는 파일을 요청하거나 GET 메소드가 아니라면 404 에러가 발생되고, 이 외 에러가 발생한 경우 500 에러로 응답합니다.

 

이어서 코드를 완성 시켜보겠습니다.

restServer.js

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

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

http
  .createServer(async (req, res) => {
    try {
      console.log(req.method, req.url);
      if (req.method === "GET") {
        // 메인 페이지 요청이 들어왔을 때
        if (req.url === "/") {
          const data = fs.readFile(path.join(__dirname, "restFront.html"));
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        }
        // about 페이지 요청 왔을 때
        else if (req.url === "/about") {
          const data = 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": "text/html; charset=utf-8" });
          return res.end(JSON.stringify(users));
        }
        // 주소가 /와 /about 둘 다 아닐 때
        try {
          const data = fs.readFile(path.join(__dirname, req.url));
          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
          return res.end(data);
        } catch (error) {
          // 주소에 해당하는 라우터를 못 찾은 경우 404 에러 발생
        }
      } else if (req.method === "POST") {
        if (req.url === "/user") {
          let body = "";
          // 요청의 body를 stream 형식으로 받음
          req.on("data", (data) => {
            console.log(data);
            body += data;
          });
          // 요청의 body를 다 받은 후 실행
          return req.on("end", () => {
            console.log("POST 본문(Body): ", body);
            const { name } = JSON.parse(body);
            const id = Data.now();
            users[id] = name;
            console.log(users);
            res.writeHead(201, { "Content-Type": "text/html; charset=utf-8" });
            res.end("등록 성공");
          });
        }
      } else if (req.method === "PUT") {
        if (req.url.startsWith("/user/")) {
          console.log(req.url.split("/"));
          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/html; 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/html; charset=utf-8" });
          return res.end(JSON.stringify(users));
        }
      }
      res.writeHead(404);
      return res.end("NOT FOUND");
    } catch (error) {
      console.error(error);
      res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(err.message);
    }
  })
  .listen(8082, () => {
    console.log("8082 포트에서 대기 중입니다.");
  });

POST와 PUT 요청 처리에 req.on('data')와 req.on('end')를 사용하는 것을 확인할 수 있는데, 이는 요청의 본문에 들어 있는 데이터를 꺼내기 위한 작업입니다.

req와 res도 내부적으로는 스트림으로 되어 있으므로 요청/응답의 데이터가 스트림 형식으로 전달됩니다.

다만, 받은 데이터는 단순 문자열이므로 이를 JSON으로 만드는 JSON.parse 작업이 필요합니다.

  • Name : 요청 주소
  • Status : HTTP 응답 코드
  • Protocol : 통신 프로토콜
  • Type : 요청 종류