Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

14장. 포인터

13장에서 구조체를 만나면서 “여러 값을 묶어 하나로 다룬다” 는 감각을 익혔다. 이제 그 묶음을 함수에 넘기고, 원본을 직접 수정해야 할 일이 생긴다.

이때 등장하는 도구가 포인터다.

포인터는 “값” 이 아니라 “값이 사는 주소” 를 들고 다니는 변수다.

목표:

  • 값 복사와 참조의 차이를 이해한다
  • &* 의 의미를 익힌다
  • 함수에서 원본 값을 바꾸는 방법을 안다
  • Go 포인터가 C 포인터와 어떻게 다른지 안다

14.1 포인터가 왜 필요한가

값 복사라는 기본 동작

Go 에서 변수에 다른 변수를 대입하면 원본의 “값” 이 복사된다.

a := 10
b := a   // a 의 값 10 이 복사됨
b = 20

fmt.Println(a) // 10
fmt.Println(b) // 20

b 를 바꿔도 a 는 그대로다. 완전히 별개의 메모리 공간을 쓰기 때문이다.

함수에 인자를 넘길 때도 똑같다. 함수는 자기 안에서 쓸 복사본을 받는다.

func change(x int) {
    x = 999
}

func main() {
    n := 1
    change(n)
    fmt.Println(n) // 1 (그대로)
}

복사가 부담스러울 때

작은 정수 하나를 복사하는 비용은 사실상 0 이다.

하지만 큰 구조체라면 얘기가 달라진다.

type Report struct {
    Title   string
    Author  string
    Lines   [10000]string
}

이런 값을 함수에 넘길 때마다 10000개의 문자열이 통째로 복사된다. 호출이 잦으면 성능을 깎아먹는다.

원본을 바꾸고 싶을 때

함수 안에서 받은 값을 바꿔도 바깥 변수는 그대로라는 게 더 자주 문제가 된다.

func birthday(p Person) {
    p.Age++  // 복사본만 1 증가
}

p := Person{Name: "둘리", Age: 10}
birthday(p)
fmt.Println(p.Age) // 10 (변하지 않음)

원본을 바꾸려면 어떻게 해야 할까. “값” 대신 “값이 있는 자리” 를 넘기면 된다.

그 “자리” 가 바로 주소이고, 주소를 담는 변수가 포인터다.


14.2 &* 의 의미

포인터를 다룰 때는 두 기호를 쓴다.

기호이름
&x주소 연산자변수 x 가 사는 주소
*p역참조 연산자포인터 p 가 가리키는 값
*int타입 표기“int 를 가리키는 포인터” 타입

& 로 주소 얻기

x := 42
p := &x   // x 의 주소를 p 에 담는다

fmt.Println(x)   // 42
fmt.Println(&x)  // 0xc0000140a8 같은 주소
fmt.Println(p)   // 같은 주소

p 는 정수가 아니다. “정수가 들어 있는 메모리 칸의 주소” 다. 타입은 *int (int 를 가리키는 포인터).

* 로 값 꺼내기

주소만 들고 있어도 의미가 없다. 가리키는 값에 접근해야 한다. 이때 * 를 앞에 붙인다.

x := 42
p := &x

fmt.Println(*p)  // 42 (p 가 가리키는 값)

*p = 100         // p 가 가리키는 자리에 100 을 쓴다
fmt.Println(x)   // 100 (원본이 바뀌었다)

*p 는 두 가지로 쓰인다.

  • 값을 읽기: fmt.Println(*p)
  • 값을 쓰기: *p = 100

같은 * 인데 헷갈리는 이유

* 가 두 가지 자리에서 등장한다.

var p *int   // 여기 *는 "타입 표기"
*p = 10      // 여기 *는 "역참조"

위치로 구분하면 된다.

  • 타입 위치 (var p *int, func f(p *int)) → “포인터 타입”
  • 변수 앞 (*p, *p = ...) → “포인터가 가리키는 값”

14.3 포인터 변수 선언

명시적 선언

var p *int       // int 를 가리키는 포인터, 아직 아무것도 안 가리킴
fmt.Println(p)   // <nil>

선언만 하면 포인터의 초기값은 nil 이다. “아무것도 가리키지 않는다” 는 뜻이다.

:= 로 한 번에

대부분의 경우 이렇게 쓴다.

x := 10
p := &x          // 타입은 자동으로 *int

fmt.Println(*p)  // 10

nil 포인터 역참조의 위험

nil 인 포인터를 * 로 풀면 런타임 에러가 난다.

var p *int
fmt.Println(*p)  // panic: runtime error: invalid memory address

포인터는 쓰기 전에 반드시 어딘가를 가리키게 한다. 안 그러면 프로그램이 죽는다.

nil 검사 패턴

함수가 포인터를 받을 때 nil 체크를 먼저 하는 습관이 안전하다.

func printValue(p *int) {
    if p == nil {
        fmt.Println("(없음)")
        return
    }
    fmt.Println(*p)
}

14.4 함수에 포인터 넘기기

포인터의 가장 흔한 용도다.

값 전달: 원본이 안 바뀐다

func addOne(x int) {
    x = x + 1
}

func main() {
    n := 5
    addOne(n)
    fmt.Println(n) // 5
}

addOnen 의 복사본을 받았다. 복사본을 6 으로 바꿔봤자 바깥의 n 과는 무관하다.

