Golang 의 채널 기본 요소

GolangBeginner
지금 연습하기

소개

Go 언어의 가장 매력적인 특징 중 하나는 동시성 프로그래밍 (concurrent programming) 에 대한 네이티브 지원입니다. 병렬 처리, 동시성 프로그래밍 및 네트워크 통신에 대한 강력한 지원을 통해 멀티 코어 프로세서를 보다 효율적으로 사용할 수 있습니다.

메모리 공유를 통해 동시성을 달성하는 다른 언어들과 달리, Go 는 채널 (channels) 을 사용하여 서로 다른 고루틴 (goroutines) 간의 통신을 통해 동시성을 구현합니다.

고루틴은 Go 에서 동시성의 단위이며, 경량 스레드 (lightweight threads) 로 생각할 수 있습니다. 기존 스레드와 비교했을 때 고루틴 간 전환 시 더 적은 리소스를 소비합니다.

채널과 고루틴은 함께 Go 의 동시성 기본 요소 (concurrency primitives) 를 구성합니다. 이 섹션에서는 이 새로운 데이터 구조인 채널에 대해 학습할 것입니다.

학습 포인트:

  • 채널의 유형
  • 채널 생성
  • 채널 연산
  • 채널 블로킹
  • 단방향 채널
이것은 가이드 실험입니다. 학습과 실습을 돕기 위한 단계별 지침을 제공합니다.각 단계를 완료하고 실무 경험을 쌓기 위해 지침을 주의 깊게 따르세요. 과거 데이터에 따르면, 이것은 초급 레벨의 실험이며 완료율은 100%입니다.학습자들로부터 100%의 긍정적인 리뷰율을 받았습니다.

채널 개요 (Overview of Channels)

채널은 주로 고루틴 간의 통신에 사용되는 특별한 데이터 구조입니다.

뮤텍스 (mutexes) 나 원자적 함수 (atomic functions) 를 사용하여 리소스에 안전하게 접근하는 다른 동시성 모델과 달리, 채널은 여러 고루틴 간에 데이터를 송수신함으로써 동기화 (synchronization) 를 가능하게 합니다.

Go 에서 채널은 선입선출 (First-In-First-Out, FIFO) 규칙을 따르는 컨베이어 벨트나 큐 (queue) 와 같습니다.

채널 유형 및 선언 (Channel Types and Declaration)

채널은 참조 타입 (reference type) 이며, 선언 형식은 다음과 같습니다.

var channelName chan elementType

각 채널은 특정 타입을 가지며, 동일한 타입의 데이터만 전송할 수 있습니다. 몇 가지 예를 살펴보겠습니다.

~/project 디렉토리에 channel.go 파일을 생성합니다:

cd ~/project
touch channel.go

예를 들어, int 타입의 채널을 선언해 보겠습니다:

var ch chan int

위 코드는 ch라는 이름의 int 채널을 선언합니다.

이 외에도 일반적으로 사용되는 다양한 채널 유형이 있습니다.

channel.go에 다음 코드를 작성합니다:

package main

import "fmt"

func main() {
    var ch1 chan int   // 정수형 채널 선언
    var ch2 chan bool  // 불리언 채널 선언
    var ch3 chan []int // 정수 슬라이스를 전송하는 채널 선언
    fmt.Println(ch1, ch2, ch3)
}

위 코드는 단순히 세 가지 다른 유형의 채널을 선언합니다.

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력 결과는 다음과 같습니다:

<nil> <nil> <nil>

채널 초기화 (Channel Initialization)

맵 (map) 과 마찬가지로, 채널도 선언 후 사용하기 전에 반드시 초기화 (initialize) 해야 합니다.

채널을 초기화하는 구문은 다음과 같습니다:

make(chan elementType)

channel.go에 다음 코드를 작성합니다:

package main

import "fmt"

