본문으로 건너뛰기 Go 슬라이스 완벽 가이드 | slice 개념·내부 구조·배열 차이·append·copy

Go 슬라이스 완벽 가이드 | slice 개념·내부 구조·배열 차이·append·copy

Go 슬라이스 완벽 가이드 | slice 개념·내부 구조·배열 차이·append·copy

이 글의 핵심

Go 언어 슬라이스(slice) 완벽 가이드: 내부 구조, 배열과의 차이, 용량과 길이, append/copy 동작 원리, 메모리 관리, 슬라이싱 연산, 실전 활용 패턴까지 완전한 예제로 다룹니다. Start now.

들어가며: 슬라이스가 뭐가 다른가요?

”배열이랑 슬라이스가 어떻게 다른가요?”

Go를 처음 배울 때 가장 헷갈리는 개념 중 하나가 슬라이스(slice)입니다. 배열처럼 보이지만 완전히 다른 동작을 하며, 때로는 예상치 못한 결과를 만들어냅니다.

// 배열
arr := [3]int{1, 2, 3}
arr2 := arr  // 값 복사
arr2[0] = 100
fmt.Println(arr[0])   // 1 (변경 안 됨)
// 슬라이스
slice := []int{1, 2, 3}
slice2 := slice  // 참조 복사
slice2[0] = 100
fmt.Println(slice[0])  // 100 (변경됨!)

이 글을 읽으면

  • 슬라이스의 내부 구조를 이해할 수 있습니다.
  • 배열과 슬라이스의 차이를 명확히 알 수 있습니다.
  • append, copy의 동작 원리를 이해할 수 있습니다.
  • 용량(capacity)과 길이(length)의 차이를 알 수 있습니다.
  • 슬라이스 관련 흔한 실수를 피할 수 있습니다.
  • 실전에서 슬라이스를 효과적으로 활용할 수 있습니다.

1. 슬라이스 기본 개념

슬라이스란?

슬라이스는 Go의 동적 배열입니다. 크기가 고정되지 않으며, 런타임에 크기를 변경할 수 있습니다.

package main
import "fmt"
func main() {
    // 슬라이스 선언 및 초기화
    var s1 []int              // nil 슬라이스
    s2 := []int{}             // 빈 슬라이스
    s3 := []int{1, 2, 3}      // 초기값이 있는 슬라이스
    s4 := make([]int, 3)      // 길이 3인 슬라이스
    s5 := make([]int, 3, 5)   // 길이 3, 용량 5인 슬라이스
    
    fmt.Printf("s1: %v, len=%d, cap=%d\n", s1, len(s1), cap(s1))
    fmt.Printf("s2: %v, len=%d, cap=%d\n", s2, len(s2), cap(s2))
    fmt.Printf("s3: %v, len=%d, cap=%d\n", s3, len(s3), cap(s3))
    fmt.Printf("s4: %v, len=%d, cap=%d\n", s4, len(s4), cap(s4))
    fmt.Printf("s5: %v, len=%d, cap=%d\n", s5, len(s5), cap(s5))
}

출력:

s1: [], len=0, cap=0
s2: [], len=0, cap=0
s3: [1 2 3], len=3, cap=3
s4: [0 0 0], len=3, cap=3
s5: [0 0 0], len=3, cap=5

2. 슬라이스 내부 구조

슬라이스는 3개의 필드를 가진 구조체

슬라이스는 내부적으로 다음과 같은 구조체입니다:

type slice struct {
    ptr    *[배열]  // 실제 데이터를 가리키는 포인터
    len    int      // 현재 길이
    cap    int      // 용량
}
flowchart LR
    subgraph slice[슬라이스 구조체]
        ptr[ptr: 포인터]
        len[len: 3]
        cap[cap: 5]
    end
    
    subgraph array[실제 배열 메모리]
        a0[0: 10]
        a1[1: 20]
        a2[2: 30]
        a3[3: ?]
        a4[4: ?]
    end
    
    ptr -->|가리킴| a0

메모리 레이아웃 예제

