코루틴으로 비동기 코드를 동기 코드처럼 작성하기

2026. 3. 2. 09:38·Kotlin

도입: 나는 코루틴을 잘 쓰고 있는 걸까?

나름 안드로이드 개발자로서 프로젝트 곳곳에 코루틴을 적극적으로 활용하고 있었다. CoroutineScope 와 launch, suspend 키워드를 익숙하게 사용하며, 비동기 처리를 잘 구현하고 있다고 생각했다.

 

그러다가 문득 프로젝트의 코드를 다시 보며 이런 생각이 들었다.

"코루틴을 사용하면 콜백지옥을 해결할 수 있다는데, 왜 난 아직도 콜백 방식으로 구현하고 있지?"

 

일시 중단 함수인 suspend 함수는 함수 내부에 일시 중단 지점을 포함할 수 있어, 비동기 작업을 마치 순차적인 동기 코드처럼 직관적으로 작성할 수 있다는 장점이 있다. 하지만 나는 그동안 기존의 습관대로 코드를 작성하며 코루틴의 장점을 놓치고 있었다.

 

코루틴 스터디에서 스터디원들과 이야기하며 무심코 사용하던 패턴을 깨닫고, 이를 개선한 과정을 기록하려고 한다.

 


문제 제기: 콜백 방식의 패턴

안드로이드 개발을 하다 보면 ViewModel에서 복잡한 요구사항을 처리해야 하는 경우가 굉장히 많다. 특히 여러 API를 순차적으로 호출해야 하는 상황은 아주 흔하다.

 

작업 A 실행 -> (성공 시) 작업 B 실행

이러한 로직을 처리할 때, 나는 주로 다음의 두 가지 방식으로 코드를 작성했다.

 

패턴 1: launch 내부에서 콜백 중첩

class ExampleViewModel(
    private val repository: ExampleRepository,
) : ViewModel() {    
    private fun fetchAThenB() {
        viewModelScope.launch {
            // 작업 A 실행
            repository.fetchA()
                .onSuccess {
                    // 성공 시 작업 B 실행
                    repository.fetchB()
                        .onSuccess { ... }
                        .onFailure { ... }
                }.onFailure { ... }
        }
    }
}

 

패턴 2: 고차 함수(콜백)를 파라미터로 넘기기

class ExampleViewModel(
    private val repository: ExampleRepository,
) : ViewModel() {
    private fun fetchAThenB() {
        // 콜백을 넘겨서 성공 시 다음 작업을 지시함
        fetchA(onSuccess = { fetchB() })
    }

    private fun fetchA(onSuccess: () -> Unit) {
        viewModelScope.launch {
            repository.fetchA()
                .onSuccess {
                    onSuccess()
                }.onFailure { ... }
        }
    }

    private fun fetchB() { ... }
}

 

두 방식 모두 코드의 형태만 미묘하게 다를 뿐, 흐름은 동일하다. fetchA 가 끝나면 콜백을 통해 fetchB 를 호출하는 형태이다. launch 블록 안에 들어있을 뿐, 사실상 코루틴의 일시 중단(suspend) 특성을 전혀 살리지 못한 코드였다.

 

이 패턴들은 다음과 같은 문제점이 있다.

  • 가독성 저하: 로직이 깊어질수록 중첩 깊이가 늘어나 코드 흐름을 한눈에 파악하기 어렵다.
  • 에러 처리 분산: 각 콜백마다 에러를 별도를 처리해야 한다.
  • 테스트 및 유지보수 어려움: 콜백이 중첩될수록 개발 함수를 단독으로 테스트하거나 흐름을 변경하기 까다로워진다.

해결책: 일시 중단(suspend) 함수로 순차적 제어

suspend 함수를 활용하면 콜백 없이도 비동기 작업을 순차적으로 실행할 수 있다. suspend 함수는 특정 시점에서 코루틴을 일시 중단했다가, 작업이 완료되면 중단된 지점부터 재개한다. 이 덕분에 비동기 코드를 동기 코드처럼 위에서 아래로 흐르는 방식으로 깔끔하게 작성할 수 있다.

class ExampleViewModel(
    private val repository: ExampleRepository,
) : ViewModel() {
    private fun fetchAThenB() {
        viewModelScope.launch {
            try {
                // 콜백 없이 위에서 아래로 순차적으로 실행됨
                fetchA()
                fetchB()
            } catch (e: Exception) {
                // 에러를 한 곳에서 처리
            }
        }
    }

    private suspend fun fetchA() { ... }

    private suspend fun fetchB() { ... }
}

콜백 블록이 완전히 사라지고, 코드가 위에서 아래로 자연스럽게 흐른다. 예외 처리 역시 try-catch 하나로 묶어서 처리할 수 있어 가독성과 유지보수성이 크게 향상되었다.

 


