2025년 7월 06일·6분 읽기

트래픽 스파이크를 위한 Go 메모리 프로파일링: pprof 워크스루

Go 메모리 프로파일링은 갑작스런 트래픽 스파이크를 처리하는 데 도움을 줍니다. pprof로 JSON, DB 스캔, 미들웨어의 할당 핫스팟을 찾는 실전 가이드.

트래픽 스파이크를 위한 Go 메모리 프로파일링: pprof 워크스루

트래픽 급증이 Go 서비스의 메모리에 미치는 영향

프로덕션에서의 “메모리 스파이크”는 보통 단일 수치가 단순히 증가하는 것을 의미하지 않습니다. RSS(프로세스 메모리)는 빠르게 오르는데 Go 힙은 거의 움직이지 않거나, 힙이 GC에 따라 급격히 오르내리는 파동을 보일 수 있습니다. 동시에 런타임이 정리 작업을 더 많이 하느라 지연(latency)이 악화되는 경우가 많습니다.

일반적인 메트릭 패턴:

  • RSS가 예상보다 빠르게 상승하고 스파이크 후에도 완전히 떨어지지 않는 경우가 있음
  • 힙 in-use가 상승했다가 GC 실행으로 뚝뚝 떨어지는 사이클을 보임
  • 할당률(초당 할당된 바이트)이 급증함
  • GC 일시정지 시간과 GC에 소비되는 CPU 시간이 증가함(각 일시정지는 작을 수 있음)
  • 요청 지연이 급증하고 tail latency가 불안정해짐

트래픽 스파이크는 요청당 할당을 증폭시킵니다. “작은” 낭비도 부하와 선형적으로 증가합니다. 예컨대 한 요청이 추가로 50KB를 할당한다면(임시 JSON 버퍼, 행당 스캔 객체, 미들웨어 컨텍스트 데이터 등), 초당 2,000 RPS에서는 초당 약 100MB를 할당기에 공급하는 셈입니다. Go는 많은 양을 처리할 수 있지만 GC는 여전히 그 짧은 수명의 객체들을 추적하고 해제해야 합니다. 할당이 정리보다 빨라지면 힙 목표가 커지고 RSS가 따라가며 메모리 한계에 도달할 수 있습니다.

증상은 익숙합니다: 오케스트레이터의 OOM 킬, 갑작스런 레이턴시 상승, GC에 더 많은 시간 소비, CPU가 고정되어 있지 않아도 서비스가 바쁜 것처럼 보이는 현상. GC 스래시가 발생하면 서비스는 계속 동작하지만 할당과 수집을 반복하느라 처리량이 급격히 떨어질 수 있습니다.

pprof는 한 가지 질문에 빠르게 답하는 데 도움이 됩니다: 어떤 코드 경로가 가장 많이 할당하고 있으며, 그 할당이 필요한가? 힙 프로파일은 지금 유지되고 있는 것이 무엇인지 보여줍니다. 할당 중심 뷰(예: alloc_space)는 생성되어 바로 버려지는 것이 무엇인지 보여줍니다.

pprof가 모든 RSS 바이트를 설명해주지는 않습니다. RSS는 Go 힙 외에도 더 많은 것을 포함합니다(스택, 런타임 메타데이터, OS 매핑, cgo 할당, 단편화 등). pprof는 Go 코드에서 할당 핫스팟을 가리키는 데 가장 적합하며, 컨테이너 수준의 정확한 메모리 총량을 증명하는 도구는 아닙니다.

pprof 안전하게 설정하기(단계별)

pprof는 HTTP 엔드포인트로 사용하는 것이 가장 쉽지만, 그 엔드포인트는 서비스에 대한 많은 정보를 노출할 수 있습니다. pprof를 퍼블릭 API가 아니라 관리자 기능으로 취급하세요.

1) pprof 엔드포인트 추가

Go에서는 pprof를 별도의 관리(admin) 서버에서 실행하는 것이 가장 간단한 설정입니다. 이렇게 하면 프로파일링 라우트를 메인 라우터와 미들웨어에서 분리할 수 있습니다.

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	go func() {
		// Admin only: bind to localhost
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	// Your main server starts here...
	// http.ListenAndServe(":8080", appHandler)
	select {}
}

