본문으로 바로가기

struct 기본

 

go는 class가 없다. 따라서 class를 생성하는 생성자도 없다. (python의 __init__, js/ts의 constructor)

대신 struct가 존재하며 struct 내부에 메서드도 정의할 수 있다. 타 언어가 OOP 기반으로 설계되어 class를 이해하는 것이 중요하였듯 go에서도 마찬가지로 struct 기반으로 언어가 설계되어있기 때문에 struct를 이해하는 것은 중요하다.

 

struct 를 생성해보자.

type person struct {
	name    string
	age     int
	favFood []string
}

func main() {
	yourFav := []string{"cheeze", "wine"}
	you := person{"mary", 18, yourFav}
	fmt.Println(you)
	fmt.Println(you.name)
}

 

위와 같은 방식은 속성이 무엇인지 확인하기 어렵다. 아래와 같이 key:value 꼴로 넘겨주는 편이 좋다.

type person struct {
	name    string
	age     int
	favFood []string
}

func main() {
	yourFav := []string{"cheeze", "wine"}
	you := person{name: "mary", age: 18, favFood: yourFav}
	fmt.Println(you)
	fmt.Println(you.name)
}

 

embedded struct

 

struct 내부에 struct를 넣을 수 있다는 거죠. 가급적 "포함된 필드" 방식으로 embed 하는 것이 좋습니다.

접근이 좋거든요.

package main

import "fmt"

type User struct {
	Name string
	ID string
	Age int
}

type VIPUser struct {
	User	// "포함된 필드" 방식으로 사용해야함
	VIPLevel int
	Price int
}

func main() {
	darren := VIPUser{User{"Darren", "DA", 100}, 3, 3000}
    
    // "포함된 필드" 방식으로 선언되었다면 darren.User.Name이 아니라. 곧바로 접근 가능
	fmt.Println(darren.Name) 
}

 

 

 

익명 struct

 

struct를 선언하자마자 사용하고, 다른 곳에 재사용이 불가능한 방식이다.

단 하나의 struct 인스턴스만이 필요할 때 사용하면 간결하고 좋다.

func main() {
	p1 := struct {
		first string
		last  string
	}{first: "James", last: "Bond"}
	fmt.Println(p1)
}

 

 

메모리 정렬과 메모리 패딩을 고려한 필드 배치

 

메모리 패딩에 대한 설명과 이를 고려한 필드 배치가 필요합니다. 이 내용은 아래 글을 참고합시다.

 

darrengwon.tistory.com/1391

 

Go로 살펴보는 메모리 정렬(alignment)과 메모리 패딩(padding)

