티스토리 뷰

Kotlin

안녕, Coroutine

nodeal 2026. 5. 17. 02:52
반응형

한때 우리 팀의 서버 코드는 거의 모두 suspend 함수였다. Kotlin을 쓰면서 Coroutine을 쓰지 않을 이유가 있나 싶었고, Spring Data의 CoroutineCrudRepository, Ktor, kotlinx.coroutines가 만들어 둔 매끈한 API들이 미래를 가리키고 있다고 믿었다. 그런데 지금, 나는 이 글의 제목처럼 작별 인사를 하고 있다.

이 글은 Coroutine을 비난하기 위한 글이 아니다. 우리가 Coroutine을 도입했는지 다시 짚어보고, 그 동기들이 2026년에도 여전히 유효한지 점검해 본 기록이다.

몇 가지 한계를 먼저 적어 둔다. 첫째, 이 글은 한 팀의 운영 경험을 정리한 의견이며 벤치마크나 통제된 비교 실험을 담고 있지 않다. 둘째, 우리는 Spring MVC + JDBC + Kotlin이라는 특정 조합 위에서 일했고, WebFlux나 R2DBC를 기본으로 쓰는 조직에는 결론이 그대로 옮겨 가지 않을 수 있다. 셋째, JEP 491이 Java 24(2025-03)에 들어왔는데도 우리가 작별을 결정한 시점은 그보다 한참 뒤다 — Java 25 LTS에서 안정화되고 운영 사례가 쌓이는 시간을 의식적으로 기다렸기 때문이다.

우리가 Coroutine으로 풀고 싶었던 것

출발점은 단순했다. OS Thread는 비싸다. 동시에 수천 건의 외부 호출을 처리하려고 Thread를 그만큼 만들면 시스템 콜과 메모리에서 비용이 폭증한다.

Array(1000) {
    Thread {
        someBlockingFunction()
    }
} // Thread가 1,000개 생성된다.
    .onEach { it.start() }
    .forEach { it.join() }

Thread Pool로 줄일 수는 있다. 하지만 SynchronousQueue를 큐로 둔 ThreadPoolExecutormaximumPoolSize를 1,000으로 잡는 순간, 결국 진짜로 1,000개의 Thread가 만들어진다. "동시성"을 진짜로 원하면 그만큼의 carrier가 필요한 셈이다.

그래서 우리는 Java 7 시절 등장한 AsynchronousFileChannel 류의 Non-blocking API로 눈을 돌렸고, 콜백 지옥에 빠졌으며, Coroutine이 그걸 동기 코드처럼 풀어 준다는 약속에 매료됐다. Kotlin이 언어 차원에서 First-class로 밀어준다는 점도 결정적이었다. Spring Data, Ktor가 모두 그 흐름에 올라탔으니 우리도 늦지 않게 따라가야 한다고 느꼈다.

막상 마주한 문제들

러닝 커브, 그리고 잘못된 자신감

CoroutineContext, CoroutineScope, CoroutineDispatcher, Job. 입문 자료를 읽으면 이해한 것 같지만, 코드 리뷰를 돌리다 보면 — 그리고 솔직히 내가 한 달 전에 쓴 코드를 다시 볼 때도 — 그렇지 않다는 사실이 드러난다. 생각해 보면 전통적인 Spring + JDBC 스택은 개발자가 Thread Pool과 비동기 모델을 직접 들여다보지 않아도 충분히 잘 굴러가도록 설계되어 있었다. 그게 잘못이라는 게 아니라, 오히려 그 잘 만들어진 추상이 우리가 그 아래층을 굳이 알 필요 없게 해 줬다는 뜻이다. 그런 환경 위에 Coroutine이 얹히면, 한 번에 익혀야 할 전제 지식이 갑자기 너무 많아진다. 그런데도 동기식 Blocking I/O가 "원죄"처럼 다뤄지는 분위기 속에서, 이해보다 형식이 앞서는 — 일단 suspend부터 붙이는 — 코드가 쌓이기 쉽다. 물론 이건 코드 리뷰와 온보딩으로 막아야 하는 문제이지 언어 기능 자체의 죄는 아니다. 다만 막아야 할 면적이 다른 도구에 비해 넓다는 점은 사실이다.

