여러 Mutation을 순차적으로 방출할 때의 위험성 - 원자적 상태 업데이트의 중요성
문제 상황: 하나의 액션, 여러 개의 상태 변경
상태를 관리하다 보면, 한 번쯤은 하나의 사용자 액션이 여러 개의 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는 아래와 같은 '상태 불일치' 상황을 마주하게 된다.
.setMainItem
이 먼저 실행된다.State 1
방출:mainItem
은 새로운 값이지만,relatedItems
와isActionEnabled
는 아직 이전 값이다. (상태 불일치)
이어서
.setRelatedItems
가 실행된다.State 2
방출:mainItem
과releatedItems
는 새로운 값이지만,isActionEnabled
는 여전히 이전 값이다. (상태 불일치)
마지막으로
.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% 유효하고 일관적인가?"를 고민하는 습관이 견고한 소프트웨어를 만드는 데 중요하다.