Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 메모리 누수 이해와 해결: 완벽 가이드

메모리 누수를 다루는 두 부분으로 구성된 시리즈의 첫 번째 글에서는 Ruby가 메모리를 관리하는 방식과 가비지 컬렉션(GC)의 동작 원리를 살펴봤습니다.

충분한 메모리를 갖춘 고성능 서버를 사용할 수 있고, 앱이 자주 재시작되어 사용자가 눈치채지 못할 수도 있습니다. 그래도 메모리 사용량은 중요한 문제입니다.

메모리 할당과 가비지 컬렉션은 공짜가 아닙니다. 누수가 발생하면 앱이 원래 해야 할 일 대신 점점 더 많은 시간을 가비지 컬렉션에 소비하게 됩니다.

이번 글에서는 메모리 누수를 발견하고 진단하는 데 활용할 수 있는 도구들을 더 깊이 살펴보겠습니다.

그럼 시작해 보겠습니다!

Ruby에서 누수 찾기

누수 감지 자체는 어렵지 않습니다. GC, ObjectSpace, 그리고 APM 도구의 RSS 그래프를 통해 메모리 사용량이 계속 증가하는지 확인하면 됩니다. 하지만 누수가 있다는 사실만으로는 문제를 해결할 수 없습니다. 누수가 어디서 발생하는지 알아야 합니다. 숫자만으로는 그 원인을 파악할 수 없습니다.

다행히도 Ruby 생태계에는 이러한 수치에 맥락을 더해주는 훌륭한 도구들이 있습니다. 대표적인 것이 memory-profilerderailed_benchmarks입니다.

memory_profiler

memory_profiler 젬은 매우 간단한 API와 함께 할당된 메모리와 유지되는(retained) 메모리에 대한 상세한 리포트를 제공합니다. 리포트에는 할당된 객체의 클래스, 크기, 할당 위치까지 포함됩니다(다소 방대해서 압도적으로 느껴질 수 있지만요). 누수가 있는 프로그램에 손쉽게 추가할 수 있습니다.


실행하면 다음과 같은 형태의 리포트가 출력됩니다.


여기에는 많은 정보가 담겨 있지만, 일반적으로 누수를 찾을 때 가장 유용한 섹션은 allocated objects by locationretained objects by location입니다. 이는 객체를 할당한 파일 위치를 할당된 객체 수 기준으로 정렬해 보여줍니다.

  • allocated 객체는 report 블록 내에서 할당(생성)된 모든 객체입니다.
  • retained 객체는 report 블록이 끝날 때까지 가비지 컬렉션되지 않은 객체입니다. 블록이 끝나기 전에 강제로 GC를 실행해 누수된 객체를 더 명확하게 볼 수 있도록 했습니다.

단, retained 객체 수를 맹신하는 것은 주의해야 합니다. 이 수치는 누수 코드의 어느 부분이 report 블록 안에 포함되어 있는지에 따라 크게 달라지기 때문입니다.

예를 들어 an_array 선언을 report 블록 안으로 옮기면, 실제로는 누수가 있는데도 코드가 정상처럼 보일 수 있습니다.


이 경우 생성된 리포트 상단에는 유지되는 객체가 거의 나타나지 않습니다(리포트 자체만 표시됨).


derailed_benchmarks

derailed_benchmarks 젬은 다양한 성능 작업에 유용한 도구 모음으로, 주로 Rails 앱을 대상으로 합니다. 누수를 찾을 때는 perf:mem_over_time, perf:objects, perf:heap_diff 세 가지 태스크가 특히 유용합니다.

이 태스크들은 실행 중인 앱에 curl 요청을 보내는 방식으로 동작하기 때문에, 앞서 만든 간단한 누수 프로그램에는 적용할 수 없습니다. 대신 요청마다 메모리를 누수시키는 엔드포인트를 가진 작은 Rails 앱을 만들고, 여기에 derailed_benchmarks를 설치해야 합니다.



이제 bin/rails s로 앱을 실행할 수 있으며, 요청마다 메모리를 누수시키는 엔드포인트를 curl로 호출해볼 수 있습니다.


이제 derailed_benchmarks로 실제 누수 현상을 확인해 보겠습니다.

perf:mem_over_time

이 태스크는 시간에 따른 메모리 사용량을 보여줍니다(앞서 watchps로 누수 스크립트의 메모리 증가를 관찰한 것과 비슷합니다).

Derailed는 프로덕션 모드로 앱을 부팅한 뒤 엔드포인트(기본값은 /)를 반복적으로 호출하며 메모리 사용량을 기록합니다. 메모리가 계속 증가한다면 누수가 있는 것입니다!


