Go 구조체와 인터페이스를 활용한 객체지향 프로그래밍 기법

1. 구조체 정의, 초기화, 그리고 메서드

구조체 정의와 초기화

Go 언어는 전통적인 '클래스' 키워드나 상속을 제공하지 않습니다. 대신 구조체(struct)를 내장(embedding)하고 인터페이스를 활용하여 더 유연하고 확장성 있는 객체지향 패턴을 구현합니다. 다음은 학생 정보를 담는 구조체 예시입니다.

type Student struct {
    id    uint
    name  string
    male  bool
    score float64
}

Go에서는 생성자가 없으므로, 관례적으로 NewXXX 형태의 전역 함수를 생성자처럼 사용합니다.

func NewStudent(id uint, name string, male bool, score float64) *Student {
    return &Student{id: id, name: name, male: male, score: score}
}

// 특정 필드만 초기화
func NewStudentV2(id uint, name string, score float64) *Student {
    return &Student{id: id, name: name, score: score}
}

사용 예시:

s := NewStudent(1, "홍길동", true, 95.5)
fmt.Println(s)

값 리시버 메서드

메서드를 정의할 때는 함수명 앞에 리시버(receiver)를 선언합니다. 리시버는 메서드가 속한 타입을 지정합니다.

func (s Student) GetName() string {
    return s.name
}

호출:

fmt.Println(s.GetName())

포인터 리시버 메서드

값을 변경해야 하는 메서드는 포인터 리시버를 사용합니다.

func (s *Student) SetName(name string) {
    s.name = name
}

포인터 리시버는 구조체의 복사본이 아닌 원본을 수정합니다. 값 리시버는 복사본을 사용하므로 외부에 영향을 주지 않습니다.

s.SetName("김철수")
fmt.Println(s.GetName()) // "김철수"

String() 메서드 구현

Python의 __str__과 유사하게, Go는 String() 메서드로 기본 문자열 출력을 정의합니다.

func (s Student) String() string {
    return fmt.Sprintf("{id: %d, name: %s, male: %t, score: %.1f}",
        s.id, s.name, s.male, s.score)
}
fmt.Println(s) // 자동으로 String() 호출

2. 컴포지션을 통한 상속과 메서드 오버라이딩

Go는 상속 대신 컴포지션(composition)을 사용합니다. 구조체 안에 다른 구조체를 필드로 포함시켜 기능을 재사용합니다.

기본 구조체 정의

type Animal struct {
    Name string
}

func (a Animal) Call() string {
    return "동물의 울음소리..."
}

func (a Animal) FavorFood() string {
    return "좋아하는 음식..."
}

func (a Animal) GetName() string {
    return a.Name
}

컴포지션으로 상속 흉내내기

type Dog struct {
    Animal // 임베딩
}

func main() {
    animal := Animal{Name: "진돗개"}
    dog := Dog{Animal: animal}
    fmt.Println(dog.GetName())   // "진돗개"
    fmt.Println(dog.Call())      // "동물의 울음소리..."
}

메서드 오버라이딩

func (d Dog) Call() string {
    return "멍멍"
}

func (d Dog) FavorFood() string {
    return "뼈다귀"
}

func main() {
    dog := Dog{Animal: Animal{Name: "푸들"}}
    fmt.Println(dog.Call())                // "멍멍"
    fmt.Println(dog.Animal.Call())         // "동물의 울음소리..."
}

다중 임베딩과 충돌 해결

type Pet struct {
    Name string
}

func (p Pet) GetName() string {
    return p.Name
}

type Dog2 struct {
    Animal
    Pet
}

// dog2.GetName() // 컴파일 오류: ambiguous selector
fmt.Println(dog2.Animal.GetName()) // 명시적 호출 필요

포인터 임베딩

type Dog3 struct {
    *Animal
    Pet
}

func main() {
    animal := Animal{Name: "시바견"}
    pet := Pet{Name: "애완견"}
    dog3 := Dog3{Animal: &animal, Pet: pet}

    fmt.Println(dog3.GetName())
}

포인터 임베딩은 메모리 효율이 더 좋습니다.

임베딩 타입에 별칭 사용

type Dog4 struct {
    animal *Animal
    pet    Pet
}

func main() {
    animal := Animal{Name: "말티즈"}
    pet := Pet{Name: "푸들"}
    dog4 := Dog4{animal: &animal, pet: pet}
    fmt.Println(dog4.animal.GetName())
}

3. 가시성(Visibility)

