2. 변수와 포인터, 그리고 메모리

오늘은 GO언어에서 메모리를 다루는 방법을 알아보도록 하자.

변수

변수(Variable)란 데이터를 저장하기 위해 할당 받은 메모리 공간을 의미하며
저장된 내용물이 변할 수 있기 때문에 변수라고 한다.

Go언어는 정적타입 언어이기 때문에 모든 변수는 자료형을 가지고있다.
Go에는 다양한 자료형이 있으므로 자료형에 대해서는 따로 글을 작성하는편이 좋을 것 같다.

변수는 크게 전역변수지역변수로 나뉜다.

전역변수는 프로그램 전체에 걸쳐서 접근이 가능한 변수로 따로 생명주기를 갖지 않는다. (굳이 따지자면 프로그램과 생명주기를 공유한다.)

지역변수는 선언된 scope안에서만 접근이 가능한 변수를 말하는데
여기서 scope는 쉽게 설명하면 중괄호 {}로 감싸진 내부를 이야기한다.
그렇기에 함수, 분기문(if,switch), 반복문(for,while) 안에서 선언된 변수는 그 안에서만 유효하게 된다.

변수 선언

Go에서는 변수를 선언하는 다양한 방법을 제공한다.
사람마다 선호하는 방식도 다르고 경우에 따라 추천하는 방식도 다르므로 모두 알고 있는 편이 좋다.

초기화 없이 선언

var 변수이름 자료형

Go언어의 변수 선언방법

기본적으로 Go는 var 키워드를 사용하여 변수를 선언한다.
변수를 선언 할 때 초기화 하지 않으면 기본값으로 초기화 된다.

변수의 이름은 같은 scope안에서 중복될 수 없다. (전역변수도 마찬가지)

var a int
var b uint8

var name string

초기화와 함께 선언

Go는 변수를 선언함과 동시에 초기화 할 수 있다. (전역변수에서도)
다만, 선언과 동시에 초기화 할 때는 아래에서 설명할 변수 선언 연산자 :=가 있으므로
var키워드는 명시적인 초기화 없이 기본값으로 초기화한다는 의미로만 사용하길 권하는 편이다.

var a int = 10

선언과 동시에 초기화 할 때는 Go 컴파일러가 오른쪽 값의 자료형을 감지할 수 있으므로
다음과 같이 선언과 동시에 초기화할 수도 있다.

var a = 10

만일 특정타입을 지정하고 싶다면 다음과 같이 사용할 수도 있다.

var a int8 = 10
var a = int8(10)

변수 선언 연산자 :=

var키워드를 사용하는 방법 외에도 Go에서는 변수 선언 연산자 :=를 제공한다.
:=연산자를 사용하면 var 키워드를 생략할 수 있으며 지역변수를 선언 할 때만 사용할 수 있다. 변수 선언 연산자를 사용할 때는 반드시 선언과 동시에 초기화하여야 한다.

변수 선언 연산자는 전역변수에 사용할 수 없다.

변수이름 := 초깃값
a := 10
b := int8(10)
c := 'a'
s := "hello"

포인터

포인터(Pointer)는 데이터가 저장된 메모리를 식별하기 위한 주소를 의미한다.

WORD의 크기 (CPU의 종류)에 따라 그 크기가 다른데
32bit의 경우 4byte, 64bit의 경우 8byte의 크기를 가진다.

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var p *int
	fmt.Println(unsafe.Sizeof(p))
}

64bit 운영체제 실행한 결과: 8

포인터를 사용하는 이유

  1. 다른 함수에서 변수의 값을 변경하기 위해
  2. 크기가 큰 데이터를 효율적으로 전달하기 위해

Go언어에서 변수를 함수의 매개변수로 사용할때 데이터를 복사해서 전달하기 떄문에
변수 자체가 전달되는게 아니라 변수의 데이터만 전달된다.
그렇기 때문에 함수에서 변수 내부의 데이터를 변경해야 하는 경우엔 포인터를 사용해서
변수에 할당된 메모리의 주소를 넘겨야 할 필요가 있는 것이다.

