Go의 채널은 goroutine 사이에서 값을 전달하면서 실행 순서를 동기화하는 기능입니다. context.Context의 Done()도 채널을 사용하지만, 일반적인 데이터 전달용 채널과는 목적과 사용 방법이 다릅니다.
다음 예제는 3초가 걸리는 작업을 실행하면서, 2초 안에 완료되지 않으면 컨텍스트의 타임아웃으로 작업 대기를 종료합니다.
package main
import (
"context"
"fmt"
"time"
)
func run(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
fmt.Println("작업 완료")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
err := run(ctx)
fmt.Println("결과:", err)
}실행 결과는 약 2초 후 다음과 같습니다.
결과: context deadline exceededtime.After(3 * time.Second)가 준비되기 전에 2초짜리 컨텍스트 타임아웃이 먼저 발생하기 때문입니다.
채널의 송신과 수신
채널에서 사용하는 <-는 화살표 방향에 따라 의미가 달라집니다.
ch <- value값이 채널 방향으로 이동하므로 채널에 값을 보내는 송신입니다.
value := <-ch값이 채널에서 변수 방향으로 이동하므로 채널에서 값을 받는 수신입니다.
수신할 값이 아직 없다면 해당 goroutine은 값이 준비될 때까지 대기합니다.
package main
import (
"fmt"
"time"
)
func main() {
messageCh := make(chan string)
go func() {
time.Sleep(2 * time.Second)
messageCh <- "작업 완료"
}()
fmt.Println("메시지를 기다립니다.")
message := <-messageCh
fmt.Println(message)
}실행 흐름은 다음과 같습니다.
메시지를 기다립니다.
약 2초 동안 message := <-messageCh에서 대기
작업 완료버퍼가 없는 채널에서는 송신자와 수신자가 모두 준비되어야 값 전달이 완료됩니다. 버퍼가 있는 채널에서는 버퍼에 빈 공간이 있으면 송신자가 즉시 값을 넣을 수 있고, 버퍼에 값이 있으면 수신자가 즉시 값을 받을 수 있습니다.
ch := make(chan int, 1)
ch <- 10
value := <-ch
fmt.Println(value) // 10채널의 방향은 타입으로 제한할 수 있습니다.
chan intchan int는 송신과 수신이 모두 가능한 양방향 채널입니다.
chan<- intchan<- int는 송신 전용 채널입니다.
<-chan int<-chan int는 수신 전용 채널입니다.
함수의 매개변수에 채널 방향을 명시하면 잘못된 사용을 컴파일 단계에서 방지할 수 있습니다.
package main
import "fmt"
func sendValue(ch chan<- int) {
ch <- 100
}
func receiveValue(ch <-chan int) {
value := <-ch
fmt.Println(value)
}
func main() {
ch := make(chan int)
go sendValue(ch)
receiveValue(ch)
}sendValue는 채널에 값을 보낼 수만 있고, receiveValue는 채널에서 값을 받을 수만 있습니다.
ctx.Done()이 반환하는 채널
context.Context 인터페이스의 Done 메서드는 다음 타입의 채널을 반환합니다.
Done() <-chan struct{}<-chan struct{}는 빈 구조체를 요소로 사용하는 수신 전용 채널입니다.
doneCh := ctx.Done()doneCh로는 수신만 할 수 있습니다.
<-doneCh다음과 같은 송신은 허용되지 않습니다.
doneCh <- struct{}{} // 컴파일 오류직접 닫는 것도 허용되지 않습니다.
close(doneCh) // 컴파일 오류ctx.Done()은 데이터를 전달하기 위한 채널이 아닙니다. 컨텍스트가 취소되었다는 사실을 알리는 신호용 채널입니다. 취소가 발생하면 컨텍스트 구현이 내부적으로 Done 채널을 닫습니다.
cancel() 호출, timeout 만료 또는 부모 context 취소
↓
Done 채널 닫힘
↓
<-ctx.Done()의 대기 상태 해제따라서 다음 코드는 값을 받는 것보다 취소 신호를 기다린다는 의미로 이해하는 것이 적절합니다.
case <-ctx.Done():
return ctx.Err()컨텍스트가 아직 취소되지 않았다면 이 수신 연산은 대기합니다. 컨텍스트가 취소되어 Done 채널이 닫히면 수신이 즉시 가능해져 해당 case가 실행됩니다.
ctx.Err()는 취소 원인을 반환합니다.
context.DeadlineExceeded설정된 기한이나 타임아웃이 지나서 취소된 경우입니다.
context.Canceledcancel()을 직접 호출하는 등의 이유로 취소된 경우입니다.
타임아웃 예제의 실행 과정
다음 코드는 2초의 제한 시간을 가진 자식 컨텍스트를 만듭니다.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()실행 흐름은 다음과 같습니다.
main 시작
↓
2초 timeout을 가진 context 생성
↓
run(ctx) 호출
↓
select가 두 채널을 기다림
├─ time.After(3초)
└─ ctx.Done()
↓
2초 후 timeout 만료
↓
context가 Done 채널을 닫음
↓
case <-ctx.Done() 실행
↓
ctx.Err()가 context.DeadlineExceeded 반환
↓
run 종료
↓
main 종료 직전에 defer cancel() 실행여기서 타임아웃이 발생할 때 사용자가 받은 cancel 함수를 Go가 다시 호출하는 것은 아닙니다. WithTimeout이 설정한 내부 타이머가 컨텍스트를 취소 상태로 만들고 Done 채널을 닫습니다.
그 후 main 함수가 끝날 때 defer cancel()이 실행됩니다. 이때 컨텍스트는 이미 취소된 상태지만 문제는 발생하지 않습니다.
CancelFunc는 처음 호출된 이후의 호출이 아무 작업도 하지 않도록 정의되어 있으므로 여러 번 호출해도 안전합니다.
cancel()
cancel()
cancel()이 코드는 panic을 발생시키지 않습니다.
cancel()을 여러 번 호출하는 것은 같은 채널에close()를 여러 번 직접 호출하는 것과 다릅니다.context구현이 최초 취소만 처리하고 이후 호출을 무시합니다.
타임아웃이 자동으로 컨텍스트를 취소하더라도 defer cancel()은 유지하는 것이 좋습니다. 함수가 타임아웃보다 먼저 끝났을 때 연관된 타이머와 부모 컨텍스트의 참조를 더 일찍 정리할 수 있기 때문입니다.
일반 채널을 닫을 때의 규칙
일반 채널은 close 함수로 닫을 수 있습니다.
ch := make(chan int)
close(ch)채널을 닫는다는 것은 앞으로 새로운 값을 보내지 않겠다고 알리는 것입니다. 채널에 이미 들어 있던 값은 닫힌 후에도 모두 받을 수 있습니다.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
fmt.Println(<-ch) // 10
fmt.Println(<-ch) // 20
}닫힌 채널에 남은 값까지 모두 수신한 후 다시 수신하면 채널 요소 타입의 기본값이 즉시 반환됩니다.
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
value := <-ch
fmt.Println(value) // 0
}기본값 0이 실제로 전송된 값인지, 닫힌 채널에서 반환된 값인지 구분하려면 두 번째 반환값을 확인합니다.
value, ok := <-chok의 의미는 다음과 같습니다.
| 상태 | value |
ok |
|---|---|---|
| 정상적으로 값을 수신함 | 전송된 값 | true |
| 채널이 닫혔지만 남은 값을 수신함 | 남아 있던 값 | true |
| 채널이 닫혔고 남은 값도 없음 | 요소 타입의 기본값 | false |
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 10
close(ch)
value, ok := <-ch
fmt.Println(value, ok) // 10 true
value, ok = <-ch
fmt.Println(value, ok) // 0 false
}닫힌 채널에 값을 보내면 실행 중 panic이 발생합니다.
ch := make(chan int)
close(ch)
ch <- 10 // panic: send on closed channel이미 닫힌 채널을 다시 닫아도 panic이 발생합니다.
ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel수신 전용 채널은 직접 닫을 수 없습니다.
func receive(ch <-chan int) {
close(ch) // 컴파일 오류
}close는 양방향 채널이나 송신 전용 채널에만 사용할 수 있습니다.
여러 송신자가 있는 채널은 누가 닫아야 하는가
여러 goroutine이 하나의 채널에 값을 보낼 수는 있습니다.
ch := make(chan int)
go func() {
ch <- 10
}()
go func() {
ch <- 20
}()하지만 각 송신자가 임의로 채널을 닫으면 다른 송신자가 닫힌 채널에 값을 보내거나 채널을 다시 닫으면서 panic이 발생할 수 있습니다.
ch := make(chan int)
go func() {
ch <- 10
close(ch)
}()
go func() {
ch <- 20
close(ch)
}()이 코드는 실행 순서에 따라 send on closed channel 또는 close of closed channel panic이 발생할 수 있습니다.
채널은 일반적으로 모든 송신이 끝났음을 확실히 아는 주체가 한 번만 닫아야 합니다.
여러 송신 goroutine을 사용하는 경우 sync.WaitGroup으로 모든 송신이 끝났는지 확인한 뒤 별도의 goroutine에서 채널을 닫을 수 있습니다.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
resultCh := make(chan int)
for workerID := 1; workerID <= 3; workerID++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
resultCh <- id * 10
}(workerID)
}
go func() {
wg.Wait()
close(resultCh)
}()
for result := range resultCh {
fmt.Println(result)
}
}실행 결과의 순서는 goroutine 스케줄링에 따라 달라질 수 있지만 10, 20, 30이 각각 한 번씩 출력됩니다.
10
30
20for range는 채널에서 값을 계속 받다가 채널이 닫히고 남은 값도 없어지면 종료됩니다.
for result := range resultCh {
fmt.Println(result)
}채널을 닫지 않으면 모든 값을 받은 후에도 for range는 다음 값을 기다리며 계속 대기할 수 있습니다.
채널을 닫는 주체는 보통 송신 측입니다. 수신자는 다른 송신이 더 남아 있는지 알지 못할 수 있으므로 임의로 채널을 닫지 않는 것이 안전합니다.
select에서 채널을 기다리는 방법
select는 여러 채널 연산 중 실행 가능한 연산 하나를 선택합니다.
select {
case value := <-resultCh:
fmt.Println("결과:", value)
case <-ctx.Done():
fmt.Println("취소:", ctx.Err())
}resultCh에서 값이 먼저 준비되면 첫 번째 case가 실행됩니다. 컨텍스트 취소가 먼저 발생하면 두 번째 case가 실행됩니다.
실행 가능한 case가 하나도 없고 default도 없다면 select는 하나 이상의 채널 연산이 가능해질 때까지 대기합니다.
둘 이상의 case가 동시에 실행 가능하면 Go가 실행 가능한 항목 중 하나를 선택합니다. 소스 코드에 먼저 작성된 case가 항상 우선되는 것은 아닙니다.
다음 예제는 작업 결과와 취소 신호를 함께 처리합니다.
package main
import (
"context"
"fmt"
"time"
)
func execute(ctx context.Context) (string, error) {
resultCh := make(chan string, 1)
go func() {
time.Sleep(3 * time.Second)
resultCh <- "작업 완료"
}()
select {
case result := <-resultCh:
return result, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
result, err := execute(ctx)
if err != nil {
fmt.Println("실패:", err)
return
}
fmt.Println(result)
}출력은 다음과 같습니다.
실패: context deadline exceeded이 예제에서 resultCh를 버퍼 크기 1로 만든 이유는 execute가 타임아웃으로 먼저 반환된 후에도 작업 goroutine이 결과를 한 번 보낼 수 있도록 하기 위해서입니다.
버퍼가 없는 채널을 사용하면 수신자가 이미 사라진 상태에서 작업 goroutine이 송신을 계속 기다리는 상황이 생길 수 있습니다.
resultCh := make(chan string) // 수신자가 없으면 송신이 계속 대기할 수 있음다만 실제 작업 자체를 즉시 중단하려면 goroutine 내부 작업도 ctx.Done()을 확인하도록 작성해야 합니다. context는 작업을 강제로 종료하지 않으며, 작업 코드가 취소 신호를 확인하고 스스로 반환해야 합니다.
package main
import (
"context"
"fmt"
"time"
)
func execute(ctx context.Context) error {
for step := 1; step <= 5; step++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
fmt.Println("작업 단계:", step)
}
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
err := execute(ctx)
fmt.Println("결과:", err)
}실행 시점의 경계에서는 타이머 준비 순서에 따라 출력되는 단계 수가 달라질 수 있지만, 타임아웃이 관찰되면 함수는 context deadline exceeded를 반환합니다.
전체 예제
다음 예제는 일반 채널의 값 전달, 채널 종료, ok 확인, 컨텍스트 타임아웃을 한 프로그램에서 확인할 수 있도록 구성했습니다.
package main
import (
"context"
"fmt"
"sync"
"time"
)
func produceValues(out chan<- int) {
defer close(out)
for value := 1; value <= 3; value++ {
out <- value
}
}
func consumeValues(in <-chan int) {
for {
value, ok := <-in
if !ok {
fmt.Println("값 채널이 닫혔습니다.")
return
}
fmt.Println("수신:", value)
}
}
func run(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
fmt.Println("작업 완료")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
valueCh := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
consumeValues(valueCh)
}()
produceValues(valueCh)
wg.Wait()
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
err := run(ctx)
fmt.Println("결과:", err)
}실행 결과는 다음과 비슷합니다.
수신: 1
수신: 2
수신: 3
값 채널이 닫혔습니다.
결과: context deadline exceeded이 예제에서 채널별 역할은 다음과 같습니다.
| 채널 | 타입 | 닫는 주체 | 목적 |
|---|---|---|---|
valueCh |
chan int |
produceValues |
정수 값 전달 |
ctx.Done() |
<-chan struct{} |
context 내부 |
취소 신호 전달 |
time.After(...) |
<-chan time.Time |
타이머 내부 관리 | 시간이 지난 사실 알림 |
valueCh는 애플리케이션 코드가 만들고 송신자가 닫습니다. ctx.Done()은 컨텍스트가 내부적으로 닫으며 애플리케이션 코드는 수신만 합니다. time.After가 반환하는 채널도 수신 전용이므로 애플리케이션에서 값을 보내거나 닫지 않습니다.
정리
Go 채널의 기본 동작은 다음과 같습니다.
ch <- value채널에 값을 보냅니다. 송신이 완료될 조건이 충족되지 않으면 대기합니다.
value := <-ch채널에서 값을 받습니다. 값이 준비되지 않았다면 대기합니다.
value, ok := <-ch값과 함께 채널이 닫혀 더 이상 값이 없는 상태인지 확인합니다.
close(ch)앞으로 값을 보내지 않겠다고 알립니다. 닫힌 채널에 송신하거나 이미 닫힌 채널을 다시 닫으면 panic이 발생합니다.
<-ctx.Done()컨텍스트가 취소될 때까지 기다립니다. Done()은 수신 전용 채널을 반환하며, 사용자가 값을 보내거나 직접 닫을 수 없습니다.
cancel()컨텍스트 취소를 요청합니다. 같은 CancelFunc를 여러 번 호출해도 첫 호출 이후에는 아무 작업도 하지 않으므로 안전합니다.
일반 채널의 close(ch)와 컨텍스트의 cancel()은 동일한 동작이 아닙니다. 일반 채널은 애플리케이션이 채널 종료 책임을 직접 설계해야 하지만, ctx.Done() 채널은 context가 취소 상태와 중복 호출을 내부적으로 관리합니다.
참고 자료
- Go 공식
context패키지 문서: https://pkg.go.dev/context - Go 언어 명세 - Channel types: https://go.dev/ref/spec#Channel_types
- Go 언어 명세 - Receive operator: https://go.dev/ref/spec#Receive_operator
- Go 언어 명세 - Send statements: https://go.dev/ref/spec#Send_statements
- Go 언어 명세 - Close: https://go.dev/ref/spec#Close
- Go 언어 명세 - Select statements: https://go.dev/ref/spec#Select_statements
- Go 공식
time패키지 문서: https://pkg.go.dev/time
'프로그래밍 > Go' 카테고리의 다른 글
| Go JSON과 XML 처리하기 (0) | 2026.07.28 |
|---|---|
| Go 공유 상태 관리: atomic, Mutex, owner goroutine (0) | 2026.07.27 |
| Go Worker Pool, WaitGroup, Rate Limiting (0) | 2026.07.27 |
| Go Timer와 Ticker (0) | 2026.07.24 |
| Go panic과 recover (0) | 2026.07.23 |
댓글