Golang 기초 (10) : 포인터에 대하여

20844 단어 입문기초gogolanggo

안녕하세요, 주니어 개발자 Eon입니다.

이번 포스트는 포인터에 대한 내용입니다.

📝 포인터란?

'메모리 주소를 가리키는 것' 을 의미합니다.
변수를 선언하면 메모리 영역에 공간이 할당되는데, 그 메모리 영역의 주소를 가리키는 것을 포인터라고 합니다.
포인터 변수는 메모리 주소를 값으로 가집니다.



📝 인스턴스란?

메모리의 실체를 말합니다.
우리가 변수를 선언하면 변수의 자료형 크기만큼 메모리가 할당됩니다.
인스턴스는, 그 할당된 메모리의 실체를 가리키는 말입니다.



📝 포인터는 왜 사용할까?

포인터를 사용하면 메모리 영역에 직접 접근하여 인스턴스를 조작할 수 있습니다.
어디서든 인스턴스에 접근할 수 있어, 메모리를 중복으로 할당하는 경우를 줄이고 불필요한 메모리 낭비를 막을 수 있습니다.

  • 퍼포먼스를 신경쓸 때
    • 포인터는 퍼포먼스를 고려하지 않을 땐 사용할 이유가 없다.
      물론, 포인터타입을 반환하는 함수를 사용할 땐 사용할 수 밖에 없다.
    • 인스턴스를 메모리에 통째로 복사해서 사용할 때, 인스턴스의 주소만 넘김으로써 메모리 낭비를 줄일 때 사용한다.


📝 포인터

📌 포인터의 초기화

var p1 *int

// var a int
var p1 *int = &a

p1 := &a

// type Sth struct {}
p1 := &Sth{}

p1 := new(int)

위의 선언은 각기 다른 포인터 변수의 선언 방법을 나타낸다.

  • var p1 *int : pointer int형의 변수 p1을 선언한다.
    이때, p1의 값은 nil이다. (아래 내용 참조)
  • var p1 *int = &a, p1 := &a : pointer int형의 변수 p1을 선언하고 &a로 초기화한다.
    &a는 변수 a의 주소값이다.
  • p1 := &Sth{} : pointer Sth{}형의 변수 p1을 선언한다.
  • p1 := new(int) : pointer int형의 변수 p1을 선언한다.
    new(Type)pointer Typezero value를 반환한다.

📌 포인터 변수의 기본값은 nil

var pt *int
fmt.Println(pt)
// <nil>
  • 초기화를 하지 않고 zero value 상태의 포인터 변수를 출력하면 <nil>이 출력된다.
  • nil은 출력할 때 항상 <, >을 포함한다.
  • 포인터 변수의 값이 nil이라는 것은 가리키고 있는 인스턴스가 없음을 말한다.

📍 nil이란?

builtin.go

// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type

nilpointer, channel, func, interface, map, or slice typezero value(기본값)이다.
(예 : intzero value0이다.)


📌 포인터의 사용

📍 예시 1

var a int = 10
var b int = 20

var p1 *int = &a
var p2 *int = &b

fmt.Printf("variable a : %d, address : %p\n", a, p1)
fmt.Printf("variable b : %d, address : %p\n", b, p2)
fmt.Printf("variable p1 is pointing a : %d\n", *p1)

// ***** result of the output *****
// variable a : 10, address : 0xc0000a8000
// variable b : 20, address : 0xc0000a8008
// variable p1 is pointing a : 10
  • 변수 abint형으로 선언하고, 각각 10, 20으로 초기화한다.
  • 변수 p1p2*int형(pointer int형)으로 선언하고, 각각 ab의 주소값으로 초기화한다.
  • 초기화한 값들을 출력하여 확인한다.
    여기서 ab의 주소값의 차이가 8만큼 나는 것을 확인할 수 있다.
    둘 다 int형 변수이고, 64비트 OS라서 int형이 8byte의 사이즈를 가진다.
    a의 메모리를 할당하고 가장 빠른 메모리 영역이 8만큼 뒤의 영역이고, ba의 바로 뒤에 메모리가 할당되었다는 것을 알 수 있다.
  • 포인터 자료형의 출력은 %p로 할 수 있다.
  • *p1으로 p1이 가리키는 인스턴스의 값을 나타내고, 출력하여 값을 확인할 수 있다.
    여기서 p1a의 주소값을 가지고 있고 해당 주소값을 * (pointer)로 가리켜, 인스턴스의 값을 나타낸다.

📍 예시 2

package main

import (
	"fmt"
	"unsafe"
)

type pTest struct {
	pStr string
	pInt int
}

func setStr(getStruct *pTest) {
	getStruct.pStr = "struct pTest's byte size :"
}

func setInt(getStruct *pTest) {
	getStruct.pInt = int(unsafe.Sizeof(pTest{}))
}

func main() {
	var example pTest

	setStr(&example)
	setInt(&example)

	fmt.Println(example)
}
// {struct pTest's byte size : 24}

위와 같이 다른 서로 다른 함수에서 같은 구조체 인스턴스example에 접근하여 요소를 조작했다.
출력 결과를 통해, 같은 인스턴스에 대하여 조작했다는 것을 확인할 수 있다.

