What's coming in Go 1.15

Ben Hoyt

Go 1.15에 담길 변화들

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

Go 프로그래밍 언어의 16번째 메이저 버전인 Go 1.15가 8월 1일 출시될 예정이다. 이번 릴리스는 예년보다 변화의 폭이 적지만, 주요 변경점 대부분은 겉으로 드러나지 않는 내부나 툴링에 집중되어 있다. 예를 들어 빌드 시간을 단축하고 바이너리 크기를 줄여 줄 새로운 링커가 도입된다. 여기에 언어 런타임의 성능 개선, 지원 아키텍처 변경, 표준 라이브러리 업데이트도 포함된다. 전반적으로 이번 버전은 탄탄한 업그레이드가 될 것으로 보인다.

Go 1.0 릴리스 이후 Go 팀은 매 버전마다 툴링과 표준 라이브러리를 꾸준히 개선해 왔지만, 언어 자체의 변경에 대해서는 항상 보수적인 태도를 유지해 왔다. 다른 많은 언어들은 매 릴리스마다 굵직한 언어 기능을 선보이지만, Go는 1.0 이후 버전들에서 몇 가지 사소한 기능만 추가했을 뿐이다.

이는 의도적인 설계 선택이다. 1.0 릴리스 이후 팀의 주안점은 안정성과 단순성이었다. Go 1 호환성 약속은 Go 1.0용으로 작성된 모든 프로그램이 수정 없이도 모든 1.x 버전에서 계속 정확히 동작함을 보장한다. Go 프로그래머들은 대체로 이를 긍정적으로 받아들인다. 프로그램은 그대로 ‘잘 동작’하면서도 대체로 꾸준히 더 빨라지기 때문이다.

예상대로 다가올 1.15 버전에서 언어 명세 자체의 변경은 사실상 없다고 봐도 무방하다. 개선은 툴링과 컴파일러 성능, 표준 라이브러리에 집중되어 있다. 기술 리드 Russ Cox가 언급했듯, 핵심 개발자들은 팬데믹 상황을 감안해 1.15에서는 한층 더 보수적으로 접근할 계획이다:

앞으로 몇 달이 얼마나 어려울지 알 수 없으니, 보수적으로 가면서 디버깅해야 할 막판의 미묘한 변경을 굳이 넣어 스스로에게 불필요한 스트레스를 주지 말자. 그런 변경은 다음 사이클 초반으로 미뤄 충분히 숙성시킬 시간을 갖도록 하자.

[...] Go 1.15는 예년보다 작은 릴리스가 될 것이고, 그래도 괜찮다.

Go 1.15는 5월 1일 기능 동결에 들어갔으며, Go 팀은 통상적인 6개월 릴리스 주기에 맞춰 8월 1일에 최종 버전을 출시할 계획이다.

Go의 개발 모델은 대부분의 오픈소스 언어와는 꽤 다르다. 이 언어는 Google에서 설계되었고 핵심 개발자 대부분이 Google에 재직 중이어서, 지속적인 개발은 사실상 Google이 후원하는 셈이다. Go는 관대한 BSD 스타일 라이선스를 사용하며, 개발은 공개적으로 진행되고 일반적인 논의는 golang-dev 메일링 리스트에서 이뤄진다. 변경 사항이나 새로운 기능은 GitHub 저장소의 이슈에서 제안·논의되며, 코드 리뷰는 Gerrit 코드 변경(“changelist” 혹은 “CL”이라 불린다)에 대한 코멘트를 통해 이루어진다.

새로운 링커

