Devin.KR

Go · 심화

동시성과 서버 설계로 깊어지는 Go

동시성 패턴 - 파이프라인·팬아웃·워커 풀

채널 소유권 규칙, 파이프라인 단계 연결, 팬아웃·팬인, 크기 제한 워커 풀, 고루틴 누수 막기

개발자KR · 원고 갱신

이 장에서 배우는 것

기본서에서 고루틴과 채널의 문법을 익혔다면, 이제 그 도구를 여러 개 이어 붙여 하나의 처리 흐름으로 만드는 단계다. 이 장은 배달 주문 중계 서비스에서 주문이 접수되어 배달원에게 배정되기까지의 흐름을 채널로 연결한다. 이때 누가 채널을 만들고 누가 닫는지, 소비자가 중간에 떠나면 나머지 고루틴이 어떻게 되는지를 규칙으로 정리한다.

  • 채널 소유권 규칙을 설명하고, 방향이 있는 채널 타입으로 규칙을 컴파일러에 맡긴다.
  • 파이프라인(pipeline) 단계를 함수 하나로 만들고 서로 연결한다.
  • 팬아웃(fan-out)과 팬인(fan-in)으로 느린 단계를 여러 고루틴에 나눈 뒤 결과를 하나로 합친다.
  • 동시에 돌아가는 고루틴 수를 고정한 워커 풀(worker pool)을 만든다.
  • 고루틴 누수(goroutine leak)가 생기는 지점을 찾아 종료 신호 채널로 막는다.

문제 상황

중계 서비스는 동네 가게에서 들어온 주문을 받아 세 가지 일을 한다. 주문이 정상인지 검증하고, 거리에 따라 배달비를 계산하고, 배달원을 배정한다. 처음에는 주문마다 go handle(order) 를 호출하는 식으로 만들기 쉽다. 점심 시간에 주문이 몰리면 고루틴이 주문 수만큼 늘어나고, 배정 단계에서 쓰는 외부 자원은 동시 접근이 몇 개로 제한돼 있으므로 대기만 쌓인다. 반대로 단계마다 슬라이스를 만들어 넘기면 앞 단계가 모두 끝나야 다음 단계가 시작돼서 첫 주문의 배정이 늦어진다.

채널로 단계를 이으면 주문 한 건이 검증을 마치는 즉시 다음 단계로 넘어간다. 대신 새로운 문제가 생긴다. 한 단계가 채널을 닫지 않으면 뒤 단계가 영원히 기다리는 상태가 되지 않도록 반드시 닫아야 하고, 배정 결과를 받는 쪽이 오류로 일찍 돌아가 버리면 앞 단계 고루틴들은 보낼 곳이 없어 멈춘 채 남는다. 서버는 오래 살아 있으므로 이런 고루틴이 요청마다 하나씩 쌓이면 메모리가 서서히 늘어난다. 이 장의 규칙은 두 문제를 함께 다룬다.

채널 소유권 규칙

만든 쪽이 보내고 닫는다

채널에는 소유자가 있다고 생각하면 설계가 단순해진다. 소유자는 채널을 만들고, 값을 보내는 유일한 쪽이며, 보낼 것이 없어지면 닫는 쪽이다. 소비자는 받기만 한다. 규칙은 다음 세 줄로 줄어든다.

  • 채널을 만든 함수(또는 고루틴)가 닫는다. 보통 defer close(out) 한 줄을 고루틴 맨 위에 둔다.
  • 닫힌 채널에 보내면 패닉이 나므로, 닫는 쪽은 더 보내지 않는다는 것을 스스로 보장해야 한다. 그래서 송신자가 하나일 때 그 송신자가 닫는 구조가 가장 안전하다.
  • 받는 쪽은 채널을 닫지 않는다. for v := range ch 는 채널이 닫혀야 끝나므로, 닫기는 소유자의 몫이다.

