[토이 프로젝트] 메모장 구현

이민재·2023년 3월 12일
0

서론

프론트엔드와 백엔드를 같이, 풀스택으로 메모장을 구현해보는 토이 프로젝트를 했다.
프론트엔드는 React + Typescrip를 사용했고, 백엔드는 Node.js로, 미들웨어는 express 구성했다. 데이터는 data.json 파일을 만들어 fs모듈로 I/O작업을 구현 하였다. 통신은 axios를 사용했고 메모장 구현에는 ReactQuill 라이브러리를 사용했다.

구성

  • Server
    • CRUD 구성
    • Postman API Spec
  • Client
    • 메인 페이지 구성
    • 상세 페이지 구성
    • 편집 페이지 구성
    • 관리자 페이지 구성
  • CRUD
    • 메모 세부 CREATE
    • 임시저장 세부 CREATE
    • 메모 전체 READ
    • 임시저장 전체 READ
    • 메모 세부 READ
    • 메모 세부 UPDATE
    • 메모 전체 DELETE
    • 메모 세부 DELETE
  • 작성된 API는 Postman에서 아래와 같이 Spec을 정리하여 프론트엔드에서 통신할 때, 쉽게 참고하여 작업할 수 있었다.

페이지 구성

메인페이지

server

  • 모든 데이터는 fs모듈로 I/O 작업을 했기 때문에 데이터를 가져와 파싱작업을 해준 뒤에 삭제가 되지 않은 항목들을 보내주었다.

  • req.body로 값을 받아서 'content'의 value로 넣어주고 id도 고유한 값이 될 수 있도록 uuid를 사용해서 데이터에 추가해 주었다. 코드는 아래와 같다.

    app.post('/', (req, res) => {
      const { content } = req.body 
      if (!content || content.length === 0) res.status(400).json({ msg: "content가 올바르지 않습니다." })
      const list = {
        id: v4(),
        content,
        created_at: Date.now(),
        updated_at: null,
        deleted_at: null
      }
      data.unshift(list)
    
      res.json(list)
      save()
    })
    
  • 메모장에서 작성되는 텍스트들을 body에 담아 보내주는 요청을 받아 변수에 담았다가, read가 필요할 때, 작성되고 있는 텍스트들을 응답 해주어 '자동 임시 저장'기능이 되게 하였다. 코드는 다음과 같다.

    let tmp = ""
    
    app.get('/tmp', (req, res) => {
      res.json({ result: tmp })
    })
    
    app.post('/tmp', (req, res) => {
      const { content } = req.body
    
      if (!content || content.length === 0) res.status(400).json({ msg: "content가 올바르지 않습니다." })
      tmp = content;
      res.json({ result: true })
    }) 
    		```

client

  • 페이지 렌더시 useEffect를 통해서 임시 저장된 값과 데이터들을 'get' 요청한다. 이후, 받아온 데이터들의 상태관리를 할 수 있도록 useState를 이용하여 state 값들을 변경한다.
  • 메모 작성후에는 'post' 요청으로 메모장의 내용들이 create될 수 있도록 한다. 이 때, ReactQuill 라이브러리에 의해서 생성된 값들('value'라는 state에 저장)은 태그가 포함된 string 타입 (예를 들어, <p 메모 /p>) 이므로 정규식 표현으로 메모 내용만 string 타입으로 가져와 예외처리 조건 값으로 사용했다.
    const onSubmit = useCallback(async()=>{
       const onlyTextContent = edit.replace(/<[/\w\s"=-]*>/gi, "")
       if(onlyTextContent.length === 0) {
         alert('메모가 비어있있습니다.')
         return;
       } else {
         const {data} = await axios.post('/', {
           content: edit
         })
         setMemoList(prev => [data, ...prev])
         setEdit('')
       }
    },[edit])
    		```

상세 페이지

server

  • 상세 페이지는 url parameter를 id로 받아서 해당하는 데이터 id로 받을 수 있게 처리했다. 이때, id는 uuid v4를 사용했기 때문에, 거의 고유한 숫자에 가까운 id 값을 받을 수 있다.

  • 상세 페이지는 수정(업데이트) 및 삭제 기능도 있다. 상세 수정 및 삭제 역시, 고유 값을 확인하기 위해 id 값을 parameter로 req를 받아서 data의 'updated_at', 'deleted_at' key 값이 변경되게 한다. 실제로 현업에서는 실제로 DB를 삭제하는 경우는 드물고 이렇게 특정 flag 값을 주어서 구분한다. 코드는 아래와 같다.

    function save() {
      fs.writeFileSync('data.json', JSON.stringify(data), 'utf-8')
    }
    
    app.delete('/:id', (req, res) => {
      const { id } = req.params
      const dataId = data.find(list => list.id === id)
    
      if (!isNaN(id)) res.status(400).json({ msg: "잘못된 id입니다." })
      if (dataId.deleted_at !== null) res.status(404).json({ msg: "이미 제거된 메모입니다." })
    
      dataId.deleted_at = Date.now()
      res.json(dataId)
    
      save()
    })
    
    app.put('/:id', (req, res) => {
      const { id } = req.params
      const dataId = data.find(list => list.id === id)
      const { content } = req.body
    
      if (!content || content.length === 0) res.status(400).json({ msg: "content가 올바르지 않습니다." })
      if (!isNaN(id)) res.status(400).json({ msg: "잘못된 id입니다." })
      if (dataId.deleted_at !== null) res.status(404).json({ msg: "이미 제거된 메모입니다." })
    
      dataId.updated_at = Date.now()
      dataId.content = content
    
      res.json(dataId)
      save()
    })

