지난 시간에는 고루틴을 실행하고 채널을 통해 기본적인 데이터를 주고받는 법을 배웠는데요.
사실 채널에는 우리가 미처 다루지 못한 흥미로운 기능들이 훨씬 더 많이 숨어 있습니다.
오늘은 그 깊은 곳까지 한번 파고들어 보겠습니다.
데이터 종료 신호 (End-of-data signaling)#
문자열을 쉼표로 쪼갠 뒤 빈 문자열을 제외하고 출력하는 간단한 프로그램을 예로 들어볼까요?
str := "one,two,,four"
in := make(chan string)
go func() { // (1)
words := strings.Split(str, ",")
for _, word := range words {
in <- word
}
}()
for { // (2)
word := <-in
if word != "" {
fmt.Printf("%s ", word)
}
}
// one two four
고루틴 ➊은 문자열을 단어로 쪼개 in 채널로 보냅니다.
그리고 루프 ➋는 채널에서 단어를 읽어 비어있지 않은 것들만 출력합니다.
언뜻 보면 완벽해 보이지만, 이 프로그램을 실행하면 치명적인 오류가 발생합니다.
fatal error: all goroutines are asleep - deadlock!
문제의 원인은 바로 무한 루프 ➋에 있는데요.
for {
word := <-in
if word != "" {
fmt.Printf("%s ", word)
}
}
도대체 언제 더 이상 단어가 없다는 것을 알고 루프를 빠져나올 수 있을까요?
예전에는 빈 문자열("") 같은 특정한 값을 종료 신호로 약속해서 해결하기도 했습니다.
for {
word := <-in
if word == "" { // 빈 문자열이면 종료?
break
}
}
하지만 지금 예제에서는 빈 문자열도 데이터의 일부로 처리되어야 합니다(물론 건너뛰긴 하지만요).
데이터로서의 빈 값과 종료 신호로서의 빈 값을 구분할 수가 없는 상황입니다.
임시방편으로 __EOF__ 같은 특수한 값을 보내서 해결할 수도 있겠지만, 이건 누가 봐도 억지스럽고 불안정한 해결책입니다.
다행히 Go는 아주 우아한 해결책을 제공하는데, 바로 **채널 닫기(Closing a channel)**입니다.
채널 닫기 (Closing a channel)#
동시성 환경에서 두 주체(작성자와 독자)가 소통할 때 가장 흔한 문제는 “언제 대화가 끝나는가?“인데요.
Go는 이 문제를 아주 명확한 메커니즘으로 해결합니다.
- **작성자(Writer)**는 채널을 닫을 수 있습니다.
- **독자(Reader)**는 채널이 닫혔는지 감지할 수 있습니다.
작성자는 close() 함수를 사용해 채널을 닫습니다.
str := "one,two,,four"
in := make(chan string)
go func() {
words := strings.Split(str, ",")
for _, word := range words {
in <- word
}
close(in) // 다 보냈으니 문을 닫습니다.
}()
독자는 채널에서 값을 읽을 때 두 번째 반환 값을 확인하여 채널의 상태를 알 수 있습니다.
이것을 흔히 “comma OK” 관용구라고 부릅니다.
for {
word, ok := <-in
if !ok { // ok가 false라면 채널이 닫혔다는 뜻입니다.
break
}
if word != "" {
fmt.Printf("%s ", word)
}
}
작성자가 “one”, “two"를 보내고 채널을 닫았다고 가정해 볼까요?
독자가 겪게 될 상황은 다음과 같습니다.
// in <- "one"
word, ok := <-in
// word = "one", ok = true
// in <- "two"
word, ok = <-in
// word = "two", ok = true
// close(in)
word, ok = <-in
// word = "", ok = false
채널이 열려 있는 동안에는 값과 함께 true를 반환받습니다.
하지만 채널이 닫히면, 해당 타입의 제로 값(문자열의 경우 "")과 false를 반환받게 됩니다.
재미있는 점은 닫힌 채널에 대해 계속 읽기를 시도해도 패닉이 발생하지 않고, 계속해서 제로 값과 false를 리턴한다는 것입니다.
하지만 주의해야 할 규칙이 두 가지 있습니다.
- 이미 닫힌 채널을 또 닫으면 패닉이 발생합니다.
- 닫힌 채널에 값을 보내려고 하면 패닉이 발생합니다.
in := make(chan string)
go func() {
in <- "hi"
close(in)
}()
fmt.Println(<-in)
// hi
in <- "bye"
// panic: send on closed channel
그래서 **“채널은 반드시 보내는 쪽(Writer)에서 닫아야 한다”**는 원칙이 생깁니다.
받는 쪽에서 닫아버리면, 보내는 쪽은 영문도 모른 채 값을 보내려다 패닉에 빠질 테니까요.
혹시 “데이터베이스 연결이나 파일처럼 채널도 항상 닫아야 하나?“라는 의문이 드시나요?
정답은 **“아니요”**입니다.
채널은 파일 같은 외부 리소스가 아닙니다.
더 이상 사용되지 않으면 Go의 가비지 컬렉터(GC)가 알아서 수거해 가거든요.
채널을 닫는 유일한 이유는 **“더 이상 보낼 데이터가 없다”**는 신호를 받는 쪽에게 줘야 할 때뿐입니다.
채널 순회 (Channel iteration)#
앞서 우리는 ok 변수를 확인하며 루프를 돌았는데요.
매번 이렇게 확인하는 건 꽤나 번거로운 일입니다.
Go는 for-range 문법을 통해 채널을 아주 편하게 순회할 수 있게 해줍니다.
for word := range in {
if word != "" {
fmt.Printf("%s ", word)
}
}
range는 채널에서 자동으로 다음 값을 읽어오고, 채널이 닫히면 알아서 루프를 종료합니다.
아주 깔끔하죠?
슬라이스를 순회할 때는 인덱스와 값을 함께 주지만, 채널을 순회할 때는 값 하나만 반환한다는 점만 기억해 두세요.
✎ 연습문제: 순회와 닫기#
책에는 연습문제가 포함되어 있어 직접 코드를 짜볼 수 있는데요.
이론만으로는 부족하니, 꼭 직접 타이핑해보시길 권장합니다.
(지금은 번역글이니 넘어가고, 바로 다음 이론으로 가보시죠!)
채널의 방향성 (Directional channels)#
채널을 함수 인자로 넘길 때, 그냥 chan string처럼 넘기면 읽기와 쓰기가 모두 가능해집니다.
그런데 한 달 뒤에 내가 짠 코드를 다시 보거나 다른 동료가 코드를 수정할 때 실수가 발생할 수 있습니다.
예를 들어, 데이터를 받기만 해야 하는 함수에서 실수로 채널을 닫아버린다면요?
func receive(stream chan string) {
for word := range stream {
// ...
}
close(stream) // (!) 으악! 여기서 닫으면 안 됩니다.
}
이런 실수는 컴파일 단계에서는 잡히지 않고, 프로그램이 실행되는 도중에 런타임 에러를 뿜어냅니다.
이런 비극을 막기 위해 Go는 채널의 방향을 지정할 수 있게 해줍니다.
chan(양방향): 읽기/쓰기 모두 가능 (기본)chan<-(송신 전용): 쓰기만 가능<-chan(수신 전용): 읽기만 가능
이제 함수 시그니처를 고쳐볼까요?
// stream은 송신 전용(chan<-)입니다.
func submit(str string, stream chan<- string) {
// ...
stream <- word
close(stream) // 송신 전용 채널은 닫을 수 있습니다.
}
// stream은 수신 전용(<-chan)입니다.
func receive(stream <-chan string) {
for word := range stream {
// ...
}
// close(stream) // 컴파일 에러! 수신 전용은 닫을 수 없습니다.
}
이제 receive 함수 안에서 채널을 닫거나 값을 보내려고 하면 컴파일러가 빨간 줄을 그어줍니다.
이렇게 함수의 파라미터에 채널 방향을 명시하는 것은 런타임 에러를 막는 아주 좋은 습관입니다.
참고로 채널을 생성할 때는 보통 양방향으로 만들고(make(chan int)), 함수에 넘길 때 Go가 알아서 방향에 맞춰 변환해 줍니다.
종료 채널 (Done channel)#
메인 함수가 고루틴보다 먼저 끝나버리는 문제를 해결하기 위해 지난 1편에서는 sync.WaitGroup을 썼었는데요.
이번에는 Done 채널이라는 패턴을 사용해 보겠습니다.
func say(done chan<- struct{}, id int, phrase string) {
for _, word := range strings.Fields(phrase) {
// ... 말하기 로직 ...
}
done <- struct{}{} // (1) 작업 완료 신호 전송
}
func main() {
phrases := []string{ "go is awesome", "cats are cute" }
done := make(chan struct{}) // (2)
for idx, phrase := range phrases {
go say(done, idx+1, phrase) // (3)
}
// 고루틴의 개수만큼 신호를 기다립니다.
for range len(phrases) { // (4)
<-done
}
}
원리는 간단합니다.
각 고루틴이 일이 끝나면 done 채널에 아무 값(보통 메모리를 차지하지 않는 빈 구조체 struct{}{})을 던집니다.
메인 함수는 고루틴을 실행한 횟수만큼 done 채널에서 값을 꺼내며 기다리는 거죠.
이 방식이 취향에 맞지 않다면 sync.WaitGroup을 써도 무방합니다.
데드락 방지 (Preventing deadlocks)#
동시성 프로그래밍의 가장 큰 적, 바로 **데드락(교착 상태)**입니다.
서로가 서로를 기다리느라 아무것도 못 하는 상태를 말하죠.
func work(done chan struct{}, out chan int) {
for i := 1; i <= 5; i++ {
out <- i
}
done <- struct{}{}
}
func main() {
out := make(chan int)
done := make(chan struct{})
go work(done, out) // (1)
<-done // (2)
for n := range out { // (3)
fmt.Println(n)
}
}
위 코드를 보면 work 함수는 out 채널에 값을 보내려고 기다립니다.
그런데 main 함수는 done 채널에서 신호가 오기만을 기다리고(<-done), 그 뒤에야 out을 읽으려고 합니다.
work는 out을 누가 읽어주길 기다리고, main은 work가 끝나기만을 기다리는 전형적인 데드락 상황입니다.
해결책은 의존성을 끊어주는 것인데요.
main 함수에서 done을 기다리는 부분을 별도의 고루틴으로 분리하면 됩니다.
go work(done, out) // (1)
go func() { // (2)
<-done
fmt.Println("work done")
close(out) // 작업이 끝나면 out 채널도 닫아줍니다.
}()
for n := range out { // (3)
fmt.Println(n)
}
데드락을 만나면 당황하지 말고, “누가 누구를 기다리고 있는가"를 차근차근 추적해 보세요.
버퍼 채널 (Buffered channels)#
지금까지 우리가 쓴 채널은 보내는 사람과 받는 사람이 동시에 준비되어야만 데이터가 넘어가는 구조였습니다.
하지만 가끔은 받는 사람이 준비되지 않아도, 보내는 사람이 데이터를 휙 던져두고 제 갈 길을 가고 싶을 때가 있습니다.
마치 우체통처럼요.
이럴 때 사용하는 것이 **버퍼 채널(Buffered Channel)**입니다.
채널을 만들 때 두 번째 인자로 크기를 지정하면 됩니다.
// 3개의 값을 저장할 수 있는 버퍼 채널
stream := make(chan int, 3)
stream <- 1 // 수신자가 없어도 블로킹되지 않음
stream <- 2
stream <- 3
// stream <- 4 // 버퍼가 꽉 찼으므로 여기서 블로킹됨!
버퍼 채널은 내부 큐(Queue)가 꽉 찰 때까지는 보내는 쪽을 막지 않습니다.
반대로 받는 쪽은 버퍼가 텅 빌 때까지는 기다리지 않고 값을 쏙쏙 빼갈 수 있죠.
하지만 버퍼 채널이 만능은 아닙니다.
꼭 필요한 경우(전송 속도와 처리 속도의 불균형 해소 등)가 아니라면, 동기화가 보장되는 일반 채널(Unbuffered channel)을 사용하는 것이 더 안전하고 명확합니다.
async/await 구현해보기#
다른 언어에는 async/await라는 편리한 비동기 키워드가 있는데요.
Go에는 이런 키워드가 없지만, 고루틴과 채널을 쓰면 단 5줄로 흉내 낼 수 있습니다.
// await는 함수 fn을 별도 고루틴에서 실행하고 결과를 기다립니다.
func await(fn func() any) any {
out := make(chan any, 1) // (1) 버퍼 1칸짜리 채널
go func() {
out <- fn() // (2)
}()
return <-out
}
여기서 버퍼가 1인 채널 ➊을 사용한 이유는, 고루틴이 값을 보내고 나서 누군가 받을 때까지 기다리지 않고 바로 종료되게 하기 위함입니다.
이렇게 하면 함수 실행(fn)과 결과 수신을 분리할 수 있죠.
물론 이건 아주 단순화된 예시일 뿐, 실제 Go에서는 이런 패턴보다는 필요한 로직을 직접 고루틴으로 띄우는 것이 더 일반적입니다.
세마포어 (Semaphore)#
고루틴이 아무리 가볍다고 해도, 100만 개의 고루틴을 동시에 띄우는 건 무리일 수 있습니다.
CPU 코어의 수는 제한적이니까요.
동시에 실행되는 고루틴의 개수를 제한하고 싶을 때, 버퍼 채널을 **세마포어(Semaphore)**처럼 사용할 수 있습니다.
예를 들어, 동시에 딱 2개의 작업만 실행하고 싶다면요?
func main() {
// 크기가 2인 세마포어 채널
sema := make(chan struct{}, 2)
for _, phrase := range phrases {
// 채널에 빈 자리가 생길 때까지 대기
sema <- struct{}{}
go func(p string) {
// 작업 수행
say(p)
// 작업이 끝나면 채널에서 값을 하나 빼서 자리를 만듦
<-sema
}(phrase)
}
}
원리는 간단합니다.
- 버퍼 크기가 N인 채널을 만듭니다.
- 작업을 시작하기 전에 채널에 값을 넣습니다 (
sema <- struct{}{}). - 버퍼가 꽉 차면 다음 작업은 자리가 날 때까지 대기하게 됩니다.
- 작업이 끝나면 채널에서 값을 빼냅니다 (
<-sema).
이렇게 하면 동시에 돌아가는 고루틴의 수를 N개로 엄격하게 제어할 수 있습니다.
이 패턴은 실무에서 과도한 리소스 사용을 막기 위해 아주 자주 쓰이니 꼭 기억해 두세요.
버퍼 채널 닫기 & nil 채널#
버퍼 채널을 닫으면 어떻게 될까요?
버퍼에 남아있는 데이터는 사라질까요?
아니요, 다행히 남아있는 데이터는 모두 정상적으로 읽을 수 있습니다.
버퍼가 다 비워지고 나서야 채널이 닫혔다는 신호(false)가 옵니다.
덕분에 보내는 쪽은 데이터를 다 밀어 넣고 쿨하게 close한 뒤 퇴근해도 됩니다.
마지막으로 nil 채널에 대해 짧게 짚고 넘어가죠.
초기화되지 않은(make를 안 한) 채널을 nil 채널이라고 하는데요.
이 녀석은 아주 위험합니다.
- nil 채널에 쓰기: 영원히 대기 (Deadlock)
- nil 채널에서 읽기: 영원히 대기 (Deadlock)
- nil 채널 닫기: 패닉 (Panic)
“그럼 이걸 어디다 써?” 싶겠지만, 나중에 배울 select 문에서 특정 케이스를 비활성화하고 싶을 때 요긴하게 쓰입니다.
그 전까지는 nil 채널을 만들지 않도록 주의하세요.
지금까지 채널의 닫기, 순회, 방향성, 데드락 방지, 버퍼링, 세마포어 패턴까지 숨 가쁘게 달려왔습니다.
이제 고루틴과 채널이라는 강력한 무기를 손에 쥐셨는데요.
다음 장에서는 이 무기들을 조합해 만드는 파이프라인(Pipelines) 패턴에 대해 알아보겠습니다.
기대해 주세요!