ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Go 언어에서 Fake Time 사용으로 병렬 처리 테스트의 혁신
    Go 2024. 10. 6. 21:33

    Go 언어에서 Fake Time 사용으로 병렬 처리 테스트의 혁신


    서론

    안녕하세요, 개발자 여러분!

     

    오늘은 Go 언어에서 병렬 처리 테스트를 보다 효율적으로 수행할 수 있는 Fake Time 도구에 대해 이야기해보려고 합니다.

     

    Go의 Goroutine은 강력한 병렬 처리 기능을 제공하지만, 이를 테스트할 때 겪는 어려움이 많습니다.

     

    이번 포스트에서는 Fake Time을 활용하여 이러한 문제들을 어떻게 해결할 수 있는지 살펴보겠습니다.

     


     

    Fake Time이란 무엇인가?

    Fake Time은 테스트 환경에서 시간을 조작할 수 있게 해주는 도구입니다.

     

    이를 통해 시간에 의존적인 코드의 테스트를 빠르고 안정적으로 수행할 수 있습니다.

     

    예를 들어, Timer나 Ticker를 사용하는 코드는 실제 시간을 기다려야 하기 때문에 테스트 시간이 길어지고 불안정할 수 있습니다.

     

    Fake Time을 사용하면 이러한 문제를 해결할 수 있습니다.

     

    주의: 현재(2020년 12월 기준) Fake Time은 Windows에서는 지원되지 않습니다.

     

    Windows 환경에서는 Docker 등을 활용해주시기 바랍니다.

     


     

    Fake Time을 이용한 테스트 실행 방법

    Fake Time을 테스트에 적용하려면 --tags=faketime 옵션을 추가해서 테스트를 실행하면 됩니다.

    go test --tags=faketime [패키지]

     

    또한, Fake Time을 사용하는 테스트 코드에는 빌드 태그를 추가하여 Fake Time이 활성화된 경우에만 해당 테스트가 실행되도록 할 수 있습니다.

    // +build faketime
    
    package hoge

    Playback Header 제거 도구: trimfaketime

    Fake Time을 사용할 때 출력되는 Playback header를 제거하기 위해 trimfaketime 도구를 사용할 수 있습니다.

     

    설치 방법:

    go get -u github.com/knightso/trimfaketime/cmd/tft

     

    테스트 실행 시 사용 방법:

    go test --tags=faketime [패키지] |& tft

     

    테스트 예제: "500/50/5" 룰 제어

    Cloud Firestore(Datastore)에서는 "500/50/5" 룰이라는 베스트 프랙티스가 있습니다.

     

    이는 대량의 데이터 접근이 필요할 때, 일시에 급증시키지 않고 초기값 500에서 시작하여 5분마다 50%씩 증가시키는 방식입니다.

     

    이를 제어하는 유틸리티를 만들고 테스트해보겠습니다.

     

    테스트 대상 코드

    package faketime
    
    import (
        "sync"
        "sync/atomic"
        "time"
    )
    
    type RampUp struct {
        rate       float64
        interval   time.Duration
        maxCount   int
        throughput int64
        closeCh    chan struct{}
        closeOnce  sync.Once
    }
    
    func NewRampUp(initial int64, rate float64, interval time.Duration, maxCount int) *RampUp {
        return &RampUp{
            rate:       rate,
            interval:   interval,
            maxCount:   maxCount,
            throughput: initial,
            closeCh:    make(chan struct{}),
        }
    }
    
    func (r *RampUp) Start() {
        ticker := time.NewTicker(r.interval)
    
        go func() {
            defer ticker.Stop()
    
            count := 0
            for {
                select {
                case <-r.closeCh:
                    return
                case <-ticker.C:
                    if count >= r.maxCount {
                        continue
                    }
                    atomic.StoreInt64(&r.throughput, int64(float64(r.throughput)*r.rate))
                    count++
                }
            }
        }()
    }
    
    func (r *RampUp) Throughput() int64 {
        return atomic.LoadInt64(&r.throughput)
    }
    
    func (r *RampUp) Stop() {
        r.closeOnce.Do(func() {
            close(r.closeCh)
        })
    }

     

    RampUp의 Start 메서드를 실행하면 내부에서 Goroutine이 시작되고, Ticker를 통해 5분마다 throughput 값을 1.5배로 증가시킵니다.

     

    테스트 코드

    // +build faketime
    
    package faketime
    
    import (
        "testing"
        "time"
    )
    
    func TestRampUp(t *testing.T) {
        rampup := NewRampUp(500, 1.5, 5*time.Minute, 18)
        rampup.Start()
        defer rampup.Stop()
    
        time.Sleep(1)
    
        if expected, actual := int64(500), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    
        time.Sleep(5 * time.Minute)
    
        if expected, actual := int64(750), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    
        time.Sleep(5 * time.Minute)
    
        if expected, actual := int64(1125), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    
        time.Sleep(5 * time.Minute)
    
        if expected, actual := int64(1687), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    
        // 생략
    
        time.Sleep(14 * 5 * time.Minute)
    
        if expected, actual := int64(492319), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    
        time.Sleep(5 * time.Minute)
    
        if expected, actual := int64(738478), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    
        time.Sleep(5 * time.Minute)
    
        // maxCount 초과
        if expected, actual := int64(738478), rampup.Throughput(); expected != actual {
            t.Fatalf("throughput - expected:%d, but was:%d", expected, actual)
        }
    }

     

    위 테스트는 time.Sleep을 사용하여 5분씩 시간을 진행시키며 값의 변화를 검증합니다.

     

    하지만 실제로는 시간을 기다려야 해서 테스트가 불안정해질 수 있습니다.


     

    Fake Time을 사용하지 않을 때의 문제점

    Fake Time을 사용하지 않고 위와 같은 테스트를 작성하면 다음과 같은 문제가 발생할 수 있습니다.

    1. 테스트 시간 지연: time.Sleep을 사용하면 테스트 실행 시간이 길어집니다.
    2. 불안정성: 실제 시간에 의존하기 때문에 테스트가 일관되게 통과하지 않을 수 있습니다. 예를 들어, 시스템이 느릴 때 테스트가 실패할 수 있습니다.
    3. 복잡한 타이밍 조절: Goroutine의 실행 순서나 타이밍을 정확히 제어하기 어렵습니다.

    Fake Time을 사용하면 이러한 문제들을 해결할 수 있으며, 테스트를 빠르고 안정적으로 수행할 수 있습니다.


     

    테스트 실행 방법

    Fake Time을 사용하는 테스트는 다음과 같이 실행할 수 있습니다.

     

    테스트가 실제로는 빠르게 종료되지만, Fake Time 덕분에 테스트 코드 내에서 시간의 흐름을 정확히 조절할 수 있습니다.

    go test --tags=faketime -timeout=100m .

     

    주의: 테스트 코드 내에서 약 100분 정도의 시간이 소요되는 것으로 설정되어 있기 때문에 -timeout 옵션을 추가하여 테스트가 중단되지 않도록 합니다.

     

    하지만 Fake Time 덕분에 실제로는 빠르게 종료됩니다.

     


     

    병렬 처리 관련 코드 테스트

    Fake Time은 단순히 Goroutine을 사용하는 코드뿐만 아니라, 여러 Goroutine이 동시에 접근하는 코드의 테스트에도 유용하게 사용됩니다.

     

    예를 들어, 캐시 라이브러리의 테스트에서도 Fake Time을 활용할 수 있습니다.

     

    예제: 캐시 라이브러리 테스트

    func TestGetAndReserve(t *testing.T) {
        start := time.Now()
    
        cache, err := New()
        if err != nil {
            t.Fatal(err)
        }
    
        // 테스트 리포트를 동기화하기 위한 유틸리티 메서드 생성
        var mux sync.Mutex
        lock := func(f func()) {
            mux.Lock()
            defer mux.Unlock()
            f()
        }
    
        var wg sync.WaitGroup
    
        wg.Add(1)
        go func() {
            defer wg.Done()
    
            if _, err = cache.Get("key1"); err != ErrEntryNotFound {
                lock(func() { t.Errorf("ErrEntryNotFound expected, but was:%v", err) })
            }
        }()
    
        wg.Add(1)
        go func() {
            defer wg.Done()
    
            time.Sleep(time.Nanosecond)
    
            resolve := cache.Reserve("key1")
    
            time.Sleep(5 * time.Nanosecond)
    
            resolve("value1", nil)
        }()
    
        for i := 0; i < 10; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
    
                time.Sleep(2 * time.Nanosecond)
    
                value, err := cache.Get("key1")
                if err != nil {
                    lock(func() { t.Error(err) })
                }
    
                // 캐시가 업데이트 된 것을 확인
                if actual, expected := time.Now().Sub(start), 6*time.Nanosecond; actual != expected {
                    lock(func() { t.Errorf("expected:%v, but was:%v", expected, actual) })
                }
    
                if actual, expected := value.(string), "value1"; actual != expected {
                    lock(func() { t.Errorf("expected:%v, but was:%v", expected, actual) })
                }
            }()
        }
    
        wg.Add(1)
        go func() {
            defer wg.Done()
    
            if _, err = cache.Get("key2"); err != ErrEntryNotFound {
                lock(func() { t.Fatalf("ErrEntryNotFound expected, but was:%v", err) })
            }
        }()
    
        time.Sleep(time.Second)
    
        wg.Wait() // 모든 Goroutine이 종료되었는지 확인
    }

     

    위 테스트에서는 여러 Goroutine을 동시에 실행하면서 Fake Time을 이용해 시간의 흐름을 정밀하게 조절합니다.

     

    이를 통해 테스트가 일관되게 통과할 수 있습니다.

     

    주의: *testing.T의 리포트 메서드(Error, Fatal 등)는 Goroutine에서 안전하게 사용할 수 없으므로, sync.Mutex 등을 이용해 동기화해야 합니다.

     


     

    Fake Time의 단점

    Fake Time을 사용할 때의 단점도 존재합니다.

    1. Fake Time 의존성: Fake Time에 의존한 테스트 코드는 Fake Time 없이 실행될 때 올바르게 동작하지 않을 수 있습니다.
    2. 호환성 문제: 일부 코드나 라이브러리는 Fake Time과 호환되지 않아 테스트가 실패할 수 있습니다. 특히 네트워크 관련 라이브러리(net, net/http 등)는 Fake Time 사용 시 문제가 발생할 수 있습니다.
    3. 빌드 태그 관리: Fake Time을 사용하는 테스트와 사용하지 않는 테스트를 구분하기 위해 빌드 태그를 관리해야 합니다.

    Fake Time을 사용하지 않는 경우에도 이러한 단점을 최소화하기 위해 다양한 테스트 전략을 함께 고려하는 것이 좋습니다.

     


     

    결론

    Fake Time을 사용하면 Go 언어에서 병렬 처리(Goroutine)를 포함한 시간에 민감한 코드의 테스트를 보다 쉽고 효율적으로 작성할 수 있습니다.

     

    테스트 시간을 단축시키고, 테스트의 안정성을 높일 수 있는 장점이 있지만, Fake Time에 대한 의존성과 호환성 문제 등 단점도 존재합니다.

     

    이러한 점을 고려하여 프로젝트에 적절히 활용하면 테스트 품질을 크게 향상시킬 수 있을 것입니다.

     

    앞으로 다양한 사례를 통해 Fake Time의 활용 방법을 더욱 탐구해보겠습니다!

     


     

Designed by Tistory.