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

Ruby는 코드를 어떻게 해석할까? RubyVM::InstructionSequence로 들여다보기

Ruby Magic 시리즈에 다시 오신 것을 환영합니다! 이번 글에서는 Ruby가 우리가 작성한 코드를 어떻게 해석하는지 살펴보고, 이 지식을 실제 개발에 어떻게 활용할 수 있는지 알아보겠습니다. 코드가 해석되는 과정을 이해하면 더 빠른 코드를 작성하는 데 큰 도움이 됩니다.

심볼 하나가 만드는 미묘한 차이

이전 Ruby Magic 아티클인 'Ruby에서 문자 이스케이프 처리하기'에서 줄바꿈 문자를 이스케이프하는 예제를 소개한 적이 있습니다.

아래 예제를 보면 두 개의 문자열을 여러 줄에 걸쳐 하나의 String으로 합치는 방법이 두 가지 있습니다. 하나는 플러스(+) 기호를 사용하는 것이고, 다른 하나는 백슬래시(\)를 사용하는 방식입니다.

"foo" +
  "bar"
=> "foobar"

# versus

"foo" \
  "bar"
=> "foobar"

두 예제는 겉보기에 매우 비슷해 보이지만, 실제 동작 방식은 상당히 다릅니다. 이 둘이 어떻게 읽히고 해석되는지 그 차이를 알려면 보통 Ruby 인터프리터의 세부 사항까지 알아야 합니다. 하지만 굳이 그럴 필요 없이, Ruby에게 직접 물어보면 됩니다.

InstructionSequence란?

RubyVM::InstructionSequence 클래스를 사용하면 Ruby가 주어진 코드를 어떻게 해석하는지 직접 확인할 수 있습니다. 이 클래스는 Ruby 내부 동작을 엿볼 수 있게 해주는 강력한 도구 모음입니다.

아래 예제에서 반환되는 결과는 YARV 인터프리터가 이해한 형태 그대로의 Ruby 코드입니다.

YARV 인터프리터

