본문으로 건너뛰기

포인터(Pointer)

Go의 포인터는 메모리 주소를 직접 다루는 강력한 기능입니다. C/C++의 포인터와 달리 포인터 산술 연산이 없어 안전하고, 가비지 컬렉터가 메모리를 관리합니다. 포인터를 이해하면 값 복사 비용을 줄이고, 함수 외부 변수를 수정할 수 있으며, 구조체 메서드를 효율적으로 작성할 수 있습니다.

포인터란 무엇인가?

포인터는 다른 변수의 메모리 주소를 저장하는 변수 입니다. Go에서는 두 가지 연산자를 사용합니다.

  • & (address-of 연산자): 변수의 메모리 주소를 얻습니다
  • * (dereference 연산자): 포인터가 가리키는 값에 접근합니다
package main

import "fmt"

func main() {
x := 42

// &x: x의 메모리 주소를 얻어 포인터 변수 p에 저장
p := &x
fmt.Println("x의 값:", x) // 42
fmt.Println("x의 주소:", &x) // 0xc0000b4008 (실행마다 다름)
fmt.Println("p의 값:", p) // 0xc0000b4008 (주소)
fmt.Println("p가 가리키는 값:", *p) // 42

// *p를 통해 x의 값을 변경
*p = 100
fmt.Println("변경 후 x:", x) // 100
fmt.Println("변경 후 *p:", *p) // 100
}

포인터 타입 선언

포인터 타입은 *타입명으로 선언합니다.

package main

import "fmt"

func main() {
var p1 *int // int를 가리키는 포인터 (nil)
var p2 *string // string을 가리키는 포인터 (nil)
var p3 *float64 // float64를 가리키는 포인터 (nil)

fmt.Println(p1, p2, p3) // <nil> <nil> <nil>

// 포인터에 주소 할당
n := 10
p1 = &n
fmt.Println(*p1) // 10

s := "hello"
p2 = &s
fmt.Println(*p2) // hello

// 포인터의 타입 확인
fmt.Printf("p1의 타입: %T\n", p1) // *int
fmt.Printf("p2의 타입: %T\n", p2) // *string
}

nil 포인터

선언만 하고 초기화하지 않은 포인터는 nil 값을 가집니다. nil 포인터를 역참조하면 런타임 패닉이 발생합니다.

package main

import "fmt"

func safeDeref(p *int) {
// nil 포인터 체크는 필수!
if p == nil {
fmt.Println("포인터가 nil입니다")
return
}
fmt.Println("값:", *p)
}

func main() {
var p *int
fmt.Println(p == nil) // true

safeDeref(p) // "포인터가 nil입니다" 출력

n := 42
safeDeref(&n) // "값: 42" 출력

// 주의: nil 포인터 역참조는 패닉!
// fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference
}

new() 함수

new() 함수는 지정한 타입의 제로 값을 힙에 할당하고 해당 메모리의 포인터를 반환합니다.

package main

import "fmt"

func main() {
// new(T)는 T 타입의 제로값을 할당하고 *T를 반환
p := new(int)
fmt.Println(*p) // 0 (int의 제로값)
fmt.Printf("%T\n", p) // *int

*p = 100
fmt.Println(*p) // 100

// 구조체에도 사용 가능
type Point struct {
X, Y int
}
pt := new(Point)
fmt.Println(*pt) // {0 0}
pt.X = 10
pt.Y = 20
fmt.Println(*pt) // {10 20}

// new(int)와 &int{} 비교
p1 := new(int)
n := 0
p2 := &n
fmt.Println(*p1 == *p2) // true (둘 다 0)
}

포인터를 매개변수로 사용하기

Go는 기본적으로 값에 의한 전달(pass by value) 을 사용합니다. 함수 내에서 원본 변수를 수정하려면 포인터를 전달해야 합니다.

package main

import "fmt"

// 값에 의한 전달: 원본 수정 불가
func doubleByValue(n int) {
n *= 2
fmt.Println("함수 내부:", n)
}

// 포인터에 의한 전달: 원본 수정 가능
func doubleByPointer(n *int) {
*n *= 2
fmt.Println("함수 내부:", *n)
}

// 두 변수를 교환하는 함수
func swap(a, b *int) {
*a, *b = *b, *a
}

func main() {
x := 10
doubleByValue(x)
fmt.Println("값 전달 후 x:", x) // 10 (변경 안됨)

y := 10
doubleByPointer(&y)
fmt.Println("포인터 전달 후 y:", y) // 20 (변경됨)

a, b := 3, 7
fmt.Printf("교환 전: a=%d, b=%d\n", a, b)
swap(&a, &b)
fmt.Printf("교환 후: a=%d, b=%d\n", a, b)
}

포인터 vs 값 반환

함수에서 지역 변수의 포인터를 반환하는 것이 Go에서는 안전합니다. Go 컴파일러가 해당 변수를 스택이 아닌 힙에 할당합니다(escape analysis).

package main

import "fmt"

// 지역 변수의 포인터 반환 — Go에서 완전히 안전!
func newInt(n int) *int {
result := n // 스택에 생성되지만 포인터 반환 시 힙으로 이동
return &result
}

type Counter struct {
count int
}

func newCounter() *Counter {
return &Counter{count: 0} // 힙에 할당된 Counter 반환
}

func (c *Counter) Increment() {
c.count++
}

func (c *Counter) Value() int {
return c.count
}

func main() {
p := newInt(42)
fmt.Println(*p) // 42

c := newCounter()
c.Increment()
c.Increment()
c.Increment()
fmt.Println(c.Value()) // 3

// 여러 포인터가 같은 데이터 공유
p1 := newInt(100)
p2 := p1 // p2는 p1과 같은 주소를 가리킴
*p2 = 200
fmt.Println(*p1) // 200 (p1도 변경됨)
fmt.Println(*p2) // 200
}

