Discriminated Union으로 안전한 상태 관리 — useReducer · 액션 타입
각 상태에 어떤 필드가 있어야 하는지 컴파일러가 정확히 알게 만드는 패턴. useReducer 상태와 액션 모두에 적용해 옵셔널 체이닝 없이 안전한 분기 처리를 합니다.
useReducer로 컴포넌트 상태를 다룰 때 가장 많이 만나는 함정은 "각 상태에 어떤 필드가 있어야 하는가"가 모호해진다는 점입니다. loading과 data가 동시에 존재할 수 있는 타입을 만들면, 한 분기에서 다른 분기에 있어야 할 필드를 실수로 만지게 됩니다. Discriminated Union(태그된 유니온)은 이 모호함을 컴파일 타임에 잡아주는 가장 단순한 패턴입니다.
1. 잘못 만들어지는 타입
흔한 실수는 모든 필드를 optional로 만드는 것입니다.
1type State = { 2 status: 'idle' | 'loading' | 'success' | 'error'; 3 data?: User[]; 4 error?: string; 5}; 6 7if (state.status === 'success') { 8 state.data.map(u => u.name); 9 // Object is possibly 'undefined'. 10}
status: 'success'로 좁혔는데도 data가 User[] | undefined라서 매번 옵셔널 체이닝이 필요합니다.
2. Discriminated Union으로 다시 쓰기
각 분기를 별도 객체 타입으로 정의하고, 공통 필드(status)를 식별자로 둡니다.
1type State = 2 | { status: 'idle' } 3 | { status: 'loading' } 4 | { status: 'success'; data: User[] } 5 | { status: 'error'; error: string }; 6 7if (state.status === 'success') { 8 state.data.map(u => u.name); // OK — data가 보장됨 9}
status만 보고도 컴파일러가 어떤 필드가 있는지 정확히 안다는 점이 핵심입니다.
3. useReducer 액션에도 동일하게
액션도 같은 패턴으로 만들면 reducer 안에서 안전합니다.
1type Action = 2 | { type: 'fetch' } 3 | { type: 'success'; data: User[] } 4 | { type: 'error'; error: string }; 5 6function reducer(state: State, action: Action): State { 7 switch (action.type) { 8 case 'fetch': return { status: 'loading' }; 9 case 'success': return { status: 'success', data: action.data }; 10 case 'error': return { status: 'error', error: action.error }; 11 } 12}
switch가 모든 분기를 처리하지 않으면 컴파일러가 알려줍니다 (exhaustiveness check).
4. exhaustive check 강제하기
새 액션 타입을 추가했을 때 모든 reducer를 갱신했는지 보장하려면 never를 활용합니다.
1function reducer(state: State, action: Action): State { 2 switch (action.type) { 3 case 'fetch': return { status: 'loading' }; 4 case 'success': return { status: 'success', data: action.data }; 5 case 'error': return { status: 'error', error: action.error }; 6 default: { 7 const _exhaustive: never = action; 8 return state; 9 } 10 } 11}
새 분기를 추가하면 _exhaustive가 컴파일 에러를 발생시켜 처리 누락을 즉시 알 수 있습니다.
5. 주의할 점
- 식별자 필드는 리터럴 유니온이어야 합니다.
string이면 좁혀지지 않습니다. - 분기 간 공통 필드가 많아도 묶어서 정의하지 말고, 각 분기 안에 명시적으로 적습니다. 가독성이 더 좋고 추론도 정확합니다.
- 클래스/객체 인스턴스를 식별하려면
instanceof또는in연산자를 함께 씁니다.
6. 정리
| 패턴 | 단점 |
|---|---|
| 모든 필드 optional | 매 분기마다 옵셔널 체이닝, 실수 가능 |
| Discriminated Union | 분기별 필드 보장, switch 만으로 안전 |
작은 useReducer 하나에도 이 패턴을 쓰면 "data가 있을 때만 X" 같은 분기가 자연스럽게 처리됩니다. 글로벌 store(Redux/Zustand)의 액션 타입에도 동일하게 적용 가능합니다.
참고: TypeScript Handbook — Discriminated Unions / "Narrowing" 섹션.