Go에서의 병렬 테이블 기반 테스트

파라렐 실행으로 Go 테스트 속도를 높이세요

Page content

테이블 기반 테스트는 Go에서 여러 시나리오를 효율적으로 테스트하는 표준적인 접근 방식입니다. t.Parallel()을 사용하여 병렬 실행을 결합하면, 특히 I/O 중심 작업에 대해 테스트 스위트 실행 시간을 크게 줄일 수 있습니다.

그러나 병렬 테스트는 경쟁 조건과 테스트 고립성에 대한 고유한 도전 과제를 소개하며, 이에 대한 주의 깊은 처리가 필요합니다.

병렬 테이블 기반 테스트 - Go - 경쟁 조건

병렬 테스트 실행 이해

Go의 테스트 패키지는 t.Parallel() 메서드를 통해 병렬 테스트 실행을 위한 내장 지원을 제공합니다. 테스트가 t.Parallel()을 호출하면 테스트 실행자에게 이 테스트가 다른 병렬 테스트와 안전하게 동시 실행될 수 있음을 알립니다. 이는 특히 테이블 기반 테스트와 결합될 때 매우 강력합니다. 이 경우 여러 독립적인 테스트 사례가 동시에 실행될 수 있습니다.

기본 병렬성은 GOMAXPROCS에 의해 제어되며, 이는 일반적으로 컴퓨터의 CPU 코어 수와 같습니다. -parallel 플래그를 사용하여 이 값을 조정할 수 있습니다: go test -parallel 4는 CPU 수와 관계없이 동시 테스트를 4개로 제한합니다. 이는 리소스 사용량을 제어하거나 테스트에 특정 병렬성 요구사항이 있을 때 유용합니다.

Go 테스트에 처음 접하는 개발자에게 테스트의 기초를 이해하는 것은 매우 중요합니다. 우리의 Go 단위 테스트 최고 실천 방법 가이드는 테이블 기반 테스트, 서브테스트 및 병렬 실행을 위한 테스트 패키지 기초를 다룹니다.

기본 병렬 테이블 기반 테스트 패턴

병렬 테이블 기반 테스트의 올바른 패턴은 다음과 같습니다:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addition", 2, 3, "+", 5, false},
        {"subtraction", 5, 3, "-", 2, false},
        {"multiplication", 4, 3, "*", 12, false},
        {"division", 10, 2, "/", 5, false},
        {"division by zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        tt := tt // 루프 변수 캡처
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 병렬 실행 활성화
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

tt := ttt.Run() 전에 매우 중요합니다. 이는 루프 변수의 현재 값을 캡처하여 각 병렬 서브테스트가 테스트 사례 데이터의 별도 복사본을 사용하도록 보장합니다.

루프 변수 캡처 문제

이것은 t.Parallel()과 테이블 기반 테스트를 사용할 때 가장 흔한 함정 중 하나입니다. Go에서 루프 변수 tt는 모든 반복에서 공유됩니다. 서브테스트가 병렬로 실행되면 모두 같은 tt 변수를 참조할 수 있으며, 루프가 계속 진행되면서 이 변수가 덮어쓰여질 수 있습니다. 이는 경쟁 조건과 예측 불가능한 테스트 실패로 이어집니다.

잘못된 예 (경쟁 조건):

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // 모든 서브테스트가 동일한 tt 값을 볼 수 있음!
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

올바른 예 (캡처된 변수):

for _, tt := range tests {
    tt := tt // 루프 변수 캡처
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // 각 서브테스트가 tt의 별도 복사본을 가짐
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

tt := tt 할당은 루프 반복에 한정된 범위의 새로운 변수를 생성하여 각 고루틴이 테스트 사례 데이터의 별도 복사본을 가질 수 있도록 보장합니다.

테스트 독립성 보장

병렬 테스트가 올바르게 작동하려면 각 테스트가 완전히 독립적이어야 합니다. 다음을 해서는 안 됩니다:

  • 전역 상태나 변수를 공유
  • 동기화 없이 공유 자원을 수정
  • 실행 순서에 의존
  • 조율 없이 동일한 파일, 데이터베이스, 네트워크 자원에 접근

독립적인 병렬 테스트 예시:

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"valid email", "user@example.com", false},
        {"invalid format", "not-an-email", true},
        {"missing domain", "user@", true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            err := ValidateEmail(tt.email)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", 
                    tt.email, err, tt.wantErr)
            }
        })
    }
}

각 테스트 사례는 공유 상태 없이 자체 입력 데이터를 사용하여 병렬 실행에 안전합니다.

경쟁 조건 탐지

Go는 병렬 테스트에서 데이터 경쟁을 탐지하기 위한 강력한 경쟁 조건 탐지기(레이스 디텍터)를 제공합니다. 개발 중에는 항상 -race 플래그를 사용하여 병렬 테스트를 실행하세요:

go test -race ./...

레이스 디텍터는 적절한 동기화 없이 공유 메모리에 대한 동시 접근을 보고합니다. 이는 특정 타이밍 조건 하에서만 나타날 수 있는 세심한 버그를 포착하는 데 필수적입니다.

