HyupUp은 애자일 방법론으로 프로젝트를 진행할 때 발생하는 에픽, 스토리, 태스크 단위의 이슈를 편하게 관리할 수 있게 해주는 협업 툴을 만드는 프로젝트입니다. 현재 에픽, 스토리, 태스크 각각에 어떤 항목들이 있는지를 리액트의 useReducer
훅으로 관리하고 있습니다. useReducer
훅은 redux와 비슷하게 flux 패턴
의 구조를 따릅니다.
flux패턴
은 액션이 발생하면 해당하는 방법으로 상태를 변화시키고 이를 view에 반영하는 단방향 데이터 흐름 구조입니다. 이 과정에서 side effect
를 없애기 위해 새로운 상태는 이전 상태와는 완전히 다른 새로운 객체가 되어야 합니다.
또한 리액트 컴포넌트의 렌더링 과정에서, 컴포넌트가 가지고 있는 상태나 props가 변하게 되면 리렌더링이 발생하게 됩니다. 이때 이전 상태값이 새로이 변경된 상태값과 동일하다면 리액트는 해당 컴포넌트의 리렌더링을 건너뜁니다. 따라서 상태가 변경될때 올바르게 리렌더링이 일어나려면 새로운 상태는 이전의 상태와는 완전히 다른 값이어야 합니다. 즉, 이전의 상태는 immutable 함이 보장되어야 합니다.
아래 사진에서 볼 수 있듯 컴포넌트 내부에서 상태를 관리하는 useState
훅은 useReducer
훅을 이용해 구현되어 있습니다. 따라서 useState를 사용하든, useReducer를 사용하든 상태의 immutability는 명백하게 지켜져야 합니다.
useState 함수는 useReducer 함수의 실행결과를 리턴한다
처음 코드를 쓸 때, 에픽을 관리하기 위한 context에 새로운 에픽을 추가할때 immutability를 고려하지 않았습니다.
function reducer(state: EpicState, action: EpicAction): EpicState {
switch (action.type) {
case 'ADD_EPIC':
if (/* 유틸함수 */ isProjectExist(state, action.payload.projectName)) {
state[action.payload.projectName] = [action.payload, ...state[action.payload.projectName]];
return { ...state };
} else {
state[action.payload.projectName] = [action.payload];
return { ...state };
}
// 생략
리듀서함수의 파라미터로 받아온 state는 이전 상태, action은 이를 어떻게 조작하는지에 관한 정보입니다. case 'ADD_EPIC'
은 action의 payload로 받아오는 객체를 이전 state 객체에 포함시키고 이를 새로운 객체로 반환함으로서 상태를 변화시키고 있습니다.
하지만 이 방법은 이전 state 객체의 immutability를 보장하지 못합니다. state 객체에 직접 접근해 값을 변경하고 있기 때문입니다.
이를 어느정도 해소하기 위해 epic 상태의 구조를 변경하기로 했습니다. 기존의 epic 상태 구조를 프로젝트이름: 에픽의 배열
형태의 객체 하나에서 epic 객체들의 배열 형태로 변경했습니다. 배열에 항목을 추가할때 [...state, newItem]
와 같은 방식을 사용한다면 기존 state을 변경하지 않고도 새로운 배열 객체를 만들어 리턴할 수 있기 때문입니다. 코드도 훨씬 단순해졌고, 이 reducer 함수가 무슨 일을 하는지 한 눈에 파악하기 쉬워졌습니다.
function reducer(state: EpicState, action: EpicAction): EpicState {
switch (action.type) {
case 'ADD_EPIC':
return [...state, action.payload];
// 생략
하지만 이 방법에도 문제는 있었습니다. [...state]
로 생성되는 배열은 깊이 1 이상의 원소들에 대해서는 불변성을 보장하지 못합니다. 자바스크립트에서 객체는 메모리 공간의 참조이기 때문입니다.
따라서 우리는 이전 상태와 새로운 상태간의 결합을 끊고, 좀 더 안정적인 immutability를 보장하기 위해 immer js를 도입했습니다.
immer js 의 사용법은 간단합니다. 모듈로부터 produce 함수를 import 하고, immutable하게 관리할 객체와 이를 어떻게 변경할지를 명시한 함수만 전달해주면 됩니다.
import produce from 'immer';
function reducer(state: EpicState, action: EpicAction): EpicState {
switch (action.type) {
case 'ADD_EPIC':
return produce(state, (draft) => {
draft.push(action.payload);
});
// 생략
state
는 immutability를 보장하고 싶은 상태 객체, (draft) => { ... }
는 state
를 변경할 함수가 됩니다. 위 reducer
함수에서 ADD_EPIC
case가 반환하는 값이 변화한 상태값이 되는데, 이것은 immer 에서 새롭게 생성한 배열 객체입니다.