들어가기에 앞서...
해당 글은 독자가 Go 언어(혹은 C언어 계열)의 문법에 대한 기본 지식이 있다고 가정하고 설명하겠다.
객체지향적 설계에 관해 집중적으로 공부한 적이 있다면 'SOLID 원칙'이라는 용어는 들어봤을 것이다. SOLID 원칙은 로버트 마틴이 그의 저서 '클린 소프트웨어'에서 제창한 용어로 다섯 가지 객체지향 소프트웨어 디자인 원칙의 약자를 모은 것이다. 이번 글에서는 이 다섯 가지 원칙 중 마지막 원칙인 DIP(의존성 역전 원칙)에 대해 설명할 것이다. 해당 원칙은 이해하기가 어려운 대신, 이해만 한다면 (특히 Go언어에서)바로 적용하기 쉬운 유익한 원칙이다.
정의
상위 모듈은 하위 모듈에 의존해서는 안 된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다. - 로버트 마틴
DIP, 의존성 역전 원칙은 용어 자체가 직관적이지 않고 글로 설명하기도 어려운 원칙이다. 위의 정의 역시 알 것 같은데 모르겠는, 수수께끼같은 인상을 준다. 모듈? 추상화? 도대체 의존은 무엇인가? 용어를 다음과 같이 바꿔보자.
모듈 -> 패키지
추상화 -> 인터페이스
의존 -> 이용 (~에 의존 -> ~를 이용)
세부 사항 -> 구현체
이러면 다음과 같은 문장이 완성된다.
상위 패키지는 하위 패키지를 이용해서는 안 된다. 상위 패키지와 하위 패키지 모두 인터페이스를 이용해야 한다. 인터페이스는 구현체를 이용해서는 안 된다. 구현체가 인터페이스를 이용해야 한다.
이제 원칙의 의미가 명확해졌다. 쉽게 말해, '되도록 인터페이스를 이용하자'이다. 하지만 이는 아직 DIP 원칙의 절반 밖에 모르는 것이다.
필자가 생각하는 좀 더 완벽한 정의는 이렇다.
인터페이스는 상위 패키지에, 그 구현체는 하위 패키지에 있어야 한다. 즉, 상위 패키지의 인터페이스는 하위 패키지에 의해 구현되어야 한다.
원리
아마 한 프로젝트에 여러 개의 패키지를 만든 적이 있다면 아래의 에러 메시지를 한번 쯤은 봤을 것이다.
패키지1
패키지2
...
패키지n:import cycle not allowed
이 에러는 순환 종속성(혹은 패키지 순환 참조) 문제로 다른 두 패키지가 서로를 참조하려고 할 때 발생한다. 이것이 문제가 되는 이유는 아래의 관용구를 보면 이해하기 쉬울 것이다.
닭이 먼저인가, 달걀이 먼저인가?
패키지1과 패키지2가 서로 참조한다고 해보자. 패키지1을 호출하자니 패키지2를 먼저 호출해야 한다고 한다. 그래서 패키지2를 생성하려고 하니 패키지1를 먼저 호출해야 한다고 한다. 쉽게 말해 무한 루프에 빠진 것이다.
패키지 a와 b가 있을 때 위의 문제를 발생시키는 가장 단순한 방법은 a->b, b->a의 형태로 참조하는 방식일 것이다. 해당 방법은 인지하기도 쉽고 수정하기도 쉽다. 문제는 a->b, b->c, c->a일 때도 순환 종속성이 발생한다는 것이다. 이렇게 간접적으로 참조하는 경우, 연관된 패키지가 많아질수록 인지하기도 어렵고 수정하기도 어렵다.
사실 이러한 문제를 해결하는 아주 간단한 방법이 있다. 순환 종속성을 불러일으키는 모든 패키지들을 한 패키지로 넣는 것이다. 사실, 객체지향적 설계 자체가 잘못된 경우에는 이 방법이 맞다. 하지만 대부분의 경우 객체(패키지)를 분리시켜 작게 유지하는 쪽이 결국 좋은 코드이다.
DIP 원칙은 순환 종속성 문제를 해결하는 것을 넘어서 예방하는 원칙이다. DIP 원칙이 잘 적용된 코드에서의 패키지는 상위 패키지만 구현을 위해 참조하고 동등한 위치의 패키지들이나 하위 패키지는 참조하지 않는다. 이러한 참조를 그래프로 나타내면 트리 형태가 되는데, 트리에는 기본적으로 사이클(순환)이 존재하지 않는다.
구현
해당 코드는 필자의 토이 프로젝트에서 가져왔다. 각 코드 최상단의 주석은 프로젝트 내 해당 파일의 위치인데, 여기에 주목하기를 바란다.
//github.com/simp7/nonograminGo/nonogram/map.go
package nonogram
type Map interface {
ShouldFilled(x, y int) bool
CreateProblem() Problem
GetHeight() int
GetWidth() int
FilledTotal() int
CheckValidity() error
HeightLimit() int
WidthLimit() int
Formatter() Formatter
}
//github.com/simp7/nonograminGo/nonogram/problem.go
package nonogram
type Problem interface {
Horizontal() ProblemUnit
Vertical() ProblemUnit
}
//github.com/simp7/nonograminGo/nonogram/problemUnit.go
package nonogram
type ProblemUnit interface {
Get() []string
Max() int
}
//github.com/simp7/nonograminGo/nonogram/formatter.go
package nonogram
type Formatter interface {
Encode(interface{}) error
Decode(interface{}) error
GetRaw(from []byte) error
Content() []byte
}
//github.com/simp7/nonograminGo/nonogram/standard/nonomap.go
package nonomap
import (
"github.com/simp7/nonograminGo/file/localStorage"
"github.com/simp7/nonograminGo/nonogram"
"strconv"
)
type nonomap struct {
Width int
Height int
Bitmap [][]bool
}
...
//github.com/simp7/nonograminGo/nonogram/standard/formatter.go
package standard
import (
"errors"
"fmt"
"github.com/simp7/nonograminGo/nonogram"
"math"
"strconv"
"strings"
)
var (
invalidType = errors.New("this file is not valid for map")
invalidMap = errors.New("map file has been broken")
)
type formatter struct {
data nonogram.Map
raw []byte
}
...
//github.com/simp7/nonograminGo/nonogram/standard/problem.go
package standard
import (
"github.com/simp7/nonograminGo/nonogram"
)
type problem struct {
horizontal unit
vertical unit
}
...
//github.com/simp7/nonograminGo/nonogram/standard/unit.go
package standard
type unit struct {
data []string
max int
}
...
경로를 보면 프로젝트의 경로인 github.com/simp7/nonograminGo 아래에 nonogram 패키지가 있고 그 아래에 standard 패키지가 있다.(Go언어에서 보통 패키지명은 디렉터리명과 같다. 단순히 디렉터리 == 패키지 라고 알고 있으면 된다.)
nonogram 패키지에는 4개의 인터페이스(Map, Formatter, Problem, ProblemUnit)가 있다. nonogram의 하위 패키지인 standard에는 이 네 인터페이스를 구현한 구현체들이 존재한다 즉, 위의 코드에 생략된 부분은 사실 위의 인터페이스를 만족하기 위한 함수를 구현하는 부분이다. 이 부분이 중요한데, 정리해서 말하면 하위 패키지에는 위에서 정의된 인터페이스를 구현하는 구현체들이 들어간다. 즉, 하위 패키지는 상위 패키지의 구현 그 자체이다.
이번에는 임포트 경로를 보자. nonogram 패키지의 파일들은 특별히 임포트를 하지 않는다. 임포트를 하더라도 외부 패키지 정도만 임포트할 것이다. 반면 standard 패키지는 상위 패키지인 nonogram 패키지나 외부 패키지를 임포트한다. DIP가 잘 적용된 패키지는 같은 단계나 하위 단계의 패키지를 임포트하지 않는다. 이러한 관점에서 볼 때 사실 이 예시에는 옥의 티가 있다. 정답을 보기 전에 한번 위로 돌아가서 찾아보자.
standard/nonomap.go의 임포트 경로 중 github.com/simp7/nonograminGo/file/localStorage는 프로젝트 단위로 볼 때에는 내부 패키지이다. 같은 프로젝트라면 전혀 다른 위치에 있는 패키지일 지라도 서로를 이용할 위험이 있다. 즉, nonomap.go는 DIP 원칙을 위반하고 있다고 볼 수 있다. (물론 필자 역시 이 문제를 수정할 예정이다😎) 여기서 또 하나의 DIP의 장점이 나오는데, DIP를 이용한 설계는 평가하기가 쉽다. 임포트 경로만 잘 확인하면 해당 객체가 DIP원칙을 잘 적용하고 있는지, 나아가 객체 지향적으로 잘 짜여졌는지 알 수 있다.
결론
필자는 DIP 원칙을 아무리 강조해도 지나치지 않다고 확신한다.(특히 Go 언어에서는 duck-typing을 이용하기 때문에 더 그렇다.) 바로 위에서 설명한 '설계를 평가하기가 쉽다'는 점에 주목하기를 바란다. 보통 코드를 짤 때 '무엇'이 객체 지향적으로 잘못되었는지 찾기 힘들다. 하지만 DIP를 적용하면서 코드를 짜다보면 '어떻게' 까지는 몰라도 '무엇'이 잘못되었는지는 알게 된다. 결국, DIP 원칙은 짧게 보면 파일과 그에 따른 코드 때문에 시간 낭비이지만, 조금만 길게 보면 문제의 발견, 더 나아가 예방으로 인한 시간 절약일 것이다. 때로는 돌아가는 것처럼 보이는 길이 지름길인 법이다. 이 글이 순환 종속성 문제로 고통 받는 사람들에게 도움이 되길 바란다.
참고 자료
- 코리 스캇, Hands-On Dependency Injection in Go, 에이콘, 2020.5