Go의 strings.Builder는 문자열을 효율적으로 합치는 용도로 설계된 타입인데, 내부 코드를 들여다보면 흥미로운 주석과 함께 다소 낯선 코드가 숨어 있습니다.
https://github.com/golang/go/blob/master/src/strings/builder.go
func (b *Builder) copyCheck() {
if b.addr == nil {
// This hack works around a failing of Go's escape analysis
// that was causing b to escape and be heap allocated.
// See issue 23382.
// TODO: once issue 7921 is fixed, this should be reverted to
// just "b.addr = b".
b.addr = (*Builder)(abi.NoEscape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
여기서 핵심은 b.addr = (*Builder)(abi.NoEscape(unsafe.Pointer(b)))입니다.
왜 이렇게까지 복잡하게 자기 자신을 대입하고 있을까요?
먼저, Escape Analysis(탈출분석)에 대해 알아볼까 합니다.
Go 컴파일러는 변수를 스택(stack) 에 둘지, 힙(heap) 에 둘지 정해야 합니다.
이 결정을 내리는 과정이 Escape Analysis(탈출 분석) 입니다.
코드 안에서 어떤 값의 주소가 함수 밖으로 “탈출(escape)”할 수 있다고 판단되면, 컴파일러는 안전을 위해 힙에 올립니다.
문제는 자기참조(self-reference) 입니다.
예를 들어 구조체가 자기 내부 필드를 가리키는 포인터를 저장하면, 컴파일러 입장에서는 “혹시라도 이게 함수 밖으로 나가지 않을까?” 하고 보수적으로 판단해 힙으로 보내버립니다.
strings.Builder 타입이 이런 케이스에 해당했습니다.
이 때문에 불필요하게 힙 할당이 일어나 성능에 영향을 주는 이슈가 보고되었는데, 그게 바로 주석에도 나와있는 Go issue #7921 입니다.
더 자세히 예제 코드와 함께 설명드리면,
type S struct {
p *int
n int
}
func (s *S) makeSelfRef() { s.p = &s.n }
여기서 s.p는 s 내부 메모리(s.n)를 가리킵니다.
이때 컴파일러는 다음을 증명해야 스택 배치를 허용할 수 있습니다:
이걸 다 못 증명하면, 스택에 두면 댕글링(dangling) 위험이 있으니 힙으로 올리는 게 안전하다고 판단하게 됩니다.
// TODO: once issue 7921 is fixed, this should be reverted to
// just "b.addr = b".
이 말은 곧, “언젠가 컴파일러의 탈출 분석이 더 똑똑해져서 b.addr = b 같은 단순한 코드도 제대로 처리할 수 있다면, 지금의 복잡한 NoEscape hacking(역자주: 우회, 눈속임)를 지우고 원래 코드로 되돌리자”는 뜻입니다.
즉, 현재는 성능을 보장하기 위한 안전한 해킹이고, 미래에는 필요 없어질 수도 있는 임시 방편인 셈입니다.
우연히 다른 오픈소스에서 strings.Builder를 사용하고 있어 코드 구현을 살펴보았는데 이 코드 구현에는 Go 컴파일러의 내부 동작과 최적화 한계, 그리고 라이브러리 개발자들의 세심한 고민이 녹아 있었습니다.
우리가 평소에 쓰는 strings.Builder 뒤에는, 성능과 안정성을 모두 잡기 위한 이런 엔지니어링의 흔적이 숨어 있다는 점이 재미있었습니다.