Learning Go 정리 5일차 - Pointer

0

Learning Go

목록 보기
5/12

Pointer

포인터는 저장된 메모리의 위치 값을 가지고 있는 변수이다. 모든 변수는 하나 혹은 그 이상의 연속적인 메모리 공간에 저장되는데 그것을 address라고 부른다.

서로 다른 타입의 변수들은 서로 다른 양의 메모리를 차지한다. 가령, int변수는 32비트를 차지하여 4byte이고, rune, bool과 같은 변수는 4비트만을 차지하므로 1byte이다.

포인터는 단순히 변수가 저장된 주소를 내용으로 가지는 변수이다. 따라서, 주소를 가리키는 포인터도 메모리를 차지하게 된다. address를 가리키는 포인터는 변수가 어떤 타입이든 간에 항상 같은 크기를 가진다. 이는 address가 메모리 주소와 관계가 있을 뿐 변수의 크기와는 관계가 없기 때문이다. 따라서 만약 cpu가 32bit주소체계라면 포인터는 4byte이고, 64bit 주소체계이면 8byte일 것이다.

  • main.go
func main() {
	a := 4
	b := false
	fmt.Println(unsafe.Sizeof(&a)) // 8
	fmt.Println(unsafe.Sizeof(&b)) // 8
}

위와 같이 변수가 정수든, 실수든, 불리언이든, 문자열이든 포인터는 주소를 나타내기 때문에 모두 같은 크기를 가진다.

만약, 포인터로 선언해놓고 어떠한 값도 할당해주지 않으면 제로값이 할당되는데 바로 nil이다. 슬라이스, 맵, 함수를 위한 제로값은 모두 nil이다. 추가로 채널과 인터페이스 역시도 포인터로 구현되어있기 때문에 선언해놓고 어떤 값을 할당해주지 않으면 nil이 나온다.

&는 주소 연산자이다. 변수 앞에 &를 붙이면 변수의 값에 저장된 메모리 위치 주소를 반환한다.

x := "hello"
pointer := &x

*는 간접 연산자이다. 포인터 타입의 변수 앞에 붙이면 가리키는 값을 반환한다. 이를 역참조(dereferencing)라고 부른다.

x := 10
pinter := &x
fmt.Println(*x) // 10

주의해야할 것은 nil인 포인터에 dereferencing을 하면 패닉이 발생한다는 것이다. 따라서, 포인터를 역참조할 때는 보장된 상황이거나 아닌 경우는 nil인지 체크를 해주어야 한다.

포인터 타입은 포인터가 어떤 타입을 가리키는지 나타낸다. 타입 이름 앞에 *을 사용하여 작성한다. 포인터 타입은 모든 타입을 기반으로 만들 수 있다.

x := 10
var pointer *int
pointer = &x

내장 함수 new는 포인터 변수를 생성한다. 제공된 타입의 제로 값을 가리키는 포인터를 반환한다.

var x = new(int)
fmt.Println(x == nil) // false
fmt.Println(*x) // 0을 출력

new함수는 정말 가끔 사용된다. 딱히 사용해서 좋은 적은 별로 없었다.

구조체를 위해 인스턴스를 만들려면 구조체 리터럴 앞에 &을 사용한다.

x := &Foo{}

기본 타입 리터럴(숫자, 불리언, 문자열)나 상수는 메모리 주소를 가지지 않기 때문에 &를 사용할 수 없다. 더 정확히는 이들의 메모리는 접근이 불가능한 곳이어서 그렇다.

type person struct {
    FirstName string
    MiddleName *string
    LastName string
}

p := person{
    FirstName: "Pat",
    MiddleName: "Perry", // 해당 라인은 컴파일되지 않는다.
    LastName: "Peterson",
}

"Perry"앞에 &을 붙이면 에러가 발생한다.

이를 위해서 타겟이 되는 상수, 리터럴을 특정 변수에 담아주어 포인터를 넘겨주면 된다.

func stringp(s string) *string {
    return &s
}

다음의 함수를 적용하면

p := person{
    FirstName: "Pat",
    MiddleName: stringp("Perry"), 
    LastName: "Peterson",
}

문제없이 동작할 것이다.

이유는 단순하다. 문자열 리터럴인 "Perry"를 함수의 파라미터 변수 s에 할당해주고, s변수의 포인터를 넘겨주는 것이다.

포인터에 대한 이해

