Table of contents
주소를 다루는 이유
Go는 기본적으로 값을 복사해서 전달한다. 함수에 변수를 넘기면 그 변수의 복사본이 만들어지고, 함수 안에서 아무리 바꿔도 원본에는 영향이 없다.
package main
import "fmt"
func tryChange(x int) {
x = 100
}
func main() {
n := 42
tryChange(n)
fmt.Println(n) // 42 — 안 바뀜
}
tryChange가 받는 x는 n의 복사본이다. 함수 안에서 100으로 바꿔봤자 원본 n은 여전히 42다. 작은 정수 하나라면 상관없겠지만, 큰 구조체를 매번 복사하는 건 메모리와 성능 면에서 낭비일 수 있다. 원본을 직접 수정해야 하는 경우도 있고.
이럴 때 포인터가 등장한다.
& — 주소 얻기
& 연산자는 변수의 메모리 주소를 가져온다.
package main
import "fmt"
func main() {
x := 42
p := &x // p는 x의 주소를 담고 있다
fmt.Println(x) // 42
fmt.Println(p) // 0xc0000b6010 (메모리 주소, 실행마다 다름)
fmt.Printf("타입: %T\n", p) // *int
}
p의 타입은 *int다. “int를 가리키는 포인터”라는 뜻으로, 값 자체가 아니라 값이 저장된 위치를 들고 있는 셈이다.
* — 주소가 가리키는 값
* 연산자는 포인터가 가리키는 실제 값에 접근한다. 역참조(dereference)라고 부른다.
package main
import "fmt"
func main() {
x := 42
p := &x
fmt.Println(*p) // 42 — p가 가리키는 값
*p = 100 // p가 가리키는 곳의 값을 100으로
fmt.Println(x) // 100 — 원본이 바뀌었다!
}
*p = 100은 p가 가리키는 메모리 위치에 직접 100을 쓰는 거다. p와 x는 같은 메모리를 보고 있으니, x의 값도 바뀐다. C 포인터와 원리가 같지만, Go에는 포인터 산술이 없어서 훨씬 안전하다.
포인터로 함수에서 원본 수정하기
아까 실패했던 tryChange를 포인터 버전으로 다시 만들어보자.
package main
import "fmt"
func changeByPointer(p *int) {
*p = 100
}
func main() {
n := 42
changeByPointer(&n)
fmt.Println(n) // 100 — 이번엔 바뀐다
}
&n으로 주소를 넘기고, 함수 안에서 *p로 역참조해서 값을 수정한다. 값 전달 vs 포인터 전달의 차이를 한눈에 보여주는 예시다.
정리하면 이렇다.
| 전달 방식 | 복사 대상 | 원본 수정 |
|---|---|---|
값 전달 (func f(x int)) | 값 자체 | 불가 |
포인터 전달 (func f(p *int)) | 주소(8바이트) | 가능 |
new — 포인터 생성
new 함수는 지정한 타입의 제로 값을 할당하고 그 포인터를 반환한다.
package main
import "fmt"
func main() {
p := new(int) // *int, 0으로 초기화됨
fmt.Println(*p) // 0
*p = 42
fmt.Println(*p) // 42
s := new(string) // *string, ""으로 초기화됨
fmt.Println(*s) // (빈 문자열)
}
new(int)는 int 크기만큼 메모리를 잡고, 0으로 초기화한 뒤, 그 주소를 돌려준다. 실무에서는 new보다 &T{} 패턴을 더 자주 쓴다. 구조체를 만들면서 초기값을 바로 설정할 수 있기 때문이다.
// new 사용
p := new(Config)
p.Port = 8080
// &T{} 사용 — 더 관용적
p := &Config{Port: 8080}
구조체 포인터
구조체는 여러 필드를 묶은 타입이라 크기가 클 수 있다. 함수에 넘길 때마다 전체를 복사하면 비효율적이니, 구조체는 포인터로 다루는 경우가 많다.
package main
import "fmt"
type User struct {
Name string
Age int
}
func birthday(u *User) {
u.Age++ // (*u).Age++와 같지만, Go가 자동으로 역참조해준다
}
func main() {
u := &User{Name: "고퍼", Age: 10}
birthday(u)
fmt.Printf("%s: %d살\n", u.Name, u.Age) // 고퍼: 11살
}
Go에서는 구조체 포인터로 필드에 접근할 때 (*u).Age 대신 u.Age로 쓸 수 있다. 컴파일러가 알아서 역참조를 해주기 때문이다. C에서의 -> 연산자가 Go에서는 .으로 통일된 셈이라, 문법이 깔끔해진다.
메서드 리시버에서도 포인터를 자주 쓴다.
func (u *User) SetName(name string) {
u.Name = name // 원본 수정
}
func (u User) GetName() string {
return u.Name // 복사본 읽기 — 원본 변경 없음
}
원본을 수정해야 하면 포인터 리시버(*User), 읽기만 하면 값 리시버(User)를 쓴다. 다만 한 타입에 포인터 리시버 메서드가 하나라도 있으면, 일관성을 위해 나머지도 전부 포인터 리시버로 통일하는 게 Go 커뮤니티의 관례다.
nil 포인터
포인터를 선언만 하고 초기화하지 않으면 기본값은 nil이다.
package main
import "fmt"
func main() {
var p *int
fmt.Println(p) // <nil>
if p != nil {
fmt.Println(*p) // nil 체크 없이 역참조하면 panic
}
}
nil 포인터를 역참조하면 런타임 panic이 발생한다. 방어적으로 코드를 짜려면, 포인터를 받는 함수에서 nil 체크를 먼저 하는 습관을 들이는 게 좋다.
func printUser(u *User) {
if u == nil {
fmt.Println("사용자 정보 없음")
return
}
fmt.Printf("%s (%d살)\n", u.Name, u.Age)
}
언제 포인터를 쓸까
모든 곳에 포인터를 쓸 필요는 없다. 몇 가지 기준을 정리하면 이렇다.
포인터를 쓰는 경우:
- 함수가 원본 값을 수정해야 할 때
- 구조체가 크거나, 복사 비용이 부담될 때
- nil이 의미 있는 값일 때 (예: “설정 없음”을 표현)
값을 그대로 쓰는 경우:
- 작은 구조체 (필드 2~3개 이하, 기본 타입으로만 구성)
- 변경이 필요 없는 데이터
- 동시성 환경에서 공유 상태를 피하고 싶을 때
Go의 포인터는 C에 비하면 훨씬 안전하다. 포인터 산술이 없고, 가비지 컬렉터가 메모리를 알아서 회수하니까 dangling pointer 같은 문제도 없다. 그렇다고 마음 놓고 쓰면 nil 역참조 같은 실수는 여전히 생기니, 포인터를 쓸 때는 항상 “이 포인터가 nil일 수 있는가?”를 먼저 생각하는 게 좋다.
포인터의 핵심은 간단하다. &는 주소를 얻고, *는 주소가 가리키는 값에 접근한다. 값 전달의 한계를 넘어서 원본을 수정하거나, 큰 데이터의 복사를 피할 때 포인터가 등장한다. 구조체와 메서드 리시버에서 특히 자주 쓰이니, 이 패턴에 익숙해지면 Go 코드를 읽는 속도가 확 빨라질 거다.
다음 편에서는 Go의 킬러 피처인 고루틴과 채널을 다룬다. 동시성 프로그래밍이 Go에서 어떻게 자연스러운 일이 되는지 살펴보자.
-> 9편: 고루틴과 채널
Loading comments...