본문 바로가기
Kotlin 공부 노트

[Kotlin-Coroutine] Channel이란? with Coroutine Deferred

by 지게요 2026. 2. 18.
728x90
반응형

이번 공부 노트에서는 MVI 패턴에서의 종종 보이는 Side Effect를 효율적으로 처리하기 위해 왜 Channel을 사용하는지 그리고 그 구현 방법에 대해 알아보자!

# Channel

- 코루틴 간의 데이터를 전송하기 위한 통신 수단이다.

 

우선 아래 공식 문서에서 가져온 채널 정의에 대한 본문이다.

Deferred values provide a convenient way to transfer a single value between coroutines. Channels provide a way to transfer a stream of values.
번역 - 연기된 값은 코루틴 간에 단일 값을 전송하는 편리한 방법을 제공합니다. 채널은 일련의 값을 전송하는 방법을 제공합니다.

 

번역을 봐도 무슨 말인지 잘 이해가 안 돼서 하나하나 살펴보려고 한다.

Deferred를 연기된 값이라고 번역이 되어있는데 Deferred는 뭐지? 싶어서 검색을 해봤다.

## Deferred란? 

검색해서 나온 결과를 종합해서 봤을 때 간단하게 설명하면 코틀린 코루틴에서 비동기 작업의 결괏값을 담는 객체이다.

핵심은 지금 당장 결과가 없어도 일단 결과가 담길 상자부터 미리 준다라는 게 핵심이다. 어떻게 보면 lateinit, by lazy처럼 지연 초기화와 유사한 개념으로 볼 수도 있다.

하지만 lateinit는 값이 없으면 예외를 던지고 Deferred는 값이 준비될 때까지 코루틴을 잠시 멈추고 기다려준다는 점이 차이점이다! 

public interface Deferred<out T> : Job {

    public suspend fun await(): T

    public val onAwait: SelectClause1<T>

    @ExperimentalCoroutinesApi
    public fun getCompleted(): T

    @ExperimentalCoroutinesApi
    public fun getCompletionExceptionOrNull(): Throwable?
}

### 어디서 나타나는가?

아무대서나 만들어지는 게 아니라 코루틴 빌더 중 하나인 async를 사용할 때 Deferred 객체가 반환된다.

public fun <T> CoroutineScope.async(...): Deferred<T>

여기서 중요한 포인트가 있는데 코루틴 실행 시 launch와 비교를 하면 좋다.

launch는 반환 값이 job이고 해당 결과 값을 반환하지 않는다.

async는 반환 값이 내부 코드처럼 Deferred <T> 형식이고 결과가 나오면 Deferred에 담아서 반환해 준다.

### 어떻게 값을 꺼내는가?

만약 아래와 같은 async를 사용해서 deferred객체를 받아왔다면 이제 결과를 꺼내야 한다.

val deferred = async {
    delay(1000L) // 1초 동안 작업
    "최종 결과값"
}

꺼내는 방법은 간단하다. 바로 아래 코드와 같이 await() 함수로 쉽게 꺼내 올 수 있다.

val finalData = deferred.await() // 여기서 1초 뒤에 "최종 결과값"을 받아옴
println(finalData)


이제 Deferred를 알아봤으니 다시 번역본을 가져와서 읽어보자!

Deferred values provide a convenient way to transfer a single value between coroutines. Channels provide a way to transfer a stream of values.
번역 - 연기된 값은 코루틴 간에 단일 값을 전송하는 편리한 방법을 제공합니다. 채널은 일련의 값을 전송하는 방법을 제공합니다.

 

연기된 값 = Deferred 이란걸 이해했고 Deferred은 코루틴 간에 단일 값을 전송하는 편리한 방법을 제공

채널은 일련의 값을 전송하는 방법이라고 번역이 되었다.

여기서 핵심은 Deferred가 비동기 작업의 최종 결과물 하나를 받기 위한 객체라면 Channel은 그와 대조적으로 단일 값이 아닌 연속적인 데이터들(일련의 값)을 전송하는 방법을 제공한다.

 

자 그래서 Deferred가 단 하나의 결과를 기다리는 방식이었다면 Channel은 두 코루틴 사이에서 데이터를 주고받을 수 있는 파이프라인(통로)이다.

# Channel 특징

이제 채널의 정의를 알아봤으니 특징도 알아보면 조금 더 이해가 쉬울 것이다.

## 1. 코루틴을 위한 중단 가능한 큐(Suspending Queue)

Channel는 자료구조의 큐(Queue)와 매우 비슷한 성격을 가진다. 먼저 들어간 데이터가 먼저 나오는 선입선출(FIFO) 구조가 대표적인 성격이다. 하지만 큐와 같은 선입선출구조이지만 아래와 같은 차이점이 있다.

 

