본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 09. 08:02

모든 Go 개발자가 첫 운영 장애를 겪기 전에 알았더라면 좋았을 것들

요약

Go 언어의 슬라이스(Slice) 동작 원리를 오해하여 발생하는 운영 장애 사례와 해결 방법을 다룹니다. 슬라이스 헤더 복사 방식과 기반 배열(Underlying array) 공유로 인한 데이터 오염 문제를 심도 있게 분석합니다.

핵심 포인트

  • 슬라이스 복사는 데이터가 아닌 포인터를 복사함
  • append 사용 시 새로운 슬라이스 헤더를 반환받아야 함
  • 슬라이스 간 기반 배열 공유로 인한 의도치 않은 데이터 변경 주의
  • 독립적인 데이터가 필요할 경우 copy 함수로 명시적 복사 수행

나에게 4시간을 앗아간 슬라이스 함정 (그리고 다시는 빠지지 않는 방법)

여러분도 그런 경험이 있을 것입니다. 도무지 이해할 수 없는 버그. 보지 않을 때 변해버리는 데이터. 조용히 괴물을 만들어낸 append.

아직도 저를 괴롭히는 어느 화요일 밤의 이야기를 해보겠습니다.

API가 중복된 항목을 반환하고 있었습니다. 항상 그런 것은 아니었습니다. 가끔씩 그랬죠. 페이로드(Payload)가 특정 크기를 넘을 때만 그랬습니다. 마치 달이 일곱 번째 집에 있을 때처럼 말이죠.

4시간. 4명의 개발자. 47개의 메시지가 담긴 하나의 Slack 스레드.

범인은 무엇이었을까요? 바로 슬라이스(Slice)였습니다. 그저 슬라이스 하나였죠. 무해해 보였습니다. 심지어 귀엽기까지 했죠. 그리고 우리 4명 모두가 완전히 오해하고 있었습니다.

오늘 여러분은 우리가 배웠던 것을 배우게 될 것입니다. 그리고 다시는 이런 실수를 반복하지 않게 될 것입니다.

파트 1: 그들이 당신에게 말하는 슬라이스의 거짓말

대부분의 튜토리얼은 슬라이스를 다음과 같이 가르칩니다:

"슬라이스는 동적으로 크기가 조절되는 배열(Array)입니다."

이 말이 틀린 것은 아니지만, 이는 "자동차는 바퀴가 달린 금속 상자입니다"라고 말하는 것과 같습니다. 기술적으로는 맞습니다. 하지만 엔진이 고장 났을 때는 완전히 무용지물이죠.

슬라이스의 실제 모습은 다음과 같습니다:

// 이것은 슬라이스가 아닙니다
numbers := []int{1, 2, 3}

...

시각화해 봅시다:

slice := []int{1, 2, 3}

┌─────────────────────────────────────┐
...

포인터(Pointer)가 위험한 부분입니다. 슬라이스를 복사할 때, 데이터가 아니라 포인터를 복사하기 때문입니다.

original := []int{1, 2, 3}
copycat := original  // 이것은 구조체(ptr, len, cap)를 복사합니다

...

두 변수 모두 동일한 기반 배열(Underlying array)을 가리킵니다. 이것은 의도된 설계입니다. 강력하죠. 하지만 동시에 슬라이스 버그의 90%가 발생하는 원인이기도 합니다.

파트 2: 당신을 물어뜯을 4가지 슬라이스 함정

함정 #1: 당신을 배신한 append

