채널 문제는 보통 값 전달보다 종료 권한과 취소 순서에서 발생합니다. 생산 완료를 알리는 close, 소비자가 더 이상 기다리지 않는다는 context 취소, 생산자 전체가 반환했다는 WaitGroup 신호는 서로 대체할 수 없습니다. 이 세 신호를 분리하면 panic과 종료 교착의 원인을 상태 전이로 좁힐 수 있습니다.
Close의 송신 종료 표시와 생산자 중단 제어
수신자가 화면 종료로 더 이상 결과를 원하지 않아 채널을 닫았는데 생산자가 아직 send 중이면 panic이 날 수 있습니다. close는 앞으로 더 이상 값을 보내지 않는다는 생산 측의 약속입니다. 소비 중단은 별도 context 취소로 전달하고, 모든 송신이 끝났음을 아는 주체만 close해야 합니다.
여러 생산자가 같은 결과 채널을 쓴다면 각자 자기 작업이 끝났다고 close하면 안 됩니다. 조정자가 전체 생산자의 종료를 기다린 뒤 한 번 닫는 구조가 단순합니다. 채널은 close해야만 GC가 회수하는 자원이 아니므로 종료 통지가 필요 없는 채널까지 습관적으로 닫을 필요는 없습니다.
두 사건을 순서대로 놓으면 의미가 선명합니다. 버퍼가 꽉 찬 상태에서 sendResult는 out <- value와 ctx.Done() 중 준비된 경로를 택하고, 둘 다 준비되어 있으면 소스 순서나 먼저 준비된 시각에 따른 우선권은 없으며, 취소 경로가 반환한 뒤에도 다른 goroutine이 송신 중이면 close는 아직 안전하지 않습니다. 따라서 반환 카운트가 모두 모인 뒤 조정자가 close해야 합니다.
송신 대기의 취소 경로
func sendResult(ctx context.Context, out chan<- int, value int) bool {
select {
case out <- value:
return true
case <-ctx.Done():
return false
}
}
이 함수는 context·채널 타입을 보여 주는 작은 예제입니다. out을 닫는 주체는 모든 sendResult 호출이 끝나기 전에 close하지 않는다는 외부 계약을 지켜야 합니다. select가 send-on-closed-channel을 안전한 실패로 바꿔 주지는 않습니다.
결과가 하나라면 버퍼 1개로 송신자가 수신자 없이 완료할 수 있게 하는 선택도 있습니다. 그러나 무한 결과 스트림·취소를 무시하는 외부 API를 버퍼 하나로 해결할 수는 없습니다. 남은 결과의 drain·폐기·내구 재처리 정책을 명시합니다.
그림을 글로 읽기
- 소비 종료·생산자 오류
- context 취소 전파
- 모든 생산자 종료 대기
- 조정자가 out을 close
- 소비 종료·생산자 오류 → context 취소 전파: 종료 요청
- context 취소 전파 → 모든 생산자 종료 대기: send·I/O가 관찰
- 모든 생산자 종료 대기 → 조정자가 out을 close: 더 이상 send 없음
방향 타입의 역할 범위와 Close 소유권
| 타입 | 허용 동작 | 여전히 정해야 할 것 |
|---|---|---|
chan T |
송신·수신·close | 실제 소유자 |
chan<- T |
송신·close | 여러 생산자 중 닫을 권한 |
<-chan T |
수신 | 조기 소비 종료의 별도 신호 |
조정자가 실제 채널을 소유하고 생산자에게 send-only, 소비자에게 receive-only를 전달하면 의도가 드러납니다. send-only에도 close가 허용되므로 타입만으로 다중 생산자의 마지막 송신을 알 수는 없습니다. 필요하면 close를 노출하지 않는 좁은 함수 인터페이스로 감쌀 수 있습니다.
WaitGroup을 사용할 때는 goroutine을 시작하기 전에 참여를 등록하고 각 생산자가 반환 시 Done을 호출합니다. 결과를 읽어야 생산자가 끝날 수 있는데 소비자가 결과를 읽지 않고 Wait부터 하면 순환 대기가 생깁니다. 조정자의 Wait·close를 별도 경로에서 수행하거나 취소 가능한 send로 소비 종료를 보장합니다.
Nil·닫힘·빈 채널의 상태 구분
nil 채널의 송수신은 진행하지 않아 select case를 비활성화할 수 있습니다. 닫힌 채널은 버퍼에 남은 값을 먼저 받고, 다 비면 영값과 ok=false를 즉시 반환합니다. ok를 무시한 루프는 계속 영값을 처리하며 CPU를 사용할 수 있습니다.
닫힌 채널 송신·이중 close·nil 채널 close는 panic입니다. 빈 열린 채널은 미래 송신을 기다릴 수 있고, 닫힌 빈 채널은 더 이상 값이 오지 않는 종결 상태입니다. select의 닫힌 채널 case를 nil로 바꿔 제거할 때도 나머지 종료 조건이 남아 있는지 확인합니다.
취소 Case 순서와 select의 준비 통신 선택
ctx.Done과 작업 수신 또는 결과 송신이 모두 준비되면 select는 준비된 통신 중 하나를 선택합니다. 취소 case를 먼저 적었다고 항상 취소가 이기지 않습니다. 실행 전 ctx.Err를 다시 확인하면 불필요한 작업을 줄일 수 있지만 그 검사 직후 취소가 올 수 있습니다.
취소 case가 선택되었다고 해서 이미 시작한 작업이 즉시 사라지거나 외부 변경이 되돌아가지는 않습니다. 따라서 ‘취소 뒤 작업 시작을 줄인다’는 보장과 ‘취소 확정 이후 어떤 외부 효과도 없다’는 보장을 나눠야 합니다. 후자를 원한다면 실행권 상태와 실제 변경 경계를 조건·직렬화로 묶어야 하며, 늦은 결과에는 소비자가 세대·종료 상태를 확인해 적용하지 않는 경로가 필요할 수 있습니다. 취소는 이미 커밋된 외부 거래의 롤백도 아닙니다.
예상 결과가 있는 시험은 생산자 하나가 오류로 context를 취소하고 다른 생산자는 막힌 송신 상태로 두는 경우입니다. 소비자가 반환해도 두 생산자가 실제로 반환하고 조정자의 Wait가 끝난 뒤에만 close가 발생해야 하며, 닫힌 채널을 수신하는 루프는 ok=false에서 종료해야 합니다.
다중 종료와 close 순서 제어
생산자 한 명이 오류를 내고 다른 생산자는 send에서 기다리도록 테스트 장벽을 둡니다. 취소 뒤 모두 반환하고 조정자가 한 번 close하는지 확인합니다. 소비자가 먼저 반환하는 경우, 버퍼에 값이 남은 경우, 작업·취소 동시 준비도 검사합니다.
기대값은 언제나 취소 case가 선택된다는 것이 아니라 문서화한 결과 폐기 정책과 종료·close 소유권이 유지되는 것입니다. race detector와 생성·종료 작업 카운터를 함께 사용하고, 외부 API가 context를 무시하면 그 고루틴이 실제로 남는 한계도 숨기지 않습니다.