두 번째 포트를 열 수 없다면 메인 서버에 pprof 라우트를 마운트할 수도 있지만, 그렇게 하면 실수로 노출하기 쉽습니다. 별도의 관리 포트가 기본적으로 더 안전합니다.

2) 배포 전에 접근을 잠그기

실수하기 어려운 제어부터 시작하세요. localhost로 바인드하면 해당 포트가 누군가 추가로 노출시키지 않는 한 인터넷에서 접근할 수 없습니다.

간단한 체크리스트:

  • pprof를 메인 사용자 포트가 아닌 관리 포트에서 실행
  • 프로덕션에서는 127.0.0.1(또는 내부 인터페이스)에 바인드
  • 네트워크 경계에서 허용 목록(예: VPN, bastion, 내부 서브넷) 적용
  • 엣지에서 인증을 적용할 수 있다면(기본 인증 또는 토큰) 적용
  • 실제로 사용할 프로파일(heap, allocs, goroutine)을 가져올 수 있는지 확인

3) 안전하게 빌드하고 롤아웃하기

변경은 작게 유지하세요: pprof 추가, 배포, 그리고 기대한 곳에서만 접근 가능한지 확인. 스테이징이 있다면 먼저 부하를 시뮬레이션하고 힙·allocs 프로파일을 캡처해 테스트하세요.

프로덕션에서는 점진적으로 롤아웃하세요(한 인스턴스 또는 소량의 트래픽). pprof가 잘못 구성되어 있어도 블라스트 반경을 작게 유지하면서 수정할 수 있습니다.

스파이크 동안 적절한 프로파일 캡처하기

스파이크 동안 단일 스냅샷으로는 충분하지 않은 경우가 많습니다. 스파이크 전(기준), 스파이크 중(영향), 스파이크 후(복구)의 짧은 타임라인을 캡처하세요. 이렇게 하면 정상 워밍업 동작과 실제 할당 변화를 구분하기 쉬워집니다.

스파이크를 재현할 수 있다면, 프로덕션과 최대한 비슷하게 맞추세요: 요청 믹스, 페이로드 크기, 동시성. 작은 요청의 스파이크와 큰 JSON 응답의 스파이크는 매우 다르게 동작합니다.

힙 프로파일과 할당 중심 프로파일을 모두 찍으세요. 둘은 다른 질문에 답합니다:

  • 힙(inuse)은 현재 살아 있고 메모리를 잡고 있는 것을 보여줌
  • 할당(alloc_space 또는 alloc_objects)은 빠르게 해제되더라도 무엇이 많이 생성되는지 보여줌

실용적인 캡처 패턴: 힙 프로파일 하나를 찍고, 그다음 할당 프로파일을 찍고, 30~60초 뒤에 반복하세요. 스파이크 동안 두 시점이 있으면 의심스러운 경로가 안정적인지 가속되는지 확인하기 쉽습니다.

# examples: adjust host/port and timing to your setup
curl -o heap_during.pprof "http://127.0.0.1:6060/debug/pprof/heap"
curl -o allocs_30s.pprof "http://127.0.0.1:6060/debug/pprof/allocs?seconds=30"

pprof 파일과 함께 런타임 통계도 기록하세요. GC가 그때 무엇을 하고 있었는지 설명할 수 있도록 힙 크기, GC 횟수, 일시정지 시간을 기록하면 충분한 경우가 많습니다. 캡처 시의 짧은 로그 라인도 “할당이 증가했다”와 “GC가 계속 실행되기 시작했다”를 연관 짓는 데 도움이 됩니다.

사건 노트도 남기세요: 빌드 버전(커밋/태그), Go 버전, 중요한 플래그, 구성 변경, 어떤 트래픽이 있었는지(엔드포인트, 테넌트, 페이로드 크기). 이러한 세부사항은 프로파일을 비교할 때 요청 믹스가 바뀌었다는 사실을 깨닫게 해 줄 수 있습니다.

