웹 백엔드

Node.js - 게시판 글 등록 및 삭제

토리쟁이 2024. 2. 3. 03:35

 

 

 

 

 

 

이번 포스팅에서는, 앞서 공부했던 node.js 내용들을 바탕으로 작은 게시판을 만들어 보려고 한다. 게시판을 만들면서 이전에 공부했던 내용들을 다시 한 번 정리해 볼 것이다.

(공부했던 내용만을 기반으로 실습을 진행했기 때문에, 로직에 오류만 없을 뿐 베스트 코드는 아님.)


 

게시판 실습 코드

 

index.ejs - 메인 화면

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>메인페이지</title>
    <!--head include-->
    <%- include('./include/head')%>
  </head>
  <body>
    <!--header include-->
    <%- include('./include/header')%>
    <h2>🐥안녕하세요! <%=user%>의 블로그입니다🐥</h2>
    <ul>
      <%for(let content of contentData){%>
      <li>
        <a href="/content/<%=content.contentID%>"><%=content.title%></a>
        <!--함수의 인자로 ejs 변수를 사용하고 싶을 떈, 반드시 ''로 묶어줌으로써 텍스트화를 해야한다.-->
        <!--this: 이벤트가 발생하는 버튼 요소-->
        <button onclick="deleteContent('<%=content.contentID%>', this)">
          삭제
        </button>
      </li>
      <%}%>
    </ul>                                                                                                                                                                                                                                                                                                                              
  </body>
</html>

 

 

 

header.ejs와 head.ejs에는bootstrap, axios, google fonts, css 등을 사용하기 위한 연결(script, link, cdn) 코드를 작성해주었으나, 편의상 코드를 첨부하지는 않겠다.

 

 

블로그의 기능으로는 1. 전체 글의 목록을 조회,  2. 글 제목 클릭시 글 세부 내용 보여주기, 3. 새 글 작성, 4. 삭제 버튼 클릭시 해당 글 삭제 이렇게 4가지 기능을 구현하였다. 기능을 구현하면서, 중요한 개념들은 추가적으로 정리하고 넘어가자.

 

 

 

설정을 위한 공통 코드는 다음과 같다.

const express = require("express"); // express 모듈 불러오기
const path = require("path"); // path 모듈 불러오기
const multer = require("multer"); // multer 모듈 불러오기

const app = express(); // 서버 생성
const PORT = 8089; // 포트 번호

const userID = "KHY"; // userID


// 미들웨어

// 뷰 관련 설정
app.set("view engine", "ejs"); // 뷰 engine을 ejs로 설정
app.set("views", "./views"); // 명시적으로 폴더 위치 선언 -> 그 안의 파일을 불러올 때 굳이 앞에 ./views를 적지 않아도 ㅇㅋ

// static 관련 설정
// app.use('/가상 경로(원하는 이름으로-)', express.static(실제 폴더 경로))
// 실제 폴더 경로: __dirname + /폴더명
 // __dirname: 현재의 파일이 위치한 폴더의 절대 경로
 // 업로드가 어디에 있는지 경로를 알려주는 코드
app.use("/static", express.static(__dirname + "/public"));
app.use("/uploads", express.static(__dirname + "/uploads"));

// body-parser 설정
// extended:false - node.js에 기본적으로 내장된 queryString 모듈 사용하여 queryString 해석
// extended:true - 추가로 설치가 필요한 qs 모듈 사용하여 queryString 해석 (queryString에 비해 보안적으로 good)
app.use(express.urlencoded({ extended: false }));
app.use(express.json()); // 요청 body에서 json 정보만 추출할 수 있도록 도와줌


// 멀티 파트 데이터 업로드 설정
const upload = multer({
  storage: multer.diskStorage({
    // 경로 설정
    destination(req, file, done) {
      done(null, "uploads/");
    },
    // 파일명 설정
    filename(req, file, done) {
      /*
        extname(파일명): 확장자 추출
        basename(파일명, 확장자): 확장자를 제외한 파일명 추출
        basename(경로명): 확장자가 포함된 파일명 추출
        */
      const ext = path.extname(file.originalname);
      done(null, path.basename(file.originalname, ext) + Date.now() + ext);
    },
  }),
  // 제한
  limits: { fieldSize: 5 * 1024 * 1024 }, // 파일 크기 바이트 단위로 제한
});

 

 

 

