Go DI(dependency injection) 1일차

0

DI(Dependency Injection)는 왜 중요한가?

DI는 의존 관계에 있는 리소스(함수 또는 구조체)를 추상화하는 코딩 방식이다. 의존성이 추상화되므로 리소스 변경 시에도 해당 리소스를 사용하는 객체 측의 코드는 크게 변경할 필요가 없다. 이러한 특정을 decoupling이라고 한다.

GO는 자바처럼 추상화 클래스를 제공하진 않지만 인터페이스클로저라고 불리는 함수 리터럴을 제공한다.

다음과 같이 인터페이스와 그것을 사용하고 있는 SavePerson() 함수 예제를 살펴보자.

type Saver interface {
	Save(data []byte) error
}

func SavePerson(person *Person, saver Saver) error {
	err := person.validate()
	if err != nil {
		return err
	}
	bytes, err := person.encode()
	if err != nil {
		return err
	}
	return saver.Save(bytes)
}

type Person struct {
	Name  string
	Phone string
}

func (p *Person) validate() error {
	if p.Name == "" {
		return errors.New("name missing")
	}

	if p.Phone == "" {
		return errors.New("Phone missing")
	}
	return nil
}

func (p *Person) encode() ([]byte, error) {
	return json.Marshal(p)
}

Saver는 어딘가에 몇 바이트의 데이터를 저장한다. 어떻게 동작하는가? 어떻게 동작하는 지 정확하게 알지 못하며 SavePerson() 함수가 실행되는 동안에도 내부가 어떻게 구현되어 있는 지 알 필요가 없다.

인터페이스가 아닌 함수 리터럴을 사용하는 예제를 확인해보자.

func LoadPerson(ID int, decodePerson func(data []byte) *Person) (*Person, error) {
	if ID <= 0 {
		return nil, fmt.Errorf("Invalid ID %d supplied", ID)
	}

	bytes, err := loadPerson(ID)
	if err != nil {
		return nil, err
	}
	return decodePerson(bytes), nil
}

decodePerson은 전달받은 bytesPerson객체로 변환한다. 내부적으로 어떻게 동작하는 지 자세히 알 필요가 없다.

이것이 DI의 첫번쨰 장점이다.

DI는 의존성을 추상적이거나 일반적인 방법으로 표현함으로써 코드의 일부분에 대한 작업을 진행할 때 필요한 지식을 줄여준다.

또한, 테스트에 있어서도 큰 장점이 있는데 추상화를 통해 fake code를 만들어 코드를 원하는 상황으로 격리시켜 테스트를 진행할 수 있다.

func TestSavePerson_happyPath(t *testing.T) {
	in := &Person{
		Name:  "Sophia",
		Phone: "0123456789",
	}

	mock := &mockSaver{}
	mock.On("Save", mock.Anything).Return(nil).Once()
	resultErr := SavePerson(in, mock)
	assert.NoError(t, resultErr)
	assert.True(t, mock.AssertExpectations(t))
}

다음과 같이 SavePerson()의 두번쨰 파라미터에 진짜 인터페이스 구현을 넣는 것이 아니라, Mock 함수를 하나 만들어서 테스팅 상황에서 원하는 환경으로 주입하는 것이다.

DI의 두 번째 장점은 다음과 같다.

DI는 의존성으로부터 코드를 격리시켜 테스트를 용이하게 한다

또한, 이러한 특성은 다음과 테스팅이 불가능한 상황도 개발자가 원하는 상황을 테스트할 수 있도록 해준다는 장점이 있다.

이러한 특성을 통해 얻을 수 있는 DI의 세 번째 장점은 다음과 같다.
DI를 통해 어렵거나 불가능한 상황을 구성해서 신속하거 안정적으로 테스트할 수 있다

만약, 데이터베이스에 접속하는 코드를 개발한다고 하자. 데이터베이스에 연결하는 코드 부분을 인터페이스로 두어 의존성을 분리하면 테스트도 용이하고 코드 변경도 적어지게 된다. 그런데 사용하던 데이터베이스를 다른 데이터베이스로 바꾼다고 해보자. 가령 RDBMS에서 NOSQL로 바꾼다고 하자. 그러면 코드를 변경해야할까?? 이미 DI가 된 부분이기 때문에 의존 관계에 있던 리소스가 무슨 로직을 가지던 상관이 없는 것이다. 이를 통해 DI의 4번째 장점을 얻을 수 있다.