힙 및 할당 프로파일 읽는 법

힙 프로파일은 보는 관점에 따라 다른 질문에 답합니다.

Inuse space는 캡처 시점에 실제로 메모리를 점유하고 있는 것을 보여줍니다. 누수, 장기 캐시, 객체를 남겨두는 요청을 찾을 때 사용하세요.

Alloc space(총 할당량)는 시간이 흐르며 무엇이 할당되었는지를 보여줍니다. 빠르게 해제되더라도 많은 GC 작업을 유발하거나 OOM을 일으킬 수 있는 치열한 할당을 찾을 때 사용합니다.

샘플링은 중요합니다. Go는 모든 할당을 기록하지 않습니다. 할당은 샘플링(runtime.MemProfileRate로 제어)되므로 작고 빈번한 할당은 과소표현될 수 있으며 수치는 추정치입니다. 하지만 스파이크 상황에서는 가장 큰 범죄자들이 여전히 두드러집니다. 완벽한 회계가 아니라 경향과 상위 기여자를 찾으세요.

가장 유용한 pprof 뷰:

  • top: inuse 또는 alloc에서 누가 지배적인지 빠르게 확인(flat과 cumulative 둘 다 확인)
  • list : 뜨거운 함수 내의 라인 단위 할당 출처
  • graph: 그 위치에 도달한 호출 경로 설명

차이(diff)가 실용적입니다. 정상 트래픽의 기준 프로파일과 스파이크 프로파일을 비교하면 무엇이 변했는지 하이라이트되어 배경 소음을 쫓는 일을 줄일 수 있습니다.

작은 변경으로 찾은 내용을 검증하세요. 대규모 리팩터링 전에:

  • 핫 경로에서 버퍼를 재사용하거나 작은 sync.Pool을 추가
  • 요청당 객체 생성을 줄이기(예: JSON을 위해 중간 맵을 만들지 않기)
  • 동일한 부하에서 재프로파일링하여 diff가 줄어드는지 확인

숫자가 기대하는 방향으로 움직이면 실제 원인을 찾은 것이고, 단순히 무서운 보고서를 본 것이 아닙니다.

JSON 인코딩에서 할당 핫스팟 찾기

Keep code clean as you iterate
Regenerate clean Go code when requirements change, without leaving old memory-heavy paths behind.
Generate Code

스파이크 동안 JSON 작업은 요청마다 실행되기 때문에 큰 메모리 비용을 유발할 수 있습니다. JSON 핫스팟은 보통 많은 작은 할당으로 나타나 GC를 더 압박합니다.

pprof에서 주의할 적신호

힙이나 할당 뷰에서 encoding/json이 지목되면 무엇을 넘기는지 면밀히 보세요. 다음 패턴이 할당을 키우는 경우가 많습니다:

  • 응답에 대해 map[string]any(또는 []any)를 사용하고 typed struct를 쓰지 않는 경우
  • 동일한 객체를 여러 번 마샬링(예: 로그와 응답 둘 다)하는 경우
  • 프로덕션에서 json.MarshalIndent로 pretty printing 하는 경우
  • 마샬링 전에 임시 문자열을 만들어(fmt.Sprintf, 문자열 이어붙이기) JSON을 구성하는 경우
  • []byte를 API 대응을 위해 string으로 자주 변환하는 경우

json.Marshal은 항상 전체 출력에 대해 새로운 []byte를 할당합니다. json.NewEncoder(w).Encode(v)는 일반적으로 한 번에 하나의 큰 버퍼를 만들지 않아서 그 큰 버퍼 할당을 피할 수 있지만, vany, 맵, 포인터가 많은 구조가 들어가면 내부적으로 여전히 할당이 발생할 수 있습니다.

빠른 수정과 실험

응답 모양이 예측 가능하면 타입이 지정된 struct를 먼저 사용하세요. 리플렉션 작업과 필드별 인터페이스 박싱을 줄여줍니다.

