home

포인터 임베딩을 통해서 참조 타입을 강제하는 방법

포인터 임베딩을 통해서 참조 타입을 강제한다는 것은 어떤 의미일까요?

“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 값 초기화 가능, 메모리 효율성 등등 장점은 있으나 모든 곳에서 필요한 것은 아닙니다. 실제로 책에서도 다음과 같이 명시하고 있습니다.

반드시 모든 구조체 타입을 이와 같이 데이터를 보호하도록 만들어야 할 필요는 없다. 다만 프로그래머라면 각 타입의 본질을 이해하고 그에 따라 적절하게 활용할 수 있어야 한다.