[Kotlin] Flutter로 비교하는 Kotlin & Compose 지식

2025. 2. 11. 10:02Knowledge/Kotlin

https://github.com/KwonGeneral/chosungmarket.git

 

GitHub - KwonGeneral/chosungmarket: 초성마켓

초성마켓. Contribute to KwonGeneral/chosungmarket development by creating an account on GitHub.

github.com

 

 

이번 포스트에서는 몇 개월만에 Kotlin으로 다시 개발하고, 또 Compose를 다루면서 습득한 새로운 지식과 다시 기억해낸 중요한 지식 몇 가지를 간단하게 이야기해볼까 합니다.

다만, 제가 최근까지 Flutter로 개발을 했기 때문에 Flutter로 비교하면서 설명해볼까 합니다.

 

먼저 제가 다룰 목록은 아래와 같습니다.

  • @Composable
  • Modifier
  • @OptIn
  • MutableStateFlow
  • Flow
  • invoke
  • Result<out T>
  • viewModelScope
  • lauch
  • CoroutineScope
  • Runblocking
  • sealed class

* @Composable

 

- Flutter의 StatelessWidget이나 StatefulWidget과 비슷한 개념입니다. Jetpack Compose에서 UI 컴포넌트를 정의할 때 사용하는 어노테이션입니다. Flutter에서 build() 메서드로 위젯을 만드는 것처럼, Jetpack Compose에서는 @Composable 어노테이션으로 UI 컴포넌트를 만듭니다.



* Modifier

 

- Flutter의 decoration, padding 등을 통합한 개념입니다. Jetpack Compose에서 UI 컴포넌트의 스타일, 레이아웃, 상호작용을 정의하는 데 사용됩니다. Flutter의 Container나 여러 위젯 프로퍼티를 한 곳에서 처리하는 느낌입니다.


* OptIn

 

- OptIn는 간단하게 설명하자면 Jetpack Compose에서 실험적인 API를 사용할 때 사용하는 어노테이션입니다. 이 어노테이션은 Flutter의 @experimental 주석이나 프리뷰 기능과 매우 유사합니다. 해당 어노테이션을 사용하면 최신 UI 컴포넌트를 시도해볼 수 있습니다. * 해당 어노테이션을 사용해서 적용한 기능은 향후 변경되거나 완전히 제거될 수 있으니, 프로덕션에서는 사용을 자제해야합니다. 저는 개인 프로젝트이고 최대한 새로운 기술을 써보고 싶어서 사용했습니다.

 



* MutableStateFlow

 

- Flutter의 ValueNotifier나 ChangeNotifier와 비슷한 반응형 상태 관리 도구입니다. 상태가 변경되면 자동으로 UI를 업데이트하는 메커니즘을 제공합니다.

 



* Flow

 

- RxDart의 Stream과 매우 유사한 비동기 데이터 스트림입니다. Flutter에서 Stream을 사용하는 것과 비슷하게 데이터의 연속된 흐름을 처리할 수 있습니다.

 



* invoke

 

- Dart의 call() 메서드와 비슷한 개념입니다. 객체를 함수처럼 호출할 수 있게 해주는 연산자입니다. 추가적인 설명을 드리자면, 객체를 함수처럼 호출할 수 있는 것은 invoke가 약속된 사용법이기 때문입니다. 예를 들어, 컴파일 시점에서 컴파일러가 obj(args)를 obj.invoke(args)로 자동 변환하는데, 이로인해 객체를 함수처럼 호출할 수 있게 해줍니다.

 

 



* Result<out T>

 

- Dart의 try-catch와 비슷하지만 더 함수형 프로그래밍 스타일의 에러 핸들링 방식입니다. 성공 또는 실패 상태를 명시적으로 다룰 수 있습니다.

 

 



* viewModelScope

 

- Flutter의 Provider나 GetX에서 사용하는 생명주기 관리와 비슷합니다. ViewModel의 생명주기에 맞춰 코루틴을 관리합니다. Flutter의 setState()와 비슷하다고 생각하시면 됩니다. 그리고 ViewModel의 생명주기에 맞춘다는 이야기는 ViewModel이 소멸될 때 자동으로 모든 코루틴을 취소한다는 뜻입니다. 때문에 메모리 누수를 방지할 수 있고 리소스도 자동으로 정리할 수 있습니다. 무엇보다 비동기 작업을 안전하게 수행할 수 있고, UI 스레드를 블로킹하지 않고 백그라운드에서 작업을 수행하기 때문에 화면이 버벅거리거나 멈추지도 않습니다.

 

 



