고 중급 #4 select와 타임아웃

5 분 소요

#3 고루틴과 채널 입문에서 단일 채널을 봤다면 — 이번엔 여러 채널을 동시에 다루는 도구. select 문.

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 #

selectdefault 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 채널에서의 송수신은 영원히 블록합니다.

nil 채널 활용
var ch chan int    // nil

select {
case v := <-ch:    // 영원히 블록 (nil)
	fmt.Println(v)
case <-done:
	return
}

이걸 활용해 — case를 동적으로 켜고 끄기가 가능합니다.

동적 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    // 보낼 거 없으면 다시 비활성화
		}
	}
}

다소 고급 패턴 — 라이브러리 내부에서 가끔 보입니다.

타임아웃과 데드라인 — 두 가지 의미 #

비슷해 보이지만 다른 두 개념.

타임아웃 vs 데드라인
// 타임아웃 — 지금부터 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이 안전합니다.

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 평가 순서 #

case 무작위 선택
for {
	select {
	case <-fast:
		// 자주 발동
	case <-slow:
		// 가끔 발동
	}
}

여러 case가 동시에 준비됐을 때 — Go는 무작위로 선택합니다. fast만 계속 선택되거나 slow가 굶지 않게(굶주림 starvation 방지).

만약 우선순위가 필요하면 — 별도의 select를 두 단계로.

우선순위 select
for {
	// 1단계: 우선순위 높은 것 먼저
	select {
	case <-priority:
		handle()
		continue
	default:
	}

	// 2단계: 우선순위 낮은 것 + 우선순위
	select {
	case <-priority:
		handle()
	case <-other:
		handleOther()
	}
}

흔한 패턴은 아니지만, 정말 우선순위가 필요할 때 사용합니다.

실전 — HTTP 클라이언트의 타임아웃 #

HTTP 클라이언트 + 타임아웃
import (
	"net/http"
	"time"
)

client := &http.Client{
	Timeout: 5 * time.Second,
}

resp, err := client.Get("https://example.com")

http.ClientTimeout 필드가 — 내부적으로 select + context를 활용한 타임아웃 처리입니다. 우리가 직접 select를 쓰는 일은 라이브러리 안쪽에서 더 많지만, 어떻게 동작하는지 알면 그 라이브러리들이 자연스러워집니다.

마무리 #

이번 글에서 정리한 내용:

  • select는 여러 채널 중 준비된 case 하나 선택
  • time.After로 타임아웃 case
  • close(done) + <-done 패턴으로 취소 신호
  • default로 논블록 송수신
  • time.NewTicker + defer Stop()으로 주기적 작업
  • for { select { ... } }가 이벤트 루프 표준
  • nil 채널은 영원히 블록 — case 동적 비활성화
  • time.After의 누수 함정 — NewTimer + Reset
  • 다중 case는 무작위 선택 (starvation 방지)

다음 글(#5 context.Context 깊이)에서는 — Go의 표준 취소/타임아웃 도구. context가 어떻게 동시성 코드의 골격이 되는지를 다룹니다.

X