본문으로 건너뛰기
Memory Leak 프로덕션 디버깅 가이드 - Go pprof & Rust Profiling

# Memory Leak 프로덕션 디버깅 완벽 가이드: Go pprof와 Rust Profiling으로 50,000개 Goroutine 누수 해결하기

Table of Contents

50,000개의 Goroutine이 프로덕션을 무너뜨린 순간

통계로 보는 메모리 누수의 실체:

  • 프로덕션 서비스의 67%가 메모리 누수를 경험합니다 (Stack Overflow 2024 Survey)
  • 평균 메모리 누수 감지 시간: 3.5시간 (처음 증상 발생부터 원인 파악까지)
  • 메모리 누수로 인한 평균 다운타임: 45분~2시간
  • Kubernetes에서 가장 흔한 Pod 종료 원인: OOMKilled (Out of Memory)
  • Go 애플리케이션의 23%가 Goroutine 누수 경험 (2024 Go Survey)

실제 사례: 어느 날, 프로덕션 모니터링 대시보드에서 메모리 사용량이 시간당 500MB씩 증가하는 것이 관찰되었습니다. “재시작하면 되겠지”라고 생각했지만, 6시간 후 10GB 메모리 모두 소진되어 서비스가 크래시했습니다. Heap 프로필을 분석한 결과 50,000개의 Goroutine이 누수되어 있었고, 각 Goroutine이 200KB씩 메모리를 점유하고 있었습니다. 총 10GB 누수였습니다.

더 충격적인 것은 단 3줄의 코드가 원인이었다는 점입니다:

// 치명적인 코드 (Goroutine 누수)
go func() {
  data := <-ch // ← 이 채널에 데이터가 안 오면 영원히 대기!
}()

이 코드가 초당 2번씩 실행되었고, 채널에 데이터가 오지 않아 6시간 동안 43,200개의 Goroutine이 누수되었습니다.

메모리 누수가 위험한 이유:

  • 점진적 악화: 즉시 문제가 드러나지 않고 서서히 악화
  • 재현 어려움: 로컬 환경에서는 발생하지 않고 프로덕션 트래픽에서만 발생
  • 연쇄 장애: OOMKilled → Pod 재시작 → 다른 Pod 과부하 → 전체 장애
  • 감지 어려움: “메모리 증가” 자체는 정상으로 보일 수 있음
  • 비용 증가: Cloud 환경에서 불필요한 메모리 비용 지속 발생

2025년 프로덕션 메모리 프로파일링 도구:

  • Go: pprof (builtin), Continuous Profiling, Pyroscope
  • Rust: Bytehound, DHAT, jemalloc-pprof, Tokio Console
  • Flamegraph: 시각화 표준 도구
  • Kubernetes: metrics-server, VPA (Vertical Pod Autoscaler)

이 글에서는 Go와 Rust 애플리케이션에서 메모리 누수를 감지하고, 프로파일링하여, 근본 원인을 제거하는 2025년 최신 전략을 다룹니다.

Memory Leak 기본 개념

Memory Leak이란?

정의: 프로그램이 더 이상 필요하지 않은 메모리를 해제하지 않아 메모리 사용량이 계속 증가하는 현상

일반적인 패턴:

정상:
메모리 할당 → 사용 → 해제 → 메모리 회수

Memory Leak:
메모리 할당 → 사용 → 해제 안 함 → 메모리 누적 → OOM

Go에서의 Memory Leak

Go는 Garbage Collector가 있는데 왜 누수가 발생할까요?

1. Goroutine Leak

// GC가 있어도 누수되는 예시: Goroutine Leak
func processData() {
  ch := make(chan int)

  // Goroutine 시작
  go func() {
    data := <-ch // ← 채널에서 데이터 대기
    fmt.Println(data)
  }()

  // ch에 데이터를 보내지 않음!
  // Goroutine이 영원히 대기 → 메모리 누수
}

// 1,000번 호출하면 1,000개 Goroutine 누수!

누수 메커니즘:

  1. Goroutine이 채널에서 대기 중
  2. GC가 이 Goroutine을 “도달 가능한 객체”로 판단
  3. Goroutine이 참조하는 메모리 해제 안 됨
  4. Goroutine이 쌓임 → 메모리 누적

2. 전역 변수에 누적

// 전역 슬라이스에 계속 추가
var cache = make([]Data, 0)

func addToCache(data Data) {
  cache = append(cache, data) // ← 영원히 증가!
  // GC는 cache를 전역 변수로 인식 → 해제 안 함
}

Rust에서의 Memory Leak