송신자가 여럿인 경우에는 송신자 중 아무도 닫지 않고, 송신자들이 모두 끝나기를 기다리는 별도의 고루틴이 닫는다. 뒤에서 나올 합치기 함수가 이 형태다.

방향 타입으로 규칙을 강제한다

Go의 채널 타입은 방향을 가질 수 있다. 단계 함수가 <-chan Order 를 반환하면 호출자는 받기만 할 수 있고 닫을 수도 없다. 규칙을 문서에 적는 대신 타입에 적는 셈이다.

채널 타입별로 허용되는 연산과 쓰는 자리
타입할 수 있는 일컴파일 오류가 나는 일주로 쓰는 자리
chan T보내기, 받기, 닫기없음채널을 만든 함수 내부
chan<- T보내기, 닫기받기송신만 하는 함수의 매개변수
<-chan T받기보내기, 닫기단계 함수의 반환 타입, 소비자 매개변수

이 장의 모든 단계 함수는 입력을 <-chan 으로 받고 출력도 <-chan 으로 돌려준다. 내부에서만 양방향 채널을 쥐고 있으므로, 닫을 수 있는 쪽은 만든 고루틴뿐이다.

파이프라인과 팬아웃·팬인

단계를 함수로 만들고 이어 붙인다

파이프라인의 한 단계는 입력 채널에서 받아 일을 하고 출력 채널로 내보내는 함수다. 모양이 같으므로 단계끼리 호출 결과를 그대로 다음 호출의 인자로 넘겨 연결할 수 있다. 이 장에서는 주문 생성, 검증, 요금 계산, 배정의 네 단계를 쓴다.

모든 단계는 자기 출력 채널을 닫고, 하나의 done 채널이 모든 단계의 종료를 알린다

그림처럼 단계 사이의 화살표는 채널이고, 화살표의 출발점 단계가 그 채널의 소유자다. 위쪽의 종료 신호 채널은 모든 단계가 함께 바라본다. 이 채널은 고루틴 누수를 다루는 절에서 설명한다.

팬아웃과 팬인

한 단계가 느리면 파이프라인 전체 속도가 그 단계에 묶인다. 배정 단계가 그렇다고 하자. 같은 입력 채널을 여러 고루틴이 함께 받으면 고루틴들이 값을 나눠 가져가는데, 이것이 팬아웃이다. 하나의 채널에서 받는 일은 고루틴에 안전하므로 값이 중복되지 않는다. 팬아웃한 고루틴마다 자기 출력 채널을 가지면 소유권 규칙이 그대로 유지된다. 이 출력 채널들을 하나로 모으는 일이 팬인이며, 합치기 함수가 맡는다.

합치기 함수는 입력 채널마다 고루틴을 하나씩 띄워 값을 공통 출력으로 옮긴다. 송신자가 여럿이므로 그 고루틴들은 닫지 않고, 모두 끝나기를 sync.WaitGroup 으로 기다리는 고루틴이 마지막에 한 번 닫는다.

워커 풀은 크기를 고정한 팬아웃이다

주문마다 고루틴을 만드는 대신 워커를 정해진 수만큼만 띄우고 모두 같은 입력 채널을 받게 하면 워커 풀이 된다. 동시에 일하는 수는 워커 수를 넘지 않고, 주문이 몰리면 입력 채널 앞에서 생산자가 기다린다. 이 기다림이 자연스러운 속도 조절 역할을 한다. 워커 수는 일의 성격에 맞춰 정한다.

일의 성격에 따른 워커 수 정하기
일의 성격병목워커 수의 기준이 장의 예
계산 위주CPU 코어코어 수 안팎에서 측정해 정한다요금 계산
대기 위주외부 호출의 동시 허용 수상대가 허용하는 동시 수 이하배달원 배정(3개)
메모리를 많이 쓰는 일가용 메모리건당 사용량으로 나눈 값해당 없음

