비동기 프로그래밍 리액티브 프로그래밍

쓰리원·2023년 4월 24일
0
post-thumbnail

비동기 프로그래밍과 리액티브 프로그래밍을 사용하면서 갑자기 개념이 모호하게 느껴져서 확실하게 정리해야겠다는 생각이 들어서, 요번에는 이것에 대해 알아보도록 하겠습니다.

1. 비동기 프로그래밍

비동기 프로그래밍은 프로그램의 실행 흐름을 블로킹하지 않고 여러 작업을 동시에 처리하는 기법입니다. 비동기 프로그래밍은 CPU의 자원을 효율적으로 사용하고, 더 나은 반응성과 성능을 제공하기 위해 사용됩니다.

1. 콜백 (Callback)

콜백은 함수를 다른 함수의 인자로 전달하여, 특정 작업이 완료된 후 실행되도록 하는 기법입니다. 콜백을 사용하면 비동기 작업을 처리할 수 있지만, 중첩된 콜백 구조로 인해 코드가 복잡해질 수 있습니다. 이를 "콜백 지옥(Callback Hell)"이라고도 합니다.

import kotlin.concurrent.thread

fun fetchData(callback: (String) -> Unit) {
    thread {
        Thread.sleep(2000)
        callback("Data fetched")
    }
}

fun processData(data: String, callback: (String) -> Unit) {
    thread {
        Thread.sleep(1000)
        callback("Processed: $data")
    }
}

fun main() {
    fetchData { fetchedData ->
        println("Fetched data: $fetchedData")
        processData(fetchedData) { processedData ->
            println("Processed data: $processedData")
        }
    }

    // fetchData와 processData 작업이 수행되는 동안 다른 작업을 실행합니다.
    for (i in 1..5) {
        Thread.sleep(500)
        println("Performing other tasks: $i")
    }
}

출력 결과

Performing other tasks: 1
Performing other tasks: 2
Performing other tasks: 3
Fetched data: Data fetched
Performing other tasks: 4
Performing other tasks: 5
Processed data: Processed: Data fetched

위 코드에서 fetchData와 processData는 각각 새로운 스레드에서 실행되지만, fetchData의 결과가 반환된 후 processData가 실행됩니다. 따라서 fetchData와 processData 사이에는 동기적인 실행 흐름이 있습니다.

하지만, 메인 스레드와 새로 생성된 스레드 사이에서는 여전히 비동기적인 실행 흐름이 존재합니다. fetchData()와 processData() 작업이 수행되는 동안 "Performing other tasks" 메시지가 출력되는 것으로 확인할 수 있습니다. 이것은 메인 스레드가 fetchData와 processData 작업이 완료될 때까지 기다리지 않고 다른 작업을 계속 진행할 수 있다는 것을 의미합니다.

추가 설명!

Thread.sleep() 함수를 사용하면, 현재 실행 중인 스레드가 블로킹됩니다. 그러나 예제 코드에서 fetchData()와 processData() 함수 내에서 새로운 스레드를 생성하고 있습니다. 따라서 Thread.sleep() 함수가 호출되면, 이 새로 생성된 스레드만 블로킹되고 메인 스레드는 블로킹되지 않습니다.

메인 스레드에서 fetchData() 함수를 호출하면, 별도의 스레드에서 데이터를 가져오는 작업이 수행됩니다. 이 때, 메인 스레드는 블로킹되지 않고 다른 작업을 계속 실행할 수 있습니다. 비슷하게 processData() 함수도 별도의 스레드에서 데이터 처리 작업을 수행하므로, 메인 스레드는 여전히 블로킹되지 않습니다.