- Queue: 데이터가 비어있을 때 꺼내려하면 에러가 나거나 스레드를 멈춰버린다.

- Channel: 데이터가 없으면 코루틴을 일시 중단(Suspend) 시키고 기다린다. 스레드는 차단하지 않으면서 데이터가 들어오는 순간 다시 깨어나서 일을 시작한다.

 

안드로이드에서 메인 스레드(UI 스레드)는 절대 blocking 되면 안 된다. 따라서 메인 스레드에서 Queue를 사용해 데이터가 비어 있는 상태로 대기할 경우 ANR과 같은 심각한 문제가 발생할 수 있다.
이런 이유로 코루틴 환경에서는 스레드를 차단하지 않고 안전하게 대기할 수 있는 Channel 사용이 훨씬 안전하며 권장된다.

## 2. Hot Stream

채널은 대표적인 Hot Stream 구조를 가진다. 그렇기에 데이터의 생산이 소비자의 상태에 의존하지 않는다.

즉 데이터 발행 시점에 수신자가 없더라도 즉시 데이터가 발생한다.

이러한 특성 때문에 스스로 작동하기 때문에 자원을 계속 소모한다. 따라서 채널뿐만 아니라 Hot Stream을 사용하면서 더 이상 필요하지 않을 때는 close() 함수를 통해 꼭 명시적으로 스트림을 닫아주는 관리가 필요하다!

 

일상생활에서 예를 들자면 나는 카페 사장이고 두쫀쿠를 만들어서 파는 입장이다.

나는 손님이 몇 명이 올지도 모르고 언제 올지도 모르기 때문에 우선 두쫀쿠를 만들어 매대에 진열을 해놓는다.

위 예시처럼 생각하면 이해가 잘될 것이다.

 

 

## 3. 일회성 이벤트에 최적화

가끔씩 StateFlow를 사용하다 보면 화면이 회전될 때나 잠시 백그라운드에 갔다 왔을 때 이전에 발생했던 이벤트가 다시 호출되어 곤란한 경우가 있었을 것이다. 이는 StateFlow가 최신 상태를 기억하고 있다가 새로운 관찰자가 붙으면 그 값을 즉시 다시 내보내는 성질이 있기 때문이다.

 

하지만 채널은 이와 다르게 데이터가 한 번 소비되면 파이프라인에서 즉시 소멸하는 특성을 가진다.

channel.send(1)

launch { channel.receive() } // 소비됨
launch { channel.receive() } // 못 받음

 

위 코드에서 알 수 있듯이 채널의 데이터는 일회용이다.

 

이번에도 두쫀쿠 카페 사장 예를 들자면 내가 만든 두쫀쿠가 1개 남았는데 손님이 그 한 개를 구매했다. 그럼 그 뒷손님은 재고가 없기 때문에 두쫀쿠를 구매하지 못한다.

이런 예시를 보면 이해가 잘될 것이다.

# 사용법

## 선언

