Go

Go 언어 인터페이스 구현 패턴

드리프트2 2024. 4. 14. 11:58

 

안녕하세요?

 

오늘은 Go 언어 인터페이스 구현 패턴과 함께 type, 메소드 그리고 인터페이스의 기본적인 설명을 추가하여 아래와 같은 것들을 살펴볼 예정입니다.

  • Go 언어에서의 인터페이스 부분 구현 패턴
  • struct에 interface를 포함시켜 동적으로 교체하는 패턴

먼저 type과 메소드, 기본적인 인터페이스 구현 방법에 대해 잠시나마 복습해보겠습니다.

 

** 목 차 **


type으로 타입을 선언하기

우선, Go 언어에서의 타입 선언 방법입니다.

 

Go 언어를 처음 시작하는 분들 중에서 type의 사용 방법을 제한적으로만 이해하고 있는 분들이 많습니다.

 

알다시피, type은 타입을 선언하기 위해 사용하는 키워드입니다.

 

아래와 같이, 구조체 타입이나 인터페이스 타입의 선언 시에 사용하는 것이 일반적입니다.

// 구조체 타입 선언
type Foo struct {
    // 필드 리스트
}

// 인터페이스 타입 선언
type Bar interface {
    // 메소드 리스트
}

 

golang.org의 The Go Programming Language Specification을 보면, type을 사용한 타입 선언의 문법은 아래와 같이 정의되어 있습니다.

TypeDecl  = "type" ( TypeSpec | "(" { TypeSpec ";" } ")" ) .
TypeSpec  = identifier Type .
Type      = TypeName | TypeLit | "(" Type ")" .
TypeName  = identifier | QualifiedIdent .
TypeLit   = ArrayType | StructType | PointerType | FunctionType | InterfaceType | SliceType | MapType | ChannelType .

 

좀 더 이해하기 쉽게 쓰면 아래와 같습니다.

type 식별자 타입

 

'식별자'는 여기에서 선언하는 타입의 이름입니다.

 

'타입'은 타입명 또는 타입 리터럴, ()로 둘러싸인 타입입니다.

 

타입명은 int나 string과 같은 내장 타입의 타입명뿐만 아니라, 각 패키지에서 type을 사용하여 선언된 기존의 독자적인 타입의 타입명도 포함합니다.

 

즉, type을 사용하면 기존의 타입에 새로운 이름을 붙일 수 있습니다.

 

아래와 같이 선언한 경우, 기본적으로는 int와 같은 방식으로 작동합니다.

 

그러나, intHex는 다른 타입이기 때문에 연산을 하거나 int 타입의 값을 Hex 타입의 변수에 넣을 경우 타입 캐스팅이 필요합니다.

 

type Hex int

 

타입 리터럴이란 아래와 같은 타입을 리터럴로 쓴 것입니다.

// 배열 타입
[10]int

// 구조체 타입
struct {
    // 필드 리스트
}

// 포인터 타입
*int

// 함수 타입
func(s string) int

// 인터페이스 타입
interface {
    // 메소드 리스트
}

// 슬라이스 타입
[]int

// 맵 타입
map[string]int

// 채널 타입
chan bool

 

이러한 타입 리터럴에 '식별자'로 지정한 타입명을 새로 붙일 수 있습니다.

 

즉, 구조체 타입이나 인터페이스 타입뿐만 아니라, 함수 타입이나 슬라이스 타입, 맵 타입, 채널 타입의 타입 리터럴에도 타입명을 붙일 수 있습니다.

 

실제로, http 패키지의 http.HandlerFunc는 아래와 같이 정의된 함수 타입입니다.

type HandlerFunc func(w http.ResponseWriter, r *http.Request)

 

모든 것을 구조체 타입으로 선언한 게 보통이지만, type을 사용한 타입 선언은 더 유연한 사용 방법이 가능하므로, 꼭 다양한 시도를 해보시기 바랍니다.


메소드

메소드는 다음과 같이 리시버와 메소드 이름, 함수 본문을 지정하여 정의합니다.

 

