kotlin

반응형
MongoDB find Query 예외

오늘은 MongoDB find query를 사용하면서 예상치 못한 예외에 직면했던 내용을 공유하려고 합니다.
먼저 Toy project 구성한 환경입니다.

  1. Spring Boot
  2. Webflux
  3. ReactiveMongodb
  4. Kotlin

The Probloem

Caused by: org.springframework.data.mongodb.UncategorizedMongoDbException: Query failed with error code 2 and error message 'Field 'locale' is invalid in: { locale: "order" }' on server localhost:27017; nested exception is com.mongodb.MongoQueryException: Query failed with error code 2 and error message 'Field 'locale' is invalid in: { locale: "order" }' on server localhost:27017

Order collection에서 locale field?? 제가 사용하는 field는 아닌데?? 정말이지.. 원인을 알 수가 없었고.. 너무 생뚱맞은 예외 메시지입니다. 원인은 바로... 아래 그림을 보시면.. Data Class에 MongoDB document를 선언했는데, 자동 완성 기능을 사용하다보니 바로.. 오타가 들어갔습니다.
Collection을 Collation으로 Collation는 MongoDB에서 데이터 정렬에 사용하는 예약어 입니다........
정말 어처구니없는 실수로... 2시간을 날려버린 거 같네요..

The Soultion

오타를 막기 위한 해결책 몇 가지 억지로 적어봅니다.

  1. 별칭 없이 작성하기..
    @Document("order")
    data class Order
  2. @Document 자체를 생략해도.. data class name을 가지고 Document 유추가 가능하기에.. 좀 억지 같네요..ㅎㅎ
    data class Order

그럼에도 가장 좋은 해결책은 오타가 발생하지 않도록 자동완성 사용 시 항상 주의를 기울이는 습관이 필요할 거 같습니다.

오늘은 오타로 인해 고생했던 내용을 공유하였습니다.

반응형
반응형
Kotlin Package Naming Rule 예외

file:///Users/jongpillee/workspace/kotlin-spring-data-mongo/src/main/kotlin/kotlin/mongo/SpringBootApplication.kt:1:1 Only the Kotlin standard library is allowed to use the 'kotlin' package

자바에서는 자바의 Package명 첫 Word를 Java로 시작해도 예외는 발생하지 않지만, Java와 달리 Kotlin에서는 kotlin이라는 Package name을 작성하게 될 경우 위와 같은 Exception이 발생합니다

The problem

위 Path에서 /src/main/kotlin/kotlin/ 이부분이 Exception이 발생합니다. 네이밍 작성 시 kotlin/ 다음 kotlin으로 사용을 할 수가 없다는 의미입니다.

Solution

해결 방법은 간단합니다. 아래와 같이 첫 패키지명으로 kotlin을 사용하지 않으면 됩니다. ㅎㅎㅎ
두 번째로 작성할 경우 이슈 없이 잘 동작합니다.

반응형
반응형
Kotlin Concurrency
  • 코루틴
  • 동시성 통신
  • 자바 동시성 사용

13.1 Coroutine

kotlin에서 동시성 처리를 위한 메커니즘으로 명령형 스타일의 코드로 작성해도 컴파일러가 코드를 효율적인 비동기 계산으로 자동변환한다. 즉, 일시 중단 가능한 함수 개념을 가지고 있다.

자바의 동시성 처리 단점? 멀티 스레드로 동시성 처리가 가능하지만 블러킹 연산(sleep, join, wait) 스레드를 실행 재개시 문맥 전환 비용, 스레드마다 시스템 자원이 많이 소모 비동기 연산을 위한 다양한 라이브러리가 있지만 일반적인 명령형 흐름제어가 아니라 복잡도가 높아짐(callback)

13.1.1 코루틴과 일시 중단 함수

코루틴의 라이브러리를 뒷받침하는 기본 요소는 일시 중단 함수

  • 코루틴의 핵심은 일시 중단 함수 개념
  • suspend 변경자로 표기
  • 여러 가지 코루틴 빌더(launch, async, runBlocking)
suspend fun kotlin() {
  println("Hello")
  delay(100L) // 일시중단 함수 (스레드를 블럭시키지 않고 다른 작업 수행)
  println("World")
}

일반 함수에서는 일시 중단 함수를 호출할 수 없다.

fun kotlin() {
  println("Hello")
  delay(100L) // error: delay is a suspend function
  println("World")
}

13.1.2 코루틴 빌더

코루틴 빌더들

  • launch { } : Job을 return 하는 coroutine
  • async { } : Deffered 를 이용하여 single value를 return 하는 coroutine. Deferred value에. await()를 호출하여 최종 결과를 얻을 수 있다. Deferred는 Job이라, cancel 할 수도 있다.
  • runBlocking { } : current thread 를 block 시키는 coroutine

launch vs async 비교

함수결과 반환반환 타입

launch X Job
async O Deferd<T>

launch() 빌더는 코루틴을 시작하고, 코루틴을 실행 중인 작업의 상태를 추적하고 변경할 수 있는 Job 객체를 반환한다.

  • 아래 프로그램의 결과를 보면 병렬적으로 실행된 것을 확인 가능
  • 다만, 실행 순서가 항상 일정하지는 않음
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.lang.System.currentTimeMillis
​
fun main() { // main이 suspend 함수가 아님에 유의
    val time = currentTimeMillis()
​
    GlobalScope.launch {
        delay(100) // thread 블럭 시키지 않고 코드 수행만 멈추는 특별한 suspending function.
        println("Task 1 finished in ${currentTimeMillis() - time} ms")
    }
​
    GlobalScope.launch {
        delay(100)  
        println("Task 2 finished in ${currentTimeMillis() - time} ms")
    }
    // 스레드를 블럭
    Thread.sleep(200) 
}
​
// result
Task 2 finished in 176 ms
Task 1 finished in 176 ms

async() 빌더는 결과를 Deferred 인스턴스로 돌려주고, await() 메서드를 통해 계산 결과에 접근할 수 있다.

suspend fun main() {
    val message = GlobalScope.async {
        delay(100)
        "abc"
    }
​
    val count = GlobalScope.async {
        delay(100)
        1 + 2
    }
​
    delay(200)
    // await() 실행되면 코루틴은 일시중단되고 결과가 반환되면 코루틴은 다시 재개되고 결과를 출력
    val result = message.await().repeat(count.await())   // await()는 일시중단 가능한 코루틴 내부에서만 사용 가능
    println(result)
}
​
// result 
abcabcabc

Launch()와 async() 빌더는 일시 중단 함수 내부에서 스레드 호출을 블록 시키지는 않지만, 백그라운드 스레드를 공유하는 풀을 통해 작업을 실행한다.

runBlocking() 빌더는 디폴트로 현재 스레드에서 코루틴을 만들고 코루틴이 완료될 때까지 현재 스레드의 실행을 블럭시킨다.

  • 코루틴이 끝나면 일시 중단 람다의 결괏값이 된다.
  • 취소되면 예외를 발생.
  • 블러킹 호출과 넌블러킹 호출 사이의 다리 역할을 위해 사용
  • 테스트나 메인 함수에서 최상위 빌더로만 사용해야 함
import kotlinx.coroutines.*
​
fun main() {
    // 공유풀 백그라운드 스레드를 새로 할당 받아 실행
    GlobalScope.launch {
        delay(100)
        println("Background task: ${Thread.currentThread().name}")
    }
    // 메인 스레드에서 실행되고 메인 스레드를 블럭 시킴
    runBlocking {
        println("Primary task: ${Thread.currentThread().name}")
        delay(200)
    }
}
​
// result
Primary task: main @coroutine#2
Background task: DefaultDispatcher-worker-1 @coroutine#1

13.1.3 코루틴 영역과 구조적 동시성

동시성 작업 사이의 부모 자식 관계로 인해 이런 실행 시간제한이 가능하다. 어떤 코루틴을 다른 코루틴의 문맥에서 실행하면 후자가 전자의 부모가 된다. 이경우 자식의 실행이 모두 끝나야 부모가 끝날 수 있도록 부모와 자식의 생명 주기가 연관된다. 이런 기능을 구조적 동시성(structured concurrency)이라고 한다.

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
​
fun main() {
    runBlocking {  // 부모 코루틴
        println("Parent task started ${Thread.currentThread().name}")
​
        launch { // 자식 코루틴
            println("Task A started ${Thread.currentThread().name}")
            delay(200)
            println("Task A finished ${Thread.currentThread().name}")
        }
​
        launch { // 자식 코루틴
            println("Task B started ${Thread.currentThread().name}")
            delay(200)
            println("Task B finished ${Thread.currentThread().name}")
        }
​
        delay(100)
        println("Parent task finished ${Thread.currentThread().name}")
    }
    println("Shutting down... ${Thread.currentThread().name}")
}
​
​
// result
Parent task started main @coroutine#1
Task A started main @coroutine#2
Task B started main @coroutine#3
Parent task finished main @coroutine#1 // 지연이 100이기에 runBlocking() 호출의 일시 중단 람다 본문이 먼저 출력됨
Task A finished main @coroutine#2
Task B finished main @coroutine#3
Shutting down... main

최상위 코루틴이 시작하고 CoroutineScope 인스턴스 안에서 launch를 호출해 두 가지 자식 코루틴을 시작한다.

실행 순서 : 부모 runBlocking 코루틴 -> 각 자식 launch 코루틴 시작 -> 자식 200 delay -> 부모 runBlocking 코루틴 실행 -> 각 자식 launch의 코루틴 출력 -> 자식 코루틴이 종료될 때까지 기다린 후 runBlocking 실행 -> 메인 스레드 블록 해제 후 main 출력되고 종료

coroutineScope()와 runBlocking()의 가장 큰 차이는 coroutineScope()가 일시 중단 함수라 현재 스레드를 블럭시키지 않는다는 점이다.

import kotlinx.coroutines.*
​
fun main() {
    runBlocking {  // 부모 코루틴
        println("Custom scope start")
​
        coroutineScope {  // 아래 자식 코루틴 실행이 끝날 때까지 일시 중단 처리 됨
            launch {  // 자식 코루틴
                delay(100)
                println("Task 1 finished")
            }
​
            launch {  // 자식 코루틴
                delay(100)
                println("Task 2 finished")
            }
        }
​
        println("Custom scope end")
    }
}
​
// result
Custom scope start main @coroutine#1
Task 1 finished main @coroutine#2
Task 2 finished main @coroutine#3
Custom scope end main @coroutine#1

13.1.4 코루틴 문맥

코루틴은 항상 특정 문맥에서 실행되는데 코루틴을 감싸는 변수 영역의 CoroutineContext 프로퍼티를 통해 이 문맥에 접근할 수 있고, 키-값 쌍으로 이뤄진 불변 컬렉션이다.

GlobalScope.launch {
  // 현재 잡을 얻고 "Task is active: true" 를 출력
  println("Task is active: $coroutineContext[Job.Key]!!.isActive")
}

13.2 코루틴 흐름 제어와 잡 생명 주기

잡은 동시성 작업의 생명 주기를 표현하는 객체다. 잡을 사용하면 작업 상태를 추적하고 필요할 때 작업을 취소할 수 있다.

Active: 작업이 시작됐고 아직 완료나 취소로 끝나지 않은 상태로, 보통 디폴트 상태.

  • CoroutineStart.DEFAULT는 디폴트 동작이며, 잡을 즉시 시작한다.
  • CoroutineStart.LAZY는 잡을 자동으로 시작하지 말라는 뜻으로, 잡이 New 상태가 되고 시작을 기다리게 된다.

Completing: 코루틴이 일시 중단 람다 블록의 실행을 끝낸 잡의 상태(완료 중)

  • 자식들의 완료를 기다리는 상태로 잡은 모든 자식이 완료될 때까지 이 상태를 유지

Completed: 모든 자식이 완료된 상태(완료 됨)

New 상태의 잡에 대해 start() or join() 메서드를 호출하면 잡이 시작되면서 활성화 상태가 된다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {  // 부모 코루틴
        val job = launch(start = CoroutineStart.LAZY) {  // 자식 코루틴
            println("Job started")
        }

        delay(100)

        println("Preparing to start...")
        job.start() // start 메서드가 호출되면 자식 코루틴 시작..
    }
}

// result
Preparing to start...
Job started

자식 코루틴의 시작을 부모 코루틴이 메시지를 호출한 뒤로 미룬다.

잡의 join() 메서드를 사용하면 조인 대상 잡이 완료될 때까지 현재 코루틴을 일시 중단시킬 수 있다. 두 자식 메시지의 실행이 끝난 후에 루트 코루틴이 출력되도록 보장해 준다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val job = coroutineContext[Job.Key]!!
        val jobA = launch { println("This is task A") }
        val jobB = launch { println("This is task B") }

        jobA.join()
        jobB.join()
				
        println("${job.children.count()} children running")
    }
}

