[Golang] 채널

2021-12-10 hit count image

Golang에서 채널(Channel)을 사용하여 고루틴간에 메시지를 주고 받는 방법에 대해서 알아보겠습니다.

개요

이번 블로그 포스트에서는 Golang에서 채널(Channel)을 사용하여 고루틴간에 메시지를 주고 받는 방법에 대해서 알아보겠습니다. 이 블로그 포스트에서 소개하는 코드는 다음 링크를 통해 확인하실 수 있습니다.

고루틴한 자세한 정보는 이전 블로그 포스트를 참고하시기 바랍니다.

채널

채널은 고루틴간의 메시지를 전달할 수 있는 메시지 큐(Thread-safe queue)입니다.

Golang에서는 다음과 같이 make()로 채널 인스턴스 생성할 수 있습니다.

// var VARIABLE_NAME chan MESSAGE_TYPE = make(chan MESSAGE_TYPE)
var messages chan string = make(chan string)

채널을 생성하였다면 <- 연산자를 통해 채널에 데이터를 전달할 수 있습니다.

// CHANNEL <- DATA
messages <- "This is a message"

반대로 <- 연산자를 통해 채널에서 데이터를 가져올 수 있습니다.

// var VARIABLE <- CHANNEL
var msg string = <- messages

이를 확인하기 위해, main.go 파일을 생성하고 다음과 같이 수정합니다.

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  ch := make(chan int)

  wg.Add(1)
  go square(&wg, ch)
  time.Sleep(time.Second)

  ch <- 3
  wg.Wait()
}

func square(wg *sync.WaitGroup, ch chan int) {
  fmt.Println("Square start")
  n := <-ch
  fmt.Println("Square:", n*n)
  wg.Done()
  fmt.Println("Square end")
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Square start
Square: 9
Square end

소스 코드를 좀 더 자세히 살펴보면, 우선 WaitGroup을 사용하여 한 개의 서브 고루틴이 종료될 때까지 메인 고루틴을 대기하도록 합니다.

func main() {
  var wg sync.WaitGroup
  ...
  wg.Add(1)
  ...
  wg.Wait()
}

그리고, make 함수를 사용하여 채널을 생성하고, go 키워드를 사용하여 square 함수를 서브 고루틴으로 실행합니다. 이때, WaitGroup과 채널을 함께 전달합니다.

func main() {
  ...
  ch := make(chan int)
  ...
  go square(&wg, ch)
  ...
}

square 함수를 보면 채널에서 값을 읽어와 계산하여 화면에 표시한 후, WaitGroupDone을 이용해, 고루틴이 종료되었음을 알리고 있습니다.

func square(wg *sync.WaitGroup, ch chan int) {
  ...
  n := <-ch
  ...
  wg.Done()
  ...
}

n := <-ch 코드를 만나면, 채널에서 데이터를 가져오게 됩니다. 하지만 현재 채널에 데이터가 없기 때문에, 데이터를 가져올 수 없어 데이터를 가져올 수 있을 때까지 고루틴은 대기하게 됩니다.

이때, 메인 고루틴에서 ch <- 3으로 데이터를 보내면, 서브 고루틴은 채널에서 데이터를 가져올 수 있으므로 서브 고루틴이 수행되고, wg.Done()이 실행되므로 메인 고루틴은 더이상 서브 고루틴을 대기하지 않고 프로그램이 종료되게 됩니다.

채널 크기

채널의 기본 크기는 0입니다. 따라서 다음과 같이 프로그램을 작성하면, 고루틴은 채널에 있는 데이터를 가져갈 때까지 계속 대기하게 됩니다.

package main

import (
  "fmt"
  "time"
)

func main() {
  ch := make(chan int)

  go square()
  ch <- 9
  fmt.Println("Never Print")
}

func square() {
  for {
    time.Sleep(2 * time.Second)
    fmt.Println("sleep")
  }
}

채널의 사이즈가 0이기 때문에, 고루틴은 채널에 데이터를 추가할 수 없습니다. 데이터를 가져가는 코드가 있다면, 바로 전달할 수 있기 때문에 문제가 없지만, 데이터를 가져가는 코드를 추가하지 않으면, 고루틴은 데이터를 추가할 수 없기 때문에 계속 대기하게 됩니다.

이제 다음과 같이 채널의 사이즈를 지정하여 채널을 생성해 봅시다.

package main

import (
  "fmt"
  "time"
)

func main() {
  // ch := make(chan int)
  ch := make(chan int, 1)

  go square()
  ch <- 9
  fmt.Println("Never Print")
}

func square() {
  for {
    time.Sleep(2 * time.Second)
    fmt.Println("sleep")
  }
}

채널에 크기가 지정되면 해당 크기만큼 데이터를 추가할 수 있습니다. 따라서 고루틴은 채널에 데이터를 가져가는 코드가 없어도, 채널에 데이터를 저장할 수 있으므로 채널에 데이터를 저장한 후, 계속 진행할 수 있게 됩니다.

채널에서 데이터 대기

다음과 같이 채널을 사용하면, 데이터가 추가될 때까지 고루틴을 대기하게 만들 수 있습니다.

for n := range ch {
  ...
}

이를 확인하기 위해서 main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  ch := make(chan int)

  wg.Add(1)
  go square(&wg, ch)

  for i := 0; i < 10; i++ {
    ch <- i * 2
  }

  wg.Wait()
}

