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

Ruby 가비지 컬렉션(GC) 튜닝 실전 가이드

아래 글은 2017년 Nate Berkopec이 작성한 'Understanding Ruby GC through GC.stat'라는 아티클에 기반한 것으로 밝혀졌습니다. 원작자가 지적하기 전까지 이 글의 일부가 표절되었음을 인지하지 못했습니다. 모든 아티클을 발행 전에 표절 검사 도구로 검수하고 있지만, 이번 사례는 적발되지 않았습니다. 이러한 부주의로 인해 Nate와 독자 여러분께 진심으로 사과드립니다.

Ruby에서 가비지 컬렉션이 어떻게 동작하는지 제대로 이해하는 것은 애플리케이션 성능을 완벽하게 관리하기 위해 반드시 필요합니다.

이 글에서는 Ruby에서 가비지 컬렉션을 구현하고 커스터마이징하는 방법을 자세히 살펴보겠습니다.

그럼 시작해 보겠습니다!

Ruby 가비지 컬렉터 모듈

Ruby의 GC(Garbage Collector) 모듈은 Ruby의 마크 앤 스윕(mark-and-sweep) 가비지 컬렉션 메커니즘에 대한 인터페이스입니다.

GC는 필요할 때 백그라운드에서 자동으로 실행되지만, GC 모듈을 사용하면 필요할 때마다 수동으로 가비지 컬렉션을 호출할 수 있고, 컬렉션 사이클이 어떻게 진행되는지에 대한 정보도 얻을 수 있습니다. 또한 이 모듈은 성능을 조절할 수 있는 몇 가지 파라미터를 제공합니다.

이 모듈에서 가장 많이 사용되는 메서드는 다음과 같습니다.

  • start / garbage_collect: 가비지 컬렉션 사이클을 수동으로 시작합니다.
  • enable / disable: 자동 가비지 컬렉션 사이클을 활성화하거나 비활성화합니다. 작업 성공 여부를 불리언 값으로 반환합니다.
  • stat: GC 모듈의 성능을 설명하는 키-값 목록을 제공합니다. 다음 섹션에서 이 지표들을 자세히 살펴보겠습니다.

Ruby 가비지 컬렉터 파라미터 이해하기

Ruby의 GC가 내부적으로 어떻게 동작하는지 이해하려면 GC 모듈의 지표를 살펴봐야 합니다. 새로 실행한 irb에서 다음 명령어를 입력해 보세요.

puts GC.stat

화면에 다음과 같은 숫자들이 출력되는 것을 볼 수 있습니다.

{
    :count=>12,
    :heap_allocated_pages=>49,
    :heap_sorted_length=>49,
    :heap_allocatable_pages=>0,
    :heap_available_slots=>19975,
    :heap_live_slots=>19099,
    :heap_free_slots=>876,
    :heap_final_slots=>0,
    :heap_marked_slots=>16659,
    :heap_eden_pages=>49,
    :heap_tomb_pages=>0,
    :total_allocated_pages=>49,
    :total_freed_pages=>0,
    :total_allocated_objects=>66358,
    :total_freed_objects=>47259,
    :malloc_increase_bytes=>16216,
    :malloc_increase_bytes_limit=>16777216,
    :minor_gc_count=>10,
    :major_gc_count=>2,
    :remembered_wb_unprotected_objects=>191,
    :remembered_wb_unprotected_objects_limit=>312,
    :old_objects=>16024,
    :old_objects_limit=>23556,
    :oldmalloc_increase_bytes=>158824,
    :oldmalloc_increase_bytes_limit=>16777216
}

이 값들은 런타임에서 가비지 컬렉션이 어떻게 진행되었는지에 대한 모든 정보를 담고 있습니다. 각 숫자가 의미하는 바를 하나씩 자세히 살펴보겠습니다.

GC 카운트(Counts)

먼저 다음 키들부터 설명하겠습니다.

{
    :count=>12,
    #…
    :minor_gc_count=>10,
    :major_gc_count=>2,
}

이것들은 GC 실행 횟수로, 매우 직관적인 정보를 담고 있습니다. minor_gc_countmajor_gc_count는 각 유형의 가비지 컬렉션이 실행된 횟수입니다.