DI는 코드를 확장하거나 변경할 때 영향을 최소화한다.

DI가 만능이라기보다는 코드를 좀더 쉽게 이해하고 테스트하며 확장 및 재사용에 유용한 도구라는 점이 중요하다.

DI가 필요한 코드 smell

DI를 모든 상황에 적용할 수 있는 것은 아니다. DI를 사용할 수 있는 상황이 있는 것이고, 그런 코드에는 냄새가 난다. 코드의 냄새에도 다양한 종류가 있다. 일반적으로 코드 냄새(smell)은 다음 4가지로 분류된다.

  1. code bloat(코드 팽창)
  2. resistance to change(변경에 대한 저항)
  3. wasted effort(낭비되는 노력)
  4. tight coupling(강한 결합)

code bloat(코드 팽창)

코드 팽창 냄새는 너무 길어서 일기 힘든 정도의 코드가 구조체 또는 함수에 추가되어 이해하기 어렵고 유지 관리와 테스트가 힘들어지는 경우를 의미한다. 이는 오래된 코드에서 자주 발견되며 의도적인 선택이 아니라 시간이 지남에 따라 점진적으로 쇠퇴하고 유지 보수가 부족해지는 데 따른 결과이다.

코드 평창 냄새는 소스 코드에 대한 정적 분석(static analytics)을 진행하거나 gocyclo과 같은 순환 복잡도(cyclomatic complexity) 검사기를 통해 찾을 수 있다. 코드 팽창 냄새는 다음과 같은 특징을 포함한다.

  1. long method(너무 긴 메서드): 30줄 이상의 메서드는 더 작은 단위로 쪼개는 것이 좋다. 컴퓨터 입장에서는 아무런 차이가 없지만 이해하기 쉬운 코드를 작성하기 위함이다.
  2. long struct(거대한 구조체): 너무 긴 메서드와 마찬가지로 구조체가 거대해질수록 이해하기 어렵고 유지 관리가 힘들어진다. 거대한 구조체는 일반적으로 너무 많은 역할을 수행하는 구조체를 의미한다. 하나의 구조체를 여러 개의 작은 구조체로 쪼개는 것이 코드의 재사용성을 높이는 좋은 방법이기도 하다.
  3. long parameter list(너무 많은 인수): 너무 많은 인수(또는 매개변수)는 메서드가 실제 해야 할 것보다 더 많은 작업을 수행하고 있음을 의미한다. 새 기능을 추가할 때는 새 사용 사례를 설명하기 위해 기존 함수에 새로운 인수를 추가하는 것이 좋다. 이 새로운 인수는 기존 사용 사례에 선택적이거나 (또는 불필요하거나) 메서드의 복잡성을 현저하게 증가시키는 것을 의미한다.
  4. long conditional block(너무 긴 조건 블록): 너무 긴 조건 블록을 사용하면 많은 공간을 차지하고 함수의 가독성을 저해한다. 다음과 같은 코드를 고려해보자
func AppendValue(buffer []byte, in interface{}) []byte {
	var value []byte
	switch concrete := in.(type) {
	case []byte:
		value = concrete
	case string:
		value = []byte(concrete)
	case int64:
		value = []byte(strconv.FormatInt(concrete, 10))
	case bool:
		value = []byte(strconv.FormatBool(concrete))
	case float64:
		value = []byte(strconv.FormatFloat(concrete, 'e', 3, 64))
	}
	buffer = append(buffer, value...)
	return buffer
}

다음과 같은 경우 interface{}로 파라미터를 받아 switch type문을 사용하여 해당 타입일 때를 처리하는 코드이다. 이 코드의 가장 큰 냄새는 long conditional block(너무 긴 조건 블록)이다. 거의 비슷한 형태의 switch문이 반복된다는 것이다. 이러한 경우 interface{}부분을 특정 인터페이스 타입을 만들어 동일한 메서드로 처리하도록 하는 것이 좋은 방법이다.