예외, 어디서 어떻게 터지는지 모르겠다

다음 코드의 동작을 한 번에 설명할 수 있을까?

suspend fun f(scope: CoroutineScope) {
    val x = scope.async<Int> {
        delay(3.seconds)
        println("Good morning")
        1
    }
    val y = scope.async<Int> {
        throw Exception()
    }
    delay(5.seconds)
    println(x.await() + y.await())
}

답은 "넘겨받은 scope가 무엇이냐에 따라 다르다"이다. coroutineScope라면 y의 실패가 형제 Job인 x까지 취소시키지만, supervisorScope라면 다른 Job을 죽이지 않고 예외는 await() 시점까지 조용히 묻혀 있다. 호출자는 함수 시그니처만 봐서는 이걸 알 수 없다.

거기에 CancellationException이라는 함정이 있다. Job 취소를 위해 의도적으로 던지는 예외인데, 이게 java.util.concurrent.CancellationException의 typealias이고 그 부모가 IllegalStateException이다. 즉 누군가 catch (e: IllegalStateException)로 너무 넓게 잡기만 해도 취소 신호가 삼켜진다. 실무에서 IllegalStateException을 광범위하게 잡는 코드가 자주 등장하는 건 아니지만, 한 번 사고가 나면 원인을 짚기까지 시간이 오래 걸리는 종류의 버그라서 의심을 들고 다녀야 하는 영역이 늘어난다는 점이 부담이다.

"진짜 Non-blocking" 라이브러리는 흔치 않다

Coroutine의 효율은 Blocking이 없을 때 성립한다. 그런데 Java 생태계의 상당 부분이 여전히 Blocking이다.

  • synchronized — Mutex로 대체 가능하지만, 너무 많은 라이브러리가 이미 의존한다.
  • ThreadLocalsynchronized처럼 어디 숨어 있을지 모른다.
  • ReentrantReadWriteLock — Coroutine 친화적인 대체재가 마땅치 않다.
  • Guava Cache — 직접적인 대안이 없다.

Kotlin 공식 답은 "Blocking은 Dispatchers.IO에 던져라"이다. 그런데 Dispatchers.IO의 기본 스레드 한도는 max(64, availableProcessors), 즉 대부분의 머신에서 64개다. 동시에 처리해야 할 외부 호출이 수백, 수천 건이라면 이 한도는 금세 병목이 된다. 물론 limitedParallelism()이나 시스템 프로퍼티 kotlinx.coroutines.io.parallelism으로 조정할 수단은 있다. 정작 어려운 건 "어디서, 얼마나, 왜" 그렇게 조정해야 하는지에 대한 표준 가이드가 없다는 쪽이다. 결국 각 팀이 자기만의 더 큰 디스패처를 만들어 가며 코드베이스 안에 출처가 제각각인 Dispatcher가 늘어 가는 풍경은 우리만의 경험은 아닐 것 같다. 도구가 강제하지 않으니 사람이 강제해야 하는 영역이 그만큼 늘어난다.

Thread Pool과 Dispatcher의 미궁

적절한 Thread Pool과 함께라면 Non-blocking I/O가 효율적이라는 건 안다. 문제는 라이브러리마다 그 Thread Pool을 다르게 만든다는 사실이다.

라이브러리Thread 모델
OkHttpExecutor를 외부에서 지정 가능
aws-sdk-kotlinHTTP 엔진(OkHttp/CRT)에 따라 다름, 자체 디스패처 보유
LettuceNetty 기반, 고정 크기 이벤트 루프
Spring WebClientReactor Netty, JVM 시스템 속성으로 조정

"라이브러리를 가져다 쓴다"가 "각 라이브러리의 스레딩 정책을 매번 학습한다"로 변한다. 어느 한 곳에 잘못된 Blocking 호출이 끼면, 어느 풀이 막혀서 장애가 났는지 추적하는 데 한참이 걸린다.