코드의 main() 함수에서 Thread.sleep(500)을 사용하여 메인 스레드를 일시적으로 블로킹하고 있지만, 이렇게 하여 "Performing other tasks" 메시지를 출력하는 것은 메인 스레드가 fetchData()와 processData() 작업이 수행되는 동안 다른 작업을 계속 실행할 수 있음을 보여주기 위한 것입니다. 이 경우에서도 Thread.sleep(500)은 메인 스레드를 일시적으로 블로킹하지만, 별도의 스레드에서 수행되는 fetchData()와 processData() 작업과 동시에 진행되므로, 전체적으로 비동기 작업 처리를 구현하고 있는 것입니다.

2. 코루틴 (Coroutine)

코틀린에서는 콜백 패턴 대신에 코루틴을 사용하는 것이 더 일반적입니다. 코루틴은 경량의 비동기 작업 처리를 위한 Kotlin의 기능입니다. 코루틴을 사용하면 코드를 블로킹 없이 동시에 실행할 수 있습니다.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun fetchData(): String {
    delay(2000)
    return "Data fetched"
}

suspend fun processData(): String {
    delay(1000)
    return "Processed"
}

fun main() = runBlocking {
    val elapsedTime = measureTimeMillis {
        val fetchedData = async { fetchData() }
        val processedData = async { processData() }
        
        println(fetchedData.await())
        println(processedData.await())
    }
    println("Elapsed time: $elapsedTime ms")
}

fetchData()와 processData()가 동시에 실행되고, 실행 시간은 약 2초가 걸립니다. 이렇게 서로 독립적인 작업을 비동기로 처리하면 실행 시간을 단축할 수 있습니다.

3. 이벤트 리스너 (Event Listener) 및 관찰자 패턴 (Observer Pattern)

이벤트 리스너와 관찰자 패턴은 객체 간의 일대다 의존성을 설정하여, 한 객체의 상태가 변경되면 이를 관찰하는 모든 객체에게 알림을 전달하는 패턴입니다. 코틀린에서는 흔히 LiveData나 Observable과 같은 라이브러리를 사용하여 이벤트 리스너와 관찰자 패턴을 구현합니다.

이러한 이벤트 리스너(Event Listener) 및 관찰자 패턴(Observer Pattern) 자체는 비동기적인 것이 아닙니다. 그렇지만 이러한 패턴은 비동기 프로그래밍과 상호 작용하는 경우가 많습니다. 이벤트 리스너와 관찰자 패턴을 사용하여 비동기 프로그래밍에서 발생하는 다양한 이벤트를 처리하고, 객체 간의 상호 작용을 관리할 수 있습니다.

예를 들어, 이벤트를 발생시키는 작업이 비동기적으로 실행되고, 이벤트를 처리하는 작업도 비동기적으로 실행되는 경우에 이 패턴을 사용할 수 있습니다. 따라서 이벤트 리스너와 관찰자 패턴은 비동기 프로그래밍과 밀접한 관련이 있습니다. 하지만 이 패턴 자체는 비동기적인 것이 아니라 동기적인 작동 방식을 가지며, 비동기 프로그래밍에서 이벤트 처리를 쉽게 구현할 수 있도록 도와줍니다.

import kotlinx.coroutines.*

// 이벤트 리스너 인터페이스
interface EventListener {
    suspend fun onEvent(event: String)
}

// 이벤트 발생기 클래스
class EventEmitter {
    private val listeners = mutableListOf<EventListener>()

    fun addListener(listener: EventListener) {
        listeners.add(listener)
    }

    suspend fun emitEvent(event: String) {
        listeners.forEach { listener ->
            CoroutineScope(Dispatchers.Default).launch { // 비동기적으로 이벤트를 처리합니다.
                listener.onEvent(event)
            }
        }
    }
}

class MyListener : EventListener {
    override suspend fun onEvent(event: String) {
        delay(2000) // 이벤트 처리에 시간이 걸립니다.
        println("Received event: $event")
    }
}

