go 역시도 타입에 메서드를 붙일 수 있으며, 인터페이스를 통해 추상화를 할 수 있다. 그러나 다른 최신 언어들과 달리 go는 소프트웨어 엔지니어가 권장하는 모범 사례를 사용하도록 하고 구조적인 것을 권장하면서 상속을 피하도록 설계되었다.
구조체를 하나 정의해보자
type Person struct {
FirstName string
LastName string
Age int
}
다음과 같이 구조체 리터럴을 이용하여 기본 타입을 갖는 field들을 만들어 구조체를 구성할 수 있다.
또한, 구조체 리터럴 이외에도 기본 타입 또는 복합 타입 리터럴을 사용하여 구체적인 타입을 정의할 수 있다.
type Score int
type Converter func(string)Score
type TeamScores map[string]Score
go는 패키지 블록에서부터 모든 블록 레벨에서도 타입을 선언할 수 있도록 한다. 하지만 타입은 해당 범위 내에서만 접근이 가능하다. 단 하나의 예외는 외부로 export된 패키지 블록 레벨 타입이다.
go는 사용자 정의 타입에 대한 메서드를 지원한다. 단, 타입을 위한 메서드는 패키지 블록 레벨에서 정의된다.
type Person struct {
FirstName string
LastName string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}
메서드의 선언에서는 receiver
를 명시해야한다. receiver
는 func
키워드와 함수 이름 사이에 들어간다.
주의할 것이, go의 메서드는 오버로드되지 않는다. 다만 다른 타입에 대한 같은 이름의 메서드는 존재할 수 있다. 즉, receiver
타입이 다른 메서드인 것이다. 이는 이름을 재사용하지 않는 것ㅇㄴ 코드가 수행하는 작업을 명확히 하는 go의 철학이 반영된 것이다.
메서드를 선언할 때는 연관된 타입과 동일한 패키지 내에 선언되어야 한다. go에서는 제어되지 않는 타입에 대해 추가적인 메서드를 추가하는 것을 허용하지 않는다.
포인터 타입의 파라미터가 함수에 들어가면 함수 내부에서 해당 파라미터의 포인터를 가지고 여러 값을 변경하고 외부에 영향을 미칠 수 있다. receiver
를 포인터로 만들면 이와 동일하다.
메서드 receiver
는 둘 중 하나이다. pointer receiver
이거나 value receiver
이다. 각 리시버 타입 사용을 결정할 때 도움이 될 만한 규칙이 있다.
nil
인스턴스를 처리할 필요가 있다면 반드시 포인터 리시버를 사용해야한다.리시버를 수정하지 않는 메서드에 값 리시버를 사용하는 지 여부는 타입에 선언된 다른 메서드에 따라 달라진다. 타입에 포인터 리시버 메서드가 있는 경우에 관행은 일관성을 유지하기위해, 리시버를 수정하지 않는 메서드를 포함하여 모든 메서드에 대해 포인터 리시버를 사용한다.
포인터와 값 리시버를 사용하는 간단한 코드가 있다. 하나는 값 리시버를 사용하고 하나는 포인터 리시버를 사용한다.
type Counter struct {
total int
lastUpdated time.Time
}
func (c *Counter) Increment() {
c.total++
c.lastUpdated = time.Now()
}
func (c Counter) String() string {
return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}
다음의 코드를 실행해보면 다음과 같다.
func main() {
var c Counter
fmt.Println(c) // total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
c.Increment()
fmt.Println(c) // total: 1, last updated: 2023-04-03 23:05:51.400495 +0900 KST m=+0.000248035
}
c
가 값임에도 포인터 리시버를 가지고 있는 메서드인 Increment
를 실행할 수 있다. 이는 go에서 자동으로 포인터 타입으로 변환시켜 실행해주기 때문에 가능한 것이다. 즉, c.Increment()
는 (&c).Increment()
로 변환된다. 따라서, 포인터 리시버를 한 번이라도 사용하는 순간부터는 모든 메서드를 포인터 리시버로 쓰는 것이 좋은 것이다. 어차피 포인터 리시버를 쓰는 순간부터 포인터에 접근하는 것이고 이후에는 값 리시버, 포인터 리시버 모두 호출이 가능하기 때문이다. 참고로, String
메서드는 fmt.Println
과 같이 stdout으로 출력하는 메서드에서 자동으로 호출한다. 즉, 타입에 String
을 구현하면 fmt
로 해당 타입을 출력할 때마다 String
이 호출되어 결과가 반환되는 것이다.
또한, go는 포인터 및 값 리시버 메서드가 모두 포인터 인스턴스를 위한 메서드 세트에 있다고 간주한다. 값 인스턴스의 경우, 값 리시버 메서드만이 메서드 세트에 있다. 이와 관련해서는 인터페이스를 배워보면서 더 알아보자.
인터페이스를 충족시키는데 필요한 경우가 아니라면 go는 구조체에 대한 getter, setter메서드를 작성하지 않는다는 것을 기억하자. go는 각 항목에 직접 접근하는 것을 권한다. 물론 이는 각자의 비지니스 로직이 어떻게되냐에 따라 유연하게 사고해야하는 부분이다. 다만, 관성적으로 setter, getter를 작성할 필요가 없다는 것만 알아두자.
nil
인스턴스를 위한 메서드 작성포인터 인스턴스에서 nil
인스턴스로 메서드를 호출하면 어떻게되는가? 대부분의 언어는 그냥 에러를 반환한다. 그러나 go는 약간 다른데 실제 메서드를 실행하려고 시도한다. 메서드가 값 리시버를 가진다면 nil
로는 어떠한 값도 가리킬 수 없기 때문에 패닉이 발생한다. 만약, 메서드가 포인터 리시버를 가진다면 해당 메서드가 nil
인스턴스의 가능성을 처리한다면 제대로 동작한다.
다음의 예제를 확인해보도록 하자.
package main
import "fmt"
type IntTree struct {
val int
left, right *IntTree
}
func (it *IntTree) Insert(val int) *IntTree {
if it == nil {
return &IntTree{val: val}
}
if val < it.val {
it.left = it.left.Insert(val)
} else if val > it.val {
it.right = it.right.Insert(val)
}
return it
}
func (it *IntTree) Contains(val int) bool {
switch {
case it == nil:
return false
case val < it.val:
return it.left.Contains(val)
case val > it.val:
return it.right.Contains(val)
default:
return true
}
}
func main() {
var it *IntTree
it = it.Insert(5)
it = it.Insert(3)
it = it.Insert(10)
it = it.Insert(2)
fmt.Println(it.Contains(2)) // true
fmt.Println(it.Contains(12)) // false
}
다음의 예시를 보면 IntTree
는 내부의 값을 변경하지도 않는데 Insert
와 Contains
에서 pointer receiver를 사용한다. 이는 인스턴스가 nil
일 경우를 처리할 수 있게하기 위한 조건으로 모든 메서드의 리시버 타입이 포인터이어야 하기 때문이다.
go에서 nil
리시버에서 메서드를 호출할 수 있다는 것은 매우 영리한 방식이며 위와 같은 방식으로 사용할 수 있다. 그러나 대부분의 경우 유용하지 않다. 사용하려는 인스턴스가 nil
이라는 것 자체가 위험한 것이다. 따라서 nil
임을 포인터 리시버를 통해 확인했다면 그냥 에러를 반환하도록 하는 것이 좋다.
메서드는 함수와 매우 유사하기 때문에 타입의 변수 혹은 파라미터가 있는 어느 때나 함수를 대체하여 메서드를 사용할 수 있다.
type Adder struct {
start int
}
func (a Adder) AddTo(val int) int {
return a.start + val
}
func main() {
myAdder := Adder{start:10} // 10
fmt.Println(myAdder.AddTo(5)) // 15
f1 := myAdder.AddTo
fmt.Println(f1(10)) // 20
}
AddTo
메서드를 변수에 할당하거나 타입이 func(int)int
의 파리미터로 전달할 수 있따. 이것을 method value
(메서드 값)이라고 부른다. 메서드 값은 생성된 인스턴스 항목에 있는 값에 접근할 수 있기 때문에 클로저와 유사하다. 타입 자체로 함수를 생성할 수도 있다. 이것을 메서드 표현(method expression)
이라 부른다.
f2 := Adder.AddTo // instance가 아닌 타입 자체의 메서드
fmt.Println(f2(myAdder, 15)) // 25
메서드 표현의 경우에 함수 시그니처 func(Adder, int) int
에서 첫번째 파라미터는 메서드를 위한 리시버이다.
물론 메서드 값(method value)
와 메서드 표현(method expression)
을 자주 사용하진 않는다. 다만, 이후에 나올 암묵적 인터페이스에서 의존성 주입을 살펴보면서 사용하는 경우가 있다.
iota
go는 열거형 타입을 가지고 있지 않다. 대신에 iota
를 사용해서 증가하는 값을 상수 세트에 할당할 수 있도록 한다.
iota
를 사용할 때는 먼저 모든 유효한 값을 나타내는 정수 기반의 타입을 정의하는 방법이 가장 좋다.
type MailCetegory int
다음은 타입을 위한 값의 세트를 정의하기 위해 const
블록을 사용한다.
const (
Uncategorized MailCategory = iota
Personal
Spam
Social
Advertisements
)
const
블럭에서 첫 번째 상수는 지정된 타입과 함께 값으로 iota
를 설정해준다. 연달아 나오는 나머지 상수들은 go컴파일러가 알아서 iota
의 값을 증가시켜 할당해준다. 즉, Uncategorized
는 0으로 할당되고 Personal
은 1로 할당된다. 여기서 새로운 const
블럭을 열면 iota
는 0부터 할당된다.
단, 어떤 스펙에 따라 코드를 구현할 때 스펙에서 특정 상수는 어떤 값을 갖는다라고 한다면,
iota
를 사용하는 것이 아니라const
로 명시적으로 적어주도록 하자.iota
는 내부목적만으로만 사용하도록하자. 내부적으로만 사용한다는 것은iota
를 사용하는 상수들은 이름으로 의미가 있을 뿐이지, 어떤 값을 갖는게 중요하지 않는다는 의미이다.iota
를 사용하는const
블럭에 어떤 값을 마구 추가하고 없애고해도 문제가 없도록 하라는 것이다.
iota
기반의 열거는 값이 큰 의미를 가지지 않고 오직 이름이 중요한 것이다. 실제적으로 값이 중요한 경우는 명시적으로 const
로 하나하나 씩 써주는 것이 좋다.
iota
는 0부터 시작하기 때문에, 제로값과 헷갈릴 수 있다. 제로값과 구분하려고 한다면 관용적으로 상수 블럭의 첫번째 iota
값을 _
또는 유효하지 않는 상수에 할당한다. 이렇게하면 초기화되지 못하여 제로값이 할당된 경우를 쉽게 검출할 수 있다.
소프트웨어 엔지니어링에서 조언으로 '클래스 상속(inheritance)보다는 객체의 구성(composition)'하라는 것이다. 이 이야기는 GoF의 디자인 패턴
에서도 잘 설명되어 있다.
go는 상속을 가지지 않지만 구성(composition)과 승격(promotion)을 위한 내장 지원을 통해 코드 재사용을 권장한다. 참고로, 승격(promotion)은 특정 타입이 가진 특성을 다른 타입이 갖게된다는 것을 의미한다.
type Employee struct {
Name string
ID string
}
func (e Employee) Description() string {
return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}
type Manager struct {
Employee
Reports []Employee
}
func (m Manager) FindNewEmployees() []Employee {
// do buinsiness logic
}
Manager
가 Employee
타입의 항목을 포함하고 있지만, 해당 항목에 이름이 지정되어 있지 않다. 이것은 Employee
를 embedding(임베딩)한 것이다. 임베딩된 타입(구조체)이 가진 모든 항목(fields)이나 메서드는 승격(promotion)되어 구조체를 포함하고 바로 실행도 가능하다. 다음의 코드는 문제없이 수행된다.
m := Manager{
Employee: Employee{
Name: "Bob Bodson",
ID: "12345",
},
Reports: []Employee{},
}
fmt.Println(m.ID)
fmt.Println(m.Description())
Manager
가 Employee
가 가진 메서드와 항목(field)를 마치 자신이 정의한 것처럼 사용하고 있다.
다른 구조체 뿐만아니라 어떤 타입이든 구조체로 임베드가 가능하다. 임베딩된 타입의 메서드는 포함하는 구조체로 승격된다.
포함하는 구조체가 임베딩되는 항목과 동일한 이름의 항목이나 메서드를 가지면, 임베딩된 타입의 항목(field)이나 메서드는 shadowing된다. 따라서, 임베딩된 항목의 타입을 사용하여 가려진 항목이나 메서드를 참조해야한다. 다음과 같이 정의된 타입을 가지게 되는 경우이다.
type Inner struct {
X int
}
type Outer struct {
Inner
X int
}
Outer
자체에서도 X
가 있고, Inner
에도 X
가 있다. Inner
의 항목과 메서드는 Outer
로 승격(promotion)되지만 Inner
의 X
는 Outer
의 X
에 가려진(shadowing)된다.
명시적으로 Inner
를 지정함으로써 Inner
의 X
만을 접근할 수 있다.
o := Outer{
Inner: Inner{
X: 10,
},
X: 20,
}
fmt.Print(o.X) // 20
fmt.Print(o.Inner.X) // 10
임베딩은 구성(composition)이지 상속은 아니다. 임베딩과 상속은 다르다. 왜냐하면 상속이라면 부모 타입의 변수에 자식 타입의 인스턴스가 들어갈 수 있어야 한다. 즉, A is B
라는 관계를 만족해야한다는 것이다. 그러나 타입 임베딩은 그렇지 않다. Manager
는 Employee
변수에 호환되지 않는다는 것이다.
var eFail Employee = m // 컴파일 오류
실행하면 오류가 발생한다.
게대가 go에서는 구체 타입(concrete type)을 위한 동적 디스패치(dynamic dispatch)는 없다. 즉, 오버라이딩이 없다는 것인데, 애시당초 상속이 없으니 별로 이상할 것이 아니다. 타입 임베딩은 composition이다. 따라서 임베딩된 타입이 가진 메서드를 임베딩한 타입에서 똑같은 이름에 똑같은 파라미터, 똑같은 반환값을 가진 함수를 만든다해서 오버라이딩되는 것이 아니다. 상속이 아니므로, 각자의 메서드가 있고, 호출한 인스턴스의 타입이 무엇이냐에 따라 다른 메서드가 호출될 뿐이다.
상속이 있고, 구체 타입(concrete type)을 위한 동적 디스패치(dynamic dispatch)가 있는 파이썬의 경우를 보자
class Inner:
def __init__(self):
self.name = "inner"
def hello(self):
print("Inner world", self.name)
def call_hello(self):
self.hello()
class Outer(Inner):
def __init__(self):
self.name = "outer"
def hello(self):
print("Outer hello", self.name)
outer = Outer()
outer.call_hello() # Outer hello outer
Inner
클래스의 call_hello
를 호출해도 Inner
의 hello
가 아닌 Outer
의 hello
가 호출된다. 이는 outer
변수의 인스턴스는 Outer
객체이다. call_hello
의 self.hello()
에서는 동적 디스패치를 통해서 self
가 Outer
의 인스턴스라는 것을 알아내어 Outer
의 hello()
를 호출하는 것이다. self.name
역시도 마찬가지이다. 이는 상속이기 때문에 가능한 일이다.
그렇다면 go에서는 어떨까??
type Inner struct {
A int
}
func (i Inner) IntPrinter(val int) string {
return fmt.Sprintf("Inner: %d", val)
}
func (i Inner) Double() string {
return i.IntPrinter(i.A * 2)
}
type Outer struct {
Inner
A int
}
func (o Outer) IntPrinter(val int) string {
return fmt.Sprintf("Outer: %d", val)
}
func main() {
o := Outer{
Inner: Inner{
A: 10,
},
A: 100,
}
fmt.Println(o.Double()) // Inner: 20
}
Outer
구조체의 o.Double
은 Inner
를 임베딩함으로서 승격(promotion)받은 것이다. o.Double
을 실행하면 Outer
안에 있는 Inner
의 o.Double
이 실행된다. 이는 Outer
와 Inner
의 관계가 상속이 아닌 구성(composition)의 관계이기 때문에 Outer
은 그저 Inner
를 포함, 가지고 있을 뿐이기 때문이다. Inner
의 o.Double
이 실행되면 IntPrinter
를 실행하는데, 이는 Inner
의 IntPrinter
이지 Outer
의 IntPrinter
가 아니다. 상속이 아닌 컴포지션의 관계이기 때문에 Outer
와 Inner
은 그저 각각의 객체이다. promotion으로 마치 Outer
가 Inner
에게서 항목과 메서드를 상속받은 것처럼보이지만 이는 편의상 기능을 제공했을 뿐인 것이지 상속이 아니다. 따라서 IntPrinter
는 Inner
의 IntPrinter
가 실행되며 Inner: 20
이라는 결과를 얻게된다.
사실 타입 임베딩은 다음과 동일하다.
type Outer struct {
Inner Inner
A int
}
그냥 Outer
에서 Inner
이라는 타입을 Inner
이라는 이름의 항목으로 가질 뿐이다. 다만 매번 저렇게쓰면 타입을 확장하는데 있어 번거로운 점이 많으므로 promotion의 기능을 추가하고 이름을 제거하여 마치 Outer
가 Inner
의 특성을 모두 가지고 있는 것처럼 보이게 하는 것 뿐이다. 따라서, 타입 임베딩은 상속이 아닌 컴포지션(포함, 구성)이다.
인터페이스는 go에서의 유일한 추상 타입인 암묵적 인터페이스이다. 다음과 같이 사용할 수 있다.
type Stringer interface {
String() string
}
인터페이스를 만족시키기 위해서는 concrete type은 반드시 인터페이스의 모든 메서드를 동일하게 구현해야한다.
다른 타입과 같이 인터페이스는 모든 블럭 내에서 선언이 가능하다. 또한, 인터페이스의 이름 마지막에 er
을 붙인다.
go는 인터페이스가 있지만, 재밌게도 implicit(암묵적) 구현을 지원한다. 즉, 구체 타입(concrete type)은 인터페이스를 선언하지 않는다. 단지, 구체 타입은 인터페이스의 메서드들을 모드 구현하면 인터페이스를 구현한 것으로 간주된다. 이것이 implicit(암묵적) 구현이다.
이 암묵적 행동은 인터페이스가 타입 안정성과 디커플링을 가능하게 하여 정적 및 동적언어의 기능을 연결하기 때문에 go의 타입에서 가장 중요한 부분이다.
인터페이스가 왜 중요할까? 디자인 패턴에서는 "구현이 아니라 인터페이스를 프로그램 하라"라고 한다. 이렇게 하면 구현이 아닌 동작에 집중하여 필요에 따라 구현을 바꿀 수 있도록 하기 때문이다. 이는 요구 사항이 불가피하게 변경되더라도 시간이 지날수록 코드가 진화할 수 있기 때문이다.
파이썬, 자바스크립트와 같은 동적 타입 언어는 인터페이스가 없다. 대신 관련 Duck typing을 지원하는데, 이는 "어떤 객체가 오리처럼 걷고, 오리처럼 운다면 그 객체는 오리이다"라는 것이다. 즉, 객체의 타입이 중요한 것이 아니라, 객체가 가진 행동(메서드)와 상태(필드)가 중요하다는 것이다. 이는 다음과 같다.
class Logic:
def process(self, data):
# business logic
def program(logic):
... # get data
logic.process(data)
logic = Logic()
program(logic)
위의 program
의 파라미터는 어떤 타입의 객체가 오든 지 간에 process
메서드만 구현하고 있으면 된다. 이것이 덕 타이핑이고 동적 타입 언어의 인터페이스 구성방식이다. 이는 프로그램의 유연성을 더해주고, 타입에 얽매이지않고 자유롭게 프로그래밍 할 수 있다는 장점이 있지만, 추후에 유지 보수나 코드 추적이 어렵다는 문제가 있다.
정적 타입 언어에서는 explicit(명시적인) 인터페이스 구현을 지원한다. 정적 타입 언어인 자바는 인터페이스를 정의하고 explicit하게 해당 인터페이스를 구현하고 있다고 클래스에 명시해준다. 또한 클라이언트는 해당 인터페이스로 프로그램에 접근한다.
public interface Logic {
String process(String data);
}
public class LogicImpl implements Logic {
public String process(String data) {
// business logic
}
}
public class Client {
private final Logic logic;
public Client(Logic logic) {
this.logic = logic;
}
public void program() {
this.logic(data);
}
}
public static void main(String[] args) {
Logic logic = new LogicImpl();
Client client = new Client(logic);
client.program();
}
위와 같이 인터페이스 Logic
을 만들고 LogicImple
는 해당 인터페이스를 구현하고 있다고 명시적으로 implements Logic
을 써준다.
정적 언어의 명시적 인터페이스 구현 방식은 어떤 문제가 있을까?? 시간이 지나 리팩토링을 하거나 새로운 사업자와 계약을 하여 코드를 추가한다고 할 때, 인터페이스에 새로운 구현이 필요할 것이다. 만약 해당 인터페이스에 특정 객체를 사용하려고하려고 한다. 그러나 객체가 해당 인터페이스를 명시적으로 구현한다는 implements
를 안해주면 아무리 인터페이스의 모든 메서드를 구현하고 있어도 이는 절대 허용될 수 없다. 가령, 특정 인터페이스에서 String() string
를 구현하면 stdout
으로 문자열을 출력해줄 수 있도록 한다고 하자. 암묵적 인터페이스 구현에서는 어떤 객체이든 간에 String() string
만 구현하면 해당 인터페이스를 가진 모든 곳들에 들어갈 수 있다. 그런데 명시적 인터페이스 구현의 경우는 해당 인터페이스를 명시적으로 구현해주어야 하는 것이다. 이는 코드의 유연성이 부족하다는 평가를 받는다.
go개발자들은 암시적, 명시적 인터페이스 구현을 모두 수용하였다. 프로그램이 커지고 시간이 지남에 따라 구현 변경을 위한 융통성이 필요하게 된다. 그러나 사람들이 코드가 수행되는 작업을 이해하려면 코드가 어떤 의존성을 가지는 지 명시할 필요가 있다. 이것이 암시적 인터페이스가 있어야하는 이유이다.
go는 이런 두 가지 스타일을 혼합하였다.
type LogicProvider struct{}
func (l LogicProvider) Process(data string) string {
//
}
type Logic interface {
Process(data string) string
}
type Client struct {
L Logic
}
func (c Client) Program() {
c.L.Process(data)
}
func main() {
c := Client{
L: LogicProvider{},
}
c.Program()
}
Logic
인터페이스를 암시적(암묵적)으로 구현하는 LogicProvider
에는 어떠한 명시적인 인터페이스 구현 선언이 없다(가령, implements
) 이는, 추후에 LogicProvider
을 다른 인터페이스에 유연하게 사용할 수 있다는 것을 의미한다. 또한, 메서드 구현 항목을 Client
측에서 볼 수 있는데, 이를 통해서 타입을 유추하고 어떤 동작하는 지 알 수 있다. 즉, 암묵적 인터페이스 구현에서 부족했던 유지 보수성을 타입을 통해 해결하는 것이다.
특히, 표준 인터페이스를 가지는 것은 굉장히 좋다. io.Reader
와 io.Writer
를 가지고 작업하는 코드를 작성한다면 로컬 디스크의 파일에 쓰거나 메모리에 값을 쓸 때에도 제대로 동작한다.
표준 인터페이스를 사용하는 것은 decorator pattern
을 권장한다. go에서는 인터페이스의 인스턴스를 가지고 동일한 인터페이스를 구현하는 다른 타입을 반환하는 팩토리 함수를 작성하는 것이 일반적이다. 가령 다음과 같은 정의를 가진 함수가 있다고 하자.
func process(r io.Reader) error
다음과 같이 코딩하면 파일에서 데이터를 가져와 처리할 수 있다.
r, err := os.Open(fileName)
if err != nil {
return err
}
defer r.Close()
return process(r)
os.Open
에서 반환되는 os.File
인스턴스는 io.Reader
인터페이스르 만족하고 데이터를 읽는 모든 코드에서 사용가능하다. 파일이 gzip
으로 압축되어 있다면 io.Reader
를 다른 io.Reader
로 랩핑할 수 있다.
r, err := os.Open(fileName)
if err != nil {
return err
}
defer r.Close()
gz, _ := gzip.NewReader(r)
defer gz.Close()
return process(gz)
gzip.NewReader(r)
을 사용하면 gzip.Reader
을 반환한다. 이는 io.Reader
를 구현한다. os.Open
으로 io.File
타입을 가진 r
을 얻고 io.File
은 io.Reader
인터페이스를 구현하였기 때문에 gzip.NewReader
에 호환이 된다. gzip.NewReader
는 io.Reader
를 구현하는 gzip.Reader
타입을 가진 gz
를 반환하였다.
이처럼, 인터페이스를 만족하는 타입을 넣어 다른 타입으로 반환해주는 방식이 빈번하다.
인터페이스를 만족하는 타입은 인터페이스의 일부가 아닌 추가적인 메서드를 지정하는 것은 문제가 없다. 한 세트의 사용자 코드는 이러한 메서드를 신경 쓰지 않을 수 있지만, 다른 세트는 신경 쓸 수 있다. 가령, io.File
타입은 io.Writer
인터페이스도 만족한다. 코드가 파일을 읽는 것만 신경을 썼다면 파일 인스턴스를 참조하기 위해 io.Reader
인터페이스만 사용하여 다른 메서드는 무시할 수 있다.
구조체에 타입을 임베딩 할 수 있는 것처럼 인터페이스에 인터페이스를 임베딩 할 수 있따. 가령, io.ReadCloser
인터페이스는 io.Reader
와 io.Closer
로 구성된다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
type ReadCloser interface {
Reader
Closer
}
함수를 만들 때 인터페이스를 받고 구조체를 반환하라라는 go에서의 격언이 있다. 이것이 의미하는 것은 함수로 실행되는 비지니스 로직은 인터페이스를 통해 실행되어야 하는 것이지만, 함수의 출력은 구체 타입이어야 한다는 것이다. 함수의 입력으로 인터페이스를 받으면 코드를 보다 유연하게 하고 사용중인 기능을 정확하게 선언할 수 있다는 장점이 있다.
인터페이스를 반환하는 API를 만든다면 암묵적 인터페이스 구현의 주요 장점인 디커플링을 잃게된다. 이는 해당 코드 사용자가 인터페이스의 동작이 아닌 인터페이스를 구현한 객체에 의존하게 되고, 결국 그 객체의 타입에 의존할 수 밖에 없는 코드를 만들게 된다. 암묵적 인터페이스 구현의 주요 장점 중 하나는 명시적으로 해당 타입이 인터페이스를 구현하고 있다는 선언을 해주지 않아도 되고, 인터페이스의 메서드를 구현만해도 인터페이스에 할당이 가능하다는 것이다. 따라서, 인터페이스에 명시된 메서드를 충족한 새로운 객체나 서드 파티의 객체를 인터페이스에 넣는 것이 가능한 반면, 명시적 인터페이스 구현은 implements
와 같은 명시적 선언이 없는 한 해당 인터페이스의 모든 메서드를 구현해도 호환이 되지 않는다. 이는 인터페이스를 사용했지만 타입에 의존적인 모습을 가지는 것이다.
그런데, 인터페이스를 반환하는 API
를 만들게 되면, 해당 인터페이스가 어떤 타입으로 구성되었는 지에 대해서 알기위한 과정이 필요해지고, 타입 단언을 통해 인터페이스의 타입에 접근해야한다. 이는 명시적 인터페이스 구현에서 나오는 단점이며, 암묵적 인터페이스 구현을 사용하려는 이유가 사라지게 된다.
인터페이스를 반환하는 것을 피하는 또 다른 이유는 버저닝(versioning
)도 있다. 함수의 반환 타입으로 구체 타입을 사용할 때, 반환 타입에 새로운 코드를 추가해도 기존 코드를 망가뜨리지 않고 새 매서드와 항목을 추가할 수 있다. 하지만 인터페이스는 다르다. 인터페이스에 새로운 메서드를 추가하는 것은 인터페이스를 구현하는 모든 구체 타입에게 영향을 주고, 전체 구조를 망가뜨리는 일을 야기할 수 있다. 이렇게 코드를 변경하여 버전을 변경하는 일에 인터페이스를 반환하는 문제는 코드의 수정에 제약을 주고 아키텍처를 더욱 무겁게 만든다.
물론, 인터페이스를 반환하는 것 외에는 선택의 여지가 없는 것들도 있다. 가령, parser와 같은 것들이 그런 것들이다.
오류는 이런 규칙에서 예외이다. error
인터페이스 타입의 반환 파라미터를 선언하는 것은 나쁜 것이 아니다. error
의 경우에는 인터페이스의 다른 구현이 반환될 수 있기 때문에 go의 유일한 추상 타입인 인터페이스를 가능한 모든 옵션을 처리하기 위해 사용된다.
그러나 입력을 인터페이스로 받는 것에는 한 가지 문제가 있는데, 바로 성능적으로 불리하다는 것이다. 따라서 성능적으로 유리한 프로그램을 하고싶다면 입력을 구조체 자체로 받도록 하는 것이 좋다.
포인터의 제로 값은 nil
이다. 인터페이스도 포인터이기 때문에 인터페이스의 제로 값은 nil
이다. 그런데, 하나 재밌는 것이 있다. 인터페이스는 자신을 구현한 구현체를 받을 때 두가지 요소를 가지고 있는다. 하나는 type
이고 하나는 value
이다. 인터페이스가 nil
이라는 것은 타입과 값 모두 nil
이어야 한다는 것이다.
다음의 코드는 첫번째 두 라인은 true
를 반환하고 마지막은 false
를 반환한다.
var s *string
fmt.Println(s == nil) // true
var i interface{}
fmt.Println(i == nil) // true
i = s
fmt.Println(i == nil) // false를 출력
go런타임에서 인터페이스는 기본 타입에 대한 포인터와 기본 값에 대한 포인터 쌍으로 구현되었다. 타입이 nil
이 아닌 한 인터페이스도 nil
이 아니다. 참고로 타입 없이 변수를 가질 수 없기 때문에 값 포인터가 nil
이 아니라면 타입 포인터는 항상 nil
이 아니다. 즉, void포인터 같은 것은 없다)
nil
이 인터페이스에 대해 나타내는 것은 인터페이스에서 메서드를 호출할 수 있는 여부이다. 이전에 구체 타입(구조체)이 nil
임에도 메서드를 호출할 수 있던 것을 볼 수 있었다. 이는 구체 타입의 값이 없더라도 타입이 있기 때문에 가능한 일이다. 그러나 인터페이스는 nil
이 이라면 type
도 없기 때문에 메서드를 호출하지 못하고 panic
을 발생시킨다.
무려 interface는 comparable하다. 정확히는 동등 연산자인 ==
가 가능하다. 만약 interface의 type과 value field가 둘 다 nil
이라면 해당 interface도 nil
인 것처럼, 두 interface 인스턴스들의 type이 동일하고 value도 동일하다면 이들은 서로 같다고 나온다.
그런데, 여기서 한가지 의문이 생기는데 만약 interface의 type이 comaparable type이 아니라면 어떻게되는가? 다음의 예제를 보도록 하자.
type Doubler interface {
Double()
}
type DoubleInt int
func (d *DoubleInt) Double() {
*d = *d * 2
}
type DoubleIntSlice []int
func (d DoubleIntSlice) Double() {
for i := range d {
d[i] = d[i] * 2
}
}
Doubler
interface에 대한 구현체인 DoubleInt
, DoubleIntSlice
type이 있다. 문제는 DoubleInt
는 int
이기 때문에 comparable하다. 반면에 DoubleIntSlice
는 comparable하지 못하다. 참고로 이전에도 말했지만 slice
, map
과 같은 type은 pointer 형태의 변수이다. 따라서 그대로 Doubler
interface에 들어갈 수 있다. 반면에 DoubleInt
는 *DobuleInt
형태로 Dobuler
interface에 들어갈 수 있다.
다음의 함수인 DoublerCompare
는 이들의 동등성을 비교하되, interface를 통해서 비교한다.
func DoublerCompare(d1, d2 Doubler) {
fmt.Println(d1 == d2)
}
4개의 변수들을 정의해보도록 하자.
var di DoubleInt = 10
var di2 DoubleInt = 10
var dis = DoubleIntSlice{1, 2, 3}
var dis2 = DoubleIntSlice{1, 2, 3}
먼저 같은 type인 DoubleInt
를 DoubleCompare
를 통해서 비교해보도록 하자.
DoublerCompare(&di, &di2)
재밌게도 이 결과는 false
인데, 두 type이 맞지만 value가 다르기 때문이다. Doubler
인터페이스에 DoubleInt
의 포인터인 *DoubleInt
를 넣었기 때문에 di
와 di2
의 주소 값을 서로 비교한 것이다. 따라서 이들은 type은 같지만 value가 서로 다르다는 결과가 나온 것이다.
다음으로 *DoubleInt
와 DoubleIntSlice
를 넣어보도록 하자.
DoublerCompare(&di, dis)
이 결과는 false
인데 이는 이 두 interface의 type이 다르기 때문이다.
마지막으로 가장 문제적인 case이다.
DoublerCompare(dis, dis2)
이 경우 컴파일 error는 발생하지않지만 runtime에 panic이 발생한다.
따라서, 가급적 interface 자체로 비교를 하는 코드를 만들기보다는 추후에 나올 generic을 활용하여 비교하는 함수를 만드는 것이 좋다.
interface가 comparable하다는 사실은 굉장히 재밌게도 map
의 key와 관련성이 있다. 왜냐하면 map
의 key는 반드시 comparable하기 때문이다. 따라서, map
의 key로 interface를 쓸 수 있는 것이다.
m := map[Doubler]int{}
그러나 만약 key
에 들어간 interface의 구현체가 comparable 하지못하는 type인 경우에는 runtime중에 panic이 발생하므로 조심해야한다.
마지막으로 정리하자면 interface에 사용할 때는 ==
와 !=
, map
의 key를 조심해야한다. 왜냐하면 runtime 중에 panic이 발생할 수 있는데, 이 panic이 어떤 이유로 발생했는 지 알기 쉽지 않기 때문이다. 특히 map
의 key로 interface를 쓰는 일은 생각보다 무의식적으로 써버리는 일도 있기 때문에, panic이 발생하면 어떻게 해결해야할 지 감을 못 잡을 때가 있다.
정적 타입 언어에서 때로는 변수가 어떤 타입의 값이라도 저장할 수 있는 방법이 있어야 한다. 즉, 모든 타입을 받을 수 있는 타입을 말하는 것이다. go
는 interface{}
을 만들면 구현해야할 메서드가 없기 때문에 모든 타입의 구현체들이 해당 인터페이스에 할당될 수 있다.
var i interface{}
i = 20
i = "hello"
i = struct{
FirstName string
LastName string
} {"Fred", "Fredson"}
interface{}
을 사용하는 상황은 두 가지인데, 어떤 입력이 올 지 모르기 때문에 사용해야하는 경우와 단일 타입에서만 동작하게 만들지 않아야할 generic한 경우이다.
가령, json.Unmarhsal
은 첫번째 파라미터로 json
객체를 두 번째 파라미터로 json
객체를 파싱해서 값을 할당해줄 구조체를 받는다. 구조체가 어떤 형식으로 올지 모르기 때문에 interface{}
로 모든 구조체를 다 받도록 한다.
그러나 interface{}
를 사용하는 경우는 매우 드물다. go는 강 타입 언어로 설계 되었기 때문에 이를 자주사용하는 것은 go의 강점을 잃게된다.
그럼 interface
로 된 변수에서 그 안에 있는 구현체, 즉, 구체 타입(구조체)를 가져오는 방법이 궁금할 것이다. 이는 타입 단언
과 타입 스위치
를 사용한다.
go는 인터페이스 변수가 특정 구체 타입을 가지고 있거나, 구체 타입이 다른 인터페이스를 구현한 것을 확인하는 두 가지 방법을 제공한다.
타입 단언은 인터페이스를 구현한 구체 타입의 이름을 지정, 즉 인터페이스를 구체 타입으로 변환하는 것이다. 또는 인터페이스 기반인 구체 타입에 의해 구현된 다른 인터페이스의 이름을 지정한다.
type MyInt int
func main() {
var i interface{}
var mine MyInt = 20
i = mine
i2 := i.(MyInt)
fmt.Println(i2 + 1)
}
위와 같이 interface i
에 mine
을 할당해주고, mine
의 타입인 MyInt
로 interface i
를 타입 단언(type assertion)를 해주는 것이다.
만약, 타입 단어이 잘못되면 코드는 패닉을 발생시킨다.
i2 := i.(string)
fmt.Println(i2)
다음과 같은 패닉이 발생한다.
panic: interface conversion: interface {} is main.MyInt, not string
타입 단언은 반드시 인터페이스의 현재 구현체와 일치하는 타입으로 단언해주어야 한다. 가령 다음과 같이 MyInt
타입이 아니라 MyInt
의 기본 타입인 int
로 해도 패닉이 발생한다.
i2 := i.(int)
fmt.Println(i2 + 1)
타입 단언은 실패할 확률이 높기 때문에, 패닉이 발생하지 않도록 회피할 방법들을 마련해야 한다. 이에 대한 방법으로 go는 ok
관용구를 준비해주었다.
i2, ok := i.(int)
if !ok {
return
}
fmt.Println(i2 + 1)
타입 변환이 성공하면 ok
는 true
로 되고, 실패하면 false
이다. false
일 때의 i2
값은 제로 값이 설정된다.
타입 단언과 타입 변환은 매우 다르다. 타입 변환은 구체 타입과 인터페이스 모두에 적용할 수 있고 컴파일 시점에서 확인된다. 타입 단언 인터페이스 타입에만 적용될 수 있고, 런타입에 확인된다. 런타임에 확인되기 때문에 변환이 되는 시점에 확인이 드러나 실패할 수 있다.
타입 단언이 유효하다고 확신이 될 지라고 콤마 OK
관용구를 사용하자. 다른 사람이 해당 코드를 재사용하는 방법을 모를 수 있기 때문이다. 조만간 검증되지 않은 타입 단언은 런타임에 실패할 것이다.
인터페이스를 여러 가능한 타입 중 하나로 사용할 때 타입 스위치(type switch
)를 사용하자.
func doThings(i interface{}) {
switch j := i.(type) {
case nil:
// j는 nil이다.
case int:
// j는 정수이다.
case MyInt:
// j는 MyInt이다.
case io.Reader:
// j는 io.Reader이다.
default:
// i가 무슨 타입인지 알 수 없기 때문에 j는 interface{} 타입이다.
}
}
switch
과 비슷하고 단지 인터페이스에 타입 단언을 하고 해당 변수가 어떤 타입으로 구현되어 있는 지 확인하는 부분 밖에 없다.
타입 스위치의 목적은 이미 존재하는 변수를 새로운 변수로 파생시키는 것이기 때문에, 전환되는 변수를 같은 이름의 변수로
i := i.(type)
로 할당하는 것이 관용적이다. 이는 쉐도잉이 좋게 쓰이는 몇 안되는 것 중 하나이다.
새로운 변수의 타입은 일치하는 case
문에 의존적이다. 인터페이스가 연관된 타입이 없다는 것을 보기 위해 case
중 하나는 nil
을 사용할 수 있다. 무슨 타입인지를 모른다면 default
절로 빠지며 이 경우는 interface{}
타입이라고 생각할 수 있다.
인터페이스의 타입을 단언하는 경우는 어떤 함수의 반환값이 인터페이스인 경우이다. 이는 이전에 말한 입력은 인터페이스로, 반환은 구체 타입으로라는 격언과 매칭되지 않는다. 이는 사용자가 인터페이스의 동작에 의존하는 것이 아니라, 인터페이스의 구현 타입에 의존할 수 밖에 없다는 문제를 만든다.
타입 단언과 타입 스위치가 유용하게 쓰인 경우도 있는데, 타입 단언의 일반적인 사용 중 하나는 인터페이스를 구현한 구체 타입이 다른 인터페이스도 구현되어 있는지 확인하는 것이다. 이는 선택적 인터페이스를 지정할 수 있도록 한다. 가령, 표준 라이브러리는 해당 기술을 사용하여 io.Copy
함수를 호출 할 때 더 효과적으로 복사할 수 있도록 한다. io.Copy
함수는 인터페이스인 io.Writer
와 io.Reader
타입의 파라미터를 받아 해당 작업을 수행하기 위해 io.copyBuffer
메서드를 호출한다. 인터페이스인 io.Reader
파라미터가 인터페이스 io.WriteTo
를 구현했거나 io.Writer
파라미터가 인터페이스 io.ReaderFrom
를 구현했다면 해당 함수의 대부분은 작업을 하지 않고 넘어간다.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
if rt, ok := dst.(ReaderFrom); ok {
return rt.ReadFrom(src)
}
...
}
선택적 인터페이스가 사용되는 또 다른 곳은 API
를 발전시킬 때이다. 이는 추후에 나올 컨텍스트(context)에서 많이 사용된다. 컨텍스트는 특히 취소(cancellation)를 관리하는 표준 방법을 제공하는 함수에 전달되는 파라미터이다.
go버전 1.8에서는 기존 인터페이스의 새로운 컨텍스트를 인지한 비슷한 기능이 database/sql/driver
패키지에 정의되었다. 가령, StmtExecContext
인터페이스는 Stmt
내의 Exec
메서드를 위한 컨텍스트 인지 대체인 ExecContext
라는 메서드를 정의한다. Stmt
의 구현이 표준 라이브러리 데이터베이스 코드로 전달될 때 StmtExecContext
도 구현이 되어 있는지 확인한다. 구현이 되어 있다면, ExecContext
를 실행하게 된다. 그렇지 않다면 go의 표준 라이브러리는 새로운 코드에서 취소 지원되는 대비책 구현을 제공한다.
func ctxDriverStmtExec(ctx context.Context, si driver.Stmt, nvdargs []driver.NamedValue) (driver.Result, error) {
if siCtx, is := si.(driver.StmtExecContext); is {
return siCtx.ExecContext(ctx, nvdargs)
}
}
선택적 인터페이스 기술에는 한 가짐 단점이 있다. 앞서 인터페이스 구현이 데코레이터 패턴을 사용하여 같은 인터페이스의 다른 구현을 계층 동작에 래핑하는 것이 일반적이라는 것을 보았다. 문제는 래핑된 구현 중 하나에 의해 구현된 선택적 인터페이스는 타입 단언이나 타입 스위치로 검출이 안된다는 것이다. 즉, 계속해서 인터페이스의 구현체를 바꾸다보면 선택적 인터페이스를 만족했던 구현체가 사라지고 다른 구현체는 선택적 인터페이스를 만족하지 못할 수 있다는 것이다.
error
는 인터페이스이기 때문에 구현해야한다. 오류는 다른 오류를 감싸서 추가적인 정보를 포함할 수 있다. 타입 스위치나 타입 단언을 통해 래핑된 오류를 검출하거나 일치시킬 수 없다. 반환된 오류의 다른 구체 구현을 처리하도록 하려면 errors.Is
와 errors.As
함수를 사용하여 래핑된 오류를 테스트하고 접근한다.
타입 switch
문은 다른 처리가 필요한 인터페이스의 여러 구현을 구별하는 기능을 제공한다. 인터페이스에 제공될 수 있는 유효한 특정 타입이 있는 경우에 가장 유용하다. 개발 시점에 알지 못한 구현을 처리하기 위해 타입 switch
내에 default
문을 초함한다는 것을 명심하자. 이것은 새로운 인터페이스 구현을 추가할 때, 타입 switch
문 업데이트를 잊지 않도록 해준다.
go에서는 함수를 일급 함수로 처리하기 때문에 하나의 타입으로도 처리할 수 있다. 때문에 func
으로 만든 타입에 메서드를 추가할 수 있다. 처음들으면 매우 이상해보이지만, 생각보다 유용하고 좋은 코드를 만들 수 있다.
가장 대표적인 것이 http
이다.
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
func(http.ResponseWriter, *http.Request)
함수 시그니처를 만족하는 함수를 하나 만들고, 타입 변환을 사용하여 http.HandlerFunc
로 변환하면 ServeHTTP
메서드를 구현하기 때문에 http.Handler
로 사용이 가능하다. 왜냐하면 함수가 ServeHTTP
메서드를 구현된 HandlerFunc
으로 함수가 형 변환되면 Handler
interface를 만족하였기 때문에 Handler
인터페이스의 구현체로 함수가 들어갈 수 있는 것이다.
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w,r)
}
ServeHTTP
에서는 그저 함수 객체 자신을 호출할 뿐이다. 이를 통해서 사용자는 따로 Handler
인터페이스의 메서드를 구현하는 객체를 따로 만들 필요가 없다. 오직 func(http.ResponseWriter, *http.Request)
함수 시그니처만 만족하는 함수라면 HandlerFunc
으로 형변환이 가능하고, HandlerFunc
은 ServeHTTTP
를 구현하기 때문에 Handler
인터페이스의 구현체 역할을 할 수 있게 되는 것이다.
이와 같이 특정 함수 시그니처를 만족하는 함수를 구현하여 메서드를 이식해주고, 함수 자체를 실행하는 패턴을 이용하면 사용자가 인터페이스에 함수를 넣어줄 수 있다는 장점이 있고, 그렇다는 것은 어떤 함수의 매개변수로 인터페이스를 사용했을 때 이를 구현하는 함수를 넣어도 된다는 것이다. 이를 이용하여 사용자 함수를 전달할 수도 있고, 클로저로 함수를 구현할 수 있는 장점이 있다.
go에서 함수는 일급 함수 개념이므로 함수에 파라미터로 함수가 종종 전달이 된다. go는 작은 인터페이스를 권장하고 단일 메서드의 인터페이스는 함수 타입의 파라미터를 쉽게 대체할 수 있다. 그럼 함수 또는 메서드는 언제 함수 타입의 파라미터를 지정해야하고 언제 인터페이스를 사용해야할까?? 즉, 인자로 함수를 사용하는 경우와, 인터페이스를 사용하는 경우는 어떻게 나눠야할까?
단일 함수에 많은 다른 함수나 해당 함수의 입력 파라미터에 지정되지 않은 다른 상태에 의존적인 것 같다면 인터페이스 파라미터를 사용하고, 함수 타입을 선언하여 함수와 인터페이스를 연결하도록 하자. http패키지의 Handler
는 구성해야 하는 일련의 호출에 대한 진입점이다. 하지만 단순한 함수라면 sort.Slice
와 같은 경우는 함수 타입의 파라미터가 좋다.
즉, Handler
의 경우는 사용자가 함수를 정의한 뒤 http
패키지에 건내주면 http
패키지에서는 이를 handler
로 관리하여 각종 상황일 때 해당 handler
를 호출한다. 즉, 전체적으로 handler
의 메서드에 대한 사용이 많고, 다양하게 의존하는 경우인 것이다. 왜냐하면 handler
인터페이스로 관리하면 ServeHTTP
메서드를 구현한 구조체 객체로도 관리가 가능하기 때문이다. 즉, 특정 상황에서는 구조체를 만들어 특정 필드를 가지도록 하여 상태를 유지할 수 있도록 하는 것이다. 이때에는 함수 시그니처인 func(http.ResponseWriter, *http.Request)
으로 받으면 상태가 저장되지 않거나 클로저를 사용해야만 상태가 변경되는 상황이 벌어지기 때문에 구현에 있어 유연성이 떨어진다.
반면, sort.Slice
에 들어가는 함수는 구분자로 sort.Slice
내부 외에는 쓰이질 않는다. 딱히 상태를 저장할 일도 없어서 구조체를 만들 필요도 없다. 따라서 인터페이스로 입력을 받는 것이 아니라, 함수 시그니터를 구현한 함수 타입을 받도록 하는 것이다.
프로그램은 시간이 지남에 따라 변경이 불가피하다. 구현이 변경되는 것에 대한 영향을 줄이기 위해서는 디커플링이 필수이며 의존성 주입(dependency injection)은 이를 가능하게해준다. go의 암묵적(implicit) interface 구현은 별다른 툴이나 프레임워크를 사용하지 않아도 의존성 주입을 가능하게 해준다.
먼저 logger를 만들어주기로 하자.
func LogOutPut(message string) {
fmt.Println(message)
}
다음으로 data storer를 만들어주도록 하자.
type SimpleDataStore struct {
userData map[string]string
}
func (sds SimpleDataStore) UserNameForID(userID string) (string, bool) {
name, ok := sds.userData[userID]
return name, ok
}
SimpleDataStore
인스턴스를 생성하기 위한 팩토리 함수를 만들어보도록 하자.
func NewSimpleDataStore() SimpleDataStore {
return SimpleDataStore{
userData: map[string]string{
"1": "Fred",
"2": "Mary",
"3": "Pat",
},
}
}
다음은 사용자를 찾고 hello
혹은 goodbye
를 출력하는 약간의 비즈니스 로직을 작성하도록 하자. 비지니스 로직은 수행하기 위해 약간의 데이터가 필요하기 때문에 데이터 저장소가 필요하다. 그리고 비즈니스 로직이 실행되면 로그를 남겨야 하는데 이는 로거가 필요하다. 하지만 나중에 다른 로거나 데이터 저장소를 사용할 수 있기 때문에 LogOUtput
이나 SimpleDataStore
에 의존하도록 강제하고 싶지는 않다. 비지니스 로직이 필요로 하는 것은 해당 로직이 의존하는 것을 기술하기 위한 인터페이스이다.
type DataStore interface {
UserNameForID(userID string) (string, bool)
}
type Logger interface {
Log(message string)
}
LogOutput
함수가 해당 인터페이스를 충족할 수 있도록 메서드와 함께 함수 타입을 정의해야 한다.
type LoggerAdapter func(message string)
func (lg LoggerAdapter) Log(message string) {
lg(message)
}
이제 의존성을 정의했으니, 비지니스 로직의 구현을 살펴보도록 하자.
type SimpleLogic struct {
l Logger
ds DataStore
}
func (sl SimpleLogic) SayHello(userID string) (string ,error) {
sl.l.Log("in SayHello for " + userID)
name, ok := sl.ds.UserNameForID(userID)
if !ok {
return "", errors.New("unknown user")
}
return "Hello, " + name, nil
}
func (sl SimpleLogic) SayGoodbye(userID string) (string ,error) {
sl.l.Log("in SayGoodbye for " + userID)
name, ok := sl.ds.UserNameForID(userID)
if !ok {
return "", errors.New("unknown user")
}
return "Goodbye, " + name, nil
}
Logger
와 DataStore
항목을 가지는 구조체를 정의했다. SimpleLogic
에는 구체 타입을 언급하는 내용이 없으므로 의존성이 없다. 나중에 완전히 다른 새로운 구현으로 바뀌더라도 해당 인터페이스와는 아무런 관계도 없기 때문에 아무런 문제가 없다. 이는 자바와 같은 명시적인 인터페이스와는 완전히 다르다. 자바는 인터페이스로부터 구현을 분리하기 위해 인터페이스를 사용하더라고 명시적 인터페이스는 client(사용하는 부분)와 provider(구현)를 함께 바인딩해야한다. 이로 인해 의존성 교체를 훨씬 어렵게 만드는 단점이 있다.
SimpleLogic
인스턴스를 만드는 팩토리 함수를 만들어보자.
func NewSimpleLogic(l Logger, ds DataStore) SimpleLogic {
return SimpleLogic{
l: l,
ds: ds,
}
}
이제 해당 Logic
을 사용하여 /hello
요청이 왔을 때 Hello
응답을 전달하도록 만들어보자.
컨트롤러 객체를 하나 만들되 hello
를 말하는 비즈니스 로직은 각 나라별로, 언어별로, 시간 대 별로 다르다. 따라서 비지니스 로직에 대한 구현은 변할 수 있으므로 인터페이스를 정의하도록 한다.
type Logic interface {
SayHello(userID string) (string ,error)
}
해당 메서드는 SimpleLogic
구조체에서 사용 가능하지만, 구체 타입은 인터페이스를 인지하지 못한다. 게다가 SimpleLogic
의 다른 메서드인 SayGoodBye
는 컨트롤러가 신경 쓰지 않기 때문에 인터페이스에 없다. 인터페이스는 사용자 코드에서 소유하기 때문에 메서드 세트는 사용자 코드의 요구에 맞춰진다.
type Controller struct {
l Logger
logic Logic
}
func (c Controller) HandleGreeting(w http.ResponseWriter, r *http.Request) {
c.l.Log("In SayHello")
userID := r.URL.Query().Get("user_id")
message, err := c.logic.SayHello(userID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
w.Write([]byte(message))
}
controller에 대한 팩토리 함수도 하나 만들도록 하자.
func NewController(l Logger, logic Logic) Controller {
return Controller {
l: l,
logic: logic,
}
}
이는 인터페이스를 받아서 구조체를 반환한다. 마지막으로 main
함수에서 모든 컴포넌트를 구성하고 서버스를 시작한다.
func main() {
l := LoggerAdapter(LogOutput)
ds := NewSimpleDataStore()
logic := NewSimpleLogic(l, ds)
c := NewController(l, logic)
http.HandleFunc("/hello", c.SayHello)
http.ListenAndServe(":8080", nil)
}
main
함수는 모든 구체 타입이 실제로 무엇인지를 알고 있는 코드의 유일한 부분이다. 따라서, 구체 타입이 다른 것으로 바뀌어야 한다면 main
에서만 변화가 이뤄지는 것이 끝이다. 의존성 주입을 통해 의존성을 외부화하는 것은 시간이 지남에 따라 코드를 발전시키는데 필요한 변경 사항을 제한한다는 것을 의미한다. 이제 컨트롤러의 Logic
을 변경하고 싶다면 main
에서 logic
인스턴스를 다른 인스턴스로 변경하여 주입시켜주기만 하면 되는 것이다. 심지어 SImpleLogic
의 이름이 바뀌어도, 구조체 필드가 달라져도, 추가되어도 문제가 없다.
의존성 주입은 테스트를 더 쉽게 만들 수 있는 훌륭한 패턴이다. 테스트하기 어려운 타입들에 대한 mock을 만들고 의존성을 주입해주기만 하면 되기 때문이다.