스택 트레이스의 상실

JVM의 가장 큰 무기 중 하나는 스택 트레이스다. Coroutine은 이걸 부분적으로 잃는다. 호출 지점이 Thread를 건너뛰면서 진입점을 착각하기 쉽고, 디버거를 붙여도 더 이상 사용되지 않을 로컬 변수는 메모리 누수 방지를 위해 일찍 캡처에서 제외되어 보이지 않는다. 라이브 디버깅이 어렵다.

포기해야 했던 Spring의 편의

@Transactional은 Spring 개발자에게 거의 공기 같은 것이다. Coroutine과는 오랜 시간 잘 맞지 않았다. Spring Framework 6.x에서 suspend 함수의 트랜잭션 컨텍스트 전파가 개선되긴 했지만, 여전히 반응형 트랜잭션 매니저(R2DBC 등) 조합이 전제되는 시나리오가 많고, JDBC + suspend 조합은 TransactionalOperator 같은 우회 경로를 거쳐야 한다.

HikariCP는 더 결정적이다. 수천 개의 가벼운 routine이 각자 Connection을 쥔 채 suspend할 수 있다면 풀에는 수천 개의 connection이 필요하다는 뜻인데, 데이터베이스 쪽에서 보면 시작도 못 할 이야기다. 솔직히 짚어 두자. 이건 동시성 모델의 죄라기보다는 RDB Connection이라는 자원 자체가 비싼 데서 오는 문제고, 뒤에서 보겠지만 VirtualThread로 옮겨 가도 같은 형태의 사이징 압력은 그대로 남는다. 다만 코루틴 환경에서는 거기에 더해 ThreadLocal 가정 충돌이라는 별개의 비호환이 얹힌다는 점이 무겁다. 그렇다고 R2DBC를 전사 표준으로 끌고 갈 것인가? 그건 또 다른 큰 결단이다.

그럼 대안은

탐색 기준은 세 가지였다. ① 동시성 제어가 충분히 잘 되어야 한다. ② Kotlin + Spring MVC와 호환되어야 한다. ③ 앞서 본 문제들을 정말 해결해야 한다. 두 번째 조건은 우리 팀의 제약이다. Spring MVC 스택 위에 쌓인 기능과 운영 노하우가 많아 WebFlux로 통째로 이주하는 비용이 정당화되지 않았다. WebFlux + Coroutine이 더 맞는 조직도 분명히 있을 것이고, 이 글의 결론을 거기에까지 일반화할 생각은 없다.

CompletableFuture

Java 표준이라 의존성이 없다는 장점은 분명하다. 그러나 가독성이 떨어지고, 예외 처리는 무시하기 쉽다. Coroutine을 택했던 두 가지 이유(읽기 쉬운 비동기 코드, 예외 처리의 명료함)를 모두 잃는다. 무엇보다 Blocking 라이브러리 문제는 그대로다.

Spring @Async

Spring과 잘 어우러지지만, 어노테이션 한 줄로 일어나는 마법은 Spring AOP의 self-invocation 문제를 동반한다. 그리고 함수에 "색깔"이 생긴다는 본질적인 문제는 suspend와 동일하다. Bob Nystrom의 What Color is Your Function?이 지적한 그 문제다.

VirtualThread

Java 21에서 정식 도입된 경량 스레드. 작업을 표현하는 virtual thread와, 실제 실행을 담당하는 carrier thread가 분리되어 있고, Blocking I/O를 만나면 JVM이 알아서 carrier에서 unmount한다. Coroutine이 Dispatcher 풀에 스레드를 반납하는 것과 본질적으로 같은 원리다.

왜 VirtualThread인가

러닝 커브가 거의 없다

정확히 말하면 "일상 코드 작성의 러닝 커브가 거의 없다"이다. "Thread를 효율적으로 쓰고 싶다"는 동기만 이해하면, 그 뒤로는 원래 Java/Kotlin 코드를 쓰던 방식 그대로 쓸 수 있다. 운영 관점의 학습 곡선(JFR 이벤트, ThreadLocal 캐싱 함정, 스레드 덤프 가독성, 잔존 핀닝 패턴 등)은 따로 있고, 이건 무시할 수 없다.