그다음 불필요한 요청당 임시 객체 생성을 제거하세요: bytes.Buffersync.Pool로 재사용(주의해서), 프로덕션에서는 들여쓰기 금지, 로그 때문에 다시 마샬링하지 않기 등.

JSON이 원인인지 확인하는 작은 실험:

  • 핫한 엔드포인트 하나에 대해 map[string]any를 struct로 바꿔보고 프로파일 비교
  • Marshal에서 응답에 바로 쓰는 Encoder로 전환
  • MarshalIndent나 디버그 전용 포맷팅 제거 후 동일 부하에서 재프로파일링
  • 변경이 없으면 캐시된 응답을 사용하여 JSON 인코딩 건너뛰기 및 변화 측정

쿼리 스캔에서 할당 핫스팟 찾기

스파이크 중 메모리 급증의 흔한 원인은 데이터베이스 읽기 단계입니다. SQL 실행 시간에만 집중하기 쉽지만, 스캔 단계에서 행당 많은 할당이 발생할 수 있습니다.

흔한 원인:

  • interface{}(또는 map[string]any)로 스캔하여 드라이버가 타입을 결정하게 두는 경우
  • 필드마다 []bytestring으로 변환하는 경우
  • 큰 결과 집합에서 nullable 래퍼(sql.NullString, sql.NullInt64)를 많이 사용하는 경우
  • 항상 필요하지 않은 큰 텍스트/블롭 컬럼을 가져오는 경우

한 패턴은 행 데이터를 임시 변수에 스캔한 뒤 실제 구조체로 복사하는 것입니다. 바로 구조체의 구체적 필드로 스캔할 수 있다면 추가 할당과 타입 검사 비용을 피할 수 있습니다.

배치 크기와 페이징은 메모리 형태를 바꿉니다. 10,000행을 한 번에 슬라이스에 가져오면 슬라이스 확장과 각 행에 대한 할당이 한꺼번에 발생합니다. 핸들러가 페이지 단위로만 필요하다면 쿼리에서 페이징을 적용하고 페이지 크기를 안정적으로 유지하세요. 많은 행을 처리해야 한다면 스트리밍하여 작은 요약만 집계하고 모든 행을 저장하지 마세요.

큰 텍스트 필드는 특별히 주의하세요. 많은 드라이버는 텍스트를 []byte로 반환합니다. 이를 string으로 변환하면 데이터가 복사되므로 모든 행마다 변환하면 할당이 폭증할 수 있습니다. 값이 가끔만 필요하다면 변환을 지연시키거나 해당 엔드포인트에서 적은 컬럼만 스캔하세요.

드라이버가 할당의 주범인지 코드가 주범인지 확인하려면 프로파일에서 무엇이 지배적인지 확인하세요:

  • 프레임이 매핑 코드(자신의 코드)를 가리키면 스캔 대상과 변환에 집중
  • 프레임이 database/sql 또는 드라이버 내부를 가리키면 행 수와 컬럼 수를 줄인 후 드라이버별 옵션 고려
  • alloc_spacealloc_objects 둘 다 확인; 작은 할당이 많은 것이 몇 개의 큰 할당보다 더 나쁠 수 있음

예: ‘주문 목록(list orders)’ 엔드포인트가 SELECT *[]map[string]any에 스캔하면, 각 요청이 수천 개의 작은 맵과 문자열을 생성합니다. 쿼리를 필요한 컬럼만 선택하고 []Order{ID int64, Status string, TotalCents int64} 같은 타입 지정 슬라이스로 스캔하면 즉시 할당량이 줄어듭니다. 이 개념은 AppMaster에서 생성된 Go 백엔드를 프로파일링할 때도 동일하게 적용됩니다: 핫스팟은 대개 결과 데이터를 어떻게 구성하고 스캔하느냐에 있습니다.

요청당 조용히 할당을 일으키는 미들웨어 패턴

Build APIs faster
Ship production-ready APIs with database modeling and business logic built in.
Create Backend

미들웨어는 래퍼라서 싸다고 느껴지지만 모든 요청에서 실행됩니다. 스파이크 동안 작은 요청당 할당이 빠르게 쌓여 할당률 상승으로 나타납니다.

