Menu

Golang 포인터: &와 *, new, nil, 언제 쓰는가

포인터는 값의 주소를 담습니다. &와 *, new, 구조체 포인터, 지역 변수의 포인터를 안전하게 반환할 수 있는 이유, 포인터를 쓸 때, 그리고 nil 포인터 역참조 패닉을 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

&와 *

포인터는 값의 메모리 주소를 담습니다. &xx를 가리키는 포인터를 줍니다. *p는 포인터를 따라가 값에 닿습니다.

별표에는 두 가지 역할이 있습니다. 타입(*int)에서는 "int를 가리키는 포인터"를 뜻합니다. 표현식(*p)에서는 "p가 가리키는 값"을 뜻합니다. p 자체를 출력하면 0xc000012345 같은 주소가 나오며, 실행할 때마다 달라집니다.

포인터가 있는 이유: 복사 대신 공유

Go는 모든 것을 값으로 넘깁니다. 함수는 각 인자의 복사본을 받으므로 호출한 쪽 변수를 바꿀 수 없습니다. 포인터를 넘기면 주소가 복사되고, 양쪽이 같은 값에 닿습니다.

Go에는 C++에서 말하는 참조가 없습니다. Go에서 "참조에 의한 전달"은 포인터를 값으로 넘기는 것을 뜻합니다.

구조체 포인터

구조체 포인터는 Go 코드에서 가장 흔한 포인터입니다. 필드 접근은 자동으로 역참조됩니다. p.Name(*p).Name을 뜻합니다.

메서드도 같은 논리를 따릅니다. 포인터 리시버를 쓰면 메서드가 구조체를 바꿀 수 있습니다. 값 리시버와 포인터 리시버 중 무엇을 고를지는 메서드 페이지에서 다룹니다.

new

new(T)는 제로 값으로 채운 T를 할당하고 그 주소를 반환합니다. 변수를 선언하고 주소를 얻는 것과 같습니다.

p := new(int) // *int pointing at 0
var x int
q := &x           // same thing, two lines
a := new(Account) // same as &Account{}

실제로 구조체에는 같은 표현식에서 필드를 설정할 수 있는 &T{...}가 더 흔합니다. new는 기본 타입의 포인터에 편리합니다. 슬라이스, 맵, 채널을 만들고 포인터가 아닌 바로 쓸 수 있는 값을 반환하는 make와 헷갈리지 마세요.

지역 변수의 포인터를 반환해도 안전하다

C에서 지역 변수의 주소를 반환하는 것은 버그입니다. Go에서는 평범한 코드입니다.

값이 스택에 살지 힙에 살지는 컴파일러의 탈출 분석이 결정합니다. 직접 고를 일도 없고, 메모리를 해제할 일도 없습니다. 아무것도 가리키지 않으면 가비지 컬렉터가 회수합니다. 궁금하다면 go build -gcflags=-m이 그 결정을 출력해 줍니다.

nil 포인터

모든 포인터 타입의 제로 값은 nil입니다. nil을 역참조하면 패닉이 납니다.

출력:

1
2
true
recovered: runtime error: invalid memory address or nil pointer dereference

recover가 없다면 프로그램은 그 메시지와 함께 panic: runtime error: invalid memory address or nil pointer dereference[signal SIGSEGV: segmentation violation ...]로 시작하는 스택 트레이스를 남기고 죽습니다. 흔한 원인은 다음과 같습니다:

  • 한 번도 설정하지 않은 포인터 변수나 구조체 필드
  • map[string]*T에서 없는 키로 m[key]를 조회해 nil이 반환됨
  • 오류 무시: f, _ := os.Open(path)는 실패하면 f가 nil로 남음
  • nil 포인터에서 호출된 포인터 리시버 메서드가 필드를 읽음

nil 포인터에서 메서드를 호출하는 것 자체는 합법입니다. 메서드가 리시버의 필드에 접근할 때만 패닉이 납니다.

포인터를 쓸 때

