Go

Go 1.23 iter 패키지 완전 정복

드리프트2 2024. 8. 24. 10:15

 

소개

 

Go 1.23 버전부터 새롭게 등장한 iter 패키지는 개발자들에게 반복 처리(iteration)를 보다 효율적이고 유연하게 관리할 수 있는 방법을 제공합니다.

 

iter 패키지는 추상화된 반복자(iterator) 개념을 도입하여, 기존의 반복 처리 방식을 개선하고 다양한 활용 가능성을 열어줍니다.

 

이 글에서는 iter 패키지의 기본 개념과 사용법, 그리고 실제 활용 예시를 통해 Go 개발에서의 활용성을 살펴보겠습니다.

 

 

iter 패키지의 핵심 개념

 

iter 패키지는 주로 for-range 문과 함께 사용됩니다.

 

핵심적인 역할은 컨텍스트(context)를 가진 논리적인 열거 가능 객체를 생성하고, 이를 다른 코드 블록에서 for-range를 통해 순회하는 데 도움을 주는 것입니다.

 

기존에는 goroutine과 channel을 활용하여 범위를 분할하는 방식으로 이러한 기능을 구현했습니다.

 

하지만 이 방식은 for-range 루프를 중간에 중단해야 할 경우 goroutine이 남아 처리되지 않는 문제점을 가지고 있었습니다.

 

또한, 단순히 반복 처리를 위해 goroutine과 channel을 사용하는 것은 자원 낭비가 될 수 있습니다.

 

 

기존 방식의 한계점: goroutine과 channel을 이용한 반복 처리

 

다음은 goroutine과 channel을 사용하여 슬라이스를 순회하는 예시입니다.

package main

func iter1[T any](a []T) func() (T, bool) {
        ch := make(chan T)
        go func() {
                defer close(ch)
                for _, v := range a {
                        ch <- v 
                }
        }()
        return func() (T, bool) {
                v, ok := <-ch
                return v, ok
        }
}

func main() {
        vv := iter1([]int{1, 2, 3})
        for {
                v, ok := vv()
                if !ok {
                        break
                }
                println(v) 
        }
} 

 

위 코드에서 for 루프를 중간에 break하더라도, goroutine은 계속 실행되어 자원을 소모합니다.

 

이러한 문제를 해결하기 위해 context 패키지를 활용하여 goroutine을 제어할 수 있지만, 코드가 더욱 복잡해집니다.

 

 

context를 활용한 개선

 

context를 활용하면 goroutine의 실행을 제어하여 자원 낭비를 줄일 수 있습니다.

package main

import (
        "context"
)

func iter1[T any](ctx context.Context, a []T) func() (T, bool) {
        ch := make(chan T)
        go func() {
                defer close(ch)
                for _, v := range a {
                        select {
                        case ch <- v:
                        case <-ctx.Done():
                        }
                }
        }()
        return func() (T, bool) {
                v, ok := <-ch
                return v, ok
        }
}

func main() {
        ctx, cancel := context.WithCancel(context.Background())
        defer cancel()

        vv := iter1(ctx, []int{1, 2, 3})
        for {
                v, ok := vv()
                if !ok {
                        break
                }
                println(v)
                if v == 2 {
                        cancel()
                        break
                }
        }
}

 

하지만 이러한 방식은 반복 처리를 위해 불필요한 goroutinechannel을 생성해야 한다는 단점을 여전히 가지고 있습니다.

 

 

iter 패키지 활용

 

iter 패키지는 이러한 문제점을 해결하고 보다 효율적인 반복 처리를 가능하게 합니다.

 

iter 패키지는 SeqSeq2라는 두 가지 주요 타입을 제공합니다.

  • Seq[V any]: 슬라이스와 같이 단일 값을 반복하는 경우 사용합니다.
  • Seq2[K, V any]: 맵과 같이 키-값 쌍을 반복하는 경우 사용합니다.

 

iter.Seq와 iter.Seq2의 차이

 

iter.Seq는 슬라이스를 for-range로 처리하는 방식과 유사합니다.

a := []int{1, 2, 3}
for v := range a {
        // v를 사용하는 코드
}

 

반면 iter.Seq2는 맵을 for-range로 처리하는 방식과 유사합니다.

