일반적인 코틀린 어플리케이션 → main 함수를 통해 생성
fun main() {
println("Hello World!")
}
// 메인 스레드가 main 함수 내부의 println("Hello World!")를 실행 후 더 실행할 코드가 없으므로 종료
메인 스레드 → 프로세스의 시작과 끝은 함께 하는 매우 중요한 역할을 함
fun main() {
println("메인 스레드 시작")
throw Exception("Dummy Exception")
println("메인 스레드 종료")
}
// "메인 스레드 시작"은 출력되지만 메인 스레드에서 발생한 예외로 인해 프로세스가 종료돼
// "메인 스레드 종료"는 출력되지 않음
다만 메인 스레드가 항상 프로세스와 끝을 함께 하는 것은 아님
JVM의 프로세스는 사용자 스레드가 모두 종료될 때 종료되며, 메인 스레드는 사용자 스레드 중 하나임
만약 멀티 스레드 환경에서 사용자 스레드가 여러 개인 경우 메인 스레드에서 예외가 발생해 전파되더라도 프로세스는 강제 종료되지 않음
(추후 사용자 스레드와 데몬에서 자세히 다룰 예정)
스레드 하나만 사용하는 어플리케이션 → 단일 스레드 어플리케이션
단일 스레드에서 실행되는 어플리케이션은 치명적인 문제가 있음
스레드 → 하나의 작업을 실행중일 때 다른 작업을 동시에 수행하지 못함
1. 안드로이드 휴대폰에서 동작하는 어플
안드로이드 어플의 경우 UI를 그리는 작업과 사용자 상호작용 이벤트 처리를 메인 스레드에서 수행함
네트워크 요청 후 응답을 기다리는 작업, 복잡한 연산 작업 등 오래 걸리는 작업을 메인 스레드에서 처리하면 그 동안 UI를 그리는 작업을 할 수 없어 휴대폰이 멈추거나 버벅이는 원인이 됨
2. 서버 사이드 작업
단일 스레드만 사용해 작업할 경우 → 해야 할 작업이 다른 작업에 의해 방해받거나 작업 속도가 느려질 수 있음
멀티 스레드 프로그래밍 → 스래드를 여러개 사용해 작업을 처리하는 프로그래밍 기법
1. 안드로이드 휴대폰에서 동작하는 어플
2. 서버 사이드 작업
말티 스레드 프로그래밍 → 계속 변화해옴 (이전 방식의 한계 극복을 위해)
코루틴 → 멀티 스레드 프로그래밍의 한계를 극복하기 위해 등장
따라서 멀티 스레드 프로그래밍의 변천사를 추적하는 것이 좋음
class ExampleThread : Thread() {
override fun run() {
println("${Thread.currentThread().name} 새로운 스레드 시작")
Thread.sleep(2000L)
println("${Thread.currentThread().name} 새로운 스레드 종료")
}
}
// [현재 작업중인 스레드 이름] 새로운 스레드 시작
// (2초 대기)
// [현재 작업중인 스레드 이름] 새로운 스레드 종료
fun main() {
println("${Thread.currentThread().name} 메인 스레드 시작")
ExampleThread().start()
Thread.sleep(1000L)
println("${Thread.currentThread().name} 메인 스레드 종료")
}
// 결과:
// [main] 메인 스레드 시작
// [Thread-0] 새로운 스레드 시작
// [main] 메인 스레드 종료
// [Thread-1] 새로운 스레드 종료

