프로젝트

[리액트] 개인 지출 관리 사이트 맹들기🐢

ejunyang 2024. 5. 30. 22:29

 

세번째 개인 프로젝트이다. 리액트로는 두번째 개인 프로젝트! 간단한 개인 지출 관리 사이트를 만들어보자!!🐢

이번 과제는 props-drilling > context > redux 순으로 상태 관리를 변경해가며 구현해야한다. 과제를 진행하면서 props-drilling의 단점과 전역 상태 관리 라이브러리의 장점을 이해하도록 하려는 취지 같았다. 결론적으론 전역 상태 라이브러리를 사용하면 props-drilling 이슈를 해결할 수 있다는 것만큼은 제대로 알았다. 

 

 


 

 

와이어 프레임

 

참고로 준 프로젝트 완성본은 맨 위에 입력 폼이 있는데 디자인은 달리 들어가도된다고 해서 따로 디자인하고 작업했다. 아래와 같이 컴포넌트 분리를 했고 react-router-dom으로 페이지 분리를 먼저 해주려고 한다.

 

 

컴포넌트

컴포넌트 설명
ExesForm.jsx 지출내역을 입력하는 컴포넌트이다. 메인 페이지 우측 하단 버튼을 누르면 모달처럼 뜨도록 구현했다.
MonthList.jsx 월별 버튼 컴포넌트이다. 해당 월을 누를 때마다 다른 스타일링이 들어간다.
ExesList.jsx 월별 버튼을 눌렀을 때 하단에 해당 월의 지출내역이 나타나는 지출 내역 리스트 컴포넌트이다.
ExesItem.jsx 각 지출 내역의 컴포넌트이다.

 

 


 

 

 

 

라우터 설정

yarn add react-router-dom

 

 

📂 shared >  📂 Router.jsx

react는 SPA(Single page application)이며 따라서 하나의 프로젝트에 하나의 html만 존재한다. 업데이트 되는 새로운 페이지만 path와 element로 연결하여 컴포넌트를 불러오는 방식이다. 고유한 id 값으로 이동할 수 있도록 설정했다. BrowserRouter로  Router을 감싸는 이유는 브라우저가 깜빡거리지 않고 이동할 수 있도록 해준다.

import React, { useState } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Home from "../pages/Home";
import Detail from "../pages/Detail";
import Layout from "./Layout";

const Router = () => {
  return (
    <div>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Layout />}>
            <Route index element={<Home />} />
            <Route path="/detail/:id" element={<Detail />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </div>
  );
};
export default Router;

 

 

📂 pages >  📂 Home.jsx

아래처럼 컴포넌트를 배치했다. 프로젝트 필수 사항에 styled-components를 사용하라고해서 모든 컴포넌트에 사용했는데 css 파일로 따로 가지 않고 js파일에서 수정할 수 있고, 조건부 스타일링이 가능한 점은 편리하고 좋지만, 컴포넌트가 많아보이고 헷갈린다.. 장점이 큰 만큼 단점도 만만치않게 큰 것 같다.

 return (
    <>
      <StContainer>
        <MonthList
          key={exes.id}
          exes={exes}
          selectedMonth={selectedMonth}
          handleMonthSelect={handleMonthSelect}
        />
        <ExesList key={exes.id} filteredList={filteredList} setExes={setExes} />
        <ExesForm onInsert={onInsert} exes={exes} />
      </StContainer>
    </>
  );
};

export default Home;

 

 

 

ExesForm.jsx

지출 내역을 입력하는 컴포넌트다. 새로운 지출내역을 추가하는 객체를 만들어준다. 지출내역엔 날짜와 항목 지출내용과 금액이 들어간다. 추가적으로 month키를 넣어주었는데 해당 키로 지출 내역이 해당하는 월을 확인할 수 있다.

✔️ 등록

const onInsert = (date, item, amount, desc) => {
    const newExes = {
      id: uuidv4(),
      date,
      item,
      amount,
      desc,
      month: selectedMonth,
    };
  };

 

월마다 지출내역이 달리 보여야하기 때문에 useState로 상태를 만들어주었다. 아래는 현재 날짜로 임의로 설정해주었는데 이는 로컬 스토리지를 사용하여 마지막으로 선택된 '월'을 저장하고, 해당 페이지가 다시 시작 될 때마다 해당 값을 불러오도록 하려고한다.

const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth());

 

불변성 유지를 위해 기존 지출 내역 객체를 복사해준다. addExes[selectedMonth]는 선택한 월의 지출내역 데이터이다. 해당 월의 지출내역이 없다면 빈배열을 가져오도록 하고, 새로운 지출내역은 해당 월 지출 내역에 추가해준다.

