
Intro
useState에서 useReducer로, 상태 공간을 줄이는 설계 React에서 폼 UI를 구현할 때 가장 흔한 접근은 여러 개의 useState를 조합하는 방식입니다.
const [answer, setAnswer] = useState('');
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<'typing' | 'submitting' | 'success'>('typing');
이 세 가지 값만 보더라도 폼의 상태를 표현하는 데 필요한 정보는 충분해 보입니다.
하지만 이 구조에는 한 가지 중요한 문제가 있습니다.
논리적으로 불가능한 상태가 “가능”해진다
UI 관점에서 다음 상태는 명백히 모순입니다.
- status === 'success' 인데 error !== null
- status === 'submitting' 인데 이미 error가 세팅되어 있음
- status === 'success' 인데 여전히 입력 중(answer 수정 가능)
하지만 React와 TypeScript는 이 상태들을 막아주지 않습니다.
각 상태가 서로 독립적으로 존재하기 때문입니다.
setStatus('success');
setError('Something went wrong'); // 타입상 문제 없음
이 구조에서는 “올바른 상태 조합”은 개발자의 머릿속 규칙에 암묵적으로 의존하게 됩니다.
상태가 늘어나고, 분기 로직이 복잡해질수록 이 규칙은 쉽게 깨집니다.
문제의 본질: 상태가 아니라 “상태 조합”이 관리 대상이다
이 문제의 핵심은 서로 의존성을 가지는 상태는 함께 수정되어 상태 조합의 경우의 수가 제한되어야 한다는 것입니다.
- status와 error는 서로 강하게 결합된 개념입니다.
- 특정 status에서는 특정 필드가 존재할 수 없거나, 반드시 존재해야 합니다.
이렇게 서로 의존성을 가지는 상태의 조합을 다루는 경우 논리적으로 가능한 경우의 수를 고민해야 합니다.
“이 UI가 가질 수 있는 상태 조합은 무엇일까?”
이런 상황에서 상태 조합의 경우의 수를 명확하게 표현할 수 있는 방법이 useReducer입니다.
useReducer로 상태 공간을 명시적으로 제한하기
useReducer의 핵심 가치는 단순히 상태를 한 객체로 묶는 데 있지 않습니다.
상태 전이와 상태의 형태를 하나의 모델로 고정할 수 있다는 점에 있습니다.
1. 상태를 “서로 배타적인 타입”으로 정의하기
type FormState =
| { status: 'typing'; answer: string }
| { status: 'submitting'; answer: string }
| { status: 'error'; answer: string; error: string }
| { status: 'success' };
여기서 중요한 점은 다음입니다.
- success 상태에는 error가 존재할 수 없습니다.
- error 상태에서만 error 필드가 존재합니다.
- 각 상태는 서로 배타적(discriminated union) 입니다.
이제 “말이 안 되는 상태”는 타입 수준에서 아예 표현할 수 없습니다.
2. 상태 전이를 reducer로 제한하기
type Action =
| { type: 'CHANGE_ANSWER'; answer: string }
| { type: 'SUBMIT' }
| { type: 'SUCCESS' }
| { type: 'FAIL'; error: string }
| { type: 'RESET' };
function formReducer(state: FormState, action: Action): FormState {
switch (action.type) {
case 'CHANGE_ANSWER':
if (state.status !== 'typing') return state;
return { ...state, answer: action.answer };
case 'SUBMIT':
if (state.status !== 'typing') return state;
return { status: 'submitting', answer: state.answer };
case 'SUCCESS':
return { status: 'success' };
case 'FAIL':
return {
status: 'error',
answer: state.status === 'submitting' ? state.answer : '',
error: action.error,
};
case 'RESET':
return { status: 'typing', answer: '' };
}
}
Reducer는 단순한 업데이트 함수가 아니라 다음의 기능을 하고 있습니다.
- 상태 변경을 유발하는 액션 케이스를 제한한다.
- 유효한 상태 조합을 정의한다.
이 구조가 주는 실질적인 이점
1. UI 분기 로직이 단순해진다
if (state.status === 'error') {
return <ErrorMessage message={state.error} />;
}
if (state.status === 'success') {
return <SuccessView />;
}
error가 있는지 없는지를 따로 검사할 필요가 없습니다. 조합의 경우가 제한됐기 때문에 모순적인 경우의 수가 사라졌습니다.
이로 인해 개발자의 실수로 인한 논리적 모순이 생길 가능성이 사라졌습니다.
2. 상태 설계가 곧 설계 리뷰 대상이 된다
useState 기반 구조에서는 잘못된 상태 조합을 생성하는 경우에도 코드상 직관적으로 알기 어렵습니다.
반면 reducer 기반 구조에서는 논리적 모순이 명확해집니다.
- 이 케이스에서 이 상태가 존재할 필요가 있는가?
- 이 조합에 모순은 없는가?
마무리
코드의 인지부하를 낮추기 위해서는 모순적인 케이스를 구조적으로 불가능하게 만드는 시스템적 접근이 필요합니다.
- 상태의 조합은 사용자의 use-case와 일치해야 합니다.
- 좋은 상태 설계는 논리적으로 모순적인 상태를 표현이 불가능해야 합니다.
- useReducer를 통해 상태 조합의 경우의 수를 제한하고 타입이 추론되도록 할 수 있습니다.
참고
https://ko.react.dev/learn/choosing-the-state-structure
State 구조 선택하기 – React
The library for web and native user interfaces
ko.react.dev
'react' 카테고리의 다른 글
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |
|---|---|
| useEffect, 의존성 배열 lint를 무시하면 안되는 이유 (0) | 2025.12.19 |
| [설계] 실무에서 자주 겪는 잘못된 추상화 사례 (0) | 2025.12.19 |
| [설계] 모듈 응집도와 단방향 의존성을 통한 유지보수 비용 줄이기 (0) | 2025.12.18 |
| [설계] UI로직에서 비즈니스 로직 분리하기 (0) | 2025.12.17 |