Ruby에는 두 가지 유형의 가비지 컬렉션이 존재합니다.

Minor GC는 새로운 객체, 즉 가비지 컬렉션 사이클을 세 번 이하로 통과한 객체만을 대상으로 컬렉션을 시도하는 방식입니다.

반면 Major GC는 세 번 이상의 가비지 컬렉션 사이클을 통과한 객체를 포함해 모든 객체를 대상으로 컬렉션을 시도합니다. countminor_gc_countmajor_gc_count의 합계입니다.

GC 카운트를 추적하면 여러모로 유용합니다. 특정 잡(job)이나 프로세스가 항상 GC를 유발하는지, 그리고 몇 번이나 유발하는지 파악할 수 있습니다. 멀티스레드 애플리케이션 같은 경우에는 100% 정확하지 않을 수 있지만, 메모리 누수 지점을 찾는 좋은 출발점이 됩니다.

힙 숫자: 슬롯과 페이지

다음은 힙 숫자(heap numbers)라고도 불리는 키들입니다.

{
    # page numbers
    :heap_allocated_pages=>49,
    :heap_sorted_length=>49,
    :heap_allocatable_pages=>0,

    # slots
    :heap_available_slots=>19975,
    :heap_live_slots=>19099,
    :heap_free_slots=>876,
    :heap_final_slots=>0,
    :heap_marked_slots=>16659,

    # Eden and Tomb pages
    :heap_eden_pages=>49,
    :heap_tomb_pages=>0,
}

여기서 말하는 힙은 C 데이터 구조체로, 현재 살아있는 모든 Ruby 객체에 대한 참조를 담고 있습니다. 힙 페이지(page)는 메모리 슬롯들로 구성되며, 각 슬롯에는 하나의 살아있는 Ruby 객체 정보만 포함됩니다.

  • heap_allocated_pages: 현재 할당된 힙 페이지의 수입니다. 이 페이지들은 완전히 비어 있거나, 가득 차 있거나, 부분적으로 채워져 있을 수 있습니다.
  • heap_sorted_length: 힙이 실제로 메모리에서 차지하는 크기로, heap_allocated_pages와는 다릅니다. 이 값은 페이지의 개수(count)가 아니라 페이지들이 이어진 길이(length)이기 때문입니다. 예를 들어 처음에 10개의 페이지를 할당한 뒤 중간의 한 페이지를 해제하면 heap_allocated_pages는 9가 되지만, heap_sorted_length는 여전히 10입니다.
  • heap_allocatable_pages: Ruby가 현재 소유하고 있으며 필요할 때 사용할 수 있는 힙의 개수입니다.

이제 슬롯 관련 지표를 살펴보겠습니다.

  • heap_available_slots: 힙 페이지에서 사용 가능한 슬롯의 총 개수입니다.
  • heap_live_slots: 메모리에 존재하는 살아있는 객체의 수입니다.
  • heap_free_slots: 할당된 힙 페이지 중 비어 있는 슬롯입니다.
  • heap_final_slots: 객체에 파이널라이저(finalizer)가 연결된 슬롯의 개수입니다. 파이널라이저는 객체가 해제될 때 실행되는 Proc으로, OOP의 소멸자(destructor)와 유사한 역할을 합니다.
  • heap_marked_slots: 오래된 객체(3회 이상의 GC 사이클을 통과한 객체)와 라이트 배리어 비보호 객체의 개수입니다.

다음은 tomb_pageseden_pages입니다.

tomb_pages는 살아있는 객체가 하나도 없는 페이지의 개수입니다. 이런 페이지들은 최종적으로 Ruby에 의해 운영체제에 반환됩니다.

반면 eden_pages는 살아있는 객체가 하나 이상 포함된 페이지의 개수로, 운영체제에 반환될 수 없습니다.

애플리케이션에서 메모리 팽창(memory bloat) 문제가 발생한다면 heap_free_slots 지표를 모니터링해 보세요.

여유 슬롯 수가 비정상적으로 많다면(25만 개 이상), 일부 컨트롤러 액션이 한 번에 대량의 객체를 할당했다가 곧바로 해제하고 있다는 신호일 수 있습니다. 이는 실행 중인 Ruby 프로세스의 크기를 영구적으로 팽창시킬 수 있습니다.