* coroutines(코루틴): lauch & async & withContext & coroutineScope & repeat 

- 위의 viewModelScope에서 주로 사용되는 메서드입니다.

 

launch

viewModelScope.launch {
    // 백그라운드 작업 수행
    repository.fetchData()
}
  • 결과를 반환하지 않는 비동기 작업에 사용합니다.

 

 

async

viewModelScope.launch {
    val result1 = async { repository.fetchFirstData() }
    val result2 = async { repository.fetchSecondData() }
    
    // 두 작업의 결과를 동시에 기다림
    val combinedResult = result1.await() to result2.await()
}
  • 결과를 반환하는 비동기 작업에 사용합니다.
  • await()로 결과를 기다릴 수 있습니다.
  • 주로 병렬 처리가 필요할 때 사용합니다. (순서 제어)

 

 

withContext

viewModelScope.launch {
    val result = withContext(Dispatchers.IO) {
        // 백그라운드 스레드에서 작업 수행
        repository.heavyComputation()
    }
    // 메인 스레드로 돌아와 UI 업데이트
    _uiState.value = result
}
  • 코루틴의 컨텍스트를 변경할 때 사용합니다.
  • 주로 스레드 변경(디스패처 변경)에 활용합니다.

 

 

coroutineScope

viewModelScope.launch {
    coroutineScope {
        launch { task1() }
        launch { task2() }
        // 두 작업 모두 완료될 때까지 대기
    }
    // 여기서 모든 자식 코루틴 작업 완료
}
  • 자식 코루틴들의 완료를 보장합니다.
  • 모든 자식 코루틴이 완료될 때까지 대기합니다.

 

 

repeat

viewModelScope.launch {
    repeat(5) { index ->
        launch {
            // 5번 비동기적으로 작업 수행
            fetchDataForIndex(index)
        }
    }
}
  • 반복 작업을 위해서 사용합니다.
  • 비동기 반복 작업에 유용합니다.

 

 



* CoroutineScope

- 비동기 작업을 관리하는 범위를 정의합니다. Flutter의 Future와 유사하지만 더 경량화되고 효율적인 비동기 처리 메커니즘입니다. 위에 적은 'coroutineScope'와는 다릅니다. 좀 더 추가적으로 설명해드릴게요.

 

 

coroutineScope

suspend fun fetchMultipleData() = coroutineScope {
    val data1 = async { fetchFirstData() }
    val data2 = async { fetchSecondData() }
    
    // 두 작업 모두 완료될 때까지 대기
    data1.await() to data2.await()
}
  • 함수 내부에서 여러 자식 코루틴을 생성하고 모든 자식 코루틴의 완료를 보장합니다.
  • 비동기적으로 작업을 수행합니다.
  • 부모 코루틴이 자식 코루틴들의 실패를 전파 받습니다.
  • 중단 함수(suspend function)에서 사용합니다.

 

 

CoroutineScope

val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
    // 작업 수행
}
  • 코루틴을 생성하고 관리하는 인터페이스입니다.
  • 코루틴의 생명주기를 제어하는 범위(scope) 정의합니다.
  • 직접 생성하거나 특정 디스패처와 함께 사용 가능합니다.

 

 



* runBlocking

fun main() = runBlocking {
    // 메인 스레드를 블로킹하며 코루틴 실행
    val result = async { fetchData() }
    println(result.await())
}

- 코루틴을 블로킹 방식으로 실행합니다. 주로 테스트나 특정 상황에서 사용되며, Flutter의 동기 Future 실행과 비슷한 개념입니다. 현재 스레드를 블로킹하면서 코루틴을 실행합니다. 주로 suspend가 아닌 일반 함수에서 코루틴 코드를 실행할 때 사용합니다. * 메인 스레드에서 사용하면 UI를 블로킹할 수 있습니다. 그렇기 때문에 주로 테스트나 특수한 상황에서 사용합니다.

 

 

아무래도 비슷한 설명이 많다보니 알듯말듯 헷갈리실 것 같아서 좀 더 쉽게 설명해드리겠습니다.

  • coroutineScope: 비동기적, 중단 함수(suspend function)내에서 사용합니다.
  • CoroutineScope: 코루틴 범위를 생성 및 관리합니다. (아래에 Dispatchers 설명을 보시면 이해하는데 도움이 되실 겁니다.)
  • runBlocking: 동기적, 스레드 블로킹

 

 