이 경우 p가 리시버가 됩니다.

func (p *Person) String() string {
    return fmt.Sprintf("%s %s (%d)", p.FirstName, p.LastName, p.Age)
}

 

위의 메소드는 다음과 같은 구조체의 포인터 타입 메소드로 정의되어 있다고 가정합니다.

type Person struct {
    FirstName string
    LastName  string
    Age       int
}

 

와 같이, 구조체 타입(또는 구조체의 포인터 타입)에 메소드를 설정할 수 있습니다.

 

그러나 메소드는 구조체 타입이나 구조체의 포인터 타입에만 정의할 수 있는 것은 아닙니다.

 

인터페이스 타입이 아니라면, 어떤 타입에도 메소드를 정의할 수 있습니다.

 

예를 들어, 위에서 언급한 Hex 타입에 메소드를 정의해 봅시다.

type Hex int

func (h Hex) String() string {
    return fmt.Sprintf("0x%x", int(h))
}

 

int 타입을 새로운 Hex 타입으로 선언하고, String이라는 메소드를 설정할 수 있었습니다.

 

물론, 마찬가지로 함수에도 메소드를 설정할 수 있습니다.

 

실제로, http.HandlerFunc는 다음과 같은 메소드를 정의하고 있습니다.

type HandlerFunc func(w ResponseWriter, r *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

 

http.HandlerFuncServeHTTP 메소드는 리시버 자신을 함수 호출하는 메소드입니다만, 왜 이러한 메소드를 선언할 필요가 있는지는 나중에 살펴보겠습니다.


기본적인 인터페이스의 구현

앞서 언급한 바와 같이, 인터페이스 타입은 다음과 같이 선언됩니다.

type TypeName interface {
    // 메소드 리스트
    Method1()
    Method2()
}

 

인터페이스 타입의 선언 시에 지정한 메소드 리스트의 메소드를 모두 구현함으로써, 인터페이스를 구현할 수 있습니다.

 

Java 언어에서와 같이, implements 등을 사용하여 명시적으로 구현할 필요는 없습니다.

 

여기까지 여러 번 나온 각 타입의 String 메소드는, fmt.Stringer 인터페이스를 구현하고 있는 것입니다.

 

참고로, fmt.Stringer는 다음과 같이 선언되어 있습니다.

type Stringer interface {
    String() string
}

 

예를 들어, 위에서 언급한 Hex 타입은 String 메소드를 가지고 있기 때문에, fmt.Stringer 인터페이스를 구현한 것입니다.

따라서, Hex 타입은 fmt.Stringer 인터페이스로서 행동할 수 있습니다.

 

fmt.Stringer 인터페이스로서 행동한다는 것은, fmt.Stringer 타입의 변수에 할당하거나, 함수의 인자로 전달할 수 있다는 것을 의미합니다.

var stringer fmt.Stringer
// int 타입의 100을 Hex 타입으로 캐스트하고, fmt.Stringer 타입의 변수에 할당하고 있습니다.
stringer = Hex(100)

 

참고로, 자주 보게 되는 interface{} 타입은, 메소드 리스트가 없는 인터페이스 타입의 타입 리터럴입니다.

 

메소드 리스트가 없다는 것은, 메소드를 하나도 구현하지 않아도, interface{} 인터페이스를 구현한 것이 되므로, interface{} 타입의 변수나 인자에는, 어떤 타입의 값이든 할당하거나 전달할 수 있습니다.

 

지금까지 type, 메소드, 인터페이스의 기본적인 구현 방법에 대해 설명했습니다.

 

지금부터는 좀 더 응용적인 방법으로 인터페이스를 구현해 보겠습니다.


함수에 인터페이스 구현하기

앞서 언급한 것처럼, 함수에도 메소드를 포함시킬 수 있습니다.

 

예를 들어, http.HandlerFunc는 다음과 같이 ServeHTTP 메소드를 가지고 있었습니다.

type HandlerFunc func(w ResponseWriter, r *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

 

http 패키지에서는, http.Handler라는 인터페이스를 정의하고 있습니다.

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

 

그리고 사실 http.HandlerFunc 타입은 같은 인자와 반환값을 가진 함수에, http.Handler 인터페이스를 구현하기 위해 선언된 타입이었습니다.

 

그래서 메소드 안에서 리시버 자신의 함수 호출을 하고 있었습니다.

 

함수를 다음과 같이 캐스트하여, 인터페이스 타입으로 변수에 할당할 수 있습니다.

 

이때, f는 http.Handler를 구현하고 있기 때문에, http.Handle의 두 번째 인자로 전달할 수 있습니다.

f := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello, world")
})

