[Go] pointer
개요
포인터는 메모리 주소를 저장하는 변수입니다. Go의 포인터는 안전하게 설계되어 있어 포인터 연산이 제한적이지만, 효율적인 메모리 관리와 데이터 공유를 가능하게 합니다.
주요 특징:
&연산자: 변수의 주소 획득*연산자: 포인터 역참조 (값 접근)- 로컬 변수 반환 가능: 스코프를 벗어나도 가비지 컬렉터가 관리
- 포인터 연산 불가: C/C++와 달리 안전성 보장
- nil 포인터: 초기화되지 않은 포인터의 기본값
기본 개념
1. 포인터 선언과 사용
package main
import "fmt"
func main() {
// 기본 변수
x := 42
fmt.Printf("x = %d, address = %p\n", x, &x)
// 포인터 선언 및 초기화
var p *int // int 포인터 선언 (nil로 초기화)
p = &x // x의 주소 할당
fmt.Printf("p = %p, *p = %d\n", p, *p)
// 포인터를 통한 값 수정
*p = 100
fmt.Printf("x = %d (포인터로 수정됨)\n", x)
// 짧은 선언으로 포인터 생성
y := 50
py := &y
fmt.Printf("py = %p, *py = %d\n", py, *py)
}
출력 예시:
x = 42, address = 0xc000018030
p = 0xc000018030, *p = 42
x = 100 (포인터로 수정됨)
py = 0xc000018038, *py = 50
2. nil 포인터
func nilPointer() {
var p *int
fmt.Println(p) // <nil>
// nil 체크
if p == nil {
fmt.Println("p is nil")
}
// nil 포인터 역참조는 패닉 발생
// fmt.Println(*p) // panic: runtime error: invalid memory address
// 안전한 사용
if p != nil {
fmt.Println(*p)
}
}
3. new 함수
func newFunction() {
// new는 타입의 제로값으로 초기화된 메모리를 할당하고 포인터 반환
p := new(int)
fmt.Printf("p = %p, *p = %d\n", p, *p) // *p = 0
*p = 42
fmt.Println(*p) // 42
// 구조체에 new 사용
type Person struct {
Name string
Age int
}
person := new(Person)
fmt.Printf("%+v\n", person) // &{Name: Age:0}
person.Name = "Alice" // (*person).Name과 동일 (자동 역참조)
person.Age = 25
fmt.Printf("%+v\n", person) // &{Name:Alice Age:25}
}
값 전달 vs 포인터 전달
1. Pass by Value (값 전달)
func modifyValue(x int) {
x = 100
fmt.Println("함수 내부:", x)
}
func main() {
x := 42
modifyValue(x)
fmt.Println("함수 외부:", x)
// 출력:
// 함수 내부: 100
// 함수 외부: 42 (변경 안 됨)
}
2. Pass by Pointer (포인터 전달)
func modifyPointer(p *int) {
*p = 100
fmt.Println("함수 내부:", *p)
}
func main() {
x := 42
modifyPointer(&x)
fmt.Println("함수 외부:", x)
// 출력:
// 함수 내부: 100
// 함수 외부: 100 (변경됨!)
}
3. 큰 구조체 전달
type LargeStruct struct {
data [1000]int
}
// 비효율적: 4KB 복사
func processByValue(ls LargeStruct) {
// ls는 복사본
}
// 효율적: 8바이트 포인터만 전달
func processByPointer(ls *LargeStruct) {
// ls는 원본 참조
}
func main() {
ls := LargeStruct{}
// 4KB 복사 발생
processByValue(ls)
// 포인터만 전달 (8바이트)
processByPointer(&ls)
}
로컬 변수의 포인터 반환
// Go에서는 안전함 (C/C++와 다름)
func createInt() *int {
x := 42
fmt.Printf("createInt 내부: x = %d, &x = %p\n", x, &x)
return &x // 스코프를 벗어나지만 안전
}
func main() {
p := createInt()
fmt.Printf("main: *p = %d, p = %p\n", *p, p)
// Go의 가비지 컬렉터가 메모리 관리
// 여러 번 호출해도 각각 다른 메모리 할당
p1 := createInt()
p2 := createInt()
fmt.Printf("p1 = %p, p2 = %p\n", p1, p2) // 다른 주소
}
이스케이프 분석 (Escape Analysis):
# 컴파일러가 포인터 이스케이프를 감지
go build -gcflags="-m" main.go
# 출력 예시:
# ./main.go:3:2: moved to heap: x
# (x가 함수를 벗어나므로 힙에 할당)
구조체와 포인터
1. 구조체 포인터 생성
type Person struct {
Name string
Age int
}
func main() {
// 방법 1: 일반 변수의 주소
p1 := Person{Name: "Alice", Age: 25}
ptr1 := &p1
// 방법 2: 직접 포인터 생성
ptr2 := &Person{Name: "Bob", Age: 30}
// 방법 3: new 사용
ptr3 := new(Person)
ptr3.Name = "Carol"
ptr3.Age = 28
fmt.Printf("%+v\n", ptr1) // &{Name:Alice Age:25}
fmt.Printf("%+v\n", ptr2) // &{Name:Bob Age:30}
fmt.Printf("%+v\n", ptr3) // &{Name:Carol Age:28}
}
2. 구조체 필드 접근
type Point struct {
X, Y int
}
func main() {
p := &Point{X: 10, Y: 20}
// Go는 자동으로 역참조
fmt.Println(p.X) // 10 (p.X는 (*p).X의 축약형)
fmt.Println((*p).X) // 10 (명시적 역참조)
p.X = 100
fmt.Println(p.X) // 100
}
메서드 리시버
1. 값 리시버 vs 포인터 리시버
type Counter struct {
count int
}
// 값 리시버: 복사본에 대해 동작
func (c Counter) IncrementValue() {
c.count++
fmt.Println("값 리시버 내부:", c.count)
}
// 포인터 리시버: 원본을 수정
func (c *Counter) IncrementPointer() {
c.count++
fmt.Println("포인터 리시버 내부:", c.count)
}
func main() {
c := Counter{count: 0}
c.IncrementValue()
fmt.Println("외부:", c.count) // 0 (변경 안 됨)
c.IncrementPointer()
fmt.Println("외부:", c.count) // 1 (변경됨)
// 포인터로 값 리시버 메서드 호출 가능 (자동 역참조)
pc := &Counter{count: 10}
pc.IncrementValue()
fmt.Println("외부:", pc.count) // 10
}
2. 포인터 리시버 사용 시점
type Rectangle struct {
Width, Height float64
}
// 읽기 전용 메서드: 값 리시버 가능 (작은 구조체)
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// 수정 메서드: 포인터 리시버 필수
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
// 큰 구조체: 포인터 리시버 권장 (복사 방지)
type LargeData struct {
data [10000]int
}
func (ld *LargeData) Process() {
// 포인터 리시버로 복사 방지
}
// 일관성: 일부 메서드가 포인터 리시버면 모두 포인터 리시버 사용 권장
type BankAccount struct {
balance float64
}
func (b *BankAccount) Deposit(amount float64) {
b.balance += amount
}
func (b *BankAccount) Withdraw(amount float64) {
b.balance -= amount
}
func (b *BankAccount) Balance() float64 {
return b.balance // 읽기 전용이지만 일관성을 위해 포인터 리시버
}
포인터와 슬라이스/맵/채널
1. 슬라이스와 포인터
func modifySlice(s []int) {
s[0] = 999 // 원본 슬라이스 수정됨
s = append(s, 100) // 새 슬라이스 생성, 원본에 영향 없음
}
func modifySlicePointer(s *[]int) {
(*s)[0] = 999 // 원본 슬라이스 수정됨
*s = append(*s, 100) // 원본 슬라이스 변경됨
}
func main() {
// 슬라이스는 이미 참조 타입처럼 동작
slice := []int{1, 2, 3}
modifySlice(slice)
fmt.Println(slice) // [999 2 3] (append는 반영 안 됨)
// 슬라이스 자체를 수정하려면 포인터 필요
slice = []int{1, 2, 3}
modifySlicePointer(&slice)
fmt.Println(slice) // [999 2 3 100]
}
2. 맵과 포인터
func modifyMap(m map[string]int) {
m["key"] = 100 // 원본 맵 수정됨
}
func main() {
// 맵은 참조 타입 (포인터 불필요)
m := map[string]int{"key": 1}
modifyMap(m)
fmt.Println(m) // map[key:100]
// 하지만 nil 맵 초기화에는 포인터 필요
var nilMap map[string]int
// nilMap["key"] = 1 // panic!
// 포인터로 nil 맵 초기화
initMap(&nilMap)
fmt.Println(nilMap) // map[key:1]
}
func initMap(m *map[string]int) {
*m = make(map[string]int)
(*m)["key"] = 1
}
new vs make
func newVsMake() {
// new: 포인터 반환, 제로값 초기화
p1 := new(int)
fmt.Printf("new(int): %p, %d\n", p1, *p1) // 0
s1 := new([]int)
fmt.Printf("new([]int): %p, %v, %v\n", s1, *s1, *s1 == nil) // nil slice
m1 := new(map[string]int)
fmt.Printf("new(map): %p, %v, %v\n", m1, *m1, *m1 == nil) // nil map
// make: 슬라이스/맵/채널 초기화, 값 반환
s2 := make([]int, 5)
fmt.Printf("make([]int): %v, len=%d, cap=%d\n", s2, len(s2), cap(s2))
m2 := make(map[string]int)
fmt.Printf("make(map): %v, len=%d\n", m2, len(m2))
ch := make(chan int, 10)
fmt.Printf("make(chan): %v\n", ch)
// new vs 복합 리터럴
p2 := new(Person)
p3 := &Person{}
fmt.Printf("new: %+v\n", p2) // &{Name: Age:0}
fmt.Printf("&{}: %+v\n", p3) // &{Name: Age:0}
// 둘 다 동일하지만 &{}가 초기값 지정 가능
p4 := &Person{Name: "Alice", Age: 25}
fmt.Printf("&{}: %+v\n", p4) // &{Name:Alice Age:25}
}
포인터 체이닝과 nil 처리
type Address struct {
City string
Country string
}
type Person struct {
Name string
Address *Address
}
func main() {
// nil 포인터 체이닝 문제
p1 := &Person{Name: "Alice"}
// fmt.Println(p1.Address.City) // panic: nil pointer dereference
// 안전한 접근
if p1.Address != nil {
fmt.Println(p1.Address.City)
} else {
fmt.Println("No address")
}
// 올바른 초기화
p2 := &Person{
Name: "Bob",
Address: &Address{
City: "Seoul",
Country: "Korea",
},
}
fmt.Println(p2.Address.City) // Seoul
}
// 헬퍼 함수로 안전하게 접근
func getCity(p *Person) string {
if p == nil || p.Address == nil {
return ""
}
return p.Address.City
}
이스케이프 분석 (Escape Analysis)
// 스택 할당 (이스케이프하지 않음)
func stackAllocation() int {
x := 42
return x // 값 복사, x는 스택에 할당
}
// 힙 할당 (이스케이프)
func heapAllocation() *int {
x := 42
return &x // 포인터 반환, x는 힙으로 이스케이프
}
// 작은 구조체는 스택
func smallStruct() Person {
return Person{Name: "Alice", Age: 25}
}
// 포인터 반환하면 힙
func largeStruct() *LargeData {
return &LargeData{}
}
// 이스케이프 분석 확인
// go build -gcflags="-m -m" main.go
컴파일러 최적화 예시:
$ go build -gcflags="-m" escape.go
./escape.go:4:2: x escapes to heap
./escape.go:3:6: can inline stackAllocation
./escape.go:8:6: can inline heapAllocation
포인터 사용 패턴
1. 옵셔널 필드
type Config struct {
Host string
Port int
Timeout *int // nil이면 기본값 사용
}
func NewConfig() *Config {
return &Config{
Host: "localhost",
Port: 8080,
// Timeout은 nil (옵셔널)
}
}
func (c *Config) GetTimeout() int {
if c.Timeout != nil {
return *c.Timeout
}
return 30 // 기본값
}
func main() {
cfg := NewConfig()
fmt.Println(cfg.GetTimeout()) // 30
timeout := 60
cfg.Timeout = &timeout
fmt.Println(cfg.GetTimeout()) // 60
}
2. 메서드 체이닝
type StringBuilder struct {
builder strings.Builder
}
func (sb *StringBuilder) Append(s string) *StringBuilder {
sb.builder.WriteString(s)
return sb // 포인터 반환으로 체이닝 가능
}
func (sb *StringBuilder) AppendLine(s string) *StringBuilder {
sb.builder.WriteString(s)
sb.builder.WriteString("\n")
return sb
}
func (sb *StringBuilder) String() string {
return sb.builder.String()
}
func main() {
result := (&StringBuilder{}).
Append("Hello").
Append(" ").
Append("World").
AppendLine("!").
Append("Go is awesome").
String()
fmt.Println(result)
// Hello World!
// Go is awesome
}
3. 싱글톤 패턴
type Database struct {
connection string
}
var instance *Database
var once sync.Once
func GetInstance() *Database {
once.Do(func() {
instance = &Database{
connection: "db://localhost:5432",
}
})
return instance
}
func main() {
db1 := GetInstance()
db2 := GetInstance()
fmt.Printf("db1: %p\n", db1)
fmt.Printf("db2: %p\n", db2)
fmt.Println("Same instance:", db1 == db2) // true
}
성능 고려사항
1. 작은 값 vs 포인터
import "testing"
type SmallStruct struct {
a, b int
}
type LargeStruct struct {
data [1000]int
}
func processByValue(s SmallStruct) int {
return s.a + s.b
}
func processByPointer(s *SmallStruct) int {
return s.a + s.b
}
func BenchmarkSmallValue(b *testing.B) {
s := SmallStruct{a: 1, b: 2}
for i := 0; i < b.N; i++ {
processByValue(s)
}
}
func BenchmarkSmallPointer(b *testing.B) {
s := &SmallStruct{a: 1, b: 2}
for i := 0; i < b.N; i++ {
processByPointer(s)
}
}
// 작은 구조체: 값 전달이 더 빠를 수 있음 (스택 할당)
// 큰 구조체: 포인터 전달이 훨씬 빠름
2. 힙 할당 최소화
// 비효율적: 매번 힙 할당
func inefficient() {
for i := 0; i < 1000; i++ {
p := &Person{Name: "Alice", Age: 25}
process(p)
}
}
// 효율적: 스택 할당 후 필요시에만 포인터 전달
func efficient() {
for i := 0; i < 1000; i++ {
p := Person{Name: "Alice", Age: 25}
// 값으로 처리 가능하면 값 사용
processValue(p)
}
}
일반적인 실수
1. 반복문에서 포인터 캡처
// ❌ 잘못된 예 (Go 1.21 이전)
func wrongLoop() {
numbers := []int{1, 2, 3, 4, 5}
var pointers []*int
for _, n := range numbers {
pointers = append(pointers, &n) // 모두 같은 변수 참조
}
for _, p := range pointers {
fmt.Print(*p, " ") // 5 5 5 5 5
}
fmt.Println()
}
// ✅ 올바른 예 1: 인덱스 사용
func correctLoop1() {
numbers := []int{1, 2, 3, 4, 5}
var pointers []*int
for i := range numbers {
pointers = append(pointers, &numbers[i])
}
for _, p := range pointers {
fmt.Print(*p, " ") // 1 2 3 4 5
}
fmt.Println()
}
// ✅ 올바른 예 2: 새 변수 생성 (Go 1.21 이전)
func correctLoop2() {
numbers := []int{1, 2, 3, 4, 5}
var pointers []*int
for _, n := range numbers {
n := n // 새 변수
pointers = append(pointers, &n)
}
for _, p := range pointers {
fmt.Print(*p, " ") // 1 2 3 4 5
}
fmt.Println()
}
2. nil 맵/슬라이스에 쓰기
func nilMapSlice() {
var m map[string]int
var s []int
// ❌ nil 맵에 쓰기 시도
// m["key"] = 1 // panic!
// ✅ 초기화 후 사용
m = make(map[string]int)
m["key"] = 1
// ✅ nil 슬라이스는 append 가능
s = append(s, 1) // OK (새 슬라이스 생성)
fmt.Println(s) // [1]
}
3. 포인터 비교
func pointerComparison() {
a := 42
b := 42
pa := &a
pb := &b
// 주소 비교
fmt.Println(pa == pb) // false (다른 메모리 주소)
fmt.Println(*pa == *pb) // true (같은 값)
// 같은 변수의 포인터
pc := &a
fmt.Println(pa == pc) // true (같은 주소)
}
4. 구조체 복사 시 포인터 필드
type Node struct {
Value int
Next *Node
}
func shallowCopy() {
n1 := &Node{Value: 1, Next: &Node{Value: 2}}
n2 := *n1 // 얕은 복사
n2.Value = 100
n2.Next.Value = 200
fmt.Println(n1.Value) // 1 (변경 안 됨)
fmt.Println(n1.Next.Value) // 200 (변경됨! 포인터 공유)
}
// 깊은 복사 필요 시 명시적 구현
func deepCopy(n *Node) *Node {
if n == nil {
return nil
}
return &Node{
Value: n.Value,
Next: deepCopy(n.Next),
}
}
실전 예제
1. 링크드 리스트
type ListNode struct {
Value int
Next *ListNode
}
type LinkedList struct {
Head *ListNode
}
func (ll *LinkedList) Append(value int) {
newNode := &ListNode{Value: value}
if ll.Head == nil {
ll.Head = newNode
return
}
current := ll.Head
for current.Next != nil {
current = current.Next
}
current.Next = newNode
}
func (ll *LinkedList) Print() {
current := ll.Head
for current != nil {
fmt.Printf("%d -> ", current.Value)
current = current.Next
}
fmt.Println("nil")
}
func main() {
list := &LinkedList{}
list.Append(1)
list.Append(2)
list.Append(3)
list.Print() // 1 -> 2 -> 3 -> nil
}
2. 트리 구조
type TreeNode struct {
Value int
Left *TreeNode
Right *TreeNode
}
func insert(root *TreeNode, value int) *TreeNode {
if root == nil {
return &TreeNode{Value: value}
}
if value < root.Value {
root.Left = insert(root.Left, value)
} else {
root.Right = insert(root.Right, value)
}
return root
}
func inorderTraversal(root *TreeNode) {
if root == nil {
return
}
inorderTraversal(root.Left)
fmt.Printf("%d ", root.Value)
inorderTraversal(root.Right)
}
func main() {
var root *TreeNode
values := []int{5, 3, 7, 1, 4, 6, 8}
for _, v := range values {
root = insert(root, v)
}
inorderTraversal(root) // 1 3 4 5 6 7 8
fmt.Println()
}
3. 빌더 패턴
type HTTPRequest struct {
method string
url string
headers map[string]string
body string
}
type RequestBuilder struct {
request *HTTPRequest
}
func NewRequestBuilder() *RequestBuilder {
return &RequestBuilder{
request: &HTTPRequest{
headers: make(map[string]string),
},
}
}
func (rb *RequestBuilder) Method(method string) *RequestBuilder {
rb.request.method = method
return rb
}
func (rb *RequestBuilder) URL(url string) *RequestBuilder {
rb.request.url = url
return rb
}
func (rb *RequestBuilder) Header(key, value string) *RequestBuilder {
rb.request.headers[key] = value
return rb
}
func (rb *RequestBuilder) Body(body string) *RequestBuilder {
rb.request.body = body
return rb
}
func (rb *RequestBuilder) Build() *HTTPRequest {
return rb.request
}
func main() {
req := NewRequestBuilder().
Method("POST").
URL("https://api.example.com/users").
Header("Content-Type", "application/json").
Header("Authorization", "Bearer token123").
Body(`{"name":"Alice"}`).
Build()
fmt.Printf("%+v\n", req)
}
정리
- 포인터는 메모리 주소를 저장 (
&로 주소 획득,*로 역참조) - Go는 로컬 변수 포인터 반환 가능 (가비지 컬렉터 관리)
- 포인터 전달로 원본 수정 및 복사 비용 절감
- nil 포인터는 초기값이며 역참조 시 패닉 발생
- 메서드: 수정 필요 시 포인터 리시버, 큰 구조체도 포인터 권장
- 슬라이스/맵은 참조 타입처럼 동작하지만 재할당에는 포인터 필요
- new는 포인터 반환, make는 슬라이스/맵/채널 초기화
- 이스케이프 분석으로 스택/힙 할당 결정
- 작은 구조체는 값 전달이 더 효율적일 수 있음
- 반복문에서 포인터 캡처 주의 (Go 1.22부터 개선)