로깅 미들웨어는 흔한 원인입니다: 문자열 포맷팅, 필드 맵 생성, 헤더 복사 등. 요청 ID 헬퍼는 ID를 생성하고 문자열로 변환한 뒤 컨텍스트에 붙이면서 할당을 일으킬 수 있습니다. context.WithValue도 매 요청 새 객체(또는 새 문자열)를 저장하면 할당을 발생시킵니다.

압축과 바디 처리도 빈번한 범죄자입니다. 미들웨어가 요청 바디를 전체 읽어 ‘미리보기’하거나 검증하면 큰 버퍼가 요청당 생깁니다. gzip 미들웨어가 매번 새 리더/라이터를 만들고 버퍼를 재사용하지 않으면 많은 할당이 발생합니다.

인증과 세션 레이어도 비슷합니다. 각 요청이 토큰을 파싱하고, 쿠키를 base64 디코딩하고, 세션 블롭을 새 구조체로 로드하면 처리 작업이 가벼운 경우에도 지속적인 할당이 발생합니다.

트레이싱과 메트릭은 레이블을 동적으로 만들 때 예상보다 더 많은 할당을 발생시킬 수 있습니다. 라우트 이름, User-Agent, 테넌트 ID 등을 새로운 문자열로 매 요청마다 이어붙이면 숨겨진 비용이 됩니다.

‘천 번의 칼날’로 자주 보이는 패턴:

  • fmt.Sprintf로 로그 라인을 만들고 매 요청 새 map[string]any를 생성
  • 로깅이나 서명용으로 헤더를 새 맵/슬라이스에 복사
  • 재사용하지 않고 매번 새 gzip 버퍼·리더·라이터 생성
  • 고카디널리티(metric labels)가 많은 많은 새 문자열 생성
  • 매 요청 컨텍스트에 새 구조체 저장

미들웨어 비용을 분리하려면 두 프로파일을 비교하세요: 전체 체인이 활성화된 경우와 미들웨어를 일시적으로 비활성화하거나 noop으로 교체한 경우. 간단한 테스트는 거의 할당이 없어야 하는 헬스 엔드포인트입니다. 스파이크 동안 /health가 많이 할당하면 핸들러가 문제가 아닙니다.

AppMaster로 생성한 Go 백엔드를 사용하는 경우에도 동일한 규칙이 적용됩니다: 로깅, 인증, 트레이싱 같은 횡단 관심 기능은 측정 가능하게 만들고 요청당 할당을 예산으로 다루세요.

빠르게 효과를 보는 수정

Offload work with internal apps
Create internal tools that reduce pressure on your core API during traffic spikes.
Try AppMaster

pprof의 힙과 allocs 뷰를 확보하면 요청당 할당을 줄이는 변경을 우선순위로 두세요. 목표는 창의적인 트릭이 아니라 핫 경로가 짧은 수명의 객체를 덜 생성하게 만드는 것입니다.

안전하고 보수적인 개선부터 시작

크기가 예측 가능하면 미리 할당하세요. 예를 들어 보통 200개 항목을 반환한다면 용량을 200으로 만든 슬라이스를 생성해 여러 번 성장·복사되는 일을 피하세요.

핫 경로에서 문자열을 만드는 것을 피하세요. fmt.Sprintf는 편리하지만 자주 할당을 일으킵니다. 로깅은 구조화된 필드를 선호하고, 적절한 곳에서는 작은 버퍼를 재사용하세요.

큰 JSON 응답을 생성한다면 한 번에 거대한 []bytestring을 만들기보다 스트리밍을 고려하세요. 일반적인 스파이크 패턴은 요청이 들어오면 큰 바디를 읽고, 큰 응답을 빌드해서 메모리가 점프했다가 GC가 따라오는 경우입니다.

