Java - 스레드 한 판에 정리하기
요즘에는 안드로이드 개발에서는 사용자가 직접 Thread를 컨트롤할 일이 없을 만큼, 고수준의 라이브러리들이 많이 존재한다. 그 덕분에 개발은 좀 더 심플해졌지만 어떻게 쓰레드가 사용되는지 굳이 알려고하지 않으면 라이브러리에 얹혀사는 개발자가 될 뿐이다. ‘라이브러리 공부하기도 바쁜데 그런 걸 언제해.’라는 안일한 마인드를 타개하고 쓰레드의 ‘ㅆ’이라도 이해하고 맛보기 위해 본 글을 작성하고자한다.
그래서 쓰레드가 뭐죠?
쓰레드(thread)는 프로세스 내에서 실행되는 흐름의 단위를 말한다.
내용이 벌써 추상적이다. 우리가 프로그램을 실행시키면 해당 프로그램이 메모리 위에 적재되는데 그것이 프로세스이다. 해당 프로세스는 하나 이상의 쓰레드를 가질 수 있는데, 만약 싱글쓰레드라면 모든 작업(코드 실행)을 하나의 스레드에서 밖에 처리하지 못한다. 예를 들어, 음악플레이어 앱이 있는데 해당 앱이 싱글쓰레드라면 음악을 듣는 도중, 화면을 터치했을 때 화면 터치에 대한 작업을 수행해야하므로 듣던 노래가 중단될 것이다.
이렇게 쓰레드는 프로세스 내에서 여러 개의 작업을 수행할 수 있게 동시성을 보장한다.
자바에는 쓰레드와 관련된 여러 Class와 Interface가 있으며 우리는 다음과 같은 개념에 대해서 다룰 것이다.
- Thread (class)
- Executors (class)
- Executor (interface)
- ExceutorService (interface)
- Runnable (interface)
- Callable (interface)
- Future (interface)
모든 document 참고는 Java8을 기준으로 한다.
Thread
public class Thread extends Object implements Runnable
개요
Thread Class는 Java 내에서 가장 기본적으로 쓰레드의 생성, 실행 등을 다룰 수 있는 클래스이다.
쓰레드 생성
쓰레드를 생성하는 방법은 두 가지이다.
두 수를 더해서 출력하는 간단한 기능의 쓰레드 예제를 작성해보았다.
1) Thread를 extend한 서브클래스를 만들어 run method를 override하기
class SumThread(private val a: Int, private val b: Int) : Thread() {
override fun run() {
println(a+b)
}
}
val sumThread = SumThread(1,2)
sumThread.start()
// 3
2) Runnable를 implement한 구체클래스를 만들어 run method를 override하고, 해당 객체를 thread 생성자의 인자로 넣기
class SumRunnable(private val a: Int, private val b: Int) : Runnable{
override fun run() {
println(a+b)
}
}
val sumThreadWithRuunable = Thread(SumRunnable(1,2))
sumThreadWithRuunable.start()
// 3
우리는 쓰레드가 처리할 작업을 run method 내부에 정의하고, 해당 내용을 Thread::start()을 통해서 실행한다. 매커니즘은 심플해서 쉽게 이해할 수 있는데, 위의 두 구현방식의 차이점은 무엇일까? 어렵게 생각할 것없이 (1) run method를 override할 것이냐, (2) Runnable을 구현하여 주입할 것이냐 의 차이이다. 어느 방식이 더 좋냐는 것에 대해 갑론을박이 있는데 찾아본 바에 따르면 다음과 같다.
평범한 사용자들에게는 Runnable을 구현하여 Thread에 주입하는 것이 좋다. 대부분 Thread를 사용하는 니즈는 task를 실행하기 위함이지, Thread의 동작을 튜닝하기 위함이 아니기 때문이다. 또한 자바는 다중상속을 지원하지 않는데 Thread를 상속하여 서브클래스를 만든다면 타입만 견고해질 뿐이다.
Thread는 프로그래머가 쓰레드를 생성하고 동시성을 보장받는 코드를 짤 수 있게 도와주었지만, 어플리케이션의 비지니스 로직이 점차 커지면서 단점들이 들어났고, Java5에서는 이 단점을 커버하기 위해 Concurrancy Package에 여러 기능들을 추가했다.
Executor
public interface Executor
Executor은 Runnable의 작업을 실행시키는 함수를 담은 interface이다. 기능적으로 보면 Thread와 비슷해서 Thread의 대체제로 생각할 수 있다. 하지만 사실 Executor만으로는 Thread와 비교하기는 무리가 있다. 그러나 해당 내용을 전부 다루기엔 복잡하므로 Thread vs Executor를 정리해보자.
|-+-+-|
| 상세 | Thread | Executor |
|:-:|:-:|:-:|
| 타입 | 클래스 | 인터페이스 |
| 기능 | 동시성 작업의 구체적 방법 명시 | 동시성 작업의 추상화 |
| 작업 실행부 & 작업| tiny coupling | decoupling |
| 작업 실행 주체 | 객체 자신 | ThreadPool |
| 실행 가능 작업 수| 1 | multiple |
| 쓰레드 생성과 실행 | 직접 | 프레임워크에 양도 |
Callable
public interface Callable<V>
위에서는 Thread와 Executor, Runnable이 무엇인지 개념을 잡았다. 그 다음에 나오는 개념은 Callable이다. 이 녀석은 또 뭘까?
Callable은 Runnable의 단점을 보안하기 위해 JAVA5에 새로 추가된 기능이다. (TMI : JAVA5는 2004년도 버젼이다). 기존의 Runnable은 값을 return할 수 없으며, 실행 동작 내에서 exception을 throw할 수 없었는데, 해당 기능을 보완하여 나온 것이 Callable이다.
왜 Runnable은 return 타입을 만들지 않았을까?
Runnable은 Java1의 스펙이다. 이 때는 Generic이라는 개념이 존재하지 않아서 리턴 타입에 대한 대응을 할 수 없었다. 하지만 Java5에 Generic 스펙이 추가됨과 동시에 쓰레드의 연산에 대한 리턴 타입을 사용자가 지정할 수 있게되었고, 그것을 반영하여 Callable이 등장하였다.
ExecutorService
public interface ExecutorService extends Executor
실제 여러 개의 쓰레드에 작업을 할당하고, 작업이 끝났을 경우 쓰레드를 종료하려는 기능을 구현하기 위해서는 매우 많은 수고가 들어간다. 따라서 Java에서는 ExecutorSerivce라는 Interface를 제공하여 해당 구현을 정형화시켰다.
다음 ThreadPool을 구성하여 여러 개의 Task를 각기 다른 Thread에서 실행하는 예제를 살펴보자.
val threadPool = Executors.newFixedThreadPool(4)
class SumCallable(private val a: Int, private val b: Int) : Callable<Int> {
override fun call(): Int {
val r = Random.nextInt(1000, 5000)
Thread.sleep(r.toLong())
println("Task(${a},${b}) is called in ${Thread.currentThread().name}, Delay = ${r}")
return a + b
}
}
val task = mutableListOf<Callable<Int>>()
task.apply {
add(SumCallable(1, 2))
add(SumCallable(2, 3))
add(SumCallable(3, 4))
add(SumCallable(5, 6))
add(SumCallable(7, 8))
}
val futureTask = threadPool.invokeAll(task)
for (i in 0 until futureTask.size) {
val t = futureTask[i]
try {
println("result = ${t.get()}")
} catch (e: Exception) {
println(e)
}
}
threadPool.shutdown()
// Task(3,4) is called in pool-1-thread-3, Delay = 2419
// Task(5,6) is called in pool-1-thread-4, Delay = 2470
// Task(2,3) is called in pool-1-thread-2, Delay = 2838
// Task(1,2) is called in pool-1-thread-1, Delay = 4181
// Task(7,8) is called in pool-1-thread-3, Delay = 3474
// result = 3
// result = 5
// result = 7
// result = 11
// result = 15
ThreadPool을 구성하기 위해 본 예제에서는 ExecutorService의 다양한 구현객체를 반환하는 Executors의 Factory Method를 사용하였다. 갯수가 정해진 FixedThreadPool을 생성하고, 해당 ThreadPool에서 실행할 작업인 SumCallable을 정의하였다. SumCallable는 input 파라미터를 더하여 반환하는 기능을 수행하며, 랜덤으로 일정 딜레이를 준다.
그 후, SumCallable 객체를 이용하여 작업 리스트를 생성하여 해당 리스트를 ExecutorService::invokeAll()을 통해서 threadPool에 할당준다. 해당 메서드는 각 작업에 대한 Future를 return한다. 이 때, log를 확인하게 되면, 각각의 작업이 서로 다른 쓰레드에 할당된 것을 알 수 있다.
Future란?
비동기 연산의 결과를 나타내는 인터페이스이다. Future::get()을 통해서 비동기적으로 처리된 연산 결과를 가져올 수 있다.
이제 우리는 비동기적으로 실행된 연산 결과를 확인할 차례이다. 각 Future에 접근하여 get() 메서드를 통하여 해당 값을 출력하면 비동기 연산의 결과가 출력된다.
그러나 출력된 결과 값에 조금 이상한 점이 있다. 분명이 쓰레드는 랜덤 딜레이를 통해서 실행된 순서가 다른데, 결과값은 순차적이다. 이것은 get() 메서드의 한계이다. get()을 통해 연산 결과를 가져올 때, 만약 비동기 계산이 끝나지 않았다면, 연산이 수행될 때까지 blocking 한다. 반복문을 통해 순차적으로 접근했기때문에 매 future마다 blocking이 걸려 순차적으로 출력된 것이다.
해당 부분을 non-blocking으로 처리하고싶다면 callback을 구현해야하며, 이는 Java8의 CompletableFuture을 통해 구현할 수 있다. 내용이 너무 꼬리에 꼬리를 무니, 해당 부분은 넘어가겠다. 더 깊게 알고싶다면 아래 링크를 참고하라.
Sync vs Async / Blocking vs Non-Blocking
마무리
본 글을 통해서 Java 내부적으로 Thread를 구현할 수 있는 다양한 방법 및 여러 Class 및 Interface의 기능과 동작방식에 대해서 한 판에 정리했다. 사실 본 글에서 다루는 내용들은 너무 오래된 Java 스펙이기때문에 라이브러리의 내부적으로만 사용될 뿐, 실제 안드로이드의 구현에는 거의 사용되지 않는다. 그러나 필자는 retrofit 및 rxjava 세대임에도 불구하고, 해당 내용들은 면접에서 질문받은 적이 있다. 면접관께서 왜 이런 질문을 했는지 곱씹어봤는데, 코어가 탄탄한 개발자인지 평가하고자함이였던 것 같다. 안드로이드와 쓰레드, 비동기는 뗄래야 뗄 수 없는 사이이므로, 실제 쓸 일이 없어도 해당 내용을 숙지해놓으면 좋은 개발자로 성장하는데 많은 도움이 될 것 같아 이렇게 정리한다.