본문으로 건너뛰기

포인터

이 챕터에서 다루는 것

Go에는 포인터가 있다. C의 포인터와 문법이 거의 같지만 산술이 없고 GC가 있다는 두 차이가 성격을 완전히 바꿔 놓았다. 여기서는 &*, Go 1.26에서 표현식을 받게 된 new, nil 역참조, 그리고 실무에서 매번 마주치는 "값이냐 포인터냐" 판단 기준을 다룬다.

문제 — 왜 포인터가 필요한가

2-5의 결론은 "Go는 예외 없이 값을 복사한다"였다. 그러면 함수가 호출자의 변수를 고칠 방법이 없다. 포인터가 그 방법이다.

func bumpValue(c Config) { c.Retries++ } // 복사본을 고친다
func bump(c *Config) { c.Retries++ } // 원본을 고친다

두 번째도 값 전달이다. 복사되는 값이 주소일 뿐이다. 이 관점을 유지하면 Go의 포인터는 전혀 어렵지 않다.

&*

examples/03-composite-types/05-pointer-basics/main.go
package main

import "fmt"

type Config struct {
Name string
Retries int
}

// bump은 포인터를 받아 원본을 고친다.
func bump(c *Config) {
c.Retries++ // (*c).Retries++ 의 축약형
}

// bumpValue는 복사본을 고친다. 호출자에게 아무 영향이 없다.
func bumpValue(c Config) {
c.Retries++
}

func main() {
c := Config{Name: "api", Retries: 1}

bumpValue(c)
fmt.Println("값으로 넘긴 뒤: ", c)

bump(&c)
fmt.Println("포인터로 넘긴 뒤:", c)

// & 로 주소를 얻고 * 로 역참조한다.
n := 10
p := &n
*p = 20
fmt.Println("n =", n, "*p =", *p)

// 포인터의 제로값은 nil이다.
var np *Config
fmt.Println("np == nil:", np == nil)

// nil 포인터 역참조는 패닉이다.
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("역참조 패닉:", rec)
}
}()
fmt.Println(np.Name)
}()

// 구조체 포인터에는 . 을 그대로 쓴다. C의 -> 같은 연산자가 없다.
pc := &Config{Name: "db", Retries: 5}
pc.Retries = 7
fmt.Println("pc:", *pc)

// 포인터끼리의 == 는 같은 대상을 가리키는지를 본다.
a := Config{Name: "x"}
b := Config{Name: "x"}
fmt.Println("값 비교: ", a == b)
fmt.Println("포인터 비교:", &a == &b, &a == &a)
}
go run ./05-pointer-basics
값으로 넘긴 뒤: {api 1}
포인터로 넘긴 뒤: {api 2}
n = 20 *p = 20
np == nil: true
역참조 패닉: runtime error: invalid memory address or nil pointer dereference
pc: {db 7}
값 비교: true
포인터 비교: false true

읽어야 할 것

c.Retries++(*c).Retries++의 축약형이다. Go에는 C의 ->가 없다. 컴파일러가 c가 포인터인지 값인지 보고 알아서 역참조한다. 덕분에 코드에서 .만 보고 다니면 되고, 값 타입을 포인터 타입으로 바꿔도 호출부를 고칠 일이 적다.

&Config{...}로 리터럴의 주소를 바로 얻는다. C였다면 malloc 후 필드를 채워야 할 자리다. Go에서는 컴파일러가 이 값을 스택에 둘지 힙에 둘지 알아서 정한다. 3-6의 주제다.

포인터의 ==는 대상의 동일성(identity) 이다. ab는 내용이 같아 a == btrue지만, &a == &bfalse다. 서로 다른 변수니까 주소가 다르다.

nil 역참조는 패닉이다. Java의 NullPointerException, Python의 AttributeError: 'NoneType'과 같은 자리인데, Go에서는 잡지 않으면 프로세스가 죽는다.

:::warning nil 역참조는 값을 읽을 때만 나는 게 아니다 Go 1.25에서 nil 검사를 지연시키던 컴파일러 버그가 고쳐졌다. 이런 코드가 이제 제대로 패닉을 낸다.

