[Coroutine] 중단 함수
코틀린 코루틴을 공부하며 정리한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀
사람들은 왜 코루틴을 사용하는 것일까? 이 글을 읽으면 그 이유에 대해 쉽게 이해할 수 있으며, 조금은 쉽게 코루틴을 이해할 수 있을 것이다. 이해를 위해 약간 극단적인 테스트를 진행해보자.
🛠️ 개발 환경 및 테스트 환경
테스트 환경은 코틀린 + 스프링 부트 단일 스레드로 환경에서 진행되며, 매 5초마다 서버에 요청을 보내고, 서버에서는 5.5초 뒤에 응답을 보내는 형태이다.
- kotlin : 1.9.24
- Spring Boot : 3.3.1
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}
server:
tomcat:
accept-count: 5
max-connections: 150
threads:
max: 1
min-spare: 20
setInterval(() => {
fetch('/like', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
like: true
})
})
}, 5000)
단일 스레드에서 코루틴을 사용하지 않을 경우
앞서 설명한 것과 같이 단일 스레드 환경에서 서버에서는 Reqeust
가 들어올 경우, 5.5초 뒤에 Response
를 보내는 형태이다.
테스트 코드
@RestController
class LikeController {
@PostMapping("/like")
fun doLike(): String {
Thread.sleep(5500L)
return "like"
}
}
테스트 결과
테스트는 좌측이 웨일, 우측이 크롬이며, 모두 동일한 주소로 접속한 뒤, 1분 가량 방치한 상태로 진행하였다.
결과를 보면 서버가 1개의 Thread
만 사용하기 때문에, 1번 요청(크롬)은 5.56초만에 받았지만, 2번 요청(크롬)은 응답을 받는 데 11.58초가 걸렸고, 3번 요청(웨일)은 16.94초가 걸렸다. 보낸 요청의 순서는 크롬 -> 웨일 -> 크롬 -> 웨일 ... 순서이다. 즉, 1개의 스레드만 일을 하기 때문에, 한 번에 한 요청만 처리해 다른 요청들은 응답을 받기까지 오랜 시간이 걸리게 되는 것이다.
단일 스레드에서 코루틴을 사용한 경우
아래 내용은 코루틴을 적용한 결과이며, 테스트 환경은 동일하지만, 컨트롤러 코드만 약간 차이가 있다.
테스트 결과
이번 테스트도 좌측이 웨일, 우측이 크롬이며, 모두 동일한 주소로 접속한 뒤, 1분 가량 방치한 상태로 진행하였다.
단일 스레드이지만, 이전과 다르게 요청을 보낼 때마다 5.5초 뒤에 바로 응답을 받는 것을 볼 수 있다.
테스트 코드
@RestController
class LikeController {
@PostMapping("/like")
suspend fun doLike(): String {
delay(5500L)
return "like"
}
}
무엇이 바뀐걸까? suspend
키워드가 함수 앞에 붙었고, Thread.sleep
이 delay
로 변경되었다. 여기서 가장 핵심이 되는 키워드는 바로 suspend
이다. Thread.sleep
과 delay
의 의미 자체가 달라 이 부분이 가장 크게 작용하지만, 이 또한 suspend
가 있어야 사용이 가능하다. 즉, 이 키워드 하나로 이런 마법 같은 일이 일어나는 것이다.
코루틴이란?
구글에 검색해서 나오는 위키 백과에서 정의한 코루틴은 다음과 같다.
코루틴(Coroutine)은 루틴의 일종으로서, 협동 루틴이라 할 수 있다.
Co는 함께라는 의미이며, routine은 규칙적인 순서를 의미한다. 즉, 내가 생각하는 코루틴은 '규칙적인 일을 순서에 맞게 함께 처리하는 것'이라 생각한다.
많은 사람들이 코루틴을 동시성 문제를 해결하기 위해 사용하며, 실제로 병렬로 일을 처리하는 것으로 생각한다. 하지만 이는 틀렸다. 코루틴으로 동시성 문제를 해결하는 것은 맞지만, 코루틴은 병렬로 일을 처리하지 않고, 중단한 뒤, 다른 일을 처리하고, 또 이를 중단하고, 다시 기존 일을 처리하는 것이다. 즉, 멀리서보면 병렬로 보이지만, 가까이서 보면 아래 사진처럼 조금씩 끊겨서 일을 처리하는 것이다.
예를 들어, 혼자서 반찬이 많이 나오는 음식점에 왔다고 가정하자. 우리는 보통 밥을 먹을 때, 하나의 반찬을 모두 다 먹을 때까지 먹지 않고, 여러 반찬을 돌아가면서 먹는다. 그렇게 모든 그릇을 비우고, 음식점을 나오게 된다.
이 예시를 코루틴에 비유하자면, 내가 서버이고, 반찬은 클라이언트가 보낸 요청이다. 그리고 내가 젓가락으로 반찬을 먹는 행위는 바로 클라이언트가 보낸 요청을 처리하는 것이다. 즉, 반찬1을 먹고 삼킨 다음 반찬2를 먹고 삼키면 반찬1은 내가 먹을 때까지 멈춰있게 된다. 이를 바로 중단 상태라고 하며, 우리가 젓가락으로 반찬을 먹는 행위를 중단 함수라고 한다.
비유는 절대적인 답이 될 수 없습니다. 이해를 위한 용도로만 생각해주시면 감사하겠습니다!
중단 함수란?
앞서 언급한 것과 같이 코틀린 코루틴에서 병렬과 같은 형태로 일을 처리할 수 있게 만들어주는 키워드는 바로 중단을 의미하는 suspend
이다. 책에 나온 비유를 보면, 게임을 하다 체크 포인트에서 저장하고, 다른 업무를 하다가, 다시 저장한 지점부터 게임을 시작하는 것과 비슷하다고 한다.
이 구현을 어떻게 했는지 코드로 살펴보자. 아래 코드는 doSomething
을 호출한 뒤, 1초 뒤에 프로그램이 종료되는 코드이다. 이런 간단한 코드가 자바 코드로 디컴파일 되면 어떤 코드로 변하는지 확인해보자.
suspend fun main() {
doSomething()
}
suspend fun doSomething() = coroutineScope {
delay(1000L)
}
아래 코드는 디컴파일 된 코드들이다.
@Nullable
public static final Object main(@NotNull Continuation $completion) {
Object var10000 = doSomething($completion);
return var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED() ? var10000 : Unit.INSTANCE;
}
우선, main
함수을 보면, 함수 매개 변수로 Continuation
을 받는 것을 볼 수 있다. 이처럼, 중단(suspend) 함수를 사용할 경우 Continuation
객체를 매개 변수로 받아, 언제든 함수를 이어서 실행할 수 있게 만든다. 이제 남은 코드들을 확인해보자.
// $FF: synthetic method
public static void main(String[] args) {
RunSuspendKt.runSuspend((Function1)null.INSTANCE);
}
@Nullable
public static final Object doSomething(@NotNull Continuation $completion) {
Object var10000 = CoroutineScopeKt.coroutineScope((Function2)(new Function2((Continuation)null) {
int label;
public final Object invokeSuspend(Object $result) {
Object var2 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
Continuation var10001 = (Continuation)this;
this.label = 1;
if (DelayKt.delay(1000L, var10001) == var2) {
return var2;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException(
"call to 'resume' before 'invoke' with coroutine"
);
}
return Unit.INSTANCE;
}
public final Continuation create(
Object value, Continuation $completion
) {
return (Continuation)(new <anonymous constructor>($completion));
}
public final Object invoke(CoroutineScope p1, Continuation p2) {
return ((<undefinedtype>)this.create(p1, p2)).invokeSuspend(Unit.INSTANCE);
}
public Object invoke(Object p1, Object p2) {
return this.invoke((CoroutineScope)p1, (Continuation)p2);
}
}), $completion);
return var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED() ? var10000 : Unit.INSTANCE;
}
무슨 저 짧은 코드가 이렇게 괴상하게 변하나 싶겠지만, 실제로 이렇게 디컴파일 된다. 하나씩 확인해보자.
main
함수가 실행되고,doSomething(completion)
이 호출된다.doSomething
은 받은Continuation
객체를 토대로 코루틴 스코프 함수를 만든다.coroutineScope
함수가 생성 되면서Function2
를 만들고,invoke
함수가 실행된다.- 함수가 실행되면서,
create
와invokeSuspend
가 실행된다. label
이 0인 상태로switch
문에 진입하고,label
을 1로 미리 변경해놓고, 1초가 지날 때까지 현재 함수를COROUTINE_SUSPENDED
, 즉 일시 중단 상태로 만든다.- 코루틴이 재개되면,
label
이 1인 경우를 확인하고, 예외가 없다면,Unit
을 반환한다.
이렇게 코루틴은 switch
문을 활용하여 어디까지 진행했는지 관리하고, 특정 부분에서 재개하는 것을 알 수 있다.
정리
코루틴은 병렬로 작업을 처리하는 것이 아닌, 중단을 통해 여러 일을 처리해, 병렬로 처리되는 것처럼 보이는 것이다. 이를 도와주는 것은 suspend
키워드가 붙은 중단 함수이며, 중단 함수를 선언하면, Continuation
객체를 매개 변수로 받는 함수로 변환되는 것을 알 수 있다. Continuaion
객체는 함수를 재개할 수 있게 도와준다.