🧪 유닛 테스트: Go에서의 커맨드 패턴 vs 모의 객체(Mocks) 🔗

Go에서 유닛 테스트를 작성할 때 파일 시스템, 시스템 시계, 또는 외부 명령어와 같은 외부 의존성을 처리하는 것은 까다로울 수 있습니다. 이 글에서는 이를 해결하는 세 가지 접근 방식을 살펴보고, 궁극적으로 왜 커맨드 패턴(Command Pattern)이 복잡한 모의 객체(mock)를 대체할 깔끔하고 테스트하기 좋은 대안을 제공하는지 보여줍니다.

예시를 하나 살펴봅시다. 증분 빌드(incremental builds)를 벤치마킹하도록 설계된 함수입니다.

func Benchmark(binary string, sourcePath string, resultPath string, numBuilds int) error {
	if err := build(binary); err != nil {
		return err
	}
	source, err := read(sourcePath)
	if err != nil {
		return err
	}
	
	deltas := []time.Duration{}
	for i := 0; i < numBuilds; i++ {
		if err := write(sourcePath, edit(source, i)); err != nil {
			return err
		}
		
		start := now()
		if err := build(binary); err != nil {
			return err
		}
		
		delta := now().Sub(start)
		deltas = append(deltas, delta)
		
		if err := revert(sourcePath); err != nil {
			return err
		}
	}
	return write(resultPath, report(deltas))
}

📑 목차 🔗


⚠️ 문제점 🔗

이 Benchmark 함수는 여러 가지 작업을 수행합니다:

  1. 바이너리를 빌드합니다.
  2. 소스 파일을 읽습니다.
  3. 루프를 돌며 소스를 수정하고, 빌드 시간을 측정하고, 파일을 원래대로 되돌립니다(revert).
  4. 최종 리포트를 작성합니다.

이 함수를 직접 테스트하기 어려운 이유는 네 가지 외부 의존성에 의존하고 있기 때문입니다:

많은 개발자들이 자체적으로 완결된 순수 함수인 edit와 report만 테스트하고, 핵심 오케스트레이션 로직은 테스트하지 않은 채 남겨두곤 합니다. 전체 프로세스를 테스트 가능하게 만들기 위해 설계를 어떻게 개선할 수 있는지 살펴봅시다.


1️⃣ 접근법 1: 코드를 Main으로 옮기기 🔗

때로는 의존성을 호출자(주로 main) 쪽으로 밀어 올릴 수 있습니다.

func Benchmark(binary string, sourcePath string, source string, numBuilds int) (string, error) {
	// ... 핵심 루프 로직은 그대로 유지 ...
	// 리포트를 파일에 쓰는 대신 리포트 문자열을 반환
	return report(deltas), nil
}

func Main() error {
	// 초기 설정 및 첫 빌드를 이곳으로 이동
	source, _ := read(sourcePath)
	result, _ := Benchmark(binary, sourcePath, source, 3)
	return write(resultPath, result)
}

결론: 이 방법도 도움이 되긴 하지만 여기서는 충분하지 않습니다. Benchmark 루프는 여전히 매 반복마다 근본적으로 빌드 시스템, 시계, 파일 시스템, 버전 관리와 상호작용해야 합니다.


2️⃣ 접근법 2: 의존성 주입 (Mocks/Fakes) 🔗

전통적인 객체 지향 접근 방식은 의존성 주입(Dependency Injection)입니다. 모든 외부 동작을 나타내는 인터페이스를 정의합니다.

type Delegate interface {
	Build(binary string) error
	Read(path string) (string, error)
	Write(path string, content string) error
	Now() time.Time
	Revert(path string) error
}

type BenchmarkRunner struct {
	Delegate Delegate
}

func (b *BenchmarkRunner) Run(binary string, sourcePath string, resultPath string, numBuilds int) error {
	// ... b.Delegate.Build(), b.Delegate.Now() 등을 사용하는 구현 ...
}

결론: 이 방법은 잘 작동하며 테스트할 때 가짜(fake) 위임 객체를 끼워 넣을 수 있게 해 줍니다. 하지만 모든 종류의 부수 효과(side effect)마다 인터페이스와 모의 구현체를 정의해야 하므로 인터페이스가 비대해질 수 있습니다.


3️⃣ 접근법 3: 커맨드 패턴 (The Command Pattern) 🔗

모든 동작에 대해 구체적인 인터페이스를 정의하는 대신, 모든 부수 효과를 중앙의 “Runner"가 실행할 일반화된 커맨드(명령)로 취급할 수 있습니다.

먼저, 커맨드를 실행한다는 것이 무엇을 의미하는지 정의합니다:

