[Go] Duck typing 과 side effect 분리, 그리고 테스트

Posted on Mar 3, 2024

Duck Typing이란?

덕 타이핑(Duck Typing)은 객체가 특정 타입인지 확인하지 않고, 해당 타입처럼 행동하는지를 확인하는 방식입니다. “If it walks like a duck and quacks like a duck, it’s a duck.“이라는 표현에서 유래했습니다.

Go는 정적 타입 언어지만, 인터페이스를 암묵적으로 만족시키는 방식으로 덕 타이핑을 구현합니다.

예를 들어, Read([]byte) 메서드를 구현한 타입은 명시적 선언 없이도 io.Reader 인터페이스로 사용 가능합니다.

type Reader interface {
	Read(p []byte) (n int, err error)
}

type MyReader struct{}

func (MyReader) Read(p []byte) (int, error) {
	return 0, nil
}
// MyReader는 Reader를 암시적으로 구현함

Go에서의 활용 이점

  • 불필요한 인터페이스 선언 제거: 구현체를 작성할 때 인터페이스를 의식할 필요 없음
  • 사용자 중심 설계: 인터페이스는 사용하는 쪽에서 정의
  • 구체 타입 기반의 명확한 구조: 코드를 이해하고 리팩토링하기 쉬움

인터페이스 제약을 활용한 제너릭 예시

Go 1.18부터 도입된 제너릭 타입과 덕 타이핑을 결합하면 다음과 같은 패턴으로 추상화가 가능합니다.

type pinger interface {
	Ping() error
}

func FromCfg[T pinger](config Config, f func(Config) (T, error)) (T, error) {
	instance, err := f(config)
	if err == nil {
		err = instance.Ping()
	}
	return instance, err
}

이 방식은 sql.DB, memcache.Client 등 공통 Ping() 메서드를 갖는 객체의 초기화에 효과적입니다.

func sql(config Config) (*sql.DB, error) {
	// db 초기화
}

func Sql(config Config) (*sql.DB, error) {
	return FromCfg(config, sql)
}

Side Effect 분리

사이드 이펙트는 외부 상태 변경이나 입출력을 포함하는 작업입니다. Go에서는 인터페이스나 함수 주입을 통해 사이드 이펙트를 로직에서 분리하는 것이 중요합니다.

분리 이유

  • 유닛 테스트 가능성 확보
  • 비즈니스 로직과 실행 환경 분리
  • 동시성 및 에러 핸들링 단순화

분리 전략

  • 실행 시점 효과 → config, env 인자로 전달
  • 처리 시점 효과 → interface, function 인젝션
type repo interface {
	Get(string) (string, error)
	Set(string, any) error
}

func logic(key string, cfg Config, r repo) (string, error) {
	val, err := r.Get(key)
	if err != nil {
		return "", err
	}
	// 로직 처리 예: val 가공, 조건 체크 등
	return val, r.Set(key, val)
}

이렇게 하면 외부 의존성을 테스트 더블(mock/stub)로 대체할 수 있습니다.

테스트 전략

Go는 testing 패키지를 통해 내장된 유닛 테스트 도구를 제공합니다. 하지만 실무에서는 테스트 코드 작성이 어려운 이유가 사이드 이펙트와 로직이 섞여 있기 때문인 경우가 많습니다.

테스트 분리 원칙

  • 로직은 유닛 테스트로 검증
  • 외부 효과는 통합 테스트 또는 E2E 테스트로 검증

현실적인 예외 처리

단순한 CRUD API의 경우 통합 테스트 중심 설계가 더 효율적일 수도 있습니다. 그러나 비즈니스 로직이 존재한다면 유닛 테스트는 필수입니다.

또한, 다음과 같은 경계 상황을 위한 전략도 유용합니다:

  • DB → in-memory DB 사용 (e.g., SQLite, go-memdb)
  • 외부 API → httptest.Server, mockgen 활용

테스트의 추가 가치

  • 집중력 향상: 작성 중간에 확인 지점 확보
  • 코드 자신감 증가: 변경 후 리스크 감소
  • 유지보수 효율: 사이드 이펙트는 통합, 로직은 유닛 기준으로 분리
type adder interface {
	Add(int, int) int
}

type realAdder struct{}

func (realAdder) Add(a, b int) int { return a + b }

func TestAdder(t *testing.T) {
	type mockAdder struct{}
	func (mockAdder) Add(a, b int) int { return 42 }
	result := mockAdder{}.Add(1, 2)
	if result != 42 {
		t.Fatal("unexpected result")
	}
}

결론 및 요약

  • Go의 암시적 인터페이스 만족은 덕 타이핑의 장점을 정적 타입에서도 실현 가능하게 합니다.
  • 제너릭과 인터페이스 제약을 조합하면 간결하고 재사용 가능한 구조를 만들 수 있습니다.
  • 사이드 이펙트를 분리하면 테스트 가능한 구조로 변환되며, 신뢰성 있는 시스템을 구축할 수 있습니다.
  • 유닛 테스트와 통합 테스트의 역할을 명확히 구분하는 설계 습관이 필요합니다.
  • 코드의 복잡도가 높아질수록, 테스트는 가드레일이자 문서 역할을 겸하게 됩니다.

이러한 구조적 접근은 유지보수성과 신뢰성을 동시에 확보하는 Go 개발의 핵심 전략입니다.