PHpullh

GO · 심층 가이드

Go 컬렉션 완전 정리

슬라이스 내부 구조와 맵 동시성 문제를 짚고, sync.Map·힙·링 버퍼·세트·이진 검색 트리까지 Go 자료구조 13가지를 직접 구현하며 익힙니다.

주제 13개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

슬라이스를 배열로 오해하면 Go 컬렉션은 계속 이상하게 굴러갑니다. 슬라이스는 포인터·길이·용량 세 값을 담은 작은 구조체이고, 복사하면 헤더만 복사됩니다. 그래서 두 슬라이스가 같은 배열을 가리키는 상황이 흔하고, 한쪽에서 원소를 바꾸면 다른 쪽에도 보입니다. 반면 append가 용량을 넘겨 재할당하면 그 연결이 끊깁니다. 같은 코드가 데이터 크기에 따라 다르게 동작하는 것처럼 보이는 이유가 이 지점입니다.

슬라이스 — Go의 핵심 자료구조슬라이스 내부 구조를 붙여 읽고 나면, 맵 — map[K]V에서 맵 동시성 문제, sync.Map으로 이어지는 흐름이 자연스럽습니다. 표준 라이브러리로 해결되는 것과 직접 만들어야 하는 것의 경계도 분명해집니다. 정렬 (sort & slices)힙 (container/heap)은 표준 도구를 쓰는 법이고, 큐 & 스택 구현·세트 구현·이진 검색 트리 구현은 슬라이스와 맵을 재료로 없는 자료구조를 만드는 연습입니다.

맵을 여러 고루틴에서 동시에 쓰면 락을 걸지 않는 한 런타임이 프로그램을 죽입니다. 이건 데이터가 깨지는 정도가 아니라 즉시 종료라서, 테스트에서는 조용하다가 부하가 걸린 운영 환경에서만 드러나기 쉽습니다. 읽기만 하는 동시 접근은 안전하지만 쓰기가 하나라도 섞이면 보호가 필요합니다. sync.Map은 만능이 아니라 키 집합이 고정적이고 읽기가 압도적인 경우에 맞는 도구이니, 일반적인 상황이라면 뮤텍스로 감싼 일반 맵이 더 나은 선택일 때가 많습니다.

01슬라이스 — Go의 핵심 자료구조

슬라이스는 동적 배열입니다. 내부적으로 배열 포인터, 길이, 용량을 갖습니다.

Go code

package main

import "fmt"

func main() {
	// 슬라이스 생성
	s1 := []int{1, 2, 3, 4, 5}
	s2 := make([]int, 5)       // len=5, cap=5, 제로값
	s3 := make([]int, 3, 10)   // len=3, cap=10

	// append — 용량 초과 시 새 배열 할당 (약 2배)
	s := []int{1, 2, 3}
	s = append(s, 4)
	s = append(s, 5, 6, 7)       // 여러 값
	s = append(s, s1...)          // 슬라이스 전개

	// 슬라이싱 [low:high:max]
	fmt.Println(s[1:4])   // [2 3 4]
	fmt.Println(s[:3])    // [1 2 3]
	fmt.Println(s[3:])    // [4 5 6 7 1 2 3 4 5]

	// copy — 독립적인 복사
	dst := make([]int, 3)
	n := copy(dst, s1)
	fmt.Println(dst, n)   // [1 2 3] 3

	// 2D 슬라이스
	matrix := make([][]int, 3)
	for i := range matrix {
		matrix[i] = make([]int, 3)
		for j := range matrix[i] { matrix[i][j] = i*3 + j }
	}

	// 슬라이스 함수들 (Go 1.21+)
	// slices.Sort, slices.Contains, slices.Index
	// import "slices"

	// 삭제 — 순서 유지
	i := 2
	s1 = append(s1[:i], s1[i+1:]...)
	fmt.Println(s1) // [1 2 4 5]

	// 삭제 — 순서 무관 (빠름)
	s2[i] = s2[len(s2)-1]
	s2 = s2[:len(s2)-1]

	fmt.Println(len(s3), cap(s3)) // 3 10
	_ = s2
}
알아두면 좋은 점

슬라이스는 배열의 참조입니다. s2 := s1로 복사하면 같은 배열을 공유합니다. 독립적인 복사가 필요하면 copy()append([]int{}, s...)를 사용하세요.

자주 하는 실수

