프로그래밍 패러다임 : 명령형 vs 반응형

SSY·2025년 8월 5일
0

Flow

목록 보기
6/7
post-thumbnail

명령형 프로그래밍

명령형 프로그래밍이란 말 그대로 '명령'을 내림으로써 코드를 실행시키고, 그에따른 결과를 받는 것을 말한다. 안드로이드로 예를 들자면, '메서드의 호출'을 사용하여 특정 UI들을 순차적으로 갱신하는 것을 말한다. 이를 의사코드로 표현하면 아래와 같이 나올 수 있다.

viewmodelScope.launch {
	showLoading()
    val products = productRepository.getProducts()
    /**
    * 가져온 상품을 UI에 갱신하는 로직. 
    **/
    dismissLoading()
}

우리가 화면을 로딩시켜 상품을 가져온다 헀을 때, 대략적인 흐름으론

  1. 로딩바를 띄운다.
  2. 상품을 가져온다.
  3. 상품을 가져왔다면 이를 UI에 갱신한다.
  4. 로딩바를 지운다.

정도의 흐름이 있다. 하지만 문제는 앱이 커졌을 때다. 커다란 앱의 경우, 하나의 viewModel안에서 여럿 정보들을 질의하고 이를 화면에 띄워준다. 또한 사용자들의 복잡다단한 상호작용을 UI에 또 반영해줘야 하는 것이다. 즉, 여러곳으로부터의 발생한 이벤트와 데이터 흐름을 UI에 적절하게 갱신해줘야하며, 이러한 로직들이 산발적으로 존재한다는 것이 문제이다. 이를 개선하기위해 나온 것이 반은형 프로그래밍이다.

반응형 프로그래밍

반응형이란 말 그대로 '반응'한다는 것인데, 이는 '데이터'를 의미한다. 즉 이를 안드로이드에 한정하면 '데이터에 반응'하여 UI를 갱신한다는 의미다. 또한 데이터에 반응하여 특수한 곳에서 이를 받아 처리하기 위해선 '스트림'에 대한 개념을 알 필요가 있다. 이는 원샷 호출과 같은 명령형이 아닌 데이터가 흐를 수 있는 통로를 의미하며, 이 곳으로부터 들어온 데이터를 수신자 측에서 선택적으로 받아 처리가 가능하단 의미다. 즉, 데이터가 흐르는 통로를 알아야 한다. 즉, 이러한 데이터에 반응하기 위해선 3가지 컴포넌트가 존재한다.

생산자

생산자는 데이터를 발생하는 곳을 말한다. 데이터 스트림이 있다 했을 때, 데이터가 첫 시작되는 곳을 의미하며, 비유하자면 시냇물이 산 위에서 시작하여 내려오는 곳이라 이해할 수 있다. 이를 Coroutine의 Flow에 대응시켜보자면, emit, update등이 있다.

val producerFlow = flow {
    emit("🍎")
    emit("🍊")
    emit("🍌")
}

중간 연산자

시냇물이 산 위에서 내려오면서 여러 지형지물을 만난다. 그러면서 물줄기가 갈라지기도 하고, 물의 모양이 넓어지기고 하고, 좁하지기도 한다. 즉, 중간 연산자란 것은 생산자가 내보낸 데이터를 중간에서 변형하는 것을 의미한다. 이를 Coroutine의 Flow에 대응시켜보자면, flatMapConcat, map, filter등이 있다.

val transformedFlow = producerFlow
    .filter { it != "🍊" }  // 오렌지는 걸러내고
    .map { "과일: $it" }    // 문자열로 가공

구독자

생산자가 데이터를 발행하고, 중간 연산자가 이를 변형하고, 최종적으로 이를 수신하는 측이 구독자이다. 예를들어, 생산자가 데이터를 여러 프로퍼티가 있는 data class로 보냈다고 가정할 때, 이곳에서 선별적으로 1개의 프로퍼티만 내려주는 것도 가능하다. 즉, 구독자란 생산자가 내보낸 데이터를 중간 연산자가 변형하고 이를 수신하는 곳이다. Coroutine의 Flow에선 collect, collectLatest, first등이 있다.

transformedFlow.collect { item ->
    println("받은 데이터: $item")
}

이처럼 구독자가 스트림을 구독함으로써 전체 흐름이 실행되며, 생산 → 가공 → 소비의 과정이 완성된다.

TIP
구독자가 꼭 스트림을 구독해야만 생산자가의 데이터 발행이 시작되는 것은 아니다. ColdStream의 경우는 생산자의 데이터 발행에 있어, 구독자의 구독이 필요조건이지만, HotStream의 경우는 그렇지 않다. StateFlowstateIn의 내부 설정(SharingStarted.Eagerly or SharingStarted.Lazy)에 따라 생산자가 데이터를 즉시 발행할 수도 있다.

반응형 프로그래밍은 쓰는 이유

Android UI에선 여러 군데로부터 데이터 변형을 위한 이벤트가 발생한다. 사용자의 터치 이벤트는 물론이고, 특정 화면을 첫 로딩했을 때 리스트를 내려주는 이벤트도 있다. 이러한 이벤트들을 1개의 스트림에 모아 발행하게 되면, 구독자 측에선 이를 수신받아 공통적으로 처리할 수 있게 된다.

대표적으로 MutableStateFlow를 사용하면 update 메서드를 통해 UI 상태를 멀티스레드 환경에서도 원자적(atomic) 으로 갱신할 수 있다. 여러 스트림으로부터 받은 데이터를 update로 갱신하고, 이 StateFlow를 UI에서 구독함으로써 최종적으로는 1개의 uiState 로만 UI를 구성할 수 있게 된다. 그로 인해 여러 군데에서 발생하는 이벤트들이 충돌 없이 하나의 흐름으로 합쳐지고, UI 일관성도 자연스럽게 보장된다.

// UI 상태를 담는 data class
data class UiState(
    val isLoading: Boolean = false,
    val products: ImmutableList<Product> = persistentList(),
    val userInfo: UserInfo? = null,
    val errorMessage: String? = null
)

// ViewModel 내부
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

// 사용자 정보와 상품 정보를 함께 가져오는 경우
fun loadAll() {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
        runCatching {
			productRepository.getProducts() to userRepository.getUserInfo()
        }.onSuccess { productsAndUserInfo ->
            _uiState.update { 
            	it.copy(
                	isLoading = false, 
                    products = productsAndUserInfo.first,
					userInfo = productsAndUserInfo.second,
                ) 
            }
        }.onFailure {
            _uiState.update { it.copy(isLoading = false, errorMessage = "불러오기 실패") }
        }
    }
}

// 상품 정보만 따로 가져오는 경우
fun loadProducts() {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
        runCatching {
			productRepository.getProducts()
        }.onSuccess { products ->
            _uiState.update { it.copy(isLoading = false, products = products) }
        }.onFailure {
            _uiState.update { it.copy(isLoading = false, errorMessage = "불러오기 실패") }
        }
    }
}

// 사용자 정보만 따로 가져오는 경우
fun loadUserInfo() {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
        runCatching {
			userRepository.getUserInfo()
        }.onSuccess { userInfo ->
            _uiState.update { it.copy(isLoading = false, userInfo = userInfo) }
        }.onFailure {
            _uiState.update { it.copy(isLoading = false, errorMessage = "불러오기 실패") }
        }
    }
}
profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글