Published on

Go 1.22 range 변수 캡처 버그? 고치는 법

Authors

서버 개발을 하다 보면 for range 안에서 고루틴을 띄우거나 콜백을 등록하는 코드를 자주 작성합니다. 그런데 결과 로그가 뒤죽박죽이거나, 모든 고루틴이 같은 값만 출력하는 현상을 한 번쯤 겪었을 겁니다. 흔히들 이를 “range 변수 캡처 버그”라고 부르지만, 실제로는 언어 스펙과 루프 변수의 스코프/재사용 방식 때문에 생기는 전형적인 함정이었습니다.

Go 1.22에서는 이 이슈를 둘러싼 상황이 꽤 달라졌습니다. 기존에 안전하다고 믿던 패턴이 더 이상 필요 없을 수도 있고, 반대로 팀의 Go 버전이 섞여 있으면 동일 코드가 서로 다른 결과를 낼 수도 있습니다. 이 글에서는 다음을 목표로 합니다.

  • 문제가 왜 발생하는지(정확히 무엇이 캡처되는지)
  • Go 1.21 이하와 Go 1.22의 동작 차이
  • 가장 안전하고 읽기 좋은 수정 패턴
  • 레거시/혼재 버전 환경에서의 실무 대응

range 변수 캡처 문제의 본질

클로저(익명 함수)가 바깥 스코프의 변수를 참조할 때, 그 변수는 값이 복사되는 것이 아니라 변수 자체(메모리 위치)를 참조합니다. 문제는 for/range 루프의 반복 변수(i, v 같은 것)가 반복마다 새로 만들어지는 게 아니라 같은 변수가 재사용되는 형태로 동작해 왔다는 점입니다(Go 1.21 이하 기준).

즉, 고루틴은 나중에 실행되는데 그때 루프 변수는 이미 마지막 반복 값으로 바뀌어 있을 수 있습니다. 그래서 “모든 고루틴이 마지막 값만 본다” 같은 현상이 발생합니다.

재현 코드: 왜 전부 같은 값이 나오나

아래 코드는 가장 흔한 실수 패턴입니다.

package main

import (
	"fmt"
	"sync"
)

func main() {
	items := []string{"a", "b", "c"}

	var wg sync.WaitGroup
	wg.Add(len(items))

	for _, v := range items {
		go func() {
			defer wg.Done()
			fmt.Println(v)
		}()
	}

	wg.Wait()
}

의도는 a, b, c를 각각 출력하는 것이지만, Go 1.21 이하에서는 종종 c만 여러 번 출력되는 식의 결과가 나올 수 있습니다(스케줄링 타이밍에 따라 달라짐).

핵심은 go func() { fmt.Println(v) }()v의 “현재 값”을 캡처하는 게 아니라, 루프 변수 v 자체를 참조한다는 점입니다.

Go 1.22에서 뭐가 바뀌었나

Go 1.22에서는 for range의 반복 변수를 둘러싼 동작이 개선되어, 많은 케이스에서 각 반복이 서로 다른 변수처럼 동작하도록 바뀌었습니다. 그 결과, 위와 같은 코드가 Go 1.22에서는 기대한 대로 동작하는 경우가 늘었습니다.

다만 실무에서는 다음 이유로 “이제 신경 끄자”가 되기 어렵습니다.

  • 팀/CI/빌드 서버/런타임이 Go 1.21 이하일 수 있음
  • range가 아닌 일반 for(인덱스 루프)에서는 여전히 동일한 함정이 남을 수 있음
  • 코드 리뷰 관점에서 “버전에 따라 의미가 달라지는 코드”는 유지보수 비용을 키움

따라서 명시적으로 안전한 패턴을 쓰는 것이 여전히 권장됩니다(특히 라이브러리, SDK, 멀티 모듈 레포, 장기 운영 서비스).

가장 안전한 수정법 1: 인자로 값 전달

클로저가 참조할 값을 함수 인자로 넘기면, 호출 시점에 값이 복사되어 안전합니다.

