Go에서 value receiver와 pointer receiver의 차이

Go에서 value receiver와 pointer receiver의 차이

Created
Apr 30, 2024 12:09 AM
Tags
go
최근 관성적으로 value receiver를 사용하고 있는데, pointer receiver와 어떤 차이인지 좀 더 제대로 알아야 겠다는 생각에서 정리를 해봤다.
 

Value Receiver라면 동작하지 않는 코드

type Authenticator struct { APIKeys []string } func NewAPIAuth(keys []string) Authenticator { return Authenticator{ APIKeys: keys, } } func (a Authenticator) Authenticate(key string) bool { for _, k := range a.APIKeys { if k == key { return true } } return false } func (a Authenticator) Set(keys []string) { a.APIKeys = keys }
 
이런 코드가 있고, API Key가 새로 발급된다거나 삭제될 경우를 Authenthicator.Set 메서드를 실행해 APIKey 배열을 업데이트한다고 하자.
(물론 이렇게 setter를 가지는 코드는 좋은 코드가 아닐 수 있다)
 
이 코드는 정상 동작하지 않는다.
이유는 value receiver를 사용하기 때문이다.
 
서버가 실행되는 동안 Set 메서드로 keys를 변경하여도 변경은 반영되지 않고, 기존 Key가 그대로 사용된다.
VS Code에서 아래와 같이 노란 밑줄과 함께 에러 메시지도 확인 가능하다.
 
notion image
 

수정된 코드

type Authenticator struct { APIKeys []string } func NewAuthenticator(keys []string) *Authenticator { return &Authenticator{ APIKeys: keys, } } func (a *Authenticator) Do(key string) bool { for _, k := range a.APIKeys { if k == key { return true } } return false } func (a *Authenticator) Set(keys []string) { a.APIKeys = keys }
 
value receiver 대신 pointer receiver로 변경해주면, API Key가 정상 추가된다. 서버가 실행되고 있어도 Set 메서드를 통해 새 APIKey 배열을 재할당할 수 있다.
 

Value Receiver vs Pointer Receiver

Value Receiver

value receiver는 오리지널 값의 복사본을 바탕으로 실행되는 메서드를 말한다. 메서드는 리시버의 인스턴스로 복사본을 받으며, 리시버를 변경하려 하더라도 복사본이므로 변경이 되지 않는다.
 
다음과 같은 경우에 사용하면 유용하다.
  • 메서드들이 리시버의 데이터를 변경하지 않음
  • 리시버가 가지고 있는 데이터가 비교적 작아 복사가 잦게 발생하더라도 비용이 크지 않음
  • 메서드가 리시버 자체를 변경하는 일이 없음
 

Pointer Receiver

pointer receiver는 리시버의 메모리 주소를 바탕으로 동작하며, 리시버 자체를 변경할 수 있다. 리시버를 업데이트하는 메서드가 필요한 경우 유용하다.
 
다음과 같은 경우에 사용하면 유용하다.
  • 메서드가 리시버를 변경해야 하는 경우가 있음
  • 리시버가 매우 큰 데이터를 가지고 있어 잦은 복사가 일어날 경우 비용이 큼
  • 리시버 내부의 변경에 대해 리시버 외부에서 확인 가능하게 하고 싶은 경우
 

성능상의 차이는 없을까?

Go에서 포인터 리시버를 사용할 경우 heap memory에 인스턴스가 할당될 수 있다. 예를들어 위 코드처럼 NewAuthenticator 함수를 사용한다고 하면 이 함수는 pointer를 반환하고 종료하기 때문에 heap에 할당된다.
 
heap memory의 경우 stack과는 다르게 메모리에서 해제되기 위해 garbage collection이 필요하다. Garbage collection은 아무리 효율적인 알고리즘을 이용하더라도 일시적인 stop-the-world를 발생시킨다.
 
따라서 이런 pointer receiver를 남용할 경우, 잠재적인 비효율이 있을 수 있다.
 
반면 value receiver는 전역변수에 할당된다거나 로컬 스코프를 탈출하는 다른 함수에 넘겨지는 경우가 아니라면 heap이 아닌 stack에 할당된다. 그리고 호출하는 함수의 사용이 끝나면 stack에서 함께 제거된다.
 
