아주 오래전, 중학생이었던 제가 혼자서 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