메서드와 리시버
이 챕터에서 다루는 것
Go에는 클래스가 없다. 대신 어떤 타입에든 함수를 붙일 수 있다. 그렇게 붙인 함수를 메서드(method)라고 한다. 여기서는 메서드 선언 문법, 값 리시버와 포인터 리시버의 선택 기준, 그리고 그 선택이 만들어 내는 메서드 집합(method set) 을 다룬다.
문제 — 함수만으로도 되는데 왜 메서드인가
3-7의 재고 프로그램을 떠올려 보자. 함수들이 전부 이렇게 생겼다.
func add(inv *Inventory, p Product) error
func adjust(inv *Inventory, sku string, delta int) error
func remove(inv *Inventory, sku string) error
첫 인자가 매번 *Inventory다. 이런 함수들은 "Inventory가 할 수 있는 일"이지
"우연히 Inventory를 받는 함수"가 아니다. 메서드는 그 사실을 문법으로 드러낸다.
func (inv *Inventory) Add(p Product) error
얻는 것은 세 가지다.
- 이름 공간이 타입에 붙는다. 패키지 최상위에
add,adjust가 흩어지지 않는다. 서로 다른 타입이 같은 이름의 메서드를 가질 수 있다. - 호출부가 읽힌다.
inv.Add(p)는add(inv, p)보다 주어가 분명하다. - 인터페이스를 만족시킬 수 있다. 이게 진짜 이유다. Go에서 인터페이스는 메서드 목록이고, 메서드가 없는 타입은 어떤 인터페이스도 만족시키지 못한다. 4-3부터의 모든 이야기가 여기서 출발한다.
메서드 선언
함수 선언에서 func와 이름 사이에 리시버(receiver) 하나가 끼어드는 것이 전부다.
func (리시버이름 리시버타입) 메서드이름(매개변수) 반환값 { ... }
package main
import "fmt"
// Celsius는 float64를 바탕으로 하는 정의된 타입이다.
// 구조체가 아니어도 정의된 타입이면 메서드를 붙일 수 있다.
type Celsius float64
// String은 값 리시버다. 온도를 고칠 일이 없다.
// float64(c)로 변환하지 않으면 String이 자기 자신을 다시 부른다.
func (c Celsius) String() string {
return fmt.Sprintf("%.1f°C", float64(c))
}
// Add는 새 값을 돌려준다. 리시버를 고치지 않는다.
func (c Celsius) Add(d Celsius) Celsius {
return c + d
}
// Counter는 상태를 들고 있다.
type Counter struct {
n int
}
// Inc는 포인터 리시버다. 리시버를 고쳐야 한다.
func (c *Counter) Inc() {
c.n++
}
// Value는 고칠 필요가 없지만 Inc와 리시버 종류를 맞췄다.
func (c *Counter) Value() int {
return c.n
}
// brokenCounter는 일부러 값 리시버를 쓴 예다.
type brokenCounter struct {
n int
}
// incByValue는 리시버의 복사본을 고치므로 호출자에게 아무 일도 일어나지 않는다.
func (c brokenCounter) incByValue() {
c.n++
}
func main() {
t := Celsius(21.5)
fmt.Println(t)
fmt.Println(t.Add(3))
fmt.Println("원본:", t)
var c Counter
c.Inc()
c.Inc()
fmt.Println("Counter:", c.Value())
// c는 변수라서 컴파일러가 c.Inc()를 (&c).Inc()로 바꿔 준다.
pc := &c
pc.Inc()
fmt.Println("포인터로 호출:", pc.Value(), "/ 같은 대상:", c.n)
var b brokenCounter
b.incByValue()
b.incByValue()
fmt.Println("값 리시버로 증가 시도:", b.n)
// 메서드 값 — 리시버가 이 시점에 묶인다.
inc := c.Inc
inc()
fmt.Println("메서드 값 호출 후:", c.Value())
// 메서드 표현식 — 리시버를 첫 인자로 받는 보통 함수가 된다.
add := Celsius.Add
fmt.Println("메서드 표현식:", add(Celsius(10), Celsius(5)))
}
go run ./01-methods
21.5°C
24.5°C
원본: 21.5°C
Counter: 2
포인터로 호출: 3 / 같은 대상: 3
값 리시버로 증가 시도: 0
메서드 값 호출 후: 4
메서드 표현식: 15.0°C
읽어야 할 것
구조체가 아니어도 된다. Celsius는 float64다. Go에서 메서드를 붙일 수 있는 것은
같은 패키지에서 선언한 정의된 타입이면 무엇이든이다
(3-5에서 본 type UserID int 같은 것).
type Handler func(string) error처럼 함수 타입에도 붙는다. Java나 C#에서 원시 타입에
메서드를 못 붙여 유틸리티 클래스를 만들던 습관은 여기서는 필요 없다.
String()은 특별 대우를 받는다. fmt.Println(t)가 21.5°C를 출력한 이유다.
fmt 패키지는 값이 String() string 메서드를 가지고 있으면 그것을 부른다. 이것이
Go에서 인터페이스가 동작하는 방식이고 4-3의 주제다.
:::danger String() 안에서 자기 자신을 포맷하지 않는다
func (c Celsius) String() string {
return fmt.Sprintf("%.1f°C", c) // 무한 재귀
}
%.1f에 Celsius를 넘기면 fmt가 다시 String()을 부른다. 스택이 넘칠 때까지.
float64(c)로 바탕 타입으로 변환하는 것이 정석이다. %v, %s도 마찬가지다.
:::
brokenCounter가 0인 이유. 리시버도 매개변수일 뿐이고, Go의 매개변수는 예외 없이
값 복사다(2-5). c.n++는 복사본을 고쳤다.
컴파일 에러도 경고도 없다 — 조용히 아무 일도 일어나지 않는다. 이것이 리시버 선택을
틀렸을 때 나는 가장 흔한 증상이다.
메서드 값과 메서드 표현식. c.Inc는 리시버가 묶인 함수 값이고(func() 타입),
Celsius.Add는 리시버를 첫 인자로 받는 함수다(func(Celsius, Celsius) Celsius 타입).
전자는 콜백을 넘길 때 종종 쓴다. 후자는 실무에서 볼 일이 드물지만, 메서드가
"첫 인자가 리시버인 함수"라는 사실을 눈으로 확인시켜 준다.
자동 주소와 자동 역참조
Go는 호출부에서 &와 *를 대신 붙여 준다.
| 값이 | 메서드가 | 결과 |
|---|---|---|
c Counter (변수) | func (c *Counter) | (&c).Inc()로 자동 변환 |
pc *Counter | func (c Counter) | (*pc).Value()로 자동 변환 |
덕분에 리시버 종류를 바꿔도 호출부를 고칠 일이 거의 없다. 하지만 자동 주소 변환에는 조건이 하나 있다. 그 값의 주소를 얻을 수 있어야 한다.
메서드 집합
타입 T의 메서드 집합은 리시버가 T인 메서드 전부다.
타입 *T의 메서드 집합은 리시버가 T이거나 *T인 메서드 전부다.
즉 *T가 더 넓다. 값 리시버 메서드는 포인터로도 부를 수 있지만, 그 반대는 자동 변환이
가능할 때만 된다.
package main
import "fmt"
type Buffer struct {
data []string
}
// Add는 포인터 리시버, Len은 값 리시버다.
// 한 타입에 이렇게 섞는 것은 나쁜 예시지만, 메서드 집합을 보이기 위해 일부러 섞었다.
func (b *Buffer) Add(s string) { b.data = append(b.data, s) }
func (b Buffer) Len() int { return len(b.data) }
// Sink는 4-3에서 본격적으로 다룬다. 여기서는 메서드 집합 확인용이다.
type Sink interface {
Add(string)
}
func main() {
b := Buffer{}
b.Add("a") // b가 변수이므로 (&b).Add("a")로 자동 변환된다
fmt.Println("Len:", b.Len(), b.data)
var s Sink = &b // *Buffer의 메서드 집합에는 Add가 있다
s.Add("b")
fmt.Println("인터페이스 경유:", b.Len(), b.data)
// var bad Sink = b
// 컴파일 에러: Buffer does not implement Sink
// (method Add has pointer receiver)
m := map[string]Buffer{"x": {}}
// m["x"].Add("c")
// 컴파일 에러: cannot call pointer method Add on Buffer
// 맵 요소는 주소를 얻을 수 없어 자동 주소 변환이 일어나지 못한다.
fmt.Println("값 맵의 값 리시버 메서드:", m["x"].Len())
// 값 맵을 고치려면 꺼내서 고치고 다시 넣는다.
tmp := m["x"]
tmp.Add("c")
m["x"] = tmp
fmt.Println("꺼내서 고치고 다시 넣기:", m["x"].Len(), m["x"].data)
// 포인터 맵이면 그냥 된다. 맵 요소가 아니라 그것이 가리키는 대상을 고치기 때문이다.
pm := map[string]*Buffer{"x": {}}
pm["x"].Add("c")
fmt.Println("포인터 맵:", pm["x"].Len(), pm["x"].data)
// 함수 반환값도 주소를 얻을 수 없다.
// newBuffer().Add("d") // 컴파일 에러
nb := newBuffer()
nb.Add("d")
fmt.Println("변수에 담은 뒤:", nb.Len(), nb.data)
}
func newBuffer() Buffer {
return Buffer{}
}
go run ./01-method-sets
Len: 1 [a]
인터페이스 경유: 2 [a b]
값 맵의 값 리시버 메서드: 0
꺼내서 고치고 다시 넣기: 1 [c]
포인터 맵: 1 [c]
변수에 담은 뒤: 1 [d]
왜 이런 규칙인가
메서드 집합이 비대칭인 이유는 자동 변환의 비대칭 때문이다. *T에서 T를 얻는
것은 언제나 가능하다 — 역참조하면 된다. 반대로 T에서 *T를 얻으려면 그 값이
어딘가에 저장돼 있어야 한다. 주소를 가리킬 대상이 없으면 불가능하다.
주소를 얻을 수 없는 것들은 3-5에서 이미 봤다.
맵 요소, 함수 반환값, 리터럴, 인터페이스에 담긴 값이다. 이 자리에서는
b.Add("a")처럼 무심코 쓰던 자동 변환이 갑자기 컴파일 에러가 된다.
var bad Sink = b가 안 되는 이유도 같다. 인터페이스에 값을 담는 순간 그 값은
복사된다. 복사본에는 원본과 다른 주소가 생기므로, 여기서 자동으로 &를 붙여 주면
s.Add("b")가 아무도 볼 수 없는 복사본을 고치게 된다. Go는 그런 조용한 사고를
허용하는 대신 컴파일 에러를 낸다. 에러 메시지도 친절하다.
cannot use b (variable of struct type Buffer) as Sink value in variable declaration:
Buffer does not implement Sink (method Add has pointer receiver)
:::tip 실무 감각
이 에러는 Go를 시작하고 첫 주에 반드시 만난다. 답은 거의 항상 &를 붙이는 것이다.
&b, 또는 애초에 생성자가 *Buffer를 반환하게 하는 것. newT()가 *T를 돌려주는
관례(3-7)가 이 문제를 미리 없애 준다.
:::
값 리시버냐 포인터 리시버냐
3-5에서 세운 세 기준이 그대로 적용된다.
고쳐야 하는가 / 복사가 비싼가 / nil이 의미를 갖는가. 여기에 메서드 고유의
기준 하나가 더 붙는다.
4. 일관성. 한 타입의 메서드는 전부 값이거나 전부 포인터여야 한다.
이 네 번째가 실무에서는 가장 자주 결론을 뒤집는다. 순서는 이렇다.
- 이 타입의 메서드 중 하나라도 리시버를 고쳐야 하는가? → 그렇다면 전부 포인터.
- 아니라면 복사가 비싼가,
nil이 의미를 갖는가? → 그렇다면 전부 포인터. - 셋 다 아니면 전부 값.
Counter.Value가 포인터 리시버인 이유가 1번이다. 고칠 필요가 없는데도 Inc에 맞췄다.
왜 섞으면 안 되는가
세 가지가 동시에 나빠진다.
첫째, 메서드 집합이 쪼개진다. 위 Buffer가 그 예다. Buffer의 메서드 집합에는
Len만 있고 Add는 없다. 그래서 Buffer 값은 Sink를 만족하지 못하는데, 코드를
읽는 사람은 Add가 선언된 것을 보고 당연히 될 거라고 생각한다.
둘째, 복사 안전성이 무너진다. 값 리시버 메서드가 하나라도 있으면 "이 타입은 복사해도 되는구나"라는 신호가 된다. 그런데 포인터 리시버 메서드가 상태를 고치고 있다면, 복사본과 원본이 갈라지는 순간 버그가 난다.
셋째, 호출부가 &를 언제 붙여야 할지 알 수 없어진다. 자동 변환이 되는 자리와
안 되는 자리가 섞이고, 그 경계가 "이 값이 addressable인가"라는 미묘한 조건이다.
:::info 표준 라이브러리에도 예외는 있다
time.Time은 전부 값 리시버, bytes.Buffer는 전부 포인터 리시버다. 반면
sync.Mutex처럼 복사되면 안 되는 것을 품은 타입은 값 리시버를 아예 두지 않는다.
잠금 상태까지 복사되기 때문이다. go vet의 copylocks 분석기가
passes lock by value로 잡아 준다 — Part 7에서 다룬다.
:::
재고 프로그램을 메서드로 정리하기
3-7의 마지막 연습문제가 물었던 것이다. 어느 함수가 메서드가 되고 어느 것이 함수로 남는가?
기준은 하나다. 그 타입의 상태를 다루는가. add/adjust/remove/snapshot은
*Inventory를 받아 그 내부를 읽거나 고친다 → 메서드. totalValue/lowStock/
countByPrefix는 []Product만 받고 Inventory를 모른다 → 함수로 남는다.
package main
import (
"errors"
"fmt"
"maps"
"slices"
)
// Product는 3-7과 같다. 작고 값 의미론이 자연스럽다.
type Product struct {
SKU string
Name string
Price int
Qty int
}
type Inventory struct {
items map[string]*Product
log []string
}
var (
errDuplicate = errors.New("이미 등록된 SKU")
errNotFound = errors.New("등록되지 않은 SKU")
errNegative = errors.New("수량이 음수가 된다")
)
func newInventory() *Inventory {
return &Inventory{
items: make(map[string]*Product),
log: make([]string, 0, 16),
}
}
// 상태를 고치는 세 연산은 포인터 리시버 메서드가 된다.
func (inv *Inventory) Add(p Product) error {
if _, ok := inv.items[p.SKU]; ok {
return fmt.Errorf("add %s: %w", p.SKU, errDuplicate)
}
stored := p
inv.items[p.SKU] = &stored
inv.log = append(inv.log, fmt.Sprintf("add %s x%d", p.SKU, p.Qty))
return nil
}
func (inv *Inventory) Adjust(sku string, delta int) error {
p, ok := inv.items[sku]
if !ok {
return fmt.Errorf("adjust %s: %w", sku, errNotFound)
}
if p.Qty+delta < 0 {
return fmt.Errorf("adjust %s (%d%+d): %w", sku, p.Qty, delta, errNegative)
}
p.Qty += delta
inv.log = append(inv.log, fmt.Sprintf("adjust %s %+d", sku, delta))
return nil
}
func (inv *Inventory) Remove(sku string) error {
if _, ok := inv.items[sku]; !ok {
return fmt.Errorf("remove %s: %w", sku, errNotFound)
}
delete(inv.items, sku)
inv.log = append(inv.log, "remove "+sku)
return nil
}
// Snapshot은 읽기만 하지만 포인터 리시버다. 이 타입의 메서드는 전부 포인터로 맞췄다.
func (inv *Inventory) Snapshot() []Product {
out := make([]Product, 0, len(inv.items))
for _, sku := range slices.Sorted(maps.Keys(inv.items)) {
out = append(out, *inv.items[sku])
}
return out
}
// totalValue와 lowStock은 Inventory를 건드리지 않는다. 메서드로 만들 이유가 없다.
func totalValue(items []Product) int {
total := 0
for _, p := range items {
total += p.Price * p.Qty
}
return total
}
func lowStock(items []Product, threshold int) []Product {
var out []Product
for _, p := range items {
if p.Qty < threshold {
out = append(out, p)
}
}
return out
}
func main() {
inv := newInventory()
seed := []Product{
{SKU: "FR-001", Name: "사과", Price: 1500, Qty: 20},
{SKU: "FR-002", Name: "바나나", Price: 900, Qty: 5},
{SKU: "VG-001", Name: "당근", Price: 700, Qty: 40},
}
for _, p := range seed {
if err := inv.Add(p); err != nil {
fmt.Println("초기화 실패:", err)
return
}
}
if err := inv.Adjust("FR-002", 15); err != nil {
fmt.Println("경고:", err)
}
if err := inv.Adjust("XX-999", 1); err != nil {
fmt.Println("경고:", err)
}
if err := inv.Remove("FR-001"); err != nil {
fmt.Println("경고:", err)
}
items := inv.Snapshot()
for _, p := range items {
fmt.Printf("%s %s x%d\n", p.SKU, p.Name, p.Qty)
}
fmt.Println("평가액:", totalValue(items))
fmt.Println("재고 부족:", lowStock(items, 10))
}
go run ./01-inventory-methods
경고: adjust XX-999: 등록되지 않은 SKU
FR-002 바나나 x20
VG-001 당근 x40
평가액: 46000
재고 부족: []
totalValue를 굳이 메서드로 만들고 싶다면 func (inv *Inventory) TotalValue() int가
되겠지만, 그러면 "스냅숏의 평가액"을 구할 수 없다. 입력이 Inventory가 아니라
[]Product인 함수는 그대로 두는 편이 재사용 가능하다. 어떤 타입에 메서드를 붙일지는
"이 동작이 누구 것인가"를 정하는 설계 결정이지, 문법 정리가 아니다.
흔히 하는 실수
1. 값 리시버로 상태를 고치려 한다
func (c Counter) Inc() { c.n++ } // 아무 일도 일어나지 않는다
컴파일도 되고 go vet도 조용하다. 증상은 "값이 안 늘어난다"뿐이다. 리시버가 값인지
포인터인지부터 확인하는 습관이 필요하다.
2. 한 타입에 두 종류 리시버를 섞는다
go vet은 잡지 않지만 golangci-lint의 일부 린터가 잡는다. 무엇보다 사람이 잡아야
한다. 하나라도 포인터가 필요하면 전부 포인터로 통일한다.
3. nil 리시버를 고려하지 않는다
포인터 리시버 메서드는 리시버가 nil이어도 호출 자체는 된다. 역참조하는 순간
패닉이다.
var inv *Inventory
inv.Remove("X") // 호출은 된다. 안에서 inv.items를 읽는 순간 패닉
반대로 이 성질을 의도적으로 쓰기도 한다. nil을 "빈 값"으로 취급하는 메서드다.
func (inv *Inventory) Len() int {
if inv == nil {
return 0
}
return len(inv.items)
}
표준 라이브러리에도 이런 것이 있다 — (*bytes.Buffer)(nil)은 아니지만,
net/http의 몇몇 타입과 트리 자료 구조에서 흔한 패턴이다. 쓸 거면 문서에 적어야
한다. 적지 않으면 호출자가 알 방법이 없다.
4. 다른 패키지의 타입에 메서드를 붙이려 한다
func (t time.Time) Korean() string { ... }
// 컴파일 에러: cannot define new methods on non-local type time.Time
Go는 이것을 금지한다. C#의 확장 메서드, Kotlin의 확장 함수, Ruby의 몽키패칭에 해당하는 것이 없다. 한 타입의 메서드 전부가 한 패키지 안에 있다는 보장을 지키기 위해서다. 필요하면 감싸는 정의된 타입을 만든다.
type Timestamp time.Time
func (t Timestamp) Korean() string { ... }
또는 임베딩을 쓴다. 다음 챕터의 주제다.
5. 리시버 이름을 this나 self로 짓는다
Go의 관례는 타입 이름의 첫 글자 한두 글자다. Counter → c, Inventory → inv,
bytes.Buffer → b. 한 타입의 모든 메서드에서 같은 이름을 쓴다.
func (c *Counter) Inc() // 좋다
func (this *Counter) Inc() // Go 코드로 읽히지 않는다
func (counter *Counter) Inc() // 길다
리시버 이름이 짧아도 되는 이유는 스코프가 메서드 하나뿐이고, 무엇을 가리키는지 시그니처에 이미 적혀 있기 때문이다.
정리
- 메서드는 리시버가 붙은 함수다. 같은 패키지에서 선언한 정의된 타입이면 구조체가 아니어도 붙일 수 있다.
- 리시버도 매개변수다. 값 리시버는 복사본을 받는다.
T의 메서드 집합은 값 리시버 메서드만,*T의 메서드 집합은 둘 다다.- 컴파일러가
&와*를 자동으로 붙여 주지만, 주소를 얻을 수 있는 값에만 그렇다. 맵 요소, 함수 반환값, 인터페이스에 담긴 값은 안 된다. - 판단 기준: 고쳐야 하는가 / 복사가 비싼가 /
nil이 의미를 갖는가 / 일관성. 하나라도 포인터가 필요하면 전부 포인터. - 리시버 이름은 타입 이름의 첫 글자.
this,self는 쓰지 않는다. - 다른 패키지의 타입에는 메서드를 붙일 수 없다.
연습문제
-
type StringSet map[string]struct{}에Add,Has,Len,Sorted() []string메서드를 붙여 보자. 어느 것이 값 리시버여야 하고 어느 것이 포인터여야 하는가? 힌트: 맵은 이미 참조 성격이다.Add가 값 리시버여도 동작하는데, 그래도 되는가?Clear를 추가하려 하면 답이 달라지는가? -
Buffer예제의Len을 포인터 리시버로 바꾸고,m["x"].Len()이 컴파일되는지 확인해 보자. 그다음Sink인터페이스에Len() int를 추가하고var s Sink = b가 왜 여전히 안 되는지 에러 메시지로 확인해 보자. -
위 재고 프로그램에
func (inv *Inventory) String() string을 붙여fmt.Println(inv)가 표를 출력하게 만들어 보자.inv가nil일 때도 패닉이 나지 않게 하려면 어디에 무엇을 넣어야 하는가?