1P by GN⁺ | ★ favorite | 댓글 1개
  • Go 1.22는 for 루프 변수를 루프 전체가 아니라 반복별 범위로 바꿔, 클로저가 같은 변수를 잘못 캡처하는 Go의 대표적 실수를 줄이려 함
  • 기존 의미론에서는 goroutine이 없어도 반복 이후 실행되는 함수가 같은 vi를 참조해, 마지막 값만 보거나 테스트가 잘못 통과할 수 있음
  • go vetgoplsloopclosure 분석기는 확실한 경우만 잡아 미탐이 생기고, 더 공격적인 검사기는 오탐 때문에 불필요한 x:= x 코드를 늘릴 수 있음
  • 새 의미론은 go.modgo 1.22 이상을 선언한 모듈에만 적용되며, Go 1.21에서는 GOEXPERIMENT=loopvar미리보기를 실행할 수 있음
  • Google은 2023년 5월 초부터 내부 Go 툴체인에서 이 모드를 모든 빌드에 강제했고, 4개월 동안 프로덕션 문제 보고는 없었지만 잘못 작성된 테스트는 드러났음

기존 for 루프의 변수 캡처 함정

  • Go의 기존 for 루프 변수는 루프 전체 범위를 가지므로, 반복이 끝난 뒤 그 변수를 참조하는 코드가 의도와 다른 값을 볼 수 있음
  • values := []string{"a", "b", "c"}를 순회하며 세 goroutine을 만들면, 각 goroutine이 반복별 v가 아니라 같은 변수 v를 출력함
  • 동시성 없이도 같은 문제가 생김
    • 반복 안에서 func() { fmt.Println(i) }를 슬라이스에 저장한 뒤 나중에 실행하면, 각 함수가 반복별 값이 아니라 같은 i를 참조함

프로덕션 장애와 분석기의 한계

  • 이런 실수는 여러 회사의 프로덕션 문제로 이어졌고, Let’s Encrypt의 공개 이슈도 그중 하나임
  • Let’s Encrypt 사례에서는 map 순회 중 kkCopy := k로 복사했지만, modelToAuthzPB(&v)가 결과 생성 과정에서 v의 필드 포인터를 사용해 v도 별도로 복사해야 했음
    • 변수 캡처가 여러 함수에 걸쳐 있어 문제를 알아차리기 어려웠음
  • 정적 분석 도구는 변수가 반복 이후까지 살아남는지 판단하기 어려워 오탐미탐 사이에서 타협해야 함
    • go vetgoplsloopclosure 분석기는 확실한 문제만 보고해 미탐을 감수함
    • 더 공격적인 검사기는 올바른 코드까지 잘못된 코드로 지목할 수 있음
  • 오픈소스 Go 코드에서 x := x 줄을 추가한 커밋을 살펴보면, 실제 버그 수정뿐 아니라 불필요한 변경도 많이 섞여 있었음
    • 개발자가 검사기를 만족시키려고 불필요한 코드를 추가하는 상황이 있었음
    • informer := informera := a 같은 두 diff 중 하나만 버그 수정이고 다른 하나는 불필요한 변경이었지만, 타입과 함수 정보를 모르면 구분하기 어려움

Go 1.22의 새 루프 의미론

  • Go 1.22에서는 for 루프 변수가 반복마다 별도 범위를 갖도록 바뀔 예정임
  • 앞선 예시들은 더 이상 버그가 있는 Go 프로그램이 아니게 되고, 이런 실수로 생기는 프로덕션 문제와 부정확한 검사 도구의 필요도 줄어듦
  • 하위 호환성을 위해 새 의미론은 go.modgo 1.22 이상을 선언한 모듈의 패키지에만 적용됨
    • 코드베이스 전체를 한 번에 바꾸지 않고 점진적으로 이동할 수 있음
    • //go:build 줄로 파일 단위 제어도 가능함
  • 기존 코드는 현재와 같은 의미를 그대로 유지함
    • 수정은 새 코드나 업데이트된 코드에만 적용됨
    • 특정 패키지에서 의미론이 바뀌는 시점을 개발자가 제어할 수 있음