공정하게 두자. 코루틴도 단순 launch/async까지의 일상 코드는 어렵지 않다. 어려운 건 그 뒤에 줄지어 등장하는 개념들(CoroutineContext, Scope, Dispatcher, Job의 결합, 예외 전파 분기)이다. 그러니 정확한 비교는 "일상 vs 일상", "운영/심화 vs 운영/심화" 두 축을 나란히 놓는 쪽이다. 우리 환경에서는 두 축 모두 VirtualThread 쪽이 더 얕은 곡선을 그렸다. 단언이라기보다는 한 팀의 관찰이다. CompletableFuture는 Thread Pool 이해를 강제하고, @Async는 "왜 이게 안 되지?"라는 디버깅을 강제한다는 점에서도, VirtualThread는 그런 강요가 적다. Spring Boot에서는 spring.threads.virtual.enabled=true 한 줄이 전환의 시작이다.

예외 전파가 단순해진다

적어도 단일 작업 수준에서는 그렇다. CancellationExceptionIllegalStateException에 묻혀 사라지는 일이 없고, supervisorScope + async의 지연 예외라는 함정도 없다. VirtualThread는 Thread를 상속하므로(엄밀히는 BaseVirtualThread를 거치지만) 기존 interrupt 기반 취소 모델이 그대로 동작한다. 다만 여러 작업을 묶어 합성할 때 필요한 StructuredTaskScope는 본문 뒤에서 보듯 아직 Preview이고, 거기까지 들어가면 코루틴의 합성 API와의 비교 우위가 단순하지 않다. "예외 전파가 쉬워진다"의 무게는 거기서 한 번 가벼워진다.

Blocking과 화해한다

Blocking I/O가 더는 "잘못"이 아니다. JVM이 carrier에서 unmount해 주기 때문에 Blocking 함수를 그대로 써도 처리량이 무너지지 않는다. Java 라이브러리의 내부 구현을 외워야 하는 부담이 사라진다. Dispatchers.IODispatchers.Default의 차이를 매번 의식할 필요도 없다.

한 가지는 분명히 해 두자. VirtualThread가 함수의 "색깔" 문제를 푼 것은 아니다. 정확히는 색깔을 시그니처에서 보이지 않게 숨긴 것이다. blocking 호출이 platform thread 위에서 일어나면 carrier를 막고, virtual thread 위에서 일어나면 unmount되는 비대칭은 그대로 남아 있다. 단지 호출자에게 그 차이를 강요하지 않을 뿐이다. 그래서 운영자 관점에서는 "어떤 컨텍스트에서 호출되는가"를 여전히 신경 써야 하고, 이 점은 솔직히 인정해야 한다.

스택 트레이스가 돌아온다

VirtualThread의 스택 트레이스는 Platform Thread의 그것과 동일하다. 디버거에서 로컬 변수도 정상적으로 보인다. 운영 환경에서 사후 분석이 가능한 익숙한 세계로의 복귀다.

@Transactional, ThreadLocal이 정상 동작한다

VirtualThread도 결국 Thread다. ThreadLocal이 정상 동작하므로 그 위에 쌓아 올린 Spring의 트랜잭션, 보안 컨텍스트, MDC 같은 인프라가 그대로 쓰인다. 이게 생각보다 굉장히 크다. 우리가 "Coroutine을 위해서" 포기했던 편의의 상당 부분이 자연스럽게 복귀한다.

Pinning은 이제 거의 해결됐다

VirtualThread 도입 시 가장 큰 걱정거리는 pinning이었다. synchronized 블록 안에서 blocking I/O가 발생하면 carrier가 묶여 풀에서 빠지는 문제다. 이 중 synchronized 케이스는 Java 24의 JEP 491("Synchronize Virtual Threads without Pinning")에서 해소됐다. 다만 네이티브 호출(JNI/FFM), 클래스 초기화 중 블로킹 같은 잔존 케이스는 남아 있고, JFR의 jdk.VirtualThreadPinned 이벤트를 모니터링하는 운영 노하우는 이제 새로 쌓아야 한다.

