Go Slices: 포인터 역설 – 왜 당신의 Append가 사라지는가 (슬라이스 수정이 지속되는 경우와 사라지는 경우 이해하기)

발행: (2025년 12월 14일 오전 04:09 GMT+9)
4 min read
원문: Dev.to

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
}

핵심 정리

  • 슬라이스 안의 포인터를 통해 필드 업데이트를 하면 기본 배열이 공유되기 때문에 원본 데이터가 변경됩니다.
  • 슬라이스 자체를 길이·용량을 바꾸는 수정은 새로운 헤더가 생성되므로, 새로운 헤더를 반환하거나 슬라이스에 대한 포인터를 전달하지 않으면 반영되지 않습니다.
  • 슬라이스는 포인터, 길이, 용량을 담고 있는 이라고 생각하면 됩니다; 포인터 부분은 공유되지만 헤더 자체는 공유되지 않습니다.
Back to Blog

관련 글

더 보기 »

Go 프로파일링(pprof 사용)

pprof란 무엇인가요? pprof는 Go의 내장 프로파일링 도구로, 애플리케이션의 런타임 데이터(예: CPU 사용량, 메모리 할당 등)를 수집하고 분석할 수 있게 해줍니다.

Go의 비밀스러운 삶: 인터페이스

암묵적 계약의 힘. 화요일 아침에 안개가 끼었다. 이든은 커피와 작은 비스코티 상자를 들고 아카이브로 내려갔다. 엘리너가 고개를 들었다. “...