이전 Go 버전에서의 안전장치

  • Go의 forward compatibility 작업에 따라 Go 1.21은 go 1.22 이상을 선언한 코드를 컴파일하지 않음
  • Go 1.20.8과 Go 1.19.13 포인트 릴리스에도 같은 효과를 내는 특수 처리가 들어감
  • Go 1.22가 릴리스된 뒤 새 의미론에 의존해 작성된 코드는, 매우 오래된 지원 종료 Go 버전을 쓰지 않는 한 기존 의미론으로 컴파일되지 않음

Go 1.21에서 미리보기 실행하기

  • Go 1.21에는 루프 범위 변경의 미리보기가 포함됨
  • GOEXPERIMENT=loopvar를 설정해 컴파일하면 go.modgo 줄을 무시하고 모든 루프에 새 의미론이 적용됨
  • 패키지와 모든 의존성이 새 루프 의미론에서도 테스트를 통과하는지 확인하려면 다음처럼 실행함
GOEXPERIMENT=loopvar go test
  • Go Playground에서는 프로그램 맨 위에 // GOEXPERIMENT=loopvar 주석을 넣어 새 의미론을 시험할 수 있음
  • Google 내부 Go 툴체인은 2023년 5월 초부터 모든 빌드에서 이 모드를 강제하도록 패치됐고, 이후 4개월 동안 프로덕션 코드 문제 보고는 없었음

새 의미론이 드러내는 테스트 버그

  • 새 루프 의미론은 프로덕션 코드 문제는 일으키지 않았지만, 잘못 통과하던 테스트를 드러냈음
  • t.Parallel을 쓰는 서브테스트 예시에서 Go 1.21은 전체 루프가 끝날 때까지 각 서브테스트를 막은 뒤 병렬 실행함
    • 루프가 끝나면 v는 항상 6이므로 모든 서브테스트가 6이 짝수인지 확인하고 통과함
    • 실제 테스트 케이스에는 1이 있으므로 테스트는 실패해야 함
  • Go 1.21에서는 loopclosure 분석기의 정밀도가 개선되어 이 문제를 식별하고 보고할 수 있음
    • Go Playground 보고 예시: 프로그램 예시
    • go vet이 테스트에서 이런 문제를 보고하면, 이를 고치는 것이 Go 1.22 준비에 도움이 됨
  • 새 의미론 적용 시 특정 테스트 실패를 일으키는 루프를 찾는 도구와 예시는 FAQ에 정리되어 있음

더 읽을거리

댓글과 토론