포인터는 굉장히 직관적이고 쉽다. 특히 c/c++과 같이 복잡한 연산이 가능한 언어와 달리 go에서는 pointer에 대한 어려운 연산들을 막고 굉장히 다루고 쉽게 만들어두었다. 오히려 포인터가 없는 자바, 자바스크립트, 파이썬이 더 어렵고 추상적인 구조를 갖는다.

자바, 자바스크립트, 파이썬에서 클래스의 인스턴스가 다른 변수에 할당되거나 함수 혹은 메서드로 넘겨진다면 다음과 같이 일이 가능할 수 있다.

class Foo:
    def __init__(self, x):
        self.x = x

def inner1(f):
    f.x = 20
    
def inner2(f):
    f = Foo(30)
        
def outer():
    f = Foo(10)
    inner1(f)
    print(f.x)
    inner2(f)
    print(f.x)
    g = None
    inner2(g)
    print(g is None)
    
outer() 

위의 코드를 동작시키면 다음의 결과가 나온다.

20
20
True

자바, 파이썬, 자바스크립트에서는 다음과 같이 설명할 수 있다.
1. 클래스의 인스턴스를 함수로 넘기고 해당 인스턴스 내의 항목 값을 변경하면, 이는 전달된 변수에도 반영된다.
2. 파라미터 변수에 새로운 클래스 인스턴스를 할당하면 이는 전달된 변수에는 반영되지 않는다.
3. nill/null/None을 파라미터 값으로 전달하면, 파라미터 자체를 새 값으로 설정해도 호출 함수의 변수가 수정되지 않는다.

어떤 사람들은 이런 언어에서는 클래스 객체를 참조에 의한 전달이기 때문에 해당 결과가 나온다고 설명한다. 그러나 이것은 틀린 말이다. 진짜 참조에 의한 전달이면, 두 번째, 세 번째 경우에도 호출 함수의 변수가 변경되어야 한다. 이런 언어들은 go와 동일하게 항상 값에 의한 전달을 하는 것이다.

이런 언어의 모든 클래스 인스턴스는 포인터로 구현된다. 클래스 인스턴스를 함수나 메서드로 넘길 때, 복사된 값은 인스턴스의 포인터이다. 즉, 위의 예제에서 outer에 있던 f 변수의 포인터가 넘어가고, inner1, inner2의 파라미터 fouterf와 같은 메모리를 참조하여 call-by-reference 같아 보이지만, 이들은 서로 다른 포인터들이다. 즉, 포인터 변수 자체의 주소들이 서로 다르다는 것이다. 때문에 inner1에서 f로 가리키고 있던 객체의 항목을 바꾸면 반영되고, inner2f가 가리키는 대상을 바꾸어 새로운 객체를 할당해도 외부의 outer f는 아무런 영향이 없던 것이다. None일 때도 마찬가지이다. 이는 outerf변수를 함수에서 파라미터 f로 값을 복사했기 때문이다. 즉 call-by-value라는 것이다.

이는 c언어로 쉽게 판별할 수 있다.

#include <stdio.h>
#include <stdlib.h>
typedef struct{
	int x;
}Temp;

void process(Temp* t){
	printf("%p\n", &t); // 0x7fffa094ba70
	t = malloc(sizeof(Temp));
	t->x = 30;
}

int main(void) {
	Temp t2;
	t2.x = 10;
	Temp* t;
	t = &t2;
	printf("%p\n", &t); // 0x7fffa094ba48
	process(t);
	
	printf("%d", t->x); // 10
	
	return 0;
}

maint는 포인터로 t2를 포인팅하고 있다. processt는 포인터로 마찬가지로 t2를 포인팅하고 있다. 그러나 maint변수와 processt변수는 서로 다른 변수이다. 왜냐하면 이들의 주소 값이 다르기 때문이다.

main -> t(0x7fffa094ba48) -> t2
process -> t(0x7fffa094ba70) -> t2

이들은 포인터이기 때문에 값이 주소이다. 이 주소는 서로 같은 t2를 향할 뿐, 이 두 변수의 근본적인 메모리는 완전히 다르다. 즉, call-by-value인 것이다. 생각해보면 call-by-reference, call-by-pointer는 설명을 위해 존재할 뿐. 사실 모든 것은 call-by-value이다.

이러한 현상은 go에서도 마찬가지이다.

package main

import "fmt"

type Temp struct {
	X int
}

