본문 바로가기
프로그래밍/Golang

Golang의 struct(구조체)와 receiver(리시버) 메서드: 객체 지향적 설계의 새로운 접근

by slowin 2024. 7. 23.

Go 언어는 객체 지향 프로그래밍의 개념을 독특한 방식으로 구현합니다. 클래스 대신 구조체를 사용하고, 메서드를 통해 행동을 정의합니다.

struct 와 receiver method

1. 구조체 (Struct) Go에는 클래스가 없지만 구조체가 있습니다.

구조체는 관련된 데이터를 그룹화하는 사용자 정의 타입입니다.

type Person struct {
    Name string
    Age  int
}

2. 메서드 (Method) 메서드는 함수와 비슷하지만, 리시버(receiver)라고 불리는 특정 타입이나 객체에 바인딩됩니다.

func (p Person) SayHello() {
    fmt.Printf("Hello, I'm %s\n", p.Name)
}

여기서 `(p Person)`이 리시버입니다. 이 메서드는 `Person 타입`의 객체에 대해 호출될 수 있습니다.

3. 값 전달 vs 참조 전달 (Pass-by-value vs Pass-by-reference)

pass-by-value : Go는 기본적으로 값 전달(pass-by-value) 방식을 사용합니다. 이는 메서드가 호출될 때 리시버의 복사본이 생성된다는 의미입니다.

func (p Person) HaveBirthday() {
    p.Age++  // 이 변경은 원본 객체에 영향을 주지 않습니다.
}

Pass-by-reference : 참조로 전달하려면 포인터 리시버를 사용합니다:

func (p *Person) HaveBirthday() {
    p.Age++  // 이 변경은 원본 객체에 영향을 줍니다.
}

4. 리시버 명명 (Receiver Naming) Go에서는 thisself 대신 짧고 의도를 드러내는 이름을 사용하는 것이 관례입니다. 보통 타입 이름의 첫 글자나 약어를 사용합니다.

func (p Person) SayHello() {  // 'p'는 Person의 약자
    fmt.Printf("Hello, I'm %s\n", p.Name)
}

이렇게 하면 코드의 가독성이 높아지고, 각 메서드의 리시버가 무엇을 나타내는지 명확해집니다.

5. Method Values Go에서는 메서드를 변수에 할당하거나 함수의 인자로 전달할 수 있습니다. 이를 메서드 값이라고 합니다.

person := Person{Name: "Alice", Age: 30}
sayHello := person.SayHello
sayHello()  // "Hello, I'm Alice" 출력

Method Values를 생성할 때, 현재 리시버의 상태가 캡처됩니다. 이후 원본 객체가 변경되어도 메서드 값에 저장된 리시버는 영향을 받지 않습니다.

6. 인터페이스와 다형성 Go의 인터페이스를 통해 다양한 타입의 객체에서 메서드를 호출할 수 있습니다. 이는 Go의 다형성을 구현하는 방법입니다.

type Speaker interface {
    Speak() string
}

type Human struct{ Name string }
type Cat struct{ Name string }

func (h Human) Speak() string { return h.Name + " says: Hello!" }
func (c Cat) Speak() string   { return c.Name + " says: Meow!" }

func MakeTalk(s Speaker) func() {
    return s.Speak
}

func main() {
    human := Human{Name: "John"}
    cat := Cat{Name: "Whiskers"}

    humanTalk := MakeTalk(human)
    catTalk := MakeTalk(cat)

    fmt.Println(humanTalk()) // "John says: Hello!" 출력
    fmt.Println(catTalk()) // "Whiskers says: Meow!" 출력
}

(이번 포스팅에서는 자세한 내용은 다루지 않겠지만, 간략하게 설명해놓겠습니다.)

이 예제에서 주목할 점은 Human과 Cat 구조체가 Speaker 인터페이스를 명시적으로 구현한다고 선언하지 않았다는 것입니다. Go에서는 타입이 인터페이스에 정의된 모든 메서드를 구현하기만 하면, 자동으로 해당 인터페이스를 구현한 것으로 간주합니다. Go에서는 암시적 인터페이스 구현 방식을 사용하기 때문입니다.