client

  • 상세 페이지 초기 렌더 시에 url parameter에 id를 담아 서버에 request를 보내야 한다. 이때는 react-router 의 useParams 훅을 사용한다. useParams는 react-router의 Route path에 parameter 값을 key/value object로 받아 동적 라우팅을 할 수 있다.

    The useParams hook returns an object of key/value pairs of the dynamic params from the current URL that were matched by the . Child routes inherit all params from their parent routes.
    [출처] https://reactrouter.com/en/main/hooks/use-params

  • 이렇게, 'get' 요청하여 id 값을 담아 req를 보낸 데이터를 res 받아와서 상태관리를 위해 state에 저장해주어 상세 페이지 기본 렌더화면을 뿌려주면 된다.
  • 수정과 삭제도 마찬가지로, 'put'과 'delete' 요청에 id 값을 추가로 req를 보내주면 된다.

관리자 페이지

  • 관리자 페이지는 현업에서 사용하는 백 오피스, 어드민 페이지와 같은 업무를 담당한다. 모든 메모를 볼 수 있으며, 모든 메모 또한 삭제할 수 있으며, 원하는 메모도 빠르게 선택하여 삭제할 수 있다.

server

  • 메모 선택 중복 삭제 기능의 경우에는, 서버에서는 수정 페이지와 마찬가지로 req.params.id 값으로 받아오면 된다. 단, 메모 전체 선택의 경우에는 이 API를 계속 호출해야하는 번거로움이 있기 때문에 하나의 요청을 받는 API를 만들어서 모든 데이터의 값에 deleted_at 값을 넣어주도록 한다.

    function save() {
    	fs.writeFileSync('data.json', JSON.stringify(data), 'utf-8')
    }
    
    app.delete('/', (req, res) => {
      const deletedArr = []
      data.map(list => {
      if (!list.deleted_at) {
      list.deleted_at = Date.now()
      deletedArr.push(list.deleted_at)
      }
      })
    
      res.json(deletedArr)
      save()
    })
  • 모든 메모 읽는 기능은 메인 페이지와 마찬가지로 모든 메모를 가져오는 get요청을 하면 되겠다.

client

  • 첫 페이지 렌더 시에는, 메인페이지와 마찬가지로 useEffect 훅을 사용해서 get 요청으로 모든 메모 READ 기능을 해주면 된다.

  • 선택되고, 제거되는 메모들의 상태관리는 useState 훅을 사용해서 state관리를 해주면 되겠다. 이 때, 다중 선택된 메모 삭제의 경우에는 Promise.all() 메소드를 이용해서 여러 개의 비동기를 병렬로 처리해주어 최적화를 해주었다.

    [참고] https://code-masterjung.tistory.com/91

    	// 선택한 메모들을 배열로 담아 state 관리.
    	const selectList = useCallback(value=>{
        setSelectedMemoList(prev => {
          if (prev.includes(value.id))
            return prev.filter(v => value.id !== v)
          return [...prev, value.id]
          })
      },[setSelectedMemoList,])
    	
    // 다중(1개 이상) 선택된 메모 제거
      const removeSelectedList = useCallback(async()=>{
        const list = []
        for (const id of selectedMemoList) {
          // await axios.delete("/"+id)
            list.push(axios.delete("/" + id))
        }
        await Promise.all(list)  
        await loadMemo()
        resetSelectedList()
        alert("제거 완료!")
      },[selectedMemoList, loadMemo])
    
    // 전체 선택된 메모 제거
      const removeAllList = useCallback(async()=>{
        if (!window.confirm("정말로 전체 제거를 하시겠습니까?")) {
          return;
        }
        await axios.delete("/")  
        await loadMemo()
        alert("제거 완료!")
        resetSelectedList()
      },[loadMemo])
profile
스스로 기억하기 위해서, 기록해요

0개의 댓글