위의 코드에 명시된 여러 가지의 미들웨어 설정을 자세히 살펴보자

 

미들웨어란, 요청(request)과 응답(response) 사이에서 중간 역할을 하는 sw로, body-parser, multer 등이 있다. 아래에서 각각의 미들웨어 설정에 대해 정리해 볼 것이다. 

 

 

 

 

뷰 관련 설정

  • view Engine: 서버에서 js로 만든 변수를 전송하여 클라이언트에서 활용할 수 있도록 해주는 엔진 → 즉, 정적인 HTML을 동적으로 만들어줌 ex) ejs, pug, nunjucks 등..
  • app.set("view engine", "ejs"); // 뷰 engine을 ejs로 설정
  • app.set("views", "./views"); // 명시적으로 폴더 위치 선언 → 그 폴더 안의 파일을 불러올 때 굳이 앞에 ./views를 적지 않아도 됨

 

 

 

static 관련 설정

  • static 폴더란, 외부에서 접근 가능한 폴더로, js, css, image 등의 변하지 않는 파일들이 포함됨
  • static 미들웨어는 express에서 제공하는 기본 미들웨어이며, 정적인 파일들을 제공하는 라우터 역할을 함
  • 기본적으로 제공되므로, 별도의 설치 없이 express 객체 안에서 꺼내서 사용하면 됨
  • app.use('요청 경로', express.static('실제 경로')): 요청 경로로 들어왔을 때, 실제 경로 안에 있는 폴더에서 해당 파일을 찾아 접근 → 즉, 서버의 폴더 경로와 요청 경로가 다르기 때문에 외부인이 서버의 구조를 쉽게 파악할 수 없음 → 보안성↑
  • __dirname: 현재 실행하는 파일의 file명을 제외한 절대 경로
    • ex) /99_express/app.js 실행시, dirname은 /99_express가 된다.
  • (__filename: 현재 실행하는 파일의 file명을 포함한 절대 경로)
  • express.static(): 정적 파일에 대한 기본 경로 제공
  • 실제 폴더 경로: __dirname + /폴더명
  • app.use("/static", express.static(__dirname + "/public"));
    • 현재 구동되고 있는 app.js의 파일명 제외한 절대 경로 + 실제 경로 →  가상 경로로 대체

 

 

 

body-parser 설정

  • 요청(request)의 body에 있는 데이터를 해석해서 req.body 객체로 만들어주는 미들웨어
  • 보통 form or ajax 요청의 데이터를 처리
  • but, 멀티 파트 데이터(이미지, 동영상, 파일 등)는 처리 불가X → multer 모듈 이용
  • app.use(express.urlencoded({extended:false}))
    • true: qs모듈(querystring 모듈을 더 확장시킨 모듈)을 사용하여 쿼리 스크링 해석, 보안성 good
      • 내장 모듈이 아니기 때문에, 설치 필요
    • false: querystring 모듈을 사용하여 쿼리스트링 해석
  • app.use(express.json())
    • json 형태의 요청(request) body를 parsing하기 위해 사용

 

 

 

multer 설정

  • 이미지, 동영상, 파일 등 과 같은 멀티파트 데이터를 멀티파트 형식으로 업로드할 때 사용되는 미들웨어
  • 내장 모듈이 아니기 때문에, 따로 설치 필요(npm install multer)
  • 멀티파트 요청은 post 방식을 사용하며,  headers에 "Content-type":"multipart/form-data" 설정 필요
  • 변수 선언 = multer({설정})
    • 경로 설정
    • 파일명 설정
      • extname(파일명): 확장자 추출
      • basename(파일명, 확장자): 확장자를 제외한 파일명 추출
      • basename(경로명): 확장자가 포함된 파일명 추출
    • 제한 limits
      • fieldsize: 파일 크기를 바이트 단위로 제한

 

 

 

 

 

이제, 미들웨어 설정에 대한 정리를 모두 했으니, 각각의 기능을 구현해보자.

 

DB는 생성하지 않았기 때문에, js 파일에서 4개의 글 객체가 담긴 배열을 선언하여 임시 DB로 사용했다.