HikariCP의 사례는 라이브러리 단의 분위기를 보여 주는 좋은 예다. PR #2055는 synchronizedReentrantLock으로 옮기자는 제안이었지만, 메인테이너의 입장은 "HikariCP의 synchronized 블록 안에서는 I/O를 하지 않으므로 핀닝 자체가 우리 문제는 아니다", "ReentrantLock 인스턴스의 할당과 GC 비용이 부담이다", "JEP 491이 들어오면 자연히 풀린다"는 쪽이었다. 즉 "정답이 어려워서 멈췄다"보다는 "라이브러리 메인테이너가 변경의 필요성 자체를 받아들이지 않았다"가 더 정직한 묘사다. 그 사이 JEP 491이 들어와 결과적으로 큰 짐을 덜었다.

그리고 핀닝이 해소됐다고 VirtualThread가 무료는 아니다. ConcurrentBagThreadLocal 캐싱은 VirtualThread 규모에서 잘 맞지 않고, 더 넓게는 ThreadLocal을 캐시로 쓰는 라이브러리들이 수만 개의 가상 스레드 환경에서 의도치 않은 메모리 압박을 만들 수 있다. 수천 개가 떠 있는 시점의 thread dump는 사람이 읽기 위해 설계된 출력이 아니다. 즉, VirtualThread도 자기 몫의 운영 학습 곡선이 분명히 있다. 다만 그 곡선이 Coroutine만큼 가파르지 않고, 일상적인 코드 작성 자체에까지 침투하는 종류는 아니라는 게 — 적어도 우리 환경에서는 — 내 판단이다.

그럼에도 Coroutine이 나은 영역

물론 VirtualThread가 모든 걸 이긴다고는 말 못한다. Coroutine이 여전히 강한 곳이 있다.

Flow와 Backpressure

전체 데이터를 한 번에 메모리에 올리지 않고 순차 처리하면서, 소비자 속도에 따라 생산자가 조절되는 backpressure가 언어 수준에서 지원된다.

fun streamAll() = channelFlow {
    CampaignTable
        .selectAll()
        // JDBC URL에 useCursorFetch=true 필요
        .fetchSize(1000)
        .forEach { send(it) }
}

suspend fun doConsume() {
    streamAll()
        .map { CampaignRepository.wrapRow(it) }
        .map { it.toValueObject() }
        .chunked(size = 200)
        .buffer(capacity = 20)
        .flatMapMerge(concurrency = 4) { chunk ->
            flow { emit(batch(chunk)) }
        }
        .collect()
}

같은 일을 LinkedBlockingDeque + VirtualThread로 흉내 낼 수는 있지만, 코드 길이와 예외 전파 처리에서 차이가 분명하다.

구조화된 동시성

"여러 작업 중 하나만 성공해도 나머지는 취소", "하나 실패하면 전부 취소", "모두 성공할 때까지 대기" 같은 패턴을 Coroutine은 일찍부터 일급으로 제공해 왔다. JVM 진영에는 StructuredTaskScope가 있지만 여전히 Preview 단계다. Java 22(JEP 462), Java 23(JEP 480), Java 24(JEP 499)까지 변경 없이 재프리뷰되던 API가 Java 25의 JEP 505에서 한 차례 크게 바뀌었다. 공개 생성자가 정적 팩토리 메서드로 교체되고, ShutdownOnSuccess / ShutdownOnFailure 같은 서브클래스 모델이 Joiner 기반 API로 옮겨졌다. 그리고 2026년 3월 GA된 Java 26의 JEP 525에서도 — 이번엔 작은 손질이지만 — anySuccessfulResultOrThrow()anySuccessfulOrThrow()로 개명되고, allSuccessfulOrThrow()의 반환 타입이 스트림에서 리스트로 바뀌고, Joiner.onTimeout()이 추가됐다. 표준화 전까지는 메이저 릴리스마다 코드를 살짝씩 고쳐 가며 따라가야 한다는 뜻이다.