슬라이스 중간 원소 삭제 시 append(s[:i], s[i+1:]...)는 원본 슬라이스를 수정합니다. 원본이 필요하면 먼저 복사하세요.

02맵 — map[K]V

Go의 해시맵. 키-값 저장소로 동시성에서는 주의가 필요합니다.

Go code

package main

import (
	"fmt"
	"sort"
)

func main() {
	// 맵 생성
	m1 := map[string]int{
		"apple":  5,
		"banana": 3,
		"cherry": 8,
	}
	m2 := make(map[string]int)       // 빈 맵
	m2["key"] = 100

	// 읽기 & 존재 확인 (comma-ok 패턴)
	val, ok := m1["apple"]
	if ok {
		fmt.Println("apple:", val)
	}

	val2 := m1["durian"] // 없으면 zero value (0)
	fmt.Println("durian:", val2)

	// 삭제
	delete(m1, "banana")

	// 순회 (순서 보장 안 됨)
	keys := make([]string, 0, len(m1))
	for k := range m1 { keys = append(keys, k) }
	sort.Strings(keys) // 정렬
	for _, k := range keys {
		fmt.Printf("%s: %d
", k, m1[k])
	}

	// 중첩 맵
	graph := map[string][]string{
		"A": {"B", "C"},
		"B": {"D"},
		"C": {"D", "E"},
	}
	fmt.Println(graph["A"])

	// 빈도 카운팅 패턴
	words := []string{"go", "is", "great", "go", "is"}
	freq := make(map[string]int)
	for _, w := range words { freq[w]++ }
	fmt.Println(freq)

	// 구조체를 값으로
	type Point struct{ X, Y int }
	points := map[string]Point{
		"origin": {0, 0},
		"unit":   {1, 1},
	}
	fmt.Println(points["unit"])
}
알아두면 좋은 점

맵에 nil 맵에 쓰기(var m map[string]int; m["key"] = 1)는 런타임 패닉입니다. 항상 make()나 리터럴로 초기화하세요.

자주 하는 실수

맵은 동시 읽기는 안전하지만 동시 쓰기는 race condition입니다. 고루틴에서 맵을 공유하려면 sync.RWMutexsync.Map을 사용하세요.

03iter 패키지 (Go 1.23+)

range over function으로 커스텀 이터레이터

Go code

<span class="cm">// iter 패키지 (Go 1.23+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("iter 패키지 (Go 1.23+)") }
알아두면 좋은 점

GO 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

04슬라이스 내부 구조

슬라이스는 배열에 대한 (포인터, 길이, 용량) 헤더입니다. 내부 동작을 이해하면 성능과 버그를 예방할 수 있습니다.

Go code

package main

import "fmt"

func main() {
	// 슬라이스 헤더: (ptr, len, cap)
	s := make([]int, 3, 5)
	fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)

	// append: 용량 초과 시 새 배열 할당
	s = append(s, 1, 2)    // cap 5 이내
	fmt.Printf("len=%d cap=%d\n", len(s), cap(s))

	s = append(s, 3)       // cap 초과 → 새 배열
	fmt.Printf("len=%d cap=%d\n", len(s), cap(s))

	// 서브슬라이스는 원본과 배열 공유!
	a := []int{1, 2, 3, 4, 5}
	b := a[1:3]             // [2, 3]
	b[0] = 99
	fmt.Println(a) // [1 99 3 4 5] — 원본도 변경됨!

	// 독립 복사
	c := make([]int, len(a))
	copy(c, a)
	c[0] = 0
	fmt.Println(a[0], c[0]) // 1 0
}
알아두면 좋은 점

서브슬라이스가 원본과 메모리를 공유하는 것을 방지하려면 copy 또는 append([]T{}, slice...)로 복사하세요.

자주 하는 실수

append가 용량을 초과하면 새 배열을 할당합니다. 기존 서브슬라이스는 여전히 옛 배열을 가리키므로 데이터 불일치가 발생할 수 있습니다.

05맵 동시성 문제

Go의 map은 동시 읽기/쓰기에 안전하지 않습니다. sync.Mutexsync.RWMutex로 보호해야 합니다.

Go code

package main

import (
	"fmt"
	"sync"
)

// RWMutex로 보호된 맵
type SafeCounter struct {
	mu sync.RWMutex
	m  map[string]int
}

func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key]++
}

