새벽 2시 반, 슬랙 알림이 울렸다. "API 응답 지연 3초 초과." Kotlin + Spring Boot 서버였고, 코루틴을 "제대로" 도입한 지 2주째. 그라파나를 열어보니 힙 메모리가 톱니바퀴처럼 올라가다 GC 한 번에 뚝 떨어지는 패턴이 아니라, 그냥 일직선으로 우상향하고 있었다. 코루틴 자체의 문제가 아니었다. 쓰는 방식이 문제였다.
GlobalScope — 편하지만 누수의 시작점
서버 코드에서 GlobalScope.launch를 쓰는 순간 structured concurrency는 끝이다. 이 코루틴은 어떤 lifecycle에도 바인딩되지 않는다. HTTP 요청이 취소돼도, 컨트롤러가 응답을 리턴해도, 코루틴은 혼자 떠돌아다닌다.
// 전형적인 실수
@GetMapping("/send-email")
suspend fun sendEmail(@RequestParam userId: String) {
GlobalScope.launch {
emailService.send(userId) // 네트워크 타임아웃 30초
}
// 응답은 즉시 나가지만, 코루틴은 어딘가에서 살아 있다
}
트래픽이 적으면 문제가 안 보인다. 초당 수백 요청이 들어오는 환경에서 이메일 서비스가 느려지면? 완료 안 된 코루틴이 수천 개 누적된다. 메모리가 천천히 올라가고, 결국 OOM. 모니터링 안 하면 원인 파악에 시간이 오래 걸린다. 스레드 덤프엔 코루틴 상태가 안 나오니까.
대안은 간단하다. coroutineScope를 쓰면 요청 lifecycle에 묶인다.
@GetMapping("/send-email")
suspend fun sendEmail(@RequestParam userId: String) {
coroutineScope {
launch { emailService.send(userId) }
}
// 요청 취소 → 코루틴도 취소. 누수 없음.
}
비동기로 "발사 후 망각" 패턴이 진짜 필요하다면, 최소한 애플리케이션 레벨의 CoroutineScope를 Bean으로 만들어서 shutdown hook에 정리 로직을 넣어야 한다. GlobalScope는 그 "최소한"조차 없다.
Dispatchers.Default에 JDBC 태우면 벌어지는 일
이 실수는 놀라울 정도로 자주 본다. Dispatchers.Default의 스레드 풀 크기는 CPU 코어 수다. 8코어 서버라면 스레드 8개. 여기에 JDBC 호출이나 외부 HTTP 콜 같은 블로킹 작업을 올리면, 8개 요청이 동시에 들어오는 순간 전체 풀이 포화된다.
// CPU 디스패처에서 블로킹 IO를 돌리는 코드
suspend fun fetchUser(id: Long) = withContext(Dispatchers.Default) {
userRepository.findById(id) // JDBC — 블로킹
}
코루틴이 수만 개 떠 있어도 실제 실행할 스레드가 없으니 전부 대기 상태. 로그에는 아무 에러도 안 찍힌다. 그냥 느려질 뿐이다. CPU 바운드 작업마저 밀려나면서 서버 전체가 응답 불능에 빠진다.
블로킹 IO는 Dispatchers.IO에 태워야 한다. 아니면 R2DBC나 Ktor 클라이언트처럼 논블로킹 드라이버를 쓰거나. 선택지가 둘밖에 없다.
withContext(SupervisorJob()) ≠ supervisorScope
이건 미묘해서 코드 리뷰에서도 잘 안 잡힌다.
"자식 코루틴 하나 실패해도 나머지는 살리고 싶다" — 이 의도로 withContext(SupervisorJob())를 쓰는 코드를 종종 본다. 문제는, 새로 만든 SupervisorJob()이 부모 Job을 교체해버리면서 기존 structured concurrency 체인이 끊어진다는 점이다. 부모가 취소돼도 이 안의 코루틴은 취소 신호를 받지 못한다. 의도한 동작이 아닌 경우가 99%다.
원래 의도대로 하려면 supervisorScope { }를 쓰면 된다. 부모-자식 관계를 유지하면서, 자식 간의 실패 전파만 차단한다. 딱 한 글자 차이 같지만, 서버에서는 메모리 누수와 정상 동작의 차이다.
runBlocking은 suspend 안에서 쓰지 마라
suspend 함수 안에서 runBlocking을 호출하면 현재 스레드를 점유한다. 코루틴의 존재 이유를 정면으로 부정하는 코드다.
suspend fun processOrder(orderId: Long) {
val result = runBlocking { // 스레드 하나 잡아먹음
orderService.validate(orderId)
}
}
Dispatchers.Default 위에서 이게 돌면 제한된 스레드를 하나씩 빼앗긴다. 최악의 경우, 내부 코루틴이 같은 디스패처의 스레드를 기다리면서 데드락에 빠진다. 해결법은 단순하다 — runBlocking을 빼고 suspend 함수를 직접 호출하면 된다. runBlocking은 main() 함수나 테스트 코드에서만 써야 하는 진입점이지, 서버 로직에 들어갈 물건이 아니다.
IntelliJ 2026.1 코루틴 인스펙션
JetBrains가 3월에 IntelliJ IDEA 2026.1을 출시하면서 코루틴 전용 인스펙션 세트를 추가했다. 위에서 다룬 실수 중 일부를 IDE가 노란 줄로 잡아준다.
실무에서 특히 쓸모 있는 세 가지가 있다. 첫째, 암묵적 리시버 감지다. coroutineScope 안에서 collectLatest를 호출할 때 async가 어느 스코프에서 실행되는지 혼동하기 쉬운 상황을 잡아낸다. 놓치면 코루틴이 의도하지 않은 스코프에 매달려서 누수가 된다.
둘째는 awaitAll() 제안이다. list.map { it.await() }를 awaitAll()로 바꿔주는데, 단순 스타일 문제가 아니다. awaitAll()은 하나라도 실패하면 나머지를 즉시 취소한다. 순차적으로 await()를 호출하면 첫 번째가 실패해도 나머지가 계속 돌아가는데, 서버 리소스 낭비다.
셋째, Flow 처리 최적화 — filter { it != null }을 filterNotNull()로, map 다음 filterNotNull을 mapNotNull로 바꿔주는 제안. 가독성과 성능을 동시에 챙긴다.
코루틴 버그는 코드 리뷰에서 사람 눈으로 잡기 어렵다. 스레드와 달리 실행 흐름이 눈에 안 보이니까. IDE 인스펙션이 만능은 아니지만, GlobalScope 남용이나 디스패처 혼동 같은 건 여전히 리뷰어가 직접 봐야 한다. 그래도 최소한의 안전망이 하나 더 생긴 셈이다.