또한, 포인터는 데이터가 저장된 메모리의 주소이기 때문에 대부분의 경우 실제 데이터보다 크기가 작다.
그렇기 때문에 큰 데이터를 함수의 매개변수로 전달할때 데이터를 복사해서 전달하기보다 포인터를 전달하여 더 효율적으로 데이터를 전달하는 것이다.

Go언어의 포인터

var 포인터변수이름 *자료형

포인터를 담는 변수를 포인터 변수라고 한다.
Go언어에서 포인터 변수를 선언하기 위해서는 자료형앞에 *(애스터리스크)를 붙여서 선언하면 된다.

var a *int
var b *string
var c *User

포인터 변수에는 포인터가 저장될 수 있는데
보통 다른 변수의 주소를 저장하는 방식으로 사용된다.

다른 변수의 포인터를 가져오기 위해서는 참조연산자 &를 사용하면 된다.
포인터가 가리키는 변수의 데이터를 가져오거나 수정하기 위해서는 역참조연산자 *를 사용하면 된다.

var a int = 10
var p *int = &a

*p = 20

println(a)

실행결과: 20

대부분 포인터는 함수의 매개변수로 사용된다.

아래의 예제는 다른 함수에서 변수의 내용을 바꾸기 위해 포인터를 사용한 예제이다.

package main

func main() {
	msg := "Hello"
	fun1(&msg)
	println(msg)
}

func fun1(p *string) {
	*p = "This message was generated by fun1."
}

실행결과: This message was generated by fun1.

포인터는 크기가 큰 데이터를 효율적으로 전달하기 위해서도 사용된다.

type Big struct {
	a int64
	b string
	c complex128
}

func main() {
	a := Big{10, "Hello, World", 10 + 3i}

	fmt.Printf("Data size: %d, ", unsafe.Sizeof(a))
	fmt.Printf("Pointer size: %d\n", unsafe.Sizeof(&a))
}

실행 결과: Data size: 40, Pointer size: 8

변수의 크기는 40Byte이지만 포인터는 8Byte이다.
만약 a변수를 다른 함수로 그대로 전달한다면 40Byte크기의 데이터가 그대로 복사가 일어나 비효율적으로 실행될 것이다.
하지만, 포인터를 사용한다면 데이터의 복사가 일어나지 않고 8Byte크기의 메모리주소만 전달하면 되므로 효율적으로 큰 데이터를 다른 함수로 전달 할 수 있다.

다만, 조심해야하는 것은 포인터를 통해 데이터를 전달한다면 다른 함수에서 내용물을 수정할 수 있으므로
데이터를 보호하기 위해서는 포인터의 불필요한 사용을 자제하고
포인터를 사용할 때는 신중을 기해야한다.

메모리

memory

프로그램이 실행되면 메모리에 LOAD되어 인스턴스화 되어야 한다.
이때 프로그램이 실행되는 동안 사용하는 데이터들도 메모리에 저장되는데
메모리는 위의 사진과 같은 구조를 가진다.

이 글에서는 Go언어를 사용하면서 다룰 데이터들이 저장되는 StackHeap을 중점적으로 다뤄보려고 한다.

Stack

Stack은 위의 그림처럼 주소가 높은곳에서 낮은곳으로 쌓이는 형태의 메모리이다.
또한, 이름처럼 나중에 들어간 데이터가 먼저 꺼내지는 후입선출 (또는 선입후출)구조이다.

Stack은 pushpop을 통해 데이터를 관리하는데
이 연산은 매우 빠르기 때문에 생명주기가 짧은 데이터들을 관리하는데 용이하다.

Stack에는 생명주기가 짧은 함수 호출 스택, 지역변수, 매개 변수, 반환 값 이 저장된다.
쉽게 말해 대부분 함수와 관련된 데이터들이 존재하는 것이다.
Stack에 저장되는 데이터들은 선언된 범위를 벗어나서는 사용할 수 없다.

아래의 예제에서 선언된 모든 변수는 함수 밖에서 사용되지 않으므로 Stack에 저장된다.