// result
This is task A
This is task B
0 children running  

현재 잡의 상태를 추적할 수 있는 프로퍼티들.

상태가 "완료됨, "취소됨"인 잡의 isCompleted가 true이기에, Cancelled와 Completed는 isCancelled로 구분할 수 있다.

13.2.1 취소

잡의 cancel() 메서드를 호출하면 잡을 취소할 수 있다. 취소에는 협력이 필요하다. 즉, 취소 가능한 코루틴이 스스로 취소가 요청됐는지 검사해서 적절히 반응해줘야 한다.

import kotlinx.coroutines.*

// 코루틴을 취소했지만 취소를 위해 협력되지 않기에 취소 불가함
suspend fun main() {
    val squarePrinter = GlobalScope.launch(Dispatchers.Default) {
        var i = 1
        while (true) {
            println(i++)
        }
    }

    delay(100) // 자식 잡이 어느 정도 실행될 시간을 준다
    squarePrinter.cancel()
}

// isActive 프로퍼티를 현재 잡의 활성화 상태
suspend fun main() {
    val squarePrinter = GlobalScope.launch(Dispatchers.Default) {
        var i = 1
        while (isActive) {  // 취소 가능한 형태의 루프
            println(i++)
        }
    }

    delay(100) 
    squarePrinter.cancel()  // 부모 코루틴이 취소되면 자동으로 모든 자식의 실행을 취소
}

isActive 프로퍼티는 CoroutineScope 객체가 자체적으로 지원하는 프로퍼티로, 코루틴의 취소 상태를 Boolean 타입으로 갖고 있다. 부모 코루틴이 cancel() 메서드를 호출하면 squarePrinter의 상태가 취소 중으로 바뀌고, 그다음 isActive 검사를 통해 루프를 종료시킬 수 있다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("${Thread.currentThread().name} : 코루틴아 달려라 $i ...")
            delay(300L)
        }
    }

    delay(1000L)
    println("${Thread.currentThread().name} : 코루틴아 이제 그만 쉬어")

    //job.cancel()  // Job 취소
    //job.join()    // Job 끝날 때 까지 대기
    job.cancelAndJoin()  // 취소하고 끝날 때 까지 대기
    println("${Thread.currentThread().name} : 코루틴 종료")
}

// result
main @coroutine#2 : 코루틴아 달려라 0 ...
main @coroutine#2 : 코루틴아 달려라 1 ...
main @coroutine#2 : 코루틴아 달려라 2 ...
main @coroutine#2 : 코루틴아 달려라 3 ...
main @coroutine#1 : 코루틴아 이제 그만 쉬어
main @coroutine#1 : 코루틴 종료

13.2.2 타임아웃

작업이 완료되기를 무작정 기다리 수 없어서 타임아웃을 설정이 필요할 경우

  • withTimeout() : TimeoutCancellationException 반환
  • withTimeoutOrNull() : 예외 대신 null 반환
import kotlinx.coroutines.*
import java.io.File

fun main() {
    runBlocking {
        val asyncData = async { File("data.txt").readText() }
        try {
            val text = withTimeout(50) { asyncData.await() }
            println("Data loaded: $text")
        } catch (e: Exception) {
            println("Timeout exceeded")
        }
    }
}

13.2.3 코루틴 디스패치하기

코루틴은 스레드와 무관하게 일시 중단 가능한 계산을 구현할 수 있게 해 주지만, 코루틴을 실행하려면 여전히 스레드와 연관시켜야 한다. 코루틴 라이브러리에는 특정 코루틴을 실행할 때 사용할 스레드를 제어하는 특별한 코루틴 디스패처 컴포넌트가 있다.

  • 디스패처는 코루틴 문맥의 일부
  • launch() or runBlocking() 등에서 지정할 수 있음
import kotlinx.coroutines.*

fun main() {
  runBlocking {
    // 전역 스레드 풀 디스패처를 사용해 코루틴을 실행한다
    launch(Dispatchers.Default) {
      println(Thread.currentThread().name) // DefaultDispatcher-worker-1
    }
    println(Thread.currentThread().name) // main
  }
}

코루틴 디스패처는 병렬 작업 사이에 스레드를 배분해 주는 자바 실행기(executor)와 비슷함.

스레드 개수를 지정하려면 asCoroutineDispatcher() 확장 함수를 사용하여 코루틴 문맥으로 전환할 수 있다.

import kotlinx.coroutines.*
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.atomic.AtomicInteger

fun main() {
    val id = AtomicInteger(0)

    val executor = ScheduledThreadPoolExecutor(5) { runnable ->
        Thread(
            runnable,
            "WorkerThread-${id.incrementAndGet()}"
        ).also { it.isDaemon = true }  // 코루틴 종료 후 자동으로 종료
    }

    executor.asCoroutineDispatcher().use { dispatcher ->
        runBlocking {
            for (i in 1..6) {
                launch(dispatcher) {
                    println(Thread.currentThread().name)
                    delay(1000)
                }
            }
        }
    }
}

// result
WorkerThread-1 @coroutine#2
WorkerThread-4 @coroutine#5
WorkerThread-2 @coroutine#3
WorkerThread-3 @coroutine#4
WorkerThread-5 @coroutine#6
WorkerThread-2 @coroutine#7

asCoroutineDispatcher()를 호출하면 ExecutorCoroutineDispatcher를 반환하고, Closeable 인스턴스도 구현하기에 시스템 자원을 해제하려면 close() or use() 함수로 해제가 가능하다.

코루틴 라이브러리에서 제공하는 디스패처

  • Dispatchers.Default: shared common pool 기반으로 pool 크기는 디폴트로 사용 가능한 CPU 코어 수이거나 일반적인 background job을 수행
  • Dispatchers.IO: shared pool 기반으로 파일을 읽고 쓰는 것처럼 잠재적으로 블러킹 될 수 있는 I/O 작업에 최적화.
  • Dispatchers.Main: 사용자 입력이 처리되는 UI 스레드에서 사용.(android view..)

newFixedThreadPoolContext() or newSingleThreadPoolContext()를 사용하여 새로운 스레드를 생성하거나 사용하는 디스패처를 만들 수도 있다. 다만, 실험적인 기능이라 추후 대치될 예정.

디스패처를 명시적으로 지정하지 않으면 코루틴이 시작한 영역부터 자동으로 상속된다.

import kotlinx.coroutines.*

fun main() {
    runBlocking { // main
        println("Root: ${Thread.currentThread().name}")

        launch {  // main
            println("Nested, inherited: ${Thread.currentThread().name}")
        }
				// 코루틴 문맥이 실행될 스레드 지정
        launch(Dispatchers.Default) {  // DefaultDispatcher-worker-1
            println("Nested, explicit: ${Thread.currentThread().name}")
        }
    }
}

// result
Root: main @coroutine#1
Nested, inherited: main @coroutine#2
Nested, explicit: DefaultDispatcher-worker-1 @coroutine#3

부모 코루틴이 없으면 암시적으로 Dispatchers.Default로 디스패처를 가정한다. 다만 runBlocking() 빌더는 현재 스레드를 사용한다. 특정 스레드에서만 실행하고 싶을 경우 withContext() 함수를 사용하면 가능하다.

import kotlinx.coroutines.*

@Suppress("EXPERIMENTAL_API_USAGE")
fun main() {
    newSingleThreadContext("Worker").use { worker ->
        runBlocking {
            println(Thread.currentThread().name)   // main
            withContext(worker) {
                println(Thread.currentThread().name) // Worker
            }
            println(Thread.currentThread().name)   // main
        }
    }
}

중단 가능 루틴의 일부를 한 스레드에서만 실행하고 싶을 때 유용하다.

13.2.4 예외 처리

lunch() 빌더는 예외를 부모 코루틴에게 전달하고, CoroutineExceptionHandler에 의해 처리된다.

  • 부모 코루틴이 (자식에게서 발생한 오류와) 똑같은 오류로 취소되고, 나머지 자식도 모두 취소됨
  • 자식들이 모두 취소되고 나면 부모는 예외를 코루틴 트리의 윗부분으로 전달(루트 코루틴)
import kotlinx.coroutines.*

fun main() {
    // 커스텀 핸들러..
    runBlocking {  // 최상위 코루틴
        launch { // 첫번째 자식 코루틴
            throw Exception("Error in task A")
            println("Task A completed")
        }

        launch { // 두번째 자식 코루틴
            delay(1000)
            println("Task B completed")
        }

        println("Root")
    }
}
// result
Root
Exception in thread "main" java.lang.Exception: Error in task A

최상위 코루틴 start -> 첫 번째 코루틴 예외 -> 최상위 코루틴 취소 -> 최상위 하위 자식 코루틴 취소

최상위에서 지정된 핸들러가 없기 때문에 Thread.uncauhtExceptionHandler에 의한 디펄트 동작을 실행 한다.

CoroutineExceptionHandler는 현재 코루틴 문맥과 던져진 예외를 인자로 전달받는다.

fun handleException(context: CoroutineContext, exception: throwable)

val handler = CoroutineExceptionHandler{ _, exception -> // 인자 2개인 람다를 받는 함수 생성
	println("Caught $exception")
}

코루틴 예외 핸들러의 인스턴스가 예외를 처리하도록 지정하려면 코루틴 문맥에 인스턴스를 넣어야 한다.

import kotlinx.coroutines.*

suspend fun main() {
    val handler = CoroutineExceptionHandler{ _, exception ->
        println("Caught $exception")
    }
    // 전역 영역에서 실행 및 handler 인스턴스 선언
    GlobalScope.launch(handler) {
        launch {
            throw Exception("Error in task A")
            println("Task A completed")
        }

        launch {
            delay(1000)
            println("Task B completed")
        }

        println("Root")
    }.join()
}
// result
Root
Caught java.lang.Exception: Error in task A

CoroutineExceptionHandler는 전역 영역에서 실행된 코루틴에 대해서만 정의할 수 있고, CoroutineExceptionHandler가 정의된 코루틴의 자식에 대해서만 적용된다. 앞선 runBlocking()의 경우 코루틴이 전역 영역에서 실행되지 않아 디펄트 핸들러를 사용하게 된다.

async() 빌더는 코루틴 데이터에 접근하는 수신 함수 await()을 호출할 때 예외를 발생시키고, 디펄트 핸들러가 호출된다. 하위 코루틴을 try - catch블록으로 예외를 처리해도 여전히 프로그램은 예외와 함께 중단된다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val deferredA = async {
            throw Exception("Error in task A")
            println("Task A completed")
        }

        val deferredB = async {
            println("Task B completed")
        }

        try {
            deferredA.await()  // 수신 함수 호출시 예외 발생
            deferredB.await()
        } catch (e: Exception) {
            println("Caught $e")
        }
        println("Root")
    }
}
// result
Caught java.lang.Exception: Error in task A
Root
Exception in thread "main" java.lang.Exception: Error in task A

슈퍼바이저 잡은 취소가 아래 방향으로만 전파된다. 슈퍼바이저 잡이 취소 되면 자동으로 자신의 모든 자식을 취소한다. 하지만 슈퍼바이저가 아니라 자식이 취소되면 슈퍼바이저나 슈퍼바이저의 다른 자식들은 영향을 받지 않는다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        supervisorScope {
            val deferredA = async {
                throw Exception("Error in task A")
                println("Task A completed")
            }

            val deferredB = async {
                println("Task B completed")
            }

            try {
               deferredA.await()
               deferredB.await()
            } catch (e: Exception) {
                println("Caught $e")
            }
           
            println("Root")
        }
    }
}
// result
Task B completed
Caught java.lang.Exception: Error in task A
Root

Task B와 루트 코루틴은 영향 없이 출력된다.

13.3 동시성 통신

스레드 안정성을 유지하면서 여러 동시성 작업 사이에 효율적으로 데이터를 공유할 수 있게 해주는 코루틴 라이브러리의 고급 기능 소개 하자.

13.3.1 채널

데이터 스트림을 코루틴 사이의 공유할 수 있는 메커니즘으로, 데이터를 전송하는 send() 메서드와 데이터를 받는 receive() 메서드를 제공한다.

채널 메커니즘

  • 자바 Blocking queue와 유사하지만, 스레드를 블록 시키지 않음.
  • 양방향 커뮤니케이션에 의한 데이터 스트림을 공유.
    • 채널 버퍼가 꽉 차게 되면 send() 호출이 일시 중단되고, 채널 버퍼가 비어 있을 경우 채널에서 송신할 때까지 receive() 호출은 일시 중단.
  • 채널에서 송신한 순서 그대로 원소를 수신되도록 보장. (FIFO)
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*
import kotlin.random.Random

