Go 포인터 리시버 메서드는 호출되는데 인터페이스는 구현하지 못하는 이유: 메서드 집합과 주소 지정

c.Inc()는 컴파일되는데 c를 인터페이스에 대입하면 오류가 납니다. Go 명세의 메서드 집합과 주소 지정 가능성 규칙으로 차이를 설명하고, 맵 원소·임베딩·메서드 값에서 생기는 경우를 정리합니다.

gomethod-setpointer-receiverinterface

Go에서 포인터 리시버 메서드를 값 변수로 호출하는 코드는 아무 문제 없이 컴파일됩니다. 그런데 같은 변수를 그 메서드를 요구하는 인터페이스에 대입하면 컴파일 오류가 납니다. 메서드는 분명히 있는데 인터페이스를 구현하지 않는다는 것입니다.

두 동작은 명세의 서로 다른 규칙에서 나옵니다. 인터페이스 구현 여부는 메서드 집합으로 정해지고, 값으로 포인터 메서드를 부를 수 있는 것은 주소를 얻을 수 있는 값에만 적용되는 호출 규칙 덕분입니다. 이 글은 이 두 규칙을 나눠서 보고, 그 차이가 맵 원소, 함수 반환값, 임베딩, 메서드 값에서 어떻게 드러나는지 정리합니다.

기준 문서는 2026-10-27에 확인한 Go 언어 명세(Language version go1.27, May 26, 2026)와 Go FAQ, Effective Go입니다. 아래 규칙은 최근 버전에서 생긴 것이 아니라 메서드 집합의 기본 정의에서 나옵니다. 제네릭 타입의 메서드는 다루지 않습니다. 예제에 적은 “컴파일 오류”와 결과는 명세 규칙에서 따라 나오는 것으로 적었고, 이 글을 위해 직접 실행한 결과는 아닙니다. 컴파일러가 출력하는 오류 문구는 명세가 정하지 않으므로 옮기지 않았습니다.

예제 타입

글 전체에서 이 타입을 씁니다.

type Counter struct{ n int }

func (c *Counter) Inc()      { c.n++ }      // 포인터 리시버
func (c Counter) Value() int { return c.n } // 값 리시버

type Incrementer interface{ Inc() }
var c Counter
c.Inc()                // 컴파일됨
var _ Incrementer = c  // 컴파일 오류
var _ Incrementer = &c // 컴파일됨

메서드 집합: T와 *T는 다르다

명세의 “Method sets” 절은 타입마다 메서드 집합이 있고, 그 집합이 해당 타입의 피연산자에서 어떤 메서드를 호출할 수 있는지 정한다고 설명합니다. 정의된 타입에 대한 규칙은 두 줄입니다.

타입 메서드 집합
T 리시버가 T인 메서드
*T (T는 포인터나 인터페이스가 아님) 리시버가 *T 또는 T인 메서드

Counter의 메서드 집합에는 Value만 있고, *Counter의 메서드 집합에는 Inc와 Value가 모두 있습니다. *T의 집합은 T의 집합을 포함하지만 반대는 아닙니다.

인터페이스 구현은 이 집합으로 판단합니다. 명세의 “Interface types” 절에 따르면 메서드만 나열한 인터페이스의 타입 집합은 그 메서드를 모두 구현하는 타입들이고, 메서드 명세 하나의 타입 집합은 메서드 집합에 그 메서드가 들어 있는 모든 비인터페이스 타입입니다. 인터페이스가 아닌 타입 T는 인터페이스의 타입 집합에 속할 때 그 인터페이스를 구현합니다. Inc는 Counter의 메서드 집합에 없으므로 Counter는 Incrementer를 구현하지 않고, *Counter는 구현합니다.

그런데 c.Inc()는 왜 되는가

호출에는 별도의 규칙이 있습니다. 명세의 “Calls” 절은 x.m()이 x의 메서드 집합에 m이 있을 때 유효하다고 정한 뒤, 예외를 하나 둡니다. x가 주소 지정 가능하고 &x의 메서드 집합에 m이 있으면 x.m()은 (&x).m()의 축약입니다. c는 변수이므로 주소를 얻을 수 있고, c.Inc()는 (&c).Inc()로 처리됩니다.

이 축약은 메서드 호출(과 아래에서 볼 메서드 값)에만 있습니다. 인터페이스에 대입할 때는 대입하는 값의 타입이 인터페이스를 구현하는지만 보며, 주소를 대신 얻어 주는 규칙은 없습니다.

