- Published on
Go gcflags로 CPU 프로파일링·인라이닝 해제
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려졌을 때 가장 먼저 떠올리는 도구가 pprof입니다. 그런데 Go는 컴파일러 최적화(인라이닝, escape 분석 기반 최적화, 디버그 정보 축소 등)가 적극적이라서, 프로파일/스택 트레이스에서 원래 작성한 함수가 사라지거나 라인 정보가 뭉개지는 일이 자주 생깁니다. 이때 go build 또는 go test에 -gcflags를 주면, **“분석 친화적인 바이너리”**를 만들 수 있습니다.
이 글은 다음을 목표로 합니다.
-gcflags로 인라이닝을 끄는 이유와 효과- CPU 프로파일링(
pprof)을 “재현 가능하게” 수집하는 방법 - 프로파일 결과가 이상할 때 점검할 체크리스트
성능 이슈는 종종 배포/운영 환경에서 재현됩니다. 운영 디버깅 플로우는 CI/CD, 쿠버네티스, 관측성까지 연결되기 때문에, 필요하면 GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기 같은 글도 같이 참고해 두면 좋습니다.
-gcflags가 필요한 순간: 인라이닝이 프로파일을 흐릴 때
Go 컴파일러는 작은 함수 호출을 “함수 호출 오버헤드 없이” 호출 지점에 펼쳐 넣는 인라이닝을 합니다. 성능엔 대체로 유리하지만, 프로파일링 관점에서는 다음 문제가 생깁니다.
- 호출 스택에서 함수 프레임이 사라져 핫스팟이 호출자 라인에 합쳐져 보임
pprof의top/list에서 기대한 함수명이 안 보이거나, 특정 라인에 샘플이 과도하게 몰림- “어떤 함수가 진짜 병목인가”가 아니라 “어떤 호출 지점이 뜨거운가”로 보이면서 원인 파악이 늦어짐
이때 가장 흔히 쓰는 옵션이 아래 두 가지입니다.
-l: 인라이닝 비활성화-N: 최적화 비활성화(일부 최적화 패스 끔)
둘을 같이 쓰면 “가장 디버깅 친화적”이지만, 실행 성능은 떨어질 수 있습니다. 따라서 프로파일링용 빌드로만 사용하고, 프로덕션 최적화 빌드와 섞지 않는 것이 안전합니다.
기본: pprof로 CPU 프로파일링 수집하기
가장 흔한 패턴은 net/http/pprof를 붙여 HTTP로 프로파일을 뜨는 방식입니다.
1) 애플리케이션에 pprof 엔드포인트 추가
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// 운영에서는 접근 통제(내부망, 인증, 포트포워딩 등)를 반드시 적용하세요.
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
// 실제 서비스 핸들러
log.Println(http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})))
}
127.0.0.1:6060처럼 루프백에만 바인딩하면 외부 노출 위험을 줄일 수 있습니다.- 쿠버네티스에서는
kubectl port-forward로만 접근하도록 구성하는 방식이 흔합니다.
2) CPU 프로파일 덤프
아래는 30초 CPU 프로파일을 수집하는 예시입니다.
curl -o cpu.pprof "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"
3) go tool pprof로 확인
go tool pprof -http=":0" ./app-binary cpu.pprof
-http=":0"는 임의 포트로 웹 UI를 띄웁니다.- 바이너리 경로를 같이 주면 심볼/라인 매핑이 더 정확해집니다.
핵심: -gcflags로 인라이닝/최적화 끄기
패키지 단위로 적용되는 -gcflags 이해하기
-gcflags는 “컴파일러에 전달할 플래그”입니다. go build/go test에서 패키지 패턴과 함께 씁니다.
- 전체 패키지에 적용:
-gcflags="all=..." - 특정 패키지에만 적용:
-gcflags="your/module/pkg=..."
MDX 렌더링 환경에서는 꺾쇠가 텍스트로 노출되면 빌드 에러가 날 수 있으니, 아래 예시는 모두 코드 블록으로 제공합니다.
인라이닝만 끄기: -l
go build -gcflags="all=-l" -o app ./cmd/app
- 실행 성능은 상대적으로 덜 떨어지면서, 스택/함수 단위가 더 또렷해지는 경우가 많습니다.
- “핫스팟이 어디 함수인지”를 찾는 1차 분석에 적합합니다.
인라이닝 + 최적화 끄기: -l -N
go build -gcflags="all=-l -N" -o app ./cmd/app
- 프로파일/디버깅 가독성은 좋아지지만, 성능과 메모리 사용이 변할 수 있습니다.
- 따라서 이 빌드로 얻은 CPU 사용량 절대값을 그대로 믿기보다는, “어디가 뜨거운지”(상대 분포)에 초점을 두는 편이 좋습니다.
특정 패키지만 인라이닝 해제하기
프로젝트 전체에 -l -N을 걸면 너무 느려지거나, 서드파티까지 영향이 갈 수 있습니다. 병목 후보 패키지에만 제한적으로 적용할 수도 있습니다.
go build -gcflags="your/module/internal/handler=-l" -o app ./cmd/app
이 방식은 “핫스팟이 의심되는 코드만 선명하게 보기”에 유리합니다.
실전 플로우: 프로파일이 이상할 때의 재현 가능한 절차
1) 동일 워크로드로 비교하기
프로파일은 워크로드에 매우 민감합니다.
- 동일 입력 데이터
- 동일 트래픽 패턴(가능하면 리플레이)
- 동일한 GOMAXPROCS, 컨테이너 CPU limit
쿠버네티스 환경에서 CPU limit이 걸리면 스케줄링/쓰로틀링 때문에 결과가 달라질 수 있습니다. 이런 운영 이슈는 네트워크/인그레스 문제와 엮여 장애처럼 보이기도 하니, 필요하면 EKS ALB Ingress 504(60초) idle_timeout 해결처럼 인프라 레이어도 함께 점검해 두는 게 좋습니다.
2) “프로덕션 빌드”와 “분석 빌드”를 분리
권장 매트릭스는 다음과 같습니다.
- 프로덕션: 기본 최적화(인라이닝 포함)
- 분석용:
-gcflags="all=-l"또는-gcflags="all=-l -N"
그리고 두 빌드 모두에서 같은 방식으로 cpu.pprof를 뜬 뒤,
- 프로덕션 빌드에서 병목 후보를 찾고
- 분석 빌드에서 함수/라인 단위로 확정
이 순서로 가면 “성능 현실성”과 “원인 규명”을 동시에 가져갈 수 있습니다.
3) pprof에서 꼭 보는 뷰
아래 명령들은 CLI에서 빠르게 감을 잡는 데 좋습니다.
go tool pprof ./app cpu.pprof
pprof 프롬프트에서:
top
top -cum
list YourFunction
web
top: 현재 함수 기준으로 뜨거운 순서top -cum: 누적 기준(호출 트리 상에서 시간을 많이 소비한 경로)list: 소스 라인별 샘플 분포
인라이닝이 켜져 있으면 list에서 “호출 라인 한 줄에 샘플이 몰려” 원인이 안 보일 수 있는데, -l로 꽤 개선됩니다.
흔한 오해와 주의점
-l -N은 “성능을 정확히 재현”하기 위한 옵션이 아니다
최적화를 끄면 코드 생성이 달라져 CPU 사용량과 캐시 특성이 변합니다. 그래서:
- 절대값(예: QPS가 20% 떨어졌다)을 그대로 결론 내리기 어렵고
- 상대 분포(어느 함수가 가장 뜨거운가)를 보는 용도로 쓰는 게 안전합니다.
프로파일이 비어 보일 때: 심볼/디버그 정보 매칭
다음 케이스에서 pprof가 기대만큼 “함수명/라인”을 못 보여줄 수 있습니다.
- 바이너리와
cpu.pprof가 서로 다른 빌드에서 나옴 - 컨테이너에서 프로파일을 뜨고 로컬에서 다른 바이너리로 열어봄
항상 go tool pprof에 프로파일을 뜬 그 바이너리를 함께 넘기세요.
go tool pprof -http=":0" /path/to/the-same-binary cpu.pprof
컨테이너/쿠버네티스에서의 권장 패턴
- 바이너리를 이미지에 포함하고, 프로파일 파일만 로컬로 복사
- 또는 프로파일을 뜬 Pod에서 바이너리를 함께
kubectl cp로 가져오기
배포 자동화가 꼬이면 분석용 이미지를 올리는 것 자체가 막히는 경우가 있습니다. 그런 상황에서는 Argo CD Sync 실패 - OutOfSync·Degraded 해결법처럼 배포 파이프라인 문제를 먼저 풀어야 합니다.
코드 예제: CPU를 태우는 함수 만들고 비교하기
인라이닝이 켜지면 작은 함수가 스택에서 사라질 수 있다는 걸 보기 위한 예시입니다.
package main
import (
"crypto/sha256"
"fmt"
"runtime"
"time"
)
func tiny(x []byte) [32]byte {
// 작은 함수라 인라이닝 후보가 되기 쉽습니다.
return sha256.Sum256(x)
}
func hotLoop(n int) {
b := []byte("some-payload")
var sink [32]byte
for i := 0; i < n; i++ {
sink = tiny(b)
_ = sink
}
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
start := time.Now()
hotLoop(20_000_000)
fmt.Println("elapsed:", time.Since(start))
}
이 코드를 기준으로:
- 기본 빌드(인라이닝 켜짐)
-gcflags="all=-l"빌드(인라이닝 꺼짐)
두 버전에서 CPU 프로파일을 뜨면, tiny가 스택/함수 리스트에서 보이는 정도가 달라질 수 있습니다.
빌드 예시:
go build -o app-default .
go build -gcflags="all=-l" -o app-noinline .
프로파일 수집은 net/http/pprof를 붙인 서버 예제로 하는 편이 일반적이지만, 간단히 로컬에서 재현할 땐 실행 파일에 runtime/pprof를 직접 붙여 파일로 떨구는 방식도 많이 씁니다(운영에서는 HTTP 방식이 편합니다).
추천 조합 요약
- 1차(현실 성능 유지): 기본 빌드로 CPU 프로파일 수집 후
top -cum으로 경로 파악 - 2차(원인 확정):
-gcflags="all=-l"로 인라이닝만 끄고 함수/라인을 선명하게 확인 - 3차(아주 복잡한 케이스):
-gcflags="all=-l -N"로 최적화까지 끄고, 디버깅 가독성 극대화
최종적으로는 pprof에서 보이는 핫스팟을 코드 변경으로 줄인 뒤, 다시 프로덕션 빌드(최적화 켜짐) 기준으로 성능을 검증하는 순서가 가장 안전합니다.
필요하면 다음 단계로, go test -bench와 결합해 “벤치마크 + 프로파일 + -gcflags”를 한 번에 굴리는 방법(예: -cpuprofile, -benchmem, -run=^$)까지 확장할 수 있습니다.