코틀린 코루틴 (4)
suspend 함수는 코루틴 실행을 일시정지할 수 있는 함수이다. 일시정지를 한다는 말이 익숙하다. 바로 delay 함수가 지정한 시간만큼 코루틴을 일시정지한다고 했다. 그런데 난데없이 suspend 함수라는 녀석이 등장해서 코루틴을 일시정지할 수 있다고 한다.
delay()는 사실 suspend 함수 종류 중 하나이다.
suspend 함수는 delay()처럼 어떤 특정 함수를 지칭하는 것이 아니다. suspend라는 키워드를 사용해서 어떤 함수를 suspend 함수로 선언할 수 있는 것이다.
그냥 delay()로 일시정지하면 될 것 같은데 이걸 어디다가 써먹지? 이러한 의문이 생기는 것은 아직 코루틴과 관련된 다른 개념들을 알지 못해서 그렇다. 또한 코루틴 라이브러리에서는 delay()뿐만 아니라 다양한 suspend 함수를 제공한다. 앞서 코루틴 빌더라고 했던 withContext도 코루틴 빌더이자 suspend 함수이다. (코루틴 빌더에서 withContext를 엄격히 말하자면 Scoping Function이라고 한 이유의 일부이다.)
그럼 이 suspend 함수를 어떻게 활용할 수 있을지 살펴보자. 일단 아래 예제 코드를 보자. 실행결과가 예상되는가? 갑자기 delay()에 대한 복습이다. 이 코드는 사실 delay 함수에서 마지막에 출력결과를 보여주고 코드로 작성해 보라고 했던 것에 대한 하나의 답안이다.
여기서 갑자기 delay()를 더 살펴보자는 것은 아니다. 이제 이 코드를 간단하게 리팩터링 해보고자 한다. launch 블록 내에 있는 부분을 각각 함수로 분리해 보자.
짜잔~ 이렇게 분리해 보았다. 하지만 문제가 있다. delay(1000L)가 에러로 표시될 것이다. 왜 그럴까?
Suspend function 'delay' should be called only from a coroutine or another suspend function.
에러 부분을 확인해 보면 위와 같은 문구를 볼 수 있다. 한국말로 옮기면 suspend 함수 'delay'는 코루틴 또는 다른 suspend 함수에서만 호출해야 한다는 말이다. 이 문구에서 delay라는 말을 제거하면 좀 더 범용적인 표현이 된다. 다른 suspend 함수에도 해당하는 것이기 때문이다.
suspend 함수는 코루틴 또는 다른 suspend 함수에서만 호출해야 한다.
그러면 위 코드를 어떻게 수정해야 할지 쉽게 알 수 있다. launchA()와 launchB()에 각각 suspend 키워드 추가하여 suspend 함수로 만들면 된다.
suspend 함수인 delay()는 코루틴 내부에서도 호출할 수 있다고 했으니 launchA() 내부를 launch로 감싸면 되지 않을까? 하지만 launch는 coroutineScope 내에서 사용할 수 있다. 그런데 suspend 함수 내부가 coroutineScope인 것은 아니다. (이 부분은 나중에 살펴보게 될 것이다.)
엥? 이게 끝인가? 단순히 함수로 추출하기 위해서? 그럴 리가!
다시 코루틴을 무엇이라고 했는지 떠올려보자. 코루틴은 특정한 스레드에 종속되지 않고 일시정지를 통해 동시성을 제공한다고 했다. 그러한 동시성은 비동기적인 동작을 통해서 이룬다고 했다. 그리고 delay()를 사용하면서 하나의 코루틴을 일시정지 했을 때 가용 가능한 스레드 자원을 다른 코루틴에게 할당하여 비동기적으로 동작하는 것을 확인하였다. 그런데 delay()는 suspend 함수의 한 종류라고 했다.
그렇다면 suspend 함수를 통해서 동시성을 달성할 수 있다는 결론을 낼 수 있다. 이때 유용한 것이 바로 코루틴 빌더에서 launch와 함께 잠시 언급되었던 async이다. (앞서 등장한 떡밥들이 회수되는 느낌이지 않은가?) 퍼즐들의 조각이 하나씩 맞춰지는 기분이지 않은가? 앞으로도 이런 방식을 유지하려고 노력할 것이기 때문에 한 문장 한 문장에 주의를 기울여 읽으면 도움이 될 것이다.
동시성을 확인하기 전에 먼저 순차적으로 동작하는 코드를 하나 살펴보자.
measureTimeMillis은 해당 블록 실행 시간을 ms 단위로 반환해 준다. 위 코드의 실행 시간은 몇 ms일까?
runBlocking으로 만든 하나의 코루틴만 존재하고 추가적인 코루틴 빌더로 생성한 코루틴이 존재하지 않는 상황이다. 이런 경우 스레드 유휴 자원을 이용할 코루틴이 없기 때문에 비동기적으로 동작하지 않는다. 따라서 순차적으로 동작하는 코드가 된다. 결국 getName()에서 1000ms, getAge()에서 1000ms씩 일시정지하게 된다. 일시정지 후 각 함수의 작업이 재개되어 완료되면 반환값을 결과로 가져온다. 따라서 총 일시정지 시간이 2000ms가 된다. 그 외에 함수 호출과 출력 등의 동작을 수행하기 위한 아주 짧은 시간이 추가적으로 소모될 것이다. 결과적으로 2000ms 보다 아주 조금 더 많은 시간이 걸리게 된다. (ex. 2020ms)
그렇다면 이 코드를 비동기적으로 동작하도록 바꾸면 어떻게 될까? 코드를 보기 전에 실행 시간이 어떻게 될지 예상해 보자. (아직까지 동시성이 무엇인지 비동기가 무엇인지에 관해 정의하고 설명한 적이 없다는 것을 기억하라. 그 개념을 정의하고 시작하기보다 동작하는 형태를 보여주고 이런 것이 비동기라는 것을 먼저 느끼게 해주고 싶어서다. 그래서 용어에 대한 정리가 안되어 지금은 혼란스러울 수도 있겠지만 앞서 다룬 내용들을 조합하여 추론할 수 있도록 최대한 빌드업해보려고 한다.)
비록 비동기라는 게 무엇인지 정의를 내린 적은 없지만 앞서 비동기라는 말을 마주친 적은 있다.
launch A의 코드 블록이 완료되는 것을 기다리지 않고 delay()를 통해서 제어권을 launch B로 넘겼다. 동기적이라면 처음의 오해처럼 launch A의 제어권이 넘어가지 않고 단순히 1초 정지 후 나머지 launch A의 모든 작업을 수행한 후에 launch B가 실행되어야 한다.
결국 delay()를 통해 일시정지한 시간 동안 아무 작업을 하지 않고 기다리는 것이 아니라 가용 자원을 다른 작업에 활용할 수 있도록 하는 것이 비동기적으로 동작하는 것이다. 일시정지 되었다는 것은 다른 말로 표현하면 해당 작업이 완료되지 않았다는 것이다. 그래서 비동기는 하나의 작업이 완료될 때까지 기다리는 것이 아니라 다른 작업을 수행하는 것을 의미한다.
다시 원래의 질문으로 돌아와서 delay 함수 내용과 그림을 꼼꼼히 보고 이해했다면 비동기적으로 실행했을 때 실행 시간이 2000ms에 비해 줄어들 것이라는 것을 유추할 수 있을 것이다.
그럼 이제 실제 코드를 보자.
변경된 내용은 getName(), getAge()가 async 블록 내부에 존재하고 해당 함수의 결괏값을 가지는 name, age를 출력하는 부분에 await()가 추가되었다. async는 launch처럼 코루틴 빌더이지만 지금처럼 작업의 결과를 가져올 수 있다는 차이가 있다. await()는 작업이 완료될 때까지 일시정지 했다가 작업이 완료되면 결괏값을 반환한다. (이 내용은 완전히 이해하지 못해도 괜찮다. 각 코루틴 빌더에 대해 더 자세히 살펴볼 시간이 있을 것이다.)
이해력이 빠른 사람이라면 이 코드의 실행 시간은 1000ms 보다 아주 조금 더 많은 시간이 소모될 것이라고 예상할 것이다. (ex. 1020ms)
suspend 함수 내의 delay()는 결국 suspend 함수를 호출하고 있는 코루틴에 대한 일시정지이다.
measureTimeMillis 내부에서 가장 많은 시간을 소모하는 부분은 await()를 통한 기다림이다. await()가 없다면 실제로 async를 통해 만들어진 코루틴이 하는 일은 getName(), getAge()을 호출 후 delay()에 의해 바로 일시정지 하는 것뿐이다. 해당 작업은 (성능에 따른 차이는 있겠지만) 10ms도 되지 않는 매우 짧은 시간에 이뤄진다. async를 결괏값을 기다리지 않는 launch로 바꿔서 동작하는 것과 큰 차이가 없다. 둘 다 단순 함수 호출 후 일시정지 되어 다음 코루틴으로 제어권을 넘길 것이기 때문이다. (실제로 코드를 변경해서 확인해 보라.)
그래서 async { getName() }에서 4ms를 소모했다고 가정하고 async { getAge() }에서 4ms를 소모했다고 가정하면 실제로 name.await()와 age.await()를 만나기 전까지 소요된 시간은 10ms 미만인 것이다. getName()에서 delay(1000L)로 일시정지 후 10ms 후에 getAge()에서 delay(1000L)로 일시정지 되었다고 가정하자. 그러면 다른 요소에 의한 시간적 소모가 없다고 가정하면 getName()가 호출된 후 age.await()는 1010ms가 된다. 앞서 함수 호출 시간까지 모두 포함하면 measureTimeMillis 내부에서 소모된 총시간은 1018ms 정도가 된다. (이 내용도 완전히 이해하지 못해도 괜찮다. 선행 학습했다고 생각하고 나중에 async에 대해서 다시 살펴볼 기회가 있을 것이다.)
여기서는 async나 await() 보다 suspend 함수에 대해 집중한다. async는 코루틴 빌더라고 했으니 블록 내부가 CoroutineScope라는 것을 이제는 알 것이다. 따라서 suspend 함수인 getName()과 getAge()를 호출할 수 있다. 그리고 처음에 말했듯이 suspend 함수는 코루틴을 일시정지할 수 있는 함수이다. 일시정지라는 것은 정지가 아니기 때문에 재개될 수 있다는 것을 의미한다. 이것은 getName()과 getAge()에서 delay()를 통해 일시정지를 했을 때 각 작업들이 종료되지 않고 지정한 시간 후에 재개되어 결괏값을 반환하는 것을 통해서 확인했다.
오해하지 말아야 하는 것은 suspend 함수가 async 때문에 비동기가 되었고 그래서 동시성을 제공하는 것이 아니다. suspend 함수가 일시정지 가능하기 때문인 것이다. async는 앞서 말했듯이 유용한 사용성을 제공하는 역할을 한다.
마지막으로 suspend 함수도 함수이다. 따라서 일반적인 함수처럼 매개변수를 받을 수도 있고 예제에서 봤듯이 반환값을 가질 수도 있다.
요약하자.
- suspend 함수는 코루틴 실행을 일시정지할 수 있는 함수이다.
- suspend 함수는 코루틴 또는 다른 suspend 함수에서만 호출해야 한다.
- 비동기는 하나의 작업이 완료될 때까지 기다리는 것이 아니라 다른 작업을 수행하는 것을 의미한다.