📍 예시 3

package main

import (
	"fmt"
	"sync"
)

type pTest struct {
	pStr string
	pInt int
}

func addStr(wg *sync.WaitGroup, getStruct *pTest, str rune) {
	getStruct.pStr = getStruct.pStr + string(97+str)
	defer wg.Done()
}

func addInt(wg *sync.WaitGroup, getStruct *pTest, num int) {
	getStruct.pInt += num
	defer wg.Done()
}

func printStruct(wg *sync.WaitGroup, getStruct *pTest, num int) {
	fmt.Println(getStruct)
	defer wg.Done()
}

func main() {
	var example pTest
	defer fmt.Println("===== Finished =====")
	wg := sync.WaitGroup{}
	defer wg.Wait()
	for i := 0; i <= 10; i++ {
		wg.Add(1)
		go addStr(&wg, &example, rune(i))
		wg.Add(1)
		go addInt(&wg, &example, i)
		wg.Add(1)
		go printStruct(&wg, &example, i)
	}
}

위의 코드를 실행하면 실행할 때마다 결과가 바뀐다.
goroutine(golang의 멀티 쓰레드)을 사용한 것인데, golang에서 동시성 프로그래밍을 할 때 사용한다.
(os 쓰레드보다 훨씬 가볍게 동작하는 가상 쓰레드이며, 비동기(asynchronously) 실행을 한다.)
아무튼 위와 같이 사용하면 인스턴스를 사용자가 예측 불가하게 무작위로 조작하기 때문에, 동시에 같은 인스턴스에 접근 및 조작하는 로직을 구현할 때는 주의해야 한다.



📝 스택 & 힙 메모리


📌 스택 메모리

스택 메모리는 함수 호출 시에 함수에 자동으로 할당되는 메모리를 말합니다.
함수가 끝날 때 자동으로 정리되는 메모리입니다.


📌 힙 메모리

힙 메모리는 프로그래머가 수동으로 할당하는 메모리를 말합니다.
수동으로 할당하기 때문에 프로그래머가 수동으로 해제해야 하는 메모리입니다.
그렇지 않으면 메모리에서 자동으로 해제되지 않기 때문에 메모리 낭비로 이어집니다.


📌 메모리 관리는 어떻게 해야 할까?

Golang은 스택이든 이든 가비지 컬렉터알아서 메모리를 정리해줍니다.
때문에 사용자가 고심하며 메모리 할당에 대해 고민할 필요가 없습니다.
물론, 나중에 무거운 기능을 수행하는 코드를 작성할 때에는 고려해야만 합니다.

예를 들어, file을 읽어오는데 그 file의 크기를 이미 알고 있다면 그에 맞게 메모리를 할당해, 힙 영역에 메모리가 생기지 않게 하는 것이 좋습니다.

📍 힙은 비용이 비싸다?

힙은 스택에 비해 비용이 비쌉니다.
비용이 비싸다는 것은, "힙은 엑세스와 메모리 해제가 스택에 비해 느리다" 라는 것을 말합니다.
여러 관점에서, 힙에 메모리가 할당되는 것은 지양하는 것이 좋습니다.


📌 탈출 분석

탈출 분석은 함수에서 함수로 인스턴스가 탈출하는지 여부를 분석하는 것입니다.
탈출 분석을 통해 탈출하는 인스턴스의 메모리를 스택이 아닌 힙에 할당합니다.
인스턴스가 함수 외부에서도 사용이 된다면 스택 메모리에 할당하는 것은 적절하지 않기 때문입니다.


📍 힙 메모리 할당 확인

아래의 명령어로 .go 파일을 컴파일하면 컴파일 과정에서 힙 메모리의 사용 여부를 확인할 수 있습니다.

go build -gcflags '-m -l'
go build -gcflags '-m'
go build -gcflags '-m=2'
  • '-m -l' : 옵티마이징 레벨을 1로 하고, 인라이닝을 없앱니다.
  • '-m' : 옵티마이징 레벨을 1로 합니다.
  • '-m=2' : 옵티마이징 레벨을 2로 합니다. (보다 상세하게 나옵니다.)

컴파일 옵션은 아래의 명령어로 확인할 수 있습니다.

go tool compile -help

📍 예시

package main

func get(p *int) *int {
	example := *p
	return &example
}

func main() {
	var a int
	var p *int = &a
	a = 20
	_ = get(p)
}

eon@vamos-eon:~/goprojects/pointer$ go build -gcflags '-m -l'
# main
./pointer.go:5:10: p does not escape
./pointer.go:6:2: moved to heap: example
./pointer.go:16:13: ... argument does not escape
./pointer.go:16:14: "" escapes to heap

위와 같이 get()함수에서 변수 example을 할당했으나, example인스턴스get()함수 외부로 반환하여 스택이 아닌 힙에 메모리가 할당된 것을 확인할 수 있다.


이번 포스트는 포인터에 대한 내용이었습니다.
감사합니다.👍

좋은 웹페이지 즐겨찾기