현재 작업 중인 프로젝트에서 여러 개의 자식 컴포넌트를 갖는 복잡한 모달을 개발 중인데, 여기서 상태를 관리하는 게 꽤나 어려웠다. 이 모달은 '데이터 선택' 페이지와 '선택된 데이터 확인' 페이지의 총 2 단계로 구성된 모달이다.
크게 이러한 구조로 나뉘는 모달인데, 필터 항목이나 선택값, 선택한 데이터 목록 등 대부분의 state 가 두 페이지 모두 사용되어야 했다. 그래서 일단 최상위인 모달 컴포넌트가 이것들을 모두 local state 로 갖도록 하고 props drilling 으로 사용하고 있었다.
나는 웬만하면 대부분의 값을 local state 로 두고 어떻게든 승부를 보려는 개발 습관을 가지고 있(었)다. 상태관리 도구를 도입하는 순간 작성해야 하는 보일러플레이트의 부담도 부담이지만, 대부분의 프로젝트에서 서비스의 ‘일부분’ 을 개발하게 되는데 그저 편하자고 ‘전역’ 상태를 사용하는 것은 오히려 컴포넌트 간의 유기성을 떨어뜨리고 전역 저장소에 불필요한 값을 넣는 것 아닐까? 싶은 생각도 있었다.
한편으로는 redux 보다 덜 부담스러운 recoil 을 사용할까 싶기도 했지만, 역시나 modal 안에서만 사용되는 데이터를 global state 로 올리는 것은 부적절한 것 같았다. Context API 는 내가 익숙하지 않아서, 일단 개발 기한에 맞춰야 하기 때문에 리팩토링 때 적용하려고 했다.
그러나 개발하다 보니 필터 선택값 같은 경우에는 결국 모달 → 1페이지(데이터 선택 페이지) → 필터 → 필터 아이템
까지 깊이 drilling 되어야 하는 상황이 발생했다.
필터 옵션도 마찬가지다. 일부 항목의 경우 서버 response를 Key로 사용해야 해서, 데이터 통계 컴포넌트에서 보여주기 위해서는 이 response 를 바탕으로 필터 옵션 데이터 안에서 find 문을 돌려 label 값을 가져와 보여줘야 한다. 따라서 모달 → 2페이지(선택된 데이터 확인 페이지) → 데이터 통계 → 개별 데이터 항목
까지 drilling 된다.
이 중 필터 옵션
같은 경우 한 번 생성되면 모달을 닫기 전까지 바뀔 일이 없는 데이터기 때문에, 단지 소비만 하기 위해 props drilling 으로 이어지는 게 불필요하다는 생각이 들었다. Hook 으로 빼면 좋지 않을까?
그래서 Hook 으로 분리해 봤다. 필터 옵션을 구성하기 위해서는 API로 일부 옵션의 데이터를 가져와야 해서, 해당 API call 후 그 response 를 사용하여 전체 filter 를 만들어 리턴해주는 custom hook 을 만들었다.
문제는, 이 API 호출이 최소 40번의 요청을 포함한다는 점이다 (현재는 더 늘어나서, 약 200개의 요청을 보내고 있다). 필터 옵션 정보를 필요로 하는 모든 컴포넌트에서 해당 Hook 을 call 한다는 건, min 40 * 컴포넌트 개수 만큼의 API 호출이 발생한다는 뜻이다. 게다가 필터는 ‘닫기’ 버튼이 있어서, 접었다 폈다 할 수 있는 UI다. 즉 필터는 아예 unmount 될 수 있는 컴포넌트라서, 이 hook 을 사용했다간 매번 접었다 펼 때마다 이 API 가 호출되는 대참사가 일어날 수 있었다.
또, react-query 처럼 key 만 같다면 어디서 호출하든 같은 데이터에 접근해 업데이트할 수 있는 케이스가 아니라면 각 Hook 호출로 가져온 데이터는 전부 독립적인 데이터다. 따라서, 모달에서 불러온 필터 옵션과 메타데이터 정보에서 불러온 필터 옵션 간의 불일치가 생길지도 모른다.
API 호출 로직을 useQuery 로 감싸서 이리저리 시도해볼 수도 있었겠지만, 개발 기한도 촉박하고 무엇보다 해당 API 를 Promise.all 로 여러 번 호출하는 데다 해당 호출 안에서 다시 중첩하여Promise.all로 API를 호출해서, 겹겹이 호출한 데이터를 useQuery 로 waterfall 없이 관리할 수 있는 로직이 쉽게 생각나지 않았다. 심지어 어떤 case 에서는 infinite loop 가 발생하기도 했다.
그래서 hook 으로 분리했던 로직을 그냥 다시 모달 안에 넣어 버렸다.
그럼 우리는 hook 을 왜 사용할까? ‘로직’ 을 재사용하기 위해서다. 아주 흔한 예시로, input value 를 읽고 쓰고 삭제하는 로직 같은 경우 웹사이트의 여기저기에서 쓰일 수 있다. 검색창, 로그인창, 댓글창, 게시글창… 이 UI 들은 동일한 ‘로직’ 을 공유할 것이다.
const [input, setInput] = React.useState('')
const handleChange = e => setInput(e.target.value)
return <TextField value={input} onChange={handleChange />
여기서 만약 input 을 그대로 반영하지 않고, 비속어 감지 등의 로직이 추가된다면?
const [input, setInput] = React.useState('')
const handleChange = e => {
const val = e.target.value;
const abuse = ['욕설1', '욕설2', ...]
if (abuse.includes(val)){
alert('비속어가 포함되었습니다');
return;
} else {
setInput(val);
}
}
return <TextField value={input} onChange={handleChange} />
이 때, 검색창, 로그인창, 댓글창 등등 각 UI 들의 state 는 독립적이다. ‘값’을 공유하는 것이 아니다. 다만 state 를 선언하고, 이벤트에 따라 해당 state 를 변경하는 ‘로직’ 이 동일한 것이다. 이러한 경우에 useInput() 같은 hook 을 만들어 해결할 수 있다.
const useInput = () => {
const [input, setInput] = React.useState('')
const editInput = e => {
const val = e.target.value;
const abuse = ['욕설1', '욕설2', ...]
if (abuse.includes(val)){
alert('비속어가 포함되었습니다');
return;
} else {
setInput(val);
}
}
const deleteInput = setInput('')
return {input, editInput, deleteInput}
}
즉, 공통적으로 사용하는 값을 다루려 하는가? 라는 질문에 ‘아니오’ 라고 답할 수 있는 로직이 hook 에 적합하다.
위의 useInput 을 보면, 해당 hook 호출로 제어되는 input state 는 각 호출 컴포넌트 별로 독립적이다. 이는 곧 컴포넌트들이 ‘로직’ 을 공유하고 있을 뿐, 같은 ‘값’ 을 공유하지 않는다는 의미이다.
나는 반대로 필터 옵션 목록이라는 ‘값’이 필요한 거지, 필터 옵션들을 가져오는 ‘로직’ 이 필요한 게 아니다. 따라서 이 경우 context API 를 사용하는 것이 적절한 방식이었다.
다른 사람들이 안 알려주는 리액트에서 Context API 잘 쓰는 방법
일단 context 는 리액트를 처음 접했을 때 전역 상태를 만들기 위해 겉핥기식으로 사용해본 게 전부라, 사실 redux 의 열화판이라고 생각하고 있었다. redux 를 쓸 줄 안다면 굳이 쓸 필요 없는?
그런데 인턴으로 일하며 엔터프라이즈 규모의 어플리케이션을 다루다 보니, 모든 곳에서 접근 가능한 global state 말고, 내가 작업 중인 subset 안에서만 공유되는 state 들이 필요해졌다. 우리 회사에서는 이런 경우 context 를 사용하여 해결하고 있었다.
context == redux
가 아니었다.
prop-drilling
을 피하는 것이다.즉, context 는 그 자체로 상태관리 도구가 아니라, 단지 컨텍스트 내부의 모든 레벨에서 특정 값에 접근 가능하도록 하는 방법일 뿐이다. useReducer 나 useState 등의 상태관리 hook 과 같이 사용되어야 redux 와 유사한 효과를 낼 수 있다.
자, 그럼 이제 어떤 값들을 Context 에 넣어야 할까?
context 하면 가장 먼저 떠오르는 리렌더링 이슈. context 가 객체나 배열을 value 로 갖는 경우, value 가 업데이트될 때 해당 값을 구독하고 있는 모든 하위 컴포넌트들이 재렌더링된다. 그 값의 일부에만 관심이 있고, 업데이트된 부분은 아예 사용하고 있지 않는 컴포넌트일지라도 무조건 재렌더링된다. (단, Provider 밑의 ‘모든’ 컴포넌트가 재렌더링 되는 것은 아니고 해당 값을 구독하고 있는 컴포넌트와 그 하위 컴포넌트들만 재렌더링된다.)
이러한 특성에 따라 수정이 빈번한 데이터는 넣지 않기로 했다. 일단 필터 옵션만 넣어 보았다.
필터 옵션 데이터는 모달이 최초로 마운트될 때 API 로 받아온 정보를 포함해 set 되고, 모달이 닫힐 때까지 변경되지 않는 데이터다.
필터 옵션은 최대 15개의 속성을 가질 수 있는 객체다. 따라서 각 속성을 변경하는 모든 부분에서 spread 연산자를 사용해서 특정 속성을 빼고 넣고… 하기에는 반복적인 코드로 너무 길어질 것 같아 useReducer 를 사용하기로 했다.
useReducer 는 reducer 함수와 initialState 값을 받아 state 및 dispatch 함수를 리턴한다. 이 값을 각각의 provider 에 전달해주고, 자식 컴포넌트에서 context 값에 접근하기 쉽게끔 hook 을 만들어줬다.
export const ModalProvider: React.FC = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<ModalStateContext.Provider value={state}>
<ModalDispatchContext.Provider value={dispatch}>
{children}
</ModalDispatchContext.Provider>
</ModalStateContext.Provider>
);
};
export function useModalState() {
const state = React.useContext(ModalStateContext);
if (!state) {
throw new Error('Cannot find ModalStateContext');
}
return state;
}
export function useModalDispatch() {
const dispatch = React.useContext(ModalDispatchContext);
if (!dispatch) {
throw new Error('Cannot find ModalDispatchContext');
}
return dispatch;
}
Context + useReducer 조합에서 한 가지 개선해볼 수 있는 점이 있다면, reducer 및 action 함수를 별도 모듈로 분리하는 것이다. 위에서 말했듯, Context 는 값 자체를 생성하거나 설정하기보다, 상위 요소가 내려주는 값을 하위 요소들에게 전달하는 pipe 역할이기 때문이다. 따라서 Context + useReducer + RTK 조합으로 보다 깔끔하게 상태관리 로직을 정리할 수 있다. (여기서 또 수많은 시행착오를 만나게 된다)
이에 더해, RTK를 활용하면 spread operator를 사용하지 않아도 내장된 immer 라이브러리에 의해 불변성이 보장되므로 더 간결하게 상태 변경 코드를 작성할 수 있다.
아무튼, 이렇게 우여곡절 끝에 Context 를 도입하면서 결과적으로 props drilling 을 없앨 수 있었다. 단지 그뿐만 아니라, reducer 를 함께 사용하면서 필터 컴포넌트의 state 변경 로직도 크게 줄어들었다.
// before context
<Checkbox
onChange={
(e) => {
const prevList = filterInput.[option];
if (!prevList) {
// logic 중략
} else {
const newList = [...prevList]
if (newList.find(value)) {...}
// logic 이하 생략
}
}
}
/>
// after context
<Checkbox
onChange={() => modalDispatch(value)}
/>
이번 프로젝트에서 커다란 데이터를 다루기 전까지, 나는 최대한 local state를 활용하는 것이 올바른(?) 프로그래밍이라고 생각하고 있었다.
일부 props drilling이 발생하더라도, 부모 컴포넌트가 state를 가짐으로써 자식 컴포넌트들이 모두 동일한 데이터를 내려받을 수 있고, state가 변경되었을 때 해당 값을 사용하는 자식 컴포넌트들의 재렌더링이 보장되는 것이 중요하다고 생각했기 때문이다.
하지만 이번 프로젝트에서, 타입이 빈번하게 변경되는 값을 state로 두고 props로 전달하려니 여간 번거로운 일이 아니었다. API가 끊임없이 수정되어서 값의 형태가 계속 변경되었는데, 그럴 때마다 자식들의 prop type을 하나하나 수정하는 것은 너무 피곤한 작업이었다.
추가로 useReducer 및 RTK를 함께 사용해 줌으로써 useState만 사용했을 때보다 상태 ‘관리’에 집중할 수 있게 되었다.
물론 어플리케이션의 일부 영역에서만 사용되는 데이터를 redux 등 전역 저장소에 무조건 집어넣는 건 지양할 일이지만, 보다 효과적인 데이터 관리와 코드 가독성을 위해 Context 혹은 전역 상태를 사용하는 것 역시 꼭 필요한 작업임을 배웠다.