Table of Contents
Golang - 포인터 사용하기
포인터의 개념
포인터는 값이 저장된 메모리의 위치 값을 가지고 있는 변수이다.
모든 변수는 하나 이상의 연속된 메모리 공간에 저장되는데, 그것을 주소라고 부른다. 포인터는 단순히 다른 변수가 저장된 주소를 내용으로 가지는 변수이다.
1var x int32 = 10
2var y bool = true
3pointerX := &x
4pointerY := &y
5var pointerZ *string
![](/images/go-use-pointer/1.png)
서로 다른 타입은 서로 다른 수의 메모리를 차지하지만, 모든 포인터는 어떤 타입을 가리키던 간에 항상 같은 크기를 가진다. pointerX
의 값은 x의 주소인 1, pointerY
의 값은 y의 주소인 5, pointerZ
는 아무것도 가리키고 있지 않기 때문에 0의 값을 가진다. 포인터의 제로 값은 nil이다.
1x := 10
2pointerToX := &x
3fmt.Println(pointerToX) // 메모리 주소 출력
4fmt.Println(*pointerToX) // 10
5
6z := 5 + *pointerToX
7fmt.Println(z) // 15
&
는 주소 연산자로, 변수 앞에 &
를 붙이면 해당 변수의 값이 저장된 메모리 위치의 주소를 반환한다.
*
는 간접 연산자로, 포인터 타입 변수 앞에 붙이면 가리키는 값을 반환하고 역참조라고 부른다.
포인터 타입
포인터가 어떤 타입을 가리키는지 나타낸다.
1x := 10
2var pointerToX *int
3pointerToX = &x
타입(int) 앞에 *를 사용하여 작성한다. 모든 타입을 기반으로 만들 수 있다.
내장 함수 new는 포인터 변수를 생성한다.
1var x = new(int)
2fmt.Println(x == nil) // false
3fmt.Println(*x) // 0
제공된 타입의 제로 값을 나타내는 포인터를 반환한다.
기본 타입 리터럴에서의 포인터
기본 타입 리터럴(숫자, 불리언, 문자열)나 상수 앞에는 메모리 주소를 가지지 않기 때문에 &를 사용할 수 없다. 기본타입을 위한 포인터가 필요하면 다음과 같이 사용한다.
1var x string
2y := &x
하나의 구조체 내에 기본 타입을 가리키는 포인터가 있다면, 리터럴을 해당 항목에 직접 할당할 수 없다.
1type person struct {
2 FirstName string
3 LastName *string
4}
5
6p := person{
7 FirstName "Pat",
8 LastName: "Perry", // 컴파일 에러
9}
이 문제를 해결하려면 두 가지 방법이 존재한다.
- 변수에 상수의 값을 가지게 한다.
- 헬퍼 함수를 작성하여 불리언, 숫자, 문자열 타입을 파라미터로 받아 해당 타입의 포인터를 반환한다.
1func stringp(s string) *string {
2 return &s
3}
4
5p := person{
6 FirstName: "Pat",
7 LastName: stringp("Perry"),
8}
상수를 함수로 전달했을 떄, 상수는 파라미터로 복사되고 변수가 되기 때문에 메모리에서 주소를 가지게 된다. 해당 함수는 결국 변수의 메모리 주소를 반환할 수 있게 된다.
함수에서의 사용
Go는 값에 의한 호출을 사용하는 언어이기 때문에 기본타입, 구조체, 배열과 같은 비 포인터 타입들은 호출된 함수에서 원본의 불변성을 보장한다. 하지만 포인터가 함수로 전달되면 호출된 함수에서 원본 데이터를 수정할 수 있다.
포인터를 함수에서 사용할 때 고려할 사항은 다음과 같다.
1. nil 포인터를 함수로 전달했을 때, 해당 값을 nil이 아닌 값으로 만들 수 없다.
1func failedUpdate(g *int) {
2 x := 10
3 g = &x
4}
5
6func main() {
7 var f *int // f = nil
8 failedUpdate(f)
9 fmt.Println(f) // nil
예시에서 f
변수는 nil을 가지고 시작하는데, failedUpdate를 호출하면 내부에서 g
변수가 x
를 가리키도록 변경되지, main에 있는 f
변수를 변경하지는 않는다. 따라서 failedUpdate가 종료되고 main으로 돌아와도 f
변수는 여전히 nil을 가진다. 즉, 포인터에 이미 할당된 값이 있는 경우에만 값을 재할당 할 수 있다.
2. 포인터 파라미터에 할당된 값을 유지하려면 포인터를 역참조하여 값을 설정해야 한다.
1func failedUpdate(px *int) {
2 x2 := 20
3 px = &x2
4}
5
6func update(px *int) {
7 *px = 20
8}
9
10func main() {
11 x := 10
12 failedUpdate(&x)
13 fmt.Println(x) // 10
14 update(&x)
15 fmt.Println(x) // 20
16}
failedUpdate에서는 px
포인터가 x2
의 주소를 가리키도록 변경되지만, 이는 x
의 주소와는 관련이 없다. 따라서 main으로 돌아왔을 때, main의 x
의 값은 변경되지 않는다. 하지만 update 에서는 x
의 주소를 가리키고 있는 px
파라미터를 역참조하여 값을 수정했기 때문에 main의 x
값이 변경된다.
포인터 사용시 주의사항
포인터는 데이터 흐름을 이해하기 어렵게 만들며, 가비지 컬렉터에게 추가적인 작업을 준다.
안티 패턴
1func MakeFoo(f *Foo) error {
2 f.Field1 = "val"
3 f.Field2 = 20
4 return nil
5}
구조체 전달을 포인트로 하여 항목을 채우는 것은 좋지 않은 코드이다.
모범 패턴
1func MakeFoo() (Foo, error) {
2 f := Foo{
3 Field1: "val",
4 Field2: 20,
5 }
6 return f, nil
7}
함수 내에서 구조체를 초기화하고 반환하는 것이 좋다.
포인터 파라미터를 사용하는 경우
포인터 파라미터를 사용해야 하는 경우는 함수가 해당 포인터를 인터페이스로 예상하는 경우에만 적용된다.
1f := struct {
2 Name string `json:"name"`
3 Age int `json:"age"`
4}
5err := json.Unmarshal([]byte(`{"name": "Bob", "age": 30}`), &f)
충분히 큰 구조체의 경우, 포인터를 이용하여 입력 파라미터 또는 반환값으로 구조체를 사용하여 성능을 향상시킬 수 있다. 모든 데이터 타입을 위한 포인터의 크기가 동일하기 때문이다. 대개의 경우, 포인터를 사용하는 것이 프로그램 성능에 영향을 주지 않지만, 함수 간에 메가바이트 이상의 데이터를 전달하는 경우 고려해볼만 하다.
슬라이스를 사용하는 경우
슬라이스는 길이, 수용력, 메모리 블록을 가리키는 포인터, 3개의 항목을 이루는 구조체로 이루어져 있다. 함수로 슬라이스를 넘길 경우 아래와 같은 사진처럼 복제된다. 길이와 수용력을 변경하는 것은 복사본만 변경되지 원본이 바뀔 수가 없다.
![](/images/go-use-pointer/2.png)
즉, 슬라이스의 내용을 수정하는 것은 원본에 반영이 되지만 append를 통해 길이를 변경하는 것은 슬라이스의 수용력과 관계 없이 원본에 반영되지 않는다. 기본적으로 함수로 넘겨진 슬라이스는 수정하지 않는 것이 권장된다.