package main
import (
    "fmt"
    "unsafe"
)
func main() {
    s := make([]int, 3, 5)
    s[0], s[1], s[2] = 10, 20, 30
    
    // 슬라이스 헤더 크기
    fmt.Printf("슬라이스 헤더 크기: %d bytes\n", unsafe.Sizeof(s))
    
    // 슬라이스 정보
    fmt.Printf("길이: %d, 용량: %d\n", len(s), cap(s))
    fmt.Printf("데이터: %v\n", s)
    
    // 포인터 주소 (내부 배열의 시작 주소)
    fmt.Printf("배열 시작 주소: %p\n", &s[0])
}

출력:

슬라이스 헤더 크기: 24 bytes  (포인터 8 + len 8 + cap 8)
길이: 3, 용량: 5
데이터: [10 20 30]
배열 시작 주소: 0xc0000b4000

3. 배열 vs 슬라이스

핵심 차이점

특징배열슬라이스
크기고정 (타입의 일부)가변
타입[N]T[]T
값 복사전체 복사헤더만 복사 (참조)
비교== 가능== 불가 (nil만 가능)
함수 전달값 복사참조 전달
메모리스택 또는 힙항상 힙

배열 예제

package main
import "fmt"
func modifyArray(arr [3]int) {
    arr[0] = 100  // 복사본 수정
}
func main() {
    arr := [3]int{1, 2, 3}
    modifyArray(arr)
    fmt.Println(arr)  // [1 2 3] - 변경 안 됨
}

슬라이스 예제

package main
import "fmt"
func modifySlice(s []int) {
    s[0] = 100  // 원본 수정
}
func main() {
    slice := []int{1, 2, 3}
    modifySlice(slice)
    fmt.Println(slice)  // [100 2 3] - 변경됨!
}

배열을 슬라이스로 변환

arr := [5]int{1, 2, 3, 4, 5}
slice := arr[:]  // 배열 전체를 슬라이스로
slice2 := arr[1:4]  // [2 3 4]
slice[0] = 100
fmt.Println(arr)  // [100 2 3 4 5] - 배열도 변경됨!

4. 슬라이스 생성 방법

방법 1: 리터럴

s := []int{1, 2, 3, 4, 5}
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s))  // len=5, cap=5

방법 2: make 함수

// make([]T, length, capacity)
s1 := make([]int, 5)      // len=5, cap=5, [0 0 0 0 0]
s2 := make([]int, 3, 10)  // len=3, cap=10, [0 0 0]
s3 := make([]int, 0, 5)   // len=0, cap=5, []

방법 3: 슬라이싱

arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4]  // [2 3 4], len=3, cap=4 (배열 끝까지)

방법 4: nil 슬라이스

var s []int  // nil 슬라이스
fmt.Println(s == nil)  // true
fmt.Println(len(s))    // 0
fmt.Println(cap(s))    // 0
// nil 슬라이스에도 append 가능
s = append(s, 1, 2, 3)
fmt.Println(s)  // [1 2 3]

5. 길이와 용량

길이(Length) vs 용량(Capacity)

  • 길이: 슬라이스가 현재 포함하고 있는 요소의 개수
  • 용량: 슬라이스가 재할당 없이 담을 수 있는 최대 요소 개수
s := make([]int, 3, 5)
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s))  // len=3, cap=5
// 길이 범위 내 접근: OK
s[0] = 1
s[1] = 2
s[2] = 3
// 용량 범위 내지만 길이 초과: 패닉!
// s[3] = 4  // panic: index out of range
// append로 추가: OK
s = append(s, 4)  // len=4, cap=5
s = append(s, 5)  // len=5, cap=5
s = append(s, 6)  // len=6, cap=10 (재할당 발생)

용량 증가 규칙

package main
import "fmt"
func main() {
    s := make([]int, 0)
    
    for i := 0; i < 20; i++ {
        s = append(s, i)
        fmt.Printf("len=%d, cap=%d\n", len(s), cap(s))
    }
}

출력 (Go 1.18+):

