go를 알아보자(3) - 포인터
목차
아주 오래전, 중학생이었던 제가 혼자서 C를 공부했던 시절이 있었습니다. 터보C 2.0 컴파일러와 안녕하세요 터보C 라는 책을 하나 들고 열심히 공부를 했습니다. 그때 당시엔 함수라는 개념을 도저히 이해할 수 없었습니다. 그리고 시간이 1-2년 지나서 그 책을 다시 집어 들고 공부를 했지만 이번엔 포인터, 구조체의 개념을 도저히 이해할 수 없었던 기억이 납니다. 그리고 포인터는 대학교 2학년이 되어서야 제대로(??? 그러니까 한 2차원 배열을 다룰 정도?) 이해할 수 있었습니다.
그리고는 그 당시 베타 버전이 출시되던 닷넷(.NET)으로 넘어가면서 포인터와의 악연은 막을 내렸죠. 그런데 말입니다…
최근에 작업을 Go로 하려다보니… 어쩔 수 없이 다시 포인터를 마주하게 됩니다. 20년도 더 지나서 포인터를 다시 보니 너무 헷갈려서 머리가 빙빙 돌더군요. 그래서 저를 위해서 포인터를 다시 한 번 정리해보자, 하는 하찮은 이유로 그냥 정리해봅니다. 미래의 저를 위해서!
포인터…⌗
포인터는 말 그대로 그냥 어느 한 지점을 가리키는 걸 말합니다. 포인터는 두 개의 특수기호 *, & 가 연관되어 있는데요, 우선 *는 어디에서 사용되느냐에 따라 의미가 다릅니다.
- 타입의 앞에 붙은 경우(ex: *bool) -> 메모리에서 해당 타입의 값이 위치하는 곳을 가리키는 포인터
- 표현식에서 사용된 경우(ex: *isActive) -> 포인터가 가리키는 지점에 위치한 값, 참조 해제(포인터->실제 값)
그리고 &는 메모리의 주소를 가져오는 연산자로, 변수나 구조체등에 붙이게 되면 값이 위치한 메모리의 주소를 리턴합니다.
다음 예시를 볼까요?
var b bool = true // bool 타입의 변수 b에 true를 설정 -> 메모리의 어딘가 변수 b가 할당됨
var p *bool = &b // 변수 b의 메모리 주소를 bool타입의 포인터 p에 할당
fmt.Println(p) // 포인터 p가 가리키는 -> 변수 b의 메모리 주소가 출력됨(ex: 0xc0000140a0)
fmt.Println(*p) // 포인터 p가 가리키는 -> 참조 해제 -> 변수 b의 값이 출력됨(true)
*p = false // 포인터 p가 가리키는 -> 변수 b의 값을 false로 변경
fmt.Println(b) // 변수 b의 값이 출력됨(false)
그림으로 보면 이렇습니다.
메모리:
┌──────────────┐
변수 b 0xc000014090 │ true │ ◄──┬── *p (참조 해제 결과: true)
└──────────────┘ │
│ p가 가진 주소를
│ 따라가서 도달
┌──────────────┐ │
포인터 p 0xc0000140a0 │0xc000014090 │ ───┘
└──────────────┘ (&b로 얻은 주소를 값으로 가짐)
&b는 변수 b가 위치한 메모리 주소(0xc000014090)를 얻어옵니다.- 포인터 p는 그 주소를 자신의 값으로 가집니다.
*p는 p가 가리키는 곳으로 따라가서 실제 값(true)을 가져옵니다.
간단해 보이지만, 두고두고 사람을 헷갈리게 만드는 요소입니다. 제가 그렇습니다.
nil 포인터 - 아무것도 가리키지 않는 포인터⌗
포인터에는 한 가지 특별한 값이 있는데요, 바로 nil 입니다. 다른 언어의 null 과 비슷한 개념이라고 생각하시면 됩니다. 포인터를 선언만 하고 아무 주소도 할당하지 않으면 zero value인 nil 이 되죠.
var p *bool // 선언만 함 -> p는 nil
fmt.Println(p) // <nil>
if p == nil {
fmt.Println("p는 아무것도 가리키지 않습니다")
}
fmt.Println(*p) // panic!
// panic: runtime error: invalid memory address or nil pointer dereference
nil 포인터를 역참조(*p) 하려고 하면 위 코드의 마지막 줄처럼 런타임 패닉이 터집니다. 그래서 포인터를 다룰 때는 사용하기 전에 nil 체크를 하는 습관이 중요합니다.
도대체 이런 포인터를 왜 사용하게 된 걸까요? 메모리를 빠르고 효율적으로 사용하기 위해서 그렇습니다. 좀 더 알아볼까요?
스택(stack) vs 힙(heap)⌗
프로그램이 실행될 때는 스택, 힙 이렇게 2가지 유형의 메모리를 사용합니다.
스택: 여러분이 알고계시는 바로 그 스택입니다. 가장 나중에 들어온 게 가장 먼저 나가는(LIFO) 방식으로 이 타입의 메모리는 관리가 매우 쉽습니다. 메모리에 할당할 때는 스택의 꼭대기를 나타내는 top 포인터를 증가/감소 시키기만 하면 됩니다. 함수가 호출될 때 마다 해당 함수에서 사용하는 모든 지역 변수들을 하나의 프레임으로 구성하여 스택의 꼭대기에 push하고, 함수의 실행이 종료되면 프레임 전체를 pop합니다.
힙: 함수의 실행 주기와 관계없이 프로그램 전체에서 공유되어야 하거나, 스택에 할당되기에 너무 큰 메모리의 경우 힙에 할당됩니다. 스택에서는 함수 실행 주기에 따라 메모리에서 자동적으로 pop을 하는 방식으로 관리가 되지만, 힙은 함수 실행과 관계없이 그대로 남아있습니다. 그래서 주기적으로 가비지 컬렉터(GC)가 더 이상 사용되지 않는 항목을 찾아서 제거하고 메모리를 압축하는 등의 작업을 수행합니다.
이 둘의 차이를 그림으로 보면 이렇습니다.
STACK (LIFO) HEAP (자유 할당)
┌──────────────────────┐
┌─────────────────┐ ◄─ top │ ┌────┐ ┌──────┐ │
│ bar() frame │ (가장 │ │obj1│ │ obj2 │ │
├─────────────────┤ 나중에 │ └────┘ └──────┘ │
│ foo() frame │ push됨) │ ┌────┐ │
├─────────────────┤ │ │obj3│ ← GC가 │
│ main() frame │ │ └────┘ 정리 │
└─────────────────┘ └──────────────────────┘
호출 순서: main() → foo() → bar()
push/pop 으로 관리
아주 단순하게 말하면 값 타입(value type)은 스택에, 참조 타입(reference type)은 힙 메모리에 할당된다고 말할 수 있는데요.(물론 Go 컴파일러는 escape analysis를 수행해서 함수의 수명보다 길게 살아남을 요소들은 힙에 할당하기도 합니다.) 그러면 값 타입과 참조 타입에 대해서 알아보죠.
값 타입(value type) vs 참조 타입(reference type)⌗
일단 값 타입(value type)과 참조 타입(reference type)부터 정리하고 가겠습니다. 너무 기본이지만 어느새 까먹어 버리는 개념이기 때문이죠.
값 타입(value type): int, bool 같은 기본(또는 원시 primitive)타입과 배열, 구조체 등의 복합 타입은 일반적으로 스택에 할당됩니다.
참조 타입(reference type): 슬라이스, 맵, 채널, 인터페이스, 포인터 처럼 힙 메모리 어딘가에 위치하는 실제 데이터를 참조하는 타입입니다.
그런데 사실 참조 타입은 값 타입이기도 합니다. 위에서 설명드린 것 처럼 값 타입이지만, 메모리 어딘가에 위치하는 데이터를 가리키는 포인터를 값으로 가지는 타입을 편의상 참조 타입으로 부르는 거죠.
Go에서는 기본적으로 모든 걸 값으로 전달(pass by value, 값을 복제해서 전달함) 하는데요, 여기에서 참조 타입은 약간 헷갈릴 수도 있게 동작합니다.
다음 코드를 볼까요?
package main
import "fmt"
func main() {
array1 := [3]int{10, 20, 30} // 값 타입 배열 선언
fmt.Println(array1) // [10 20 30]
array2 := array1 // array2 에 array1 할당(pass by value, 배열이 복사됨)
array2[0] = 100
fmt.Println(array1) // [10 20 30] array1과 array2는 별개의 복사본이므로 변화 없음
slice1 := make([]int, 3, 5) // len:3, cap: 5
slice1[0] = 10
slice1[1] = 20
slice1[2] = 30
fmt.Println(slice1) // [10 20 30]
slice2 := slice1 // slice1를 slice2에 할당(pass by value)
slice2[0] = 100
fmt.Println(slice1) // [100 20 30] slice1과 slice2는 동일한 배열 데이터를 참조함
}
값 타입인 배열의 경우, array2 := array1 에서 배열의 모든 요소들이 새로운 배열로 복제되어 array2에 할당됩니다. 그래서 array2의 데이터를 변경해도 array1은 전혀 영향을 받지 않죠.
참조 타입인 슬라이스의 경우 slice2 := slice1 에서 값 타입인 슬라이스의 헤더(실제 배열 값을 가리키는 포인터, 슬라이스 요소 개수 len, 배열 길이 cap)만 복제되어 slice2로 전달됩니다. 두 슬라이스 모두 동일한 포인터를 가지고 있기 때문에 slice2의 요소를 변경하면 slice1도 변경된 값을 참조하게 됩니다.
이 차이를 그림으로 보면 한눈에 들어옵니다.
▶ 배열 (값 타입) - array2 := array1 후, array2[0] = 100 까지 실행한 상태
array1 array2
┌──┬──┬──┐ 복사 ┌───┬──┬──┐
│10│20│30│ ────────► │100│20│30│ ← 완전히 별개의 메모리
└──┴──┴──┘ └───┴──┴──┘ (array2만 100으로 변경됨)
▶ 슬라이스 (참조 타입) - slice2 := slice1 후, slice2[0] = 100 까지 실행한 상태
slice1 헤더 slice2 헤더
┌──────┬───┬───┐ ┌──────┬───┬───┐
│ ptr │ 3 │ 5 │ │ ptr │ 3 │ 5 │ ← 헤더만 복사
└──┬───┴───┴───┘ └──┬───┴───┴───┘
│ │
└────────────┬────────────┘
▼
underlying array (힙)
┌───┬──┬──┬─┬─┐
│100│20│30│ │ │ ← 동일한 배열을 공유
└───┴──┴──┴─┴─┘ (slice2의 변경이 slice1에도 보임)
len=3 cap=5
배열은 통째로 복제되어 두 개의 독립된 메모리가 되지만, 슬라이스는 헤더만 복제되고 실제 데이터(underlying array)는 하나를 공유합니다. 그래서 slice2[0] = 100 으로 변경한 값이 slice1 에서도 보이는 거죠.
구조체도 헷갈리게…⌗
Go는 OOP를 지원하긴 하지만, 클래스를 사용하지 않습니다. 이 부분이 저도 처음엔 좀 의아하긴 했는데요, Go는 값 타입인 구조체를 활용해서 이 문제를 해결합니다.
package main
import "fmt"
type Counter struct {
count int
}
func main() {
counter1 := Counter{count: 1}
fmt.Println(counter1) // {1}
counter2 := counter1
counter2.count = 10
fmt.Println(counter1) // {1}
}
구조체는 값 타입이기 때문에 counter1을 counter2에 대입할 때 count 값이 새로운 복제본으로 복사되어 전달됩니다.
구조체의 메서드 정의⌗
클래스는 지원하는 언어는 클래스의 내부에 메서드(멤버 메서드)를 정의합니다. 하지만 Go는 클래스 대신 구조체를 사용하죠. 그래서 구조체 외부에 메서드를 정의하고 그 메서드의 수신자가 누구인지를 명시하도록 되어 있습니다.
package main
import "fmt"
type Counter struct {
count int
}
// 값 수신자(value receiver) - 복사본에 대해 수행
func (c Counter) IncrementOnCopy() {
c.count++
}
// 포인터 수신자(pointer receiver) - 원본에 대해 수행
func (c *Counter) Increment() {
c.count++ // 원래는 (*c).count++ 이어야 하지만, Go는 자동으로 참조를 해제해준다
}
func main() {
counter := &Counter{count: 0}
// 위 라인은 아래 두 줄과 동일
// tmp := Counter{count: 0}
// counter := &tmp
fmt.Println("복사본에 대해 카운트 증가")
for i := 0; i < 5; i++ {
counter.IncrementOnCopy()
fmt.Printf("%d번 증가: %d \n", i+1, counter.count)
}
fmt.Println("원본에 대해 카운트 증가")
for i := 0; i < 5; i++ {
counter.Increment()
fmt.Printf("%d번 증가: %d \n", i+1, counter.count)
}
}
위 코드의 실행결과는 다음과 같습니다.
❯ go run .
복사본에 대해 카운트 증가
1번 증가: 0
2번 증가: 0
3번 증가: 0
4번 증가: 0
5번 증가: 0
원본에 대해 카운트 증가
1번 증가: 1
2번 증가: 2
3번 증가: 3
4번 증가: 4
5번 증가: 5
Go에서는 데이터를 전달할 때 항상 값으로 전달(pass by value)을 사용합니다. 따라서 값 수신자에 전달되는 구조체 데이터 역시 복제되어 전달됩니다. 아무리 IncrementOnCopy() 를 호출해도 복사본에 대해 수행되기 때문에 카운트는 제자리에 멈추게 됩니다.
포인터 수신자는 구조체의 참조를 전달받아 연산을 수행하기 때문에 원본의 값을 변경하게 되고 카운트가 계속 증가하게 됩니다.
이 차이를 그림으로 정리하면 다음과 같습니다.
▶ 값 수신자: func (c Counter) IncrementOnCopy()
[호출자의 메모리] [함수의 스택 프레임]
counter 값 복사 c (Counter의 복사본)
┌────────┐ ───────────► ┌────────┐
│count: 0│ │count: 0│
└────────┘ └───┬────┘
▲ │ c.count++
│ ▼
│ ┌────────┐
│ ── 함수가 끝나도 ── │count: 1│ ← 복사본만 증가
└── 여전히 count: 0 └────────┘
함수 종료 → c 폐기
▶ 포인터 수신자: func (c *Counter) Increment()
[호출자의 메모리] [함수의 스택 프레임]
counter 주소(&counter) 전달 c (*Counter)
┌────────┐ ─────────────────► ┌────────────┐
│count: 0│ │0xc000014090│
│ ↓ │ ◄── (*c).count++ 으로 ───── │ (원본을 │
│count: 1│ 원본을 직접 수정 │ 가리킴) │
└────────┘ └────────────┘
원본이 변경됨
IncrementOnCopy() 는 호출할 때마다 매번 새로운 복사본을 만들어 그 위에서 1을 더한 뒤, 함수가 끝나면 복사본은 사라집니다. 그래서 원본인 counter.count 는 계속 0인 거죠. 반면 Increment() 는 counter 의 주소만 받기 때문에 함수 안에서 c.count++ 를 하면 원본의 메모리가 직접 바뀝니다.
new() 와 &Counter{} - 둘 다 구조체 포인터를 만들어요⌗
위 예시에서 counter := &Counter{count: 0} 으로 구조체 포인터를 만들었는데요, Go에는 내장 함수 new() 라는 또 다른 방법도 있습니다.
counter1 := &Counter{} // 빈 구조체 포인터 (모든 필드가 zero value)
counter2 := new(Counter) // 위와 동일한 결과
counter3 := &Counter{count: 10} // 초기값을 지정하고 싶다면 이쪽
new(T) 는 타입 T의 zero value를 가지는 새 메모리를 할당하고 그 포인터를 돌려주는 내장 함수입니다. 초기화할 필드가 없다면 new(Counter) 가 좀 더 간결하긴 한데요, 실제로는 초기값을 함께 지정하는 경우가 대부분이라서 &Counter{...} 쪽이 훨씬 자주 사용됩니다.
참고자료⌗
- Tucker의 Go 언어 프로그래밍
- Claude