fun main() {
    runBlocking {
        val streamSize = 5
        val channel = Channel<Int>(3) // 채널 용량 = 3

        launch {
            for (n in 1..streamSize) {
                delay(Random.nextLong(100))
                val square = n*n
                println("Sending: $square")
                channel.send(square)  // 정수 제곱 값의 스트림을 전송함
            }
        }

        launch {
            for (i in 1..streamSize) {
                delay(Random.nextLong(100))
                val n = channel.receive()
                println("Receiving: $n") // 생성된 스트림을 수신함
            }
        }
    }
}
// result
Sending: 1
Receiving: 1
Sending: 4
Receiving: 4
Sending: 9
Receiving: 9
Sending: 16
Receiving: 16
Sending: 25
Receiving: 25

Channel Buffer Type

  • Channel.UNLIMITED (= Int.MAX_VALUE): 채널의 용량은 제한이 없고, 내부 버퍼는 필요에 따라 증가함.
    • 송신 측은 일시 중단 하지 않고, 수신 측의 경우 버퍼가 비어있으면 일시 중단.
  • Channel.RENDEZVOUS (= 0): 채널의 버퍼를 설정하지 않을 경우 설정된다. 내부 버퍼가 없는 상태.(default)
    • 송신 측과 수신 측 모두 데이터 스트림이 발생할 때까지 일시 중단 됨.
  • Channel.CONFLATED (= -1): 버퍼는 사이즈는 1이고, 송신된 값이 하나로 합쳐지는 채널.
    • 수신 측이 송신 측 보다 처리 속도가 느리거나, 송신 측에 새로운 데이터 스트림이 들어오면 기존의 값을 덮어쓰고 마지막 값을 전송함. 즉, 송신 측은 일시 중단 하지 않음.

Channel <Int>(Channel.RENDEZVOUS)는

val channel = Channel<Int>(Channel.RENDEZVOUS) // 0

//result
Sending: 1
Receiving: 1
Sending: 4
Receiving: 4
Sending: 9
Receiving: 9
Sending: 16
Receiving: 16
Sending: 25
Receiving: 25

값이 합쳐지는 Channel <Int>(Channel.CONFLATED)는 데이터 스트림 중 송신 측에서 일부가 누락되거나 버려 저도 무관할 경우에 적합하다.

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val streamSize = 5
        val channel = Channel<Int>(Channel.CONFLATED)

        launch {
            for (n in 1..streamSize) {
                delay(100)
                val square = n*n
                println("Sending: $square")
                channel.send(square)
            }
        }

        launch {
            for (i in 1..streamSize) {
                delay(200)             // 소비자 지연 시간 2배
                val n = channel.receive()
                println("Receiving: $n")
            }
        }
    }
}

// result
Sending: 1
Receiving: 1
Sending: 4
Sending: 9
Receiving: 9
Sending: 16
Sending: 25
Receiving: 25

수신자 코루틴이 1부터 streamSize까지 이터레이션 하므로 수신할 것으로 기대하기에 프로그램이 종료되지 않는다. 이 경우 Channle API는 Close() 메서드를 사용해 더 이상 데이터 스트림을 보내지 않는다는 신호를 보낼 수 있다.

소비자 쪽에서는 이터레이션 횟수를 고정하는 대신 채널에서 들어오는 데이터에 대해 이터레이션을 할 수 있다.

launch {
  for (n in 1..streamSize) {
    delay(100)
    val square = n*n
    println("Sending: $square")
    channel.send(square)
  }
  channl.close() // 채널 종료 메서드 호출
}

launch {
	for(n in channel) {
		println("Receiving: $n")
		delay(200)
	}
}

데이터 교환이 완료되면 정상적으로 프로그램이 종료된다.

소비자 쪽에서는 명시적인 이터레이션 대신 consumeEach() 함수를 통해 모든 채널 콘텐츠를 얻어서 사용할 수 있다.

Channle.consumerEach {
	println("Receiving: $it")
	delay(200)
}

consumerEach() 함수는 송신 측 및 수신 측 예외시 CloseSendChannelException 예외가 발생하고, channel 이 닫히고 코루틴이 종료된다.

한 채널의 데이터 스트림을 여러 코루틴이 동시에 receive() 하는 Fan out이라고 한다.

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*
import kotlin.random.Random

fun main() {
    runBlocking {
        val streamSize = 5
        val channel = Channel<Int>(2)

        launch {
            for (n in 1..streamSize) {
                val square = n*n
                println("Sending: $square")
                channel.send(square)
            }
            channel.close()
        }

        for (i in 1..3) {  // 3개의 데이터 스트림 소비자 생성
            launch {
                for (n in channel) {
                    println("Receiving by consumer #$i: $n")
                    delay(Random.nextLong(100))
                }
            }
        }
    }
}

// result
Sending: 1
Sending: 4
Sending: 9
Receiving by consumer #1: 1
Receiving by consumer #2: 4
Receiving by consumer #3: 9
Sending: 16
Sending: 25
Receiving by consumer #2: 16
Receiving by consumer #1: 25

위와 반대의 케이스로 여러 생산자 코루틴이 한 채널에 데이트 스트림을 전송하고 한 명의 수신 측 코루틴이 소비하는 Fan in도 있다. 일반적인 경우 여러 생산자와 여러 소비자가 여러 채널을 공유할 수도 있다.

13.3.2 생산자

produce()라는 코루틴 빌더를 사용해 동시성 데이터 스트림을 생성할 수 도 있다. Channel과 비슷한 send() 메서드를 제공하는 ProducerScope 영역을 도입해 준다.

import kotlinx.coroutines.channels.*
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val channel = produce {
            for (n in 1..5) {
                val square = n*n
                println("Sending: $square")
                send(square)
            }
        }

        launch {
            channel.consumeEach { println("Receiving: $it") }
        }
    }
}
// result
Sending: 1
Receiving: 1
Sending: 4
Sending: 9
Receiving: 4
Receiving: 9
Sending: 16
Sending: 25
Receiving: 16
Receiving: 25

이 경우 채널을 명시적으로 닫을 필요가 없고, 코루틴이 종료되면 produce() 빌더가 채널을 자동으로 닫아준다. 예외 처리 관점에서 produce()는 async()/await() 정책을 따른다.

13.3.3 티커(obsolete)

Ticker Channel은 특별한 채널로, 한 원소와 다음 원소의 발생 시점 이후에 delay 시간이 지나면 Unit 값을 송신한다.

  • delayMillis: 티커 원소의 발생 시간 간격을 밀리초 단위로 지정한다.
  • initalDelayMillis: 티커 생성 시점과 원소가 최초로 발생하는 시점 사이의 시간 간격이다.
  • Context: 티커를 실행할 코루틴 문맥이다
  • Mode: 티커의 행동을 결정하는 TickerMode Enum이다.
    • TickerMode.FIXED_PERIOD: 생성되는 원소 사이의 시간 간격을 지정된 지연 시간에 최대한 맞추기 위해 실제 지연 시간을 조정한다.
    • TickerMode.FIXED_DELAY: 실제 흘러간 시간과 관계없이 delayMillis로 지정한 지연 시간만큼 시간을 지연시킨 후 다음 원소를 송신한다.
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val ticker = ticker(100)  // 100ms 마다 send()
    println(withTimeoutOrNull(50) { ticker.receive() }) // ticker delayMillis 100 이기에 delay 시간은 50ms 이라 titmeout 발생..
    println(withTimeoutOrNull(60) { ticker.receive() }) // 100ms 지남..
    delay(250)  // 250ms 일시 중단 하는데, 100ms 지나면 티커는 send를 1번 출한다. 그리고, 티커와 소비자 모두 150ms 동안 일시 중단 상태..
    println(withTimeoutOrNull(1) { ticker.receive() })  // 신호가 이미 보내졌기에 결과 반환...
    println(withTimeoutOrNull(60) { ticker.receive() }) // 그리고..250ms 가 지났다는 사실을 티커는 알게되서 딜레이시 시간을 100-50 = 50으로 조정 하기에 60ms 이내로 다시 출력할수 있게 됨.
    println(withTimeoutOrNull(60) { ticker.receive() }) // 이후 티커는 100ms을 대기하기에 60 이내에 응답을 못주고, 결국 못기다리고 timeout
}
// result
null
kotlin.Unit
kotlin.Unit
kotlin.Unit
null

현재 실험 단계에 있으며 미래 코루틴 라이브러리 버전에서는 언제든 변경될 수 있다.

13.3.4 액터(obsolete)

가변 상태를 스레드 안전하게 공유하는 방법을 구현하는 일반적인 방법으로, 코루틴에서 스레드 간에 메시지를 보내서 동시성 통신을 진행할 수 있는 수단을 제공하는 객체이다.

액터는 자기 자신에게 들어오는 메시지를 listen 하고, 자신의 상태를 바꾸면서 메시지에 응답할 수 있으며, 다른 메시지를 보낼 수 있고 새로운 액터를 시작할 수 있다. 결과를 생성하지 않고 Job을 시작한다는 점은 launch() 빌더와 비슷하다.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.actor

sealed class AccountMessage

class GetBalance(
    val amount: CompletableDeferred<Long>
) : AccountMessage()

class Deposit(val amount: Long) : AccountMessage()

class Withdraw(
    val amount: Long,
    val isPermitted: CompletableDeferred<Boolean>
) : AccountMessage()

fun CoroutineScope.accountManager(
    initialBalance: Long
) = actor<AccountMessage> {

    var balance = initialBalance

    for (message in channel) {
        when (message) {
            is GetBalance ->message.amount.complete(balance) // 액터 클라이언트에 결과 전달

            is Deposit -> {
                balance += message.amount
                println("Deposited ${message.amount}")
            }

            is Withdraw -> {
                val canWithdraw = balance >= message.amount
                if (canWithdraw) {
                    balance -= message.amount
                    println("Withdrawn ${message.amount}")
                }
                message.isPermitted.complete(canWithdraw)
            }
        }
    }
}

액터는 데이터를 받기 위해 채널을 사용하고, 기본적으로 rendezvous 채널을 사용한다. 액터 클라이언트에게 요청 결과를 돌려줄 때는 CompletableDeferred에서 complete() 메서드를 사용한다.

13.4 자바 동시성 사용하기

코틀린에서만 사용할 수 있는 코루틴 라이브러리 외에도 JVM 플랫폼에서는 JDK가 제공하는 동기화 요소를 활용할 수 있다.

13.4.1 스레드 시작하기

범용 스레드를 시작하려면, 스레드에서 실행하려는 실행 가능 객체에 대응하는 람다와 스레드 프로터피들을 지정해서 thread() 함수를 사용하면 된다.

  • start: 스레드를 생성하자마자 시작할지 여부(디펄트 true)
  • isDaemon: 스레드를 데몬 모드로 시작할지 여부(디펄트 false)
  • contextClassLoader: 스레드 코드가 클래스와 자원을 적재할 때 사용할 클래스 로더(디펄트 null)
  • name: 커스텀 스레드 이름(디폴트 null), Thread-1..... 등
  • priority: Thread.MIN_PRIORITY(=1) ~(=10) 사이의 값으로 정해지는 우선순위로 어떤 스레드가 얼마나 많은 CPU 시간을 배정받는지 결정.
  • block: () -> Unit 타입의 함숫값으로 새 스레드가 생성되면 실행할 코드
// 150ms 마다 메시지 출력하는 스레드
import kotlin.concurrent.thread

fun main() {
    println("Starting a thread...")

    thread(name = "Worker", isDaemon = true) { // setDaemon(true)
        for (i in 1..5) {
            println("${Thread.currentThread().name}: $i")
            Thread.sleep(150)
        }
    }

    Thread.sleep(500)
    println("Shutting down...")
}
// result
Worker: 5 Starting a thread...
Worker: 1
Worker: 2
Worker: 3
Worker: 4
Shutting down...

// timer() 함수로도 고정된 시간 간격으로 thread 실행 가능함
timer(period = 150, name = "Worker", daemon = true) {
	println("${Thread.currentThread().name}: ${++counter}")
}

13.4.2 동기화와 락

동기화는 특정 코드 블록이 한 스레드에서만 실행되는 것을 보장한다. 이런 코드 블록을 다른 스레드가 실행하고 있다면 이를 사용하려고 시도하는 스레드는 모두 대기 한다.

자바와 동일하게 코틀린도 동기화 블럭을 사용해 동기화해야 하는 구문 코드를 감싸주면 된다.

import kotlin.concurrent.thread

fun main() {
    var counter = 0
    val lock = Any()

    for (i in 1..5) {
        thread(isDaemon = false) {
            synchronized(lock) {
                counter += i // thread 가 연산 중일 때 다른 스레드는 대기..
                println(counter)
            }
        }
    }
}
// result
6 
10 
13 
15 
1

synchronized block이 없으면
??

일반적으로 synchronized() 함수는 람다의 반환값을 반환한다. 호출 시점의 중간 카운터를 읽을 수도 있다.

val currentCount = synchronized(lock) {counter}
println("count $currentCount")