실제 숫자는 측정으로 정해야 하며, 측정 방법은 벤치마크를 다루는 장에서 본다. 이 장의 예제는 외부 배정 시스템이 동시에 세 건까지만 받는다고 가정하고 워커를 세 개로 둔다.

팬아웃된 워커들은 주문을 어느 고루틴이 가져갈지 정해져 있지 않다. 그래서 결과가 도착하는 순서도 실행마다 다를 수 있다. 순서가 중요하면 결과에 주문 번호를 싣고 마지막에 정렬한다. 예제의 배달원 배정도 워커 번호가 아니라 주문 번호에서 계산하므로, 어느 워커가 처리하든 결과가 같다.

고루틴 누수 막기

누수는 대개 이렇게 생긴다. 소비자가 결과 두 건만 받고 오류를 만나 함수에서 돌아간다. 생산자 고루틴은 세 번째 값을 out <- v 로 보내려고 기다리는데 받는 쪽이 없다. 채널이 닫히지도 않고 받는 쪽도 없으니 이 고루틴은 프로세스가 끝날 때까지 남는다. 단계가 넷이면 네 개가 남는다.

종료 신호 채널이 없으면 생산자가 보내기에서 막힌 채 남고, 있으면 select로 빠져나온다

해결은 모든 보내기를 select 로 바꾸는 것이다. 한쪽은 정상적인 보내기이고 다른 쪽은 종료 신호 채널의 수신이다. 소비자는 떠나기 전에 종료 신호 채널을 닫고, 닫힌 채널은 수신이 즉시 성공하므로 막혀 있던 모든 고루틴이 select 의 두 번째 분기로 빠져나온다. 이 장에서는 chan struct{} 를 종료 신호 채널로 직접 쓴다. 다음 장에서 다룰 context 는 같은 아이디어를 표준 방식으로 감싼 것이므로, 원리를 먼저 손으로 익혀 두면 이해가 쉽다.

종료했는지 확인하는 방법도 필요하다. 이 장의 예제는 모든 단계가 WaitGroup 에 자기를 등록하고, 소비자가 종료 신호를 보낸 뒤 Wait 으로 전원이 돌아올 때까지 기다리게 한다. 고루틴 수를 세는 방식과 달리 결과가 시간에 따라 흔들리지 않는다. 자세한 내용은 Go 블로그의 파이프라인과 취소 글에서도 확인할 수 있다.

완성 코드

아래 코드를 main.go 하나로 저장한다. 주문 여덟 건 중 수량이 0인 두 건은 검증에서 걸러지고, 나머지 여섯 건이 배정된다. 두 번째 실행에서는 결과를 두 건만 받고 일부러 중단한다.

package main

import (
	"fmt"
	"sort"
	"sync"
)

type Order struct {
	ID     int
	Shop   string
	Items  int
	Meters int
}

type Priced struct {
	Order
	Fee int
}

type Dispatch struct {
	OrderID int
	Shop    string
	Fee     int
	Courier string
}

func generate(wg *sync.WaitGroup, done <-chan struct{}, orders []Order) <-chan Order {
	out := make(chan Order)
	wg.Add(1)
	go func() {
		defer wg.Done()
		defer close(out)
		for _, o := range orders {
			select {
			case out <- o:
			case <-done:
				return
			}
		}
	}()
	return out
}

func validate(wg *sync.WaitGroup, done <-chan struct{}, in <-chan Order) <-chan Order {
	out := make(chan Order)
	wg.Add(1)
	go func() {
		defer wg.Done()
		defer close(out)
		for o := range in {
			if o.Items <= 0 {
				continue
			}
			select {
			case out <- o:
			case <-done:
				return
			}
		}
	}()
	return out
}

func price(wg *sync.WaitGroup, done <-chan struct{}, in <-chan Order) <-chan Priced {
	out := make(chan Priced)
	wg.Add(1)
	go func() {
		defer wg.Done()
		defer close(out)
		for o := range in {
			p := Priced{Order: o, Fee: 3000 + o.Meters/500*500}
			select {
			case out <- p:
			case <-done:
				return
			}
		}
	}()
	return out
}

