[React + Node.js] 간단한 게시판 만들기

프로젝트 목표
SPA ( Single Page Application ) 구현
Rest API 로 서버와 통신하기
React 의 Hook , Props , States 활용해보기
Node.js 와 Express.js 로 서버 구현하기
쿠키를 이용해서 로그인 상태 보존하기


  1. 11 웹개발에 처음 입문하고 4달간 여러 기술을 배우면서 제대로 된 프로젝트를 진행 해 본적이 없다. 지금까지 배운 기술들을 복습하고 정리할겸 만들기 쉽고 접하기 쉬운 게시판을 구현해 보기로 했다.
    물론 처음하는 분야에 대한 지식이 많지 않기에 개인 블로그에 내 코드를 리뷰하고, 다른 개발자의 코드와 비교하며 보완해 갈 계획이다. 이것 때문에 Velog 를 시작한 이유이고, 나의 성장 과정을 기록하기 위한 나만의 공간이다.

게시판 기능 구현 목표
1 . 포스팅 조회하기
2 . 포스팅 등록하려면 회원가입해야합니다.
3 . 해당 포스팅을 등록한 사람만 수정, 삭제를 할 수 있습니다.
4 . 포스팅이 수십 개로 늘어나면 한 번에 목록을 다 보여주기보다는 페이지네이션을 해서 보여줍니다.
5 . 각각의 포스팅에는 댓글을 달 수도 있습니다.
6 . 포스팅과 똑같이 댓글을 등록하려면 회원 로그인을 해야합니다.
7 . 해당 댓글을 등록한 사람만 삭제를 할 수 있습니다.

제시된 코드들은 코드중에 일부만 추출해서 가져왔습니다.
전체 코드는 하단에 있는 GitHub를 참조해주세요.


구현 하기

로그인 화면 구현하기

 

const [id, changeid] = useState('');
const [pw, changepw] = useState('');
const [loginid, setloginid] = useState('');

const signup = async () => {
        const signupresult = await axios.post('http://localhost:8080/signup', { id, pw });
        if (signupresult.data[0] === null) {
            alert('아이디 또는 비밀번호를 잘못 입력했습니다.');
        } else {
            setloginid(signupresult.data[0].id);
            setFrame();
        }
    }

useEffect(() => {
        setCookie('myToken', loginid, {
            path: "/",
            secure: true,
            sameSite: "none",
        })
    }, [loginid]);

Axios 로 서버에 Post를 요청해 로그인 요청을 했습니다. 원래 GET을 사용하려 했지만, 서버로 값을 보내기에는 URL 파라미터나 POST를 사용해야 하지만, 보안상 문제가 될꺼같았습니다.
이에 https://velog.io/@jch9537/REST-API-LogIn-GET-vs-POST
게시글을 참조하여 POST를 사용했습니다.

하지만 가장 좋은 방법은 SSL(https)로 처리하는 방법입니다.

2번째는 useEffect() Hook을 사용하여 loginid(로그인을 성공했을 때)의 상태값이 바뀔때마다 로그인된 아이디를 쿠키값으로 등록했습니다.


회원가입 창 구현

.

const [id, setid] = useState('');
    const [showpw, setshowpw] = useState('password');

    const showpwbt = () => {
        if (showpw === 'password') {
            setshowpw('text');
        } else {
            setshowpw('password');
        }
    }
    


    const regist = async () => {
        const registresult = await axios.post('http://localhost:8080/regist', { id, pw });

        if (registresult.data === -1) {
            alert('이미 존재하는 아이디입니다.')
        }
        if (registresult.data === 1) {
            alert('회원가입이 성공적으로 되었습니다.')
            window.location.replace('/login');
        }
    }

비밀번호 표시는 CheckBox 선택 시 type 속성에 showpw State 가 최신화 되면서 값이 새로고침 될 수 있도록 했습니다.

<input type={showpw} className="idInput" placeholder="password" onChange={typingpw} />

회원가입 또한 axios 를 이용해 서버와 POST 요청으로 데이터베이스에 해당 계정이 있는지 확인 후 , 가입 여부를 결정했습니다.

