고 중급 #4 select와 타임아웃
#3 고루틴과 채널 입문에서 단일 채널을 봤다면 — 이번엔 여러 채널을 동시에 다루는 도구. select 문.
select 기본
#
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}여러 채널 동작 중 — 준비된 것 하나를 선택해 실행합니다. 둘 다 준비됐으면 무작위로 하나.
switch와 모양이 비슷하지만 — case 들이 채널 동작이라는 게 차이입니다. 어느 case도 준비되지 않으면 블록됩니다.
자주 쓰는 패턴들 #
1) 타임아웃 #
time.After가 일정 시간 뒤에 값을 보내는 채널을 돌려줍니다.
select {
case v := <-ch:
fmt.Println("받음:", v)
case <-time.After(2 * time.Second):
fmt.Println("타임아웃")
}ch에서 2초 안에 값이 안 오면 — time.After의 채널이 발동해 타임아웃 처리. fetch의 timeout 같은 경우에 어울립니다.
2) 취소 신호 듣기 #
done := make(chan struct{})
go func() {
for {
select {
case v := <-ch:
process(v)
case <-done:
fmt.Println("종료")
return
}
}
}()
// 종료시키고 싶을 때
close(done)done 채널이 close 되면 — <-done이 즉시 zero value를 받아 case가 선택됩니다. closed 채널의 receive가 즉시 진행한다는 특성을 활용한 표준 패턴입니다.
이 패턴이 #5 context가 도와주는 부분입니다.
3) 논블록 송신/수신 — default
#
select에 default case를 두면 — 모든 case가 준비 안 됐을 때 즉시 실행. 블록 안 함.
select {
case v := <-ch:
fmt.Println("받음:", v)
default:
fmt.Println("아무것도 없음")
}기다리지 않고 “있으면 받고 없으면 패스” 가 가능. 폴링 같은 경우에 어울리지만 — 남용하면 CPU를 낭비합니다. 보통 채널이 자동으로 동기화하는 게 더 효율적입니다.
select {
case ch <- v:
fmt.Println("보냄")
default:
fmt.Println("받는 사람 없음 — 버림")
}큐가 가득 찼을 때 작업을 버리는 식의 패턴.
time.Tick — 주기적 작업
#
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("매 초")
case <-done:
return
}
}time.NewTicker는 일정 간격으로 시간을 보내는 채널을 가진 ticker를 돌려줍니다. cron 같은 단순 주기 작업에 어울립니다.
time.Tick도 있지만 — 종료할 방법이 없어 누수 가능성. 항상 NewTicker + defer Stop()이 안전.
select + for — 이벤트 루프
#
위 코드들이 보여주듯, for { select { ... } } 패턴이 매우 흔합니다. 영원히 살면서 여러 종류의 이벤트를 처리하는 고루틴.
for {
select {
case msg := <-incoming:
handleMessage(msg)
case t := <-ticker.C:
heartbeat(t)
case <-shutdown:
fmt.Println("정상 종료")
return
}
}세 종류의 이벤트(수신 메시지, 주기 신호, 종료 신호) 중 무엇이 와도 한곳에서 처리. 서버 코드에서 자주 만나는 모양입니다.
채널 자체를 nil로 — case 비활성화 #
select의 한 가지 특이한 동작 — nil 채널에서의 송수신은 영원히 블록합니다.
var ch chan int // nil
select {
case v := <-ch: // 영원히 블록 (nil)
fmt.Println(v)
case <-done:
return
}이걸 활용해 — case를 동적으로 켜고 끄기가 가능합니다.
var send chan<- int = nil // 처음엔 비활성
for {
select {
case v := <-incoming:
// 데이터 들어오면 보낼 채널 활성화
send = outgoing
buffer = append(buffer, v)
case send <- buffer[0]:
// 보냈으면 버퍼에서 빼고
buffer = buffer[1:]
if len(buffer) == 0 {
send = nil // 보낼 거 없으면 다시 비활성화
}
}
}다소 고급 패턴 — 라이브러리 내부에서 가끔 보입니다.
타임아웃과 데드라인 — 두 가지 의미 #
비슷해 보이지만 다른 두 개념.
// 타임아웃 — 지금부터 N초
case <-time.After(2 * time.Second):
// 데드라인 — 어떤 절대 시점까지
deadline := time.Now().Add(2 * time.Second)
case <-time.After(time.Until(deadline)):context.Context는 두 가지를 모두 표현할 수 있습니다. 다음 글에서 자세히.
자주 만나는 함정 #
1) time.After가 누수될 수 있음
#
time.After는 매 호출마다 새 timer를 만듭니다. select가 다른 case로 빠지면 — timer가 만료될 때까지 GC 안 됨.
for {
select {
case v := <-ch:
// ...
case <-time.After(time.Second):
// 매번 새 timer
}
}매 반복에서 새 timer가 만들어지지만, ch에 값이 자주 오면 timer가 GC 되지 못하고 누적될 수 있습니다. 빈번한 루프에서는 — time.NewTimer + 명시적 reset이 안전합니다.
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
select {
case v := <-ch:
if !timer.Stop() {
<-timer.C
}
timer.Reset(time.Second)
// ...
case <-timer.C:
// 타임아웃
}
}다소 까다롭습니다. 일반적인 경우에는 — context.WithTimeout이 더 깔끔합니다.
2) select의 case 평가 순서 #
for {
select {
case <-fast:
// 자주 발동
case <-slow:
// 가끔 발동
}
}여러 case가 동시에 준비됐을 때 — Go는 무작위로 선택합니다. fast만 계속 선택되거나 slow가 굶지 않게(굶주림 starvation 방지).
만약 우선순위가 필요하면 — 별도의 select를 두 단계로.
for {
// 1단계: 우선순위 높은 것 먼저
select {
case <-priority:
handle()
continue
default:
}
// 2단계: 우선순위 낮은 것 + 우선순위
select {
case <-priority:
handle()
case <-other:
handleOther()
}
}흔한 패턴은 아니지만, 정말 우선순위가 필요할 때 사용합니다.
실전 — HTTP 클라이언트의 타임아웃 #
import (
"net/http"
"time"
)
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get("https://example.com")http.Client의 Timeout 필드가 — 내부적으로 select + context를 활용한 타임아웃 처리입니다. 우리가 직접 select를 쓰는 일은 라이브러리 안쪽에서 더 많지만, 어떻게 동작하는지 알면 그 라이브러리들이 자연스러워집니다.
마무리 #
이번 글에서 정리한 내용:
select는 여러 채널 중 준비된 case 하나 선택time.After로 타임아웃 caseclose(done)+<-done패턴으로 취소 신호default로 논블록 송수신time.NewTicker+defer Stop()으로 주기적 작업for { select { ... } }가 이벤트 루프 표준- nil 채널은 영원히 블록 — case 동적 비활성화
time.After의 누수 함정 —NewTimer+ Reset- 다중 case는 무작위 선택 (starvation 방지)
다음 글(#5 context.Context 깊이)에서는 — Go의 표준 취소/타임아웃 도구. context가 어떻게 동시성 코드의 골격이 되는지를 다룹니다.