본문 바로가기

클라우드 기반

[GoLang] 함수와 메서드, 완벽히 파헤치기 (feat. 값 vs 포인터 리시버)

안녕하세요! Go 언어를 처음 배울 때, func 키워드는 여기저기서 보이는데 어떤 건 '함수'라고 하고 어떤 건 '메서드'라고 해서 헷갈릴 때가 있습니다. 저 또한 그랬습니다.

 

특히 다른 객체 지향 언어(Java, Python 등)의 클래스에 익숙하다면, 데이터(필드)는 struct 안에 정의하는데 왜 행동(함수)은 밖에 따로 정의해서 연결하는지, 그리고 왜 어떤 메서드는 (t T) 이고 다른 메서드는 (t *T) 인지 궁금증이 생깁니다.

이번 포스트에서는 그 궁금증을 해결하기 위해, 제가 이해한 내용을 바탕으로 Go의 함수와 메서드의 차이, 그리고 값/포인터 리시버의 핵심적인 차이점을 명확하게 정리해보려고 합니다.

 

1. 기본 단위, '함수(Function)'

Go에서 함수는 특정 타입에 소속되지 않은, 독립적인 코드 블록입니다. 우리가 흔히 알고 있는 바로 그 함수입니다.

 

기본 구조:

func 함수이름(파라미터 목록) 반환타입 {
    // ... 함수 본문 ...
    return 반환값
}

 

예시:

// 두 정수를 더하는 간단한 함수
func Add(a int, b int) int {
    return a + b
}

이 Add 함수는 main 패키지에 소속된 독립적인 함수이며, main.Add(1, 2) 와 같이 호출할 수 있습니다

(같은 패키지 내에서는 Add(1, 2)).

 

Go의 핵심 원칙: 값 전달 (Pass-by-Value)

함수를 이해할 때 가장 중요한 원칙입니다. Go에서는 함수에 인자를 전달할 때, 항상 그 값의 복사본을 만들어 전달합니다.

func main() {
    x := 10
    double(x)
    fmt.Println(x) // 출력: 10 (x의 값은 변하지 않음!)
}

func double(n int) {
    n = n * 2 // n은 x의 '복사본'이므로, 복사본의 값만 20으로 바뀜
}

 

만약 함수 안에서 원본 값을 직접 수정하고 싶다면 어떻게 해야 할까요?

바로 이때 포인터를 사용합니다. 값의 복사본이 아닌, 값이 저장된 메모리 주소를 전달하는 것입니다.

 
func main() {
    x := 10
    doubleWithPointer(&x) // x의 '주소'를 전달
    fmt.Println(x) // 출력: 20 (원본 x의 값이 변경됨!)
}

func doubleWithPointer(n *int) { // int의 주소를 받는 포인터 타입
    *n = *n * 2 // n이 가리키는 곳의 실제 값(*n)을 변경
}

 

2. 특정 타입의 행동, '메서드(Method)'

 

메서드는 특정 타입에 소속된(associated with) 함수입니다. func 키워드와 함수 이름 사이에 **리시버(receiver)**를 명시하여 어떤 타입에 속한 메서드인지를 알려줍니다.

 

기본 구조:

func (리시버변수 리시버타입) 메서드이름(파라미터 목록) 반환타입 {
    // ... 메서드 본문 ...
}

 

이것이 바로 Go가 객체 지향의 '행동'을 구현하는 방식입니다. 데이터(필드)는 구조체에, 그 데이터를 다루는 행동은 메서드에 정의하여 분리합니다.

 

 

* 메서드의 핵심: 값 리시버 vs. 포인터 리시버

 

이제 가장 중요한 부분입니다. 리시버는 왜 어떤 때는 값(c Circle)이고, 어떤 때는 포인터(c *Circle)일까요? 이는 위에서 설명한 함수의 '값 전달' 원칙과 정확히 같습니다.

 

값 리시버 (Value Receiver) func (c Circle) ...

비유: "보고서 사본을 받아서 읽고 분석하기"

 

앞서 말한 것처럼 Go에서는 메서드가 호출될 때, 리시버 타입의 복사본이 생성되어 전달됩니다.

  • 특징: 메서드 안에서 리시버 변수의 필드 값을 변경해도, 복사본을 변경하는 것이므로 원본 객체에는 아무런 영향을 주지 않습니다.
  • 언제 사용하나요?
    • 해당 타입의 데이터를 읽기만 할 때.
    • 원본 데이터를 절대 변경하면 안 될 때.
    • 구조체의 크기가 작아서 복사하는 비용이 부담되지 않을 때.

예시:

type Wallet struct {
    balance int
}

// Balance 메서드는 잔액을 읽기만 할 뿐, 변경하지 않습니다.
func (w Wallet) Balance() int {
    return w.balance
}

 

포인터 리시버 (Pointer Receiver) func (c *Circle) ...

비유: "공유된 원본 문서의 링크를 받아 직접 수정하기"

 

메서드가 호출될 때, 리시버 타입의 인스턴스를 가리키는 **포인터(메모리 주소)**가 전달됩니다.

  • 특징: 리시버 변수는 원본 객체를 가리키는 '리모컨'과 같습니다. 따라서 메서드 안에서 리시버의 필드 값을 변경하면 원본 객체가 직접 수정됩니다.
  • 언제 사용하나요?
    1. (필수) 원본 객체의 상태를 수정해야 할 때: 이것이 포인터 리시버를 사용하는 가장 주된 이유입니다.
    2. (권장) 구조체의 크기가 클 때: 불필요한 큰 데이터 복사를 피해 성능을 향상시킬 수 있습니다.

예시:

// Deposit 메서드는 지갑(원본)의 잔액을 '수정'해야 합니다.
func (w *Wallet) Deposit(amount int) {
    w.balance += amount // w는 포인터지만, Go가 편의를 위해 w.balance 형태로 접근하게 해줍니다.
}

 

결론

Go는 함수와 메서드를 명확히 구분하고, 특히 메서드 리시버를 값으로 할지 포인터로 할지를 통해 데이터의 불변성(immutability)과 가변성(mutability)을 개발자가 명시적으로 제어하도록 합니다. 이 차이를 이해하면, 더 안전하고 의도가 명확한 Go 코드를 작성하는 데 큰 도움이 될 것입니다.


이 글은 Go를 학습하며 정리한 내용을 바탕으로 작성되었습니다. 잘못된 점이 있다면 언제든지 피드백 부탁드립니다.