2025. 2. 11. 10:02ㆍKnowledge/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 |
---|