let tempDB = [
  {
    contentID: 1,
    title: "글제목1",
    content: "글 내용1",
    img: null, // null or path(string)
  },
  {
    contentID: 2,
    title: "글제목2",
    content: "글 내용2",
    img: null, // null or path(string)
  },
  {
    contentID: 3,
    title: "글제목3",
    content: "글 내용3",
    img: null, // null or path(string)
  },
  {
    contentID: 4,
    title: "글제목4",
    content: "글 내용4",
    img: null, // null or path(string)
  },
];

const userID = "KHY";

 

 

 

 

1. 메인 페이지 - 전체 글 목록 조회

app.get("/", function (req, res) {
  res.render("index.ejs", {
    // 두번째 인자에는 {}로 감싸 데이터 객체를 보낼 수 있음
    user: userID,
    contentData: tempDB, //[{}, {}...]
  });
});

 

백 로직은 서버에 들어가면(= get 방식으로 요청을 받으면),  userID와 글이 담긴 DB를 객체로 묶어 메인 페이지로 응답하며 렌더링해주는 것이다. 초반의 index.ejs 코드를 보면 알 수 있듯, 서버로부터 넘겨 받은 user와 contentData를 ejs 템플릿 엔진을 사용해 화면에 뿌려줌으로써 메인 화면에서 모든 글을 조회할 수 있다.

 

 

 

 

 2. 글 제목 클릭시 글 세부 내용 보여주기

// :parameter를 설정한 이름의 변수로 받아오겠다는 의미
app.get("/content/:contentID", (req, res) => {
  console.log(req.params); // 첫 번째 글을 클릭한 경우-> { contentID: '1' }

  // 구조 분해 할당으로 받아줘야 함(안 그러면, undefined 찍힘)
  // 구조 분해 할당: 구문의 배열이나 객체의 속성을 해체하여 그 값을 개별 변수에 담음
  const { contentID } = req.params;
  // 구조 분해 할당을 하지 않을 경우, req.params.contentID으로 접근하면 contentID 값을 알 수 있음
  console.log(req.params.contentID); // 1

  // 일치하는 게시물 여부 확인을 위해 tempDB의 contentID와 params로 들어오는 contentID 비교
  // contentID의 타입은 string이므로, 숫자로 바꾸어 비교할 것
  // isContent에는 일치하는 게시물 객체를 담음
  const isContent = tempDB.filter(
    (obj) => obj.contentID === Number(contentID)
  )[0];
  console.log("isContent", isContent); // {객체} or undefined

  if (isContent) { // 일치하는 게시물이 있으면
  // content 페이지에 isContent 데이터를 보내며 렌더링
    res.render("content", isContent); // 추가적인 데이터를 포함시켜 넘기고 싶은 경우엔 ...연산자를 사용하거나 객체로 묶어서 보낼 것
  } else {
    res.render("404");
  }
});

 

위의 get 요청에 있는 경로를 보면, "/content/:contentID" 로 되어 있다. 이는 글에 따라 해당 글의 세부 내용을 보여주는 페이지로 이동해야 되기 때문에, 고정 경로가 아닌 변수에 따라 경로가 달라지도록 하였다. 방법은 경로에 :를 사용하면 : 뒤를 parameter로 받을 수 있다. 위의 코드처럼 : 뒤에 있는 contentID를 변수로 받아 경로가 contentID에 따라 달라지도록 하였다. 

그 다음엔, 구조 분해 할당을 사용했다. 구조 분해 할당이란, 구문의 배열이나 객체의 속성을 해체하여 그 값을 개별 변수에 담아 사용하기 위해 사용되는 문법이다. 여기서 요청시 넘겨받은 contentID 변수의 값이 req.params에 객체 형태로 들어 있었는데, 이를 사용하기 위해 구조 분해 할당 문법을 사용했으며, 구조 분해 할당을 사용하지 않고 req.params.contentID를 사용해도 된다.

그 다음으로는, 요청으로부터 넘겨받은 contentID값과 일치하는 글의 객체를 담기 위해 임시 DB에 filter 함수를 사용해 해당 글을 담았다. 해당 글이 있을 경우엔, 그 글의 객체를 isContent 변수에 담아 content 페이지로 렌더링해줬고 그렇지 않으면  404 페이지로 렌더링해주었다. content 페이지는 다음과 같다.

 

 

