본 글은 조세영님의 <코틀린 코루틴의 정석>을 읽고 이를 바탕으로 작성되었습니다.
안드로이드의 비동기 처리하면 단연 떠오르는 것은 코루틴(Coroutine)이다. 나 역시 비동기 프로그램을 구현할 때 코루틴을 주로 사용했는데, 코루틴의 동작 방식과 적절한 시점에서의 효율적인 처리 방법을 알아봐야겠다고 생각해 위 도서를 읽기 시작했고, 읽으면서 배운 내용을 본격적으로 블로그에 정리하기로 했다.
코루틴에 대해서 알아보기 전에, 코루틴이 대체 왜 등장했는가에 대해 알기 위해 비동기 / 동기와 멀티 스레드의 개념에 대해 먼저 짚고 넘어가보자.
동기 / 비동기 ?
- Synchronous (동기) : 요청을 보낸 후 해당 요청의 응답을 받아야 다음 동작을 실행하는 방식, 즉 순차적인 실행
- Asynchronous (비동기) : 요청을 보낸 후 응답과 관계없이 다음 동작을 실행
스레드 / 프로세스 ?
- Process (프로세스) : 프로그램이 메모리에 적재되고 CPU 자원을 할당받아 실행되는 것
- Thread (스레드) : 프로세스가 할당받은 자원을 이용하는 실행 흐름의 단위
프로세스와 스레드에서는 추후 자세히 알아보기로 하고, 간략하게 설명하면
하나의 프로그램에는 하나 이상의 프로세스가 있고, 하나의 프로세스에는 하나 이상의 스레드가 있다.
또, 같은 프로세스에 종속된 스레드는 각자의 스택을 가지고, 하나의 힙을 공유한다는 것만 알아두고 일단 넘어가자.
단일 스레드 / 멀티 스레드 ?
이름처럼 단일 스레드는 하나의 스레드가 운영되며, 멀티 스레드는 2개 이상의 스레드가 운영된다.
단일 스레드와 멀티 스레드는 각각 장단점을 가지고 있는데, 본 글에서의 요점은 단일 스레드의 한계이다.
만약 어플리케이션이 단일 스레드만 사용해 작업한다면 다른 작업을 동시에 수행하지 못하고, 따라서 메인에서의 작업이 오래걸린다면 요청을 처리하는 속도가 늦어질 것이다. 이는 응답 속도 저하나 UI 중단, 사용자 입력을 받지 못하는 등 성능 저하와 사용성 저하를 초래할 것이다.
즉, 단일 스레드만 사용하면 해야할 작업이 다른 작업에 의해 방해받거나 작업 속도가 느려질 수 있다.
이를 해결하기 위해 멀티 스레드 프로그래밍을 도입한다. 안드로이드에서는 오래 걸리는 작업을 멀티 스레드 프로그래밍을 통해 메인스레드 대신 백그라운드 스레드가 처리하도록 해 위의 문제상황들을 방지한다.
여기까지 이해했다면 왜 멀티 스레딩이 필요한지는 모두 알게됐을 것이다. 하지만 그 답이 꼭 코루틴이어야 하는 이유는 무엇인가 ? 그 답을 알기위해 코루틴이 등장하기 전의 멀티 스레딩 기법들에 대해 알아보자.
코루틴 이전의 멀티 스레드 프로그래밍 기법
Thread 클래스를 이용한 멀티 스레드
먼저 스레드 클래스를 직접 다루어서 스레드를 생성해 멀티 스레드를 구현할 수 있다.
class ExampleThread : Thread() {
override fun run() {
super.run()
println("[${currentThread().name}] 새로운 스레드 시작")
sleep(2000L)
println("[${currentThread().name}] 새로운 스레드 종료")
}
}
위와 같이 Thread 클래스의 run()을 오버라이드하면 새로운 클래스에서 실행할 코드를 작성할 수 있다.
하지만 이렇게 Thread 클래스를 직접 다루는 방법에 한계가 있다.
- Thread 클래스를 직접 다루므로 이를 상속한 클래스를 인스턴스화할때마다 매번 새로운 스레드를 생성해야하는데, 스레드는 생성 비용이 비싸므로 성능적으로 좋지 않다.
- 또, 스레드 생성과 그 관리를 개발자가 직접해야한다. 따라서 오류, 메모리 누수의 가능성이 높다.
Executor 프레임워크를 이용한 Thread Pool 사용
Thread Pool은 스레드의 집합으로, Executor 프레임워크는 이 스레드풀을 관리하고, 사용자가 작업을 요청하면 스레드풀의 스레드들에게 작업을 할당한다. 또, 스레드가 작업을 끝내면 해당 스레드를 종료하고 다음 작업에 재사용시킨다.
스레드풀은 미리 생성되어있고, 개발자가 더 이상 스레드를 직접 관리하지 않고 스레드가 재사용된다는 점에서 Thread 클래스를 직접 사용하는 방법보다 효율적이다. 하지만 여기에도 여전히 한계가 존재한다.
Thread Blocking : 스레드가 아무것도 하지 못하고 사용될 수 없는 상태
- 여러 스레드가 동기화 블록에 동시 접근할 때 / 뮤텍스나 세마포어로 인해 공유자원에 접근 불가할 때가 주 원인이다.
ExecutorService를 사용할때, 언제 반환될지 모르는 결과값을 기다릴때 Future를 사용한다.
이때, Future은 미래에 언제 올지 모르는 값을 기다리기 위해 get()을 사용하고, 이를 호출한 스레드가 결과가 반환될때까지 블로킹된다
→ 앞서 언급한 것 처럼 스레드는 비싼 자원이기 때문에 블로킹, 즉 사용될 수 없는 상태에 놓이는 것은 성능적으로 좋지않다.
이후 위 한계를 보완하기 위해 CompletableFuture, RXJava가 등장했지만 이들은 결국 모두 근본적인 한계를 가지고 있었다.
작업의 단위가 스레드인 멀티 스레드 프로그래밍에서는 Blocking 문제가 필연적으로 발생한다
결국 멀티 스레드 프로그래밍에서는 스레드에 각 작업을 할당하고, 스레드1의 결과를 스레드2가 받아 사용해야될 경우에 스레드1의 처리가 지연된다면 스레드2는 그동안 아무것도 하지못한다. 즉, Blocking 상태에 놓인다.
→ 앞서 언급한 것처럼 스레드는 비싼자원이므로 쓰이지 못한다는 사실 자체만으로 성능에 치명적이다.
콜백이나 체이닝 함수를 이용해 간단한 작업은 blocking을 피할 수는 있지만, 이는 피하는 방법일 뿐 수많은 작업들이 교차하는 어플리케이션안에서는 무용지물이다.
그렇다면 코루틴은 대체 어떤 방식이길래 이러한 문제를 극복했는가 ?
Coroutine의 작업 단위
사실 위의 작업 단위라는 말에서 눈치챈 사람들도 있을 것이다. 결국 작업 단위가 스레드라면 blocking문제는 피할 수 없다. 따라서 코루틴은 작업에 스레드를 할당하는게 아니라 Coroutine Object를 할당해 이 문제에서 벗어난다. 이게 코루틴이 경량 스레드라고 불리우는 이유이기도 하다.
코루틴의 작업 방식은 아래의 그림을 통해 한번에 설명하고 이해해보겠다.
위와 같이 코루틴은 일시 중단할 수 있는 작업 단위로서, 스레드에 자유롭게 뗐다 붙였다할 수 있다. 이를 통해 blocking 없이 비동기적으로 작업을 처리할 수 있고, 이를 통해 응답성과 성능 모두 향상시킬 수 있다.
'코틀린 > Kotlin Coroutine' 카테고리의 다른 글
[Kotlin][Coroutine] Flow의 이해 (0) | 2024.07.06 |
---|---|
[Kotlin][Coroutine] CoroutineContext (0) | 2024.07.02 |
[Kotlin][Coroutine] Async, Await, Deferred, withContext (0) | 2024.07.02 |
[Kotlin][Coroutine] CoroutineBuilder와 Job (0) | 2024.06.25 |
[Kotlin][Coroutine] CoroutineDispatcher와 ThreadPool (0) | 2024.05.18 |