func main() {
	t := &Temp{X: 20}
	process(t)
	fmt.Println(t.X) //20
}

func process(t *Temp) {
	t = &Temp{X: 10}
}

파이썬, 자바, 자바스크립트에서 보듯이 go에서는 더욱 명확하게 포인터 변수를 사용한다. go와 이런 언어들 간에 차이는 원시 값과 구조체 모두를 위해 값으로 사용할 지 포인터로 사용할 지에 대한 선택을 제공한다는 점이다.

사실 대분 값으로 사용하는 것이 좋다. 심지어 메모리 측면에서도 가비지 컬렉터를 적게 호출하기 때문에 좋은 경우가 많다.

포인터는 변경 가능한 파라미터를 가리킨다.

불변성은 프로그램을 이해하기 쉽게 만들며 디버깅을 하기 쉽게하며 버그를 줄여준다.

go에서는 불변한 선언을 할 수 있는 방법이 적다. 그러나 파라미터를 값이나 포인터로 선택할 수 있도록 하는 기능이 그러한 문제를 해결할 수 있다. go개발자들은 어떤 변수나 파라미터를 불변으로 선언하는 것보다 가변인 파라미터를 가리키는 포인터를 사용한다.

go는 값에 의한 호출을 사용하는 언어이기 때문에 함수로 전달된 값은 복사된다. 기본타입, 구조체, 배열과 같은 비 포인터 타입들은 호출된 함수에서 원본을 수정할 수 없다는 의미이다. 이렇게 호출된 함수는 원본의 복사본을 가지기 때문에 원본의 불변성을 보장한다.

하지만 포인터가 함수로 전달되면 함수는 포인터의 복사를 얻게 된다. 해당 포인터는 원본 데이터를 가리키고 있는데, 이는 호출된 함수에서 원본 데이터를 수정할 수 있다는 의미이다. 이것과 관련된 몇 가지 예시가 있다.

첫번째 예시는 nil포인터를 함수로 전달 했을 때, 해당 값을 nil이 아닌 값으로 만들 수 없다. 이는 원시타입이든, 구조체이든 상관없다. 포인터에 이미 할당된 값이 있는 경우에만 값을 재할당 할 수 있다. 처음에는 혼란스럽지만, 점차 이해가 된다. 메모리 위치가 값에 의한 호출을 통해 함수로 넘어가기 때문에 정수 파라미터의 값을 변경할 수 있는 것 이상으로 메모리 주소를 변경할 수 없다.

package main

import "fmt"

type Temp struct {
	X int
}

func main() {
	var f *int // f is nil
	failedupdate(f)
	fmt.Println(f) // nil

	var t *Temp // nil
	newTemp(t)
	fmt.Println(t) // nil
}

func failedupdate(f *int) {
	x := 10
	f = &x
}

func newTemp(t *Temp) {
	t = &Temp{X: 20}
}

failedupdate에서 원시타입의 포인터는 직접 리터럴한 값을 받을 수 없어, 타변수를 통해 값을 할당받으려고 하는 것을 볼 수 있다. 그러나, 포인터 변수가 값 자체로 복사되었기 때문에 외부에는 영향을 주지 않고 failedupdate 함수의 f에만 적용된다. newTemp도 마찬가지이다.

복사되는 포인터의 두 번째 예시는 함수를 종료해도 포인터 파라미터에 할당된 값이 그래도 유지되도록 하려면 포인터를 역 참조하여 값을 설정해야 한다는 것이다. 포인터를 변경하면 복사본을 변경하는 것이지 원본이 아니다. 역참조는 원본과 복사본이 가리키는 메모리 위치에 접근하는 것이므로, 새로운 값을 넣어도 반영이 된다.

package main

import "fmt"

type Temp struct {
	X int
}

func main() {
	f := 10
	update(&f)
	fmt.Println(f) // 20

	t := Temp{X: 10}
	newTemp(&t)
	fmt.Println(t) // {20}
}

func update(f *int) {
	*f = 20
}

func newTemp(t *Temp) {
	*t = Temp{X: 20}
}

위에서 이미 첫번째 법칙으로 포인터가 가리키는 값이 nil이면 포인터로 접근하는 함수에서는 어떻게해서든 변경이 불가능하다고 하였다. 따라서, 이번에는 nil이 아닌 값을 주고 시작하였다. update에서는 역참조를 통해 f의 원본에 접근하여 20을 할당한다. newTemp에서는 t변수에 역참조를 하여 포인터가 포인팅하는 개체의 원본에 접근한 다음, 새로운 객체 Temp{X:20}을 할당해준다. 즉, maint변수에 접근하여 새로운 객체를 할당해준 것 뿐이다.