냄새가 나는 코드에 DI를 적용해 개별 코드 조각을 더 작고 분리된 작은 조각으로 나눔으로써, 코드의 복잡성을 줄여 이해하기 쉽고 유지 관리와 테스트가 용이한 코드로 변경할 수 있다.

resistance to change(변경에 대한 저항)

이는 새로운 기능을 추가하는 것이 어렵거나 오랜 시간이 소요되는 것을 의미한다. 신규 기능을 추가하는 것과 마찬가지로 테스트 코드를 작성하는 것은 매우 어려우며 특히 실패 조건하에서 테스트 코드를 작성하는 것은 더욱 그렇다. 변경에 대한 저항은 앞서 살펴본 코드 팽창과 마찬가지로 시간이 지남에 따라 점진적으로 쇠퇴하고 유지 보수가 부족해지는 데 따른 결과일 수 있지만, 사전 계획 수립이 부족하거나 API 디자인이 부실해서 발생할 수도 있다.

새로운 기능을 추가할 때 코드의 다른 부분에 사소한 변경이 많을 경우에는 이를 감지할 수 있다. 만약 팀에서 기능에 대한 속도를 추적를 추적하고 속도가 감소하는 것을 인지할 경우 이또한 나쁜 냄새의 징후이다.

변경에 대한 저항 냄새는 다음과 같은 특징을 포함한다.

  1. 변경 분산(shotgun surgery): 샷건 수술?은 소프트웨어 반패턴으로, 하나의 구조체에 작은 변경을 가했을 경우 다른 구조체에도 변경이 필요한 상황을 의미한다. 구조체 뿐만아니라 함수, 클래스 등에서 일부의 기능을 변경, 추가했을 뿐이데 이리저리 여러부분을 변경하고 추가해야한다는 것이다. 마치 샷건 한발이 여러 방향으로 산탄알들이 날아드는 모양과 같다해서 shotgun surgery라고 한다. 이처럼 간단한 변경을 하는 경우에도 코드의 많은 부분을 뜯어고쳐야 한다는 것은 코드의 구성이나 추상화가 잘못되어 있음을 의미한다. 일반적으로 이러한 모든 변경 사항은 하나의 구조체 내에서 이루어져 한다. 다음 예제에서 person 데이터에 이메일 필드가 추가되는 경우에는 세 가지 구조체 모두에서 변경이 필요한 상황을 확인할 수 있다.
type Renderer struct{}

func (r Renderer) render(name, phone string, output io.Writer) {
	// print person object
}

type Validator struct{}

func (v Validator) validate(name, phone string) error {
	// validate person
	return nil
}

type Saver struct{}

func (s *Saver) Save(db *sql.DB, name, phone string) {
	// store person object in DB
}
  1. 상세 구현 내용 노출(leaking implementation details): GO 커뮤니티에서 가장 많이 사용되고 있는 관용 표현 중 하나는 '함수의 매개변수로 인터페이스를 받아들이고 구조체를 반환'하라 이다. 매우 효율적인 패턴인데, 코드를 작성할 때 함수의 매개변수로 구조체를 전달받을 경우, 해당 함수를 사용하는 사용자의 코드가 특정 조건에 대한 동적 구현으로 한정된다. 코드에서 이러한 제약 조건은 추후 변경이나 재사용을 어렵게 만든다. 확장을 통해 함수의 세부 구현 내용이 변경될 경우 API가 변경되고 해당 함수의 사용자 또한 변경되어야 한다.

앞에서 살펴본 냄새나는 코드에 DI를 적용하는 것은 미래에 대한 투자이다. 이처럼 잘못된 코드를 당장 코치지 않는다고 해서 큰 문제가 생기지는 않지만 코드의 품질은 점차 저하된다. DI는 구체적인 구현에서 인터페이스를 분리해 격리된 환경에서 작은 코드 단위를 쉽게 리팩터링, 테스트, 유지 관리할 수 있다.

wasted effort(낭비되는 노력)