YARV(Yet Another Ruby VM)는 Ruby 1.9 버전부터 도입된 인터프리터로, 기존의 MRI(Matz's Ruby Interpreter)를 대체했습니다.

인터프리터를 사용하는 언어는 중간 컴파일 단계 없이 코드를 직접 실행합니다. 즉, Ruby는 C, Rust, Go 같은 컴파일 언어처럼 프로그램을 먼저 최적화된 기계어로 변환하지 않습니다.

Ruby에서는 프로그램이 먼저 Ruby VM용 명령어 집합(instruction set)으로 변환된 후, 곧바로 실행됩니다. 이 명령어들은 여러분의 Ruby 코드와 Ruby VM에서 실행되는 코드 사이의 중간 단계 역할을 합니다.

이러한 명령어 덕분에 Ruby VM은 문법 해석 작업을 거치지 않고도 Ruby 코드를 쉽게 이해할 수 있습니다. 문법 해석은 명령어를 생성하는 단계에서 이미 처리되기 때문입니다. 명령어 시퀀스는 해석된 코드를 나타내는 최적화된 연산 집합입니다.

일반적인 Ruby 프로그램 실행 중에는 이런 명령어들을 볼 수 없지만, 직접 확인해 보면 Ruby가 우리 코드를 올바르게 해석했는지 검토할 수 있습니다. InstructionSequence를 사용하면 YARV가 명령어를 실행하기 전에 어떤 명령어를 생성하는지 살펴볼 수 있습니다.

물론 Ruby 인터프리터를 구성하는 모든 YARV 명령어를 이해할 필요는 없습니다. 대부분의 명령어는 이름만 봐도 의미를 알 수 있습니다.

"foo" +
  "bar"
RubyVM::InstructionSequence.compile('"foo" + "bar"').to_a
# ... [:putstring, "foo"], [:putstring, "bar"] ...

# versus

"foo" \
  "bar"
RubyVM::InstructionSequence.compile('"foo" "bar"').to_a
# ... [:putstring, "foobar"] ...

실제 출력에는 나중에 살펴볼 몇 가지 초기화 명령어가 더 포함되어 있지만, 여기서 "foo" + "bar""foo" "bar"의 진짜 차이를 확인할 수 있습니다.

전자는 두 개의 문자열을 생성한 후 이를 결합합니다. 반면 후자는 문자열을 하나만 생성합니다. 즉, "foo" "bar"는 문자열을 한 개만 만들지만, "foo" + "bar"는 무려 세 개의 객체를 생성하게 됩니다.

  1       2           3
  ↓       ↓           ↓
"foo" + "bar" # => "foobar"

물론 이것은 가장 기본적인 예제일 뿐이지만, Ruby 언어의 사소한 디테일 하나가 성능에 큰 영향을 미칠 수 있음을 잘 보여주는 사례입니다:

  • 더 많은 할당: String 객체 하나하나가 개별적으로 메모리에 할당됩니다.
  • 더 많은 메모리 사용: 할당된 String 객체마다 메모리를 차지합니다.
  • 더 긴 가비지 컬렉션 시간: 모든 객체는 수명이 짧더라도 가비지 컬렉터가 정리하는 데 시간이 걸립니다. 할당이 많아질수록 가비지 컬렉션 시간도 길어집니다.

디스어셈블링으로 로직 오류 찾기

또 다른 활용 사례는 로직 문제를 디버깅하는 것입니다. 아래는 누구나 실수하기 쉬우면서도 큰 결과를 초래할 수 있는 코드입니다. 차이점을 찾아보겠습니다.

1 + 2 * 3
# versus
(1 + 2) * 3

이렇게 조금 더 복잡한 예제에서도 Ruby의 도움을 받아 차이를 파악할 수 있습니다.

코드를 디스어셈블하면 Ruby가 수행하는 명령어를 더 읽기 쉬운 표 형태로 출력해 줍니다.

1 + 2 * 3
# => 7
puts RubyVM::InstructionSequence.compile("1 + 2 * 3").disasm
# == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
# 0000 trace            1                                               (   1)
# 0002 putobject_OP_INT2FIX_O_1_C_
# 0003 putobject        2
# 0005 putobject        3
# 0007 opt_mult         <callinfo!mid:*, argc:1, ARGS_SIMPLE>
# 0009 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>
# 0011 leave

# versus

(1 + 2) * 3
# => 9
puts RubyVM::InstructionSequence.compile("(1 + 2) * 3").disasm
# == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
# 0000 trace            1                                               (   1)
# 0002 putobject_OP_INT2FIX_O_1_C_
# 0003 putobject        2
# 0005 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>
# 0007 putobject        3
# 0009 opt_mult         <callinfo!mid:*, argc:1, ARGS_SIMPLE>
# 0011 leave

위 예제는 YARV 명령어 수가 좀 더 많지만, 명령어가 출력되고 실행되는 순서만 봐도 괄호 한 쌍이 만들어내는 차이를 알 수 있습니다.

1 + 2에 괄호를 사용하면 수학의 연산 순서에서 덧셈이 먼저 수행되도록 우선순위를 높일 수 있습니다.

참고로 디스어셈블리 출력 자체에는 괄호가 직접 표시되지 않으며, 괄호가 나머지 코드에 미친 영향만 드러납니다.

디스어셈블리 출력 읽는 법

디스어셈블리 출력에는 처음에는 이해하기 어려운 내용이 많이 포함되어 있습니다.

출력되는 표 형식에서 각 줄은 연산 번호로 시작하고, 그다음 연산 이름, 마지막으로 해당 연산의 인자가 표시됩니다.

지금까지 살펴본 주요 연산들을 간단히 정리하면 다음과 같습니다:

  • trace - 트레이싱을 시작합니다. 자세한 내용은 TracePoint 공식 문서를 참고하세요.
  • putobject - 객체를 스택에 푸시합니다.
  • putobject_OP_INT2FIX_O_1_C_ - 정수 1을 스택에 푸시합니다. 최적화된 연산입니다. (01은 최적화되어 있습니다.)
  • putstring - 문자열을 스택에 푸시합니다.
  • opt_plus - 덧셈 연산입니다. (내부적으로 최적화되어 있습니다.)
  • opt_mult - 곱셈 연산입니다. (내부적으로 최적화되어 있습니다.)
  • leave - 현재 코드 컨텍스트를 종료합니다.

이제 Ruby 인터프리터가 개발자 친화적이고 읽기 쉬운 Ruby 코드를 YARV 명령어로 변환하는 과정을 알았으니, 이를 활용해 애플리케이션을 최적화할 수 있습니다.

RubyVM::InstructionSequence에는 메서드 전체는 물론 파일 전체를 전달하는 것도 가능합니다.

puts RubyVM::InstructionSequence.disasm(method(:foo))
puts RubyVM::InstructionSequence.compile_file("/tmp/hello.rb").disasm

어떤 코드가 왜 동작하고, 또 어떤 코드가 왜 동작하지 않는지 밝혀낼 수 있습니다. 특정 심볼이 코드의 동작을 어떻게 달라지게 하는지 배울 수도 있습니다. 악마는 디테일에 숨어 있습니다. 여러분의 Ruby 코드가 애플리케이션에서 실제로 어떻게 동작하는지, 그리고 최적화할 여지가 있는지 아는 것이 중요합니다.

컴파일 옵션을 활용한 추가 최적화

인터프리터 수준에서 코드를 들여다보고 최적화하는 것 외에도, InstructionSequence를 사용하면 코드를 한층 더 최적화할 수 있습니다.

InstructionSequence를 사용하면 Ruby에 내장된 성능 최적화 기능으로 특정 명령어를 최적화할 수 있습니다. 사용 가능한 전체 최적화 옵션 목록은 RubyVM::InstructionSequence.compile_option = 메서드 문서에서 확인할 수 있습니다.

그중 하나가 바로 꼬리 호출 최적화(Tail Call Optimization)입니다.

RubyVM::InstructionSequence.compile 메서드는 아래와 같이 옵션을 전달하여 이 최적화를 활성화할 수 있습니다:

some_code = <<-EOS
def fact(n, acc=1)
  return acc if n <= 1
  fact(n-1, n*acc)
end
EOS
puts RubyVM::InstructionSequence.compile(some_code, nil, nil, nil, tailcall_optimization: true, trace_instruction: false).disasm
RubyVM::InstructionSequence.compile(some_code, nil, nil, nil, tailcall_optimization: true, trace_instruction: false).eval

심지어 RubyVM::InstructionSequence.compile_option =을 사용하면 모든 코드에 대해 이 최적화를 일괄 적용할 수도 있습니다. 단, 다른 코드가 로드되기 전에 반드시 먼저 설정해야 합니다.

RubyVM::InstructionSequence.compile_option = {
  tailcall_optimization: true,
  trace_instruction: false
}

Ruby에서 꼬리 호출 최적화가 어떻게 동작하는지 더 자세히 알고 싶다면 'Tail Call Optimization in Ruby'와 'Tail Call Optimization in Ruby: Background' 같은 관련 아티클을 참고해 보세요.

마무리

RubyVM::InstructionSequence를 통해 Ruby가 여러분의 코드를 어떻게 해석하는지 학습하고, 코드가 실제로 무엇을 하고 있는지 확인해 보세요. 그렇게 하면 더 높은 성능의 코드를 만들 수 있습니다.

InstructionSequence 입문서는 Ruby가 내부적으로 어떻게 동작하는지 재미있게 배울 수 있는 방법이기도 합니다. 어쩌면 Ruby 코드 자체에 기여하는 개발자가 되고 싶어질지도 모릅니다.

Ruby의 코드 컴파일에 대한 짧은 소개는 여기까지입니다. 이 글이 도움이 되었는지, 궁금한 점이 있는지, 그리고 다음에 어떤 주제를 다뤘으면 좋겠는지 @AppSignal을 통해 알려주세요!