자, 우선 C++을 보자. struct Foo { uint32_t mUInt1; // 32비트 uint8_t mUint2; // 8비트 int32_t mInt1; // 32비트 bool mBool1; // 8비트 char* mCharPtr; // 32비트 }; 아래 경우에는 합쳐서 16byte가 연속..

darrengwon.tistory.com

 

결론적으론 아래를 이해하면 됩니다.

8바이트보다 작은 필드는 크기를 고려해서 배치해야 함을 이해하면 됩니다.

// lame한 방법이고 메모리 고려를 못한 필드 배치
type User struct {
	A int8 // 1byte
	B int64 // 8byte
	C int8 // 1byte
	D int64 // 8byte
}

func main() {
	var user User;
	fmt.Println(unsafe.Sizeof(user)) // 32 바이트
}

 

아래처럼 해야죠~

type User struct {
	A int8 // 1byte
	C int8 // 1byte
	B int64 // 8byte
	D int64 // 8byte
}

func main() {
	var user User;
	fmt.Println(unsafe.Sizeof(user)) // 24 바이트
}

 

 

 

구조체의 값 복사에 대하여

package main

import "fmt"

type Student struct {
	Age int
	No int
	Score float64
}

func PrintStudent(s Student) {
	fmt.Printf("%d, %d, %.2f\n", s.Age, s.No, s.Score)
}

func main() {
	me := Student{Age: 100, No: 35, Score: 23.536}
	me2 := me // me의 값이 모두 값 복사됨.
	PrintStudent(me2) // Go는 모두 함수 인자를 call by value하므로 지역 변수로 값을 또 복사
}

 

 

객체 복사를 줄이면서 struct(구조체)를 생성하자! 

요약 : struct factory에서 값이 아닌 포인터를 반환하세요. 

 

이렇게 하면 됩니다. struct 자체를 값복사하는 것보다 훨씬 메모리를 절약할 수 있겠쥬?

func NewAccount(owner string) *Account {
	account := Account{owner: owner, balance: 0}
	return &account
}

 

C++ 에서 최적화를 언급 할 때 "객체복사가 최대한 일어나지 않게 한다" 가 주요 화두를 차지 한다. ("알고리즘"을 개선하라가 사실 더 중요하긴 하지만) 이에 따라서 RVO(Return Value Optimization) 같은 개념도 생겨나고, 아예 RVO를 믿지 못하고 매개변수에 리턴 받을 포인터를 전달하는 형태를 취하기도 한다. - 출처: https://hamait.tistory.com/1065

 

Go는 class가 아닌 struct를 사용한다. class가 내부 메서드를 가지고 있는 반면 struct는 메서드는 별도로 분리하여 정의한다. 그런데 이 struct에는 생성자가 없어서 별도로 struct를 생성하는 함수(factory)를 이용하여 생성한다.

 

Go에서 struct는 기본적으로 mutable 개체로서 필드값이 변화할 경우 (별도로 새 개체를 만들지 않고) 해당 개체 메모리에서 직접 변경된다. 좋다. 이건 메모리를 아낄 수 있는 좋은 방법이다.

 

하지만, Go는 기본적으로, 다른 함수의 인자를 함수 내 지역 변수로 재선언하고 값복사를 한다.

이런 점은 array에서도 마찬가지입니다. effective go의 array 부분을 발췌하자면 

(golang.org/doc/effective_go.html#arrays)

  • Arrays are values. Assigning one array to another copies all the elements.
  • In particular, if you pass an array to a function, it will receive a copy of the array, not a pointer to it.
func Sum(a *[3]float64) (sum float64) {
    for _, v := range *a {
        sum += v
    }
    return
}

array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array)  // Note the explicit address-of operator

 

생각해보면, js나 python에서 객체는 reference 타입으로, 함수에 사용해도 자동으로 메모리 주소를 참조해서 별다른 조치를 취하지 않아도 되었습니다. 그러나 Go 에서 struct는 value type, 즉 값입니다. 따라서 struct를 함수의 파라미터로 사용하고자하면 주의해야 합니다.

 

값으로 넘기는 것(Pass by Value)이 왜 문제냐면... 새로운 메모리에 값을 복사하게되어 최적화의 원칙인 '객체 복사가 최대한 일어나지 않게 한다'를 위배하기 때문입니다. 쉽게 말해 한 값이 메모리 상에 여기에도 할당되고, 저기에도 할당되어 같은 객체임에도 여러 곳에 할당되어 메모리를 낭비하는 꼴이 된다는 거죠.

 

 

생성자 없는 Go에서 struct를 생성하는 법

Capitalized된 것만이 export될 수 있음을 기억합시다. 

package accounts

// Account struct
type Account struct {
	owner   string
	balance int
}

// 반환을 포인터(*Account)로 함. 따라서 return값은 생성된 객체의 주소로 해야함
func NewAccount(owner string) *Account {
	account := Account{owner: owner, balance: 0}
	return &account
}

 

여기서 NewAccount는 리턴해주는 값이 생성된 객체의 메모리 주소를 반환하는 것을 확인할 수 있다. 정확히 말하면 생성된 객체가 저장된 메모리 주소를 담고 있는 변수(포인터)를 반환한다.

struct는 필드의 메모리값을 모두 더한 것에 메모리 패딩까지 더해서 8의 배수로 계산하니 용량이 크지만, 포인터는 커봐야 int값이니 8바이트박에 하지 않는다. 이로써 객체복사에 대한 낭비를 줄일 수 있다.

 

 

할당된 객체의 포인터를 리턴 해 주면 이건 "죽은 자식 불알 만지기" 가 되는게 아니냐로 반문 할 수 있을 것이지만, Go언어에서는 외부로 레퍼런싱 되는 객체의 경우 힙에 할당해 주며 자동으로 관리 해 준다.
출처 - https://hamait.tistory.com/1065

좀 알아보니, Go에서는 탈출 검사를 통해서 함수 외부로 반환되는 객체를 스택이 아닌 힙 메모리에 할당한다고 합니다.

 

 

만약 아래와 같이 생성된 포인터가 아닌 struct를 반환하게 되면 해당 함수를 이용해 생성된 account는 깊은 복사가 일어나 메모리를 낭비하는 꼴이 됩니다.

 

1. 함수에서 한 번

2. 해당 함수를 이용해서 할당하면서 한 번

// NewAccount creates Account
func NewAccount(owner string) Account {
	account := Account{owner: owner, balance: 0}
	return account // 여기서 값을 리턴하면 함수 사용하면서 account 값이 또 복사가 됨
}

func main() {
	myAccount := accounts.NewAccount("darren") // myAccount를 위해 메모리에 할당됨 (메모리 낭비)

	fmt.Println(myAccount)
}

 

*tip) 참고로 위와 같이 struct를 생성하게 되면 d는 힙 메모리에 할당된다. Go 언어가 그렇게 짜여져 있다.