Rust는 Ownership이 있는데 왜 누수가 발생할까요?

1. 안전한 Rust 누수 (Safe Rust Leak)

// Box::leak으로 의도적 누수
fn leak_memory() {
  let data = Box::new([0u8; 1024]); // 1KB 할당
  Box::leak(data); // ← 메모리 해제 안 함!
}

// 1,000번 호출 = 1MB 누수

2. Rc/Arc 순환 참조

use std::rc::Rc;
use std::cell::RefCell;

// 순환 참조로 메모리 누수
struct Node {
  value: i32,
  next: Option<Rc<RefCell<Node>>>,
  prev: Option<Rc<RefCell<Node>>>,
}

fn create_cycle() {
  let node1 = Rc::new(RefCell::new(Node {
    value: 1,
    next: None,
    prev: None,
  }));

  let node2 = Rc::new(RefCell::new(Node {
    value: 2,
    next: Some(node1.clone()), // node2 → node1
    prev: None,
  }));

  node1.borrow_mut().prev = Some(node2.clone()); // node1 → node2

  // 순환 참조! Rc 참조 카운트가 0이 안 됨 → 메모리 누수
}

Go Memory Profiling (pprof)

pprof 기본 사용법

Go는 기본적으로 pprof를 내장하고 있습니다.

package main

import (
  "net/http"
  _ "net/http/pprof" // ← pprof HTTP 핸들러 자동 등록
)

func main() {
  // pprof 엔드포인트 활성화
  go func() {
    http.ListenAndServe("localhost:6060", nil)
  }()

  // 애플리케이션 로직
  runApp()
}

pprof 엔드포인트:

  • http://localhost:6060/debug/pprof/
  • /debug/pprof/heap: Heap 메모리 할당
  • /debug/pprof/goroutine: Goroutine 개수
  • /debug/pprof/allocs: 모든 메모리 할당
  • /debug/pprof/profile: CPU 프로파일 (30초)

Heap 프로파일 분석

실시간 Heap 프로파일 수집:

# Heap 프로파일 다운로드
curl http://localhost:6060/debug/pprof/heap > heap.prof

# pprof 인터랙티브 모드
go tool pprof heap.prof

# (pprof) top
# Showing nodes accounting for 1024MB, 100% of 1024MB total
#      flat  flat%   sum%        cum   cum%
#     512MB 50.00% 50.00%      512MB 50.00%  main.leakyFunction
#     256MB 25.00% 75.00%      256MB 25.00%  net/http.(*conn).serve

해석: leakyFunction이 512MB를 할당하고 있습니다. 메모리 누수 의심 지점입니다.

Flamegraph 생성:

# Flamegraph 시각화
go tool pprof -http=:8080 heap.prof

브라우저에서 http://localhost:8080/ui/flamegraph로 접속하면 시각화된 데이터를 볼 수 있습니다.

Goroutine Leak 감지

Goroutine 프로파일 수집:

# Goroutine 프로파일 다운로드
curl http://localhost:6060/debug/pprof/goroutine > goroutine.prof

# pprof 분석
go tool pprof goroutine.prof

# (pprof) top
# Showing nodes accounting for 50000 goroutines, 100% of 50000 total
#      flat  flat%   sum%        cum   cum%
#     50000 100.00% 100.00%    50000 100.00%  main.processData.func1

50,000개 Goroutine이 processData.func1에서 대기 중입니다!

상세 분석:

# (pprof) list processData
# Total: 50000 goroutines
# 50000 func processData() {
#     ch := make(chan int)
#     go func() {
#         data := <-ch  ← 50,000개 모두 여기서 대기!
#         fmt.Println(data)
#     }()
# }

수정:

// 채널에 타임아웃 추가
func processDataSafe() {
  ch := make(chan int)
  done := make(chan struct{})

  go func() {
    select {
    case data := <-ch:
      fmt.Println(data)
    case <-time.After(5 * time.Second): // ← 5초 타임아웃
      fmt.Println("Timeout!")
      return // Goroutine 종료
    case <-done:
      return // 명시적 종료
    }
  }()

  // 5초 후 done 채널로 종료 신호
  time.AfterFunc(5*time.Second, func() {
    close(done)
  })
}

Rust Memory Profiling

Bytehound (고성능 메모리 프로파일러)

Bytehound 특징:

  • Rust로 작성된 초고속 프로파일러
  • 프로덕션 환경에 최소 영향 (스트리밍 방식)
  • 실시간 데이터 수집

설치 및 사용:

# Bytehound 설치
cargo install --git https://github.com/koute/bytehound