func (c *SafeCounter) Get(key string) int {
	c.mu.RLock()         // 읽기 전용 잠금
	defer c.mu.RUnlock()
	return c.m[key]
}

func main() {
	counter := SafeCounter{m: make(map[string]int)}

	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Inc("visits")
		}()
	}
	wg.Wait()
	fmt.Println("visits:", counter.Get("visits")) // 1000
}
알아두면 좋은 점

읽기가 많고 쓰기가 적은 경우 sync.RWMutexsync.Mutex보다 효율적입니다.

자주 하는 실수

map을 동시에 읽고 쓰면 fatal error: concurrent map read and map write 런타임 에러가 발생합니다.

06sync.Map

sync.Map은 동시성 안전한 맵입니다. 키가 안정적이고 고루틴별로 다른 키를 사용할 때 최적입니다.

Go code

package main

import (
	"fmt"
	"sync"
)

func main() {
	var m sync.Map

	// 저장
	m.Store("lang", "Go")
	m.Store("version", "1.22")

	// 로드
	v, ok := m.Load("lang")
	fmt.Println(v, ok) // Go true

	// 없으면 저장, 있으면 기존 값 반환
	actual, loaded := m.LoadOrStore("lang", "Rust")
	fmt.Println(actual, loaded) // Go true (이미 있음)

	// 삭제
	m.Delete("version")

	// 순회
	m.Store("os", "linux")
	m.Range(func(key, value any) bool {
		fmt.Printf("%s = %s\n", key, value)
		return true // false면 순회 중단
	})

	// LoadAndDelete (Go 1.15+)
	v, loaded = m.LoadAndDelete("os")
	fmt.Println("삭제:", v, loaded)
}
알아두면 좋은 점

sync.Map은 키가 한번 쓰고 여러 번 읽히거나, 고루틴마다 다른 키를 사용할 때 최적입니다.

자주 하는 실수

sync.Map은 타입 안전하지 않습니다(any 사용). 대부분의 경우 제네릭 + sync.RWMutex 조합이 더 낫습니다.

07정렬 (sort &amp; slices)

slices.Sortslices.SortFunc로 슬라이스를 정렬합니다. Go 1.21+의 제네릭 기반 정렬입니다.

Go code

package main

import (
	"cmp"
	"fmt"
	"slices"
)

type Student struct {
	Name  string
	Score int
}

func main() {
	// 기본 정렬
	nums := []int{5, 2, 8, 1, 9}
	slices.Sort(nums)
	fmt.Println(nums) // [1 2 5 8 9]

	// 커스텀 정렬
	students := []Student{
		{"Alice", 90}, {"Bob", 85}, {"Charlie", 95},
	}
	slices.SortFunc(students, func(a, b Student) int {
		return cmp.Compare(b.Score, a.Score) // 내림차순
	})
	for _, s := range students {
		fmt.Printf("%s: %d\n", s.Name, s.Score)
	}

	// 안정 정렬
	slices.SortStableFunc(students, func(a, b Student) int {
		return cmp.Compare(a.Name, b.Name)
	})

	// 이진 검색
	idx, found := slices.BinarySearch(nums, 5)
	fmt.Printf("5 at index %d, found=%t\n", idx, found)
}
알아두면 좋은 점

slices.SortFunc의 비교 함수는 음수(a<b), 0(같음), 양수(a>b)를 반환합니다. cmp.Compare를 활용하세요.

자주 하는 실수

slices.Sort는 불안정 정렬입니다. 동일한 키의 원래 순서가 중요하면 slices.SortStableFunc를 사용하세요.

08힙 (container/heap)

container/heap으로 우선순위 큐를 구현합니다. heap.Interface를 구현해야 합니다.

Go code

package main

import (
	"container/heap"
	"fmt"
)

type Item struct {
	Value    string
	Priority int
}

type PriorityQueue []*Item

func (pq PriorityQueue) Len() int            { return len(pq) }
func (pq PriorityQueue) Less(i, j int) bool   { return pq[i].Priority > pq[j].Priority }
func (pq PriorityQueue) Swap(i, j int)        { pq[i], pq[j] = pq[j], pq[i] }

func (pq *PriorityQueue) Push(x any) {
	*pq = append(*pq, x.(*Item))
}

func (pq *PriorityQueue) Pop() any {
	old := *pq
	n := len(old)
	item := old[n-1]
	*pq = old[:n-1]
	return item
}