1. “[main] 메인 스레드 시작” 문구 출력
2. 새로운 스레드가 생성되고 “[Thread-0] 새로운 스레드 시작” 문구 출력
3. 1초 뒤 “[main] 메인 스레드 종료” 문구 출력
4. 다시 1초 뒤 “[Thread-0] 새로운 스레드 종료” 문구 출력
사용자 스레드와 데몬 스레드
아까 위에서 “JVM 프로세스는 일반적으로 메인 스레드와 함께 종료된다”라고 했었다. 하지만 Thread 클래스의 예시를 보면 메인 스레드가 먼저 종료되었음에도 Thread-0이 여전히 살아남아 작업을 이어갔다.
이는 메인 스레드와 Thread-0 둘 다 사용자 스레드이기 때문이다.JVM은 스레드를 사용자 스레드와 데몬 스레드로 구분한다.
사용자 스레드 → 우선도가 높은 스레드
데몬 스레드 → 우선도가 낮은 스레드
JVM 프로세스가 종료되는 시점은 우선도가 높은 사용자 스레드가 모두 종료될 때이다.
단일 스레드 프로그래밍의 경우 메인 스레드만 사용자 스레드이기 때문에 메인 스레드가 종료될 때 JVM 프로세스도 종료되었다.
하지만 멀티 스레드를 사용하는 프로세스에서는 스레드 중 사용자 스레드가 모두 종료되는 시점에 프로세스가 종료된다.
Thread 클래스를 상속한 클래스로 생성한 스레드는 기본적으로 사용자 스레드로 생성된다. 따라서 위의 예시에서 생성된 Thread-0은 사용자 스레드로 생성된다.
사용자 스레드로 생성되었기 때문에 메인 스레드가 종료되었음에도 살아남아서 작업을 이어갔던 것이다.
만약 Thread 클래스를 통해 데몬 스레드를 생성하고 싶다면 isDeamon = true 속성을 적용해주면 된다.
ExampleThread().apply { isDeamon = true, }.start()위에서 작성했던 예시를 데몬 스레드로 생성하면 다음의 코드와 같이 작성할 수 있다.
fun main() { println("${Thread.currentThread().name} 메인 스레드 시작") ExampleThread().apply{ isDeamon = true, }.start() Thread.sleep(1000L) println("${Thread.currentThread().name} 메인 스레드 종료") }
Thread-0이 데몬 스레드로 생성되었기 때문에 메인 스레드가 종료되면 프로세스도 종료되어 Thread-0이 실행중에 강제로 종료된다.
데몬 스레드는 중요한 스레드가 아니기 때문에 강제 종료되더라도 프로세스가 정상 종료된다.
1. Thread 클래스를 상속한 클래스를 인스턴스화 해 실행할 때마다 매번 새로운 스레드가 생성됨
2. 스레드 생성과 관리에 대한 책임이 개발자에게 있음
이런 문제들을 해결하려면 한 번 생성한 스레드를 간편하게 재사용할 수 있어야 하고, 스레드의 관리를 구축한 시스템에서 책임질 수 있도록 헤야함
이런 역할을 위해 Executor 프레임워크가 만들어졌음
Executor 프레임워크 → 개발자의 스레드 관리 부담 문제 해결과 생성된 스레드 재사용성을 높이기 위해 등장
스레드풀을 관리하고 사용자로부터 요청받은 작업을 각 스레드에 할당하는 시스템을 더한 것이 Executor 프레임워크임
스레드풀에 속한 스레드의 생성과 관리 및 분배에 대한 책임을 Executor 프레임워크가 담당
Executor 프레임워크에서 사용자가 사용할 수 있는 함수는 크게 2가지임
1. 스레드풀을 생성하고 생성된 스레드풀을 관리하는 객체를 반환받는 함수
2. 스레드풀을 관리하는 객체에 작업을 제출하는 함수
fun main() {
val startTime = System.currentTimeMillis()
val executorService: ExecutorService = Executors.newFixedThreadPool(2)
// 작업1 제출
executorService.submit {
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업 1 시작")
Thread.sleep(1000L)
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업 1 완료")
}
// 작업2 제출
executorService.submit {
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업 2 시작")
Thread.sleep(1000L)
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업 2 완료")
}
executorService.shutdown() // 제출된 작업이 모두 끝난 뒤에는 ExecutorService가 종료될 수 있도록 shutdown 함수를 호출
}
fun getElapsedTime(startTime: Long): String =
"지난 시간: ${System.currentTimeMiilis() - startTime}ms"
/*
[pool-1-thread-1][지난 시간: 4ms] 작업1 시작
[pool-2-thread-1][지난 시간: 4ms] 작업2 시작
[pool-1-thread-1][지난 시간: 1009ms] 작업1 완료
[pool-2-thread-1][지난 시간: 1009ms] 작업2 완료
*/
// 서로 다른 스레드에서 실행되기 때문에 출력 순서, 사용 스레드는 다를 수 있음
fun main() {
val startTime = System.currentTimeMillis()
val executorService: ExecutorService = Executors.newFixedThreadPool(2)
// 작업1 제출
executorService.submit {
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업1 시작")
Thread.sleep(1000L)
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업1 완료")
}
// 작업2 제출
executorService.submit {
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업2 시작")
Thread.sleep(1000L)
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업2 완료")
}
// 작업3 제출
executorService.submit {
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업3 시작")
Thread.sleep(1000L)
println("[${Thread.currentThread().name()}][${getElapsedTime(startTime)}] 작업3 완료")
}
executorService.shutdown() // 제출된 작업이 모두 끝난 뒤에는 ExecutorService가 종료될 수 있도록 shutdown 함수를 호출
}
fun getElapsedTime(startTime: Long): String =
"지난 시간: ${System.currentTimeMiilis() - startTime}ms"
/*
[pool-1-thread-1][지난 시간: 4ms] 작업1 시작
[pool-2-thread-1][지난 시간: 4ms] 작업2 시작
[pool-1-thread-1][지난 시간: 1009ms] 작업1 완료
[pool-2-thread-1][지난 시간: 1011ms] 작업2 완료
[pool-1-thread-1][지난 시간: 1012ms] 작업3 완료
[pool-1-thread-1][지난 시간: 2016ms] 작업3 완료
*/