예시 레이스 조건:

var counter int // 공유 상태 - 위험!

func TestIncrement(t *testing.T) {
    tests := []struct {
        name string
        want int
    }{
        {"test1", 1},
        {"test2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            counter++ // 레이스 조건!
            if counter != tt.want {
                t.Errorf("counter = %d, want %d", counter, tt.want)
            }
        })
    }
}

이를 -race 플래그와 함께 실행하면 counter의 동시 수정을 탐지합니다. 이 문제를 해결하려면 공유 상태 대신 로컬 변수를 사용하여 각 테스트가 독립적으로 작동하도록 해야 합니다.

성능 이점

병렬 실행은 테스트 스위트 실행 시간을 크게 줄일 수 있습니다. 속도 향상은 다음과 같은 요소에 따라 달라집니다:

  • CPU 코어 수: 더 많은 코어는 동시에 실행되는 테스트 수를 늘릴 수 있습니다.
  • 테스트 특성: I/O 중심 테스트는 CPU 중심 테스트보다 더 많은 이점을 얻을 수 있습니다.
  • 테스트 수: 테스트 스위트가 클수록 절대적인 시간 절약이 더 큽니다.

성능 측정:

# 순차 실행
go test -parallel 1 ./...

# 병렬 실행 (기본값)
go test ./...

# 사용자 정의 병렬성
go test -parallel 8 ./...

많은 I/O 작업(데이터베이스 쿼리, HTTP 요청, 파일 작업)이 포함된 테스트 스위트의 경우, 현대 멀티코어 시스템에서 2~4배의 속도 향상을 달성할 수 있습니다. CPU 중심 테스트는 CPU 리소스에 대한 경쟁으로 인해 덜 많은 이점을 얻을 수 있습니다.

병렬성 제어

병렬 테스트 실행을 제어하는 여러 가지 옵션이 있습니다:

1. 최대 병렬 테스트 제한:

go test -parallel 4 ./...

2. GOMAXPROCS 설정:

GOMAXPROCS=2 go test ./...

3. 선택적 병렬 실행:

특정 테스트만 t.Parallel()로 표시합니다. 이 호출이 없는 테스트는 순차적으로 실행되며, 일부 테스트가 순서대로 실행되어야 하거나 자원을 공유해야 할 때 유용합니다.

4. 조건부 병렬 실행:

func TestExpensive(t *testing.T) {
    if testing.Short() {
        t.Skip("짧은 모드에서 비용이 많이 드는 테스트 건너뛰기")
    }
    t.Parallel()
    // 비용이 많이 드는 테스트 로직
}

일반적인 패턴 및 최고 실천 방법

패턴 1: 병렬 실행 전에 설정

모든 테스트 사례에 공유되는 설정이 필요한 경우, 루프 전에 수행합니다:

func TestWithSetup(t *testing.T) {
    // 병렬 실행 전에 한 번 실행되는 설정 코드
    db := setupTestDatabase(t)
    defer db.Close()

    tests := []struct {
        name string
        id   int
    }{
        {"user1", 1},
        {"user2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // 각 테스트는 db를 독립적으로 사용
            user := db.GetUser(tt.id)
            // 테스트 로직...
        })
    }
}

패턴 2: 테스트별 설정

독립적인 설정이 필요한 테스트는 각 서브테스트 내부에서 수행합니다:

func TestWithPerTestSetup(t *testing.T) {
    tests := []struct {
        name string
        data string
    }{
        {"test1", "data1"},
        {"test2", "data2"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // 각 테스트는 자체 설정을 받음
            tempFile := createTempFile(t, tt.data)
            defer os.Remove(tempFile)
            // 테스트 로직...
        })
    }
}

패턴 3: 순차 및 병렬 테스트 혼합

동일한 파일 내에서 순차 및 병렬 테스트를 혼합할 수 있습니다:

func TestSequential(t *testing.T) {
    // t.Parallel() 없음 - 순차 실행
    // 순서대로 실행되어야 하는 테스트에 적합
}

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"test1"}, {"test2"}}
    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 이들은 병렬로 실행됨
        })
    }
}

병렬 실행을 사용하지 않는 경우

병렬 실행은 항상 적절하지는 않습니다. 다음 상황에서는 사용하지 마세요:

  1. 테스트가 상태를 공유: 전역 변수, 싱글톤, 또는 공유 자원
  2. 테스트가 공유 파일을 수정: 임시 파일, 테스트 데이터베이스, 또는 설정 파일
  3. 테스트가 실행 순서에 의존: 일부 테스트가 다른 테스트보다 먼저 실행되어야 함
  4. 테스트가 이미 빠름: 병렬화의 오버헤드가 이점보다 크면
  5. 리소스 제약: 병렬화 시 메모리 또는 CPU 사용량이 너무 많음