누적 수치(Cumulative Numbers)

{
    :total_allocated_pages=>49,
    :total_freed_pages=>0,
    :total_allocated_objects=>66358,
    :total_freed_objects=>47259,
}

이 숫자들은 프로세스의 전체 생명 주기 동안 누적되는 값입니다. GC가 이 값을 초기화하지 않으며, 기술적으로 감소할 수도 없습니다. 네 가지 값 모두 이름 그대로의 의미를 가집니다.

가비지 컬렉션 임계값(Thresholds)

이 숫자들을 이해하려면 먼저 GC가 언제 트리거되는지 알아야 합니다.

{
    :malloc_increase_bytes=>16216,
    :malloc_increase_bytes_limit=>16777216,
    :remembered_wb_unprotected_objects=>191,
    :remembered_wb_unprotected_objects_limit=>312,
    :old_objects=>16024,
    :old_objects_limit=>23556,
    :oldmalloc_increase_bytes=>158824,
    :oldmalloc_increase_bytes_limit=>16777216
}

GC가 고정된 간격으로 실행된다는 흔한 오해와 달리, GC는 Ruby가 메모리 공간이 부족해지기 시작할 때 트리거됩니다. Minor GC는 Ruby의 free_slots가 부족해질 때 발생합니다.

Minor GC 실행 후에도 free_slots가 부족하거나, oldmalloc·malloc·오래된 객체 수·shady(라이트 배리어 비보호) 객체 수의 임계값이 초과되면 Major GC가 트리거됩니다. 위 gc.stat의 해당 부분이 바로 이 임계값들을 보여줍니다.

malloc_increase_bytes는 지금까지 설명한 외부에서 할당된 메모리의 양을 의미합니다. 객체의 크기가 표준 메모리 슬롯 크기(약 40바이트)를 초과하면, Ruby는 그 객체만을 위해 별도의 공간을 malloc으로 확보합니다. 이렇게 추가로 할당된 공간의 총합이 malloc_increase_bytes_limit를 초과하면 Major GC가 트리거됩니다.

oldmalloc_increase_bytes는 오래된 객체에 대한 유사한 임계값입니다. old_objects는 오래된 것으로 마킹된 객체 슬롯의 개수이며, 이 수가 old_objects_limit를 초과하면 Major GC가 트리거됩니다.

remembered_wb_unprotected_objects라이트 배리어(write-barrier)로 보호되지 않으면서 기억 집합(remembered set)에 속하는 객체의 총 개수입니다.

라이트 배리어는 Ruby 런타임과 객체 간의 인터페이스로, 인터프리터가 객체가 생성되는 즉시 해당 객체와 주고받는 참조를 추적할 수 있게 해줍니다.

C 확장(extension)은 라이트 배리어를 거치지 않고 객체에 새로운 참조를 만들 수 있으며, 이 경우 해당 객체는 shady, 즉 라이트 배리어 비보호 객체로 마킹됩니다. 기억 집합은 새로운(new) 객체를 하나 이상 참조하는 오래된(old) 객체들의 목록입니다.

Ruby 가비지 컬렉션 성능 커스터마이징

Ruby GC가 애플리케이션의 메모리를 어떻게 관리하는지 이해했으니, 이제 GC의 동작을 커스터마이징할 수 있는 옵션들을 살펴볼 차례입니다.

다음은 Ruby GC의 성능을 조절하고, 나아가 애플리케이션 성능을 개선하는 데 사용할 수 있는 환경 변수들입니다.

RUBY_GC_HEAP_INIT_SLOTS
RUBY_GC_HEAP_FREE_SLOTS
RUBY_GC_HEAP_GROWTH_FACTOR
RUBY_GC_HEAP_GROWTH_MAX_SLOTS
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
and other variables