1.15에서 가장 큰 툴링 변화 중 하나는 완전히 새로 작성된 링커다. Go 핵심 기여자 Austin Clements가 2019년 9월에 작성한 새로운 링커 설계 문서는 재작성의 동기와 가져올 개선점을 상세히 설명한다. 새로운 링커에는 세 가지 주요 구조적 변경이 있다:

  • 링커에서 컴파일러로 작업 이전: 컴파일은 여러 CPU(또는 머신)에서 병렬로 수행되지만 링크 단계는 거의 항상 빌드 마지막에 직렬로 수행되어야 하므로 병렬화를 가능하게 한다. 또한 컴파일 결과는 Go 툴링에 의해 캐시된다.
  • 핵심 자료구조 개선, 주로 문자열 사용을 피하는 방식. 현재 링커는 문자열로 인덱싱된 거대한 심볼 테이블을 사용하지만, 새로운 설계에서는 심볼 넘버링 기법을 사용해 가능한 한 문자열 사용을 피한다.
  • 모든 입력 오브젝트 파일을 한 번에 메모리에 로드하지 않음: 이를 통해 새 링커는 대형 프로그램에서 메모리를 덜 사용하고 전체 할당량도 줄인다(현재 링커는 시간의 20% 이상을 가비지 컬렉터에서 보낸다).

원래 링커의 작성자인 Ken Thompson이 은퇴한 지금, 유지보수성 문제도 있다. Clements의 표현을 빌리자면:

원래 링커는 지금보다 단순했고 그 구현은 튜링상 수상자 한 명의 머릿속에 들어갈 정도였기 때문에 추상화나 모듈성이 거의 없었다. 안타깝게도 링커가 성장하고 진화하는 동안에도 이러한 구조 부족은 그대로 유지되었고, 우리의 유일한 튜링상 수상자는 은퇴했다.

이러한 광범위한 장기적 변경을 고려해, 이 작업은 안정적인 시점에만 master에 병합되는 브랜치(dev.link)에서 진행되고 있다. 새 링커 작업을 맡고 있는 Than McIntosh는 1.15를 위해 이미 완료된 내용을 설명했는데, 새로운 오브젝트 파일 형식과 더 촘촘한 심볼 표현을 포함해 설계 문서의 구조적 개선 대부분이 완료되었다. 빌드는 이미 1.14보다 빨라졌고 메모리 사용량도 줄었지만, 일부 기능(예: DWARF 5 디버깅 형식 사용)은 1.16을 기다려야 한다.

Clements는 병렬화 노력과 작업이 단계적으로 도입되는 방식에 대해 더 자세히 덧붙였다:

우리는 [...] 핵심 단계들을 병렬화하고 불필요한 I/O 동기화를 대거 제거하는 등 여러 다른 개선도 함께 진행했다. 링커에 대한 과거의 모든 작업을 최대한 활용하기 위해, 우리는 이 변환을 “웨이브프론트” 방식으로 수행했는데, 새로운 표현에서 기존 표현으로 변환하는 단계를 링커 안에서 점점 더 뒤쪽으로 밀어 넣는 식이었다. 아직 끝나지 않았다. 그 변환 단계는 여전히 남아 있으며, 정확히 언제 일어나고 무엇을 하는지는 플랫폼에 따라 다르다. amd64 ELF 플랫폼에서는 상당히 늦게 일어나고 하는 일도 비교적 적다. 다른 플랫폼에서는 아직 그만큼 뒤로 밀리지 않아 하는 일이 더 많기 때문에 개선 효과가 아직은 그만큼 크지 않다. 어느 쪽이든 1.16에서 기대할 것이 더 남아 있다.

현재로서는 링커가 여전히 링킹 마지막 부분을 위해 출력을 기존의 인메모리 표현으로 다시 변환한다. 아마도 향후 Go 버전에서는 이러한 마지막 단계들이 새 링커로 옮겨지고 변환 단계 자체가 완전히 제거되어 링크 시간과 메모리 사용량이 더욱 줄어들 것이다.

더 작아진 바이너리

이와 관련해 Go 1.15로 빌드된 실행 파일 크기를 줄이는 여러 개선이 있다. Brad Fitzpatrick이 보여줬듯, 새 링커는 훨씬 더 많은 미사용 코드를 제거해, Fitzpatrick의 (다소 인위적인) 테스트 프로그램 크기를 Go 1.14의 8.2MB에서 1.15의 3.9MB로 줄였다. 보다 현실적인 프로그램에서는 바이너리 크기가 3.5%에서 최대 22%까지 감소한다. 필자가 운영하는 웹 서버 프로그램은 21.4MB에서 20.3MB로 줄어 4.9% 감소했다.

