직접 만들어보는 To Do List - Express.js + React.js + SQLite (5)

리액트에서 express의 API 서버와 통신하도록 컴포넌트 별로 정리하여 작성하였습니다. 가장 기본적인 CRUD를 구현하기 위해서 props drilling과 fetch api를 활용하여 최대한 외부 라이브러리에 의존하지 않고 기능을 구현하는데 집중하였습니다

직접 만들어보는 To Do List - Express.js + React.js + SQLite (5)
Photo by Nataliya Melnychuk / Unsplash
  1. Express.js 설치
  2. Express.js - Route 설정
  3. React 설치
  4. Backend API

Frontend

필요한 라이브러리부터 일단 설치하겠습니다.

npm i open-props react-hot-toast react-icons prop-types pretendard

최근 유행하는 tailwindcss 는 일단 배제하고,  open-props 를 사용하여 CSS를 작성했습니다.

open-props 에 대해서 궁금하신 분들은 아래 포스팅을 참고하시기 바랍니다.

Open Props - 지저분한 HTML을 만드는 Tailwindcss의 대안
OpenProps로 디자인토큰을 사용해 웹을 쉽고 빠르게 구축할 수 있습니다. CSS변수를 통해 TailwindCSS의 대안으로 사용하기에 좋은 기능들이 잘 준비되어있습니다.
  • react-hot-toast - Notification(알림) 컴포넌트 라이브러리로, 작업의 변화, 결과 등을 알려주는데 사용합니.
  • react-icons - 오픈소스 아이콘 라이브러리
  • prop-types - 부모로부터 전달 받은 props의 타입을 체크하는데 사용합니다.
  • pretendard - 오픈소스 폰트 라이브러리

지난 글에서 vite 로 리액트를 설치하면서 기본적으로 작성된 css 들 부터 먼저 정리해주겠습니다.

CSS