for _, v := range items {
	go func(x string) {
		defer wg.Done()
		fmt.Println(x)
	}(v)
}

장점:

  • Go 버전과 무관하게 안전
  • 의도가 명확(“이 고루틴은 v의 스냅샷을 쓴다”)

단점:

  • 인자 목록이 길어지면 조금 번잡

가장 안전한 수정법 2: 반복마다 새 변수 만들기(섀도잉)

전통적으로 가장 널리 쓰인 패턴은 반복문 내부에서 새 변수를 선언해 “반복마다 다른 변수”를 만드는 것입니다.

for _, v := range items {
	v := v // 섀도잉
	go func() {
		defer wg.Done()
		fmt.Println(v)
	}()
}

장점:

  • 인자 전달보다 코드가 짧을 때가 많음

주의:

  • 팀에 따라 v := v가 낯설어 보일 수 있어 주석이 필요할 때가 있음
  • Go 1.22에서는 많은 경우 불필요해졌지만, 혼재 버전을 생각하면 여전히 유효한 방어 코드

인덱스도 함께 캡처할 때

i, v를 둘 다 쓰는 경우가 흔합니다. 이때도 동일 원칙이 적용됩니다.

for i, v := range items {
	i, v := i, v
	go func() {
		defer wg.Done()
		fmt.Printf("%d:%s\n", i, v)
	}()
}

또는 인자로 넘기기:

for i, v := range items {
	go func(i int, v string) {
		defer wg.Done()
		fmt.Printf("%d:%s\n", i, v)
	}(i, v)
}

range가 아니라 일반 for 루프라면

다음 패턴은 range가 아니라 일반 for에서도 자주 보입니다.

for i := 0; i < len(items); i++ {
	go func() {
		defer wg.Done()
		fmt.Println(items[i])
	}()
}

이 코드는 i가 변하는 동안 고루틴이 실행되면 items[i] 접근이 잘못되어 패닉이 날 수도 있습니다(예: index out of range).

해결은 동일합니다.

for i := 0; i < len(items); i++ {
	i := i
	go func() {
		defer wg.Done()
		fmt.Println(items[i])
	}()
}

또는 인자로 넘기기:

for i := 0; i < len(items); i++ {
	go func(i int) {
		defer wg.Done()
		fmt.Println(items[i])
	}(i)
}

실무에서 더 자주 터지는 케이스: 콜백 등록

고루틴뿐 아니라, 콜백을 슬라이스에 담아 나중에 실행하는 경우도 동일합니다.

func main() {
	items := []string{"a", "b", "c"}
	var fns []func()

	for _, v := range items {
		fns = append(fns, func() {
			fmt.Println(v)
		})
	}

	for _, fn := range fns {
		fn()
	}
}

여기서도 Go 1.21 이하에서는 마지막 값만 출력될 수 있습니다. 따라서 콜백 등록 시점에 값을 고정해야 합니다.

for _, v := range items {
	v := v
	fns = append(fns, func() {
		fmt.Println(v)
	})
}

“Go 1.22면 이제 안 고쳐도 되나요?”에 대한 현실적인 답

  • 사내 서비스가 단일 레포이고, 빌드/런타임이 모두 Go 1.22로 고정되어 있으며, range만 사용한다면: 많은 코드가 “우연히” 안전해졌을 가능성이 큽니다.
  • 하지만 라이브러리/공용 패키지/멀티 모듈/외부 배포 바이너리라면: 호출자가 어떤 Go 버전으로 빌드할지 통제하기 어렵습니다. 이런 경우는 여전히 명시적 방어 패턴(인자 전달 또는 섀도잉)을 유지하는 편이 안전합니다.

이 지점은 운영 이슈 디버깅과도 연결됩니다. 재현이 어려운 동시성 버그는 환경 차이(빌더 이미지, 런타임 베이스 이미지, CI 캐시 등)에서 시작하는 경우가 많습니다. 비슷한 결로, 인프라에서 “특정 조건에서만 실패”하는 문제를 추적할 때도 환경/버전 차이를 먼저 의심해야 합니다. 예를 들어 TLS 협상 실패가 특정 조합에서만 나는 케이스를 다룬 글인 EKS에서 TLS 1.3만 실패할 때 - OpenSSL·ALPN도 결국 조건/버전/협상 차이를 좁혀가는 접근이 핵심입니다.

