Go Slices: 포인터 역설 – 왜 당신의 Append가 사라지는가 (슬라이스 수정이 지속되는 경우와 사라지는 경우 이해하기)
Source: Dev.to
func main() {
users := []*User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
updateUsers(users)
fmt.Println("After update:")
for _, u := range users {
fmt.Printf("%s: %d\n", u.Name, u.Age)
}
// Output:
// Alice: 40 - changed
// Bob: 30
// Charlie: ? - missing
}
func updateUsers(users []*User) {
// This change persists
users[0].Age = 40
// This change disappears
users = append(users, &User{Name: "Charlie", Age: 35})
}
첫 번째 변경은 유지되고 추가된 요소는 사라지는 이유는?
슬라이스 헤더
Go 슬라이스는 배열이 아닙니다. 내부적으로는 세 개의 필드로 구성된 헤더를 가집니다:
// Conceptual slice header
type sliceHeader struct {
pointer *array // points to the underlying array
length int // number of elements in the slice
capacity int // size of the underlying array
}
슬라이스를 함수에 전달하면 헤더가 복사됩니다; 기본 배열은 복사되지 않습니다.
[]*User와 두 단계의 간접 참조
users (slice header)
│
├──→ [ptr1, ptr2] (array of pointers)
│ │ │
│ │ └──→ Bob struct
│ │
│ └──→ Alice struct
│
└── length: 2, capacity: 2
호출자와 피호출자 모두 같은 기본 배열에 대한 포인터를 공유하므로, 포인터가 가리키는 구조체를 수정하면(users[0].Age = 40 등) 원본 데이터가 업데이트됩니다.
append가 일어날 때
append는 기존 용량이 부족할 경우 새 배열을 할당할 수 있습니다. 그 후 새 슬라이스 헤더를 만들어 이 새로운 배열을 가리키게 합니다. 헤더가 값으로 전달되었기 때문에, 새로운 헤더를 보는 것은 피호출자뿐이며, 호출자는 여전히 원래 헤더를 가지고 있습니다.
Before call (in main):
users → [ptr1, ptr2] (len=2, cap=2)
Inside updateUsers after append:
users → [ptr1, ptr2, ptr3] (len=3, cap=4) // new header, possibly new array
After return:
main.users is unchanged → still length 2
ptr3 (Charlie) becomes unreachable → eligible for GC
호출자에게 append 결과를 보이게 하는 방법
1. 새로운 슬라이스 반환하기
func updateUsers(users []*User) []*User {
users[0].Age = 40
return append(users, &User{Name: "Charlie", Age: 35})
}
func main() {
users := []*User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
users = updateUsers(users)
// users now contains Charlie
}
2. 슬라이스에 대한 포인터 전달하기
func updateUsers(users *[]*User) {
(*users)[0].Age = 40
*users = append(*users, &User{Name: "Charlie", Age: 35})
}
func main() {
users := []*User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
updateUsers(&users)
// users now contains Charlie
}
3. 포인터 리시버 메서드 사용하기
type UserList []*User
func (ul *UserList) AddUser(name string, age int) {
*ul = append(*ul, &User{Name: name, Age: age})
}
func main() {
users := UserList{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
users.AddUser("Charlie", 35)
// users now contains Charlie
}
핵심 정리
- 슬라이스 안의 포인터를 통해 필드 업데이트를 하면 기본 배열이 공유되기 때문에 원본 데이터가 변경됩니다.
- 슬라이스 자체를 길이·용량을 바꾸는 수정은 새로운 헤더가 생성되므로, 새로운 헤더를 반환하거나 슬라이스에 대한 포인터를 전달하지 않으면 반영되지 않습니다.
- 슬라이스는 포인터, 길이, 용량을 담고 있는 값이라고 생각하면 됩니다; 포인터 부분은 공유되지만 헤더 자체는 공유되지 않습니다.