len=1, cap=1
len=2, cap=2
len=3, cap=4
len=4, cap=4
len=5, cap=8
len=6, cap=8
len=7, cap=8
len=8, cap=8
len=9, cap=16
...

규칙

  • 용량이 256 미만: 약 2배씩 증가
  • 용량이 256 이상: 약 1.25배씩 증가

6. append 동작 원리

기본 append

s := []int{1, 2, 3}
s = append(s, 4)        // [1 2 3 4]
s = append(s, 5, 6, 7)  // [1 2 3 4 5 6 7]
// 다른 슬라이스 추가
s2 := []int{8, 9}
s = append(s, s2...)    // [1 2 3 4 5 6 7 8 9]

append의 내부 동작

func appendExample() {
    s := make([]int, 3, 5)
    s[0], s[1], s[2] = 1, 2, 3
    
    fmt.Printf("Before: len=%d, cap=%d, ptr=%p\n", 
               len(s), cap(s), &s[0])
    
    // 용량 내 추가: 재할당 없음
    s = append(s, 4)
    fmt.Printf("After 1: len=%d, cap=%d, ptr=%p\n", 
               len(s), cap(s), &s[0])
    
    s = append(s, 5)
    fmt.Printf("After 2: len=%d, cap=%d, ptr=%p\n", 
               len(s), cap(s), &s[0])
    
    // 용량 초과: 재할당 발생
    s = append(s, 6)
    fmt.Printf("After 3: len=%d, cap=%d, ptr=%p\n", 
               len(s), cap(s), &s[0])
}

출력:

Before:  len=3, cap=5, ptr=0xc0000b4000
After 1: len=4, cap=5, ptr=0xc0000b4000  (같은 주소)
After 2: len=5, cap=5, ptr=0xc0000b4000  (같은 주소)
After 3: len=6, cap=10, ptr=0xc0000b6000 (다른 주소 - 재할당!)

append 시 주의사항

package main
import "fmt"
func main() {
    s := []int{1, 2, 3}
    s2 := s
    
    // s에만 append
    s = append(s, 4)
    
    // s2는 여전히 원래 배열을 가리킴
    fmt.Println("s:", s)    // [1 2 3 4]
    fmt.Println("s2:", s2)  // [1 2 3]
    
    // 하지만 원소 수정은 공유됨 (용량 내에서)
    s[0] = 100
    fmt.Println("s:", s)    // [100 2 3 4]
    fmt.Println("s2:", s2)  // [100 2 3] - 영향받음!
}

7. 슬라이싱 연산

기본 슬라이싱

s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := s[2:5]    // [2 3 4]
s2 := s[:5]     // [0 1 2 3 4]
s3 := s[5:]     // [5 6 7 8 9]
s4 := s[:]      // [0 1 2 3 4 5 6 7 8 9] (전체 복사 아님!)

슬라이싱의 용량

s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := s[2:5]
fmt.Printf("s1: %v, len=%d, cap=%d\n", s1, len(s1), cap(s1))
// s1: [2 3 4], len=3, cap=8 (원본의 2번 인덱스부터 끝까지)

Full Slice Expression (3-index slice)

s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// s[low:high:max]
// low: 시작 인덱스
// high: 끝 인덱스 (포함 안 됨)
// max: 용량 제한
s1 := s[2:5:7]  // [2 3 4], len=3, cap=5 (7-2)
fmt.Printf("len=%d, cap=%d\n", len(s1), cap(s1))
// len=3, cap=5

슬라이싱과 메모리 공유

package main
import "fmt"
func main() {
    s := []int{1, 2, 3, 4, 5}
    s1 := s[1:3]  // [2 3]
    
    // s1 수정 → s도 영향받음
    s1[0] = 100
    fmt.Println("s:", s)    // [1 100 3 4 5]
    fmt.Println("s1:", s1)  // [100 3]
    
    // s 수정 → s1도 영향받음
    s[2] = 200
    fmt.Println("s:", s)    // [1 100 200 4 5]
    fmt.Println("s1:", s1)  // [100 200]
}