프로파일 전후에 뚜렷하게 나타나는 빠른 변경:

  • 크기 범위를 알면 슬라이스와 맵을 미리 할당
  • 요청 처리에서 fmt 중심 포맷팅을 더 저렴한 대안으로 교체
  • 큰 JSON 응답은 스트리밍(응답 writer에 직접 인코드)
  • 동일한 형태의 재사용 가능한 객체(버퍼, 인코더)에 sync.Pool 사용 및 일관되게 반환
  • 요청 제한(body 크기, 페이로드 크기, 페이지 크기) 설정으로 최악의 경우 억제

sync.Pool을 조심해서 사용

sync.Pool은 요청마다 반복적으로 동일한 것을 할당할 때(예: 요청당 bytes.Buffer) 도움이 됩니다. 하지만 예측 불가능한 크기의 객체를 풀링하거나 리셋을 잊으면 큰 백킹 배열을 계속 유지해 역효과를 냅니다.

동일한 워크로드로 측정 전후를 비교하세요:

  • 스파이크 창에서 allocs 프로파일 캡처
  • 한 번에 하나의 변경만 적용
  • 동일한 요청 믹스로 다시 실행하고 총 allocs/op 비교
  • 메모리뿐 아니라 tail latency도 관찰

AppMaster로 생성한 Go 백엔드를 사용하는 경우에도 이러한 수정은 핸들러 주변의 커스텀 코드, 통합, 미들웨어에서 적용됩니다. 스파이크 유발 할당은 그쪽에 숨겨져 있는 경우가 많습니다.

흔한 pprof 실수와 오탐

잘못된 것을 최적화하면 하루를 허비하기 쉽습니다. 서비스가 느리면 CPU부터 시작하고, OOM으로 죽으면 힙부터 시작하고, 살아남지만 GC가 계속 돌아간다면 할당률과 GC 동작을 보세요.

또 다른 함정은 top만 보고 끝내는 것입니다. top은 문맥을 숨깁니다. 항상 호출 스택(또는 플레임 그래프)을 검사하여 누가 할당기를 호출했는지 확인하세요. 수정은 종종 뜨거운 함수 위 한두 프레임 위에 있습니다.

inuse와 churn(치열한 할당)을 혼동하지 마세요. 한 요청이 5MB의 짧은 수명 객체를 할당해 추가 GC를 유발하지만 최종적으로는 200KB만 inuse로 남길 수 있습니다. inuse만 보면 치열한 할당을 놓치고, 총 할당만 보면 실제로 머무르지 않는 것을 최적화할 수 있습니다.

코드를 변경하기 전에 빠른 점검:

  • 올바른 뷰를 보고 있는지 확인: 유지(보존)는 heap inuse, 치열한 할당은 alloc_space/alloc_objects
  • 함수 이름뿐 아니라 스택을 비교(encoding/json은 종종 증상일 뿐)
  • 현실적인 트래픽으로 재현: 동일 엔드포인트, 페이로드 크기, 헤더, 동시성
  • 기준 프로파일과 스파이크 프로파일을 캡처하고 diff

비현실적인 부하 테스트는 오탐을 만듭니다. 테스트가 작은 JSON 바디를 보낸다면 프로덕션의 200KB 페이로드를 놓치게 되고 잘못된 경로를 최적화하게 됩니다. 테스트가 한 행만 반환하면 500행에서 나타나는 스캔 동작을 결코 보지 못합니다.

노이즈를 쫓지 마세요. 함수가 스파이크 프로파일에만 나타나면 유력한 단서입니다. 기준과 스파이크에서 동일 수준으로 나타난다면 배경 작업일 수 있습니다.

현실적인 인시던트 워크스루

Validate fixes with profiles
Prototype a safer data access pattern and validate it with before-after allocation profiles.
Try Now

월요일 아침 프로모션이 나가고 Go API에 평상시보다 8배 트래픽이 몰립니다. 첫 증상은 충돌이 아닙니다. RSS가 상승하고 GC가 바빠지며 p95 지연이 상승합니다. 가장 뜨거운 엔드포인트는 모바일 앱이 화면을 열 때마다 새로 고침하는 GET /api/orders였습니다.

