[Kotlin] StateFlow가 원자성을 보장하는 방법

Inhyeop Lee·2026년 3월 15일

코틀린

목록 보기
4/4
post-thumbnail

안드로이드에서 상태를 관리하는 일은 매우 중요합니다.

특히 화면에 표시되는 UI는 상태에 의해 결정되기 때문에,
상태를 어떻게 관리하냐에 따라 코드의 안정성과 유지보수성이 크게 달라집니다.

현대의 안드로이드 개발에서는 코루틴 기반의 Flow를 활용한 상태 관리가 널리 사용되고 있으며,
그 중에서도 StateFlow가 가장 많이 활용되고 있다고 할 수 있습니다.

StateFlow는 현재 상태를 보존하고 변경 사항을 구독자에게 알리는 Hot Flow로
ViewModel에서 UI 상태를 관리할 때 가장 많이 사용됩니다.

abstract class BaseViewModel<UiState, UiEvent>(
    initialState: UiState,
) : ViewModel() {
    private val _uiState = MutableStateFlow(initialState)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}

보통은 위와 같은 형태로 상태를 관리하게 되는데,
ViewModel 내부에는 수정이 가능한 MutableStateFlow를 사용하기 때문에
상태가 변경될 때 아래와 같이 새로운 상태를 할당하여 UI를 갱신할 수 있습니다.

_uiState.value = _uiState.value.copy(..) // 새로운 상태

하지만 여러 코루틴이 접근할 수 있는 환경에서는 예상하지 못한 동시성 문제가 발생할 수 있습니다.
특히 이전 상태를 기반으로 새로운 상태를 계산하는 코드에서는 Race Condition이 발생할 가능성이 높습니다.

이러한 문제를 해결하기 위해 StateFlow는 상태를 안전하게 변경할 수 있는 메커니즘을 제공하며,
이를 통해 원자성을 보장합니다.

Race Condition

다음과 같은 코드를 살펴보겠습니다.

val counter = MutableStateFlow(0)

fun increment() {
	counter.value = counter.value + 1
}

이 코드는 현재 값을 읽은 뒤 1을 더하고 저장하는 로직입니다.
싱글 스레드 환경에서는 전혀 문제가 없어 보입니다.

하지만 여러 코루틴에서 동시에 increment()를 호출한다면 상황이 달라질 수 있습니다.
예를 들어 다음과 같은 순서로 실행된다고 가정해보겠습니다.

Coroutine A: counter.value = 5 읽음
Coroutine B: counter.value = 5 읽음

Coroutine A: 5 + 1 계산 -> 6 저장
Coroutine B: 5 + 1 계산 -> 6 저장

이 경우 최종 결과는 다음과 같습니다.

기댓값: 7
실제값: 6

두 코루틴이 같은 값을 읽고 각각 계산을 수행한 뒤 덮어쓰게 되면서 한 번의 증가 연산이 사라집니다.
이러한 상황을 Race Condition이라고 합니다.

이 문제가 발생하는 이유는 다음 연산이 하나의 원자적 연산이 아니기 때문입니다.

1. 현재 값 읽기
2. 새로운 값 계산
3. 저장

이 세 단계 사이에 다른 코루틴이 개입할 수 있기 때문에,
결과적으로 상태가 의도와 다르게 변경될 수 있습니다.

이러한 문제를 방지하려면 상태 갱신 자체가 하나의 원자적 연산으로 수행되도록 보장해야 합니다.

Race Condition에 대해 더 자세하게 알고 싶다면 해당 영상을 시청하는 것을 추천드립니다.

update

앞서 살펴본 것처럼 MutableStateFlow를 직접 수정하는 방식은
이전 값을 기반으로 상태를 변경할 때 Race Condition이 발생할 가능성이 있습니다.

이를 해결하기 위해 MutableStateFlow는 상태를 안전하게 변경할 수 있는 update 함수를 제공합니다.

public inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
    while (true) {
        val prevValue = value
        val nextValue = function(prevValue)
        if (compareAndSet(prevValue, nextValue)) {
            return
        }
    }
}

위에서 예시로 들었던 로직을 update로 작성하면 다음과 같습니다.

fun increment() {
	counter.update { currentValue -> currentValue + 1 } 
}

update는 매개 변수로 람다를 전달받아 현재 상태와 새로운 상태를 비교합니다.

따라서 위와 같은 연산을 원자적으로 수행하도록 보장합니다.
즉 여러 코루틴이 동시에 update를 호출하더라도 이전과 같은 문제가 발생하지 않습니다.

원리

update 메소드는 CAS(Compare-And-Swap)라는 알고리즘을 통해 원자성을 보장합니다.
동작 과정은 다음과 같습니다.

1. 현재 값을 읽는다
2. 새로운 값을 계산한다
3. compareAndSet으로 값이 변경되지 않았는지 확인한다
4. 성공하면 종료, 실패하면 다시 시도한다

이 구조 덕분에 여러 코루틴이 동시에 상태를 변경하더라도,
이전 값이 유효한지 확인한 뒤에 갱신이 수행됩니다.

위의 compareAndSet은 내부적으로 updateState를 호출합니다.

    override fun compareAndSet(expect: T, update: T): Boolean =
        updateState(expect ?: NULL, update ?: NULL)

그리고 실제 상태 갱신은 updateState 내부에서 이뤄집니다.