Hacker News 의견들
  • 훨씬 이른 예도 있겠지만, 60초 검색으로 찾은 이 동작에 대한 가장 오래된 경고는 30년 넘게 전인 1992년에 올라온 comp.lang.lisp FAQ였음
    DOTIMES, DOLIST, DO는 반복 변수를 갱신할 때 바인딩이 아니라 대입을 쓰므로, 예시처럼 lambdan을 캡처하면 10개의 클로저가 모두 같은 변수 N의 값 위에 만들어진다고 설명되어 있음

    • D도 같은 이슈가 있음: https://issues.dlang.org/show_bug.cgi?id=2043
      참조로 캡처한다면 사실 예상되는 동작임
    • 표준에는 이런 루프가 값을 변경하는지 재바인딩하는지 명시되어 있지 않아서, 변수를 캡처한다면 재바인딩하지 않는다고 가정해야 함
      그래도 한 번 동작 방식을 배우면 문제가 아니게 되고, 필요하면 폼을 선택해서 매크로 확장해 구현 방식을 확인할 수 있음
  • C# 언어 팀도 C# 4.0에서 가벼운 클로저를 도입한 뒤 같은 문제를 겪었고, 이게 곧 함정이라는 게 드러났음
    사용자는 거의 항상 루프 변수를 잘못 썼고, C# 5.0에서 호환성을 깨는 변경을 넣었음
    Eric Lippert가 그 관점에서 “왜”를 잘 설명한 글을 썼음: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
    원래 C# 5 발표 글은 찾기 어려웠는데, 2012년 이후 Microsoft 도메인의 여러 블로그 이전 과정에서 사라지지 않았기를 바람

    • Python도 수년간 같은 기능 요청을 여러 번 받았지만, 늘 “큰 이득은 적고 기존 코드를 깨뜨린다”는 답이 돌아왔음: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      Python 2에서 3으로 넘어갈 때 문자열 타입 변경만으로도 난리가 났던 걸 생각하면, 이 변경이 Python 4.0 전에는 들어갈 것 같지 않음
      그리고 누군가는 Python이 이런 걸 고치지 않아서 나쁘다고 하다가, 2003년에 만든 스크립트가 안 돌아간다고 또 Python을 욕할 듯함
    • C# 팀의 jaredpar가 이 Go 제안의 GitHub 토론에 첫 댓글을 달았음: https://github.com/golang/go/discussions/56010
      언어 변경 제안이 기본적으로 가져야 하는 “일단 거부” 장벽을 넘는 데 큰 역할을 했다고 봄
      또 하나 크게 설득된 부분은 공개 소스 코드베이스를 스캔해서, 수정되는 버그와 새로 생기는 버그의 균형을 본 결과였음
    • Java도 익명 클래스에서 이 문제가 있었고, 보통은 함수 객체를 도입해서 해결함
      값 전달이기 때문에 호출 시점의 변수 상태를 캡처해서 코드의 모호함을 줄여줌
      변수를 이상하게 캡처하려고 하면, 예를 들어 배열을 맵으로 바꾸려고 누적하는 컬렉션과 선언된 변수들이 서로 다르게 동작하게 됨
      Go는 루프 카운터에만 이런 동작을 적용해 균형을 맞추려는 듯하지만, 여전히 일부 변수는 이상하게 동작함
      특히 입력을 직접 스캔하려고 루프 변수를 여러 개 정의하는 경우에는 어떤 일이 생길지 궁금함
    • JavaScript도 같은 문제가 있었고 for(let) 루프를 도입했음
    • Go답게 이전 언어들에서 배우지 않고 이 동작을 무시했다가, 나중에 다시 고치려 드는 흐름임
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo...가 이 문제를 더 자세히 설명하는 듯함

    • 예전의 i := i 트릭이 동작하는 이유가 생각했던 것과 전혀 달랐다는 게 흥미로움
      처음에는 새 i가 고루틴에 전달되니 탈출 분석이 이를 렉시컬 범위 밖으로 탈출한다고 표시하고, 그래서 힙에 할당되며, 반복마다 힙 할당이 하나씩 생겨 각 고루틴이 고유한 메모리 위치를 참조한다고 생각했음
      실제로는 Go 컴파일러가 참조 캡처와 값 캡처를 고르는 휴리스틱을 갖고 있고, 초기화 이후 갱신되지 않는 값은 값으로 캡처하는 조건이 있음
      ifor 본문 범위에 있고 루프 자체가 갱신하지 않으므로 초기화 이후 갱신되지 않는 값으로 판단되어, 힙 할당 없이 값으로 캡처하는 코드가 생성됨
      후자가 더 낫다는 건 알겠지만, 전자의 방식이 왜 같이 일어나지 않는지 Go를 깊이 아는 사람에게 듣고 싶음
  • 이 변경이 현재 동작에 의존하는 프로그램을 깨뜨리지는 않을까?

    • 기존 코드와의 하위 호환성을 보장하기 위해 새 의미론은 go.mod에서 go 1.22 이상을 선언한 모듈 안의 패키지에만 적용됨
      파일 단위로는 //go:build 줄을 이용해 결정할 수도 있음
    • 왜 다운보트 받는지 모르겠지만, 실제로는 Go 1 호환성 약속을 깨는 변경이 맞음
      그 약속은 Go 1 명세로 작성된 프로그램이 명세의 수명 동안 변경 없이 계속 컴파일되고 올바르게 실행되어야 하며, 언젠가 Go 2 명세가 나올 수는 있지만 그전까지는 Go 1.1, Go 1.2 같은 포인트 릴리스에서도 오늘 동작하는 Go 프로그램은 계속 동작해야 한다고 말함
    • Go 1.21 준비 과정에서 매우 큰 Go 코드 말뭉치를 분석해 무엇이 영향을 받을지 봤고, 그 수가 아주아주 작았다고 했음
      이 설계 때문에 의도치 않은 버그를 만든 사람 수가, 수정으로 영향을 받는 사람 수보다 훨씬 많을 거라고 생각했음
    • 원래 제안서는 이 문법의 기존 사용 사례를 조사한 내용을 꽤 자세히 다뤘음
      기억으로는 Google 코드베이스나 GitHub 코드에서 이 변경이 기대 동작을 깨뜨리는 경우가 거의 없었다고 함
      영향을 받는 코드베이스가 얼마나 적은지 확인하고, go.mod의 버전 명시로 새 동작을 쓰려면 코드를 능동적으로 수정해야 하는 메커니즘을 만든 뒤에야 하위 호환성을 깨기로 결정한 것임
    • 꽤 많음
      https://twitter.com/go100and1/status/1690412229135601664
      https://twitter.com/go100and1/status/1690587305806057472
      https://twitter.com/go100and1/status/1690589791686119424
      https://twitter.com/go100and1/status/1690591234715492352
      https://twitter.com/go100and1/status/1690593184857145344
      https://twitter.com/go100and1/status/1691456732151889920
      대부분은 제안 문서에 전혀 언급되지 않았음
  • Python에서도 이 문제를 겪은 적이 있지만 최근은 아님
    Python이 바뀐 건지, 내가 문제를 알아차리게 된 건지는 확실치 않음
    여전히 Python에서 문제가 될 수 있다는 건 이 코드만으로도 충분히 보임: funcs = [(lambda: x) for x in range(3)]; funcs[0]()2를 출력함

    • 맞는 동작임
      Python은 예전에는 더 나빴고, 리스트 내포 바깥의 범위까지 공유했음
    • 이 동작은 Python 클로저의 늦은 바인딩 때문임
      리스트 내포나 루프 안에서 람다를 쓰면 x의 현재 값이 아니라 변수 x에 대한 참조를 캡처함
      funcs[0]()를 호출할 때는 이미 xrange의 마지막 값인 2로 설정된 뒤임
      원하는 동작을 얻으려면 람다의 기본 인자로 x를 넘기면 됨: funcs = [(lambda x=x: x) for x in range(3)]
  • Go를 조금만 써봤고 이 변경이 해결하는 일반적인 문제는 알지만, 더 미묘한 예시인 letsencrypt 사례나 "range c.informerMap""range alarms"는 잘 이해가 안 됨
    for k, v := range someMap에서 v는 맵 값 타입이고, 루프 전체에 하나의 바인딩이 있어서 반복마다 복사되는 건가? 그렇다면 문제가 설명되지만, v가 맵 안쪽을 가리키는 참조일 거라고 예상했음
    명세의 “For statements with range clause”를 빠르게 훑어봐도 답을 못 찾았는데, Go를 거의 안 만져서 엉뚱한 곳을 본 듯함: https://go.dev/ref/spec#For_statements
    편집: 답은 코드 블록 형식의 표에 있었음. 배너처럼 지나쳐 버린 듯함. v는 참조가 아니라 복사된 값이라니 놀라움

    • Go는 맵 키나 값에 대한 포인터를 지원하지 않음
      배열 슬롯에 대한 포인터는 지원하지만, for range는 각 슬롯을 가리키는 포인터를 주는 대신 복사함
    • 문자열에서 정수로 가는 맵이 있다면 v의 타입은 int
      값이지 int에 대한 포인터가 아님
    • 이 코드 조각들의 원본을 찾아냈음
      궁금하면 확인해도 됨: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      기본적으로 컴파일러가 자동 역참조 때문에 go a.Monitor(b)(&a).Monitor(b)로 바꾸고 있음
  • “전방 호환성 작업의 결과로 Go 1.21은 go 1.22 이상을 선언한 코드를 컴파일하려 하지 않는다. Go 1.20.8과 Go 1.19.13 포인트 릴리스에도 같은 효과의 특수 처리를 넣었으므로, Go 1.22가 출시되면 새 의미론에 의존해 작성된 코드는 아주 오래된 미지원 Go 버전을 쓰지 않는 한 절대 기존 의미론으로 컴파일되지 않는다”는 부분이 어떻게 동작하는지 궁금함
    어떤 패키지가 1.22로 고정했고 내가 1.18로 컴파일하면, 컴파일이 되나 아니면 1.22 컴파일러가 필요하다고 오류가 나나?

    • 약간 교묘한 방식을 썼음
      Go 1.21에서 go.mod 파일의 버전 번호 형식을 바꿨기 때문에 Go 1.18로 빌드하려 하면 go.mod:3: invalid go version '1.21.0': must match format 1.23 같은 오류가 남
      다만 이건 go mod init으로 모듈을 만들었을 때만 그렇고, go.mod에 수동으로 go 1.21을 쓰면 불평 없이 빌드됨
    • 흥미롭게도 Go 1.21에서 모듈이 더 높은 Go 버전을 선언하면, 기본 동작은 더 새 도구체인을 가져와 대신 쓰는 것임: https://go.dev/blog/toolchain
      꽤 멋진 기능이지만, 놀라운 동작이고 바이너리를 받으러 Google이 통제하는 서버에 접속한다는 점 때문에 조금 망설여짐
      모듈 프록시와 함께 Go에서 가장 양가적인 기능 중 하나이고, Go가 Google이 지분만 가진 재단에 의해 관리됐다면 훨씬 마음이 편했을 것 같음
      편집: 생각해보니 이건 의존성이 다른 버전을 선언했을 때가 아니라 현재 모듈이 선언했을 때 얘기라 원문 질문과는 다름
    • 내가 이해하기로는 Go 1.18에서는 1.22 모듈이 의존성으로 들어와도 컴파일되고, 이 기능에 의존한다면 잘못된 로직을 만들 수 있음
      그래서 Go 1.18 사용은 적극적으로 위험해짐
      Go 1.19에서는 컴파일러 오류가 날 것임
      어차피 Go는 오래된 릴리스와 표준 라이브러리에 보안 버그 수정을 하지 않으니, 그런 버전을 쓰는 것 자체가 위험하다고 봄
    • 컴파일 오류가 나야 함
      하지만 Go 1.22로 컴파일하더라도, 네 코드는 여전히 Go 1.18 의미론을 가짐
  • Go는 어떤 면에서 굉장히 이상한 언어임
    매우 강한 의견을 가진 언어이면서 동시에 너무 의견이 없는 언어처럼 보임

  • c.informerMap을 순회하는 코드와 alarms를 순회하는 코드의 차이가 뭔지 확실치 않지만 추측해보면, 한쪽의 루프 변수는 포인터이고 다른 쪽은 값일 수 있음
    메서드 호출이 포인터 리시버를 쓰기 때문에, 값인 경우 컴파일러가 리시버에 대한 참조를 자동으로 넣는 것 아닐까?

    • GitHub 코드 검색으로 이 코드 조각이 들어 있는 원본을 찾아냈음
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      차이는 한쪽에서 informer가 인터페이스라 메서드 호출이 즉시 informer.Run으로 해석되어 문제가 없다는 것임
      다른 쪽에서 aAlarm 구조체이고 값으로 복사되며, Monitor 메서드는 포인터 리시버를 받음
      그래서 컴파일러가 사실상 go a.Monitor(b)go (&a).Monitor(b)로 바꾸고, 이게 루프 변수에 대한 참조를 만들어 문제를 일으킴
    • Go에서 맵을 순회하면 항상 값이 복사되므로 첫 번째 코드는 기대대로 동작하는 듯함
      두 번째는 a가 결국 alarms의 마지막 요소 값만 갖게 되기 때문에, 글에서 설명한 원래 문제가 발생한다고 추측함
    • 이름만 보면 위쪽은 맵이고 아래쪽은 슬라이스임
      내부 지식은 거기까지지만, 슬라이스는 힙의 백킹 배열을 가지므로 포인터나 참조가 어느 정도 얽혀 있음
    • 컴파일러가 값을 잡아오는 걸 아는 식의 뭔가가 분명히 있는 듯함
  • 이걸 읽으니 크게 안도됨
    Go에서 가장 큰 하나가 고쳐지는 것임

    • 아니, 가장 큰 흠은 오류 처리
      foo, err := getFoo(); if err != nil ... 다음에 bar, err := getBar(); fmt.Println(bar)처럼 쓰면 getBar의 오류 검사를 놓침
      범위 규칙 때문에 if foo, err := getFoo(); err != nil 패턴은 중첩이 조금만 깊어져도 감당하기 어려워짐
      또한 잘못된 상태를 도입함. getFoo가 오류를 반환할 때 뭘 반환해야 하나? API를 포인터 반환으로 바꿔 nil을 반환할지, 아니면 유효하지 않은 상태의 부분 생성 객체를 둘지 고민하게 됨
    • 다음에는 인터페이스의 nil 검사를 고치면 됨