// Java 26(JEP 525) 기준, --enable-preview 필요 — 다음 메이저에서 또 바뀔 가능성이 높다
val joiner = StructuredTaskScope.Joiner
    .anySuccessfulOrThrow<Int>() // Java 25에서는 anySuccessfulResultOrThrow()
val output = StructuredTaskScope.open(joiner).use {
    for (i in 1..3) {
        it.fork(Callable {
            Thread.sleep(i * 1000L)
            return@Callable i
        })
    }
    it.join()
}
println(output) // 1

이 글의 예제 코드도 시간이 지나면 다시 손봐야 할 가능성이 높다. 그게 "표준화 전까지는 메이저마다 따라가야 한다"의 살아 있는 증거다.

Android는 선택지 자체가 다르다

엄밀히 말하면 Android는 "Coroutine이 낫다"가 아니라 "사실상 Coroutine뿐"인 자리다. Jetpack Compose의 LaunchedEffect를 비롯해 현대 Android의 생명주기와 UI 동시성 모델이 Coroutine을 전제한다. Android는 D8 desugaring으로 일부 최신 Java 문법을 컴파일할 수 있지만, ART 런타임이 VirtualThread를 네이티브로 지원하지는 않는다. 서버에서 떠나도 클라이언트에서는 한동안 함께 가야 한다. 그래서 Android 팀이 있는 조직에서 "Coroutine을 완전히 졸업한다"는 표현은 과장이다. 서버 쪽 기본값을 옮긴다는 정도가 정확한 묘사다.

안녕, Coroutine

Coroutine이 우리에게 가르쳐 준 것은 분명히 크다. 비동기를 동기처럼 쓰는 경험, 구조화된 동시성, Flow의 우아함. 이 글에서 다룬 단점들이 Coroutine의 전부도 아니고, 우리가 Coroutine으로부터 얻은 것들이 작별 후에 사라지는 것도 아니다.

다만 서버 사이드에서 우리가 일상적으로 마주하는 트레이드오프를 다시 계산해 보면, 2026년의 답은 더 이상 자동으로 Coroutine이 아니다. JVM이 일찍이 잘하던 일들(스택 트레이스, ThreadLocal, @Transactional, 익숙한 디버깅 워크플로)을 포기하지 않고도, Thread 효율이라는 원래의 동기를 충족하는 길이 생겼기 때문이다. 그렇다고 답이 자동으로 VirtualThread라는 것도 아니다. Flow의 흐름 처리와 구조화된 취소가 더 자연스러운 자리가 있고, Android는 선택지가 다르며, WebFlux 위에 서 있는 조직은 또 다른 결정에 도달할 것이다.

그래서 우리는 새 서비스에서는 VirtualThread를 기본 후보로 두기 시작했다. 무조건이라는 뜻이 아니라, "Kotlin이니까 Coroutine"이라는 디폴트를 한 번 멈춰 세웠다는 의미다.

이런 결정이 "그럼 Kotlin을 왜 쓰느냐"로 이어지지는 않는다. null safety, data class, 확장 함수, sealed 계층, 표현력 있는 컬렉션 API — Kotlin이 Coroutine을 빼고도 우리에게 주는 것이 많다. 이번 결정은 Kotlin과의 작별이 아니라, Kotlin 안에서 동시성 도구만 바꾸는 일이다.

안녕, Coroutine. 미워서 보내는 게 아니라, 이제는 매번 검토해 결정해야 할 만큼 선택지가 늘었기 때문이다.

반응형

'Kotlin' 카테고리의 다른 글

Ktor Pipeline, PipelinePhase  (0) 2023.08.26
Nullable value class: Long? long?  (1) 2023.05.07
Kotlin/JVM Vector, ArrayList, Mutex 수행 시간  (0) 2023.02.11
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함