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

2025. 2. 11. 10:02·개발/모바일
반응형

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 개발에는 문제가 없을 겁니다. 정말 자주 쓰이는 핵심만 다뤘습니다.

 

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

반응형
저작자표시 비영리 변경금지 (새창열림)

'개발 > 모바일' 카테고리의 다른 글

[Coding Test] (3) 프로그래머스 스킬체크 Lv.1  (0) 2025.02.17
[Coding Test] (2) 이전 문제 다시 풀어보기  (0) 2025.02.13
[Kotlin] 문법 - 반복문 & 배열 & 해시 & 정렬  (0) 2025.02.13
[Coding Test] (1) 프로그래머스 스킬체크 Lv.1  (0) 2025.02.12
[Kotlin Project] 초성마켓 - 프로젝트 전체적인 구조  (0) 2025.02.11
[Kotlin Project] 초성마켓 - 홈, 퀴즈 페이지 개발  (0) 2025.02.10
[Kotlin Project] 초성마켓 - 로그인 페이지 개발  (0) 2025.02.07
[Kotlin Project] 초성마켓 - 클린 아키텍처 적용: DI  (0) 2025.02.07
'개발/모바일' 카테고리의 다른 글
  • [Kotlin] 문법 - 반복문 & 배열 & 해시 & 정렬
  • [Coding Test] (1) 프로그래머스 스킬체크 Lv.1
  • [Kotlin Project] 초성마켓 - 프로젝트 전체적인 구조
  • [Kotlin Project] 초성마켓 - 홈, 퀴즈 페이지 개발
권퓨터
권퓨터
만드는 걸 좋아하는 개발자의 기록. 코드든 글이든, 일단 만들어 봅니다.
  • 권퓨터
    권퓨터: Kwonputer
    권퓨터
  • 티스토리 홈 관리자
  • 전체
    오늘
    어제
    • 분류 전체보기 (557)
      • 개발 (56)
        • 프로젝트 (5)
        • 모바일 (44)
        • 프론트엔드 (0)
        • 백엔드 (2)
        • 인프라 (0)
        • AI · 머신러닝 (4)
      • IT · 테크 (8)
        • 기술 트렌드 (3)
        • 도구 · 생산성 (1)
        • 제품 리뷰 · 추천 (0)
        • 마케팅 · 수익화 (4)
      • 자기계발 (7)
        • 공부법 · 언어 (0)
        • 취업 · 커리어 (7)
      • 아카이브 (486)
        • 일기 (480)
        • 취미 (6)
  • 블로그 메뉴

    • 홈
  • 링크

    • 블로그 이전
  • 공지사항

    • 서브 블로그 => https://kwonputer.com/
  • 인기 글

  • 태그

    ai 게임 개발
    python
    파이썬 기초
    https://www.kwonputer.shop/
    TypeScript
    파이썬
    내러티브 게임
    클린 아키텍처
    서버리스 아키텍처
    Single Table Design
    사이드프로젝트
    FACEBOOK광고
    kotlin
    aws lambda
    1인개발
    https://github.com/kwongeneral/kortfolio.git
    Clean Architecture
    flutter 개발자
    Prompt Engineering
    OpenAI GPT
    dynamodb
    AWS CDK
    riverpod
    injectable
    다국어 블로그
    flutter 면접 질문
    크로스플랫폼
    상태관리
    python 기초
    flutter
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
권퓨터
[Kotlin] Flutter로 비교하는 Kotlin & Compose 지식
상단으로

티스토리툴바