Go channel(채널) – 업무에 사용하는 Go 언어 19

Go channel(채널)
Go channel(채널)

Go 언어를 처음 접하는 분들께는 “채널(channel)”이라는 개념이 낯설게 느껴질 수 있습니다. 특히 컴퓨터 프로그래밍에서 채널이란 무엇인지 처음 듣는 분들도 계실 것입니다. 하지만 Go 언어를 본격적으로 활용하려면 이 채널이라는 개념을 꼭 이해하고 넘어가는 것이 중요합니다. 이 글에서는 go channel을 주제로, 기초 개념부터 실제 코드 사용 예까지 차근차근 설명하겠습니다.

필자는 채널과 컨텍스트를 함께 다루는 것이 이해해 좋다고 생각하지만, 내용의 길이가 매우 길어져서 두 편으로 나누게 되었다. 또한, 이번 글의 채널에 대한 이야기를 이해하기 위해 꼭 이전 글을 통해 고루틴이 무엇인지 숙지하도록 하자.

Go channel이란?

Go channel은 간단히 말해서 고루틴(goroutine)들 사이에서 데이터를 주고받기 위한 통로입니다. 마치 파이프처럼 하나의 고루틴이 어떤 값을 보내면, 다른 고루틴이 그 값을 받을 수 있도록 연결해주는 역할을 합니다.

Go 언어는 기본적으로 동시성 처리를 매우 쉽게 만들기 위해 고루틴과 채널이라는 두 가지 도구를 제공합니다. 고루틴이 비동기 작업을 수행하는 실행 단위라면, 채널은 이들 사이에서 데이터를 안전하게 전달할 수 있도록 돕는 매개체라고 할 수 있습니다.


Go channel은 언제 사용하나?

동시에 여러 작업을 처리할 수 있다고 해서 모든 고루틴이 서로 제멋대로 동작하게 되면 프로그램은 매우 복잡해지고 오류가 발생하기 쉽습니다. 특히 여러 고루틴이 하나의 데이터를 동시에 수정하게 되면 문제가 생깁니다. 이를 경쟁 상태(race condition)라고 합니다.

채널을 사용하면 고루틴 간에 명확한 데이터 전달 경로가 생기기 때문에, 이러한 문제를 줄일 수 있습니다.
데이터를 주고받을 때 동기화가 이루어지기 때문입니다.

예를 들어, 한 고루틴이 채널을 통해 데이터를 보낼 때, 다른 고루틴이 그 데이터를 받을 준비가 되어 있어야만 실제로 데이터가 전달됩니다. 이 과정에서 자동으로 동기화가 일어나는 셈입니다.


Go 채널의 기본 문법

Go에서 채널을 선언하고 사용하는 기본적인 방법은 다음과 같습니다:

Go
ch := make(chan int) // int 타입을 주고받는 채널 생성

데이터를 채널에 보내려면 다음과 같이 합니다:

Go
ch <- 10 // 10이라는 값을 채널에 보냄

채널에서 데이터를 받으려면:

Go
value := <-ch // 채널에서 값을 읽어 value에 저장

이 간단한 문법만으로도 고루틴 간에 데이터를 주고받을 수 있습니다.
직접 예제를 살펴보면서 사용해보세요.


Go channel 예제

아래는 두 개의 고루틴이 채널을 통해 데이터를 주고받는 간단한 예제입니다:

Go
package main

import (
    "fmt"
)

func sendData(ch chan string) {
    ch <- "고루틴에서 보낸 메시지"
}

func main() {
    ch := make(chan string)
    
    go sendData(ch)

    msg := <-ch
    fmt.Println("메인 함수에서 받은 메시지:", msg)
}

이 코드를 실행하면 고루틴이 채널을 통해 보낸 메시지를 메인 함수에서 받아 출력하게 됩니다.
중요한 포인트는, ch <- "메시지" 코드가 실행되면 msg := <-ch가 실행될 때까지 대기하게 된다는 점입니다.
이로 인해 자연스럽게 두 고루틴 간에 동기화가 일어납니다.


버퍼 채널과 비버퍼 채널

위 예제에서 사용한 채널은 버퍼가 없는 비버퍼 채널입니다.
즉, 수신자가 데이터를 받을 준비가 되어 있어야만 전송이 이루어집니다.
그러나 버퍼 채널을 사용하면 일정 개수만큼 데이터를 보내고, 나중에 받아도 됩니다.

