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

Ruby 내부 구조 탐험: Ruby 객체의 메모리 레이아웃 완벽 이해하기

Ruby의 내부 동작 원리가 궁금하신가요?

그렇다면 아주 흥미로운 시간이 될 것입니다.

왜냐하면

이번 글에서는 Ruby 객체가 메모리에 어떻게 배치되는지 함께 살펴보고, 내부 데이터 구조를 조작해서 재미있는 작업들을 수행하는 방법까지 알아보겠습니다.

안전벨트를 매고 Ruby 인터프리터의 깊은 곳으로 떠나는 여정을 시작해 볼까요?

배열(Array)의 메모리 레이아웃

배열을 생성하면 Ruby는 이를 뒷받침하기 위해 시스템 메모리와 약간의 메타데이터를 할당해야 합니다.

메타데이터에는 다음이 포함됩니다:

  • 배열 크기(요소 개수)
  • 배열 용량(capacity)
  • 클래스
  • 객체 상태(frozen 여부)
  • 데이터가 메모리에 저장된 위치를 가리키는 포인터

주류 Ruby 인터프리터인 MRI(Matz's Ruby Interpreter)는 C 언어로 작성되었기 때문에 C에는 객체라는 개념이 없습니다.

대신 다른 것이 존재하는데, 바로 구조체(struct)입니다.

C에서 구조체는 관련된 데이터를 하나로 묶어 저장하는 역할을 하며, MRI 소스 코드에서는 Array, String 등 다양한 종류의 객체를 표현하는 데 널리 사용됩니다.

이러한 구조체를 들여다보면 객체의 메모리 레이아웃을 추론할 수 있습니다.

그럼 Array를 위한 구조체인 RArray를 살펴보겠습니다:

struct RArray {
  struct RBasic basic;

  union {
    struct {
      long len;

      union {
        long capa;
        VALUE shared;
      } aux;

      const VALUE *ptr;
    } heap;

    const VALUE ary[RARRAY_EMBED_LEN_MAX];
  } as;
};

C 언어에 익숙하지 않다면 다소 어렵게 느껴질 수 있지만 걱정하지 마세요! 하나씩 쉽게 나누어 설명해 드리겠습니다 🙂

먼저 눈에 띄는 것은 RBasic이라는 구조체입니다:

struct RBasic {
  VALUE flags;
  VALUE klass;
}

이것은 대부분의 Ruby 객체가 공통으로 가지고 있는 요소로, 해당 객체의 클래스 정보와 객체가 frozen 상태인지 등을 나타내는 이진 플래그(tainted 속성 같은 것도 포함)를 담고 있습니다.

:

RBasic은 객체의 일반적인 메타데이터를 담당합니다.

그다음에는 배열의 길이(len)를 담는 또 다른 구조체가 등장합니다.

union 표현식은 aux가 용량을 의미하는 capa 또는 shared 중 하나가 될 수 있음을 나타냅니다. 이는 주로 최적화를 위한 것으로, Pat Shaughnessy의 훌륭한 포스트에서 더 자세히 설명하고 있습니다. 메모리 할당 관점에서 보면 컴파일러는 union 내부에서 가장 큰 타입의 크기를 사용합니다.

마지막으로 ptr은 실제 Array 데이터가 저장된 메모리 주소를 담고 있습니다.

아래 그림은 이 구조를 시각적으로 보여줍니다(흰색/회색 박스 하나는 32비트 시스템 기준 4바이트입니다):

Ruby 내부 구조 탐험: Ruby 객체의 메모리 레이아웃 완벽 이해하기

ObjectSpace 모듈을 사용하면 객체가 차지하는 메모리 크기를 확인할 수 있습니다:

require 'objspace'

ObjectSpace.memsize_of([])
# 20

이제 재미있는 실험을 해볼 준비가 되었습니다!

Fiddle을 활용한 재미있는 실험

RBasic은 32비트 시스템에서 정확히 8바이트, 64비트 시스템에서는 16바이트입니다. 이 사실을 알면 Fiddle 모듈을 사용해 객체의 원시 메모리 바이트에 직접 접근하고 값을 변경하는 재미있는 실험을 해볼 수 있습니다.

예를 들어:

단 하나의 비트만 토글하여 frozen 상태를 변경할 수 있습니다.

본질적으로 이것이 freeze 메서드가 하는 일인데, 눈치채셨겠지만 Ruby에는 unfreeze 메서드가 없습니다.

재미삼아 직접 구현해 봅시다!

먼저 Ruby 표준 라이브러리의 일부인 Fiddle 모듈을 불러오고, frozen 상태의 문자열을 생성합니다.

require 'fiddle'

str = 'water'.freeze
str.frozen?
# true

다음으로:

문자열의 메모리 주소가 필요한데, 다음과 같이 구할 수 있습니다.

memory_address = str.object_id * 2

마지막으로:

Ruby가 객체의 frozen 여부를 판단할 때 확인하는 정확한 비트를 뒤집습니다. 그리고 frozen? 메서드를 호출해 실제로 작동했는지 확인합니다.

Fiddle::Pointer.new(memory_address)[1] ^= 8

str.frozen?
# false

여기서 인덱스 [1]flags 값(총 4바이트로 구성)의 두 번째 바이트를 가리킵니다.

그리고 ^=는 "XOR"(배타적 논리합) 연산자로, 해당 비트를 뒤집는 데 사용됩니다.

flags 내부의 각 비트는 서로 다른 의미를 가지므로, 관련 없는 정보를 실수로 변경하지 않도록 특정 비트만 정확히 조작하는 것입니다.

이전에 Ruby 트릭 관련 글을 읽으셨다면 비슷한 예제를 보신 적이 있을 텐데, 이제 그 작동 원리를 이해하게 된 것입니다 🙂

또 한 가지 시도해 볼 만한 것은 배열의 길이를 변경한 후 배열을 출력해 보는 것입니다.

배열이 실제로 짧아지는 것을 확인할 수 있습니다!

심지어 클래스 정보를 변경해서 Array 객체가 자신을 String이라고 믿게 만들 수도 있습니다…

마무리

이번 글에서는 Ruby의 내부 동작 방식, 즉 Ruby 객체의 메모리가 어떻게 배치되는지, 그리고 Fiddle 모듈을 활용해 그 메모리를 자유롭게 다루는 방법에 대해 알아보았습니다.

실제 애플리케이션에서 이런 식으로 Fiddle을 사용하는 것은 권장하지 않지만, 실험해 보는 것은 분명 재미있는 경험이 될 것입니다.

더 많은 사람들이 볼 수 있도록 이 글을 공유하는 것도 잊지 마세요 🙂