func main() {
	pq := &PriorityQueue{}
	heap.Init(pq)

	heap.Push(pq, &Item{"낮음", 1})
	heap.Push(pq, &Item{"높음", 10})
	heap.Push(pq, &Item{"중간", 5})

	for pq.Len() > 0 {
		item := heap.Pop(pq).(*Item)
		fmt.Printf("[%d] %s\n", item.Priority, item.Value)
	}
}
알아두면 좋은 점

Less에서 >를 사용하면 최대 힙(높은 우선순위 먼저), 를 사용하면 최소 힙이 됩니다.

자주 하는 실수

직접 append하지 말고 반드시 heap.Push/heap.Pop을 사용해야 힙 속성이 유지됩니다.

09링 버퍼 (container/ring)

container/ring으로 고정 크기 순환 버퍼를 구현합니다. 최근 N개 이벤트 추적에 유용합니다.

Go code

package main

import (
	"container/ring"
	"fmt"
)

func main() {
	// 크기 5인 링 버퍼
	r := ring.New(5)

	// 값 채우기
	for i := 1; i <= 5; i++ {
		r.Value = fmt.Sprintf("이벤트-%d", i)
		r = r.Next()
	}

	// 순회
	r.Do(func(v any) {
		if v != nil {
			fmt.Println(v)
		}
	})

	// 새 이벤트 추가 (가장 오래된 것 덮어쓰기)
	r.Value = "이벤트-6"
	r = r.Next()

	fmt.Println("--- 업데이트 후 ---")
	r.Do(func(v any) {
		if v != nil {
			fmt.Println(v)
		}
	})

	fmt.Println("링 크기:", r.Len())
}
알아두면 좋은 점

링 버퍼는 로그 롤링, 최근 N개 메트릭 추적, 순환 스케줄링에 적합합니다. 메모리를 고정 크기로 유지할 수 있습니다.

자주 하는 실수

ring.New의 값은 nil로 초기화됩니다. Do에서 nil 체크를 하지 않으면 패닉이 발생할 수 있습니다.

10링크드 리스트 (container/list)

container/list는 이중 연결 리스트를 제공합니다. O(1) 삽입/삭제가 필요할 때 사용합니다.

Go code

package main

import (
	"container/list"
	"fmt"
)

func main() {
	l := list.New()

	// 삽입
	l.PushBack("두 번째")
	front := l.PushFront("첫 번째")
	l.PushBack("세 번째")
	l.InsertAfter("사이에", front)

	// 순회
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Println(e.Value)
	}

	// 역순 순회
	fmt.Println("--- 역순 ---")
	for e := l.Back(); e != nil; e = e.Prev() {
		fmt.Println(e.Value)
	}

	// 삭제
	l.Remove(front)
	fmt.Println("길이:", l.Len())
}
알아두면 좋은 점

리스트 원소의 Valueany 타입입니다. 타입 안전성이 필요하면 제네릭 래퍼를 만드세요.

자주 하는 실수

이미 삭제된 원소에 Next()를 호출하면 예상치 못한 동작이 발생합니다. 삭제된 원소를 참조하지 마세요.

11큐 &amp; 스택 구현

슬라이스로 큐(FIFO)와 스택(LIFO)을 구현합니다. 제네릭으로 타입 안전하게 만듭니다.

Go code

package main

import "fmt"

// 제네릭 스택
type Stack[T any] struct{ items []T }

func (s *Stack[T]) Push(v T)       { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
	if len(s.items) == 0 { var z T; return z, false }
	v := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }

// 제네릭 큐
type Queue[T any] struct{ items []T }

func (q *Queue[T]) Enqueue(v T)       { q.items = append(q.items, v) }
func (q *Queue[T]) Dequeue() (T, bool) {
	if len(q.items) == 0 { var z T; return z, false }
	v := q.items[0]
	q.items = q.items[1:]
	return v, true
}
func (q *Queue[T]) Len() int { return len(q.items) }

func main() {
	stack := &Stack[int]{}
	stack.Push(1); stack.Push(2); stack.Push(3)
	v, _ := stack.Pop()
	fmt.Println("스택 Pop:", v) // 3

	queue := &Queue[string]{}
	queue.Enqueue("첫째"); queue.Enqueue("둘째")
	s, _ := queue.Dequeue()
	fmt.Println("큐 Dequeue:", s) // 첫째
}
알아두면 좋은 점