Go
ch := make(chan int, 3) // 버퍼 크기가 3인 채널 생성

버퍼 채널은 쓰레드 간 통신에서 유연성을 줄 수 있지만, 버퍼가 가득 차면 그때부터는 수신자가 값을 받아야만 새로운 데이터를 보낼 수 있습니다.

여기서 쉽게 이해할 수 있는 비유를 하나 들어보겠습니다. 버퍼 채널은 마치 우체통과 같습니다. 편지를 보낼 수 있는 공간이 3칸 있다고 생각해보세요. 편지를 한 장씩 채워 넣을 수 있고, 우체통이 가득 차면 우체부가 와서 편지를 수거하기 전까지는 더 이상 넣을 수 없습니다. 반면, 비버퍼 채널은 우체통 없이 편지를 손에서 손으로 직접 건네야 하는 구조라고 보면 됩니다. 편지를 받을 사람이 손을 내밀고 있어야만 전달이 가능하죠.

필자의 경우, 한 곳에서 값을 보내고 여러 곳에서 동시에 값을 읽어야 할 때에는 주로 비버퍼 채널을 사용하는 편이다.
비버퍼 채널은 송수신 시 동기화가 이루어지기 때문에, 각 수신 고루틴이 순차적으로 데이터를 처리하도록 만들 수 있다.

채널을 닫는 방법과 주의사항

채널은 한쪽 방향으로만 데이터를 전달할 수 있기 때문에, 송신자가 더 이상 보낼 데이터가 없다면 채널을 닫아야 합니다.

Go
close(ch)

채널을 닫은 후에는 데이터를 더 이상 보낼 수 없습니다.
하지만 수신자는 range 문을 사용하여 채널이 닫힐 때까지 반복해서 데이터를 받을 수 있습니다:

Go
for val := range ch {
    fmt.Println(val)
}

채널을 너무 일찍 닫으면 수신 측에서 문제가 생기고, 너무 늦게 닫으면 자원이 낭비될 수 있습니다.
따라서 닫는 시점은 매우 중요합니다.


select문으로 여러 채널 다루기

Go에서는 select 문을 사용하면 여러 채널을 동시에 감시할 수 있습니다. 이는 네트워크나 타이머, 여러 고루틴에서 데이터를 받을 때 유용합니다.

Go
select {
case msg1 := <-ch1:
    fmt.Println("채널1에서 받은 메시지:", msg1)
case msg2 := <-ch2:
    fmt.Println("채널2에서 받은 메시지:", msg2)
default:
    fmt.Println("데이터가 준비되지 않았습니다.")
}

select는 여러 개의 채널을 동시에 감시할 수 있게 해주는 문법입니다. 각각의 case는 특정 채널로부터 데이터를 받거나 보내는 동작을 정의합니다. select 문을 실행하면 여러 case 중에서 실행 가능한 것이 있을 경우, 그 중 하나를 무작위로 선택해 실행합니다.

만약 모든 case가 데이터를 주고받을 준비가 되어 있지 않은 블로킹 상태라면, default 문이 정의되어 있다면 그 블록이 실행됩니다. 이 구조는 마치 여러 개의 전화 벨이 울릴 수 있는 상황에서 가장 먼저 걸려오는 전화를 받는 것과 비슷하다고 볼 수 있습니다.

select는 동시에 여러 채널로부터의 입력을 기다릴 수 있게 해 주기 때문에, 고루틴 간 통신이 보다 유연하고 효율적으로 이루어질 수 있다.

지금까지 go channel(채널)에 대해 기본 개념부터 실제 사용법까지 간단하고 쉽게 설명해보았습니다. 고루틴과 함께 채널을 적절히 활용하면, 복잡한 동시성 문제를 안전하고 효율적으로 해결할 수 있습니다.

Go 채널은 처음에는 낯설게 느껴질 수 있지만, 몇 가지 예제만 따라 해보면 생각보다 금방 익숙해집니다. 이번 글이 그 시작점이 되었기를 바랍니다. 다음 글에서는 채널과 함께 자주 언급되는 컨텍스트(context)에 대해 다룰 예정입니다.