다음 경우에 포인터를 쓰세요:

  • 함수나 메서드가 호출한 쪽의 값을 바꿔야 할 때
  • 값이 큰 구조체이고 자주 넘겨질 때
  • 타입이 복사되면 안 될 때(sync.Mutex를 담고 있거나, 커넥션처럼 유일한 자원을 나타낼 때)
  • nil로 "값 없음"을 표현해야 할 때. 예를 들어 JSON 구조체의 선택적 필드(*int는 "없음"과 0을 구분합니다)

다음 경우에는 포인터를 쓰지 마세요:

  • 값이 작고 읽기 전용일 때(time.Time, Point)
  • 타입이 이미 참조 같은 값일 때: 슬라이스, 맵, 채널, 함수, 인터페이스 앞에 *가 필요한 경우는 드뭅니다. *[]int*map[string]int는 거의 항상 실수입니다

포인터가 자동으로 더 빠른 것은 아닙니다. 작은 구조체를 복사하는 비용은 적고, 포인터는 힙 할당을 강제해서 가비지 컬렉터의 일을 늘릴 수 있습니다.

포인터와 반복 변수

Go 1.22부터는 반복마다 자기 변수를 가지므로 반복 변수의 주소를 얻어도 안전합니다:

이 코드는 0 1 2를 출력합니다. Go 1.22 이전(range 3도 1.22에서 도입되었습니다)에는 같은 반복문을 for i := 0; i < 3; i++로 쓰면 포인터 세 개가 변수 하나를 공유했기 때문에 3 3 3이 출력되었습니다. for _, v := range items에서 &v는 슬라이스 안의 요소가 아니라 요소의 복사본을 가리킨다는 점에 주의하세요. 요소 자체를 가리키는 포인터가 필요하면 &items[i]를 쓰세요.

흔한 실수

  • 확인 없이 역참조함. nil일 수 있는 곳에서는 if p == nil로 확인하세요.
  • range 값의 포인터. &v는 복사본을 가리킵니다. &s[i]를 쓰세요.
  • 슬라이스와 맵의 포인터. 거의 필요하지 않습니다. 대신 새 슬라이스를 반환하세요.
  • append를 거치면서 &s[i]를 들고 있음. append가 재할당하면 포인터는 여전히 이전 배열을 가리키고, 이후에 쓴 값은 보이는 곳에 반영되지 않습니다.

자주 묻는 질문

Go에서 &와 *는 무슨 뜻인가요?

&xx의 주소를 얻어 *T 타입의 포인터를 줍니다. *p는 포인터를 역참조해서 가리키는 값을 읽거나 씁니다. 타입에서 *T는 "T를 가리키는 포인터"를 뜻합니다.

Go에서 new와 make의 차이는 무엇인가요?

new(T)는 제로 값으로 채운 T를 할당하고 *T를 반환하며, 모든 타입에 쓸 수 있습니다. make는 슬라이스, 맵, 채널에만 쓸 수 있고, 그 타입의 초기화된(제로 값도 아니고 포인터도 아닌) 값을 반환합니다. make(map[string]int)는 바로 쓸 수 있지만, new(map[string]int)는 nil 맵을 가리키는 포인터입니다.

Go에서 지역 변수의 포인터를 반환해도 되나요?

네. C와 달리 안전합니다. 컴파일러의 탈출 분석(escape analysis)이 변수가 함수보다 오래 산다는 것을 파악하고 힙에 할당합니다. func newInt() *int { x := 5; return &x }는 올바른 Go 코드입니다.

Go에서 "invalid memory address or nil pointer dereference"는 왜 발생하나요?

nil 포인터를 통해 읽거나 쓸 때 발생합니다. 한 번도 대입하지 않은 *T 변수, map[K]*V에서 없는 키를 조회한 결과, 확인하지 않은 오류와 함께 nil을 반환한 함수 등이 원인입니다. nil인지 확인하거나 사용 전에 포인터를 초기화하세요.

Go에 포인터 연산이 있나요?

없습니다. 포인터에 값을 더하거나 포인터로 메모리를 인덱싱할 수 없습니다. unsafe 패키지는 저수준 코드를 위해 이를 허용하지만 안전성은 보장하지 않습니다. 일반적인 Go 코드는 연속된 메모리에 슬라이스를 씁니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기