따라서 Human과 Cat 구조체는 각각 Speak 메서드를 구현하고 있기 때문에, Speaker 인터페이스를 구현한 것으로 간주됩니다. 이를 통해 MakeTalk 함수에서 Speaker 인터페이스를 인자로 받을 수 있으며, 각 구조체의 Speak 메서드를 반환하는 함수를 생성할 수 있습니다.

Method Values(메서드 값) 사용시 주의

사용할 때 주의해야 할 몇 가지 사항이 있습니다. 이러한 주의사항을 이해하고 적절히 대응하면 예기치 않은 버그를 방지하고 더 효과적으로 코드를 작성할 수 있습니다.

1. 리시버 타입에 따른 동작 차이

값 리시버와 포인터 리시버 메서드는 메서드 값으로 사용될 때 다르게 동작할 수 있습니다.

type Counter struct {
    count int
}

func (c Counter) Increment() {
    c.count++
}

func (c *Counter) IncrementPointer() {
    c.count++
}

func main() {
    c := Counter{count: 0}
    
    incrementValue := c.Increment
    incrementPointer := c.IncrementPointer
    
    incrementValue()
    incrementPointer()
    
    fmt.Println(c.count) // 출력: 1
}

이 예에서 Increment (값 리시버)는 c의 복사본을 수정하므로 원본 c에 영향을 주지 않습니다.

반면 IncrementPointer (포인터 리시버)는 원본 c를 직접 수정합니다.

2. 메서드 값 생성 시점의 상태 캡처

메서드 값은 생성 시점의 리시버 상태를 캡처합니다. 이후 원본 객체가 변경되어도 메서드 값은 캡처된 상태를 유지합니다.

type Person struct {
    Name string
}

func (p Person) SayHello() {
    fmt.Printf("Hello, I'm %s\n", p.Name)
}

func main() {
    p := Person{Name: "Alice"}
    sayHello := p.SayHello
    
    p.Name = "Bob"
    
    sayHello() // 출력: Hello, I'm Alice
}

sayHello 메서드 값은 "Alice"라는 이름을 캡처했으므로, p.Name이 "Bob"으로 변경된 후에도 "Alice"를 출력합니다.

3. nil 리시버 주의

포인터 리시버 메서드의 경우, nil 리시버에 대해 메서드 값을 생성할 수 있지만 이를 호출하면 패닉이 발생할 수 있습니다.

type NullableInt struct {
	value *int
}

func (n *NullableInt) GetValue() int {
    if n == nil || n.value == nil {
        return 0
    }
    return *n.value
}

func (n *NullableInt) DangerousMethod() {
	println(n.value)
}


func main() {
    var n *NullableInt = nil
    getValue := n.GetValue // 이는 유효합니다

    fmt.Println(getValue()) // 정상 작동: 0 출력

    // 하지만 다음과 같은 경우는 주의해야 합니다:
    var dangerousMethod func()
    dangerousMethod = n.DangerousMethod
    dangerousMethod() // 만약 DangerousMethod가 n.value에 접근하려 한다면 패닉 발생
}

주의사항:

  • 리시버 타입에 따른 동작 차이를 이해해야 합니다.
  • 메서드 값은 생성 시점의 상태를 캡처합니다.
  • nil 리시버에 대한 메서드 호출 시 주의가 필요합니다.

정리

  1. 구조체 (Struct): 클래스 대신 사용되며, 관련 데이터를 그룹화합니다.
  2. 메서드 (Method): 특정 타입에 바인딩된 함수로, 리시버를 통해 정의됩니다.
  3. 값 리시버: 메서드 내에서 리시버의 복사본을 사용하며, 원본 객체를 변경하지 않습니다.
  4. 포인터 리시버: 메서드 내에서 원본 객체를 직접 참조하며, 원본 객체를 변경할 수 있습니다.
  5. 인터페이스와 다형성: 명시적 선언 없이 인터페이스를 구현하여 다형성을 실현합니다.