func square(wg *sync.WaitGroup, ch chan int) {
  for n := range ch {
    fmt.Println("Square:", n*n)
    time.Sleep(time.Second)
  }
  fmt.Println("Done")
  wg.Done()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
fatal error: all goroutines are asleep - deadlock!

메인 함수에서는 채널에 데이터를 추가하게 됩니다. 하지만, 채널 사이즈가 0이기 때문에, 채널에서 데이터를 읽는 코드가 없다면 계속 대기하게 될 것입니다. 이때 서브 고루틴으로 실행된 square 함수에서 채널에 데이터가 존재하는 경우 데이터를 읽어오게 됩니다.

그러니깐, 메인 함수에서 데이터를 연속적으로 10번 추가하고 싶은데, 채널 사이즈가 0이므로 기다리게 됩니다. 이때, square 함수에서 데이터가 있는 경우, 데이터를 읽어오는 코드가 있어, 메인 함수에서 데이터를 한번 쓰고, square 함수에서 데이터를 가져와 출력할 수 있게 됩니다. 그럼 다시 메인 함수는 채널에 데이터를 추가할 수 있게 되고, square함수는 이를 다시 읽어오게 됩니다.

이것이 반복되며 10번이 반복되면, 메인 함수는 더이상 채널에 데이터를 추가하지 않습니다. 하지만 square 함수는 채널에 데이터를 계속 읽어오도록 대기하는 코드가 존재하기 때문에, 서브 고루틴이 종료되지 않고, 어떠한 동작도 수행하지 않기 때문에 데드락(Deadlock)이 발생한 것을 확인할 수 있습니다.

채널 닫기

이처럼 고루틴에서 채널에 데이터를 계속 기다리는 코드를 사용하면, 고루틴이 무한 대기하는 좀비 고루틴 또는 고루틴 릭(Leak)이 발생하게 됩니다.

이를 방지하기 위해서는 다 사용한 채널은 close 함수를 통해 채널을 닫아주어야 합니다.

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  ch := make(chan int)

  wg.Add(1)
  go square(&wg, ch)

  for i := 0; i < 10; i++ {
    ch <- i * 2
  }
  close(ch)

  wg.Wait()
}

