댓글 기능 개발 프로젝트 회고

redux를 활용해 댓글 CRUD를 구현하는 기업 과제

시작

https://github.com/wanted-pre-onboarding-fe-6th-team2/pre-onboarding-assignment-week-3-2-team-2

이번 과제는 JSON Server API 기반으로 댓글 CRUD를 구현하는 과제이다. (하지만 Redux를 첨가한..)

Redux는 엘리스 SW 엔지니어 트랙 2기에서 한 번 학습했지만 당시 진도도 너무 빠르고 이해도 못해서 넘어가고 따로 유튜브에서 강의도 듣고 공식 문서도 계속 확인하면서 어느 정도 이해는 한 상태였는데 팀으로 하는 과제에서 시간이 끌려서 민폐가 될까봐 걱정했지만 다른 팀원 한 분과 페어 프로그래밍으로 작업하기로 해서 결정했다.

걱정스러운 점을 채워주고 함께하는 팀원들에게 정말 감사함을 느낀다.

요구사항 분석

Redux 관련 요구사항은 단순하다.

  • Redux-saga 혹은 Redux-thunk를 사용
  • Redux를 이용한 비동기 처리 필수
  • Redux logger, Redux-Devtools 설정 필수

따라서 Redux로 우선 구현한 뒤에 Redux-Toolkit으로 전환 여부를 결정하기로 했다. 또한 • 간단한 댓글 CRUD 애플리케이션이고, 과제 마감일자를 고려했을 때 구현 러닝 커브가 상대적으로 낮은 Redux-Thunk를 미들웨어로 도입하는 것이 적절할 것으로 판단하여 Redux-Thunk를 활용했다.

Redux로 구현하기

우선 Store를 설정하고 Provider를 활용해 앱에 연결해주었다. 이 과정에서 applyMiddleware를 활용해 loggerthunk도 연결했다.

// store.js
 
import { legacy_createStore as createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { createLogger } from "redux-logger";
import { composeWithDevTools } from "@redux-devtools/extension";
import commentReducer from "@/reducers/commentReducer";
 
const logger = createLogger();
 
const store = createStore(
  commentReducer,
  composeWithDevTools(applyMiddleware(thunk, logger)),
);
 
export default store;
// main.jsx
 
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
  </Provider>
);

commentcomments는 서로 다른 역할을 한다고 판단해 분리하는 게 맞다고 생각했다. 특히, CRUD에서 조회의 경우는 comments를 조회하지만, 생성과 수정은 해당 데이터 하나를 가지고 있어야 하기 때문에 comment 데이터가 필요하다. 따라서 두 가지로 나누어 Reducer, Action을 구현했다.

// comments.js
 
import { ACTION_TYPE } from "@/constants/actionType";
 
const initialState = {
  comments: {
    isLoading: false,
    data: [],
    error: null,
  },
  comment: {
    isLoading: false,
    data: null,
    error: null,
  },
};
 
const commentReducer = (state = initialState, action = {}) => {
  switch (action.type) {
    case ACTION_TYPE.GET_COMMENTS:
      return {
        ...state,
        comments: {
          isLoading: true,
          data: null,
          error: null,
        },
      };
 
    case ACTION_TYPE.GET_COMMENTS_SUCCESS:
      return {
        ...state,
        comments: {
          isLoading: false,
          data: action.comments,
          error: null,
        },
      };
 
    case ACTION_TYPE.GET_COMMENTS_FAILURE:
      return {
        ...state,
        comments: {
          isLoading: false,
          data: null,
          error: action.error,
        },
      };
 
    // 생략..
 
    default:
      return state;
  }
};
 
export default commentReducer;

이제 비동기 로직을 적용하기 위해 Redux-Thunk 로직을 구현했다.

import commentsApiService from "@/api/commentsApiService";
import { ACTION_TYPE } from "@/constants/actionType";
 
export const getCommentsThunk = () => async (dispatch) => {
  dispatch({ type: ACTION_TYPE.GET_COMMENTS });
  try {
    const comments = await commentsApiService.getComments();
    dispatch({ type: ACTION_TYPE.GET_COMMENTS_SUCCESS, comments });
  } catch (error) {
    dispatch({ type: ACTION_TYPE.GET_COMMENTS_FAILURE, error });
  }
};
 
export const getCommentThunk = (commentId) => async (dispatch) => {
  dispatch({ type: ACTION_TYPE.GET_COMMENT });
  try {
    const comment = await commentsApiService.getComment({ commentId });
    dispatch({ type: ACTION_TYPE.GET_COMMENT_SUCCESS, comment });
  } catch (error) {
    dispatch({ type: ACTION_TYPE.GET_COMMENT_FAILURE, error });
  }
};
 