func worker(wg *sync.WaitGroup, done <-chan struct{}, in <-chan Priced) <-chan Dispatch {
	out := make(chan Dispatch)
	wg.Add(1)
	go func() {
		defer wg.Done()
		defer close(out)
		for p := range in {
			d := Dispatch{
				OrderID: p.ID,
				Shop:    p.Shop,
				Fee:     p.Fee,
				Courier: fmt.Sprintf("배달원%d", p.ID%3+1),
			}
			select {
			case out <- d:
			case <-done:
				return
			}
		}
	}()
	return out
}

func merge(wg *sync.WaitGroup, done <-chan struct{}, ins ...<-chan Dispatch) <-chan Dispatch {
	out := make(chan Dispatch)
	var inner sync.WaitGroup
	inner.Add(len(ins))
	for _, in := range ins {
		wg.Add(1)
		go func() {
			defer wg.Done()
			defer inner.Done()
			for d := range in {
				select {
				case out <- d:
				case <-done:
					return
				}
			}
		}()
	}
	wg.Add(1)
	go func() {
		defer wg.Done()
		inner.Wait()
		close(out)
	}()
	return out
}

func dispatchPool(wg *sync.WaitGroup, done <-chan struct{}, in <-chan Priced, workers int) <-chan Dispatch {
	outs := make([]<-chan Dispatch, 0, workers)
	for i := 0; i < workers; i++ {
		outs = append(outs, worker(wg, done, in))
	}
	return merge(wg, done, outs...)
}

func run(wg *sync.WaitGroup, done <-chan struct{}, orders []Order, workers int) <-chan Dispatch {
	src := generate(wg, done, orders)
	valid := validate(wg, done, src)
	priced := price(wg, done, valid)
	return dispatchPool(wg, done, priced, workers)
}

func fullRun(orders []Order) {
	var wg sync.WaitGroup
	done := make(chan struct{})
	var results []Dispatch
	for d := range run(&wg, done, orders, 3) {
		results = append(results, d)
	}
	close(done)
	wg.Wait()

	sort.Slice(results, func(i, j int) bool {
		return results[i].OrderID < results[j].OrderID
	})
	fmt.Printf("접수 %d건, 배정 %d건\n", len(orders), len(results))
	for _, d := range results {
		fmt.Printf("주문 %d (%s) 배달비 %d원 → %s\n", d.OrderID, d.Shop, d.Fee, d.Courier)
	}
}

func earlyStop(orders []Order) {
	var wg sync.WaitGroup
	done := make(chan struct{})
	out := run(&wg, done, orders, 3)
	taken := 0
	for range out {
		taken++
		if taken == 2 {
			break
		}
	}
	close(done)
	wg.Wait()
	fmt.Printf("%d건만 받고 중단했다\n", taken)
	fmt.Println("중단 후 모든 고루틴이 종료됐다")
}

func main() {
	orders := []Order{
		{1, "김밥집", 2, 900},
		{2, "분식집", 0, 1200},
		{3, "치킨집", 1, 2600},
		{4, "빵집", 3, 400},
		{5, "국밥집", 2, 1800},
		{6, "카페", 0, 700},
		{7, "반찬가게", 4, 3100},
		{8, "떡집", 1, 1500},
	}
	fullRun(orders)
	earlyStop(orders)
}

줄별 해설

타입 선언. Priced 는 Order 를 필드 이름 없이 포함한다. 그래서 p.ID 처럼 바로 접근할 수 있고, 요금 계산 단계가 원본 주문을 건드리지 않고 배달비만 덧붙인다. Dispatch 는 배정 결과이며 정렬에 쓸 OrderID 를 가진다.