자바에서는 메서드에 synchronized 변경자를 지정하여 동기화를 구현할 수도 있다. 코틀린에서는 @Synchronized 애너테이션으로 지정할 수 있다.

import kotlin.concurrent.thread

class Counter {
    private var value = 0
    @Synchronized fun addAndPrint(value: Int) {
        this.value += value
        println(value)
    }
}

fun main() {
    val counter = Counter()
    for (i in 1..5) {
        thread(isDaemon = false) { counter.addAndPrint(i) }
    }
}

// result
4 2 5 3 1

코틀린 코루틴 기반 동시성의 기초를 다뤘다. 일시 중단 함수를 통해 동시성 코드를 작성하는 방법 및 코루틴 빌더와 영역 및 문맥을 사용해 코루틴의 생명 주기를 관리하는 방법을 이해했다.

13장 동시성

배우는 내용

  • 코루틴
  • 동시성 통신
  • 자바 동시성 사용

13.1 코루틴

kotlin에서 동시성 처리를 위한 메커니즘으로 명령형 스타일의 코드로 작성해도 컴파일러가 코드를 효율적인 비동기 계산으로 자동변환한다. 즉, 일시 중단 가능한 함수 개념을 가지고 있다.

자바의 동시성 처리 단점?

  • 멀티 스레드로 동시성 처리가 가능하지만 블러킹 연산(sleep, join, wait)
  • 스레드를 블록하고 실행 재개시 문맥 전환 비용, 스레드마다 시스템 자원이 많이 소모
  • 비동기 연산을 위한 다양한 라이브러리가 있지만 일반적인 명령형 흐름제어가 아니라 복잡도가 높아짐(callback)

13.1.1 코루틴과 일시 중단 함수

코루틴의 라이브러리를 뒷받침하는 기본 요소는 일시 중단 함수

  • 코루틴의 핵심은 일시 중단 함수 개념
  • suspend 변경자로 표기
  • 여러 가지 코루틴 빌더(launch, async, runBlocking)
suspend fun kotlin() {
  println("Hello")
  delay(100L) // 일시중단 함수 (스레드를 블럭시키지 않고 다른 작업 수행)
  println("World")
}

일반 함수에서는 일시 중단 함수를 호출할 수 없다.

fun kotlin() {
  println("Hello")
  delay(100L) // error: delay is a suspend function
  println("World")
}

13.1.2 코루틴 빌더

코루틴 빌더들

  • launch { } : Job을 return 하는 coroutine
  • async { } : Deffered 를 이용하여 single value 를 return 하는 coroutine. Deferred value 에 .await() 를 호출하여 최종 결과를 얻을 수 있다. Deferred 는 Job 이라, cancel 할 수도 있다.
  • runBlocking { } : current thread 를 block 시키는 coroutine

launch vs async 비교

함수결과 반환반환 타입

launch X Job
async O Deferd<T>

launch() 빌더는 코루틴을 시작하고, 코루틴을 실행 중인 작업의 상태를 추적하고 변경할 수 있는 Job 객체를 반환한다.

  • 아래 프로그램의 결과를 보면 병렬적으로 실행된 것을 확인 가능
  • 다만, 실행 순서가 항상 일정하지는 않음
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.lang.System.currentTimeMillis
​
fun main() { // main이 suspend 함수가 아님에 유의
    val time = currentTimeMillis()
​
    GlobalScope.launch {
        delay(100) // thread 블럭 시키지 않고 코드 수행만 멈추는 특별한 suspending function.
        println("Task 1 finished in ${currentTimeMillis() - time} ms")
    }
​
    GlobalScope.launch {
        delay(100)  
        println("Task 2 finished in ${currentTimeMillis() - time} ms")
    }
    // 스레드를 블럭
    Thread.sleep(200) 
}
​
// result
Task 2 finished in 176 ms
Task 1 finished in 176 ms

async() 빌더는 결과를 Deferred 인스턴스로 돌려주고, await() 메서드를 통해 계산 결과에 접근할 수 있다.

suspend fun main() {
    val message = GlobalScope.async {
        delay(100)
        "abc"
    }
​
    val count = GlobalScope.async {
        delay(100)
        1 + 2
    }
​
    delay(200)
    // await() 실행되면 코루틴은 일시중단되고 결과가 반환되면 코루틴은 다시 재개되고 결과를 출력
    val result = message.await().repeat(count.await())   // await()는 일시중단 가능한 코루틴 내부에서만 사용 가능
    println(result)
}
​
// result 
abcabcabc

Launch() 와 async() 빌더는 일시 중단 함수 내부에서 스레드 호출을 블럭시키지는 않지만, 백그라운드 스레드를 공유하는 풀을 통해 작업을 실행한다.

runBlocking() 빌더는 디폴트로 현재 스레드에서 코루틴을 만들고 코루틴이 완료될 때까지 현재 스레드의 실행을 블럭시킨다.

  • 코루틴이 끝나면 일시 중단 람다의 결과값이 된다.
  • 취소되면 예외를 발생.
  • 블러킹 호출과 넌블러킹 호출 사이의 다리 역할을 위해 사용
  • 테스트나 메인 함수에서 최상위 빌더로만 사용해야 함
import kotlinx.coroutines.*
​
fun main() {
    // 공유풀 백그라운드 스레드를 새로 할당 받아 실행
    GlobalScope.launch {
        delay(100)
        println("Background task: ${Thread.currentThread().name}")
    }
    // 메인 스레드에서 실행되고 메인 스레드를 블럭 시킴
    runBlocking {
        println("Primary task: ${Thread.currentThread().name}")
        delay(200)
    }
}
​
// result
Primary task: main @coroutine#2
Background task: DefaultDispatcher-worker-1 @coroutine#1

13.1.3 코루틴 영역과 구조적 동시성

동시성 작업 사이의 부모 자식 관계로 인해 이런 실행 시간 제한이 가능하다. 어떤 코루틴을 다른 코루틴의 문맥에서 실행하면 후자가 전자의 부모가 된다. 이경우 자식의 실행이 모두 끝나야 부모가 끝날 수 있도록 부모와 자식의 생명 주기가 연관된다. 이런 기능을 구조적 동시성(structured concurrency)이라고 한다.

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
​
fun main() {
    runBlocking {  // 부모 코루틴
        println("Parent task started ${Thread.currentThread().name}")
​
        launch { // 자식 코루틴
            println("Task A started ${Thread.currentThread().name}")
            delay(200)
            println("Task A finished ${Thread.currentThread().name}")
        }
​
        launch { // 자식 코루틴
            println("Task B started ${Thread.currentThread().name}")
            delay(200)
            println("Task B finished ${Thread.currentThread().name}")
        }
​
        delay(100)
        println("Parent task finished ${Thread.currentThread().name}")
    }
    println("Shutting down... ${Thread.currentThread().name}")
}
​
​
// result
Parent task started main @coroutine#1
Task A started main @coroutine#2
Task B started main @coroutine#3
Parent task finished main @coroutine#1 // 지연이 100이기에 runBlocking() 호출의 일시 중단 람다 본문이 먼저 출력됨
Task A finished main @coroutine#2
Task B finished main @coroutine#3
Shutting down... main

최상위 코루틴이 시작하고 CoroutineScope 인스턴스 안에서 launch를 호출해 두가지 자식 코루틴을 시작한다.

실행 순서 : 부모 runBlocking 코루틴 -> 각 자식 launch 코루틴 시작 -> 자식 200 delay -> 부모 runBlocking 코루틴 실행 -> 각 자식 launch 의 코루틴 출력 -> 자식 코루틴이 종료될 때까지 기다린 후 runBlocking 실행 -> 메인 스레드 블럭 해제 후 main 출력되고 종료

coroutineScope()와 runBlocking()의 가장 큰 차이는 coroutineScope()가 일시 중단 함수라 현재 스레드를 블럭시키지 않는다는 점이다.

import kotlinx.coroutines.*
​
fun main() {
    runBlocking {  // 부모 코루틴
        println("Custom scope start")
​
        coroutineScope {  // 아래 자식 코루틴 실행이 끝날 때까지 일시 중단 처리 됨
            launch {  // 자식 코루틴
                delay(100)
                println("Task 1 finished")
            }
​
            launch {  // 자식 코루틴
                delay(100)
                println("Task 2 finished")
            }
        }
​
        println("Custom scope end")
    }
}
​
// result
Custom scope start main @coroutine#1
Task 1 finished main @coroutine#2
Task 2 finished main @coroutine#3
Custom scope end main @coroutine#1

13.1.4 코루틴 문맥

코루틴은 항상 특정 문맥에서 실행되는데 코루틴을 감싸는 변수 영역의 CoroutineContext 프로퍼티를 통해 이 문맥에 접근할 수 있고, 키-값 쌍으로 이뤄진 불변 컬렉션이다.

GlobalScope.launch {
  // 현재 잡을 얻고 "Task is active: true" 를 출력
  println("Task is active: $coroutineContext[Job.Key]!!.isActive")
}

13.2 코루틴 흐름 제어와 잡 생명 주기

잡은 동시성 작업의 생명 주기를 표현하는 객체다. 잡을 사용하면 작업 상태를 추적하고 필요할 때 작업을 취소할 수 있다.

Active: 작업이 시작됐고 아직 완료나 취소로 끝나지 않은 상태로, 보통 디폴트 상태.

  • CoroutineStart.DEFAULT는 디폴트 동작이며, 잡을 즉시 시작한다.
  • CoroutineStart.LAZY는 잡을 자동으로 시작하지 말라는 뜻으로, 잡이 New 상태가 되고 시작을 기다리게 된다.

Completing: 코루틴이 일시 중단 람다 블록의 실행을 끝낸 잡의 상태(완료 중)

  • 자식들의 완료를 기다리는 상태로 잡은 모든 자식이 완료될 때까지 이 상태를 유지

Completed: 모든 자식이 완료된 상태(완료 됨)

New 상태의 잡에 대해 start() or join() 메서드를 호출하면 잡이 시작되면서 활성화 상태가 된다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {  // 부모 코루틴
        val job = launch(start = CoroutineStart.LAZY) {  // 자식 코루틴
            println("Job started")
        }

        delay(100)

        println("Preparing to start...")
        job.start() // start 메서드가 호출되면 자식 코루틴 시작..
    }
}

// result
Preparing to start...
Job started

자식 코루틴의 시작을 부모 코루틴이 메세지를 호출한 뒤로 미룬다.

잡의 join() 메서드를 사용하면 조인 대상 잡이 완료될 때까지 현재 코루틴을 일시 중단시킬 수 있다. 두 자식 메세지의 실행이 끝난 후에 루트 코루틴이 출력되도록 보장해 준다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val job = coroutineContext[Job.Key]!!
        val jobA = launch { println("This is task A") }
        val jobB = launch { println("This is task B") }

        jobA.join()
        jobB.join()
				
        println("${job.children.count()} children running")
    }
}

// result
This is task A
This is task B
0 children running  

현재 잡의 상태를 추적할 수 있는 프로퍼티들.

상태가 "완료됨, "취소됨"인 잡의 isCompleted가 true이기에, Cancelled와 Completed는 isCancelled로 구분 할 수 있다.

13.2.1 취소

잡의 cancel() 메서드를 호출하면 잡을 취소할 수 있다. 취소에는 협력이 필요하다. 즉, 취소 가능한 코루틴이 스스로 취소가 요청됐는지 검사해서 적절히 반응해줘야 한다.

import kotlinx.coroutines.*

// 코루틴을 취소했지만 취소를 위해 협력되지 않기에 취소 불가함
suspend fun main() {
    val squarePrinter = GlobalScope.launch(Dispatchers.Default) {
        var i = 1
        while (true) {
            println(i++)
        }
    }

    delay(100) // 자식 잡이 어느 정도 실행될 시간을 준다
    squarePrinter.cancel()
}

// isActive 프로퍼티를 현재 잡의 활성화 상태
suspend fun main() {
    val squarePrinter = GlobalScope.launch(Dispatchers.Default) {
        var i = 1
        while (isActive) {  // 취소 가능한 형태의 루프
            println(i++)
        }
    }

    delay(100) 
    squarePrinter.cancel()  // 부모 코루틴이 취소되면 자동으로 모든 자식의 실행을 취소
}

isActive 프로퍼티는 CoroutineScope 객체가 자체적으로 지원하는 프로퍼티로, 코루틴의 취소 상태를 Boolean 타입으로 갖고 있다. 부모 코루틴이 cancel() 메서드를 호출하면 squarePrinter의 상태가 취소 중으로 바뀌고, 그 다음 isActive 검사를 통해 루프를 종료 시킬 수 있다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("${Thread.currentThread().name} : 코루틴아 달려라 $i ...")
            delay(300L)
        }
    }

    delay(1000L)
    println("${Thread.currentThread().name} : 코루틴아 이제 그만 쉬어")

    //job.cancel()  // Job 취소
    //job.join()    // Job 끝날 때 까지 대기
    job.cancelAndJoin()  // 취소하고 끝날 때 까지 대기
    println("${Thread.currentThread().name} : 코루틴 종료")
}