참고: Derailed는 테스트를 위해 Rails 앱을 프로덕션 모드로 부팅하며, 기본적으로 먼저 require rails/all을 실행합니다. 이 예제 앱에는 데이터베이스가 없으므로 DERAILED_SKIP_ACTIVE_RECORD=true로 해당 동작을 비활성화해야 합니다.

여러 엔드포인트에 대해 이 벤치마크를 실행하면 어떤 엔드포인트가 누수를 일으키는지(있다면) 파악할 수 있습니다.

perf:objects

perf:objects 태스크는 내부적으로 memory_profiler를 사용하기 때문에 출력되는 리포트가 익숙하게 느껴질 겁니다.


이 리포트는 누수된 메모리가 어디에서 할당되었는지 좁혀 나가는 데 도움이 됩니다. 예제에서는 리포트 마지막 섹션인 Retained String Report가 문제의 원인을 정확히 알려줍니다.


LeaksController 3번째 줄에서 "ABC"를 담은 문자열 10,000개가 누수되었습니다. 실제 규모 있는 앱이라면 이 리포트는 훨씬 방대해지며, 의도적으로 유지해야 하는 문자열들(쿼리 캐시 등)도 함께 나타납니다. 그래도 이 섹션과 다른 'by location' 섹션들은 누수 지점을 좁혀 나가는 데 큰 도움이 될 것입니다.

perf:heap_diff

perf:objects의 리포트가 너무 복잡해서 누수 원인을 파악하기 어렵다면, perf:heap_diff 벤치마크가 유용합니다.

이름에서 알 수 있듯이 perf:heap_diff는 세 개의 힙 덤프를 생성하고 그 차이를 계산합니다. 덤프 사이에 유지된 객체의 타입과 할당 위치를 포함한 리포트를 만들어 줍니다.


자세한 내용이 궁금하다면 Tracking a Ruby memory leak in 2021 아티클도 참고해 보세요.

리포트는 우리가 찾아야 할 지점을 정확히 짚어줍니다. diff 최상단에서 LeaksController 3번째 줄에서 할당된 999,991개의 유지된 문자열 객체를 확인할 수 있습니다.

실제 Ruby·Rails 앱에서의 누수

지금까지 살펴본 예제들이 실제 앱에 적용되는 일은 없기를 바랍니다. 누구도 의도적으로 메모리를 누수시키려 하진 않으니까요!

규모 있는 실제 앱에서는 메모리 누수를 추적하는 것이 훨씬 어렵습니다. 유지되는 객체가 반드시 나쁜 것만은 아닙니다. 캐시된 항목이 가비지 컬렉션된다면 그 캐시는 존재 의미가 없겠죠.

하지만 모든 누수에는 공통점이 있습니다. 어딘가에서 루트 레벨 객체(클래스, 전역 변수 등)가 어떤 객체에 대한 참조를 유지하고 있는 것입니다.

대표적인 예가 제한이나 제거(eviction) 정책 없이 운영되는 캐시입니다. 정의상 캐시에 넣은 모든 객체가 영원히 남기 때문에 이는 곧 메모리 누수입니다. 시간이 지나면 캐시는 앱 메모리에서 차지하는 비중이 점점 커지는데, 실제로 사용되는 비율은 점점 작아집니다.

게임의 하이 스코어를 조회하는 다음 코드를 살펴보세요. 과거에 실제로 본 적 있는 패턴과 유사합니다. 이 조회는 비용이 크고, 값이 변경될 때 캐시를 무효화하기 쉬우므로 캐싱하고 싶은 상황입니다.


@scores 해시는 전혀 통제되지 않습니다. 모든 사용자의 모든 하이 스코어를 담기 위해 무한정 커지게 됩니다. 사용자나 스코어가 많다면 결코 이상적이지 않죠.

Rails 앱이라면 적절한 만료 정책을 가진 Rails.cache를 사용하는 편이 좋습니다(Redis에서 발생하는 누수 역시 누수입니다!).

Rails가 아닌 앱이라면 해시 크기를 제한하고, 가장 오래되었거나 가장 오랫동안 사용되지 않은 항목부터 제거하는 방식을 고려하세요. LruRedux가 좋은 구현체입니다.

더 미묘한 형태의 누수도 있습니다. 크기 제한은 있지만 키 자체의 크기가 임의적인 캐시입니다. 키가 커지면 캐시 역시 함께 커집니다. 흔한 상황은 아니지만, 객체를 JSON으로 직렬화해 키로 사용한다면 사용량에 따라 함께 커지는 데이터(예: 사용자가 읽은 메시지 목록)까지 직렬화하고 있지 않은지 반드시 확인하세요.

순환 참조(Circular References)

순환 참조는 가비지 컬렉션될 수 있습니다. Ruby의 가비지 컬렉션은 "Mark and Sweep" 알고리즘을 사용합니다. Peter Zhu와 Matt Valentine-House가 가변 폭 할당(variable width allocation)을 소개하는 발표에서 이 알고리즘의 동작 방식을 훌륭하게 설명했습니다.

