프로젝트/풀스택 프로젝트

4-8. [React + Node.js Express] 게시판 - 게시글 리스트, 상세보기, 수정, 삭제 ( React-Quill ) CRUD

찰리-누나 2022. 12. 15.

 

풀스택 CRUD 포트폴리오의 마지막 단계, 게시글 리스트 불러오기와 상세보기, 수정 및 삭제 단계이다. 간단해 보여도 은근히 처리해야 할 부분들이 자잘하게 많다. 글을 작성한 작성자만 자신의 글을 수정 및 삭제할 수 있도록 버튼 노출 여부를 설정해 주어야 하고, 데이터가 렌더링 된 이후에 컴포넌트가 그려지도록 해야 한다. 또 ref를 이용해 저장했던 정보는 어떻게 참조해야 하는지도 알아보자.

 


 

Frontend

 

 

 

 

먼저 게시글 리스트를 보여주는 페이지를 만든다. 각 제목을 클릭하면, 포스트의 _id를 백엔드로 보내 게시글 상세보기 페이지로 진입시킬 것이다. 

게시글 리스트

 

하단의 페이지네이션은 아래 코드를 사용했다. 별도의 컴포넌트로 만들고 props를 이용해 값을 전달한 뒤, 게시글의 수에 맞는 만큼만 수를 증가시킨다. 온전히 UI를 위한 부분이다.

페이지네이션

Pagination.js
import React from "react";
import styled from "styled-components";

function Pagination({ total, limit, page, setPage }) {
  const numPages = Math.ceil(total / limit);

  return (
    <>
      <Nav>
        <Button onClick={() => setPage(page - 1)} disabled={page === 1}>
          &lt;
        </Button>
        {Array(numPages)
          .fill()
          .map((_, i) => (
            <Button
              key={i + 1}
              onClick={() => setPage(i + 1)}
              aria-current={page === i + 1 ? "page" : null}
            >
              {i + 1}
            </Button>
          ))}
        <Button onClick={() => setPage(page + 1)} disabled={page === numPages}>
          &gt;
        </Button>
      </Nav>
    </>
  );
}

const Nav = styled.nav`
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 4px;
  margin: 16px;
`;

const Button = styled.button`
  border: none;
  border-radius: 8px;
  padding: 1px 5px px 5px;
  margin: 0;
  background: white;
  color: black;
  font-size: 1rem;

  &:hover {
    background: lightGray;
    cursor: pointer;
    transform: translateY(-2px);
  }

  &[disabled] {
    background: lightGray;
    cursor: revert;
    transform: revert;
  }

  &[aria-current] {
    background: black;
    font-weight: bold;
    color: white;
    cursor: revert;
    transform: revert;
  }
`;

export default Pagination;

 

 

posts.js

이제 포스트 목록을 불러와, 화면에 노출시켜보자. limit, page, setPage, offset은 페이지네이션을 위한 State이다.