func something(arg string) {
	var a int
	b := 10

	user := User{}
	person := &Person{} // 함수 밖에서 사용되지 않기 때문에 컴파일러가 Stack에 저장한다. 만약 이 변수를 반환하는 경우 힙에 저장된다.
}

Heap

Heap은 위에 그림에서 코드, 데이터, 스택 영역을 제외한 남은 공간에 할당된다.

Heap에 저장되는 데이터는 필요에 따라 동적으로 할당되며
할당된 함수 밖에서도 사용할 수 있도록 긴 생명주기를 가진다.
생명주기를 다하여 필요가 없어진 데이터는 가비지컬렉터(GC)에 의해 해제되어 여유공간을 확보하게 된다.

Go에서 동적 할당을 하려면 new키워드를 사용하거나 참조 연산자 &를 사용할 수 있다.

func something() *int {
	i := new(int)
	return i // 함수 밖으로 escape 하는 경우에만 Heap에 할당됨
}
// 다음 두 코드는 완전히 같다.
u := new(User)
u := &User{}

그럼 어떤 변수가 Heap에 할당되는지 한번 알아보자.
Go는 컴파일할때 --gcflags -m 옵션을 지정하여 빌드하면 escape 분석 결과를 볼 수 있다.

package main

func main() {
	a := something()
	println(a)
}

func something() int {
	i := new(int)
	return *i
}

위의 코드를 --gcflags -m 옵션을 지정하여 빌드하면 다음과 같이 출력된다.

# command-line-arguments
./main.go:8:6: can inline something
./main.go:3:6: can inline main
./main.go:4:16: inlining call to something
./main.go:4:16: new(int) does not escape
./main.go:9:10: new(int) does not escape

동적 할당 되었음에도 함수 밖으로 escape하지 않았기 때문에 컴파일러가 Heap에 저장하지 않는다.

그럼 다음 예시를 보자

package main

func main() {
	a := something()
	println(*a)
}

func something() *int {
	i := new(int)
	return i
}

위의 코드를 --gcflags -m 옵션을 지정하여 빌드하면 다음과 같이 출력된다.

# command-line-arguments
./main.go:8:6: can inline something
./main.go:3:6: can inline main
./main.go:4:16: inlining call to something
./main.go:4:16: new(int) does not escape
./main.go:9:10: new(int) escapes to heap

동적 할당된 포인터 변수가 함수 밖으로 escape하기 때문에 컴파일러가 Heap에 데이터를 할당하게 된다.

가비지컬렉터 (GC)

Heap에 할당된 데이터는 필요가 없어지게 되어도 임의로 해제하지 않으면 계속 남아있게 된다.
이를 해결하기 위해 Go언어는 가비지컬렉터 (GC)를 사용한다.
가비지 컬렉터는 동적할당된 데이터를 관리하기 위한 메모리 관리 기법 중 하나로
필요 없어진 메모리 영역을 탐지하여 해제하는 기법을 의미한다.

Go언어는 GC가 있기 때문에 메모리를 직접 해제하지 않아도 되기 때문에
프로그래머가 신경쓸 부분이 적어 생산성에서의 이점을 가질 수 있게 된다.
다만 GC는 컴퓨팅 자원을 소모하는 동작이므로 정말 조금의 성능 저하조차 용납할 수 없는 시스템이나 애플리케이션에서는 GO가 선호되지 않는 이유이기도 하다.

Go언어의 GC는 컴파일 시점에 실행파일과 함께 컴파일되며
프로그램 실행시에 병렬 쓰레드에서 동시에 실행된다.

만약, 직접 GC를 실행시키고 싶다면 runtime패키지의 GC함수를 호출하면 된다.
GC는 비용이 많이 드는 작업인 만큼 신중에 신중을 기해 사용해야한다.

import "runtime"

runtime.GC()

끝마치며

이번 글을 작성하면서 아는 내용들은 더 확실하게 설명할 수 있게 되었고
대략적으로만 알고 있었던 내용들을 더 공부하는 계기가 되었다.
메모리관련 내용은 매번 공부해도 매번 배울게 생기는 부분인 것 같다.

results matching ""

    No results matching ""