루비는 표준 라이브러리에 코드 성능을 측정할 수 있는 벤치마킹 도구를 기본으로 제공합니다. 이 도구는 특히 두 가지 구현 방식을 비교해 어느 쪽이 더 빠른지 확인해야 할 때 가장 큰 빛을 발합니다.
예제 소개: 문자열 키를 심볼 키로 변환하기
이번 예제에서는 문자열 키를 가진 해시(예: {"foo" => "bar"})를 심볼 키를 가진 해시(예: {:foo => "bar"})로 변환하는 작업을 다룹니다. 예제 전반에서는 영어 알파벳 26개 글자를 키와 값으로 갖는 해시를 사용합니다.
매번 직접 입력하지 않고도 이 해시를 손쉽게 만들기 위해, 알파벳 범위를 테스트용 해시로 변환하는 방식을 사용하겠습니다. 결과물은 이후 계속 활용할 수 있도록 input 변수에 저장해 둡니다.
input = ("a".."z").map {|letter| [letter, letter]}.to_h
# => {"a"=>"a", "b"=>"b", "c"=>"c", ..., "z"=>"z"}
input 변수가 준비되었으니, 이제 실제 변환 로직을 작성하고 그 성능을 살펴보겠습니다. 입력 해시의 모든 키를 문자열 대신 심볼로 바꾸는 깔끔한 한 줄짜리 코드는 다음과 같습니다.
input.map { |key, value| [key.to_sym, value] }.to_h
# => {:a=>"a", :b=>"b", :c=>"c", ..., :z=>"z"}
이 구현은 map 메서드로 해시를 순회하며 각 키-값 쌍마다 블록을 실행합니다. 블록 안에서는 키를 심볼로 변환한 뒤, 새로 만든 심볼 키와 그대로인 값을 담은 두 요소짜리 배열을 반환합니다.
map의 실행 결과는 키-값 배열 26개가 들어 있는 배열입니다. 최종적으로 필요한 것은 해시이므로, #to_h를 호출해 이 배열을 다시 해시로 변환합니다.
Benchmark.measure로 실행 시간 측정하기
동작하는 구현이 준비되었으니, 이제 루비의 Benchmark 모듈로 성능을 측정해 보겠습니다.
require 'benchmark'
input = ('a'..'z').map { |letter| [letter, letter] }.to_h
puts Benchmark.measure {
50_000.times do
input.map { |key, value| [key.to_sym, value] }.to_h
end
}
Benchmark.measure는 블록을 받아 해당 블록을 실행하면서 소요 시간을 기록합니다. 실행이 끝나면 보고서 문자열을 반환하며, 이를 puts로 콘솔에 출력할 수 있습니다.
측정 대상 코드가 아주 짧기 때문에, 눈에 띄는 결과를 얻기 위해 50,000번 반복 실행합니다.
$ ruby bench.rb
0.810000 0.000000 0.810000 ( 0.816964)
보고서 문자열에는 네 개의 숫자가 표시됩니다. 각각 사용자 CPU 시간(코드 실행에 소요된 시간), 시스템 CPU 시간(커널에서 소요된 시간), 이 둘을 합친 총 CPU 시간이며, 마지막 괄호 안의 값은 블록 실행에 실제로 걸린 시간(월 클록 시간)을 의미합니다.
실제 경과 시간을 보면 위 코드 블록을 50,000번 실행하는 데 약 800밀리초 조금 넘게 걸립니다. 인상적인 숫자처럼 보이지만, 다른 구현과 비교해 보기 전까지는 이 수치가 좋은 것인지 판단하기 어렵습니다.
Benchmark.bm으로 여러 구현 비교하기
Benchmark.measure 외에도 루비는 Benchmark.bm을 제공합니다. 이 메서드는 여러 코드 샘플을 실행하고 그 결과를 한 번에 출력해 줍니다. 각 샘플마다 이름과 실행할 블록을 함께 넘겨 Benchmark#report를 호출하면 됩니다.
require 'benchmark'
input = ("a".."z").map { |letter| [letter, letter] }.to_h
n = 50_000
Benchmark.bm do |benchmark|
benchmark.report("Hash[]") do
n.times do
input.map { |key, value| [key.to_sym, value] }.to_h
end
end
benchmark.report("{}.tap") do
n.times do
{}.tap do |new_hash|
input.each do |key, value|
new_hash[key.to_sym] = value
end
end
end
end
end
이 벤치마크에서는 Benchmark.bm을 사용해 두 가지 구현을 각각 50,000번씩 실행해 비교합니다. 첫 번째 측정 블록은 앞선 예제와 동일한 코드입니다.
두 번째 측정 블록은 다소 긴 구현을 사용합니다. 미리 새 해시를 생성해 두고, 문자열 키 해시를 순회하면서 항목 하나하나를 새 해시에 추가하는 방식입니다. 이렇게 하면 해시를 배열로 바꿨다가 다시 해시로 되돌리는 과정이 필요 없습니다.
벤치마크를 다시 실행해 보면, 코드가 더 길고(앞서의 한 줄 코드보다 다소 덜 세련되었음에도) 이 구현이 25% 이상 빠르다는 사실을 확인할 수 있습니다.
$ ruby bench.rb
user system total real
Hash[] 0.850000 0.000000 0.850000 ( 0.851106)
{}.tap 0.610000 0.020000 0.630000 ( 0.637070)
더 깊이 있는 벤치마킹
코드베이스의 중요한 코드를 작업할 때 벤치마크를 실행해 여러 구현을 비교하면 실행 속도에 대한 통찰을 얻을 수 있습니다. 서로 다른 구현이 성능에 어떤 영향을 주는지 이해하면 안티패턴을 피하고 더 빠른 루비 코드를 작성할 수 있습니다.
팁: 흔히 쓰이는 관용구(idiom) 상당수는 이미 벤치마크가 진행되어 fast-ruby라는 이름으로 결과가 공개되어 있습니다. 이 예제들을 미리 읽어두면 앞으로 불필요한 벤치마킹 시간을 아낄 수 있습니다.
이 예제에서 시도해 볼 수 있는 옵션은 더 많고, 루비 벤치마킹 라이브러리에는 훨씬 정교한 기능들도 마련되어 있습니다. 하지만 위 내용만으로도 루비에서 벤치마킹이 어떻게 동작하는지 이해하기에 충분한 입문이 될 것입니다. 벤치마킹에 대해 더 알고 싶거나 궁금한 점, 제안할 내용이 있다면 @AppSignal로 알려주세요.