func main() {
    original := []int{1, 2, 3}
    addElement(original)
...

이것이 실패하는 이유:

append는 새로운 슬라이스 헤더(Slice header)를 반환합니다. 용량(Capacity)이 허용된다면 동일한 기반 배열을 가리킬 수도 있고, 용량을 초과하면 완전히 새로운 배열을 가리킬 수도 있습니다. 하지만 함수 내부의 s는 헤더의 복사본입니다. 이를 수정하는 것은 호출자의 변수에 영향을 주지 않습니다.

해결책:

func addElement(s []int) []int {
    return append(s, 4)
}
...

규칙: 함수가 슬라이스(길이, 용량 또는 요소)를 수정하는 경우, 새로운 슬라이스를 반환하거나 포인터를 사용해야 합니다.

함정 #2: 내 주말을 망친 공유 배열 (The Shared Array That Ate My Weekend)

이것은 프로덕션 환경에서 치명적인 문제입니다.

func main() {
    users := []User{
        {Name: "Alice"},
...

무슨 일이 일어났나:

모든 subsetusers와 동일한 기본 배열(underlying array)을 공유했습니다. users[1]을 변경하면 해당 인덱스를 포함하는 모든 슬라이스가 함께 변경되었습니다.

해결책: 독립성이 필요할 때 강제로 복사해야 합니다.

subset := make([]User, i+1)
copy(subset, users[:i+1])
admins = append(admins, subset)

규칙: 독립성이 필요하면 복사하세요. 공유가 필요하다면 (성능을 위해) 참조를 유지하세요. 하지만 어떤 것을 가지고 있는지 알아야 합니다.

함정 #3: 용량 혼란 (The Capacity Confusion)

func main() {
    s1 := make([]int, 3, 3)  // len=3, cap=3
    s2 := append(s1, 4)      // len=4, cap=6 (새 배열!)
...

append는 용량이 초과되면 새로운 배열을 생성합니다. 슬라이스들이 조용히 분리됩니다.

규칙: append를 수행한 후에는 결과를 원본과 잠재적으로 다르게 취급해야 합니다. 절대 메모리를 공유한다고 가정하지 마세요.

함정 #4: 루프 변수 재사용 (Loop Variable Reuse) (고전적인 사례)

func main() {
    users := []string{"Alice", "Bob", "Charlie"}
    var pointers []*string
...

끔찍한 점: 루프 변수 name이 재사용됩니다. 이 주소는 절대 바뀌지 않습니다. 모든 포인터가 동일한 메모리 위치를 가리키게 되고, 결국 마지막 값을 담게 됩니다.

해결책:

for _, name := range users {
    n := name  // 새로운 변수를 생성합니다
    pointers = append(pointers, &n)
...

또는 인덱스를 사용하세요:

for i := range users {
    pointers = append(pointers, &users[i])
}

파트 3: 모든 것을 해결하는 정신 모델 (The Mental Model That Fixes Everything)

슬라이스를 '동적 배열'로 생각하는 것을 멈추세요. 슬라이스는 창문 보기(window views)라고 생각하세요.

기본 배열이 집들이 늘어선 긴 거리에 있다고 상상해 보세요:

[H0][H1][H2][H3][H4][H5][H6][H7]  ← 기본 배열 (Underlying array)

슬라이스(Slice)는 연속된 섹션을 바라보는 창(window)입니다:

slice1 := array[2:5]  → [H2][H3][H4]를 바라보는 창
slice2 := array[3:6]  → [H3][H4][H5]를 바라보는 창

이 모델의 핵심 통찰(Key insights):

  • 두 개의 창이 같은 집을 바라볼 수 있습니다. H3를 변경하면 두 창 모두 그 변화를 봅니다.
  • 창을 확장(appending)할 때, 현재 거리(street)의 오른쪽에 더 이상 집이 없다면 새로운 거리로 이동해야 할 수도 있습니다.
  • 창을 복사(copying)한다고 해서 집들이 복사되는 것은 아닙니다. 단지 뷰(view)만 복사될 뿐입니다.

슬라이스를 창으로 이해하고 나면, 모든 동작이 이해될 것입니다.

파트 4: 실제로 사용하게 될 치트 시트 (Cheat Sheet)

언제 복사해야 하는가

시나리오복사 여부이유
슬라이스를 수정하는 함수로부터 슬라이스를 반환할 때호출자(Caller)는 독립성을 기대함
...

복사 패턴 (The Copy Pattern)

// 슬라이스를 복사하는 안전한 방법
func copySlice(original []int) []int {
    result := make([]int, len(original))
...

추가 패턴 (The Append Pattern) (항상 이것을 사용하세요)

// 나쁜 예: append가 제자리에서(in place) 수정한다고 가정함
func add(s []int, v int) {
    s = append(s, v)  // 호출자는 이를 볼 수 없음
...

파트 5: 실제 운영 환경에서의 사례

예시 1: 페이지네이션 버그 (The Pagination Bug)

// 수정 전: 버그 발생
func paginate(items []Item, page, size int) []Item {
    start := page * size
...
// 수정 후: 해결책
func paginate(items []Item, page, size int) []Item {
    start := page * size
...

예시 2: 배치 프로세서 (The Batch Processor)

type Batch struct {
    Items []Item
}
...

파트 6: 전문가 수준의 슬라이스 최적화 (Slice Optimization)

더 깊이 파고들 준비가 되었다면, 용량 계획(capacity planning)을 이해해야 합니다.

// 나쁜 예: 여러 번의 재할당 (reallocations)
func collect(results chan int) []int {
    var collected []int  // cap=0
...

하지만 Go는 이미 기하급수적 성장(geometric growth, 일반적으로 1024까지는 두 배씩 늘어나고 그 이후에는 약 1.25배씩 증가)을 수행합니다. 그러니 솔직히 말씀드리면, 성능 문제를 직접 측정해 보지 않았다면 그냥 append를 사용하고 이를 믿으셔도 됩니다.

이번 주의 도전 과제

현재 작성 중인 Go 코드에서 모든 슬라이스를 찾아보세요. 그리고 각 슬라이스에 대해 다음을 질문해 보세요:

  1. 기본 배열(underlying array)의 소유권은 누구에게 있는가? (누가 이를 수정할 수 있는가?)
  2. 이 슬라이스(slice)가 공유되는가? (함수로 전달되는가? 구조체(structs)에 저장되는가?)
  3. append가 예기치 않은 동작을 유발할 수 있는가? (결과값이 다시 할당되는가?)

만약 어떤 슬라이스에 대해 이 질문들에 답할 수 없다면, 잠재적인 버그가 있는 것입니다. 지금 바로 수정하세요.

다음 주 예고

"Context: Go에서 가장 오해받는 73줄"

Go의 context 패키지를 해부할 예정입니다. 왜 이것이 단순히 타임아웃(timeouts)만을 위한 것이 아닌지, 어떻게 올바르게 사용하는지, 그리고 운영 환경에서 nil 포인터 패닉(nil pointer panics)을 유발하는 단 하나의 패턴은 무엇인지 알아봅니다.

그때까지 이것을 기억하세요: 슬라이스(slice)는 옷장이 아니라 창문입니다. 같은 창문을 통해 보면, 똑같은 것들을 보게 됩니다.

지난주 독자들이 배운 점

"3년 동안 Go를 작성해 왔지만 '소유권(ownership)'에 대해 의식적으로 생각해 본 적이 없었습니다. 이 글은 제가 모든 것을 설계하는 방식을 바꾸어 놓았습니다." — Miguel, 플랫폼 엔지니어 (Platform Engineer)

"소유권/흐름(ownership/flow)에 대한 멘탈 모델 덕분에 드디어 채널(channels)의 개념이 이해되었습니다." — Priya, 백엔드 개발자 (Backend Developer)

"이 글을 읽고 제 코드베이스에서 3개의 슬라이스 버그를 찾아냈습니다. 무려 3개나 말이죠." — Thomas, 테크 리드 (Tech Lead)

여러분의 Go Gazette는 다음 주 금요일에 편지함으로 배달됩니다. 같은 Go 시간, 같은 Go 채널에서 뵙겠습니다.

부록: 슬라이스 디버깅 빠른 참조

흔한 슬라이스 실수와 해결 방법

// 실수 1: append가 새로운 슬라이스를 반환한다는 사실을 잊음
mySlice := []int{1, 2, 3}
append(mySlice, 4)  // ❌ 결과값이 버려짐
...

슬라이스 용량(Capacity) 증가 (Go 1.18+)

초기 용량 (Initial Cap)Append Until새로운 용량 (New Cap)
011
...
이러한 증가 패턴은 컴파일러 구현 세부 사항(implementation detail)입니다. 이를 정확하게 신뢰하지는 마되, 이러한 패턴이 존재한다는 점은 이해해 두세요.

밤 11시에 슬라이스 버그를 디버깅하며 작성되었습니다. 두 번 편집되었습니다. Go 1.22에서 테스트되었습니다. 패닉(panic) 예제들은 AI가 생성하지 않았습니다. 제가 직접 하나하나 겪으며 얻어낸 결과물입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0