중요한 파라미터들을 하나씩 살펴보겠습니다.

  • RUBY_GC_HEAP_INIT_SLOTS: Ruby 힙의 초기 슬롯 수를 정의하며, 기본값은 10,000입니다. 앱이 시작 시점에 대부분의 객체를 할당할 것이라고 확신한다면 이 값을 조정하는 것이 좋습니다.
  • RUBY_GC_HEAP_FREE_SLOTS: GC 사이클 직후 반드시 확보되어야 하는 최소 여유 슬롯 수를 제어합니다. 기본값은 4,096이며, 이 값은 런타임에서 첫 번째 힙 확장 시 한 번만 사용됩니다.
  • RUBY_GC_HEAP_GROWTH_FACTOR: Ruby 인터프리터가 사용 가능한 힙을 늘리는 배율입니다. 기본값은 1.8입니다. Ruby는 이미 힙 확장에 적극적이므로 이 값을 변경하는 것은 큰 의미가 없습니다. 현대 인터프리터에서는 힙이 필요할 때 할당되므로, 값을 줄여도 큰 차이가 없습니다.
  • RUBY_GC_HEAP_GROWTH_MAX_SLOTS: Ruby가 한 번에 힙 공간에 추가할 수 있는 최대 슬롯 수입니다. 기본값은 0으로, 제한이 없음을 의미합니다. 앱의 생명 주기 동안 수백만 개의 객체를 할당해야 한다면 이 파라미터에 상한을 두는 것이 좋습니다. 다만 앱의 GC 시간에는 큰 영향을 미치지 못합니다.
  • RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR: 메모리 내 오래된 객체의 총 수가 '이 값 × 마지막 GC 사이클 이후의 오래된 객체 수'를 초과하면 인터프리터가 Major GC를 수행하도록 강제합니다. 많은 객체가 올드 제너레이션에 진입한 후 더 이상 사용되지 않을 것이라고 예상된다면 이 값을 늘리는 것이 좋습니다. 하지만 이 설정이 필요한 경우는 드뭅니다.
  • RUBY_GC_MALLOC_LIMIT: 새로운 제너레이션을 위한 malloc 호출의 최소 한도입니다. 기본값은 16MB입니다. RUBY_GC_MALLOC_LIMIT_MAX는 동일한 malloc 호출의 최대 한도로, 기본값은 32MB입니다. 애플리케이션이 평균 이상의 메모리를 사용한다면 이 두 한도를 늘리는 것이 좋습니다. 다만 너무 크게 올리면 피크 메모리 소비량이 증가할 수 있으므로 주의해야 합니다. 항상 4~8MB씩 점진적으로 늘리세요.
  • RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR: 새로운 제너레이션의 malloc 한도 증가 배율입니다. 기본값은 1.4입니다. 앱이 메모리를 한꺼번에 할당하지 않고 나누어 할당한다면 이 값을 늘리는 것을 고려해 보세요.
  • 유사하게, RUBY_GC_OLDMALLOC_LIMITRUBY_GC_OLDMALLOC_LIMIT_MAX는 오래된 제너레이션을 위한 malloc의 최소 및 최대 한도입니다. 기본값은 각각 16MB와 128MB입니다.
  • RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR: 이 한도의 증가 배율입니다. 기본값은 1.2입니다. 최대 효과를 위해 새로운 제너레이션 한도와 함께 조정하는 것을 고려할 수 있습니다.

Ruby 가비지 컬렉션 파인 튜닝

지금까지 애플리케이션의 전반적인 성능을 개선하는 데 도움이 되는 GC 모듈 커스터마이징의 일반적이고 간단한 방법들을 살펴봤습니다. 하지만 이러한 튜닝이 모든 상황에서 효과를 보장하지는 않습니다. 무엇을 조정할지 결정하기 전에 반드시 애플리케이션의 메모리 사용 패턴을 파악해야 합니다.

반대로, 이러한 파라미터들의 최적값을 자동으로 찾아주는 테스트를 실행해 보는 것도 좋은 방법입니다. TuneMyGC 같은 도구는 환경 변수의 최적값 조합을 찾는 데 매우 유용합니다.

애플리케이션이 이상하게 동작한다면 반드시 GC 파라미터를 점검해 보세요. 작은 변경 하나만으로도 앱의 메모리 소비를 크게 줄이고 메모리 팽창을 예방할 수 있습니다.

이 글이 Ruby 가비지 컬렉션 모듈을 커스터마이징할 때 무엇을 주의해야 하는지 파악하는 데 도움이 되었기를 바랍니다. 더 자세한 입문 내용은 가비지 컬렉션 소개 Part I과 Part II를 참고하세요.

즐거운 코딩 되세요!