포인터는 최후의 수단

포인터는 데이터의 흐름을 이해하기 어렵게 만들며 가비지 컬렉터에게 추가적인 작업을 준다. 따라서, 함수에 포인터를 넘기는 일은 그렇게 좋은 코드가 되기는 힘들다. 다만, 함수에서 포인터 파라미터를 사용해서 변수를 수정하는 유일한 경우가 있는데, 이는 함수가 해당 포인터를 인터페이스로 예상할 때이다.

이 패턴은 json 패키지에서 json을 사용할 때 자주 사용되는 패턴이다.

f := struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}{}
err := json.Unmarshal([]byte(`{"name": "Bob", "age": 30}`), &f)

Unamrshal 함수는 json을 포함하는 바이트 슬라이스로부터 변수를 채운다. 두번째 파라미터는 anyinterface{}에 해당한다. interface{}파라미터는 반드시 포인터를 전달해야한다. 그렇지 않으면 오류가 발생한다.

이러한 경우를 제외하고는 포인터로 함수 파라미터를 받는 것은 좋은 경우가 없다.

함수에서 값을 반환할 때는 값 타입을 사용하는 것을 선호해야 한다. 데이터 타입 내에 수정될 필요가 있는 상태 정보를 갖고 있는 경우에만 포인터를 반환 타입으로 사용한다.

이러한 경우가 몇가지 있는데, io와 관련된 기능을 이후에 살펴볼 때 데이터를 읽고 쓰기 위한 버퍼 사용을 해보면서 알아보도록 하자. 또한, 동시성을 사용하면서 반드시 포인터로 넘겨줘야 하는 데이터 타입도 있다. 이는 추후에 다뤄보도록 하자.

포인터로 성능 개선

구조체가 충분히 커진다면, 함수의 입력 파라미터나 반환값으로 구조체에 대한 포인터를 사용하여 성능을 향상 시킬 수 있다. 포인터는 모든 데이터 타입을 함수로 전달할 때 상수 시간이 걸리는데 보통 1 나노초 정도가 걸린다. 반면 구조체의 경우는 데이터가 커질수록 함수로 전달되는 시간은 더 걸리게 된다. 약 10 메가 바이트의 데이터를 전달하는데 1 밀리초가 걸린다.

포인터를 반환하는 수행과 값을 반환하기 위한 수행의 차이에서 재미난 부분이 있다. 1 메가 바이트 보다 작은 데이터 구조의 경우 실제로 값 타입으로 반환하는 것보다 포인터 타입으로 반환하는 것이 더 느리다. 가령 100 바이트 데이터는 반환되는데 10 나노초가 걸린다면, 포인터로 넘기면 30 나노초가 걸린다. 일단 해당 데이터가 1 메가보다 커지면 성능은 반대가 된다. 10 메가바이트의 데이터를 값으로 반환하면 2 밀리초가 걸리는데, 포인터로 넘기면 0.5 밀리초가 된다.

물론 이 정도의 시간 차이는 굉장히 짧기 때문에 굳이 포인터를 쓸 필요는 없기 때문이다.

제로 값과 값없음의 차이

go에서 포인터의 다른 일반적인 사용은 제로 값이 할당된 변수나 항목과 아무런 값도 할당되지 않은 변수나 항목의 차이를 나타낼 수 있다. 이런 구분이 프로그램에서 중요하다면 할당되지 않은 변수나 구조체 항목을 나타내기 위해 nil 포인터를 사용하도록 하자.

포인터는 또한 변경 가능함을 나타내므로 이런 패턴을 사용할 때는 주의해야한다. 함수에서 포인터를 nil로 설정하고 반환하는 것보다 맵을 다루면서 살펴봤던 값과 불리언을 반환하는 ok 관용구를 사용하도록하자.

nil 포인터를 파라미터나 파리미터의 한 항목으로 넘긴다면 값을 어디에도 저장할 수 없기 때문에 함수 내에서 값을 설정할 수 없다는 것을 기억하자. nil이 아닌 값을 포인터로 전달하더라도 해당 동작을 문서화하지 않는 한 수정하지 않도록 하자.