8. copy와 메모리 관리

copy 함수

// copy(dst, src) int
// 반환값: 복사된 요소 개수 (min(len(dst), len(src)))
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Printf("복사된 개수: %d\n", n)  // 3
fmt.Println("dst:", dst)           // [1 2 3]
fmt.Println("src:", src)           // [1 2 3 4 5] (변경 없음)
// dst 수정해도 src 영향 없음
dst[0] = 100
fmt.Println("src:", src)  // [1 2 3 4 5]

슬라이스 복제

// 방법 1: copy 사용
src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
copy(dst, src)
// 방법 2: append 사용
dst2 := append([]int(nil), src...)
// 방법 3: 슬라이싱 + append
dst3 := append(src[:0:0], src...)

메모리 누수 방지

// ❌ 나쁜 예: 큰 슬라이스의 일부만 사용
func processData(data []byte) []byte {
    // data가 1GB인데 처음 100바이트만 필요
    return data[:100]  // 1GB 전체가 메모리에 유지됨!
}
// ✅ 좋은 예: 필요한 부분만 복사
func processData(data []byte) []byte {
    result := make([]byte, 100)
    copy(result, data[:100])
    return result  // 100바이트만 유지
}

9. 완전한 예제

예제 1: 동적 배열 구현

package main
import "fmt"
type DynamicArray struct {
    data []int
}
func NewDynamicArray() *DynamicArray {
    return &DynamicArray{
        data: make([]int, 0, 10),
    }
}
func (da *DynamicArray) Append(value int) {
    da.data = append(da.data, value)
}
func (da *DynamicArray) Get(index int) (int, bool) {
    if index < 0 || index >= len(da.data) {
        return 0, false
    }
    return da.data[index], true
}
func (da *DynamicArray) Size() int {
    return len(da.data)
}
func (da *DynamicArray) Capacity() int {
    return cap(da.data)
}
func main() {
    arr := NewDynamicArray()
    
    for i := 0; i < 15; i++ {
        arr.Append(i)
        fmt.Printf("Size: %d, Capacity: %d\n", 
                   arr.Size(), arr.Capacity())
    }
}

예제 2: 슬라이스 필터링

