세션 스토리지, 로컬 스토리지 이용하여 ToDoList 만들기

ChoiYongHyeun·2024년 3월 22일
1

망가뜨린 장난감들

목록 보기
16/19
post-thumbnail

또두리스트

이번에 또 만들어봤습니다.

만드는 목적은 세션 스토리지와 로컬 스토리지를 활용하는 것에 대해서 생각하기 위함이였으나

실제로 하루 꼬박 만들면서 많이 배운 점들이 있어 기록하기 위해서 ..

전체 코드 : https://github.com/yonghyeun/React-study/tree/main/15.windowStorage-study
이론과 관련된 내용 : 로컬 스토리지와 세션 스토리지에 대한 이해


결과물을 먼저 보고 가자

결과물은 별 거 없다.

local storage , seession storage 를 이용해 TODOLIST 들을 브라우저 단에 저장해두고

이를 로컬 스토리지와 세션 스토리지의 스코프 , 라이프 사이클 등을 실습으로 알아보는 프로젝트였다.

프로젝트 구조

프로젝트 구조는 다음과 같다.

단순하게 다음처럼 구성되어있다.

  • asset : 프로젝트에 필요한 이미지 파일이나 스타일 관련 속성을 넣어두는 폴더
  • components : 컴포넌트들을 담은 폴더로 폴더 내에서 폴더 구조를 계층적으로 추가해 사용 할 수 있게 한다.
  • components - core : 다른 컴포넌트들의 코어가 되는 컴포넌트
  • context : 컴포넌트들에게 전달 할 컨텍스트들을 정의해둔 폴더로 최상위 컴포넌트로 존재한다.
  • hooks : 사용할 커스텀 훅들을 저장한 폴더이다.
  • utils : 여러 컴포넌트에서 공통적으로 공유해 사용 할 로직이 담긴 폴더

리액트 공식문서에서 제시하는 전통적인 프로젝트 구조로 만들어봤는데

개인적으로 만들면서 구조를 이렇게 짜두니 확실히 컴포넌트 간 관계에 대해서 이해하기 편했다.

다만 이런 전통적인 파일 구조를 넘어서 다양한 폴더 구조들이 있는데

이를 통해 clean architecture 를 구성 할 수 있다고 한다.

프로젝트 구조에 대해서 크게 생각을 안하고 있었는데 다음번 사이드 프로젝트를 하기 전에

클린 아키텍쳐에 대해서 , 또 컴포넌트의 의존성 등과 관련하여 전체적인 아키텍쳐에 대해 공부해봐야겠다.


Storage 다루기

utils/usingStorage

// Storage는 객체 형태를 저장하는 것이 가능하지만
// 저장시에는 JSON.strantify 로 직렬화
// 조회시에는 JSON.parse 로 객체 형태로 가져와야 한다.

export const setStorageItem = (storageName, key, value) => {
  switch (storageName) {
    case 'localStorage':
      window.localStorage.setItem(key, JSON.stringify(value));
      break;
    case 'sessionStorage':
      window.sessionStorage.setItem(key, JSON.stringify(value));
      break;
    default:
      throw Error('localStorage 혹은 sessionStorage 에서 선택해주세요');
  }
};

export const removeStorageItem = (storageName, key) => {
  switch (storageName) {
    case 'localStorage':
      window.localStorage.removeItem(key);
      break;
    case 'sessionStorage':
      window.sessionStorage.removeItem(key);
      break;
    default:
      throw Error('localStorage 혹은 sessionStorage 에서 선택해주세요');
  }
};

export const getStorageItems = (storageName) => {
  switch (storageName) {
    case 'localStorage':
      const localStorageItems = Object.values({ ...window.localStorage }).map(
        (todoString) => JSON.parse(todoString),
      );
      return localStorageItems.toSorted(
        (todo, nextTodo) => todo.id - nextTodo.id,
      );

    case 'sessionStorage':
      const sessionStorageItems = Object.values({
        ...window.sessionStorage,
      }).map((todoString) => JSON.parse(todoString));

      return sessionStorageItems.toSorted(
        (todo, nextTodo) => todo.id - nextTodo.id,
      );

    default:
      throw Error('localStorage 혹은 sessionStorage 에서 선택해주세요');
  }
};

usingStorage 에서는 Storage 객체를 이용하는 다양한 함수들을 모아놨다.

각 함수는 그저 단순하게 사용할 Stroage 이름을 받아 고유한 메소등을 이용해

값을 저장 , 삭제 , 조회 하는 정도이다.

다만 usingStorage 파일을 만들며 조금 신경썻던 부분이 두 가지 있다.

하나는 조건적으로 무엇인가를 하고 싶을 때에는 if/else 를 이용했는데 이번에는 switch 문을 이용해봤다.

개인적으로는 swicth 문이 좀 더 깔끔하고 에러 핸들링 하기에 더 편한 느낌이 든다.