큐의 Dequeue에서 q.items[1:]는 메모리를 해제하지 않습니다. 큰 큐에서는 링 버퍼를 사용하세요.

자주 하는 실수

슬라이스 기반 큐에서 앞에서 제거(items[1:])만 반복하면 GC가 앞부분 메모리를 회수하지 못해 메모리 누수가 발생합니다.

12세트 구현

map[T]struct{}로 세트를 구현합니다. 빈 구조체는 메모리를 차지하지 않습니다.

Go code

package main

import "fmt"

type Set[T comparable] map[T]struct{}

func NewSet[T comparable](items ...T) Set[T] {
	s := make(Set[T], len(items))
	for _, item := range items { s[item] = struct{}{} }
	return s
}

func (s Set[T]) Add(item T)    { s[item] = struct{}{} }
func (s Set[T]) Remove(item T) { delete(s, item) }
func (s Set[T]) Has(item T) bool { _, ok := s[item]; return ok }

func (s Set[T]) Union(other Set[T]) Set[T] {
	result := NewSet[T]()
	for k := range s { result.Add(k) }
	for k := range other { result.Add(k) }
	return result
}

func (s Set[T]) Intersect(other Set[T]) Set[T] {
	result := NewSet[T]()
	for k := range s {
		if other.Has(k) { result.Add(k) }
	}
	return result
}

func main() {
	a := NewSet(1, 2, 3, 4)
	b := NewSet(3, 4, 5, 6)

	fmt.Println("합집합:", a.Union(b))       // 1~6
	fmt.Println("교집합:", a.Intersect(b))   // 3, 4
	fmt.Println("Has 2:", a.Has(2))         // true
}
알아두면 좋은 점

struct{}는 크기가 0바이트입니다. map[T]bool보다 메모리 효율적입니다.

자주 하는 실수

맵의 순회 순서는 랜덤입니다. 세트 출력이 매번 다를 수 있으므로 정렬된 결과가 필요하면 별도로 정렬하세요.

13이진 검색 트리 구현

제네릭으로 이진 검색 트리(BST)를 구현합니다. 정렬된 데이터의 삽입/검색에 사용됩니다.

Go code

package main

import (
	"cmp"
	"fmt"
)

type Node[T cmp.Ordered] struct {
	Value T
	Left  *Node[T]
	Right *Node[T]
}

type BST[T cmp.Ordered] struct {
	Root *Node[T]
}

func (t *BST[T]) Insert(val T) {
	t.Root = insert(t.Root, val)
}

func insert[T cmp.Ordered](n *Node[T], val T) *Node[T] {
	if n == nil { return &Node[T]{Value: val} }
	if val < n.Value {
		n.Left = insert(n.Left, val)
	} else if val > n.Value {
		n.Right = insert(n.Right, val)
	}
	return n
}

func (t *BST[T]) InOrder() []T {
	var result []T
	inOrder(t.Root, &result)
	return result
}

func inOrder[T cmp.Ordered](n *Node[T], result *[]T) {
	if n == nil { return }
	inOrder(n.Left, result)
	*result = append(*result, n.Value)
	inOrder(n.Right, result)
}

func main() {
	tree := &BST[int]{}
	for _, v := range []int{5, 3, 7, 1, 4, 6, 8} {
		tree.Insert(v)
	}
	fmt.Println(tree.InOrder()) // [1 3 4 5 6 7 8]
}
알아두면 좋은 점

cmp.Ordered 제약을 사용하면 , > 연산자를 제네릭 함수에서 사용할 수 있습니다.

자주 하는 실수

균형 잡히지 않은 BST는 최악의 경우 O(n) 검색 시간이 됩니다. 실무에서는 btree 라이브러리나 표준 맵을 사용하세요.

정리하며

  • 슬라이스 복사는 헤더만 복사하므로 두 슬라이스가 같은 배열을 공유할 수 있습니다
  • append가 용량을 넘기면 재할당되어 기존 슬라이스와의 공유가 끊깁니다
  • 맵에 동시 쓰기가 하나라도 섞이면 런타임이 프로그램을 즉시 종료시킵니다
  • sync.Map은 읽기 편중 상황용이고 일반적으로는 뮤텍스 + 맵이 더 적합합니다

더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.