Flutter 관점에서 보면 이렇게도 설명할 수 있습니다. (절대 똑같다는 말이 아닙니다. 느낌이 비슷한겁니다~!)

  • coroutineScope = Future.wait()
  • CoroutineScope = Stream 컨트롤러
  • runBlocking = 동기 Future 실행

 

 


* Dispatchers

- Kotlin의 Dispatchers는 코루틴이 실행될 스레드를 결정하는 컨텍스트입니다. 주요 Dispatchers는 Main & IO & Default & Unconfined가 있습니다.

 

Main

withContext(Dispatchers.Main) {
    // UI 업데이트 작업
    textView.text = "Updated"
}
  • UI 스레드에서 실행합니다.
  • UI 업데이트나 이벤트 처리에 사용합니다.

 

 

IO

withContext(Dispatchers.IO) {
    // 네트워크 호출, 파일 읽기 등
    val result = apiService.fetchData()
}
  • 네트워크, 파일 I/O 작업에 최적화 되어있습니다.
  • 백그라운드 스레드 풀을 사용합니다.
  • 블로킹 I/O 작업을 위한 디스패처

 

 

* 스레드 풀(Thread Pool)

class DataRepository {
    private val backgroundDispatcher = Dispatchers.IO.limitedParallelism(4)

    suspend fun fetchMultipleData() = withContext(backgroundDispatcher) {
        // 최대 4개의 병렬 작업 처리
        coroutineScope {
            val job1 = async { fetchFirstData() }
            val job2 = async { fetchSecondData() }
            val job3 = async { fetchThirdData() }

            // 모든 작업 동시 실행
            listOf(job1, job2, job3).awaitAll()
        }
    }
}

- 백그라운드 스레드 풀(Background Thread Pool)은 동시에 여러 작업을 비동기적으로 실행할 수 있도록 미리 생성되어 대기하고 있는 스레드들의 집합입니다. 특징으로는 스레드 재사용이 있습니다. 매번 새로운 스레드를 생성하는 대신 미리 생성된 스레드를 재사용합니다. 그렇기에 스레드 생성 및 소멸의 오버헤드가 감소합니다. 다만.. 단점이 정말 많습니다. 리소스 제한 & 오버헤드 & 데드락 & 예측 불가능한 실행 순서 & 디버깅 복잡 & 튜닝 어려움 등등 단점이 정말 많기 때문에 잘 쓰지는 않습니다. (코루틴을 잘 활용합시다!)

 

 

Default

withContext(Dispatchers.Default) {
    // 대규모 데이터 처리, 복잡한 계산
    val processedList = largeList.map { complexComputation(it) }
}
  • CPU 집약적인 작업에 최적화 되어있습니다.
  • 기본 스레드 풀을 사용합니다.
  • 복잡한 계산, 데이터 처리에 적합합니다.

 

 

* 이해를 돕기 위해 CPU 집약적인 작업과 기본 스레드 풀의 장단점을 설명해드리겠습니다.

 

* [ CPU 집약적인 작업 ] - 장점

  • 복잡한 계산을 효율적으로 처리할 수 있습니다.
  • CPU 코어 활용도가 최대화됩니다.
  • 병렬 처리를 통한 성능이 향상됩니다. 
  • 시스템 리소스를 효율적으로 사용할 수 있습니다.

 

 

* [ CPU 집약적인 작업 ] - 단점

  • 과도한 CPU 사용으로 인한 시스템 부하가 있습니다.
  • 다른 애플리케이션 성능 저하 가능성이 있습니다.
  • 배터리 소모가 증가합니다.
  • 잘못된 최적화로 인한 성능 저하의 위험이 있습니다.

 

 

* [ 기본 스레드 풀 ] - 장점

  • 스레드 재사용으로 생성/삭제 오버헤드가 감소합니다.
  • 시스템 리소스를 효율적으로 관리할 수 있습니다.
  • 동적인 작업 처리가 가능합니다.
  • 일관된 성능을 유지합니다.

 

 

* [ 기본 스레드 풀 ] - 단점

  • 고정된 스레드 풀 크기로 인한 유연성이 제한됩니다.
  • 부하에 따른 대기 시간이 증가합니다.
  • 스레드 풀 크기 튜닝이 복잡합니다.
  • 잘못된 스레드 풀 설정으로 인해 성능이 저하될 수 있습니다.

 

 

 

Unconfined

withContext(Dispatchers.Unconfined) {
    // 스레드 이동이 자유로운 작업
}
  • 특별한 경우에만 사용합니다.
  • 호출한 스레드에서 시작, 중단 지점에서 다른 스레드로 이동 가능합니다.
  • * 주의해서 사용해야 합니다.

 

 