// func Handle(pattern string, handler Handler)
http.Handle("/", f)

 

또한, http 패키지에는 이 과정을 더 간단하게 할 수 있는 http.HandleFunc라는 함수가 준비되어 있습니다.

 

보통은 이 함수를 사용하는 것이 일반적입니다.

func HandleFunc(pattern string, handler func(ResponseWriter, *Request))

구조체에 포함시켜 인터페이스 구현하기

구조체에는, 익명 필드로 지정된 타입의 값을 포함시킬 수 있습니다.

 

포함된 타입의 값의 필드나 메소드는, 마치 포함된 구조체의 필드나 메소드처럼 호출할 수 있습니다.

 

예를 들어, 다음과 같이 Name의 포인터 타입에 String 메소드를 포함시키고, Person 타입에 포함시켜 봅니다.

 

Person 타입은 String 메소드를 가지고 있지 않지만, 포함된 *Name 타입이 String 메소드를 가지고 있기 때문에, p.String()처럼 호출할 수 있습니다.

type Name struct {
    FirstName string
    LastName  string
}

func (n *Name) String() string {
    return fmt.Sprintf("%s %s", n.FirstName, n.LastName)
}

type Person struct {
    // *Name 타입의 값을 포함시킴
    *Name
    Age int
}

func main() {
    n := &Name{
        FirstName: "Jisung",
        LastName:  "Park",
    }

    p := &Person{
        // *Name 타입의 n을 포함시킴
        Name: n,
        Age: 20,
    }
    fmt.Println(p.String())
}

 

이때, *Name 타입은 String 메소드를 가지고 있기 때문에, fmt.Stringer 인터페이스를 구현하고 있습니다.

 

그러나 사실 *Name 타입을 Person 타입에 포함시킨 것으로 인해, Person 타입과 *Person 타입도 fmt.Stringer 인터페이스를 구현하게 되었습니다.

 

즉, 위의 변수 p는 다음과 같이 fmt.Stringer 타입의 변수에 할당할 수 있습니다.

var stringer fmt.Stringer = p
fmt.Println(stringer.String())

인터페이스의 부분 구현을 위한 임베딩 사용

인터페이스를 정의할 때 여러 메소드를 구현하도록 지정할 수 있습니다.

 

예를 들어, 다음과 같은 인터페이스를 정의한다고 가정해 봅시다.

type Person interface {
    // 호칭
    Title() string
    // 이름
    Name() string
}

 

위에서 언급한 임베딩을 통한 인터페이스 구현을 응용하여, 인터페이스에 지정된 메소드 리스트의 일부를 구조체 타입에 임베딩된 타입으로 구현할 수 있습니다.

 

다음의 *person 타입은 Name 메소드를 가지고 있습니다.

 

그러나 Title 메소드는 가지고 있지 않기 때문에, Person 인터페이스를 구현하고 있다고 볼 수 없습니다.

type person struct {
    firstName string
    lastName  string
}

func (p *person) Name() string {
    return fmt.Sprintf("%s %s", p.firstName, p.lastName)
}

 

그러나 *person 타입을 Title 메소드를 구현한 구조체에 임베딩함으로써, Person 인터페이스를 구현할 수 있습니다.

 

다음 예에서는 *female 타입과 *male 타입에 Title 메소드를 구현하고 있습니다.

 

각 타입에는 *person 타입이 임베딩되어 있기 때문에 Name 메소드도 구현하고 있습니다.

 