type Runner interface {
	Run(cmd Command) (Result, error)
}

다음으로, 구체적인 커맨드들을 단순한 구조체(또는 상수)로 정의합니다:

type BuildCmd struct { Binary string }
type ReadCmd struct { Path string }
type WriteCmd struct { Path string; Content string }
type NowCmd struct {}
type RevertCmd struct { Path string }

이제 실행하고 싶은 커맨드를 반환하거나 runner에게 제어권을 넘기도록 벤치마크 로직을 리팩터링합니다:

type BenchmarkProcess struct {
	runner Runner
}

func (p *BenchmarkProcess) Execute(binary, sourcePath, resultPath string, numBuilds int) error {
	_, err := p.runner.Run(BuildCmd{Binary: binary})
	if err != nil { return err }
	
	readRes, err := p.runner.Run(ReadCmd{Path: sourcePath})
	if err != nil { return err }
	source := readRes.(string) // 여기서 타입 단언(Type assertion)이 필요함

	// ... p.runner.Run()을 사용하는 나머지 루프 로직 ...
}

💡 커맨드 패턴이 테스트에 더 유리한 이유 🔗

이 패턴의 진정한 힘은 유닛 테스트에서 빛을 발합니다. 모든 의존성에 대해 복잡한 모의 인터페이스를 구현하는 대신, 단 하나의 FakeRunner만 작성하면 됩니다.

테스트는 예상되는 커맨드와 모의 응답의 단순한 목록이 됩니다:

func TestBenchmark(t *testing.T) {
	// 1. 스크립트 정의: "이 커맨드를 보면 이 결과를 반환하라"
	fakeRunner := NewFakeRunner([]Step{
		{Cmd: BuildCmd{"binary"}, Err: nil},
		{Cmd: ReadCmd{"source_path"}, Res: "source code", Err: nil},
		{Cmd: WriteCmd{"source_path", "edited code"}, Err: nil},
		{Cmd: NowCmd{}, Res: time.Unix(0, 0), Err: nil},
		// ... 이하 생략 ...
	})

	p := BenchmarkProcess{runner: fakeRunner}
	err := p.Execute("binary", "source_path", "result_path", 1)
	
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	
	// 2. runner가 예상된 모든 단계를 소진했는지 검증
	if !fakeRunner.IsDone() {
		t.Fatalf("did not execute all expected commands")
	}
}

🎯 요약 🔗

함수 호출을 데이터 구조(Commands)로 전환함으로써 비즈니스 로직을 부수 효과로부터 분리(decouple)할 수 있습니다.


📚 무료 전자책 다운로드 🔗

카우에의 삶의 방식 오프라인 에디션을 받아보세요—🍏 건강, 💰 부, ❤️ 사랑, 🧘 의미를 최적화하기 위한 실전 가이드입니다.

카우에의 삶의 방식

카우에의 삶의 방식

📬 뉴스레터 구독하기 🔗

더 나은 삶을 위한 새로운 에세이, 도서 요약, 실전 가이드를 발행할 때 알림을 받아보세요. 스팸은 없으며 언제든 구독을 취소할 수 있습니다.


💬 댓글 및 토론 🔗

질문, 의견 또는 제안이 있으신가요? 아래에 생각을 남겨주세요. 댓글은 GitHub Discussions(광고 및 스팸 없음)를 통해 제공됩니다—댓글이나 반응을 남기려면 아래의 GitHub으로 로그인을 클릭하세요(무료 GitHub 계정은 1분 이내에 만들 수 있습니다).



최종 업데이트: 2026. 10. 11..

면책 조항: 본 사이트의 콘텐츠는 정보 제공 및 교육 목적으로만 제공되며 의학, 심리, 재무, 투자, 세무 또는 법률 자문을 구성하지 않습니다. 여기에 제시된 생각, 큐레이션 및 의견은 전적으로 저 개인의 것이며 고용주의 입장을 대변하지 않습니다. 도서, 강연 및 아티클 요약은 독립적인 교육적 해설이며 각 저자의 지적 재산입니다. 편집, 서식 지정 및 요약을 돕기 위해 AI 도구가 사용될 수 있으며, 정확성을 기하기 위해 최선을 다하고 있으나 오류가 포함될 수 있습니다. 개인정보 처리방침: 본 사이트는 선택적 뉴스레터 구독(follow.it), 댓글(giscus / GitHub Discussions) 및 임베디드 미디어(YouTube)를 위해 제3자 서비스를 사용하며, 해당 서비스들은 각자의 개인정보 처리방침에 따라 방문자의 IP 주소와 쿠키를 처리할 수 있습니다.