팀 코드베이스에 적용하는 가이드

1) 규칙을 정하자: “루프 변수는 캡처하지 않는다”

가장 단순한 팀 규칙은 이것입니다.

  • for/range 안에서 고루틴을 띄울 때, 루프 변수(i, v)를 직접 참조하지 않는다.
  • 반드시 func 인자로 전달하거나, v := v로 새 변수를 만든다.

이 규칙은 Go 1.22로 업그레이드하더라도 코드 의미를 안정적으로 유지합니다.

2) 리뷰 체크리스트에 넣기

동시성 버그는 “코드가 틀려 보이지” 않아서 리뷰에서 놓치기 쉽습니다. 리뷰 템플릿에 다음 항목을 넣으면 효과가 큽니다.

  • 루프 내부 go func()가 바깥 변수를 참조하는가
  • 콜백 슬라이스에 func()를 append할 때 루프 변수를 참조하는가
  • err 같은 변수도 루프에서 재사용되는가(에러 변수를 캡처하는 실수도 흔함)

3) 장애 대응 관점: 재현 로그를 먼저 확보

이 문제는 스케줄링에 따라 증상이 달라져 “로컬에서는 안 나고 운영에서만 나는” 형태가 많습니다. 따라서 운영 진단 시에는 다음이 중요합니다.

  • 고루틴 시작 시점에 캡처한 값(인자)과 실제 참조 값이 다른지 로그로 확인
  • 빌드에 사용된 Go 버전, 베이스 이미지, 모듈 캐시를 함께 기록

운영 장애를 좁혀가는 방법론은 다른 영역에도 그대로 적용됩니다. 예를 들어 데드락의 범인을 로그로 추적하는 방식은 MySQL InnoDB 데드락(1213) 로그로 범인 찾기처럼 “증거를 남기고 범위를 줄이는” 접근이 핵심입니다.

자주 묻는 질문

Q. range에서 포인터를 모을 때도 문제인가요?

네. 예를 들어 구조체 슬라이스를 돌며 주소를 모으는 코드는 Go 초보자 함정으로 유명합니다. 반복 변수의 주소를 취하면 결국 같은 주소를 가리키게 될 수 있습니다(Go 1.21 이하에서 특히).

안전한 패턴은 인덱스로 원본 슬라이스 원소의 주소를 취하는 것입니다.

type Item struct{ Name string }

items := []Item{{"a"}, {"b"}, {"c"}}
var ptrs []*Item

for i := range items {
	ptrs = append(ptrs, &items[i])
}

Q. v := v는 성능에 영향이 있나요?

대부분의 경우 무시 가능한 수준입니다. 오히려 잘못된 캡처로 인해 재시도/롤백/장애 대응 비용이 발생하는 게 훨씬 큽니다. 성능이 민감한 구간이라면 마이크로벤치마크로 확인하되, 기본값은 명확성과 안정성을 우선하는 것이 좋습니다.

정리

  • “range 변수 캡처 버그”는 버그라기보다 클로저가 변수를 참조하는 방식루프 변수 재사용이 만나 생긴 함정입니다.
  • Go 1.22에서 range 관련 동작이 개선되어 많은 케이스가 안전해졌지만, 실무에서는 버전 혼재/일반 for 루프/콜백 등록 등으로 여전히 문제가 재발할 수 있습니다.
  • 가장 안전한 해결은 두 가지입니다.
    • func 인자로 값 전달
    • 반복마다 새 변수 생성(섀도잉) v := v

동시성은 “가끔만 터지는” 형태로 가장 비싸게 문제를 만듭니다. Go 1.22의 변화는 분명 반가운 개선이지만, 팀 규칙과 코드 패턴을 통해 버전과 환경에 무관한 안전성을 확보하는 것이 장기적으로 더 큰 이득입니다.