Go 포인터, 이것만 알면 됩니다
Python 쓰다가 Go를 시작하면 포인터에서 한 번 멈칫하게 됩니다. *int가 뭐고, &x는 뭔지. Python에서는 본 적 없는 것들이 튀어나옵니다.
근데 막상 이해하면 별거 아닙니다. 어려운 개념이 아니라 그냥 낯선 개념입니다.
포인터가 뭔지부터
컴퓨터 메모리를 번호가 매겨진 칸이라고 생각하면 됩니다. 모든 변수는 어딘가의 칸에 들어가 있습니다. 포인터는 그 칸의 번호를 저장하는 변수입니다. 값 자체가 아니라 값이 있는 위치를 갖고 있는 겁니다.
메모리:
주소 100번: [42] ← 변수 x
주소 200번: [100] ← 변수 p (x의 주소를 저장)
Go에서는 두 개의 연산자로 이걸 다룹니다.
&x— x의 주소를 가져옵니다*p— p가 가리키는 주소의 값을 가져옵니다
&는 값에서 주소로, *는 주소에서 값으로 가져옵니다.
참고로 *는 쓰이는 위치에 따라 의미가 다릅니다.
- 타입 앞:
*int— "int를 가리키는 포인터" 타입 - 변수 앞:
*p— "p가 가리키는 값을 꺼내라" (역참조)
같은 기호인데 하나는 타입 선언이고 하나는 연산입니다. 처음엔 헷갈리는데 맥락으로 구분하면 됩니다. 타입 자리에 있으면 타입이고, 값 자리에 있으면 역참조입니다.
x := 42
p := &x // p는 x의 주소를 갖고 있음
fmt.Println(x) // 42 — 값
fmt.Println(p) // 0xc000012... — 메모리 주소
fmt.Println(*p) // 42 — 그 주소에 있는 값
포인터에 왜 타입이 필요한가
var p *int라고 쓰면 "이 포인터는 int의 주소를 저장한다"는 뜻입니다. 근데 주소에 왜 타입이 필요할까요?
메모리에 저장된 값은 타입에 따라 크기가 다릅니다. int64는 8바이트, bool은 1바이트, string은 16바이트. *p로 값을 읽을 때 Go는 그 주소에서 몇 바이트를 읽어야 하는지, 그 바이트를 어떻게 해석해야 하는지 알아야 합니다. 타입이 그 정보입니다.
집 주소로 비유하면 — 주소는 위치를 알려주지만 그 건물이 식당인지 병원인지는 안 알려줍니다. 타입이 그 라벨입니다.
var p *int
i := 42
p = &i // ✓ — &i는 int의 주소
s := "hello"
p = &s // ✗ 컴파일 에러 — string 주소를 int 포인터에 넣을 수 없음
포인터는 메모리를 공유
포인터가 어떤 변수를 가리키면, 둘은 같은 메모리를 봅니다. 한쪽을 바꾸면 다른 쪽도 바뀝니다.
i := 42
p := &i
*p = 21
fmt.Println(i) // 21 — 바뀜
i = 99
fmt.Println(*p) // 99 — 이것도 바뀜
*p = 21은 주소를 바꾸는 게 아닙니다. 그 주소에 있는 값을 바꾸는 겁니다. 택배 보내는 주소는 그대로인데 안에 든 물건만 바꾸는 거라고 보면 됩니다.
Python에서는 왜 포인터가 없는가
Python에서는 객체를 함수에 넘기면 알아서 참조로 전달됩니다.
def update_user(u):
u.name = "Bob" # 원본이 바뀜 — 별도 문법 없음
user = User("Alice")
update_user(user)
print(user.name) # "Bob"
Go는 다릅니다. 기본 동작이 복사입니다.
func update(u User) {
u.Name = "Bob" // 복사본만 바뀜
}
user := User{Name: "Alice"}
update(user)
fmt.Println(user.Name) // 여전히 "Alice"
원본을 바꾸려면 포인터를 써야 합니다.
func update(u *User) {
u.Name = "Bob" // 원본이 바뀜
}
user := User{Name: "Alice"}
update(&user)
fmt.Println(user.Name) // "Bob"
| 언어 | 기본 동작 | 포인터 필요? |
|---|---|---|
| Python | 참조 자동 전달 | 아니오 |
| JavaScript | 객체는 참조 전달 | 아니오 |
| Go | 값 복사 | 예 |
Python은 알아서 해줍니다. Go는 직접 선택해야 합니다. 코드가 좀 더 길어지는 대신 뭐가 복사되고 뭐가 공유되는지 항상 눈에 보입니다.
포인터 만드는 세 가지 방법
// 1. & — 기존 변수의 주소를 가져오기
i := 42
p := &i
// 2. new() — 새로운 메모리 할당
p := new(int)
*p = 42
// 3. & + struct 리터럴 — 실무에서 가장 많이 보는 패턴
p := &User{Name: "Alice", Age: 30}
세 번째가 제일 많이 쓰입니다. new()는 생각보다 잘 안 쓰입니다.
메서드와 리시버
Go에는 클래스가 없습니다. 대신 타입에 메서드를 붙입니다.
type User struct {
Name string
}
func (u *User) SetName(name string) {
u.Name = name // 포인터 리시버 — 원본 수정 가능
}
func (u User) GetName() string {
return u.Name // 값 리시버 — 복사본으로 동작
}
(u *User) 부분이 Python의 self와 비슷합니다. 클래스 안에 있는 게 아니라 함수 선언에 붙어있다는 것만 다릅니다.
nil 포인터
포인터의 기본값은 nil입니다. 아무것도 가리키지 않는다는 뜻입니다. nil 포인터를 역참조하면 프로그램이 죽습니다.
var p *int
fmt.Println(*p) // panic: nil pointer dereference
다른 언어에서도 같은 문제가 있습니다. Java의 NullPointerException, Python의 AttributeError: 'NoneType', JavaScript의 Cannot read properties of null. 전부 같은 얘기입니다. 없는 걸 쓰려고 했다는 거죠.
그래서 사용 전에 체크해야 합니다.
if p != nil {
fmt.Println(*p)
}
Stack과 Heap
메모리는 크게 두 영역으로 나뉩니다. Stack은 함수 호출과 함께 자동으로 할당되고 해제되는 빠른 메모리입니다. 반면 Heap은 상대적으로 느리고, 가비지 컬렉터가 정리를 담당합니다.
Go에서 로컬 변수의 포인터를 리턴하면 어떻게 될까요?
func newInt() *int {
x := 42
return &x // x는 로컬 변수인데?
}
C에서 이러면 버그입니다. 함수가 끝나면 stack이 해제되니까 댕글링 포인터가 됩니다.
Go에서는 괜찮습니다. 컴파일러가 "이 변수가 함수 밖으로 나간다"는 걸 감지하면 자동으로 stack이 아니라 heap에 할당합니다. 이걸 escape analysis라고 합니다. 가비지 컬렉터가 나중에 알아서 정리해줍니다.
Common Gotchas
1. 의도하지 않은 공유 변경
여러 포인터가 같은 값을 가리키면 하나를 바꿨을 때 나머지도 전부 바뀝니다.
a := 10
p1 := &a
p2 := &a
*p1 = 99
fmt.Println(*p2) // 99
디버깅할 때 "나는 p1만 바꿨는데 왜 p2도 바뀌지?" 하는 상황이 이겁니다. 포인터가 어디를 가리키는지 항상 추적해야 합니다.
2. nil 포인터를 담은 interface는 nil이 아님
Go에서 가장 악명 높은 gotcha입니다.
type MyError struct{}
func (e *MyError) Error() string { return "error" }
func getError() error {
var err *MyError = nil
return err // nil 포인터를 interface에 담아서 리턴
}
func main() {
err := getError()
fmt.Println(err == nil) // false (!!)
}
interface는 내부적으로 (타입, 값) 두 개를 갖고 있습니다. *MyError 타입의 nil 포인터를 넣으면 타입 정보가 들어가기 때문에 interface 자체는 nil이 아닙니다. (nil, nil)이어야 진짜 nil인데 (*MyError, nil)이 된 겁니다.
에러 리턴할 때 여기를 타면 if err != nil 분기가 항상 탑니다. 해결법은 명시적으로 nil을 리턴하는 겁니다.
func getError() error {
var err *MyError = nil
if err == nil {
return nil // interface nil을 직접 리턴
}
return err
}
3. struct 복사 시 포인터 필드는 shallow copy
struct를 복사하면 필드 값이 복사됩니다. 근데 포인터 필드는 주소가 복사되는 거라 결국 같은 데이터를 가리킵니다.
type Config struct {
Items *[]string
}
original := Config{Items: &[]string{"a", "b"}}
copied := original // struct 복사
(*copied.Items)[0] = "z"
fmt.Println((*original.Items)[0]) // "z" — 원본도 바뀜
Python의 shallow copy와 같은 문제입니다. 독립적인 복사가 필요하면 포인터가 가리키는 데이터까지 직접 복사해야 합니다.
// deep copy — 데이터를 새로 만들어서 복사
newItems := make([]string, len(*original.Items))
copy(newItems, *original.Items)
copied := Config{Items: &newItems}
(*copied.Items)[0] = "z"
fmt.Println((*original.Items)[0]) // "a" — 원본 안 바뀜
4. map 값은 주소를 못 가져옴
m := map[string]User{
"alice": {Name: "Alice"},
}
m["alice"].Name = "Bob" // ✗ 컴파일 에러
p := &m["alice"] // ✗ 컴파일 에러
map은 내부적으로 데이터가 재배치될 수 있습니다(rehash). 주소를 줬는데 그 사이에 데이터가 이사 가버리면 댕글링 포인터가 되니까 아예 막아둔 겁니다. 값을 수정하려면 꺼내서 바꾸고 다시 넣거나, 처음부터 포인터를 값으로 저장해야 합니다.
// 방법 1: 꺼내서 바꾸고 다시 넣기
u := m["alice"]
u.Name = "Bob"
m["alice"] = u
// 방법 2: 포인터를 값으로 저장
m := map[string]*User{
"alice": {Name: "Alice"},
}
m["alice"].Name = "Bob" // ✓ — 포인터를 통해 수정
5. pointer receiver와 interface method set
포인터 리시버로 정의한 메서드는 값 타입으로 interface를 만족시킬 수 없습니다.
type Saver interface {
Save()
}
type Doc struct{}
func (d *Doc) Save() {} // 포인터 리시버
var s Saver
s = &Doc{} // ✓ — 포인터니까 OK
s = Doc{} // ✗ 컴파일 에러
왜 이런 제약이 있냐면 — interface 안에 값이 들어가면 그 값의 주소를 안정적으로 가져올 수 없기 때문입니다. 포인터 리시버는 원본 주소가 필요한데, interface에 복사된 값은 원본이 아닙니다.
실무에서는 거의 항상 포인터를 넘기니까 자주 겪진 않습니다. 겪게 된다면 에러 메시지가 "does not implement interface"라서 처음 보면 당황합니다.
6. 루프 변수 포인터
Go 1.22 이전에는 루프 변수가 매 반복마다 재사용됐습니다. 주소를 캡처하면 마지막 값만 남는 문제가 있었습니다.
// Go 1.21 이하에서 문제되는 코드
var ptrs []*int
for _, v := range []int{1, 2, 3} {
ptrs = append(ptrs, &v) // 전부 같은 v의 주소
}
// ptrs[0], ptrs[1], ptrs[2] 전부 3을 가리킴
Go 1.22에서 수정됐습니다. 매 반복마다 새 변수가 생깁니다. 근데 레거시 코드나 1.22 이전 버전을 쓰는 환경에서는 여전히 주의해야 합니다.
7. 포인터 남용 금지
int나 bool 같은 작은 값은 복사가 더 쌉니다. 포인터는 간접 참조 비용이 있고, heap 할당을 유발할 수 있고, GC에 부담을 줍니다. 포인터는 원본을 수정해야 하거나, 구조체가 크거나, nil로 "값 없음"을 표현해야 할 때 쓰면 됩니다.
C와의 차이
포인터 개념 자체는 C와 같습니다. 근데 Go가 훨씬 안전합니다. 가장 큰 차이는 포인터 연산이 없다는 겁니다.
// C — 메모리를 직접 돌아다닐 수 있음
int *p = arr;
p++; // 다음 주소로 이동
*(p + 2); // 두 칸 앞으로 점프
// Go — 이런 건 안 됨
p := &arr[0]
p++ // 컴파일 에러
포인터의 유용한 부분(참조, 데이터 공유)은 그대로 쓰면서, 위험한 부분(버퍼 오버플로우, segfault)은 아예 차단한 겁니다.
정리
Go 포인터의 핵심은 이겁니다. &는 주소를 가져오고, *는 타입 앞에서는 포인터 타입을, 변수 앞에서는 역참조를 뜻합니다. Go는 기본이 복사라서 원본을 바꾸려면 포인터가 필요합니다. nil 체크는 항상 해야 합니다.
Python에서 넘어오면 번거롭게 느껴질 수 있습니다. 근데 그 번거로움만큼 뭐가 공유되고 뭐가 복사되는지 명확하게 보입니다. 거기에 포인터 연산까지 빠져있으니 C만큼 위험하지도 않습니다.
포인터가 있어서 불편한 게 아니라, 포인터가 있어서 투명한 겁니다.