Go 언어의 메서드 깊이 파고들기
1. 함수 (Function): 독립적인 동작 단위
Go 언어의 함수는 다른 프로그래밍 언어의 함수와 크게 다르지 않습니다. 특정 작업을 수행하고, 선택적으로 인자를 받아들이며, 값을 반환할 수 있는 독립적인 코드 블록입니다.
package main
import "fmt"
// greeting 함수는 문자열 인자를 받아 메시지를 출력합니다.
func greeting(name string) {
fmt.Printf("안녕하세요, %s님!\n", name)
}
// add 함수는 두 개의 정수 인자를 받아 합을 반환합니다.
func add(a, b int) int {
return a + b
}
func main() {
greeting("김철수") // 함수 호출
result := add(10, 20) // 함수 호출
fmt.Println("합계:", result) // 출력: 합계: 30
}
- 독립성: 함수는 어떤 특정 데이터 타입에 묶여 있지 않습니다. 전역적으로 선언되거나 다른 함수 내부에 중첩될 수 있습니다.
- 호출 방식:
함수이름(인자)형태로 직접 호출합니다.
2. 메서드 (Method): 특정 타입에 속하는 동작
메서드는 특정 타입에 바인딩(연결)된 함수입니다. 즉, 어떤 데이터(리시버)에 대해 수행되는 동작을 정의할 때 사용합니다. Go 언어에는 클래스 개념이 없기 때문에, 메서드는 객체 지향 프로그래밍의 '메서드'와 유사한 역할을 수행하며, '타입'을 기반으로 동작을 확장하는 강력한 수단이 됩니다.
중요한 점은, Go 언어에서는 구조체(struct) 내부에 직접 함수를 선언할 수 없다는 것입니다. 다른 객체 지향 언어의 클래스처럼 구조체 안에 메서드를 포함시키는 것이 아니라, 별도로 선언하되 특정 구조체(또는 다른 사용자 정의 타입)와 연결하는 방식을 사용합니다. 이때 사용되는 것이 바로 **리시버(Receiver)**입니다.
메서드 선언 문법
메서드는 함수 선언과 비슷하지만, 함수 이름 앞에 리시버를 명시해야 합니다.
func (리시버변수 리시버타입) 메서드이름(매개변수) 반환타입 {
// 메서드 본문
}
- 리시버 변수: 메서드 내부에서 리시버 타입의 인스턴스에 접근할 때 사용되는 변수 이름입니다. 관례적으로 타입 이름의 첫 글자를 소문자로 사용합니다 (예:
uforUser,pforPoint). - 리시버 타입: 메서드가 바인딩될 특정 타입입니다. 구조체(struct) 타입이 가장 일반적이지만, **모든 사용자 정의 타입(정의된 타입)**에 메서드를 연결할 수 있습니다. (예:
type MyInt int와 같이 기본 타입을 새로 정의한 타입에도 가능)
메서드 예시: 구조체와 함께 사용하기
가장 흔한 메서드 사용 예시는 구조체와 결합하는 경우입니다.
package main
import "fmt"
// Person이라는 구조체 타입 정의
type Person struct {
Name string
Age int
}
// Person 타입에 Greet 메서드 연결 (리시버는 값 타입)
// 이 메서드는 Person 구조체 인스턴스의 데이터를 읽어와서 사용합니다.
func (p Person) Greet() {
fmt.Printf("안녕하세요, 제 이름은 %s이고, %d살입니다.\n", p.Name, p.Age)
}
// Person 타입에 Birthday 메서드 연결 (리시버는 포인터 타입)
// 이 메서드는 Person 구조체 인스턴스의 데이터를 변경합니다.
func (p *Person) Birthday() {
p.Age++ // p가 포인터이므로 원본 Person의 Age를 직접 변경
fmt.Printf("%s의 새 나이는 %d살입니다!\n", p.Name, p.Age)
}
func main() {
// Person 구조체 인스턴스 생성
person1 := Person{Name: "김영희", Age: 25}
// 메서드 호출: 리시버.메서드이름()
person1.Greet() // 출력: 안녕하세요, 제 이름은 김영희이고, 25살입니다.
// Birthday 메서드 호출
person1.Birthday() // 출력: 김영희의 새 나이는 26살입니다!
person1.Greet() // 출력: 안녕하세요, 제 이름은 김영희이고, 26살입니다.
// Birthday 메서드가 p *Person (포인터 리시버)로 선언되었기 때문에 person1의 Age가 실제로 변경됨
// 포인터 리시버 vs 값 리시버의 중요성
person2 := Person{Name: "박철수", Age: 30}
person2.Greet() // 안녕하세요, 제 이름은 박철수이고, 30살입니다.
// Birthday 메서드 호출 전후 Age 값 확인
fmt.Println("Birthday 메서드 호출 전:", person2.Age) // 30
person2.Birthday() // 박철수의 새 나이는 31살입니다!
fmt.Println("Birthday 메서드 호출 후:", person2.Age) // 31
}
포인터 리시버 vs. 값 리시버 (매우 중요!)
메서드를 선언할 때 리시버를 값 타입((p Person))으로 할 것인지, 포인터 타입((p *Person))으로 할 것인지 결정해야 합니다. 이는 Go 언어에서 매우 중요한 개념입니다.
- 값 리시버 (
(p Person)):- 메서드가 호출될 때, 리시버 인스턴스의 복사본이 메서드에 전달됩니다.
- 메서드 내에서 리시버의 필드를 변경하더라도, 원본 인스턴스에는 아무런 영향을 주지 않습니다.
- 주로 리시버의 값을 변경하지 않는 '읽기 전용' 작업을 수행할 때 사용됩니다.
- 작은 구조체나 기본 타입의 경우 오버헤드가 적지만, 큰 구조체의 경우 복사 비용이 발생할 수 있습니다.
- 포인터 리시버 (
(p *Person)):- 메서드가 호출될 때, 리시버 인스턴스의 **메모리 주소(포인터)**가 메서드에 전달됩니다.
- 메서드 내에서 리시버의 필드를 변경하면, 원본 인스턴스의 데이터가 실제로 변경됩니다.
- 리시버의 상태를 변경해야 하는 '쓰기' 작업을 수행할 때 반드시 사용해야 합니다.
- 복사 비용이 발생하지 않으므로 효율적입니다. Go에서는 대부분의 경우 포인터 리시버를 선호합니다.
nil포인터일 수도 있으므로, 메서드 내에서nil체크가 필요할 수도 있습니다.
메서드는 모든 사용자 정의 타입에 연결 가능
메서드 기능은 구조체에만 국한되지 않습니다.type MyInt int와 같이 기본 타입을 기반으로 새로운 타입을 정의한 경우에도 메서드를 연결할 수 있습니다.
package main
import "fmt"
type Celsius float64 // float64 타입을 기반으로 Celsius라는 새 타입 정의
// Celsius 타입에 ToFahrenheit 메서드 연결
func (c Celsius) ToFahrenheit() float64 {
return (c * 9 / 5) + 32
}
func main() {
tempC := Celsius(25.0)
tempF := tempC.ToFahrenheit()
fmt.Printf("섭씨 %.2f도는 화씨 %.2f도입니다.\n", tempC, tempF)
// 출력: 섭씨 25.00도는 화씨 77.00도입니다.
}
이것은 기존 타입에 새로운 동작을 추가하는 강력한 방법이며, 인터페이스와 결합될 때 더욱 빛을 발합니다.
함수와 메서드의 주요 차이점 비교
| 특징 | 함수 (Function) | 메서드 (Method) |
|---|---|---|
| 선언 방식 | func 이름(인자) 반환타입 { ... } |
func (리시버) 이름(인자) 반환타입 { ... } |
| 바인딩 | 특정 타입에 바인딩되지 않음 (독립적) | 특정 타입(리시버)에 바인딩됨 |
| 선언 위치 | 어디서든 선언 가능 | 구조체 내부에 선언 불가능, 구조체 외부에서 리시버를 통해 연결 |
| 호출 방식 | 이름(인자) |
리시버인스턴스.이름(인자) |
| 목적 | 독립적인 작업 수행 | 특정 타입의 데이터에 대한 동작 수행 |
| OOP 유사성 | 일반적인 절차적 프로그래밍의 함수 | 객체 지향 프로그래밍의 '메서드' 역할과 유사 |
| 캡슐화 | 직접적인 캡슐화 기능 없음 | 리시버를 통해 데이터와 동작을 함께 묶는 효과 제공 |
왜 Go는 '메서드'를 이렇게 설계했을까? (추가적인 통찰)
Go 언어는 클래스나 상속과 같은 전통적인 객체 지향 개념을 직접적으로 제공하지 않습니다.
대신, **임베딩(Embedding)**과 인터페이스(Interface), 그리고 이 메서드 기능을 통해 유연하고 확장 가능한 소프트웨어를 만들 수 있도록 설계되었습니다.
- 다형성 구현: 메서드는 인터페이스와 결합하여 Go의 다형성(Polymorphism)을 구현하는 핵심 요소입니다. 어떤 타입이 특정 메서드를 구현하면, 해당 타입은 그 인터페이스를 만족하게 됩니다. 이는 유연한 시스템 설계로 이어집니다.
- 컴포지션 선호: Go는 상속보다 컴포지션(Composition)을 선호합니다. 메서드는 특정 타입에 동작을 연결함으로써, 작은 단위의 기능들을 조합하여 더 큰 기능을 만드는 컴포지션 접근 방식을 자연스럽게 지원합니다.
- 캡슐화 (Kind of): 리시버를 통해 메서드 내부에서 해당 타입의 데이터에 접근할 수 있게 함으로써, 해당 데이터와 관련된 동작들을 논리적으로 묶어 캡슐화와 유사한 효과를 얻을 수 있습니다.
결론
Go 언어의 함수는 독립적인 작업을 수행하는 코드 블록인 반면, 메서드는 특정 타입에 속하는 동작을 정의하여 해당 타입의 인스턴스에 대한 작업을 수행하도록 합니다. 특히 구조체 내부에 함수를 직접 선언할 수 없으므로, 구조체의 동작을 정의하기 위해 메서드를 활용한다는 점이 중요합니다. 또한 포인터 리시버와 값 리시버의 차이를 명확히 이해하고 적절히 사용하는 것이 Go 언어의 효율성과 안전성을 높이는 데 핵심입니다.
'Go언어' 카테고리의 다른 글
| Go의 고루틴, Kotlin의 코루틴, 그리고 Java 21의 가상 스레드 (2) | 2025.08.04 |
|---|---|
| Go언어, 동시성 철학: “공유 메모리가 아닌, 통신으로 메모리를 공유하라” (3) | 2025.08.03 |
| Go언어, 코루틴, 채널, Context 알아보기 (5) | 2025.08.03 |
| Go언어, 인터페이스 (3) | 2025.08.02 |
| Go언어, 동적 배열 `슬라이스` (0) | 2025.08.02 |