Thread 구조와 다중 Thread 작업의 필요성
하나의 프로세스(Process) 에는 여러 스레드(Thread) 가 있고, 각 스레드는 독립적으로 작업을 수행할 수 있다. 예를 들어 JVM 프로세스 상에서는 스레드는 그림1과 같이 구성된다.
그림1의 메인 스레드(Main Thread)를 보자. JVM프로세스는 메인 스레드가 main 함수를 실행하며 시작되며, 만약 프로세스 내에 사용자 스레드가 메인 스레드 밖에 없는 상황에서는 메인 스레드가 종료되면 프로세스 또한 강제로 종료되는 특성을 가진다. 이때 메인 스레드는 한 번에 하나의 작업을 실행 가능하다.
그런데 그림에서는 메인 스레드말고 다른 2개의 스레드(Thread)가 보인다. 이 스레드(Thread)들은 사용자에 의해 생성되는 스레드로, 메인 스레드와 마찬가지로 작업을 수행할 수 있다.
일반적으로 데몬 스레드로 생성되지만, 종종 메인 스레드와 같이 사용자 스레드로 생성된다. JVM 프로세스는 사용자 스레드가 모두 종료될 때 종료된다.
안드로이드 앱을 예제로 들면 메인 스레드는 가장 중요한 스레드로, UI를 그려주고 사용자가 화면을 눌렀을 때 이벤트를 전달받는 스레드이다. 즉, 사용자와의 인터렉션을 담당하는 스레드이다. 만약 이 Thread가 높은 부하를 받는 작업에 의해 블로킹된다면 안드로이드 앱은 멈춤 현상이 생기고, 일정 시간 이상 블로킹될 때 앱은 강제로 종료된다. 따라서 메인 스레드에서 많은 부하를 받는 작업은 지양해야 하며, 다른 스레드를 생성해 해당 스레드에 높은 부하를 주는 작업을 수행하도록 만들어야 한다.
기존의 접근 방식과 한계점
그 동안 코틀린 애플리케이션에서는 메인 스레드가 블로킹되는 문제를 해결하기 위해 많은 방법이 시도되었다. 가장 대표적인 방법으로는 다음의 방식들이 있다.
Thread 클래스를 상속하는 방법
Thread 클래스를 상속하는 새로운 클래스(아래에서는 ExampleThread)를 만들고, run 메서드를 override 하면, 새로운 스레드에서 실행될 작업을 정의할 수 있다. 새로운 클래스의 인스턴스를 만들어 start 함수를 호출하면, 새로운 스레드에서 작업이 실행된다.
fun main() {
val exampleThread = ExampleThread()
exampleThread.start()
}
class ExampleThread : Thread() {
override fun run() {
println("[${Thread.currentThread().name}] New Thread Running")
}
}
/*
출력
[Thread-0] New Thread Running
*/
하지만 이 방법으로 생성한 스레드의 인스턴스는 많은 메모리를 차지하면서, 재사용이 어려운 단점이 있었다. 또한 스레드를 개발자가 직접 생성하고 관리해야 하기 때문에 메모리 누수의 가능성이 올라간다.
이런 문제를 해결하기 위해서는 한 번 생성한 스레드의 재사용이 용이해야 하며 생성된 스레드의 관리가 개발자가 아닌 미리 구축한 시스템이 할 수 있어야 한다. 이러한 역할을 하기 위해 Executor 프레임웍이 등장한다.
Executor 프레임웍을 사용하는 방법
Executor 프레임웍은 개발자의 스레드 관리에 대한 책임을 낮추고 생성된 스레드 인스턴스의 재사용을 높였다. Executor 프레임웍은 사용자의 요청에 따라 스레드의 집합인 '스레드 풀'을 생성하고, 사용자가 작업을 제출하면 이 스레드 풀의 스레드 중 하나에 작업을 할당한다. 다음은 그 예시이다.
fun main() {
// ExecutorService 생성
val executorService: ExecutorService = Executors.newFixedThreadPool(4)
// 작업 제출
executorService.submit {
println("[${Thread.currentThread().name}] 새로운 작업1 시작")
}
// 작업 제출
executorService.submit {
println("[${Thread.currentThread().name}] 새로운 작업2 시작")
}
// ExecutorService 종료
executorService.shutdown()
}
/*
출력
[pool-1-thread-1] 새로운 작업1 시작
[pool-1-thread-2] 새로운 작업2 시작
*/
Rx 라이브러리를 사용하는 방식
Rx 라이브러리는 엄밀히 Reactive Programming을 돕기 위한 라이브러리이며, 데이터 스트림을 정의하고 데이터 스트림을 구독해 처리할 수 있게 하는 라이브러리이다. 라이브러리 내부에서는 subscribeOn, observeOn 메서드를 통해을 통해 데이터를 발행하는 스레드를 데이터를 구독하는 스레드를 손쉽게 분리할 수 있게 하지만, 간단한 작업들도 모두 데이터 스트림으로 만들어야 했기에 불편함이 존재했다.
publisher.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
기존 접근 방식들의 한계
위와 같은 기존 접근 방식들의 한계점은 작업의 단위가 스레드라는 점이다. 메인 스레드의 블로킹을 방지하기 위해 다른 스레드로 작업을 넘기면 된다고 했는데 작업의 단위가 스레드인 것이 문제라니 도대체 무슨 말인가 싶을 것이다.
스레드는 생성 비용이 비싸고 작업을 전환하는 비용이 비싸다. 또한 한 스레드가 다른 스레드부터의 작업을 기다려야 하면, 기본적으로 다른 스레드의 작업을 기다리는 스레드는 다른 작업이 스레드를 사용할 수 없도록 Blocking 된다. 이렇게 되면 해당 스레드는 하는 작업 없이 다른 작업이 끝마쳐질 때까지 기다려야 하기 때문에 자원은 낭비된다. 이것이 위의 작업 단위가 스레드일 경우 생기는 고질적인 문제점이다.
구체적인 사례를 다음 코드를 통해 살펴보자.
fun main() {
// ExecutorService 생성
val executorService: ExecutorService = Executors.newFixedThreadPool(4)
// 작업2 제출
val future : Future<String> = executorService.submit<String> {
println("작업2 시작")
Thread.sleep(2000L) // 작업 시간 2초
println("작업2 완료")
"작업2 결과"
}
// 작업1 제출
executorService.submit {
println("작업1 실행")
val result = future.get() // 작업1을 중지하고 작업2가 완료되는 것을 기다림 스레드 블로킹
println("${result}를 가지고 나머지 작업")
}
// ExecutorService 종료
executorService.shutdown()
}
이 코드는 그림3과 같이 동작한다.
위의 그림3을 보면 스레드1에서 작업1 수행 도중 스레드2의 작업2의 결과물이 작업1을 수행하는데 필요해졌다. 그때 작업1을 실행하던 스레드1은 아무것도 하는일 없이 블로킹되며 스레드2로부터 결과를 전달받아 작업1을 재개하기 까지 많은 시간이 소요된다. 이렇게 짧은 시간동안만 블로킹되면 다행이지만, 실제 상황에서는 스레드의 성능을 반도 발휘하지 못하게 만드는 블로킹이 반복될 수 있다.
그렇다면 코루틴은 기존 한계점을 어떻게 극복하는가?
코루틴은 작업 단위로, 스레드를 사용해 코루틴을 실행 할 수 있다. 하지만, 스레드 상에서 동작하는 코루틴은 언제든지 일시 중단이 가능하며, 이는 마치 스레드에 코루틴을 붙였다 땠다 할 수 있는 것과 같다. 이 때문에 코루틴은 '경량 스레드'라고도 불린다.
도대체 경량 스레드가 무엇인가? 일시 중단 가능한 것이 무엇인가? 이건 아무리 글로 설명해도 이해가 잘 안간다. 여러 글을 읽어보았지만, 실제로 써보면서 만든 것이 코루틴을 이해나는데 훨씬 도움되었다.
위의 그림3의 상황을 코루틴을 이용해 해결해보자. 작업1, 작업2는 각각 코루틴1, 코루틴2로 바꾸며 코루틴3이 추가로 실행 요청되는 상황을 가정하자.
1. 코루틴1이 생성돼 스레드1에 실행 요청되고, 코루틴2가 생성돼 스레드2에 실행 요청된다. 스레드1에서 코루틴1 실행 도중 나머지 연산에 코루틴2로부터의 결과가 필요해진다. 하지만, 코루틴2의 작업이 끝나지 않아 코루틴1의 작업을 마저할 수 없다. 이때 코루틴1은 스레드1을 블로킹 하는 대신 사용 권한을 양보하고 다른 코루틴이 스레드 위에서 실행될 수 있도록 한다.
2. 코루틴 3이 추가로 요청되면, 코루틴3은 자유로워진 스레드1 위에서 실행된다.
3. 코루틴 3의 실행을 마치면 스레드1 사용 권한을 반납한다.
4. 이후 스레드2에서 실행되면 코루틴2의 작업이 종료되고 결과를 반환한다. 그러면 코루틴1은 할당 받은 작업이 없는 스레드1 혹은 스레드2를 사용해 실행된다.(그림5에서는 코루틴1이 스레드1을 사용해 재개되는 것을 가정한다.)
즉, 코루틴은 스레드가 필요 없을 때 스레드의 사용 권한을 양보한다. 이를 통해 스레드를 블로킹 하는 상황이 줄어 각 스레드를 최대한 활용할 수 있다. 스레드는 비용이 매우 큰 객체이다. 코루틴은 스레드가 필요 없어지면, 스레드를 양보하는 방식으로 스레드 사용을 최적화 한다.
이를 정리하면 다음과 같다.
코루틴은 스레드 안에서 실행되는 일시 중단 가능한 작업의 단위이다.
하나의 스레드에서 여러 코루틴이 서로 스레드를 양보해가며 실행될 수 있다.
Kotlin Coroutines 공식 기술 문서 번역이 GitHub 오픈소스로 배포되었습니다. Starganizer가 되어 오픈소스를 지지해주세요.