한 가지 예외가 있는데, JSON 변환은 이런 규칙에서 예외적인 경우이다. JSON에서 데이터를 변환하거나 데이터를 JSON으로 변환할 때, 제로 값과 값이 할당되지 않은 것을 구분 짓는 방법이 필요할 것이다. nil이 입력 가능한 구조체 항목을 위해 포인터 값을 사용하도록 하자.

JSON 또는 다른 프로토콜을 다루지 않을 떄 포인터 항목에 값이 없음을 나타내려고 시도하지 않도록 하자. 포인터는 값이 없음을 나타내는 가장 쉬운 방법을 제공하지만, 값을 수정할 일이 없다면 대신에 불리언과 쌍을 이루는 값의 타입을 사용하자.

map과 slice의 차이

이전에 살펴보았듯이 함수로 넘겨진 맵의 수정은 넘겨진 원본 변수에 반영이 된다. 이는 go 런타임 내에서 맵은 구조체를 가리키는 포인터로 구현되어 있기 때문에 때문에 함수로 맵을 넘기는 것은 포인터를 복사한다는 것을 의미한다.

package main

import "fmt"

func main() {
	tempMap := make(map[string]int)
	TempMapChange(tempMap)
	fmt.Println(tempMap["temp"]) // 1
}

func TempMapChange(target map[string]int) {
	target["temp"]++
}

이런 이유 때문에 함수 파라미터나 반환값으로 맵의 사용은 금기해야한다. 또한, 맵에 어떤 키가 있는지 명시적으로 정의하는 것이 없으므로, 어떤 값을 가지고 있고 어떻게 구성되었는지 파악하기 너무 어려워 진다. 불변성의 관점에서도 맵은 최종적으로 어떤 결과가 들어가 있을 것인지를 확인하는 유일한 방법이 맵이 이용된 모든 함수를 추적하는 것 뿐이기 때문에 좋지 않다. 또한, 메모리 사용에서도 맵을 파라미터로 넘기고 받는 것보다 구조체를 사용하는 것이 훨씬 더 효율적이다.

반면, 함수로 슬라이스를 넘기는 것은 조금 더 복잡하다. 슬라이스의 내용을 수정하는 것은 원본 변수에 반영이 되지만 append를 통해 길이를 변경하는 것은 슬라이스의 capacity가 충분함에도 불구하고 원본 변수에 반영되지 않는다.

package main

import "fmt"

func main() {
	temp := []string{}
	temp = append(temp, "temp")
	TempSliceChange(temp)
	fmt.Println(temp[0]) // not temp

	TempSliceAppend(temp)
	fmt.Println(temp) // [not temp]
}

func TempSliceChange(target []string) {
	target[0] = "not temp"
}

func TempSliceAppend(target []string) {
	target = append(target, "two temp")
}

그 이유는 슬라이스는 3개의 field를 가지는 구조체로 구현이 되어 있기 때문이다.

  1. 길이를 위한 정수 항목(len)
  2. 수용력을 위한 정수 항목(cap)
  3. 메모리 블록을 가리키는 포인터(array)
{array , len: 3, cap: 10}      <--원본
  |
  |
[1, 2, 3, , , , , , , ]

슬라이스가 다른 변수로 복사되거나 함수로 전달될 때 길이, 수용력, 포인터를 복사하게 된다.

{array , len: 3, cap: 10}      <--원본
  |
  |
[1, 2, 3, , , , , , , ]
  |
  |
{array , len: 3, cap: 10}      <--복사본

슬라이스 내에 값의 변경은 포인터가 가리키는 메모리를 변경해서 원본과 복사본에 모두 변경이 발생한다. 가령 slice의 3번째 값인 3을 4로 바꾼다면 다음과 같다.

{array , len: 3, cap: 10}      <--원본
  |
  |
[1, 2, 4, , , , , , , ]
  |
  |
{array , len: 3, cap: 10}      <--복사본

길이와 수용력을 변경하는 것은 원본으로 다시 반영이 안되는데, 이는 복사본만 변경이 되기 때문이다. 수용력의 변경은 포인터가 이제 새롭고 더 큰 메모리의 블록을 가리킨다는 의미이다. 즉, 이전 배열의 정보를 카피하고 수용력을 키운 새로운 배열을 가진다는 것이다.

{array , len: 3, cap: 10}      <--원본
  |
  |