generate. 출력 채널 out 을 만들고, 고루틴 맨 위에서 defer close(out) 로 닫기 책임을 선언한다. defer 는 나중에 선언한 것부터 실행되므로 wg.Done() 이 먼저 등록되고 close 가 먼저 실행된다. 어느 쪽 순서여도 동작하지만, 채널을 닫은 뒤에 완료를 알리는 쪽이 읽기 쉽다. 반환 타입이 <-chan Order 이므로 호출자는 이 채널을 닫을 수 없다. 보내기는 select 로 감싸 done 이 닫히면 return 한다.

validate. 입력은 range 로 받는다. 입력 채널이 닫히면 반복이 끝나고, 끝나면서 자기 출력도 닫는다. 이렇게 닫힘이 단계를 따라 전파된다. 수량이 0 이하인 주문은 continue 로 버려진다. 걸러진 주문은 아무 데도 보내지 않으므로 보내기 쪽 select 에 들어가지 않는다.

price. 배달비는 기본 3,000원에 500미터 구간마다 500원을 더한다. o.Meters/500*500 은 왼쪽부터 계산되는 정수 나눗셈이어서 500미터 단위로 내림한다. 예를 들어 900미터는 500원이 붙어 3,500원이 된다.

worker. 워커 하나는 입력 채널을 읽는 고루틴 하나와 자기 출력 채널 하나로 이루어진다. 세 워커가 같은 in 을 읽는 것이 팬아웃이다. 배달원은 p.ID%3+1 로 정해서 어느 워커가 처리해도 결과가 같다.

merge. 입력 채널마다 고루틴을 하나씩 띄워 공통 out 으로 옮긴다. 반복문 변수 in 은 Go 1.22부터 반복마다 새로 만들어지므로 클로저가 각자 자기 채널을 본다. 이 고루틴들은 out 을 닫지 않는다. 대신 마지막에 띄운 고루틴이 inner.Wait() 로 전부 끝나기를 기다린 뒤 한 번만 닫는다. wg.Add(1) 은 고루틴을 시작하기 전에 호출 쪽에서 실행한다.

dispatchPool 과 run. 워커를 workers 개 만들고 출력 채널들을 merge 에 펼쳐서 넘긴다. run 은 네 단계를 이어 붙이고 최종 출력만 돌려준다. 단계 사이 채널이 모두 버퍼 없는 채널이므로 소비자가 읽지 않으면 모든 단계가 보내기에서 대기한다.

fullRun. 결과를 끝까지 읽으면 합쳐진 채널이 닫히고 반복이 끝난다. 그 뒤에도 close(done) 과 wg.Wait() 를 호출하는 것은 정상 경로와 중단 경로의 마무리를 같은 모양으로 두기 위해서다. 팬인 결과의 도착 순서는 정해져 있지 않으므로 sort.Slice 로 주문 번호 순서를 고정한 뒤 출력한다.

earlyStop. 결과를 두 건 받고 break 한다. 이 시점에 나머지 고루틴은 보내기에서 막혀 있다. close(done) 이 그들을 깨우고, wg.Wait() 이 돌아왔다는 것은 등록된 모든 고루틴이 반환했다는 뜻이다. 어느 두 건을 받았는지는 실행마다 다를 수 있으므로 건수만 출력한다.

실행 결과

$ go run main.go
접수 8건, 배정 6건
주문 1 (김밥집) 배달비 3500원 → 배달원2
주문 3 (치킨집) 배달비 5500원 → 배달원1
주문 4 (빵집) 배달비 3000원 → 배달원2
주문 5 (국밥집) 배달비 4500원 → 배달원3
주문 7 (반찬가게) 배달비 6000원 → 배달원2
주문 8 (떡집) 배달비 4500원 → 배달원3
2건만 받고 중단했다
중단 후 모든 고루틴이 종료됐다

실무에서 자주 틀리는 것

1. 받는 쪽이나 여러 송신자가 각자 닫는다