* [ 호출한 스레드에서 시작, 중단 지점에서 다른 스레드로 이동 가능 ] - 장점

  • 동적인 스레드 전환으로 유연성이 높습니다.
  • 블로킹 없는 비동기 처리가 가능합니다.
  • 리소스를 효율적으로 사용할 수 있습니다.
  • 복잡한 비동기 워크플로우 구현에 용이합니다.

 

 

* 호출한 스레드에서 시작, 중단 지점에서 다른 스레드로 이동 가능 ] - 단점

  • 스레드 동작이 예측 불가능합니다.
  • 디버깅이 어렵습니다.
  • 잠재적인 레이스 컨디션이 발생합니다.
  • 컨텍스트 스위칭 오버헤드가 발생합니다.

 

 

* 레이스 컨디션

class Counter {
    private var count = 0
    
    // 레이스 컨디션 발생 가능한 메서드
    fun increment() {
        // 읽기 -> 계산 -> 쓰기 과정에서 동시성 문제 발생
        count++
    }
}

// 동시에 여러 스레드에서 호출
fun demonstrateRaceCondition() {
    val counter = Counter()
    repeat(1000) {
        Thread { counter.increment() }.start()
    }
    // 예상 결과: 1000
    // 실제 결과: 1000보다 작을 수 있음
}
  • 여러 스레드나 프로세스가 동시에 같은 자원에 접근하려 할 때 발생하는 경쟁 상태입니다.
  • 실행 순서에 따라 결과가 달라질 수 있는 비결정적 상황입니다.

 

 

* 컨텍스트 스위칭 오버헤드

// 빈번한 컨텍스트 스위칭
fun frequentContextSwitch() {
    repeat(1000) {
        Thread {
            // 매우 짧은 작업
            println("Short task")
        }.start()
    }
}

// 개선된 접근
fun efficientConcurrency() {
    val executor = Executors.newFixedThreadPool(4)
    repeat(1000) {
        executor.submit {
            // 스레드 풀로 컨텍스트 스위칭 최소화
            println("Efficient task")
        }
    }
}

// 코루틴은 경량 스레드로 컨텍스트 스위칭 비용 최소화
suspend fun efficientCoroutines() = coroutineScope {
    repeat(1000) {
        launch {
            // 매우 가벼운 컨텍스트 전환
            println("Coroutine task")
        }
    }
}
  • 하나의 스레드에서 다른 스레드로 제어권을 넘기는 과정에서 발생하는 성능 비용입니다.
  • CPU가 레지스터 상태, 메모리 매핑 등을 저장하고 복원하는 데 드는 시간입니다.

 

 

주요 오버헤드 요소로는 아래 4가지 정도가 있습니다.

  • 레지스터 상태 저장/복원
  • 캐시 메모리 무효화
  • 스케줄러 개입
  • 메모리 컨텍스트 전환

 

 


* 컨텍스트를 구분해서 사용해야 하는 주요 이유

class UserRepository {
    suspend fun loadUserData() = withContext(Dispatchers.IO) {
        // 네트워크 호출 (I/O 작업)
        val userProfile = apiService.fetchProfile()
        
        withContext(Dispatchers.Default) {
            // 복잡한 데이터 처리 (CPU 집약적 작업)
            processUserData(userProfile)
        }
        
        withContext(Dispatchers.Main) {
            // UI 업데이트 (메인 스레드)
            updateUI(userProfile)
        }
    }
}
// 나쁜 예: 메인 스레드에서 네트워크 호출
fun loadData() {
    // UI 스레드를 블로킹하여 앱 응답 느려짐
    val data = apiService.fetchData() 
}

// 좋은 예: 컨텍스트 분리
suspend fun loadData() = withContext(Dispatchers.IO) {
    // 백그라운드에서 안전하게 네트워크 호출
    val data = apiService.fetchData()
    
    withContext(Dispatchers.Main) {
        // UI 업데이트는 메인 스레드에서
        updateUI(data)
    }
}

 

1. 성능 최적화

  • 각 디스패처는 특정 유형의 작업에 최적화되어 있습니다.
  • Dispatchers.IO는 I/O 작업을 위해 스레드 풀을 효율적으로 관리합니다.
  • Dispatchers.Default는 CPU 집약적 작업을 위해 코어 수에 맞게 스레드를 조정합니다.

 

 

2. UI 응답성 유지

  • Dispatchers.Main은 UI 스레드를 블로킹하지 않고 가볍게 유지합니다.
  • 무거운 작업을 다른 디스패처로 이동시켜 앱의 반응성을 보장합니다.

 

 