이는 코드를 유지 관리하는 비용이 필요 이상으로 높은 것을 의미한다. 이와 같은 코딩 스타일은 간식을 먹는 것과 같다. 먹을 때는 좋지만 막상 나중에되면 끔찍한 결과를 초래한다.

이러한 문제는 소스코드를 주의 깊게 살펴보고 스스로에게 '이 코드는 정말로 필요한가?' 또는 '이 코드는 이해하기 쉽게 작성되었는가?'와 같은 질문을 던지고 답을 찾는 과정에서 발견할 수 있다.

dupl, PMD는 코드에 산재되어 있는 잠재적인 문제점을 찾기 위해 유용하게 사용할 수 있는 code inspection 도구이다.

wasted effort는 다음과 같은 특징을 포함한다.
1. 과도하게 중복된 코드(excessive duplicated code): 때로는 코드를 복사하는 것이 유지 관리와 변경이 쉬운 시스템을 구축하는 데 도움이 되지만, 대부분의 경우에는 좋지 못한 결과를 초래한다. 코드가 이러한 냄새를 풍기는 근본적인 원인은 이후에 더 알아보자
2. 과도한 주석(excessive comment): 본인이 작성한 코드에 대해 추후 다른 사람이 수정할 수 있도록 주석을 남기는 것은 좋지만 주석이 너무 장황해버리면 이 또한 리팩터링이 필요하다.
3. 중첩되고 복잡한 코드(overlay complicated code): 다른 사람이 이해하기 어려운 코드는 매우 나쁘다.
4. DRY/WET code: DRY(Don't Repeat Yourself, 중복배제)는 책임을 그룹화하고 깔끔하게 정리된 추상화를 통해 개발 과정에서 필요한 모든 형태의 중복을 지양하는 원칙이다. 이와 대조적으로 WET(Waste Everyone's Time) 코드에서는 동일한 책임이 여러 곳에 산재해 있음을 알 수 있다. 이 코드 속 나쁜 냄새는 포맷팅(formatting)이나 변환을 하는 코드에서 주로 나타난다. 이러한 종류의 코드는 시스템의 경계에 위치해야 하며 사용자의 입력을 변환하거나 출력을 포맷팅한다.

물론 DI를 사용하지않고 코드 속 나쁜 냄새의 많은 부분을 제거할 수 있지만 DI는 좀 더 쉽게 추상화할 수 있는 방법을 제공한다. DI는 추상화를 통해 코드의 중복된 내용을 줄여주고 코드의 가독성과 유지 관리성을 향상시켜준다.

tight coupling(강한 결합)

결합이란 각 객체들이 서로 관련되어 있거나 의존하는 정도를 나타내는 척도이다. 두 객체가 강한 결합 관계를 가지면 한 객체를 변경할 경우 다른 객체도 변경된다. 이처럼 강한 결합은 복잡도와 유지 관리 비용을 증가시킨다.

강한 결합 냄새는 다음과 같은 특징을 포함한다.

  1. 신 객체에 대한 의존(dependence on god objects): 신 객체(god object)는 너무 많은 것을 알고 있거나, 너무 많은 일을 수행하는 객체를 의미한다. DI관점에서는 하나의 객체에 너무 많은 코드가 의존하고 있다는 것이 문제이다. 신 객체가 있는 경우 GO 환경에서 순환 종속성 문제가 발생해서 금방 컴파일 오류가 발생할 것이다. 흥미롭게도 GO는 객체 레벨이 아닌 패키지 레벨에서 의존성에 대한 정의와 import를 수행한다. 따라서 GO 환경에서는 신 패키지(god package)를 사용하는 것도 피해야 한다.
  2. 순환 종속성(circular dependency): 이러한 문제는 패키지 A가 패키지 B에 의존하고 있을 경우, 그와 동시에 패키지 B가 패키지 A에 의존하는 경우에 발생한다. 다음의 예제를 확인해보자.
  • config/config.go
package config

import (
	"errors"
	"main/payment"
)

type Config struct {
	Address         string
	DefaultCurrency payment.Currency
}

func Load(filename string) (*Config, error) {
	return nil, errors.New("not implemented yet")
}

config 패키지의 Config 객체는 payment 패키지의 Currency를 의존한다. payment 패키지를 보면 다음과 같다.

  • payment/payment.go