[1, 2, 4, , , , , , , ]
  
  
{array , len: 3, cap: 11}      <--복사본
  |
  |
[1, 2, 4, , , , , , , ]

만약 복사된 슬라이스에 값이 추가되고, 새 슬라이스를 할당하지 않을 정도로 충분한 수용력이 있는 경우에는 복사된 슬라이스의 길이가 변경되고 새로운 값은 복사본과 원본이 공유하는 메모리 블록에 저장된다. 그런데 왜 원본에는 새로운 원소가 보이지 않았던 걸까??

이는 원본 슬라이의 len 즉, 길이값이 변하지 않았기 때문이다. 이 경우에는 go런타임이 원본 슬라이스의 길이를 넘어서서 존재하는 값들을 원본 슬라이스에서 확인할 수 없도록 한다. 가령, 복사본에 4,5,6이라는 값을 추가해보도록 하자.

{array , len: 3, cap: 10}      <--원본
  |
  |
[1, 2, 4, 4, 5, 6, , , , ]
  |
  |
{array , len: 6, cap: 10}      <--복사본

원본 슬라이스의 len은 여전히 3이기 때문에, 이보다 더 큰 인덱스를 갖는 배열 안의 값을 볼 수도 없고, 접근할 수도 없는 것이다.

따라서, 정리해보면 함수로 넘겨진 슬라이스는 해당 내용은 수정할 수 있지만, 슬라이스의 크기를 제조정할 수 없다는 것을 알 수 있다. 기본적으로 함수에 의해 수정할 수 없다고 가정하자. 만약 슬라이스의 내용을 수정한다면 함수의 문서에 꼭 넣어두록 하자.

입력 파라미터로 슬라이스를 사용하는 다른 경우는 재사용이 가능한 버퍼를 위한 것이 가장 이상적이다.

버퍼 슬라이스

외부 자원(파일이나 네트워크 연결과 같은)에서 데이터를 읽어 들일 때, 많은 언들이 다음과 같이 코드를 사용한다.

r = open_resource()
while r.has_data() {
	data_chunk = r.next_chunk()
	process(data_chunk)
}
close(r)

이러한 패턴의 문제는 while loop를 통해 순회할 때마다 단지 한번만 사용되더라도 data_chunk는 매번 할당되어야 한다. 이것은 불필요한 수많은 메모리 할당을 만든다. 가비지 컬렉션을 사용하는 언어는 자동으로 이런 할당들을 처리하지만 이런 일들이 처리가 끝난 뒤에는 정리가 필요하다.

go도 가비지 컬렉터를 사용하지만 관용적으로 go로 작성하면 불필요하면 할당을 피할 수 있다. 데이터 소스에서 매번 읽을 때마다 새 할당을 반환하기보다 일단 바이트 슬라이스를 생성하고 데이터 소스를 읽어들이는 버퍼로 사용한다.

f, err := os.Open(filename)
if err != nil {
	return err
}

defer f.Close()
data := make([]byte, 100)
for {
	count, err := f.Read(data)
	if err != nil {
		return err
	}
	if count == 0 {
		return nil
	}
	process(data[:count])
}

함수로 넘겨진 슬라이스의 길이와 수용력은 바꿀 수 없지만, 현재 길이에서 해당 내용을 변경할 수 있다는 것을 기억하자. 이 코드에서 100 바이트 버퍼를 만들어 매 루프에서 다음 블록의 바이트만큼 슬라이스에 복사한다. 버퍼에 채워진 곳까지만 process함수로 넘겨 처리할 수 있도록 하면 된다.

가비지 컬렉션 작업량 줄이기

버퍼를 사용하는 것은 가비지 컬렉터의 작업량을 줄이는 방법 중 하나이다. 가비지 컬렉터의 대상은 어떠한 포인터도 포인팅하지 않는 데이터이다. 일단 어떤 포인터도 가리키지 않은 데이터가 차지하고 있던 메모리는 재사용 될 수 있다는 것이다.

자동적으로 메모리를 해제해주는 가비지 컬렉터는 환상적이긴하지만, 그렇다고해서 가비지 데이터를 많이 만들어도 되는 것은 아니다.

