Upgrade to Pro — share decks privately, control downloads, hide ads and more …

스프링과 코루틴, 내가 겪은 사고들

스프링과 코루틴, 내가 겪은 사고들

짧은 타임아웃과 수만 TPS의 WebFlux 환경에서 코루틴과 함께 살아남은 이야기

Avatar for 이해찬

이해찬

August 16, 2026

Other Decks in Programming

Transcript

  1. Kotlin User Groups Seoul KotlinConf Extended 2026 F EA TUR

    ED SE SSI ON 스프링과 코루틴, 내가 겪은 사고들 이해찬
  2. 목차 1장 틱이 자꾸 밀립니다 — @Scheduled + runBlocking 2장

    파드를 늘렸는데 왜 안 빨라지죠? — 코루틴과 리액터의 다리 3장 Dispatchers.IO.immediate는 왜 없지? — 코루틴 스케줄러 동작 파악하기 4장 내 suspend 컨트롤러는 어느 스레드에서 도나? 5장 인자를 그만 넘기고 싶다 — CoroutineContext / context params 6장 정지 함수와 AOP
  3. 1장 @Scheduled 테스크의 틱이 자꾸 밀립니다 @Scheduled(cron = "0 */10

    * * * *") fun refresh() { runBlocking { if (!cacheService.isFresh()) { cacheService.store(cacheService.load()) } } }
  4. before — 스케줄 스레드를 통째로 붙잡는다 @Service class CacheRefreshScheduler(private val

    cache: CacheService) { @Scheduled(cron = "0 */10 * * * *") fun refresh() { runBlocking { // 스케줄 스레드 점유 if (!cache.isFresh()) { cache.store(cache.load()) } } } } 기본 TaskScheduler 풀 크기 = 1 runBlocking — blocking 세계와 코루틴 세계를 잇는 다리 이름 그대로 block — 안의 코루틴이 끝날 때까지 현재 스레드를 붙잡는다 (단일 스레드 @Scheduled = 틱 밀림)
  5. after — 전용 디스패처로 분리 // 스케줄 · 이벤트 루프

    풀과 분리된 전용 디스패처 val Dispatchers.CRON: CoroutineDispatcher by lazy { Executors.newFixedThreadPool(3).asCoroutineDispatcher() } private val handler = CoroutineExceptionHandler { _, e -> log.error("cron failed", e) } private val cronScope = CoroutineScope(Dispatchers.CRON + SupervisorJob() + handler) @Scheduled(cron = "0 */10 * * * *") fun cron() { cronScope.launch { // 즉시 반환 fetchTargets() .map { batch -> async { cache(batch) } } .awaitAll() } } 스케줄 스레드는 launch만 하고 바로 빠진다 → 틱이 더 이상 밀리지 않는다
  6. 2장 파드를 늘렸는데 왜 안 빨라지죠? (코루틴과 리액터의 다리) //

    파드를 늘려도 응답 속도는 그대로 suspend fun sendEvent(id: String): String = webClient.get().uri("/event/$id").retrieve() .bodyToMono<String>() .block()
  7. suspend fun 안의 .block() → awaitBody() before — suspend인데 내부는

    블로킹 after — Mono를 suspend 다리로 연결 suspend fun sendEvent(id: String) suspend fun sendEvent(id: String) : String = : String = webClient.get().uri("/event/$id") webClient.get().uri("/event/$id") .retrieve() .retrieve() .bodyToMono<String>() .awaitBody() // 논블로킹 대기 .block() // 스레드 점유 → 시그니처가 suspend여도 .block()이면 그 스레드는 묶인다 → 이벤트 루프가 막혀 파드를 늘려도 응답 속도는 그대로 → await 시 스레드 반납 → 다른 요청 처리 → 응답 도착하면 그 지점부터 재개
  8. suspend ≠ 논블로킹 이벤트 루프 스레드 점유 .block() awaitBody() 요청

    A 스레드 반납 → 다른 요청 처리 시그니처가 suspend여도 .block()이면 스레드는 묶인다 — 파드를 늘려도 그대로 awaiteBody()는 spring-webflux가 제공하는 코루틴 확장 ⋯ 재개
  9. 코루틴 → Reactor 다리— awaitExchange 구현 spring-webflux/src/main/kotlin/.../function/client/WebClientExtensions.kt suspend fun <T>

    RequestHeadersSpec<*>.awaitExchange( responseHandler: suspend (ClientResponse) -> T): T { val context = currentCoroutineContext().minusKey(Job.Key) // ① return withContext(context.toReactorContext()) { // ② exchangeToMono { // ③ mono(context) { responseHandler.invoke(it) } }.awaitSingle() // ④ } } ① Job 제거 — mono 빌더가 Job 섞인 컨텍스트를 거부한다 require(context[Job] === null) { "Mono context cannot contain job in it. " "Its lifecycle should be managed via Disposable handle." } ② 코루틴 컨텍스트를 Reactor Context로 전파 ③ suspend 핸들러를 mono{}로 감싸 Reactor 세계로 넘긴다 ④ Mono 결과를 awaitSingle()로 다시 suspend로 되받는다
  10. 코루틴 코루틴 Reactor '다리 함수'는 이렇게 생겼다 1 2 3

    minusKey(Job) toReactorContext mono { } Reactor 4 awaitSingle()
  11. Reactor → 코루틴 — awaitSingle 내부 reactive/kotlinx-coroutines-reactive/src/Await.kt public suspend fun

    <T> Publisher<T>.awaitSingle(): T = awaitOne(Mode.SINGLE) private suspend fun <T> Publisher<T>.awaitOne(mode: Mode): T = suspendCancellableCoroutine { cont -> subscribe(object : Subscriber<T> { override fun onSubscribe(sub: Subscription) { cont.invokeOnCancellation { sub.cancel() } sub.request(if (mode == FIRST) 1 else Long.MAX_VALUE) } override fun onNext(t: T) { value = t; seenValue = true } override fun onComplete() { if (seenValue) cont.resume(value as T) else cont.resumeWithException(NoSuchElementException()) } override fun onError(e: Throwable) = cont.resumeWithException(e) }) }
  12. 다른 세계로 가는 다리 함수들 대상 코루틴으로 받는 다리 스레드

    Reactor (WebFlux) awaitSingle() · awaitBody() · mono { } 안 막힘 CompletableFuture .await() 안 막힘 Reactive Mongo / Redis /R2DBC await* · asFlow() 안 막힘 블로킹 드라이버 (JDBC · Jedis · MongoTemplate) Dispatchers.IO 로 격리 스레드 점유 정석은 다리를 제공하는 리액티브 라이브러리를 쓰는 것 반대로 블로킹 드라이버(JDBC · Jedis · MongoTemplate)는 withContext(Dispatchers.IO)로 격리해야 하고, 그만큼 스레드를 먹는다
  13. 3장 Dispatchers.IO.immediate는 왜 없지? // "이미 IO 스레드면 스위칭 없이

    바로 실행하자” // Main.immediate는 있는데 IO.immediate는 없다 withContext(Dispatchers.IO.immediate) { // loadFromCache() }
  14. Main, IO 차이 — 단일 쓰레드와 쓰레드 풀 Dispatchers.Main Dispatchers

    IO + Default 단일 UI 스레드 교체 가능한 워커 풀 Main ? .immediate ✓ .immediate ✗ 없음 Default와 IO는 같은 스레드 풀을 공유하는 형태
  15. 코루틴 스케줄러 동작방식 : 로컬 큐 → 글로벌 큐 →

    work-stealing worker-1 worker-2 worker-3 ① 내 로컬 큐 로컬 큐 로컬 큐 ② 글로벌 큐 외부 제출 · overflow ③ 남의 큐 훔치기 ▸ 집는 순서: 로컬 큐(LIFO) → 글로벌 큐 → 남의 큐 훔치기. ▸ IO와 Default는 같은 워커 풀 — CPU 작업(Default)만 코어 수만큼의 실행쓰레드 수(permit) 제한 ▸ 블로킹 진입 시 permit 반납 + 다른 워커 기동 ▸ 각각 워커의 수는 점진적 증가
  16. 'IO. Immediate' 가능한가요? 예 · 코루틴 워커 스레드에서 제출 Worker-2

    코루틴 호출 내 로컬 큐에 제출 worker-2 그대로 단일 LIFO 슬롯 우선 스레드 안 바뀜 워커에서 제출? 아니오 · 워커가 아닌 스레드에서 제출 글로벌 큐로 worker-? 로 이동 아무 워커나 집는다 스레드 바뀜 디스패치는 분명히 일어난다 — 목적지가 자기 로컬 큐라서 같은 스레드가 이어받을 뿐이다
  17. 글로벌 큐의 스케줄링 방식 globalCpuQueue permit 있음 → 둘 다

    조회 CPU nextInt(2)로 순서 결정 — 랜덤 globalBlockingQueue permit 없음 → 블로킹 큐만 외부 제출 또는 overflow 블로킹 permit 못 얻은 워커가 블로킹만 골라 집게 하려고 글로벌 큐를 둘로 쪼갰다
  18. CPU permit — 회의실 열쇠 = 코어 수만큼 // 코어

    4개인 머신이라고 하자 permit = 코어 수만큼 (예: 4개) launch(Dispatchers.Default) { // 열쇠 필요 heavyCalculation() } // → 동시에 4개까지만 실행 Default — 열쇠가 있어야 실행 (3 사용 · 1 여유) launch(Dispatchers.IO) { // 열쇠 없이 jdbcTemplate.query(SQL) } // → blocking 플래그만 붙는다 withContext(Dispatchers.IO) { blockingCall() // 열쇠를 반납한다 } // → 그 슬롯으로 다른 워커가 이어감 IO — 열쇠 없이 실행 최대 max(64, 코어 수)까지 증가 블로킹 진입 시 열쇠 반납 → 대기 워커가 이어받는다
  19. 4장 내 suspend 컨트롤러는 어느 스레드에서 도나? @RestController class ReportController(val

    jdbc: JdbcTemplate) { @GetMapping("/report") suspend fun report(): Report = jdbc.query(SQL, mapper) // } 어느 스레드에서 돌지?
  20. suspend는 어디서 Mono가 되나 // ① WebFlux 핸들러 spring-webflux/.../result/method/InvocableHandlerMethod.java →

    CoroutinesUtils.invokeSuspendingFunction(method, bean, args) // ② Spring MVC 핸들러 (서블릿) spring-web/.../method/support/InvocableHandlerMethod.java → CoroutinesUtils.invokeSuspendingFunction(...) // 실제 스택 at kotlinx.coroutines.reactor.MonoKt$monoInternal$1.accept(Mono.kt:55) at o.s.core.CoroutinesUtils.invokeSuspendingFunction(CoroutinesUtils.java:110) at o.s.aop.support.AopUtils$KotlinDelegate .invokeSuspendingFunction(AopUtils.java:377) at o.s.aop.framework.CglibAopProxy$CglibMethodInvocation .proceed(CglibAopProxy.java:765)
  21. Spring은 Unconfined로 시작한다 spring-core/src/main/java/org/springframework/core/CoroutinesUtils.java public static Publisher<?> invokeSuspendingFunction( Method method,

    Object target, @Nullable Object... args) { return invokeSuspendingFunction( Dispatchers.getUnconfined(), method, target, args); // ← 하드코딩 } Javadoc도 명시한다 — "Uses an unconfined dispatcher"
  22. Unconfined 재개 스레드 = await를 '완료시킨' 스레드 awaitBody() withContext Default

    Reactive DB delay() dispatch Netty IO 워커 Default 워커 Lettuce 타이머 톰캣 워커 이벤트 루프 스레드 스레드 I/O 스레드 스레드 응답 write 재개 스레드는 await를 완료시킨 쪽이 정한다 — 여러분의 소스에 따라 다름
  23. WebFlux — 이벤트 루프 쓰레드는 코어 수만큼뿐이다 격리 없이 reactor-http-nio-2

    점유 — 이 루프에 걸린 요청 전부 대기 블로킹 호출 격리 후 IO로 넘김 요청 A 루프 스레드 반납 → 다른 요청 처리 톰캣 200개와 달리 루프 스레드는 코어 수만큼 — 하나만 막혀도 처리량이 무너진다 재개
  24. Webflux면 웬만하면 Dispatcher로 넘겨라 컨트롤러에서 그냥 부른다 @GetMapping("/report") suspend fun

    report(): Report = jdbcTemplate.query(SQL, mapper) // 블로킹 드라이버 @GetMapping("/score") suspend fun score(): Score = heavyCalculation() // CPU 점유 재개 스레드가 Netty 이벤트 루프면 전체가 멈춘다 MVC는 톰캣 워커에서 실행 디스패처로 넘긴다 @GetMapping("/report") suspend fun report(): Report = withContext(Dispatchers.IO) { jdbcTemplate.query(SQL, mapper) } @GetMapping("/score") suspend fun score(): Score = withContext(Dispatchers.Default) { heavyCalculation() } 애매하면 넘기는 쪽이 안전하다 — 비용은 디스패치 한 번
  25. 5장 인자를 그만 넘기고 싶다 suspend fun handle(req: SomeRequest, traceId:

    String, now: Instant) { val loaded = load (req, traceId, now) val checked = validate (req, traceId, now, loaded) val result = render (req, traceId, now, checked) } // 쓰지도 않으면서 받아서 넘긴다 suspend fun validate(req: SomeRequest, traceId: String, now: Instant, ctx: Ctx) = repo.find(req, traceId, now, ctx)
  26. 코루틴 커스텀 콘텍스트 활용 모든 시그니처에 req를 끌고 다닌다 handler(req)

    service(req, …) repo(req, …) 인자 없이 coroutineContext[Key] withContext(RequestContext(req)) handler() util(req, …) service() repo() 요청 스코프 값은 인자가 아니라 컨텍스트로 — 자식 코루틴에 자동 전파
  27. CoroutineContext.Element // 1. 컨텍스트 원소로 정의한다 — Key는 companion으로 class

    RequestContext(val request: RequestInfo) : CoroutineContext.Element { override val key = Key companion object Key : CoroutineContext.Key<RequestContext> } // 2. 진입점에서 한 번만 주입한다 withContext(Dispatchers.IO + RequestContext(req)) { service.process() // 이하 전부 인자 없이 } // 3. 필요한 곳에서 꺼내 쓴다 suspend fun currentRequest(): RequestInfo = checkNotNull(coroutineContext[RequestContext]).request 자식 코루틴에 자동 상속 — 전파는 공짜다 대신 컨텍스트가 있는지 컴파일타임에 검증할 수 없다 → 유실되면 런타임에서야 터진다
  28. 컴파일타임 안전 — context parameters (2.4 Stable) // 1. 선언

    — 이름 붙은 숨은 파라미터로 들어온다 context(ctx: RequestContext) suspend fun process(): Result { val req = ctx.request // 없으면 컴파일 에러 } // 2. 호출부 — context()로 제공한다 val rc = RequestContext(req) context(rc) { process() // rc를 안 넘겨도 꽂아준다 } // 3. 중간 함수도 계속 명시해야 흐른다 (자동 전파 없음) context(ctx: RequestContext) suspend fun step() = process() JVM에선 인자 하나 더 있는 평범한 메서드, 런타임 0비용
  29. 6장 정지 함수와 AOP @Timed suspend fun handler(id: String): Data

    = repo.find(id) @Around("@annotation(Timed)") fun around(jp: ProceedingJoinPoint): Any? { val t0 = System.nanoTime() val result = jp.proceed() // record(System.nanoTime() - t0) return result //
  30. 이전 — 평범한 advice를 그대로 붙이면 @Timed suspend fun handler(id:

    String): Data = repo.find(id) // JVM 실제 시그니처: handler(id, $cont) → jp.args = [id, $cont] // CSP Style @Around("@annotation(Timed)") fun around(jp: ProceedingJoinPoint): Any? { val t0 = System.nanoTime() val result = jp.proceed(jp.args) // ① record(System.nanoTime() - t0) // ② return result // ③ } ① 원본 Continuation을 그대로 넘긴다. ② 그래서 proceed()는 리턴 값 불명 or 즉시 리턴→ 측정값 ≈ 0ms ③ 리턴값이 Data가 아니라 COROUTINE_SUSPENDED 마커 → 호출부에서 ClassCastException
  31. 왜 즉시 리턴되나 — suspend는 상태 기계로 컴파일된다 // 상태

    머신 fun handler(id: String, $cont: Continuation<Data>): Any? { val sm = $cont as? HandlerSM ?: HandlerSM($cont) when (sm.label) { 0 -> { sm.label = 1 val r = repo.find(id, sm) if (r === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED // ← 평범한 JVM return } 1 -> { /* 재개 지점 — sm.result 로 이어붙는다 */ } } } 정지 지점을 즉시 끝낼 수 없으면 continuation을 그 작업에 등록하고 마커를 리턴한다 jp.proceed()는 Method.invoke일 뿐 — 리턴이 오면 AOP는 "끝났다"고 본다 Await가 없으면 정상반환, 있으면 ClassCastException
  32. 전체 소스 @Around("@annotation(Timed)") fun around(jp: ProceedingJoinPoint): Any? { val args

    = jp.args val ctx = (args.last() as Continuation<*>).context.minusKey(Job) // ④ return mono(ctx) { // ② val t0 = System.nanoTime() try { val rtn = suspendCoroutineUninterceptedOrReturn<Any?> { cont -> jp.proceed(args.dropLast(1).toTypedArray() + cont) // ③ } if (rtn is Mono<*>) rtn.awaitSingleOrNull() else rtn // ① } finally { record(System.nanoTime() - t0) } } }
  33. ② suspend 실행 불가능 mono로 우회 // mono를 생성하여 suspend함수

    호출 // AOP 시그니쳐는 mono반환 가능 return mono(ctx) { ... } // Mono → advice 안에서는 suspend 함수를 호출할 수 없다 → 값이 아니라 Publisher(Mono)를 반환하면 코루틴이 대신 기다린다 // advice 시그니처에는 suspend를 붙일 수 없다 fun around(jp: ProceedingJoinPoint): Any? { val rtn = ... rtn.awaitSingle() // 불가 — 여기서 기다릴 수 없다 }
  34. ③ 프록시는 Conitnuation을 갈아끼운 원본을 불러야 한다 // Before :

    X jp.proceed(jp.args) // 원본의 Continuation이 그대로 재사용 // After : advice가 결과를 받으려면 '내 Continuation'을 끼워 넣어야 한다 mono(ctx) { … suspendCoroutineUninterceptedOrReturn<Any?> { cont -> jp.proceed(jp.args.dropLast(1).toTypedArray() + cont) } } → 마지막 인자를 내가 만든 Continuation으로 교체한다 → 그래야 원본의 resume이 advice로 돌아온다
  35. 원본 Continuation은 건드리지 않는다 — 이중 resume 함정 val rtn

    = suspendCoroutineUninterceptedOrReturn<Any?> { cont -> jp.proceed(args.dropLast(1).toTypedArray() + cont) // 내 cont } ✕ 원본 Continuation을 직접 resume → 프록시도 같은 것을 완료시켜 이중 resume ✕ IllegalStateException, CompletedContinuation 캐스팅 예외 ✓ mono { }로 새 코루틴을 만들고 원본 Continuation은 손대지 않는다 ✓ 재개는 Spring 리액티브 브리지가 맡고, Reactor가 '한 번만 완료'를 보장한다
  36. proceed()가 돌려주는 것 — 값일 수도, Mono일 수도 val rtn

    = suspendCoroutineUninterceptedOrReturn<Any?> { cont -> ... } val result = if (rtn is Mono<*>) rtn.awaitSingleOrNull() // Mono가 온 경우 else rtn // 값이 온 경우 ▸ Spring 6.1+: 프록시가 suspend 원본을 Mono로 바꿔 실행 → proceed()는 Mono를 돌려준다 ▸ 그 이전 경로에서는 실제 값(또는 COROUTINE_SUSPENDED 마커)이 온다 ▸ 분기 없이 값으로 캐스팅하면 ClassCastException (spring-framework #31855)
  37. ④ mono는 Job이 섞인 컨텍스트를 거부한다 val ctx = (jp.args.last()

    as Continuation<*>).context mono(ctx) { ... } // IllegalArgumentException — Job 포함 mono(ctx.minusKey(Job)) { ... } // OK — Job만 떼고 나머지는 유지 // WebClient의 awaitExchange도 같은 트릭을 쓴다 currentCoroutineContext().minusKey(Job.Key) → MDC·Dispatcher 등 컨텍스트는 살리고 생명주기(Job)만 떼낸다 → 새 코루틴의 Job은 mono 빌더가 직접 만든다
  38. 이후 — 네 조각을 합친 advice @Around("@annotation(Timed)") fun around(jp: ProceedingJoinPoint):

    Any? { val args = jp.args val ctx = (args.last() as Continuation<*>).context.minusKey(Job) // ④ return mono(ctx) { // ② val t0 = System.nanoTime() try { val rtn = suspendCoroutineUninterceptedOrReturn<Any?> { cont -> jp.proceed(args.dropLast(1).toTypedArray() + cont) // ③ } if (rtn is Mono<*>) rtn.awaitSingleOrNull() else rtn // ① } finally { record(System.nanoTime() - t0) } } } → ① 마커 처리 · ② Publisher 반환 · ③ Continuation 교체 · ④ Job 제거
  39. 에러와 취소는 어떻게 건너가나 에러 취소 코루틴에서 throw Mono.error로 나간다

    정지 지점에서 다시 던진다 mono { throw e } subscriber.onError(e) resumeWithException(e) 코루틴 취소 구독을 취소한다 업스트림까지 전파 job.cancel() invokeOnCancellation subscription.cancel() 반대 방향도 흐른다 — 구독이 dispose되면 mono{} 내부 코루틴이 CancellationException으로 끝난다 minusKey(Job)로 Job 링크를 끊어도 취소는 구독으로 흐른다 — 구조적 동시성 유지
  40. 쓸 수는 있지만, 보장은 없다 ▸ 공식 지원은 @Controller suspend

    핸들러와 프레임워크 자체 인터셉터(@Transactional 등)까지 사용자 @Around의 suspend 호출은 지원 대상이 아니다 — #31855는 invalid / not planned 로 클로즈 ▸ 동작은 하는데 계약이 없는 회색지대 Job이 빠지니까 여전히 불안정 —결국 AOP를 걷어내고 명시 호출로 돌리는 것 고려