// result
main @coroutine#2 : 코루틴아 달려라 0 ...
main @coroutine#2 : 코루틴아 달려라 1 ...
main @coroutine#2 : 코루틴아 달려라 2 ...
main @coroutine#2 : 코루틴아 달려라 3 ...
main @coroutine#1 : 코루틴아 이제 그만 쉬어
main @coroutine#1 : 코루틴 종료

13.2.2 타임아웃

작업이 완료되기를 무작정 기다리 수 없어서 타임아웃을 설정이 필요할 경우

  • withTimeout() : TimeoutCancellationException 반환
  • withTimeoutOrNull() : 예외 대신 null 반환
import kotlinx.coroutines.*
import java.io.File

fun main() {
    runBlocking {
        val asyncData = async { File("data.txt").readText() }
        try {
            val text = withTimeout(50) { asyncData.await() }
            println("Data loaded: $text")
        } catch (e: Exception) {
            println("Timeout exceeded")
        }
    }
}

13.2.3 코루틴 디스패치하기

코루틴은 스레드와 무관하게 일시 중단 가능한 계산을 구현할 수 있게 해주지만, 코루틴을 실행하려면 여전히 스레드와 연관시켜야 한다. 코루틴 라이브러리에는 특정 코루틴을 실행할 때 사용할 스레드를 제어하는 특별한 코루틴 디스패처 컴포넌트가 있다.

  • 디스패처는 코루틴 문맥의 일부
  • launch() or runBlocking() 등에서 지정할 수 있음
import kotlinx.coroutines.*

fun main() {
  runBlocking {
    // 전역 스레드 풀 디스패처를 사용해 코루틴을 실행한다
    launch(Dispatchers.Default) {
      println(Thread.currentThread().name) // DefaultDispatcher-worker-1
    }
    println(Thread.currentThread().name) // main
  }
}

코루틴 디스패처는 병렬 작업 사이에 스레드를 배분해 주는 자바 실행기(executor)와 비슷함.

스레드 개수를 지정하려면 asCoroutineDispatcher() 확장 함수를 사용하여 코루틴 문맥으로 전환 할 수 있다.

import kotlinx.coroutines.*
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.atomic.AtomicInteger

fun main() {
    val id = AtomicInteger(0)

    val executor = ScheduledThreadPoolExecutor(5) { runnable ->
        Thread(
            runnable,
            "WorkerThread-${id.incrementAndGet()}"
        ).also { it.isDaemon = true }  // 코루틴 종료 후 자동으로 종료
    }

    executor.asCoroutineDispatcher().use { dispatcher ->
        runBlocking {
            for (i in 1..6) {
                launch(dispatcher) {
                    println(Thread.currentThread().name)
                    delay(1000)
                }
            }
        }
    }
}

// result
WorkerThread-1 @coroutine#2
WorkerThread-4 @coroutine#5
WorkerThread-2 @coroutine#3
WorkerThread-3 @coroutine#4
WorkerThread-5 @coroutine#6
WorkerThread-2 @coroutine#7

asCoroutineDispatcher()를 호출하면 ExecutorCoroutineDispatcher를 반환하고, Closeable 인스턴스도 구현하기에 시스템 자원을 해제하려면 close() or use() 함수로 해제가 가능하다.

코루틴 라이브러리에서 제공하는 디스패처

  • Dispatchers.Default: shared common pool 기반으로 pool 크기는 디폴트로 사용 가능한 CPU 코어 수이거나 일반적인 background job을 수행
  • Dispatchers.IO: shared pool 기반으로 파일을 읽고 쓰는 것처럼 잠재적으로 블러킹될 수 있는 I/O 작업에 최적화.
  • Dispatchers.Main: 사용자 입력이 처리되는 UI 스레드에서 사용.(android view..)

newFixedThreadPoolContext() or newSingleThreadPoolContext()를 사용하여 새로운 스레드를 생성하거나 사용하는 디스패처를 만들 수도 있다. 다만, 실험적인 기능이라 추후 대치될 예정.

디스패처를 명시적으로 지정하지 않으면 코루틴이 시작한 영역부터 자동으로 상속된다.

import kotlinx.coroutines.*

fun main() {
    runBlocking { // main
        println("Root: ${Thread.currentThread().name}")

        launch {  // main
            println("Nested, inherited: ${Thread.currentThread().name}")
        }
				// 코루틴 문맥이 실행될 스레드 지정
        launch(Dispatchers.Default) {  // DefaultDispatcher-worker-1
            println("Nested, explicit: ${Thread.currentThread().name}")
        }
    }
}

// result
Root: main @coroutine#1
Nested, inherited: main @coroutine#2
Nested, explicit: DefaultDispatcher-worker-1 @coroutine#3

부모 코루틴이 없으면 암시적으로 Dispatchers.Default로 디스패처를 가정한다. 다만 runBlocking() 빌더는 현재 스레드를 사용한다. 특정 스레드에서만 실행하고 싶을 경우 withContext() 함수를 사용하면 가능하다.

import kotlinx.coroutines.*

@Suppress("EXPERIMENTAL_API_USAGE")
fun main() {
    newSingleThreadContext("Worker").use { worker ->
        runBlocking {
            println(Thread.currentThread().name)   // main
            withContext(worker) {
                println(Thread.currentThread().name) // Worker
            }
            println(Thread.currentThread().name)   // main
        }
    }
}

중단 가능 루틴의 일부를 한 스레드에서만 실행하고 싶을 때 유용하다.

13.2.4 예외 처리

lunch() 빌더는 예외를 부모 코루틴에게 전달하고, CoroutineExceptionHandler에 의해 처리 된다.

  • 부모 코루틴이 (자식에게서 발생한 오류와) 똑같은 오류로 취소되고, 나머지 자식도 모두 취소 됨
  • 자식들이 모두 취소되고 나면 부모는 예외를 코루틴 트리의 윗부분으로 전달(루트 코루틴)
import kotlinx.coroutines.*

fun main() {
    // 커스텀 핸들러..
    runBlocking {  // 최상위 코루틴
        launch { // 첫번째 자식 코루틴
            throw Exception("Error in task A")
            println("Task A completed")
        }

        launch { // 두번째 자식 코루틴
            delay(1000)
            println("Task B completed")
        }

        println("Root")
    }
}
// result
Root
Exception in thread "main" java.lang.Exception: Error in task A

최상위 코루틴 start -> 첫 번째 코루틴 예외 -> 최상위 코루틴 취소 -> 최상위 하위 자식 코루틴 취소

최상위에서 지정된 핸들러가 없기 때문에 Thread.uncauhtExceptionHandler에 의한 디펄트 동작을 실행 한다.

CoroutineExceptionHandler는 현재 코루틴 문맥과 던져진 예외를 인자로 전달 받는다.

fun handleException(context: CoroutineContext, exception: throwable)

val handler = CoroutineExceptionHandler{ _, exception -> // 인자 2개인 람다를 받는 함수 생성
	println("Caught $exception")
}

코루틴 예외 핸들러의 인스턴스가 예외를 처리하도록 지정하려면 코루틴 문맥에 인스턴스를 넣어야 한다.

import kotlinx.coroutines.*

suspend fun main() {
    val handler = CoroutineExceptionHandler{ _, exception ->
        println("Caught $exception")
    }
    // 전역 영역에서 실행 및 handler 인스턴스 선언
    GlobalScope.launch(handler) {
        launch {
            throw Exception("Error in task A")
            println("Task A completed")
        }

        launch {
            delay(1000)
            println("Task B completed")
        }

        println("Root")
    }.join()
}
// result
Root
Caught java.lang.Exception: Error in task A

CoroutineExceptionHandler는 전역 영역에서 실행된 코루틴에 대해서만 정의할 수 있고, CoroutineExceptionHandler가 정의된 코루틴의 자식에 대해서만 적용된다. 앞선 runBlocking()의 경우 코루틴이 전역 영역에서 실행되지 않아 디펄트 핸들러를 사용하게 된다.

async() 빌더는 코루틴 데이터에 접근하는 수신 함수 await()을 호출 할 때 예외를 발생 시키고, 디펄트 핸들러가 호출 된다. 하위 코루틴을 try - catch블럭으로 예외를 처리 해도 여전히 프로그램은 예외와 함께 중단 된다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val deferredA = async {
            throw Exception("Error in task A")
            println("Task A completed")
        }

        val deferredB = async {
            println("Task B completed")
        }

        try {
            deferredA.await()  // 수신 함수 호출시 예외 발생
            deferredB.await()
        } catch (e: Exception) {
            println("Caught $e")
        }
        println("Root")
    }
}
// result
Caught java.lang.Exception: Error in task A
Root
Exception in thread "main" java.lang.Exception: Error in task A

슈퍼바이저 잡은 취소가 아래 방향으로만 전파 된다. 슈퍼바이저 잡이 취소 되면 자동으로 자신의 모든 자식을 취소한다. 하지만 슈퍼바이저가 아니라 자식이 취소되면 슈퍼바이저나 슈퍼바이저의 다른 자식들은 영향을 받지 않는다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        supervisorScope {
            val deferredA = async {
                throw Exception("Error in task A")
                println("Task A completed")
            }

            val deferredB = async {
                println("Task B completed")
            }

            try {
               deferredA.await()
               deferredB.await()
            } catch (e: Exception) {
                println("Caught $e")
            }
           
            println("Root")
        }
    }
}
// result
Task B completed
Caught java.lang.Exception: Error in task A
Root

Task B와 루트 코루틴은 영향없이 출력된다.

13.3 동시성 통신

스레드 안정성을 유지하면서 여러 동시성 작업 사이에 효율적으로 데이터를 공유할 수 있게 해주는 코루틴 라이브러리의 고급 기능 소개 하자.

13.3.1 채널

데이터 스트림을 코루틴 사이의 공유할 수 있는 메커니즘으로, 데이터를 전송하는 send() 메서드와 데이터를 받는 receive() 메서드를 제공한다.

채널 메커니즘

  • 자바 Blocking queue와 유사하지만, 스레드를 블럭시키지 않음.
  • 양방향 커뮤니케이션에 의한 데이터 스트림을 공유.
    • 채널 버퍼가 꽉차게 되면 send() 호출이 일시 중단되고, 채널 버퍼가 비어 있을 경우 채널에서 송신할 때까지 receive() 호출은 일시 중단.
  • 채널에서 송신한 순서 그대로 원소를 수신되도록 보장. (FIFO)
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*
import kotlin.random.Random

fun main() {
    runBlocking {
        val streamSize = 5
        val channel = Channel<Int>(3) // 채널 용량 = 3

        launch {
            for (n in 1..streamSize) {
                delay(Random.nextLong(100))
                val square = n*n
                println("Sending: $square")
                channel.send(square)  // 정수 제곱 값의 스트림을 전송함
            }
        }

        launch {
            for (i in 1..streamSize) {
                delay(Random.nextLong(100))
                val n = channel.receive()
                println("Receiving: $n") // 생성된 스트림을 수신함
            }
        }
    }
}
// result
Sending: 1
Receiving: 1
Sending: 4
Receiving: 4
Sending: 9
Receiving: 9
Sending: 16
Receiving: 16
Sending: 25
Receiving: 25

Channel Buffer Type

  • Channel.UNLIMITED (= Int.MAX_VALUE): 채널의 용량은 제한이 없고, 내부 버퍼는 필요에 따라 증가함.
    • 송신측은 일시 중단 하지 않고, 수신측의 경우 버퍼가 비어있으면 일시 중단.
  • Channel.RENDEZVOUS (= 0): 채널의 버퍼를 설정하지 않을 경우 설정된다. 내부 버퍼가 없는 상태.(default)
    • 송신측과 수신측 모두 데이터 스트림이 발생할 때까지 일시 중단 됨.
  • Channel.CONFLATED (= -1): 버퍼는 사이즈는 1이고, 송신된 값이 하나로 합쳐지는 채널.
    • 수신측이 송신측 보다 처리 속도가 느리거나, 송신측에 새로운 데이터 스트림이 들어오면 기존의 값을 덮어 쓰고 마지막 값을 전송함. 즉, 송신측은 일시 중단 하지 않음.

Channel<Int>(Channel.RENDEZVOUS)는 생산자와 소비자 코루틴이 교대로 활성화되기에 안정적인 동작 순서를 보장한다.

val channel = Channel<Int>(Channel.RENDEZVOUS) // 0

//result
Sending: 1
Receiving: 1
Sending: 4
Receiving: 4
Sending: 9
Receiving: 9
Sending: 16
Receiving: 16
Sending: 25
Receiving: 25

