입사 첫 주, 회사 기술 스택을 익히겠다는 마음 하나로 LINE 조세영 님의 「코틀린 코루틴의 정석」 강의를 결제했었다.
운이 좋게도 책 증정 이벤트에 당첨되어 책까지 받았지만, 정신없이 업무에 몰입하다 보니 그 책은 어느새 모니터 옆에서 먼지를 뒤집어쓴 채 있었다.

그로부터 9개월이 지난 지금, 다시 그 책을 꺼냈다.
실무에서 코루틴이 어떻게 사용되고, 어디서 도움이 되며, 어떤 함정이 있는지를 직접 코드로 체감하기 위한 목적이다.
코루틴은 단순히 비동기를 구현하기 위한 문법이 아니라, 시스템의 흐름과 동시성을 이해하기 위한 사고방식이라는 걸 실무를 통해 얕게나마 깨달았다.
그래서 이번에는 왜 이렇게 동작하는가, 서비스 코드에서는 어떻게 활용해야 하는가를 중심으로 처음부터 차근차근 정리해보려 한다.
블로그에는 코루틴의 기본 구조부터 컨텍스트, 스코프, 예외처리, 그리고 실제 비즈니스 로직에 적용하는 과정까지 "실무 감각"으로 다시 공부하는 코루틴의 본질을 기록할 예정이다.
9개월 전의 초심으로 돌아가 다시 한 장씩 넘겨보며 진짜로 내 것이 되게 만들어보려 한다.
✍️이 글은 인프런 「코틀린 코루틴의 정석」 강의와 교재를 기반으로 공부한 내용을 정리한 글입니다.
책의 일부 문구나 문장을 (페이지 표기와 함께) 간단히 인용하지만, 전체 내용은 개인적인 해석과 Spring Boot에서의 실무 관점에서 재구성한 것입니다.
스레드 기반 작업
Kotlin에서의 실행 진입점은 main함수를 통해 만들어지는데, 어플리케이션이 시작되면 JVM은 프로세스를 시작하며 메인 스레드가 함께 생성되고 이후 요청 처리, 비동기 작업, 스케줄링 등을 위해 여러 스레드를 추가로 생성한다.
단일 스레드는 한 번에 하나의 작업만 수행할 수 있다. 하나의 작업이 수행할 때 다른 작업을 동시에 수행하지 못한다는 것이다.(p31)
특히 I/O 작업 중에는 CPU가 놀게 되고 대기시간이 발생하게 된다.
이러한 문제는 멀티 스레드 프로그래밍으로 해결할 수 있다.
멀티 스레드 프로그래밍이란 스레드를 여러 개 사용해 작업을 수행하는 프로그래밍 기법으로, 프로세스는 멀티 스레드 프로그래밍을 통해 여러 개의 스레드로 작업을 실행한다.
각각의 스레드가 한 번에 하나의 작업을 처리할 수 있으므로 여러 작업을 동시에 처리하는 것이 가능해진다. (p33)