송신자가 둘인 채널을 송신자마다 닫으면 먼저 닫은 쪽이 닫힌 채널에 보내려는 다른 쪽에 패닉을 일으킨다. 받는 쪽에서 닫는 것도 같은 위험이 있다.

// 틀린 코드: 두 고루틴이 같은 out 을 각자 닫는다
for _, in := range ins {
	go func() {
		defer close(out)
		for d := range in {
			out <- d
		}
	}()
}
// 고친 코드: 송신자는 닫지 않고 별도 고루틴이 한 번만 닫는다
var inner sync.WaitGroup
inner.Add(len(ins))
for _, in := range ins {
	go func() {
		defer inner.Done()
		for d := range in {
			out <- d
		}
	}()
}
go func() {
	inner.Wait()
	close(out)
}()

2. 보내기를 select 없이 쓴다

소비자가 중간에 떠날 수 있다면 모든 보내기가 종료 신호를 알아야 한다. 아래 단계는 정상 경로에서는 잘 돌지만, 소비자가 떠나면 고루틴이 남는다.

// 틀린 코드: 소비자가 떠나면 out <- o 에서 영구히 대기한다
go func() {
	defer close(out)
	for o := range in {
		out <- o
	}
}()
// 고친 코드: 종료 신호와 함께 기다린다
go func() {
	defer close(out)
	for o := range in {
		select {
		case out <- o:
		case <-done:
			return
		}
	}
}()

3. wg.Add 를 고루틴 안에서 호출한다

Add 가 고루틴 안에 있으면 Wait 가 먼저 실행되어 카운터가 0인 채로 지나갈 수 있다. 그러면 아직 시작하지 않은 고루틴을 기다리지 않는다.

// 틀린 코드
go func() {
	wg.Add(1)
	defer wg.Done()
	work()
}()
wg.Wait()
// 고친 코드: 고루틴을 시작하기 전에 등록한다
wg.Add(1)
go func() {
	defer wg.Done()
	work()
}()
wg.Wait()

Go 1.25에서 추가된 wg.Go(func() { ... }) 메서드는 등록과 완료를 한 번에 처리하므로 이 실수를 구조적으로 피할 수 있다. 이 장에서는 규칙이 드러나도록 Add 와 Done 을 직접 썼다.

4. 입력마다 고루틴을 만들어 동시성을 제한하지 않는다

주문 하나에 고루틴 하나를 만들면 폭주하는 시간대에 외부 시스템의 동시 허용 수를 넘는다. 고루틴 자체는 가볍지만 그 안에서 쓰는 연결이나 파일, 외부 호출은 가볍지 않다.

// 틀린 코드: 주문 수만큼 동시에 배정을 호출한다
for p := range priced {
	go assign(p)
}
// 고친 코드: 정해진 수의 워커가 같은 채널을 나눠 읽는다
for i := 0; i < 3; i++ {
	go func() {
		for p := range priced {
			assign(p)
		}
	}()
}

한눈에 보기

이 장의 규칙과 어겼을 때의 증상
주제규칙코드에서의 모양어기면
소유권만든 쪽이 보내고 닫는다defer close(out)패닉 또는 영구 대기
방향 타입반환은 <-chan T단계 함수 시그니처소비자가 닫을 수 있게 된다
파이프라인단계는 입력 채널을 받아 출력 채널을 돌려준다run 의 호출 연결단계별 슬라이스로 지연 증가
팬아웃·팬인워커마다 출력 채널, 합치기는 마지막에 한 번 닫는다worker, merge중복 닫기 패닉
워커 풀워커 수를 고정한다dispatchPool 의 반복문외부 자원 과부하
누수 방지보내기는 select 와 종료 신호 채널case <-done고루틴이 남는다
소비자 쪽 마무리 절차
순서할 일이유이 장의 코드
1결과를 읽는다소비자가 읽어야 앞 단계가 진행한다range out
2종료 신호 채널을 닫는다막힌 보내기를 풀어 준다close(done)
3모든 고루틴을 기다린다종료를 확인하고 자원을 정리한다wg.Wait()

