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
}
addOne 은 n 의 복사본을 받았다.
복사본을 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.Field는p가 값이든 포인터든 똑같이 동작한다. 어느 쪽이라도 필드에 그대로 접근할 수 있다.
필드 수정도 자연스럽다
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 Person | Person | 값 |
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 |
|---|---|---|
주소 얻기 (&) | O | O |
역참조 (*) | O | O |
포인터 산술 (p+1) | O | X |
직접 free | O | X (GC) |
| 댕글링 포인터 | 가능 | 발생 안 함 |
Go 의 포인터는 “위험은 빼고, 편의는 남긴” 포인터다.
14.7 정리
이 장에서 살펴본 내용:
- 값 대입과 인자 전달은 모두 “복사” 다
- 원본을 바꾸려면 주소를 넘긴다 → 포인터
&x는 주소,*p는 그 자리의 값*int,*Person같은 타입 표기를 익혔다- 함수 시그니처에
*T를 받으면 원본 수정이 가능하다 - 구조체 포인터는
p.Field로 자연스럽게 접근한다 new(T)로 제로값 구조체의 포인터를 한 번에 얻는다- Go 포인터는 산술 / 댕글링 / 이중 해제가 없어 안전하다
포인터가 손에 익으면 다음 단계가 자연스럽게 열린다.
값을 “수정하는 동작” 을 함수에 묶어 두면 어떻게 될까. 그게 다음 장에서 다룰 메서드다. 포인터를 알게 됐으니 값 리시버와 포인터 리시버의 차이도 자연스럽게 이해할 수 있다.