Go 1.27 작은 객체 할당 최적화 적용 판단법
Go 1.27의 크기 특화 메모리 할당이 내 서비스에 효과적인지 측정하고 안전하게 적용하는 방법을 설명한다.
2026-09-24
먼저 기대치를 바로 잡는다
Go 1.27로 서비스를 다시 빌드하면 80바이트 이하의 일부 힙 할당이 더 빠른 경로를 이용한다. Go 공식 설명에 따르면 해당 할당은 최대 20~30% 빨라질 수 있다.
이 수치를 프로그램 전체 성능 향상으로 읽으면 안 된다. 할당이 많은 프로그램에서도 예상되는 전체 개선은 최대 약 1%다. 애플리케이션은 메모리 할당 외에도 사용자 코드 실행, 동기화, 입출력과 같은 작업에 시간을 쓰기 때문이다.
따라서 업그레이드 검증에서 물어야 할 질문은 “할당기가 얼마나 빨라졌는가”가 아니다. “우리 서비스의 실행 시간에서 이 최적화가 적용되는 작은 객체 할당이 얼마나 큰 비중을 차지하는가”를 확인해야 한다.
작은 객체가 어떤 경로로 할당되는지 이해한다
힙 할당의 일반 경로는 런타임의 mallocgc 함수로 이어진다. 컴파일러가 객체를 힙에 두어야 한다고 판단하면 newobject 호출을 삽입한다. newobject는 객체의 크기와 포인터 포함 여부를 추출해 mallocgc에 전달한다.
할당기는 객체 크기를 size class로 분류한다. 80바이트 이하의 범위는 다음과 같이 나뉜다.
| Size class | 객체 크기 범위 | 할당되는 블록 크기 |
|---|---|---|
| 1 | 1~8바이트 | 8바이트 |
| 2 | 9~16바이트 | 16바이트 |
| 3 | 17~24바이트 | 24바이트 |
| 4 | 25~32바이트 | 32바이트 |
| 5 | 33~48바이트 | 48바이트 |
| 6 | 49~64바이트 | 64바이트 |
| 7 | 65~80바이트 | 80바이트 |
예를 들어 17바이트 객체와 24바이트 객체는 모두 size class 3에 속하며 24바이트 블록을 받는다.
런타임은 객체에 포인터가 있는지도 함께 구분한다. 가비지 컬렉터에 필요한 장부 처리가 달라지기 때문이다. 두 정보는 다음 식으로 span class에 인코딩된다.
spanClass = sizeClass<<1 | noPointers
Go 1.27은 이 span class별로 특화된 mallocgc 변형을 생성한다. 포인터가 없고 size class 3인 객체를 위한 함수 이름은 mallocgcSmallNoScanSC3이다. 이름의 Small, NoScan, SC3가 각각 작은 객체, 포인터 없음, size class 3을 나타낸다.
컴파일러가 객체 크기를 알면 newobject 대신 특화 함수를 직접 호출할 수 있다. 동적 길이의 슬라이스처럼 컴파일 시점에 크기를 알 수 없는 경우에는 기존처럼 mallocgc가 크기를 판별하고 적절한 경로를 선택한다.
특화 함수가 줄이는 비용을 살펴본다
가장 큰 개선은 작은 메모리 영역을 초기화하는 과정에서 나온다. 일반 경로는 고도로 최적화된 어셈블리 함수인 memclrNoHeapPointers를 사용할 수 있다. 그러나 작은 객체를 위한 특화 함수에서는 초기화할 크기가 상수다. 컴파일러가 함수 호출을 객체를 직접 초기화하는 명령으로 바꿀 수 있어 호출과 분기 비용이 줄어든다.
span class도 다시 계산할 필요가 없다. 어느 span에서 객체를 가져와야 하는지가 함수 자체에 이미 반영되어 있기 때문이다. 할당 크기가 상수이므로 포인터 위치 표시를 비롯한 장부 처리에도 추가 최적화를 적용할 수 있다.
생성 과정에서는 일부 도우미 함수의 본문도 호출 지점에 직접 삽입한다. 일반적인 컴파일러 인라이닝 기준으로는 너무 큰 함수도 생성 코드에서는 수동으로 인라인할 수 있다. 자주 사용되지 않는 런타임 디버깅 처리 등은 느린 경로로 옮겨 빠른 경로를 작게 유지한다.
이 특화 코드들은 손으로 각각 작성하지 않는다. 공통 코드는 일반 Go 코드로 작성해 런타임과 함께 빌드하고 타입 검사한다. 이후 go/ast와 golang.org/x/tools/go/ast/astutil을 사용하는 생성기가 AST를 처리해 각 변형을 만든다. 특화 함수들이 서로 다른 방향으로 변경되는 위험을 줄이기 위한 구조다.
적용 범위가 작은 이유를 서비스 특성과 연결한다
특화 함수의 이점은 객체가 커질수록 감소한다. 크기가 커지면 메모리를 지우는 작업 자체가 할당 시간을 지배하기 시작해 함수 호출이나 span class 계산을 없애는 효과가 상대적으로 작아진다.
특화 함수를 계속 늘리는 것도 공짜가 아니다. 함수가 추가될수록 실행 파일이 커지고 명령어 캐시를 더 사용한다. 자주 호출되는 하나의 mallocgc는 명령어 캐시에 남아 있을 가능성이 높지만, 특화 함수가 지나치게 많으면 필요한 코드를 캐시로 가져오는 비용이 이점을 상쇄할 수 있다. 사용자 코드가 사용할 명령어 캐시 공간도 줄어든다.
Go 팀은 여러 size class에서 경계를 바꾸며 벤치마크했고, 80바이트에서 특화를 멈추는 지점을 선택했다. Go 1.27 릴리스 노트는 이 변경으로 실행 파일 크기가 워크로드와 무관하게 약 60KB 증가한다고 설명한다.
16바이트와 24바이트 할당은 특히 큰 이점을 받을 수 있다. 64비트 값 두 개로 구성되는 인터페이스 값과 문자열, 값 세 개로 구성되는 슬라이스처럼 흔한 형태가 이 범위에 들어가기 때문이다. 그렇다고 해당 타입을 사용한다는 이유만으로 서비스 전체가 눈에 띄게 빨라진다고 단정해서는 안 된다. 실제 힙 할당 빈도와 전체 비용에서의 비중을 측정해야 한다.
업그레이드 효과는 같은 워크로드로 판정한다
적용 자체에는 별도의 코드 변경이 필요하지 않다. 프로그램을 Go 1.27로 빌드하면 특화 할당을 사용할 수 있다.
검증할 때는 기존 빌드와 Go 1.27 빌드에 같은 워크로드를 적용한다. 처리량이나 지연 시간만 보지 말고, 결과가 해당 워크로드에서 반복되는지도 확인한다. 기대되는 프로그램 전체 개선이 약 1% 수준이므로 측정 조건이 달라지면 런타임 변경의 효과와 다른 변동을 구분하기 어렵다.
다음 특성을 가진 서비스라면 변화가 나타날 가능성을 우선 확인할 수 있다.
- 힙 할당이 전체 실행 비용에서 큰 비중을 차지한다.
- 80바이트 이하 객체가 자주 할당된다.
- 인터페이스 값, 문자열, 슬라이스와 관련된 작은 할당이 빈번하다.
- 객체 크기를 컴파일 시점에 알 수 있어 특화 함수를 직접 호출할 기회가 많다.
반대로 동적 크기 할당이 많으면 mallocgc가 적절한 특화 함수를 찾고 호출해야 한다. 이 경우에도 특화 함수가 사용될 수 있지만 동적 선택 비용이 추가된다. 큰 객체의 메모리 초기화나 할당 이외의 작업이 지배적인 프로그램에서는 전체 차이가 작을 수 있다.
회귀가 의심되면 특화 할당만 분리한다
Go 팀은 회귀가 발생하지 않을 것으로 예상하지만, 비교가 필요하면 빌드 시 다음 설정으로 size-specialized allocation을 끌 수 있다.
GOEXPERIMENT=nosizespecializedmalloc go build
동일한 Go 1.27 도구 체인에서 기본 빌드와 이 설정을 적용한 빌드를 같은 워크로드로 비교하면 특화 할당의 영향을 분리할 수 있다. 설정을 껐을 때만 문제가 사라진다면 Go 이슈 등록 경로를 통해 재현 조건과 함께 보고해야 한다.
이 설정은 영구적인 운영 옵션으로 제시된 것이 아니다. 릴리스 노트는 GOEXPERIMENT=nosizespecializedmalloc 선택 해제가 Go 1.28에서 제거될 예정이라고 밝힌다. 따라서 장기적인 우회책으로 두기보다 회귀 확인과 원인 분리에 사용해야 한다.
런타임 변경과 함께 업그레이드 범위를 확인한다
Go 1.27은 할당기만 바꾸는 릴리스가 아니다. 제네릭 메서드를 지원하며, 구조체 리터럴의 키에는 해당 구조체 타입에 유효한 필드 셀렉터를 사용할 수 있다. 제네릭 함수를 호환되는 함수 타입의 변수에 대입하거나 해당 타입으로 변환할 때 적용되는 함수 타입 추론도 일반화됐다.
제네릭 메서드에는 경계가 있다. 인터페이스의 메서드는 자체 타입 매개변수를 선언할 수 없으며, 제네릭 메서드로 인터페이스 메서드를 구현할 수도 없다.
런타임에서는 runtime/pprof의 goroutineleak 프로필이 정식 제공된다. /debug/pprof/goroutineleak 엔드포인트에서도 사용할 수 있다. 반면 전역 변수나 실행 가능한 고루틴의 지역 변수를 통해 도달 가능한 동시성 기본 요소에서 발생한 누수는 탐지하지 못할 수 있다.
업그레이드 검증 범위를 작은 객체 할당 성능에만 한정하지 말고, 서비스가 사용하는 언어 기능과 런타임 동작도 함께 확인해야 한다. Go는 호환성을 유지하며 거의 모든 프로그램이 이전처럼 컴파일되고 실행될 것으로 예상하지만, 성능 개선 여부와 애플리케이션 동작 검증은 서로 다른 항목이다.
소스까지 확인할 때 기준점을 잡는다
런타임 구현을 직접 대조해야 한다면 Go 공식 소스 저장소를 기준으로 삼는다. 확인할 핵심은 호출 경로의 이름보다 각 경로가 전제하는 정보다.
- 일반
mallocgc경로는 할당 크기와 포인터 포함 여부를 입력으로 받는다. - span class는 size class와 포인터 포함 여부를 함께 표현한다.
- 컴파일러가 span class를 알면 특화 함수를 직접 호출할 수 있다.
- 특화 함수가 처리하지 않는 상황은 일반 경로로 폴백한다.
- 특화 범위는 80바이트 이하에서 멈춘다.
서비스 코드에서 이 구현에 맞춘 수동 최적화를 추가할 필요는 없다. 먼저 Go 1.27로 빌드하고 실제 워크로드에서 차이를 측정한다. 개선이 관측되면 그대로 활용하고, 회귀가 의심되면 특화 할당을 끈 비교 빌드로 영향을 분리하는 것이 이 변경을 적용하는 실무적인 순서다.