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

Ruby 마스터하기: 메모리 누수를 효율적으로 감지하고 수정하는 방법

메모리 누수(memory leak)란 의도치 않게, 통제 없이, 끝없이 메모리 사용량이 증가하는 현상을 말합니다. 아무리 작은 누수라도 결국에는 프로세스의 메모리가 고갈되어 크래시(crash)로 이어집니다. 주기적으로 앱을 재시작하며 이런 크래시를 피한다고 해도(비난할 생각은 없습니다, 저도 해봤으니까요!), 여전히 메모리 누수로 인한 성능 저하는 감수해야 합니다.

이 글은 메모리 누수 시리즈 두 편 중 첫 번째 글로, Ruby가 메모리를 어떻게 관리하는지, 가비지 컬렉션(GC)이 어떻게 동작하는지, 그리고 누수를 어떻게 찾아내는지 살펴보겠습니다.

두 번째 글에서는 누수의 근본 원인을 추적하는 더 깊은 내용을 다룰 예정입니다.

그럼 시작해 보겠습니다!

Ruby의 메모리 관리

Ruby 객체는 힙(heap)에 저장되며, 각 객체는 힙 위의 하나의 슬롯(slot)을 차지합니다.

Ruby 3.1 이전에는 힙의 모든 슬롯이 동일한 크기였습니다. 정확히는 40바이트였죠. 슬롯에 들어가기에는 너무 큰 객체는 힙 외부에 저장되었고, 각 슬롯에는 해당 객체가 옮겨진 위치에 대한 참조가 포함되어 있었습니다.

Ruby 3.1에서는 String 객체를 위한 가변 폭 할당(variable width allocation)이 병합되었습니다. 조만간 모든 객체 타입에 대해 가변 폭 할당이 표준이 될 것입니다.

가변 폭 할당은 캐시 지역성(cache locality)을 개선하여 성능 향상을 목표로 합니다. 즉, 객체의 모든 정보가 두 개의 메모리 위치에 분산되는 대신 한 곳에 저장되는 것이죠.

또한 메모리 관리의 일부 측면도 단순화됩니다. 현재는 두 개의 '힙'이 존재합니다:

  • 작은 Ruby 객체를 저장하는 Ruby 힙(GC 힙)
  • 더 큰 객체를 저장하는 C 힙(malloc/트랜지언트 힙)

가변 폭 할당이 표준이 되면 후자의 힙은 더 이상 필요하지 않게 됩니다.

힙은 지정된 크기(기본값 10,000 슬롯)로 시작하며, 객체가 생성될 때 빈 슬롯에 할당됩니다. Ruby가 객체를 생성하려는데 빈 슬롯이 없으면, 빈 슬롯을 확보하기 위해 가비지 컬렉션(GC)이 발생합니다.

GC 이후에도 빈 슬롯이 너무 적다면 힙이 확장됩니다(이에 대해서는 잠시 후 자세히 다루겠습니다).

다음은 제어할 수 있는 요소들과 해당 환경 변수입니다:

  • 힙의 초기 크기 - RUBY_GC_HEAP_INIT_SLOTS
  • GC 발생 후 확보되어야 하는 빈 슬롯 수 - RUBY_GC_HEAP_FREE_SLOTS
  • 힙이 확장되는 비율 - RUBY_GC_HEAP_GROWTH_FACTOR

Ruby의 가비지 컬렉션

Ruby의 가비지 컬렉션은 '세상을 멈춥니다(stop the world)' — GC가 실행되는 동안에는 다른 어떤 작업도 처리되지 않습니다. 또한 Ruby의 가비지 컬렉션은(2.1부터) 세대별(generational) 방식으로, 가비지 컬렉터가 두 가지 모드를 가집니다:

  • 마이너 GC(Minor GC) - '젊은(young)' 객체(최근에 생성된 객체)를 검사합니다.
  • 메이저 GC(Major GC) - '젊은' 객체뿐 아니라 '늙은(old)' 객체까지 검사합니다(모든 객체).