다만 각 case 별로 return 문과 같이 각 케이스가 끝나지 않는 경우 case 별로 break 를 모두 적어줘야하는 부분이 못생긴 거 같기는 한데

나는 스위치문이 더 코드가 읽기 편하고 관리하기 편한 것 같다.

코드 줄이 늘어난다는 단점이 존재하기는 하지만 말이다.

두 번째는 getStorageItems 함수에서 key 값으로 순서를 보장하며 가져오기 위해서 toSorted를 사용한 부분이다.

toSorted 는 2023년부터 모든 브라우저들에서 사용 가능하게 된 메소드로 sorted 와 동일하게 작동하지만 원본 배열을 변화시키지 않는다는 장점이 있다.

usingStorage 파일에 존재하는 함수들을 이용해 Storage 객체를 다루도록 하고

state 로 정의된 ToDoList 들과 Storage 객체가 대응 되도록 한다.


전체 컴포넌트에서 사용 할 Context 정의

context/Context.js

import { createContext, useState } from 'react';
import { getStorageItems } from '../utils/usingStorage';
export const LocalStorageContext = createContext(null);
export const SessionStorageContext = createContext(null);

/**
 * 각 Context.Provider 들은 Storage에 저장된 값을 객체형태로 받아 배열에 담아 전달해줌
 * @param {children} Context.Provider 들에게 값을 받을 하위 컴포넌트들
 * @returns
 */
export function ContextProvider({ children }) {
  const [localTodo, setLocalTodo] = useState(getStorageItems('localStorage'));
  const [sessionTodo, setSessionTodo] = useState(
    getStorageItems('sessionStorage'),
  );

  return (
    <LocalStorageContext.Provider value={[localTodo, setLocalTodo]}>
      <SessionStorageContext.Provider value={[sessionTodo, setSessionTodo]}>
        {children}
      </SessionStorageContext.Provider>
    </LocalStorageContext.Provider>
  );
}

전역적으로 제공 할 localTodo , sessionTodo 등을 정의해주고 Context.Provider 를 이용해 컴포넌트들에게 내려주도록 했다.

초기 렌더링 될 때 useState 자체에서 각 Storage 에서 값을 가져와 사용하게 하여

이전에 이용하여 Storage에 정보가 존재하며 전체적인 컴포넌트가 최초로 마운트 될 때 (페이지를 새롭게 리로드 하거나 새롭게 접속했을 때)

저장되어 있는 값들을 가져와 state 를 정의하게 하였다.


customHook 을 정의하여 Context 에 담긴 값 조건적으로 가져오기

hooks/useDynamicStorage

import { useContext } from 'react';
import { LocalStorageContext, SessionStorageContext } from '../context/Context';

export default function useDynamicStorage(storageName) {
  const localState = useContext(LocalStorageContext);
  const sessionState = useContext(SessionStorageContext);

  return storageName === 'localStorage' ? localState : sessionState;
}

storageName 을 인수로 받아 조건적으로 상위 컨텍스트에서 정의된 state 들을 내려주는 커스텀 훅이다.

조건적으로 state 를 내려주기 위해서 어떻게 해야하나 처음에는 감을 잘 못잡았다.

Hook 들은 조건적으로 호출되면 안되기 때문이다.

그래서 그냥 조건 없이 모두 호출해놓고 조건에 따라 state 들을 반환하도록 하였다.


TodoInput

components/core/Todoinput

import { useRef } from 'react';
import useDynamicStorage from '../../hooks/useDynamicStorage';
import { setStorageItem } from '../../utils/usingStorage';

/**
 * TodoInput 은 param 으로 받은 storageName에 따라 추가되는 todo 값을 webStorage에 저장합니다.
 * @param {{storageName : String}} 저장에 사용할 webStorage 의 이름
 * @returns {JSX.Element}
 */
export default function TodoInput({ storageName }) {
  const [state, setState] = useDynamicStorage(storageName);
  const inputRef = useRef(null);
  const createTime = new Date();

  const handleClick = () => {
    const newTodo = {
      id: createTime.getTime(),
      content: inputRef.current.value,
      createTime: createTime.toDateString(),
    };

    setState([...state, newTodo]); // state 설정
    setStorageItem(storageName, newTodo.id, newTodo); // storage 설정

    inputRef.current.value = '';
    inputRef.current.focus();
  };

  return (
    <div>
      <input type='text' ref={inputRef} />
      <button onClick={handleClick}>Set Todo</button>
    </div>
  );
}

TodoInput 컴포넌트는 storageNameprops 로 받아 적절한 storage 와 연동되는 state 를 가지는 컴포넌트들이다.

button 엘리먼트가 클릭되는 순간 handleClick 함수가 호출되어 newTodo 객체가 state , storage 에 모두 저장되도록 하였다.