여러분은 조용한 순간(기준)과 스파이크 동안의 스냅샷을 각각 찍습니다. 동일한 유형의 힙 프로파일을 캡처해야 비교가 공정합니다.

현장에서 통하는 흐름:

  • 기준 힙 프로파일을 찍고 현재 RPS, RSS, p95 지연을 기록
  • 스파이크 중에 힙 프로파일 하나와 할당 프로파일 하나를 같은 1~2분 창에서 캡처
  • 두 프로파일 간 상위 할당자를 비교하고 가장 많이 성장한 항목에 집중
  • 가장 큰 함수에서 호출자 쪽으로 걸어 올라가 핸들러 경로를 찾음
  • 작은 변경 하나를 하고 단일 인스턴스에 배포한 뒤 재프로파일

이 사례에서는 스파이크 프로파일에서 대부분의 새로운 할당이 JSON 인코딩에서 발생했습니다. 핸들러가 map[string]any로 행을 구성한 다음 슬라이스를 json.Marshal 했습니다. 각 요청이 많은 짧은 수명 문자열과 인터페이스 값을 생성했습니다.

가장 작은 안전한 수정은 맵을 만드는 것을 멈추는 것이었습니다. 데이터베이스 행을 타입이 지정된 struct로 바로 스캔하고 그 슬라이스를 인코딩했습니다. 다른 것은 바꾸지 않았습니다: 동일한 필드, 동일한 응답 형태, 동일한 상태 코드. 한 인스턴스에 변경을 롤아웃하자 JSON 경로의 할당이 줄고 GC 시간이 줄어들며 지연이 안정화되었습니다.

그 이후 메모리, GC, 오류율을 보면서 점진적으로 롤아웃하세요. AppMaster 같은 노코드 플랫폼으로 서비스를 구축했다면 응답 모델을 타입화하고 일관되게 유지하는 것이 숨겨진 할당 비용을 피하는 데 도움이 된다는 점을 기억하세요.

다음 스파이크를 예방하기 위한 단계

스파이크를 안정화한 뒤에는 다음 스파이크가 평범하도록 만드세요. 프로파일링을 반복 가능한 연습으로 만드세요.

팀이 지친 상태에서도 따라할 수 있는 짧은 런북을 작성하세요. 무엇을 캡처할지, 언제 캡처할지, 기준과 어떻게 비교할지 등을 포함하세요. 실전용 명령, 프로파일 저장 위치, 주요 할당자가 “정상”일 때의 모습 등을 적으세요.

OOM 전에 할당 압력을 가볍게 모니터링하세요: 힙 크기, 초당 GC 사이클 수, 요청당 할당 바이트 등을 경보에 넣으세요. “요청당 할당 30% 증가” 같은 추세 포착이 하드 메모리 알람을 기다리는 것보다 유용한 경우가 많습니다.

대표 엔드포인트에 대해 CI에서 짧은 부하 테스트를 넣어 변경을 일찍 포착하세요. 작은 응답 변경도 추가 복사를 유발하면 할당을 두 배로 만들 수 있으므로 프로덕션 전에 발견하는 것이 낫습니다.

생성된 Go 백엔드를 운영한다면 소스를 내보내 동일한 방식으로 프로파일하세요. 생성된 코드도 여전히 Go 코드이며 pprof는 실제 함수와 줄을 가리킬 것입니다.

요구사항이 자주 바뀐다면, AppMaster (appmaster.io)는 앱이 발전하면서 백엔드를 다시 빌드·재생성하기에 실용적인 방법이 될 수 있습니다. 생성된 코드를 현실적인 부하에서 프로파일링한 뒤 배포하세요.

자주 묻는 질문

갑작스러운 트래픽 스파이크 때문에 코드 변경 없이도 메모리가 뛰는 이유는 무엇인가요?

스파이크는 보통 요청당 할당률을 예상보다 크게 높입니다. 요청당 작은 임시 객체라도 RPS가 올라가면 선형적으로 쌓여서 GC를 더 자주 실행하게 만들고, 그 결과 메모리가 급증할 수 있습니다.

RSS는 커지는데 Go 힙은 안정적으로 보이는 이유는 무엇인가요?

