포인터 임베딩을 통해서 참조 타입을 강제하는 방법
포인터 임베딩을 통해서 참조 타입을 강제한다는 것은 어떤 의미일까요?
“Go에서는 구조체 복사를 원천적으로 막을 수 없기 때문에, Public 래퍼 구조체 내부에 실제 구현체를 담은 private 구조체를 포인터로 임베딩함으로써, 래퍼가 복사되더라도 핵심 구현부는 항상 참조 타입으로 동작하도록 보장하는 패턴”
즉, 구조체 복사를 할 때 내부에 포인터로 임베딩 private 구현체를 감싸서 래퍼는 복사되지만 실제 상태나 로직은 참조로 공유되게 하는 것을 의미합니다.
// 실제 구현 (private)
type serviceImpl struct {
state int
mu sync.Mutex
}
// 복사 가능한 래퍼 (public)
type Service struct {
*serviceImpl // 항상 참조로 동작
}
이렇게 하면 사용자는 Service
를 값처럼 자유롭게 복사할 수 있지만, 내부 구현은 모든 복사본이 같은 인스턴스를 공유하게 됩니다. 구체적인 예시는 밑과 같아요.
// 실제 구현을 담은 private 구조체
type counterImpl struct {
value int
mu sync.Mutex
}
func (c *counterImpl) increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
// Public wrapper/proxy 구조체
type Counter struct {
*counterImpl // private 구조체를 포인터로 임베딩
}
// Public 생성자 - 반드시 포인터 임베딩으로 초기화
func NewCounter() Counter {
return Counter{
counterImpl: &counterImpl{}, // 참조 타입으로 생성
}
}
func (c Counter) Increment() {
c.counterImpl.increment() // 항상 같은 인스턴스에 접근, c.increment()로도 호출이 가능하다.ㅂ
}
여기서 중요한 것은 Public 메서드인 Increment
에서 내부 counterImpl의 increment
를 호출할 때 c.increment()
로도 부를 수 있다는 겁니다. 이는 Go의 메서드 승격 덕분에 가능한데요, counterImpl
의 increment
메서드가 Counter
타입의 메서드로 자동 승격되어 Counter
가 직접 그 메서드를 가진 것 처럼 동작합니다.
만약 인터페이스가 있다면 내부 구현체가 만족하는 인터페이스를 외부 래퍼도 자동으로 만족하게 되겠죠!
위의 예시를 실제로 사용 하면 이런 느낌입니다.
func main() {
counter1 := NewCounter()
counter2 := counter1 // 복사 발생!
counter1.Increment() // counterImpl 인스턴스의 값 증가
counter2.Increment() // 같은 counterImpl 인스턴스의 값 증가
fmt.Println(counter1.value) // 2
fmt.Println(counter2.value) // 2 (같은 값!)
// 함수 인자로 전달해도 마찬가지
processCounter(counter1) // 복사되지만 내부 구현은 공유
}
func processCounter(c Counter) { // 값으로 전달 (복사)
c.Increment() // 원본과 같은 counterImpl에 접근
}
이 외에도 nil 값 초기화 가능, 메모리 효율성 등등 장점은 있으나 모든 곳에서 필요한 것은 아닙니다. 실제로 책에서도 다음과 같이 명시하고 있습니다.
반드시 모든 구조체 타입을 이와 같이 데이터를 보호하도록 만들어야 할 필요는 없다. 다만 프로그래머라면 각 타입의 본질을 이해하고 그에 따라 적절하게 활용할 수 있어야 한다.