func square(wg *sync.WaitGroup, ch chan int) {
  for n := range ch {
    fmt.Println("Square:", n*n)
    time.Sleep(time.Second)
  }
  fmt.Println("Done")
  wg.Done()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
Done

이전과 다르게 데드락이 발생하지 않는 것을 확인할 수 있습니다. 메인 함수에서 채널에 메시지를 모두 추가한 후 close(ch)를 통해 채널을 닫습니다. 그럼 square 함수는 더이상 채널에서 데이터를 가져오기 위해 대기할 필요가 없으므로 서브 고루틴이 정상 종료되게 됩니다. 따라서 이전과는 다르게 데드락이 발생하지 않습니다.

select 문

다음과 같이 select문을 사용하면 여러 채널에 데이터를 동시에 기다리며 데이터를 가져올 수 있습니다.

select {
  case n := <- ch1:
    ...
  case n := <- ch2:
    ...
  case ...
}

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  ch := make(chan int)

  wg.Add(1)
  go square(&wg, ch)

  for i := 0; i < 10; i++ {
    ch <- i * 2
  }
  close(ch)

  wg.Wait()
}

func square(wg *sync.WaitGroup, ch chan int) {
  tick := time.Tick(time.Second)
  terminate := time.After(10 * time.Second)

  for {
    select {
    case <-tick:
      fmt.Println("Tick")
    case <-terminate:
      fmt.Println("Terminate")
      wg.Done()
      return
    case n := <-ch:
      fmt.Println("Square:", n*n)
      time.Sleep(time.Second)
    }
  }
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Square: 0
Square: 4
Square: 16
Tick
Square: 36
Square: 64
Tick
Square: 100
Tick
Square: 144
Tick
Square: 196
Tick
Square: 256
Square: 324
Square: 0
Terminate

time 패키지의 Tick()은 일정 간격으로 신호를 주는 채널을 반환합니다. 또한 After()는 일정 시간 대기후 한번만 신호를 주는 채널을 반환하게 됩니다. 여기서 select 문을 통해 square 함수가 여러 채널에 데이터를 동시에 기다리고 동작하도록 구현할 수 있었습니다.

단방향 채널

Golang에서는 다음과 같이 단방향 채널을 생성할 수 있습니다.

// 데이터를 넣을 수만 있는 채널
chan <- string
// 데이터를 빼낼 수만 있는 채널
<- chan string

이를 확인하기 위해 다음과 같이 main.go 파일을 수정합니다.

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  ch := make(chan int)

  wg.Add(1)
  go square(&wg, ch)

  for i := 0; i < 10; i++ {
    ch <- i * 2
  }
  close(ch)

  wg.Wait()
}

func square(wg *sync.WaitGroup, ch <-chan int) {
  for n := range ch {
    fmt.Println("Square:", n*n)
    time.Sleep(time.Second)
  }
  fmt.Println("Done")
  wg.Done()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Square: 0
Square: 4
Square: 16
Square: 36
Square: 64
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
Done

단방향 채널은 채널의 역할을 명확하게 하기 위해서 사용합니다. 예제와 같이 square 함수에서 채널은 단순히 데이터를 읽어오기 위해 사용합니다. 이때 단방향 채널을 사용하여 명확하게 이를 지정할 수 있습니다. 그럼 실수로 square 함수에서 채널에 데이터를 추가하는 코드를 작성하더라도 컴파일 에러가 발생하기 때문에 안정하게 채널을 사용할 수 있게 됩니다.

완료

이번 블로그 포스트에서는 Golang에서 채널을 사용하여 고루틴간에 데이터를 주고 받는 방법에 대해서 알아보았습니다. 또한 채널의 크기 조절, 데이터를 계속 기다리는 채널 등 채널을 활용하는 다양한 방법에 대해서도 알아보았습니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.

스무디 한 잔 마시며 끝내는 React Native, 비제이퍼블릭
스무디 한 잔 마시며 끝내는 리액트 + TDD, 비제이퍼블릭
[심통]현장에서 바로 써먹는 리액트 with 타입스크립트 : 리액트와 스토리북으로 배우는 컴포넌트 주도 개발, 심통
Posts