Redux) createEntityAdapter 이용해 스테이트 구조(State structure) normalize 하기

Ava Kim·2022년 7월 6일
0

프로젝트 요약

source code: https://github.com/kimava/journal
리덕스로 상태 관리 + 파이어베이스 Realtime Data Base 사용

파이어베이스 DB에서 posts(일기) 데이터를 받아와서 initial state에 넘겨줄 때, 오브젝트로 받아 오는 방식을 선택했다.

내 프로젝트는 데이터 양이 많지 않고, 한 유저 당 1년에 하루도 빼먹지 않고 일기를 쓴다고 해도 365개니까 사실 배열을 사용해도 될 것 같지만, 많은 양의 데이터를 처리해야 할 경우를 대비해서 object key-value pair를 이용한 패턴도 연습해 보고 싶었다.

각각의 일기 페이지로 이동하거나 수정, 삭제 할 때 id를 이용해서 해당 포스트를 찾는데, array를 다 순회하는 대신 key 값으로 찾는 게 더 효율적이라고 판단했다.

참조 포스트: https://medium.com/@mahesh_joshi/javascript-performance-array-vs-object-794f1e30e920

수정 전 실수한 부분

const initialState = { posts: {}, status: 'idle', error: null };

데이터를 {id1: {}, 1d2:{}, ... } 와 같이 key - value pair에 맞게 받아왔어야 하는데 그냥 냅다 빈 오브젝트 안에 받아버리는 실수를 저질렀다. 결과적으로 id로 포스트 찾을 때 아래와 같이 가독성도 떨어지고 오브젝트를 쓰나마나 한 로직을 작성했다. (이 때 진짜 정신 놓고 코드 짰다🤦🏻‍♀️)