content.ejs

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <!--head include-->
    <%- include('./include/head')%>
  </head>
  <body>
    <!--header include-->
    <%- include('./include/header')%>
    <style>
      h1,
      div {
        text-align: center;
      }

      p {
        /*
        white-space
        default값: normal -> html문서 안에서 띄어쓰기/들여쓰기/줄바꿈 등 아무리 공백 문자를 많이 쓰더라도 하나의 띄어쓰기로 처리됨
         pre-line -> 줄바꿈이 일어남
        */
        white-space: pre-line;
      }
    </style>
    
    <h1><%= title%></h1>
    <div>
      <p><%= content%></p>
      <%if(img){%>
      <img src="/<%=img%>" alt="<%=contentID%>번글 이미지" width="300" />
      <%}%>
    </div>
  </body>
</html>

 

응답(response)받은 isContent(글 객체)에 들어있는 title과 content, img를 빼와서 화면에 출력

 

 

 

 

3. 새 글 작성

//  새 글 작성하기 렌더링
// /content/write 불가능 -> write를 파라미터로 인식해서 글쓰기 요청을 아래에서 처리하지 못함
app.get("/write", function (req, res) {
  res.render("writeContent"); // 글 쓰기 페이지 렌더링
});

 

 

새 글 작성 경로를 /write로 지정하였다. /content/write는 앞서 게시물 조회 경로를 /content/:contentID로 설정했기 때문에 /content 뒤에 오는 write를 변수로 인식하여 글 쓰기 요청을 처리하지 못 한다. 따라서, 그냥 /write 경로로 지정했으며, 이와 같이 경로에 변수를 설정한 경우 다른 요청의 경로를 신경써서 지정해야 한다.

 

 

글 작성 페이지 writeContent.ejs

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>글 작성하기(폼 태그 제출)</title>
    <%- include('./include/head')%>
    <style>
      h1 {
        text-align: center;
      }
      form {
        text-align: center;
        border: 1px solid #ddd;
        border-radius: 20px;
        padding: 1rem 0;
        margin: 2rem;
      }
      form textarea {
        width: 400px;
        height: 500px;
      }

      form input[type="text"] {
        width: 400px;
      }

      button {
        border: none;
        border-radius: 10px;
        font-weight: 700;
        padding: 8px;
      }
    </style>
  </head>
  <body>
    <h1>글 작성하기</h1>
    <form action="/blog/post" method="post" enctype="multipart/form-data">
      <input type="text" name="title" placeholder="제목을 작성해주세요😊" />
      <br />
      <textarea name="content" placeholder="내용을 작성해주세요😊"></textarea>
      <br />
      <input type="file" name="img" id="" />
      <br />
      <br />
      <button type="submit">작성</button>
    </form>
  </body>
</html>

 

작성된 새로운 글을 전송하기 위해 form에서 /blog/post 경로 및 post 방식을 지정했으며, 멀티 파트 데이터 업로드를 위해 enctype을 "multipart/form-data"로 명시하여 작성하였다. + 새로운 글 작성을 위해서는 작성된 글 데이터를 서버로 전송시켜 임시 DB인 tempDB에 넣는 로직도 필요하다.

 

 

// 작성된 글 전송하기
app.post("/blog/post", upload.single("img"), (req, res) => {
  // 들어온 데이터를 객체 형태로 저장
  tempDB.push({
    // DB가 비어있지않다면, 마지막 번호 +1 비어있다면 1번으로-
    contentID:
      tempDB.length !== 0 ? tempDB[tempDB.length - 1].contentID + 1 : 1, // 가장 마지막의 게시물 번호 +1
    title: req.body.title,
    content: req.body.content,
    img: req.file ? req.file.path : null, // 파일 객체가 있다면, 파일의 경로를 넣어주고 그렇지 않다면 null로-
  });

  res.redirect("/"); // 작성 완료 후 메인화면 렌더링
});

 