값이 합쳐지는 Channel<Int>(Channel.CONFLATED)는 데이터 스트림 중 송신측에서 일부가 누락되거나 버려저도 무관할 경우에 적합하다.

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val streamSize = 5
        val channel = Channel<Int>(Channel.CONFLATED)

        launch {
            for (n in 1..streamSize) {
                delay(100)
                val square = n*n
                println("Sending: $square")
                channel.send(square)
            }
        }

        launch {
            for (i in 1..streamSize) {
                delay(200)             // 소비자 지연 시간 2배
                val n = channel.receive()
                println("Receiving: $n")
            }
        }
    }
}

// result
Sending: 1
Receiving: 1
Sending: 4
Sending: 9
Receiving: 9
Sending: 16
Sending: 25
Receiving: 25

수신자 코루틴이 1부터 streamSize까지 이터레이션 하므로 수신할 것으로 기대하기에 프로그램이 종료되지 않는다. 이 경우 Channle API는 Close() 메서드를 사용해 더이상 데이터 스트림을 보내지 않는 다는 신호를 보낼 수 있다.

소비자쪽에서는 이터레이션 횟수를 고정하는 대신 채널에서 들어오는 데이터에 대해 이터레이션을 할 수 있다.

launch {
  for (n in 1..streamSize) {
    delay(100)
    val square = n*n
    println("Sending: $square")
    channel.send(square)
  }
  channl.close() // 채널 종료 메서드 호출
}

launch {
	for(n in channel) {
		println("Receiving: $n")
		delay(200)
	}
}

데이터 교환이 완료되면 정상적으로 프로그램이 종료된다.

소비자 쪽에서는 명시적인 이터레이션 대신 consumeEach() 함수를 통해 모든 채널 콘텐츠를 얻어서 사용할 수 있다.

Channle.consumerEach {
	println("Receiving: $it")
	delay(200)
}

consumerEach() 함수는 송신측 및 수신측 예외시 CloseSendChannelException 예외가 발생하고, channel 이 닫히고 코루틴이 종료된다.

한 채널의 데이터 스트림을 여러 코루틴이 동시에 receive() 하는 Fan out 이라고 한다.

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*
import kotlin.random.Random

fun main() {
    runBlocking {
        val streamSize = 5
        val channel = Channel<Int>(2)

        launch {
            for (n in 1..streamSize) {
                val square = n*n
                println("Sending: $square")
                channel.send(square)
            }
            channel.close()
        }

        for (i in 1..3) {  // 3개의 데이터 스트림 소비자 생성
            launch {
                for (n in channel) {
                    println("Receiving by consumer #$i: $n")
                    delay(Random.nextLong(100))
                }
            }
        }
    }
}

// result
Sending: 1
Sending: 4
Sending: 9
Receiving by consumer #1: 1
Receiving by consumer #2: 4
Receiving by consumer #3: 9
Sending: 16
Sending: 25
Receiving by consumer #2: 16
Receiving by consumer #1: 25

위와 반대의 케이스로 여러 생산자 코루틴이 한 채널에 데이트 스트림을 전송하고 한명의 수신측 코루틴이 소비하는 Fan in도 있다. 일반적인 경우 여러 생산자와 여러 소비자가 여러 채널을 공유할 수도 있다.

13.3.2 생산자

produce() 라는 코루틴 빌더를 사용해 동시성 데이터 스트림을 생성할 수 도 있다. Channel과 비슷한 send() 메서드를 제공하는 ProducerScope 영역을 도입해준다.

import kotlinx.coroutines.channels.*
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        val channel = produce {
            for (n in 1..5) {
                val square = n*n
                println("Sending: $square")
                send(square)
            }
        }

        launch {
            channel.consumeEach { println("Receiving: $it") }
        }
    }
}
// result
Sending: 1
Receiving: 1
Sending: 4
Sending: 9
Receiving: 4
Receiving: 9
Sending: 16
Sending: 25
Receiving: 16
Receiving: 25

이 경우 채널을 명시적으로 닫을 필요가 없고, 코루틴이 종료되면 produce() 빌더가 채널을 자동으로 닫아준다. 예외 처리 관점에서 produce()는 async()/await() 정책을 따른다.

13.3.3 티커(obsolete)

Ticker Channel은 특별한 채널로, 한 원소와 다음 원소의 발생 시점 이후에 delay 시간이 지나면 Unit 값을 송신한다.

  • delayMillis: 티커 원소의 발생 시간 간격을 밀리초 단위로 지정한다.
  • initalDelayMillis: 티커 생성 시점과 원소가 최초로 발생하는 시점 사이의 시간 간격이다.
  • Context: 티커를 실행할 코루틴 문맥이다
  • Mode: 티커의 행동을 결정하는 TickerMode Enum이다.
    • TickerMode.FIXED_PERIOD: 생성되는 원소 사이의 시간 간격을 지정된 지연 시간에 최대한 맞추기 위해 실제 지연 시간을 조정한다.
    • TickerMode.FIXED_DELAY: 실제 흘러간 시간과 관계없이 delayMillis로 지정한 지연 시간만큼 시간을 지연시킨 후 다음 원소를 송신한다.
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val ticker = ticker(100)  // 100ms 마다 send()
    println(withTimeoutOrNull(50) { ticker.receive() }) // ticker delayMillis 100 이기에 delay 시간은 50ms 이라 titmeout 발생..
    println(withTimeoutOrNull(60) { ticker.receive() }) // 100ms 지남..
    delay(250)  // 250ms 일시 중단 하는데, 100ms 지나면 티커는 send를 1번 출한다. 그리고, 티커와 소비자 모두 150ms 동안 일시 중단 상태..
    println(withTimeoutOrNull(1) { ticker.receive() })  // 신호가 이미 보내졌기에 결과 반환...
    println(withTimeoutOrNull(60) { ticker.receive() }) // 그리고..250ms 가 지났다는 사실을 티커는 알게되서 딜레이시 시간을 100-50 = 50으로 조정 하기에 60ms 이내로 다시 출력할수 있게 됨.
    println(withTimeoutOrNull(60) { ticker.receive() }) // 이후 티커는 100ms을 대기하기에 60 이내에 응답을 못주고, 결국 못기다리고 timeout
}
// result
null
kotlin.Unit
kotlin.Unit
kotlin.Unit
null

현재 실험 단계에 있으며 미래 코루틴 라이브러리 버전에서는 언제든 변경될 수 있다.

13.3.4 액터(obsolete)

가변 상태를 스레드 안전하게 공유하는 방법을 구현하는 일반적인 방법으로, 코루틴에서 스레드간에 메시지를 보내서 동시성 통신을 진행할 수 있는 수단을 제공하는 객체이다.

액터는 자기 자신에게 들어오는 메시지를 listen하고, 자신의 상태를 바꾸면서 메시지에 응답할 수 잇으며, 다른 메시지를 보낼 수 있고 새로운 액터를 시작할 수 있다. 결과를 생성하지 않고 Job을 시작한다는 점은 launch() 빌더와 비슷하다.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.actor

sealed class AccountMessage

class GetBalance(
    val amount: CompletableDeferred<Long>
) : AccountMessage()

class Deposit(val amount: Long) : AccountMessage()

class Withdraw(
    val amount: Long,
    val isPermitted: CompletableDeferred<Boolean>
) : AccountMessage()

fun CoroutineScope.accountManager(
    initialBalance: Long
) = actor<AccountMessage> {

    var balance = initialBalance

    for (message in channel) {
        when (message) {
            is GetBalance ->message.amount.complete(balance) // 액터 클라이언트에 결과 전달

            is Deposit -> {
                balance += message.amount
                println("Deposited ${message.amount}")
            }

            is Withdraw -> {
                val canWithdraw = balance >= message.amount
                if (canWithdraw) {
                    balance -= message.amount
                    println("Withdrawn ${message.amount}")
                }
                message.isPermitted.complete(canWithdraw)
            }
        }
    }
}

액터는 데이터를 받기 위해 채널을 사용하고, 기본적으로 rendezvous 채널을 사용한다. 액터 클라이언트에게 요청 결과를 돌려줄 때는 CompletableDeferred에서 complete() 메서드를 사용한다.

13.4 자바 동시성 사용하기

코틀린에서만 사용할 수 있는 코루틴 라이브러리 외에도 JVM 플랫폼에서는 JDK가 제공하는 동기화 요소를 활용할 수 있다.

13.4.1 스레드 시작하기

범용 스레드를 시작하려면, 스레드에서 실행하려는 실행 가능 객체에 대응하는 람다와 스레드 프로터피들을 지정해서 thread() 함수를 사용하면 된다.

  • start: 스레드를 생성하자마자 시작할지 여부(디펄트 true)
  • isDaemon: 스레드를 데몬 모드로 시작할지 여부(디펄트 false)
  • contextClassLoader: 스레드 코드가 클래스와 자원을 적재할 때 사용할 클래스 로더(디펄트 null)
  • name: 커스텀 스레드 이름(디폴트 null), Thread-1.....등
  • priority: Thread.MIN_PRIORITY(=1) ~(=10) 사이의 값으로 정해지는 우선순위로 어떤 스레드가 얼마나 많은 CPU 시간을 배정받는지 결정.
  • block: () -> Unit 타입의 함숫값으로 새 스레드가 생성되면 실행할 코드
// 150ms 마다 메시지 출력하는 스레드
import kotlin.concurrent.thread

fun main() {
    println("Starting a thread...")

    thread(name = "Worker", isDaemon = true) { // setDaemon(true)
        for (i in 1..5) {
            println("${Thread.currentThread().name}: $i")
            Thread.sleep(150)
        }
    }

    Thread.sleep(500)
    println("Shutting down...")
}
// result
Worker: 5 Starting a thread...
Worker: 1
Worker: 2
Worker: 3
Worker: 4
Shutting down...

// timer() 함수로도 고정된 시간 간격으로 thread 실행 가능함
timer(period = 150, name = "Worker", daemon = true) {
	println("${Thread.currentThread().name}: ${++counter}")
}

13.4.2 동기화와 락

동기화는 특정 코드 블럭이 한 스레드에서만 실행되는 것을 보장한다. 이런 코드 블럭을 다른 스레드가 실행하고 있다면 이를 사용하려고 시도하는 스레드는 모두 대기 한다.

자바와 동일하게 코틀린도 동기화 블럭을 사용해 동기화 해야하는 구문 코드를 감싸주면 된다.

import kotlin.concurrent.thread

fun main() {
    var counter = 0
    val lock = Any()

    for (i in 1..5) {
        thread(isDaemon = false) {
            synchronized(lock) {
                counter += i // thread 가 연산 중일 때 다른 스레드는 대기..
                println(counter)
            }
        }
    }
}
// result
6 
10 
13 
15 
1

synchronized block이 없으면
??

일반적으로 synchronized() 함수는 람다의 반환값을 반환한다. 호출 시점의 중간 카운터를 읽을 수도 있다.

val currentCount = synchronized(lock) {counter}
println("count $currentCount")

자바에서는 메서드에 synchronized 변경자를 지정하여 동기화를 구현할 수도 있다. 코틀린에서는 @Synchronized 애너테이션으로 지정할 수 있다.

import kotlin.concurrent.thread

class Counter {
    private var value = 0
    @Synchronized fun addAndPrint(value: Int) {
        this.value += value
        println(value)
    }
}

fun main() {
    val counter = Counter()
    for (i in 1..5) {
        thread(isDaemon = false) { counter.addAndPrint(i) }
    }
}

// result
4 2 5 3 1

코틀린 코루틴 기반 동시성의 기초를 다뤘다. 일시 중단 함수를 통해 동시성 코드를 작성하는 방법 및 코루틴 빌더와 영역 및 문맥을 사용해 코루틴의 생명 주기를 관리하는 방법에 대하여 정리하였습니다.

참고 자료

https://kotlinlang.org/docs/home.html

 

Kotlin Docs | Kotlin

 

kotlinlang.org

https://play.kotlinlang.org/

 

Kotlin Playground: Edit, Run, Share Kotlin Code Online

 

play.kotlinlang.org

https://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-0-20176d431e9d

 

Reading Coroutine official guide thoroughly — Part 0

요즘 안드로이드 앱 개발자들은 주개발 언어가 Java 에서 Kotlin 으로 많이 전환 되고 있다고 생각합니다. iOS 가 objective-c 에서 swift 로 전환할 때 처럼 안드로이드 SDK 공식 가이드에서도 Kotlin 과 Java

myungpyo.medium.com

 

반응형
반응형

클래스 상속 개념을 소개하고 하위 클래스를 정의하는 방법을 설명하며, 추상 클래스, 인터페이스, 클래스 위임, 봉인된 클래스를 통해 클래스 계층 구조를 알아보자.

  • 상속과 오버라이딩
  • 타입 검사와 캐스트
  • 추상 클래스
  • 인터페이스
  • 봉인된 클래스
  • 위임