f, err := os.Open("nonExistentFile")
name := f.Name() // f는 nil이다 — 여기서 패닉
if err != nil {
return
}

"에러는 받은 즉시 확인한다"가 왜 규칙인지 보여 주는 사례다. Part 4에서 다시 본다. :::

포인터 산술이 없다

C에서는 p++로 다음 요소로 넘어가고 p + 3으로 건너뛴다. Go에는 없다. p + 1은 컴파일 에러다.

왜 뺐는가. 세 가지 이유가 겹친다.

  1. 메모리 안전. 포인터 산술은 버퍼 오버런의 주된 통로다. 배열 끝을 넘어가는 접근이 조용히 다른 데이터를 읽고 쓴다. Go는 그 문 자체를 닫았다.
  2. GC가 객체 경계를 알아야 한다. GC는 어떤 포인터가 어떤 객체를 가리키는지 추적한다. 포인터가 객체 중간이나 바깥을 가리킬 수 있으면 이 추적이 불가능해진다.
  3. 슬라이스가 그 일을 대신한다. C에서 포인터 산술로 하던 일 — 배열 순회, 부분 배열 전달 — 은 전부 슬라이스가 더 안전하게 한다. p + 3이 필요하면 s[3:]을 쓴다.

정말 필요하면 unsafe.Pointerunsafe.Add가 있지만, 이름 그대로다. GC가 추적하지 못하는 코드를 쓰는 순간 안전 보장이 사라진다.

new — 이제 표현식을 받는다

new(T)는 원래 타입만 받아서 T의 제로값을 만들고 그 주소를 돌려줬다. Go 1.26부터 표현식도 받는다.

examples/03-composite-types/05-new-expr/main.go
package main

import "fmt"

// Settings의 포인터 필드는 "설정하지 않음"(nil)과 "0으로 설정함"을 구분한다.
type Settings struct {
Timeout *int
Verbose *bool
}

func describe(s Settings) string {
out := ""
if s.Timeout == nil {
out += "timeout=기본값"
} else {
out += fmt.Sprintf("timeout=%d", *s.Timeout)
}
if s.Verbose == nil {
out += " verbose=기본값"
} else {
out += fmt.Sprintf(" verbose=%t", *s.Verbose)
}
return out
}

func defaultTimeout() int {
return 30
}

func main() {
// new(T)는 T의 제로값을 힙(또는 스택)에 만들고 그 주소를 준다.
p := new(int)
fmt.Println("new(int):", *p)

// new는 이제 표현식도 받는다. 값의 주소를 바로 얻을 수 있다.
q := new(2 + 20)
fmt.Println("new(2 + 20):", *q)

r := new(defaultTimeout())
fmt.Println("new(defaultTimeout()):", *r)

// 리터럴에 & 를 붙이는 것은 원래 됐다.
verbose := &[]bool{true}[0] // 예전에는 이런 곡예도 있었다
fmt.Println("옛날 방식:", *verbose)

// 지금은 그냥 이렇게 쓴다.
s := Settings{
Timeout: new(0),
Verbose: new(true),
}
fmt.Println(describe(s))
fmt.Println(describe(Settings{}))

// 구조체 리터럴에는 & 를 붙이면 된다. new(T{...})와 같다.
c1 := &Settings{Timeout: new(5)}
c2 := new(Settings)
fmt.Println(describe(*c1), "/", describe(*c2))
}
go run ./05-new-expr
new(int): 0
new(2 + 20): 22
new(defaultTimeout()): 30
옛날 방식: true
timeout=0 verbose=true
timeout=기본값 verbose=기본값
timeout=5 verbose=기본값 / timeout=기본값 verbose=기본값

이게 왜 반가운 변화인가

Go에서 주소를 얻을 수 있는 것(addressable) 은 변수, 슬라이스 요소, 역참조된 포인터, 구조체 필드다. 리터럴과 함수 반환값은 주소를 얻을 수 없다.

p := &42 // invalid operation: cannot take address of 42 (untyped int constant)
p := &f() // invalid operation: cannot take address of f() (value of type int)
p := &Config{} // 이건 된다 — 복합 리터럴은 예외