/* index.css */
:root {
  font-family: "Pretendard", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

index.css 파일에 많은 내용들이 있습니다만, 다 지우고 위의 내용만 남겨두겠습니다. Pretendard 폰트를 설치해주었기 때문에, font-family 부분에서 가장 앞에 추가해주는 작업만 해주면 됩니다.

App.css 는 바꿔야 할 내용이 상당히 많습니다. 전부 다 지우고, 아래 링크에서 간단하게 복사해서 붙여 넣으시기 바랍니다.

todo_list-express-react-sqlite/frontend/src/App.css at master · baehyunki/todo_list-express-react-sqlite
To Do List - Express.js + React.js + SQLite. Contribute to baehyunki/todo_list-express-react-sqlite development by creating an account on GitHub.

미리보기

비글 이미지는 OPEN AIDALL·E 3 를 이용해서 생성했습니다

완성 화면을 미리 보면 위와 같습니다.

주 된 기능은 할 일 입력, 내용 수정, 완료 유무 체크, 할 일 삭제 입니다. 결과에 따라서 react-hot-toast 라이브러리를 이용해 알림이 뜹니다.

전체적인 흐름은 App.jsx , ListItem.jsx , Form.jsx 컴포넌트에서 주된 작업들이 진행 될 것입니다.

App.jsx

App.jsx에서는 전체 할 일 목록을 불러옵니다. 백엔드 apiGET요청을 보낼 것이고, getTodos 컨트롤러를 통해 전체 목록을 뿌려줍니다.

import { useEffect, useRef, useState } from "react"
import { toast, Toaster } from "react-hot-toast"
import "./App.css"
import Form from "./components/Form.jsx"
import ListItem from "./components/ListItem.jsx"
import { GrGithub } from "react-icons/gr"
import Footer from "./components/Footer.jsx"
const App = () => {
  const [list, setList] = useState([]) // 빈 배열로 초기화된 list 상태를 생성
  const [value, setValue] = useState("") // 빈 문자열로 초기화된 value 상태를 생성
  const [edit, setEdit] = useState({ id: "", status: false }) // 빈 id와 false 상태를 가지는 객체로 초기화된 edit 상태를 생성
  const [loading, setLoading] = useState(true) // true로 초기화된 loading 상태를 생성
  const [error, setError] = useState({ message: "", status: false }) // false로 초기화된 error 상태를 생성
  const ref = useRef(null) // 요소에 대한 참조를 저장하기 위해 ref를 생성

  /**
   * @description `ref`로 참조된 요소에 포커스를 설정합니다.
   */
  const setFocus = () => {
    // `ref`로 참조된 요소에 포커스를 설정합니다.
    ref.current.focus()
  }

  // 컴포넌트가 마운트될 때 API 서버에서 데이터를 가져옵니다.
  useEffect(() => {
    fetch("http://localhost:5000/api/todos") // API 서버에서 데이터를 가져옵니다.
      .then((data) => data.json()) // 받은 데이터를 JSON으로 변환합니다.
      .then((result) => setList(result.todos)) // 받은 todos로 list 상태를 업데이트합니다.
      .catch((error) => setError({ message: error.message, status: true })) // fetch 작업 중 발생한 오류를 로그에 기록합니다.
      .finally(() => setLoading(false)) // fetch 작업이 완료되면 loading 상태를 false로 설정합니다.
  }, [])

  if (error.status) return <div>404</div>

  // 데이터를 가져오는 동안 로딩 메시지를 표시합니다.
  if (loading) {
    return <div>Loading...</div>
  }

  return (
    <>
      <div className={"container"}>
        {/* Form 컴포넌트를 렌더링합니다. */}
        <Form
          ref={ref}
          setFocus={setFocus}
          edit={edit}
          setEdit={setEdit}
          value={value}
          setValue={setValue}
          setList={setList}
        />

        {/* list가 비어있을 때 메시지를 표시합니다. */}
        {list.length === 0 && (
          <div
            style={{
              padding: "var(--size-fluid-1)",
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
            }}
          >
            <h2
              style={{
                width: "fit-content",
                textAlign: "center",
                fontSize: "var(--font-size-fluid-1)",
                lineHeight: "var(--line-height-fluid-1)",
                fontWeight: "var(--font-weight-6)",
                color: "var(--teal-5)",
              }}
            >
              새로운 할 일을 추가해주세요
            </h2>
            <img src="nodata.png" alt="empty" />
          </div>
        )}

        <ul>
          {/* list의 각 항목을 렌더링합니다. */}
          {list &&
            list.map((todo) => (
              <ListItem
                key={todo.id}
                todo={todo}
                edit={edit}
                setEdit={setEdit}
                list={list}
                setList={setList}
                setValue={setValue}
                setFocus={setFocus}
              />
            ))}
        </ul>
        {/* Toaster 컴포넌트를 렌더링합니다. */}
        <Toaster position={"bottom-right"} />
      </div>
      <Footer />
    </>
  )
}

export default App

Form.jsx

Form 컴포넌트에서는 updatecreate를 담당할 것입니다. POST 요청으로 createTodo 컨트롤러를 통해 새로운 할 일을 데이터베이스에 저장합니다.

그리고 수정이 발생했을 경우엔 PUT 요청으로 updateTodo 컨트롤러를 통해 데이터베이스에 접근해 해당 아이디와 일치하는 내용을 변경합니다.

import { forwardRef } from "react"
import { BiEdit, BiPlus } from "react-icons/bi"
import { toast } from "react-hot-toast"
import PropTypes from "prop-types"

/**
 * @description 특정 할 일 아이템을 나타내는 컴포넌트
 * @param {object} props - 컴포넌트에 전달되는 속성들
 * @param {object} props.todo - 할 일 아이템 객체
 * @param {Array} props.list - 할 일 목록 배열
 * @param {Function} props.setList - 할 일 목록을 업데이트하는 함수
 * @param {object} props.edit - 편집 상태를 나타내는 객체
 * @param {Function} props.setEdit - 편집 상태를 업데이트하는 함수
 * @param {Function} props.setValue - 입력 값(state)을 업데이트하는 함수
 * @param {Function} props.setFocus - 입력 필드에 포커스하는 함수
 * @returns {JSX.Element} 할 일 아이템을 나타내는 JSX 요소
 */
// eslint-disable-next-line react/display-name
const Form = forwardRef(
  ({ setFocus, edit, setEdit, setList, value, setValue }, ref) => {
    /**
     * @description 입력 형식을 위한 핸들러 함수
     * @param {Event} e - 이벤트 객체
     * @param {number} id - todo 아이디
     * @returns {void}
     */
    const submitHandler = (e, id) => {
      e.preventDefault() // 기본 폼 동작 방지

      // API 엔드포인트와 HTTP 메서드 설정
      const url = edit.status
        ? `http://localhost:5000/api/todos/${id}`
        : "http://localhost:5000/api/todos/create"
      const method = edit.status ? "PUT" : "POST"
      const headers = {
        "Content-Type": "application/json",
      }

      // 값이 비어있는지 확인
      if (value === undefined || value.trim().length === 0) {
        setFocus()
        return toast("값을 입력해주세요", { type: "error" })
      }

      // 엔터 키를 누르면 값 비우기
      e.key === "Enter" && setValue("")

      if (edit.status) {
        // todo 업데이트를 위해 PUT 요청
        return fetch(url, {
          method,
          headers,
          body: JSON.stringify({ id, value }),
        })
          .then((res) => res.json())
          .then((result) => {
            const { todos, message } = result

            // 수정 모드 토글
            setEdit((prevState) => {
              return { ...prevState, status: !prevState.status }
            })

            // todo 목록 업데이트
            setList(todos)

            // 값 비우기
            setValue("")

            // 성공 토스트 메시지 출력
            toast(message, { type: "success" })
          })
      } else {
        // todo 생성을 위해 POST 요청
        return fetch(url, {
          method,
          headers,
          body: JSON.stringify({
            value,
            completed: false,
          }),
        })
          .then((result) => result.json())
          .then((result) => {
            // 새로운 todo 목록에 추가
            setList((prevState) => [...prevState, result.todos])

            // 값 비우기
            setValue("")

            // 포커스 설정
            setFocus()

            // 성공 토스트 메시지 출력
            toast(`${value} 추가 성공`, { type: "success" })
          })
          .catch((error) => console.log(error))
      }
    }
    /**
     * 입력값에 따라 값을 변경하는 핸들러 함수입니다.
     * @param {Event} e - 이벤트 객체
     */
    const changeHandler = (e) => {
      // 입력값에 따라 값을 변경합니다.
      setValue(e.target.value)
    }

    return (
      <form
        className={"form-group"}
        onSubmit={(e) => submitHandler(e, edit.status ? edit.id : null)}
      >
        <label htmlFor="add-todo">
          {edit.status ? <BiEdit /> : <BiPlus />}
        </label>
        <input
          ref={ref}
          id="add-todo"
          type="text"
          onChange={changeHandler}
          value={value}
        />
        {edit.status ? (
          <button type="submit">수정</button>
        ) : (
          <button type="submit">추가</button>
        )}
      </form>
    )
  },
)

export default Form

Form.propTypes = {
  setFocus: PropTypes.func,
  edit: PropTypes.object,
  setEdit: PropTypes.func,
  setList: PropTypes.func,
  value: PropTypes.string,
  setValue: PropTypes.func,
}

ListItem.jsx

ListItem 컴포넌트에서는 할 일 종료 여부를 표시하는 check(completed) 와  할 일 삭제 - delete 를 수행합니다. API 서버로는 PATCH 요청으로 completeTodo , DELETE 요청으로 deleteTodo 를 수행하도록 연결해줍니다.

import { GiCancel } from "react-icons/gi"
import { BiEdit } from "react-icons/bi"
import { AiFillDelete } from "react-icons/ai"
import { toast } from "react-hot-toast"
import PropTypes from "prop-types"

/**
 * @description 특정 할 일 아이템을 나타내는 컴포넌트
 * @param {object} props - 컴포넌트에 전달되는 속성들
 * @param {object} props.todo - 할 일 아이템 객체
 * @param {Array} props.list - 할 일 목록 배열
 * @param {Function} props.setList - 할 일 목록을 업데이트하는 함수
 * @param {object} props.edit - 편집 상태를 나타내는 객체
 * @param {Function} props.setEdit - 편집 상태를 업데이트하는 함수
 * @param {Function} props.setValue - 입력 값(state)을 업데이트하는 함수
 * @param {Function} props.setFocus - 입력 필드에 포커스하는 함수
 * @returns {JSX.Element} 할 일 아이템을 나타내는 JSX 요소
 */
const ListItem = ({
  todo,
  list,
  setList,
  edit,
  setEdit,
  setValue,
  setFocus,
}) => {
  /**
   * @description 체크박스 클릭 이벤트를 처리하는 함수
   * @param {string} id - 할 일 아이템의 ID
   * @param {boolean} completed - 할 일 완료 여부
   */
  const checkHandler = (id, completed) => {
    fetch(`http://localhost:5000/api/todos/${id}`, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        id: id,
        completed: !completed,
      }),
    })
      .then((res) => res.json())
      .then((result) => {
        const { todos, message } = result
        setList(todos)
        toast(
          message === "완료" ? "완료로 상태 변경" : "진행중으로 상태 변경",
          { type: "success" },
        )
      })
      .catch((error) => console.log(error))
  }

  /**
   * @description 삭제 버튼 클릭 이벤트를 처리하는 함수
   * @param {string} id - 할 일 아이템의 ID
   */
  const deleteHandler = (id) => {
    fetch(`http://localhost:5000/api/todos/${id}`, {
      method: "DELETE",
    })
      .then((res) => res.json())
      .then((result) => {
        const { todos, message } = result
        const getItem = list.find((todo) => todo.id === id)
        const filteredList = todos.filter((todo) => todo.id !== id)
        setList(filteredList)
        toast(getItem.title + message, { type: "success" })
      })
      .catch((error) => console.log(error))
  }

  return (
    <li key={todo.id}>
      <input
        id={todo.id}
        type="checkbox"
        checked={todo.completed}
        onChange={() => checkHandler(todo.id, todo.completed)}
      />
      <label htmlFor={todo.id}>{todo.title}</label>
      <div className="button-group">
        {edit.id === todo.id && edit.status ? (
          <button
            onClick={() => {
              setEdit((prevState) => {
                return { ...prevState, status: false }
              })
              setValue("")
            }}
          >
            <GiCancel color={"var(--orange-6)"} />
          </button>
        ) : (
          <button
            onClick={() => {
              setEdit((prevState) => {
                return {
                  id: todo.id,
                  title: todo.title,
                  status: !prevState.status,
                }
              })
              setFocus()
              setValue(todo.title)
            }}
          >
            <BiEdit color={"var(--teal-6)"} />
          </button>
        )}

        <button onClick={() => deleteHandler(todo.id)}>
          <AiFillDelete color={"var(--pink-6)"} />
        </button>
      </div>
    </li>
  )
}

