본문 바로가기
안드로이드

백만 줄 스레드 코드를 코루틴으로 바꾸되, 호출부는 한 줄도 건드리지 않는 법

by 안드뽀개기 2026. 6. 30.
반응형

수십만 줄의 레거시 코드가 오래된 커스텀 스레딩 라이브러리에 의존하고 있다고 가정해보자. 그 라이브러리를 코루틴으로 교체하고 싶은데, 호출부를 전부 손대는 건 현실적으로 불가능하다.

이럴 때 쓸 수 있는 전략이 있다. 라이브러리의 내부 구현만 교체하고, 기존 API는 그대로 유지하는 것이다. 핵심 파일 몇 개를 다시 쓰는 것만으로 전체 코드베이스가 코루틴 위에서 돌아가게 할 수 있다.


왜 내부만 교체하는 전략인가

호출부를 바꾸는 방식은 이론적으로 깔끔하지만 비용이 너무 크다. 수천 개의 호출부를 suspend 함수로 바꾸거나 launch 블록으로 감싸야 하고, 이 과정에서 버그가 조용히 끼어들 수 있다.

반면 내부만 바꾸면 기존 API를 유지하면서 실행 엔진만 교체할 수 있다. 호출부는 예전 패턴 그대로 쓰고, 그 아래에서는 코루틴이 돌아간다. 팀이 멈출 필요도 없고, 점진적으로 마이그레이션할 여지도 생긴다.

단, 조건이 있다. 기존 동작을 완벽하게 유지해야 한다. 이게 생각보다 까다롭다.


지켜야 할 세 가지 불변 조건

전형적인 레거시 스레딩 라이브러리는 다음 형태를 가진다:

abstract class Component {
    protected fun async(block: () -> Unit) { /* 컴포넌트 스레드에서 실행 */ }
}

interface Promise<T, E> {
    fun waitForResult(): Result<T, E> // 결과가 나올 때까지 현재 스레드 블로킹
    fun setResult(result: Result<T, E>)
}

async는 겉으로 단순해 보이지만, 세 가지 불변 조건을 암묵적으로 보장한다:

  1. FIFO 순서: 외부에서 호출하면 컴포넌트 스레드 큐에 순서대로 적재된다.
  2. 재진입(Reentrance): 컴포넌트 스레드 안에서 호출하면 즉시 동기 실행된다.
  3. 스레드 점유: 블록이 완료될 때까지 스레드를 내어주지 않는다.

이 세 가지를 코루틴으로 복원하는 것이 교체 작업의 전부다.


단일 스레드 디스패처로 시작하되, 한계를 알아야 한다

처음 떠올릴 수 있는 접근은 단일 스레드 디스패처다:

abstract class Component {
    private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
    protected val scope = CoroutineScope(dispatcher + SupervisorJob())

    protected fun async(block: () -> Unit) {
        scope.launch { block() }
    }
}

FIFO는 이걸로 해결된다. launch 호출 순서대로 단일 스레드 큐에 쌓이기 때문이다.

문제는 블록 안에서 suspend 함수가 등장하는 순간이다. 코루틴이 suspend 지점에서 스레드를 반납하면, 같은 디스패처의 다른 코루틴이 그 스레드를 가져간다. 논리적 스레드 점유가 깨지고, 재진입 판단도 틀어진다.


재진입을 ThreadLocal로 추적하기

재진입 감지는 ThreadLocal로 해결할 수 있다. 블록 실행 전에 "지금 이 컴포넌트의 스레드에 있다"고 마킹하고, async 호출 시 이를 확인한다:

abstract class Component {
    private val ownerThread = ThreadLocal<Component?>()
    private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
    protected val scope = CoroutineScope(dispatcher + SupervisorJob())

    protected fun async(block: () -> Unit) {
        if (ownerThread.get() === this) {
            block() // 재진입: 잠금 없이 즉시 동기 실행
        } else {
            scope.launch {
                ownerThread.set(this@Component)
                try {
                    block()
                } finally {
                    ownerThread.set(null)
                }
            }
        }
    }
}

블록이 일반 람다(non-suspend)인 동안은 이 방식이 정확하게 동작한다. 스레드가 suspend로 교체되지 않기 때문에 ThreadLocal의 값이 블록 실행 내내 안정적으로 유지된다.


Promise를 CompletableDeferred로 교체하기

Promise의 내부를 CompletableDeferred로 바꾸면 코루틴 친화적인 구현이 된다:

class CoroutinePromise<T, E> : Promise<T, E> {
    private val deferred = CompletableDeferred<Result<T, E>>()

    override fun setResult(result: Result<T, E>) {
        deferred.complete(result)
    }

    override fun waitForResult(): Result<T, E> = runBlocking {
        deferred.await()
    }
}

여기서 반드시 짚어야 할 함정이 있다. runBlocking은 호출 스레드를 점유하면서 코루틴을 기다린다. waitForResult를 컴포넌트의 단일 스레드 디스패처 위에서 호출하면, 그 스레드가 블로킹된 채로 setResult를 처리할 코루틴도 같은 스레드를 기다리는 데드락이 발생한다.

이를 피하려면 waitForResult 호출 위치를 의도적으로 격리해야 한다. setResult가 컴포넌트 스레드와 독립된 디스패처에서 실행되도록 구조를 잡거나, 코드 리뷰 수준에서 호출 규칙을 강제하는 방식이 현실적이다.


이 전략이 통하는 때와 통하지 않는 때

다음 조건에서는 이 방식이 잘 맞는다. 레거시 API의 시맨틱이 명확하고 테스트로 검증 가능할 때, 호출부가 너무 많아 일괄 교체가 불가능할 때, 팀이 멈추지 않고 점진적으로 개선하길 원할 때다.

반면 효과가 떨어지는 상황도 있다. 레거시 API 자체의 설계가 코루틴과 근본적으로 충돌할 때(예: 반환값이 이미 RxJava Observable이나 LiveData인 경우), 또는 기존 API의 동작이 모호해서 불변 조건을 명확히 정의할 수 없을 때는 내부 교체보다 점진적 API 교체가 낫다.


내부 교체가 끝난 다음

라이브러리 내부가 코루틴으로 돌아가기 시작하면, 그 다음 단계는 비로소 자연스러워진다. async(block: () -> Unit)async(block: suspend () -> Unit)으로 바꾸고, Promise.waitForResult()suspend fun awaitResult()로 전환하는 작업을 호출부에서 하나씩 진행할 수 있다.

이미 실행 엔진은 코루틴이기 때문에, API 교체는 순수하게 점진적인 작업이 된다. 레거시 코드가 코루틴을 "받아들이는" 것이 아니라, 이미 코루틴 위에서 돌아가고 있는 상태가 되는 것이다.

※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형