본문 바로가기

TIL

TIL - 리액트로 가계부 만들기 - 1

오늘은 리액트를 활용하여 가계부 만들기 프로젝트를 진행했습니다.

목표

  1. CRUD가 가능한 가계부 만들기
  2. 처음에는 props drilling를 경험
  3. 두 번째는 useContext로 리팩토링
  4. 마지막은 Redux를 활용하여 리팩토링해서 완성

여기서 props drilling이란, 부모 컴포넌트가 자식 컴포넌트에 데이터를 전달할 때, 여러 중첩된 컴포넌트를 통해 props를 계속 전달해야 하는 상황을 말합니다.

props drilling의 단점

  • 가독성 저하: 데이터가 여러 컴포넌트를 통해 전달되므로, 코드가 복잡해지고 가독성이 떨어집니다.
  • 유지보수 어려움: 컴포넌트 계층 구조가 변경될 때마다, 모든 중간 컴포넌트의 props를 업데이트해야 하므로 유지보수가 어렵습니다.
  • 불필요한 렌더링: 모든 중간 컴포넌트가 다시 렌더링될 수 있어, 성능이 저하될 수 있습니다.

1. 프로젝트 구조 설정

초기 구상 단계에서는 App.jsx에서 데이터를 받아와 props drilling 를 통해 main 페이지인 Homepage와 가계부 항목의 detail 페이지에 전달하기 위해 react-router-dom 사용하여 SPA를 구현하고 App.jsx에서 로컬 스토리지에 데이터 항목을 추가한 뒤 Homepage에 항목 제출 폼에서 제출 시 로컬 스토리지에 값을 추가하고 리렌더링 하는 것을 목표로 했습니다.

// App.jsx
import { useEffect, useState } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./App.css";
import GlobalStyle from "./GlobalStyle";
import ExpenseDetail from "./assets/pages/ExpenseDetail";
import Homepage from "./assets/pages/Homepage";
import fetchData from "./fetchData";

function App() {
  const [data, setData] = useState([]);

  useEffect(() => {
    const loadData = async () => {
      const fetchedData = await fetchData();
      const localData = JSON.parse(localStorage.getItem("dataItem")) || [];
      const combinedData = [...fetchedData, ...localData];
      setData(combinedData);
      localStorage.setItem("dataItem", JSON.stringify(combinedData));
    };
    loadData();
  }, []);

  const addExpense = (newExpense) => {
    const updateData = [...data, newExpense];
    setData(updateData);
    localStorage.setItem("dataItem", JSON.stringify(updateData));
  };

  return (
    <>
      <GlobalStyle />
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Homepage data={data} addExpense={addExpense} />} />
          <Route path="/expenses/:itemId" element={<ExpenseDetail />} />
        </Routes>
      </BrowserRouter>
    </>
  );
}

export default App;

 

useEffect는 리액트 함수형 컴포넌트에서 사이드 이펙트를 수행하기 위해 사용됩니다. 여기서 사이드 이펙트란, 데이터 가져오기, 구독, 타이머 설정 등 컴포넌트가 렌더링될 때마다 발생할 수 있는 작업을 말합니다. 위의 useEffect는 컴포넌트가 처음 마운트될 때 loadData 함수를 실행하여 데이터를 가져오고, 이를 상태에 저장합니다. 빈 배열 []을 두 번째 인수로 전달하여, 이 useEffect는 컴포넌트가 마운트될 때 한 번만 실행됩니다.

Homepage 컴포넌트 구현

Homepage 컴포넌트는 크게 3개의 섹션으로 나누어집니다. 폼 섹션, 달력 선택 섹션, 항목 표시 섹션이 있습니다.

// Homepage.jsx
import { useState } from "react";
import styled from "styled-components";
import ExpenseForm from "../../components/ExpenseForm";
import ExpenseListByMonth from "../../components/ExpenseListByMonth";
import MonthlyExpenses from "../../components/MonthlyExpenses";

const StrMain = styled.main`
  display: flex;
  flex-direction: column;
  gap: 20px;
`;

const StrSection = styled.section`
  background-color: white;
  border-radius: 16px;
  padding: 20px;
`;

function Homepage({ data, addExpense }) {
  const [filterMonth, setFilterMonth] = useState("");
  return (
    <StrMain className="main-container">
      <StrSection>
        <ExpenseForm addExpense={addExpense} />
      </StrSection>
      <StrSection>
        <MonthlyExpenses setFilterMonth={setFilterMonth} />
      </StrSection>
      <StrSection>
        <ExpenseListByMonth data={data} filterMonth={filterMonth} />
      </StrSection>
    </StrMain>
  );
}

export default Homepage;

 

폼 섹션

// ExpenseForm.jsx
import { useEffect, useRef, useState } from "react";
import styled from "styled-components";
import { v4 as uuidv4 } from "uuid";
import { validateDate } from "./dateValidator";

function ExpenseForm({ addExpense }) {
  console.log("폼 컴포넌트 리렌더링");
  const [date, setDate] = useState("");
  // 나머지 state 생략
  const dateInputRef = useRef(null);

  const onSubmitHandler = (event) => {
    event.preventDefault();
    // 유효성 검사
    if (!date || !item || !amount || !description) {
      alert("입력창을 모두 작성해주세요.");
      return;
    }
    // 날짜 유효성 검사
    const dateValidationError = validateDate(date);
    if (dateValidationError) {
      alert(dateValidationError);
      return;
    }

    const newExpense = {
      id: uuidv4(),
      date,
      item,
      amount: parseFloat(amount),
      description,
    };

    addExpense(newExpense);

    setDate("");
    setItem("");
    setAmount("");
    setDescription("");
  };

  useEffect(() => {
    if (date === "") {
      dateInputRef.current.focus();
    }
  }, [date]);

  return (
    <StrForm onSubmit={onSubmitHandler}>
      <StrDiv>
        <label htmlFor="date">날짜</label>
        <StrInput
          ref={dateInputRef}
          type="text"
          id="date"
          name="date"
          placeholder="YYYY-MM-DD"
          value={date}
          onChange={(event) => setDate(event.target.value)}
        />
      </StrDiv>

	  // ... 나머지 항목 생략

      <StrBtn type="submit">저장</StrBtn>
    </StrForm>
  );
}