따라서 *female 타입과 *male 타입은 Person 인터페이스를 구현하고 있습니다.

type Gender int

const (
    Female Gender = iota
    Male
)

type female struct {
    *person
}

func (f *female) Title() string {
    return "Ms."
}

type male struct {
    *person
}

func (m *male) Title() string {
    return "Mr."
}

 

Go 언어에서는 소문자로 시작하는 타입은 해당 패키지 내에서만 사용할 수 있습니다.

 

따라서 female 타입과 male 타입은 다른 패키지에서 숨겨져 있습니다.

 

다른 패키지에서는 이러한 타입들을 의식하지 않고 Person 인터페이스로 사용할 수 있는 것이 바람직합니다.

 

Person 인터페이스를 구현한 값을 반환하는 NewPerson 함수를 생각해 봅시다.

 

인자로 성별(gender)을 지정함으로써 내부에서 생성하는 구조체의 타입을 전환하고 있습니다.

 

이렇게 호출자는 *female 타입인지, *male 타입인지 의식하지 않고, Title 메소드의 구현을 전환할 수 있습니다.

func NewPerson(gender Gender, firstName, lastName string) Person {
    p := &person{firstName, lastName}

    if gender == Female {
        return &female{p}
    }

    return &male{p}
}

인터페이스의 동적 구현을 위한 임베딩 사용

 

구조체 타입에 임베딩할 수 있는 타입은 이름이 있는 타입이라면 어떤 타입이든 가능합니다.

 

그러나 타입 리터럴은 임베딩할 수 없습니다.

 

예를 들어, chan int[]int와 같은 타입 리터럴은 구조체 타입에 임베딩할 수 없습니다.

 

반면에, fmt.Stringer 인터페이스와 같은 이름이 있는 인터페이스 타입은 구조체 타입에 임베딩할 수 있습니다.

type Foo struct {
    chan int        // 불가능
    []int           // 불가능
    fmt.Stringer    // 가능
}

 

임베딩하는 타입이 구조체 타입이나 그 포인터일 경우, 해당 타입의 값만 임베딩할 수 있습니다.

 

그러나 임베딩하는 타입이 인터페이스 타입이라면, 그 인터페이스를 구현하는 어떤 값이든 임베딩할 수 있습니다.

type Foo struct {
    *Bar          // *Bar 타입의 값만 임베딩 가능
    fmt.Stringer   // fmt.Stringer 인터페이스를 구현하면 임베딩 가능
}

 

위에서 언급한 것처럼, 임베딩된 타입은 익명 필드로 선언되어 있기 때문에, 값을 동적으로 변경할 수 있습니다.

type Person struct {
    fmt.Stringer
    FirstName string
    LastName  string
    Age       int
}

func main() {
    p := Person{
        Stringer:  nil,
        FirstName: "Jisung",
        LastName:  "Park",
        Age:       20,
    }
    fmt.Println(p.Stringer) // nil
    p.Stringer = ???        // fmt.Stringer 인터페이스를 구현하면 할당 가능
}

 

위의 Person 타입에 fmt.Stringer 타입을 임베딩할 수 있지만, Person 타입의 값을 사용하여 String 메소드를 구현한 값이 아니라면 큰 의미가 없습니다.

 

이 경우에는 FirstName이나 LastName을 사용하여 문자열을 조합하는 것이 바람직합니다.

 

그래서 다음과 같이 fmt.Stringer 인터페이스를 구현하는 함수 타입을 생각해 봅시다.

 

StringerFunc는 문자열을 반환하는 함수에 fmt.Stringer 인터페이스를 구현하기 위한 타입입니다.

type StringerFunc func() string

func (sf StringerFunc) String() string {
    return sf()
}

 

더 나아가, Person 타입의 포인터를 인자로 받는 func(p *Person) string 타입의 함수를 StringerFunc 타입으로 캐스트 가능한 func() string 타입으로 변환하는 함수를 생각해 봅시다.

 