반대 방향인 포인터로 값 메서드를 부르는 경우는 메서드 집합만으로 설명됩니다. *Counter의 메서드 집합에 Value가 들어 있기 때문입니다. “Selectors” 절의 규칙대로 T나 *T 타입의 값 x에서 x.f는 T 안의 해당 필드나 메서드를 가리키고, 명세의 예제는 p.M1()(리시버가 T1)을 ((*p).T1).M1()처럼 역참조된 값으로 부르는 식으로 풀어 씁니다.

왜 인터페이스에는 자동 &가 없는가

FAQ의 “Why do T and *T have different method sets?” 항목이 이유를 설명합니다. 인터페이스 값에 포인터 *T가 들어 있으면 메서드 호출은 포인터를 역참조해 값을 얻을 수 있습니다. 하지만 인터페이스 값에 T 값이 들어 있으면 메서드 호출이 포인터를 얻을 안전한 방법이 없습니다. 그렇게 할 수 있다면 메서드가 인터페이스 안에 든 값을 바꿀 수 있게 되는데, 명세는 이를 허용하지 않습니다.

FAQ는 컴파일러가 주소를 얻을 수 있는 경우라도, 메서드가 값을 수정하면 그 변경이 호출한 쪽에서는 사라진다고 덧붙입니다. 예로 드는 코드는 이렇습니다.

var buf bytes.Buffer
io.Copy(buf, os.Stdin) // 허용되지 않음

이것이 허용된다면 표준 입력은 buf가 아니라 buf의 복사본에 쓰입니다. FAQ는 이것이 거의 원하는 동작이 아니기 때문에 언어가 막는다고 설명합니다. bytes.Buffer의 Write는 포인터 리시버이므로 io.Copy(&buf, os.Stdin)처럼 주소를 넘겨야 합니다.

Effective Go는 같은 규칙을 한 문장으로 요약합니다. 값 메서드는 포인터와 값 모두에서 호출할 수 있지만, 포인터 메서드는 포인터에서만 호출할 수 있습니다. 그리고 값이 주소 지정 가능할 때는 언어가 주소 연산자를 자동으로 넣어 주는 편의 규칙이 있다고 설명합니다. 이 문서의 ByteSlice 예제가 fmt.Fprintf(&b, ...)로 &b를 넘기는 것도 *ByteSlice만 io.Writer를 만족하기 때문입니다.

주소를 얻을 수 없는 값에서는 호출도 안 된다

x.m() 축약은 x가 주소 지정 가능할 때만 적용되므로, 어떤 값이 주소 지정 가능한지가 중요합니다. 명세의 “Address operators” 절은 주소 지정 가능한 피연산자를 다음으로 한정합니다.

  • 변수
  • 포인터 간접 참조(*p)
  • 슬라이스 인덱싱(s[i])
  • 주소 지정 가능한 구조체 피연산자의 필드 선택자
  • 주소 지정 가능한 배열의 인덱싱

이 목록에 없는 대표적인 값이 맵 원소, 함수 호출 결과, 복합 리터럴입니다.

func newCounter() Counter { return Counter{} } // 패키지 수준 함수
s := []Counter{{}}
s[0].Inc() // 컴파일됨: 슬라이스 원소는 주소 지정 가능

m := map[string]Counter{"a": {}}
m["a"].Inc()   // 컴파일 오류
m["a"].Value() // 컴파일됨: 값 리시버라 주소가 필요 없음

newCounter().Inc() // 컴파일 오류

Counter{}.Inc()    // 컴파일 오류
(&Counter{}).Inc() // 컴파일됨

맵 원소에 대해서는 명세가 “주소를 얻을 수 없다”고 한 문장으로 적지는 않습니다. 다만 주소 지정 가능한 피연산자 목록에 맵 인덱싱이 없고, 대입문 절도 왼쪽 피연산자를 “주소 지정 가능한 것, 맵 인덱스 식, 또는 빈 식별자”로 따로 나열합니다. 맵 원소는 대입할 수는 있어도 주소 지정 가능한 피연산자는 아니라는 것이 이 두 규칙에서 나오는 결론입니다.

함수 반환값은 명세의 메서드 값 예제에 직접 나옵니다. makeT().Mp는 makeT()의 결과가 주소 지정 가능하지 않으므로 유효하지 않다고 적혀 있습니다. 호출 newCounter().Inc()도 같은 조건을 쓰므로 같은 이유로 막힙니다.

