이번 게시글에서는 golang의 포인터에 대해 정리합니다.
1. 포인터의 사용
package main
import "fmt"
func main() {
test1()
test2()
test3()
test4()
}
func test1() {
var value int
getTest1Value(value)
// 콜스택은 독립적인 공간(네임스페이스)를 갖습니다.
// 메서드 내부에서 값을 바꿔주더라도 해당 공간에 새로 만든 stack 변수 값만 바뀝니다.
// 내부에서 변수에서 무슨짓을하든 외부에서의 값은 바뀌지 않습니다.
fmt.Println("test1 값:", value) // output 0
}
func getTest1Value(a int) {
// 콜스택에 a라는 새로운 stack 변수에 값을 변경
a = 5
}
func test2() {
var value int
// reference 를 넘겨줬습니다.
// 아규먼트 패싱시 pointer 변수로 받습니다.
getTest2Value(&value)
// 값은 정상적으로 변경되어 5가 나옵니다.
fmt.Println("test2 값:", value)
}
func getTest2Value(a *int) {
// 포인터 사용의 올바른 practice 입니다.
// 아규먼트 패싱시 포인터 변수로 받아와서 값을 변경해줍니다.
// 애스터리스크로 스택에 있는 주소를 따라가서 힙에 접근한후
// 힙에 있는 값을 변경해줍니다.
*a = 5
}
func test3() {
// 변수를 포인터변수로 선언해주고 new 를 사용하여 힙을 잡았습니다.
// 동작방식은 ref 를 넘긴 2번 방식과 유사합니다.
// 개인적으로 안티패턴이라고 생각합니다.
var value *int
value = new(int)
getTest3Value(value)
fmt.Println("test3 값:", *value)
}
func getTest3Value(a *int) {
*a = 7
}
func test4() {
// 아규먼트 패싱이나 return 을 할 때 주소가 왔다갔다 하면
// go 는 탈출검사를 통해 힙이 필요하면 할당해버립니다.
// 즉 ref 를 넘겨도(ref를 넘기려면 포인터 변수로 아규먼트 패싱을 해야함) 일반적으로 힙이 할당된 상태입니다.
// 힙이 할당된경우 힙을 가르키는 공간과 해당 스택을 가르키는 공간 두개의 주소가 생겨버리고 접근할 수 있습니다.
// 포인터 변수를 생성해 스택을 가르키는 공간을 넘겨줍니다.(더블포인터로 힙까지 접근할 수 있도록)
var value *int
getTest4Value(&value)
fmt.Println("test4 값:", *value)
}
// 이런류의 메서드는 c, golang, c#의 unsafe 를 제외하고는 허용하지 않습니다.
// null을 넘기는 자체가 위험한 행위이며 null을 깡통주소로 취급하지 않고 ref에 접근할수 있게 해주는 언어만 가능합니다.
func getTest4Value(a **int) {
// 포인터 예제를 위해 더블포인터를 사용했으며
// 실제 더블포인터는 포인터 배열을 위해 사용되지 이런식으로 사용하지는 않습니다.
// 스택의 위치를 가르키는 공간에 접근해서 스택의 위치로 간후
// new를 사용하여 heap을 할당하고 stack의 위치에 heap을 가르키는 주소를 넣어줍니다.
*a = new(int)
// 첫번째 애스터리스크는 스택의 위치로 이동할수 있는 주소를 이용해 스택으로 이동하고
// 두번째 애스터리스크는 스택에 있는 주소로 힙으로 이동해서 10이라는 값을 넣어줍니다.
**a = 10
}
아규먼트 패싱시 ref를 넘기고 포인터변수로 받게되면 힙할당을 최소화할 수 있습니다.
필요에 따라 사용할 수도 있지만, 동기화에 신경쓰지 않으면 버그가 발생하기 쉬우니 주의합니다.
(test2가 올바른 practice이며 test3은 좋지 못한 패턴입니다.)
2. struct와 포인터
package main
import "fmt"
type Member struct {
Name string
Age int
}
func (member Member) changeName(name string) {
// 해당 member 는 스택에 있는 member 이므로 스코프 종료 이후 소멸됩니다.
member.Name = name
}
func (member *Member) changeAge(age int) {
member.Age = age
}
func changeMemberName(newMember Member, name string) {
newMember.Name = name
}
func main() {
member := Member{"철수", 15}
// 아래의 예시를 만들기 위해 극단적인 예시를 만들었습니다.
// 이름은 바뀌지 않습니다.
changeMemberName(member, "영희")
fmt.Println(member.Name, member.Age)
member.changeName("민수")
member.changeAge(20)
// struct가 사용할수 있는 메서드를 만들어 변경했지만
// 첫번째 예시와 같은 이유로 나이만 바뀌었습니다.
fmt.Println(member.Name, member.Age)
}
struct를 객체처럼 사용해야 한다면 포인터를 사용해야 합니다.
고랭은 ref에서 멤버에 접근할때 c++처럼 화살표로 접근하지 않아도 됩니다. 구분없이 사용해도 컴파일타임에는 문제가 발생하지 않으니 주의합니다.
3. struct를 사용하여 concurrent한 자료구조 생성
package utils
import "sync"
type ConcurrentStringArray struct {
data []string
mutex *sync.Mutex
}
func NewConcurrentStringArray() *ConcurrentStringArray {
return &ConcurrentStringArray{nil, &sync.Mutex{}}
}
func (s *ConcurrentStringArray) Append(msg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.data = append(s.data, msg)
}
func (s *ConcurrentStringArray) GetCount() int {
return len(s.data)
}
func (s *ConcurrentStringArray) GetData() []string {
return s.data
}
멀티스레드 환경에서 array에 안전하게 append를 하려면 lock이 필요합니다.
비즈니스 로직에 lock이나 attomic을 걸기 시작하면 여기저기 흩어져있는 lock에 가독성이 떨어질뿐 아니라 버그를 양산할 확률도 증가합니다.
하나의 struct로 관리해서 응집성을 높이기 위해서 포인터 struct를 사용합니다.
4. layered architecture를 위한 struct의 경우
package main
import "fmt"
type DiningHall struct {
kitchen Kitchen
}
func (diningHall DiningHall) Order() string {
return diningHall.kitchen.getFood()
}
type Kitchen struct {
totalFoodCnt int
}
func (kitchen *Kitchen) getFood() string {
kitchen.totalFoodCnt -= 1
return fmt.Sprintf("음식 나갑니다~ 음식 %d개 남았어요~", kitchen.totalFoodCnt)
}
func main() {
diningHall := DiningHall{}
diningHall.kitchen = Kitchen{totalFoodCnt: 10}
fmt.Println(diningHall.Order())
fmt.Println(diningHall.Order())
fmt.Println(diningHall.Order())
}
로직의 관심사를 분리하기 위한 컴포넌트들도 포인터로 관리하는 것이 좋습니다.
실제로는 추상(인터페이스)에 의존하는 경우가 많은데 인터페이스는 모두 포인터입니다.
위의 예제는 음식이 줄어들지 않습니다.
음식이 줄어들지 않는 에러는 다음과 같이 변경할 수 있습니다.
DiningHall 이 의존하는 식당을 주입할때 ref 를 넘겨주거나(식당이 하나의 주방의 주소값을 갖도록, 주소값을 가져야하므로 포인터변수로
받으면 됩니다. 개인적으로 옳은 방식이라고 생각합니다.)
DiningHall 의 Order 메서드를 포인터 메서드로 변경하거나(식당의 메서드를 호출시 ref로 호출해서 하나의 식당만 호출해서 하나의 주방을 가르키도록)
(최상단만 ref를 갖게 되도 내부의 멤버들은 모두 힙으로 들어갑니다.)
특히 인메모리에서 데이터를 보관해야하는 상황이 있는 경우에는
관심사를 분리하기 위한 컴포넌트들의 의존성은 모두 포인터로 가져가야합니다.
5. 힙할당을 직접 확인하기
$ go build -gcflags '-m'
해당 명령어로 힙할당을 직접 확인할 수 있습니다.
그러나 golang의 스탠다드 라이브러리들은 대부분 인터페이스로 추상화 되어 있고 내부적으로 ref 이동이 많아 예상하지 못한 결과가 나올수 있으니 주의합니다.(fmt.println 등의 라이브러리를 사용하는순간 이미 힙할당이 일어납니다.)
6. 언제 포인터를 활용하면 좋을까?
1. layer를 위한 컴포넌트를 제어할때,
2. 하나의 struct의 인스턴스의 멤버에 접근해야 할때
3. 외부의 데이터가 null이 나오는 형태라 null이 필요한 경우(rdb에서 null을 뱉는 필드)
4. return 값을 꼭 nil로 가져가야 하는 경우
위 조건을 제외하고는 굳이 포인터를 생성해서 사용할 이유가 없습니다.
직접 포인터를 생성할때는(탈출분석이 아닌 직접 생성)
꼭 포인터를 사용해야 하는지 다시한번 생각해봐야 합니다.
ref를 넘겨야 할때는 탈출분석을 사용하는것이 안전하고,
call by ref 등으로 데이터를 변경하는 행위는 위험합니다.(데이터를 변경하는 비즈니스가 여기저기 흩어져있을수도 있으며 동기화 처리도 고민해 봐야합니다.)
데이터는 ref를 넘겨 이곳저곳에서 관리하기보다는 하나의 요청에 움직이게 하는게 좋으며 인메모리보다는 애플리케이션이 내려가도 안전한 공간에 보관하는것이 좋습니다. (데이터를 저장하는 공간들은 보통 thread로부터 안전하게 만들어져 있습니다.)
또한 스택은 함수종료시 정리되지만 힙은 가비지콜렉터가 동작해야하므로
golang의 포인터가 성능적으로도 유리하다고 하기 애매합니다.
7. c++의 유명한 구문
Use references when you can, and pointers whenever have to
Avoid pointers until you can't
`사용할 수 있다면 참조자를, 어쩔 수 없다면 포인터를 써라`
꼭 필요한 경우가 아니라면 고민해봅시다.
'golang' 카테고리의 다른 글
[golang] aws-sdk-go-v2 의 s3 copyobject 사용법(copyobject example) (0) | 2022.04.11 |
---|---|
[golang] 고루틴 사용시 주의점(오버플로우 방지, 고루틴풀, 고루틴큐) (0) | 2022.03.08 |
[golang] decorate 패턴을 사용하여 timer 구축하기(golang 시간 차이 구하기) (0) | 2022.02.28 |
[golang] vue와 golang echo framework 연동하기 (0) | 2022.01.24 |
[golang] go context, go-cache를 활용한 캐시 서버 만들기 (0) | 2022.01.13 |
댓글