m := map[string]int{}
for k, v := range m {
        // k 또는 v를 사용하는 코드
}

 

iter.Seq를 이용한 반복 처리 예시

 

iter.Seq를 사용하여 모든 값을 출력하는 함수를 다음과 같이 정의할 수 있습니다.

func PrintAll[V any](seq iter.Seq[V]) {
        for v := range seq {
                fmt.Println(v)
        }
} 

 

위 함수는 기존의 for-range 문과 동일한 방식으로 iter.Seq를 사용합니다.

 

iter 구현: 파일에서 줄 읽어오기

 

iter 패키지를 직접 구현하는 방법은 조금 복잡하지만, 일관된 패턴을 따르면 어렵지 않습니다.

 

예를 들어, 파일에서 줄 단위로 데이터를 읽어와 for-range로 처리하는 함수를 구현해보겠습니다.

package main

import (
        "bufio"
        "io"
        "iter"
        "log"
        "os"
)

func lines(r io.Reader) iter.Seq[string] {
        scanner := bufio.NewScanner(r)
        return func(yield func(string) bool) {
                for scanner.Scan() {
                        if !yield(scanner.Text()) {
                                break
                        }
                }
        }
}

 

lines 함수는 io.Reader를 입력받아 iter.Seq[string]을 반환합니다.

 

iter.Seq의 본체는 함수이며, 이 함수의 인수 yield는 각 줄을 처리하는 코루틴 역할을 합니다.

 

yield 함수는 처리할 값을 입력받고, 반환값 boolfor-range 루프를 계속 진행할지 여부를 결정합니다.

 

false를 반환하면 for-range 루프가 중단됩니다.

 

 

iter.Seq2를 이용한 반복 처리 예시

 

iter.Seq2를 사용하는 예시로, 학급의 학생 성적을 관리하는 구조체를 생각해 볼 수 있습니다.

type ClassRoom struct {
        // ...
}

func (cr *ClassRoom) Scores() iter.Seq2[string, int] {
        // ...
}

func main() {
        cr, _ := loadClassRoom("3A")

        for name, score := range cr.Scores() {
                fmt.Printf("name=%v, score=%v\n", name, score)
        }
}

 

표준 패키지에서의 iter 활용

 

slicesmaps 패키지에는 iter 패키지와 연동하여 사용할 수 있는 다양한 함수들이 추가되었습니다.

 

예를 들어, slices.Values 함수는 슬라이스의 모든 값을 iter.Seq로 반환하고, maps.Keys 함수는 맵의 모든 키를 iter.Seq로 반환합니다.

 

 

iter의 효과: 메모리 효율성

 

iter 패키지는 단순히 범위를 분리하는 것뿐만 아니라 메모리 효율성도 향상시킵니다.

 

예를 들어, 매우 큰 문자열을 공백으로 분리하여 처리해야 하는 경우, strings.Split 함수를 사용하면 모든 토큰을 메모리에 저장해야 하므로 메모리 부족 현상이 발생할 수 있습니다.

 

하지만 iter 패키지를 사용하면 필요한 토큰만 메모리에 로드하여 처리할 수 있으므로 메모리 사용량을 줄일 수 있습니다.

 

 

벤치마크 결과

 

다음은 strings.Split 함수와 iter.Seq를 사용한 벤치마크 결과입니다.

goos: linux
goarch: amd64
pkg: iterbench
cpu: Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
BenchmarkWithoutIter-8           190472      5547 ns/op    1792 B/op          1 allocs/op
BenchmarkWithIter-8              492627      2129 ns/op      24 B/op          2 allocs/op
PASS
ok      iterbench       2.743s

 

결과에서 확인할 수 있듯이, iter 패키지를 사용하면 실행 속도와 메모리 사용량을 모두 개선할 수 있습니다.

 

마무리

 

Go 1.23 버전부터 도입된 iter 패키지는 반복 처리를 보다 효율적이고 유연하게 관리할 수 있는 강력한 도구입니다.

 

처음에는 함수 시그니처가 다소 복잡해 보일 수 있지만, 익숙해지면 iter 패키지를 활용하여 코드를 더욱 깔끔하고 효율적으로 작성할 수 있습니다.

 

iter 패키지를 적극 활용하여 견고하고 유지보수하기 쉬운 Go 애플리케이션을 개발해 보시기 바랍니다.