export default ExpenseForm;

달력 선택 섹션

// MonthlyExpenses
import { useEffect, useState } from "react";
import styled from "styled-components";

// styled-components 디자인 생략

function MonthlyExpenses({ setFilterMonth }) {
  console.log("월별 컴포넌트 리렌더링");
  const [activeMonth, setActiveMonth] = useState(null);

  useEffect(() => {
    const storedMonth = localStorage.getItem("selectedMonth");
    if (storedMonth !== null) {
      setActiveMonth(parseInt(storedMonth));
      setFilterMonth(parseInt(storedMonth));
    } else {
      const currentMonth = new Date().getMonth();
      setActiveMonth(currentMonth);
      setFilterMonth(currentMonth);
    }
  }, [setFilterMonth]);

  const handleMonthClick = (index) => {
    setActiveMonth(index);
    setFilterMonth(index);
    localStorage.setItem("selectedMonth", index);
  };

  const months = [
    "1월", "2월", "3월", "4월", "5월", "6월",
    "7월", "8월", "9월", "10월", "11월", "12월",
  ];

  return (
    <StrDiv>
      {months.map((month, index) => (
        <StrBtn
          key={index}
          active={activeMonth === index}
          onClick={() => handleMonthClick(index)}
        >
          {month}
        </StrBtn>
      ))}
    </StrDiv>
  );
}

export default MonthlyExpenses;

항목 표시 섹션

// ExpenseListByMonth
import { useEffect, useState } from "react";
import styled from "styled-components";

// styled-components 스타일 생략

function ExpenseListByMonth({ data, filterMonth }) {
  console.log(data);
  const [filteredData, setFilteredData] = useState([]);

  useEffect(() => {
    const filtered = data.filter((item) => {
      const month = new Date(item.date).getMonth();
      return month === filterMonth;
    });
    setFilteredData(filtered);
  }, [data, filterMonth]);

  return (
    <StrDiv>
      {filteredData.map((item) => (
        <StrItemWrapDiv key={item.id}>
          <StrDateItemWrapDiv>
            <span>{item.date}</span>
            <StrDateItemText>
              {item.item} - {item.description}
            </StrDateItemText>
          </StrDateItemWrapDiv>
          <span>{item.amount} 원</span>
        </StrItemWrapDiv>
      ))}
    </StrDiv>
  );
}

export default ExpenseListByMonth;

문제 발생

App.jsx 컴포넌트가 마운트 될 때마다 로컬 스토리지 안에 있는 데이터가 중복되어 표시되는 문제와 폼에서 제출할 때 실행되는 함수에 setFecthData로 상태를 업데이트를 하지 않아서 폼을 제출을 해도 항목이 표시되지 않는 문제가 발생했습니다.

 

해결 코드

// App.jsx
import { useEffect, useState } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./App.css";
import GlobalStyle from "./GlobalStyle";
import ExpenseDetail from "./assets/pages/ExpenseDetail";
import Homepage from "./assets/pages/Homepage";
import fetchData from "./fetchData";

function App() {
  const [fetchedData, setFetchedData] = useState([]);

  useEffect(() => {
    const loadData = async () => {
      const fetchedData = await fetchData();
      const localData = JSON.parse(localStorage.getItem("dataItem")) || [];
      const combinedData = [
        ...fetchedData.filter(
          (item) => !localData.some((localItem) => localItem.id === item.id)
        ),
        ...localData,
      ];
      setFetchedData(combinedData);
      localStorage.setItem("dataItem", JSON.stringify(combinedData));
    };
    loadData();
  }, []);

  const addExpense = (newExpense) => {
    setFetchedData((prevData) => {
      const updatedData = [...prevData, newExpense];
      localStorage.setItem("dataItem", JSON.stringify(updatedData));
      return updatedData;
    });
  };

  return (
    <>
      <GlobalStyle />
      <BrowserRouter>
        <Routes>
          <Route
            path="/"
            element={<Homepage data={fetchedData} addExpense={addExpense} />}
          />
          <Route path="/expenses/:itemId" element={<ExpenseDetail />} />
        </Routes>
      </BrowserRouter>
    </>
  );
}

export default App;

처음에는 state로 data를 받아와서 표시하는 방법을 구상했고 나중에 로컬 스토리지를 활용하는 방안이 생각나서 수정 전App.jsx는 로컬 스토리지에 값이 추가되어도 항목이 표시되지 않는 문제가 있어서 리팩토링 해서 addExpense 함수에서 상태를 업데이트하도록 수정하여 문제점을 해결했고 useEffect는 중복 데이터를 필터링하는 로직을 추가해서 문제점들을 해결 했습니다.  

결론

데이터를 받아와 App 컴포넌트에서 Homepage로 props를 전달하고, Homepage 컴포넌트 안에 폼 컴포넌트에서 폼 제출 시 부모 컴포넌트인 App에서 상태를 관리하는 과정을 통해 가계부의 Create와 Read 기능을 구현해 보고 상태 끌어올리기 (Lifting State Up)를 경험해 봤습니다. 다음 단계로는 항목을 클릭하여 상세 페이지로 이동하고, Update와 Delete 기능을 구현할 예정입니다.