홈으로 돌아가기

Go 함정: slices channels nil

이 기사는 Go의 slices, channels, strings, nil의 예상치 못한 동작을 분석합니다. 내부 구현 설명과 코드 예제가 제공됩니다. 중급/시니어 개발자를 위한 자료입니다.

Go 비밀: slices와 channels가 실패하는 이유
Advertisement 728x90

Go의 슬라이스, 채널, nil의 함정: 구현 세부 사항 심층 분석

Go 슬라이스는 간단해 보이지만, 예측 가능한 동작을 위해서는 내부 메커니즘을 이해해야 합니다. 슬라이스는 백업 배열을 참조하기 때문에 예기치 않은 변경과 메모리 누수가 발생할 수 있습니다.

배열 재사용의 기본 사례를 살펴보겠습니다:

a := []int{1, 2, 3, 4}
b := a[1:3] // b = [2, 3]
b[0] = 99
fmt.Println(a)

출력: [1 99 3 4]. b를 수정하면 원본 배열 a에도 영향을 미칩니다.

Google AdInline article slot

이제 aappend를 추가해 봅시다:

a := []int{1, 2, 3, 4}
b := a[1:3]
a = append(a, 5)
b[0] = 99 
fmt.Println(a)

출력: [1 2 3 4 5]. append 연산은 새로운 배열을 할당했기 때문에 b의 변경은 더 이상 원본에 영향을 주지 않습니다.

append와 용량의 미묘한 점

func main() {
    a := []int{1, 2, 3, 4}
    _ = append(a[:3], 5)
    fmt.Println(a)
}

출력: [1 2 3 5]. 충분한 용량이 있었기 때문에 실제 재할당이 발생하지 않았습니다.

Google AdInline article slot

명시적인 용량 제한을 적용하면:

func main() {
    a := []int{1, 2, 3, 4}
    _ = append(a[:3:3], 5)
    fmt.Println(a)
}

출력: [1 2 3 4]. 용량 제한이 재할당을 유도했습니다.

슬라이스 길이를 초과해 확장하는 경우:

Google AdInline article slot
func main() {
    s := []int{1, 2, 3, 4, 5}[1:3]
    fmt.Println(s)
    extendedSlice := s[:4]
    fmt.Println(extendedSlice)
}

출력:

[2 3]

[2 3 4 5]

메모리 누수와 슬라이스 전달

큰 배열에서 작은 슬라이스를 만들면 전체 버퍼를 유지하게 됩니다:

bigArray := make([]int, 1e6)
smallSlice := bigArray[:10]

값으로 전달하면 백업 배열이 변하지만 용량은 바뀌지 않습니다:

func modifySlice(s []int) {
    s[0] = 99
    s = append(s, 100)
}

func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s)
}

출력: [99 2 3].

가변 요소에 대한 참조를 포함한 반복문:

func main() {
    s := []int{}
    refs := []*int{}

    for i := 0; i < 5; i++ {
        s = append(s, i)
        refs = append(refs, &s[0])
    }

    *refs[4] = 4
    *refs[0] = 99999
    fmt.Println(s)
}

출력: [4 1 2 3 4].

슬라이스와 맵의 nil 불일치

var s []int
fmt.Println(len(s)) // 0
s = append(s, 1)

var m map[string]string
fmt.Println(len(m)) // 0
m["key"] = "value" // 패닉

nil 슬라이스는 문제없이 작동하지만, nil 맵은 할당 시 패닉이 발생합니다.

문자열은 바이트로 저장된다

func main() {
    str := "å"
    fmt.Println(str[1]) // 165
}
func main() {
    str := "Three"
    fmt.Println(len(str)) // 6
}

미리 선언된 식별자 가림

func main() {
    true := false
    uint := "bob"
    string := 0
    fmt.Printf("%v, %v, %v", true, uint, string)
}

출력: false, bob, 0.

채널: 읽기 및 닫기

읽기에는 채널 상태를 확인해야 합니다:

ch := getCountChannel[int]()

if v, ok := <-ch; ok {
    fmt.Println(v)
} else {
    fmt.Println("channel closed")
}

채널을 nil로 설정하면 select 분기 기능이 비활성화됩니다:

var in <-chan int = ch
if paused {
    in = nil
}

select {
    case v := <-in:
        fmt.Println("got", v)
    case <-ctx.Done():
        return
}

close() 이후에는 0 값이 채널에서 읽힙니다:

func main() {
    ch := make(chan int, 1)
    ch <- 0
    close(ch)

    fmt.Println(<-ch) // 0 true
    fmt.Println(<-ch) // 0 false
    fmt.Println(<-ch) // 0 false
}

체크를 통해:

v, ok := <-ch
fmt.Println(v, ok)

인터페이스에서 타입화된 nil

type MyErr struct{}

func (MyErr) Error() string { return "boom" }

func f() error {
    var e *MyErr = nil
    return e
}

func main() {
    err := f()
    fmt.Println(err == nil) // false
}

인터페이스는 타입과 값을 모두 저장합니다. 해결 방법:

func f() error {
    var e *MyErr = nil
    if e == nil {
        return nil
    }
    return e
}

for range와 포인터의 문제점

vals := []int{1, 2, 3}
ptrs := []*int{}

for _, v := range vals {
    ptrs = append(ptrs, &v)
}

fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 3 3 3

변수 v는 반복 사이클에서 재사용됩니다.

핵심 요약:

  • 슬라이스는 백업 배열을 공유하므로 변경 사항이 어디서든 보입니다.
  • 용량을 초과하면 append는 메모리 재할당을 수행할 수 있습니다.
  • nil 슬라이스는 append 가능, nil 맵은 불가능합니다.
  • 문자열은 바이트를 저장하며, len()은 바이트 수를 계산합니다.
  • 인터페이스 내 타입화된 nilnil과 같지 않습니다.
  • for range는 단일 루프 변수를 생성합니다.

— Editorial Team

Advertisement 728x90

다음 읽기