이에 가장 크게 기여한 것은 새 링커의 미사용 코드 제거와 더불어 몇 가지 집중적인 개선이다. 예를 들어 Clements의 CL 230544는 실행 파일에 포함되는 스택 및 레지스터 맵의 수를 줄인다. 이 맵들은 Go의 가비지 컬렉터(GC)가 살아 있는 객체를 판단하는 데 사용되지만, 이제는 모든 명령어가 아니라 호출 지점에서만 필요하게 되었다. 이 변경으로 go 바이너리 크기가 5.7% 감소했으며, 컴파일과 링크 속도도 크게 빨라졌다.

Go는 런타임에 타입을 검사할 수 있는(reflect 패키지 사용) 능력 때문에 바이너리에 상당한 양의 타입 정보를 담고 있다. Cherry Zhang의 CL 231397은 심볼의 타입 정보를 인터페이스로 변환된 경우에만 출력에 포함한다(인터페이스로 변환된 값만 리플렉션에 사용할 수 있다). 이 변경으로 hello-world 프로그램의 크기가 7.2% 감소한다.

그 외에도 바이너리 크기에 대한 몇 가지 사소한 개선이 있다. 예를 들어 Brad Fitzpatrick의 CL 228111은 둘 중 하나만 사용될 경우 TLS 클라이언트와 서버 코드를 모두 출력에 포함하지 않도록 하여, TLS dial hello world 프로그램의 크기를 3.2% 줄인다.

성능 개선

Go 1.15는 여러 사소한 성능 개선을 도입하지만, 그중 더 주목할 만한 두 가지는 다작의 비Google 기여자 Josh Bleecher Snyder의 작업이다. CL 216401은 작은 정수를 인터페이스 값으로 변환할 때 메모리 할당을 피함으로써 컴파일-어셈블리 시간을 2% 개선한다. 인터페이스 값으로의 변환은 다른 언어의 “박싱”과 유사하며, 이 최적화는 정신적으로는 Python의 작은 정수 캐싱과 비슷하지만 정적 타이핑 덕분에 Go에서는 훨씬 덜 빈번하게 일어난다.

Snyder의 두 번째 변경은 컴파일러와 런타임 내부의 CL 226367로, 컴파일러가 가비지 컬렉터의 쓰기 장벽(write-barrier) 호출에 더 많은 x86 레지스터를 사용할 수 있게 한다. Go는 GC가 사용자 코드와 동시에 실행될 때 힙의 데이터 무결성을 유지하기 위해 쓰기 장벽(일종의 락과 유사)을 사용한다(이 Go GC에 대한 상세 분석에 더 많은 정보가 있다). 그 결과 바이너리 크기가 약간 줄어들고 컴파일 시간이 1% 개선된다.

Michael Knysze는 메모리 할당자의 “mcentral” 자료구조를 재설계해 락 경합을 줄임으로써 대형 블록에 대한 메모리 할당 처리량을 크게 높였다. 새로운 할당 코드는 12KB 이상 블록에서 두 배 이상 빠르다.

툴링과 포트

Go의 “modules” 기능(Go의 의존성 관리 시스템)은 Go 1.11에서 처음 도입되었고, 1.13에서는 모듈 미러 혹은 “proxy”에 대한 지원이 추가되었다. 1.15 버전은 폴백 프록시에 대한 지원을 추가해, 모듈 소스 코드를 다운로드할 때 첫 번째 호스트가 실패하면 go 도구가 보조 호스트로 폴백할 수 있게 한다. 폴백은 GOMODCACHE 환경 변수의 새로운 “|” 구분자를 사용해 지정한다.

