메모리 47GB 찍고 멈춘 서버, 재시작 없이 5만 개 고루틴 누수 잡고 살아남는 법

메모리 47GB 찍고 멈춘 서버, 재시작 없이 5만 개 고루틴 누수 잡고 살아남는 법

최수연·2026년 2월 1일·3

토요일 새벽 3시, 메모리 47GB를 점유하며 멈춰버린 서버. 재시작 없이 라이브 상태에서 5만 개의 고루틴 누수를 추적하고 해결한 긴박한 디버깅 기록을 공유합니다.

토요일 새벽 3시, 휴대폰 진동이 울리면 등골이 서늘해집니다.
메인 API 서비스 상태는 처참했습니다. 메모리 사용량 47GB, 응답 시간 32초, 고루틴(Goroutine) 50,847개.

온콜 담당자는 "일단 재시작할까요?"라고 묻더군요. 전형적인 '감'으로 일하는 방식입니다. 재시작하면 당장은 살겠죠. 하지만 6주 뒤 똑같은 시간에 또 불려 나올 겁니다. 이건 단순한 메모리 누수가 아니라, 코드 깊숙한 곳에 박힌 고루틴 누수(Goroutine Leak)였기 때문입니다.

결국 우리는 서버를 끄지 않고 라이브 상태에서 원인을 찾아냈습니다. 이 글은 프로덕션 환경을 죽일 뻔했던 5만 개의 고루틴을 추적하고 해결한 과정에 대한 기록입니다. 막연히 "Go는 가볍잖아"라고 방심했다가 큰코다친 경험, 공유합니다.

모두가 무시한 '조용한 킬러'의 징후

사건은 갑자기 터진 게 아니었습니다. 데이터는 이미 경고를 보내고 있었습니다. 다만 우리가 '괜찮겠지'라는 안일함으로 무시했을 뿐입니다.

주차별 변화 추이
- 1주 차: 1,200 고루틴, 2.1GB RAM (p99 대기시간 250ms)
- 3주 차: 8,900 고루틴, 7.2GB RAM (p99 대기시간 610ms)
- 6주 차: 50,847 고루틴, 47GB RAM (p99 대기시간 32s)

개발자는 "API가 좀 굼뜬 것 같아요"라고 했고, QA는 "타임아웃이 조금 늘었네요"라고 했습니다. DevOps는 "메모리가 늘고 있지만 허용 범위 내입니다"라고 리포팅했죠. 각자 파편만 보고 전체 그림을 보지 못한 결과입니다. 지수함수적으로 증가하는 그래프를 보고도 "내 문제는 아니겠지"라고 생각한 대가는 혹독했습니다.

겉보기엔 완벽했던 코드 (Code Blindness)

문제의 원인은 WebSocket 알림 시스템에 있었습니다. 코드를 열어봤을 때, 시니어 개발자 3명이 리뷰를 통과시킨 이유를 알 것 같았습니다. 논리적으로는 완벽해 보였거든요.

func (s *NotificationService) Subscribe(...) {
    ctx, cancel := context.WithCancel(context.Background())
    sub := &subscription{ ... cancel: cancel }
    
    // 메시지 처리와 하트비트 시작
    go s.pumpMessages(ctx, sub)
    go s.heartbeat(ctx, sub)
}

context.WithCancel을 썼고, 고루틴을 분리했습니다. 아주 모범적인 Go 코드처럼 보입니다. 하지만 이 코드에는 세 가지 치명적인 결함이 숨어 있었습니다.

범인은 디테일에 있었다: 3가지 치명적 버그

우리는 Uber에서 사용하는 LeakProf와 유사한 방식, 그리고 pprof를 통해 디버깅을 시작했습니다. WebSocket 연결이 끊겨도 고루틴이 살아있는 것을 확인했습니다. 도대체 왜 안 죽었을까요?

