useReducer의 명과 암
제3화
발행일: 2025년 05월 07일
불필요한 렌더링이라는 늪에서 허우적거리던 카토에게 또 다른 복병이 나타났다. 바로 ‘상태 로직의 복잡성’이었다.
프로젝트 ‘아틀라스’는 단순한 정보 표시를 넘어, 사용자와의 상호작용이 끊임없이 일어나는 역동적인 시스템이었다. 장바구니에 상품을 추가하고, 수량을 변경하고, 쿠폰을 적용하고, 배송지를 선택하고… 각 단계마다 상태는 변화무쌍하게 움직였다.
처음에는 useState
를 여기저기 흩뿌려놓는 것으로 시작했지만, 상태 간의 의존성이 생기고 로직이 얽히기 시작하자 금세 한계에 부딪혔다. 특정 상태를 변경하기 위해 여러 개의 setState
함수를 순차적으로 호출해야 했고, 비동기 처리까지 겹치면서 예측 불가능한 버그들이 고개를 들었다.
“이대로는 안 되겠어. 상태 변경 로직을 한곳에서 관리해야 해.”
카토는 결단을 내렸다. 그리고 자연스럽게 React가 제시하는 다음 카드로 눈을 돌렸다. 바로 useReducer
훅이었다.
useReducer
는 컴포넌트 외부에서 순수 함수(Reducer)를 통해 상태 변경 로직을 관리하는 방식이었다. 마치 은행 창구처럼, 모든 상태 변경 요청(Action)은 정해진 절차(Reducer)를 통해서만 처리되었다. 덕분에 상태 업데이트 흐름이 명확해지고, 테스트도 용이해진다는 장점이 있었다.
“그래, 이거야! 이걸로 상태 로직을 깔끔하게 분리하는 거야!”
카토는 다시 희망을 품고 코드를 리팩토링하기 시작했다. 흩어져 있던 useState
들을 useReducer
로 통합하고, 상태 변경 로직을 리듀서 함수 안으로 옮겼다. 액션 타입을 정의하고, 해당 타입에 따라 상태를 어떻게 변경할지 switch
문으로 분기했다.
확실히 이전보다는 상태 관리 코드가 체계적으로 변했다. 컴포넌트는 이제 ‘어떻게’ 상태를 바꿀지 고민할 필요 없이, 그저 ‘무엇을’ 하고 싶은지만 액션으로 던져주면 되었다(dispatch).
“음, 훨씬 보기 좋군.”
카토는 잠시 만족스러운 미소를 지었다. 복잡하게 엉켜 있던 실타래가 조금은 풀린 기분이었다.
하지만 그 만족감은 오래가지 못했다.
useReducer
를 본격적으로 사용하면서, 새로운 종류의 번거로움이 스멀스멀 피어올랐다. 바로 ‘보일러플레이트(Boilerplate)’ 코드의 습격이었다.
상태를 변경하는 로직 하나를 추가하려면, 먼저 액션 타입을 문자열 상수로 정의해야 했다. 그리고 리듀서 함수에 해당 액션 타입을 처리하는 case
블록을 추가해야 했다. 때로는 액션 객체를 생성하는 함수(Action Creator)를 따로 만들기도 했다. 이 모든 과정이 새로운 기능을 추가할 때마다 기계적으로 반복되었다.
// actions/cartActions.js
export const ADD_TO_CART = 'cart/ADD_TO_CART';
export const REMOVE_FROM_CART = 'cart/REMOVE_FROM_CART';
export const UPDATE_QUANTITY = 'cart/UPDATE_QUANTITY';
export const addToCart = (item) => ({ type: ADD_TO_CART, payload: item });
// ...
// reducers/cartReducer.js
const cartReducer = (state, action) => {
switch (action.type) {
case ADD_TO_CART:
// 장바구니 추가 로직...
return newState;
case REMOVE_FROM_CART:
// 장바구니 삭제 로직...
return newState;
case UPDATE_QUANTITY:
// 수량 변경 로직...
return newState;
default:
return state;
}
};
// components/ProductDetail.js
import { cartReducer, initialState } from '../reducers/cartReducer';
import { addToCart } from '../actions/cartActions';
// ...
const [state, dispatch] = useReducer(cartReducer, initialState);
const handleAddToCart = () => {
dispatch(addToCart(product));
};
“하아…”
카토는 화면을 가득 메운 액션 타입 정의 파일과 리듀서 코드를 보며 한숨을 내쉬었다. 분명 로직은 명확해졌지만, 그 대가로 코드량이 눈에 띄게 불어났다. 단순한 상태 변경 하나를 위해 여러 파일을 넘나들며 코드를 추가하고 수정해야 했다. 마치 복잡한 서류 절차를 거쳐야만 간단한 민원을 처리할 수 있는 관공서 같았다.
더 큰 문제는, 이 useReducer
가 여전히 Context API와 함께 사용되고 있다는 점이었다. 상태 로직은 리듀서로 분리했지만, 그 상태 자체는 여전히 Context Provider를 통해 하위 컴포넌트로 전달되었다.
결국, 리듀서가 상태의 아주 작은 일부만 변경해도, 해당 Context를 구독하는 모든 컴포넌트는 여전히 불필요한 리렌더링의 늪에서 벗어날 수 없었다. useReducer
는 상태 로직 관리의 ‘명확성’이라는 빛을 가져왔지만, 동시에 ‘보일러플레이트’와 ‘Context와의 불편한 동거’라는 그림자까지 함께 드리운 셈이었다.
“간결함… React의 핵심 철학은 어디로 간 거지?”
카토는 다시 원점으로 돌아온 기분이었다. Context의 성능 문제에 이어, useReducer
의 번거로움까지. 문제를 해결하기 위해 도입한 기술들이 또 다른 문제를 낳고, 코드는 점점 더 비대해지고 있었다.
그는 키보드에서 손을 떼고 의자 깊숙이 몸을 묻었다. 모니터 불빛만이 어두운 사무실을 외롭게 비추고 있었다.
‘뭔가 근본적으로 잘못됐어. 이 길은 아니야.’
Context와 useReducer
. React가 제시한 공식적인 길. 하지만 그 길은 카토가 추구하는 효율성과 간결함과는 점점 더 멀어지고 있었다. 그의 마음속에서는 기존 방식에 대한 의문이 더욱 커져만 갔다. 그리고 그 의문은 새로운 길을 찾아야 한다는 절박함으로 번져나가기 시작했다.