Go 1.24 range-over-func로 스트리밍 API 파이프라인 리팩토링하기 — 채널·콜백·이터레이터 패턴 비교
작년 온콜 중에 서비스 모니터링 대시보드에서 고루틴 카운트가 조용히 2,000개를 넘어가는 걸 발견했습니다. 원인을 추적해 보니 채널 기반 DB 스트리밍 함수에서 소비자가 break를 누른 뒤 생산자 고루틴이 채널에 쓰려고 영구 대기하는 누수였습니다. 함수 자체는 완벽해 보였고, 코드 리뷰도 통과했었습니다. ctx.Done() 처리 하나를 빠뜨렸을 뿐인데.
Go 1.23에서 정식 안정화되고 1.24에서 표준 라이브러리 전반으로 확장된 range-over-func 이터레이터는 이 종류의 문제를 언어 레벨에서 해결합니다. 이 글에서는 채널 기반 파이프라인, 콜백 패턴, iter.Seq 이터레이터를 실제 스트리밍 시나리오에서 나란히 비교하면서, 언제 어떤 패턴이 적합한지를 살펴봅니다.
세 가지 패턴, 무엇이 다른가
Go에서 값의 시퀀스를 소비자에게 전달하는 방법은 크게 세 가지입니다.
채널 패턴: 고루틴 사이에 채널을 놓고 데이터를 주고받습니다. 병렬 생산/소비에 자연스럽지만, 소비자가 break로 중단하면 생산자 고루틴이 채널에 쓰려다 영구 대기하는 누수가 생깁니다.
콜백 패턴: func(item T) bool 형태의 함수를 넘겨서 항목마다 호출합니다. break가 없으니 누수 위험이 없고 빠르지만, for range 문법을 쓸 수 없고 어댑터를 중첩하면 가독성이 떨어집니다.
range-over-func: yield func(V) bool을 받는 함수로, 컴파일러가 루프 바디를 yield 클로저로 자동 변환합니다. break/return/panic 시 yield에 false가 자동 전달되어 생산자가 즉시 정리할 수 있습니다.
이 다이어그램은 세 패턴의 조기 중단 동작과 병렬성 특성을 비교합니다.
range-over-func의 기본 구조
iter 패키지의 두 핵심 타입입니다.
// iter 패키지 정의 (Go 1.23+)
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)이터레이터를 만드는 쪽은 yield를 호출해 값을 밀어 넣고, 소비하는 쪽은 for range로 받습니다.
func Integers(start, end int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := start; i < end; i++ {
if !yield(i) { // 소비자가 break하면 false 반환
return // 여기서 정리 로직 실행
}
}
}
}
for n := range Integers(1, 100) {
if n > 10 {
break // 안전: yield에 false가 자동 전달됨
}
fmt.Println(n)
}★ Insight ─────────────────────────────────────
- range-over-func는 컴파일러 변환입니다.
for n := range Integers(...)는 컴파일 시Integers(...)(func(n int) bool { ... })형태의 함수 호출로 변환됩니다. 런타임에 새로운 추상화가 추가되는 게 아니라 문법 설탕입니다. yield반환값을 무시해도 컴파일 에러가 발생하지 않습니다.yield(i)처럼if !yield(i)없이 써도 컴파일됩니다. 이게 가장 흔한 실수(footgun)입니다. 팀 코드 리뷰 체크리스트에 반드시 추가하세요.─────────────────────────────────────────────────
push vs pull: iter.Seq와 iter.Pull 선택 기준
range-over-func는 push 모델입니다. 이터레이터가 소비자에게 값을 밀어 넣습니다. 대부분의 스트리밍 시나리오에서는 이 방식이 자연스럽습니다.
그런데 두 이터레이터를 동시에 진행하며 값을 교차(zip)하거나, 외부 API의 pull 인터페이스에 이터레이터를 연결해야 할 때는 pull 변환이 필요합니다.
iter.Pull은 내부적으로 코루틴을 사용합니다. Go 1.23 런타임에 추가된 코루틴은 고루틴과 다릅니다. 두 함수가 OS 스레드 하나에서 제어권을 서로 교환(yield/resume)하기 때문에, OS 스케줄러나 채널 뮤텍스를 거치지 않습니다. 채널 기반 통신이 컨텍스트 스위치마다 ~400ns/op 수준인 데 반해, 코루틴 기반 iter.Pull은 ~20ns/op를 달성합니다.¹
next, stop := iter.Pull(someIterator)
defer stop() // 반드시! 누락 시 백킹 코루틴 영구 대기
for {
val, ok := next()
if !ok {
break
}
// val 처리
}¹ bwplotka의 "Optimizing in-process gRPC with Go 1.23 Iterators and Coroutines" (2025.01) 벤치마크 기준. Apple M-시리즈 칩, Go 1.23, in-process gRPC 시나리오.
Go 1.24에서 표준 라이브러리가 달라진 점
Go 1.23에서는 slices·maps 패키지가, Go 1.24에서는 strings·bytes 패키지까지 이터레이터를 반환하는 함수를 추가했습니다.
// Go 1.24 strings 패키지
for line := range strings.Lines(bigText) {
process(line)
}
for field := range strings.FieldsSeq(csvLine) {
columns = append(columns, field)
}
// slices 패키지 (Go 1.23+)
for i, v := range slices.All(mySlice) {
fmt.Println(i, v)
}새로 작성하는 코드에서 자연스럽게 이터레이터와 마주치게 됩니다.
실전 1: DB 스트리밍 쿼리
수백만 건의 사용자 데이터를 배치 처리할 때 메모리에 전부 올리면 OOM이 납니다. 채널로 스트리밍하는 코드가 흔한 선택이었지만 문제가 있습니다.
채널 방식 — 조기 중단 위험
func StreamUsers(ctx context.Context, db *sql.DB) <-chan User {
ch := make(chan User)
go func() {
defer close(ch)
rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
return // 에러를 전달할 방법이 없음 (brevity — 실제로는 별도 에러 채널 필요)
}
defer rows.Close()
for rows.Next() {
var u User
rows.Scan(&u.ID, &u.Name, &u.Email)
select {
case ch <- u:
case <-ctx.Done(): // 이게 없으면 break 시 고루틴 영구 대기
return
}
}
}()
return ch
}ctx.Done() 처리를 빠뜨리는 실수는 코드 리뷰에서도 잘 안 잡힙니다. 도입부에서 언급한 고루틴 2,000개 사건이 바로 이 패턴에서 비롯됐습니다.
이터레이터 방식 — 안전하고 에러도 함께
func StreamUsers(ctx context.Context, db *sql.DB) iter.Seq2[User, error] {
return func(yield func(User, error) bool) {
rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
yield(User{}, err)
return
}
defer rows.Close()
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
// 스캔 오류: 해당 row를 오류로 전달하고 다음 row로 계속 진행.
// 일부 row 손상을 허용하는 배치 처리를 가정한 것입니다.
// 단 하나의 오류도 허용하지 않는다면 yield 후 return으로 변경하세요.
if !yield(User{}, err) {
return
}
continue
}
if !yield(u, nil) {
return // 소비자가 break하면 즉시 정리
}
}
if err := rows.Err(); err != nil {
yield(User{}, err)
}
}
}
// 소비자
for u, err := range StreamUsers(ctx, db) {
if err != nil {
log.Printf("오류: %v", err)
continue
}
if u.ID > 1000 {
break // 안전: rows.Close()가 defer로 호출됨. ctx 없이도 누수 없음.
}
process(u)
}break를 누르는 순간 yield에 false가 전달되고, 이터레이터 안의 defer rows.Close()가 실행됩니다.
실전 2: HTTP SSE 스트리밍 파이프라인
AI API의 SSE(Server-Sent Events) 스트림을 처리하는 코드입니다. 청크를 받아서 필터링하고 변환하는 파이프라인을 이터레이터 어댑터로 만들어봅니다.
범용 Map / Filter 어댑터
func Map[In, Out any](seq iter.Seq[In], fn func(In) Out) iter.Seq[Out] {
return func(yield func(Out) bool) {
for v := range seq {
if !yield(fn(v)) {
return
}
}
}
}
func Filter[V any](seq iter.Seq[V], pred func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range seq {
if pred(v) {
if !yield(v) {
return
}
}
}
}
}실제 AI API 스트리밍 파이프라인
type Chunk struct {
Text string
Done bool
}
func StreamAIResponse(ctx context.Context, prompt string) iter.Seq[Chunk] {
return func(yield func(Chunk) bool) {
resp, err := callAIAPI(ctx, prompt)
if err != nil {
return
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
chunk, ok := parseSSELine(scanner.Text())
if !ok {
continue
}
if !yield(chunk) {
return // 소비자가 멈추면 즉시 resp.Body.Close
}
if chunk.Done {
return
}
}
}
}
func ProcessStream(ctx context.Context, prompt string) iter.Seq[string] {
raw := StreamAIResponse(ctx, prompt)
nonEmpty := Filter(raw, func(c Chunk) bool { return c.Text != "" })
return Map(nonEmpty, func(c Chunk) string { return c.Text })
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
for text := range ProcessStream(r.Context(), r.URL.Query().Get("prompt")) {
fmt.Fprintf(w, "data: %s\n\n", text)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}Filter → Map 체인은 지연 평가로 동작합니다. 중간 슬라이스를 전혀 만들지 않고, 각 청크가 파이프라인을 바로 통과합니다.
★ Insight ─────────────────────────────────────
Map과Filter어댑터는 이터레이터를 반환하는 고차 함수입니다. 이 구조는 Haskell의 lazy list나 Rust의 Iterator trait과 본질적으로 같습니다. Go 이터레이터가 함수형 언어의 오래된 아이디어를 Go 스타일로 가져온 것입니다.ProcessStream()을 호출해도 HTTP 요청이 발생하지 않습니다.for range가 시작될 때 비로소 첫 청크를 당겨옵니다. 이터레이터 어댑터는 "소비될 때까지" 아무 계산도 하지 않는 순수한 지연 평가입니다.─────────────────────────────────────────────────
실전 3: gRPC 서버 스트림 래핑과 iter.Pull2
gRPC 서버 스트리밍 응답을 이터레이터로 래핑할 때 포인터 재사용 함정에 주의해야 합니다.
포인터 재사용 버그 (사용 금지)
// 잘못된 코드: 같은 msg 포인터에 RecvMsg로 계속 덮어씀
// 소비자가 이전 값을 참조하고 있어도 다음 호출이 그 메모리를 덮어씀
func RecvAllBad[T any](stream grpc.ClientStream, msg *T) iter.Seq2[*T, error] {
return func(yield func(*T, error) bool) {
for {
if err := stream.RecvMsg(msg); err != nil {
if err != io.EOF {
yield(nil, err)
}
return
}
if !yield(msg, nil) { // 위험: 다음 RecvMsg가 이 포인터 내용을 덮어씀
return
}
}
}
}올바른 구현 — 값으로 yield
func RecvAll[T any](recv func() (*T, error)) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
for {
msg, err := recv()
if err == io.EOF {
return
}
if err != nil {
var zero T
yield(zero, err)
return
}
if !yield(*msg, nil) { // 값 복사로 yield — 소비자가 안전하게 참조 가능
return
}
}
}
}
// 사용 예
for resp, err := range RecvAll(stream.Recv) {
if err != nil {
return fmt.Errorf("스트림 오류: %w", err)
}
process(resp)
}iter.Pull2로 두 스트림 병합
두 gRPC 스트림에서 값을 번갈아 읽어야 할 때처럼 두 Seq2를 동시에 진행해야 하는 경우에 iter.Pull2를 씁니다. iter.Pull2는 Seq2[K, V]를 받아 next() (K, V, bool), stop() 쌍을 반환합니다.
func MergeStreams[T any](a, b iter.Seq2[T, error]) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
nextA, stopA := iter.Pull2(a)
defer stopA()
nextB, stopB := iter.Pull2(b)
defer stopB()
for {
vA, errA, okA := nextA()
if okA && !yield(vA, errA) {
return
}
vB, errB, okB := nextB()
if okB && !yield(vB, errB) {
return
}
if !okA && !okB {
return
}
}
}
}stop()을 잊으면 백킹 코루틴이 영구 대기합니다. iter.Pull / iter.Pull2를 쓸 때는 선언 즉시 defer stop()을 붙이는 습관을 들여두세요.
실전 4: Go 1.24 strings 패키지로 로그 파이프라인
Go 1.24에 추가된 strings.FieldsSeq()는 이터레이터를 반환합니다. 대용량 로그 파일을 메모리에 올리지 않고 처리하려면 bufio.Scanner와 조합해야 합니다. io.ReadAll로 전체를 읽은 뒤 strings.Lines()로 순회하면 스트리밍의 의미가 없습니다.
func ParseAccessLog(r io.Reader) iter.Seq2[LogEntry, error] {
return func(yield func(LogEntry, error) bool) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Go 1.24: strings.FieldsSeq는 이터레이터 반환 — slices.Collect로 구체화
fields := slices.Collect(strings.FieldsSeq(line))
if len(fields) < 4 {
continue
}
entry, err := parseFields(fields)
if !yield(entry, err) {
return
}
}
if err := scanner.Err(); err != nil {
yield(LogEntry{}, err)
}
}
}
// 느린 요청만 집계
var slowRequests []LogEntry
for entry, err := range ParseAccessLog(logFile) {
if err != nil {
log.Println("파싱 오류:", err)
continue
}
if entry.Latency > 500*time.Millisecond {
slowRequests = append(slowRequests, entry)
}
}★ Insight ─────────────────────────────────────
strings.FieldsSeq(line)은 이터레이터를 반환하지만 여기서는len()검사를 위해slices.Collect()로 슬라이스로 변환했습니다. 필드 수 검사가 필요 없다면slices.Collect없이 직접for field := range strings.FieldsSeq(line)으로 처리해 할당을 줄일 수 있습니다.bufio.Scanner의 기본 버퍼는 64KB입니다. 한 줄이 그보다 긴 로그라면scanner.Buffer(make([]byte, 1<<20), 1<<20)으로 버퍼를 명시적으로 늘려야 합니다.─────────────────────────────────────────────────
자주 만나는 실수 4가지
1. yield 반환값 무시
// 잘못된 코드 — 컴파일 에러 없음, 버그 있음
func BadIterator() iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i < 1_000_000; i++ {
yield(i) // 반환값 무시! 소비자가 break해도 루프가 계속 돔
}
}
}
// 올바른 코드
func GoodIterator() iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i < 1_000_000; i++ {
if !yield(i) {
return
}
}
}
}팀 코드 리뷰 체크리스트에 "이터레이터 함수 안에서 yield(...) 반환값을 if !yield(...) 로 확인했는가?"를 추가해두길 권합니다.
2. iter.Pull에서 stop() 누락
// 잘못된 코드 — 백킹 코루틴 영구 대기
next, stop := iter.Pull(someSeq)
for {
v, ok := next()
if !ok { break }
process(v)
}
// 올바른 코드
next, stop := iter.Pull(someSeq)
defer stop() // 선언 즉시 defer3. 이터레이터 안에서 패닉
range-over-func에서 이터레이터 함수와 소비자 루프 바디는 같은 goroutine의 동일 콜 스택 위에서 실행됩니다. 따라서 이터레이터 내부에서 패닉이 발생하면 호출 스택을 타고 소비자 쪽으로 전파됩니다. 위험한 연산은 에러로 처리하거나 이터레이터 내부에서 recover()를 씁니다.
func SafeIterator(data []string) iter.Seq2[int, error] {
return func(yield func(int, error) bool) {
for _, s := range data {
n, err := strconv.Atoi(s)
if err != nil {
if !yield(0, err) {
return
}
continue
}
if !yield(n, nil) {
return
}
}
}
}4. next()를 여러 goroutine에서 동시 호출
iter.Pull이 반환하는 next()는 goroutine-safe하지 않습니다. 공식 문서에도 명시된 제약입니다. 여러 goroutine에서 동시 호출하면 data race가 발생합니다.
// 잘못된 코드 — data race 발생
next, stop := iter.Pull(seq)
defer stop()
for range workers {
go func() {
v, ok := next() // 동시 호출 금지!
process(v)
}()
}병렬 처리가 필요하다면 처음부터 채널 기반 파이프라인으로 설계하는 것이 훨씬 명확합니다. 이터레이터는 기본적으로 순차적이며, 병렬성을 끼워 넣으려면 next()를 뮤텍스로 직렬화하는 추가 작업이 필요합니다.
패턴 비교표
| 항목 | 채널 패턴 | 콜백 패턴 | range-over-func |
|---|---|---|---|
| 성능 (단순 전달) | ~400ns/값 | ~2–5ns/값¹ | ~2–5ns/값 (push) |
| 성능 (pull 변환) | — | — | ~20ns/값 (iter.Pull) |
| 조기 중단 안전성 | 위험 (ctx 필요) | 안전 | 안전 (컴파일러 보장) |
| 에러 전달 | 별도 채널 필요 | 반환값 활용 | Seq2[V, error] |
| for range 사용 | 가능 | 불가능 | 가능 |
| 지연 평가 | 버퍼 크기 의존 | 가능 | 기본 지원 |
| 병렬 처리 | 자연스러움 | 직접 구현 | 채널 패턴 권장 |
| 디버깅 | 고루틴 스택 추적 | 쉬움 | 코루틴 스택 |
¹ 콜백과 push 이터레이터는 모두 함수 호출 수준이며 성능이 유사합니다. 컴파일러 인라인 최적화 여부에 따라 달라질 수 있습니다.
병렬 생산/소비 패턴에는 채널이 더 적합합니다. 이것은 채널 패턴의 명확한 장점입니다. 이터레이터의 순차성은 단일 스트림을 순서대로 처리하는 대부분의 스트리밍 시나리오에 잘 맞지만, 여러 소스를 병렬로 처리해야 한다면 처음부터 채널 기반으로 설계하세요.
마치며
range-over-func는 기존 콜백 패턴을 for range 문법으로 쓸 수 있게 한 것이 핵심입니다. 여기에 더해 break/return/panic 처리를 컴파일러가 보장해줌으로써, 채널 패턴에서 손수 관리해야 했던 고루틴 누수 위험을 언어 수준에서 제거합니다. 채널 기반 패턴이 지닌 병렬성 장점은 그대로이므로, 두 패턴은 경쟁 관계가 아니라 용도에 따라 선택하는 도구입니다.
Go 1.24 기준으로 slices, maps, strings, bytes 패키지가 이미 이터레이터를 반환하고 있어서, 새 코드에서 자연스럽게 접하게 됩니다. 기존 채널·콜백 API를 전면 재작성할 필요는 없습니다. 신규 API부터 iter.Seq / iter.Seq2로 설계하고, 점진적으로 전환하는 것이 현실적입니다.
지금 시작해볼 수 있는 3단계:
- 기존 채널 이터레이터 중
break가 위험한 곳을 찾아서iter.Seq로 교체 - DB 쿼리나 HTTP 스트림처럼 에러가 있는 스트림은
iter.Seq2[V, error]로 설계 - **
iter.Pull/iter.Pull2를 쓸 때는 반드시defer stop()**을 패턴으로 굳혀두기
참고 자료
- Range Over Function Types — Go 공식 블로그 (2024.08)
- Go 1.23 Release Notes
- Go 1.24 Release Notes
- iter 패키지 공식 문서
- Optimizing in-process gRPC with Go 1.23 Iterators and Coroutines — bwplotka (2025.01)
- Streaming Database Queries in Go 1.23 Using Iterators
- First impressions of Go 1.23's range-over-func feature — Boldly Go
- Ranging over functions in Go 1.23 — Eli Bendersky
- Go's range-over-func: 4 Footguns the Compiler Won't Warn You About
- Go Iterators: A Practical Guide to the iter Package
- Channel iteration and goroutine leak — Redowan's Reflections
- Go Concurrency Patterns: Pipelines and cancellation — Go 공식 블로그