const addExes = { ..exes };
    if (!addExes[selectedMonth]) {
      addExes[selectedMonth] = [];
    }
    addExes[selectedMonth].push(newExes);
    setExes(addExes);

 

해당 월의 지출내역 데이터를 fiteredList 변수에 할당한다. 이 변수는 선택한 월에 해당하는 지출 내역을 보여주는 ExesList에 props로 내려준다. 필터링된 데이터로 지출 아이템을 하나씩 만들어줘야하기 때문!

const filteredList = exes[selectedMonth];

 

 

✔️ 로컬스토리지

지출 내역 초기값을 설정해준다. 

const initalLocalData = localStorage.getItem("expenseList")
    ? JSON.parse(localStorage.getItem("expenseList"))
    : {};

 

마지막으로 확인한 월 저장.

const initialSelectedMonth = localStorage.getItem("selectedMonth")
    ? parseInt(localStorage.getItem("selectedMonth"))
    : new Date().getMonth() + 1;

 

지출 내역과 월 초기값 상태.

const [exes, setExes] = useState(initalLocalData);
const [selectedMonth, setSelectedMonth] = useState(initialSelectedMonth);

 

데이터 저장. 

localStorage.setItem("expenseList", JSON.stringify(addExes));

 useEffect(() => {
    localStorage.setItem("selectedMonth", selectedMonth);
  }, [selectedMonth]);

 

 

 

 

ExesList.jsx

선택한 월에 해당하는 지출 내역을 보여주는 리스트 컴포넌트이다.

const ExesList = ({ filteredList }) => {
  return (
    <div>
      <ul>
        {filteredList &&
          filteredList.map((exe) => {
            return <ExesItem key={exe.id} exe={exe} />;
          })}
      </ul>
    </div>
  );
};

export default ExesList;

 

 

 

ExesItem.jsx

필터링된 데이터로 각 지출 내역을 보여주는 컴포넌트이다. 아이템을 눌렀을 때 해당하는 지출의 상세페이지로 이동해야하기 때문에(Detail.jsx) useNavigate() 훅을 사용해주었다. 

const ExesItem = ({ exe }) => {
  const navigate = useNavigate();
  const { id, date, item, amount, desc } = exe;

  const onDetailButtonHandler = (id) => {
    navigate(`/detail/${id}`, { state: { exe } });
  };
  return (
    <>
      <ExeItem onClick={() => onDetailButtonHandler(id)}>
        <ExeHead>{item}</ExeHead>
        <ExeItemP>
          {desc}
          <Sapn>{date}</Sapn>
        </ExeItemP>
        <H2>{amount}원</H2>
      </ExeItem>
    </>
  );
};

export default ExesItem;

 

 

 

 

MonthList.jsx

월별 버튼 컴포넌트이다. 1-12월까지 해당하는 버튼을 눌렀을 때 선택된 스타일링이 들어가야한다. 월별 버튼을 구현할 때 필수사항이었던 style-component를 사용해 조건부 스타일링을 해주었다.

const MonthList = ({ handleMonthSelect }) => {
  const monthArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

  return (
    <>
      <MonthWrap>
        {monthArray.map((MonthText, idx) => {
          return (
            <MonthItem
              key={idx}
              $active={selectedMonth === idx}
              onClick={() => handleMonthSelect(idx)}
            >
              {`${MonthText}월`}
            </MonthItem>
          );
        })}
      </MonthWrap>
    </>
  );
};

export default MonthList;

 

 

월이 들어가있는 배열을 순회하면서 $active props에 선택한 월과 배열에 있는 인덱스가 같다면 스타일링을 줄 수 있도록 했다.

{monthArray.map((MonthText, idx) => {
          return (
            <MonthItem
              key={idx}
              $active={selectedMonth === idx}
              onClick={() => handleMonthSelect(idx)}
            >
              {`${MonthText}월`}
            </MonthItem>
          );
        })}

 

삼항 연산자로 처리했다. props로 $active가 있다면 색상을 파란색으로 바꾸고 아니라면 회색(디폴트)색으로 변경. 폰트 색상도 동일하게 조건부 스타일링을 해주었다.

background-color: ${(props) => (props.$active ? "#89ACEC" : "#f4f5f7")};
color: ${(props) => (props.$active ? "#fff" : "#2e2e2e")};

 

상태 업테이트까지 하면 완성.

const handleMonthSelect = (idx) => {
    setSelectedMonth(idx);
  };

 

 

Detail.jsx

각 지출 내역의 상세페이지 컴포넌트이다. props-drilling으로 만들면서 디테일 페이지에는 어떻게 props를 전달해줘야하나 고민을 많이 했다.. 구글링을 하면서 useLocation으로 가져오는 방법을 찾아서 전체 지출 내역 리스트를 가져와서 사용해봤다.

