Go 채널(Channel) : 고루틴 간 안전한 통신하기
Go 언어의 채널(Channel)은 고루틴 간의 안전한 데이터 교환을 위한 핵심 도구입니다. "Don't communicate by sharing memory; share memory by communicating"이라는 Go의 철학을 구현하는 대표적인 기능이죠. 이번 글에서는 채널의 기본 개념부터 고급 사용법까지 실용적인 예제와 함께 살펴보겠습니다.
채널이란 무엇인가?
채널은 고루틴 간에 데이터를 주고받을 수 있는 파이프라인입니다. 한 고루틴에서 채널에 값을 보내면, 다른 고루틴에서 그 값을 받을 수 있습니다. 이를 통해 메모리 공유 없이도 안전하게 데이터를 교환할 수 있습니다.
기본 채널 사용법
채널 생성과 기본 송수신
package main
import "fmt"
func main() {
// int 타입 채널 생성
ch := make(chan int)
// 고루틴에서 채널에 값 전송
go func() {
ch <- 42 // 채널에 42 전송
}()
// 메인 고루틴에서 값 수신
value := <-ch // 채널에서 값 수신
fmt.Println("받은 값:", value) // 출력: 받은 값: 42
}
채널을 이용한 고루틴 동기화
package main
import (
"fmt"
"time"
)
func worker(id int, done chan bool) {
fmt.Printf("작업자 %d 시작\n", id)
time.Sleep(time.Second)
fmt.Printf("작업자 %d 완료\n", id)
done <- true // 작업 완료 신호
}
func main() {
done := make(chan bool, 1)
go worker(1, done)
<-done // 작업 완료까지 대기
fmt.Println("모든 작업 완료")
}
버퍼드 채널 vs 언버퍼드 채널
언버퍼드 채널 (Synchronous)
package main
import "fmt"
func main() {
ch := make(chan string) // 버퍼 크기 0 (기본값)
go func() {
ch <- "동기화된 메시지"
}()
msg := <-ch
fmt.Println(msg)
}
버퍼드 채널 (Asynchronous)
package main
import "fmt"
func main() {
ch := make(chan string, 2) // 버퍼 크기 2
// 버퍼가 가득 찰 때까지 블로킹되지 않음
ch <- "첫 번째 메시지"
ch <- "두 번째 메시지"
fmt.Println(<-ch) // 첫 번째 메시지
fmt.Println(<-ch) // 두 번째 메시지
}
채널 방향성 제어
함수 매개변수에서 채널의 방향을 제한할 수 있습니다.
package main
import "fmt"
// 송신 전용 채널
func sender(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
// 수신 전용 채널
func receiver(ch <-chan int) {
for value := range ch {
fmt.Printf("받은 값: %d\n", value)
}
}
func main() {
ch := make(chan int)
go sender(ch)
receiver(ch)
}
Select 문을 이용한 다중 채널 처리
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "채널 1에서 메시지"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "채널 2에서 메시지"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("ch2:", msg2)
case <-time.After(3 * time.Second):
fmt.Println("타임아웃!")
return
}
}
}
실용적인 예제: Worker Pool 패턴
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("작업자 %d가 작업 %d 시작\n", id, job)
// 랜덤한 작업 시간 시뮬레이션
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
fmt.Printf("작업자 %d가 작업 %d 완료\n", id, job)
results <- job * 2 // 작업 결과 전송
}
}
func main() {
const numJobs = 5
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
var wg sync.WaitGroup
// 워커 고루틴 시작
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// 작업 전송
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// 모든 워커가 완료될 때까지 대기
go func() {
wg.Wait()
close(results)
}()
// 결과 수집
for result := range results {
fmt.Printf("결과: %d\n", result)
}
}
채널을 이용한 팬아웃/팬인 패턴
package main
import (
"fmt"
"sync"
)
// 팬아웃: 하나의 입력을 여러 고루틴으로 분산
func fanOut(input <-chan int) (<-chan int, <-chan int, <-chan int) {
out1 := make(chan int)
out2 := make(chan int)
out3 := make(chan int)
go func() {
defer close(out1)
defer close(out2)
defer close(out3)
for value := range input {
// 모든 출력 채널에 동일한 값 전송
out1 <- value
out2 <- value
out3 <- value
}
}()
return out1, out2, out3
}
// 팬인: 여러 입력을 하나의 출력으로 합병
func fanIn(inputs ...<-chan int) <-chan int {
output := make(chan int)
var wg sync.WaitGroup
// 각 입력 채널에 대해 고루틴 생성
for _, input := range inputs {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for value := range ch {
output <- value
}
}(input)
}
// 모든 입력이 완료되면 출력 채널 닫기
go func() {
wg.Wait()
close(output)
}()
return output
}
func main() {
input := make(chan int)
// 팬아웃
out1, out2, out3 := fanOut(input)
// 팬인
result := fanIn(out1, out2, out3)
// 입력 데이터 전송
go func() {
defer close(input)
for i := 1; i <= 3; i++ {
input <- i
}
}()
// 결과 출력
for value := range result {
fmt.Printf("결과: %d\n", value)
}
}
채널 사용 시 주의사항
1. 데드락 방지
// 잘못된 예제 - 데드락 발생
func badExample() {
ch := make(chan int)
ch <- 42 // 수신자가 없어서 영원히 블로킹됨
fmt.Println(<-ch)
}
// 올바른 예제
func goodExample() {
ch := make(chan int, 1) // 버퍼드 채널 사용
ch <- 42
fmt.Println(<-ch)
}
2. 채널 닫기
func channelClosing() {
ch := make(chan int, 3)
// 데이터 전송
ch <- 1
ch <- 2
ch <- 3
close(ch) // 더 이상 전송하지 않음을 알림
// 채널이 닫혀도 남은 데이터는 수신 가능
for value := range ch {
fmt.Println(value)
}
// 닫힌 채널에서 수신 시 zero value와 false 반환
value, ok := <-ch
fmt.Printf("값: %d, 열려있음: %t\n", value, ok)
}
성능 고려사항
채널을 사용할 때는 다음과 같은 성능 특성을 고려해야 합니다:
- 언버퍼드 채널은 동기화 오버헤드가 있지만 메모리 사용량이 적습니다
- 버퍼드 채널은 비동기 처리가 가능하지만 메모리를 더 사용합니다
- 채널 연산은 뮤텍스보다 약간 느리지만 더 안전하고 읽기 쉽습니다
결론
Go의 채널은 동시성 프로그래밍을 위한 강력하고 우아한 도구입니다. 적절히 사용하면 복잡한 동시성 로직을 명확하고 안전하게 구현할 수 있습니다. 채널의 다양한 패턴을 익히고 적재적소에 활용한다면, 더욱 견고하고 효율적인 Go 프로그램을 작성할 수 있을 것입니다.
핵심은 "메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라"는 Go의 철학을 이해하고 실천하는 것입니다. 채널을 통한 명시적인 통신은 코드의 의도를 명확하게 만들고, 경쟁 상태와 같은 동시성 버그를 예방하는 데 큰 도움이 됩니다.
'개발 > go' 카테고리의 다른 글
Go로 크로스 플랫폼 WiFi 스캐너 (0) | 2025.05.28 |
---|---|
Go(golang) 테트리스 (0) | 2025.04.07 |
Golang으로 화면보호기와 절전 모드 방지하기 (0) | 2025.03.24 |