fun main() = runBlocking {
    val eventEmitter = EventEmitter()
    val listener = MyListener()

    eventEmitter.addListener(listener)

    // 이벤트를 비동기적으로 발생시킵니다.
    val eventJob = launch(Dispatchers.Default) {
        for (i in 1..5) {
            delay(1000)
            println("emit event: $i")
            eventEmitter.emitEvent("Event $i")
        }
    }

    eventJob.join() // 이벤트 처리가 완료될 때까지 기다립니다.
    delay(5000)
}

출력 결과

emit event: 1
emit event: 2
Received event: Event 1
emit event: 3
Received event: Event 2
emit event: 4
Received event: Event 3
emit event: 5
Received event: Event 4
Received event: Event 5

위 예제에서 EventEmitter 클래스는 이벤트를 발생시키고, MyListener 클래스는 이벤트를 처리합니다. EventEmitter의 emitEvent 메소드는 이벤트를 발생시킬 때 각 이벤트 리스너의 onEvent 메소드를 비동기적으로 호출합니다. 이를 통해 이벤트 발생과 이벤트 처리가 동시에 진행되며, 이벤트 처리에 걸리는 시간이 다른 이벤트의 발생을 지연시키지 않습니다.

이벤트 리스너와 관찰자 패턴은 특히 UI 프로그래밍에서 유용하며, 애플리케이션의 다양한 구성 요소 간에 상태 변경을 전달하는 데 사용됩니다. 코틀린에서는 LiveData, Observable, Flow, StateFlow 등의 라이브러리를 사용하여 이 패턴을 구현할 수 있습니다.

4. Flow (코루틴 기반 리액티브 스트림)

코틀린에서는 코루틴을 기반으로 하는 리액티브 스트림인 Flow를 사용하여 이벤트 및 비동기 데이터 처리를 수행할 수 있습니다. Flow는 데이터의 비동기 전달을 가능하게 하는 데 도움이 되며, 리액티브 프로그래밍 패턴을 쉽게 구현할 수 있게 합니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

suspend fun generateData(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(500) // 500ms마다 데이터 생성
        emit(i) // 생성된 데이터를 flow에 전달
    }
}

suspend fun processData(value: Int): Int {
    delay(300) // 데이터 처리에 300ms가 걸림
    return value * 2
}

fun main() = runBlocking {
    val dataFlow = generateData()

    // 비동기로 생성된 데이터를 처리하는 코드
    dataFlow
        .map { value ->
            async { processData(value) } // 각 데이터를 비동기로 처리
        }
        .collect { deferred ->
            val result = deferred.await() // 처리된 데이터를 기다림
            println("Processed data: $result") // 처리된 데이터를 출력
        }
}

이 예제에서 비동기 전달이란, generateData() 함수에서 생성되는 데이터가 flow를 통해 바로 처리되지 않고, 대신 Flow 타입의 객체를 통해 전달되는 것을 의미합니다. 이로 인해 데이터를 생성하는 로직과 데이터를 처리하는 로직이 별도의 코루틴에서 동작할 수 있습니다.

generateData()에서 생성한 데이터를 dataFlow로 전달하며, 이를 map 및 collect를 사용하여 처리합니다. 여기서 map을 사용해 processData(value)를 비동기로 호출하고, collect에서 deferred.await()를 통해 처리된 결과를 기다립니다. 이렇게 함으로써 데이터 생성과 처리가 동시에 이루어질 수 있고 전체 프로그램의 실행 시간을 줄일 수 있습니다.

이렇게 코틀린에서는 콜백, 코루틴, 이벤트 리스너 및 관찰자 패턴, 그리고 Flow등 다양한 비동기 프로그래밍 패턴을 사용하여 애플리케이션에서 비동기 작업을 처리할 수 있습니다. 이러한 패턴 중 일부는 다른 패턴과 함께 사용되어 더 복잡한 비동기 작업을 처리하는 데 도움이 될 수 있습니다.