복합 리터럴은 조금 다릅니다. “Address operators” 절은 주소 지정 요건의 예외로 & 연산자의 피연산자에 복합 리터럴을 허용하고, “Composite literals” 절은 그 주소를 얻으면 리터럴 값으로 초기화된 고유한 변수에 대한 포인터가 만들어진다고 설명합니다. 이 예외는 &를 직접 쓸 때의 것이고 리터럴 자체를 주소 지정 가능하게 만들지는 않으므로, Counter{}.Inc()는 축약이 적용되지 않고 (&Counter{}).Inc()는 됩니다. 앞 문장은 명세의 정의를 이렇게 읽은 추론입니다.

맵에 든 값을 포인터 메서드로 바꾸려면 두 가지 방법이 흔합니다.

// 1. 꺼내서 바꾸고 다시 넣기
c := m["a"]
c.Inc()
m["a"] = c

// 2. 처음부터 포인터를 저장하기
pm := map[string]*Counter{"a": {}}
pm["a"].Inc() // pm["a"]는 *Counter 값이므로 그대로 호출됨

두 번째 방법에서 pm["a"]는 여전히 주소 지정 가능하지 않지만, 그 값 자체가 *Counter라서 메서드 집합에 Inc가 있으므로 축약이 필요 없습니다.

임베딩하면 메서드 집합은 어떻게 되나

명세의 “Struct types” 절은 임베딩한 필드에서 승격되는 메서드가 바깥 구조체의 메서드 집합에 들어가는 규칙을 따로 둡니다. 구조체 타입 S와 타입 이름 T에 대해:

  • S가 T를 임베딩하면 S와 *S는 리시버가 T인 승격 메서드를 가집니다. *S는 리시버가 *T인 승격 메서드도 가집니다.
  • S가 *T를 임베딩하면 S와 *S 모두 리시버가 T 또는 *T인 승격 메서드를 가집니다.
type Named struct{ Counter }     // 값으로 임베딩
type NamedPtr struct{ *Counter } // 포인터로 임베딩

var n Named
var _ Incrementer = n  // 컴파일 오류: Named의 메서드 집합에 Inc 없음
var _ Incrementer = &n // 컴파일됨

np := NamedPtr{&Counter{}}
var _ Incrementer = np // 컴파일됨