8.1 상속

자바와 마찬가지로 코틀린 클래스는 단일 상속만을 지원한다.

8.1.1 하위 클래스 선언

클래스를 정의하면서 주생성자 뒤에 콜론(

:

)으로 표기한다.

  • 자바의 클래스와 메서드는 default로 상속에 열려있고, 상속을 금지하려면 final을 명시
  • 코틀린은 default로 final 이고, 상속을 허용하려면 클래스 앞에 open 지시어 선언
open class Vehicle {
  var currentSpeed = 0

  fun start() {
    println("I'm moving")
  }

  fun stop() {
    println("Stopped")
  }
}

open class FlyingVehicle : Vehicle() {    // Vehicle는 open 지시어를 통해 상속에 열려 있음
  fun takeOff() {
    println("Take off")
  }

  fun land() {
    println("Landed")
  }
}

class Aircraft(val seats: Int) : FlyingVehicle()

// error: this type is final, so it cannot be inherited from
class Airbus(seats: Int) : Aircraft(seats) // Aircraft는 default final 이기에 상속에 대해 닫혀 있음

하위 클래스 인스턴스는 상위 클래스의 멤버를 모두 상속한다.

val aircraft = Aircraft(100)
val vehicle: Vehicle = aircraft
vehicle.start()
vehicle.stop()
aircraft.start()
aircraft.takeOff()
aircraft.land()
aircraft.stop()
println(aircraft.seats)

data 클래스는 항상 final 이기에 open 선언이 불가하다.

// error: modifier 'open' is incompatible with 'data'
open data class Person(val name: String, val age: Int)

인라인 클래스는 상속 및 상위 클래스 역할 불가하다.

class MyBase
// error: inline classes can be only final
open value class MyString(val value: String)
// error: inline class cannot extend classes
value class MyStringInherited(val value: String): MyBase()

객체(동반 객체 포함)는 클래스 상속이 가능하다.

open class Person(val name: String, val age: Int) {
    companion object : Person("Unknown", 0)
}

object JohnDoe : Person("John Doe", 30)

상위 클래스의 open으로 지정된 멤버를 하위 클래스에서 오버라이드 가능하고,

자바와 차이점은 구현하는 메서드 앞에 override 지시어를 붙여야 함

open class Vehicle2 {
  open fun start() {
    println("I’m moving")
  }

  fun stop() {             // final 이기에 상속만 가능하고 하위 메서드에서 오버라이드 불가
    println("Stopped")
  }
}

class Car : Vehicle2() {
  override fun start() {
    println("I'm riding")
  }
}

class Boat : Vehicle2() {
  override fun start() {
    println("I'm sailing")
  }
}

오버라이드하는 멤버를 final로 선언하면 더 이상 하위 클래스에서 오버라이드 불가하다.

open class Vehicle6 {
  open fun start() {
    println("I’m moving")
  }
}

open class Car5 : Vehicle6() {
  final override fun start() {
    println("I'm riding a car")
  }
}

// Car5 클래스에서 start 멤버 함수를 final로 선언했기 때문에 Car5클래스를 오버라이드할 수 없음
class Bus : Car5() {
  // error: 'start' in 'Car' is final and cannot be overridden
  override fun start() {
    println("I'm riding a bus")
  }
}

프로퍼티 오버라이드는 본문 구현 외에 주생성자 파라미터 선언으로 오버라이드 가능하다.

open class Entity {
  open val name: String get() = ""
}

class Person2(override val name: String) : Entity()

불변 프로퍼티를 가변 프로퍼티로 오바라이드할 수 있다.

(단, 꼭 필요한 경우가 아니라면 상위 클래스의 선언한 값을 그대로 사용)

open class Entity2 {
  open val name: String get() = ""
}

class Person3() : Entity2() {
  override var name: String = ""   // val -> var 
}

자바와 마찬가지로 코틀린도 멤버의 영역을 하위 클래스의 영역으로만 제한하는 특별한 접근 변경자(protected)를 제공한다.

  • Java: 동일한 패키지 안에서 속한 코드에서 멤버 접근을 허용
  • Kotlin: 동일 클래스 및 그 클래스를 상속한 하위 클래스에서만 멤버 접근이 허용
open class Vehicle7 {
  protected open fun onStart() { }
  fun start() {
    println("Starting up...")
    onStart()
  }
}

class Car6 : Vehicle7() {
  override fun onStart() {
    println("It's a car")
  }
}

fun main3() {
  val car = Car6()
  car.start()    // Ok
  // error: cannot access 'onStart': it is protected in 'Car'
  // car.onStart()  상속한 하위 클래스에서만 접근 가능
}

함수나 프로퍼티를 오버라이드한 상위 클래스를 재사용해야 하는 경우 super 키워드를 사용한다.

open class Vehicle {
    open fun start(): String? = "I'm moving"
}

open class Car : Vehicle() {
    override fun start() = super.start() + " in a car" 
}

fun main() {
    println(Car().start())
}

// I'm moving in a car

8.1.2 하위 클래스 초기화

특정 클래스의 인스턴스 생성 시 자신의 상위 클래스 생성자를 호출하며 최상위 클래스에 이를 때까지 연쇄적으로 일어나고, 최상위클래스로 부터 하위 클래스 순서로 초기화가 진행된다.

open class Vehicle {
  init {
    println("Initializing Vehicle")
  }
}

open class Car : Vehicle() {
  init {
    println("Initializing Car")
  }
}

class Truck : Car() {
  init {
    println("Initializing Truck")
  }
}

fun main() {
  Truck()
}

// result
Initializing Vehicle
Initializing Car
Initializing Truck

위임 호출(delegating call)을 사용하면 인자를 상위 클래스의 일반생성자 및 주,부생성자에게 전달할 수 있다.

// 일반생성자에 전달
open class Person(val name: String, val age: Int)

class Student(name: String, age: Int, val university: String) :
    Person(name, age)

fun main() {
  Student("Euan Reynolds", 25, "MIT")
}

// 주, 부생성자에 전달
open class Person {
  val name: String
  val age: Int

  constructor(name: String, age: Int) {
    this.name = name
    this.age = age
  }
}

class Student(name: String, age: Int, val university: String) :
    Person(name, age)

Student 클래스에서 부생성자를 사용할 경우 위임 호출할 클래스를 생성자 뒤에

( )

괄호없이 작성한다.

open class Person(val name: String, val age: Int)

class Student : Person {
  val university: String

  constructor(name: String, age: Int, university: String) :
    super(name, age) {  // super 키워드는 부생성자가 상위 클래스의 생성자를 위임 호출하는것을 컴파일러에게 전달
    this.university = university
  }
}

상위 클래스의 초기화 코드가 호출되고 하위 클래스에서 오버라이드한 함수가 아직 초기화되지 않은 상태일 경우 this 누출(leaking this) 문제가 발생할 수 있다.

open class Person10(val name: String, val age: Int) {
  open fun showInfo() {
      println("$name, $age")
  }

  init {
      showInfo()  // 잠재적인 위험 요소
  }
}

class Student7(
  name: String,
  age: Int,
  val university: String
) : Person10(name, age) {
  override fun showInfo() {
      println("$name, $age (student at $university)")
  }

}

fun main() {
  // Euan Reynolds, 25 (student at null)
  Student7("Euan Reynolds", 25, "MIT")
}

8.1.3 타입 검사와 캐스팅

코틀린의 타입 검사와 캐스팅 연산에는 is를 사용한다.

val objects = arrayOf("1", 2, "3", 4)

// objects 는 Any 로 이뤄진 배열에 원소에 String or Int 연산을 바로 할 수 없음
for (obj in objects) {
  println(obj*2) // error: unresolved reference
}

for (obj in objects) {
  println(obj is Int)  // is 연산자를 통해 타입 검사가 가능하다
}

// result
false
true
false
true

null값에 대한 is 연산 처리

  • Java instanceof 와 유사하고 차이점은 null 에 대해 항상 false
  • 연산자 오른쪽에 널이 될 수 있는지 여부에 따라 다르다(스마트 캐스트 지원)
//null이 될 수 없는 타입
println(null is Int)     // false
//null이 될 수 있는 타입
println(null is String?) // true 

!is 연산자는 is와 반대인 연산을 제공한다.

val o: Any = ""
println(o !is Int)    // true
println(o !is String) // false

값의 타입을 자동으로 세분화해서 Null이 될 수 없는 타입으로 바꿔준다.

val objects2 = arrayOf("1", 2, "3", 4)

var sum = 0

for (obj in objects2) {
  if (obj is Int) {
    sum += obj // 여기서는 obj의 타입을 `Int`로 세분화한다
  }
}
println(sum) // 6

is/!is 검사를 in/!in처럼 특별한 조건으로 사용할 수 있는 식 내부에서도 스마트 캐스트가 지원된다.

  • 컴파일러는 검사 시점과 사용 시점 사이에 변수가 변경되지 않는다고 확신할 수 있을 때만 스마트 캐스트를 허용한다.
val objects3 = arrayOf("1", 2, "3", 4)
var sum2 = 0

for (obj in objects3) {
  when (obj) {
    is Int -> sum2 += obj            // 여기서 obj는 Int 타입이다
    is String -> sum2 += obj.toInt() // 여기서 obj는 String 타입이다
  }
}
println(sum2) // 10
  • 프로퍼티나 커스텀 게터가 정의된 변수에는 스마트 캐스트 사용이 불가하다. (값이 변경될 수 있다고 예측될 때)
class Holder {
  val o: Any get() = ""   // 게터 통해서 값이 변경될 수 있음
}
fun main9() {
  val o: Any by lazy { 123 }

  if (o is Int) {
    println(o*2)             // error: smart cast to 'Int' is impossible
  }

  val holder = Holder()

  if (holder.o is String) {
    println(holder.o.length) // error: smart cast to 'String' is impossible
  }
}
  • 가변 지역 변수의 경우 런타임 시점에 변수가 변경될지 예측할 수 없을 경우 스마트 캐스트 지원이 불가하다.
fun main() {
  var o: Any = 123    // 위임이 없는 불변 지역변수는 가능
  if (o is Int) {
    println(o + 1)    // Ok: Int로 스마트 캐스트
    o = ""
    println(o.length) // Ok: String으로 스마트 캐스트
  }
  if (o is String) {
    val f = { o = 123 }  // 람다 안에서 변수를 변경할 경우 지원 불가
    //  println(o.length) // error: smart cast to 'String' is impossible
  }
}
  • 스마트 캐스트를 쓸 수 없는 경우 (as와 as?)명시적인 연산자를 사용해 값의 타입을 강제로 변환할 수 있다.
val o: Any = 123
println((o as Int) + 1)              // 124
println((o as? Int)!! + 1)           // 124
println((o as? String ?: "").length) // 0       변환하려는 타입과 일치하지 않치만 안전한 as?
//println((o as String).length)      // java.lang.ClassCastException  실제 타입과 일치하지 않아 예외

8.1.4 공통 메서드

Kotlin.Any 클래스는 코틀린 클래스 계층 구조의 루트로 모든 클래스는 직간적접으로 상속하고 기본 연산자를 제공한다.

open class Any {
    public open operator fun equals(other: Any?): Boolean // 구조적 동등성 (== 나 !=)
    public open fun hashCode(): Int // HashSet, HashMap
    public open fun toString(): String // String 변환
}

디폴트로 정의된 모든 클래스에는 Any에서 상속받은 참조 동등성만 구현하기에 두 인스턴스가 같은 객체로 간주되지 않는다.

fun main() {
  val addresses = arrayOf(
    Address("London", "Ivy Lane", "8A"),
    Address("New York", "Kingsway West", "11/B"),
    Address("Sydney", "North Road", "129")
  )

  // -1
  println(addresses.indexOf(Address("Sydney", "North Road", "129")))
}

equal() 메서드를 오버라이드하여 값 비교 (동등성) 문제를 해결할 수 있다.

class Address(
  val city: String,
  val street: String,
  val house: String
) {
  // Address에 정의한 equals 함수
  override fun equals(other: Any?): Boolean {
    if (other !is Address) return false
    return city == other.city &&
      street == other.street &&
      house == other.house
  }
}

fun main() {
    // 2
    println(addresses.indexOf(Address("Sydney", "North Road", "129")))  
}

일반 값에 대한 동등성 비교는 ==와 !=, 참조 동등성은 === 사용하여 비교한다.

  • 코틀린에서 ==는 내부적으로 equals 호출 (=자바 equals)
  • 자바 주소 값 비교인 == 와 코틀린의 === 동일한 역할
val addr1 = Address2("London", "Ivy Lane", "8A")
val addr2 = addr1                                // 같은 인스턴스
val addr3 = Address2("London", "Ivy Lane", "8A") // 다른 인스턴스지만, 동등함
println(addr1 === addr2) // true
println(addr1 == addr2)  // true
println(addr1 === addr3) // false