요약하면, 비동기 프로그래밍 패턴에는 다음과 같은 방법들이 있습니다.

  1. 콜백 (Callback): 함수를 인자로 전달하여 특정 작업이 완료된 후 실행되도록 하는 기법입니다.
  1. 코루틴 (Coroutine): 경량의 비동기 작업 처리를 위한 Kotlin의 기능으로, 코드를 블로킹 없이 동시에 실행할 수 있습니다.
  1. 이벤트 리스너 (Event Listener) 및 관찰자 패턴 (Observer Pattern): 객체 간의 일대다 의존성을 설정하여 한 객체의 상태가 변경되면 이를 관찰하는 모든 객체에게 알림을 전달하는 패턴입니다.
  1. Flow (코루틴 기반 리액티브 스트림): 코루틴을 기반으로 하는 리액티브 스트림으로, 데이터의 비동기 전달을 가능하게 하며 리액티브 프로그래밍 패턴을 쉽게 구현할 수 있습니다.

각 패턴은 특정 상황에 맞게 사용할 수 있으며, 여러 패턴을 조합하여 다양한 비동기 작업 처리 요구 사항에 대응할 수 있습니다. 코틀린에서는 주로 코루틴과 Flow를 사용하여 간편하고 가독성 높은 비동기 코드를 작성할 수 있습니다. 이를 통해 애플리케이션의 동시성과 비동기 작업을 효율적으로 관리할 수 있습니다.

2. 리액티브 프로그래밍

리액티브 프로그래밍은 데이터 흐름과 변화에 반응하는 프로그래밍 패러다임입니다. 리액티브 프로그래밍은 데이터 스트림을 사용하여 변화를 전파하고, 이를 통해 연관된 컴포넌트나 로직이 자동으로 업데이트되도록 합니다. 이 패러다임은 사용자 인터페이스 개발, 실시간 데이터 처리 등에서 유용하게 사용됩니다. 주요 리액티브 프로그래밍 라이브러리에는 RxJava, RxJS, Kotlin Flow 등이 포함됩니다.리액티브 프로그래밍은 비동기 프로그래밍과 함께 사용되어 효과적인 데이터 처리와 반응성을 높일 수 있습니다.

예를 들어, 네트워크 요청을 처리하는 애플리케이션을 개발한다고 가정하겠습니다. 이 경우 비동기 프로그래밍을 사용하여 요청을 처리하고 결과를 받을 수 있으며, 이를 통해 사용자 인터페이스가 블로킹되지 않고 부드럽게 동작하도록 할 수 있습니다. 한편, 리액티브 프로그래밍을 사용하면 이러한 결과를 데이터 스트림으로 변환하여 자동으로 UI에 반영하고, 다른 관련 로직에 전파할 수 있습니다.

1. 효율적인 데이터 처리: 리액티브 프로그래밍은 데이터 스트림을 사용하여 변화를 전파하므로, 데이터 처리가 더욱 효율적이고 간결해집니다.

  • 애플리케이션은 서버에서 아이템 목록을 가져옵니다.
  • 가져온 아이템 목록에서 특정 조건에 맞는 아이템만 필터링하여 보여줍니다.
  • 필터링된 아이템 목록을 정렬하여 최종 결과를 표시합니다.

리액티브 프로그래밍 없이 이러한 기능을 구현하려면 복잡한 반복문과 조건문이 필요하며, 코드가 길어지고 가독성이 떨어집니다. 하지만 RxJava를 사용하면 다음과 같이 간단하게 구현할 수 있습니다.

val apiService: ApiService // 아이템 목록을 가져오는 API 서비스

// 서버에서 아이템 목록을 가져오는 함수
fun fetchItems() {
    apiService.getItems()
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .map { items ->
            // 특정 조건에 맞는 아이템만 필터링
            items.filter { item -> item.condition }
        }
        .map { filteredItems ->
            // 필터링된 아이템 목록 정렬
            filteredItems.sortedBy { item -> item.sortKey }
        }
        .subscribe({ sortedItems ->
            // 정렬된 아이템 목록을 UI에 표시
            displayItems(sortedItems)
        }, { error ->
            // 에러 처리
            handleError(error)
        })
}