1. 아무도 Cancel을 호출하지 않음 (The Orphan Context)
ctx, cancel := context.WithCancel(...)cancel 함수를 만들어 구독 객체(sub)에 저장했습니다. 그런데 정작 WebSocket 연결이 끊어졌을 때 이 cancel()을 호출하는 로직이 빠져 있었습니다.
고루틴들은 취소 신호가 오기만을 기다리며 영원히 대기 상태로 남았습니다. 좀비가 된 겁니다.

2. 멈추지 않는 심장 (Ticker Leak)
하트비트 함수 내부를 보니 time.NewTicker(30 * time.Second)를 사용하고 있었습니다.
문제는 ticker.Stop()이 없었다는 점입니다. Ticker는 전역 타이머 힙을 사용합니다. 5만 개의 멈추지 않는 타이머가 런타임을 괴롭히고 있었던 겁니다.

3. 닫히지 않는 채널 (Unclosed Channel)
구독자에게 메시지를 보내는 채널(sub.messages)을 아무도 닫지 않았습니다. 쓰는 쪽에서는 계속 데이터를 밀어 넣고, 읽는 쪽(연결 끊김)은 사라졌으니 메모리 큐는 폭발할 수밖에 없습니다.

재시작 없이 원인을 찾는 3단계 루틴

활성 사용자가 너무 많아 무턱대고 재시작할 수 없었습니다. 우리는 라이브 디버깅을 택했습니다.

STEP 1: 고루틴 덤프 확보
curl http://api-server:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
이 명령어 하나면 현재 서버의 모든 고루틴 스택을 텍스트로 볼 수 있습니다.

STEP 2: 패턴 분석
덤프 파일을 열어보니 NotificationService.pumpMessagesheartbeat 함수가 각각 25,423개씩 실행 중이었습니다. 도합 5만 개.

STEP 3: 실제 연결과 비교
긴급하게 진단 엔드포인트를 하나 뚫어서 실제 활성 WebSocket 연결 수를 확인했습니다.
- 실제 연결: 약 1,000개
- 실행 중인 구독 고루틴: 약 51,000개
- 결론: 약 5만 개의 고루틴이 '유령' 상태.

액션 아이템: 살고 싶다면 기본을 지켜라

이 사건 이후 우리 팀은 다음과 같은 원칙을 세웠습니다. 솔직히 말해, 이건 선택이 아니라 생존 문제입니다.

Context의 주인이 되어라
고루틴을 생성할 때는 반드시 언제, 어떻게 종료될지 명확한 시나리오가 있어야 합니다. defer cancel()을 습관처럼 사용하세요.

Ticker는 반드시 멈춰라
time.NewTicker를 썼다면 defer ticker.Stop()을 짝꿍처럼 붙이세요. Go 런타임이 알아서 해주지 않습니다.

프로덕션 모니터링 대시보드 수정
단순 메모리 사용량만 보지 마세요. runtime.NumGoroutine() 지표를 대시보드 최상단에 올리세요. 고루틴 개수가 사용자 수와 비례하지 않고 혼자 치솟는다면, 100% 누수입니다.

마치며

"나중에 고치지 뭐", "재시작하면 되겠지"라는 생각은 버리십시오. 엔지니어링에 요행은 없습니다. 데이터는 거짓말을 하지 않습니다. 여러분의 서비스가 이유 없이 느려지고 메모리를 잡아먹는다면, '감'을 믿지 말고 pprof를 켜십시오.

5만 개의 유령 고루틴이 당신의 서버를 노리고 있을지도 모릅니다. 지금 당장 대시보드를 확인하세요.

최수연
최수연핀테크 유니콘 리드 PO

전통 금융의 보수적인 장벽을 부수고, 숫자로 증명되지 않는 직관을 가장 경계하는 12년차 프로덕트 오너입니다. '아름다운 기획서'보다 '지저분한 엑셀 데이터'에서 고객의 욕망을 읽어내며, 치열한 핀테크 전쟁터에서 생존한 실전 인사이트를 기록합니다.

최수연님의 다른 글

댓글 0

첫 번째 댓글을 남겨보세요!