package main
import "fmt"
func filter(s []int, predicate func(int) bool) []int {
    result := make([]int, 0, len(s))
    for _, v := range s {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}
func main() {
    numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    
    // 짝수만 필터링
    evens := filter(numbers, func(n int) bool {
        return n%2 == 0
    })
    
    fmt.Println("짝수:", evens)  // [2 4 6 8 10]
    
    // 5보다 큰 수만 필터링
    greaterThan5 := filter(numbers, func(n int) bool {
        return n > 5
    })
    
    fmt.Println("5보다 큰 수:", greaterThan5)  // [6 7 8 9 10]
}

예제 3: 슬라이스 역순 정렬

package main
import "fmt"
func reverse(s []int) {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}
func main() {
    s := []int{1, 2, 3, 4, 5}
    fmt.Println("Before:", s)
    
    reverse(s)
    fmt.Println("After:", s)  // [5 4 3 2 1]
}

예제 4: 슬라이스에서 요소 제거

package main
import "fmt"
// 인덱스로 요소 제거 (순서 유지)
func removeOrdered(s []int, index int) []int {
    return append(s[:index], s[index+1:]...)
}
// 인덱스로 요소 제거 (순서 무시, 빠름)
func removeUnordered(s []int, index int) []int {
    s[index] = s[len(s)-1]
    return s[:len(s)-1]
}
func main() {
    s1 := []int{1, 2, 3, 4, 5}
    s1 = removeOrdered(s1, 2)
    fmt.Println("Ordered:", s1)  // [1 2 4 5]
    
    s2 := []int{1, 2, 3, 4, 5}
    s2 = removeUnordered(s2, 2)
    fmt.Println("Unordered:", s2)  // [1 2 5 4]
}

10. 자주 발생하는 실수

실수 1: append 결과를 받지 않음

// ❌ 잘못된 예
func wrong() {
    s := []int{1, 2, 3}
    append(s, 4)  // 결과를 받지 않음!
    fmt.Println(s)  // [1 2 3] - 변경 안 됨
}
// ✅ 올바른 예
func correct() {
    s := []int{1, 2, 3}
    s = append(s, 4)  // 결과를 받아야 함
    fmt.Println(s)  // [1 2 3 4]
}

실수 2: 슬라이싱 후 원본 수정

// ⚠️ 주의: 메모리 공유
func dangerous() {
    s := []int{1, 2, 3, 4, 5}
    s1 := s[1:3]  // [2 3]
    
    s1[0] = 100
    fmt.Println(s)  // [1 100 3 4 5] - 원본도 변경됨!
}
// ✅ 안전한 방법: 복사
func safe() {
    s := []int{1, 2, 3, 4, 5}
    s1 := make([]int, 2)
    copy(s1, s[1:3])
    
    s1[0] = 100
    fmt.Println(s)  // [1 2 3 4 5] - 원본 유지
}

실수 3: 반복문에서 append

// ❌ 비효율적
func inefficient() []int {
    var result []int
    for i := 0; i < 1000; i++ {
        result = append(result, i)  // 여러 번 재할당
    }
    return result
}
// ✅ 효율적: 용량 미리 할당
func efficient() []int {
    result := make([]int, 0, 1000)
    for i := 0; i < 1000; i++ {
        result = append(result, i)  // 재할당 없음
    }
    return result
}

실수 4: nil 슬라이스와 빈 슬라이스 혼동

var s1 []int        // nil 슬라이스
s2 := []int{}       // 빈 슬라이스
s3 := make([]int, 0) // 빈 슬라이스
fmt.Println(s1 == nil)  // true
fmt.Println(s2 == nil)  // false
fmt.Println(s3 == nil)  // false
// 하지만 len, cap은 모두 0
fmt.Println(len(s1), cap(s1))  // 0 0
fmt.Println(len(s2), cap(s2))  // 0 0
fmt.Println(len(s3), cap(s3))  // 0 0
// append는 모두 동작
s1 = append(s1, 1)
s2 = append(s2, 1)
s3 = append(s3, 1)

실수 5: 슬라이스를 함수에 전달할 때

// ⚠️ 주의: 요소는 수정되지만 길이/용량은 공유 안 됨
func modify(s []int) {
    s[0] = 100  // ✅ 원본 수정됨
    s = append(s, 4)  // ❌ 원본에 영향 없음
}
func main() {
    s := []int{1, 2, 3}
    modify(s)
    fmt.Println(s)  // [100 2 3] - append는 반영 안 됨
}
// ✅ 올바른 방법: 포인터 사용
func modifyCorrect(s *[]int) {
    (*s)[0] = 100
    *s = append(*s, 4)
}
func main() {
    s := []int{1, 2, 3}
    modifyCorrect(&s)
    fmt.Println(s)  // [100 2 3 4]
}

11. 실전 활용 패턴

패턴 1: 슬라이스 사전 할당

// 크기를 알 때
func preAllocate(size int) []int {
    return make([]int, 0, size)
}
// 사용 예
result := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
    result = append(result, i)
}

패턴 2: 슬라이스 풀링

import "sync"
var slicePool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 0, 1024)
    },
}
func processData(data []byte) {
    buf := slicePool.Get().([]byte)
    defer func() {
        buf = buf[:0]  // 길이를 0으로 리셋
        slicePool.Put(buf)
    }()
    
    // buf 사용...
}

패턴 3: 슬라이스 중복 제거

func unique(s []int) []int {
    seen := make(map[int]bool)
    result := make([]int, 0, len(s))
    
    for _, v := range s {
        if !seen[v] {
            seen[v] = true
            result = append(result, v)
        }
    }
    
    return result
}
// 사용
numbers := []int{1, 2, 2, 3, 3, 3, 4, 5, 5}
fmt.Println(unique(numbers))  // [1 2 3 4 5]

패턴 4: 슬라이스 병합

