프론트엔드 시스템 디자인 - 5. 복잡한 상태관리의 한계를 넘는 프론트엔드 FSM 설계법

    인프런 강의인 미국 빅테크론트엔드 시스템 디자인 실전: 단순 구현자로 남지 않기 위한 프론트엔드 개발자를 위해 (*이 글에는 인프런 파트너스 활동의 일환으로 수수료를 제공받을 수 있는 링크가 포함되어 있습니다. 강의는 제 돈으로 직접 구매해서 수강했고, 내용 정리와 평가는 전부 제 판단입니다.) 를 참고하여 공부하며 포스트로 정리하고 있습니다. 처음부터 보시려면 프론트엔드 시스템 디자인 - 1. RADIO 접근법 부터..! 

     

     

    복잡한 UI 상태 설계는 값을 저장하는 법이 아니라, 사용자와 시스템이 어떤 상태에서 어떤 이벤트를 통해 어디로 이동할 수 있는지 설계하는 일이다.

     

    이전 글 (프론트엔드 시스템 디자인 - 4. 대규모 상태 관리와 Data Fetching 패러다임) 에서 상태의 위치를 정하는 방법을 다뤘다면, 이번에는 그 상태가 시간에 따라 어떻게 이동하는지를 다룬다. FSM은 컴퓨터공학에서 오래 쓰인 모델이라 앞 글의 의사수식들과 달리 정식으로 정의된 개념이다. 다만 이걸 React에 옮기는 부분은 정해진 답이 있는 영역은 아니다.

    1. 상태는 변수가 아니다

    프론트엔드 상태는 단순히 변수가 아니다. 상태는 현재 UI가 사용자에게 어떤 의미를 가지는지 설명하는 정보다.

    API 요청 화면을 예로 들면 이런 상태값들을 가질 수 있다.

    idle, loading, success, error, retrying

     

    기능이 들어가 있으면 더 복잡해진다. 자동완성 검색창을 보자.

    empty, typing, debouncing, fetching, showing_results, no_results, failed

     

    장바구니 추가 버튼이라면 더 많은 상태를 가질 수 있다.

    여기서 중요한 것은 상태가 시간에 따라 변한다는 점이다.

    UI(t) = f(State(t), Event(t), ServerData(t), Network(t), Permission(t))

     

    사용자가 클릭하고, 서버 응답이 오고, 네트워크가 실패하고, 사용자가 다시 시도하는 동안 UI는 계속 이동한다.

    idle -> loading -> success
                    -> error -> retrying -> loading -> success

    2. useState는 상태를 저장하는 도구

    상태는 그냥 useState에 넣는 값 아니냐고 할 수 있다. useState는 상태를 저장하는 도구다.

    상태 설계를 한 뒤에 useState를 쓰는 것이다.

     

    그래서 먼저 알아야 할 것이 세 가지다.

    1. 이 UI가 가질 수 있는 상태는 무엇인가
    2. 그 상태를 바꾸는 이벤트는 무엇인가
    3. 어떤 상태 전이는 허용되면 안 되는가

    세 번째 질문이 특히 중요하지 않은 것처럼 느껴질 수 있다. 그런데 프론트엔드 시스템 디자인을 하다 보면 화면마다 상태값들이 있고, 그 상태값들은 사용자의 조작에 따라, 서버 상태에 따라 시간이 지나면서 계속 변한다. 화면마다 입력과 출력이 있고, 그 사이에 있는 상태들이 모여 변화를 주면서 출력이 나온다.

    3. boolean으로 상태를 표현할 때 생기는 문제

    이런 조합이 가능하다고 해보자.

    loading + success + error
    

     

    이건 버그라기보다 상태 모델이 잘못된 것이다. 이런 조합이 실제로 안 일어날 수도 있지만, 확률이 적을 뿐 보여질 수는 있다.

    boolean 하나는 두 가지 상태를 가진다.

    boolean => true
            => false
    

     

    boolean이 두 개면 네 가지 조합이 나온다.

    true  true
    true  false
    false true
    false false
    
    2^2 = 4
    

     

    세 개면 8가지다.

    2^3 = 8
    2^n => boolean n개의 조합
    

     

    boolean이 5개면 가능한 조합은 32개, 8개면 256개다. 이 중 상당수는 제품 의미상 불가능한 조합이다. 하지만 코드상으로는 가능하다. 로딩 중이면서 에러이면서 성공인 화면은 사용자에게 보여줄 수 있는 화면이 아니다. 이건 버그가 아니라 상태 모델이 불가능한 상태를 허용하고 있다는 뜻이다.

    물론 boolean이 많아도 잘 관리하면 될 수도 있고, 작은 코드에서는 가능하다. 하지만 요청 시작, 성공, 실패, 재시도, 취소가 여러 함수에 흩어지면 사람이 전체 조합을 검증하기 어려워진다. 실제로는 성공했는데 에러가 남아서 isSuccess도 true이고 error도 true인 상태가 생긴다. 실패했는데 성공 데이터와 에러 값이 같이 남기도 한다.

    boolean 개수가 문제라기보다는, 서로 배타적이어야 하는 상태를 독립된 boolean으로 표현하는 것이 문제다. loading, success, error는 동시에 존재하면 안 되는데 boolean으로 만들면 동시에 존재할 수 있다.

    4. FSM을 쓸 조건

    다음 조건을 충족하면 FSM을 사용할 수 있다.

    • 상태가 3개 이상이다
    • 상태들이 서로 배타적이다
    • 비동기 요청이 있다
    • 실패, 재시도, 취소가 있다
    • 상태별로 UI가 다르다

    FSM은 Finite State Machine, 유한 상태 기계다. 시스템이 가질 수 있는 상태를 유한하게 정리하고, 이벤트가 들어왔을 때 다음 상태가 무엇인지 명확히 정하는 모델이다.

    5. FSM의 정의

    FSM은 수학적으로 이렇게 표현한다.

    FSM = (S, E, T, s0)
    
    S  = States              → 시스템이 가질 수 있는 상태들의 집합
    E  = Events              → 상태를 바꾸는 이벤트들의 집합
    T  = Transition Function → 현재 상태와 이벤트를 받아 다음 상태를 결정하는 함수
    s0 = Initial State       → 초기 상태
    

     

    NextState = Transition(CurrentState, Event)
    

    API 요청 상태를 FSM으로 모델링하기

    S  = { idle, loading, success, error, retrying }
    E  = { FETCH, API_SUCCESS, API_FAILURE, RETRY, CANCEL }
    s0 = idle
    

     

    전이 함수를 정의하면 이렇게 된다.

    Transition(idle,     FETCH)       = loading
    Transition(loading,  API_SUCCESS) = success
    Transition(loading,  API_FAILURE) = error
    Transition(error,    RETRY)       = retrying
    Transition(retrying, FETCH)       = loading
    Transition(loading,  CANCEL)      = idle
    

     

    FSM은 상태 이름을 나열하는 것이 아니다. 어떤 상태에서 어떤 이벤트가 허용되는지를 제한하는 것이다.

    예를 들어 이런 경우는 허용되면 안 된다.

    idle + API_SUCCESS => success
    

     

    요청도 하지 않았는데 성공 이벤트가 들어오는 것이다. 초기 상태에서 바로 API_SUCCESS를 받는 전이는 허용되면 안 된다.

    6. 유니온 타입만으로는 부족하다

    React에서 status를 이렇게 만드는 경우가 있다.

    status = 'loading' | 'success' | 'error'
    

     

    동시에 세 상태가 되는 문제는 이걸로 막힌다. 하지만 이상한 전이는 여전히 가능하다. setStatus로 어디서든 바꿀 수 있기 때문이다.

    그래서 전이 규칙이 필요하다.

    CurrentState + Event => NextState
    

     

    현재 상태에서 이 이벤트가 들어왔을 때만 다음 상태로 간다는 규칙이다. 이것이 FSM 모델링이다.

    복잡한 UI에서 중요한 것은 상태값이 무엇인가보다, 더 근본적으로 어떤 상태 이동이 가능한가와 어떤 상태 이동은 불가능해야 하는가다.

    7. State와 Context 구분하기

    FSM을 실무로 가져올 때 헷갈리는 지점이 State와 Context다.

    type UserProfileState = {
      status: "idle" | "loading" | "success" | "error"
      user?: User
      error?: Error
      previousUser?: User
    }
    

     

    여기서 status는 유한 상태다. idle, loading, success, error로 개수가 제한되어 있다.

    반면 user, error, previousUser는 Context 또는 Extended State다.

    user         => 실제 서버 데이터
    error        => 실제 에러 객체
    previousUser => 이전 성공 데이터
    

     

    이 값들은 무한히 다양할 수 있다.

    finite state:
      status = "loading"
    
    context / extended state:
      previousUser = { id: 1, name: "..." }
      error = Error(...)
    

     

    상태 흐름을 그리면 idle에서 loading으로 가고, loading에서 success나 error로 간다.

    왜 분리해야 하나

    status  => 흐름을 결정한다
    context => 화면 데이터를 채운다
    

     

    status는 UI가 어떤 단계인지를 결정하고, context는 그 단계에서 필요한 데이터다.

    • success 상태에서는 user가 필요하다
    • error 상태에서는 error가 필요하다
    • loading 상태에서는 previousUser가 있을 수도 있다

    분리하지 않으면 상태의 의미가 여러 데이터 값으로 흩어진다. 그래서 먼저 status를 정할 때는 이 UI가 지금 어떤 단계에 있는지를 보고, context를 볼 때는 이 단계에서 필요한 데이터가 무엇인지를 본다.

    8. React에서 FSM 쓰기: useReducer

    FSM의 전이 함수는 React의 useReducer와 거의 같은 구조다.

    FSM         : NextState = Transition(CurrentState, Event)
    useReducer  : NextState = reducer(CurrentState, Action)
    

     

    Transition이 reducer가 되고, Event가 Action이 된다.

    const [state, dispatch] = useReducer(transition, initialState);
    
    dispatch({ type: "fetch_success", user });
    dispatch({ type: "fetch_failure", error });
    

     

    이렇게 하면 상태를 직접 조작하지 않는다. 이 방식의 장점은 상태 변경이 값 조작이 아니라 이벤트 발생으로 이루어진다는 점이다.

    FSM을 구현하려면 꼭 useReducer를 써야 하는 것은 아니다. 다만 FSM은 현재 상태와 이벤트를 받아 다음 상태를 계산하고 useReducer도 똑같은 구조라서, React에서는 가장 적합한 도구다.

    다음 경우라면 FSM으로 상태를 모델링해서 React에 적용해볼 만하다.

    • 상태가 여러 단계로 이동한다
    • 이벤트 종류가 많다
    • 상태 변경 규칙이 중요하다
    • setState가 여러 곳에 흩어져 있다
    • 전이 함수를 테스트하고 싶다

    마지막 항목은 특히 실용적이다. transition 함수는 순수 함수라서 React 없이도 테스트할 수 있다. 상태와 이벤트를 넣고 다음 상태를 확인하면 끝이라, 렌더링이나 비동기를 끼지 않고 전이 규칙만 검증할 수 있다.

    9. 덧붙이는 시각

    React Query를 쓰면 FSM이 필요 없나

    React Query는 이미 isLoading, isSuccess, isError를 배타적으로 관리해준다. 단순한 조회 화면이라면 FSM을 따로 만들 이유가 거의 없다.

    FSM이 필요해지는 지점은 서버 요청 하나로 끝나지 않는 흐름이다. 결제처럼 약관 동의, 수단 선택, 인증, 승인 대기, 완료로 이어지면서 각 단계마다 되돌아갈 수 있는 경로가 다른 경우다. 이때 관리해야 하는 건 요청의 성패 뿐만 아니라 화면 자체의 단계다.

     

    상태 폭발은 FSM에서도 일어난다

    FSM이 boolean 조합 폭발을 막아주지만, 상태를 평평하게 나열하면 이번엔 상태 개수가 늘어난다. 검색창 예시에서 로그인 여부까지 얹으면 typing_logged_in, typing_logged_out 같은 이름이 생기기 시작한다.

    이걸 다루는 방법이 계층 상태와 병렬 상태인데, 강의에서는 다루지 않는다. XState 같은 라이브러리는 이 부분을 statechart라는 확장 모델로 처리한다. 직접 만든 reducer로 갈지 라이브러리를 쓸지는 흐름이 얼마나 복잡한지에 달려 있다.

     

    서버 상태와 UI 상태를 섞지 않기

    context에 서버 데이터를 넣는 구조는 편하지만, 앞 글에서 본 기준으로 보면 그 데이터의 원본은 서버다. FSM의 context에 넣는 순간 캐싱이나 stale 관리를 직접 해야 한다.

    실무에서는 서버 데이터는 React Query가 들고, FSM은 화면 단계만 관리하는 쪽이 깔끔한 경우가 많다. 이때 context에는 사용자가 고른 값이나 진행 중인 입력처럼 클라이언트가 원본인 데이터만 남는다.

     

    참고할 만한 글

     

    댓글