따라서 이런 요소를 고려해 본다면, 구조체의 값 복사로 인한 메모리 증가가 비교적 작은 상황이고, 구조체 내부의 데이터 변경이 불필요한 상황이라면 value receiver가 pointer receiver보다 효율적일 수 있다. 다만, 위에서 확인해본 것처럼 value receiver라고 해서 항상 stack에 할당되는 것은 아니며, 케이스에 따라서는 heap에 할당되고 GC가 필요할 수도 있는 만큼, 꼭 성능 비교가 필요하다면, Go 컴파일러의 escape analycis를 해보는 게 좋다.
 
아래와 같은 명령어로 escape analysis를 해볼 수 있다.
// run escape analysis go build -gcflags='-m' auth/authenticator //value receiver auth/authenticator.go:9:6: can inline NewAPIAuth auth/authenticator.go:16:6: can inline Authenticator.Authenticate auth/authenticator.go:26:6: can inline Authenticator.Set <autogenerated>:1: inlining call to Authenticator.Authenticate <autogenerated>:1: inlining call to Authenticator.Set auth/authenticator.go:9:17: leaking param: keys to result ~r0 level=0 auth/authenticator.go:16:7: a does not escape auth/authenticator.go:16:37: key does not escape auth/authenticator.go:26:7: a does not escape auth/authenticator.go:26:28: keys does not escape // pointer receiver result auth/authenticator.go:9:6: can inline NewAPIAuth auth/authenticator.go:16:6: can inline (*Authenticator).Authenticate auth/authenticator.go:26:6: can inline (*Authenticator).Set auth/authenticator.go:9:17: leaking param: keys auth/authenticator.go:10:9: &Authenticator{...} escapes to heap auth/authenticator.go:16:7: a does not escape auth/authenticator.go:16:38: key does not escape auth/authenticator.go:26:7: a does not escape auth/authenticator.go:26:29: leaking param: keys
 
Pointer receiver를 사용할 때 &Authenticator{...} escapes to heap, heap 영역에 할당되는 것을 확인할 수 있다.
 

번외편: 이 코드는 좋은 코드였나?

위 코드에서 구조체는 생성자와 메서드, 그리고 setter를 가지고 있었다. Getter와 Setter를 일반적으로 사용하는 Java 언어와는 달리, Go에서는 Setter가 강제되지 않는다.
 
Setter의 사용은 concurrent한 환경에서 위험할 수도 있는데, race condition을 발생시킬 수도 있기 때문이다.
또,
 
이렇게 Setter를 두는 코드는 장점도, 단점도 있다.
 

Setter 사용의 장점

  • 코드를 캡슐화한다.
  • 유연하게 변경할 수 있다.
 

Setter 사용의 단점

  • concurrency 문제가 발생한다. 서로 다른 스레드에서 Authenticator에 접근할 경우, race condition이 발생할 수 있다.
  • Mutable State로 인해 코드의 가독성이 떨어지고 유지관리가 어려우며 잠재적인 버그의 원인이 된다.
 
 
쓰다보니 장점은 간단히 적었는데, 단점이 장황하다. 그렇다. 나는 개인적으로 setter를 위 방식으로 사용하는게 좋지 않다고 생각한다.
 
코드는 혼자서만 짜는 경우는 드물다. 담당자의 퇴사, 전출, 혹은 여러가지 이유로 한명 이상이 유지보수 하거나, 원래 작성자가 아닌 사람이 코드를 읽고 운영하는 경우가 흔하다.
 
상태를 변경하는 코드, 즉 mutable state는 코드가 복잡해질수록 추적하기 어렵고, 특정 실행 단계에서 그 객체가 어떤 값을 가질지 예측하는 것이 정말 어렵다.
 
따라서 가능하다면 캐싱은 다른 방법으로 하자. 웬만하면 객체는 Immutable하게 관리하는 게 Go를 사용할 때 더 나은 방법이라고 생각한다.
 

결론

  • Value Receiver는 값복사를 이용하므로 setter로 내부 데이터를 변경할 수 없다.
  • setter를 이용해 내부의 데이터를 변경하려면 pointer receiver를 사용해야 한다.
  • Pointer Receiver는 heap 영역에 할당되나, Value Receiver는 stack에 할당될 가능성이 높다. heap은 GC가 필요하다. 잦은 GC는 성능 하락의 원인이 된다.
  • Pointer Receiver는 필요할 때만 사용하고, 웬만하면 객체의 Immutable state를 유지하는 게 좋다.