Go에서 Mocking 마스터하기: gomock vs. 인터페이스 기반 Fake
Olivia Novak
Dev Intern · Leapcell

Go에서 Mocking 마스터하기: gomock vs. 인터페이스 기반 Fake
테스트는 코드 신뢰성과 유지보수성을 보장하는 소프트웨어 개발의 필수적인 부분입니다. Go를 포함한 다른 모든 언어에서 단위 테스트는 종종 테스트 대상 구성 요소를 종속성에서 격리해야 합니다. 이 격리는 예측 가능하고 집중된 테스트를 위해 중요하며, 여기서 "Mocking"이 중요합니다. Mocking을 사용하면 실제 종속성의 동작을 시뮬레이션하여 제어된 응답을 제공하고 상호 작용을 검증할 수 있습니다. 이 기법은 Go의 동시적이고 고도로 모듈화된 생태계에서 특히 중요합니다. 그러나 올바른 Mocking 전략을 선택하는 것은 테스트 스위트의 명확성, 유지보수성 및 효율성에 상당한 영향을 미칠 수 있습니다. 이 글에서는 Go의 두 가지 주요 Mocking 접근 방식인 강력한 코드 생성 도구인 gomock
과 더 관용적인 "인터페이스 기반 Fake"를 탐구할 것입니다. 메커니즘을 자세히 알아보고, 실질적인 적용을 시연하며, 정보에 입각한 결정을 내릴 수 있도록 각각의 강점과 약점을 논의할 것입니다.
핵심 Mocking 개념 이해하기
자세히 알아보기 전에, 우리의 논의에서 중심이 될 Mocking과 관련된 몇 가지 기본 용어를 명확히 해 보겠습니다.
- 종속성(Dependency): 소프트웨어에서 종속성은 다른 구성 요소가 기능을 수행하기 위해 의존하는 모든 구성 요소, 모듈 또는 서비스입니다. 예를 들어, 데이터베이스에서 데이터를 가져오는 서비스는 데이터베이스 클라이언트에 종속됩니다.
- 단위 테스트(Unit Testing): 소스 코드의 개별 단위, 하나 이상의 컴퓨터 프로그램 모듈과 관련 제어 데이터, 사용 절차 및 운영 절차를 테스트하여 사용에 적합한지 확인하는 소프트웨어 테스트 방법입니다.
- Mock: Mock 객체는 실제 객체의 동작을 제어된 방식으로 모방하는 시뮬레이션된 객체입니다. 종종 실제 종속성을 대체하는 데 사용됩니다.
- 느린(예: 네트워크 호출, 데이터베이스 작업),
- 예측 불가능한(예: 외부 API),
- 설정하기 어려운(예: 복잡한 인프라) 또는
- 사용 불가능한(예: 개발 중인 서비스). Mocks를 사용하면 이러한 종속성의 "출력"을 제어하고 코드가 올바르게 상호 작용하는지 확인할 수 있습니다.
- Stub: Mock과 유사하게, Stub은 사전 정의된 데이터를 보유하고 테스트 중에 호출에 응답하는 데 사용하는 더미 객체입니다. Stub은 주로 고정된 응답 제공에 중점을 두는 반면, Mock은 상호 작용(예: 특정 인수로 메서드가 호출되었는지)도 검증할 수 있습니다.
- Fake: 테스트 목적으로 실제 종속성을 대체하는 모든 객체에 대한 일반 용어입니다. Fakes는 간단한 Stub부터 더 정교한 Mock 객체 또는 실제 서비스의 단순화된 인 메모리 버전까지 다양할 수 있습니다. 우리가 논의할 인터페이스 기반 Fake는 종종 이 범주에 속합니다.
- 인터페이스(Interface): Go에서 인터페이스는 메서드 서명의 집합을 정의합니다. 인터페이스의 모든 메서드를 구현하는 모든 유형은 해당 인터페이스를 만족한다고 말합니다. 인터페이스는 Go의 다형성에 기본이며
gomock
과 인터페이스 기반 Fakes 모두 작동하는 방식의 중심입니다.
gomock을 사용한 Mocking
gomock
은 Go 팀에서 공식적으로 지원하는 Go의 인기 있는 Mocking 프레임워크입니다. 인터페이스의 Mock 구현을 생성하여 작동합니다. 이 접근 방식은 강력한 유형 안전성을 제공하고 정교한 동작 정의 및 상호 작용 검증을 허용합니다.
gomock의 작동 방식
- 인터페이스 정의: 코드는 종속성에 대해 구체적인 유형이 아닌 인터페이스를 사용해야 합니다. 이것은 Mocking 프레임워크에 관계없이 테스트 용이성을 위한 모범 사례입니다.
- Mock 생성:
gomock
의 일부인mockgen
도구를 사용하여 인터페이스의 Mock 구현을 포함하는 Go 소스 파일을 생성합니다. - 테스트에서 Mock 사용: 단위 테스트에서는 이러한 생성된 Mock의 인스턴스를 생성하고, 예상 동작(어떤 메서드가 어떤 인수로 호출되어야 하며 무엇을 반환해야 하는지)을 정의한 다음, 테스트 대상 코드를 실행합니다. 그러면
gomock
은 예상대로 상호 작용이 발생했는지 검증합니다.
gomock을 사용한 실질적인 예제
데이터를 검색하는 Fetcher
인터페이스와 Fetcher
를 사용하는 Processor
서비스가 있다고 가정해 보겠습니다.
// main.go package main import ( "fmt" ) // Fetcher 인터페이스는 데이터를 가져오는 방법을 정의합니다 type Fetcher interface { Fetch(id string) (string, error) } // DataProcessor 서비스는 Fetcher를 사용합니다 type DataProcessor struct { f Fetcher } // NewDataProcessor는 새 DataProcessor를 생성합니다 func NewDataProcessor(f Fetcher) *DataProcessor { return &DataProcessor{f: f} } // ProcessData는 데이터를 가져와서 처리합니다 func (dp *DataProcessor) ProcessData(id string) (string, error) { data, err := dp.f.Fetch(id) if err != nil { return "", fmt.Errorf("failed to fetch data: %w", err) } // 실제 시나리오에서는 데이터를 처리합니다 return "Processed: " + data, nil }
이제 DataProcessor
에 대한 테스트를 gomock
을 사용하여 작성해 보겠습니다.
먼저 gomock
과 mockgen
을 설치합니다.
go install github.com/golang/mock/mockgen@latest
다음으로 Fetcher
인터페이스에 대한 Mock을 생성합니다. main.go
가 현재 디렉토리에 있다고 가정합니다.
mockgen -source=main.go -destination=mock_fetcher_test.go -package=main_test
이 명령은 동일한 디렉토리에 mock_fetcher_test.go
를 생성합니다. -package=main_test
인수는 생성된 Mock이 별도의 _test
패키지에 있게 됨을 의미하며, 이는 Go 테스트의 일반적인 관행입니다.
이제 processor_test.go
에서 테스트를 작성해 보겠습니다.
// processor_test.go package main_test import ( "errors" "testing" "github.com/golang/mock/gomock" "main" ) func TestDataProcessor_ProcessData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() // 모든 예상 호출이 이루어졌는지 확인 mockFetcher := NewMockFetcher(ctrl) // 생성된 Mock 사용 // 예상 동작 정의: Fetch는 "123"으로 호출되고 "test-data"를 반환해야 합니다 mockFetcher.EXPECT().Fetch("123").Return("test-data", nil).Times(1) processor := main.NewDataProcessor(mockFetcher) result, err := processor.ProcessData("123") if err != nil { t.Fatalf("expected no error, got %v", err) } expected := "Processed: test-data" if result != expected { t.Errorf("expected %q, got %q", expected, result) } } func TestDataProcessor_ProcessData_Error(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockFetcher := NewMockFetcher(ctrl) expectedErr := errors.New("network error") // 오류 케이스에 대한 예상 동작 정의 mockFetcher.EXPECT().Fetch("456").Return("", expectedErr).Times(1) processor := main.NewDataProcessor(mockFetcher) _, err := processor.ProcessData("456") if err == nil { t.Fatal("expected an error, got nil") } if !errors.Is(err, expectedErr) { t.Errorf("expected error containing %v, got %v", expectedErr, err) } }
gomock
의 장점:
- 강력한 유형 안전성:
gomock
은 코드를 생성하므로 유형 불일치 또는 잘못된 메서드 호출은 컴파일 시점에 감지됩니다. - 풍부한 DSL(Domain Specific Language): 인수 매처(
gomock.Any()
,gomock.Eq()
), 호출 횟수 예상(Times()
,MinTimes()
,MaxTimes()
), 순차적 호출(InOrder()
)을 포함하여 예상 정의를 위한 강력하고 표현력이 풍부한 API를 제공합니다. - 상호 작용 검증: 단순히 값을 반환하는 것을 넘어,
gomock
은 예상 메서드가 올바른 인수로 호출되었는지 검증합니다. - 자동 생성: 복잡한 인터페이스에 대한 상용구 코드를 줄여줍니다.
gomock
의 단점:
- 빌드 단계: 추가
mockgen
단계가 필요하며, CI/CD 파이프라인에 제대로 통합되지 않으면 개발 루프를 약간 늦추거나 복잡하게 만들 수 있습니다. - 코드 비대: 생성된 Mock 파일은 특히 메서드가 많은 인터페이스의 경우 클 수 있으며, 프로젝트를 어수선하게 만들 수 있습니다.
- 학습 곡선: DSL은 강력하지만 초보자에게는 학습 곡선이 있습니다.
- 덜 관용적인 Go: 일부 Go 개발자는 생성된 코드보다 명시적인 수기 코드를 선호합니다.
인터페이스 기반 Fake (수기 Fake)
이 접근 방식은 인터페이스를 구현하는 구조체를 수동으로 만드는 것입니다. 이러한 "Fake" 구현은 일반적으로 테스트 파일 또는 전용 testutil
패키지에 직접 작성되며 특정 테스트에 필요한 동작을 정확히 제공합니다.
인터페이스 기반 Fake의 작동 방식
- 인터페이스 정의:
gomock
과 마찬가지로 코드는 인터페이스에 의존합니다. - Fake 구현 생성: 인터페이스를 만족하는
struct
를 수동으로 작성합니다. 이 구조체에는 종종 예상 반환 값을 저장하거나, 상호 작용 인수를 캡처하거나, 사용자 정의 로직을 주입하기 위한 필드가 포함됩니다. - 테스트에서 Fake 사용: Fake 인스턴스를 만들고, 동작을 직접 구성한 다음, 테스트 대상 코드에 전달합니다. 그런 다음 Fake의 상태(예: 캡처된 인수) 또는 테스트된 함수의 직접 결과에 대한 검증이 수행됩니다.
인터페이스 기반 Fake를 사용한 실질적인 예제
Fetcher
와 DataProcessor
예제를 사용해 보겠습니다.
// main.go (동일하게 유지) package main import ( "fmt" ) type Fetcher interface { Fetch(id string) (string, error) } type DataProcessor struct { f Fetcher } func NewDataProcessor(f Fetcher) *DataProcessor { return &DataProcessor{f: f} } func (dp *DataProcessor) ProcessData(id string) (string, error) { data, err := dp.f.Fetch(id) if err != nil { return "", fmt.Errorf("failed to fetch data: %w", err) } return "Processed: " + data, nil }
이제 Fake Fetcher
를 작성하고 processor_test.go
에서 테스트해 보겠습니다.
// processor_test.go package main_test import ( "errors" "testing" "main" ) // FakeFetcher는 Fetcher 인터페이스의 수기 Fake 구현입니다 type FakeFetcher struct { FetchFunc func(id string) (string, error) FetchedID string // Fetch에 전달된 인수를 캡처하기 위해 FetchCallCount int // Fetch가 호출된 횟수를 세기 위해 } // Fetch는 FakeFetcher의 Fetcher 인터페이스를 구현합니다 func (ff *FakeFetcher) Fetch(id string) (string, error) { ff.FetchCallCount++ ff.FetchedID = id // 인수 캡처 if ff.FetchFunc != nil { return ff.FetchFunc(id) } // 특정 FetchFunc가 제공되지 않은 경우의 기본 동작 return "default-fake-data", nil } func TestProcessor_ProcessData_Fake(t *testing.T) { // 성공 시나리오를 위해 FakeFetcher 구성 fakeFetcher := &FakeFetcher{ // Fetch에 대한 동작을 명시적으로 정의합니다 FetchFunc: func(id string) (string, error) { if id == "123" { return "test-data", nil } return "", errors.New("unexpected ID") }, } processor := main.NewDataProcessor(fakeFetcher) result, err := processor.ProcessData("123") if err != nil { t.Fatalf("expected no error, got %v", err) } expected := "Processed: test-data" if result != expected { t.Errorf("expected %q, got %q", expected, result) } // 상호 작용 검증 if fakeFetcher.FetchedID != "123" { t.Errorf("expected Fetch to be called with ID '123', got %q", fakeFetcher.FetchedID) } if fakeFetcher.FetchCallCount != 1 { t.Errorf("expected Fetch to be called once, got %d", fakeFetcher.FetchCallCount) } } func TestProcessor_ProcessData_Fake_Error(t *testing.T) { expectedErr := errors.New("database connection failed") fakeFetcher := &FakeFetcher{ FetchFunc: func(id string) (string, error) { return "", expectedErr }, } processor := main.NewDataProcessor(fakeFetcher) _, err := processor.ProcessData("456") if err == nil { t.Fatal("expected an error, got nil") } if !errors.Is(err, expectedErr) { t.Errorf("expected error containing %v, got %v", expectedErr, err) } }
인터페이스 기반 Fake의 장점:
- 관용적인 Go: 이 접근 방식은 Go 개발자에게 매우 자연스러우며 인터페이스와 구조체를 직접 활용합니다.
- 코드 생성 없음: 관리할 추가 빌드 단계나 생성된 파일이 없습니다.
- 완전한 제어: Fake의 구현에 대한 완전한 제어를 제공하여 매우 구체적이고 복잡한 테스트 시나리오를 허용합니다.
- 명시적이고 가독성 좋음: Fake의 동작은 테스트 코드에 명시적으로 정의되어 있으며, 종종 한눈에 그 목적을 이해하기 쉽게 만듭니다.
인터페이스 기반 Fake의 단점:
- 상용구 코드: 특히 메서드가 많은 인터페이스의 경우, 포괄적인 Fake를 작성하는 것은 상당한 상용구 코드가 필요할 수 있으며, 특히 모든 메서드가 다양한 테스트 케이스에 대해 구현되어야 하는 경우(결과 값을 반환하더라도) 그렇습니다.
- 수동 검증: 메서드 호출, 인수 및 호출 횟수를 검증하려면 Fake 내에서 수동 추적과 테스트에서의 명시적인 검증이 필요하며, 이는 오류가 발생하기 쉽고 장황할 수 있습니다.
- 복잡한 예상에 대한 유연성 부족: 복잡한 조건부 동작 또는 고급 인수 일치를 정의하면 수기 Fake가 번거로워질 수 있습니다.
- 컴파일 시 안전성: 컴파일러는 Fake가 인터페이스를 구현하는지 확인하지만, 테스트가 Fake의 내부 상태를 올바르게 설정했는지(예:
FetchFunc
설정을 잊어버린 경우) 확인하지는 않습니다.
Mocking 전략 선택하기
gomock
과 인터페이스 기반 Fake 간의 선택은 종종 편의성, 제어 및 인터페이스의 복잡성 균형을 맞추는 데 달려 있습니다.
-
gomock
사용 시:- 인터페이스가 크거나 복잡할 때:
gomock
은 여러 메서드를 구현하는 상용구 코드를 줄여줍니다. - 상세한 상호 작용 검증이 필요할 때: 특정 횟수, 특정 순서 또는 정확한 인수로 메서드가 호출되었는지 확인하는 것이
gomock
의 DSL에서gomock
이 빛나는 부분입니다. - 강력한 유형 안전성이 최우선일 때: 컴파일 시점 확인은 많은 일반적인 Mocking 실수를 방지합니다.
- 코드 생성에 익숙할 때: 빌드 단계와 생성된 파일이 방해가 되지 않아야 합니다.
- 인터페이스가 크거나 복잡할 때:
-
인터페이스 기반 Fake 사용 시:
- 인터페이스가 작고 집중적일 때: Fake를 수동으로 작성하는 비용이 적습니다.
io.Reader
,io.Writer
가 대표적인 예입니다. - 명시적이고 손으로 쓴 코드를 선호할 때: 더 "순수한 Go" 접근 방식을 위해 코드 생성을 피하는 것이 우선 순위입니다.
- 동작이 간단하고 테스트를 위해 주로 상태가 없을 때: Fake는 복잡한 로직이나 검증 요구 사항 없이 주로 특정 값을 반환합니다.
- 성능이 매우 중요할 때 (Mock의 경우 종종 무시할 만하지만):
gomock
의 내부 작동에 대한 반사와 동적 동작을 피하는 것은 특정 성능에 민감한 테스트 스위트의 경우 사소한 고려 사항일 수 있습니다. - 코드보다 직접 표현하기 쉬운 매우 사용자 정의되거나 시나리오별 동작이 필요할 때 DSL을 통하지 않고.
- 인터페이스가 작고 집중적일 때: Fake를 수동으로 작성하는 비용이 적습니다.
결론
gomock
과 인터페이스 기반 Fake 모두 Go의 단위 테스트에 유용한 도구이며, 각각 고유한 강점을 가지고 있습니다. gomock
은 코드 생성을 활용하여 복잡한 Mocking 시나리오를 위한 강력하고 유형 안전하며 기능이 풍부한 DSL을 제공합니다. 반면에 인터페이스 기반 Fake는 외부 도구 없이 더 간단한 Mocking 요구 사항에 대한 관용적이고 투명하며 매우 사용자 정의 가능한 솔루션을 제공합니다. 가장 좋은 전략은 종종 Mocking될 인터페이스의 복잡성과 팀의 코드 생성 또는 명시적인 수기 코드에 대한 선호도에 맞는 도구를 선택하는 것입니다. Go에서의 효과적인 테스트는 궁극적으로 종속성을 격리하는 것으로 귀결되며, 두 방법 모두 이 중요한 목표를 달성할 수 있는 강력한 방법을 제공합니다.