그래서 *int 필드에 값을 넣으려면 임시 변수를 거치거나,

t := 30
s := Settings{Timeout: &t}

이런 헬퍼를 프로젝트마다 하나씩 만들어 두는 것이 관행이었다.

func ptr[T any](v T) *T { return &v } // 이제 필요 없다
s := Settings{Timeout: ptr(30)}

이제는 new(30)이면 끝이다. 위 예제의 &[]bool{true}[0] 같은 곡예도 사라진다 (슬라이스 요소는 addressable이라는 점을 이용한 것이다).

:::info 이게 실제로 쓰이는 곳 JSON의 선택적 필드가 대표적인 동기다. Timeout int라면 JSON에 "timeout": 0이 왔는지 아예 안 왔는지 구분할 수 없다. Timeout *intnil이 "안 옴", new(0)이 "0으로 옴"이다. 위 예제의 describe(s)describe(Settings{}) 출력 차이가 그것이다. Part 9에서 본격적으로 다룬다. :::

new(T)&T{}. 구조체는 &Config{Name: "db"}가 관용적이다. new(Config)는 필드를 채울 수 없어서 제로값이 필요할 때만 쓴다. 실무에서 new를 보는 빈도는 낮았는데, new(expr) 덕에 앞으로는 늘어날 것이다.

이름 있는 타입과 포인터

2-1에서 type Celsius float64를 봤다. 같은 문법으로 어떤 타입에든 이름을 붙일 수 있다.

type UserID int
type Temperature float64
type Handler func(string) error
type Registry map[string][]string

이렇게 만든 것을 정의된 타입(defined type) 이라 하고, 바탕이 되는 타입과는 서로 다른 타입이다. UserIDint는 명시적 변환 없이 섞이지 않는다.

var id UserID = 7
var n int = id // 컴파일 에러
var n int = int(id) // 이렇게

값이 하나뿐인 int를 굳이 감싸는 이유는 인자 순서를 바꿔 넣는 사고를 컴파일러가 잡게 하기 위해서다. func grant(user, group int)은 인자를 바꿔 넣어도 컴파일되지만, func grant(user UserID, group GroupID)는 안 된다.

포인터 타입도 똑같이 만들어진다. *UserID, *Config, **int까지 된다(마지막은 실무에서 볼 일이 거의 없다). 정의된 타입이 진짜 힘을 발휘하는 것은 메서드를 붙일 수 있게 되는 Part 4부터다. 거기서 "값 리시버냐 포인터 리시버냐"라는, 지금 배울 판단 기준의 확장판을 만나게 된다.

값이냐 포인터냐

가장 자주 하는 질문이고, 답은 세 가지 기준의 조합이다.

examples/03-composite-types/05-value-vs-pointer/main.go
package main

import "fmt"

type Point struct {
X, Y int
}

type Account struct {
ID string
Balance int
}

// move는 새 값을 만들어 돌려준다. 작고 불변인 값에 어울리는 형태다.
func move(p Point, dx, dy int) Point {
return Point{X: p.X + dx, Y: p.Y + dy}
}

// deposit은 대상을 고쳐야 하므로 포인터를 받는다.
func deposit(a *Account, amount int) {
a.Balance += amount
}

// fill은 슬라이스를 포인터로 받을 필요가 없다. 요소 수정은 그대로 보인다.
func fill(s []int, v int) {
for i := range s {
s[i] = v
}
}

// grow는 길이를 바꾸므로 새 슬라이스를 반환해야 한다.
func grow(s []int, v int) []int {
return append(s, v)
}

// growInPlace는 굳이 포인터를 받는 형태다. 표준 라이브러리는 이렇게 하지 않는다.
func growInPlace(s *[]int, v int) {
*s = append(*s, v)
}

// record는 맵도 포인터가 필요 없음을 보여 준다.
func record(m map[string]int, key string) {
m[key]++
}