연습 문제

  1. func drain(in chan Order) 안에서 close(in) 을 호출하면 문제가 없다. 이 함수가 입력을 읽기만 한다면 매개변수 타입을 어떻게 바꾸고, 바꾼 뒤 close(in) 을 쓰면 어떤 일이 생기는지 설명하라.
  2. 완성 코드의 run(..., 3) 을 run(..., 2) 로 바꾸면 첫 번째 실행의 출력이 달라지는가. 이유와 함께 답하라.
  3. 요금 계산 단계 뒤에 배달비가 5,000원 이상인 주문만 통과시키는 단계 expensive 를 추가하라. 어떤 주문이 남는지도 쓰라.
  4. 다음 함수는 짝수를 처음 찾으면 돌아간다. 고루틴이 남는 이유를 설명하고 고쳐라.
    func firstEven(nums []int) int {
    	ch := make(chan int)
    	go func() {
    		for _, n := range nums {
    			ch <- n
    		}
    		close(ch)
    	}()
    	for n := range ch {
    		if n%2 == 0 {
    			return n
    		}
    	}
    	return -1
    }
    

정답과 해설

1번. 매개변수를 in <-chan Order 로 바꾼다. 받기 전용 채널에 close 를 쓰면 컴파일 단계에서 받기 전용 채널은 닫을 수 없다는 취지의 오류가 난다. 실행 중 패닉이 아니라 빌드에서 잡히므로, 소유권 규칙을 코드 리뷰에 의존하지 않고 컴파일러에 맡길 수 있다.

2번. 출력은 같다. 워커 수는 동시에 처리하는 고루틴 수만 바꾼다. 결과는 주문 번호로 정렬한 뒤 출력하고, 배달원은 워커가 아니라 주문 번호에서 계산하므로 어느 워커가 어느 주문을 가져가도 값이 같다. 결과의 도착 순서만 달라질 수 있으나 정렬이 그 차이를 없앤다.

3번. 요금 계산 단계와 같은 모양으로 만든다.

func expensive(wg *sync.WaitGroup, done <-chan struct{}, in <-chan Priced, min int) <-chan Priced {
	out := make(chan Priced)
	wg.Add(1)
	go func() {
		defer wg.Done()
		defer close(out)
		for p := range in {
			if p.Fee < min {
				continue
			}
			select {
			case out <- p:
			case <-done:
				return
			}
		}
	}()
	return out
}

run 에서는 priced := expensive(wg, done, price(wg, done, valid), 5000) 로 연결한다. 배달비가 5,000원 이상인 주문은 3번(5,500원)과 7번(6,000원)이므로 두 건이 배정된다. 새 단계도 자기 출력만 닫고, 보내기는 select 로 감싼다는 두 규칙을 그대로 따른다.

4번. 짝수를 찾아 돌아가면 소비자는 더 읽지 않는다. 생산자 고루틴은 다음 숫자를 ch <- n 으로 보내려고 대기하며, 닫는 일도 하지 못한다. 짝수 앞에 홀수가 남아 있는 한 이 고루틴이 남는다. 종료 신호 채널을 추가한다.

func firstEven(nums []int) int {
	ch := make(chan int)
	done := make(chan struct{})
	defer close(done)
	go func() {
		defer close(ch)
		for _, n := range nums {
			select {
			case ch <- n:
			case <-done:
				return
			}
		}
	}()
	for n := range ch {
		if n%2 == 0 {
			return n
		}
	}
	return -1
}

함수가 어떤 경로로 돌아가든 defer close(done) 이 실행되어 생산자가 빠져나온다. 다음 장에서는 이 종료 신호 채널을 context 로 바꿔, 호출 경계를 넘어 취소를 전달하는 방법을 다룬다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.