안드로이드와 멀티스레드 프로그래밍
안드로이드는 메인 스레드라 불리는 UI 스레드에서 모든 UI 관련 작업들을 처리한다. 만약 메인 스레드가 서버와의 네트워크 통신이나 데이터베이스 읽기/쓰기 작업들에 의해 길게 점유된다면 안드로이드 앱은 버벅이거나 멈추게 된다. 안드로이드를 개발하는 사람이라면 누구나 겪어보는 오류인 ANR(Application Not Responding)은 앱이 입력된 이벤트에 5초 이내에 응답하지 않는 경우에 일어나는데, 이 또한 메인 스레드를 길게 점유하는 작업이 원인이다.
이를 해결하기 위해 개발자들은 메인스레드 외에 새로운 스레드를 만들어 해당 스레드에서 길게 걸리는 작업을 처리한다. 이를 여러개의 스레드를 사용한다 해서 멀티스레드 프로그래밍이라 한다. 멀티 스레드 프로그래밍을 사용하고 길게 걸리는 작업을 별도의 스레드에서 처리하도록 만들면 앱에 버벅임이나 멈춤이 없어진다. 또한 하나의 실행중인 앱이 여러 개의 스레드를 가지고 각 스레드에서 작업을 처리할 수 있게 해주기 때문에 여러 작업을 동시에 처리할 수 있고, 같은 작업을 여러 스레드에서 수행한다면 더욱 빠르게 처리할 수 있다.
기존 멀티 스레드 프로그래밍 방식
안드로이드의 멀티 스레드 프로그래밍 방법은 프로그래밍이 생긴 뒤로 계속해서 발전해왔다. 안드로이드는 더욱 안전하고, 확장성 있는 멀티스레드 프로그래밍 방법을 만들어왔다. 그 방법들은 시간 순으로 다음과 같다.
- Thread를 직접 사용하는 방식
- Runnable을 만든 후, Thread에 Runnable을 넘겨서 실행하도록 한 방식
- Executor Service를 활용해 Thread Pool을 만들어 Runnable을 submit 하는 방식
- Rx 라이브러리를 이용하는 방식
각 방법 모두 깊이가 있어 자세히 다루는 것은 어렵지만 간단히 소개해보고자 한다. 어떤 식으로 멀티스레드 방법이 발전해왔는지 간단히 살펴보도록 하자.
Thread를 직접 사용하는 방식
Thread Class의 run 메서드를 구현하는 클래스를 만든 후, start 메서드를 사용해 실행 시키면 run 메서드 내부의 코드가 생성된 스레드에서 실행된다.
val thread = object : Thread() {
override fun run() {
sleep(1000L)
println("Hello Thread")
}
}
thread.start()
- 장점 : Main Thread가 아닌 별도 스레드에서 긴 시간이 걸리는 작업을 처리한다. 이로 인해 UI에 블로킹이 없어진다.
- 단점 : Thread 내부의 run 메서드를 오버라이드해서 사용하는 방식은 만든 Thread 인스턴스를 재활용하기 어렵게 만드는 문제가 있다.
Runnable을 만든 후, Thread에 Runnable을 넘겨서 실행하도록 한 방식
Runnable 인터페이스를 구현한 후, Thread의 파라미터로 구현한 Runnable을 넘겨 실행하는 방식.
val runnable = Runnable {
sleep(1000L)
println("Hello Runnable")
}
val runnableThread = Thread(runnable)
runnableThread.start()
- 장점 : 작업의 단위를 Thread가 아닌 Runnable로 만듦으로써, 작업(Runnable)이 재사용 가능해진다.
- 단점 : Runnable을 사용하기 위해서는 Thread 객체가 필요하다. 매번 스레드를 생성하고 해제하는 것은 매우 비용이 큰 작업이다.
Executor Service를 활용해 Thread Pool을 만들어 Runnable을 submit 하는 방식
Executors 클래스를 사용해 미리 스레드들을 잡아 놓는 Thread Pool을 만든 후, Thread Pool에 작업(Runnable)을 넘겨서 처리하도록 한 방식. 작업에 대한 스캐쥴링은 모두 Thread Pool에서 처리한다.
*스캐쥴링 : 어떤 스레드에서 어떤 작업을 할 것인지에 대한 설정
- 장점 : 여러 스레드를 미리 생성해서 Thread 생성 비용이 중복으로 생기기 않는다. 또한 스레드풀이 스캐쥴링을 하므로, 프로그래머는 스레드풀 내부의 스캐쥴링에 대해 약간만 신경 써도 된다.
- 단점 : 스레드를 Blocking 하는 작업이 많아질 경우, Thread Pool의 성능에 문제가 생기기 쉽다. 또한 멀티 스레드 작업의 경우 보통 한 스레드 풀만 사용하는 것이 아닌 용도별(계산을 위한 스레드풀, IO 작업을 위한 스레프 풀 등) 로 다른 스레드 풀은 사용하는데 스레드 풀 간에 스위칭이 매우 어렵다.
* 스레드를 Blocking 하는 작업 : 스레드가 멈추고 다른 작업이 완료될 때까지 기다리는 작업. 네트워크 통신이나, 데이터 베이스 관련 작업 등이 있다.
val executorService = Executors.newFixedThreadPool(2)
val runnable =Runnable{
sleep(1000L)
println("Hello threadpool")
}
executorService.submit(
runnable
)
기존 멀티 스레드 방식의 한계
안드로이드의 멀티스레드 프로그래밍 방식은 계속해서 진화해 왔다. 하지만 기존의 멀티 스레드 프로그래밍은 방식들은 모두 중요한 문제점을 안고 있다. 바로 모든 작업의 단위가 ‘스레드’ 라는 점이다. 스레드가 작업 단위가 되면 사용에 제한이 생긴다. 이유는 스레드가 블로킹 될 때 해당 스레드는 사용이 불가능해지기 때문이다.
또한 블로킹 작업은 많은 상황에서 문제를 일으킬 수 있다. 예를 들어 데드락이 생길 수 있다. 데드락이 생기게 되면 데드락에 걸린 스레드들은 교착 상태에 빠진다.
* 교착 상태란 두 개 이상의 스레드가 서로의 작업이 끝나기만을 기다리게 되어 작업을 더 이상 진행하지 못하는 상태를 의미한다.
가장 중요한 문제는 블로킹 작업들이 생길 때 블로킹이 끝날 때까지 해당 스레드를 더이상 사용하기 어렵다는 점이다. 아래와 같이 하나의 스레드로 이루어진 Thread Pool이 있고 해당 스레드 풀에서 1초를 기다린 후 ‘.’을 프린트 하는 작업을 1000번 수행하도록 한다고 해보자.
fun main() {
val threadPool = Executors.newSingleThreadExecutor()
repeat(1000) {
threadPool.submit {
Thread.sleep(1000L)
print(".")
}
}
}
그러면 모든 작업을 수행하려면 대략 1000초가 걸린다. 스레드는 블로킹 되는 동안 사용이 불가능 하기 때문이다. 아래 그림을 보면 매우 느리게 '.'이 찍히는 것을 볼 수 있다.
‘Thread는 실행중인데 사용이 불가능하다는 것’은 여러 문제점을 야기한다. 대표적인 문제는 바로 리소스가 낭비된다는 점이다. 스레드는 생성 비용이 비싸고 작업을 전환하기 위한 비용이 비싼 객체이다. Thread 객체가 생성 후 아무런 작업을 하지 않고 다른 작업이 끝나기를 기다린다면 그만큼 리소스는 낭비된다.
자 이제 Coroutines가 어떻게 Blocking Job을 최적화 하는지 살펴보자.
'안드로이드를 위한 Coroutines' 카테고리의 다른 글
[안드로이드를 위한 Coroutines] 책 소개 (0) | 2023.01.31 |
---|