[Language/Java] - [Kotlin] 자바와 코틀린의 공통점과 차이점
[Language/Java] - [Kotlin] 코루틴(Coroutines)에 대해서 알아보자
이전 글을 통해 코루틴에 대해 알아보았습니다. 이번에는 코루틴 기초에 대해서 자세하게 알아보도록 하겠습니다.
suspend 함수와 일반 함수의 차이점
- 먼저 suspend fun으로 선언되는 일시 중단 함수는 함수 내에 일시 중단 지점을 포함할 수 있는 특별한 함수입니다. 코루틴은 특정 시점에서 작업을 일시 중단하고, 다른 코루틴을 실행하도록 허용한 후 나중에 해당 작업을 재개할 수 있습니다. 일시 중단 함수는 코루틴에서 실행되는 일시 중단 지점이 포함된 코드들을 재사용할 수 있는 코드의 집합으로 만드는 역할을 합니다.
import kotlinx.coroutines.delay
class SuspendExample {
fun nomarl() {
println("Start")
delay(1000)
println("End")
}
}
- main 함수에서 delay 함수를 실행하려고 하면 아래와 같은 에러가 뜨는데요, 그 이유는 뭘까요?
- 이 오류를 살펴보면, 일시 중단 함수인 delay는 일시 중단 함수에서만 호출될 수 있다는 의미입니다. 왜 이런 오류가 나는 것일까? 바로 delay 함수는 코루틴이 일정 시간 동안 스레드를 양보하도록 만드는 함수여서 일시 중단 함수로 선언되었기 때문입니다. 따라서 이 함수를 호출하는 곳도 일시 중단 함수여야 합니다.
Suspend function 'delay' should be called only from a coroutine or another suspend function
- suspend 함수 안에서 실행해보도록 하겠습니다. 그리고 내부적으로 어떤 동작을 수행하는지 확인하기 위해, Bytecode를 Decompie하여 Java 코드로 변환해보도록 하겠습니다.
class SuspendExample {
suspend fun greet() {
delay(100)
println("Hello, World!")
}
}
- 가장 먼저 greet에 Continuation이라는 인자가 추가된 걸 볼 수 있습니다.
public final class SuspendExample {
@Nullable
public final Object greet(@NotNull Continuation $completion) {
...
}
}
- 코드를 살펴보면 아래와 같은 특성이 존재합니다.
- undefinedtype이 정확히 알 순 없지만 추가되었습니다.
- Continuation 인자가 undefinedtype을 구현했는지 체크합니다.
- 맞다면 $continuation에 추가하고 break 합니다.
- 아니라면 ContinuationImpl 추상 클래스를 상속하여 invokeSuspend 함수를 구현합니다.
- ContinuationImpl에는 label과 result를 포함한합니다.
Object $continuation;
label20: {
if ($completion instanceof <undefinedtype>) {
$continuation = (<undefinedtype>)$completion;
if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
break label20;
}
}
$continuation = new ContinuationImpl($completion) {
// $FF: synthetic field
Object result;
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return SuspendExample.this.greet((Continuation)this); // 여기서 재귀 호출
}
};
}
- switch 문을 통해 label에 따라서 코드를 수행합니다.
- switch 문이 종료된 이후, System.out.println을 통해서 Hello, World! 출력한다.
- Kotlin의 Unit을 반환한다.
Object $result = ((<undefinedtype>)$continuation).result;
Object var5 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (((<undefinedtype>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
((<undefinedtype>)$continuation).label = 1;
if (DelayKt.delay(1000L, (Continuation)$continuation) == var5) {
return var5;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
String var2 = "Hello, World!";
System.out.println(var2);
return Unit.INSTANCE;
- 최종 코드는 아래와 같습니다.
public final class SuspendExample {
@Nullable
public final Object greet(@NotNull Continuation $completion) {
Object $continuation;
label20: {
if ($completion instanceof <undefinedtype>) {
$continuation = (<undefinedtype>)$completion;
if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
break label20;
}
}
$continuation = new ContinuationImpl($completion) {
// $FF: synthetic field
Object result;
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return SuspendExample.this.greet((Continuation)this);
}
};
}
Object $result = ((<undefinedtype>)$continuation).result;
Object var5 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (((<undefinedtype>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
((<undefinedtype>)$continuation).label = 1;
if (DelayKt.delay(1000L, (Continuation)$continuation) == var5) {
return var5;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
String var2 = "Hello, World!";
System.out.println(var2);
return Unit.INSTANCE;
}
}
- 위 샘플 코드에 대한 Koltin Complier의 변환 결과는 아래와 같습니다. 아직은 이해가 잘 안되도 괜찮습니다. 일단 그렇다는 사실만 알아둡시다.
- Continuation 인자를 추가
- Continuation 구현체를 생성
- switch문을 추가
- DelayKt.delay를 호출하며 continuation을 전달하고 종료
- System.out.println을 수행
- Unit 반환
- suspend 함수의 동작을 이해하려면
FSM
과Continuation passing style
에 대해 추가로 학습해봐야 합니다. 그 중 FSM에 대해서, 먼저 알아봅시다.
FSM (finite state machine)
FSM이란 컴퓨터 과학에서 널리 사용되는 개념으로, 다양한 시스템의 동작을 모델링하고 설계하는 데 유용합니다. FSM은 유한한 수의 상태와 그 상태들 간의 전이(transition)를 통해 시스템의 동작을 표현합니다.
기본 구성 요소
- 상태 (State)
- 시스템이 특정 시점에 있을 수 있는 각 상황을 나타냅니다.
- FSM은 유한한 수의 상태를 가지며, 한 번에 오직 하나의 상태만을 가질 수 있습니다.
- 초기 상태 (Initial State)
- 시스템이 시작할 때의 상태입니다.
- FSM은 항상 하나의 초기 상태를 가집니다.
- 종료 상태 (Final State)
- 시스템이 종료될 수 있는 상태입니다.
- 종료 상태는 여러 개일 수 있으며, 반드시 필요한 것은 아닙니다.
- 전이 (Transition)
- 하나의 상태에서 다른 상태로의 이동을 나타냅니다.
- 전이는 특정 이벤트나 조건에 의해 발생합니다.
- 이벤트 (Event) 또는 입력 (Input)
- 상태 전이를 유발하는 외부 요인입니다.
구현
- FSM을 구현하면 아래와 같습니다.
- label을 이용해서 when 문을 수행합니다.
- 각각의 case에서 작업을 수행하고 label을 변경합니다.
- 재귀함수를 호출하며 nextLabel을 전달하여 상태를 변경합니다.
- 실행순서는 다음과 같습니다. Initial -> State1 -> State2 -> End
class FsmExample {
fun execute(label: Int = 0) {
var nextLabel: Int? = null
when (label) {
0 -> {
log.info("Initial")
nextLabel = 1
}
1 -> {
log.info("State1")
nextLabel = 2
}
2 -> {
log.info("State2")
nextLabel = 3
}
3 -> {
log.info("End")
}
}
// transition
if (nextLabel != null) {
this.execute(nextLabel)
}
}
}
fun main() {
val fsmExample = FsmExample()
fsmExample.execute()
}
이번에는 FSM과 일반적인 방식으로 구현한 코드를 비교해보겠습니다.
object NormalCalculator {
fun calculate(initialValue: Int) {
var result = initialValue
result += 1
result *= 2
log.info("Result: {}", result)
}
}
fun main() {
NormalCalculator.calculate(5)
}
12라는 결과가 출력됩니다. FSM 방식으로 구현한 코드도 마찬가지입니다.
object FsmCalculator {
data class Shared(
var result: Int = 0,
var label: Int = 0,
)
fun calculate(initialValue: Int,
shared: Shared? = null) {
val current = shared ?: Shared()
val simpleCont = Continuation<Int>(EmptyCoroutineContext) {
this.calculate(initialValue, current)
}
when (current.label) {
0 -> {
current.label = 1
initialize(initialValue, simpleCont)
current.result = initialValue
}
1 -> {
current.result += 1
current.label = 2
}
2 -> {
current.result *= 2
current.label = 3
}
3 -> {
log.info("Result: {}", current.result)
return
}
}
}
private fun initialize(value: Int, cont: Continuation<Int>) {
log.info("Initial")
cont.resume(value)
}
private fun addOne(value: Int, cont: Continuation<Int>) {
log.info("Add one")
cont.resume(value + 1)
}
private fun multiplyTwo(value: Int, cont: Continuation<Int>) {
log.info("Multiply two")
cont.resume(value * 2)
}
}
fun main() {
FsmCalculator.calculate(5)
}
코틀린의 suspend 함수는 FSM의 상태 기반 로직에 따라, 비동기 프로그래밍 로직을 구현합니다.
- 상태 관리
- 코루틴의 실행 상태는 FSM의 상태와 유사하게 관리될 수 있습니다. 코루틴은 일시 중단(suspend)되고 재개(resume)되는 다양한 상태를 가질 수 있습니다. 이는 FSM에서 상태 전이가 발생하는 방식과 비슷합니다.
- 이벤트 처리
- FSM은 이벤트에 기반한 상태 전이를 정의하는데, 코루틴도 비슷하게 비동기 이벤트(예: 데이터 수신, 타이머 완료 등)를 처리합니다. suspend 함수는 이러한 이벤트가 발생할 때 코루틴의 상태를 변경할 수 있습니다.
- 상태 전이와 일시 중단
- FSM에서 상태 전이는 특정 조건이나 이벤트에 의해 발생합니다. 마찬가지로 코루틴에서 suspend 함수는 비동기 작업이 완료되거나 특정 조건이 충족될 때 재개됩니다. 이는 상태 전이와 유사한 개념입니다.
- 복잡한 흐름 제어
- 복잡한 비동기 흐름을 관리하기 위해 FSM을 사용할 수 있으며, 코루틴의 suspend 함수도 복잡한 비동기 로직을 간결하게 표현하는 데 도움을 줍니다. 두 개념을 결합하면 복잡한 비동기 상태 관리를 명확하게 할 수 있습니다.
Continuation passing style
- Continuation은 Kotlin의 코루틴에서 사용되는 개념으로, suspend 함수의 중단된 지점을 재개할 수 있는 객체입니다. 이 Continuation이 코루틴에서 비동기 작업을 순차적으로 작성할 수 있게 해줍니다. 또한 코루틴 내부에서 자동으로 관리되며, 일반적으로 개발자가 직접 다루지 않습니다. 코루틴 빌더 (launch, async, runBlocking 등)를 통해 코루틴을 시작하면, 컴파일러가 내부적으로 Continuation을 생성하고 관리합니다.
- Continuation passing style이란 Caller가 Callee를 호출하는 상황에서 Callee는 값을 계산하여 continuation을 실행하고 인자로 값을 전달하며, continuation이 callee를 가장 마지막에서 딱 한 번 실행하는 것을 말합니다.
object CpsCalculator {
fun calculate(initialValue: Int, continuation: (Int) -> Unit) {
initialize(initialValue) { initial ->
addOne(initial) { added ->
multiplyTwo(added) { multiplied ->
continuation(multiplied)
}
}
}
}
private fun initialize(value: Int, continuation: (Int) -> Unit) {
log.info("Initial")
continuation(value)
}
private fun addOne(value: Int, continuation: (Int) -> Unit) {
log.info("Add one")
continuation(value + 1)
}
private fun multiplyTwo(value: Int, continuation: (Int) -> Unit) {
log.info("Multiply two")
continuation(value * 2)
}
}
fun main() {
CpsCalculator.calculate(5) { result ->
log.info("Result: {}", result)
}
}
- 위 예제를 보면, continuation 자체는 마지막에 한 번 호출되고 함께 전달되는 인자 값을 계속 연산하는 것을 볼 수 있습니다.
콜백 함수와의 차이점
- 콜백함수
- 프로그래밍에서 특정 이벤트가 발생하거나 특정 작업이 완료될 때 호출되는 함수입니다.
- 다른 함수에 인수로 전달되며, 그 함수가 특정 조건이 만족될 때 이를 호출합니다.
- 콜백은 비동기 프로그래밍, 이벤트 처리, UI 이벤트, 그리고 다양한 비동기 작업에서 중요한 역할을 합니다.
- Continuation
- 콜백과 다르게, 마지막에서 딱 한번 호출된다는 특징이 있습니다.
- 주로 모든 결과를 계산하고 다음으로 넘어가는 상황에서 호출합니다.
Continuation 인터페이스와 구현
- Kotlin coroutines에서는 아래와 같은 Continuation 인터페이스를 제공합니다.
- resumeWith를 구현하여, 외부에서 해당 continuation을 실행할 수 있는 endpoint를 제공합니다.
- 또한 CoroutineContext를 포함합니다.
package kotlin.coroutines
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
- 이제 실제 구현 코드를 살펴봅시다. 아래 코드는 Continuation 인터페이스를 구현하는 익명 클래스를 생성하여 context와 resumeWith를 구현한 코드입니다.
- context에는 EmptyCoroutineContext를 두고, resumeWith에서는 상태에 따라 다른 코드가 실행되도록 합니다.
- 결과 뿐 아니라, 에러도 전달 가능합니다.
- 실행 순서는 다음과 같습니다. Visited: now -> Result: Success(10) -> Result: Failure(java.lang.IllegalStateException)
fun main() {
var visited = false
val continuation = object: Continuation<Int> {
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: Result<Int>) {
if (visited) {
log.info("Result: {}", result)
} else {
log.info("Visit now")
visited = true
}
}
}
continuation.resumeWith(10)
continuation.resumeWith(10)
continuation.resumeWithException(
IllegalStateException()
)
}
FSM과 CPS의 적용
- FSM과 CPS의 적용을 어떻게 수행하는지 알아보기 위해, 1차에선 직접 연산을 수행하는 부분을 CPS가 적용된 연산 함수로 대체하고 2차에선 calculate 함수에 CPS를 적용해보록 하겠습니다.
- 아래 코드는 FSM 을 통해, 구현한 계산기입니다. current.label에 따라 수행되는 로직이 달라지고, 각 label에서 연산을 직접 수행하고 수행된 값을 current에 직접 반영합니다.
- 수행하는 연산은 다음과 같습니다. 값 초기화, +1, *2.
- 아래 코드는 FSM 을 통해, 구현한 계산기입니다. current.label에 따라 수행되는 로직이 달라지고, 각 label에서 연산을 직접 수행하고 수행된 값을 current에 직접 반영합니다.
object FsmCalculator {
data class Shared(
var result: Int = 0,
var label: Int = 0,
)
fun calculate(initialValue: Int,
shared: Shared? = null) {
val current = shared ?: Shared()
val simpleCont = Continuation<Int>(EmptyCoroutineContext) {
this.calculate(initialValue, current)
}
when (current.label) {
0 -> {
current.result = initialValue
current.label = 1
}
1 -> {
current.result += 1
current.label = 2
}
2 -> {
current.result *= 2
current.label = 3
}
3 -> {
log.info("Result: {}", current.result)
return
}
}
this.calculate(initialValue, current)
}
}
fun main() {
FsmCalculator.calculate(5)
}
- 이번에는 각각의 연산에 CPS를 적용해보겠습니다.
- 각각의 함수는 cont.resume가 무엇을 하는지 모르지만, 값을 계산하여 cont.resume으로 전달합니다.
private fun initialize(value: Int, cont: Continuation<Int>) {
log.info("Initial")
cont.resume(value)
}
private fun addOne(value: Int, cont: Continuation<Int>) {
log.info("Add one")
cont.resume(value + 1)
}
private fun multiplyTwo(value: Int, cont: Continuation<Int>) {
log.info("Multiply two")
cont.resume(value * 2)
}
- 이번엔 직접 재귀함수를 호출하지 않고 Continuation의 resumeWith에서 재귀함수를 대신 호출해보겠습니다.
- 결국, 각각의 연산자들은 cont.resume이 무엇인지 몰랐지만 연산을 수행하고 재귀함수를 호출하게 됩니다.
- 마지막으로 resume을 통해서 건네받은 결과값을 Shared의 result에 저장합니다.
fun calculate(initialValue: Int, shared: Shared? = null) {
val current = shared ?: Shared()
val cont = object: Continuation<Int> {
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: Result<Int>) {
current.result = result.getOrThrow()
this@FsmCalculatorUpgrade1.calculate(initialValue, current)
}
}
when (current.label) {
0 -> {
current.label = 1
initialize(initialValue, cont)
}
1 -> {
val initialized = current.result as Int
current.label = 2
addOne(initialized, cont)
}
2 -> {
val added = current.result as Int
current.label = 3
multiplyTwo(added, cont)
}
3 -> {
val multiplied = current.result as Int
log.info("Result: {}", multiplied)
return
}
}
}
- label에 따라서 다른 연산이 수행되며
- 각각의 case에서 Shared 객체의 result 값(이전 연산 함수의 결과를 저장)을 꺼내서 변수에 저장하고 활용합니다.
- label을 변경합니다.
- 연산 함수를 실행하면서 continuation을 전달합니다.
- 3번 케이스에 도달하면 Shared 객체의 result를 log로 출력합니다.
- 실행 결과는 다음과 같습니다.
- Initial -> Add one -> Multiply two -> Result: 12
- 실행 결과는 다음과 같습니다.
fun calculate(initialValue: Int,
shared: Shared? = null) {
val current = shared ?: Shared()
val cont = object: Continuation<Int> {
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: Result<Int>) {
current.result = result.getOrThrow()
this@FsmCalculatorUpgrade1.calculate(initialValue, current)
}
}
when (current.label) {
0 -> {
current.label = 1
initialize(initialValue, cont)
}
1 -> {
val initialized = current.result as Int
current.label = 2
addOne(initialized, cont)
}
2 -> {
val added = current.result as Int
current.label = 3
multiplyTwo(added, cont)
}
3 -> {
val multiplied = current.result as Int
log.info("Result: {}", multiplied)
return
}
}
}
- calculate에 CPS 적용
- CPS를 통해서 모든 함수는 continuation을 인자로 전달받고 resume을 수행한다.
- calculate 함수는 Continuation 인터페이스를 활용하여 main이 전달하는 completion과 스스로가 전달하는 CustomContinuation 모두 수용한다.
- 마지막 state가 아니라면 CustomContinuation의 resume을 수행한다.
- 마지막 state라면 main의 completion을 수행한다.
class FsmCalculatorUpgrade2 {
private class CustomContinuation(
val completion: Continuation<Int>,
val that: FsmCalculatorUpgrade2,
) : Continuation<Int> {
var result: Any? = null
var label: Int = 0
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: Result<Int>) {
this.result = result.getOrThrow()
that.calculate(0, this)
}
fun complete(value: Int) {
completion.resume(value)
}
}
fun calculate(
initialValue: Int,
continuation: Continuation<Int>,
) {
val cont = if (continuation is CustomContinuation) {
continuation
} else {
CustomContinuation(continuation, this)
}
when (cont.label) {
0 -> {
cont.label = 1
initialize(initialValue, cont)
}
1 -> {
val initialized = cont.result as Int
cont.label = 2
addOne(initialized, cont)
}
2 -> {
val added = cont.result as Int
cont.label = 3
multiplyTwo(added, cont)
}
3 -> {
val multiplied = cont.result as Int
cont.complete(multiplied)
}
}
}
private fun initialize(value: Int, cont: Continuation<Int>) {
log.info("Initial")
cont.resume(value)
}
private fun addOne(value: Int, cont: Continuation<Int>) {
log.info("Add one")
cont.resume(value + 1)
}
private fun multiplyTwo(value: Int, cont: Continuation<Int>) {
log.info("Multiply two")
cont.resume(value * 2)
}
}
fun main() {
val completion = Continuation<Int>(EmptyCoroutineContext) {
log.info("Result: {}", it)
}
FsmCalculatorUpgrade2().calculate(5, completion)
}
레퍼런스
반응형
'Language > Java & Kotlin' 카테고리의 다른 글
[Kotlin] 자주 쓰이는 코틀린 문법 정리 1. 코틀린 스코프함수 (apply, run, with, let) (0) | 2024.08.23 |
---|---|
[Kotlin] 코루틴(Coroutines)에 대해서 알아보자 (0) | 2024.06.14 |
[Java] 여러 개의 Java 버전이 설치되어 있을 때, Java 버전을 자동으로 변경하는 방법 (0) | 2024.06.04 |
[Kotlin] 자바와 코틀린의 공통점과 차이점 (0) | 2024.05.26 |
[JPA] 영속성 컨텍스트란 (0) | 2024.01.29 |
댓글