메모리에 대해서 알아보려면 스택에 대해서 알아야 한다. 스택은 연속적인 블록의 메모리이다. 스레드 실행 내에 있는 모든 함수의 호출은 같은 스택을 공유한다. 즉, 지역변수라고 불리는 변수들은 모두 스택에 할당된다. 스택에서 메모리 할당은 빠르고 간단하다. 스택포인터(stack pointer)는 메모리가 할당된 마지막 위치를 추적하면서 추가적인 메모리 할당은 스택 포인터를 이동함으로써 간단히 처리된다. 함수가 실행될 때 새로운 스택 프레임(stack frame)이 함수 데이터를 위해 생성된다. 지역 변수들과 함수로 넘어온 파라미터가 스택에 저장된다. 각 새 변수는 값의 크기만큼 스택 포인터를 이동시킨다. 함수가 종료될 때, 함수의 반환값은 스택을 통해 호출함수로 복사되고 스택 포인터는 종료된 함수를 위한 스택 프레임의 초기 위치로 이동시켜 함수가 사용한 지역 변수와 파라미터는 스택 메모리에서 해제된다.

go는 프로그램이 실행되는 동안 스택의 크기를 늘릴 수 있다는 점에서 특이하다. 이는 각 고루틴은 자신의 스택을 갖고 있고 이런 고루틴은 운영체제가 아니라 go런타임에서 관리하기 때문에 가능한 것이다. 이는 장점(go스택을 작게 시작하여 적은 메모리를 사용할 수 있다)과 단점(스택 공간이 더 필요하다면 기존에 갖고 있던 모든 데이터가 복사되어야 해서 느리다)을 갖고 있다. 이것은 또한 스택을 늘렸다 줄였다를 반복하게 되는 최악의 시나리오를 가질 수 있게 한다.

스택에 어떤 것을 저장하기 위해서 컴파일 시점에 정확히 스택이 어느정도 크기가 될지를 알아야 한다. go에서 값 타입(기본 값, 배열, 구조체)를 살펴볼 때, 한 가지 공통점을 가진다. 컴파일 시점에 해당 타입들이 얼만큼 메모리를 사용할 지를 정확히 알 수 있다는 것이다. 이것이 크기가 배열 타입의 일부로 간주되는 이유다. 배열 크기를 알고 있기 때문에, 힙 대신 스택에 할당할 수 있다. 포인터 타입의 크기도 알고 있기 때문에 이것 또한 스택에 저장된다.

해당 규칙은 포인터가 가리키는 데이터가 들어왔을 때 더 복잡하다. go에서 포인터가 가리키는 데이터를 스택에 할당하려면 몇 가지 조건을 갖춰야한다.

  1. 컴파일 시점에 데이터 크기를 알고 있는 지역 변수여야 한다.
  2. 포인터는 함수에서 반환될 수 없다.

포인터가 함수에 전달되면, 컴파일러는 이러한 조건이 여전히 유지되는 지 확인할 수 있어야 한다. 만약 크기를 알지 못한다면 단순히 스택 포인터를 이동시켜 공간을 만들 수 없게 된다. 포인터 변수가 반환된다면, 함수가 종료되었을 때 포인터가 가리키는 메모리 공간은 더 이상 유효하지 않게 된다. 컴파일러가 데이터가 스택에 저장될 수 없다고 판단했을 때, 포인터가 가리키는 데이터는 스택을 벗어났고 해당 데이터는 컴파일러가 힙에 저장하게 된다.

힙은 가비지 컬렉터에 의해 관리되는 메모리이다. 가비지 컬렉터에서 힙 메모리를 관리하는 것은 스택 포인터를 옮기는 것과 달리 더 많은 복잡성을 가지고 있다. 힙에 저장되는 모든 데이터는 스택 포인터 타입 변수가 접근하는 동안에는 유효하다. 더 이상 해당 데이터로 가리키는 포인터(혹은 해당 데이터를 가리키는 데이터)가 없다면 그 데이터는 가비지의 대상이되고 가비지 컬렉터에 의해 삭제된다.

이런 go컴파일러에 의해 진행되는 escape analysis는 완벽하지 않다. 스택에 저장된 데이터가 스택을 벗어나 힙에 저장되는 몇 가지 경우가 있다. 하지만 컴파일러는 보수적이어야 한다. 유효하지 않은 데이터에 대한 참조를 남겨두는 것은 메모리 손상을 일으킬 수 있기 때문에 힙에 있어야할 경우에 스택에 남겨둘 필요가 없다.

