메서드(Method)
Go의 메서드는 특정 타입에 연결된 함수입니다. 클래스가 없는 Go에서 메서드는 구조체 등 사용자 정의 타입에 동작을 추가하는 핵심 수단입니다. 값 리시버와 포인터 리시버의 차이를 명확히 이해하는 것이 Go 개발의 핵심입니다.
메서드 기본 문법
메서드는 함수 선언에 리시버(receiver) 를 추가한 형태입니다. 리시버는 함수명 앞에 (변수명 타입) 형식으로 작성합니다.
package main
import (
"fmt"
"math"
)
type Circle struct {
Radius float64
}
// Circle 타입의 메서드 — 값 리시버
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.Radius
}
func (c Circle) String() string {
return fmt.Sprintf("Circle(r=%.2f)", c.Radius)
}
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
func main() {
c := Circle{Radius: 5}
fmt.Println(c) // Circle(r=5.00)
fmt.Printf("넓이: %.2f\n", c.Area()) // 넓이: 78.54
fmt.Printf("둘레: %.2f\n", c.Perimeter()) // 둘레: 31.42
rect := Rectangle{Width: 4, Height: 6}
fmt.Printf("사각형 넓이: %.2f\n", rect.Area()) // 24.00
fmt.Printf("사각형 둘레: %.2f\n", rect.Perimeter()) // 20.00
}
값 리시버 vs 포인터 리시버
Go 메서드에서 가장 중요한 개념입니다.
값 리시버 (Value Receiver)
- 타입의 복사본 을 받아 동작
- 원본 데이터를 수정할 수 없음
- 작은 구조체나 읽기 전용 메서드에 적합
포인터 리시버 (Pointer Receiver)
- 타입의 포인터 를 받아 동작
- 원본 데이터를 수정할 수 있음
- 대형 구조체 복사 비용 절감
- 상태를 변경하는 메서드에 필수
package main
import "fmt"
type Counter struct {
count int
}
// 값 리시버: 원본을 수정하지 않음
func (c Counter) Value() int {
return c.count
}
// 포인터 리시버: 원본을 수정함
func (c *Counter) Increment() {
c.count++
}
func (c *Counter) Decrement() {
c.count--
}
func (c *Counter) Reset() {
c.count = 0
}
func main() {
c := Counter{}
c.Increment()
c.Increment()
c.Increment()
fmt.Println(c.Value()) // 3
c.Decrement()
fmt.Println(c.Value()) // 2
c.Reset()
fmt.Println(c.Value()) // 0
// 포인터로 선언해도 동일하게 동작
cp := &Counter{}
cp.Increment()
cp.Increment()
fmt.Println(cp.Value()) // 2
}
자동 역참조와 자동 주소 취득
Go는 메서드 호출 시 값/포인터를 자동으로 변환해줍니다.
package main
import "fmt"
type Point struct {
X, Y float64
}
func (p Point) String() string {
return fmt.Sprintf("(%g, %g)", p.X, p.Y)
}
func (p *Point) Scale(factor float64) {
p.X *= factor
p.Y *= factor
}
func main() {
// 값으로 선언된 변수에서 포인터 리시버 메서드 호출
p := Point{X: 3, Y: 4}
p.Scale(2) // Go가 자동으로 (&p).Scale(2)로 변환
fmt.Println(p) // (6, 8)
// 포인터로 선언된 변수에서 값 리시버 메서드 호출
pp := &Point{X: 1, Y: 2}
fmt.Println(pp.String()) // Go가 자동으로 (*pp).String()으로 변환
// 또는 그냥
fmt.Println(pp) // (*pp)가 출력: (1, 2)
// 단, 주소를 취할 수 없는 값(임시값)에는 자동 변환 안됨
// Point{X: 1, Y: 2}.Scale(2) // 컴파일 에러!
}
메서드 셋(Method Set)
메서드 셋은 어떤 타입이 어떤 메서드를 갖는지를 정의합니다. 이는 인터페이스 구현과 직접 연관됩니다.
| 타입 | 사용 가능한 메서드 |
|---|---|
T (값 타입) | 값 리시버 메서드만 |
*T (포인터 타입) | 값 리시버 + 포인터 리시버 메서드 모두 |
package main
import "fmt"
type Animal struct {
Name string
}
// 값 리시버 메서드
func (a Animal) Speak() string {
return a.Name + " says hello"
}
// 포인터 리시버 메서드
func (a *Animal) Rename(name string) {
a.Name = name
}
func main() {
// 값 타입: 값 리시버만 호출 가능 (단, Go가 자동 변환)
dog := Animal{Name: "Rex"}
fmt.Println(dog.Speak()) // Rex says hello
dog.Rename("Max") // Go가 (&dog).Rename("Max")로 자동 변환
fmt.Println(dog.Name) // Max
// 포인터 타입: 모든 메서드 호출 가능
cat := &Animal{Name: "Whiskers"}
fmt.Println(cat.Speak()) // Whiskers says hello
cat.Rename("Luna")
fmt.Println(cat.Name) // Luna
}
다양한 타입에 메서드 추가
구조체뿐 아니라 같은 패키지 내의 어떤 타입에도 메서드를 추가할 수 있습니다.
package main
import (
"fmt"
"strings"
)
// 기본 타입에 직접 메서드 추가는 불가 — 새 타입 정의 필요
type Celsius float64
type Fahrenheit float64
func (c Celsius) ToFahrenheit() Fahrenheit {
return Fahrenheit(c*9/5 + 32)
}
func (f Fahrenheit) ToCelsius() Celsius {
return Celsius((f - 32) * 5 / 9)
}
func (c Celsius) String() string {
return fmt.Sprintf("%.1f°C", float64(c))
}
func (f Fahrenheit) String() string {
return fmt.Sprintf("%.1f°F", float64(f))
}
// 슬라이스 타입에 메서드 추가
type StringSlice []string
func (ss StringSlice) Join(sep string) string {
return strings.Join(ss, sep)
}
func (ss StringSlice) Contains(s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}
func (ss *StringSlice) Append(s string) {
*ss = append(*ss, s)
}
func main() {
boiling := Celsius(100)
fmt.Println(boiling) // 100.0°C
fmt.Println(boiling.ToFahrenheit()) // 212.0°F
bodyTemp := Fahrenheit(98.6)
fmt.Println(bodyTemp.ToCelsius()) // 37.0°C
fruits := StringSlice{"apple", "banana", "cherry"}
fmt.Println(fruits.Join(", ")) // apple, banana, cherry
fmt.Println(fruits.Contains("banana")) // true
fruits.Append("date")
fmt.Println(fruits.Join(", ")) // apple, banana, cherry, date
}
메서드 표현식(Method Expression)
메서드를 함수 값으로 사용하는 두 가지 방법이 있습니다.
package main
import "fmt"
type Calculator struct {
result float64
}
func (c *Calculator) Add(n float64) *Calculator {
c.result += n
return c
}
func (c *Calculator) Multiply(n float64) *Calculator {
c.result *= n
return c
}
func (c Calculator) Result() float64 {
return c.result
}
func main() {
// 메서드 값 (Method Value): 특정 인스턴스에 바인딩된 메서드
calc := &Calculator{result: 10}
addFn := calc.Add // calc에 바인딩된 Add 메서드
addFn(5) // calc.Add(5)와 동일
addFn(3) // calc.Add(3)와 동일
fmt.Println(calc.Result()) // 18
// 메서드 표현식 (Method Expression): 타입에서 메서드를 함수로 추출
// 첫 번째 인자가 리시버
addExpr := (*Calculator).Add
calc2 := &Calculator{result: 0}
addExpr(calc2, 10) // calc2.Add(10)과 동일
addExpr(calc2, 20)
fmt.Println(calc2.Result()) // 30
// 함수형 프로그래밍 패턴
ops := []func(*Calculator) *Calculator{
func(c *Calculator) *Calculator { return c.Add(1) },
func(c *Calculator) *Calculator { return c.Multiply(3) },
func(c *Calculator) *Calculator { return c.Add(5) },
}
calc3 := &Calculator{result: 2}
for _, op := range ops {
op(calc3)
}
fmt.Println(calc3.Result()) // (2+1)*3+5 = 14
}
메서드 체이닝(Method Chaining)
메서드가 리시버를 반환하면 체이닝 스타일로 작성할 수 있습니다.
package main
import (
"fmt"
"strings"
)
type QueryBuilder struct {
table string
conditions []string
orderBy string
limit int
}
func NewQuery(table string) *QueryBuilder {
return &QueryBuilder{table: table, limit: -1}
}
func (q *QueryBuilder) Where(condition string) *QueryBuilder {
q.conditions = append(q.conditions, condition)
return q
}
func (q *QueryBuilder) OrderBy(field string) *QueryBuilder {
q.orderBy = field
return q
}
func (q *QueryBuilder) Limit(n int) *QueryBuilder {
q.limit = n
return q
}
func (q *QueryBuilder) Build() string {
query := fmt.Sprintf("SELECT * FROM %s", q.table)
if len(q.conditions) > 0 {
query += " WHERE " + strings.Join(q.conditions, " AND ")
}
if q.orderBy != "" {
query += " ORDER BY " + q.orderBy
}
if q.limit > 0 {
query += fmt.Sprintf(" LIMIT %d", q.limit)
}
return query
}
func main() {
// 메서드 체이닝으로 쿼리 빌드
query := NewQuery("users").
Where("age > 18").
Where("active = true").
OrderBy("created_at DESC").
Limit(10).
Build()
fmt.Println(query)
// SELECT * FROM users WHERE age > 18 AND active = true ORDER BY created_at DESC LIMIT 10
}
임베딩을 통한 메서드 상속
Go는 클래스 상속 대신 구조체 임베딩(embedding) 으로 메서드를 재사용합니다.
package main
import "fmt"
type Base struct {
ID int
Name string
}
func (b Base) Describe() string {
return fmt.Sprintf("ID: %d, Name: %s", b.ID, b.Name)
}
func (b *Base) SetName(name string) {
b.Name = name
}
type Employee struct {
Base // Base를 임베딩 — Base의 메서드를 모두 사용 가능
Department string
Salary float64
}
func (e Employee) Describe() string {
// Base의 Describe를 명시적으로 호출
return fmt.Sprintf("%s, Department: %s", e.Base.Describe(), e.Department)
}
type Manager struct {
Employee // Employee를 임베딩
Reports []*Employee
}
func (m *Manager) AddReport(e *Employee) {
m.Reports = append(m.Reports, e)
}
func main() {
emp := Employee{
Base: Base{ID: 1, Name: "Alice"},
Department: "Engineering",
Salary: 75000,
}
fmt.Println(emp.Describe()) // ID: 1, Name: Alice, Department: Engineering
emp.SetName("Alicia") // Base의 메서드를 직접 호출
fmt.Println(emp.Name) // Alicia
fmt.Println(emp.Base.Describe()) // ID: 1, Name: Alicia
mgr := Manager{
Employee: Employee{
Base: Base{ID: 2, Name: "Bob"},
Department: "Engineering",
},
}
mgr.AddReport(&emp)
fmt.Printf("%s 팀원 수: %d\n", mgr.Name, len(mgr.Reports))
}
실전 예제: 은행 계좌 시스템
package main
import (
"errors"
"fmt"
"time"
)
type Transaction struct {
Type string
Amount float64
Timestamp time.Time
}
type BankAccount struct {
owner string
balance float64
transactions []Transaction
}
func NewBankAccount(owner string, initialBalance float64) (*BankAccount, error) {
if initialBalance < 0 {
return nil, errors.New("초기 잔액은 0 이상이어야 합니다")
}
return &BankAccount{
owner: owner,
balance: initialBalance,
}, nil
}
func (a *BankAccount) Deposit(amount float64) error {
if amount <= 0 {
return errors.New("입금액은 0보다 커야 합니다")
}
a.balance += amount
a.transactions = append(a.transactions, Transaction{
Type: "입금",
Amount: amount,
Timestamp: time.Now(),
})
return nil
}
func (a *BankAccount) Withdraw(amount float64) error {
if amount <= 0 {
return errors.New("출금액은 0보다 커야 합니다")
}
if amount > a.balance {
return fmt.Errorf("잔액 부족: 현재 잔액 %.2f원, 출금 요청 %.2f원", a.balance, amount)
}
a.balance -= amount
a.transactions = append(a.transactions, Transaction{
Type: "출금",
Amount: amount,
Timestamp: time.Now(),
})
return nil
}
func (a BankAccount) Balance() float64 {
return a.balance
}
func (a BankAccount) Owner() string {
return a.owner
}
func (a BankAccount) PrintStatement() {
fmt.Printf("=== %s 님의 계좌 명세서 ===\n", a.owner)
for _, t := range a.transactions {
fmt.Printf("[%s] %s: %.2f원\n",
t.Timestamp.Format("15:04:05"), t.Type, t.Amount)
}
fmt.Printf("현재 잔액: %.2f원\n", a.balance)
}
func main() {
account, err := NewBankAccount("홍길동", 10000)
if err != nil {
fmt.Println("에러:", err)
return
}
account.Deposit(5000)
account.Deposit(3000)
account.Withdraw(2000)
if err := account.Withdraw(20000); err != nil {
fmt.Println("출금 실패:", err)
}
account.PrintStatement()
}
핵심 정리
- 메서드 = 리시버가 있는 함수, 리시버는 메서드가 속한 타입
- 값 리시버: 복사본 동작(읽기 전용), 포인터 리시버: 원본 수정 가능
- 한 타입에는 값 리시버와 포인터 리시버를 혼용하지 않는 것이 관례
- Go는 값/포인터 사이를 자동 변환하지만, 인터페이스 구현 시에는 주의 필요
- 구조체 임베딩으로 메서드 재사용(상속 대체)