// 아이템 목록을 가져오기
fetchItems()

이 예제에서는 RxJava를 사용하여 네트워크 요청을 수행하고, 가져온 데이터를 처리하며, 최종 결과를 표시합니다. 이를 통해 데이터 처리가 간결하고 효율적이며, 코드가 짧아지고 가독성이 향상됩니다. 이처럼 리액티브 프로그래밍을 사용하면 효율적인 데이터 처리를 구현할 수 있습니다.

2. 복잡한 상태 관리 개선: 리액티브 프로그래밍을 사용하면 상태 변화에 자동으로 반응하도록 구현할 수 있으므로, 복잡한 상태 관리를 줄이고 코드를 간소화할 수 있습니다.

  • 사용자는 검색어를 입력하면 일치하는 사용자 목록이 실시간으로 표시됩니다.
  • 검색 결과는 네트워크 요청을 통해 가져옵니다.
  • 검색어가 변경되면 이전 검색 결과를 취소하고 새로운 검색을 시작합니다.
  • 사용자가 입력하는 동안 과도한 네트워크 요청을 방지하기 위해 입력 간격이 일정 시간 이상이어야 검색을 수행합니다.

리액티브 프로그래밍 없이 이러한 기능을 구현하려면 복잡한 상태 관리와 콜백이 필요하며, 코드가 길어지고 가독성이 떨어집니다. 하지만 RxJava를 사용하면 다음과 같이 간단하게 구현할 수 있습니다.

val searchEditText: EditText // 사용자 입력을 받는 EditText
val apiService: ApiService // 검색 요청을 처리하는 API 서비스

// EditText의 텍스트 변경 이벤트를 Observable로 변환
val searchTextObservable = searchEditText.textChanges()
    .skipInitialValue() // 초기 값 건너뛰기
    .debounce(500, TimeUnit.MILLISECONDS) // 500ms 동안 입력이 없을 때만 검색 수행
    .distinctUntilChanged() // 이전 검색어와 다른 경우에만 검색 수행

// 검색어를 사용하여 네트워크 요청을 수행하고 결과를 처리하는 함수
fun searchUsers(query: CharSequence) {
    apiService.searchUsers(query.toString())
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({ users ->
            // 검색 결과를 UI에 표시
            displayUsers(users)
        }, { error ->
            // 에러 처리
            handleError(error)
        })
}

// 검색어가 변경될 때마다 searchUsers 함수 호출
searchTextObservable.subscribe(::searchUsers)

이 예제에서는 RxJava를 사용하여 EditText의 텍스트 변경 이벤트를 관찰하고, 검색 요청을 수행하며, 결과를 처리합니다. 이를 통해 상태 관리가 간소화되고, 코드가 짧아지며 가독성이 향상됩니다. 이처럼 리액티브 프로그래밍을 사용하면 복잡한 상태 관리를 줄이고 코드를 간소화할 수 있습니다.

  1. 코드 가독성 및 유지 관리성 향상: 리액티브 프로그래밍을 사용하면 이벤트 기반의 코드를 더 명확하게 표현할 수 있으므로, 코드 가독성과 유지 관리성이 향상됩니다.
  1. 에러 처리 및 복구 용이: 리액티브 프로그래밍은 에러 처리를 스트림 수준에서 처리할 수 있으므로, 에러 처리 및 복구가 용이해집니다.

3. reference

https://velog.io/@ubrain/%EC%8A%A4%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9C%BC%EB%A1%9C-%EB%AC%B4%EA%B1%B0%EC%9A%B4-%EB%A1%9C%EC%A7%81-%EC%B2%98%EB%A6%AC

profile
가장 아름다운 정답은 서로의 협업안에 있다.

0개의 댓글