Skip to content
ioob.dev
Go back

Go 입문 8편 — 포인터

· 5분 읽기

Table of contents

주소를 다루는 이유

Go는 기본적으로 값을 복사해서 전달한다. 함수에 변수를 넘기면 그 변수의 복사본이 만들어지고, 함수 안에서 아무리 바꿔도 원본에는 영향이 없다.

package main

import "fmt"

func tryChange(x int) {
    x = 100
}

func main() {
    n := 42
    tryChange(n)
    fmt.Println(n) // 42 — 안 바뀜
}

tryChange가 받는 xn의 복사본이다. 함수 안에서 100으로 바꿔봤자 원본 n은 여전히 42다. 작은 정수 하나라면 상관없겠지만, 큰 구조체를 매번 복사하는 건 메모리와 성능 면에서 낭비일 수 있다. 원본을 직접 수정해야 하는 경우도 있고.

이럴 때 포인터가 등장한다.

& — 주소 얻기

& 연산자는 변수의 메모리 주소를 가져온다.

package main

import "fmt"

func main() {
    x := 42
    p := &x // p는 x의 주소를 담고 있다

    fmt.Println(x)  // 42
    fmt.Println(p)  // 0xc0000b6010 (메모리 주소, 실행마다 다름)
    fmt.Printf("타입: %T\n", p) // *int
}

p의 타입은 *int다. “int를 가리키는 포인터”라는 뜻으로, 값 자체가 아니라 값이 저장된 위치를 들고 있는 셈이다.

* — 주소가 가리키는 값

* 연산자는 포인터가 가리키는 실제 값에 접근한다. 역참조(dereference)라고 부른다.

package main

import "fmt"

func main() {
    x := 42
    p := &x

    fmt.Println(*p) // 42 — p가 가리키는 값
    *p = 100        // p가 가리키는 곳의 값을 100으로
    fmt.Println(x)  // 100 — 원본이 바뀌었다!
}

*p = 100p가 가리키는 메모리 위치에 직접 100을 쓰는 거다. px는 같은 메모리를 보고 있으니, x의 값도 바뀐다. C 포인터와 원리가 같지만, Go에는 포인터 산술이 없어서 훨씬 안전하다.

포인터로 함수에서 원본 수정하기

아까 실패했던 tryChange를 포인터 버전으로 다시 만들어보자.

package main

import "fmt"

func changeByPointer(p *int) {
    *p = 100
}

func main() {
    n := 42
    changeByPointer(&n)
    fmt.Println(n) // 100 — 이번엔 바뀐다
}

&n으로 주소를 넘기고, 함수 안에서 *p로 역참조해서 값을 수정한다. 값 전달 vs 포인터 전달의 차이를 한눈에 보여주는 예시다.

정리하면 이렇다.

전달 방식복사 대상원본 수정
값 전달 (func f(x int))값 자체불가
포인터 전달 (func f(p *int))주소(8바이트)가능

new — 포인터 생성

new 함수는 지정한 타입의 제로 값을 할당하고 그 포인터를 반환한다.

package main

import "fmt"

func main() {
    p := new(int)    // *int, 0으로 초기화됨
    fmt.Println(*p)  // 0

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

    s := new(string) // *string, ""으로 초기화됨
    fmt.Println(*s)  // (빈 문자열)
}

new(int)int 크기만큼 메모리를 잡고, 0으로 초기화한 뒤, 그 주소를 돌려준다. 실무에서는 new보다 &T{} 패턴을 더 자주 쓴다. 구조체를 만들면서 초기값을 바로 설정할 수 있기 때문이다.

// new 사용
p := new(Config)
p.Port = 8080

// &T{} 사용 — 더 관용적
p := &Config{Port: 8080}

구조체 포인터

구조체는 여러 필드를 묶은 타입이라 크기가 클 수 있다. 함수에 넘길 때마다 전체를 복사하면 비효율적이니, 구조체는 포인터로 다루는 경우가 많다.

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func birthday(u *User) {
    u.Age++ // (*u).Age++와 같지만, Go가 자동으로 역참조해준다
}

func main() {
    u := &User{Name: "고퍼", Age: 10}
    birthday(u)
    fmt.Printf("%s: %d\n", u.Name, u.Age) // 고퍼: 11살
}

Go에서는 구조체 포인터로 필드에 접근할 때 (*u).Age 대신 u.Age로 쓸 수 있다. 컴파일러가 알아서 역참조를 해주기 때문이다. C에서의 -> 연산자가 Go에서는 .으로 통일된 셈이라, 문법이 깔끔해진다.

메서드 리시버에서도 포인터를 자주 쓴다.

func (u *User) SetName(name string) {
    u.Name = name // 원본 수정
}

func (u User) GetName() string {
    return u.Name // 복사본 읽기 — 원본 변경 없음
}

원본을 수정해야 하면 포인터 리시버(*User), 읽기만 하면 값 리시버(User)를 쓴다. 다만 한 타입에 포인터 리시버 메서드가 하나라도 있으면, 일관성을 위해 나머지도 전부 포인터 리시버로 통일하는 게 Go 커뮤니티의 관례다.

nil 포인터

포인터를 선언만 하고 초기화하지 않으면 기본값은 nil이다.

package main

import "fmt"

func main() {
    var p *int
    fmt.Println(p) // <nil>

    if p != nil {
        fmt.Println(*p) // nil 체크 없이 역참조하면 panic
    }
}

nil 포인터를 역참조하면 런타임 panic이 발생한다. 방어적으로 코드를 짜려면, 포인터를 받는 함수에서 nil 체크를 먼저 하는 습관을 들이는 게 좋다.

func printUser(u *User) {
    if u == nil {
        fmt.Println("사용자 정보 없음")
        return
    }
    fmt.Printf("%s (%d살)\n", u.Name, u.Age)
}

언제 포인터를 쓸까

모든 곳에 포인터를 쓸 필요는 없다. 몇 가지 기준을 정리하면 이렇다.

포인터를 쓰는 경우:

값을 그대로 쓰는 경우:

Go의 포인터는 C에 비하면 훨씬 안전하다. 포인터 산술이 없고, 가비지 컬렉터가 메모리를 알아서 회수하니까 dangling pointer 같은 문제도 없다. 그렇다고 마음 놓고 쓰면 nil 역참조 같은 실수는 여전히 생기니, 포인터를 쓸 때는 항상 “이 포인터가 nil일 수 있는가?”를 먼저 생각하는 게 좋다.


포인터의 핵심은 간단하다. &는 주소를 얻고, *는 주소가 가리키는 값에 접근한다. 값 전달의 한계를 넘어서 원본을 수정하거나, 큰 데이터의 복사를 피할 때 포인터가 등장한다. 구조체와 메서드 리시버에서 특히 자주 쓰이니, 이 패턴에 익숙해지면 Go 코드를 읽는 속도가 확 빨라질 거다.

다음 편에서는 Go의 킬러 피처인 고루틴과 채널을 다룬다. 동시성 프로그래밍이 Go에서 어떻게 자연스러운 일이 되는지 살펴보자.

-> 9편: 고루틴과 채널


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 9편 — 고루틴과 채널
Next Post
Go 입문 7편 — 인터페이스