포인터 전달: 원본을 바꿀 수 있다

func addOne(p *int) {
    *p = *p + 1
}

func main() {
    n := 5
    addOne(&n)
    fmt.Println(n) // 6
}

차이는 두 군데다.

  • 함수 시그니처: int*int
  • 호출 시: n&n
  • 함수 안: x*p

*p = *p + 1 은 “p 가 가리키는 자리에 들어 있는 값에 1 을 더해 다시 그 자리에 쓴다” 는 뜻이다.

두 변수 값 맞바꾸기 예제

값 전달로는 안 되고, 포인터로만 된다.

func swap(a, b *int) {
    *a, *b = *b, *a
}

func main() {
    x, y := 1, 2
    swap(&x, &y)
    fmt.Println(x, y) // 2 1
}

14.5 구조체와 포인터

포인터가 가장 자주 쓰이는 자리다.

큰 구조체 복사 비용 줄이기

type Person struct {
    Name string
    Age  int
}

func printPerson(p *Person) {
    fmt.Println(p.Name, p.Age)
}

값으로 받으면 매 호출마다 전체 구조체가 복사된다. 포인터로 받으면 주소(보통 8바이트) 만 넘어간다.

자동 역참조

C 라면 p->Name 처럼 따로 써야 하지만 Go 는 자동으로 풀어준다.

p := &Person{Name: "고길동", Age: 40}

fmt.Println(p.Name)     // OK (자동 역참조)
fmt.Println((*p).Name)  // 동일하지만 거의 안 씀

Go 에서 p.Fieldp 가 값이든 포인터든 똑같이 동작한다. 어느 쪽이라도 필드에 그대로 접근할 수 있다.

필드 수정도 자연스럽다

func birthday(p *Person) {
    p.Age++   // 원본이 바뀐다
}

func main() {
    me := Person{Name: "둘리", Age: 10}
    birthday(&me)
    fmt.Println(me.Age) // 11
}

new() 함수

새 구조체의 포인터를 한 번에 얻고 싶을 때 쓴다.

p := new(Person)
// p 는 *Person 타입
// 가리키는 Person 의 모든 필드는 제로값

위 코드는 다음과 같다.

var x Person
p := &x

세 가지 비교:

방식결과 타입메모
var x PersonPerson
p := &Person{}*Person가장 흔한 패턴
p := new(Person)*Person모든 필드 제로값

실무에서는 &Person{...} 가 가장 많이 쓰인다. 초기값을 같이 줄 수 있어 편하기 때문이다.


14.6 Go 포인터의 안전성

C / C++ 의 포인터는 강력한 만큼 무섭다. Go 는 그중 위험한 기능 대부분을 잘라냈다.

포인터 연산이 없다

C 에서는 이런 코드가 가능하다.

int *p = arr;
p++;       // 다음 요소 주소로 이동
p = p + 5; // 5칸 건너뛰기

Go 에서는 p + 1 같은 연산을 허용하지 않는다. “엉뚱한 메모리를 가리키게 만드는” 실수를 문법적으로 막아 둔 셈이다.

댕글링 포인터가 없다

C 에서는 이미 해제된 메모리를 포인터가 계속 가리키는 일이 흔하다.

Go 는 가비지 컬렉터가 있다. 어떤 포인터가 어떤 값을 가리키고 있다면 그 값은 절대 해제되지 않는다.

func newCounter() *int {
    n := 0
    return &n   // n 이 함수 스택에 있더라도 안전하다
}

n 은 함수가 끝나면 사라질 것 같지만, 포인터를 통해 바깥으로 새어 나가는 게 보이면 Go 컴파일러가 알아서 힙(heap)으로 옮긴다. 이걸 escape analysis 라고 부른다.

이 주제는 26장에서 더 깊이 다룬다. 지금은 “Go 는 포인터를 반환해도 안전하다” 만 기억하면 충분하다.

이중 해제가 없다

C 의 또 다른 단골 버그는 free 를 두 번 호출하는 것. Go 에는 free 자체가 없다. 사용하지 않는 메모리는 GC 가 알아서 정리한다.

정리: Go 포인터의 위치

기능C / C++Go
주소 얻기 (&)OO
역참조 (*)OO
포인터 산술 (p+1)OX
직접 freeOX (GC)
댕글링 포인터가능발생 안 함

Go 의 포인터는 “위험은 빼고, 편의는 남긴” 포인터다.


14.7 정리

이 장에서 살펴본 내용:

  • 값 대입과 인자 전달은 모두 “복사” 다
  • 원본을 바꾸려면 주소를 넘긴다 → 포인터
  • &x 는 주소, *p 는 그 자리의 값
  • *int, *Person 같은 타입 표기를 익혔다
  • 함수 시그니처에 *T 를 받으면 원본 수정이 가능하다
  • 구조체 포인터는 p.Field 로 자연스럽게 접근한다
  • new(T) 로 제로값 구조체의 포인터를 한 번에 얻는다
  • Go 포인터는 산술 / 댕글링 / 이중 해제가 없어 안전하다

포인터가 손에 익으면 다음 단계가 자연스럽게 열린다.

값을 “수정하는 동작” 을 함수에 묶어 두면 어떻게 될까. 그게 다음 장에서 다룰 메서드다. 포인터를 알게 됐으니 값 리시버와 포인터 리시버의 차이도 자연스럽게 이해할 수 있다.