package payment

import (
	"errors"
	"main/config"
)

type Currency string

type Processor struct {
	Config *config.Config
}

func (p *Processor) Pay(amount float64) error {
	return errors.New("not implemented yet")
}

payment 패키지의 ProcessorConfig 패키지의 Config 객체에 의존한다. 이는 서로가 순환 구조로 의존하는 관계로 GO에서는 다음과 같은 에러를 반환해준다. import cycle not allowed

  1. 객체 섞음(object orgy): 한 객체가 다른 객체의 내부 구조에 대해 너무 많은 지식 또는 접근 권한을 갖고 있을 때 발생한다. 이는 다시 말해 객체 사이의 불충분한 캡슐화를 의미한다. 이러한 환경에서 객체는 서로 뗼 수 없는 단짝 관계가 되서, 한 객체가 변경되면 다른 객체도 변경해야 하므로 코드를 이해하기 어려워지고 유지 보수 비용이 증가한다.
type PageLoader struct {
}

func (o *PageLoader) LoadPage(url string) ([]byte, error) {
	b := newFetcher()
	payload, err := b.cache.Get(url)
	if err == nil {
		return payload, nil
	}

	resp, err := b.httpClient.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	payload, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	go func(key string, value []byte) {
		b.cache.Set(key, value)
	}(url, payload)

	return payload, nil
}

type Fetcher struct {
	httpClient http.Client
	cache      *Cache
}

위 예제에서 PageLoader는 반복적으로 Fetcher의 맴버 변수를 호출한다. 따라서 Fetcher의 구현이 변경되면 PageLoader가 영향을 받을 가능성이 매우 크다. 이 경우에는 PageLoader에 추가 기능이 구현되어 있지 않으므로 두 객체를 병합해야 한다.

  1. 요요 문제(yo-yo problem): 코드의 상속 관계가 너무 길고 복잡해서 프로그래머가 코드를 이해하기 위해 코드의 서로 다른 부분을 계속 넘겨봐야 하는 상황을 의미한다. 기본적으로 GO는 상속을 제공하지 않지만 GO에서도 복잡한 관계를 갖도록 코드를 작성할 경우에는 얼마든지 발생할 수 있다. 이 문제를 해결하려면 객체들이 서로 약한 연관 관계를 갖도록 하고 가능한 추상화를 하는 것이 좋다. 이 방법을 사용하면 코드에 대한 변경 작업을 수행할 때 훨씬 작은 범위에 집중할 수 있고 다수의 작은 객체로 대규모 시스템을 구성할 수 있다.
  2. 기능에 대한 욕심(feature envy): 함수가 다른 객체의 데이터와 함수를 광범위하게 사용하는 경우에는 '다른 객체를 부러워한다'고 표현한다. 일반적으로 함수는 자신이 속한 객체가 아닌 다른 객체에 대해서는 관심을 갖지 말아야 한다. DI가 이러한 문제를 해결하기 위한 솔루션은 아니지만 이러한 특징을 갖는 코드 속 나쁜 냄새는 강한 결합을 의미하기 때문에 DI 기술을 적용하는 것을 고려해봐야 한다.
func doSearchWithEnvy(request searchRequest) ([]searchResults, error) {
	if request.query == "" {
		return nil, errors.New("search term is missing")
	}

	if request.start.IsZero() || request.start.After(time.Now()) {
		return nil, errors.New("start time is missing or invalid")
	}

	if request.end.IsZero() || request.end.Before(request.start) {
		return nil, errors.New("end time is missing or invalid")
	}
	return performSearch(request)
}

func doSearchWithoutEnvy(request searchRequest) ([]searchResults, error) {
	err := request.validate()
	if err != nil {
		return nil, err
	}
	return performSearch(request)
}

