상태 리듀서 패턴(State reducer pattern)
개요
기본적으로 웹 프론트엔드 애플리케이션은 이벤트 기반 프로그램이다. 여기서 사용자의 행동은 높은 엔트로피를 갖기에, 상태 전이 모델의 예측 가능성은 종잡기 어렵다. 통상 아토믹하게 컴포넌트를 디자인하는 이유도 재사용성이 높은 컴포넌트를 디자인하기 위함인데, 모든 사용 맥락과 패턴의 상태 전이를 미리 예측해 상태 전이 모델을 설계하고 props로 제공하는 것은 불가능에 가깝다. 심지어 애플리케이션이 점점 두꺼워짐에 따라 공용 컴포넌트는 다양한 소비처의 정책을 수용해야 하는데, 여기서 내부 구현과 외부 정책의 결합이 탄생한다. 그럼 여기서 상태를 어떻게 전이시킬 것인가? 부모 컴포넌트는 useRef 같은 걸 통해 자식 컴포넌트의 모든 걸 알고 있어야 할까?
상태 리듀서 패턴(State Reducer Pattern)
상태 리듀서 패턴을 이용하면 컴포넌트 내부 상태 머신의 전이 지점만 안정적으로 노출하고, 이 컴포넌트를 이용하는 소비자는 안정적으로 상태를 승인하거나 거부하거나 수정할 수 있게 된다. 이 패턴의 중요한 점은 상태 자체가 아니라 상태 전이의 게이트를 여는 것이다.
사용자 이벤트
↓
컴포넌트 내부 '액션'으로 정규화
↓
기본 '리듀서'가 '다음 상태' 후보 계산
↓
'상태 리듀서'가 후보를 승인/거부/수정
↓
최종 '상태' 반영이를테면 Select 요소의 행동을 정규화해보면 다음과 같다.
type SelectAction =
| { type: "Open" }
| { type: "Close" }
| { type: "ItemHover"; index: number }
| { type: "ItemSelect"; item: Item }
| { type: "InputChange"; value: string }
| { type: "Blur" }
| { type: "EscapeKeyDown" };무한한 사용자 행동을 직접 노출하지 않고, 컴포넌트의 의미 체계 안에서 유한한 전이 이벤트로 치환한다. 따라서 이 패턴은 예측 가능성을 확보하기 위한 구조에 가까우며, 예측 대상은 전체 앱의 상태 트리가 아니라 해당 컴포넌트의 로컬 상태 머신이다.
효과
이 패턴은 컴포넌트 내부 구현과 외부 제품 정책 사이의 결합도를 낮추는 효과가 있다. 구체적으로 컴포넌트의 기본 동작을 가져야 하는데, 소비처에서 일부 상태 전이를 바꿀 수 있어야 하고, 모든 예외를 props 옵션화하지 않고자 하는 경우, 헤드리스 컴포넌트, 디자인 시스템 컴포넌트를 만드는 경우, 내부 상태를 유지하고 정책은 외부화시키는 경우에 유용하다고 볼 수 있다.
이를테면 Select 요소에서 내부의 item 선택 시 기본적으로 팝업을 닫히게 만든다고 하자.
case "ItemSelect":
return {
selectedItem: action.item,
isOpen: false,
highlightedIndex: null,
};그런데 특정 제품에선 item을 선택해도 메뉴가 닫히지 않았으면 좋겠다. 이 때, 나쁜 방식은 Select 요소 내부에 제품 정책을 계속해서 쌓는 것이다.
<Select
closeOnSelect={false}
closeOnSelectWhenMultiple={false}
keepOpenAfterItemClick
keepOpenAfterKeyboardSelect
/>이렇게 되면 Select는 점점 공용 컴포넌트라기보다 제품별 예외 정책을 모두 흡수하는 슈퍼 오브젝트가 된다.
Select 요소의 모든 속성을 외부로 노출시키는 것도 당연히 좋지 않다. 그것은 구현을 외부로 위임하는 것이다. 이렇게 되면 상위 컴포넌트가 구현을 전부 책임지고, 이 Select 요소는 정말 퍼블리싱 외의 역할에서 수행하는 롤이 거의 남아있지 않게 된다.
<Select
onClose={
isMultiple
? ...
: ...
}
onKeyDown={
...
}
/>결국 추상화가 너무 약해서 사용자가 다 직접 해야 하는 수준이 되거나, 추상화가 너무 강해서 예외 케이스마다 props가 늘어나게 된다.
여기서 상태 리듀서를 사용하면 Select는 자신의 기본 동작만 유지할 수 있다.
case "ItemSelect":
return {
selectedItem: action.item,
isOpen: false,
};제품의 정책은 외부에서 정의하고 부여한다.
<Select
stateReducer={(state, action) => {
if (action.type === "ItemSelect") {
return {
...action.changes,
isOpen: true,
};
}
return action.changes;
}}
/>이러면 Select는 ‘item을 선택하면 기본적으로 닫힌다’는 본연의 의미를 유지하게 된다. 다만 컴포넌트의 소비 측에서 ‘우리 맥락에서는 닫히면 안된다’고 전이 결과를 수정한다.
즉, 컴포넌트가 외부 컴포넌트의 구체적 요구사항을 몰라도 되게끔 만든다. Select는 구체적 사용처를 모르고, 사용처는 Select의 공개된 action/state 모델만 안다. 결합을 없애는 게 아니라 결합의 위치를 옮기는 것이다.
// Before
Select
├─ MultiSelectPage 정책을 앎
├─ AdminFilterPanel 정책을 앎
└─ CommandPalette 정책을 앎
// After
Select
└─ 기본 상태 전이만 앎
MultiSelectPage
└─ stateReducer로 자기 정책 주입
AdminFilterPanel
└─ stateReducer로 자기 정책 주입
CommandPalette
└─ stateReducer로 자기 정책 주입Vue Directive
상태 리듀서 패턴은 쉽게, 컴포넌트 로컬 상태 머신의 트랜지션 인터셉터라고 볼 수 있다. 상태 전이의 확장 슬롯을 제공하는 것이다.
그리고 이는 Vue의 디렉티브와 유사하다. Vue 디렉티브는 DOM, 또는 컴포넌트에 특정 동작을 선언적으로 부착한다.
<input v-model="value" />
<div v-show="isOpen" />
<div v-if="condition" />
<button v-on:click="handleClick" />
// 커스텀 디렉티브
<input v-focus v-tooltip />디렉티브는 기본 렌더링 구조는 유지하되, 특정 생명주기나 DOM의 동작에 외부 정책을 부착함이다. 이 점에서 상태 리듀서는 기본 컴포넌트 구조를 유지하되, 특정 상태 전이에 외부 정책을 부착하는 부분이 유사하다고 볼 수 있다.
다만 동작하는 층위는 다소 다르다. Vue 디렉티브는 대체로 DOM 행위 또는 렌더링 행위에 가까운 hook이다.
<input v-focus />이를테면 이는 ‘이 요소가 mounted되면 focus를 부여하라’에 가깝다. 즉, 요소 생명주기 → 디렉티브 훅 실행 → DOM 조작/부가 동작인 것이다. 반면 상태 리듀서는 DOM보다 한 단계 위에 있다.
사용자 이벤트
↓
컴포넌트 action
↓
상태 전이 후보
↓
stateReducer
↓
렌더링 결과 변화즉, Vue 디렉티브가 렌더링 결과물에 붙는 조작자에 가깝다면, 상태 리듀서는 렌더링 결과를 만들기 전 상태 결정 과정에 개입하는 조정자다.
컴포넌트 너머
Vue
또한 React와 Vue의 철학에서도 차이가 보인다.
Vue는 템플릿에 디렉티브를 얹어 의도를 표현하는 방식이 주다. 선언적이지만, 템플릿 레벨에 프레임워크가 제공하는 문법이 많다.
<div v-if="isOpen" />
<div v-for="item in items" />
<input v-model="inputValue" />{isOpen && <div />}
{items.map(item => <Item key={item.id} item={item} />)}
<input value={inputValue} onChange={handleChange} />React는 상대적으로 ‘JS 함수와 props로 조합해라’에 가깝다. 그래서 Vue 디렉티브가 확장 지점처럼 보이는 영역을 React에서는 props, render props, 커스텀 훅, compound component, control props, 상태 리듀서, 컨텍스트 같은 것으로 풀게 된다.
Redux
이벤트 기반 프로그램이라는 관점에서 보면, Redux와도 닮은 점이 있다. Redux는 다음과 같은 흐름을 지닌다.
event/action
→ reducer
→ next global state상태 리듀서 패턴은 컴포넌트 내부에서 다음과 같은 흐름을 갖는다.
component event/action
→ default reducer
→ changes
→ user stateReducer
→ next local state둘 다 event → reducer → state 구조를 사용하는 점은 닮았지만, 스케일과 목적은 다소 상이하다. Redux는 애플리케이션 상태를 예측 가능하게 만들기 위한 아키텍처고, 상태 리듀서는 컴포넌트의 내부 상태 전이를 확장 가능하게 만들기 위한 API 패턴이라고 볼 수 있다.
배경
당시
이 패턴이 나온 시점에는 HOC, render props, control props, compound components, prop getters 등의 고급 컴포넌트 패턴이 많이 쓰였다. Hooks가 본격적으로 일반화되기 전이었고, 재사용 로직을 표현하기 위해 고차 컴포넌트 패턴(HOC)이나 render props가 자주 쓰였다.
render props는 ‘UI를 어떻게 렌더링할지는 소비자가 정하게 하자’는 것이었다. 마크업 제어를 열어줬지만, 상태 전이 정책까지는 바꾸기에는 부족했다.
<Downshift>
{({ getInputProps, getItemProps }) => (
// 사용자가 원하는 마크업
)}
</Downshift>control props는 상태 자체를 외부에서 완전히 제어토록 했다. 제어/비제어(컴포넌트)의 사고 방식은 UI 라이브러리의 상태 소유권 설계에도 확장되어 사용되었다. 그렇지만 비용이 컸다. 외부 state를 초기화해야 하고, 이벤트 핸들러를 연결해야 하며, 내부 기본 동작을 재구현해야 했다. 여기에 상태가 여럿이 될수록 코드의 복잡도는 기하급수적으로 상승했다.
<Combobox
isOpen={isOpen}
selectedItem={selectedItem}
onStateChange={handleStateChange}
/>상태 리듀서 패턴은 여기서 중간에 있었다. 내부 상태는 컴포넌트가 갖고, 기본 상태 전이도 컴포넌트가 알고 있다. 하지만 최종 상태 전이 결정에 소비자가 개입할 수 있다.
uncontrolled component
↓
state reducer
↓
control props이렇게 되니 props 폭발을 막을 수 있게 되었다. 기존에는 예외 케이스마다 prop을 추가해야 했는데, 상태 리듀서를 사용하면 하나의 escape hatch로 처리할 수 있게 되었다. 만약 라이브러리나 디자인 시스템을 개발한다고 하면, 모든 예외 API로 만들지 않아도 된다는 장점이 되는 것이다.
<Select
stateReducer={(state, action) => {
switch (action.type) {
case "ItemClick":
return {
...action.changes,
isOpen: true,
highlightedIndex: state.highlightedIndex,
};
default:
return action.changes;
}
}}
/>또한 기본 동작을 유지하면서 일부만 바꿀 수도 있게 되었다. control props를 사용하면 소비자가 내부 상태 관리를 많이 재구현해야 한다. 심지어 하나를 제어하기 시작하면, 다른 상태와의 관계도 신경 써야 했다. 상태 리듀서는 기본 리듀서가 계산한 나머지 변경은 그대로 받고, 내가 바꾸고 싶은 부분만 덮어 이걸 줄일 수 있었다.
// Before
const [isOpen, setIsOpen] = useState(false);
<Combobox
isOpen={isOpen}
onStateChange={(changes) => {
if (changes.type === "ItemClick") {
setIsOpen(true);
}
}}
/>
// After
stateReducer={(state, action) => {
if (action.type === "ItemClick") {
return {
...action.changes,
isOpen: true,
};
}
return action.changes;
}}이벤트 의도도 명시할 수 있게 되었다. 상태 리듀서 패턴에서는 type을 같이 준다. 같은 상태 변화라도 여러 이유로 발생할 수 있는데, 소비자는 어떤 종류의 상태 변경이 일어나려는지 알아야 원하는 경우에만 개입할 수 있다.
{
type: "ItemClick",
changes: {
selectedItem: item,
isOpen: false,
}
}기본적으로 상태 리듀서 패턴은 제어의 역전(inversion of control)인데, control props도 제어의 역전이지만, 상태 전체를 넘기는 것이 아닌 상태 전이 제어만 역전시킨다는 점이 다르다.
트레이드오프
상태 리듀서 패턴의 가장 큰 위험은 ‘컴포넌트 내부 상태 구조와 action type이 외부 API가 된다’는 점이다.
가령 다음과 같다고 하자.
stateReducer={(state, action) => {
if (action.type === "ItemClick") {
return {
...action.changes,
isOpen: true,
highlightedIndex: state.highlightedIndex,
};
}
return action.changes;
}}그러면 라이브러리 개발자는 ItemClick, isOpen, highlightedIndex의 의미를 함부로 바꿀 수 없다. 원래는 내부 구현이었는데 외부 사용자가 의존하는 상태가 되었기 때문이다.
즉, API 표면적을 줄이는 것처럼 보이나 실제로는 상태 전이 모델 자체를 API를 공개하는 것이다.
또한 소비자에게 내부 모델 이해를 요구하는 형태라는 점도 있다.
<Select closeOnSelect={false} />위의 형태는 누구나 읽고 이해하기 어렵지 않다. 다음의 형태도 쉽다고 볼 수 있을까?
stateReducer={(state, action) => {
switch (action.type) {
case Select.stateChangeTypes.ItemClick:
return {
...action.changes,
isOpen: true,
};
default:
return action.changes;
}
}}결국 소비자는 state에는 어떤 필드가 있고, action.type의 종류는 어떠하며, changes는 무엇인가, state와 changes를 반환하는 것은 어떤 의미인가 등을 알아야만 한다.
타입 설계도 까다롭다. 가령 다음과 같다고 하자.
type Action =
| {
type: "InputChange";
inputValue: string;
changes: {
inputValue: string;
isOpen: boolean;
};
}
| {
type: "ItemClick";
item: Item;
changes: {
selectedItem: Item;
inputValue: string;
isOpen: boolean;
};
};잘 설계하면 좋지만, 라이브러리 입장에서는 부담이다. 너무 엄격하면 사용자가 쓰기 어렵고, 너무 느슨하면 타입의 안정성이 무너진다.
기본적으로 리듀서는 순수해야 하는데, 부수 효과를 넣기도 쉽다.
// Before
stateReducer={(state, action) => {
if (action.type === "Toggle") {
analytics.track("toggle");
}
return action.changes;
}}
// After
stateReducer={(state, action) => {
if (action.type === "Toggle" && shouldBlock(action)) {
return state;
}
return action.changes;
}}
onStateChange={(changes) => {
analytics.track("toggle", changes);
}}그러나 상태 리듀서는 ‘결정’을 해야하지 ‘부수 효과’를 부여하면 안된다. 당장 부수효과를 위한 훅, useEffect도 별도로 있고, 이벤트 핸들러를 이용할 수도 있다.
상태 리듀서는 control props와의 공존도 가능하다. 상태 리듀서가 특정 prop을 true로 반환했는데 controlled prop에서는 false라면 최종 값은 어떻게 될까? 이 경우, 진실의 원천의 관리와 구분이 취약해지게 되고, 따라서 이런 구조는 문서화가 매우 중요하게 된다.