export const addCommentThunk = (newComment) => async (dispatch) => {
  const { profileUrl, author, content, createdAt } = newComment;
  dispatch({ type: ACTION_TYPE.ADD_COMMENT });
  try {
    const comment = await commentsApiService.createComment({
      comment: { profileUrl, author, content, createdAt },
    });
    dispatch({ type: ACTION_TYPE.ADD_COMMENT_SUCCESS, payload: comment });
  } catch (error) {
    dispatch({ type: ACTION_TYPE.ADD_COMMENT_FAILURE, payload: error });
  }
};
 
export const updateCommentThunk = (commentId, comment) => async (dispatch) => {
  dispatch({ type: ACTION_TYPE.UPDATE_COMMENT });
  try {
    const comments = await commentsApiService.updateComment({
      commentId,
      comment,
    });
    dispatch({ type: ACTION_TYPE.UPDATE_COMMENT_SUCCESS, payload: comments });
  } catch (error) {
    dispatch({ type: ACTION_TYPE.UPDATE_COMMENT_FAILURE, payload: error });
  }
};
 
export const deleteCommentThunk = (commentId) => async (dispatch) => {
  dispatch({ type: ACTION_TYPE.DELETE_COMMENT });
  try {
    const comments = await commentsApiService.deleteComment({ commentId });
    dispatch({ type: ACTION_TYPE.DELETE_COMMENT_SUCCESS, comments });
  } catch (error) {
    dispatch({ type: ACTION_TYPE.DELETE_COMMENT_FAILURE, error });
  }
};

역시 악명 높은 Redux의 보일러 플레이트 코드답게 동일한 로직이 반복되어 리팩토링하고 싶었지만, 밤새 페어로 작업하면서 결국 실패했다. 그래서 우리는 Redux-Toolkit으로 리팩토링을 하기로 했다.

Redux-Toolkit으로 구현하기

Redux와 동일하게 Store를 먼저 추가하고 이를 연결해주면서 logger와 thunk 처리를 해주었다.

import { configureStore } from "@reduxjs/toolkit";
import logger from "redux-logger";
import commentsReducer from "@/store/comments";
import commentReducer from "@/store/comment";
 
const store = configureStore({
  reducer: { comments: commentsReducer, comment: commentReducer },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
  devTools: process.env.NODE_ENV !== "production",
});
 
export default store;

RTK의 경우 configureStore를 활용해 쉽게 미들웨어와 Reducer를 Store로 불러올 수 있다.

이후 각각 commentcomments의 Action과 Reducer를 구현하려고 했는데, Redux 구현 시 Action, Reducer가 각각 다른 파일에 있어 가독성이 떨어지고, 그래서 개발하기에 효율적이지 못했다. 그래서 Ducks Pattern을 도입하고자 했다.

Ducks Pattern이란, 하나의 Data를 관리하기 위해 Action, Reducer, ActionType이 필요한데, 이를 나누어 관리하면 추후 유지보수나 코드의 가독성이 떨어지고, 개발 경험도 낮아지기 때문에 하나의 파일로 결합해 구현하는 방식을 의미한다.

// comment.js
 
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import commentsApiService from "@/api/commentsApiService";
 