데이터베이스 관련 테스트의 경우, 트랜잭션 롤백 또는 테스트별 별도 데이터베이스 사용을 고려하세요. 우리의 Go에서 다중 테넌트 데이터베이스 패턴 가이드는 병렬 테스트와 잘 작동하는 고립 전략을 다룹니다.

고급: 동시 코드 테스트

동시 코드 자체(테스트를 병렬로 실행하는 것에 한정되지 않음)를 테스트할 때는 추가 기술이 필요합니다:

func TestConcurrentOperation(t *testing.T) {
    tests := []struct {
        name      string
        goroutines int
    }{
        {"2 goroutines", 2},
        {"10 goroutines", 10},
        {"100 goroutines", 100},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            var wg sync.WaitGroup
            results := make(chan int, tt.goroutines)
            
            for i := 0; i < tt.goroutines; i++ {
                wg.Add(1)
                go func() {
                    defer wg.Done()
                    results <- performOperation()
                }()
            }
            
            wg.Wait()
            close(results)
            
            // 결과 검증
            count := 0
            for range results {
                count++
            }
            if count != tt.goroutines {
                t.Errorf("예상된 결과 수 %d, 실제 결과 수 %d", tt.goroutines, count)
            }
        })
    }
}

이러한 테스트를 실행할 때 -race 플래그를 사용하여 테스트 코드 내의 데이터 경쟁을 탐지하는 것이 중요합니다.

CI/CD 통합

병렬 테스트는 CI/CD 파이프라인과 원활하게 통합됩니다. 대부분의 CI 시스템은 여러 CPU 코어를 제공하므로 병렬 실행이 매우 유리합니다:

# GitHub Actions 예시
- name: 테스트 실행
  run: |
    go test -race -coverprofile=coverage.out -parallel 4 ./...
    go tool cover -html=coverage.out -o coverage.html    

CI에서 -race 플래그는 로컬 개발 환경에서는 나타나지 않는 동시성 버그를 포착하는 데 특히 중요합니다.

병렬 테스트 실패 디버깅

병렬 테스트가 간헐적으로 실패할 경우 디버깅이 어렵습니다:

  1. -race 플래그로 실행: 데이터 경쟁 탐지
  2. 병렬성 감소: go test -parallel 1로 실행하여 실패가 사라지는지 확인
  3. 특정 테스트 실행: go test -run TestName으로 문제를 고립
  4. 로깅 추가: t.Log()를 사용하여 실행 순서 추적
  5. 공유 상태 확인: 전역 변수, 싱글톤, 공유 자원을 확인

테스트가 순차적으로 실행될 때는 실패하지만 병렬로 실행될 때는 실패할 경우, 경쟁 조건 또는 공유 상태 문제를 가지고 있을 가능성이 높습니다.

실제 사례: HTTP 핸들러 테스트

HTTP 핸들러를 병렬로 테스트하는 실제 예시입니다:

func TestHTTPHandlers(t *testing.T) {
    router := setupRouter()
    
    tests := []struct {
        name       string
        method     string
        path       string
        statusCode int
    }{
        {"GET users", "GET", "/users", 200},
        {"GET user by ID", "GET", "/users/1", 200},
        {"POST user", "POST", "/users", 201},
        {"DELETE user", "DELETE", "/users/1", 204},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            router.ServeHTTP(w, req)
            
            if w.Code != tt.statusCode {
                t.Errorf("예상된 상태 %d, 실제 상태 %d", tt.statusCode, w.Code)
            }
        })
    }
}

각 테스트는 httptest.NewRecorder()를 사용하여 고립된 응답 기록기를 생성하므로 병렬 실행에 안전합니다.

결론

테이블 기반 테스트의 병렬 실행은 Go에서 테스트 스위트 실행 시간을 줄이는 강력한 기술입니다. 성공의 열쇠는 루프 변수 캡처 요구사항을 이해하고, 테스트의 독립성을 보장하며, 동시성 문제를 조기에 탐지하기 위해 레이스 디텍터를 사용하는 것입니다.

기억하세요:

  • 항상 tt := ttt.Parallel() 전에 수행하세요
  • 테스트가 공유 상태 없이 독립적이어야 합니다
  • 개발 중에는 -race 플래그로 테스트를 실행하세요
  • 필요할 경우 -parallel 플래그로 병렬성을 제어하세요
  • 자원을 공유하는 테스트에 병렬 실행을 피하세요

이러한 실천을 따르면 테스트 스위트를 빠르게 실행하면서도 신뢰성을 유지할 수 있습니다. 더 많은 Go 테스트 패턴을 보려면 우리의 포괄적인 Go 단위 테스트 가이드를 참조하세요. 이 가이드는 테이블 기반 테스트, 모킹 및 기타 필수 테스트 기술을 다룹니다.

더 큰 Go 애플리케이션을 구축할 때, 이러한 테스트 실천은 다양한 도메인에 적용됩니다. 예를 들어, Cobra 및 Viper를 사용한 Go CLI 애플리케이션 구축에서, 비슷한 병렬 테스트 패턴을 사용하여 명령 처리기 및 설정 파싱을 테스트합니다.

유용한 링크

외부 자료