func main() {
    // 정수형 데이터를 저장하는 채널
    ch1 := make(chan int)

    // 불리언 데이터를 저장하는 채널
    ch2 := make(chan bool)

    // []int 데이터를 저장하는 채널
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력 결과는 다음과 같습니다:

0xc0000b4000 0xc0000b4040 0xc0000b4080

위 코드에서는 세 가지 다른 유형의 채널이 정의되고 초기화되었습니다.

출력을 살펴보면, 맵과 달리 채널 자체를 직접 출력하는 것은 그 내용을 보여주지 않는다는 것을 알 수 있습니다.

채널을 직접 출력하면 채널 자체의 메모리 주소만 표시됩니다. 이는 채널이 포인터와 마찬가지로 메모리 주소에 대한 참조 (reference) 이기 때문입니다.

그렇다면 채널 내부의 값에 접근하고 연산하려면 어떻게 해야 할까요?

채널 작업 (Channel Operations)

채널 연산은 데이터 송신 (sending) 과 수신 (receiving) 을 중심으로 이루어지며, 이는 <- 연산자를 사용하여 수행됩니다. 채널이 실제로 작동하는 것을 보려면, 동시성 (concurrency) 을 위한 Go 의 경량 스레드인 고루틴 (goroutine) 과 함께 사용해야 합니다.

채널은 송신 고루틴과 수신 고루틴을 연결합니다. 둘 중 하나가 준비되지 않으면, 다른 하나는 블록 (block) 됩니다. 완전한 예제를 살펴보겠습니다.

channel.go에 다음 코드를 작성합니다:

package main

import (
    "fmt"
    "time"
)

func main() {
    // 버퍼가 없는 채널 생성
    ch := make(chan int)

    // 익명 함수를 사용하여 새 고루틴 시작
    go func() {
        fmt.Println("Goroutine starts sending...")
        // 채널에 값 10 전송
        ch <- 10
        fmt.Println("Goroutine finished sending.")
    }()

    fmt.Println("Main is waiting for data...")
    // 채널에서 값 수신
    value := <-ch
    fmt.Printf("Main received: %d\n", value)

    // 고루틴이 최종 메시지를 출력할 시간을 주기 위해 잠시 대기
    time.Sleep(time.Second)
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력은 다음과 유사할 것입니다 (순서는 약간 다를 수 있습니다):

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

이 예시에서:

  1. ch := make(chan int)는 버퍼가 없는 채널을 생성합니다. 버퍼가 없는 채널은 통신이 일어나기 위해 송신자와 수신자 모두가 동시에 준비되어야 합니다. 이를 동기화 (synchronization) 라고 합니다.
  2. go func() { ... }()는 새로운 고루틴을 시작합니다. go 키워드는 해당 함수를 main 함수와 동시에 실행합니다.
  3. 고루틴 내부에서 ch <- 10은 값 10을 채널로 전송합니다. 이 연산은 main 고루틴이 수신할 준비가 될 때까지 블록됩니다.
  4. main 함수에서 value := <-ch는 값을 수신합니다. 이 연산은 고루틴이 값을 보낼 때까지 블록됩니다.
  5. 둘 다 준비되면 값이 전송되고, 두 고루틴 모두 블록이 해제되어 실행을 계속합니다. 고루틴이 최종 메시지를 출력할 수 있도록 time.Sleep을 추가했습니다.

채널 닫기 (Closing the Channel)

채널에 더 이상 보낼 값이 없을 때, 송신자는 close() 함수를 사용하여 채널을 닫을 수 있습니다. 채널이 닫힐 때까지 모든 값을 읽는 일반적인 방법은 for range 루프를 사용하는 것입니다.

channel.go를 수정해 보겠습니다:

package main

import "fmt"

func main() {
    // 여러 값을 저장할 버퍼링된 채널
    ch := make(chan int, 3)

    // 데이터를 전송할 고루틴 시작
    go func() {
        fmt.Println("Goroutine sending 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // 모든 값 전송 후 채널 닫기
        close(ch)
        fmt.Println("Goroutine closed the channel.")
    }()

    fmt.Println("Main is receiving data...")
    // 채널이 닫힐 때까지 값을 수신하기 위해 for-range 루프 사용
    for value := range ch {
        fmt.Printf("Main received: %d\n", value)
    }
    fmt.Println("Main finished receiving.")
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력 결과는 다음과 같습니다 (순서는 약간 다를 수 있습니다):

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

이는 일반적인 패턴을 보여줍니다: for range 루프는 채널이 닫혔는지 확인하는 것을 자동으로 처리하며, 닫히면 루프를 종료합니다.

닫힌 채널을 다룰 때 다음 주요 규칙을 기억해야 합니다:

  • 닫힌 채널로 전송을 시도하면 런타임 오류 (panic) 가 발생합니다.
  • 닫히고 비어 있는 채널에서 수신을 시도하면 해당 채널 타입의 제로 값 (zero value) 이 즉시 반환됩니다.
  • 이미 닫힌 채널을 다시 닫으려고 하면 panic 이 발생합니다.
  • 일반적으로 채널은 송신자만 닫아야 하며, 수신자는 절대 닫으면 안 됩니다.

채널 블로킹 (Channel Blocking)

이전 단계에서 보았듯이, 채널 연산은 블록 (block) 될 수 있습니다. 이는 고루틴이 동기화할 수 있도록 하는 핵심 기능입니다. 이를 좀 더 자세히 살펴보겠습니다.

버퍼가 없는 채널 (Unbuffered Channels)

make(chan T)로 생성된 채널은 버퍼가 없습니다 (unbuffered).

ch1 := make(chan int) // 버퍼가 없는 채널

버퍼가 없는 채널은 용량 (capacity) 이 없습니다. 버퍼가 없는 채널에 대한 송신 연산은 다른 고루틴이 수신할 준비가 될 때까지 송신 고루틴을 블록시킵니다. 반대로, 수신 연산은 다른 고루틴이 송신할 준비가 될 때까지 수신 고루틴을 블록시킵니다. 이는 이전 단계의 첫 번째 예제에서 보았듯이 강력한 동기화 지점을 만듭니다.

버퍼링된 채널 (Buffered Channels)

채널은 버퍼링될 수도 있습니다 (buffered). 이는 블록되기 전에 특정 개수의 값을 저장할 수 있는 용량이 있음을 의미합니다.

ch2 := make(chan int, 5) // 용량이 5 인 버퍼링된 채널

버퍼링된 채널의 경우:

  • 송신 연산은 버퍼가 가득 찼을 때만 블록됩니다.
  • 수신 연산은 버퍼가 비었을 때만 블록됩니다.

버퍼가 가득 차지 않았다면, 수신자가 즉시 준비되지 않아도 송신자는 값을 보낼 수 있습니다. 해당 값은 채널의 큐에 저장됩니다.

데드락 예제 (Deadlock Example)

채널의 용량을 초과하면 어떻게 될까요? 프로그램은 데드락 (deadlock) 상태에 빠지게 됩니다. 데드락은 프로그램 내의 모든 고루틴이 결코 일어나지 않을 일을 기다리며 블록될 때 발생합니다.

channel1.go라는 이름의 파일을 생성합니다:

cd ~/project
touch channel1.go

용량이 1 인 채널에 두 개의 값을 보내려고 시도하는 다음 코드를 작성합니다. 이 모든 작업은 main 고루틴 내에서 수행됩니다:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // This succeeds
    fmt.Println("Sent 10 to channel")
    ch <- 20 // This blocks forever because the channel is full
    fmt.Println("succeed")
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel1.go

프로그램 출력 결과는 다음과 같습니다:

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

프로그램은 데드락 오류와 함께 패닉 (panic) 합니다. main 고루틴이 10을 전송하여 버퍼를 채웁니다. 그런 다음 20을 전송하려고 시도하지만 버퍼가 가득 찼기 때문에 블록됩니다. 다른 어떤 고루틴도 채널에서 수신하지 않을 것이므로, main 고루틴은 영원히 블록되고, Go 런타임은 데드락을 감지하게 됩니다.

이를 해결하려면, 버퍼의 공간을 확보하기 위해 값을 수신할 수 있는 다른 고루틴과 연산을 조정해야 합니다.

단방향 채널 (Unidirectional Channels)

기본적으로 채널은 양방향 (bidirectional) 이며 읽기 및 쓰기가 모두 가능합니다. 때로는 채널의 사용을 제한해야 할 필요가 있습니다. 예를 들어, 특정 함수 내에서 채널에 쓰기만 가능하거나 읽기만 가능하도록 보장해야 할 수 있습니다. 이를 어떻게 달성할 수 있을까요?

초기화하기 전에 사용이 제한된 채널을 명시적으로 선언할 수 있습니다.

쓰기 전용 채널 (Write-only Channel)

쓰기 전용 채널이란 특정 함수 내에서 해당 채널에 쓰기만 가능하고 읽을 수는 없음을 의미합니다. int 타입의 쓰기 전용 채널을 선언하고 사용하는 예는 다음과 같습니다:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Data written to channel")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Data received:", <-ch)
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력 결과는 다음과 같습니다:

Data written to channel
Data received: 10

이 예시에서 writeOnly 함수는 채널을 쓰기 전용으로 제한합니다.

읽기 전용 채널 (Read-only Channel)

읽기 전용 채널이란 특정 함수 내에서 해당 채널에서 읽기만 가능하고 쓸 수는 없음을 의미합니다. int 타입의 읽기 전용 채널을 선언하고 사용하는 예는 다음과 같습니다:

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력 결과는 다음과 같습니다:

Data received from channel: 20

이 예시에서 readOnly 함수는 채널을 읽기 전용으로 제한합니다.

쓰기 전용 및 읽기 전용 채널 결합

쓰기 전용 채널과 읽기 전용 채널을 결합하여 제한된 채널을 통해 데이터가 어떻게 흐르는지 시연할 수 있습니다:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    readOnly(ch)
}

다음 명령어를 사용하여 프로그램을 실행합니다:

go run channel.go

프로그램 출력 결과는 다음과 같습니다:

Data written to channel
Data received from channel: 30

이 예제는 쓰기 전용 채널이 어떻게 데이터를 읽기 전용 채널로 전달할 수 있는지 보여줍니다.

요약

본 랩 (lab) 에서는 다음을 포함하여 채널의 기본 사항을 학습했습니다:

  • 채널의 유형 및 선언 방법
  • 채널 초기화 방법
  • 채널에 대한 연산
  • 채널 블로킹 개념
  • 단방향 채널 선언