const EditJournalForm = () => {
  ...
  const allJournals = useSelector(selectAllJournals);
  const key = Object.keys(allJournals).find(
    (journal) => allJournals[journal].id === journalId
  );
  const journal = allJournals[key];

그래서 본격적으로 코드 개선하기 전에 리덕스 공식문서를 다시 한 번 살펴보다가 createEntityAdapter 를 발견했는데, 내가 원했던 게 이거잖아! 바로 해당 부분 공식문서 공부하고 프로젝트에 적용하기로 함.

CreateEntityAdapter란?

참조: 리덕스 공식문서

createEntityAdapter 는 normalized state structure 위에서 CRUD를 구현하기 위해 미리 만들어진 리듀서와 셀렉터를 생성하는 함수이다.

createEntityAdapter 의 getInitialState 를 이용하면 아래와 같은 형태의 normalize된 스테이트 오브젝트를 생성한다.

{
  // The unique IDs of each item. Must be strings or numbers
  ids: []
  // A lookup table mapping entity IDs to the corresponding entity objects
  entities: {
  }
}

Parameters

하나의 오브젝트 parameter를 받는데, 안에 2개의 optional fields가 있다.

selectId

하나의 Entity instance를 받는 함수로, 고유 ID 필드가 들어있는 내부 값을 반환한다. 제공되지 않으면 디폴트는  entity => [entity.id](http://entity.id) 이다.

sortCoparer

두 개의 인스턴스를 받는 콜백 함수이고, Array.sort()와 같은 방식으로 동작한다. (addOne()updateMany()와 같이 CRUD function을 통해 state가 변경될 때만 sorting 된다.

CRUD Functions

아래와 같은 리듀서 함수들을 제공하는데 나는 addOne , removeOne ,upsertMany 를 사용했다.

  • addOne: (state에 이미 존재하지 않는다면) 하나의 entity 더해 줌
  • addMany
  • setOne: 하나의 entity를 받아서 더해주거나 있다면 대체함
  • setMany
  • setAll
  • removeOne: 하나의 entity ID 값을 받아서 해당 ID를 가진 entity가 존재하면 제거함
  • removeMany
  • removeAll
  • updateOne
  • updateMany
  • upsertOne
  • upsertMany:  Record<EntityId, T> 형태의 오브젝트나 entity 배열을 받아서 shallowly uspsert 함


에러 발생했던 부분

fetch는 문제없이 바로 됐다. 그리고 extraReducers에 각각 save, deleteJournal이 fulfill 된 이후 addOneremoveOne이 실행되도록 addCase를 더했는데 에러가 발생했다.

  extraReducers(builder) {
    builder
      .addCase(saveJournal.fulfilled, postsAdapter.addOne)
      .addCase(deleteJournal.fulfilled, postsAdapter.removeOne);
  },

공식문서에서 리듀서 부분 다시 보고 내 코드를 봤더니 save/delete 한 후에 return 되는 값이 없는 게 문제였다.
save(add)는 entity, 즉 post object가 전달되어야 하므로 post 리턴,
delete는 id로 찾으니까 id 리턴해서 에러를 해결했다.

(지금 보니까 변수 이름을 post, journal 섞어 써서 되게 혼란스럽다ㅠㅠ 고쳐야지)

// addOne에 post 전달
export const saveJournal = createAsyncThunk('journals/saveJournal', (post) => {
  try {
    set(ref(db, `users/${post.userId}/journals/${post.id}`), post);
    return post; // <= 기존 코드에서 추가해 준 부분
  } catch (error) {
    console.log(error);
  }
});
// removeOne에 postId 전달
export const deleteJournal = createAsyncThunk(
  `journals/deleteJournal`,
  (post) => {
    remove(ref(db, `users/${post.userId}/journals/${post.journalId}`));
    return post.journalId; // <= 기존 코드에서 추가해 준 부분
  }
);

journalSlice 최종 코드

Firebase 비동기 로직이 slice에 있는게 굉장히 찜찜하고 냄새나는 코드인 것 같아서 리팩토링 완강 우선 하고 또 수정할 거지만, 일단 entityAdapter 이용해서 state 구조는 개선했다.

...

const postsAdapter = createEntityAdapter({
  selectId: (journal) => journal.id,
  sortComparer: (a, b) => b.date.localeCompare(a.date),
});

const initialState = postsAdapter.getInitialState({
  status: 'idle',
  error: null,
});

export const fetchJournals = (userId) => {
  return (dispatch) => {
    const query = ref(db, `users/${userId}/journals`);
    onValue(query, (snapshot) => {
      const result = snapshot.val();
      dispatch(journalAdded(result));
    });
  };
};

export const saveJournal = createAsyncThunk('journals/saveJournal', (post) => {
  try {
    set(ref(db, `users/${post.userId}/journals/${post.id}`), post);
    return post;
  } catch (error) {
    console.log(error);
  }
});

export const deleteJournal = createAsyncThunk(
  `journals/deleteJournal`,
  (post) => {
    remove(ref(db, `users/${post.userId}/journals/${post.journalId}`));
    return post.journalId;
  }
);

export const journalsSlice = createSlice({
  name: 'journals',
  initialState,
  reducers: {
    journalAdded(state, action) {
      if (action.payload) {
        postsAdapter.upsertMany(state, action.payload);
      }
    },
    journalUpdated(state, action) {
      const { id, title, content, mood } = action.payload;
      const existingJournal = state.entities[id];
      if (existingJournal) {
        existingJournal.title = title;
        existingJournal.content = content;
        existingJournal.mood = mood;
      }
    },
  },
  extraReducers(builder) {
    builder
      .addCase(saveJournal.fulfilled, postsAdapter.addOne)
      .addCase(deleteJournal.fulfilled, postsAdapter.removeOne);
  },
});

export const { journalAdded, journalUpdated } = journalsSlice.actions;

export default journalsSlice.reducer;

export const { selectAll, selectById, selectIds } = postsAdapter.getSelectors(
  (state) => state.journals
);

EditForm에 적용

createEntityAdapter 에서 제공하는 selector function을 이용해서 끔찍한 코드를 한 줄로 변경했다.

//변경 전
const EditJournalForm = () => {
  ...
  const allJournals = useSelector(selectAllJournals);
  const key = Object.keys(allJournals).find(
    (journal) => allJournals[journal].id === journalId
  );
  const journal = allJournals[key];

//변경 후
const journal = useSelector((state) => selectById(state, journalId));

느낀점

  • 리덕스는 러닝커브도 있고, boilerplate 때문에 코드량이 많다는 점에서 비추하는 글들도 꽤 많이 봤는데, 개인적으로는 하나씩 배우고 적용하는 재미가 크다. (내가 좋아하는 유튜버의 말을 빌리자면 💩인지 된장인지는 내가 구분한다! 🤪)

  • 이번 에러는 발생하자마자 뭐가 문제인지 알고 금방 해결했지만, 처음부터 이런 에러는 발생시키지 않고 한 번에 착! 생각해내는 개발자가 되고 싶다 😭

  • 지금 이 앱은 정말 기본적인 CRUD만 구현했고 캘린더 뷰나 일기장 앱으로서 기능이 부족해서 더 추가하고 싶은데 저 slice 지저분한 코드도 고치고 싶다. 취준 + 인강 듣기 + 코드 개선 + 기능 추가 + 새로운 프로젝트 이 사이에서 시간 배분과 우선순위 결정을 잘 해야하는데, 균형을 잘 잡는게 생각보다 쉽지 않다. 이런 걸 잘 핸들링하는 것도 개발자의 역량일 테니까 또 배워 나가야지!

profile
FE developer | self-believer | philomath

0개의 댓글