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
은 전달받은 bytes
를 Person
객체로 변환한다. 내부적으로 어떻게 동작하는 지 자세히 알 필요가 없다.
이것이 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를 모든 상황에 적용할 수 있는 것은 아니다. DI를 사용할 수 있는 상황이 있는 것이고, 그런 코드에는 냄새가 난다. 코드의 냄새에도 다양한 종류가 있다. 일반적으로 코드 냄새(smell)은 다음 4가지로 분류된다.
코드 팽창 냄새는 너무 길어서 일기 힘든 정도의 코드가 구조체 또는 함수에 추가되어 이해하기 어렵고 유지 관리와 테스트가 힘들어지는 경우를 의미한다. 이는 오래된 코드에서 자주 발견되며 의도적인 선택이 아니라 시간이 지남에 따라 점진적으로 쇠퇴하고 유지 보수가 부족해지는 데 따른 결과이다.
코드 평창 냄새는 소스 코드에 대한 정적 분석(static analytics)을 진행하거나 gocyclo
과 같은 순환 복잡도(cyclomatic complexity) 검사기를 통해 찾을 수 있다. 코드 팽창 냄새는 다음과 같은 특징을 포함한다.
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를 적용해 개별 코드 조각을 더 작고 분리된 작은 조각으로 나눔으로써, 코드의 복잡성을 줄여 이해하기 쉽고 유지 관리와 테스트가 용이한 코드로 변경할 수 있다.
이는 새로운 기능을 추가하는 것이 어렵거나 오랜 시간이 소요되는 것을 의미한다. 신규 기능을 추가하는 것과 마찬가지로 테스트 코드를 작성하는 것은 매우 어려우며 특히 실패 조건하에서 테스트 코드를 작성하는 것은 더욱 그렇다. 변경에 대한 저항은 앞서 살펴본 코드 팽창과 마찬가지로 시간이 지남에 따라 점진적으로 쇠퇴하고 유지 보수가 부족해지는 데 따른 결과일 수 있지만, 사전 계획 수립이 부족하거나 API 디자인이 부실해서 발생할 수도 있다.
새로운 기능을 추가할 때 코드의 다른 부분에 사소한 변경이 많을 경우에는 이를 감지할 수 있다. 만약 팀에서 기능에 대한 속도를 추적를 추적하고 속도가 감소하는 것을 인지할 경우 이또한 나쁜 냄새의 징후이다.
변경에 대한 저항 냄새는 다음과 같은 특징을 포함한다.
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
}
앞에서 살펴본 냄새나는 코드에 DI를 적용하는 것은 미래에 대한 투자이다. 이처럼 잘못된 코드를 당장 코치지 않는다고 해서 큰 문제가 생기지는 않지만 코드의 품질은 점차 저하된다. DI는 구체적인 구현에서 인터페이스를 분리해 격리된 환경에서 작은 코드 단위를 쉽게 리팩터링, 테스트, 유지 관리할 수 있다.
이는 코드를 유지 관리하는 비용이 필요 이상으로 높은 것을 의미한다. 이와 같은 코딩 스타일은 간식을 먹는 것과 같다. 먹을 때는 좋지만 막상 나중에되면 끔찍한 결과를 초래한다.
이러한 문제는 소스코드를 주의 깊게 살펴보고 스스로에게 '이 코드는 정말로 필요한가?' 또는 '이 코드는 이해하기 쉽게 작성되었는가?'와 같은 질문을 던지고 답을 찾는 과정에서 발견할 수 있다.
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는 추상화를 통해 코드의 중복된 내용을 줄여주고 코드의 가독성과 유지 관리성을 향상시켜준다.
결합이란 각 객체들이 서로 관련되어 있거나 의존하는 정도를 나타내는 척도이다. 두 객체가 강한 결합 관계를 가지면 한 객체를 변경할 경우 다른 객체도 변경된다. 이처럼 강한 결합은 복잡도와 유지 관리 비용을 증가시킨다.
강한 결합 냄새는 다음과 같은 특징을 포함한다.
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
패키지의 Processor
는 Config
패키지의 Config
객체에 의존한다. 이는 서로가 순환 구조로 의존하는 관계로 GO에서는 다음과 같은 에러를 반환해준다. import cycle not allowed
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
에 추가 기능이 구현되어 있지 않으므로 두 객체를 병합해야 한다.
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)
}
doSearchWithEncy
는 searchRequest
의 기능을 사용하여 파라미터 validation 기능을 수행한다. 사실 doSearchWithEnvy
함수는 아무것도 하는 것이 없고 그저 searchRequest
라는 함수의 기능을 부러워하고 있는 것이다. 반면에 doSearchWithoutEnvy
는 searchRequest
의 기능을 부러워하지 않고 searchRequest
가 가진 validate
를 통해 validation 기능을 수행한다. 이렇게 만들면 코드의 결합도가 약해지면서, 개별 파트(패키지, 인터페이스, 구조체)에 더욱 집중할 수 있다. 이는 다시 말해 높은 응집도를 갖는다고 표현할 수 있다. 낮은 결합도와 높은 응집도는 코드를 이해하기 쉽게 만들어서 변경 작업과 유지 보수를 용이하게 해준다.
GO뿐만 아니라 대부분의 프로그래밍 언어에서 발견되는 특징 중 하나인데, '왜 해당 코드가 좋은 지는 모르겠지만 관용적으로, 전통적으로, 어떤 책에서, 어떤 강의에서 썼으니 좋은 코드다.' 라고 말하는 사람들이 있다. 특히 객체지향과 디자인 패턴을 신봉하는 개발자들이 흔히하는 실수 중 하나이다. 관용적 표현, 패턴이 안좋은 것이라고 말하는 것이 아니다. 프로그래밍은 일종의 공예이다. 하나의 어플리케이션을 작성하기 위해 프로그램은 일정한 형태의 일관성(관용적 표현, 패턴)을 적용하고 유지해야하지만, 모든 공예와 마찬가지로 유연성을 가져야 한다. 결국 혁신은 정해진 규칙을 어기고 틀을 깨는 과정에서 발견된다. 그렇다면 관용적인 GO가 의미하는 것은 무엇인가??
gofmt
사용과 관련해서는 프로그래머에게 논쟁의 여지가 없다. 공식지원 되는 도구이며 공식적인 스타일을 사용한다.위 3가지는 관용적인 GO를 위한 최소한의 요구 사항이다. 이외에 공감할 만한 몇 가지 아이디어가 있다.
신입 GO 프로그래머들이 자주하는 실수는 '다른 개발 언어의 패턴을 GO언어에 적용'하려고 하는 것이다. 마치 GO언어를 자바처럼 개발하려고 하는 것이다. GO에서는 GO만의 패턴이 있고, 방식이 있다. 코드를 디자인할 때 인터페이스를 작은 단위로 분리하는 것을 이해하고, 메모리 예약 없이 GO루틴을 실행하고 채널을 쉽게 사용하며, 다형성 구현을 위해 한 개 이상의 구성 요소가 필요한 이유가 궁금해지는 등, GO 언어에 익숙해질 때까지 계쏙해서 공부를 하도록 하자.