# Rust 애플리케이션 프로파일링
bytehound ./target/release/myapp

DHAT (Dynamic Heap Analysis Tool)

DHAT 특징:

  • Valgrind 기반 도구
  • 메모리 사용 패턴 분석
  • 비효율적인 할당 식별

사용:

# Valgrind DHAT 실행
valgrind --tool=dhat ./target/release/myapp

DHAT 리포트 예시:

Total bytes allocated: 10,240,000,000 (10GB!)
Total allocations: 1,000,000
Peak memory usage: 8,192,000,000 (8GB)

Top allocators:
1. alloc_vec (7GB) - Vec<String> in process_data()

Continuous Profiling (2025년 표준)

Continuous Profiling이란?

정의: 프로덕션 환경에서 항상 켜져 있는 프로파일링 (24/7 수집)

기존 vs Continuous:

  • 기존 프로파일링: 문제 발생 → 프로파일링 활성화 → 데이터 수집 (이미 늦음)
  • Continuous Profiling: 항상 프로파일링 중 → 문제 발생 → 과거 데이터 즉시 조회 및 비교

Pyroscope (Continuous Profiling Platform)

Go 애플리케이션 통합:

package main

import (
  "github.com/pyroscope-io/client/pyroscope"
)

func main() {
  pyroscope.Start(pyroscope.Config{
    ApplicationName: "myapp",
    ServerAddress:   "http://localhost:4040",
    Logger:          pyroscope.StandardLogger,
    ProfileTypes: []pyroscope.ProfileType{
      pyroscope.ProfileAllocObjects, // Heap 할당
      pyroscope.ProfileInuseSpace,   // 사용 중 메모리
    },
  })

  runApp()
}

메모리 누수 감지: Pyroscope UI에서 inuse_space 그래프가 톱니 모양이 아닌 우상향 직선을 그린다면 누수입니다.

실전 디버깅 사례

사례 1: Go Goroutine Leak (50,000개 누수)

증상:

  • 메모리 사용량: 시간당 500MB 증가
  • 6시간 후: 10GB 도달 → OOMKilled

진단: pprof goroutine 프로파일을 통해 특정 함수에서 50,000개의 Goroutine이 대기 중임을 확인했습니다.

원인: HTTP 핸들러 내에서 생성된 Goroutine이 채널 수신 대기 상태에서 영원히 멈춰 있었습니다.

수정: context.WithTimeout을 사용하여 Goroutine에 종료 조건을 추가했습니다.

사례 2: Rust Vec 무한 증가

증상:

  • 메모리 사용량: 시간당 1GB 증가
  • 12시간 후: 12GB 도달 → OOMKilled

진단: Bytehound 프로파일링을 통해 Vec::push가 지속적으로 호출되지만 해제되지 않음을 확인했습니다.

원인: 전역 Vec 캐시에 데이터를 계속 추가만 하고 제거하는 로직이 없었습니다.

수정: LruCache를 도입하여 캐시 크기를 제한했습니다.

프로덕션 체크리스트

Go 메모리 누수 방지

  1. Goroutine 종료 조건: Context나 타임아웃을 반드시 사용하세요.
  2. 채널 관리: 버퍼를 사용하거나 타임아웃을 설정하세요.
  3. 리소스 해제: defer를 적극 활용하여 파일이나 네트워크 연결을 닫으세요.
  4. 전역 변수: 크기 제한 없는 전역 슬라이스나 맵 사용을 피하세요.

Rust 메모리 누수 방지

  1. 순환 참조: Weak 포인터를 사용하여 순환을 끊으세요.
  2. Box::leak: 정말 필요한 경우에만 제한적으로 사용하세요.
  3. Drop trait: 리소스 해제가 필요한 경우 Drop을 구현하세요.

마치며

Memory Leak은 프로덕션 서비스의 67%가 경험하는 가장 흔한 문제입니다.

핵심 요약:

  1. Go Goroutine Leak: Context + select로 반드시 종료 조건 추가
  2. Rust 순환 참조: Weak 포인터로 순환 끊기
  3. pprof (Go): heap, goroutine 프로파일로 누수 지점 식별
  4. Bytehound (Rust): 프로덕션 영향 최소화 + 실시간 프로파일링
  5. Continuous Profiling: Pyroscope로 24/7 모니터링

50,000개 Goroutine 누수는 단 3줄의 코드 실수로 발생했지만, Context + select 패턴 하나로 완전히 방지할 수 있습니다. 지금 바로 프로덕션 메모리 사용량을 점검하고, 이상 징후가 보이면 즉시 프로파일링을 시작하세요!

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트