Go에서는 private, protected, public 같은 키워드가 없습니다. 대신 식별자(변수, 함수, 구조체 필드, 메서드 등)의 첫 글자가 대문자이면 패키지 외부에서 접근 가능하고, 소문자이면 패키지 내부에서만 접근 가능합니다.

패키지 예시 (animal 패키지):

animal.go

package animal

type Animal struct {
    Name string // 대문자: 외부 접근 가능
}

func (a Animal) Call() string {
    return "동물의 울음소리..." // 메서드도 동일 규칙
}

func (a Animal) FavorFood() string {
    return "좋아하는 음식..."
}

func (a Animal) GetName() string {
    return a.Name
}

pet.go

package animal

type Pet struct {
    Name string
}

func (p Pet) GetName() string {
    return p.Name
}

dog.go

package animal

type Dog struct {
    Animal *Animal
    Pet     Pet
}

func (d Dog) FavorFood() string {
    return "뼈다귀"
}

func (d Dog) Call() string {
    return "멍멍"
}

main.go

package main

import (
    "fmt"
    . "example/animal" // '.' import로 패키지명 생략
)

func main() {
    animal := Animal{Name: "진돗개"}
    pet := Pet{Name: "애완견"}
    dog := Dog{Animal: &animal, Pet: pet}

    fmt.Println(dog.Animal.GetName())
    fmt.Println(dog.Call())
}

비공개 속성을 통한 캡슐화

필드명을 소문자로 시작하면 비공개가 되어, 생성자를 통해서만 초기화할 수 있습니다.

animal.go (수정)

package animal

type Animal struct {
    name string // 비공개
}

func NewAnimal(name string) Animal {
    return Animal{name: name}
}

func (a Animal) GetName() string {
    return a.name
}

main.go

func main() {
    animal := NewAnimal("진돗개")
    fmt.Println(animal.GetName()) // "진돗개"
    // animal.name = "시바견" // 컴파일 오류
}

4. 인터페이스 정의와 구현

Go의 인터페이스는 타입 시스템의 핵심입니다. 클래스가 인터페이스를 명시적으로 implements하지 않아도, 해당 인터페이스가 요구하는 모든 메서드를 구현하고 있으면 자동으로 구현했다고 간주합니다(덕 타이핑).

type File struct {
    // ...
}

func (f *File) Read(buf []byte) (n int, err error)    { return 0, nil }
func (f *File) Write(buf []byte) (n int, err error)   { return 0, nil }
func (f *File) Seek(off int64, whence int) (pos int64, err error) { return 0, nil }
func (f *File) Close() error                          { return nil }

type IFile interface {
    Read(buf []byte) (n int, err error)
    Write(buf []byte) (n int, err error)
    Seek(off int64, whence int) (pos int64, err error)
    Close() error
}

type IReader interface {
    Read(buf []byte) (n int, err error)
}

// File은 IFile, IReader 모두를 구현합니다.

인터페이스 임베딩

type A interface {
    Foo()
}

type B interface {
    A
    Bar()
}

type T struct{}

func (t T) Foo() { fmt.Println("A.Foo") }
func (t T) Bar() { fmt.Println("B.Bar") }

// T는 B를 구현합니다. Foo만 구현하면 A만 구현합니다.

5. 인터페이스 할당

구체 타입 인스턴스를 인터페이스에 할당

type Integer int

func (a Integer) Add(b Integer) Integer     { return a + b }
func (a Integer) Multiply(b Integer) Integer { return a * b }

type Math interface {
    Add(i Integer) Integer
    Multiply(i Integer) Integer
}

var a Integer = 10
var m Math = a        // 값 리시버일 때 가능
m2 := Math(&a)       // 포인터도 가능
fmt.Println(m.Add(5)) // 15

포인터 리시버가 포함된 경우

func (a *Integer) Add(b Integer) {
    *a = *a + b
}

// var m Math = a // 컴파일 오류! Integer는 Add를 값 리시버로 갖고 있지 않음
var m Math = &a    // 포인터만 가능

인터페이스 간 할당

메서드 집합이 같은 인터페이스는 서로 할당 가능합니다.

type Number1 interface {
    Equal(i int) bool
    LessThan(i int) bool
    MoreThan(i int) bool
}

type Number2 interface {
    Equal(i int) bool
    MoreThan(i int) bool
    LessThan(i int) bool // 순서 달라도 동일
}

type Number int

