자신이 사용하는 도구에 대해 더 깊이 이해할수록 개발자로서 더 나은 결정을 내릴 수 있습니다. 특히 성능 문제를 디버깅할 때는 Ruby가 여러분의 프로그램을 실행하는 동안 실제로 무엇을 하고 있는지 이해하는 것이 큰 도움이 됩니다.
이 글에서는 간단한 프로그램 하나가 렉싱(lexing)되고, 파싱(parsing)되고, 바이트코드로 컴파일되기까지의 여정을 따라가 보겠습니다. Ruby가 제공하는 도구들을 활용해 인터프리터의 동작을 단계별로 직접 들여다볼 것입니다.
걱정하지 마세요. 전문가가 아니더라도 충분히 따라올 수 있도록 쉽게 풀어 설명했습니다. 기술 매뉴얼이라기보다는 가이드 투어에 가깝습니다.
샘플 프로그램 소개
예시로 간단한 if/else 문 하나를 사용하겠습니다. 공간을 절약하기 위해 삼항 연산자(ternary operator)로 작성하지만, 속지 마세요. 사실상 if/else와 다르지 않습니다.
x > 100 ? 'foo' : 'bar'
앞으로 보겠지만, 이렇게 간단한 프로그램조차 처리 과정에서 상당히 많은 데이터로 변환됩니다.
참고: 이 글의 모든 예제는 Ruby(MRI) 2.2 기준으로 작성되었습니다. 다른 Ruby 구현체를 사용 중이라면 예제가 그대로 동작하지 않을 수 있습니다.
토크나이징(Tokenizing)
Ruby 인터프리터가 프로그램을 실행하기 전에, 자유로운 형태의 프로그래밍 언어를 더 구조화된 데이터로 변환해야 합니다.
첫 번째 단계는 프로그램을 조각(chunk)으로 나누는 것입니다. 이 조각들을 토큰(token)이라고 부릅니다.
# 문자열
"x > 1"
# 토큰
["x", ">", "1"]
Ruby 표준 라이브러리는 Ripper라는 모듈을 제공하며, 이를 활용하면 Ruby 코드를 인터프리터와 거의 같은 방식으로 처리할 수 있습니다.
아래 예제에서는 tokenize 메서드를 사용합니다. 코드가 토큰 배열로 반환되는 것을 확인할 수 있습니다.
require 'ripper'
Ripper.tokenize("x > 100 ? 'foo' : 'bar'")
# => ["x", " ", ">", " ", "1", " ", "?", " ", "'", "foo", "'", " ", ":", " ", "'", "bar", "'"]
토크나이저는 꽤 단순한 녀석입니다. 완전히 잘못된 Ruby 코드를 넣어도 아무런 불평 없이 그대로 토큰화해 버립니다.
# 잘못된 코드
Ripper.tokenize("1var @= \/foobar`")
# => ["1", "var"]
렉싱(Lexing)
렉싱은 토크나이징보다 한 단계 더 나아간 과정입니다. 문자열은 여전히 토큰으로 분해되지만, 이번에는 각 토큰에 추가 정보가 붙습니다.
아래 예제에서는 Ripper로 앞선 프로그램을 렉싱해 봅니다. 각 토큰이 식별자 :on_ident, 연산자 :on_op, 정수 :on_int 등으로 분류되는 것을 볼 수 있습니다.
require 'ripper'
require 'pp'
pp Ripper.lex("x > 100 ? 'foo' : 'bar'")
# [[[1, 0], :on_ident, "x"],
# [[1, 1], :on_sp, " "],
# [[1, 2], :on_op, ">"],
# [[1, 3], :on_sp, " "],
# [[1, 4], :on_int, "100"],
# [[1, 5], :on_sp, " "],
# [[1, 6], :on_op, "?"],
# [[1, 7], :on_sp, " "],
# [[1, 8], :on_tstring_beg, "'"],
# [[1, 9], :on_tstring_content, "foo"],
# [[1, 12], :on_tstring_end, "'"],
# [[1, 13], :on_sp, " "],
# [[1, 14], :on_op, ":"],
# [[1, 15], :on_sp, " "],
# [[1, 16], :on_tstring_beg, "'"],
# [[1, 17], :on_tstring_content, "bar"],
# [[1, 20], :on_tstring_end, "'"]]
이 단계에서도 아직 실질적인 문법 검사는 일어나지 않습니다. 렉서는 잘못된 코드도 아무 문제 없이 처리합니다.
파싱(Parsing)
Ruby가 코드를 관리하기 쉬운 조각들로 나눴으니, 이제 파싱을 시작할 차례입니다.
파싱 단계에서 Ruby는 텍스트를 추상 구문 트리(AST, Abstract Syntax Tree)라고 불리는 구조로 변환합니다. 추상 구문 트리란 프로그램을 메모리 위에 표현한 형태입니다.
심지어 프로그래밍 언어 전반을, 추상 구문 트리를 더 사용자 친화적으로 기술하는 방법이라고 표현하기도 합니다.
require 'ripper'
require 'pp'
pp Ripper.sexp("x > 100 ? 'foo' : 'bar'")
# [:program,
# [[:ifop,
# [:binary, [:vcall, [:@ident, "x", [1, 0]]], :>, [:@int, "100", [1, 4]]],
# [:string_literal, [:string_content, [:@tstring_content, "foo", [1, 11]]]],
# [:string_literal, [:string_content, [:@tstring_content, "bar", [1, 17]]]]]]]
출력을 읽기는 쉽지 않지만, 한동안 응시하고 있으면 원본 프로그램과 어떻게 대응되는지 어렴풋이 눈에 들어오기 시작합니다.
# 프로그램 정의
[:program,
# "if" 연산 수행
[[:ifop,
# 조건 검사 (x > 100)
[:binary, [:vcall, [:@ident, "x", [1, 0]]], :>, [:@int, "100", [1, 4]]],
# 참이면 "foo" 반환
[:string_literal, [:string_content, [:@tstring_content, "foo", [1, 11]]]],
# 거짓이면 "bar" 반환
[:string_literal, [:string_content, [:@tstring_content, "bar", [1, 17]]]]]]]
이 시점에서 Ruby 인터프리터는 여러분이 무엇을 원하는지 정확히 파악합니다. 사실 지금 당장 프로그램을 실행할 수도 있습니다. 실제로 Ruby 1.9 이전 버전에서는 그렇게 했습니다. 하지만 요즘은 한 단계가 더 남아 있습니다.
바이트코드로 컴파일하기
요즘 Ruby는 추상 구문 트리를 직접 순회하는 대신, 이를 더 저수준인 바이트코드(bytecode)로 컴파일합니다.
그리고 이 바이트코드는 Ruby 가상 머신이 실행하게 됩니다.
RubyVM::InstructionSequence 클래스를 통해 가상 머신의 내부 동작을 엿볼 수 있습니다. 아래 예제에서는 샘플 프로그램을 컴파일한 뒤 디스어셈블(disassemble)해서 사람이 읽을 수 있는 형태로 만듭니다.
puts RubyVM::InstructionSequence.compile("x > 100 ? 'foo' : 'bar'").disassemble
# == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
# 0000 trace 1 ( 1)
# 0002 putself
# 0003 opt_send_without_block <callinfo!mid:x, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0005 putobject 100
# 0007 opt_gt <callinfo!mid:>, argc:1, ARGS_SIMPLE>
# 0009 branchunless 15
# 0011 putstring "foo"
# 0013 leave
# 0014 pop
# 0015 putstring "bar"
# 0017 leave
우와! 갑자기 Ruby보다는 어셈블리 언어에 훨씬 가까워 보입니다. 한 줄씩 짚어가며 의미를 파악해 보겠습니다.
# self에서 메서드 `x`를 호출하고 결과를 스택에 저장
0002 putself
0003 opt_send_without_block <callinfo!mid:x, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 스택에 숫자 100을 올림
0005 putobject 100
# 비교 연산 수행 (x > 100)
0007 opt_gt <callinfo!mid:>, argc:1, ARGS_SIMPLE>
# 비교 결과가 거짓이면 15번 위치로 이동
0009 branchunless 15
# 비교 결과가 참이면 "foo" 반환
0011 putstring "foo"
0013 leave
0014 pop
# 여기가 15번 위치입니다. 비교가 거짓이면 여기로 점프합니다. "bar" 반환
0015 putstring "bar"
0017 leave
이후 Ruby 가상 머신(YARV)이 이 명령어들을 하나씩 순서대로 실행합니다. 이것으로 끝입니다!
마치며
지금까지 Ruby 인터프리터를 아주 단순화되고 만화 같은 방식으로 둘러봤습니다. 여기서 소개한 도구들을 활용하면 Ruby가 프로그램을 해석하는 방식에 대한 막연한 추측을 상당 부분 걷어낼 수 있습니다. AST보다 더 구체적인 증거는 없으니까요. 다음에 난해한 성능 문제로 골머리를 앓게 된다면 바이트코드를 한번 들여다보세요. 문제가 바로 해결되지 않을 수도 있지만, 적어도 잠시 머리를 환기하는 데는 도움이 될 겁니다. :)