여러 Mutation을 순차적으로 방출할 때의 위험성 - 원자적 상태 업데이트의 중요성

iosSwiftReactorKitState ManagementArchitecture
avatar
2025.06.19
·
6 min read

문제 상황: 하나의 액션, 여러 개의 상태 변경

상태를 관리하다 보면, 한 번쯤은 하나의 사용자 액션이 여러 개의 State 프로퍼티를 동시에 변경해야 하는 상황에 마주치게 된다.

예를 들어, 아이템 상태 보기 화면에서 '새로고침' 버튼을 누르면 아래의 세 가지 상태를 모두 갱신해야 한다고 가정해보자.

struct State {
    var mainItem: Item?            // 1. 메인 아이템 정보
    var relatedItems: [Item] = []  // 2. 연관 아이템 목록
    var isActionEnabled: Bool = false // 3. 특정 액션 버튼의 활성화 여부
}

이 요구사항을 구현하기 위해 가장 먼저 떠올릴 수 있는 직관적인 방법은, 각 상태 변경을 별도의 Mutation으로 정의하고 순차적으로 실행하는 것이다.

// Mutation 정의
enum Mutation {
    case setMainItem(Item)
    case setRelatedItems([Item])
    case setIsActionEnabled(Bool)
}

// mutate 함수 내부
func refreshData() -> Observable<Mutation> {
    return .concat( // 순서를 보장하기 위해 concat 사용
        .just(.setMainItem(newItem)),
        .just(.setRelatedItems(newRelatedItems)),
        .just(.setIsActionEnabled(true))
    )
}

이 코드는 각 Mutation을 순서대로 실행시켜주기 때문에, 언뜻 보기에는 합리적인 해결책처럼 보인다.

잠재적 위험 분석: 상태 불일치

하지만 위 방식에는 눈에 잘 보이지 않는 심각한 위험이 숨어있다. 바로 상태 불일치가 발생하는 아주 찰나의 순간들이다. 상태 불일치가 발생하는 이유는 reduce 메서드가 각 Mutation을 처리할 때마다 새로운 State가 방출되기 때문이다.

위 코드의 실행 흐름을 따라가 보면, View는 아래와 같은 '상태 불일치' 상황을 마주하게 된다.

  1. .setMainItem이 먼저 실행된다.

    • State 1 방출: mainItem은 새로운 값이지만, relatedItemsisActionEnabled는 아직 이전 값이다. (상태 불일치)

  2. 이어서 .setRelatedItems가 실행된다.

    • State 2 방출: mainItemreleatedItems는 새로운 값이지만, isActionEnabled는 여전히 이전 값이다. (상태 불일치)

  3. 마지막으로 .setIsActionEnabled가 실행된다.

    • State 3 방출: 드디어 모든 상태가 일관성을 갖추게 된다.

만약 View가 State 1을 받는 찰나의 순간에 UI를 그린다면, 사용자에게는 메인 아이템은 바뀌었는데, 그에 맞는 연관 아이템 목록은 아직 이전 그대로인 이상한 화면이 잠시 보일 수 있다. 이것은 UI 깜빡임(Flicker)이나 데이터가 꼬여 보이는 버그의 원인이 될 수 있다.

해결 방안: 원자적(Atomic) Mutation으로 상태 통합

이 문제의 가장 이상적인 해결책은 논리적으로 함께 움직여야 하는 상태들을 하나의 원자적인 Mutation으로 통합하는 것이다.

예를 들어, '계좌 이체'가 'A계좌 출금'과 'B계좌 입금'이라는 두 개의 개별 작업이 아닌, 'A에서 B로 이체'라는 단 하나의 트랜잭션으로 묶여야 하는 것과 같은 원리다.

enum Mutation {
    // 관련된 모든 상태 변경을 하나의 케이스로 통합
    case setRefreshedData(
        mainItem: Item,
        relatedItems: [Item],
        isActionEnabled: Bool
    )
}

// mutate 내부
func refreshData() -> Observable<Mutation> {
    // 모든 계산을 마친 뒤...
    let newItem = ...
    let newRelatedItems = ...
    let isActionEnabled = ...
    
    // ...단 하나의 Mutation에 모든 데이터를 담아 전달한다.
    return .just(.setRefreshedData(
        mainItem: newItem, 
        relatedItems: newRelatedItems, 
        isActionEnabled: isActionEnabled
    ))
}

// reduce 내부
case .setRefreshedData(let mainItem, let relatedItems, let isActionEnabled):
    // 한 번의 reduce 호출로 모든 관련 상태를 동시에 업데이트한다.
    newState.mainItem = mainItem
    newState.relatedItems = relatedItems
    newState.isActionEnabled = isActionEnabled

이 방식을 통해 State는 불완전한 중간 상태를 거치지 않고, 항상 완벽하게 일관된 상태로만 변경된다.

결론 및 교훈

하나의 논리적 작업은 하나의 원자적 Mutation으로 처리해야 한다.

사용자의 액션 하나가 유발하는 상태 변화가 논리적으로 강하게 연결되어 있다면, 여러 Mutation으로 쪼개는 것은 상태 불일치를 유발할 수 있다. 이들을 하나의 Mutation으로 묶어 '트랜잭션'처럼 다루는 것이 데이터의 일관성을 보장하는 핵심이다.

항상 "내 앱의 상태가 어떤 순간에도 100% 유효하고 일관적인가?"를 고민하는 습관이 견고한 소프트웨어를 만드는 데 중요하다.







- 컬렉션 아티클