func (n Number) Equal(i int) bool     { return int(n) == i }
func (n Number) LessThan(i int) bool    { return int(n) < i }
func (n Number) MoreThan(i int) bool   { return int(n) > i }

var num Number = 5
var n1 Number1 = num
var n2 Number2 = n1   // 가능

메서드 집합이 부분집합인 경우에도 할당 가능하지만, 역은 불가능합니다.

// Number2에 Add 메서드가 더 있다고 가정
// var n1 Number1 = n2 // 컴파일 오류

6. 타입 단언(Type Assertion)

인터페이스 타입 단언

var num Number = 10
var n2 Number2 = &num

// n2가 Number1도 구현하는지 확인
if n1, ok := n2.(Number1); ok {
    fmt.Println(n1.Equal(10)) // true
}

구조체 타입 단언

type IAnimal interface {
    GetName() string
    Call() string
    FavorFood() string
}

var animal = NewAnimal("진돗개")
var ianimal IAnimal = Dog{Animal: animal}

if dog, ok := ianimal.(Dog); ok {
    fmt.Println(dog.Call()) // "멍멍"
}

주의: Go의 컴포지션은 상속이 아니므로, 부모 타입으로 단언하면 실패합니다.

// ianimal.(Animal) // false

리플렉션 기반 타입 단언

import "reflect"

func checkType(args ...interface{}) {
    for _, arg := range args {
        switch reflect.TypeOf(arg).Kind() {
        case reflect.Int:
            fmt.Println(arg, "is int")
        case reflect.String:
            fmt.Println(arg, "is string")
        }
    }
}

간단한 타입은 .(type) 스위치로도 가능합니다.

func whatType(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Println("int:", v)
    case string:
        fmt.Println("string:", v)
    default:
        fmt.Println("unknown")
    }
}

7. 빈 인터페이스, 리플렉션, 그리고 제네릭

빈 인터페이스 (interface{})

모든 타입을 나타낼 수 있습니다. Java의 Object와 유사하지만 더 간결합니다.

var v1 interface{} = 42
var v2 interface{} = "hello"
var v3 interface{} = struct{ Name string }{"Go"}

함수 인자로도 사용됩니다.

func PrintAnything(v interface{}) {
    fmt.Println(v)
}

리플렉션(Reflection)

reflect 패키지를 사용하면 런타임에 타입 정보를 얻고 조작할 수 있습니다.

type MyStruct struct {
    Field1 string
    Field2 int
}

func main() {
    s := MyStruct{"hello", 42}
    t := reflect.TypeOf(s)
    v := reflect.ValueOf(s)

    fmt.Println("Type:", t.Name()) // "MyStruct"
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        fmt.Printf("Field %d: %s (%s) = %v\n", i, field.Name, field.Type, v.Field(i))
    }
}

리플렉션 기반 제네릭 컨테이너

type Container struct {
    slice reflect.Value
}

func NewContainer(t reflect.Type, size int) *Container {
    if size <= 0 {
        size = 64
    }
    return &Container{
        slice: reflect.MakeSlice(reflect.SliceOf(t), 0, size),
    }
}

func (c *Container) Put(val interface{}) error {
    if reflect.ValueOf(val).Type() != c.slice.Type().Elem() {
        return fmt.Errorf("type mismatch: expected %s, got %T", c.slice.Type().Elem(), val)
    }
    c.slice = reflect.Append(c.slice, reflect.ValueOf(val))
    return nil
}

func (c *Container) Get(val interface{}) error {
    if reflect.ValueOf(val).Kind() != reflect.Ptr ||
        reflect.ValueOf(val).Elem().Type() != c.slice.Type().Elem() {
        return fmt.Errorf("invalid get target")
    }
    reflect.ValueOf(val).Elem().Set(c.slice.Index(0))
    c.slice = c.slice.Slice(1, c.slice.Len())
    return nil
}

func main() {
    c := NewContainer(reflect.TypeOf(0), 16)
    for _, v := range []int{1, 2, 3} {
        c.Put(v)
    }
    var result int
    c.Get(&result)
    fmt.Println(result) // 1
}

빈 구조체(struct{})

struct{}는 메모리를 전혀 차지하지 않습니다. 주로 채널 시그널링에 사용됩니다.

ch := make(chan struct{})
go func() {
    // 작업 수행
    ch <- struct{}{} // 빈 값 전송
}()
<-ch // 완료 신호 수신

태그: go struct Interface composition Method

7월 3일 17:36에 게시됨