위처럼 작업 간에 독립성이 있을 때만 멀티 스레드에서 병렬로 실행이 가능하다. (p35)
실제 서비스 코드에서는 외부 API호출, DB, Redis, Kafka 등등등.. 대기 시간이 길 수 있는 작업이 꽤 있다.
이때 단일 스레드 구조에서는 I/O가 끝날 때까지 CPU가 놓기 때문에 TPS가 크게 떨어진다.
그래서 웹 서버, Batch 시스템, 메시지 Consumer 같은 것들은 모두 멀티스레드를 기본값으로 운영한다.
그렇다고 해서 단순히 스레드를 많이 늘리는건 좋은 해법이 아니다. 스레드당 스택 메모리(1MB 내외)가 할당되기 때문에 수천개만 생성이 되어도 기가바이트급의 메모리를 차지하게 된다.
자바, 코틀린에서는 Thread 클래스를 사용해서 새로운 스레드에서 작업을 실행할 수 있는데, 간편해 보이지만 생성 비용이 비싸다는 문제와, 스레드 생성과 관리에 대한 책임이 개발자에게 있어, 실수로 인한 오류나 메모리 누수가 발생할 가능성이 있다.
이 문제를 해결하려면 한 번 생성한 스레드를 간편하게 재사용할 수 있어야 하는데,
이런 역할을 위해 Executor Framework가 만들어졌다.
Executor Framework
Executor Framework는 스레드의 재사용성을 높이려고 등장했는데, 스레드를 관리하려고 여기에서 스레드풀(Thread Pool)이라는 개념이 등장했다.
이는 작업 처리를 위해 스레드를 미리 생성해 놓고 작업요청이 들어오면 쉬고 있는 스레드에 작업을 분배한다는 것. (p43)
동작은 아래와 같다.
- 서버 시작 시 지정된 개수의 스레드를 미리 만들어둠.
- 새로운 작업이 들어오면 대기 큐(BlockingQueue)에 적재함
- 여유 스레드가 생기면 큐에서 작업을 가져와 실행
- 작업 완료 후 스레드는 종료되지 않고 다시 풀로 반환
즉, 스레드풀은 스레드의 생명 주기를 재사용해 성능을 최적화하는 방식이다.
스프링 프레임워크는 Executor Framework를 기반으로 다양한 내부 비동기 작업을 처리한다.
대표적으로 자바/코틀린 개발자라면 모를 수 없는 @Async 메서드는 SimpleAsyncTaskExecutor와 ThreadPoolTaskExecutor를 사용하고,
스케줄러 작업에서 사용되는 @Scheduled 스케줄러는 ScheduledExecutorService를 사용하는데,
이 Executor들은 내부적으로 TaskExecutor( = Executor Framework의 추상화) 를 사용하여 스레드를 재사용하며, application.yml에서 쉽게 풀 사이즈를 설정 가능하다.
추가적으로 내부적으로 트랜잭션 롤백이나 이벤트 발행을 담당하는 ApplicationEventPublisher 에서도 별도의 Executor를 활용하곤 한다.
이러한 Executor Framework에도 한계점이 있는데, 바로 스레드 블로킹이다.
스레드 블로킹이란 스레드가 아무 것도 하지 못하고 사용될 수 없는 상태에 있는 것을 뜻하는데, 스레드는 비싼 자원이기 때문에 이 상황이 반복되면 어플리케이션의 성능이 급격히 떨어지게 된다.
동기화 블록에 여러 스레드가 접근하거나, 뮤텍스나 세마포어에 의해서 스레드가 제한되는 경우에도 발생할 수 있다. (p49)
이처럼 기존의 멀티 스레드 프로그래밍은 스레드 기반으로 작업한다는 한계를 갖고 있다.
스레드는 생성 비용과 작업을 전환하는 비용이 비싸고, 스레드 블로킹이 발생하게 되면 스레드라는 비싼 자원을 사용할 수 없게 만든다는 점에서 성능에 매우 치명적인 영향을 준다.
스레드 블로킹은 스레드 기반 작업을 하는 멀티 스레드 프로그래밍에서 피할 수 없는 문제이다.
실제로 어플리케이션은 작업 간의 종속성이 매우 복잡하고 네트워크 작업을 수없이 실행하므로 스레드 블로킹이 발생하는 것은 필연적이다. (p53)
코루틴이 스레드 블로킹 문제를 극복하는 방법
코루틴은 경량 스레드라고 불리며, 작업 단위 코루틴을 통해 스레드 블로킹을 해결한다.
작업 단위 코루틴이란 스레드에서 작업 실행 도중 일시 중단할 수 있는 작업 단위이다.
작업이 일시 중단되면 다른 작업에게 스레드의 사용 권한을 양보하며, 양보된 스레드는 다른 작업을 실행하는 데 사용할 수 있다.
일시 중단된 코루틴은 재개 시점에 다시 스레드에 할당되어 실행된다. (p55)

코루틴은 자신이 스레드를 사용하지 않을 때 스레드 사용 권한을 반납하는데, 그렇게 되면 해당 스레드에서는 다른 코루틴이 실행될 수 있다.
위 그림처럼 코루틴1 실행 도중 코루틴2의 결과가 필요한 상황이라면 코루틴1은 코루틴2의 결과가 반환될 때 까지 Thread-0 사용 권한을 반납하고 일시 중단한다. 이렇게 일시 중단되면 코루틴3이 Thread-0에서 작업이 가능하게 된다.
Spring에서도 spring-kotlin-coroutine 혹은 kotlinx.coroutines를 통해 적용할 수 있다. 특히 webflux와 통합하면 "비동기 논블로킹"을 유지하면서 코드 구조는 동기식처럼 작성이 가능하다.
아래 NonBlocking/순차실행 코드를 한번 보자.
@GetMapping("/user")
suspend fun getUser(): UserResponse {
val user = userService.findUser()
val orders = orderService.findOrders(user.id)
return UserResponse(user, orders)
}
기존의 CompletableFuture나 Mono/Flux 기반 코드보다 훨씬 가독성이 좋고 디버깅도 자연스럽다.
실무에서는 코루틴을 모든 곳에 도입하기보다는 비동기 호출이 많고 I/O 대기 비율이 높은 구간에 선택적으로 도입하는게 이상적이지 않을까 생각이 된다.