public fun <E> Channel(
    capacity: Int = RENDEZVOUS,
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
    onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E>

 

위 내부 코드를 보다시피 채널은 총 3가지의 속성을 가진다.

- capacity : 채널의 버퍼의 용량을 정한다.

 

  • RENDEZVOUS - 0 크기 (기본값)
  • BUFFERED - 64 크기
  • UNLIMITED - 무한한 공간. (메모리가 허용하는 한 계속 쌓음 단 OutOfMemoryError 발생할 위험이 있음) 
  • CONFLATED: 공간은 1개지만 새 데이터가 오면 기존 것을 버리고 최신 것만 유지.

- onBufferOverflow : 버퍼가 가득 찼는데 또 전송(send)이 들어올 경우 어떻게 대처할지를 정함

  • BufferOverflow.SUSPEND - 버퍼의 빈 공간이 생길 때까지 송신자의 실행을 일시 중단(Suspend) 시킴 (기본값)
  • BufferOverflow.DROP_OLDEST - 버퍼가 가득 찼을 때 가장 오래된 데이터(가장 먼저 들어온 데이터)를 삭제하고 새로운 데이터를 버퍼에 추가함
  • BufferOverflow.DROP_LATEST - 버퍼가 가득 찼을 때 새로 들어온 데이터(지금 전송하려는 데이터)를 즉시 버림

- onUndeliveredElement : 전송된 데이터가 소비자에게 도달하지 못하고 유실되는 상황에서 호출되는 후 처리 콜백

해당 콜백의 데이터로는 전송에 실패한 바로 그 데이터 객체가 넘어온다. 

콜백 발생 조건은 아래와 같다

  • 채널 닫힘 - 데이터 전송 후 수신자가 받기 전에 channel.close() 호출된 경우
  • 코루틴 취소 - 수신 측 코루틴이 데이터를 처리하기 직전에 취소된 경우
  • 버퍼 오버플로우: DROP_OLDEST 등의 전략에 의해 버퍼 내의 데이터가 강제로 삭제된 경우

주로 자원 전달 실패 시 이벤트 로그를 남기거나 리소스 해제를 하기 위해 사용된다.

val channel = Channel<CustomResource>(
    onUndeliveredElement = { resource ->
        // 실패 시 자원을 안전하게 해제
        resource.close()
        println("자원 전달 실패 및 해제 완료: $resource")
    }
)

 

 

채널은 제네릭 타입을 인자로 받아 해당 타입의 데이터를 전달하도록 선언해 주면 된다.

// String 데이터를 전달하는 가장 기본적인 채널 생성
val channel = Channel<String>()

 

## 전송

public interface SendChannel<in E> {
    
    /**
     * 이 채널에 데이터를 보냅니다.
     * 채널의 버퍼가 가득 차면 수신자가 나타나거나 버퍼에 자리가 생길 때까지 
     * 호출한 코루틴을 일시 중단(suspend)시킵니다.
     */
    public suspend fun send(element: E)

    /**
     * 즉시 데이터를 전송하려고 시도합니다. (비중단형)
     * 성공하면 'Successful', 버퍼가 꽉 찼거나 채널이 닫혔으면 실패를 반환합니다.
     * 일반 함수에서도 호출 가능합니다.
     */
    public fun trySend(element: E): ChannelResult<Unit>
    
    // ... 기타 메서드 (close 등)
}

- send(element: E)

- trySend(element: E)

전송하기 위에서는 위 두 가지 함수를 사용해야 한다.

각 함수의 사용법을 알아보자!

### send()

launch {
    // 채널에 데이터 전송
    channel.send("데이터 A")
 }

- 중단형 전송

- suspend 함수 O

- 채널의 버퍼가 가득 찼다면 자리가 날 때까지 호출한 코루틴을 일시중단(suspend)시킨다.

- 데이터 유실 없이 확실하게 전달해야 할 때 사용

### trySend() 

// 일반 함수에서도 호출 가능 (launch 블록이 없어도 됨)
val result = channel.trySend("클릭 이벤트")

- 비중단형 전송

- suspend 함수 X

- 일시중단(suspend) 하지 않는다. 즉시 전송을 시도하고 성공 여부를 반환 버퍼가 꽉 찼다면 실패를 반환하고 다음 코드로 넘어 감

- 코루틴 밖에서 호출해야 하거나 전송이 안 되면 그냥 포기해도 되는 실시간 데이터에 사용

## 수신

public interface ReceiveChannel<out E> {
    
    // 데이터를 받을 때까지 중단함. 채널 닫히면 예외 발생.
    public suspend fun receive(): E

    // 데이터를 받을 때까지 중단함. 채널 닫혀도 예외 대신 결과(Failure) 반환.
    public suspend fun receiveCatching(): ChannelResult<E>

    // 즉시 수신 시도. 없으면 바로 실패 반환. (비중단형)
    public fun tryReceive(): ChannelResult<E>

    // 채널 내의 모든 데이터를 순회하며 꺼내올 수 있는 반복자 제공
    public operator fun iterator(): ChannelIterator<E>
    
    // ... 기타 속성 (isClosedForReceive 등)
}

 

수신은 총 4개의 방식이 있다.

receive(), receiveCatching(), tryReceive(), iterator() 각각 알아보자!

### receive()

LaunchedEffect(Unit) {
    try {
        // 1. 버퍼를 1로 설정해서 send가 멈추지 않게 함
        val channelData = Channel<String>(1)

        channelData.send("1")
        Log.d("채널 테스트", "데이터 보냄: 1")

        val data = channelData.receive()
        Log.d("채널 테스트", "첫 번째 수신 성공: data = $data")

        channelData.close()
        Log.d("채널 테스트", "채널 닫음")

        delay(100)

        Log.d("채널 테스트", "두 번째 수신 시도 시작...")
        // 2. 여기서 데이터가 없는 상태로 닫힌 채널에 접근하므로 예외 발생!
        val reData = channelData.receive()
        Log.d("채널 테스트", "이 로그는 찍히지 않습니다: $reData")
        
    } catch (e: Exception){
        // 3. 여기서 발생한 예외를 잡아 로그로 출력
        Log.d("채널 테스트", "예외 발생 확인 성공! -> error = $e")
    }
}

- 데이터를 받을 때까지 코루틴을 멈추고 기다린다.

- 만약 채널이 닫힌 상태에서 호출되면 예외를 발생시킨다.

### receiveCatching()

LaunchedEffect(Unit) {
    // 1. 버퍼를 1로 주어 send에서 멈추지 않게 함
    val channelData = Channel<String>(1)

    channelData.send("1")

    // 첫 번째 수신 (정상 성공)
    val result1 = channelData.receiveCatching()
    Log.d("채널 테스트", "첫 번째 결과: isSuccess = ${result1.isSuccess}, value = ${result1.getOrNull()}")

    // 채널 닫기
    channelData.close()
    Log.d("채널 테스트", "채널 닫음")

    delay(100)

    // 두 번째 수신 시도 (채널이 닫혔고 데이터가 없으므로 실패)
    // receive()와 달리 여기서 Exception이 발생하지 않고 Result 객체를 반환함
    val result2 = channelData.receiveCatching()

    Log.d("채널 테스트", "두 번째 결과: isSuccess = ${result2.isSuccess}")
    Log.d("채널 테스트", "두 번째 결과: isClosed = ${result2.isClosed}")
    Log.d("채널 테스트", "두 번째 결과 상세: $result2")

    // 실패 원인 확인 가능
    val exception = result2.exceptionOrNull()
    Log.d("채널 테스트", "실패 원인(에러): $exception")
}

- 위 receive()와 동일하게 받을 때까지 코루틴을 멈추고 기다리지만 ChannelResult <E>를 반환한다.

- 채널 성공 / 실패를 ChannelResult에 담아 반환하므로 예외처리가 간편하다.

### tryReceive()

// 1. 버퍼 1개짜리 채널 생성
val channelData = Channel<String>(1)

// 상황 A: 데이터가 없을 때 (Failure)
val result1 = channelData.tryReceive()
Log.d("채널 테스트", "데이터 없을 때: $result1")

// 상황 B: 데이터가 있을 때 (Success)
channelData.trySend("Hello")
val result2 = channelData.tryReceive()
Log.d("채널 테스트", "데이터 있을 때: ${result2.getOrNull()}")

- 기다리지 않고 즉시 수신을 시도한다.

- suspend함수가 아니라서 일반 함수에서도 호출이 가능하다.

- 데이터가 없으면 즉시 실패를 반환한다.

### iterator()

LaunchedEffect(Unit) {
    // 1. 버퍼가 넉넉한 채널 생성
    val channelData = Channel<String>(5)

    // 2. 데이터 미리 몇 개 넣어두기
    channelData.send("데이터1")
    channelData.send("데이터2")
    channelData.send("데이터3")

    // 3. 채널 닫기 (중요: close를 해야 iterator가 루프를 종료함)
    channelData.close()
    Log.d("채널 테스트", "채널 닫음 (남은 데이터는 소비 가능)")

    // 4. iterator()를 이용한 수신 (for문이 내부적으로 iterator를 사용)
    val iterator = channelData.iterator()

    // hasNext()는 suspend 함수라 코루틴 내부여야 한다.
    while (iterator.hasNext()) {
        val data = iterator.next()
        Log.d("채널 테스트", "수신 데이터: $data")
    }

    Log.d("채널 테스트", "모든 데이터 수신 완료 및 루프 종료")
}

- 내부의 hasNext()가 데이터를 기다리며 코루틴을 중단시킨다.

- 채널이 닫히면 루프가 자동으로 종료되어 스트림 처리에 가장 적합.


# 그래서 왜 주로 MVI 패턴에서 Side Effect는 Channel을 사용할까?

보통 MVI에서 Side Effect는 토스트 메시지 출력, 페이지 이동, 스낵바 표시와 같은 일회성 작업을 처리하기 위해 사용한다.

위에서 알아본 채널의 특징들을 종합해 보면 왜 Channel이 Side Effect 처리에 좋은지 알 수 있을 것이다.

## 중복 호출 방지

만약 Channel이 아닌 StateFlow로 작업을 했다면 StateFlow는 상태를 저장하기 때문에 화면 회전 시에 아까 발생했던 토스트 메시지가 또 발생하는 현상이 일어난다. 하지만 Channel은 데이터를 한 번 소비하면 즉시 소멸하므로 중복 호출 문제가 발생하지 않는다.

## 일회성 이벤트에 최적화된 설계

위에서 설명드렸다시피 Side Effect는 말 그대로 한 번 일어나고 끝나는 부수 효과이다. 보낸 쪽은 확실히 보내고 받는 쪽은 딱 한 번만 받는다는 Channel의 철학이 MVI의 Side Effect와 찰떡궁합이다.

 


사실 지금까지는 MVI 패턴을 쓰면서 다들 Side Effect에 채널을 쓰니까 그냥 관성적으로 코드를 복붙해왔던 것 같다.

원래는 왜 굳이 채널이어야 하는가라는 의문이 머릿속에 맴돌았지만 당장 눈앞의 기능을 구현하느라 깊게 파고들 여유가 없었다.

 

하지만 이번에 직접 코드를 뜯어보고 테스트하면서 느낀 건 단순히 데이터를 전송, 수신하는 수준이 아니라 전송 방식이나 수신 방식이 이렇게나 다양할 줄은 몰랐다. 

 

 

반응형