FSM & Reducer
FSM - Finite State Machine
FSM - Finite State Machine 은, 말 뜻 그대로 해석하자면, ‘유한 상태 머신’ 입니다. 주로 하드웨어와 로우레벨 제어에서 사용되는 아키텍쳐 인데, 일련의 선형 진행, 또는 트리구조 진행에서, 엄격한 상태 전이를 관리하기 위한 아키텍쳐입니다.
사례를 들어보면, 이해를 좀 더 빠르게 하고, 이야기를 쉽게 시작할 수 있을 것 같습니다.
CGV 영화 예매 프로세스를 예로 들면 적절할 것 같습니다. 영화 예매 경험으로 생각해볼 수 있는 프로세스는 아래와 같겠죠.
영화 선택
영화관, 상영관 선택
상영 시간 선택
인원 선택
좌석 선택
결제 프로세스
전체 예매 프로세스 완료
설계자의 의도에 따라 일부 순서가 뒤바뀔 수도 있겠습니다만, 우리가 주목해야 할 점은, 영화관 선택, 시간 선택, 인원 선택, 등의 프로세스가 완결되지 않았을 때, ‘좌석 선택’ 이 허용될 수 없다.. 는 것입니다. 결제 프로세스 또한 허용되어선 안될 것입니다.
이번 사례에서는 1~7 까지, 총 7개의 ‘제한된 상태’ 가 도메인 로직의 구성요소가 됩니다. 그리고, 각각의 ‘상태’ 가 완료/완성 되기 전에는, 다음 단계의 상태를 사용자에게 노출해주지 않는다는 도메인 규칙이 필요합니다.
문제를 다시 정리해보자면,
7개의 Step State 가 존재한다.
직전 Step State 가 완료되지 않은 상황에서는, 다음 Step 을 노출하지 않는다.
라고 정리할 수 있겠습니다.
이처럼, 제한된 State 를 도메인 로직의 중심 요소로, 기둥으로, 세워두고 앱을 구성하는 아키텍쳐를 FSM 이라고 합니다.
.
.
Why FSM?
왜 굳이 FSM 이라는 걸 익혀야 하는지 설득당해봅시다.
1~7 까지 7개의 단계를, UI 로 구성해줄 때, 아키텍쳐 고민이 생깁니다. 특히 SPA 또는 RN 프로젝트에서, 이 고민은 더 깊어지죠. if 와 조건부 렌더링으로, 렌더링을 분기해주면 될 것 같지만, 실제로 시작해보면 문제가 간단하지 않다는 걸 직감하게 됩니다. n^7 만큼의 state 조합이 발생하는데, 이들 모두를 if 조건으로 분기해준다는 건, 일단 옳지 않죠. 잘못된 길로 들어서기 시작할 때 느끼던 불길함이 느껴집니다. 결국엔 State 관리가 복잡해지다가, UI 에 조건과 비조건이 스파게티처럼 꼬이기 시작합니다. 보다 체계적인 State 관리 아키텍쳐가 있는지 알아봐야 하는 순간이 오죠.
아래 코드를 보면, 왜 FSM 을 스킬 슬롯에 장착해야만 하는지를 직접 느낄 수 있을 것 같습니다.
return (
<>
<Pressable
onPress={() => { if (mapExpanded) setMapExpanded(false); }}
className='flex-1 flex flex-col items-center justify-start h-full
bg-background dark:bg-background-dark'
>
{(flow === 'idle') && (
<ScreenNotification />
)}
{(flow === 'movie_selecting') && (
<Movies />
)}
{(flow === 'theater_selecting') && (
<Theaters />
)}
{flow === 'time_selecting' && (
<Schedule
defaultSchedule={default}
mapExpanded={mapExpanded}
/>
)}
</Pressable>
{rateLimit.active && (
<OverlayWithStoreCooldown errCode={429} errMessage="서버에 너무 많은 요청을 발생시키셨습니다." />
)}
</>
);
};
간결해 보이지 않습니까?
flow 라는 finite state 의 상태값에 따라서, UI 의 렌더가 제어됩니다. 예를 들어, flow 가 ‘영화 선택’ - movie_selecting 일 때, 나머지 UI 컴포넌트는 렌더되지 않겠죠. flow 가 ‘좌석 선택’ 일 때에도 나머지 UI 컴포넌트는 렌더되지 않습니다.
FSM 의 가장 강력한 장점이라면, flow 의 엄격한 관리와 상태 최적화 이외에도, UI 의 코드가 가장 단순, 간결해진다는 점입니다. 다른 어떤 방법으로도, 이렇게까지는 간결해질 수 없을 것 같습니다.
.
.
FSM & React Reducer
그러면 이제, FSM 을, React, React Native 프로젝트에서 사용하는 방법을 정리해 보겠습니다.
.
Structuring FSM
먼저, 프로젝트에서 사용할 ‘제한된 상태’ 들을 구성해야 합니다. 이때 주의해야 할 점은, FSM 의 상태들은, ‘고정된 상태’ 가 아니라, ‘대기중인 상태’ 이어야 한다는 점입니다.
즉, 우리의 사례에서, ‘영화 선택’ 이라는 Step 에서 세워둬야 하는 상태는, ‘영화선택을 기다리는 상태’ 이어야 한다는 것이죠. 만약 이 규칙을 따르지 않고, ‘영화 선택이 완료된 상태’ 를 도메인 상태로 설정한다면, ‘다음 단계’ 를 설정해줄 때 문제가 발생합니다. 특히 선형 진행일 때, ‘완료된 상태’ 에서는 다음 단계로의 안전한 진행을 보장할 수 없습니다. 직전 상태를 참조해서 다음상태로의 전이를 허용해야할 때, ‘완료된 상태’ 에서는 직전 상태를 참조하기 어려워지기 때문입니다.
다시 우리의 사례에서는, ‘상영시간 선택’ 이 완료된 이후에만 ‘좌석 선택’ 이 가능해야 하겠죠. 즉, ‘좌석 선택’ 상태로의 전이를 위해서는, 직전 상태가 ‘상영시간 선택’ 이었는지를 참조해야만 합니다. 사전 정의된 ‘다음 상태로의 전이를 기다리고 있는 상태’ 라면, 안전하게 다음 상태로의 전이를 확보하고 신뢰할 수 있을 것입니다. FSM 은, 다음 상태로의 안전한 전이를 위한 엄격한 가이드입니다.
다시 강조하지만, 가장 주의해야 할 점은, 문서적 정의로서의 단계-Step 와 FSM 의 State 가 같아서는 안된다는 점입니다. 구체적으로는, ‘영화 선택’ 이 아니라, ‘영화 선택을 기다리는 상태’ 이어야 한다는 점입니다. 그래서, FSM 의 기본 타입은 이렇게 설정됩니다.
type BookingStateType =
| "idle"
| "movie_selecting"
| "theater_selecting"
| "time_selecting"
| "people_selecting"
| "seat_selecting"
| "payment_processing"
| "completed"
| "error";
.
State Type 이 결정되었으므로, 다음 차례로, Action Type 을 추가합니다. Action Type 은, reducer 에서, 엄격한 입력만을 처리해주기 위해 추가하는, 일종의 타입 가드입니다.
type BookingAction =
| { type: "SELECT_MOVIE"; movieId: string }
| { type: "SELECT_THEATER"; theaterId: string }
| { type: "SELECT_TIME"; timeId: string }
| { type: "SET_PEOPLE"; count: number }
| { type: "SELECT_SEATS"; seats: string[] }
| { type: "START_PAYMENT" }
| { type: "PAYMENT_SUCCESS"; paymentId: string }
| { type: "PAYMENT_FAILED" }
| { type: "RESET" };
.
Reducer
React 의 useReducer 로 사용할, reducer 펑션을 생성합니다. 이번 예제 코드에서는 useReducer 대신에 Zustand Store 가 사용되고, Zustand 가 자체 생성한 dispatch 를 리턴합니다.
아래 bookingReducer 는, 단순입력-단순출력 을 담당하는, 순수 펑션일 뿐입니다. 주의 깊게 관찰해야 할 점은, state 와 action 을 '엄격하게' 검사하고, 오직 허용된 직전상태 -> 다음상태 로의 전이요청만을 처리해주고 있다는 점입니다.
function bookingReducer(
state: BookingState,
action: BookingAction
): BookingState {
switch (action.type) {
case "SELECT_MOVIE": {
if (state === "idle") {
return "movie_selecting";
}
return state;
}
case "SELECT_THEATER": {
if (state === "movie_selecting") {
return "theater_selecting";
}
return state;
}
case "SELECT_TIME": {
if (state === "theater_selecting") {
return "time_selecting";
}
return state;
}
case "SET_PEOPLE": {
if (state === "time_selecting") {
return "people_selecting";
}
return state;
}
...
}
return state;
}
reducer 에 요청된 액션 타입과, 현재 상태를 확인한 후에, 허용된 현재 상태로부터 다음 상태로의 전이만을 처리해주고 있습니다.
reducer 펑션은, 엄격하게 직전 상태를 참조해서, 직전 상태가 허용된 상태가 아니라면 아무 일도 하지 않습니다. 도메인 로직에 선언된, 허용된 직전 상태로부터의 상태전이일 때에만 다음 상태로 전이하고 다음 상태를 리턴합니다.
.
Zustand Store
FSM 의 State 를 전역 상태관리용 전역 공간에 보관하고 업데이트 관리해주는, Zustand Store 를 추가합니다.
import { create } from "zustand"
type BookingStore = BookingState & {
dispatch: (action: BookingAction) => void
}
const initialState: BookingState = {
step: "movie_selecting"
}
export const useBookingStore = create<BookingStore>((set, get) => ({
...initialState,
dispatch: (action) => {
const current = get()
const next = bookingReducer(current, action)
set(next)
}
}))
.
그리고, 엔드 컴포넌트에서 사용되는 코드입니다.
// 상영관을 선택하는 액션에 추가되고,
// 다음 단계인 'select_movie' 로 전이 시킵니다.
function MovieSelect() {
const dispatch = useBookingStore(s => s.dispatch)
const selectMovie = (movieId: string) => {
dispatch({
type: "SELECT_MOVIE",
movieId
})
}
}
사용자의 액션에, dispatch(ACTION) 을 해줌으로써, FSM 을 업데이트 해줍니다. 전역 FSM 이 업데이트되는 즉시, 렌더링에 변화가 발생하겠죠.
중요한 건, dispatch 의 모든 액션 요청이 수용되지 않는다는 점입니다. FSM 과 reducer 의 엄격한 규칙이 가드하고 있기 때문에, ‘직전 상태’ 로부터 ‘혀용된 상태’ 로의 전이만 가능해집니다. 이 부분이, FSM 에 주목해야 할 가장 중요하고 단단한 점입니다.
.
.
마지막으로, 엔드 컴포넌트의 렌더링에서 FSM 을 참조하는 방식입니다.
const step = useBookingStore(s => s.step)
return (
<>
{step === "movie_selecting" && <MovieSelect />}
{step === "theater_selecting" && <TheaterSelect />}
{step === "time_selecting" && <TimeSelect />}
{step === "people_selecting" && <PeopleSelect />}
{step === "seat_selecting" && <SeatSelect />}
{step === "payment_processing" && <Payment />}
{step === "completed" && <Success />}
</>
)
각 step 에 따라서, 해당 컴포넌트들이 렌더링 됩니다. 충격적일 정도로 UI 의 코드가 단순화 되었습니다.
어쩌면 누군가는, 아직 이렇게 코딩하고 있을지 모릅니다.
{(
step !== "movie_selecting"
&& step !== "theater_selecting"
&& step !== "time_selecting"
&& step !== "people_selecting"
) && <SeatSelect />}
조건부 렌더링, 조건분기 처리방식의 또다른 위험은, '상태 폭발' 이라는 것입니다. 프로젝트의 규모가 커질수록 '상태' 하나 찾기가 미로 속 치즈 찾기가 되어버리는 상황이 자주 발생하죠. 그리고 몇 겹의 미로를 헤쳐서 찾아낸 치즈조각이, 하나가 아니었고 다른 치즈들도 존재한다는 걸 확인하게 되는 순간을, 이미 많은 개발자들이 경험해봤을 것입니다.
.
.
API Rate Limit & FSM
위의 사례에서 들었던 CGV 예매 프로세스에서도 백엔드 API 질의가 필요하겠지만, 이번 사례에서는 서버에 부담이 될만한 요청은 거의 없을 것 같습니다. 하지만 API 질의가 악의적인 공격이 되어버리는 경우도 있겠죠. 쉬운 사례로, 역지오코딩(좌표 to 주소) 같은 경우를 생각해볼 수 있겠습니다. 무한히 허용할 경우, 폭탄 청구서를 받게되겠죠.
API 에서, 지정된 시간동안에 허용된 요청 횟수를 초과한 API 요청에 대해서 다음과 같은 응답을 보내왔다고 가정해 보겠습니다.
{
"status": "error",
"data": {
"message": "Rate limit exceeded (10 req / 60s). Retry after 76s."
},
"errorCode": 429,
"retryAfterSec": 76
}
허용된 요청 횟수를 초과한 요청에 대해 429 코드를 포함한 Json 을 응답하고 있습니다. 이 응답을 수신한 앱에서는, “retryAfterSec” 동안, 오버레이를 띄워주고, 더이상의 API 요청을 발생시킬 수 없도록 차단합니다.
fetch → response(429) → store FSM
의 순서로 진행합니다.
if ((error as APIErrorType)?.status === 429) {
const match = (error as APIErrorType).message.match(/Retry after (\\d+)s/);
const seconds = match ? Number(match[1]) : null;
if (seconds) {
dispatch({ type: 'RATE_LIMITED', retryAfter: seconds });
return;
}
}
.
Flow Store & Countdown
그런데, RATE_LIMITED 상태에서는 sec 카운트다운이 필요하겠죠. retryAfterSec 에서 지정해준 시간 후에는 앱에서 다시 API 요청이 허용되어야 합니다. 즉, dispatch(’RATE_LIMITED’) 와 동시에 쿨다운 타임의 카운트다운도 필요합니다. 그리고 카운트다운이 0 으로 종료되는 즉시, RATE_LIMITED 상태가 해제되어야 하겠죠. 자칫하면 도메인 로직이 UI 속으로 녹아들어가서 프로젝트의 코드가 지저분해질 수 있는 상황입니다.
아래의 코드를 자세히 살펴보시죠. dispatch 가 RATE_LIMITED 로 실행되었을 때, dispatch 내부에서 자체적으로 카운트다운을 시작하고, 카운트다운이 종료되는 즉시, 다시 ‘COOLDOWN_FINISHED’ 로 디스패치 됩니다. 카운트다운을 UI 내부에서 고민할 필요 없이, 내부적으로 자동화 되는 것이죠.
interface InquiryFlowState {
flow: InquiryStateType;
cooldownEnd: number | null, // 초기값은 null
dispatch: (action: InquiryEventType) => void;
startCooldown: (sec: number) => void;
}
export const useInquiryFlowStore = create<InquiryFlowState>(
(set, get) => ({
flow: 'idle',
cooldownEnd: null,
dispatch: (action) => {
const current = get().flow;
const next = inquiryFlowReducer(current, action);
const nextState: Partial<InquiryFlowState> = { flow: next };
// rate limited 인 경우, cooldown 시작
if (action.type === 'RATE_LIMITED') {
// cooldown 시작
get().startCooldown(action.payload)
}
set(nextState);
},
startCooldown: (sec: number) => {
const end = Date.now() + (sec * 1000)
set({ cooldownEnd: end })
setTimeout(() => {
get().dispatch({ type: "COOLDOWN_FINISHED" })
}, sec * 1000)
},
})
);
굉장하죠? API 의 에러 처리와 쿨다운 처리가 공짜로 모두 해결되었습니다. RATE_LIMITED 가 트리거되는 순간, Store 내부에서 타이머가 작동하기 시작하고, 타이머가 종료되는 순간에 COOLDOWN_FINISHED 가 디스패치 되어서, 자체적으로 종결처리 됩니다.
이 솔루션이 갖는 또 하나의 장점이 있는데, 바로 timer freeze 가 방지된다는 것입니다. 앱에서 ‘종료시간’ 을 기준으로 카운트다운 되기 때문에, timer freeze 가 방지된다는 것이 어떤 의미인지는, 반대의 경우를 생각해보면 이해할 수 있습니다. 즉, ‘종료시간’ 을 기준으로 하지 않고, number 로 카운트다운 한다면, 타이머가 프리징되는 경우가 발생합니다. 앱이 백그라운트로 내려간 다음, AppState 가 다시 foreground 로 되돌아왔을 때, timer number 는 그대로 변화 없이 남아있게 되죠. 백그라운드에서는 JS 가 작동을 멈추기 때문입니다. 하지만, 위 솔루션에서는 ‘종료시간’ 을 기준으로 카운트다운 되기 때문에, foreground 로 돌아왔을 때에도 ‘종료시간’ 을 확인하기 때문에 timer freeze 는 발생하지 않습니다.
.
Overlay with Cooldown
UI 를 쿨다운 시간동안 block 해주는 오버레이를 추가합니다. 중요한 점은, 오버레이에서는 도메인 로직을 전혀 신경쓸 필요가 없다는 점입니다. cooldownEnd 카운트가 필요하긴 한데, 이는 사용자에게 남은 시간을 노출시켜주기 위해서 zustand store 로부터 가져왔을 뿐, 오버레이 자체의 조건분기에 필요로 하지는 않습니다. FSM 이 도입된 프로젝트에서만 기대할 수 있는 클린코드라고 할 수 있겠습니다.
// /components/uis/OverlayWithStoreCooldown.tsx
import { useInquiryCooldownUntil } from '@/zustand/inquiry/useInquiryFlowStore';
import React, { useEffect, useState } from 'react';
import { Modal, View } from 'react-native';
import { DesignText } from './DesignText';
type OverlayWithStoreCooldownProps = {
errCode: number;
errMessage: string;
}
const OverlayWithStoreCooldown = ({ errCode, errMessage }: OverlayWithStoreCooldownProps) => {
const cooldownEnd = useInquiryCooldownUntil();
const [countDown, setCountDown] = useState(cooldownEnd ? Math.floor((cooldownEnd - Date.now()) / 1000) : 0);
useEffect(() => {
if (!cooldownEnd) return;
const timeout = setInterval(() => {
setCountDown(Math.floor((cooldownEnd - Date.now()) / 1000) + 1);
}, 1000);
return () => clearInterval(timeout);
}, [cooldownEnd]);
// if (countDown <= 0) return null;
return (
<Modal
transparent
visible
animationType="fade"
>
<View className="flex-1 bg-black/50 justify-center items-center">
<View className="w-4/5 bg-white dark:bg-gray-300 p-6 rounded-2xl items-center">
<DesignText size="xl" className="mb-4">
Error: {errCode}
</DesignText>
<DesignText size="lg" className="mb-4">
{errMessage}
</DesignText>
<DesignText className="mb-6">
{countDown} 초 후에 다시 시도할 수 있습니다.
</DesignText>
</View>
</View>
</Modal>
);
};
export default OverlayWithStoreCooldown
이미 FSM 으로 도메인 상태가 관리되고 있으므로, dispatch(’RATE_LIMITED’) 가 실행되면, 도메인 상태가 ‘RATE_LIMITED’ 로 전이되고, UI 단에서는 ‘RATE_LIMITED’ 에 해당하는 컴포넌트인, 오버레이 컴포넌트가 렌더링 될 것입니다.
{rateLimit.active && (
<OverlayWithStoreCooldown errCode={429} errMessage="서버에 너무 많은 요청을 발생시키셨습니다." />
)}
.
.
FSM Everywhere
서두에서 예로 들었던 CGV 예매와 같은 상황은, 대표적, 거시적으로 FSM 이 유용하게 사용되는 케이스였습니다만, FSM 은 생각보다 많은 곳에서, 미시세계에서도 활용할 수 있습니다.
구글, 네이버 검색창을 예로 들어보겠습니다.
검색 입력창은, 이미 1세대 자동완성을 지나서 2세대 커스터마이즈드 제안이 서비스되고 있는 상황이죠. 검색입력창-TextInput 을 클릭-focus 하면, 내가 검색했던 기록, 나에게 커스터마이즈드 된 제안이 먼저 드롭다운 됩니다. 그리고 검색어를 한 글자씩 추가했을 때, 역시 커스터마이즈드 제안이 먼저 노출되고, 그 다음으로 차차 1세대 자동완성이 제시됩니다.
React 개발자라면,
on TextInput focus
on keyDown start
more keyDown
이렇게 세가지 상태-단계에 대해서, 각각을 전담한 컴포넌트가 마운트 되고 있다는 것을 알 수 있습니다. 그리고, 각 상태는 중첩되지 않습니다.
이를 바탕으로, FSM 상태를 구성해보겠습니다.
TextInput focus 이벤트와, TextInput value === “” 인 조건에,
dispatch(”FOCUSING_INPUT”) ==> 상태 “input_focus” 로 전이.
keyDown start 이벤트와 TextInput value.length > 0 인 조건에,
dispatch(”SEARCHING_START”) ==> 상태 “search_start” 로 전이.
more keyDown 이벤트와, TextInput value > 1 인 조건에,
dispatch(”SEARCHING_GENERAL”) ==> 상태 “search_general” 로 전이.
이를 표로 구성하면 아래와 같습니다.
State Name | 사용자/외부 액션 | Action Type |
idle | ||
TextInput focus | FOCUSING_INPUT | |
input_focus | ||
keyDown start && > 0 | SEARCHING_START | |
search_start | ||
more keyDown && > 1 | SEARCHING_GENERAL | |
search_general |
이제, Reducer 를 구성해줘야 합니다. 상태 전이는 직전 상태로부터 다음 상태로의 전이만 가능하지만, 역진행 또한 허용되어야 합니다. 즉, idle → search_general 은 허용되지 않습니다만, input_focus → idle 은 허용되어야 합니다.
그리고, input_focus 로의 전이는, 빈 TextInput 에 focus 되었을 때와, TextInput 에 선입력된 글자가 모두 지워졌을 경우에 허용됩니다.
따라서, input_focus 로의 전이는, idle, search_start 양쪽에서의 양방향 전이가 허용되어야 합니다.
// 역진행을 허용합니다.
case "FOCUSING_INPUT": {
if (state.status === "idle" || state.status === "search_start") {
return {
...state,
status: "input_focus",
}
}
return state
}
// 전/후 양방향 전이를 허용..
case "SEARCHING_START": {
if (state.status === "input_focus" || state.status === "search_general") {
return {
...state,
status: "search_start",
}
}
return state
}
이제, UI 구성이 극단적으로 단순해집니다. TextInput 바로 다음에 오는 코드, 컴포넌트를 아래처럼 구성해주고, 해당 이벤트에서 dispatch 만 해주면, 2세대 자동완성 제안이 가볍게 구현됩니다.
{(searchInputFlow === 'input_focus') && (
<PersonalSearchHistory />
)}
{(searchInputFlow === 'search_start') && (
<CustomizedSuggestion />
)}
{searchInputFlow === 'search_general' && (
<GeneralAutoCompletion/>
)}
.
.
개인이나 소규모 조직에서 검색엔진을 직접 구현해야 할 일은 없겠습니다만, 우버나 숙박 플랫폼 같은 서비스는 누구나 한번쯤은 생각해볼 수 있겠죠. 사용자가 여행 목적 지역을 검색할 때에도 FSM 과 2세대 자동완성을 제공한다면, 사용자의 긍정 반응과 피드백을 기대할 수 있게 될 것입니다.
FSM 은 이 외에도 또 다른 미시적인 구현과 마이크로 인터랙션에서도 힘을 발휘할 수 있을 것입니다.
.
.
FSM Orchestration
Added 2026년 3월 22일 오후 8:25
프로젝트 내에서 복수의 FSM 이 사용될 때, 이 FSM 들이 서로 충돌하거나 무질서하게 도메인 리소스에 접근하게되는 문제와 부닥칠 수 있습니다.
다시 사례를 들어보겠습니다.
우버나 운행 플랫폼, 대리운전 앱 등에서는, 출발위치 확인, 도착위치 확인, 전체 오더/조회 프로세스 등이 필요한데요… 여기에 더해, 중요한 한가지 FSM 이 더 필요한데, 바로 디바이스의 Location 정보에 접근 권한을 허용해주는 Location 권한인증 프로세스를 관리해줄 FSM 입니다.
권한 인증 프로세스는, Expo Location 또는 Geolocation 등의 내장 메서드를 통해서 사용자에게 권한을 허용할 것인지를 물어보는 UI 를 띄워줌으로 시작됩니다.
사용자 프롬프트 활성화
사용자의 권한 허가
권한 획득 즉시 granted 밸류와 함께 좌표 획득
여기에서 ‘권한’ 은, OS 의 상태로 상주하는 리소스입니다.
이제, 이번 우버/대리운전 사례에서 필요로하는 주요 FSM 들을 나열해보겠습니다.
Dept FSM
Dest FSM
Inquiry FSM
Location FSM
Dept 는 departure, 출발위치를 선택하고 결정하기 위한 프로세스입니다. Dest 는 destination, 도착위치를 선택, 결정하기 위한 프로세스이고, Inquiry 는 출발위치로부터 도착위치로의 운행시간, 운행요금 등을 포함한 API 조회 결과를 획득, 렌더링까지 이끌어가는 중심 플로우입니다. 그리고 Location 은, 디바이스의 위치정보에 접근하기 위한 권한을 사용자로부터 획득하고 확인하는 프로세스입니다.
그런데 만약, 여기에서 Location FSM 을 따로 분리하지 않는다고 가정해보죠. 이번 사례에서, 단말기의 위치정보에 접근하고싶어하는 FSM 은, Dept 와 Inquiry 두 곳이 될 것입니다. Dept 는 단말기의 현재위치를, Inquiry 는 조회할 구간의 출발위치를 참조해야만 하기 때문이죠.
이 상황을 FSM 으로 구성해서 시각화 해보겠습니다.
Dept FSM
State Name | 사용자/외부 액션 | Action Type |
idle | ||
Inquiry Page 진입 | DEPT_FSM_START | |
checking_permission | ||
Location.getCurrentPositionAsync() | COORDS_READY | |
coords_ready | ||
Render Map | RENDER_MAP | |
address_resolving | ||
디바이스 좌표로 서버API 에 역지오코딩 요청 & 결과 수신 | DEPT_ADDRESS_RESOLVING | |
R-Geo Response 200 | DEPT_ADDRESS_READY | |
R-Geo Response 404 | DEPT_ADDRESS_404 | |
R-Geo Response 429 | DEPT_ADDRESS_429 | |
address_resolved | ||
dept_ready 라는 최종단계가 필요하다. poi 진입경로도 허용되기 때문… | DEPT_READY | |
dept_ready | ||
Reset Button 클릭 | RESET_DEPT_FLOW | |
reset_dept_flow | ||
Reset Stores All | START_DEPT_POI | |
typing | ||
input.length > 1 && 200 | DEPT_POI_COORDS_200 | |
input.length > 1 && 404 | DEPT_POI_SEARCH_404 | |
input.length > 1 && 429 | DEPT_POI_SEARCH_429 | |
poi_results_ready | ||
POI Select Click | DEPT_POI_SELECTED | |
poi_coords_requested | ||
POI Coords Response 200 | POI_COORDS_200 | |
POI Coords Response 404 | POI_COORDS_404 | |
POI Coords Response 429 | POI_COORDS_429 | |
dept_ready | ||
permission_denied | ||
권한거부 사용자에게 모달 팝업 & | DENIAL_AND_KEEP_GOING | |
denial_and_keep_going | ||
사용자가 권한승인을 거절했을 경우, 이 상태로 고정된다. 상태전이는 다음 스텝으로의 전이를 허용한다. | DENIAL_AND_DEPT_POI | |
waiting_dept_poi | ||
ERROR CASES | ||
address_resolving | ||
R-Geo Response 404 | DEPT_ADDRESS_404 | |
address_not_found | ||
[Retry], [Random spot in Korea] 버튼 클릭 | RANDOM_SPOT_IN_KOREA | |
address_resolving | ||
poi_not_found |
좀 복잡해보이긴 합니다만, Dept 프로세스의 전체 스토리와 시나리오가, 마이크로 인터랙션 까지도, 모두 FSM 에 담겨져 있습니다. FSM 프로젝트에서는, FSM 시나리오 구성 작업이 전체 작업의 절반에 이른다고 말해도 과장이 아니죠. 하지만 일단 FSM 이 완성된 이후에는, UI 코딩과 유지보수는 지극히 단순해집니다. 시간을 두고 천천히 분석해볼만한 가치가 있습니다.
여기서 눈여겨 봐야할 부분은, checking_permission 이 전제된다는 점입니다. 단말기의 좌표로부터 디폴트 위치정보를 확보해야 하므로, checking_permission 은 불가피한 flow 입니다. 지금은 Location FSM 이 빠진 상황을 전제하고 있으므로, Dept 에서는, 단말기 위치정보에 접근할 수 있는 권한이 꼭 필요합니다. 피할 수 없는 FSM 구조입니다.
Inquiry FSM 도 이와 다르지 않습니다. idle 이후에, dept, dest 를 차례로 확보, 확인 해야 하는데, dept 는 일차적으로 Location 좌표에 의존해야 하므로, 역시 단말기의 좌표를 필요로 하는, 똑같은 단계-상태 를 사용합니다. 결국 두 개의 서로 다른 프로세스에서, 동일한 리소스인 ‘디바이스 좌표정보 접근권한’ 을 획득하려 하게되는 것이죠.
여기서 문제는, 코드가 중복된다는 정도가 아니라, ‘하나의 외부 리소스 상태’ 에 대한 주도권 충돌이 발생하게 된다는 점입니다. 두 개의 프로세스가 서로 다른 퍼미션 상태를 주장할 수 있다는 것이죠. 즉, 단말기 좌표정보 획득 권한을, 두 개의 프로세스에서 각각 획득하고 관리하게 된다는 것인데, 이대로 방치했다가는 헬이 열립니다.
헬이 열리게 되는 극단적인 사례를 들어보죠. 사용자가 앱의 위치정보 권한을 허용해준 후에, 앱을 백그라운드로 내린 후, OS 설정에서 다시 앱의 위치정보 권한을 disable 해버리는 상황을 상상할 수 있겠습니다. 그리고 앱이 다시 foreground 로 돌아왓을 때, 설계자/개발자는 이에 대비한 방어책으로, AppState 변경시에 FSM 의 상태 초기화 또는 fallback 지점으로의 롤백 같은 대책을 마련해두게 됩니다. 하지만 여기서 등골이 서늘해지는 문제가 느껴지기 시작하죠. 우리의 사례에서는 이미 두 가지 FSM 의 롤백을 필요로 하고 있습니다. 만약에 FSM 이 5개 이상으로 늘어나게 될 경우, 또 극단적인 상황이 더 늘어나게 되는 경우, 지옥문이 열리게 됩니다.
이들 상황관리를 FSM 조차 없이 if-state 만으로 해결하려 든다는 건 이제 상상하기도 어렵겠죠. 빌드 까지는 어찌어찌 가겠으나, 유지보수는 불가능할 것입니다.
.
Location FSM
이제, Location FSM 을 추가해 보겠습니다.
State Name | Event | Action Type |
idle | ||
initLocationOrchestration() | CHECKING_PERMISSION | |
checking_permission | ||
Location.getCurrentPositionAsync() | COORDS_READY | |
PERMISSION_DENIED | ||
coords_ready | ||
reverse-geocoding API or Rollback | ADDRESS_READY | |
address_ready | ||
gps 실패후 롤백 좌표로 강제 롤백시 | ROLLBACK_APPLIED | |
rollback_applied | ||
permission_denied | ||
권한거부 사용자에게 모달 팝업 & 거부상태로 계속 이용 버튼 클릭 | DENIAL_AND_KEEP_GOING | |
denial_and_keep_going |
Location FSM 이, 퍼미션, 좌표 뿐만 아니라, address 확보까지 담당하고 있습니다. 위치기반 앱에서는, 앱 구동시 반드시 필요한 데이터죠. 굳이 분리하기 보다는, 가장 루트 레이어에 가까운 곳에서 이 데이터 확보와 FSM 상태를 관리해주는 것이 좋아 보입니다.
이렇게, Location FSM 을 분리해낸, 위치기반 앱에서의 Dept FSM 은 아래와 같이 다듬어졌습니다.
.
Dept FSM - Final
intent: "none" | "device_location" | “poi_selected” | "rollback" -- 출발위치 좌표의 출처를 의미한다.
state name | event / userAction | ActionType | next state |
idle | |||
Inquiry Page 진입 / 초기화 트리거 | DEPT_FSM_START | address_resolving | |
디바이스 위치 설정 요청 | SET_DEVICE_LOCATION | address_resolving | |
검색 초기화 요청 | RESET_DEPT_FLOW | input_waiting | |
address_resolving | |||
역지오코딩 요청 완료 | DEPT_ADDRESS_RESOLVING | address_resolved | |
주소 없음 응답 | DEPT_ADDRESS_404 | address_not_found | |
지원하지 않는 지역 | UNSUPPORTED_REGION | unsupported_region | |
디바이스 위치 재요청 | SET_DEVICE_LOCATION | address_resolving | |
Inquiry 진입 재트리거 | DEPT_FSM_START | address_resolving | |
address_resolved | |||
주소 확정 | DEPT_ADDRESS_READY | dept_ready | |
강제 완료 처리 | DEPT_READY | dept_ready | |
롤백 적용 | ROLLBACK_APPLIED | address_resolved | |
address_not_found | |||
unsupported_region | |||
디바이스 위치 재요청 | SET_DEVICE_LOCATION | address_resolving | |
dept_ready | |||
디바이스 위치 재설정 | SET_DEVICE_LOCATION | address_resolving | |
검색 초기화 | RESET_DEPT_FLOW | input_waiting | |
input_waiting | |||
검색 요청 | DEPT_SEARCH_REQUEST | pending | |
POI 히스토리 닫기 | DEPT_POI_HISTORY_DISMISS | prevState | |
검색 초기화 | RESET_DEPT_FLOW | input_waiting | |
typing | |||
검색 요청 | DEPT_SEARCH_REQUEST | pending | |
검색 성공 | DEPT_SEARCH_200 | results_ready | |
검색 실패 | DEPT_SEARCH_404 | poi_not_found | |
검색 초기화 | RESET_DEPT_FLOW | input_waiting | |
pending | |||
검색 취소 | DEPT_SEARCH_CANCEL | prevState | |
검색 성공 | DEPT_SEARCH_200 | results_ready | |
검색 실패 | DEPT_SEARCH_404 | poi_not_found | |
검색 초기화 | RESET_DEPT_FLOW | input_waiting | |
API 요청 타임아웃 | DEPT_SEARCH_TIMEOUT | timeout_error | |
results_ready | |||
POI 선택 | DEPT_POI_SELECTED | poi_coords_requested | |
검색 요청 (재검색) | DEPT_SEARCH_REQUEST | pending | |
검색 성공 (갱신) | DEPT_SEARCH_200 | results_ready | |
검색 실패 | DEPT_SEARCH_404 | poi_not_found | |
검색 초기화 | RESET_DEPT_FLOW | input_waiting | |
API 요청 타임아웃 | DEPT_COORDS_TIMEOUT | timeout_error | |
poi_not_found | |||
검색 요청 | DEPT_SEARCH_REQUEST | pending | |
검색 성공 | DEPT_SEARCH_200 | results_ready | |
검색 초기화 | RESET_DEPT_FLOW | input_waiting | |
poi_coords_requested | |||
좌표 조회 성공 | POI_COORDS_200 | dept_ready | |
좌표 조회 실패 | POI_COORDS_404 | address_not_found | |
rate_limited | |||
쿨다운 종료 | COOLDOWN_FINISHED | prevState | |
* | API rate limit 발생 | RATE_LIMITED | rate_limited |
* | 롤백 적용 | ROLLBACK_APPLIED | address_resolved |
* | 롤백 승인 | ROLLBACK_APPROVED | dept_ready |
위 전이조건 외에도, AbortController 의 어보트 상황, API 통신시 타임아웃 등의 추가적인 예외 상황과 그에 따른 상태전이가 필요할 수도 있습니다. 각자의 판단에 따라, 이를 상태전이로 처리하거나 이벤트로 처리하는 등의 선택지가 있을 수 있는데, 이는 개발팀과 리더의 선택문제라 할 수 있겠습니다.
FSM 테이블은, 개발 지침과 나침반, 지도와도 같기 때문에, 수시로 참조해야 합니다. 옆 모니터에 띄워두고 작업해야 합니다.
.
.
FSM Orchestration
FSM 들이 무질서하게 리소스를 확보하고, 서로 다른 리소스 상태를 주장하게 되는 등의 혼란을 해결하기 위해서, FSM 을 또다른 중앙/병렬 모듈에서, 조화롭게 화해하도록 만들어주는 특별한 기법이 사용되고 있습니다. 이를 FSM 오케스트레이션, 또는 FSM 레이어링 이라고 하는데요… FSM 오케스트레이션 이라는 명칭만으로도 무엇을 의미하는지는 충분히 전달되는 것 같습니다. FSM 오케스트레이션은, 본질적으로는 이벤트 에미터 패턴의 동기화 알고리즘입니다.
코드를 먼저 보고 이야기를 이어가보겠습니다.
// orchestrator.ts
import { useInquiryFlowStore } from "@/zustand/inquiry/useInquiryFlowStore"
import { useLocationStore } from "@/zustand/location/useLocationStore"
import { useDeptFlowStore } from "@/zustand/poi/inquiryDeptFlowStore"
// 앱 시작 시 1번 실행
export function initLocationOrchestrator() {
const locationStore = useLocationStore
const deptStore = useDeptFlowStore
const inquiryStore = useInquiryFlowStore
// 상태 동기화 함수
const sync = (locState: string) => {
const dept = deptStore.getState()
const inquiry = inquiryStore.getState()
switch (locState) {
// foreground 복귀시, 이미 진행중인 Inquiry 플로우는 건드리지 않는다.
case "coords_ready":
if (dept.state.state !== "address_resolving" &&
dept.state.state !== "typing" &&
dept.state.state !== "results_ready" &&
dept.state.state !== "dept_ready"
) {
dept.transition({ state: "address_resolving", prevState: dept.state.state })
}
if (inquiry.state !== "api_requesting" &&
inquiry.state !== "render_result" &&
inquiry.state !== "inquiry_finished"
) {
inquiry.transition("api_requesting")
}
break;
case "address_ready":
if (dept.state.state !== "address_resolved" &&
dept.state.state !== "typing" &&
dept.state.state !== "results_ready" &&
dept.state.state !== "dept_ready"
) {
dept.transition({ state: "address_resolved", prevState: dept.state.state })
}
if (inquiry.state !== "api_requesting" &&
inquiry.state !== "render_result" &&
inquiry.state !== "inquiry_finished"
) {
inquiry.transition("api_requesting")
}
break;
// GPS 좌표 실패했지만, 백업 좌표로 계속 진행
case "rollback_applied":
if (dept.state.state !== "address_resolving" &&
dept.state.state !== "typing" &&
dept.state.state !== "results_ready" &&
dept.state.state !== "dept_ready"
) {
dept.transition({ state: "address_resolving", prevState: dept.state.state })
}
if (inquiry.state !== "api_requesting" &&
inquiry.state !== "render_result" &&
inquiry.state !== "inquiry_finished"
) {
inquiry.transition("api_requesting")
}
break;
case "permission_denied":
if (dept.state.state !== "address_not_found") {
dept.transition({ state: "address_not_found", prevState: dept.state.state })
}
if (inquiry.state !== "permission_denied") {
inquiry.transition("permission_denied")
}
break;
case "denial_and_keep_going":
if (dept.state.state !== "typing") {
dept.transition({ state: "typing", prevState: dept.state.state })
}
if (inquiry.state !== "api_requesting") {
inquiry.transition("api_requesting")
}
break;
}
}
// ✅ 1. 초기 상태 동기화 (중요)
sync(locationStore.getState().state)
// location 상태 변화 구독
const unsubscribe = locationStore.subscribe(
(state) => {
// 콜백 시점의 실시간 상태를 읽어야 중복 방어가 올바르게 동작함
const deptState = deptStore.getState()
const inquiryState = inquiryStore.getState()
switch (state.state) {
case "checking_permission":
break;
case "coords_ready":
if (deptState.state.state !== "address_resolving") {
deptStore.getState().transition({ state: "address_resolving", prevState: deptState.state.state })
}
if (inquiryState.state !== "api_requesting") {
inquiryStore.getState().transition("api_requesting")
}
break;
case "address_ready":
if (deptState.state.state !== "address_resolved") {
deptStore.getState().transition({ state: "address_resolved", prevState: deptState.state.state })
}
if (inquiryState.state !== "api_requesting") {
inquiryStore.getState().transition("api_requesting")
}
break;
case "permission_denied":
if (deptState.state.state !== "address_not_found") {
deptStore.getState().transition({ state: "address_not_found", prevState: deptState.state.state })
}
if (inquiryState.state !== "permission_denied") {
inquiryStore.getState().transition("permission_denied")
}
break;
case "denial_and_keep_going":
if (deptState.state.state !== "typing") {
deptStore.getState().transition({ state: "typing", prevState: deptState.state.state })
}
if (inquiryState.state !== "api_requesting") {
inquiryStore.getState().transition("api_requesting")
}
break;
}
}
)
return unsubscribe
}
오케스트레이터가 sync() 로, 초기 상태를, 형제 FSM 과 동기화 하고, 이후 추가되는 변화를 형제 FSM 과 동기화 해주는 에미터 subscribe() 를 추가해주는 구조입니다.
initLocationOrchestrator 는, 이벤트 에미터로, 최대한 루트레벨에 가까운 위치에서, 앱에서 단 한번만 실행해주면 됩니다. 루트 레벨에서 병렬되는 위치에 두면 되겠습니다.
initLocationOrchestrator 는, Location FSM 의 상태를 다른 두 FSM 과 연동시켜주는 역할만을 수행합니다. Location FSM 자신을 업데이트하는 코드를 포함하고 있지 않습니다. Location FSM 의 dispatch 는 앱의 중추적인 역할을 하는 프로바이더에서 관리해주면 되고, 프로바이더에서 dispatch 된 Location FSM 의 상태는 즉시 Dept, Inquiry 두 곳의 FSM 에 반영되고 상태전이를 일으키게 됩니다.
오케스트레이터 라고 네이밍을 했지만, 세부 코드는 마치 reducer 와 비슷하죠. 현재 상태와 다음 상태로의 전이 요청을 비교해서, 허용된 전이만 transition 해줍니다. 그리고 여기서 transition 은 zustand 의 내장 메서드가 아니라, zustand store 를 생성할 때 추가해줘야 하는 커스텀 메서드입니다.
// transition 은, 이렇게 간단하게, dispatch 와 같은 레벨에서 생성해주면 됩니다.
// dispatch 는 reducer 를 경유하지만, transition 은 직접 상태변이를 발생시킵니다.
type LocationStoreType = {
state: LocationStateType
transition: (next: LocationStateType) => void
dispatchLocation: (action: LocationStateAction) => void
}
export const useLocationStore = create<LocationStoreType>((set, get) => ({
state: "idle",
transition: (next) => set({ state: next }),
dispatchLocation: (action: LocationStateAction) => {
const current = get().state;
const next = locationStateReducer(current, action);
set({ state: next });
},
reset: () => set({ state: "idle" }),
}))
그리고 또 한가지 여기서 주목해야 할 문제는, 왜 dispatch 가 아닌, transition 을 사용하고 있는가… 인데요…
dispatch 는, reducer 를 경유합니다. 그리고 reducer 는, ‘사전정의된 Type’ 을 통해 상태전이를 일으키게 되죠. 이 말이 의미하는 바는, dispatch 를 사용한다면, Location FSM 관련 FSM 상태를, Dept FSM 과 Inquiry FSM 이 포함할 수 밖에 없게 된다는 것입니다.
즉, Dept 와 Inquiry 가 다시 Location 연관 상태를 관리하게 된다는 것입니다. Dept 와 Inquiry FSM 에서 Location 관련 상태를 제거하기 위해 Location FSM 을 도입한 목적과 충돌하게 되는 것이죠. Location 관련 상태는 오직, Location FSM 에서만 관리하도록 하고, Location FSM 의 상태를 Dept 와 Inquiry 에서 동기화, 참조할 수 있도록 하는 것이 FSM Orchestration 의 목적입니다.
.
그리고 또 한가지, 기억해둬야 할 코드는, zustand store.subscribe() 입니다.
zustand store.subscribe() 는, zustand store 의 상태변화를 감시하고, 이에 대한 콜백 펑션을 등록해주는, 일종의 이벤트 에미터 메서드입니다. Zustand Store Hook 이라고 이해해도 무방할 것 같습니다. Zustand Store 의 변경에 콜백 펑션이 구동되도록 등록해두는 것이죠.
zustandStore.subscribe() 는, 아래와 같은 구문으로 사용합니다.
// zustandStore1 의 상태변화에 대해, zustandStore2 의 상태변경을 연동시킵니다.
zustandStore1.subscribe(
(state) => zustandStore2.getState().transition(state, "address_resolving");
);
zustandStore1 의 상태변화를 이벤트 리스너 형식으로 감시하고 콜백을 등록하는 것이죠. 이 예제코드에서는, zustandStore1 의 상태변화에, zustandStore2 의 상태변화를 ‘연동’ 시켜줍니다.
요즘 유행하는 웹훅, SQL 훅과 비슷해 보입니다. FSMStore 의 상태변화에, 또다른 FSMStore 의 상태변화를 연동시킬 수 있게되는 것이죠. 이 덕분에, Location FSM 의 완전한 독립이 가능해지고, 또 Location FSM 의 상태변화에 Dept, Inquiry FSM 이 자신의 상태를 연동할 수 있게 됩니다.
이처럼, FSM 의 상태를 연동시키는 방식으로 두 개 이상 FSM 들의 화학적 결합을 시도하는 기법을, FSM Orchestration 이라고 합니다.
.
이벤트 에미터는, 최상위 레이아웃에서 컴포넌트로 병렬 추가해주면, 앱 생명주기에 단 한번 실행되고, 이벤트 에미터로서의 감시임무가 시작됩니다. 이후 Location 권한 상태는 변경 발생 즉시, 다른 FSM 으로 전파됩니다.
// /orchestrators/LocationOrchestrator.tsx
import { useEffect } from "react"
import { initLocationOrchestrator } from "./functions/locationOrchestrator"
export function LocationOrchestrator() {
useEffect(() => {
const unsubscribe = initLocationOrchestrator()
return () => {
unsubscribe()
}
}, [])
return null
}
.
ZustandStore.subscribe() 만 트리거해주면 되므로, 루트 레벨에서 병렬 컴포넌트로 한 번 실행될 수 있도록 위치시켜 줍니다. 이제, Location FSM 의 상태 변경이, Dept, Inquiry FSM 으로 즉시 연동될 것입니다.
.
.
별책부록 - LocationPermissionAddressProvider
Maintaining Location Permission
LocationOrchestrator 는, Location FSM 의 상태변화를 감지하고, 이를 즉시 형제 FSM 에게 전파합니다. 앱의 중추적 역할을 담당하는 프로바이더에서 Location FSM 을 관리해준다면, 이와 연계된 형제 FSM 의 상태를 자동으로 전이시킬 수 있을 것입니다. AppState 가 변경되고, 그 사이에 퍼미션도 변경되는 경우, 프로바이더에서 AppState 변화를 감시하고 이를 반영해서, 퍼미션 점검을 초기화 할 수 있습니다. 쉽지는 않게 느껴지긴 합니다만, 아래에 작동중인 프로바이더의 코드를 추가해두었습니다.
이제는, 중심 프로바이더에서 확보하고 공급해주는, 퍼미션 상태, 좌표, 주소 등을, 엔드 컴포넌트에서 가져다 쓰면 되겠습니다. 오케스트레이터로 상태가 자동 동기화 되고 있으므로, 연계된 FSM 들의 상태를 참조해서 엔드 컴포넌트를 작성할 수 있습니다.
.
LocationPermissionAddressProvider
이제 마지막으로, Location 의 초기화 프로세스를 담당해주는 프로바이더, LocationPermissionAddressProvider 입니다. 이 프로바이더는, Location 의,
Location 퍼미션 획득, 확인
Location Coords 획득
Coords 에 해당하는 주소 - 역지오코딩 api 질의
이렇게 세가지 프로세스를 담당합니다. 위치기반 앱에서는, 앱의 로딩과 함께 위의 3가지 프로세스가 완료되어야 합니다. 프로바이더 내부에서 좌표와 주소 등을 확보해야 하고, 특히 Location FSM 의 진행상태를 전이시킵니다. 전이된 Location FSM 은, 오케스트레이터에 의해 동료 FSM 들에게도 전파될 것입니다.
문제는 퍼미션 획득, 좌표 획득, 주소 획득… 이라는 세 단계의 프로세스가, 직전 프로세스의 결과값에 의존해서 다음 프로세스가 시작되어야 한다는 것입니다. 즉, 퍼미션 없이는 좌표 획득 프로세스가 진행될 수 없습니다. 좌표 없이는 주소 질의 프로세스도 시작될 수 없습니다. 그리고 이들 모두가 비동기 작업이기 때문에 엄격한 선형진행을 보장해야 하지만, 특히 ‘좌표 획득’ 이라는 중간단계에서 심각한 문제가 자주 발생합니다. 환경에 따라 GPS 좌표값 획득은 컴퓨팅 자원을 필요로 하고, 길어질 경우 수 초 이상 소요되거나 실패하기도 하는데요.. 이 때문에, 하나의 Async 프로세스에서는 엄격한 단계적 진행을 보장할 수 없습니다. 레이스 컨디션이 발생하기 쉽고, 앱이 프리징 된 듯 보여지는 부작용도 있습니다.
몇 번의 시행착오 끝에, useEffect 를 3개로 분할하고, 1번의 결과 상태를 2번 useEffect 의 디펜던시로 지정해주는 방식으로, 3개의 독립된 프로세스 진행을 보장하는 방법이 사용되었습니다. 그리고, AppState 변화에는, 1번, 2번, 3번 프로세스 모두를 초기화 해주는 0번 useEffect 를 추가해줬습니다.
// /contexts/LocationPermissionAddressProvider.tsx
import { useLocationPermission } from "@/hooks/useLocationPermission";
import { DEFAULT_LOCATION, DeviceLocationCoord, useLocationPosition } from "@/hooks/useLocationPosition";
import { useBackupLocation } from "@/hooks/useBackupLocation";
import { withAbortableTimeout } from "@/utils/withAbortableTimeout";
import { useReverseGeocoding } from "@/api/useReverseGeocoding";
import { extractCityDistrict } from "@/utils/extractCityDestrict";
import { AddressInfoType } from "@/zustand/inquiry/useInquiryDataStore";
import { useLocationStore } from "@/zustand/location/useLocationStore";
import { LocationPermissionStatus } from "@/zustand/useLocationPermissionStore";
import { createContext, useContext, useEffect, useRef, useState } from "react";
const LocationPermissionAddressContext = createContext<LocationPermissionAddressContextType>({
locationPermission: null,
locationCoords: null,
locationAddress: null,
deptCityKu: '',
checkLocationPermission: async () => null,
readCurrentLocationAsync: async () => DEFAULT_LOCATION,
});
type LocationPermissionAddressContextType = {
locationPermission: string | null;
locationAddress: AddressInfoType | null;
locationCoords: DeviceLocationCoord | null;
deptCityKu: string;
checkLocationPermission: () => Promise<"granted" | "blocked" | "denied" | null>;
readCurrentLocationAsync: () => Promise<DeviceLocationCoord | null>;
};
export const LocationPermissionAddressProvider = ({ children }: { children: React.ReactNode }) => {
// Local States for useEffect chaining async operations Effect 1 → Effect 2
const [permissionState, setPermissionState] = useState<LocationPermissionStatus | null>(null);
// GPS 취득 성공 좌표: Effect 2 → Effect 3 체인의 명시적 연결고리.
const [coordsState, setCoordsState] = useState<DeviceLocationCoord | null>(null);
// Effect 0 → Effect 1 체인 연결고리.
// chainResetCount 를 통해 Effect 0 의 상태 리셋이
// React 배치로 완전히 적용된 이후에 Effect 1 이 실행되도록 보장한다.
const [chainResetCount, setChainResetCount] = useState(0);
const [addressState, setAddressState] = useState<AddressInfoType | null>(null);
const [deptCityKu, setDeptCityKu] = useState<string>('');
// ---- Location Permission Store ----
const { checkLocationPermission } = useLocationPermission();
// ---- Location Position Store ----
const { readCurrentLocationAsync } = useLocationPosition();
// ---- Reverse Geocoding API Call Function ----
const { reverseGeocode } = useReverseGeocoding();
// ---- 마지막으로 확보한 위치정보를 백업한다. ----
const { setBackupLocationAsync, getBackupLocationAsync } = useBackupLocation();
// reverseGeocode 는 apiToken 변경 시 참조가 바뀌어 Effect 3 이 재실행되고
// readCurrentLocationAsync 가 isFetchingRef 때문에 null 을 반환하는 레이싱 컨디션이 생긴다.
// useRef 로 항상 최신 참조를 유지하되, deps 에는 포함하지 않는다.
const reverseGeocodeRef = useRef(reverseGeocode);
useEffect(() => { reverseGeocodeRef.current = reverseGeocode; });
// ---- Location FSM Store ----
const dispatchLocation = useLocationStore(state => state.dispatchLocation);
const foregroundRefreshTrigger = useLocationStore(s => s.foregroundRefreshTrigger);
// ---- Backup Location
const rollbackLocationAsync = async () => {
const backupLocation = await getBackupLocationAsync();
if (backupLocation) {
setCoordsState(backupLocation);
setAddressState({
jibun: backupLocation?.address?.jibun || DEFAULT_LOCATION_ADDRESS.jibun,
road: backupLocation?.address?.road || DEFAULT_LOCATION_ADDRESS.road,
building: backupLocation?.address?.building || DEFAULT_LOCATION_ADDRESS.building,
});
} else {
setCoordsState(DEFAULT_LOCATION);
setAddressState(DEFAULT_LOCATION_ADDRESS);
}
}
// Effect 0: 앱 포그라운드 복귀 시 Effect 1→2→3 체인 전체 재시작
// 최초 마운트(trigger=0)에서는 실행하지 않음 — Effect 1 이 자연스럽게 실행하므로.
const isMountedRef = useRef(false);
useEffect(() => {
if (!isMountedRef.current) {
isMountedRef.current = true;
return;
}
setPermissionState(null);
setCoordsState(null);
setChainResetCount(c => c + 1); // ← React 배치 적용 후 Effect 1 트리거
}, [foregroundRefreshTrigger]);
// Effect 1: 마운트 시 OS 권한 상태 조회
useEffect(() => {
const runPermissionCheck = async () => {
//
try {
const permission = await checkLocationPermission();
// Update FSM for orchestrator
dispatchLocation({ type: 'CHECKING_PERMISSION' });
if (!permission) return;
// Update local state for useEffect chaining
setPermissionState(permission as LocationPermissionStatus);
} catch (e) {
setPermissionState('denied');
dispatchLocation({ type: 'PERMISSION_DENIED' });
// 퍼미션 획득 실패시 알람
await rollbackLocationAsync();
toast.show("디바이스 좌표 확보 실패로, 마지막 유효 위치정보로 설정되었습니다.");
}
};
runPermissionCheck();
}, [checkLocationPermission, dispatchLocation, chainResetCount]);
// Effect 2: 권한 허용 시 GPS 좌표 취득 → 반환값을 coordsState 에 저장 → Effect 3 트리거
// permissionState === null: 아직 권한 확인 전 → 아무것도 하지 않음 (Effect 3 오발화 방지)
// permissionState !== 'granted': 권한 없음 → coordsState 는 null 유지 (DEFAULT_LOCATION 세팅 금지)
useEffect(() => {
if (permissionState === null) return; // 권한 확인 전, 대기
if (permissionState !== 'granted') {
dispatchLocation({ type: 'PERMISSION_DENIED' });
return;
}
let cancelled = false;
const abortController = new AbortController(); // 언마운트시, 프리징 된 await 를 abort
const runLocationRead = async () => {
try {
const currentCoord = await withAbortableTimeout(
readCurrentLocationAsync(),
LOCATION_TIMEOUT_MS,
abortController,
'readCurrentLocationAsync'
);
if (abortController.signal.aborted) return;
if (!cancelled && currentCoord) {
dispatchLocation({ type: 'COORDS_READY' });
setCoordsState(currentCoord); // 반환값 직접 저장 → Effect 3 명시적 트리거
}
} catch (e) {
// 의도적 abort(언마운트/리셋)는 FSM 건드리지 않고 조용히 종료
if (abortController.signal.aborted) return;
// 실제 실패(타임아웃, GPS 불가)만 FSM 전이 + rollback
await rollbackLocationAsync();
dispatchLocation({ type: 'ROLLBACK_APPLIED' });
toast.show("디바이스 좌표 확보 실패로, 마지막 유효 위치정보로 설정되었습니다.");
}
};
runLocationRead();
return () => {
cancelled = true;
abortController.abort();
};
}, [permissionState, readCurrentLocationAsync, dispatchLocation]);
// Effect 3: coordsState 확보 후 역지오코딩 + DataStore 업데이트
// coordsState 는 GPS 성공 시에만 null 이 아니므로, 오발화 없음.
useEffect(() => {
if (!coordsState) return;
let cancelled = false;
const abortController = new AbortController(); // 언마운트시, 프리징 된 await 를 abort
const runReverseGeocode = async () => {
try {
const result = await withAbortableTimeout(
reverseGeocodeRef.current(coordsState),
REVERSE_GEOCODING_TIMEOUT_MS,
abortController,
'reverseGeocode'
);
if (abortController.signal.aborted) return;
if (cancelled || !result?.parsed?.data) return;
// Update FSM for orchestrator
// nothing to do...
// Update local state
setAddressState({
jibun: result.parsed.data.jibun || '',
road: result.parsed.data.road || '',
building: result.parsed.data.building || null,
});
// Update local state
setDeptCityKu(extractCityDistrict(result.parsed.data.jibun ?? '') ?? '');
} catch (e) {
// 의도적 abort(언마운트/리셋)는 FSM 건드리지 않고 조용히 종료
if (abortController.signal.aborted) return;
await rollbackLocationAsync();
dispatchLocation({ type: 'ROLLBACK_APPROVED' });
toast.show("디바이스 주소 확보에 실패하였습니다. 마지막 유효 위치정보가 설정되었습니다.");
}
};
runReverseGeocode();
return () => {
cancelled = true;
abortController.abort();
};
}, [coordsState]);
return (
<LocationPermissionAddressContext.Provider
value={{
locationPermission: permissionState,
locationCoords: coordsState,
locationAddress: addressState,
deptCityKu,
checkLocationPermission,
readCurrentLocationAsync,
}}>
{children}
</LocationPermissionAddressContext.Provider>
);
};
export const useLocationPermissionAddress = (selector: (state: LocationPermissionAddressContextType) => any) => {
const context = useContext(LocationPermissionAddressContext);
if (!context) {
throw new Error('useLocationPermissionAddress must be used within a LocationPermissionAddressProvider');
}
return selector ? selector(context) : context;
};
프로바이더 치고는 코드가 길어졌습니다만, 자세히 뜯어보면 단순한 구조입니다. 여러 훅들과 FSM 들을 불러와서, 3+1 개의 useEffect 를 순차적으로 진행시켜주고 있습니다. RN 또는 웹 프로젝트에서, FSM 을 기반으로하면서 OS 레벨 퍼미션을 필요로 하는 프로바이더를 작성해야 할 때, 두고두고 참고할 수 있을만한 프로바이더 코드가 될 것이라고 자부합니다.
여기에서 AbortController 가 누락되면, 디바이스의 GPS 읽어오기가 실패하는 환경에서, 앱이 프리징 되거나 페이지 이동시 앱크래시를 발생시키게 됩니다. GPS 좌표 읽기 시도가 지정된 시간 10sec. 가 경과하면 await 는 timeout 으로 reject 됩니다. 이후 catch 의 롤백 코드가 실행되고, FSM 은 준비된 롤백으로 전이됩니다. 그리고 언마운트시에는 abortController.abort() 가 작동함으로서 await 를 reject 시켜주고 안전한 페이지 이동을 가능하게 해줍니다.
유심히 봐야 할 부분은, AppState 변화 및 GPS 타임아웃 등, 시스템 레벨 간섭요소를 포함하고 있다는 점입니다. 앱이 백그라운드로 내려갔다가 다시 포어그라운드로 올라왔을 때, 그리고, 이미 진행중이었던, 혹은 결과를 렌더하고 있는 스크린이 있었던 경우, FSM 전체가 롤백된다면, 네가티브 사용자 경험으로 이어질 수 있습니다. 앱을 잠시 내렸을 뿐인데, 작업했던 결과물이 모두 리셋되는 일은 피해야 합니다. 이런 문제를 피하기 위한 상세한 상태전이 조건을 위에서 Orchestrator 로 작성해두었죠. 이미 상당부분 진행된 작업이 존재할 때, AppState 변경시 이들 FSM 이 롤백 되는 일을 방지합니다.
그리고, Dept, Inquiry FSM 관련 상태는 이 프로바이더에서 건드리지 않을 것입니다. Dept, Inquiry FSM 의 Location 관련 상태는, 오케스트레이터에 의해 자동 동기화 되고 있으므로 이를 신뢰할 것이고, Location 권한과 데이터 확보 이후의 Dept, Inquiry FSM 프로세스는 해당 스크린 컴포넌트 레벨에서 시작되어야 합니다.
이제, 로케이션 프로바이더를 루트 레이아웃에 추가해줍니다.
return (
<>
<ServerAuthProvider>
<LocationPermissionAddressProvider>
<RootProviders />
<AppStateWatcher />
<NetInfoWatcher />
</LocationPermissionAddressProvider>
<LocationOrchestrator />
<ErrorEventWatcher />
</ServerAuthProvider>
</>
);
AppStateWatcher 는, 앱의 foreground <-> background 변화를 감시하고 트리거를 업데이트 합니다.
// /components/watchers/AppStateWatcher.tsx
import { useLocationStore } from "@/zustand/location/useLocationStore";
import { useEffect } from "react";
import { AppState } from "react-native";
const AppStateWatcher = () => {
const triggerForegroundRefresh = useLocationStore(s => s.triggerForegroundRefresh);
useEffect(() => {
const sub = AppState.addEventListener("change", status => {
if (status === "active") {
triggerForegroundRefresh();
}
});
return () => { sub.remove(); };
}, [triggerForegroundRefresh]);
return null;
}
export default AppStateWatcher
AppStateWatcher 에서 업데이트되는 triggerForegroundRefresh 는 전역 zustand store 에서 관리되고, 이는 LocationPermissionAddressProvider 에서 useEffect[0] 의 트리거로 작용합니다. 이와 같은 방법으로 NetInfo 의 변경도 적용해주면 되겠습니다.
구성이 더하고 뺄 것 없이 깔끔해 보이는군요. 프로바이더 내부에서 워쳐들이 보조하고 있습니다. 굿 잡~
.
.
React Web 에서도 상태폭발 State Explosion 의 위험은 존재합니다만, RN 에서의 상태폭발과 렌더파동의 위험이 압도적으로 큽니다. 기존 상태조합 위험에 더해, OS 레벨의 접근권한과 AppState 등이 제곱 값으로 추가되기 때문입니다. Web 에서는 브라우저가 이들 상태를 처리해주고 있기 때문에, Web 프로젝트에서는 이들 문제를 신경써야 할 필요가 없거나 매우 적죠. 하지만 네이티브 앱에서는 사정이 그렇지 않습니다. 앱에서 자체적으로 모든 예외상황과 시스템 환경에 대비한 코딩이 필요합니다. 체감상, Web 프로젝트에서 느낄 수 있는 상태폭발 위험도가 30 정도라면, RN 에서는 3,000 정도로 느껴집니다. 때문에 RN 에서의 상태관리는 훨씬 더 입체적, 구조적으로 설계되어야 한다는 필요를 느끼게 되는데요... 이런 상황에서 FSM 은, 피할 수 없는, 필수로 장착해야 할, 디자인패턴이면서 '스킬' 이라고 생각됩니다.
여기까지, React, RN 에서의 FSM 을 정리해봤습니다. 이정도면, 리얼월드에서 실전무공으로 쓰기에 충분할 것 같습니다.
.
.
.