이번에 특히 신경썼던 부분은 불필요한 재렌더링을 피하기 위해 useRef 를 사용했다는 것이다.

맨 처음에는 input 에 적히는 값들을 추가로 state 로 정의해줬었다.

다만 이렇게 하니 input 값에 글자가 적힐 때 마다 text state 가 변경되어 렌더링이 계속 일어나더라

input 값에 적히는 값들은 굳이 리액트의 state 로 정의해주지 않더라도 브라우저 단에서

input.value 값으로 동기화 되기 때문에 state 를 치우고 useRef 을 이용해 input 값과 매칭 시켜주었다.

	...
  const createTime = new Date();
	...
  const handleClick = () => {
    const newTodo = {
      id: createTime.getTime(), // id 값은 유일하게 생성되는 createTime 을 이용
      content: inputRef.current.value,
      createTime: createTime.toDateString(),
    };

todo 객체들은 모두 유일한 식별자이며 정렬의 기준이 되는 id 를 갖기 위해 createTime 값을 가지도록 하였다.

state 로 정의된 투두 리스트들은 새로운 값이 추가 될 때 마다 배열에 차곡 차곡 쌓이기 때문에 크게 문제가 없지만

정렬의 기준이 되는 id 값을 가지게 한 것은 Storage 에서 값을 가져 올 때 정렬한채로 가져오기 위함이였다.

사실 맨 처음에는 id 값을 설정하기 위해 const idRef = useRef(0) 으로 설정한 후 newTodo 가 생성될 때 마다 선형적으로 증가하는 id 값을 가지게 했었다.

이는 페이지가 새로고침되는 등으로 인해 컴포넌트들이 모두 unmount -> mount 될 때 idRef 값이 모두 0 으로 초기화 됨에 따라

충돌하더라

그래서 컴포넌트의 생명주기와 상관없이 유일하면서 선형적인 관계를 갖는 생성 시간을 id 로 이용해주었다.


TodoContent

components/core/TodoContent

import useDynamicStorage from '../../hooks/useDynamicStorage';
import { removeStorageItem } from '../../utils/usingStorage';

export default function TodoContent({ storageName }) {
  const [state, setState] = useDynamicStorage(storageName);

  function handleDelete(e) {
    const idWillRemove = e.target.dataset.id;
    setState(state.filter(({ id }) => id !== Number(idWillRemove)));
    removeStorageItem(storageName, idWillRemove);
  }

  return (
    <ul>
      {state.map((todo) => {
        return (
          <li key={todo.id}>
            <b>{todo.content}</b>
            <i>{todo.createTime}</i>
            <button data-id={todo.id} onClick={handleDelete}>
              Delete
            </button>
          </li>
        );
      })}
    </ul>
  );
}

TodoContent 컴포넌트는 storageNameprops 를 인수로 받아 렌더링 된다.

Delete 버튼에 handleDelete 이벤트 핸들러를 부착해줌으로서 state, Storage 간의 매칭을 시켜주었다.

이 때 buttontodoid 값을 기억하도록 dataset 어트리뷰트를 이용해주었다.


LocalStorageTodo , SessionStorageTodo 컴포넌트 조립

core 에 정의된 TodoInput , TodoContent 를 조립하여 각 컴포넌트를 만들어주었다.

components/LocalStorageTodo

import TodoInput from './core/TodoInput';
import TodoContent from './core/TodoContent';

export default function LocalStorageTodo() {
  return (
    <article style={{ backgroundColor: '#3F7C85' }}>
      <h1> LocalStorageTodo</h1>
      <TodoContent storageName='localStorage' />
      <TodoInput storageName='localStorage' />
    </article>
  );
}

components/SessionStorageTodo

import TodoInput from './core/TodoInput';
import TodoContent from './core/TodoContent';

export default function SessionStorageTodo() {
  return (
    <article style={{ backgroundColor: '#FF5F5D' }}>
      <h1> SessionStorageTodo</h1>
      <TodoContent storageName='sessionStorage' />
      <TodoInput storageName='sessionStorage' />
    </article>
  );
}

components/App

import { ContextProvider } from '../context/Context';
import LocalStorageTodo from './localStorageTodo';
import SessionStorageTodo from './sessionStorageTodo';
import '../assets/App.css';

function App() {
  return (
    <main>
      <ContextProvider>
        <LocalStorageTodo />
        <SessionStorageTodo />
      </ContextProvider>
    </main>
  );
}

export default App;

index.js (엔트리 파일)

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './components/App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

결과물

호호호 SessionStorageTodo 는 역시 sessionStorage 를 사용하니까 탭별로 서로 다른 Storage 스코프를 가지기 때문에 공유가 안되는군

처음 의도로는 Storage 를 다루는 방법에 대해서 배워보려고 했었는데

하다보니 좀 더 코어적인 부분들을 이해하기 좋았다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글