

오늘은 코틀린 코루틴의 정석이라는 책을 읽으며 코루틴을 학습하려고 한다. 책을 읽으며 핵심을 정리하고 그 외 의문들을 확장해 나갈 것이다.
해당 책은 해당 분야에서 평가가 좋길래 Ebook으로 구매하였다.
(구매처 : 교보문고 사이트 )
성능 좋은 프로그램을 만들 때는 비동기 프로그래밍이 필수적이여서 비동기 프로그래밍을 위해 코루틴을 배워야한다.
코틀린 애플리케이션의 진입점은 main 함수이다.
애플리케이션이 실행되면 jVM이 프로세스를 시작하고 메인 스레드를 생성하여 main 함수 내부 코드를 실행한다.
main 내부가 모두 실행되면 애플리케이션이 종료된다.
이때 JVM(Java Virtual Machine) 이란, 자바와 코틀린 같은 언어로 작성된 프로그램을 실행하기 위한 가상 머신이다.
운영체제 위에서 동작하고, 우리가 작성한 소스 코드를 바이트 코드(.class)로 변환 후 이를 읽어서 실행한다.
-> 개발자는 특정 OS에 종속되지 않고 JVM 위에서 동일하기 동작하는 프로그램을 작성할 수 있다. 예를 들어, 내가 Kotlin 컴파일러로 아래의 코드를 컴파일 한다면 JVM이 이해할 수 있는 바이트코드(.class) 파일이 생성된다. 이 .class 파일은 윈도우, 리눅스, 맥OS 어디서든 JVM만 설치되어 있다면 실행 가능하다.
그래서 JVM 기반 언어(Java, Kotlin, Scala 등)는 “한 번 작성하면 어디서나 실행된다 WORA(Write Once, Run Anywhere)”라는 철학을 따르게 된다.
fun main() {
println("Hello, JVM World!")
}
코틀린은 "자바 생태계(JVM, 라이브러리, 툴킷)"을 활용하기 위해 만들어진 언어로, 둘 다 JVM위에서 돌아가고, 자바 라이브러리, 프레임워크를 그대로 쓸 수 있다. .class 바이트코드로 변환되어 JVM 에서 실행된다. 차이점으로는 코틀린은 간결성과 안정성에 더 초점을 맞춘다. (nullsafety)
안드로이드 진영에서 자바 대신 코틀린을 밀어주는 이유는?
안드로이드 초기엔 Java가 사실상 유일한 언어였다. 그런데 자바는 문법이 장황하다. 예를 들어 버튼 클릭 리스너를 달려면 클래스 익명 객체를 길게 써야했다. 그리고 가장 큰 문제는 NPE(NullPointerException) 이었다. 안드로이드 앱 크래시의 상당수가 NPE 때문이었고, 이는 치명적이었다. 코틀린은 람다 한 줄이면 리스너를 달 수 있고, 타입 시스템 차원에서 null을 안전하게 처리하고, 함수형 프로그래밍, 비동기 처리를 위한 코루틴 같은 기능이 있어 안드로이드 생태계에 더욱 잘 맞는 언어였다.
다시 돌아와서 코틀린에서는 main 이 진입점으로 jVM이 프로세스 시작 후 메인 스레드를 생성해서 main 을 실행 후 완료 시 애플리케이션을 종료한다고 했다.
그러면..
메인스레드는 항상 프로세스의 끝과 함께하는가?
사실 메인 스레드 = 프로세스의 생명주기 전체는 아니다. JVM은 non-daemon 스레드가 종료되었을 때 종료된다. main() 은 메인 스레드에서 실행되지만 메인 스레드가 끝났다고 해서 프로세스가 종료되는 것은 아니다. 이게 뭔 뜻이냐면, 만약 내가 메인 스레드 실행 중에 다른 non-daemon 스레드를 만들어 놓았다면 그 스레드들이 끝날 때까지는 프로세스가 살아있는 것이다. (아래 코드를 보면 메인 스레드는 금방 끝나더라도 jVM은 새로 만든 스레드가 3초 후 종료될 때까지 살아있다.)fun main() { Thread { Thread.sleep(3000) println("작업 스레드 끝!") }.start() println("메인 스레드 끝!") }
daemon 스레드란?
데몬 스레드는 백그라운드 보조용 스레드로 "프로세스의 생명 유지 여부에 영향을 주지 않는 스레드" 이다. 주로 보조 작업(Garbage Collector), 로그 기록 등을 담당한다. 데몬 스레드는 메인 스레드가 종료되면 함께 종료된다. (JVM은 non-daemon 스레드가 하나라도 남아 있으면 종료되지 않지만, daemon 스레드만 남아 있다면 JVM은 바로 종료)
논데몬 스레드와 데몬 스레드의 차이
- non-daemon 스레드 = 프로세스 생명 유지에 직접 관여 (끝나야 JVM 종료)
- daemon 스레드 = 보조 역할, 프로세스 종료 조건에 영향을 주지 않음 (남아 있어도 JVM은 종료 가능)
코틀린 프로그램은 기본적으로 메인 스레드 하나로 시작한다. (하지만 JVM 내부적으로는 GC 같은 데몬 스레드도 함께 돌고 있기 때문에 ‘완전한 단일 스레드 환경’이라고 보긴 어렵다.) 다만 개발자가 명시적으로 스레드를 만들지 않는 이상, main 함수는 메인 스레드에서 단일 실행 흐름으로 진행된다.
하지만 이런 단일 스레드는 치명적인 약점이 존재한다.
스레드는 하나의 작업을 수행할 때 다른 작업을 동시에 수행할 수 없다.
메인 스레드 또한 예외가 아니라 메인 스레드 작업이 오래 걸리면 해당 작업 처리 동안 다른 작업을 처리할 수 없다. 즉, 하나의 작업에서 오래 걸리면 다른 작업은 할 수 없는 응답성의 문제가 발생한다.
안드로이드 휴대폰에서 메인 스레드가 UI를 그리는 작업이랑 사용자가 화면을 누르는 이벤트를 전달받아 처리하는 작업을 반복적으로 수행한다. 이 때 메인 스레드가 네트워크 요청하고 응답을 대기하거나 복잡한 연산을 수행하면 그 동안 UI를 그리는 것을 멈추고 사용자 입력 또한 받지 못한다.
이러한 단일 스레드 문제는 멀티 스레드 프로그래밍으로 해결할 수 있다.
멀티 스레드 프로그래밍을 통해 오래 걸리는 작업은 메인 스레드 대신 별도의 스레드가 처리할 수 있도록 만들어 이 문제를 해결한다. 이렇게 여러 스레드가 동시에 작업을 처리하는 것을 병렬 처리라고 한다.
이렇게 좋아보이는 멀티 스레드 프로그래밍에도 단점이 존재한다.
스레드는 Thread 클래스를 상속하거나 Runnable 인터페이스를 구현해서 실행할 수 있는데, 이때마다 매번 새로운 스레드가 생성된다. 문제는 스레드가 생성·소멸되는 데 드는 비용이 상당히 크다는 점이다. 스레드 개수가 많아지면 문맥 전환(Context Switching) 비용도 커져서 오히려 성능이 저하될 수 있다.
또한, 안드로이드에서 네트워크 통신 같은 I/O 작업을 단순히 스레드로 처리하면 코드가 복잡해진다. 예외 처리, 콜백 중첩, 스레드 풀 관리 등을 개발자가 직접 신경 써야 하기 때문이다.
그래서 자바5부터 Executor 프레임워크가 도입되었다.
Executor는 스레드를 직접 생성·관리하지 않고 스레드 풀(Thread Pool) 을 통해 재사용할 수 있게 해준다.
스레드 풀 : 스레드와 풀의 합성어인데 풀은 직역하면 모음 또는 집합이라는 의미이다. 스레드 풀은 스레드의 집합이고, 스레드풀을 관리하고 사용자로부터 요청받은 작업을 각 스레드에 할당하는게 Executor
예를 들어, 매번 새로운 스레드를 생성하는 대신, 미리 만들어둔 스레드 풀에서 스레드를 가져와 작업을 수행하고 다시 풀에 반환한다. 이렇게 하면 불필요한 스레드 생성 비용을 줄이고, 동시에 실행되는 스레드 개수를 제어할 수 있다.
하지만 Executor만으로도 여전히 문제가 있다.
Executor는 스레드 풀을 관리해주지만, 스레드 블로킹 문제를 근본적으로 해결하지 못한다.
스레드 블로킹 문제란 스레드가 I/O 작업을 하면서 결과를 기다리는 동안 아무일도 하지 못하고 멈춰있는 상태를 의미한다.
스레드가 놀고 있는데도 여전히 운영체제 리소스를 차지하고 있기 때문에 낭비가 발생한다.
만약 스레드 풀 크기가 작고, 풀에 있는 스레드들이 모두 블로킹에 걸리면, 새로운 작업은 대기열에서 멈춰 버린다. 즉, 풀로도 스레드가 막혀 버리는 상황은 막지 못한다.
또한, Executor에서 작업 결과를 받으려면 Future를 사용해야 한다. 하지만 Future.get()은 결과가 올 때까지 현재 스레드를 블로킹시키기 때문에 “비동기”의 장점이 반감된다. 또, 여러 작업을 동시에 실행하고 각각의 결과를 모아 처리하려면 Future들을 일일이 합치고 기다리는 로직을 직접 짜야 한다. 이렇게 되면 코드가 복잡해지고 가독성이 떨어진다.
그 외 예외 처리를 Future 기반으로 하면 try/catch 블록이 흩어져 관리하기 힘들고, 콜백(callback) 방식으로 연결하면 중첩이 깊어져 “콜백 지옥(callback hell)”이 생긴다.
그래서 한 단계 발전한 것이 코루틴(Coroutine) 이다.
코루틴은 스레드를 새로 생성하거나 전환하지 않고, 기존 스레드 위에서 경량 실행 단위를 만들어 동시성을 구현한다.
이 실행 단위는 운영체제가 아닌 kotlinx.coroutines 같은 라이브러리가 관리하며, 특정 지점에서만 suspend(일시 중단) 되었다가 필요할 때 다시 resume(재개) 될 수 있다.
즉, 네트워크 요청이나 I/O 같은 작업에서 스레드 전체가 블로킹되는 대신, 해당 지점에서만 코루틴이 잠시 멈추고 스레드는 다른 코루틴을 실행한다.
덕분에 소수의 스레드로도 수많은 코루틴을 동시에 처리할 수 있어, 불필요한 자원 낭비를 줄이고 효율적인 동시성을 달성할 수 있다.
연습 문제
1. JVM이 프로그램을 종료하는 기준은 무엇인가?
2. Daemon 스레드와 Non-daemon 스레드의 차이를 설명하라.
3. 멀티 스레드 프로그래밍의 단점 2가지를 서술하라.
4. Executor(스레드 풀)의 한계는 무엇인가?
5. 코루틴이 스레드 블로킹 문제를 어떻게 해결하는지 설명하라.
해답
1. JVM이 프로그램을 종료하는 기준은 무엇인가?
→ JVM은 모든 non-daemon 스레드가 종료되었을 때 종료된다.
메인 스레드가 끝나도 다른 non-daemon 스레드가 남아 있다면 JVM은 살아 있고, daemon 스레드만 남아 있다면 JVM은 바로 종료된다.
→ 코루틴은 스레드를 새로 생성하지 않고, 기존 스레드 위에서 경량 실행 단위를 관리한다. suspend 지점에서만 일시 중단되고, 그동안 스레드는 다른 코루틴을 실행할 수 있다. 따라서 소수의 스레드로도 수많은 동시 작업을 효율적으로 처리할 수 있으며, 스레드가 블로킹되는 문제를 최소화한다.