private fun updateState(expectedState: Any?, newState: Any): Boolean {
	var curSequence: Int
	var curSlots: Array<StateFlowSlot?>? // benign race, we will not use it
	synchronized(this) {
		val oldState = _state.value
		if (expectedState != null && oldState != expectedState) return false // CAS support
		if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
		_state.value = newState
		curSequence = sequence
		if (curSequence and 1 == 0) { // even sequence means quiescent state flow (no ongoing update)
			curSequence++ // make it odd
			sequence = curSequence
		} else {
			// update is already in process, notify it, and return
			sequence = curSequence + 2 // change sequence to notify, keep it odd
        	return true // updated
		}
		curSlots = slots // read current reference to collectors under lock
	}
	/*
		Fire value updates outside of the lock to avoid deadlocks with unconfined coroutines.
		Loop until we're done firing all the changes. This is a sort of simple flat combining that
		ensures sequential firing of concurrent updates and avoids the storm of collector resumes
		when updates happen concurrently from many threads.
	*/
	while (true) {
	// Benign race on element read from array
		curSlots?.forEach {
			it?.makePending()
		}
		// check if the value was updated again while we were updating the old one
		synchronized(this) {
		if (sequence == curSequence) { // nothing changed, we are done
			sequence = curSequence + 1 // make sequence even again
			return true // done, updated
		}
		// reread everything for the next loop under the lock
			curSequence = sequence
			curSlots = slots
		}
	}
}

먼저 상태 갱신이 하나의 스레드에서만 이뤄질 수 있도록 synchronized 블록을 사용합니다.

val oldState = _state.value

oldState는 현재 MutableStateFlow의 값입니다.

if (expectedState != null && oldState != expectedState) return false 

현재 값이 존재하지 않거나, 이전에 update 실행 시 복사한 값과 일치하지 않는 경우
false를 반환하여 상태 갱신을 다시 하게끔 유도합니다.

즉 불러온 현재 값이 유효하면 갱신하고, 그렇지 않으면 다시 계산하게끔 하는 것입니다.

if (oldState == newState) return true

상태가 실제로 변경되지 않았다면 추가적인 처리 없이 early return을 하기 위한 조건문입니다.

_state.value = newState

위의 두 가지 조건문을 통과해야만 현재 상태가 저장됩니다.
그리고 위의 _state는 다음과 같이 선언되어 있습니다.

private val _state = atomic(initialState) 

atomic() 메소드는 kotlinx.atomicfu 라이브러리에서 제공하는 함수로,
원자적 변수를 다루기 위해 자바의 AtomicReference 클래스를 추상화한 것입니다.

결국, synchronized 블록을 통해 다른 스레드의 접근을 제한하면서
while문과 여러 조건문을 통해 새로운 상태를 저장하는 것입니다.

또한 updateState 코드를 자세히 보면 sequenceslots라는 필드가 등장합니다.
이들은 StateFlow의 collector 관리와 동시 갱신 처리를 위한 중요한 역할을 합니다.

sequence

sequence는 현재 상태 갱신의 진행 상태를 나타내는 값입니다.

private var sequence = 0 // serializes updates, value update is in process when sequence is odd

if (curSequence and 1 == 0) { // even sequence means quiescent state flow (no ongoing update)
	curSequence++ // make it odd
	sequence = curSequence
} else {
	// update is already in process, notify it, and return
	sequence = curSequence + 2 // change sequence to notify, keep it odd
	return true // updated
}

여기서 중요한 점은 짝수와 홀수를 이용해 상태를 구분한다는 것입니다.

  • 짝수 -> 갱신이 진행 중이지 않은 상태
  • 홀수 -> 업데이트가 진행 중인 상태

즉 어떤 스레드에서 갱신을 시작하면 sequence가 홀수로 변경되며,
다른 스레드에서 동시에 갱신을 시도하면 sequence 값을 증가시켜 갱신이 추가로 발생했음을 알립니다.
그리고 갱신이 완료되면 sequence를 짝수로 변경합니다.

이러한 구조 덕분에 여러 스레더에서 동시에 상태를 갱신해도 흐름이 안정적으로 유지됩니다.

slots

slots는 현재 StateFlow를 구독하고 있는 구독자 목록을 저장하는 배열입니다.

protected var slots: Array<S?>? = null // allocated when needed

curSlots = slots // read current reference to collectors under lock

만약 갱신이 되면 다음과 같이 구독자에게 알려줍니다.

curSlots?.forEach {
	it?.makePending()
}

이렇게 하면 코루틴이 새로운 값을 방출할 수 있게끔 준비 상태가 됩니다.
또 위와 같은 동작은 데드락을 방지하기 위해 synchronized 블록 외부에서 실행됩니다.

결론

안드로이드 개발에서 StateFlow를 통해 상태를 관리하는 것이
어떻게 보면 당연하다고 느끼는 부분이 없지 않아 있습니다.

하지만 모든 코드에는 항상 근거가 있어야 한다고 생각합니다.

StateFlow는 단순히 thread-safe한 자료형으로 보이지만,
내부적으로 여러 메커니즘을 통해 상태의 일관성을 유지합니다.

이러한 특성 덕분에 여러 코루틴이 동시에 상태를 갱신하는 상황에서도
상태가 예기치 않게 깨지거나 유실되는 문제를 방지할 수 있습니다.

단순히 값을 저장하는 역할을 넘어 동시성 환경에서도 안정적으로 상태를 공유할 수 있도록 돕는 구조를 가지고 있는 것입니다.

평소에는 이러한 동작을 깊이 의식하지 않고 사용할 때가 많지만,
동작 원리를 한 번 이해하고 나면 StateFlow가 널리 사용되는 이유를 알 수 있는 것 같습니다.

이러한 배경을 이해하고 사용한다면 상태 관리에 대한 확신을 가지고
안정적인 코드를 작성할 수 있을 것입니다.

profile
Flutter, Android

0개의 댓글