프로퍼티는 각자의 equals()와 hashCode() 오버라이드하여 해시와 동등성을 계산 할 수 있다.(배열은 타입은 예외)

class Address(
  val city: String,
  val street: String,
  val house: String
) {
  // Address에 정의한 equals 함수
  override fun equals(other: Any?): Boolean {
    if (other !is Address2) return false
    return city == other.city &&
      street == other.street &&
      house == other.house
  }

  // 위 equals와 호환이 되는 해시코드 정의
  override fun hashCode(): Int {
    var result = city.hashCode()
    result = 31 * result + street.hashCode()
    result = 31 * result + house.hashCode()
    return result
  }
}

fun main() {
 val addr1 = Address("London", "Ivy Lane", "8A")
 val addr3 = Address("London", "Ivy Lane", "8A") 

 // true
 println(addr1 == addr3)
}

8.2 추상 클래스와 인터페이스

8.2.1 추상 클래스와 추상 멤버

자바와 동일하게 추상 클래스를 지원하고, abstract라는 키워드를 붙여야 한다.

abstract class Entity(val name: String)

// Ok: 하위 클래스에서 위임 호출
class Person(name: String, val age: Int) : Entity(name)

// 인스턴스 생성 불가하고 부모 클래스로만 사용
// error: cannot create an instance of an abstract class    
val entity = Entity("Unknown")

추상 클래스는 추상 멤버를 정의할 수 있다.

import kotlin.math.PI

abstract class Shape {
  abstract val width: Double
  abstract val height: Double
  abstract fun area(): Double
}

class Circle(val radius: Double) : Shape() {
  val diameter get() = 2*radius
  override val width get() = diameter    // 추상 클래스 멤버를 오버라이드해서 구현
  override val height get() = diameter
  override fun area() = PI*radius*radius
}

class Rectangle(
  override val width: Double,
  override val height: Double
) : Shape() {
  override fun area() = width*height
}

fun Shape.print() {
  println("Bounds: $width*$height, area: ${area()}")
}

fun main() {
  // Bounds: 20.0*20.0, area: 314.1592653589793
  Circle(10.0).print()

  // Bounds: 3.0*5.0, area: 15.0
  Rectangle(3.0, 5.0).print()
}

추상 클래스 제약사항은 다음과 같다.

  • 추상 프로퍼티를 초기화할 수 없고 명시적인 접근자나 by 절을 추가할 수 없다.
  • 추상 함수에는 본문이 없어야 한다.
  • 추상 프로퍼티와 함수 모두 명시적으로 반환 타입을 적어야 한다.
  • 추상 멤버는 암시적으로 열려 있어 open 생략 가능하다.

8.2.2 인터페이스

코틀린 인터페이스 개념은 자바의 인터페이스와 비슷하고,자바 8에 디폴트 메서드가 도입된 이후로 더 많이 비슷해 졌다.

  • default 키워드 없이 메서드 구현
  • 프로퍼티, 함수 구현 추가 가능
interface Vehicle9 {
  val currentSpeed: Int  // 디펄트가 추상 멤버이고, abstract 지시어는 생략 가능
  fun move()
  fun stop()
}

인터페이스는 클래스나 다른 인터페이스의 상위 타입이 될 수 있다.

  • 코틀린에서는 모든 상속(클래스, 인터페이스)을 똑같은 기호 ( : )를 사용해 표시한다.
interface FlyingVehicle2 : Vehicle9 {
  val currentHeight: Int
  fun takeOff()
  fun land()
}

class Car8 : Vehicle9 {           
  override var currentSpeed = 0    // Vehicle9에 추상 멤버에 대한 구현을 제공해야 한다
      private set

  override fun move() {
    println("Riding...")
    currentSpeed = 50
  }

  override fun stop() {
    println("Stopped")
    currentSpeed = 0
  }
}

class Aircraft2 : FlyingVehicle2 {
  override var currentSpeed = 0    // FlyingVehicle2, Vehicle9에 추상 멤버에 대한 구현을 제공해야 한다
      private set

  override var currentHeight = 0
      private set

  override fun move() {
    println("Taxiing...")
    currentSpeed = 50
  }

  override fun stop() {
    println("Stopped")
    currentSpeed = 0
  }

  override fun takeOff() {
    println("Taking off...")
    currentSpeed = 500
    currentHeight = 5000
  }

  override fun land() {
    println("Landed")
    currentSpeed = 50
    currentHeight = 0
  }
}

인터페이스 안의 함수와 프로퍼티에 구현을 추가할 수도 있다.

interface Vehicle10 {
  val currentSpeed: Int

  val isMoving get() = currentSpeed != 0

  fun move()            // 암시적으로 열려 있고, final로 정의하면 컴파일 오류 발생

  // error: modifier 'final' is not applicable inside 'interface'
  final fun move() {}

  fun stop()

  fun report() {
    println(if (isMoving) "Moving at $currentSpeed" else "Still")
  }
}

인터페이스 내부에는 초기화 코드나 위임이 붙은 프로퍼티는 금지된다.

interface Vehicle13 {
  // error: property initializers are not allowed in interfaces
  val currentSpeed = 0
  // error: delegated properties are not allowed in interfaces
  val maxSpeed by lazy { 100 }
}

추상 클래스와 달리 인터페이스에 대한 생성자는 금지된다.

// error: property initializers are not allowed in interface
interface Person(val name: String)

interface Vehicle {
    // error: delegated properties are not allowed in interface
    constructor(name: String)
}

한 타입이 동일한 시그니처를 가지는 멤버가 들어있는 다른 인터페이스를 둘 이상 상속할 때는 super<상위타입>.메서드 호출을 사용한다.

interface Car11 {
  fun move()
}

interface Ship2 {
  fun move()
}

class Amphibia : Car11, Ship2 {
  override fun move() {
    println("I'm moving")
  }
}

interface Car12 {
  fun move(){
    println("I'm riding")
  }
}

interface Ship3 {
  fun move()
}

class Amphibia2 : Car12, Ship3 {
  override fun move() {
    super.move() // Car에서 상속받은 메서드를 호출하는 문제가 생김

    super<Car11>.move()  // Car11에서 상속받은 메서드를 호출
    super<Ship2>.move()
  }
}

fun main() {
  Amphibia2().move() // I'm riding
}

8.2.3 봉인된 클래스와 인터페이스

Enum 클래스는 미리 정의된 상수 집합을 표현하여 이외의 값이 들어오는걸 막을 수 있다.

enum class Result {
  SUCCESS, ERROR
}

fun runComputation(): Result {
  try {
    println("Input a int:")
    val a = readLine()?.toInt() ?: return Result.ERROR
    println("Input a int:")
    val b = readLine()?.toInt() ?: return Result.ERROR

    println("Sum: ${a + b}")

    return Result.SUCCESS
  } catch (e: NumberFormatException) {
    return Result.ERROR
  }
}

fun main() {
  val message = when (runComputation()) {   // when식을 통해 미리 정의된 상수 처리 및 이외의 값이 들어올 수 없는 것을 컴파일 단에서 감지
    Result.SUCCESS -> "Completed successfully"
    Result.ERROR -> "Error!"
  }

  println(message)
}

하지만 특정 케이스의 경우 각 종류별로 애트리뷰트가 다를 수 있어, 추상 클래스를 사용하여 성공 및 하위 클래스의 특정 케이스를 처리할 수 있지만, Enum 처럼 type의 제한을 할 수 없다.

abstract class Result2 {
  class Success(val value: Any) : Result2() {
    fun showResult() {
      println(value)
    }
  }

  class Error(val message: String) : Result2() {
    fun throwException() {
      throw Exception(message)            // type 을 다양하게 처리 가능함
    }
  }
}

fun runComputation2(): Result2 {
  try {
    println("Input a int:")
    val a = readLine()?.toInt()
        ?: return Result2.Error("Missing first argument")
    println("Input a int:")    
    val b = readLine()?.toInt()
        ?: return Result2.Error("Missing second argument")

    return Result2.Success(a + b)
  } catch (e: NumberFormatException) {
    return Result2.Error(e.message ?: "Invalid input")
  }
}

fun main() {
  val message = when (val result = runComputation2()) {
    is Result2.Success -> "Completed successfully: ${result.value}"
    is Result2.Error -> "Error: ${result.message}"
    else -> return     // result 결과를 제한할 수 없어 else 절이 필요함 
  }
  println(message)
}

코틀린은 (sealed)봉인된 클래스나 인터페이스(코틀린 1.5 이상)를 통해 이런 문제를 극복할 수 있다.

  • 상속하는 클래스는 내포된 클래스 or 객체로 정의, 같은 파일 내에 최상위 클래스로 정의돼야만 한다.
  • 이 밖에 영역에서는 봉인된 클래스 상속 불가하다. (final 클래스와 동일 효과)
  • 직접 인스턴스를 만들 수 없어 기본적으로 추상 클래스 이다.
sealed class Result {  // Result 클래스는 Success 와 Error로 제한했다
    class Success(val value: Any) : Result() {...}
    class Error(val message: String) : Result() {...}
}

Enum과 마찬가지로 불필요한 else를 사용 안해도 되는 빠진 부분이 없는 when 형태를 지원한다.

// compile 시점에 Result의 하위 클래스는 Success와 Error만 존재한다고 인식
val message = when (val result = runComputation()) {
    is Result.Success -> "Completed successfully: ${result.value}"
  is Result.Error -> "Error: ${result.message}"
}

// 봉인된 클래스 확장시 when 구문에 실수로 코드를 누락하는일이 발생하지 않는다
class Confirm(val message: String) : Result() {...}

상속 제한은 봉인된 클래스를 직접 상속한 클래스에 대해서만 성립한다.

// Result.kt
sealed class Result {  // Result 클래스는 Success 와 Error로 제한했다
    class Success(val value: Any) : Result()
    open class Error(val message: String) : Result()
}

// Util.kt
class FatalError(message: String): Result.Error(message)

데이터 클래스가 봉인된 클래스 계층에 속할 수도 있다.

sealed class Expr

data class Const(val num: Int): Expr()   // 봉인된 클래스의 하위 계층으로 사용
data class Neg(val operand: Expr): Expr()

봉인된 클래스를 객체로 구현할 수도 있다.

sealed class Result {
    object Completed: Result()
    class ValueProduced(val value: Any) : Result()
    class Error(val message: String) : Result()
}

8.2.4 위임

기존 클래스의 확장이나 변경이 어려울 경우 위임 패턴을 사용할 수 있다.

interface PersonData {
  val name: String
  val age: Int
}

open class Person17(
  override val name: String,
  override val age: Int
): PersonData

data class Book(val title: String, val author: PersonData) {
  override fun toString() = "'$title' by ${author.name}"
}

fun main() {
  val valWatts = Person17("Val Watts", 30)
  val introKotlin = Book("Introduction to Kotlin", valWatts)

  println(introKotlin) // 'Introduction to Kotlin' by Val Watts
}

// 메서드나 프로퍼티를 다른 객체에 전달하기 위해 작성해야하는 준비 코드가 너무 많은 것이 단점
class Alias(
  private val realIdentity: PersonData,
  private val newIdentity: PersonData
) :PersonData {
  override val name: String        // 인터페이스의 프로퍼티를 오버라이드한 커스텀 게터 작성
    get() = newIdentity.name

  override val age: Int
    get() = newIdentity.age
}

fun main21() {
  val valWatts = Person17("Val Watts", 30)
  val johnDoe = Alias(valWatts, Person17("John Doe", 25))
  val introJava = Book("Introduction to Java", johnDoe)

  println(introJava) // 'Introduction to Java' by John Doe
}

코틀린은 위와 같은 위임을 처리하는 기능을 내장하고 있다.

  • 상위 인터페이스 이름 바로 뒤 (by) 키워드 작성 후 위임할 인스턴스를 작성한다
class Alias(
  private val realIdentity: PersonData,
  private val newIdentity: PersonData
): PersonData by newIdentity // newIdentity 인스턴스에 있는 이름으로 메서드가 구현된다

구현을 바꾸고 싶은 경우 직접 멤버를 오버라이드할 수 있다.

class Alias(
  private val realIdentity: PersonData,
  private val newIdentity: PersonData
): PersonData by newIdentity {
    override val age: Int get() = realIdentity.age
}

클래스는 인터페이스 멤버를 구현할 때만 위임을 쓸 수 있다.

open class Person(  
  override val name: String,
  override val age: Int
)

class Alias(
  private val realIdentity: PersonData,
  private val newIdentity: PersonData
): Person by newIdentity  // error: only interface can be delegated to   // 클래스는 위임 불가

클래스 위임은 번거로운 준비 코드없이 객체 합성과 상속의 이점을 살릴 수 있다.

반응형

+ Recent posts

반응형