작성된 글은 멀티파트 데이터가 포함될 수 있으므로, multer 미들웨어로 생성한 upload 객체를 사용하여 하나의 파일을 받을 수 있게 작성했다. post 방식이기 때문에 작성된 제목과 내용은 req.body에 들어오고 파일은 req.file에 들어온다. 작성된 새 글은 배열 관련 메소드인 .push()를 사용해 {} 안 객체로 묶어 tempDB에 넣었다. 배열에 넣기 전, contentID를 결정하기 위해 삼항 연산자를 사용했다. 만약 tempDB에 게시물이 들어있으면, 가장 마지막에 위치한 게시물의 contentID에 1을 더한 값을 contentID에 넣었다. 하지만, 게시물이 들어있지 않으면 contentID 값을 1로 넣었다.

(참고: 파일 경로는 req.file.path에 들어있음)

 

 

 

 

 

4. 게시물 삭제

 

index.ejs를 보면, 각각의 게시물 옆에 삭제 버튼을 달아줬었다.

<ul>
      <%for(let content of contentData){%>
      <li>
        <a href="/content/<%=content.contentID%>"><%=content.title%></a>
        <!--함수의 인자로 ejs 변수를 사용하고 싶을 땐, 반드시 ''로 묶어줌으로써 텍스트화를 해야한다.-->
        <!--this: 이벤트가 발생하는 버튼 요소-->
        <button onclick="deleteContent('<%=content.contentID%>', this)">
          삭제
        </button>
      </li>
      <%}%>
    </ul>

 

그 삭제 버튼을 누르면, 게시물을 지우는 함수가 작동되는데, 코드는 다음과 같다.

 

<script>
      function deleteContent(contentID, btn) {
        console.log(contentID); // 지우려는 게시물의 contentID 확인
        console.log(btn); // this로 전달된 button: <button onclick="deleteContent('1', this)"> 삭제 </button>
        if (confirm("정말 삭제하시겠습니까?😢")) {
          // 삭제 로직 작성
          axios({
            method: "delete",
            url: "blog/delete?contentID=" + contentID,
          }).catch((err) => {
            console.err(err);
          });

          // button의 부모요소를 선택하여 삭제
          btn.parentNode.remove();
        }
      }
    </script>

함수의 인자로 contentID해당 클릭 이벤트가 발생하는 요소인 btn을 넘겨받는다. 삭제 버튼을 누르면, confirm창을 띄우고 삭제 확인을 받으면 axios 방식의 통신으로 해당 contentID를 쿼리 스트링에 담아 delete 요청을 보낸다. 그 후, axios 통신이 완료되면, 클릭 이벤트가 발생한 해당 버튼 요소인 btn의 부모요소에 접근하여(.parentNode) .remove 함수를 사용해 해당 요소를 삭제한다. 

 

// 게시물 삭제
app.delete("/blog/delete", (req, res) => {
  console.log(req.query); // { contentID: '1' }

  // 구조 분해 할당 (바로 사용하기 위해-)
  // 구조 분해 할당: 구문의 배열이나 객체의 속성을 해체하여 그 값을 개별 변수에 담음
  const { contentID } = req.query;

  // 해당 게시물 아이디가 아닌 게시물들만 필터링하여 모음
  tempDB = tempDB.filter((obj) => obj.contentID !== Number(contentID));
  console.log(tempDB);

  // 따로 res or send를 사용해 응답(response)해주지 않기 때문에 명시적으로 end를 사용해 응답을 종료 시켜줘야 함
  res.end(); // 응답 종료
});

 

클라이언트로부터 삭제하고 싶은 contentID는 req.query에 들어있으며, filter 함수를 사용해 tempDB에서 해당 contentID가 아닌 글 객체들을 필터링하여 다시 tempDB에 재할당하면, 해당 글이 삭제된다. 삭제는 따로 클라이언트에게 응답해주는 것이 없기 때문에 res.end()를 사용하여 명시적으로 응답을 종료시켰다.

 

 

 

 

설정해놓은 페이지가 아니라면, 404 페이지 렌더링

***반드시 맨 아래에서 작성할 것***

// **맨 아래에서 작성할 것**
// 설정해놓은 경로가 아니라면, 404 페이지 렌더링
app.get("*", (req, res) => {
  res.render("404");
});

 

 


참고

express.static(): https://hwb0218.tistory.com/35

body-parser: https://cheony-y.tistory.com/267      https://kirkim.github.io/javascript/2021/10/16/body_parser.html

파라미터 전달: https://gofnrk.tistory.com/104