func test5() *Duck {
    var d Duck = Duck{}
    return &d
}


변수 d는 반환된 뒤에도 사용될 가능성이 있으므로 스택에 둘 수 없다. 따라서 힙.
※ C 언어 등의 다른 언어로 이런 코드를 쓰면 안 된다. 지역 변수의 주소를 반환 하는 것은 엉뚱한 짓이다.
※ Go 언어는 괜찮다. 언어 사양으로 문제 없다.

 

출처 - jacking75.github.io/go_stackheap/

 

 

힙/스택 메모리

 

메모리는 코드, 데이터, 힙, 스택 영역으로 분리된다. 코드 영역은 코드 자체를 구성하는 영역이고, 데이터 영역은 static 변수와 전역 변수가 저장되는 곳이며 프로그램이 끝나기 전까지는 없어지지 않는다. 사실 이 부분은 크게 신경쓸 일이 없습니다.

 

문제가 되는 곳은 주로 힙/스택 영역이다. 스택 영역은 함수 매개 변수, 내부 지역 변수들이 쌓이는 곳이고 사용자가 직접 관리하지 않는 곳이다. 함수가 pop되면 당연히 함수 스택 영역에서도 사라진다.

 

반면 힙 영역은 콜 스택과는 관계 없으므로 함수 범위에 얽매이지 않고 객체를 저장해 둔다. 동적으로 할당되는 부분이며 코더가 효율적으로 짜야하는 곳이다. java, lisp javascript와 같은 언어는 이 힙 영역을 GC가 관리해주므로 메모리 문제를 걱정하지 않고 코드를 짤 수 있다. 반면 C 같은 경우에는 직접 힙 메모리를 관리할 수 있다. malloc() 또는 new 연산자를 통해 할당하고 free() 또는 delete 연산자를 통해서 해제가 가능하다. 그러나 코더가 메모리 관리를 잘 하지 못하는 경우 C, C++로 작성하더라도 GC가 존재하는 언어보다 성능이 더 낮아질 수도 있다고 한다.

 

그런데 변수들을 할당하는 동작은 스택이 훨씬 빠른 반면 힙은 느린 편이다.

왜? 스택에서 할당의 의미는 이미 생성되어 있는 스택에 대해 포인터의 위치만 바꿔주는 단순한 CPU Instruction(덧셈과 뺄셈 연산, 일반적으로 단일 Instruction)이다. 반면 힙에서의 할당은 요청된 chunk의 크기, 현재 메모리의 fragmentation 상황 등 다양한 요소를 고려하기 때무에 더 많은 CPU Instruction이 필요하다. 쉬운 말로하자면, 힙은 빈 영역을 찾고, GC로 쓸모 없게된 객체를 회수하는 등 작업을 해야하므로 힙에 할당하는 것은 스택보다 더 많은 시간이 소요된다.

 

결론만 말하자면 스택 할당은 싸고(가벼운 처리) 힙 할당은 비싸다(무거운 처리). 따라서, 가급적 힙 할당은 회피하는 것이 좋다.

 

 

* 스택 메모리만 사용하면 되지 않느냐는 의문이 생길 수 있겠다. 그러나 스택 메모리는 윈도우는 약 1mb 크기, 리눅스는 약 8mb 정도이다. (왜 이렇게 작은 지에 대해서는 여기를 읽어보자) 잘못 설정하면 overflow가 생긴다고 한다. 스택 영역은 낮은 주소부터 높은 주소로 올라가는데 이 과정에서 힙 메모리 영역을 침범하면 그것이 stack overflow다.

 

 

Go언어에서의 힙/스택 할당

 

Go 컴파일러는 이스케이프 분석이라는 기술을 사용하여 스택 할당과 힙 할당 중 어느 것을 사용할지를 선택한다.

