Go 스타일: 읽기 쉽고 빠르게
목표는 유지보수자가 코드를 위에서 아래로 읽을 수 있으면서, 성능은 코드 전체에 흩어진 트릭이 아니라 API와 데이터의 형태에서 나오게 하는 것입니다. 설계가 올바르면 가독성과 속도는 거의 충돌하지 않습니다. 충돌할 경우, 측정된 핫 경로에서만 가독성을 포기하고, 그 이유를 주석에 명시하십시오.
이 가이드는 Dave Cheney의 "API 설계를 성능 프리미티브로"를 따릅니다. 일정 수준 이상에서는 성능을 나중에 추가할 수 없으므로, 처음부터 설계하고 나머지는 단순하게 유지하십시오.
출처: Cheney의 발표(https://dave.cheney.net/paste/gophercon-uk-2026.html), github.com/pkg/json 및 github.com/davecheney/bytereader의 코드; John Ousterhout, "Define errors out of existence".
우선순위
- 정확성. 속도나 간결함을 위해 절대 희생하지 마십시오.
- API 형태와 big-O. 누가 메모리를 소유하는지, 무엇이 반환되는지, 각 호출이 얼마나 많은 작업을 하는지. 이것이 이후의 어떤 튜닝보다 성능을 결정합니다.
- 핫 경로에서의 할당. 각 할당은 할당 한 번, 가비지 컬렉터 한 번, 총 두 번의 비용이 듭니다. 컬렉터가 빠를 수는 있지만 "할당을 하지 않는 것이 항상 더 빠릅니다."
- 입력 단위당 작업. 각 바이트를 한 번만 건드리고, 바이트당이 아니라 토큰 또는 레코드당 호출을 만드십시오.
- 마이크로 최적화(수동 인라이닝, 비트 트릭, 경계 검사 회피)는 프로파일이 핫하다고 보여주는 경로에서만, 그리고 이득을 증명하는 벤치마크와 함께만 사용하십시오.
변경 사항이 위 수준에서 정당화되지 않으면, 수행하지 마십시오.
핫 경로와 콜드 경로 분리
어떤 코드가 이벤트나 요청당 실행되는지(핫)와 설정, 빌드, 구성 또는 업데이트 시간에 실행되는지(콜드)를 결정하십시오.
- 콜드 경로: 가장 단순한 코드를 작성하십시오. 공유 대신 복제하고, 맵과
fmt를 사용하며, 자유롭게 할당하십시오. 핫 경로 관용구를 여기에 가져오지 마십시오. 가독성만 해치고 얻는 것이 없습니다. - 핫 경로: 재사용 가능한 버퍼, 세대 카운터, 평면 배열 및 유사한 기술을 사용할 수 있습니다. 각 기술에 이름(작은 타입이나 헬퍼)을 붙이고 왜 존재하는지 설명하는 주석을 추가하여, 독자가 역공학하지 않도록 하십시오.
성능을 위한 API 설계
API는 호출자가 절약할 수 있는 최대치를 결정합니다. API가 모든 결과를 힙에 강제로 넣으면 "가비지 컬렉터를 벤치마킹하는" 꼴이 됩니다. 빠른 경로가 자연스러운 경로가 되도록 형태를 만드십시오. 2-10배의 이득은 종종 "다른 API를 받아들일 준비가 되어 있다면" 얻을 수 있습니다.
- 이스케이프해야 하는 값을 반환하지 마십시오.
interface{}/any, 새 문자열, 맵 또는 항목당 새 구조체에 대한 포인터를 통해 프리미티브를 반환하면 호출당 할당이 강제됩니다(encoding/json.Decoder.Token이 전형적인 "가비지 공장"입니다). 구체적인 타입이나[]byte와 같은 뷰를 반환하고, 호출자가 필요한 것만 변환하도록 하십시오. - 뷰를 반환하고, 복사본을 반환하지 마십시오. 호출자가 읽기만 하면 되는 경우에 적용하십시오. 뷰가 유효한 기간을 문서화하십시오("다음 호출까지 유효",
bufio.Scanner.Bytes처럼). 원시 입력의 뷰(첫 번째 바이트가 종류를 알려주는 토큰의 바이트)는 호출자가 무시할 수 있는 디코딩된 값보다 저렴합니다. - 호출자가 재사용 가능한 메모리를 소유하도록 하십시오. 호출당 할당하는 대신 스크래치나 버퍼 인수를 받거나 호출자가 유지하는 구조체에 보관하십시오. 호출자가 초기 버퍼를 제공할 수 있게 하여 스택에 있거나 풀에서 올 수 있도록 하십시오:
var buf [8 << 10]byte; d := NewDecoderBuffer(r, buf[:]). - 버퍼 관리를 몇 가지 메서드 뒤에 숨기십시오.
io.Reader는 호출자가 관리하는 버퍼로 복사하고 모든 호출자가 다시 채우도록 합니다. 작은 윈도우 API는 데이터를 제자리에 유지합니다:Window() []byte로 보기,Release(n)으로 소비,Extend() int로 더 읽기, 문서 주석에 "윈도우는 Release 또는 Extend에 의해 무효화됩니다"라고 명시. - 오류를 핫 루프 밖으로 정의하십시오. bool이나 빈 결과를 반환하고 오류를 나중에 보고하는 반복을 사용하십시오:
for s.Next() { ... }그 다음s.Err(). 느린 경로에 센티널 결과를 사용하십시오(Extend가 0을 반환하면 호출자가 오류를 물음). 내부 루프에 오류 분기가 없도록 하십시오.io.EOF를 다른 종료 조건처럼 취급하십시오. - 입력 단위당 작업을 계산하십시오. 각 바이트를 한 번 처리하고, 바이트당이 아니라 토큰이나 레코드당 함수 호출을 만드십시오. 중간 복사본을 제거하십시오. 바이트당 호출에서 토큰당 호출로 이동하는 것은 종종 할당 제거 다음으로 두 번째로 큰 이득입니다.
- API를 계층화하십시오. 저수준의 할당이 없는 빌딩 블록(원시 토큰, 뷰, 스크래치를 소유하는 반복자)을 제공하고 그 위에 편리한 API를 구축하십시오. 편리하고 할당하는 API만 유일한 API로 만들지 마십시오.
- 소유권과 수명을 문서 주석에 작성하십시오: 무엇이 빌려오는지, 무엇이 유지되는지, 동시 사용에 안전한지, 제로 값이 무엇을 의미하는지.
- 제로 값을 유용하게 만드십시오. 자연스러운 경우에만 적용하십시오.
- 변경 가능한 공유 상태에 대한 잠금보다 불변 공유 값과 새 스냅샷 게시를 선호하십시오. 읽기 작업이 지배적인 경우.
구현뿐만 아니라 API를 측정하십시오: 교체하는 API와 비교하여 allocs/op와 처리량을 비교하십시오. references/examples.md는 이러한 형태를 나란히 보여줍니다.
핫 루프 튜닝, 마지막으로
API와 big-O가 올바르고 프로파일이 루프가 핫하다고 보여준 후에만:
- 상태에 대한
switch는 비교 체인으로 컴파일됩니다.func필드(d.state = (*Decoder).stateValue)를 통해 선택된 상태별 작은 메서드는 각 상태를 읽기 쉽게 유지하고 디스패치를 제거할 수 있습니다. - 검사를 값을 사용하는 함수로 이동하여 컴파일러가 경계 검사가 불필요함을 증명할 수 있게 하고, 엔트리 포인트를 인라이닝 예산(80; 호출 비용은 약 52) 내에 유지하십시오.
go build -gcflags=-m=2로 확인하십시오. - 호출을 피하기 위해 두 함수를 병합하면 토큰당 경로에서 5-15%의 이득을 얻을 수 있지만, 더 깊은 중첩의 대가가 따릅니다. 벤치마크와 함께만 수행하고 이유를 주석에 명시하십시오.
Cheney가 말했듯이: "API의 big O 효과를 다루지 않고" 이러한 트릭을 사용하지 마십시오. "바늘을 움직이지 않을 것입니다."
함수를 작고 평평하게 유지
각 함수가 하나의 일을 하고, 기껏해야 한두 개의 분기를 갖도록 하십시오. 지침으로, 순환 복잡도를 10 이하, 인지 복잡도를 10 이하로 유지하십시오(gocyclo, gocognit). 각 케이스를 하나의 호출로 넘기는 평면 타입 스위치는 그 이상도 괜찮지만, 중첩은 안 됩니다.
- 다중 조건에 이름을 붙이십시오.
if len(a)*len(b) > maxN || size(a)*len(b)+size(b)*len(a) > maxM를if !fitsProduct(a, b)로 바꾸십시오. 이름이 의도를 문서화하고 함수가 설명을 담을 수 있습니다. - 디스패치 후 위임하십시오. 노드나 메시지 종류에 대한 스위치는 케이스당 하나의 메서드를 호출해야 하지, 각 케이스의 로직을 포함해서는 안 됩니다.
- 일찍 반환하십시오. 가드 절을 먼저, 주요 경로는 들여쓰기 없이.
- 케이스를 반복하지 마십시오. 두 분기가 피연산자 순서만 다르다면, 하나의 헬퍼를 작성하고 두 번 호출하십시오.
- 혼합된 관심사를 분리하십시오. "찾기", "새 값 만들기", "정리하기"는 하나가 아닌 세 개의 함수입니다.
- bool 플래그 매개변수를 피하십시오. 가장 명확한 옵션이라면, 의미 있는 이름을 붙이고 호출 지점에서 설명하십시오.
- 룩업 테이블(
map[string]Kind,[256]bool)을 문자열이나 바이트 비교 체인 대신 사용하십시오.
독자와 CPU를 위한 데이터 형태 만들기
- 병렬 슬라이스를 구조체 슬라이스로 그룹화하십시오. 동일한 ID로 인덱싱된 네 개의 슬라이스는 하나의 구조체입니다. 읽기에 좋고 일반적으로 지역성을 향상시킵니다.
- 반복되는 메커니즘에 자체 작은 타입을 부여하십시오. 동일한 카운터 랩, 비트맵 또는 버퍼 로직이 두 번 나타나면, 두세 개의 메서드를 가진 타입입니다.
- 비트 패킹보다 명시적 필드를 선호하십시오. 패킹이 측정된 결과에 영향을 미치는 경우가 아니라면.
[]segment{name, each}가 시프트 연산이 있는uint64플래그보다 낫습니다. - 표현을 적게 유지하십시오. 상태나 종류가 적을수록 중복된 스위치가 줄어듭니다. 측정 가능한 비용이 없다면 특수 케이스를 일반 케이스에 병합하십시오.
성능 주장 증명
측정 없이 변경이 더 빠르다, 느리다, 또는 할당이 없다고 주장하지 마십시오. 성능에 민감한 코드를 변경하기 전:
- 관련 벤치마크의
benchstat기준선을 저장하십시오. - 변경을 수행한 후 비교하십시오. 유의미한 차이를 숫자로 보고하십시오.
go build -gcflags=-m출력을 기준선과 비교하여 새로운 힙 이스케이프를 확인하십시오.- 제로 할당을 약속하는 경로에 대해
testing.AllocsPerRun테스트를 유지하거나 추가하십시오. - 회귀 원인을 추측하기 전에 프로파일링하십시오.
단순화가 측정 가능한 핫 경로 시간을 소모하고 이를 깔끔하게 회복할 수 없다면, 멈추고 트레이드오프를 보고하십시오. 조용히 복잡성을 다시 추가하거나 조용히 회귀를 받아들이지 마십시오.
정확한 명령과 복잡도 측정 방법 및 일반적인 회귀 원인에 대해서는 references/verify.md를 읽으십시오. 스타일을 보여주는 전후 리팩터 예제는 references/examples.md를 읽으십시오.
Go 관례 따르기
gofmt,go vet및 프로젝트의 린터를 통과해야 합니다. 코드를 수정하고, 린터 규칙을 약화시키거나 제외하지 마십시오. 명명된 규칙과 이유가 있을 때만 한 줄을 억제하십시오.- 짧은 리시버 이름, 타입의 메서드 전체에서 일관되게 사용하십시오. 이유 없이 하나의 타입에 포인터와 값 리시버를 혼용하지 마십시오.
- 문서 주석은 식별자 이름으로 시작하고 계약을 명시하십시오.
- 무엇이 아니라 왜를 주석으로 남기십시오. 주변 주석 밀도와 일치시키십시오.
- 직접 작성한 헬퍼보다 표준 라이브러리(
slices,maps,strings,math/bits)를 우선 사용하십시오. - 생성된 코드를 직접 편집하지 마십시오.
리뷰 또는 리팩터링 시
- 아무것도 건드리기 전에 핫 경로와 그 벤치마크를 식별하십시오.
- 복잡도를 측정하여 최악의 함수를 찾고, 그 함수부터 먼저 수정하십시오.
- 작은 단계로 리팩터링하고, 각 단계 후에 집중된 테스트를 실행하십시오.
- 동작을 동일하게 유지하십시오: 동일한 결과, 동일한 예산 및 폴백, 동일한 할당 보장. 동작 변경은 별도의 명시된 결정으로만 수행하십시오.
- 무엇이 변경되었는지, 전후 복잡도와 벤치마크 수치, 그리고 검증되지 않은 모든 것을 보고하십시오.