실전 적용: '야구보구' 직관 인증 로직 리팩터링

사실 A 다음에 B를 호출하는 단순한 로직에서는 콜백 방식도 흐름을 읽는 데 큰 무리가 없다. 진짜 문제는 요구사항이 복잡해질 때 발생한다.

 

현재 진행 중인 '야구보구' 프로젝트의 직관 인증 로직은 다음과 같이 뎁스가 굉장히 깊다.

1. 야구장 및 경기 정보 요청
2. -> (성공 시) 유저 인증 여부 확인
3. -> (성공 시) 현재 디바이스 위치 정보 요청
4. -> (성공 시) 경기장과 현재 위치 간의 거리 계산
5. -> (성공 시) 더블헤더 경기 여부 확인
6. -> (성공 시) 직관 인증 완료

 

이 흐름을 콜백으로 구현한다면 콜백이 6단계 깊이로 중첩되어 이른바 '콜백 지옥' 코드가 탄생하거나, 전체적인 흐름을 쫓아가기 위해 함수를 계속 타고 들어가야 한다.

 

리팩터링 결과

fun checkIn() {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            fetchStadiums()		// 1. 야구장 및 경기 정보 요청
            fetchCheckInStatus()	// 2. 유저 인증 여부 확인
            fetchCurrentLocation()	// 3. 현재 디바이스 위치 정보 요청
            checkIfWithinThreshold()	// 4. 경기장과 현재 위치 간의 거리 계산
            checkDoubleHeader()		// 5. 더블헤더 경기 여부 확인
            addCheckIn()		// 6. 직관 인증 완료
        } catch (e: Exception) {
            // 통합 에러 처리
        }
    }
}

private suspend fun fetchStadiums() { ... }

private suspend fun fetchCheckInStatus() { ... }

private suspend fun fetchCurrentLocation() { ... }

private suspend fun checkIfWithinThreshold() { ... }

private suspend fun checkDoubleHeader() { ... }

private suspend fun addCheckIn() { ... }

코드가 위에서 아래로 물 흐르듯 읽힌다. 비즈니스 로직의 전체 흐름이 checkIn() 함수 하나에 명확하게 작성되어 한눈에 파악할 수 있게 되었다.

또한 각 suspend 함수가 단일 책임을 갖도록 분리되어 있어, 특정 단계만 수정하거나 개별 테스트하기도 훨씬 쉬워졌다.

 

추가: 결과값이 필요한 경우

각 단계의 결과를 다음 단계에 전달해야 하는 경우에도 suspend 함수 방식은 깔끔하게 처리할 수 있다.

fun checkIn() {
    viewModelScope.launch {
        try {
            val stadiumInfo = fetchStadiums()       		// 반환값을
            val status = fetchCheckInStatus(stadiumInfo)	// 다음 함수에 전달
            val location = fetchCurrentLocation()
            checkIfWithinThreshold(stadiumInfo, location)
            checkDoubleHeader(stadiumInfo)
            addCheckIn(stadiumInfo)
        } catch (e: CheckInException) {
            // ...
        }
    }
}

콜백 방식에서는 결과값을 전달하기 위해 외부 변수에 저장하거나 콜백 파라미터를 늘려야 했는데, suspend 함수에서는 일반 함수처럼 반환값을 받아 그대로 사용하면 된다.


마무리

'코루틴을 활용한다'라는 것은 단순히 launch 블록을 열고 닫는 것이 전부가 아니다.

suspend 함수의 일시 중단과 재개라는 특징을 활용해, 복잡한 비동기 로직을 동기식 코드처럼 직관적으로 작성할 수 있다.

 

이번 리팩터링을 하면서 앞으로도 단순히 기능 구현에 급급하기보다, 사용하는 기술의 진짜 철학과 장점을 살려 코드를 작성해야겠다고 생각했다.

 

콜백 방식으로 작성된 코드가 있다면, suspend 함수로 리팩터링해보자!

코드는 더 짧아지고, 흐름은 더 명확해지며, 에러 처리는 한 곳으로 모인다.

(아직 콜백 방식의 코드가 남아있긴 하다..)

'Kotlin' 카테고리의 다른 글

코루틴은 왜 경량 스레드라고 불릴까?  (0) 2026.01.25
'Kotlin' 카테고리의 다른 글
  • 코루틴은 왜 경량 스레드라고 불릴까?
jiyuneel
jiyuneel
  • jiyuneel
    Yuun
    jiyuneel
  • 전체
    오늘
    어제
    • 분류 전체보기 (6)
      • Android (4)
      • Kotlin (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Android
    Android Studio
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
jiyuneel
코루틴으로 비동기 코드를 동기 코드처럼 작성하기
상단으로

티스토리툴바