참고: '늙은' 객체란 메이저든 마이너든 3번의 GC를 생존한 객체를 의미합니다.

힙이 가득 차면 먼저 마이너 GC가 호출됩니다. 한계치 아래로 충분한 슬롯을 확보하지 못하면 메이저 GC가 호출됩니다. 그래도 여전히 빈 슬롯이 부족할 때에야 힙이 확장됩니다.

메이저 GC는 더 많은 객체를 검사하기 때문에 마이너 GC보다 비용이 큽니다.

세대별 GC가 더 높은 성능을 내는 이론적 배경은 객체가 대체로 두 범주로 나뉜다는 점입니다:

  1. 할당된 후 빠르게 스코프를 벗어나는 객체. Rails 앱에서 페이지를 렌더링하기 위해 DB에서 가져온 모델들은 요청이 끝나면 스코프를 벗어납니다.
  2. 할당된 후 오랫동안 유지되는 객체. 클래스와 캐시는 앱의 전체 수명 동안 계속 사용될 가능성이 높습니다.

빈 슬롯이 충분하더라도, 늙은 객체의 수가 특정 임계값을 초과하면 마이너 GC 이후 메이저 GC가 추가로 실행됩니다. 이 한계치는 힙 크기가 커짐에 따라 증가하며, RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR 환경 변수로 조절할 수 있습니다.

메모리 누수가 발생하면 정리할 수 없는 객체, 즉 점점 더 많은 늙은 객체가 생성됩니다. 이는 (비용이 큰) 메이저 GC가 원래보다 훨씬 자주 실행된다는 뜻입니다. GC가 실행되는 동안에는 다른 작업이 전혀 처리되지 않으므로, 그만큼 시간이 낭비되는 셈입니다.

Ruby의 메모리 레이아웃과 가비지 컬렉터에 대해 더 읽어볼 수 있는 링크를 글 말미에 남겨두었습니다.

Ruby에서 메모리 누수는 어떤 모습일까?

Unix 시스템이라면 어디서나 사용할 수 있는 간단한 도구로 메모리 누수를 확인할 수 있습니다. 다음 코드를 예로 들어 보겠습니다.


이 코드를 '누수'라고 표현하는 건 좀 억울할 수 있습니다 — 애초에 하는 일이 누수뿐이니까요! — 하지만 우리 목적에는 충분합니다.

한 터미널에서 이 프로그램을 실행하고, 다른 터미널에서 pswatch를 사용해 시간에 따른 메모리 증가를 관찰하면 누수를 아주 간단히 확인할 수 있습니다.


pgrep -f "ruby ./leaky.rb"는 프로세스 ID를 찾아주므로, ps 출력을 우리가 관심 있는 프로세스로만 제한할 수 있습니다. 짐작하셨겠지만, 이는 프로세스용 grep 같은 도구입니다.

watch 도구는 주어진 명령의 출력을 주기적으로 폴링(polling)하여 화면에 갱신해 주므로, 터미널 안에서 실시간 대시보드처럼 활용할 수 있습니다.

몇 초마다 갱신되는 출력은 대략 다음과 같습니다.


%MEMRSS 값이 계속 증가하는 것을 볼 수 있을 겁니다. 각각의 의미는 다음과 같습니다:

  • %MEM - 호스트 머신 전체 메모리 대비 해당 프로세스가 사용하는 메모리의 백분율입니다.
  • RSS(resident set size, 상주 집합 크기) - 해당 프로세스가 사용하는 RAM의 양(바이트)입니다.

이 기본적인 OS 수준의 정보만으로도 누수 여부를 파악하기에 충분합니다 — 메모리가 계속 올라간다면 누수가 있다는 뜻입니다!

GC 모듈로 Ruby 누수 찾기

Ruby 코드 자체에서도 GC 모듈을 통해 누수를 감지할 수 있습니다.


