루비 개발자라면 해시(Hash)를 사랑할 수밖에 없습니다. 하지만 해시에는 몇 가지 잘 알려진 단점이 있습니다. Richard가 Hashie Considered Harmful에서 지적했듯이, 해시는 때때로 지나치게 유연합니다. 오타 하나만 있어도 의도하지 않은 키를 할당하고 참조하게 될 수 있습니다.
a = { type: "F150" }
a[:typo] # nil
흔히 사용되는 해시 대안들
해시로 실제 구조화된 데이터를 저장한다면, 굳이 그런 유연성이 필요 없다고 판단할 수 있습니다. 과도한 유연성은 결국 문제를 일으킬 뿐입니다.
몇 가지 대안이 있습니다. XY 좌표 쌍을 저장해야 하는 상황을 가정해 보겠습니다. 첫 번째 방법은 오직 XY 좌표 쌍을 담는 역할만 하는 클래스를 직접 정의하는 것입니다.
class PointClass # 클래스 이름 끝에 Class를 붙이는 건 추천하지 않습니다 :)
attr_accessor :x, :y
def initialize(args)
@x = args.fetch(:x)
@y = args.fetch(:y)
end
end
point_class = PointClass.new(x: 1, y: 2)
point_class.x # 1
이 경우 단순히 데이터를 캡슐화하는 것이 목적이므로, 더 간결한 선택지는 Struct를 사용하는 것입니다. 다음과 같이 작성할 수 있습니다.
PointStruct = Struct.new(:x, :y)
point_struct = PointStruct.new(1, 2)
point_struct.x # 1
세 번째 선택지는 OpenStruct입니다. OpenStruct는 Struct처럼 보이지만, 해시처럼 임의의 값을 설정할 수 있다는 점이 다릅니다. 예시는 다음과 같습니다.
point_os = OpenStruct.new(x: 1, y: 2)
point_os.x # 1
성능에 미치는 영향
[2015년 7월 10일 업데이트: 제 벤치마크 스크립트가 해시에 불공정했다는 사실이 밝혀졌습니다. Patrick Helm이 지적했듯이, 비효율적인 초기화 방식을 사용하고 있었습니다. 따라서 해시 관련 결과는 무시해 주시기 바랍니다. 다만 OpenStruct가 매우 느리다는 핵심 주장은 여전히 유효합니다.]
이 네 가지 선택지를 살펴보며 성능에 어떤 차이가 있는지 궁금해졌습니다. 소량의 데이터만 다룬다면 어떤 방식이든 충분히 빠르다는 것은 자명합니다. 하지만 수천 또는 수백만 개의 항목을 처리해야 한다면, 해시 vs OpenStruct vs Struct vs 클래스 간의 성능 차이가 실질적으로 중요해질 수 있습니다.
Honeybadger에서는 매초 수천 개의 예외가 API로 보고되기 때문에, 이런 성능 특성을 이해하는 일은 언제나 우리의 중요한 관심사입니다.
그래서 간단한 벤치마크 스크립트를 작성했습니다. 이런 종류의 실험에는 benchmark-ips 젬(gem)을 사용하는 것을 선호합니다. 적절한 샘플 크기를 자동으로 산정해주고, 표준 편차까지 보고해주기 때문입니다.
초기화 성능 비교
PointClass, PointStruct, Hash, OpenStruct의 초기화 시간을 벤치마크한 결과, PointClass와 PointStruct가 압도적인 승자였습니다. 두 방식은 OpenStruct보다 약 10배, 해시보다 약 2배 빠른 속도를 보여줬습니다.

PointClass와 PointStruct는 OpenStruct보다 거의 10배 빠릅니다
이러한 결과는 자연스럽게 납득됩니다. Struct가 가장 단순한 구조이므로 가장 빠르고, OpenStruct는 가장 복잡합니다(내부적으로 해시의 래퍼(wrapper)이기 때문입니다). 다만 속도 차이의 규모가 이렇게 크다는 점은 다소 놀랍습니다.
이 실험 이후에는 속도가 중요한 코드에서 OpenStruct를 사용하는 것을 꺼리게 됩니다. 또한 성능이 중요한 코드 경로에서 해시를 발견하면 경계하는 눈으로 바라보게 될 것입니다.
읽기 / 쓰기 성능 비교
초기화와 달리, 값을 설정하고 접근하는 측면에서는 네 가지 방식 모두 거의 비슷한 성능을 보입니다.

읽기/쓰기 벤치마크에서는 Struct, 클래스, 해시, OpenStruct 간에 큰 차이가 없습니다
벤치마크 스크립트 전문
직접 시스템에서 벤치마크를 실행해보고 싶다면 아래 스크립트를 사용해 보세요. 필자는 OSX 환경의 MRI 2.1에서 실행했습니다. 다른 루비 인터프리터에서의 성능이 궁금하다면, Michael Cohen이 MRI 2.2, JRuby 등의 결과를 정리해둔 훌륭한 gist를 참고하시기 바랍니다.
require 'benchmark/ips'
require 'ostruct'
data = { x: 100, y: 200 }
PointStruct = Struct.new(:x, :y)
class PointClass
attr_accessor :x, :y
def initialize(args)
@x = args.fetch(:x)
@y = args.fetch(:y)
end
end
puts "\n\nINITIALIZATION =========="
Benchmark.ips do |x|
x.report("PointStruct") { PointStruct.new(100, 200) }
x.report("PointClass") { PointClass.new(data) }
x.report("Hash") { Hash.new.merge(data) }
x.report("OpenStruct") { OpenStruct.new(data) }
end
puts "\n\nREAD =========="
point_struct = PointStruct.new(100, 200)
point_class = PointClass.new(data)
point_hash = Hash.new.merge(data)
point_open_struct = OpenStruct.new(data)
Benchmark.ips do |x|
x.report("PointStruct") { point_struct.x }
x.report("PointClass") { point_class.x }
x.report("Hash") { point_hash.fetch(:x) }
x.report("OpenStruct") { point_open_struct.x }
end
puts "\n\nWRITE =========="
Benchmark.ips do |x|
x.report("PointStruct") { point_struct.x = 1 }
x.report("PointClass") { point_class.x = 1 }
x.report("Hash") { point_hash[:x] = 1 }
x.report("OpenStruct") { point_open_struct.x = 1 }
end