Android 개발/Kotlin

IntelliJ IDEA 2026.1 코루틴 인스펙션, 6년차 코드를 17번 찌르다

stackD 2026. 6. 8. 18:00

 

코루틴 안티패턴 12건, Flow 최적화 5건. IDEA 2026.1 새 인스펙션을 제 안드로이드 사이드 프로젝트에 돌리고 나온 숫자입니다.

 

6년차가 짠 코드인데도요.

 

정식 출시된 IntelliJ IDEA 2026.1 Ultimate 를 깔고, 평소처럼 사이드 프로젝트 폴더를 열었어요. 30초쯤 지나니까 우측 거터에 노란 줄이 쫙 깔리더라고요. "이거 다 새로 잡힌 건가?" 싶어서 Problems 탭을 열어보니 17건. 잠시 멍해졌습니다.

 

IDEA 2026.1 코틀린 코루틴 인스펙션 12종, 어디까지 잡아주나

새 인스펙션은 제 나름대로 크게 세 그룹으로 정리됩니다.

 

1. 취소·구조적 동시성 (4종)

suspendCoroutine 이 취소를 보장하지 못하는 지점, CoroutineContextWithJob(Job 이 든 컨텍스트를 빌더에 넘기는 케이스), suspend 함수 내부의 runBlocking, 그리고 암시적 CoroutineScope 리시버 캡처. 이 네 가지가 부모-자식 Job 관계를 끊거나 스레드를 막아버리는 지점을 짚어줍니다.

 

2. 성능·가독성 (7종)

Async without Await, forEach { it.join() } → joinAll(), map { it.await() } → awaitAll() 같은 병렬화 제안에다, Flow 체인 최적화 3종까지. map{}.filterNotNull()을 mapNotNull{} 한 줄로 합치는 게 가장 자주 잡혔어요.

 

3. 최신 관용구 (1종)

kotlin.coroutines.coroutineContext 프로퍼티 대신 kotlinx.coroutines.currentCoroutineContext() 함수를 쓰라는 권장입니다. 인라인 람다 안에서 오래된 컨텍스트가 캡처되는 케이스를 막아주는 셈이지요.

 

 

안드로이드 사이드 프로젝트에서 잡힌 코루틴 안티패턴 12건

부끄럽지만 분류해보면 이렇게 됩니다.

  1. Scope 오용 5건. ViewModel 바깥에 둔 GlobalScope.launch 로깅 코드, Activity에서 만들고 cancel() 안 부른 CoroutineScope(Dispatchers.IO) 가 대표 사례였습니다.
  2. Dispatcher 누락 4건. Room의 suspend DAO를 부른 다음에 추가 직렬화 로직이 Main 스레드에서 그대로 도는 부분이 잡혔어요.
  3. suspend 함수 내부 GlobalScope 3건. "fire and forget" 로깅이라고 그냥 둔 코드에 Async without Await 까지 겹쳐서 한 곳에 경고 세 개가 동시에 떴습니다.

특히 1번이 뼈아팠어요. "Implicit CoroutineScope receiver" 경고가 처음 떴을 때, 제가 람다 안에서 바깥 스코프 리시버를 무심코 끌어다 쓴 지점을 한 번에 짚어주더라고요. 6년 동안 못 본 자리였습니다.

// before — 경고 3개 동시 발생
suspend fun logEvent(event: Event) {
    GlobalScope.async {        // Async without Await
        api.send(event)        // Deferred 결과 무시
    }
}

// after
suspend fun logEvent(event: Event) = withContext(Dispatchers.IO) {
    runCatching { api.send(event) }
}

 

 

Flow 최적화 5건과 Quick Fix 자동 적용 범위

Flow 쪽은 의미론이 동일한 자동 수정이 많아서 부담이 적었습니다.

  • map { ... }.filterNotNull() → mapNotNull { ... } 병합 2건
  • 리팩토링 잔재로 남아 있던 빈 map 같은 중복 연산자 제거 1건
  • flow { emit(...) }.flatMapConcat, .toList().first() 같은 복잡한 체인을 flowOf, firstOrNull로 단순화 2건

마지막 두 건이 압권이었네요. 8줄짜리 Flow 체인이 3줄로 줄어들었습니다. 가독성만 좋아진 게 아니라 중간 Flow 객체 할당도 사라지는 부분이에요.

 

awaitAll() 자동 변환은 절대 그냥 누르지 마세요

여기서 한 박자 쉬어가야 합니다. map { it.await() } → awaitAll() 자동 변환은 의미가 살짝 달라져요.

 

awaitAll() 은 자식 중 하나가 실패하면 즉시 예외를 던집니다. (구조적 동시성 안에 있다면 부모 스코프가 형제 자식들까지 취소해주는 식이고요.) 반면 map { it.await() } 은 첫 Deferred 를 기다리는 동안 두 번째 예외가 즉시 노출되지 않을 수 있어요. try-catch 위치가 어긋나 있으면 동작이 바뀌는 케이스입니다.

 

저도 한 번 자동 적용 눌렀다가, 기존 단위 테스트 두 개가 빨갛게 변하는 걸 보고 멘붕이 오더라고요. 예외 처리 의도가 바뀐 거였습니다. 의미론이 다른 Quick Fix는 diff를 한 줄 한 줄 읽고 적용하는 게 안전합니다.

 

 

안드로이드 스튜디오 반영 시차와 6년차 우선순위

현재 안정 빌드인 Android Studio Panda 4(2025.3.4) 와 프리뷰인 Quail(2026.1.x) 모두 인스펙션 반영 시점은 공식 공지가 별도로 없는 상태입니다. 우선은 IDEA Ultimate 에서 안드로이드 모듈을 열어 검사하는 쪽이 빠릅니다.

 

개인적으로는 12종을 다 켜기보다 세 개 먼저 활성화하는 쪽을 권합니다. RunBlocking in suspend function, Async without Await, CoroutineContextWithJob. 이 셋이 실제 운영 장애로 이어졌던 경험과 가장 정확히 일치하는 지점이에요. 팀에 도입하실 거면 joinAll/awaitAll 자동 변환은 PR diff가 폭증하고 동작이 바뀌니까, 코드 리뷰 필수 항목으로 컨벤션에 박아두시는 게 안전합니다.

 

마치며

문제가 17개 떴을 때, 솔직히 6년이 헛수고였나 싶었어요. 근데 한 시간쯤 고치다보니까 생각이 달라졌습니다. 이건 제 코드가 형편없었던 게 아니라, 2020년에 베스트였던 패턴과 2026년에 권장되는 패턴이 다른 자리였어요. 정적 분석 도구가 그 간격을 메워주는 거였습니다.

 

다음 EAP에서는 어떤 인스펙션이 또 추가될지, 그때도 한 시간 정도는 마음 비우고 노란 줄 따라갈 생각이에요.