본문으로 건너뛰기

메서드(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는 값/포인터 사이를 자동 변환하지만, 인터페이스 구현 시에는 주의 필요
  • 구조체 임베딩으로 메서드 재사용(상속 대체)