요즘 루비(Ruby) 성능에 관한 이야기가 많은데, 그럴 만한 이유가 있습니다. 코드를 조금만 다듬어도 성능을 최대 99.9%까지 끌어올릴 수 있다는 사실이 여러 차례 증명되었기 때문입니다.
그런데 코드를 최적화하는 방법을 다룬 글은 널려 있는 반면, 한 번 최적화된 상태를 어떻게 유지할 수 있는지 이야기하는 글은 드물습니다.
자주 호출되는 메서드 안에 동결된(frozen) 상수 대신 문자열 리터럴을 그대로 삽입하면 그 결과를 매번 신경 쓰지 않게 되기 마련입니다. 이후 코드를 유지보수하는 과정에서 최적화로 얻은 이득은 생각보다 쉽게 사라집니다.
Honeybadger의 루비 젬(gem)에서 같은 코드를 두 번째, 아니 세 번째 최적화하면서 저는 이런 생각을 했습니다. "이런 최적화들이 회귀(regression)하지 않도록 보장해 주는 방법이 있으면 좋지 않을까?"
성능 회귀란 무엇일까?
회귀는 이름을 몰라도 대부분의 개발자가 경험해 본 문제입니다. 회귀란 과거에 해결했던 버그나 이슈가, 이후 같은 코드에 가해진 변경 때문에 다시 나타나는 현상을 말합니다. 아무도 같은 작업을 두 번 하고 싶어 하지 않습니다. 회귀는 마치 방금 쓸어둔 바닥에 흙을 다시 묻히는 것과 같습니다.
다행히 우리에게는 비밀 무기가 있습니다. 바로 테스트입니다. 교조적인 TDD를 실천하지 않더라도, 테스트는 버그 수정에 놀랍도록 유용합니다. 문제와 해결책을 프로그래밍 방식으로 명확히 증명해 주기 때문입니다. 테스트 덕분에 코드가 변경되더라도 회귀가 일어나지 않을 것이라는 확신을 얻을 수 있습니다.
익숙한 이야기죠? 저도 그랬습니다. 그래서 이런 질문이 떠올랐습니다. "성능 최적화도 회귀할 수 있다면, 그 회귀 역시 테스트로 잡아낼 수 있지 않을까?"
AllocationStats로 객체 할당 프로파일링하기
루비의 다양한 성능 요소, 즉 객체 할당(object allocations), 메모리, CPU, 가비지 컬렉션 등을 프로파일링할 수 있는 훌륭한 도구가 많습니다. ruby-prof, stackprof, allocation_tracer 등이 대표적인 예입니다.
저는 최근 allocation_stats를 사용해 객체 할당을 프로파일링하고 있습니다. 할당량을 줄이는 일은 비교적 쉬운 편이면서도, 메모리 사용량과 실행 속도를 개선하는 저비용·고효율의 성과를 거두기에 좋은 영역입니다.
예를 들어, 기본값이 'foo'인 문자열 5개를 배열에 담아 저장하는 간단한 루비 클래스가 있습니다.
class MyClass
def initialize
@values = Array.new(5)
5.times { @values << 'foo' }
end
end
AllocationStats의 API는 단순합니다. 프로파일링할 블록을 넘기면, 어디에서 가장 많은 객체가 할당되는지 출력해 줍니다.
$ ruby -r allocation_stats -r ./lib/my_class
stats = AllocationStats.trace { MyClass.new }
puts stats.allocations(alias_paths: true).group_by(:sourcefile, :sourceline, :class).to_text
^D
sourcefile sourceline class count
--------------------- ---------- ------- -----
/lib/my_class.rb 4 String 5
/lib/my_class.rb 3 Array 1
- 1 MyClass 1
할당 그룹에 대해 호출하는 #to_text 메서드는 지정한 기준으로 그룹화된 결과를 사람이 읽기 쉬운 표 형태로 출력해 줍니다.
RSpec 커스텀 매처 작성하기
수동 프로파일링에는 이 출력만으로 충분하지만, 제 목표는 평소 사용하는 유닛 테스트 스위트(RSpec으로 작성됨)와 함께 실행할 수 있는 테스트를 만드는 것이었습니다. 위 결과를 보면 my_class.rb의 4번째 줄에서 문자열 5개가 할당되는데, 전부 같은 값을 가지므로 불필요해 보입니다. 제가 원하는 시나리오는 "MyClass 초기화 시 6개 미만의 객체를 할당한다"처럼 자연스럽게 읽히는 것이었습니다. RSpec으로 표현하면 다음과 같습니다.
describe MyClass do
context "when initializing" do
specify { expect { MyClass.new }.to allocate_under(6).objects }
end
end
이 구문을 사용하면 커스텀 RSpec 매처(matcher)를 통해 expect 블록 안의 코드가 특정 숫자 미만의 객체를 할당하는지 검증할 수 있습니다.
AllocationStats는 trace 결과를 출력하는 것 외에도 #allocations, #new_allocations 같은 메서드를 제공해 루비 코드에서 할당 정보에 직접 접근할 수 있습니다. 저는 이 메서드들을 활용해 매처를 만들었습니다.
begin
require 'allocation_stats'
rescue LoadError
puts 'Skipping AllocationStats.'
end
RSpec::Matchers.define :allocate_under do |expected|
match do |actual|
return skip('AllocationStats is not available: skipping.') unless defined?(AllocationStats)
@trace = actual.is_a?(Proc) ? AllocationStats.trace(&actual) : actual
@trace.new_allocations.size < expected
end
def objects
self
end
def supports_block_expectations?
true
end
def output_trace_info(trace)
trace.allocations(alias_paths: true).group_by(:sourcefile, :sourceline, :class).to_text
end
failure_message do |actual|
"expected under #{ expected } objects to be allocated; got #{ @trace.new_allocations.size }:\n\n" << output_trace_info(@trace)
end
description do
"allocates under #{ expected } objects"
end
end
초기 require 구문에서 LoadError를 rescue하는 이유는, 모든 테스트 실행에 AllocationStats를 포함하고 싶지 않을 수 있기 때문입니다(테스트 속도가 눈에 띄게 느려지는 경향이 있습니다). 이어서 :allocate_under 매처를 정의하고, match 블록 안에서 trace를 수행합니다. failure_message 블록도 중요합니다. AllocationStats trace의 to_text 출력을 실패 메시지 안에 그대로 포함시키기 때문입니다! 나머지 부분은 대체로 표준적인 RSpec 설정입니다.
테스트 실행하고 최적화 적용하기
매처를 로드했다면, 이제 앞서 만든 시나리오를 실행해 실패하는 모습을 확인할 수 있습니다.
$ rspec spec/my_class_spec.rb
MyClass
when initializing
should allocates under 6 objects (FAILED - 1)
Failures:
1) MyClass when initializing should allocates under 6 objects
Failure/Error: expect { MyClass.new }.to allocate_under(6).objects
expected under 6 objects to be allocated; got 7:
sourcefile sourceline class count
--------------------------- ---------- ------- -----
<PWD>/spec/my_class_spec.rb 6 MyClass 1
<PWD>/lib/my_class.rb 3 Array 1
<PWD>/lib/my_class.rb 4 String 5
# ./spec/my_class_spec.rb:6:in `block (3 levels) in <top (required)>'
Finished in 0.15352 seconds (files took 0.22293 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/my_class_spec.rb:5 # MyClass when initializing should allocates under 6 objects
좋습니다. 이제 MyClass가 동일한 값의 문자열 객체를 불필요하게 추가로 할당한다는 성능 문제를 프로그래밍 방식으로 입증했습니다. 해당 값들을 동결된 상수에 옮겨 담아 문제를 해결해 보겠습니다.
class MyClass
DEFAULT = 'foo'.freeze
def initialize
@values = Array.new(5)
5.times { @values << DEFAULT }
end
end
문제를 수정했으니 테스트를 다시 실행해 통과하는 모습을 확인합니다.
$ rspec spec/my_class_spec.rb
MyClass
when initializing
should allocates under 6 objects
Finished in 0.14952 seconds (files took 0.22056 seconds to load)
1 example, 0 failures
이제 다음에 MyClass#initialize 메서드를 수정하더라도, 과도한 객체 할당이 발생하지 않는다는 확신을 가질 수 있습니다.
성능 테스트를 효율적으로 운영하는 방법
할당 프로파일링은 상대적으로 느린 편이므로, 항상 실행하기보다 필요할 때만 실행하는 것이 이상적입니다. allocation_stats가 없는 환경을 이미 우아하게 처리해 두었기 때문에, Bundler로 여러 개의 gemfile을 만들고 BUNDLE_GEMFILE 환경 변수로 사용할 gemfile을 지정하는 방식을 활용할 수 있습니다.
$ BUNDLE_GEMFILE=with_performance.gemfile bundle exec rspec spec/
$ BUNDLE_GEMFILE=without_performance.gemfile bundle exec rspec spec/
또 다른 선택지는 appraisal 젬 같은 라이브러리를 사용하는 것입니다. 같은 접근 방식을 취하면서 Bundler 사용 시 겪을 수 있는 여러 함정까지 해결해 줍니다. Jason Clark가 2015년 3월 Ruby on Ales에서 이 주제에 관해 훌륭한 발표를 했으니, 자세한 내용은 그의 슬라이드를 참고하시기 바랍니다.
또한 이런 종류의 테스트는 일반 유닛 테스트와 분리해 관리하는 것이 좋다고 생각합니다. 새로운 "performance" 디렉터리를 만들어 유닛 테스트 스위트는 spec/unit/에, 성능 테스트 스위트는 spec/performance/에 배치했습니다.
spec/
|-- spec_helper.rb
|-- unit/
|-- features/
|-- performance/
저는 아직도 루비 코드의 성능을 프로파일링하는 방식을 다듬고 있습니다. 성능 테스트 스위트를 꾸준히 유지하면 지금 코드의 속도를 개선할 수 있고, 앞으로도 빠른 상태를 유지할 수 있으며, 나 자신과 다른 개발자를 위한 살아있는 문서로도 활용할 수 있기를 바랍니다.