12 minute read

개요

포인터는 메모리 주소를 저장하는 변수입니다. 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부터 개선)