5 min read조회 0

TypeScript Discriminated Union으로 상태 관리 버그 없애기

복잡한 상태를 타입으로 정확하게 표현하는 Discriminated Union 패턴과 실무 활용법을 알아봅니다. API 응답, UI 상태, 에러 처리까지.

정다운정다운

isLoading, hasError, data 플래그를 동시에 관리하다 버그를 만들어본 적 있으신가요? Discriminated Union을 사용하면 불가능한 상태를 타입 레벨에서 차단할 수 있습니다.

문제: 플래그 지옥

// ❌ 흔한 실수: 불가능한 상태 조합이 가능함
interface DataState {
  isLoading: boolean;
  error: Error | null;
  data: User[] | null;
}
 
// 이런 상태가 가능해져 버림 🚨
const buggyState: DataState = {
  isLoading: true,  // 로딩 중인데...
  error: new Error('실패'), // 에러도 있고...
  data: [{ id: 1, name: 'Kim' }], // 데이터도 있음?!
};

이 구조에서는 런타임에 if (isLoading), if (error), if (data) 조건을 매번 체크해야 하고, 조합 실수가 버그로 이어집니다.

해결: Discriminated Union

// ✅ 불가능한 상태가 타입 레벨에서 차단됨
type DataState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: User[] };
 
// 컴파일 에러! error는 status가 'error'일 때만 존재
const invalid: DataState = {
  status: 'loading',
  error: new Error('?'), // ❌ Type error
};

status라는 공통 필드(discriminant)로 각 상태를 구분합니다. TypeScript는 이를 자동으로 좁혀줍니다.

타입 좁히기(Type Narrowing) 자동 적용

function renderState(state: DataState) {
  switch (state.status) {
    case 'idle':
      return <p>시작하려면 검색하세요</p>;
    
    case 'loading':
      return <Spinner />;
    
    case 'error':
      // state.error가 자동으로 Error 타입으로 좁혀짐
      return <p>에러: {state.error.message}</p>;
    
    case 'success':
      // state.data가 자동으로 User[] 타입으로 좁혀짐
      return <UserList users={state.data} />;
    
    default:
      // exhaustive check: 모든 케이스를 처리했는지 컴파일 타임에 검증
      const _exhaustive: never = state;
      return null;
  }
}

실무 패턴 1: API 응답 타입

// API 응답의 성공/실패를 명확하게 구분
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: { code: string; message: string } };
 
async function fetchUser(id: string): Promise<ApiResponse<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return {
        success: false,
        error: { code: 'FETCH_ERROR', message: res.statusText },
      };
    }
    return { success: true, data: await res.json() };
  } catch (e) {
    return {
      success: false,
      error: { code: 'NETWORK_ERROR', message: '네트워크 오류' },
    };
  }
}
 
// 사용할 때: 깔끔한 분기 처리
const result = await fetchUser('123');
if (result.success) {
  console.log(result.data.name); // User 타입 자동 추론
} else {
  console.error(result.error.code); // 에러 타입 자동 추론
}

실무 패턴 2: React 비동기 상태 훅

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T };
 
function useAsync<T>(asyncFn: () => Promise<T>) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
 
  const execute = useCallback(async () => {
    setState({ status: 'loading' });
    try {
      const data = await asyncFn();
      setState({ status: 'success', data });
    } catch (error) {
      setState({ status: 'error', error: error as Error });
    }
  }, [asyncFn]);
 
  return { ...state, execute };
}
 
// 사용 예시
function UserProfile({ userId }: { userId: string }) {
  const userState = useAsync(() => fetchUser(userId));
 
  useEffect(() => {
    userState.execute();
  }, [userId]);
 
  // 각 상태에서 정확한 타입만 접근 가능
  if (userState.status === 'loading') return <Spinner />;
  if (userState.status === 'error') return <p>{userState.error.message}</p>;
  if (userState.status === 'success') return <div>{userState.data.name}</div>;
  return <button onClick={userState.execute}>Load</button>;
}

실무 패턴 3: 폼 상태 머신

type FormState =
  | { step: 'info'; data: { name: string; email: string } }
  | { step: 'payment'; data: { name: string; email: string; cardNumber: string } }
  | { step: 'confirm'; data: OrderData }
  | { step: 'complete'; orderId: string };
 
type FormAction =
  | { type: 'NEXT'; payload: Partial<OrderData> }
  | { type: 'BACK' }
  | { type: 'SUBMIT' }
  | { type: 'COMPLETE'; orderId: string };
 
function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'NEXT':
      if (state.step === 'info') {
        return {
          step: 'payment',
          data: { ...state.data, ...action.payload } as any,
        };
      }
      // ... 다른 단계들
      return state;
    
    case 'BACK':
      if (state.step === 'payment') {
        return { step: 'info', data: state.data };
      }
      return state;
    
    case 'COMPLETE':
      return { step: 'complete', orderId: action.orderId };
    
    default:
      return state;
  }
}

실무 패턴 4: 에러 타입 분류

type AppError =
  | { type: 'NETWORK'; message: string; retryable: true }
  | { type: 'VALIDATION'; field: string; message: string; retryable: false }
  | { type: 'AUTH'; reason: 'expired' | 'invalid'; retryable: false }
  | { type: 'SERVER'; statusCode: number; message: string; retryable: boolean };
 
function handleError(error: AppError) {
  switch (error.type) {
    case 'NETWORK':
      // 항상 재시도 가능
      return <RetryButton />;
    
    case 'VALIDATION':
      // 특정 필드에 에러 표시
      return <FieldError field={error.field} message={error.message} />;
    
    case 'AUTH':
      if (error.reason === 'expired') {
        return <SessionExpiredModal />;
      }
      return <LoginRedirect />;
    
    case 'SERVER':
      return error.retryable ? <RetryButton /> : <ContactSupport />;
  }
}

고급 팁: 타입 가드 함수

// 커스텀 타입 가드로 재사용 가능한 체크 함수 만들기
function isSuccess<T>(state: AsyncState<T>): state is { status: 'success'; data: T } {
  return state.status === 'success';
}
 
function isError<T>(state: AsyncState<T>): state is { status: 'error'; error: Error } {
  return state.status === 'error';
}
 
// 사용
const state = useAsync(fetchUsers);
if (isSuccess(state)) {
  state.data.forEach(user => console.log(user.name));
}

고급 팁: exhaustive check 유틸리티

// 모든 케이스를 처리했는지 컴파일 타임에 강제
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}
 
function getStatusMessage(state: AsyncState<User>): string {
  switch (state.status) {
    case 'idle': return '대기 중';
    case 'loading': return '로딩 중';
    case 'error': return state.error.message;
    case 'success': return `${state.data.name}님 환영합니다`;
    default: return assertNever(state);
    // 새로운 status가 추가되면 컴파일 에러 발생!
  }
}

결론

Discriminated Union은 TypeScript의 가장 강력한 기능 중 하나입니다:

  1. 불가능한 상태 차단 - 플래그 조합 실수 원천 봉쇄
  2. 자동 타입 좁히기 - switch/if 문에서 정확한 타입 추론
  3. exhaustive check - 새로운 케이스 추가 시 처리 누락 방지
  4. 자기 문서화 - 타입 정의만 봐도 가능한 상태가 명확함

isLoading && hasError && data의 플래그 지옥에서 벗어나, 타입이 상태를 정확하게 표현하도록 만들어보세요.

💬 댓글