1. 정의
1-1. 코루틴이란?
코루틴은 비동기 프로그래밍을 지원하는 Kotlin의 라이브러리이다.
스레드와 비슷한 역할을 하지만, 비교적 가벼우며, 쉽게 사용할 수 있다.
1-2. 비동기 작업은?
비동기 작업은 애플리케이션에서 시간이 오래 걸리는 작업을 수행할 때, 결과를 기다리지 않고 다른 작업을 수행하는 방식을 말한다. 한 번에 여러가지 일을 처리하기 위함이라고 생각하면 편하다.
일반적으로 네트워크 요청, 데이터베이스 조회, 파일 다운로드 등과 같은 작업을 비동기적으로 처리한다.
비동기 작업을 수행하면 애플리케이션의 응답성이 향상되고, 화면이 멈추는 등의 문제를 방지할 수 있다.
이를 구현하는 방식으로는 콜백, 스레드, 코루틴 등이 있다.
1) 콜백이란?
콜백은 비동기 작업이 완료되면 호출되는 함수로, 일반적으로 인터페이스나 람다식으로 정의된다.
콜백 함수는 비동기 작업이 완료되면 자동으로 호출되며, 이때 결과를 처리할 수 있다.
네트워크에서 데이터를 가져오는 콜백 예시 코드는 아래와 같다.
fun fetchDataFromNetwork(onDataLoaded: (String) -> Unit, onError: (Throwable) -> Unit) {
// 네트워크에서 데이터를 가져옴
val result = "data"
if (result != null) {
// 데이터가 있으면 onDataLoaded 콜백 함수 호출
onDataLoaded(result)
} else {
// 데이터가 없으면 onError 콜백 함수 호출
onError(Throwable("Data not found"))
}
}
// fetchDataFromNetwork 함수 호출
fetchDataFromNetwork(
// onDataLoaded 콜백 함수 정의
onDataLoaded = { data ->
// 데이터 로딩 완료 처리
println(data)
},
// onError 콜백 함수 정의
onError = { error ->
// 에러 처리
println(error.message)
}
)
fetchDataFromNetwork 함수를 호출할 때, onDataLoaded와 onError 콜백 함수를 정의하여 넘겨준다.
fetchDataFromNetwork 함수는 네트워크에서 데이터를 가져온 후 , 데이터가 있으면 onDataLoaded 콜백 함수를 호출하고, 데이터가 없으면 onError 콜백 함수를 호출한다.
네트워크에서 데이터를 가져와야 그것을 출력하는 함수가 작동 되는 것으로 출력 함수가 먼저 작동이 되는 불상사를 방지할 수 있다.
2) 스레드란?
스레드는 애플리케이션에서 병렬 처리를 수행할 때 사용되는 기본적인 방법이다.
스레드는 메인(UI) 스레드와 별도로 실행되며, 복잡한 계산 작업이나 네트워크 작업 등을 처리할 수 있다.
하지만, 스레드를 직접 다루는 것은 코드가 복잡해지고 예외 처리가 어려운 문제가 있다.
예시 코드는 아래와 같다.
Thread(Runnable {
// 스레드에서 실행할 코드 작성
fetchDataFromNetwork()
// UI 업데이트 작업 예시
runOnUiThread {
updateUi()
}
}).start()
Thread 클래스를 사용하여 새로운 스래드를 생성한다.
안드로이드에서는 스레드를 생성하는 다양한 방법이 있다.
여기서는 Runnable를 안에 넣어 한 번에 실행하게 만들었다.
스레드에서 네트워크 작업을 실행한 후, UI 업데이트를 위해 runOnUiThread 함수를 호출한다.
안드로이드에서 UI 작업은 메인 스레드에서만 가능하로 다른 스레드에서 UI 작업을 할 수 없다.
이 점을 유의해서 작성해야한다.
2. 코루틴 사용법
2-1. Implementation
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}
2-2. 코루틴에서 사용되는 개념
1) CoroutineScope
코루틴을 실행하기 위한 범위를 지정하는 객체이다.
2) CoroutineContext
코루틴을 실행할 때 필요한 컨텍스트(실행 환경)를 지정하는 객체이다.
스레드, 예외 핸들러 등을 지정할 수 있다.
3) launch
새로운 코루틴을 실행하는 함수이다.
CoroutineScope 내에서 새로운 코루틴을 실행할 수 있다.
4) async
새로운 코루틴을 실행하고 결과를 반환하는 함수.
launch 함수와 달리 Deferred 객체를 반환하며, 이는 await 메서드를 호출하여 코루틴의 결과를 얻을 수 있다.
5) 예시 코드
import kotlinx.coroutines.*
suspend fun fetchDataFromNetwork(): String {
delay(1000L) // 네트워크 작업을 위한 딜레이
return "Data from network"
}
suspend fun saveDataToFileSystem(data: String) {
delay(2000L) // 파일 시스템 작업을 위한 딜레이
println("Saved data to file system: $data")
}
fun main() = runBlocking {
// CoroutineScope를 생성합니다.
val myScope = CoroutineScope(Dispatchers.Default)
// CoroutineContext를 정의합니다.
val myContext = myScope.coroutineContext + Job()
// launch를 사용하여 코루틴을 실행합니다.
val job = myScope.launch(myContext) {
// async-await를 사용하여 비동기적으로 작업을 실행합니다.
val data = async { fetchDataFromNetwork() }.await()
// withContext를 사용하여 코루틴이 실행될 스레드를 변경합니다.
withContext(Dispatchers.IO) {
saveDataToFileSystem(data)
}
}
job.join()
}
2-3. 코루틴 스레드 종류
코루틴에서는 다양한 스레드에서 실행될 수 있는 'Dispatcher'라는 개념이 존재한다.
'Dispatcher'는 코루틴이 실행될 스레드의 종류와 풀을 지정하는 역할을 한다.
'Dispatcher' 종류는 크게 3가지가 존재한다.
1) 'Dispatchers.Main'
안드로이드의 UI 스레드에서 코루틴을 실행하기 위한 'Dispatcher'이다.
UI 업데이트와 관련된 작업은 이 'Dispatcher'에서 실행되어야 한다.
2) 'Dispatchers.IO'
I/O 작업(네트워크 요청, 파일 입출력 등)을 위한 'Dispatcher'이다.
이 'Dispatcher'는 네트워크 작업이나 파일 입출력 등 I/O 작업에 최적화되어 있다.
3) 'Dispatchers.Default'
CPU 바운드 작업(계산, 데이터 처리 등)을 위한 'Dispatcher'이다.
이 'Dispatcher'는 코어 개수에 맞게 스레드 풀을 생성하며, CPU 작업에 최적화되어 있다.
복잡한 계산 작업을 할 때 사용하면 유용하다.
4) 가벼운 예시
그렇다면 만약 네트워크 요청으로 서버에서 데이터를 가져오고, 그 데이터를 원래 있던 파일과 비교하여 기존에 있던 파일과 같으면 유지하고, 다르면 교체하는 식으로 업데이트 하는 프로그램을 만들면 어떻게 될까?
일단 네트워크 요청과 파일 입출력은 모두 'Dispatchers.IO'로 처리하는 것이 맞을 것이다.
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
// 네트워크 작업
val result = withContext(Dispatchers.IO) {
fetchDataFromNetwork()
}
// 파일 입출력 작업
withContext(Dispatchers.IO) {
val existingData = readDataFromFile()
if (existingData != result) {
writeDataToFile(result)
}
}
}
fetchDataFromNetwork에서 가져온 데이터를 result에 저장하고, readDataFromFile에서 가져온 데이터를 existingData에 저장해서, 이 둘을 비교해서 다르면 result를 저장하는 방식으로 코드를 만들었다.
여기서 withContext는 현재 실행 중인 스레드를 일시 중지하고, 지정된 스레드에서 코드 블록을 실행하고 나서 다시 현재 코루틴이 실행 중인 스레드로 돌아오게 한다.
따라서 위와 같이 작성하면 우선적으로 네트워크 작업을 맡은 코루틴이 작동하고, 그 뒤로 파일 입출력을 맡은 코루틴이 작동하게 된다.
3. 주의해야 할 점
단순히 코루틴만 보면 사용하기에는 편해 보인다.
실제로 작성하면 편하다는 것을 느낄 수 있다.
하지만 코드가 복잡해질 수록 생각해질 것도 많아진다.
앱을 제작하다가 보면 의도하던 것과 다르게 굴러가는 것을 확인할 수 있다.
1) 메모리 누수
코루틴은 기본적으로 메모리 누수를 방지하기 위해 자동으로 취소된다.
하지만 취소되지 않고 존재하는 경우도 있기에, 이에 주의하면서 코루틴을 취소하고 자원을 반환하는 것을 확인해야 한다.
2) 예외 처리
코루틴은 예외를 처리하기 위한 CoroutinExceptionHandler를 지정할 수 있다.
예외 처리는 코루틴이 예외를 던질 때, 이를 적절하게 처리하여 프로그램의 안정성을 유지하는 것이 중요하다.
3) 코루틴 스케줄링
코루틴이 실행되는 스레드를 지정하기 위해 스케줄러를 사용할 수 있다.
따라서, 적절한 스케줄러를 선택하고, 작업을 분리하여 최적의 성능을 발휘하는 것이 중요하다.
4) 코루틴 간의 데이터 공유
코루틴은 기본적으로 스레드 적으로 안전하지만, 여러 코루틴에서 같은 데이터를 사용할 경우 데이터 불일치 문제가 발생할 수 있다. 따라서, 공유 데이터에 대한 적절한 동기화 처리가 필요하다.
여기서 "각각의 코루틴은 해당 스레드의 데이터를 공유하고, 이를 순차적으로 접근하기에 오류가 발생하지 않는 거 아닌가?" 하는 의문점을 가질 수 있다.
하지만 모든 코루틴이 같은 스레드에서 작동하는 것이 아니다.
두 코루틴이 같은 공유 데이터를 사용하지만, 다른 스레드에서 작동할 수 있다. 바로 아래 코드와 같은 경우이다.
import kotlinx.coroutines.*
import java.util.concurrent.Executors
fun main() {
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
var count = 0
runBlocking {
launch(dispatcher) {
for (i in 1..10) {
count++
delay(100)
println("Coroutine 1: count = $count, thread = ${Thread.currentThread().name}")
}
}
launch {
for (i in 1..10) {
count++
delay(100)
println("Coroutine 2: count = $count, thread = ${Thread.currentThread().name}")
}
}
}
dispatcher.close()
}
newSingleThreadExecutor은 새로운 스레드를 생성하는 메서드이다.
이를 통해 두 코루틴을 count라는 공유 데이터를 사용하는 상황에서 다른 스레드에서 작동하게 만들었다.
구현하고자 하는 것은 카운트가 각각의 스레드에서 10씩 증가하여 총 20으로 마무리가 되는 것을 원하지만 실제 결과는 아래와 같이 나온다.
Coroutine 2: count = 1, thread = main @coroutine#2
Coroutine 2: count = 2, thread = main @coroutine#2
Coroutine 1: count = 1, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 3, thread = main @coroutine#2
Coroutine 1: count = 2, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 4, thread = main @coroutine#2
Coroutine 1: count = 3, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 5, thread = main @coroutine#2
Coroutine 1: count = 4, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 6, thread = main @coroutine#2
Coroutine 1: count = 5, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 7, thread = main @coroutine#2
Coroutine 1: count = 6, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 8, thread = main @coroutine#2
Coroutine 1: count = 7, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 9, thread = main @coroutine#2
Coroutine 1: count = 8, thread = pool-1-thread-1 @coroutine#1
Coroutine 2: count = 10, thread = main @coroutine#2
Coroutine 1: count = 9, thread = pool-1-thread-1 @coroutine#1
Coroutine 1: count = 10, thread = pool-1-thread-1 @coroutine#1
이런 경우에 두 코루틴이 사용하는 공유 데이터에 대한 동기화가 필요한 것이다.
이에 대표적인 사용되는 것은 AtomicInteger과 Mutex가 있다.
AtomicInteger이 좀 더 간단한 해결 방법이고, Mutex는 좀 더 강력한 해결 방법이다.
예시 코드와 결과는 아래와 같다.
<AtomicInteger>
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicInteger
val counter = AtomicInteger(0)
suspend fun coroutine1() {
for (i in 1..10) {
val count = counter.incrementAndGet()
println("Coroutine 1: count = $count, thread = ${Thread.currentThread().name} @coroutine#${coroutineContext[CoroutineId]}")
delay(100)
}
}
suspend fun coroutine2() {
for (i in 1..10) {
val count = counter.incrementAndGet()
println("Coroutine 2: count = $count, thread = ${Thread.currentThread().name} @coroutine#${coroutineContext[CoroutineId]}")
delay(200)
}
}
fun main() = runBlocking<Unit> {
launch(Dispatchers.Default) {
coroutine1()
}
launch {
coroutine2()
}
}
Coroutine 2: count = 1, thread = main @coroutine#2
Coroutine 2: count = 2, thread = main @coroutine#2
Coroutine 1: count = 3, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 2: count = 4, thread = main @coroutine#2
Coroutine 1: count = 5, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 2: count = 6, thread = main @coroutine#2
Coroutine 2: count = 7, thread = main @coroutine#2
Coroutine 1: count = 8, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 2: count = 9, thread = main @coroutine#2
Coroutine 2: count = 10, thread = main @coroutine#2
Coroutine 1: count = 11, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 2: count = 12, thread = main @coroutine#2
Coroutine 1: count = 13, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 2: count = 14, thread = main @coroutine#2
Coroutine 2: count = 15, thread = main @coroutine#2
Coroutine 1: count = 16, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 1: count = 17, thread = DefaultDispatcher-worker-1 @coroutine#3
Coroutine 2: count = 18, thread = main @coroutine#2
Coroutine 2: count = 19, thread = main @coroutine#2
Coroutine 1: count = 20, thread = DefaultDispatcher-worker-1 @coroutine#3
<Mutex>
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
val mutex = Mutex()
var count = 0
suspend fun coroutine1() {
for (i in 1..10) {
mutex.withLock {
count++
println("Coroutine 1: count = $count, thread = ${Thread.currentThread().name}")
}
delay(100)
}
}
suspend fun coroutine2() {
for (i in 1..10) {
mutex.withLock {
count++
println("Coroutine 2: count = $count, thread = ${Thread.currentThread().name}")
}
delay(200)
}
}
fun main() = runBlocking<Unit> {
launch(Dispatchers.Default) {
coroutine1()
}
launch {
coroutine2()
}
}
Coroutine 1: count = 1, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 2: count = 2, thread = main @coroutine#3
Coroutine 1: count = 3, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 2: count = 4, thread = main @coroutine#3
Coroutine 1: count = 5, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 1: count = 6, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 2: count = 7, thread = main @coroutine#3
Coroutine 1: count = 8, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 1: count = 9, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 2: count = 10, thread = main @coroutine#3
Coroutine 1: count = 11, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 1: count = 12, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 2: count = 13, thread = main @coroutine#3
Coroutine 1: count = 14, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 1: count = 15, thread = DefaultDispatcher-worker-1 @coroutine#2
Coroutine 2: count = 16, thread = main @coroutine#3
Coroutine 2: count = 17, thread = main @coroutine#3
Coroutine 2: count = 18, thread = main @coroutine#3
Coroutine 2: count = 19, thread = main @coroutine#3
Coroutine 2: count = 20, thread = main @coroutine#3
Atomic이 좀 더 간단하지만, 값 수정이 빈번하면 두 스레드에 있는 코루틴이 비동기적으로 동시에 접근하여 한 코루틴이 접근에 실패하는 불필요한 오버헤드가 발생할 수 있다.
Mutex는 안전하지만 코드가 보다 싶이 좀 복잡해지고, 성능이 약간 저하될 수 있다.
5) 무한 루프
코루틴 내에서 무한 루프를 돌리면, 다른 코루틴이 실행되지 않고 대기하게 된다.
따라서, 코루틴 내에서 무한 루프를 사용할 경우, yield나 delay 등을 이용하여 다른 코루틴이 실행될 수 있도록 해야 한다.
적다보니 생각보다 길어졌다. 코루틴은 처음 접근할 때는 쉬워보이지만, 파고들면 파고들 수록 신경쓸 것도 많아지고, 복잡해져서 제대로 효율적으로 다루기는 힘든 것 같다.
'Kotlin > 개념 정리' 카테고리의 다른 글
| 개념 정리 : 데이터 바인딩 (0) | 2023.02.17 |
|---|---|
| 개념 정리 : 뷰 바인딩 (0) | 2023.02.17 |
| 개념 정리 : ViewPager - 기본 (0) | 2023.02.16 |
| 개념 정리 : 콜백 함수(Callback Function) (0) | 2023.02.16 |
| 개념 정리 : 안드로이드 Activity 생명 주기 (Activity Life Cycle) (0) | 2023.02.15 |