Go 1.15는 두 개의 오래된 포트인 darwin/386darwin/arm을 제거한다. 이들은 macOS 및 기타 Apple 운영체제에서 32비트 바이너리를 제공하던 포트다. Fitzpatrick은 언급했듯이 macOS Catalina가 32비트 앱 실행을 지원하지 않으므로 이러한 포트를 제거하면 macOS 빌드 머신을 확보하는 데 도움이 되고 컴파일러 크기도 약간 줄어들 것이라고 했다. 이들 포트는 Go 1.14 릴리스에서 지원 중단으로 공지되었으며 Go 1.15에서 제거될 예정이다.

반면 linux/arm64 포트는 “first class 포트”로 승격되었다. 이는 linux/arm64 빌드가 깨지면 릴리스가 차단된다는 의미이며, 공식 바이너리와 설치 문서도 Go 팀이 제공한다. Fitzpatrick이 언급했듯, 이제 Linux 64비트 Arm은 이미 first class 포트인 32비트 Arm만큼이나 중요하다.

Windows에서는 Go 1.15부터 주소 공간 배치 난수화(ASLR)를 기본적으로 사용하는 실행 파일을 생성한다. ASLR은 위치 독립 코드를 사용해 시작 시 다양한 데이터 영역의 주소를 난수화함으로써, 공격자가 대상 주소를 예측하고 메모리 손상 취약점을 악용하기 어렵게 만든다.

표준 라이브러리 추가 사항

Go의 표준 라이브러리는 규모가 크고 상당히 안정적이며, Go 1.15에서는 비교적 사소한 기능들만 추가되었다.

표준 라이브러리의 testing 패키지는 상당히 미니멀하다. Go의 철학은 테스트와 어서션을 작성하기 위한 도메인 특화 언어를 피하고 개발자가 이미 알고 있는 일반 Go 코드로 작성하도록 하는 것이다. 하지만 핵심 개발자들은 임시 디렉터리 생성이 충분히 유용하다고 판단해, 현재 테스트를 위한 임시 디렉터리를 지연 생성하고 테스트가 끝나면 자동으로 삭제하는 TempDir() 메서드를 추가하는 것을 승인했다.

net/url 패키지에는 URL을 문자열로 반환하되 비밀번호를 가린(xxxxx로 대체) URL.Redacted() 메서드가 새로 추가되었다. https://username:[email protected]/ 과 같은 비밀번호가 포함된 URL은 더 이상 브라우저에서는 흔히 사용되지 않지만, 스크립트나 도구에서는 여전히 놀라울 정도로 흔하다. Redacted(): 뒤 부분을 평문으로 노출하지 말라는 RFC 3986의 지침에 따라 URL을 더 안전하게 로그로 남기는 데 사용할 수 있다.

실행 파일에 시간대 데이터베이스의 정적 복사본을 임베드할 수 있도록 새로운 time/tzdata 패키지가 추가되었다. 실행 파일에 약 800KB가 추가되므로 이 기능은 옵트인 방식이다. time/tzdata 패키지를 임포트하거나 timetzdata 빌드 태그를 지정해 컴파일하면 활성화된다. 임베드된 데이터베이스는 일부 시스템(특히 Windows)에서 시간대 데이터베이스 접근을 더 일관되고 안정적으로 만들 수 있으며, Docker 컨테이너나 Go playground 같은 가상화 환경에서도 유용할 수 있다.

맺으며

Go는 모든 버그와 기능 요청을 GitHub 이슈로 추적하므로, Go 1.15 마일스톤에서 닫힌 이슈 목록을 살펴보며 이번 릴리스에 포함된 내용을 더 탐색할 수 있다. 1.15 최종 릴리스까지는 아직 2개월 이상 남았지만, gotip 도구를 이용해 최신 버전에 대해 자신의 코드를 쉽게 테스트해 볼 수 있으며, 6월 1일로 예정된 바이너리 베타 릴리스를 기다릴 수도 있다. 지금 발견된 버그는 거의 확실히 1.15 최종 릴리스 전에 수정될 것이다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글