컴파일러가 코드 영역에 걸쳐 변수의 범위를 추적하여 수명이 특정 범위로 한정 될 수 있거나 메모리 크기가 컴파일 시에 확정 할 경우 스택 할당된다. 이러한 추적에서 탈출(이스케이프)한 경우는 힙 할당을 한다.

 

포인터를 사용하면 대부분의 경우 힙 할당되어 버린다. 앞서 힙 할당은 회피하는 것이 좋다고 했듯, 포인터는 스택 할당의 저해 요인이므로 가능하면 피해야 한다. 그러나... struct를 생성하는 부분만큼은 예외인 듯 합니다. 참고한 코드들은 모두 포인터를 반환하고 있었습니다. 이는 struct가 값타입이라는 데에 기인합니다.

 

* tip) Go 언어는 스택 메모리가 부족하면 새로운 청크를 확보하여 추가하므로 스택에 메모리를 너무 많이 쌓아서 죽는(StackOverflow) 경우는 생기기 어렵다

 

 

 

그래서 뭘 어떻게 해야 하나?

 

출처 - https://hamait.tistory.com/1065

원본 - stackoverflow.com/questions/23542989/pointers-vs-values-in-parameters-and-return-values

 

 

  • 구조체의 메서드를 사용하여 메서드를 호출한 객체를 변환하기 위해서는 receiver pointer를 사용하게 됩니다. (다음 글에서 다룸) 그렇지 않으면 구조체의 복사본을 변형하게 되어 원하는 결과값을 받을 수 없게 됩니다. 잘 모르겠다면 무조건 포인터를 쓰십쇼.
  • Slices, maps, channels, strings, function values, 와 interface values 는 내부에 포인터로 구현되 있으므로 굳이 포인터로 처리하는 것은 낭비이다. struct 사용하실 때만 pointer 사용하셔서 객체 복사로 인한 낭비를 막으시면 됩니다.
  • 거대한 구조체와 변경되길 원하는 구조체에는 포인터를 사용하고, 그 밖에는 value로 넘겨라. 난데없는 변경은 (포인터를 통한) 혼란을 일으킨다. Immutablity 가 중요할때가 많다. => 흠... 

 

 

ref)

 

dc7303.github.io/go/2020/02/22/goStructIsValueType/

 

[GO] 구조체(Struct)는 값 타입(Value Type)이다

고에서 구조체는 값 타입이다. 간단하게 정리해본다.

dc7303.github.io

 

https://hamait.tistory.com/1065

 

Go 언어에서 포인터는 언제 사용 해야 하나?

C++ 에서 최적화를 언급 할 때 "객체복사가 최대한 일어나지 않게 한다" 가 주요 화두를 차지 한다. ("알고리즘"을 개선하라가 사실 더 중요하긴 하지만) 이에 따라서 RVO 같은 개념도 생겨나고, 아

hamait.tistory.com

jacking75.github.io/go_stackheap/

 

golang - 스택과 힙에 대해 - jacking75

실행 시 동적으로 메모리를 확보하는 영역으로서 스택과 힙이 있다. 스택 메모리는 함수 호출 스택을 저장하고 로컬 변수, 인수, 반환 값도 여기에 둔다. 스택의 Push와 Pop은 고속이므로 객체를

jacking75.github.io

jacking75.github.io/go_heap-allocations/

 

golang - go로 쓴 코드의 힙 할당 여부 확인하는 방법 - jacking75

출처 서두 Allocation Efficiency in High-Performance Go Services · Segment Blog 라는 기사를 읽었다. 좋기 때문에 꼭 일독을 권장한다. 이 글은 나의 이해와 실제로 시험해 본 결과의 메모이다. 가장 중요한 포인

jacking75.github.io

blog.golang.org/pprof

 

Profiling Go Programs - The Go Blog

Russ Cox, July 2011; updated by Shenghou Ma, May 2013 24 June 2011 At Scala Days 2011, Robert Hundt presented a paper titled Loop Recognition in C++/Java/Go/Scala. The paper implemented a specific loop finding algorithm, such as you might use in a flow ana

blog.golang.org

golang.org/doc/faq#stack_or_heap

 

Frequently Asked Questions (FAQ) - The Go Programming Language

Frequently Asked Questions (FAQ) Origins What is the purpose of the project? At the time of Go's inception, only a decade ago, the programming world was different from today. Production software was usually written in C++ or Java, GitHub did not exist, mos

golang.org

 


darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체