값으로 임베딩하면 바깥 타입도 T/*T의 구분을 그대로 물려받습니다. 포인터로 임베딩하면 바깥 구조체 값만으로도 포인터 메서드가 집합에 들어옵니다. 대신 NamedPtr{}처럼 임베딩한 포인터가 nil인 상태도 만들 수 있으므로, 어느 쪽을 택할지는 nil 필드를 다룰 준비가 되었는지와 함께 정해야 합니다.

메서드 값과 메서드 식

메서드를 호출하지 않고 함수 값으로 꺼낼 때도 같은 규칙이 적용됩니다. 명세의 “Method values” 절은 호출과 마찬가지로, 주소 지정 가능한 값에서 포인터 리시버 메서드를 참조하면 주소를 자동으로 얻는다고 적습니다(t.Mp는 (&t).Mp와 같음). 포인터에서 값 리시버 메서드를 참조하면 자동으로 역참조합니다(pt.Mv는 (*pt).Mv와 같음).

같은 절은 메서드 값을 만들 때 리시버 식을 평가해 저장하고, 나중에 호출할 때 저장한 복사본을 리시버로 쓴다고 정합니다. 이 규칙을 Counter에 적용하면 다음 결과가 나옵니다.

var c Counter
inc := c.Inc     // (&c).Inc: 포인터 &c가 저장됨
val := c.Value   // 이 시점의 c 값(n == 0)이 복사되어 저장됨
inc()
inc()
fmt.Println(c.Value()) // 2
fmt.Println(val())     // 0

inc는 c의 주소를 들고 있으므로 c를 바꾸고, val은 만들 때 복사한 값을 계속 씁니다. 명세의 예제도 메서드 값을 만든 뒤 *t = 42로 바꾼 값이 저장된 리시버에 영향을 주지 않는다고 보여 줍니다.

메서드 식에서는 자동 변환이 한쪽으로만 됩니다. 명세의 “Method expressions” 절에 따르면 값 리시버 메서드로부터 포인터 리시버를 받는 함수 (*T).Mv를 만들 수 있지만, 포인터 리시버 메서드를 값 리시버 함수로 만드는 T.Mp는 포인터 리시버 메서드가 값 타입의 메서드 집합에 없으므로 허용되지 않습니다. (*Counter).Value는 되고 Counter.Inc는 안 됩니다.

인터페이스를 가리키는 포인터에는 메서드가 없다

메서드 집합 규칙의 두 번째 줄은 T가 포인터도 인터페이스도 아닐 때만 적용됩니다. 명세는 그 밖의 타입은 메서드 집합이 비어 있다고 정하므로, *io.Writer 같은 인터페이스 포인터의 메서드 집합은 비어 있습니다. FAQ의 “When should I use a pointer to an interface?” 항목도 같은 결론입니다. var w io.Writer를 fmt.Fprintf(&w, ...)로 넘기면 컴파일되지 않으며, 구체 타입을 가리키는 포인터는 인터페이스를 만족할 수 있어도 인터페이스를 가리키는 포인터는 (빈 인터페이스에 대입하는 경우를 빼면) 인터페이스를 만족할 수 없습니다. “인터페이스가 포인터를 요구하니 &를 붙이자”는 수정은 대입하는 값이 구체 타입일 때만 맞습니다.

리시버를 고르는 기준

FAQ의 “Should I define methods on values or pointers?” 항목은 리시버가 메서드의 인자처럼 동작하므로, 값과 포인터 중 무엇을 고를지는 함수 인자를 값으로 받을지 포인터로 받을지와 같은 질문이라고 설명합니다. 고려할 점은 다음과 같습니다.

  • 메서드가 리시버를 수정해야 하면 포인터여야 합니다. 슬라이스와 맵은 참조처럼 동작해서 조금 미묘하지만, 메서드에서 슬라이스의 길이를 바꾸려면 여전히 포인터 리시버가 필요합니다.
  • 큰 구조체라면 포인터 리시버가 더 저렴할 수 있습니다.
  • 일관성: 일부 메서드가 포인터 리시버여야 한다면 나머지도 포인터로 맞춰, 타입을 어떻게 쓰든 메서드 집합이 일관되게 합니다.
  • 기본 타입, 슬라이스, 작은 구조체처럼 복사가 싼 값은 메서드 의미상 포인터가 필요하지 않다면 값 리시버가 효율적이고 명확합니다.

세 번째 항목이 이 글의 문제와 직접 연결됩니다. Counter처럼 리시버를 섞으면 Counter와 *Counter의 메서드 집합이 달라지고, 어떤 인터페이스는 *Counter만 구현하게 됩니다.

타입이 의도한 인터페이스를 구현하는지는 FAQ가 소개하는 방식으로 컴파일 시점에 확인할 수 있습니다.

var _ Incrementer = (*Counter)(nil) // *Counter가 Incrementer를 구현하는지 확인

Counter 값으로 구현해야 한다면 var _ Incrementer = Counter{}로 적습니다. 이 줄이 컴파일되지 않으면 구현이 빠진 것입니다.

정리

  • T의 메서드 집합은 리시버가 T인 메서드, *T의 메서드 집합은 리시버가 T나 *T인 메서드입니다. 인터페이스 구현은 이 집합으로 판단합니다.
  • 값 변수로 포인터 메서드를 부를 수 있는 것은 x가 주소 지정 가능할 때의 호출 축약 (&x).m() 덕분이고, 인터페이스 대입에는 이런 축약이 없습니다.
  • 맵 원소, 함수 반환값, 복합 리터럴 자체는 주소 지정 가능하지 않으므로 그 위에서 포인터 메서드를 부를 수 없습니다. 맵에는 포인터를 저장하거나 꺼내서 바꾼 뒤 다시 넣습니다.
  • 임베딩은 T로 하면 구분을 물려받고, *T로 하면 바깥 값도 포인터 메서드를 가집니다.
  • 인터페이스를 가리키는 포인터의 메서드 집합은 비어 있습니다.

인터페이스에 nil 포인터를 담았을 때 생기는 다른 문제는 Go에서 nil 포인터를 담은 error가 nil이 아닌 이유에서 다뤘습니다.

출처

모두 2026-10-27에 확인했습니다.

  • The Go Programming Language Specification (Language version go1.27, May 26, 2026): https://go.dev/ref/spec (“Method sets”, “Interface types”, “Struct types”, “Selectors”, “Calls”, “Address operators”, “Composite literals”, “Assignment statements”, “Method values”, “Method expressions”)
  • Go FAQ: https://go.dev/doc/faq (“Why do T and *T have different method sets?”, “Should I define methods on values or pointers?”, “When should I use a pointer to an interface?”, “How can I guarantee my type satisfies an interface?”)
  • Effective Go, “Pointers vs. Values”: https://go.dev/doc/effective_go (2009년 출시 시점에 쓰였고 적극적으로 갱신되지 않는다고 문서에 적혀 있음)

블로그로