구조체와 포인터

구조체를 포인터로 다룰 때 Go는 자동으로 역참조를 수행하므로 (*p).Field 대신 p.Field로 접근할 수 있습니다.

package main

import "fmt"

type Person struct {
Name string
Age int
}

func birthday(p *Person) {
p.Age++ // (*p).Age++와 동일 — Go가 자동으로 역참조
}

func changeName(p *Person, name string) {
p.Name = name
}

func main() {
alice := Person{Name: "Alice", Age: 29}
fmt.Println(alice) // {Alice 29}

birthday(&alice)
fmt.Println(alice) // {Alice 30}

changeName(&alice, "Alicia")
fmt.Println(alice) // {Alicia 30}

// 포인터로 선언한 구조체
bob := &Person{Name: "Bob", Age: 25}
bob.Age = 26 // 자동 역참조
fmt.Println(*bob) // {Bob 26}

// 포인터 비교
p1 := &alice
p2 := &alice
fmt.Println(p1 == p2) // true (같은 주소)

carol := Person{Name: "Carol", Age: 22}
p3 := &carol
fmt.Println(p1 == p3) // false (다른 주소)
}

이중 포인터

포인터의 포인터도 사용할 수 있습니다. 실제로는 자주 쓰이지 않지만 개념을 이해하는 것이 중요합니다.

package main

import "fmt"

func modifyPointer(pp **int, newVal int) {
n := newVal
*pp = &n
}

func main() {
x := 42
p := &x // p는 x를 가리키는 포인터
pp := &p // pp는 p를 가리키는 포인터의 포인터

fmt.Println(x) // 42
fmt.Println(*p) // 42
fmt.Println(**pp) // 42

**pp = 100
fmt.Println(x) // 100

// 포인터 자체를 변경
y := 999
*pp = &y
fmt.Println(*p) // 999 (이제 p는 y를 가리킴)
fmt.Println(x) // 100 (x는 변경되지 않음)

// 함수를 통해 포인터 변경
modifyPointer(&p, 777)
fmt.Println(*p) // 777
}

슬라이스·맵과 포인터

슬라이스와 맵은 이미 내부적으로 포인터를 포함하므로, 함수에 전달할 때 별도의 포인터가 필요 없는 경우가 많습니다.

package main

import "fmt"

// 슬라이스 내용 수정 — 포인터 없이도 반영됨
func addOne(nums []int) {
for i := range nums {
nums[i]++
}
}

// append는 새 슬라이스를 반환하므로 포인터 또는 반환값이 필요
func appendElement(nums *[]int, elem int) {
*nums = append(*nums, elem)
}

// 맵 수정 — 포인터 없이도 반영됨
func addEntry(m map[string]int, key string, val int) {
m[key] = val
}

func main() {
// 슬라이스 내용 수정
s := []int{1, 2, 3, 4, 5}
addOne(s)
fmt.Println(s) // [2 3 4 5 6]

// append는 포인터 필요
appendElement(&s, 7)
fmt.Println(s) // [2 3 4 5 6 7]

// 맵은 포인터 없이 수정 가능
m := map[string]int{"a": 1}
addEntry(m, "b", 2)
fmt.Println(m) // map[a:1 b:2]
}

실전 예제: 연결 리스트 구현

포인터를 활용한 연결 리스트 구현입니다.

package main

import "fmt"

type Node struct {
Value int
Next *Node
}

type LinkedList struct {
Head *Node
Size int
}

func (l *LinkedList) Push(val int) {
node := &Node{Value: val, Next: l.Head}
l.Head = node
l.Size++
}

func (l *LinkedList) Pop() (int, bool) {
if l.Head == nil {
return 0, false
}
val := l.Head.Value
l.Head = l.Head.Next
l.Size--
return val, true
}

func (l *LinkedList) Print() {
current := l.Head
for current != nil {
fmt.Printf("%d", current.Value)
if current.Next != nil {
fmt.Print(" -> ")
}
current = current.Next
}
fmt.Println()
}

func main() {
list := &LinkedList{}

list.Push(1)
list.Push(2)
list.Push(3)
list.Print() // 3 -> 2 -> 1

val, ok := list.Pop()
fmt.Printf("Pop: %d, ok: %v\n", val, ok) // Pop: 3, ok: true
list.Print() // 2 -> 1

fmt.Println("크기:", list.Size) // 크기: 2
}

포인터와 인터페이스

인터페이스 구현 시 포인터 리시버와 값 리시버의 차이를 이해해야 합니다(자세한 내용은 Ch7에서 다룸).

package main

import "fmt"

type Stringer interface {
String() string
}

type MyType struct {
Value int
}

// 포인터 리시버로 인터페이스 구현
func (m *MyType) String() string {
return fmt.Sprintf("MyType(%d)", m.Value)
}

func printIt(s Stringer) {
fmt.Println(s.String())
}

func main() {
m := &MyType{Value: 42} // 포인터로 생성해야 인터페이스 구현
printIt(m) // MyType(42)

// m2 := MyType{Value: 42}
// printIt(m2) // 컴파일 에러: MyType does not implement Stringer
// printIt(&m2) // OK
}

핵심 정리

  • &변수: 변수의 주소를 얻음, *포인터: 포인터가 가리키는 값에 접근
  • nil 포인터 역참조는 패닉 → 항상 nil 체크 필요
  • new(T): 제로값으로 초기화된 T의 포인터 반환
  • Go의 지역 변수 포인터 반환은 안전 (escape analysis)
  • 구조체 포인터는 . 연산자로 자동 역참조
  • 슬라이스·맵은 이미 내부에 포인터 포함