func merge(slices ...[]int) []int {
    totalLen := 0
    for _, s := range slices {
        totalLen += len(s)
    }
    
    result := make([]int, 0, totalLen)
    for _, s := range slices {
        result = append(result, s...)
    }
    
    return result
}
// 사용
s1 := []int{1, 2, 3}
s2 := []int{4, 5, 6}
s3 := []int{7, 8, 9}
merged := merge(s1, s2, s3)
fmt.Println(merged)  // [1 2 3 4 5 6 7 8 9]

패턴 5: 슬라이스 분할

func chunk(s []int, size int) [][]int {
    var chunks [][]int
    
    for i := 0; i < len(s); i += size {
        end := i + size
        if end > len(s) {
            end = len(s)
        }
        
        // 복사본 생성 (메모리 공유 방지)
        chunk := make([]int, end-i)
        copy(chunk, s[i:end])
        chunks = append(chunks, chunk)
    }
    
    return chunks
}
// 사용
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
chunks := chunk(numbers, 3)
fmt.Println(chunks)  // [[1 2 3] [4 5 6] [7 8 9] [10]]

12. 정리

핵심 요약

개념설명
슬라이스 구조포인터 + 길이 + 용량 (24 bytes)
배열 vs 슬라이스고정 크기 vs 가변 크기, 값 복사 vs 참조
길이현재 요소 개수 (len)
용량재할당 없이 담을 수 있는 최대 개수 (cap)
append용량 초과 시 자동 재할당 (약 2배)
슬라이싱원본 배열 공유 (메모리 공유)
copy독립적인 복사본 생성

슬라이스 사용 원칙

  1. 용량 사전 할당: 크기를 알면 make([]T, 0, capacity) 사용
  2. append 결과 받기: s = append(s, value) 형태로 사용
  3. 메모리 공유 주의: 슬라이싱 후 원본 수정 시 영향 고려
  4. 독립 복사: 필요시 copy 사용
  5. nil 체크: len(s) == 0 대신 s == nil 사용 가능

성능 최적화 팁

// ❌ 비효율적
var result []int
for i := 0; i < 10000; i++ {
    result = append(result, i)  // 여러 번 재할당
}
// ✅ 효율적
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    result = append(result, i)  // 재할당 없음
}
// ✅ 더 효율적 (길이를 알 때)
result := make([]int, 10000)
for i := 0; i < 10000; i++ {
    result[i] = i  // append 없이 직접 할당
}

참고 자료


자주 묻는 질문 (FAQ)

Q. 슬라이스는 언제 재할당되나요?

A. append 시 현재 용량을 초과하면 자동으로 재할당됩니다. 일반적으로 기존 용량의 약 2배로 증가합니다.

Q. nil 슬라이스와 빈 슬라이스의 차이는?

A. nil 슬라이스는 var s []int로 선언되고 메모리 할당이 없습니다. 빈 슬라이스는 []int{}로 메모리가 할당되어 있습니다. 하지만 lencap은 모두 0이으며, append는 둘 다 동작합니다.

Q. 슬라이스를 함수에 전달하면 복사되나요?

A. 슬라이스 헤더(24 bytes)만 복사됩니다. 실제 데이터는 공유되므로, 요소 수정은 원본에 영향을 줍니다. 하지만 append로 길이/용량 변경은 원본에 영향 없습니다.

Q. 슬라이스의 최대 크기는?

A. 이론적으로는 int의 최대값이지만, 실제로는 사용 가능한 메모리에 의해 제한됩니다.

Q. 슬라이스를 비교할 수 있나요?

A. == 연산자로는 nil과만 비교 가능합니다. 요소별 비교는 직접 구현하거나 reflect.DeepEqual을 사용해야 합니다. 한 줄 요약: Go 슬라이스는 포인터·길이·용량으로 구성된 동적 배열로, 메모리를 공유하므로 사용 시 주의가 필요합니다.

관련 글


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Go, slice, 슬라이스, 배열, 메모리, 포인터 등으로 검색하시면 이 글이 도움이 됩니다.