회원정보 찾기

.

const findid = async () => {
        const result = await axios.put('http://localhost:8080/findid', id);
        if (result.data === -1) {
            alert('찾을 수 없는 아이디입니다.');
        } else {
            alert(`${id} 님의 앞3자리 비밀번호는 ${result.data}`);
        }
    }

회원 정보를 찾기를 구현할 때, 사용자의 아이디를 입력받아 최소한의 비밀번호 (서버측에서 문자열 0번째에서 3번째 까지만 보냅니다.) 를 노출 시키는것으로 결정했습니다.

res.send(pwresult.substring(0, 3));


게시글 구현

게시글 틀 구현하기

const getcookiestat = getCookie('myToken');

const gs = () => {
        const a = getCookie('myToken');
        if (a === '') {
            setloginstatus(false);
        }
        else {
            setloginstatus(true);
        }
    }

<Routes>
        <Route path='/noticelist' element={<NoticeList idstatus={getcookiestat} />}></Route>
        <Route path='/newpost' element={<NewPost idstatus={getcookiestat} />}></Route>
        <Route path='/modified/:id' element={<NewPost idstatus={getcookiestat} />}></Route>
        <Route path='/viewpost' element={<PostView idstatus={getcookiestat} />}></Route>
        <Route path='*' element={<NotFound />} />
</Routes>

로그인 창에서 구현한 쿠키를 가져오며, 라우터를 이용해 Single Page Application 을 구현했습니다. path='*'은 잘못된 주로소 접근했을 때 404 페이지를 보여주기 위해 구현했습니다.


게시글 목록 구현하기

.

const getpost = async () => {
        const getpostresult = await axios.get('http://localhost:8080/getpost');
        setpoststat(getpostresult.data);
    }

<Postcontent data={poststat} pages={pages} />
  

우선 GET 요청으로 데이터베이스에서 게시글의 정보를 가져와, Props을 이용하여, 게시글을 배열 형태를 그대로 전달했습니다.
또한 페이지네이션으로 10페이지씩 보여줄것이기 때문에, 현재 페이지의 값 ( 페이지네이션으로 지금 보고있는 페이지 ) 를 보내줍니다.

물론 배열와 페이지 값은 State에 저장되어있어, 서버에서 값을 가져올 때 쉽게 갱신될 수 있게 했습니다.

// Postcontent 파일

const Postcontent = ({ data, pages }) => {

  const arrs = data.slice((pages - 1) * 10, pages * 10);


  return (
      arrs.map(posts => (
          <Link to={`/viewpost?page=${posts.numbers - 1}`} className="linktopost" key={posts.numbers}>
              <div className="noticedescription userPost">
                  <span>{posts.numbers}</span>
                  <span>{posts.title}</span>
                  <span>{posts.writer}</span>
                  <span>{posts.posttime}</span>
                  <span>{posts.likes}</span>
                  <span>{posts.views}</span>
              </div>
          </Link>
      ))
  );
};

Props 으로 받은 배열 data 은 map을 통해 하나씩 열거합니다. 또한 페이지마다 각각의 query string 을 할당해주어, 각각의 페이지로 연결될 수 있게 했습니다.

 const arrs = data.slice((pages - 1) * 10, pages * 10);

서버에서 모든 데이터를 받아 배열을 현재 페이지네이션을 기준으로 10개씩 나눠 새 변수에 배열을 저장합니다.


게시글 뷰 구현하기

.

const params = new URLSearchParams(window.location.search).get('page');

const findpost = getpostresult.data[getpostresult.data.findIndex(datas => datas.numbers === parseInt(params) + 1)]
setnowpages(findpost.numbers);
setcontentarr(findpost); // findindex 로 해당 키값이 어떤 배열에 저장되어있는지 확인 후 해당 배열 반환
await axios.get(`http://localhost:8080/viewupdate/${findpost.numbers}`);

우선 리스트에서 각자 부여한 url string 를 이용해 데이터베이스에서 각 페이지에 해당하는 페이지를 불러옵니다.
findpost 는 서버에서 받은 해당 페이지의 데이터를 url string 과 대조해서 그에 해당하는 데이터를 가져옵니다.