func main() {
p := Point{X: 1, Y: 1}
fmt.Println("move:", move(p, 2, 3), "원본:", p)

a := Account{ID: "A-1", Balance: 100}
deposit(&a, 50)
fmt.Println("deposit:", a)

s := make([]int, 3)
fill(s, 7)
fmt.Println("fill:", s)

s = grow(s, 8)
fmt.Println("grow:", s)

growInPlace(&s, 9)
fmt.Println("growInPlace:", s)

m := map[string]int{}
record(m, "hit")
record(m, "hit")
fmt.Println("record:", m)
}
go run ./05-value-vs-pointer
move: {3 4} 원본: {1 1}
deposit: {A-1 150}
fill: [7 7 7]
grow: [7 7 7 8]
growInPlace: [7 7 7 8 9]
record: map[hit:2]

판단 기준

1. 고쳐야 하는가. 호출자의 값을 고쳐야 하면 포인터다. 이유가 필요 없는 유일한 기준이고, 나머지 둘보다 우선한다.

2. 복사가 비싼가. 필드가 서너 개인 구조체는 포인터보다 값이 빠른 경우가 많다. 포인터는 8바이트를 복사하는 대신 역참조 비용이 붙고, 힙 할당을 유발할 수 있고, GC가 추적해야 할 대상이 하나 는다. 수십 바이트를 넘어가면 포인터가 유리해지기 시작하지만, 정확한 경계는 실측 영역이다.

3. nil이 의미를 갖는가. "값이 없음"을 표현해야 하면 포인터다. 앞서 본 Settings.Timeout이 그 경우다. 반대로 nil이 의미가 없다면 포인터는 호출자에게 "이게 nil일 수도 있나?"라는 부담만 준다.

정리하면 이렇다.

상황선택
호출자의 값을 고쳐야 함포인터
"값 없음"을 표현해야 함포인터
큰 구조체(수십 바이트 이상)를 자주 넘김포인터
작고 읽기만 하는 값
값 의미론이 자연스러운 것 (time.Time, Point)
복사되면 안 되는 것 (sync.Mutex를 품은 타입)포인터

마지막 줄은 안전 문제다. sync.Mutex를 값으로 복사하면 잠금 상태까지 복사돼 서로 다른 잠금이 된다. go vetcopylocks 분석기가 이를 잡아 준다. Part 7에서 다시 본다.

슬라이스와 맵은 포인터로 넘길 필요가 없다

fill, record가 그 증거다. 슬라이스 헤더의 포인터, 맵 값 자체가 이미 포인터라서 한 겹이면 충분하다. *[]int*map[string]int를 받는 함수를 쓰고 있다면 십중팔구 설계가 잘못됐다.

유일한 예외growInPlace처럼 슬라이스 헤더 자체를 바꿔야 할 때다. 길이나 용량이 바뀌면 헤더가 바뀌고, 그건 호출자에게 안 보인다. 그런데 이 경우조차 Go의 관용구는 포인터가 아니라 반환이다.

s = append(s, v) // 이쪽
appendInPlace(&s, v) // 이쪽이 아니다

append 자신이 func append(s []T, v ...T) []T인 이유다. 표준 라이브러리 전체가 이 방향을 따른다 — slices.Delete, slices.Insert, strconv.AppendInt 모두 새 슬라이스를 반환한다.

맵은 예외조차 없다. 맵에는 "헤더"에 해당하는 것이 없어서, 항목을 넣고 빼는 모든 작업이 포인터 없이 호출자에게 보인다. *map[K]V가 필요한 유일한 경우는 함수 안에서 m = make(...)맵 자체를 갈아 끼울 때인데, 그건 반환하는 게 낫다.

일관성도 기준이다

같은 타입을 다루는 함수들은 전부 값이거나 전부 포인터인 편이 낫다. 섞이면 호출부에서 &를 붙일지 말지 매번 생각해야 하고, 그 자체가 버그의 원천이다. Part 4에서 메서드를 배우면 이 원칙이 훨씬 중요해진다.

흔히 하는 실수

1. nil인지 확인하지 않고 역참조한다

특히 포인터를 반환하는 함수의 결과와 포인터 맵의 조회 결과다. 3-3에서 본 byPointer["about"].Hits++가 그 사례다.