doSearchWithEncysearchRequest의 기능을 사용하여 파라미터 validation 기능을 수행한다. 사실 doSearchWithEnvy 함수는 아무것도 하는 것이 없고 그저 searchRequest라는 함수의 기능을 부러워하고 있는 것이다. 반면에 doSearchWithoutEnvysearchRequest의 기능을 부러워하지 않고 searchRequest가 가진 validate를 통해 validation 기능을 수행한다. 이렇게 만들면 코드의 결합도가 약해지면서, 개별 파트(패키지, 인터페이스, 구조체)에 더욱 집중할 수 있다. 이는 다시 말해 높은 응집도를 갖는다고 표현할 수 있다. 낮은 결합도와 높은 응집도는 코드를 이해하기 쉽게 만들어서 변경 작업과 유지 보수를 용이하게 해준다.

GO에 관한 고찰

GO뿐만 아니라 대부분의 프로그래밍 언어에서 발견되는 특징 중 하나인데, '왜 해당 코드가 좋은 지는 모르겠지만 관용적으로, 전통적으로, 어떤 책에서, 어떤 강의에서 썼으니 좋은 코드다.' 라고 말하는 사람들이 있다. 특히 객체지향과 디자인 패턴을 신봉하는 개발자들이 흔히하는 실수 중 하나이다. 관용적 표현, 패턴이 안좋은 것이라고 말하는 것이 아니다. 프로그래밍은 일종의 공예이다. 하나의 어플리케이션을 작성하기 위해 프로그램은 일정한 형태의 일관성(관용적 표현, 패턴)을 적용하고 유지해야하지만, 모든 공예와 마찬가지로 유연성을 가져야 한다. 결국 혁신은 정해진 규칙을 어기고 틀을 깨는 과정에서 발견된다. 그렇다면 관용적인 GO가 의미하는 것은 무엇인가??

  1. gofmt를 통한 코드 포맷팅: gofmt 사용과 관련해서는 프로그래머에게 논쟁의 여지가 없다. 공식지원 되는 도구이며 공식적인 스타일을 사용한다.
  2. Effective Go, Code Review Comments:사이트의 각 페이지에는 많은 양의 지식이 담겨 있다. 여러번 읽도록 하자.
  3. 유닉스의 철학: 유닉스 철학은 하나의 일을 잘하도록 코드를 디자인해야 한다는 점 외에 다른 코드와 함께 잘 동작해야 한다는 점도 말하고 있다.

위 3가지는 관용적인 GO를 위한 최소한의 요구 사항이다. 이외에 공감할 만한 몇 가지 아이디어가 있다.

  1. 인터페이스를 받아들이고 구조체를 반환: 인터페이스를 사용해 함수 파라미터의 타입을 선언하는 것을 통해 코드를 잘 분리할 수 있지만, 구조체를 반환하는 것은 모순에 직면하게 된다. 인터페이스를 반환하는 것이 느슨하게 결합된 코드라고 느낄 수도 있지만 실제로는 그렇지 않다. 어떤 종류의 코드이든 간에 함수에서 반환되는 값은 하나일 수 밖에 없다. 필요한 경우라면 인터페이스를 반환하는 것이 좋지만 그렇지 않고 사용을 강요한다면 코드가 길어질 수 있다.
  2. 적절한 기본값: GO언어로 전환한 이후에 사용자에게 모듈을 구성할 수 있는 기능을 제공하려고 했지만, 실제로 이러한 configuration은 자주 사용되지 않는 경우가 많았다. 다른 개발 언어에서는 이러한 기능을 여러 개의 생성자를 통해 정의하거나, 드물게는 매개변수를 통해 구현했다. 하지만 적절한 기본값 패턴을 적용하면, 훨씬 더 깔끔하게 API를 정의할 수 있으며 유지 관리해야 할 코드가 줄어든다.

신입 GO 프로그래머들이 자주하는 실수는 '다른 개발 언어의 패턴을 GO언어에 적용'하려고 하는 것이다. 마치 GO언어를 자바처럼 개발하려고 하는 것이다. GO에서는 GO만의 패턴이 있고, 방식이 있다. 코드를 디자인할 때 인터페이스를 작은 단위로 분리하는 것을 이해하고, 메모리 예약 없이 GO루틴을 실행하고 채널을 쉽게 사용하며, 다형성 구현을 위해 한 개 이상의 구성 요소가 필요한 이유가 궁금해지는 등, GO 언어에 익숙해질 때까지 계쏙해서 공부를 하도록 하자.

0개의 댓글