핵심은 마킹(marking)과 스위핑(sweeping)의 두 단계로 이루어져 있다는 점입니다.

  • 마킹 단계에서 가비지 컬렉터는 루트 객체(클래스, 전역 변수 등)에서 시작해 이를 마킹하고, 참조하는 객체들을 살펴봅니다.

    그런 다음 참조된 모든 객체를 마킹합니다. 이미 마킹된 객체는 다시 확인하지 않습니다. 더 이상 확인할 객체가 없을 때, 즉 모든 참조 객체가 마킹될 때까지 이 과정이 반복됩니다.

  • 이후 가비지 컬렉터는 스위핑 단계로 넘어갑니다. 마킹되지 않은 객체는 모두 정리됩니다.

따라서 살아있는 참조를 가진 객체도 정리될 수 있습니다. 루트 객체가 결국 해당 객체를 참조하지 않으면 수거 대상이 되는 것입니다. 이런 방식으로 순환 참조를 이루는 객체 묶음도 여전히 가비지 컬렉션될 수 있습니다.

APM: 이벤트 타임라인과 할당 객체 그래프

시리즈 첫 번째 글에서 언급했듯이, 프로덕션 수준의 앱이라면 어떤 형태로든 APM(Application Performance Monitoring)을 사용해야 합니다.

선택지는 다양합니다. 직접 구축하는 방법도 있지만(큰 팀에게만 권장됩니다). APM에서 꼭 확보해야 할 핵심 기능 중 하나는 액션(또는 백그라운드 잡)이 수행하는 할당 수를 확인할 수 있는 기능입니다. 좋은 APM 도구는 이를 세분화해 할당이 어디에서 발생했는지(컨트롤러, 뷰 등) 인사이트를 제공합니다.

이를 흔히 '이벤트 타임라인'이라고 부릅니다. APM이 커스텀 코드를 통해 타임라인을 더 세분화할 수 있다면 금상첨화입니다.

다음 Rails 컨트롤러 코드를 살펴보세요.


APM에서 리포팅하면 '이벤트 타임라인'은 AppSignal의 다음 스크린샷과 비슷하게 나타납니다.

Ruby 메모리 누수 이해와 해결: 완벽 가이드

여기에 계측(instrumentation)을 추가하면 타임라인에서 코드의 어떤 부분이 할당을 발생시키는지 확인할 수 있습니다. 실제 앱에서는 코드만 보고 파악하기가 훨씬 어려울 겁니다 😅


AppSignal의 계측된 이벤트 타임라인 예시입니다:

Ruby 메모리 누수 이해와 해결: 완벽 가이드

어디를 계측해야 할지 파악하는 것은 쉽지 않은 경우가 많습니다. 애플리케이션 코드에 대한 깊은 이해를 대체할 수는 없지만, '스멜(smell)'로 삼을 만한 신호들은 있습니다.

APM이 시간에 따른 GC 실행이나 할당 정보를 제공한다면, 급증(spike) 지점이 특정 엔드포인트 호출이나 백그라운드 잡 실행과 일치하는지 확인해 보세요. AppSignal의 Ruby VM 매직 대시보드에서 가져온 또 다른 예시입니다:

Ruby 메모리 누수 이해와 해결: 완벽 가이드

이런 식으로 할당 정보를 분석하면 메모리 문제를 조사할 때 탐색 범위를 크게 좁힐 수 있습니다. memory_profilerderailed_benchmarks 같은 도구를 효율적으로 활용하기도 한결 수월해집니다.

할당 통계와 GC 통계 추적 같은 AppSignal Ruby 젬의 최신 기능에 대한 내용도 읽어보세요.

마치며

이번 글에서는 메모리 누수를 찾고 수정하는 데 도움이 되는 다양한 도구를 살펴봤습니다. memory_profiler, derailed_benchmarks, perf:mem_over_time, perf:objects, perf:heap_diff, 그리고 AppSignal의 이벤트 타임라인과 할당 객체 그래프까지 말이죠.

이 글과 첫 번째 글이 여러분의 Ruby 앱에서 메모리 누수를 진단하고 해결하는 데 도움이 되기를 바랍니다.

사용한 도구에 대해 더 알아보세요:

  • memory_profiler
  • derailed_benchmarks
  • 누수가 있는 Rails 앱 예제

추가 심화 학습 자료:

  • GC 모듈 공식 문서
  • ObjectSpace 모듈 공식 문서
  • Garbage Collection Deep Dive
  • Variable Width Allocation

즐거운 코딩 되세요!

P.S. Ruby Magic의 새 글을 가장 먼저 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!