GC.stat 메서드는 유용한 정보가 가득한 해시(hash)를 반환합니다. 여기서 우리가 주목할 것은 :heap_live_slots, 즉 힙에서 사용 중인 슬롯의 수입니다. 이는 :heap_free_slots의 반대 개념이죠. 루프가 끝나면 강제로 메이저 GC를 실행한 뒤 사용 중인 슬롯 수, 즉 GC 이후에도 남아 있는 객체의 수를 출력합니다.

이 작은 프로그램을 실행하면 이 값이 무한히 증가하는 것을 볼 수 있습니다. 누수가 있다는 뜻이죠! 같은 효과를 위해 GC.stat(:old_objects)를 사용할 수도 있습니다.

GC 모듈로 누수 여부와 (puts 문을 잘 활용하면) 누수가 발생하는 위치까지 파악할 수 있지만, ObjectSpace 모듈을 사용하면 어떤 타입의 객체가 누수되고 있는지 확인할 수 있습니다.


ObjectSpace.count_objects 메서드는 살아 있는 객체들의 개수를 담은 해시를 반환합니다. 예를 들어 T_STRING은 메모리에 살아 있는 문자열의 수입니다. 누수가 심한 우리 프로그램에서는 이 값이 GC 이후에도 루프를 돌 때마다 증가합니다. 즉, 문자열 객체가 누수되고 있음을 알 수 있습니다.

AppSignal로 운영 환경 애플리케이션 성능 모니터링하기

psGC를 가지고 노는 것은 장난감 프로젝트에는 합리적인 방법이고(사실 재미있고 배우는 것도 많습니다!), 프로덕션 앱의 메모리 누수 감지 솔루션으로는 권장하지 않습니다.

이럴 때 필요한 것이 바로 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링) 도구입니다. 규모가 매우 큰 회사라면 직접 구축할 수도 있지만, 규모가 작은 조직이라면 검증된 APM 제품을 도입하는 것이 좋습니다. 월 구독료를 지불해야 하지만, 제공되는 정보의 가치는 그 이상입니다.

메모리 누수를 감지하려면 서버나 프로세스의 메모리 사용량(종종 RSS라고 불립니다) 그래프를 시간 흐름에 따라 확인해야 합니다. 다음은 AppSignal의 '프로세스 메모리 사용량' 대시보드에서 볼 수 있는, 배포 직후 건강한 앱의 예시 스크린샷입니다:

Ruby 마스터하기: 메모리 누수를 효율적으로 감지하고 수정하는 방법

그리고 배포 후 문제가 있는 앱의 모습입니다:

Ruby 마스터하기: 메모리 누수를 효율적으로 감지하고 수정하는 방법

AppSignal은 GC와 힙 슬롯 같은 Ruby VM 통계까지 보여주므로, 메모리 누수에 대한 더욱 명확한 신호를 얻을 수 있습니다. 살아 있는 슬롯 수가 계속 증가한다면 누수가 있다는 뜻입니다!

Ruby 마스터하기: 메모리 누수를 효율적으로 감지하고 수정하는 방법

Ruby를 위한 AppSignal에 대해 더 자세히 알아보세요.

마무리 및 더 읽을거리

이번 글에서는 Ruby의 메모리 관리와 가비지 컬렉터를 간단히 둘러보았습니다. 그리고 Unix 도구와 Ruby의 GC 모듈을 사용해 메모리 누수를 발견하는 방법을 진단해 보았습니다.

다음 글에서는 memory_profilerderailed_benchmarks를 사용해 누수를 찾고 수정하는 방법을 알아보겠습니다.

그동안 이 글에서 사용한 도구들을 더 읽어보세요:

  • watch
  • ps
  • pgrep

추가로 읽으면 좋은 자료들:

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

즐거운 코딩 되시고, 다음에 만나요!

P.S. Ruby Magic 글이 발행되는 대로 바로 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!