Go 힙은 Go 런타임이 관리하는 메모리만 추적합니다. RSS는 여기에 더해 고루틴 스택, 런타임 메타데이터, OS 매핑, 단편화, 일부 cgo 할당 등을 포함합니다. 스파이크 동안 RSS와 힙은 다르게 움직이는 것이 정상이며, RSS 총량을 맞추려 하기보다 pprof로 할당 핫스팟을 찾아보세요.

스파이크 동안 먼저 힙을 볼까요 아니면 alloc 프로파일을 볼까요?

유지되는 객체(무엇이 남아 있는지)를 의심할 때는 힙 프로파일을 먼저 보고, 짧게 생성되고 바로 해제되는 객체(치열한 할당)를 의심할 때는 allocs/alloc_space 같은 할당 중심 프로파일을 보세요. 트래픽 스파이크에서는 보통 치열한 할당이 문제를 일으켜 GC CPU 시간과 지연을 높입니다.

프로덕션에서 pprof를 안전하게 노출하는 가장 안전한 방법은 무엇인가요?

가장 간단하고 안전한 설정은 pprof를 별도의 관리 전용 서버에서 127.0.0.1에 바인드하여 운영 내부에서만 접근 가능하게 하는 것입니다. pprof는 서비스 내부 정보를 많이 드러낼 수 있으니 관리 인터페이스 취급하세요.

몇 개의 프로파일을 언제 캡처해야 하나요?

짧은 타임라인을 캡처하세요: 스파이크 몇 분 전(기준), 스파이크 중(영향), 스파이크 후(복구) 각각 한 번씩. 이렇게 하면 변화를 분리해 분석하기 쉽습니다.

pprof의 inuse와 alloc_space 차이는 무엇인가요?

inuse는 캡처 시점에 실제로 남아 있는 것을 찾을 때 사용하고, alloc_space는 많이 생성되는 것을 찾을 때 사용하세요. 흔한 실수는 inuse만 보고 치열한 할당(churn)을 놓치는 것입니다.

JSON 관련 할당을 빠르게 줄이려면 어떻게 해야 하나요?

만약 encoding/json이 많은 할당을 차지한다면 보통 데이터 형태가 문제입니다. map[string]any 대신 타입이 지정된 struct를 쓰고, json.MarshalIndent를 피하고, 임시 문자열로 JSON을 만드는 작업을 줄이면 즉시 할당량을 줄일 수 있습니다.

스파이크 동안 데이터베이스 쿼리 스캔이 메모리를 폭발시키는 이유는 무엇인가요?

유연한 대상(interface{}map[string]any)에 스캔하거나, 많은 필드에서 []bytestring으로 변환하거나, 너무 많은 행·열을 가져오는 경우 요청당 큰 할당이 생깁니다. 필요한 컬럼만 선택하고 페이징을 적용하며, 구조체에 직접 스캔하면 큰 개선이 납니다.

‘천 번의 칼날’ 방식의 할당을 일으키는 미들웨어 패턴은 무엇인가요?

미들웨어는 모든 요청에서 동작하므로 작은 할당이 누적되어 큰 문제가 됩니다. fmt.Sprintf로 로그를 만들거나, 매 요청마다 문자열을 새로 만들고, gzip 리더/라이터를 매번 새로 생성하거나, 컨텍스트에 매번 새 객체를 저장하면 지속적인 할당이 발생합니다.

이 pprof 워크플로를 AppMaster로 생성된 Go 백엔드에 적용할 수 있나요?

네—생성된 코드든 수작업 코드든 동일한 프로파일 기반 접근법이 적용됩니다. 생성된 백엔드 소스를 내보내서 pprof로 프로파일링하면 할당 경로를 찾아 모델·핸들러·교차 관심 로직을 조정해 다음 스파이크 이전에 요청당 할당을 줄일 수 있습니다.

쉬운 시작
멋진만들기

무료 요금제로 AppMaster를 사용해 보세요.
준비가 되면 적절한 구독을 선택할 수 있습니다.

시작하다