export default ListItem

ListItem.propTypes = {
  todo: PropTypes.object,
  list: PropTypes.array,
  setList: PropTypes.func,
  edit: PropTypes.object,
  setEdit: PropTypes.func,
  setValue: PropTypes.func,
  setFocus: PropTypes.func,
}

Footer.jsx

footer 에서는 간단한 정보를 노출하는 것이 전부입니다.

import { GrGithub } from "react-icons/gr"
import style from "./Footer.module.css"

const Footer = () => {
  return (
    <footer className={style.footer}>
      <div className={style.info}>
        &copy; {new Date().getFullYear()} by
        <a
          href="https://github.com/baehyunki/todo_list-express-react-sqlite"
          target="_blank"
          rel={"noreferrer"}
          style={{ display: "flex" }}
        >
          baehyunki
          <GrGithub />
        </a>
      </div>

      <span className={style.desc}></span>
    </footer>
  )
}

export default Footer

Footer.module.css

module 방식으로 css 를 사용하였습니다.

.footer {
  display: flex;
  flex-direction: column;

  width: var(--size-content-2);
  margin: 0 auto;
  gap: 0.25rem;
}
.info {
  display: flex;
  font-size: var(--font-size-fluid-0);
  gap: var(--size-fluid-1);
  color: var(--stone-6);
}
.footer a {
  color: var(--indigo-5);
}
.desc {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  font-size: var(--font-size-fluid-0);
  color: var(--stone-5);
}

마무리

전반적으로 react 의 기본적인 사용법을 익힌 상태에서 손쉽게 쓸 수 있는 Prop Drilling 방식으로 각 컴포넌트와 통신하고 있습니다.

조금 더 여러개의 컴포넌트를 사용한다면 Context API 또는 Redux , Zustand 와 같은 상태관리를 도와주는 도구들을 사용해야겠지만, 아주 단순한 앱이기 때문에 부가적인 도구들을 사용하지 않았습니다.

다소 지저분하고 부끄러운 소스이기 합니다만, 저도 리액트를 공부하고 적용해보는 상황에서 학습한 것들을 공유하는 차원에서 작성했습니다.

GitHub - baehyunki/todo_list-express-react-sqlite: To Do List - Express.js + React.js + SQLite
To Do List - Express.js + React.js + SQLite. Contribute to baehyunki/todo_list-express-react-sqlite development by creating an account on GitHub.

흔히 MERN 스택이라고 불리는 구성과는 조금 다르지만, 큰 맥락에서는 많이 다르지 않습니다. 개인적으로는 학습할 때 단골처럼 등장하는 mongoosesequelize 와 같은 ORM 도구 없이 SQL 표준 문법을 사용했다는 점이 새로운 시도였습니다.