인자를 받지 않고 특정 변수(이 경우에는 p)에 접근하기 위해서는 클로저를 사용하는 것이 좋습니다.

func BindStringer(p *Person, f func(p *Person) string) fmt.Stringer {
    return StringerFunc(func() string {
        return f(p)  
    })
}

 

BindStringer 함수의 반환 타입은 fmt.Stringer 타입이지만, 실제로 반환되는 값은 StringerFunc 타입입니다.

 

함수 내부에서는 첫 번째 인자인 p와 두 번째 인자인 f를 참조할 수 있는 func() string 타입의 클로저를 생성하고, StringerFunc 타입으로 캐스트하고 있습니다.

 

여기서 생성된 클로저 내부에서는 인자 pf를 참조할 수 있기 때문에, pf의 인자로 사용하여 함수 호출을 수행하고 있습니다.

 

이와 같이 func(p *Person) string 타입을 func() string 타입으로 변환하고, 더 나아가 StringerFunc 타입으로 캐스트함으로써, *Person 타입을 구현으로 사용한 fmt.Stringer 인터페이스를 만족하는 값을 생성할 수 있습니다.

 

그럼 이제 위의 함수를 *Person 타입을 초기화하는 NewPerson 함수에 통합해 봅시다.

 

그리고 fmt.Stringer 인터페이스의 구현을 동적으로 변경할 수 있도록 SetStringer 메소드를 준비해 봅시다.

func NewPerson(firstName, lastName string, age int) (p *Person) {
    p = &Person{
        Stringer:  nil,
        FirstName: firstName,
        LastName:  lastName,
        Age:       age,
    }

    p.Stringer = StringerFunc(func() string {
        return fmt.Sprintf("%s %s (%d)", p.FirstName, p.LastName, p.Age)
    })

    return
}

func (p *Person) SetStringer(sf func(p *Person) string) {
    p.Stringer = StringerFunc(func() string {
        return sf(p)
    })
}

 

NewPerson 함수에서는 fmt.Stringer의 기본값으로, "Jisung Park (20)"과 같은 문자열을 반환하는 StringerFunc을 임베딩하고 있습니다.

 

SetStringer 메소드는 *Person 타입을 인자로 받고, 문자열을 반환하는 함수를 인자 sf로 받습니다.

 

sf는 위에서 언급한 BindStringer 함수와 같은 방식으로 StringerFunc로 캐스트되어, Person 타입의 익명 필드인 Stringer에 설정됩니다.

 

SetStringer를 사용하면, 임베딩된 fmt.Stringer 인터페이스의 구현을 실행 시에 동적으로 변경할 수 있습니다.

 

여기서 주의해야 할 점이 있습니다.

 

Person 타입에 임베딩된 fmt.Stringer 타입은 fmt 패키지에서 외부에 공개되어 있는 타입입니다.

 

따라서 Person 타입에 임베딩된 값을 Person 타입을 선언한 패키지 외부에서도 변경할 수 있습니다.

 

그래서 다음과 같이 Person 타입에 임베딩하는 타입은 fmt.Stringer 타입에 새로운 이름을 붙인 패키지 외부에 공개되지 않는 타입으로 만들어, 패키지 외부에서 임베딩된 값을 변경되지 않도록 할 수 있습니다.

 

그리고 stringer 인터페이스를 구현하는 타입은 fmt.Stringer 인터페이스도 구현하고 있기 때문에, 본질적인 동작은 변하지 않습니다.

// 패키지 외부에 공개되지 않는 타입으로 선언
type stringer fmt.Stringer

type Person struct {
    stringer            // 패키지 외부에서 할당할 수 없는 익명 필드
    FirstName string
    LastName  string
    Age       int
}

마지막으로

이 글에서는 인터페이스에 관한 기초적인 지식을 다시 짚어보고, 더 나아가 응용적인 인터페이스 구현 방법을 설명했습니다.

 

저 자신도 여기서 언급한 응용적인 인터페이스 구현 방법이 현실의 Go 언어 프로그래밍에 어떻게 활용될 수 있는지 모색 중입니다.

 

그럼.