[#7] React로 Task Manager 만들기

오닐·2022년 6월 9일
0

React : Task Manager

목록 보기
7/11
post-thumbnail

🎍1. Redux와 Redux Toolkit

다이어리 및 위시리스트 데이터를 저장할 state가 자주 변경될 예정이라 Redux를 도입하기로 했었다. Udemy React 강의에 Redux를 다루는 파트가 따로 있지만, Redux가 주가 되는 프로젝트가 아니기 때문에 하나부터 열까지 톺아보기에는 무리가 있다고 판단. 그래서 Redux를 보다 쉽게 사용할 수 있는 라이브러리인 Redux Toolkit을 쓰기로 했다.

React에서 Redux를 쓰는 보다 정확한 이유

  • React context는 생성한 context를 provider를 통해 전달하기 때문에 전달받을 컴포넌트를 <Context.provider></Context.provider> 감싸주어야 한다. 이때 Provider가 많아질수록 코드가 중첩되면서 코드가 복잡해질 수 있다.
  • context로 보내는 데이터가 자주 변경되면 애플리케이션의 성능이 저하될 수 있다.

🎍1-1. Store

//store.js
import { configureStore } from "@reduxjs/toolkit";

import { diaryReducer } from "./diary";
import { wishReducer } from "./wishList";

export const store = configureStore({
  reducer: { diary: diaryReducer.reducer, wish: wishReducer.reducer },
});

store.subscribe(() => {
  localStorage.setItem("data", JSON.stringify(store.getState()));
});

store와 관련된 파일들을 관리하기 위해 src 폴더 내에 store 폴더를 만들고, 그 안에 스토어로 사용할 store.js 파일을 만들었다.

한 프로젝트당 하나의 스토어만 만들 수 있기 때문에 처음에는 Reducer도 하나만 만들까 했으나, createSlice를 이용하면 여러 slice로 나눠서 관리할 수 있다는 것을 알고 diary용과 wishList용으로 분리했다.

분리한 slice들은 상단에 있는 것처럼 import한 다음, configureStore에 넣어주었다. configureStore는 Reducer에서 반환된 새로운 state를 Store라는 객체로 정리해 관리하는 곳이라고 한다.

하단의 subscribe 함수는 스토어에서 관리되는 state를 localStorage에 저장하기 위한 함수이다. 처음에는 reducer의 action마다 저장 및 수정하는 함수를 달아줘야 하나 했는데 다행히 간단한 방법이 있었다. 스택오버플로우 글을 참고해서 data라는 key에 JSON 객체로 만들어서 저장했다.

subscribe에 특정 함수를 전달하면 Action이 dispatch 될 때마다 전달한 함수가 호출된다고 한다. subscribe가 구독이라는 뜻이니까 해당 함수가 스토어를 구독해서 들여다보고 있다가 action이 dispatch되면 store가 변경됐음을 알리는 느낌인 듯.

이렇게 생성한 store는 React Context와 마찬가지로 Provider를 통해 공급된다. <Provider store={store}> 태그를 써서 애플리케이션 전체를 감싸주면 끝.

🎍1-2. Reducer

//diary.js
import { createSlice } from "@reduxjs/toolkit";

export const diaryReducer = createSlice({
  name: "diary", //state 이름
  initialState: [], //state의 초기값
  reducers: { //action creator 메서드 설정
    diaryInit: (state, action) => {
      return action.payload;
    },
    diaryCreate: (state, action) => {
      state.unshift(action.payload);
    },
    diaryEdit: (state, action) => {
      return state.map((item) =>
        item.id === action.payload.id ? action.payload : item
      );
    },
    diaryRemove: (state, action) => {
      return state.filter((item) => item.id !== action.payload);
    },
  },
});

//action들 내보내기
export const diaryActions = diaryReducer.actions;

export default diaryReducer.reducer;

이제 막 다이어리와 관련된 작업을 끝냈으니 diary.js 파일을 예시로 들어보자. (WishList도 거의 동일한 로직이 사용될 듯하다)

우선 Redux toolkit에서 제공하는 createSlice를 사용할 것이니 import해 온다. 그리고 아래와 같이 작성하면 Reducer 하나가 뚝딱 완성된다. createStore를 사용하는 방법도 있지만, createSlice를 사용하면 action.type 정의, action creator 생성, reducer 생성을 한 번에 할 수 있다고 한다. (정공법을 공부해야 하는데 자꾸 지름길로 가는 느낌이지만 일단 이렇게 흐름이라도 느껴보자고)

Reducer가 완성되면 다른 컴포넌트에서 Reducer의 action들을 사용할 수 있도록 export한다. 그리고 이렇게 store.js가 아닌 다른 파일에 Reducer를 만들었다면 마찬가지로 export해서 store.js 파일에 import 해주어야 정상적으로 사용할 수 있다.

//state를 사용할 파일
import { diaryActions } from 'action이 정의된 파일 경로';

//state 조회하기
const diary = useSelector((state) => state.diary); //조회 안 해도 state 사용할 수 있음

//원하는 자리에 dispatch()로 감싸서 사용
<button onClick={()=> dispatch(diaryActions.diaryCreate())}></button>

Store와 Reducer까지 잘 만들었다면 원하는 컴포넌트에 위와 같은 코드를 첨부하면 된다. context를 먼저 써봐서 그런지 여기까지는 그렇게 어렵지 않았다.

🎍1-3. context API 대체 예시


코딩할 때는 하나를 다 만들어 둔 다음에 다음 단계로 넘어가는 게 이해도 쉽고 코드를 대체하기에도 좋은 것 같다. 아마 처음부터 Redux를 썼다면 무슨 소리인지 하나도 몰랐을 것...

🎍오늘의 문제1

An immer producer returned a new value and modified its draft. Either return a new value or modify the draft.

reducer 안에 diaryCreate 메서드를 만드는 과정에서 발생한 오류.

immer producer가 new value를 반환하든지 아니면 수정하든지 둘 중 하나만 하나 그래서 return을 없앴더니 오류가 해결되었다. init, edit, delete는 모두 값을 return해야 작동하던데 create만 왜 이런 오류가 발생했을까.

에러 메시지를 다시 읽어보면 return a new value or modify라고 되어 있다. 이 말은 새로운 값은 return하는 것이고 기존 값을 변경하는 것은 return이 아니라는 말이다. 이걸 생각하면서 내가 짠 코드를 다시 봐 보자.

diaryInit: (state, action) => {
      return action.payload;
    },
    diaryCreate: (state, action) => {
      state.unshift(action.payload);
    },
    diaryEdit: (state, action) => {
      return state.map((item) =>
        item.id === action.payload.id ? action.payload : item
      );
    },
    diaryRemove: (state, action) => {
      return state.filter((item) => item.id !== action.payload);
    }

Init, Edit, Remove는 return이 있어야 동작한다. 이 말은 이 action들은 새로운 값을 return한다는 뜻이다. Udemy React 완벽 가이드의 Redux 강의에 따르면, Redux toolkit는 내부적으로 immer라는 패키지를 사용한다고 한다. 때문에 코드를 작성하면 자동으로 원래 있는 state를 복제해서 새로운 state 객체를 생성함으로써 state를 변경할 수 없게 유지시킨다고 한다.

종합해 보면, immer가 이미 state를 복제해서 return했는데 거기에 대고 state.unshift를 하면서 state에 변경까지 가하려고 했기 때문에 이런 오류가 뜨지 않았나 싶다. 구글링하면 void니 어쩌니 뭐가 많이 나오지만 지금 상황에서는 이 정도로 이해하고 넘어가는 게 맞는 듯하다. Redux 강의는 꼭 한 번 제대로 들어야겠다.

🎍오늘의 문제2

createSlice로 diary와 wish를 나눈 후, 어느 reducer에서 만들어진 객체인지 구분하기 위해 initialState를 { diary: [] }처럼 객체로 만들어 놓았었다. 그런데 이렇게 하니 action이 dispatch 되고 나서 변경된 state를 보면 { diary: { diary: [...] } } 이런 식으로 중첩되는 버그가 발생했다.

처음에는 init, 즉 localStorage에서 데이터를 가져와서 state를 초기화하는 과정에만 발생하는 문제라 init하는 함수 부분에 문제가 생긴 줄 알았다. 그래서 코드를 state = action.payload로 고치는 등 별짓을 다 해봤는데도 안 돼서 꼬박 하루를 이걸로 씨름했는데...

해결책은 의외로 간단했다! configureStore에서 reducer 부분을 보자.

slice가 두 개 이상일 때는 { diary: diaryReducer.reducer, wish: wishReducer.reducer } 이렇게 객체 형식으로 만들어서 넣어줘야 한다. 그리고 이렇게 하면 reducer로 만들어진 state가 설정한 key값(diary, wish 부분)을 따라 자동으로 분류가 된다. 즉, 만약 내가 store에 저장된 state를 localStorage에 저장한다고 하면 알아서 diary: 어쩌구, wish: 어쩌구로 분리되어 저장되기 때문에 initialState을 { diary: [] } 이런 식으로 설정할 필요가 없다는 거...^^ 이걸 몰라서 개고생을................................. 하긴 했지만?

대신 이제 createSlice로 분리하는 것도 끄떡없다! 우하하...~


🎍2. 썸네일 직접 업로드하기

다이어리 작성 및 수정 페이지의 에디터에는 아이콘만 대충 하드코딩 해놓은 툴바가 있었다. 삽질 좀 하다 보면 에디터 자체도 만들 수 있겠지만, Task Manager의 핵심 기능은 아니기 때문에 과감히 없애기로 했다.

대신 이미지 첨부 기능을 넣어서 업로드한 이미지를 다이어리 썸네일로 구현해보기로 했다. 원래는 Unsplash나 GIPHY처럼 사진 관련된 API를 사용해서 랜덤으로 가져올까 했으나, dummy data로 랜덤 썸네일을 구현했을 때 정신 사납기도 했고 어차피 Diary이니 내용과 관련된 이미지를 직접 첨부할 수 있도록 하는 게 유저 관점에서도 맞는 것 같았다.

그래서 관련 기능을 찾아보던 중에 FileReader라는 WEB API를 알게 되었다. MDN에 따르면 웹 애플리케이션이 비동기적으로 데이터를 읽기 위하여 읽을 파일을 가리키는File 혹은 Blob 객체를 이용해 파일의 내용을(혹은 raw data버퍼로) 읽고 사용자의 컴퓨터에 저장하는 것을 가능하게 해준다고 하니 딱 적당해 보였다. 참고 글은 여기로.

먼저 이미지 첨부를 위해 원하는 곳에 <input type="file"> 태그를 사용한다. 그러면 우리가 흔히 아는 파일 선택 버튼이 만들어지면서 파일을 직접 올릴 수 있게 된다. 여기에 accept 속성을 넣으면 올릴 수 있는 파일의 종류도 제한할 수 있다. 나는 아이콘들이 전멸된 툴바 자리에 input 태그를 넣어주었다.

이렇게 파일을 업로드한다고 해서 화면 어딘가에 해당 파일이 뜨는 것은 아니다. 업로드한 파일에 대한 정보는 단지 FileList라는 객체에 담겨있을 뿐이다. 해당 객체에는 e.target.files로 접근할 수 있다.

보통 블로그나 일기 어플에 사진을 첨부하면 유저들은 해당 사진을 에디터에서 바로 볼 수가 있다. 그렇기 때문에 나도 단순히 썸네일에 바로 적용시키기 이전에 사진을 잘 선택했는지 미리 볼 수 있게 하고 싶었다. HTML의 이미지 태그는 src 속성에 url을 받아와서 이미지를 띄워준다. 그렇다면 에디터 내에 img 태그 추가하고 업로드한 이미지의 url을 받아와서 여기에 넣어주면 뜨겠군?

const readFile = (e) => {
    if (e.target.files.length) {
      const reader = new FileReader();
      reader.readAsDataURL(e.target.files[0]);
      reader.onload = (e) => {
        setImgPreview(e.target.result);
      };
    }
  };

이러한 작업을 위해 필요한 것이 FileReader API라고 한다. FileReader API에는 readAsText, readAsDataURL, readAsBinaryString 등의 메서드가 있고, 지금은 URL이 필요하기 때문에 readAsDataURL을 사용했다.

파일을 업로드한 뒤 e.target.files을 콘솔창에 찍어보니 몇 개를 업로드하든 FileList 객체에는 제일 마지막에 올린 파일 하나만이 저장되어 있다. 그러므로 객체의 0번째 데이터에 접근해야 업로드한 파일의 정보를 가져올 수 있다.

onload 이벤트 핸들러를 이용하면 파일이 제대로 업로드 됐을 때 실행될 함수를 설정할 수 있다. 나는 이미지 태그가 있는 에디터 페이지가 아닌 다른 컴포넌트에 이 url을 전달해서 다이어리의 썸네일로 만들어 줘야 하기 때문에 imgPreview라는 state를 만들어서 다른 form 데이터들과 함께 diary state에 저장해 주었다.

다른 데이터들과 함께 localStorage에 저장할 것이기 때문에 FormData API는 이용하지 않았다. Reducer를 잘 만들어놔서 객체에 key 하나만 추가해 주니 저장도 잘 되고 불러오기도 잘 된다 후후...

그런데 여기서 예상하지 못한 문제가 발생했다. 업로드된 이미지의 url이 너어어어무 길어서 허용 용량이 크지 않은 localStorage에 저장하니 다이어리를 두세 개만 만들어도 버벅인다ㅠㅠ 잠깐이나마 배포도 할 생각이라 일부러 DB와 연결하지 않으려 했는데ㅠㅠ...

그냥 썸네일 업로드 기능을 없애면 되기는 하지만 기왕 만들어 놓은 거 아깝기도 하고, firebase 정도면 연결할 수 있지 않을까 하는 생각이 든다. 어째 점점 플젝이 커지는 거 같은 건 기분 탓이겠지...ㅎㅎ...


🎍3. 가벼운 회고

예전 JS 프로젝트 때도 그랬지만, 내가 원하는 기능을 구현하는 일은 생각보다 쉽기도 하고 어렵기도 하다. 그리고 그 과정에서 새로운 기술을 알게 되면서 욕심이 생기기도 한다. 지금만 해도 처음에는 Redux가 뭔지도 몰랐는데 적용하고 또 firebase도 기웃거려보려고 하고 있으니까.

이런 태도는 매우 바람직하지만 일단 나는 취업을 목표로 포트폴리오에 넣은 프로젝트를 만들고 있다는 걸 잊어서는 안 된다. 너무 삼천포로 빠지지 않고 핵심 기능만 몇 개 구현해야 된다는 거 부디 까먹지 않기를.

일단 wishList 부분 구현하고 Home에 필요없는 부분들을 좀 덜어내야겠다. 연간, 월간, 주간으로 투두 리스트 나눌 수 있게 해놨는데 그냥 기간에 상관없는 목표랑 프로그레스 바만 남겨두고 없애야겠다. 대신 그 자리에 시계나 날씨 위젯 넣는 것도 나쁘지 않을 듯. 전에 해보기는 했어도 하도 오래 전이라 까먹었으니 복습 겸... 그리고 무드 트래커는... 캘린더 만들어서 정말 한 번 해보고 싶긴 한데 다른 거 다 만들어 보고 뭔가 부족하다 싶으면 하는 걸로. 이번 달 안에 완성시켜야 이력서를 넣을 거 아녀...!ㅋㅋㅋㅋ

0개의 댓글