export const getCommentThunk = createAsyncThunk(
  "comment/getComment",
  async (commentId, thunkAPI) => {
    try {
      const comment = await commentsApiService.getComment({ commentId });
      return comment;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
 
export const addCommentThunk = createAsyncThunk(
  "comment/addComment",
  async (comment, thunkAPI) => {
    try {
      const newComment = await commentsApiService.createComment({ comment });
      return newComment;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
 
export const updateCommentThunk = createAsyncThunk(
  "comment/updateComment",
  async ({ commentId, comment }, thunkAPI) => {
    try {
      const newComment = await commentsApiService.updateComment({
        commentId,
        comment,
      });
      return newComment;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
 
export const deleteCommentThunk = createAsyncThunk(
  "comment/deleteComment",
  async (commentId, thunkAPI) => {
    try {
      return await commentsApiService.deleteComment({ commentId });
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
 
const initialState = {
  isLoading: false,
  data: null,
  error: null,
};
 
export const commentSlice = createSlice({
  name: "comment",
  initialState,
  extraReducers: {
    [getCommentThunk.pending]: (state) => {
      state.isLoading = true;
    },
    [getCommentThunk.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.data = action.payload;
    },
    [getCommentThunk.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.error.message;
    },
    [addCommentThunk.pending]: (state) => {
      state.isLoading = true;
    },
    [addCommentThunk.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.data = action.payload;
    },
    [addCommentThunk.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.error.message;
    },
    [updateCommentThunk.pending]: (state) => {
      state.isLoading = true;
    },
    [updateCommentThunk.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.data = action.payload;
    },
    [updateCommentThunk.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.error.message;
    },
    [deleteCommentThunk.pending]: (state) => {
      state.isLoading = true;
    },
    [deleteCommentThunk.fulfilled]: (state) => {
      state.isLoading = false;
    },
    [deleteCommentThunk.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.error.message;
    },
  },
});
 
export default commentSlice.reducer;
// comments.js
 
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import commentsApiService from "@/api/commentsApiService";
 
export const getCommentsThunk = createAsyncThunk(
  "comments/getComments",
  async (thunkAPI) => {
    try {
      const comments = await commentsApiService.getComments();
      return comments;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
 
const initialState = {
  isLoading: false,
  data: null,
  error: null,
};
 
export const commentsSlice = createSlice({
  name: "comments",
  initialState,
  extraReducers: {
    [getCommentsThunk.pending]: (state) => {
      state.isLoading = true;
    },
    [getCommentsThunk.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.data = action.payload;
    },
    [getCommentsThunk.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.error.message;
    },
  },
});
 
export default commentsSlice.reducer;

이때, 반복되는 코드가 RTK에서도 많아서 결국 리팩토링으로 줄여보고자 extraReducerStatus 를 만들어 적용했다.

  • 리팩토링한 RTK 코드
// comment.js
 
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import commentsApiService from "@/api/commentsApiService";
import { extraReducerUtils } from "@/lib/asyncUtils";
 
export const getCommentThunk = createAsyncThunk(
  "comment/getComment",
  async (commentId, thunkAPI) => {
    try {
      const comment = await commentsApiService.getComment({ commentId });
      return comment;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
export const addCommentThunk = createAsyncThunk(
  "comment/addComment",
  async (comment, thunkAPI) => {
    try {
      const newComment = await commentsApiService.createComment({ comment });
      return newComment;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
export const updateCommentThunk = createAsyncThunk(
  "comment/updateComment",
  async ({ commentId, comment }, thunkAPI) => {
    try {
      const newComment = await commentsApiService.updateComment({
        commentId,
        comment,
      });
      return newComment;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
export const deleteCommentThunk = createAsyncThunk(
  "comment/deleteComment",
  async (commentId, thunkAPI) => {
    try {
      return await commentsApiService.deleteComment({ commentId });
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
const initialState = {
  isLoading: false,
  data: null,
  error: null,
};
export const commentSlice = createSlice({
  name: "comment",
  initialState,
  extraReducers: {
    ...extraReducerUtils(getCommentThunk),
    ...extraReducerUtils(addCommentThunk),
    ...extraReducerUtils(updateCommentThunk),
    ...extraReducerUtils(deleteCommentThunk),
  },
});
 
export default commentSlice.reducer;
// comments.js
 
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import commentsApiService from "@/api/commentsApiService";
import { extraReducerUtils } from "@/lib/asyncUtils";
 
export const getCommentsThunk = createAsyncThunk(
  "comments/getComments",
  async (thunkAPI) => {
    try {
      const comments = await commentsApiService.getComments();
      return comments;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);
const initialState = {
  isLoading: false,
  data: null,
  error: null,
};
export const commentsSlice = createSlice({
  name: "comments",
  initialState,
  extraReducers: extraReducerUtils(getCommentsThunk),
});
 
export default commentsSlice.reducer;

개선점

Duck Pattern의 4가지 원칙이 있는데 그러한 원칙까지 지키기에 어려워서 일단 넘기고 작업을 진행했는데 해당 원칙에 맞게 수정해야할 것 같다. 다른 사람들이 보기에 원칙을 지키지 않는다면 가독성이 떨어지고 이해하기 어려울 수 있음이 분명하기 때문이다.

또한 더 나은 리팩토링 방법이나 더 좋은 코드를 만들고 싶다!!! 워낙 악명 높은 코드여서 줄여보고자 노력했는데 조금 아쉬웠다.

또한, 두 가지로 데이터를 나눈 것을 추후 다른 팀원들이 UI 구현 작업에서 사용하면서 이슈가 발생했다. 첫 페이지에서만 CRUD 작업 후 리렌더링이 발생하지 않는 문제였는데 데이터를 하나로 관리하지 않아 렌더링이 되지 않는 것 같았다. 구현 방식에 문제는 없었는데 하나로 합치면 댓글의 수정, 생성 등이 문제가 되어 결국 첫 페이지인 경우에는 강제로 리로드하게 하여 우선적으로 조치해 두었다.

시간이 지난 후에 확인해도 아직 여전히 왜 문제가 해결되지 않았는지 모르겠다.

마무리

악명 높고 학습할 때도 어려웠던 Redux와 RTK를 실제로 적용해 코드를 작성해보니 확실히 한 번 만들어보는게 정말 도움이 된다고 느꼈다. 이제 새로운 기술이나 학습할 기술들은 무조건 한 번 이상 사용해보는 걸로 다짐했다. 이번에도 역시나 힘들지만 코딩은 즐겁고 팀원들과 함께하는 건 재밌다!

끝! 🙋🏻‍♂️