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

백엔드 개발에 이제 코루틴은 필요 없을까?

백엔드 개발에 이제 코루틴은 필요 없을까?

Virtual Thread 와 Coroutine 을 이해하고, 두 기술을 어떻게 활용하는 것이 좋은지 이야기 합니다.
예시 코드를 Spring MVC → 동시성 → Coroutine → Virtual Thread → Virtual Thread + Coroutine 조합까지 단계별로 고쳐가며,
스레드가 실제로 어떻게 쓰이는지 그림과 코드로 따라갑니다.
(KotlinConf Extended 2026 South Korea)

Avatar for yglee8048

yglee8048

August 01, 2026

Other Decks in Programming

Transcript

  1. SPEAKER 연사 소개 이영규 @tigger 카카오뱅크 AI서버 홈, 수신 로그인,

    얼굴인증 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  2. FEATURED SESSION 백엔드 개발에 이제 코루틴은 필요 없을까? KOTLINCONF EXTENDED

    2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  3. AGENDA 목차 Kotlin Bank 홈 화면 개선하기 Spring MVC —

    비동기·병렬 처리 Coroutine — 구조적 동시성 Virtual Thread — 값싼 실행 Virtual Thread + Coroutine — 함께 쓰기 번외: StructuredTaskScope · non-blocking 환경의 Coroutine · 실전 팁 Conclusion — 최종 비교, 그래서 언제 무엇을 쓸까? KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  4. MISSION 의 홈 화면 서버 개발자가 되었다! 당신은 kotlin bank

    ☰ 💰 생활비 통장 1,234,000원 110-1234-5678 🐷 비상금 통장 567,000원 🏦 오픈뱅킹 89,000원 333-9876-5432 777-1111-2222 계좌 카드 목록 계좌마다 별칭 · 색상 · 아이콘 · 잔액을 보여준다. 잔액은 출처가 두 곳 당행 계좌는 코어뱅킹 서버에서, 오픈뱅킹 계좌는 오픈뱅킹 서버에서 따로 조회해야 한다. 접속 기록도 남긴다 홈에 들어온 기록을 유저 활동 DB 에 적재한다. (응답에는 쓰이지 않는다) + 계좌 추가하기 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  5. MISSION 당신은 의 홈 화면 서버 개발자가 되었다 ! 홈

    화면 한 번을 그리기 위해 네 군데를 다녀와야 한다 코어뱅킹 서버 계좌 목록, 잔액 조회 부가정보 DB 계좌 별칭, 색상, 아이콘 조회 오픈뱅킹 서버 오픈뱅킹 잔액 조회 유저 활동 DB 유저 접속 기록 적재 단, 계좌 목록이 있어야 별칭도 잔액도 조회할 수 있다 — 호출 간 의존 관계가 있다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? → 외부 서버 호출 → DB → 조회 외부 서버 호출 → DB 저장
  6. STEP 1 · SPRING MVC 이제 개발해보자 ! 평범한 blocking

    MVC — 위에서 아래로 읽힌다 @RestController class HomeItemController(private val homeItemService: HomeItemService) { @GetMapping("/api/v1/home/items") fun getHomeItems(userId: UserId): List<HomeItem> = homeItemService.getHomeItems(userId) } fun getHomeItems(userId: UserId): List<HomeItem> { // 1. 코어뱅킹에서 계좌 목록 조회 (외부 서버 호출) val accounts = coreBankAdapter.getAccounts(userId) if (accounts.isEmpty()) return emptyList() // 2. 부가정보 DB 에서 계좌 별칭·색상·아이콘 조회 val accountIds = accounts.map { it.accountId } val homeItemInfos = homeItemInfoRepository .getHomeItemInfos(accountIds).associateBy { it.accountId } KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  7. STEP 1 · SPRING MVC 이제 개발해보자 ! 조회하고, 적재하고,

    조립해서 반환하면 끝 // 3. 오픈뱅킹 서버에서 잔액 조회 (외부 서버 호출) val openBankIds = accounts.filter { it.isOpenBank() }.map { it.accountId } val openBankBalances = openBankingAdapter .getBalances(openBankIds).associateBy { it.accountId } // 4. 유저 활동 DB 에 접속 기록 적재 userLogRepository.saveEvent(userId, UserEvent.GET_HOME) // 5. 응답 조립 return accounts.map { account -> HomeItem( accountId = account.accountId, balance = account.balanceOrOpenBank(openBankBalances), alias = homeItemInfos[account.accountId]?.alias ?: ... ) } } KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 기본값,
  8. STEP 1 · SPRING MVC 일반적으로는 괜찮지만 ... 모든 호출이

    I/O 대기다 — CPU 는 거의 놀고 있다 코어뱅킹 서버 계좌 목록, 잔액 조회 → 외부 서버 호출 = IO 대기 300ms 부가정보 DB 별칭, 색상, 아이콘 조회 → DB 조회 = IO 대기 200ms 오픈뱅킹 서버 오픈뱅킹 잔액 조회 → 외부 서버 호출 = IO 대기 500ms 유저 활동 DB 접속 기록 적재 → DB 저장 = IO 대기 700ms http-nio-8080-exec-1 P 300 200 톰캣 스레드 하나가 1.84초 내내 점유 — 그중 CPU 를 쓴 시간은 거의 0 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 500 700
  9. STEP 2 · 계획 여기를 고치면 ...? 의존 그래프를 그려보면

    — 병렬로 쏠 곳과 비동기로 던질 곳이 보인다 뒤의 두 조회가 이 결과에 의존한다 병렬화 불가 // → val accounts = coreBankAdapter.getAccounts(userId) // → // 300ms val homeItemInfos = homeItemInfoRepository.getHomeItemInfos(ids) // 200ms val openBankBalances = openBankingAdapter.getBalances(obIds) // 500ms 서로 의존이 없다 병렬 // 응답에 쓰이지 않는다 → 비동기 (fire-and-forget) userLogRepository.saveEvent(userId, UserEvent.GET_HOME) return accounts.map { /* 병렬 // 700ms 조립 */ } 200ms + 500ms → max(200, 500) 부가정보와 오픈뱅킹은 서로를 기다릴 이유가 없다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 비동기 700ms → 0ms 접속 기록은 응답에 안 쓰인다 — 기다릴 이유가 없다
  10. STEP 2 · COMPLETABLEFUTURE + @ASYNC 동시성을 적용해보자 서로 의존

    없는 두 조회를 각자의 Executor 로 — supplyAsync 시점에 이미 실행 중이다 val accounts = coreBankAdapter.getAccounts(userId) // // 여긴 그대로 (의존 관계) 부가정보 DB 조회 (병렬) val homeCardInfoFuture = CompletableFuture.supplyAsync( { homeItemInfoRepository.getHomeItemInfos(accountIds) }, homeInfoTaskExecutor, ) // 오픈뱅킹 잔액 조회 (병렬) val openBankBalanceFuture = CompletableFuture.supplyAsync( { openBankingAdapter.getBalances(openBankAccountIds) }, openBankingTaskExecutor, ) KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  11. STEP 2 · COMPLETABLEFUTURE + @ASYNC 동시성을 적용해보자 응답에 안

    쓰이고 유실을 허용하는 작업은 로 best-effort 처리 @Async // 접속 기록 적재 — 응답을 기다리게 하지 않는다 userLogRepository.saveEventAsync(userId, UserEvent.GET_HOME) // 이미 실행 중이므로 각각 join() — 대기 시간은 max(200ms, 500ms) val homeItemInfos = homeCardInfoFuture.join().associateBy { it.accountId } val openBankBalances = openBankBalanceFuture.join().associateBy { it.accountId } return accounts.map { /* @Async(LOG_TASK_EXECUTOR) 조립 */ } // UserLogRepository fun saveEventAsync(userId: UserId, event: UserEvent) { saveEvent(userId, event) } // AsyncConfig — 하위 시스템마다 전용 풀. 크기의 근거는 "상대 시스템의 capacity" homeInfoTaskExecutor : core 10 / queue 50 / CallerRunsPolicy openBankingTaskExecutor : core 30 / queue 100 / AbortPolicy logTaskExecutor : core 5 / queue 500 / DiscardPolicy KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 커넥션 풀에 맞춤 // 상대 시스템 처리량 // 유실 허용 데이터만 // DB
  12. STEP 2 · 결과 응답시간이 개선되었다 ! 1.84s → 0.83s

    — 300 + max(200, 500) + 프레임워크 오버헤드 V1 순차 1.84s V2 동시성 http-nio-8080-exec-2 P join() 대기 500 getAccounts 300 home-info-1 P open-banking-1 P user-log-1 P curl 이 끝난 뒤에 [saveEvent] end 로그가 한 줄 늦게 찍힌다 — fire-and-forget 의 증거 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 200 500 700 — 응답이 나간 뒤에도 실행 중 →
  13. STEP 3 · 문제 thread local— thread 은 어떻게 되지

    ? local 하지만 문제가 하나 더 있다 // 을 전파해야 한다 요청 하나에 묶이는 호출 컨텍스트 — ThreadLocal 에 담아둔다 data class CallContext( val traceId: String, val channel: Channel, // 비즈니스 로직이 분기에 사용 val deviceId: String?, ### V2 — 톰캣 스레드를 벗어나는 순간 P[http-nio-8080-exec-2] [trace=TRACE-V2] [getAccounts] start P[home-info-1 ] [trace=none ] [getHomeItemInfos] ctx= P[open-banking-1 ] [trace=none ] [getBalances] ctx= P[user-log-1 ] [trace=none 없음 ] [saveEvent] ctx=없음 val userId: UserId?, traceId 로 grep 하면 앞 두 줄만 나온다 ) object CallContextHolder { private val holder = ThreadLocal<CallContext>() fun get(): CallContext? = holder.get() 정작 느린 오픈뱅킹 구간이 장애 조사에서 사라진다. 병렬화로 얻은 성능을 관측 가능성으로 지불한 것. ... 로그만의 문제가 아니다 } // + 로그의 traceId 는 MDC (이것도 ThreadLocal) KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? worker 스레드에서 CallContextHolder.get() == null — channel 로 분기하는 로직이 조용히 다르게 동작한다. 없음
  14. STEP 3 · THREADLOCAL 전파 thread local 전파하기 ① 요청

    진입 시 컨텍스트 세팅 @Component @Order(Ordered.HIGHEST_PRECEDENCE) class CallContextFilter : OncePerRequestFilter() { override fun doFilterInternal(request, response, filterChain) { val context = CallContext( traceId = request.getHeader("X-Trace-Id") ?: newTraceId(), channel = Channel.from(request.getHeader("X-Channel")), ... ) try { CallContextHolder.set(context) MDC.put("traceId", context.traceId) filterChain.doFilter(request, response) } finally { CallContextHolder.clear() MDC.clear() // 톰캣 스레드도 재사용된다 — 원복 필수 } } } KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  15. STEP 3 · THREADLOCAL 전파 thread local 전파하기 ② 캡처

    → 주입 → 원복 class CallContextTaskDecorator : TaskDecorator { override fun decorate(runnable: Runnable): Runnable { // 1. 캡처 — 여기는 "제출하는" 스레드 (톰캣) val capturedContext = CallContextHolder.get() val capturedMdc = MDC.getCopyOfContextMap() return Runnable { // 2. 주입 — 여기는 "실행하는" 스레드 (풀) val previousContext = CallContextHolder.get() val previousMdc = MDC.getCopyOfContextMap() try { CallContextHolder.set(capturedContext) applyMdc(capturedMdc) runnable.run() } finally { // 3. 원복 — 풀 스레드는 재사용된다. 안 지우면 다음 요청이 남의 traceId 를 본다 CallContextHolder.set(previousContext) applyMdc(previousMdc) } } } } KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  16. STEP 3 · THREADLOCAL 전파 thread local 전파하기 ③ executor

    에 한 줄 ThreadPoolTaskExecutor().apply { corePoolSize = 30 ... setTaskDecorator(CallContextTaskDecorator()) } ### V3 — MDC 와 커스텀 ThreadLocal 이 둘 다 따라간다 (@Async 포함) P[http-nio-8080-exec-4] [trace=TRACE-V3] [getAccounts] start ctx=MOBILE/TRACE-V3 P[home-info-v3-1 ] [trace=TRACE-V3] [getHomeItemInfos] start ctx=MOBILE/TRACE-V3 P[open-banking-v3-1 ] [trace=TRACE-V3] [getBalances] start ctx=MOBILE/TRACE-V3 P[user-log-v3-1 ] [trace=TRACE-V3] [saveEvent] start ctx=MOBILE/TRACE-V3 전파는 호출부가 아니라 실행 경계의 문제. 경계에서 한 번 해결하면 호출부는 몰라도 된다. 다만 전파 대상(MDC, CallContext, SecurityContext, ...)이 늘 때마다 이 decorator 를 고쳐야 한다 — 새로 생긴 부채 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  17. STEP 4 · 빌드업 응답 시간은 개선되었지만 ... 쓰레드 사용은

    오히려 늘어났다 — 점유시간 × 개수로 실질 사용을 따져보면 V1 순차 V2 동시성 응답은 빨라졌는데 자원은 더 쓴다 1,840 thread·ms 톰캣 830 같은 일을 여러 스레드에 나눠 담았을 뿐 — 대기 자체가 사라진 게 아니다. 500 로그 700 톰캣 스레드는 여전히 830ms 점유 join() 으로 결과를 기다리는 동안 붙잡혀 있다. 처리량은 좋아졌지만 요청 스레드 수가 다음 천장이 다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 2,230 thread·ms (스레드 개 4 ) 그럼 저 대기부터 없애면? 이미 딴 스레드가 하는 일을 join() 으로 앉아서 기다리는 것은 순수 낭비다 → STEP 4
  18. STEP 4 · DEFERREDRESULT 쓰레드 조금 더 효율적으로 사용하기 join()

    으로 앉아서 기다리던 시간을 없앤다 — 서비스가 값이 아니라 Future 를 반환한다 fun getHomeItemsV4(userId: UserId): CompletableFuture<List<HomeItem>> { // 코어뱅킹 조회는 blocking 그대로 — 뒤의 두 조회가 여기 의존하므로 어차피 기다려야 한다 val accounts = coreBankAdapter.getAccounts(userId) return composeRest(userId, accounts) } private fun composeRest(...): CompletableFuture<List<HomeItem>> { val homeCardInfoFuture = CompletableFuture.supplyAsync({ ... }, homeInfoTaskExecutor) val openBankBalanceFuture = CompletableFuture.supplyAsync({ ... }, openBankingTaskExecutor) userLogRepository.saveEventAsyncV3(userId, UserEvent.GET_HOME) // join() 대신 thenCombine — 병렬 결과를 앉아서 기다리는 스레드가 없다 return homeCardInfoFuture.thenCombine(openBankBalanceFuture) { infos, balances -> assemble(accounts, infos, balances) } } KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? // 조립 로직이 콜백 안으로 끌려 들어왔다
  19. STEP 4 · DEFERREDRESULT 쓰레드 조금 더 효율적으로 사용하기 코어뱅킹

    조회 후 DeferredResult 를 반환하고, 병렬 조회 결과는 기다리지 않는다 @GetMapping("/items") fun getHomeItems(userId: UserId): DeferredResult<List<HomeItem>> { val deferredResult = DeferredResult<List<HomeItem>>(3_000) homeItemServiceV4.getHomeItemsV4(userId).whenComplete { result, error -> if (error != null) deferredResult.setErrorResult(error) else deferredResult.setResult(result) } return deferredResult // } P[http-nio-8080-exec-9] [v4] 톰캣 스레드는 여기서 풀로 돌아간다 진입 24.928 P[http-nio-8080-exec-9] [getAccounts] start → end 24.929 → 25.232 P[home-info-v3-2 ] [getHomeItemInfos] start 25.237 ┐ P[open-banking-v3-2 ] [getBalances] start 25.247 ┘ P[http-nio-8080-exec-9] [v4] P[open-banking-v3-2 반환 (톰캣 스레드 반납) ] [getBalances] end → Future 25.249 ← 병렬 시작 ← 톰캣 스레드 (300ms) 점유 321ms 완료. 이후 Servlet async dispatch 로 응답 처리 얻은 것 — 톰캣 점유 830ms → 320ms: join() 으로 앉아서 기다리던 500ms 제거 · 잃은 것 — 콜백으로 뒤집힌 코드, CompletionException, 응답에만 걸리는 timeout 코어뱅킹까지 executor 로 넘기면 톰캣 점유를 ~0ms 로 줄일 수 있다. 대신 worker 점유·thread hop·코드 복잡도가 늘어난다 — 격리와 처리량을 위한 선택이다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  20. STEP 5 · COROUTINE 코루틴을 도입한다면 어떻게 될까? 스레드가 아니라

    일시중단 가능한 계산. 수십만 개를 띄워도 힙에 상태만 남는다. 대기 중에는 스레드를 놓아준다. suspend / await — 값을 꺼내는 것처럼 보이지만 스레드를 막지 않는다. 콜백 없이 위에서 아래로 읽히는 코드. 동시 작업의 부모-자식 관계가 코드 구조가 된다. 실패하면 형제가 취소되고, 블록을 나오면 자식이 하나도 안 남아 있음이 보장된다. STEP 2에서 코드가 답하지 못했던 질문들 — “이 두 작업은 한 묶음인가? 하나가 실패하면? 묶음 전체의 timeout 은?” 코루틴은 언어의 suspend 추상화와 kotlinx.coroutines의 구조화 API로 작업 관계를 코드에 드러낸다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  21. COROUTINE · INTERNALS 실행 계층 : coroutine 은 dispatcher 의

    platform worker 에 매핑되고, worker 는 OS 가 CPU 에 스케줄링한다 OS 영역 CPU 실제 연산 ↔ OS 스케줄러 kernel scheduler 커널 스레드 ↔ ↔ kernel-1 JVM / OS kernel-2 runtime boundary kernel-3 ↔ JVM · KOTLIN 영역 Coroutine Dispatcher platform worker worker-1 ↔ worker-2 ↔ C-1 C-2 C-3 C-4 C-5 C-6 C-7 C-8 C-9 Dispatchers.IO worker-3 platform worker 는 OS 의 kernel thread 와 1:1 로 연결되고, 많은 coroutine 이 dispatcher 의 worker 풀 위에 다중화된다. coroutine 이 worker 를 놓는 것은 suspension 지점에서다. 일반 blocking 호출은 worker 를 계속 점유한다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? coroutine
  22. STEP 5 · COROUTINE 코루틴을 도입해보자 병렬화할 blocking 호출은 처음부터

    Dispatchers.IO에서 — launch 로 던지고 async 로 실행한다 fun getHomeItemsV5(userId: UserId): List<HomeItem> { val accounts = coreBankAdapter.getAccounts(userId) // suspend // 아님! 그냥 blocking val (homeCardInfos, openBankBalances) = runBlocking(Dispatchers.IO) { // 접속 기록 적재 — 응답에 안 쓰이니 launch 로 던져두고 launch { userLogRepository.saveEvent(userId, UserEvent.GET_HOME) } // 서로 의존 없는 두 조회는 async 로 병렬 val homeCardInfoDeferred = async { homeItemInfoRepository.getHomeItemInfos(accountIds) } val openBankBalanceDeferred = async { openBankingAdapter.getBalances(openBankAccountIds) } homeCardInfoDeferred.await() to openBankBalanceDeferred.await() } return accounts.map { /* } 조립 — STEP 1과 같은 자리로 돌아왔다 */ } 자식들이 DefaultDispatcher-worker-N 으로 흩어져 세 작업이 동시에 돈다. 컨트롤러도, 반환 타입도, 호출자도 그대로 — Future의 콜백 없이 마지막 조립도 원래 자리로 돌아왔다. 그런데 응답 시간을 재보면... KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  23. STEP 5 · COROUTINE 어라 , 응답이 더 300 +

    max(200, 500, 700) — 응답에 쓰이지도 않는 접속 기록이 응답 시간을 지배한다 V2 Future 0.83s V5 지금 코드 1.02s P[tDispatcher-worker-5] [getBalances] end P[tDispatcher-worker-1] [saveEvent] end P[http-nio-8080-exec-5] [v5] 반환 응답 재료는 다 모였는데 46.380 ← 이걸 기다렸다가 46.380 ← 그제야 반환한다 46.181 ← 이게 구조적 동시성이다 — 스코프는 블록을 나가기 전에 모든 자식이 끝나기를 기다린다. 구조화된 스코프 안에서는 자식을 흘리고 먼저 반환할 수 없다. join() 을 빼먹으면 조용히 새는 CompletableFuture 와의 결정적 차이 — 버그가 아니라 약속이고, 이 약속 때문에 응답보다 오래 살아야 할 작업은 이 스코프의 자식이면 안 된다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  24. STEP 5 · STRUCTURED CONCURRENCY 구조적 동시성 : 작업도 스코프가

    부모가 되고, launch·async 로 시작한 작업은 그 안에서 태어난 자식이 된다 runBlocking · 요청 스코프 블록을 나가기 전에 모든 자식의 종료를 확인한다 launch { saveEvent() } async { getHomeItems() } 응답에는 안 쓰지만 여전히 요청의 자식 정상 종료 모든 자식이 끝나야 부모도 끝난다 async { getBalances() } 응답 재료를 만드는 병렬 조회 자식 실패 실패를 부모에 알리고 형제를 취소한다 응답 재료를 만드는 병렬 조회 부모 취소 살아 있는 모든 자식에게 취소가 전파된다 작업의 생명주기가 코드 블록의 경계와 맞물린다 — 응답보다 오래 살아야 한다면, 이 요청 스코프의 자식으로 만들면 안 된다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  25. STEP 5 · COROUTINE 응답보다 오래 살 작업은 부모를 갈아탄다

    요청 스코프의 자식이 아니라, 애플리케이션 수명에 묶인 별도 스코프로 - val (homeCardInfos, openBankBalances) = runBlocking(Dispatchers.IO) { - launch { userLogRepository.saveEvent(userId, UserEvent.GET_HOME) } + applicationCoroutineScope.launch { + // runBlocking 밖 userLogRepository.saveEvent(userId, UserEvent.GET_HOME) + } + val (homeCardInfos, openBankBalances) = runBlocking(Dispatchers.IO) { ... } P[tDispatcher-worker-3] [getBalances] end 27.681 P[http-nio-8080-exec-2] [v5] 27.682 반환 P[tDispatcher-worker-1] [saveEvent] end 즉시 반환. 1.02s → 0.85s 27.883 ← 응답이 나간 뒤에 끝난다 ← 스코프 밖으로 내보내는 작업이 코드에 그대로 보인다 — 실수로 새는 것과 의도적으로 내보내는 것이 구분된다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  26. STEP 5 · COROUTINE 빠른 취소까지 얻으려면 — 구조적 동시성은

    실패 시 형제를 취소한다. 그런데 blocking 호출은 취소를 알아채지 못한다 코루틴 취소는 협조적 — 다음 suspension point 에서야 CancellationException 이 던져진다. // JDBC·HTTP 같은 blocking 호출은 suspension point 가 없다: async { openBankingAdapter.getBalances(ids) } // 취소돼도 호출이 끝날 때까지 스레드는 일한다 // async { runInterruptible { openBankingAdapter.getBalances(ids) } } // // 취소 → 스레드 인터럽트 → 대상이 반응하면 중단 매번 쓰기 좋게 헬퍼로 — dispatcher 지정까지 겸하므로 최종 코드는 이걸로 통일한다 suspend fun <T> blockingIo(dispatcher: CoroutineDispatcher = Dispatchers.IO, block: () -> T): T = runInterruptible(dispatcher, block = block) async { blockingIo { openBankingAdapter.getBalances(openBankAccountIds) } } 취소는 공짜가 아니다 — 취소 가능성은 하위 계층까지 이어져야 하는 계약이다. runInterruptible 은 인터럽트를 보낼 뿐이다. 그래서 실전의 blocking 호출은 — 정말 끊길까? → KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  27. STEP 5 · 솔직한 이야기 실전의 blocking 은 인터럽트로 끊길까

    ? 실패 시점은 같아도, I/O 방식에 따라 취소가 반영되는 시점이 달라진다 I/O 시작 전 실패 작업 자식 다른 자식 blocking I/O 진행 중 작업 자식 다른 자식 모든 방식 JDBC · RestClient non-blocking I/O 진행 중 WebClient · R2DBC 작업 자식 다른 자식 상대 서버·DB I/O × ↓ interrupt blocking I/O × I/O 대기 × 원격 작업은 계속될 수 있음 ··· 호출 예정 즉시 중단 I/O를 시작하지 않는다 I/O 종료 후 중단 interrupt를 무시하는 구현 구독 즉시 취소 원격 작업 중단은 별도 계약 코루틴 취소 ≠ 원격 작업 중단. JDBC·RestClient 는 드라이버와 클라이언트의 interrupt 지원에 의존하고, WebClient·R2DBC 도 서버·쿼리 timeout 은 여전히 필요하다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  28. STEP 5 · COROUTINE 뭐가 좋아진 걸까 — STEP 3

    과 비교 둘 다 병렬이고 톰캣 점유도 같다 — 이제 코드 모양뿐 아니라 작업 수명·실패 의미론을 비교한다 STEP 3 (FUTURE) 응답 · 톰캣 점유 STEP 5 (COROUTINE) 0.85s · 828ms — 같다 병렬 구간 코드 0.83s · 830ms supplyAsync + executor + join 실행 자원 (데모 구 성) 전용 풀 3개 공유 IO dispatcher 실패 시 형제 취소 자동 아님 구조화된 스코프가 취소 예외 CompletionException 원래 예외 그대로 작업 수명 join 누락 시 방치된 작업 지속 구조화된 스코프는 이탈 불 가 async + await ### ?failFast=true — 오픈뱅킹을 50ms 만에 실패시키면 CompletableFuture (STEP 2~4) P[open-banking-v3-1] [getBalances] P[home-info-v3-1 의도된 실패 ] [getHomeItemInfos] end ← 끝까지 돈다 Coroutine (STEP 5) 의도된 실패 P[worker-1] [getHomeItemInfos] 취소됨(인터럽트) ← 1ms 만에 P[worker-3] [getBalances] 데모 어댑터는 sleep 기반이라 인터럽트에 즉시 반응한다 — 실제 드라이버는 앞 장 참고 이미 실패한 요청을 위해 일하지 않는다 형제가 취소되고 DB 커넥션도 일찍 놓는다. 응답도 0.52s → 0.37s. 스레드 효율은 아직 STEP 4에 진다 점유 828ms vs 320ms — runBlocking 이 막고 있다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  29. 잠깐 · 번외 Executor.asCoroutineDispatcher 코루틴을 쓴다고 경량 쓰레드가 생기는 게

    아니다 // 기존 스레드 풀을 dispatcher 로 쓸 수 있다 val dispatcher = openBankingTaskExecutor.asCoroutineDispatcher() val balances = withContext(dispatcher) { openBankingAdapter.getBalances(ids) // 그 풀의 스레드 30개 위에서 돈다 } 바뀌는 것 코루틴 문법으로 그 풀에 작업을 태울 수 있다. 구조적 동시성·취소는 그대로 얻는다. 안 바뀌는 것 실행은 여전히 그 스레드 풀이다. 풀이 30개면 동시 실행도 30개 — 경량이 아니다. 코루틴 = 경량 쓰레드가 아니라, “무엇 위에서 실행할지(dispatcher)와 분리된 계산”. 어디서 도는지는 dispatcher 가 결정한다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  30. 잠깐 · 번외 parallelism — blocking 구간의 동시 실행 제한

    // 기존 스레드 풀의 "동시 실행 30개 제한"을 코루틴 식으로 쓰면 val openBankingDispatcher = Dispatchers.IO.limitedParallelism(30) 번째 blocking 작업은? — 실행 슬롯이 날 때까지 dispatcher 내부에서 대기 // 31 BLOCKING 작업이 몰릴 때 넘치는 작업 THREAD POOL 정책 CallerRuns — 호출 스레드가 대신 실행 (상한이 샌다) Abort — 예외로 거절 (버린다) Discard — 조용히 버린다 COROUTINE (LIMITEDPARALLELISM) 실행 슬롯까지 대기 대기 중 스레드는 잡지 않지만 대기 코루틴은 계속 쌓일 수 있다 제한은 suspension 사이의 실행 구간에 적용된다. suspending 호출 전체의 동시 요청 수는 Semaphore 로 제한하고, 과부하에는 timeout·bounded admission 이 별도로 필요하다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  31. STEP 6 · SUSPEND CONTROLLER 톰캣 쓰레드를 좀 더 효율적으로

    쓰려면 ..? runBlocking 을 걷어내고 경계를 컨트롤러 밖으로 — 코어뱅킹 blocking 은 그대로 - fun getHomeItemsV5(userId: UserId): List<HomeItem> { + suspend fun getHomeItemsV6(userId: UserId): List<HomeItem> = coroutineScope { val accounts = coreBankAdapter.getAccounts(userId) - val (infos, balances) = runBlocking { ... } + val homeCardInfoDeferred = async { ... } // 처럼 톰캣 스레드에서 blocking // STEP 4 블록이 그대로 펼쳐진다 @GetMapping("/items") suspend fun getHomeItems(userId: UserId): List<HomeItem> = // 컨트롤러는 suspend 한 단어 homeItemServiceV6.getHomeItemsV6(userId) P[http-nio-8080-exec-2] [v6] 진입 P[http-nio-8080-exec-2] [getAccounts] start 첫 suspension P[tDispatcher-worker-1] [v6] 반환 (진입과 다른 스레드다) P[http-nio-8080-exec-2] [getAccounts] end → 05.887 톰캣 스레드 06.207 ← 여기서 톰캣 반납. 점유 320ms 05.888 ← 06.701 코어뱅킹 300ms 는 STEP 4와 같이 톰캣 스레드에서 실행 — 그 뒤 첫 suspension 에서 톰캣 스레드를 반납한다. 점유 조건을 같게 맞춰도 코드는 위에서 아래로 읽히고, HTTP 응답 타입 은 STEP 1과 같다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  32. STEP 6 · SUSPEND CONTROLLER 이제 STEP 4 와 비교해보자

    둘 다 톰캣 스레드를 반납한다 — 같은 목표를 어떻게 다르게 이뤘나 STEP 4 — DEFERREDRESULT STEP 6 — SUSPEND 컨트롤러 반환 타입 DeferredResult<List<HomeItem>> List<HomeItem> 구조적 동시성 없음 — 형제 취소·묶음 수명 표현 불가 있음 예외 CompletionException 으로 감싸짐 전용 풀 3개 원래 예외 그대로 톰캣 스레드 점유 코드가 위에서 아래로 실행 자원 (데모 구성) 320ms (코어뱅킹 blocking) X — thenCompose / thenCombine 콜백 320ms (코어뱅킹 blocking) O — STEP 1과 같은 모양 공유 IO dispatcher 비동기가 되었는데 반환 타입과 순차적인 코드 모양은 STEP 1과 같다. 다만 suspend 호출 경계는 위로 전파된다 — "간결한 코드"와 "스레드 반납"은 서로 다른 축이고, 코루틴은 둘 다 가져간다. 하위 시스템 격리·상한은 Future 든 Coroutine 이든 별도 설계다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  33. STEP 7 · CONTEXT PROPAGATION thread local, 코루틴에서는 ? —

    일단 손으로 worker ctx= — element 로 넘어가면 // 없음 전파할 타입마다 를 만들어 코루틴 시작점마다 끼워넣으면 된다 전파할 ThreadLocal 타입마다 element 하나 — MDC 용은 MDCContext 가 이미 있다 runBlocking(MDCContext() + CallContextElement()) { ... } // 코루틴 시작점마다 + 로 applicationCoroutineScope.launch(MDCContext() + CallContextElement()) { ... } class CallContextElement( private val callContext: CallContext? = CallContextHolder.get(), // 캡처 ) : ThreadContextElement<CallContext?> { override fun updateThreadContext(ctx: CoroutineContext): CallContext? = CallContextHolder.get().also { CallContextHolder.set(callContext) } // 주입 — 재개될 때마다 override fun restoreThreadContext(ctx: CoroutineContext, old: CallContext?) = CallContextHolder.set(old) // 원복 — 중단될 때마다 } STEP 3 decorator 와 같은 캡처 → 주입 → 원복 — 코루틴은 스레드를 갈아타므로, element 가 재개/중단마다 대신 해준다. 하지만 전파할 타입마다 element ×, 코루틴 시작점마다 + — STEP 3의 부채가 모양만 바꿔 돌아왔다. 선언 하나로 안 될까? → KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  34. STEP 7 · 자동 전파 설정 spring boot, Spring4 에서

    thread local 전파하기 먼저 전파할 값을 등록하고 // 이 컨텍스트 경계를 넘겨주도록 설정한다 ① 값을 읽고 · 주입하고 · 지우는 방법을 등록 class CallContextThreadLocalAccessor : ThreadLocalAccessor<CallContext> { ② 자동 전파 활성화 프로퍼티 대신 직접 활성화 Hooks.enableAutomaticContextPropagation() ContextRegistry.getInstance() .registerThreadLocalAccessor( CallContextThreadLocalAccessor(), ) .registerThreadLocalAccessor( MdcThreadLocalAccessor(), ) override fun key(): Any = "callContext" override fun getValue(): CallContext? = CallContextHolder.get() override fun setValue(value: CallContext) = CallContextHolder.set(value) override fun setValue() = CallContextHolder.clear() } } 설정은 애플리케이션 경계에 한 번 — 서비스 코드에서는 전파 대상도, 전파 시점도 직접 다루지 않는다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 등록 // + accessor @PostConstruct fun enableAutomaticContextPropagation() { // Boot 4:
  35. STEP 7 · 자동 전파 결과 직접 주입하지 않아도 전파된다

    도 도 없지만, 모든 작업에서 같은 컨텍스트가 보인다 MDCContext() CallContextElement() 직접 주입 없음 톰캣 스레드에서 blocking suspend fun getHomeItemsV7(userId: UserId) = coroutineScope { // val accounts = coreBankAdapter.getAccounts(userId) // val homeCardInfoDeferred = async { blockingIo { homeItemInfoRepository.getHomeItemInfos(accountIds) } } val openBankBalanceDeferred = async { blockingIo { openBankingAdapter.getBalances(openBankAccountIds) } } /* await } 후 응답 조립 */ ### MDCContext() / CallContextElement() 없이 호출 P[http-nio-8080-exec-2] [trace=TR-V7] [getAccounts] start ctx=MOBILE/TR-V7 P[tDispatcher-worker-3] [trace=TR-V7] [getHomeItemInfos] start ctx=MOBILE/TR-V7 P[tDispatcher-worker-2] [trace=TR-V7] [getBalances] start ctx=MOBILE/TR-V7 각 자식에 컨텍스트를 직접 넣지 않아도 부모의 ThreadLocal 값이 재개 시점마다 자동 복원된다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  36. STEP 7 · CONTEXT PROPAGATION 다만 , 자동 전파는 자식

    코루틴까지만 완전 자동이 아니다 — 프레임워크가 코루틴의 진입점을 알아야 붙는다 // suspend 컨트롤러에서 시작된 코루틴 트리 → 자식까지 자동 전파 ✓ suspend fun getHomeItems(...) = coroutineScope { async { ... } launch { ... } 자식 — PropagationContextElement 를 물려받는다 // ✓ 자식 // ✓ } 트리를 벗어나면 전파도 벗어난다 → 앞에서 본 element 로 수동 전파 ✗ applicationCoroutineScope.launch { ... } // ✗ STEP 5와 동일 — 부모가 달라 ctx=없음 // applicationCoroutineScope.launch( MDCContext() + CallContextElement(), ) { ... } // ✓ 손으로 얹으면 된다 원리: Spring MVC 가 suspend 컨트롤러를 호출할 때 CoroutineContext 에 PropagationContextElement 를 얹고, 코루틴이 재개될 때마다 accessor 로 ThreadLocal 을 복원한다. 방금 손으로 만든 element 와 같은 메커니즘이다. dispatcher 가 아니라 재개 시점에 걸린다 — 그래서 뒤에서 dispatcher 를 가상 스레드로 갈아치워도 그대로 동작한다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  37. VIRTUAL THREAD 그리고 등장하는 JDK 21+ — 스레드를 OS 가

    아니라 JVM 이 스케줄링한다 플랫폼 스레드 가상 스레드 힙에 저장, 필요에 따라 증가 생성 비용 native stack (보통 MB 단위 예약) 비쌈 → 그래서 풀링 실용적 개수 보통 수천 단위 훨씬 많음 — 메모리·외부 자원 의존 I/O 대기 중 OS 스레드 점유 지원되는 blocking I/O 는 unmount 스택 쌈 → 풀링하지 않는다 blocking 코드를 그대로 두고, 대부분의 I/O 대기에서 캐리어 점유 비용을 크게 줄인다. 지연시간은 그대로이고 native·foreign 호출은 pinning 가능 — 그래도 “값싼 실행”을 런타임이 해결했다. 그럼 코루틴은 이제 필요 없나? KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  38. VIRTUAL THREAD · INTERNALS 실행 계층 : virtual thread 는

    carrier 에 매핑되고, carrier 는 OS 가 CPU 에 스케줄링한다 OS 영역 CPU 실제 연산 ↔ OS 스케줄러 kernel scheduler 커널 스레드 ↔ ↔ kernel-1 JVM / OS kernel-2 runtime boundary kernel-3 ↔ JVM 영역 JVM 스케줄러 carrier thread carrier-1 ↔ KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? carrier-2 ↔ VT-1 VT-2 VT-3 VT-4 VT-5 VT-6 VT-7 VT-8 VT-9 ForkJoinPool carrier 는 OS 의 kernel thread 와 1:1 로 연결되지만, 더 많은 virtual thread 가 소수의 carrier 위에 다중화된다. I/O 대기로 하나가 unmount 되면 빈 carrier 에 다른 virtual thread 가 mount 된다. virtual thread carrier-3
  39. VIRTUAL THREAD · PARK / UNMOUNT suspend 가 없는데, 언제

    멈출까? Virtual Thread 는 키워드가 아니라 Virtual Thread 를 지원하는 JDK blocking 경로와 JVM 의 협력으로 중단된다 Coroutine suspend RUNNING virtual thread mounted on carrier Virtual Thread 호출 → continuation 중단 → 지원되는 blocking 지점 Socket.read() Thread.sleep() LockSupport.park() → JDK blocking park + unmount 보존 반납 continuation carrier 경로 → park / unmount → JVM 이 모든 blocking 을 사후에 “감지”하는 것이 아니다. 해당 JDK API 구현이 virtual thread 를 park 하는 경로로 협력한다. READY → REMOUNT poller / timer / permit → unpark → scheduler CPU loop 는 자동으로 멈추지 않고, native·foreign 함수 호출 중에는 carrier 에 pin 될 수 있다. (JDK 24+ 에서 synchronized pinning 은 JEP 491로 해소) KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  40. STEP 8 · VIRTUAL THREAD 다시 , STEP 1 코드로

    돌아가자 가상 스레드에서 돌릴 코드다 — STEP 1에서 봤던 그대로, 고칠 곳을 찾지 말고 보시라 오픈뱅킹 서버에서 잔액 조회 (외부 서버 호출) @RestController // 3. class HomeItemController(private val homeItemService: HomeItemService val openBankIds = accounts.filter { it.isOpenBank() }.map { it.ac @GetMapping("/api/v1/home/items") val openBankBalances = openBankingAdapter fun getHomeItems(userId: UserId): List<HomeItem> = .getBalances(openBankIds).associateBy { it.accountId } homeItemService.getHomeItems(userId) } // 4. 유저 활동 DB 에 접속 기록 적재 userLogRepository.saveEvent(userId, UserEvent.GET_HOME) // 5. fun getHomeItems(userId: UserId): List<HomeItem> { // 1. 코어뱅킹에서 계좌 목록 조회 (외부 서버 호출) return accounts.map { account -> HomeItem( val accounts = coreBankAdapter.getAccounts(userId) accountId = account.accountId, if (accounts.isEmpty()) return emptyList() // 2. 부가정보 DB 에서 계좌 별칭·색상·아이콘 조회 ) val homeItemInfos = homeItemInfoRepository KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? balance = account.balanceOrOpenBank(openBankBalances), alias = homeItemInfos[account.accountId]?.alias ?: ... val accountIds = accounts.map { it.accountId } .getHomeItemInfos(accountIds).associateBy { it.accountId } 응답 조립 } } 기본
  41. STEP 8 · VIRTUAL THREAD virtual thread 도입하기 — yaml

    방금 그 코드는 한 글자도 손대지 않는다 바꾸는 건 # application-vt.yaml — 네 줄이 전부다 이게 전부다 spring: threads: virtual: enabled: true ### 같은 /api/v1 — 바뀐 것은 로그 첫 글자 P → V 와 스레드 이름뿐. 응답 시간은 그대로 1.84s V[tomcat-handler-0 ] [getAccounts] start V[tomcat-handler-0 ] [getHomeItemInfos] start V[tomcat-handler-0 ] [getBalances] start V[tomcat-handler-0 ] [saveEvent] start 지연은 안 줄어든다 I/O 대기 1.7초는 여전히 1.7초. 바뀐 건 그동안 무엇을 붙잡고 있느냐다. 요청 스레드 풀 크기 제약이 사라진다 초당 1000 요청 × 1.84s ≈ 동시 1,840개 요청. 가상 스레드에선 1,840개가 그냥 존재한다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? Boot 가 바꿔주는 건 자기 것뿐 직접 만든 ThreadPoolTaskExecutor 는 그대로 P. “켰는데 왜 그대로죠?”의 90%가 이것.
  42. 동작 원리 스레드는 실제로 어떻게 쓰이고 있나 ① Spring MVC

    — 요청 하나가 플랫폼 스레드 하나를 응답까지 통째로 점유 http-nio-exec-1 P ─────────── I/O 대기 포함 1.8s 내내 점유 ─────────── ② Coroutine (suspend) — 코어뱅킹까지는 톰캣, 이후 병렬 조회는 dispatcher http-nio-exec-1 P getAccounts 300ms 병렬 조회 Dispatchers.IO worker P ③ Virtual Thread — 코드는 ①인데, 지원되는 I/O 대기에서 캐리어를 반납한다 tomcat-handler-0 V carrier (ForkJoinPool) P 반납 — 다른 요청 처리 unmount (힙에서 대기) unmount ... 그동안 캐리어는 다른 VT 실행 다른 VT 들 다른 VT 들 ②는 코드(언어)가 스레드를 놓아주고, ③은 런타임(JVM)이 놓아준다 — 결과는 닮았지만 층이 다르다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  43. STEP 9 · VT + COROUTINE 이번엔 STEP 6 코드를

    그대로 올려보자 vt 프로파일에서 다시 돌린다 — 고친 곳은 한 군데도 없고, 병렬을 만드는 건 여전히 이 세 줄이다 suspend fun getHomeItemsV6(...): List<HomeItem> = coroutineScope { val accounts = coreBankAdapter.getAccounts(userId) // 진입 스레드에서 그대로 blocking applicationCoroutineScope.launch { blockingIo { userLogRepository.saveEvent(...) } } val homeCardInfoDeferred = async { blockingIo { homeItemInfoRepository.getHomeItemInfos(...) } } val openBankBalanceDeferred = async { blockingIo { openBankingAdapter.getBalances(...) } } homeCardInfoDeferred.await(); openBankBalanceDeferred.await() ... } 고친 줄: 0 — 톰캣이 가상 스레드로 바뀐 것을 이 코드는 모른다. 단 blockingIo 안쪽은 여전히 Dispatchers.IO — 플랫폼 스레드다. 다음 장 로그에서 P[tDispatcher-worker-N] 으로 찍히는 자리가 여기다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  44. STEP 9 · VT + COROUTINE 그럼 코루틴은 이제 필요

    없나 ? 방금 그 코드를 vt 프로파일에서 돌린 결과 — 그리고 가상 스레드가 주지 않는 것들 필요한 것 가상 스레드가 주는가 값싼 실행 O X — 직접 짜야 함 X X X X — 별도 bridge 필요 병렬 실행 부모-자식 관계 실패 시 형제 취소 묶음 timeout 자식 작업으로 컨텍스트 전달 v1 은 VT 위에서도 순차 실행 1.84초다. 동시에 호출하려면 여전히 무언가가 필요하고, 그 순간 STEP 2의 질문들이 전부 되살아난다. (같은 요청 VT 안의 ThreadLocal 은 유지된다 — fan-out 하면 별도 전파) KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? ### vt 프로파일 + v6 (suspend) — 0.86s ] [v6] 진입 V[tomcat-handler-4 V[tomcat-handler-4 ] [getAccounts] start P[tDispatcher-worker-2] [getHomeItemInfos] start P[tDispatcher-worker-5] [getBalances] start P[tDispatcher-worker-5] [v6] 반환 양쪽 이득이 그대로 합쳐진다 코어뱅킹은 톰캣 가상 스레드에서 실행하고, 이후는 suspend 로 반납·구조적 동시성도 그대로. 그런데 dispatcher 가 아직 P 다 Dispatchers.IO 기본 한도 = max(64, CPU 코어 수). 톰캣의 천장은 없앴는데 바로 뒤에 다른 천장이 있었다. 기본 한도를 넘는 blocking 작업은 여기서 대기한다.
  45. STEP 9 · 동작 원리 캐리어 스레드와 디스패처는 어떻게 맞물리나

    가상 스레드의 세계 — 톰캣 워커 (spring.threads.virtual.enabled) tomcat-handler-4 V getAccounts 300ms suspend — 병렬 조회 동안 반납 기본 ≈ CPU I/O 대기 중 unmount 다른 가상 스레드들을 실행 carrier pool P 플랫폼 스레드의 세계 — 코루틴 dispatcher (blockingIo → Dispatchers.IO) getBalances 500 tDispatcher-worker-1 P getHomeItemInfos 200 tDispatcher-worker-2 P 요청의 수명은 코루틴이 관리 suspend → 재개를 오가며 취소·실패·timeout 이 구조적 동시성 트리를 타고 전파된다. 실행 비용은 각 세계가 지불 코어뱅킹은 톰캣 VT, 이후 병렬 blocking 호출은 IO 풀의 플랫폼 스레드가 담당. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 완료 → MVC async dispatch 병목은 가장 낮은 천장으로 Dispatchers.IO 의 기본 max(64, CPU) 한도가 남은 천장. → STEP 10에서 이 플랫폼 dispatcher 를 갈아치 운다.
  46. STEP 10 · VT DISPATCHER virtual thread dispatcher dispatcher —

    blocking 플랫폼 의 기본 한도를 없앤다 작업마다 가상 스레드를 만든다 @Bean(name = [VIRTUAL_THREAD_DISPATCHER], destroyMethod = "close") fun virtualThreadDispatcher(): ExecutorCoroutineDispatcher { val threadFactory = Thread.ofVirtual().name("vt-dispatch-", 0).factory() return Executors.newThreadPerTaskExecutor(threadFactory) .asCoroutineDispatcher() // Dispatchers.LOOM 은 아직 없다 — 직접 만든다 } // 서비스는 dispatcher 인자 하나만 바뀐다 기본 max(64, CPU)) blockingIo { ... } // STEP 7: Dispatchers.IO ( blockingIo(virtualThreadDispatcher) { ... } // STEP 10: V[tomcat-handler-4] [trace=TR-11] [getAccounts] start V[vt-dispatch-1] [trace=TR-11] [getHomeItemInfos] start 코어뱅킹은 톰캣 ← 전파도 그대로 ← V[vt-dispatch-2] [trace=TR-11] [getBalances] start KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 가상 스레드
  47. STEP 10 · 동작 원리 STEP 9와 뭐가 다른가 STEP

    9 — 두 세계가 공존: 톰캣은 V, blocking 호출은 P (기본 max(64, CPU)) tomcat-handler V Dispatchers.IO P × 기본 한도 STEP 10 — 전부 가상 스레드: 고정된 executor 풀 크기 한도가 없다 tomcat-handler V vt-dispatch-N V ×many carrier pool P 기본 ≈ CPU suspend blocking 호출 — 기본 한도 초과부터 대기 suspend 지원되는 I/O 대기 = unmount scheduler parallelism 기본값은 CPU 코어 수, JVM 옵션으로 조정 가능 응답 시간은 똑같이 0.83s — 동시성이 낮을 때는 아무 이득이 없다. 차이는 동시 요청 수백 개부터. 그리고 가상 스레드여도 취소는 저절로 안 된다 — 취소 → 인터럽트 → 대상이 반응하면 중단, runInterruptible 사슬은 똑같이 필요하다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  48. STEP 11 · 문제 너무 값싸서 생기는 문제 스레드 풀은

    사실 두 가지 일을 겸업하고 있었다 실행 자원 재사용 스레드가 비싸니 만들어두고 돌려쓴다. → 가상 스레드가 없앤 역할. 잘 가라. 동시성 제한 풀 크기 30 = 오픈뱅킹 동시 호출 30 상한. → 풀과 함께 사라져버린 역할. 문제는 여기. 요청이 몰리면 오픈뱅킹에 동시 호출 를 그대로 날릴 수 있다 — 상한을 걸어둔 STEP 8 이전보다 위험 DB 커넥션 풀은 그대로 10개 — 가상 스레드 10,000개가 커넥션 10개를 기다리며 문제가 더 늦게 드러난다 bounded queue 가 없으면 대기 중인 가상 스레드만 쌓인다 — 과부하 신호와 admission control 을 따로 만들어야 한다 가상 스레드는 실행 자원 재사용만 없앤다. 동시성 제한은 따로 챙겨야 한다 → STEP 11 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  49. STEP 11 · RESILIENCE Spring Framework 7 의 Spring Framework

    7 — org.springframework.resilience 외부 라이브러리 없이 동시성 제한을 실행 자원에서 분리한다 STEP 2 — THREAD POOL 로 제한 상한 초과 시 새거나(CallerRuns) 버리거나(Abort) STEP 11 — @CONCURRENCYLIMIT 기다리는 비용 호출 스레드(톰캣)를 태운다 concurrency throttle — VT 위에선 캐리어 반납 상한의 위치 실행 자원(풀 크기)에 얹혀 있음 보호할 메서드에 선언 virtual thread 와 함께일 때 가장 효과적 BLOCK 대기는 carrier 를 반납하지만 waiter 는 쌓인다. 과부하에는 timeout·REJECT·load shedding 을 함께 둔다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 기다리거나(BLOCK) 거절하거나(REJECT) 동시에, 더 중요해지는 것들 고정 풀 상한이 사라지는 순간 — 외부 호출의 timeout, DB 커넥션 풀 크기가 마지막 방어선이 된다.
  50. STEP 11 · RESILIENCE 하위 시스템 상한 되찾기 — 실제

    구현 활성화 — 다른 bean 의 proxy 를 통해 호출해야 한다 @EnableResilientMethods // Spring AOP @ConcurrencyLimit(3) 시연용으로 작게. 동기 method invocation 상한 // fun getBalances(accountIds: List<AccountId>): List<OpenBankBalance> { ... } suspend fun getHomeItemsV11(...) = withTimeout(timeoutMillis) { 와 같은 구조 */ } // 묶음 전체 timeout coroutineScope { /* STEP 5 } 동시 8건 → 정확히 3개씩 웨이브 36.473 [guarded] 상한(3) 통과 ┐ 36.473 [guarded] 상한(3) 통과 │ 3개 36.473 [guarded] 상한(3) 통과 ┘ 36.975 [guarded] 상한(3) 통과 ┐ +502ms 36.975 [guarded] 상한(3) 통과 │ 3개 36.975 [guarded] 상한(3) 통과 ┘ 37.481 [guarded] 상한(3) 통과 ┐ +506ms ### 사슬이 끝까지 (sleep 기반 데모) V[vt-dispatch-4] [getHomeItemInfos] 취소됨(인터럽트) V[vt-dispatch-5] [getBalances] 취소됨(인터럽트) TimeoutCancellationException — end 는 하나도 없다 P[user-log-v7-4] [saveEvent] ← 스코프 밖. 의도대로 계속 실행 ### ?timeoutMillis=400 → withTimeout → 스코프 취소 → 자식 취소 → 인터럽트 → blocking 중단. 이 사슬이 가능한 건 STEP 5의 구조적 동시성 덕분 — 가상 스레드만으로는 취소를 전파할 부모-자식 관계가 없다. 가상 스레드(값싼 실행) + 구조적 동시성(수명·취소) + @ConcurrencyLimit(하위 시스템 보호) — 층이 달라서 서로를 대체하지 않는다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  51. STEP 12 · 번외 (PREVIEW) virtual thread 만으로 비동기 ·

    병렬 — StructuredTaskScope JDK 25 preview (JEP 505, 5 ) — JDK 26 JEP 525, 6 preview 데모 기준 차 // STEP 5 — Coroutine coroutineScope { val a = async { infoRepository.get(ids) } // val b = async { openBanking.get(ids) } launch { saveAuditLog() } // 에서는 차 결과 필요 결과 불필요 assemble(a.await(), b.await()) } // STEP 12 — StructuredTaskScope StructuredTaskScope.open( Joiner.awaitAllSuccessfulOrThrow(), { it.withTimeout(ofMillis(timeout)) }, ).use { scope -> val a = scope.fork(Callable { infoRepository.get(ids) }) // async val b = scope.fork(Callable { openBanking.get(ids) }) scope.fork(Runnable { saveAuditLog() }) // launch scope.join() assemble(a.get(), b.get()) } 결과도 거의 같다 — 부모-자식, 형제 취소, 묶음 timeout, 누수 불가 의도된 실패 V[virtual-122] [getHomeItemInfos] 취소됨(인터럽트) ← 형제 취소도 된다. 인정할 건 인정하자 ### V[virtual-123] [getBalances] async ↔ fork(Callable), coroutineScope의 launch ↔ fork(Runnable). Kotlin에서 fork { }는 Runnable으로 잡히므로 결과가 필요하면 Callable을 명시한다. STEP 5의 applicationCoroutineScope.launch처럼 스코프 밖으로 내보내는 작업은 직접 대응물이 없다 — StructuredTaskScope는 닫힐 때 모든 subtask를 기다린다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  52. STEP 12 · CONTEXT PROPAGATION StructuredTaskScope 에서 MDC 전파하기 bridge

    — owner , subtask 자동 는 없다 스레드에서 캡처하고 각 의 accessor 재사용 의 경계를 직접 감싼다 적용 // STEP 7 ### snapshot.wrap val snapshot = ContextSnapshotFactory.builder() V[virtual-72] [trace=VT-12] [getInfos] .build() .captureAll() // owner 스레드에서 캡처 StructuredTaskScope.open(...).use { scope -> val a = scope.fork( snapshot.wrap(Callable { infoRepository.get(ids) }), ctx=MOBILE/VT-12 V[virtual-73] [trace=VT-12] [getBalances] ctx=MOBILE/VT-12 왜 동작하나 이 실행 직전에 등록된 ThreadLocal을 설정하 고, 종료 시 이전 값으로 원복한다. wrap(Callable) ) val b = scope.fork( snapshot.wrap(Callable { openBanking.get(ids) }), ) scope.join() } KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? JDK 네이티브 방식은 ScopedValue ScopedValue binding은 subtask에 자동 상속된다. 다만 SLF4J MDC는 ThreadLocal이라, 그대로 쓰려면 여전히 bridge가 필요하 다.
  53. STEP 12 · 번외 (PREVIEW) 그래도 남는 차이 COROUTINE STRUCTUREDTASKSCOPE

    상태 정식 — 오늘 프로덕션에 컨트롤러 통합 suspend — 코어뱅킹 이후 요청 스레드 반납 accessor 로 자동 (STEP 7) runBlocking 하나로 시작 → 위로 확장 Flow, Channel preview (--enable-preview 필요) join() 동안 요청 VT 수명 유지 — carrier 는 반납 자동 아님 — snapshot.wrap 필요 플랫폼에서도 동작 · 확장성은 요청 VT 전제 컨텍스트 전파 점진 도입 스트림 ### snapshot.wrap 대응물 없음 없이 실행하면 진입 V[tomcat-handler-7] [v12] 없음 ← accessor가 자동 호출되지는 않는다 V[tomcat-handler-7] [v12] 반환 (같은 VT 의 수명 830ms — carrier 독점 아님) V[virtual-72 ] [trace=none] [getHomeItemInfos] start ctx= VT 가 “값싼 실행”을, STS 가 “구조화”를 가져갔다. STS 는 플랫폼 스레드에서도 동작하지만 요청 스레드를 붙잡는다. 그리고 여전히 preview — 오늘 프로덕션에 적용할 안정된 구조화 선택지로는 코루틴이 앞선다 KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  54. 실전 팁 ① Imperative 트랜잭션 + 비동기는 위험하다 JDBC +

    PlatformTransactionManager thread-bound — 는 @Transactional // 잔액 증가 + 이력 적재는 한 원자성 단위 fun chargeAsync(userId: UserId, amount: Long) { pointRepository.addBalance(userId, amount) 이력은 느리니까 비동기로 빼자" ← 함정 // " pointRepository.insertHistoryAsync(userId, amount) 적립 실패!") throw IllegalStateException(" 이번엔 로그가 아니라 데이터가 깨진다 적립은 롤백됐는데 이력만 남았다 같은 원자성 단위에 있어야 할 작업을 비동기로 빼면 그 순간 원자성이 깨진다. 부하 상황에서만 재현되는 읽기 경합·락 대기 변종은 더 고약하다. } [addBalance] 트랜잭션 활성=true [insertHistory] 트랜잭션 활성=false ← 딴 스레드 적립 실패로 롤백됨 최종 상태: {"balance": 0, "historyCount": 1} ★ 코루틴에서는 신호가 더 약하다 withContext(Dispatchers.IO) { insertHistory() } @Async 같은 시각적 신호조차 없이 스레드가 바뀐다. 규칙: imperative 트랜잭션 경계에서 스레드를 넘기지 않는다. 대안 커밋 이후로 미루기 — @TransactionalEventListener(AFTER_COMMIT) 유실이 안 되면 outbox. Reactive transaction 은 executeAndAwait. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  55. 실전 팁 ② CoroutineScope 는 Bean 으로 fire-and-forget — 유실을

    허용하는 용 스코프만 애플리케이션 수명에 묶어 하나 @Component class ApplicationCoroutineScope : CoroutineScope { private val supervisorJob = SupervisorJob() // ① private val exceptionHandler = CoroutineExceptionHandler { ctx, t -> log.warn(...) } // ② override val coroutineContext = supervisorJob + Dispatchers.IO + exceptionHandler @PreDestroy // ③ // 즉시 취소 후 종료를 기다린다 fun shutdown() = runBlocking { withTimeoutOrNull(5_000) { supervisorJob.cancelAndJoin() } } } ① SupervisorJob 독립 작업 하나의 실패가 다른 API 의 작업까지 취소시키지 않도록 격리한다. ② 스코프 단위 예외 처리 void @Async 의 uncaught handler 는 전역 설정. 여기서는 이 root launch 들의 예외 정책을 스코프에 둔다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? ③ @PreDestroy 취소 전파 서버 종료 시 실행 중인 작업도 취소한다. 완료 보장이 필요하면 graceful drain 이나 outbox 로.
  56. 여기까지 정리 스레드는 이렇게 흘러왔다 — 번외와 최종 비교는 뒤에

    1. MVC 순차 P 2~3. Future 병렬 P 톰캣 스레드가 1.84s 통째로 0.83s — 여전히 응답까지 점유 4. DeferredResult P 코어뱅킹 320ms 6~7. Coroutine P 코어뱅킹 320ms 톰캣 반납 — 조립은 콜백 안으로 suspend 로 반납 IO 기본 한도 blocking 그대로인데 unmount — 코드 0줄 수정 8. Virtual Thread V 코루틴 V 톰캣 VT 코어뱅킹 12. STS V preview 요청 VT 코어뱅킹 9~11. VT + 풀 3~4개 관리 suspend 반납 join 이 요청 VT 를 붙잡는다 — carrier 는 반납 Virtual Thread 는 코루틴을 대체한 것이 아니라, 코루틴이 필요했던 이유 중 하나 — 값싼 실행 — 를 제거했다. 남은 이유 — 수명·실패·취소의 구조화, 간결한 비동기 코드 — 는 그대로다. blocking MVC 에서도 코루틴은 여전히 유효하다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? 구조적 동시성 + 상한
  57. 번외 · NON-BLOCKING non-blocking 세계에서는 더 강력하다 WebFlux operator 도입

    장벽 중 하나였던 // WebFlux (Reactor) — operator 조합 코드를 코루틴이 순차형 코드로 바꾼다 체인 // WebFlux + Coroutine — fun getHomeItems(userId: UserId): Mono<List<HomeItem>> = 같은 non-blocking suspend fun getHomeItems(userId: UserId): List<HomeItem> = coreBank.getAccounts(userId) coroutineScope { .flatMap { accounts -> val accounts = coreBank.getAccounts(userId) Mono.zip( val infos // awaitBody = async { infoRepo.getInfos(accounts.ids()) } infoRepo.getInfos(accounts.ids()), val balances = async { openBanking.getBalances(accounts.obIds( openBanking.getBalances(accounts.obIds()), assemble(accounts, infos.await(), balances.await()) ).map { (infos, balances) -> assemble(accounts, infos, balances) } } .contextWrite { ... } // } // 스레드 hop 이 몇 번이든 위에서 아래로 읽힌다 앱 컨텍스트 예시 — tracing 은 자동화 가능 설정 후 context bridge 자동 Reactor Context ↔ CoroutineContext 는 ReactorContext element 로 연결 된다. MDC·tracing ThreadLocal 은 STEP 7의 hook·accessor 설정을 통해 재 개 시점에 복원된다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? Flow 로 스트리밍까지 SSE·WebSocket 같은 long-lived response 도 긴 Flux operator chain 대신 Flow 로 표현할 수 있다.
  58. 잠깐 · 번외 코루틴 없이도 — STEP 8의 yaml 은

    직접 만든 executor 를 안 바꿔준다 — 그럼 그 executor 를 가상 스레드로 선언하면 된다 의 ThreadPoolTaskExecutor 자리를 대체 같은 STEP 2 코드 — 로그 첫 글자만 바뀐다 // AsyncConfig — STEP 2 ### @Bean(OPEN_BANKING_TASK_EXECUTOR, destroyMethod = "close") P[tomcat-handler-2] [getAccounts] start fun openBankingTaskExecutor(): ExecutorService = V[open-banking-1] [getBalances] start Executors.newVirtualThreadPerTaskExecutor() // // 작업마다 VT 하나 호출부는 STEP 2 그대로 — 타입이 Executor 라서 val openBankBalanceFuture = CompletableFuture.supplyAsync( { openBankingAdapter.getBalances(openBankAccountIds) }, openBankingTaskExecutor, ) // ← 도는 스레드만 P → V 스레드 이름을 남기려면 newThreadPerTaskExecutor(ofVirtual().name(…)) — 데모 코드가 그 방식이다. 다만 순수 ExecutorService 로 갈아끼우면 STEP 3의 TaskDecorator 전파가 사라진다 — 전파를 유지하려면 SimpleAsyncTaskExecutor.setVirtualThreads(true) 쪽. 얻는 것 — 코루틴 없이도 풀 크기 튜닝 없이 동시 호출이 늘어난다. STEP 2~4 코드베이스도 executor 정의만 갈면 된다. 그대로 남는 것 / 조립, 구조적 동시성 없음, 예외는 CompletionException 으로 감싸짐. thenCompose join 가상 스레드는 실행 방식만 갈아끼운다 — 그런데 core 30 / AbortPolicy 로 걸어둔 동시 호출 상한도 같이 사라졌다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ? ← P V[home-info-1] [getHomeItemInfos] start 였던 자리
  59. FINAL COMPARISON 최종 비교 — VT 위에 실행은 모두 가상

    스레드다. 차이는 동시 작업의 관계를 어떻게 표현하고 관리하느냐에 있다 VT + COROUTINE VT dispatcher VT + COMPLETABLEFUTURE VT executor VT + STRUCTUREDTASKSCOPE JVM preview 작업 표현 async / await thenCombine / handle fork / join / get 구조적 동시성 있음 — 수명·실패·취소 전파 없음 — 직접 묶고 취소 컨텍스트 Spring accessor 설정 시 API 상태 정식 — 프로덕션 사용 잘 맞는 경우 Kotlin에서 작업 관계와 executor 경계에서 decorator·wrapper 정식 — 프로덕션 사용 기존 Future 코드를 최소 변경으로 VT에 올릴 때 기준 순차형 코드 자식에 자동 복원 간결한 코드를 함께 원할 때 콜백·조합 체인 blocking 형태 유지 있음 — 수명·실패·취소 전파 ScopedValue 상속 MDC는 bridge 필요 preview — 플래그 필요 preview를 감수하고 JVM API만 쓰고 싶을 때 셋 다 blocking 작업을 가상 스레드에서 실행할 뿐, DB·외부 시스템의 용량은 늘리지 않는다. 동시성 상한·timeout·과부하 제어는 어느 선택이든 별도다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  60. SPRING × KOTLIN 이제 Java + Spring 이 아니라 Java를

    버린다는 뜻이 아니다 — Kotlin이 단순한 호환 언어를 넘어 Spring의 first-class backend target이 되고 있다 2025.05 JetBrains × Spring 전략적 파트너십 목표는 Kotlin이 Spring 백엔드에서 더 자연스럽고 강력한 선택이 되도록 프레임워크·언어·도구를 함께 맞추는 것. 이미 들어온 변화 — Boot 4 / Framework 7 Kotlin 2.2 baseline — Spring 포트폴리오가 Kotlin 2 세대로 JSpecify null-safety — Spring·Reactor·Micrometer API가 Kotlin의 nullable/non-null 타입으로 보인다 Coroutine context 자동 전파 지원 — suspend 함수의 observability·tracing 연결 공식 가이드의 Kotlin 버전 — 주요 Spring 학습 경로도 Kotlin 코드로 다음 연결점 — 2026 탐색 로드맵 검토 중 · 확정 아님 Spring MVC 네이티브 coroutine reactive bridge 없는 suspend 지원 Virtual Thread dispatcher 지금 발표에서 직접 만든 연결을 프레임워크 쪽으로 Kotlin 런타임·DSL 개선 reflection 효율, inline value class, Kotlin다운 logging·설정 Spring 위에 Kotlin을 얹는 단계를 넘어, Spring과 Kotlin이 서로의 backend 모델에 맞춰지는 중이다. Sources · Spring Blog — Next level Kotlin support in Spring Boot 4 · JetBrains — Strategic Partnership With Spring KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  61. CONCLUSION 그래서 어떻게 하라고? 일반적인 경우 Spring MVC blocking IO

    가 많고 더 효율적일 필요가 있다 동시 호출의 수명·실패·취소를 구조화하고 싶다 요청·응답이지만 reactive 런타임이 요구된다 응답 자체가 스트림이다 — SSE · WebSocket · backpressure 만으로 충분 + Virtual Thread + Virtual Thread + Coroutines WebFlux + Coroutines + R2DBC + WebClient WebFlux + Coroutines + Kotlin Flow 동시 호출이 latency 를 줄이고, Virtual Thread 는 동시 요청의 플랫폼 스레드 비용을 낮춘다. 어느 층에 있든, Kotlin 프로덕션에서 작업의 수명과 실패를 코드에 표현해야 하는 순간 코루틴 은 여전히 강한 선택이다. KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  62. REFERENCE 더 큰 규모의 실제 사례가 궁금하다면 KotlinConf 2026 —

    for highly scalable, streaming, concurrent servers youtu.be/6D1yV5o4CWo 구조적 동시성이 대규모 스트리밍 서버에서 어떻게 유의미해지는지 — 오늘 본 홈 화면 하나의 이야기가 검색 규모로 확장된다 기술 근거 · 데모 기준 JDK 25 / Spring Boot 4.1 Spring Coroutines · docs.spring.io/spring-framework/reference/languages/kotlin/coroutines.html Spring Resilience · docs.spring.io/spring-framework/reference/core/resilience.html Kotlin Dispatchers.IO · kotlinlang.org/api/kotlinx.coroutines/.../Dispatchers/IO.html Virtual Threads · openjdk.org/jeps/444 · Pinning · openjdk.org/jeps/491 Structured Concurrency · openjdk.org/jeps/505 (JDK 25) · /jeps/525 (JDK 26) KOTLINCONF EXTENDED 2026 · 백엔드 개발에 이제 코루틴은 필요 없을까 ?
  63. 감사합니다 이영규 · @tigger · 카카오뱅크 KOTLINCONF EXTENDED 2026 ·

    백엔드 개발에 이제 코루틴은 필요 없을까 ?