게시글 페이지네이션 구현

const buttoncall = () => {
  		arrs.length = 0;
        pages < Math.ceil(poststat.length / 10)
        for (let i = 1; i <= pages.length; i++) {
            if (nowpage === i) {
                arrs.push([<span className='noticePagenationButtonNow' onClick={() => setpage(i)}>{i}</span>, i])
            } else {
                arrs.push([<span className='noticePagenationButton' onClick={() => setpage(i)}>{i}</span>, i])
            }
        }
}
        useEffect(() => {
        buttoncall();
    }, [pages, nowpage]);

    (function () {
        buttoncall();
    }());


	arrs.map((data) => (
        <Fragment key={data[1]}>
            {data[0]}
        </Fragment>
    )

페이지가 많아지면 페이지를 나눠 적당한 갯수만 노출 될 수 있도록 해야합니다. 또한 사용자가 몇번 페이지에 있는지 알려주는 기능도 구현했습니다.
먼저

pages < Math.ceil(poststat.length / 10)

위의 코드를 이용해서 페이지네이션 갯수를 알아냈습니다. 10씩 나누고 나머지는 무조건 올리는 방식을 사용했습니다.

(function () {
        buttoncall();
}());

즉시 생성 함수를 이용해 위에 함수가 즉시 한번은 실행하게해 밑의 페이네이션을 보이게 해놨습니다. 그리고 현재 페이지가 바뀔때마다 배열을 초기화 하고 ( arrs.length = 0; ) arrs.push를 이용해 현재 페이지가 어딜 보고있는지 구현했습니다.

게시글 좋아요 구현

if (checkid === postcontentarr.writer) {
                alert('자신의 글에 좋아요를 할 수 없습니다.');
            } else {
                const likeresult = await axios.patch('http://localhost:8080/likes', [findpost.likes, findpost.numbers, checkid]);
                if (checkid === '') {
                    alert('로그인 후 이용해주세요.');
                }
                else if (likeresult.data === -2) {
                    alert('이미 좋아요를 눌렀습니다.');
                }
            }

	

만약 글 게시자와 현재 로그인된 사용자가 같거나, 로그인이 안되어있으면 거부하고, 그게 아니라면 PATCH 요청으로 서버에 likes 와 게시글 번호 , 좋아요를 누른 사용자의 아이디를 보내줍니다.

같은 사용자가 좋아요를 눌렀을 때 거부하기 위해 사용자의 id를 보냅니다.


게시글 삭제 구현

 if (checkid === postcontentarr.writer) {
            if (window.confirm("게시글을 삭제하시겠습니까?")) {
                const result = await axios.delete(`http://localhost:8080/deletepost/${data}`);
                await axios.delete(`http://www.localhost:8080/deleteallcommit/${data}`);
                if (result.data === -1) {
                    alert('오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
                } else if (result.data === 0) {
                    window.location.replace('/noticelist');
                }
            }
        } else {
            alert('내용 삭제는 작성자만 할 수 있습니다.');
        }

글 삭제는 작성자와 로그인된 사용자가 일치해야 삭제할 수 있어야합니다.
우선 첫번째 if 문으로 게시자와 사용자가 일치하는지 확인 후 해당 게시글과 댓글을 한꺼번에 삭제하기 위한 요청을 합니다.
만약 정상적으로 처리되면 window.location.replace 를 이용해 목록으로 돌아갑니다.


게시글 수정 구현

게시글 특성 상 수정을 위해선 해당 게시글에 있는 글을 그대로 가져와 이어서 작성할 수 있어야합니다.

if (checkid === postcontentarr.writer) {
            window.location.href = `/modified/${postcontentarr.numbers}`;
        } else {
            alert('글 수정은 작성자만 할 수 있습니다.');
        }

글 수정은 삭제와 마찬가지로 글 작성자와 사용자가 같아야합니다. 우선 링크를

<Route path='/newpost' element={<NewPost idstatus={getcookiestat} />}>
<Route path='/modified/:id' element={<NewPost idstatus={getcookiestat} />}>

미리 설정해 둔 라우터에 연결합니다. 여기서 저는 수정하는 페이지와 새 글을 입력하는 페이지를 같은 코드에 적용했습니다. url를 분석해 수정을 하기위해 접근했는지, 새 글을 등록하기 위해 접근했는지 파악했습니다.

const urlstat = window.location.pathname;
const urlresult = urlstat.split("/");
if (urlresult[1] === 'modified') {
      const serverresult = await axios.get('http://localhost:8080/getpost');
      const postdata = serverresult.data[serverresult.data.findIndex(datas => datas.numbers === parseInt(urlresult[2]))];
      if (idstatus !== postdata.writer) { // url를 통해 불법적 접근은 제어
        alert('비정상적인 접근입니다.');
        window.location.href = '/noticelist';
      } else {
        settitlecontent(postdata.title);
        setcontent(postdata.content);
      }

저는 pathname 으로 url 주소를 알아내고 '/' 단위마다 잘라 url 정보에 접근했습니다.

const postdata = serverresult.data[serverresult.data.findIndex(datas => datas.numbers === parseInt(urlresult[2]))];

위의 코드는 데이터베이스에서 참조 하고자 하는 게시글의 데이터를 가져옵니다.
수정 후 등록하는 과정은 게시글 등록 구현과 동일합니다.

const newpostresult = await axios.patch('http://localhost:8080/modifiedpost', { content, titlecontent, pagenumber });

하지만 URL을 통해서 불법적인 접근을 하는것을 막기 위해 현재 사용자와 게시글 작성자를 지속적으로 확인해 정상적인 접근인지 확인했습니다.


게시글 등록 구현

.

const [titlecontent, settitlecontent] = useState('');
const [content, setcontent] = useState('');

const onchangetitlecontent = (e) => {
    settitlecontent(e.target.value);
}
const newpostresult = await axios.post('http://localhost:8080/newpost', { content, titlecontent, checkidstatus });

새글 등록하는건 State 변수에 게시글의 정보를 저장해 두었다가, 사용자가 등록 버튼을 눌렀을 때 POST 요청으로 제목 , 내용을 한꺼번에 넘겨주었습니다.

그리고 필자는 에디터를 CTK 에디터 라이브러리를 사용했습니다.

<CKEditor className="editor"
          editor={ClassicEditor}
          data={content} // 기본값 속성
          onReady={editor => {
          }}
          onChange={(event, editor) => {
            const data = editor.getData();
            onchangecontent(data)
          }}
          onBlur={(event, editor) => {
          }}
          onFocus={(event, editor) => {
          }}
        />

댓글 창 구현

댓글 등록창 구현

.

const commitresult = await axios.post('http://www.localhost:8080/addcommit', [nowpagenumbers, commit, checkid]);

댓글 구현 게시글 등록과 비슷하지만, 저는 데이터베이스에 댓글 아이디를 외래키를 이용해 게시글 아이디와 연결시켜줬습니다. 그래서 값을 불러올때 항상 연결된 댓글 창을 가지고 올 수 있게 구현해줬습니다.


댓글 삭제창 구현

 <NoticeboardCommit data={commitdata} delbutton={delcommit} idstats={idstatus} />
   
const arrs = data.map(postcommit => ({ ...postcommit, option: postcommit.writer === idstats }));
    return (
        arrs.map(commit => (
                {commit.option ? <button className="userPostDelete" onClick={() => delbutton(commit.numbers)}>삭제</button> : null}
            </div>
        ))
    ) // 가독성을 위해 필요한 코드만 적어놓았습니다.

이 부분은 TodoList 와 비슷하다. Props으로 전체 배열을 전달해주고, 해당 컴포넌트 내에서 map을 이용해서 전개 해줬습니다.

여기서 차이점은 댓글 삭제 버튼은 사용자에게만 보이는 것이다.

여기서 작성자만 삭제버튼을 보여지기 위해서는 객체를 다시 만드는 방법을 사용했습니다. 작성자와 사용자가 일치하면 option을 true 값으로 설정한 객체를 새로 만들고 map 으로 전개하면서 option 값에 따라 버튼을 보일지 말지 여부를 선택했습니다.

서버 구현하기

서버는 Node.js + Express.js + RestApI + MySQL을 사용했습니다.

const express = require('express');
const app = express();
const server = require('http').createServer(app);

const cors = require('cors');
const mysql = require('mysql');

초기 구성은 이렇게 설정했으며 포트가 다르면 cors 오류가 나기에 cors 설정을 따로 해줬습니다.
서버측도 필요한 코드만 올렸기에 전체 코드는 하단에 GITHUB를 참조해주세요.

게시글 데이터 가져오기

app.post('/newpost', (req, res) => {
  const setposttime = `${today.getFullYear()} . ${today.getMonth() + 1} . ${today.getDate()}`;
  req.on('data', (data) => {
    console.log();
    const postdata = JSON.parse(data.toString());
    try {
      connection_post.query(`select MAX(numbers) from dbpostinfo`, (error, rows) => { // 게시물중 가장 큰 숫자 가져오기
        if (error) {
          res.send('-1');
          throw error;
        };
        const valueData = (Object.values(JSON.parse(JSON.stringify(rows[0]))))[0] + 1; // RowDataPacket { 'MAX(numbers)': 1 } 에 접근해서 1을 가져오기 게시글 아이디를 갱신화
        connection_post.query(`INSERT INTO dbpostinfo (numbers,content , title, posttime, writer , views, likes, wholikes) VALUES ('${valueData}' , '${postdata.content}' , '${postdata.titlecontent}' , '${setposttime}' , '${postdata.checkidstatus}' , 0 , 0 , ' ')`, (error, rows) => {
          if (error) {
            res.send('-1');
            throw error;
          };
          res.send('0');
        });
      });
    } catch (e) {
      console.log(`POST /newpost 부분에서 오류가 발생했습니다. ${e}`)
      res.send('-1');
    }
  })
});

우선 /newpost 부분에서 POST 요청이 들어오면 req.on 을 이용해서 값을 받아옵니다.

값은 JSON 으로 받아오기 때문에 자바스크립트 객체로바꿔주는
JSON.parse 작업을 해줘야합니다.

(Object.values(JSON.parse(JSON.stringify(rows[0]))))[0] + 1;

위의 코드는 데이터베이스에서 가져온 값을 추출합니다.
자바스크립트 객체 변환 후 값만 추출합니다.


게시글 리스트 가져오기

app.get('/getpost', (req, res) => {
 connection_post.query(`select * from dbpostinfo ORDER BY numbers DESC`, (error, rows) => {
   res.send(rows);
 });
});

데이터베이스 ORDER BY numbers DESC 문법을 이용해서 뒤에값부터 가져와 배열을 클라이언트 측으로 전달해줬습니다. 게시글 특성상 나중에 들어간 데이터가 먼저 나오기 때문에 이런 방식을 채택했습니다.


게시글 삭제하기

app.delete('/deletepost/:id', (req, res) => {
  const delData = parseInt(req.params.id);
  try {
    connection_post.query(`DELETE from dbpostinfo where numbers = ${delData}`, (error, rows) => {
      if (error) {
        console.log(`DELETE /deletepost 부분에서 오류가 발생했습니다. ${error}`);
        res.send('-1');
        throw error;
      }
      res.send('0');
    });
  } catch (e) {
    console.log(`인자가 없습니다.`);
    res.send('-1');
  }
});

DELETE 요청과 함께 url 파라미터 값을 이용해 삭제할 게시글 아이디 값을 가져왔습니다.


게시글 수정하기

app.patch('/modifiedpost', (req, res) => {
  req.on('data', (data) => {
    const postdata = JSON.parse(data.toString());
    try {
      connection_post.query(`UPDATE dbpostinfo SET title='${postdata.titlecontent}' , content='${postdata.content}' WHERE numbers=${postdata.pagenumber}`, (error, rows) => {
        if (error) throw error;
        res.send('0');
      });
    } catch (e) {
      console.log(`PATCH /modifiedpost 부분에서 오류가 발생했습니다. ${e}`);
      res.send('-1');
    }
  });
});

게시글 수정 요청이 들어오면 SET 문법을 이용해서 해당하는 문법만 수정해주었습니다.


게시글 좋아요 구현하기 ( 중복 불가 )

app.patch('/likes', (req, res) => {
  req.on('data', (data) => {
    const postdata = JSON.parse(data);
    try {
      connection_post.query(`SELECT * from dbpostinfo WHERE numbers=${postdata[1]}`, (error, rows) => {
        if (error) throw error;
        if (rows[0].wholikes.indexOf(postdata[2]) != -1) {
          res.send('-2');// 값이 존재할때
        } else {
          const countlikes = rows[0].likes;
          const likesArr = rows[0].wholikes;
          connection_post.query(`UPDATE dbpostinfo SET wholikes='${postdata[2] + ' ' + likesArr}' WHERE numbers=${postdata[1]}`, (error, rows) => {
            if (error) throw error;
          });
          connection_post.query(`UPDATE dbpostinfo SET likes=${parseInt(countlikes) + 1} WHERE numbers=${postdata[1]}`, (error, rows) => {
            if (error) throw error;
          });
        }
      });
    } catch (e) {
      console.log(`PATCH /likes 부분에서 오류가 발생했습니다. ${e}`);
    }
  });
});

우선 첫번째 SELECT * from dbpostinfo WHERE numbers=${postdata[1]} 구문에서 데이터를 가져옵니다.

중복 좋아요를 막기 위해 데이터 안에 좋아요를 눌렀는지 확인을 했습니다.
이후 좋아요 누른 사람의 목록에 추가하고 좋아요 갯수도 하나 늘려줍니다.

데이터베이스 특성상 다중 값을 넣으려면 속성을 계속 추가해줘야하는데,
저는 일시적으로 문자열로 계속 입력받고 slice를 이용해 값을 나눠 배열을 대체했습니다.


게시글 삭제시 댓글 전부 삭제

app.delete('/deleteallcommit/:id', (req, res) => {
  const setpage = parseInt(req.params.id);
  try {
    connection_reply.query(`set sql_safe_updates=0`, (error, rows) => { // 안전모드 일시 정지.
    });
    connection_reply.query(`DELETE FROM dbreplyinfo WHERE postnumber =  ${setpage}`, (error, rows) => {
      res.send('0');
    })
  } catch (e) {
    console.log(`DELET /delcommit 부분에서 오류가 발생했습니다. ${e}`);
    res.send('-1');
  }
})

게시글이 삭제되면 게시글안에 있는 댓글도 삭제되어야합니다.
하지만 한 투플들을 전부 삭제하면 안전모드로 인해 삭제가 안되기 때문에 일시적으로 안전모드를 해제후에 삭제해줬습니다.

set sql_safe_updates=0


프로젝트 후기

먼저 프론트엔드에 대해 깊게 파봤는데, 사용자에게 데이터를 정리해서 잘 보여주고 비정상적인 값에 처리하는 방법을 알아야 한다는것을 알게 되었습니다. 프론트엔드로써 백엔드를 사용해 서버쪽과 통신을 하기 위해선 어떻게 데이터를 전송을 하고 받는지, 서버와의 상호작용 하는 방법을 알게 되었습니다. 하지만 아직 부족한 부분이 많다고 생각하고, 앞으로 좋은 개발자가 되기 위해선 수많은 경험이 필요하다고 느꼈습니다.

프로젝트를 마치며...

GitHub : https://github.com/5tr1ker/react-simplepost
프로젝트 진행 기간 : 2022.3.14 ~ 2022.3.20


적용기술

  • HTML5 / CSS
  • REACT.JS ( SPA )
  • ES6+
  • Node.js ( Express.js )
  • Rest API
  • MySql

보완할점

  • 반복되는 코드가 많다.
  • 불필요한 코드가 많다.
  • ES6+ 에 대한 활용이 부족하다.

좋은 웹페이지 즐겨찾기