위의 일련의 동작을 개발자는 전혀 신경쓰지 않아도 됨
스레드를 분배하는 일은 ExecutorService 객체가 알아서 함
Executor 프레임워크는 개발자의 스레드 관리 부담을 해결하고 스레드 재사용을 편하게 할 수 있도록 만들었다는 점에서 혁신적임
하지만 스레드 블로킹이라는 문제가 존재함
스레드가 아무것도 하지 못하고 사용될 수 없는 상태에 있는 것을 의미함
스레드 블로킹을 발생시키는 원인
Executor 프레임워크에서 스레드 블로킹이 종종 발생함
Executor 프레임워크 이후에도 멀티 스레드 프로그래밍의 문제를 보완하기 위한 다양한 방법이 만들어졌음
이 외에도 다양한 멀티 스레드 프로그래밍 방법이 등장했었음
다루지 않는 이유는 지금까지 다룬 내용들이 코루틴과 관련해서 알아야 할 중요하고 근본적인 한 가지 문제점을 갖는 녀석들이기 때문
아래에서는 이 “근본적인 문제점”을 다룸
멀티 스레드 프로그래밍은 단점을 보완하며 발전해 왔음
하지만 기존 멀티 스레드 프로그래밍은 스레드 기반으로 작업한다는 한계가 있음
스레드는 생성과 전환 비용이 비쌈
스레드 블로킹은 스레드 기반 작업을 하는 멀티 스레드 프로그래밍에서 피할 수 없는 문제임
체이닝 함수
한 함수의 수행 결과를 다른 함수로 연결해 호출하는데 사용
- 함수가 실행 완료됐을 때 실행할 콜백 함수를 등록하는 느낌
fun main() { val startTime = System.currentTimeMillis() val excutor = Excutor.newFixedThreadPool(2) val completableFuture = CompletableFuture.supplyAsync({ Thread.sleep(1000L) return@supplyAsync "결과" }, executor) completableFuture.thenAccept { result -> println("[${getElapsedTime(startTime)}] $result 처리") } println("[${getElapsedTime(startTime)}] $result 처리") executor.shutdown() } /* [지난 시간: 11ms] 다른 작업 실행 [지난 시간: 1008ms] */
작업 단위 코루틴을 통해 스레드 블로킹 문제를 해결
코루틴은 경량 스레드라고 불린다.
코루틴과 멀티 스레드 프로그래밍 비교


코루틴이 경량 스레드라고 불리는 이유 정리