const Detail = () => {
  const { id } = useParams();
  const navigate = useNavigate(); 
  const location = useLocation(); 
  const exe = location.state.exe;

  return (
    <>
      <StContainer>
        <TopWrap>
          <IoIosArrowBack
            style={{ fontSize: "25px", cursor: "pointer" }}
            onClick={onBackButtonHandler}
          />
          <H1>상세 내역</H1>
          <IoIosArrowBack style={{ fontSize: "25px", opacity: "0" }} />
        </TopWrap>

        <ExeItem>
          <ExeHead>{exe.item}</ExeHead>
          <ExeItemP>
            {exe.desc} <Sapn>{exe.date}</Sapn>
          </ExeItemP>
          <H2>{exe.amount}원</H2>
        </ExeItem>

        <DetailContainer>
          <label>날짜</label>
          <StInput ref={dateRef} defaultValue={exe.date} />
          <label>항목</label>
          <StInput ref={itemRef} defaultValue={exe.item} />
          <label>내용</label>
          <StInput ref={descRef} defaultValue={exe.desc} />
          <label>금액</label>
          <StInput ref={amountRef} defaultValue={+exe.amount} />

          <ButtonWrap>
            <EditButton onClick={() => onModify(id)}>수정</EditButton>
            <RemoveButton onClick={() => onRemove(id)}>삭제</RemoveButton>
          </ButtonWrap>
        </DetailContainer>
      </StContainer>
    </>
  );
};

export default Detail;

 

 

✔️ 삭제

로컬스토리지에 저장된 데이터를 삭제하고 업데이트하는 로직으로 구현했다. 로컬스토리지에 저장되어있는 전체 지출 내역을 data 변수에 할당해준다. 

let data = JSON.parse(localStorage.getItem("expenseList"));

 

삭제 버튼 클릭 시 즉시 삭제하기 보다는 사용자에게 확인받은 뒤 삭제처리하도록 했다. data[exe.month]는 해당하는 월의 지출내역을 담고있다. 배열이기때문에 filter 메서드를 사용해 삭제하려는 데이터는 제외한 나머지 데이터들을 removeData 변수에 할당해주었다.

그리고 여기서 해당하는 월의 지출내역에 다시 재할당 해준다. 아무리 구글링해도 setState는 props로 가져올수가 없어서 상태를 업데이트 해주는 대신 저장된 데이터를 업데이트해주는 로직으로 구현한 것이다..

const onRemove = (id) => {
    if (confirm("정말 삭제하시겠습니까?") == true) {
      const removeData = data[exe.month].filter((ex) => ex.id !== id);
      data[exe.month] = removeData;
      navigate(-1);
    } else {
      return false;
    }
  };

 

 

 

✔️ 수정

useRef를 사용해서 수정 기능을 구현했다. 리렌더링되도 초기값이 변하지 않는다. 

  const dateRef = useRef(exe.date); //초기값
  const itemRef = useRef(exe.item);
  const descRef = useRef(exe.desc);
  const amountRef = useRef(exe.amount);

 

이것도 삭제와 같은 방법으로 상태 업데이트가 아니라 저장된 로컬스토리지 데이터에 수정된 데이터를 덮어씌우는 로직으로 갔다. 

수정하려는 객체를 하나 만들어주고 변수명.current.value로 수정하려는 데이터에 접근할 수 있다. 해당 월 지출 내역을 순회하면서 수정하려는 데이터의 id가 해당 월 지출내역의 id가 같다면 기존 지출내역을 복사하고 수정된 데이터도 복사해 합쳐준다. 

const onModify = (id) => {
    const modifiedData = {
      id: exe.id,
      date: dateRef.current.value,
      item: itemRef.current.value,
      amount: +amountRef.current.value,
      desc: descRef.current.value,
    };
    const modifyExes = data[exe.month].map((ex) =>
      ex.id === id ? { ...ex, ...modifiedData } : ex
    );
    data[exe.month] = modifyExes;
    localStorage.setItem("expenseList", JSON.stringify(data));
    navigate(-1);
  };

 

처음 짠 코드는 아래와 같다. 이렇게 했더니 정상적으로 동작하지 않았다. 기존에 있는 데이터는 month 값이 있지만 수정한 객체는 month 값이 들어가 있지 않아서 생긴 오류였다. 그래서 위와 같이 스프레드 연산자를 사용해 불변성을 유지하고 기존 데이터에 있는 month의 값도 들어갈 수 있었다. 

const modifyExes = data[exe.month].map((ex) =>
      ex.id === id ? modifiedData : ex
    );