function Posts(props) {

	// about Pagination
  const [limit, setLimit] = useState(10);
  const [page, setPage] = useState(1);
  const offset = (page - 1) * limit;

 

props로 category를 불러와 주었다. 공지사항 카테고리에 글을 작성할 것이라, props.category의 내용은 'notice'이다.

const [postList, setPostList] = useState([])


const navigate = useNavigate()

  let category = {
    category:props.category
}

  useEffect(() => {
    axios.post('api/board/getBoard',category)
    .then(response=> {
        if(response.data.success) {
            console.log('성공',response.data.boards)            
            setPostList(response.data.boards)
        } else {
            alert('불러오기 실패')
        }
    })
  }, []);

▶ axios.post를 통해 카테고리를 보내주면, 백엔드는 전달받은 카테고리에 해당하는 글을 '모두' 찾아 반환한다. 반환받은 정보를 postList에 저장하였다.

 

 

return (

...
 <tbody>
        {postList.slice(offset, offset + limit).map(({ subject, createdAt, writer, view, good, _id },i) => (
           <tr key={i} 
            onClick={()=>{navigate(`/${_id}`, {
              state:
              {
                category:props.category,
                writer: writer.nikname
              }
            })}}>
              <td>{i}</td>
              <td>{subject}</td>
              <td
              >{writer.nikname}</td>
              <td>{new Date(createdAt).toLocaleString('ko-KR',{month: 'long',day: '2-digit', hour:'2-digit', minute:'2-digit'})}</td>
              <td>{view}</td>  
              <td>{good}</td>            
          </tr>
        ))}
         </tbody>

▶ map을 통해 postList 데이터를 한 번에 하나씩 불러와 화면을 그린다. 제목, 작성 날짜, 글쓴이, 조회수, 좋아요 개수 및 몽고db에 저장된 포스트의 _id를 불러와 사용하였다. 날짜는 .toLocalString을 통해 변환해 주었는데, 변환할 날짜를 인자로 넣고 new Date를 해 주면 ko-KR 값으로 변환할 수 있다. tr에 navigate를 주어, 게시물 목록에서 내용을 볼 게시물을 클릭하면 디테일 페이지로 넘어갈 수 있게 해 주었다. navigate를 이용해 state를 전달하려면 아래와 같이 한다.

 

navigate('/이동할주소', {
              state:
              {
                state이름:state내용,
                state이름2:state내용2
              }
            })

 

 

 

Content.js

다음으로 게시글 리스트에서 클릭을 통해 이동할 디테일 페이지를 작성한다. 위에서 navigate(`_id`)를 통하여, url 파라미터로 게시글의 _id값을 주었다. 따라서 디테일 페이지에서는 useParams를 통해 파라미터를 추출하고, 추출한 파라미터의 _id값을 이용해 몽고DB에서 게시물을 검색할 것이다. 백엔드는 검색한 게시물을 반환하고, 프론트는 반환받은 게시물의 상세내용을 이용하여 화면을 그린다.

이렇게 생겼음

 

function Content(){
  // ---------------------- State ----------------------

    const [doc, setDoc] = useState()
    let redux = useSelector((state)=>{return state})

    
    // ---------------------- 유저 검사 ----------------------
    let possible = false

    if(doc?.writer == redux.setUser.u_id){
        possible = true
    }

    // ---------------------- get Category ----------------------

    let location = useLocation()

    let category = location.state.category
    let writer = location.state.writer

    // ---------------------- get parameter ----------------------

    let params = useParams()

▶ 우선 필요한 정보들을 받아준다. url 파라미터를 추출할 것이므로 useParams()를 사용해 파라미터를 추출해 주었다. 또한 게시물 상세사항 검색 api를 마치면 반환된 정보를 doc 라는 스테이트에 저장해준다. 수정과 삭제 버튼은 '현재 로그인 한 유저와, 게시글을 작성한 유저가 같을 경우' 에만 노출된다. 따라서 redux에 저장되어 있는 유저의 _id값과, 게시글을 작성한 유저의 _id값을 비교해주기 위하여 유저 검사 로직을 추가하였다. 

참고로 ?를 붙여주면 ' 그 값이 존재할 때만 수행하라 ' 는 뜻이 되어, 새로고침을 했을 때 빈 화면이 나오는 불상사가 일어나지 않는다. 대부분의 리액트 오류가 비동기적인 특성, 즉 화면을 먼저 그리고 데이터를 불러오는 리액트의 특징 때문에 일어나므로 api를 불러올때 ? 또는 && 문을 사용해 주는 습관을 들이는 것이 좋다.

 

다른 아이디로 작성한 글에서는, [수정]과 [삭제] 버튼이 보이지 않는다.

 

 

// ---------------------- get Content ----------------------


    useEffect(()=>{

        let body = {
            category : category,
            postId : params.postId
        }

        axios.post('/api/board/getContent',body)
        .then(response=>{
            console.log(response)
            setDoc(response.data.doc)
        })       

    },[])

▶ api를 사용해 게시글 정보를 불러와 주었다. 추출한 파라미터를 postId라는 이름으로 전송해 주었고, 카테고리 또한 전달해 주었다. 백엔드는 카테고리가 일치하는 글 중에서 postId로 전달받은 _id값이 존재하는 게시글을 '하나만' 찾아 반환해 분다. 애초에 _id는 고유하고 유니크한 키값이기 때문에 결과도 하나밖에 없어야 한다.

 

 

  // ---------------------- Edit Button ----------------------

    function editButton() {
        navigate(`/edit/${params.postId}`, {
            state:
            {
              category:category,
              doc: doc
            }
          })
    }

    // ---------------------- delete Button ----------------------

    function deleteModal() {
        setModalShow(true)
    }

    function deletePost() {
        let _id = {
            _id:params.postId
        }
        console.log('게시물을 삭제합니다.')
        axios.post('/api/board/deletePost',_id)
        .then(response => {
            console.log('삭제 결과',response)
        })

        navigate(-1)
    }

게시글 수정 버튼과, 삭제 버튼을 눌렀을 때 실행될 것들도 미리 작성해 놓았다. [수정] 버튼을 누르면 게시글의 _id를 url에 전송하여 /edit/_id 페이지로 이동하도록 해 주었고, state로는 카테고리와 게시글 내용들을 전송해 주었다.

삭제 버튼 또한 게시글의 _id 값을 전달해 주어, 몽고DB에서 해당 값을 찾아 삭제하는 api를 호출한다. 그 전에 모달창을 띄워 한번 더 진짜 삭제할거냐는 질문을 하도록 모달창을 만들어 주었다.

 

 

 return(    
           ...
           
            <Card style={{ width: '80rem'}}>
            <Card.Header>
                <span
                >{doc?.subject}</span>
               </Card.Header>
            <ListGroup variant="flush">
            
                <ListGroup.Item> <span>글쓴이 : {writer}&nbsp;&nbsp;&nbsp;&nbsp;
                날짜 : {new Date(doc?.createdAt).toLocaleString('ko-KR')}</span>
                </ListGroup.Item>
                
              <ListGroup.Item>
                  <div  className="view ql-editor" 
                  dangerouslySetInnerHTML={{ __html: doc?.content }} />                  
                  </ListGroup.Item>
                  
            </ListGroup>
          </Card> 
         
            {
                possible ? <span>
                <Button variant="danger" onClick={deleteModal}>삭제</Button>
                <Button variant="dark" onClick={editButton}>수정</Button>   
                </span> : <span></span>
            }
            
          </div>
                  
                  //  //////////////////모달창//////////////////
                  
          </div>
          <MyVerticallyCenteredModal
            show={modalShow}
            onHide={() => setModalShow(false)}
            deletePost={deletePost}
      />

          </div>
            
    )

불러온 doc 속의 suject값을 제목으로 설정해 주고, 글쓴이의 닉네임을 불러와 데이터를 바인딩 해 준다. react-quill같이 에디터를 이용한 글은 html 형태로 저장되어 있다. 따라서 이용자에게 올바른 형태로 보여주기 위해서는 이 코드를 사용해야 한다.

 <div  className="view ql-editor" 
                  dangerouslySetInnerHTML={{ __html: doc?.content }} />                  
                  </ListGroup.Item>

여기서 className은 React-Quill을 사용할 때 주어야 하는 값인데, 이를 설정해야만 react-quill의 css를 정상적으로 불러올 수 있다.  dangerouslySetInnerHTML을 사용하면 html 문서를 렌더링 할 수 있다. 

 

 

 

 

 

Edit.js

마지막으로 게시글 수정 페이지를 작성한다. write.js를 그대로 가져와 조금만 수정했다. 코드가 길고, 이전 글과 중복되는 내용이 있기 때문에 수정한 내용 위주로만 기술하였다.

 

수정 페이지. 기본값으로 작성했던 글 내용이 들어있다.

 

먼저 수정해주어야 하는 로직은 다음과 같다.


1. 제목 및 게시글 내용의 기본값을, 수정할 게시물의 제목 및 내용으로 설정해주기
2. img src를 추출하되, base64 로 인코딩된 부분만 Blop으로 디코딩 해 주기
3. 백엔드로 수정한 내용들을 전달해, 기존 게시글 수정하기
 
function Edit(){    

    let params = useParams()    
    let postId = params.postId
    
       // category의 경우, api에서 건네받는다. 기본값은 notice.
    let category = ''    
      
    let location = useLocation()

    category = location.state.category
    let doc = location.state.doc
    
    const [subject, setSubject] = useState(doc.subject)
    const [content, setContent] = useState(doc.content)

▶ 1. 우선 파라미터를 추출해 저장한다. 또한 state로 받아온 카테고리와 문서를 저장해, 제목 및 내용의 초기값으로 바인딩 해 준다. react-quill 에디터 속에 html 태그를 전달할 경우, quill이 전달받은 html 태그를 자동으로 렌더링하기 때문에 굳이 우리가 렌더링 해 주어야 할 필요는 없다. 

 

 while(gainSource.test(content)){
                    console.log('이미지가 있을때만 진행함.')
                    let result = RegExp.$2
                    // console.log('src 추출 결과 : ',result)
                    srcArray.push(result)
                    console.log('srcArray 추가: ',srcArray)

                    let isbase64 = result.includes('base64')
                    console.log('isabase64:', isbase64)


                    // 만일 업로드한 파일 중 isbase64가 있으면 아래 과정을 실행한다.
                    // 파일을 blop으로 바꾸고, formdata에 넣어서, 서버에 저장하는 과정
                    if (isbase64) {
                    // base64파일 Blop으로 바꾸기
                    }
                   else { //업로드한 파일 중 이미 서버에 저장된 내용이 있으면, 그 내용을 유지하기 위해서 어레이에 넣어준다.
                        urlArray.push(result)
                    }

▶ 2. base64 파일을 Blop으로 바꾸는 부분을 손보았다. 만일 수정한 게시물의 이미지 파일이 '이미 서버에 저장된 이미지' 일 경우 변경하지 않고 그대로 두며, '새로 삽입한 base64 형태의 이미지' 일 경우에는 Blop으로 변환하는 코드를 실행한다.

 

 // 카테고리, 제목, 글쓴이, 컨텐츠 내용 백엔드에 보내 수정하기
                               
                let writeInform = {                    
                    postId:postId,
                    category: category,
                    subject: subject,
                    content: endContent,
                    writer:redux.setUser.u_id,
                    imgList:urlArray,
                }      

                axios.post('/api/board/update', writeInform)
                .then(response => {
                    if(response.data.success) {
                        console.log('수정 성공')
                        console.log('수정한 데이터 : ',response)
                    } else {
                        alert('업로드에 실패하였습니다.')
                    }
                
                })

▶ 3. 백엔드로 수정한 내용들을 전달하여, 게시글을 수정하였다.

 

 

 

 

Backend

 

이제 대망의 백엔드 api만 작업하면 된다. 글쓰기와 이미지 업로드하기는 저번 시간에 이미 완료했으므로, '글 리스트 찾기', '글 찾기', '글 수정', '글 삭제' 만 수행하면 된다.

 

board.js

 

먼저 글 목록을 불러오는 api를 작성하였다.

// ===================

// 글 목록 불러오기

// ===================

router.post('/getBoard', (req,res)=> {
    // 카테고리에 해당하는 글을 찾아라
    Board.find({category:req.body.category})
    .populate('writer')
    .sort({'createdAt':-1})
    .exec((err,boards) => {
        if(err) return res.status(400).send(err)
        res.status(200).json({success:true,boards:boards})
    })
}
)

▶ 카테고리를 전달받아, 해당 카테고리를 가진 모든 게시글을 검색한다. 이때 populate를 주어 writer, 즉 글을 작성한 user의 정보를 함께 찾도록 했다. Mongoose의 populate를 사용하면 ref로 참조한 정보를 함께 불러올 수 있다. 

sort는 '글을 작성된 시간 순서' 대로, 즉 오래된 글이 제일 아래로 가도록 정렬하기 위해 선언해 주었다.

 

글 목록이 불러와졌다.

 

 

 

이제 글 상세페이지를 불러와 보자. 게시글 고유의 _id를 전달받아 검색한다.

// ===================

// 글 상세페이지 불러오기

// ===================

router.post('/getContent', function(req,res){
    Board.findOne({$and : [{category:req.body.category},{_id:req.body.postId}]}, (err,doc)=> {
        if(!doc) return res.json({success:false,message:'게시물을 찾지 못했습니다.'})
        return res.json({success:true, doc:doc})
    })
   
})

▶ req.body에 담겨있는 카테고리 및 _id를 이용해 검색하고, 검색 결과를 반환한다.

 

검색 결과를 반환한다.

 

글 수정 api를 작성한다. 회원 정보 수정 api와 다를바가 없다. 이번에도 게시글의 _id를 검색해 기존 데이터를 찾은 다음, 바뀐 내용을 수정해 주었다.

// ===================

// 글 수정하기

// ===================


router.post('/update', function(req,res){

    Board.findOneAndUpdate({_id:req.body.postId},
        {category:req.body.category,
         subject:req.body.subject,
         content:req.body.content,
         imgList:req.body.imgList   
    },(err,doc)=> {
        if(err) return res.json({success:false,err:err})
        return res.status(200).send({
            success:true,massage:'수정 성공',doc:doc
        })
    })

       
})

▶ req.body에 담겨있는 _id를 이용해 검색하고, 수정한다.

 

게시물의 제목 및 내용이 수정되었다.

 

 

마지막으로 글 삭제 api를 작성한다.

// ===================

// 글 삭제하기

// ===================

router.post('/deletePost',function(req,res){
    console.log(req.body._id)
    Board.deleteOne({_id:req.body._id})
    .exec((err,result) => {
        if(err) return res.status(400).send(err)
        res.status(200).json({success:true,result:result})
    })
})

▶ 마찬가지로 게시글의 _id를 찾아 deleteOne 해 주었다.

 

 

 

 


 

 

결과

 

게시글 불러오기, 수정, 삭제

페이지 당 표시할 게시물 수 및 페이지네이션

 

댓글