3. 리소스 관리

  • 각 디스패처는 다른 스레드 풀과 스케줄링 전략을 사용합니다.
  • 작업 유형에 따라 시스템 리소스를 효율적으로 할당합니다.

 

 

4. 교착 상태 방지

  • 적절한 컨텍스트 사용으로 스레드 간 블로킹 위험을 최소화합니다.
  • 각 작업을 적절한 스레드에서 처리합니다.

 

 

5. 확장성

  • 서로 다른 유형의 작업을 병렬로 처리가 가능합니다.
  • 작업 유형에 따라 최적의 실행 환경을 제공합니다.

 

 

 

* 주의할 점

  • 과도한 컨텍스트 스위칭은 오버헤드가 발생합니다.
  • 상황에 맞는 적절한 디스패처 선택이 중요합니다.

 

 



* sealed class

- Dart의 enum과 비슷하지만 더 강력한 패턴 매칭 기능을 제공합니다. 제한된 클래스 계층을 정의할 때 사용됩니다.

 

'enum'과 'sealed class'에 대해서 좀 더 자세히 설명드리겠습니다.

 

* enum

enum class Result { SUCCESS, ERROR, LOADING }

fun handleResult(result: Result) = when(result) {
    Result.SUCCESS -> println("성공")
    Result.ERROR -> println("오류")
    Result.LOADING -> println("로딩 중")
}
  • 고정된 상수의 집합입니다.
  • 상태 표현에 제한적입니다.
  • 메서드를 추가할 수는 있지만 제한적입니다.
  • 단순한 열거형 값을 표현합니다.

 

 

* sealed class

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
    object Loading : Result()
}

fun handleResult(result: Result) = when(result) {
    is Result.Success -> println(result.data)
    is Result.Error -> println(result.message)
    Result.Loading -> println("로딩 중")
}

 

  • 복잡한 상태 및 계층 구조의 표현이 가능합니다.
  • 다양한 속성과 메서드를 추가할 수 있습니다.
  • 패턴 매칭에 더 유연합니다.
  • 추상 클래스처럼 동작 가능합니다.

 

 

 


* let & also & run & apply & with

- Kotlin에서는 범위 지정 함수를 지원합니다. 이를 활용하면 개발 속도와 편의성을 챙길 수 있습니다.

 

* let (Null 체크, 일회성 작업)

val nullableValue: String? = "Hello"
nullableValue?.let {
    println(it.length) // null이 아닐 때만 실행
}

# 또는

nullableValue?.let { custom =>
    println(custom.length) // null이 아닐 때만 실행
}
  • Null 체크와 함께 사용합니다. (* 주로 null 체크 목적으로 아주 많이 씁니다.)
  • 블록 내부에서 it 키워드로 객체를 참조합니다. (it 키워드는 변경할 수 있습니다. 위의 예시에서는 'custom')

 

 

 

* also (부수 작업, 원본 객체 유지)

val numbers = mutableListOf(1, 2, 3)
numbers.also {
    it.add(4)
    println("List modified: $it")
}
// 원래 numbers 리스트 반환
  • 객체에 대해 추가 작업을 수행합니다.
  • 원래 객체를 반환합니다.

 

 

 

* run (복잡한 초기화, 계산)

val result = "Test".run {
    length // 블록의 마지막 값 반환
}
  • 객체의 새로운 컨텍스트에서 작업을 수행합니다.
  • 블록의 마지막 표현식을 반환합니다.

 

 

 

* apply (객체 설정)

val person = Person().apply {
    name = "John"
    age = 30
}
  • 객체 설정에 주로 사용합니다.
  • 객체 자체를 반환합니다.

 

 

 

* with (여러 메서드 연속 호출)

val numbers = mutableListOf(1, 2, 3)
with(numbers) {
    add(4)
    remove(2)
    println(this) // numbers 리스트 출력
}
  • 객체의 여러 메서드 연속 호출 시 사용합니다.
  • 수신 객체를 묵시적으로 사용합니다.

 

 

 


위의 지식을 깊게는 아니어도 키워드만 알고 계셔도 Kotlin 개발에는 문제가 없을 겁니다. 정말 자주 쓰이는 핵심만 다뤘습니다.

 

그럼, 오늘 포스트는 여기서 마치겠습니다!

'Knowledge > Kotlin' 카테고리의 다른 글

[Kotlin] 문법 - 반복문 & 배열 & 해시 & 정렬  (0) 2025.02.13