p := find(id)
if p == nil {
return fmt.Errorf("%s 없음", id)
}

2. 필요 없는 곳에 포인터를 쓴다

func sum(nums *[]int) int // 필요 없다
func sum(nums []int) int // 이렇게

포인터가 하나 늘 때마다 호출부는 &를 신경 써야 하고, 읽는 사람은 "nil일 수 있나?", "이 함수가 내 값을 고치나?"를 확인해야 한다. 공짜가 아니다.

3. 작은 구조체를 포인터로 넘기면 항상 빠를 거라고 생각한다

Point{X, Y int}는 16바이트다. 포인터 8바이트보다 크지만, 값으로 넘기면 힙 할당이 없고 역참조도 없다. 실측 없이 포인터로 바꾸는 것은 최적화가 아니라 추측이다. 3-6에서 왜 포인터가 오히려 할당을 늘릴 수 있는지 본다.

4. 반복문에서 요소의 주소를 모아 놓고 안심한다

var ptrs []*Item
for i := range items {
ptrs = append(ptrs, &items[i]) // items의 백킹 배열을 가리킨다
}
items = append(items, extra) // 재할당이 일어나면 ptrs는 옛 배열을 본다

ptrs가 옛 배열을 가리키게 되고, 이후 items의 변경이 ptrs에 안 보인다. 반대도 마찬가지다. 3-2의 백킹 배열 이야기가 포인터로 번진 형태다.

(참고로 &items[i] 자체는 정상이다. Go 1.22부터 루프 변수가 반복마다 새로 만들어지므로 for _, v := range items { ptrs = append(ptrs, &v) }도 이제는 의도대로 동작한다. 2-6에서 본 변화다.)

5. sync.Mutex를 품은 구조체를 값으로 복사한다

go vetpasses lock by value로 잡아 준다. Part 7에서 다룬다.

6. *string, *bool 필드를 남발한다

"값 없음"이 진짜 필요한 자리에만 쓴다. *bool은 상태가 셋(nil/true/false)이라 if *flag를 쓸 때마다 nil 검사가 따라붙는다. 대부분은 그냥 bool과 잘 고른 제로값으로 충분하다.

정리

  • 포인터도 값이다. 복사되는 값이 주소일 뿐이다.
  • &로 주소, *로 역참조. 구조체는 -> 없이 .만 쓴다.
  • 포인터 산술이 없다. 메모리 안전과 GC 때문이고, 그 자리는 슬라이스가 채운다.
  • nil 역참조는 패닉이다. 포인터를 반환하는 함수의 결과는 확인하고 쓴다.
  • new는 이제 표현식을 받는다. new(30)ptr(30) 헬퍼를 대체한다.
  • 리터럴과 함수 반환값은 주소를 못 얻는다. 복합 리터럴 &T{}만 예외다.
  • 정의된 타입 type UserID int는 바탕 타입과 다른 타입이다. 인자 뒤바뀜을 컴파일러가 잡는다.
  • 판단 기준: 고쳐야 하는가 / 복사가 비싼가 / nil이 의미를 갖는가.
  • 슬라이스와 맵은 포인터로 넘기지 않는다. 헤더를 바꿔야 하면 반환한다.

연습문제

  1. func swap(a, b *int)을 쓰고, 같은 일을 포인터 없이 하는 방법과 비교해 보자. 어느 쪽이 Go답고 왜 그런가? 힌트: 2-5의 다중 반환값을 떠올려 보자.

  2. Settings 구조체에 *int, *string 필드를 두고, "설정 안 함 / 제로값으로 설정함"을 구분하는 merge(base, override Settings) Settings를 써 보자. new(expr)을 쓰면 테스트 데이터를 얼마나 짧게 쓸 수 있는지 확인해 보자.

  3. 필드가 두 개인 구조체와 스무 개인 구조체를 각각 만들고, 값으로 넘기는 함수와 포인터로 넘기는 함수를 써 보자. go build -gcflags=-m으로 어느 쪽이 힙에 할당되는지 확인해 보자. 결과가 예상과 같은가? 힌트: 다음 챕터가 이 질문에 답한다.