2025-11-28
Go 언어에서 Array, Slice, Map은 가장 기본이 되는 자료구조이지만, 특히 Slice는 Go만의 독특한 참조(Reference) 및 메모리 관리 방식을 내포하고 있어 초보 개발자에게 가장 많은 혼란과 실수를 유발합니다.
이 글에서는 단순히 문법을 나열하는 것을 넘어, 각 자료구조의 메모리상 동작 원리, 실무에서 발생하는 치명적인 Slice 버그, 그리고 성능 최적화를 위한 Map 사용법까지 심층적으로 다룹니다.
🛠️ 실무적 자료구조 이해의 중요성
Go 언어는 컴파일러와 가비지 컬렉터(GC)가 메모리 관리를 대부분 수행하지만, Slice와 Map을 잘못 사용하면 불필요한 메모리 재할당(Re-allocation)과 GC 부하를 일으켜 서버의 성능 저하로 이어집니다. 자료구조의 내부 동작을 이해하는 것은 고성능 Go 애플리케이션을 구축하는 핵심 역량입니다.
Array는 단일 타입의 요소가 고정된 크기로 연속된 메모리 공간에 저장되는 자료구조입니다.
var a [5]int // 길이가 5인 int 배열, Zero Value인 0으로 초기화
b := [5]int{1, 2, 3, 4, 5} // 리터럴 초기화
🔑 실무 통찰: Go에서는 Array가 통째로 복사되는 ‘값 타입’이기 때문에, 크기가 큰 배열을 함수 인수로 전달하면 메모리 복사 비용이 매우 커집니다. 이 때문에 Array는 Go 실무에서 거의 사용되지 않으며, 대신 Slice가 주로 사용됩니다. Array는 오직 Slice의 기반 데이터 구조(Backing Array)로만 활용됩니다.
Slice는 Go 언어에서 가장 중요하고 자주 사용되는 동적 자료구조입니다. 겉보기에는 가변 길이 배열처럼 보이지만, 그 동작 원리는 Array의 일부를 참조하는 구조입니다.
Slice는 데이터 자체가 아닌, 다음 세 가지 정보를 담는 헤더(Header) 구조체입니다. 이 헤더는 24바이트(64비트 환경 기준)의 작은 값 타입(Value Type)입니다.
| 구성 요소 | 설명 | 실무적 의미 |
|---|---|---|
| Ptr (포인터) | 실제 데이터가 저장된 배열의 시작 주소를 가리킵니다. | 여러 Slice가 같은 배열을 가리킬 수 있습니다. |
| Len (길이) | 현재 Slice가 포함하는 요소의 개수입니다. (len() 함수로 확인) | for range 루프의 반복 횟수를 결정합니다. |
| Cap (용량) | Ptr이 가리키는 시작점부터 배열의 끝까지의 총 요소 개수입니다. (cap() 함수로 확인) | 메모리 재할당 없이 확장 가능한 최대 크기입니다. |
Slice는 Array를 참조하므로, 두 개 이상의 Slice가 같은 배열의 일부를 참조하고 있다면, 하나의 Slice를 통한 데이터 변경이 다른 Slice에 영향을 미칩니다.
arr := [5]int{1, 2, 3, 4, 5}
slice1 := arr[1:3] // {2, 3} (Backing Array: arr[1] ~ arr[2])
slice2 := arr[0:4] // {1, 2, 3, 4} (Backing Array: arr[0] ~ arr[3])
slice1[0] = 99 // slice1의 첫 번째 값(원래 2)을 99로 변경
// 🔴 결과
fmt.Println(arr) // [1 99 3 4 5] -> 원본 배열이 변경됨!
fmt.Println(slice2) // [1 99 3 4] -> slice2의 두 번째 값도 99로 변경됨!
🔑 실무 통찰: 함수가 Slice를 인수로 받으면, Slice 헤더(24바이트)는 복사되지만, 내부의 Ptr은 원본 배열을 가리킵니다. 따라서 함수 내부에서 Slice의 요소 값을 변경하면 호출자에게도 영향을 미칩니다. 이를 방지하고 싶다면
copy()내장 함수를 사용하여 깊은 복사(Deep Copy)를 해야 합니다.
append()의 비밀: 메모리 재할당과 성능 저하append() 내장 함수는 Slice에 요소를 추가하는 데 사용됩니다. 여기서 Cap(용량) 개념이 핵심적으로 작동합니다.
slice := []int{1, 2, 3} // Len=3, Cap=3
slice = append(slice, 4) // 용량 부족!
// 🔴 내부 동작: 새로운 배열(Cap=6) 할당 및 복사 발생
이 메모리 재할당 및 복사 과정은 매우 비싼 작업이므로, 빈번하게 발생하면 GC 부하와 성능 저하로 직결됩니다.
make()를 이용한 사전 할당실무에서는 Slice의 최종 크기를 예측할 수 있다면, make() 함수를 사용하여 미리 충분한 메모리를 할당하는 것이 성능 최적화의 핵심입니다.
// make([]T, len, cap)
// 100개의 요소를 저장할 공간을 미리 할당 (Cap=100)
result := make([]MyStruct, 0, 100)
for i := 0; i < 100; i++ {
// 100번의 append 동안 메모리 재할당(복사)이 단 한 번도 발생하지 않음
result = append(result, MyStruct{...})
}
cap을 0보다 큰 값으로 명시적으로 지정하면, Go는 100번의 append 호출 동안 복사 작업을 수행할 필요가 없어지므로 I/O나 대용량 데이터 처리 루프의 성능이 획기적으로 개선됩니다.
Map은 키(Key)와 값(Value)의 쌍으로 이루어진 자료구조입니다. Go의 Map은 내부적으로 해시 테이블(Hash Table)을 기반으로 구현되어 있어, O(1)에 가까운 평균 조회 속도를 제공합니다.
make의 역할Slice와 마찬가지로 Map은 참조 타입(Reference Type)이므로, 선언만 하고 초기화하지 않으면 nil 값을 가집니다.
var m map[string]int (읽기는 가능, 쓰기/삭제 시 Panic 발생)m := make(map[string]int) (읽기, 쓰기, 삭제 모두 가능)// ✅ 권장: make를 사용하여 초기화
m := make(map[string]string)
m["apple"] = "사과"
// ✅ 리터럴을 사용한 초기화
m2 := map[int]string{
1: "하나",
2: "둘",
}
🔑 실무 통찰: Map은
make를 통해 초기화해야만 값을 쓸 수 있습니다. 초기화되지 않은nilMap에 값을 대입하려고 하면 런타임 에러(Panic)가 발생하여 프로그램이 종료됩니다.
Map에서 키를 조회할 때, 해당 키가 존재하지 않으면 값의 Zero Value가 반환됩니다. 예를 들어, map[string]int에서 존재하지 않는 키를 찾으면 0이 반환됩니다.
grades := map[string]int{"Alice": 90}
fmt.Println(grades["Bob"]) // 출력: 0 (Bob의 점수가 0점인지, 존재하지 않는지 알 수 없음)
이를 구별하기 위해 Go는 “Comma-Ok Idiom (콤마-오케이 관용구)“을 사용합니다.
// value: 값, ok: 키 존재 여부 (true/false)
value, ok := grades["Bob"]
if !ok {
fmt.Println("🔴 Bob이라는 키는 Map에 존재하지 않습니다.")
} else {
fmt.Printf("✅ Bob의 점수는 %d점입니다.\n", value)
}
실무에서는 Map에서 값을 조회할 때 이 ok 변수를 항상 확인하는 것이 표준이며, 이를 생략하면 논리적 오류를 유발할 수 있습니다.
Go의 기본 map 자료구조는 고루틴(Goroutine)을 사용하는 동시성 환경에서 안전하지 않습니다.
🛠️ 해결책:
sync.RWMutex또는sync.Map
sync.RWMutex사용: Map을 구조체 내부에 포함하고, 읽기/쓰기 시 잠금(Lock)을 설정하여 안전하게 접근합니다. (가장 일반적인 패턴)sync.Map사용: 표준 라이브러리에서 제공하는 동시성 안전 Map을 사용합니다. (단, 사용법이 일반 Map과 다르고 특정 상황에서만 성능상 이점이 있음)
for range의 활용과 주의점Array, Slice, Map을 순회할 때 Go 언어의 for range는 가장 간결하고 안전한 방식입니다.
| 자료구조 | for range가 반환하는 값 | 특징 |
|---|---|---|
| Array/Slice | 인덱스(Index), 값(Value) | 값을 복사하여 반환합니다. |
| Map | 키(Key), 값(Value) | 순서가 보장되지 않습니다. |
| String | 바이트 인덱스, 유니코드 문자(rune) | UTF-8 문자를 안전하게 순회합니다. |
Go의 Map은 순서가 보장되지 않습니다. Map을 순회할 때마다 요소가 반환되는 순서가 달라질 수 있습니다.
🔑 실무 통찰: 만약 순서가 필요한 경우라면, 별도의 Slice에 Map의 Key만 모아 정렬한 후, 이 Key Slice를 순회하며 Map에서 값을 조회하는 패턴을 사용해야 합니다.
for range의 값 복사 재확인이전 흐름 제어 글에서 다룬 것처럼, Slice나 Array를 순회할 때 val은 요소의 복사본입니다. 이 복사본의 주소를 Goroutine에 넘겨주면 버그가 발생하므로, 반드시 item := val처럼 루프 내에서 다시 변수를 선언하여 전달해야 합니다. 이는 Go 언어에서 가장 중요한 동시성 버그 방지 기법 중 하나입니다.
이 글에서는 Go 언어의 가장 핵심적인 자료구조인 Array, Slice, Map을 내부 구조와 실무적 관점에서 깊이 있게 다루었습니다. 특히 Slice의 메모리 관리와 Map의 동시성 문제는 견고하고 고성능의 Go 서버를 구축하는 데 필수적인 지식입니다. 다음 글에서는 이러한 참조 타입 자료구조들이 가질 수 있는 ‘초기화되지 않은 상태’인 nil에 대해 심도 있게 다루며, 실무에서 가장 흔하게 발생하는 ‘인터페이스-nil’ 함정을 파헤쳐 보겠습니다.