힙에 데이터를 저장하는 것이 무엇이 나쁜 것일까?? 먼저 가비지 컬렉터의 작업에 시간이 든다. 힙에 있는 사용 가능한 모든 메모리 청크를 추적 유지하거나, 메모리 블록이 여전히 유효한 포인터를 가지고 있는지 추적하는 것은 쉬운일이 아니다. 이것은 프로그램이 수행하도록 작성된 내용을 처리하는 것과는 별개로 진행된다.

많은 가비지 컬렉션 알고리즘이 작성되어 오면서 두 가지 대략적인 범주로 분류할 수 있다.

  1. 높은 처리량(단일 스캔에서 가능한 많은 가비지 찾기)
  2. 낮은 지연(가능한 가비지 스캔을 빠르게 완료)

go런타임에 사용되는 가비지 컬렉터는 낮은 지연 시간을 선호한다. 각 가비지 컬렉션의 주기는 500ms보다 적게 소비하도록 설계되었다. 하지만 개발자가 만든 go프로그램에 많은 가비지를 만든다면 가비지 컬렉터는 한 번의 실행 주기에 모든 가비지를 찾지 못할 수 있고 가비지 컬렉터를 느리게 만들면서 메모리 사용량은 증가한다.

두 번째 문제는 컴퓨터 하드웨어 특성을 처리해야 한다는 것이다. 램은 random access memory를 의미하지만 메모리를 빠르게 읽기 위해서는 연속적으로 접근하는 것이 더 좋다. go에서 구조체 슬라이스는 모든 데이터가 메모리에 연속적으로 배치된다. 이는 빠르게 로그하고 빠르게 처리할 수 있게한다. 구조체를 가리키는 포인터의 슬라이스 또는 구조체의 항목(field)가 포인터인 경우에는 ram전체에 데이터가 흩어져 있어 읽기 및 처리 속도가 훨씬 느리다. 심지어 포인터로만 구성된 구조체의 경우는 포인터가 field가 없는 구조체에 비해 속도가 2배 느렸다.

go와 java를 비교해보면 지역 변수에 선언된 벼수들은 동일하게 스택에 저장된다. 그러나 자바의 객체는 포인터로 구현되었다. 이것은 모든 객체 변수 인스턴스에 대한 포인터만 스택에 할당되고 객체 내의 데이터는 힙에 할당된다는 것이다. primitive 변수들(숫자, 불리언, 문자)는 완전히 스택에 저장된다. 이것은 자바의 가비지 컬럭터가 많은 작업을 수행해야 함을 의미한다. 또한 자바의 리스트와 같은 것들은 실제로 포인터 배열에 대한 포인터라는 것을 의미한다. 따라서 선형적으로 보일지라도 실제로 데이터를 읽는 것은 비선형적이기 때문에 매우 비효율적이라는 것이다.

파이썬, 루비, 자바스크립트 역시도 마찬가지이다. 자바 가상 머신에는 많은 작업을 수행하는 매우 영리한 가비지 컬렉터로 구성되어 있다. 일부는 처리 속도에, 일부는 지연에 대해 최적화되며 모든 configuration 설정은 최상의 성능을 위해 튜닝된다. 파이썬, 루비, 자바스크립트는 조금 덜 최적화되어 성능 저하가 있다.

반면 go는 지역변수의 경우 스택에 저장하려고 한다. 설령 구조체라고 할 지라도 힙에 저장하지 않는다. 포인터의 경우는 포인터가 가리키는 객체의 크기를 컴파일단계에서 이미 알고있으며, 해당 포인터를 반환하지만 않으면 스택에 넣어 처리한다. 다만, 위의 두 가지 원칙 중 하나라도 어겨지면 포인터로 가리키는 대상은 스택에서 힙으로 전달된다.

이제 go가 포인터를 드물게 사용하도록 권장하는 이유를 알 수 있을 것이다. 가능한 많이 스택에 저장하도록 하여 가비지 컬렉터의 작업량을 줄이도록 하는 것이다. 구조체의 슬라이스나 기본 타입은 빠른 접근을 위해 메로리에 연속적으로 데이터를 정렬한다. 그리고 가비지 컬렉터가 일을 시작할 때, 가장 많은 가비지를 모으는 것보다 빠르게 반환할 수 있도록 최적화되어 있다. 이런 접근 방식이 작동하도록 만드는 핵심은 처음부터 가비지를 덜 ㅁ만들게 하는 것이다. 이는 go의 메모리를 가장 효율적으로 관리하는 방법이다.

0개의 댓글