Shim Won

December 24, 2014 12:3 pm

번역 Hashie는 위험해요 - 해쉬와 OpenStruct를 위한 송시

흔한 뉴비 프로그레머의 잘못된 믿음으로 “해쉬는 모든 곳에서 사용되어야 한다”고 생각하는 것이 있습니다. 해쉬는 덕지덕지 붙여지고 그러지 말아야 할 곳에서 쓰여지죠. 해쉬를 만들고 넘기는 것보다 그냥 루비 객체를 사용하는 것이 아마 더 좋을 겁니다. 결국 해쉬가 객체처럼 동작하길 원하기 시작할 거고 이는 매우 끔찍할 겁니다. 이제 보여드리죠.^1

저는 해쉬도 객체도 사랑합니다. 값을 해쉬에 넣을 수 있고 객체에 로직을 넣을 수 있습니다. 왜인지 알려면 좀더 파봐야 합니다. 루비의 해쉬는 멍청한 데이터 구조입니다. 키를 잘못 입력해도, 경고나 에러가 나오지 않죠. 해쉬에서 커스텀 세터와 게터를 사용할 쉬운 방법은 없습니다. 해쉬는 빠르고 유연합니다. 해쉬는 데이터를 넘길때에 그럭 저럭 동작하긴 합니다만, 제어와 구조화된 방식으로 저장하기엔 부족합니다. 이해를 돕기 위해, 이 예제를 보시죠.

hash = {}
hash[:spellning] = "richard"

클래스와 다르게 키를 잘못 입력하더래도, 에러가 발생하지 않습니다.

class Foo
  attr_accessor :spelling
end
Foo.new.spellning = "richard"
# => NoMethodError: undefined method `spellning=' for #<Foo:0x007fbf2388bb40>

이 에러는 조기에 실수에 대한 피드백을 제공하기에 아주 가치가 있습니다. 해쉬로는 나중에 hash[:spelling]값에 접근하려하지 않는 이상 에러가 발생하지않습니다. nil 이 반환될 뿐이죠. 이경우에는 우리는 에러의 원인이 되는 줄을 사냥해야하고, 지치고 피곤할 때 하면 매우 절망적입니다. 루비 객체를 사용하면, 나중에 찾지 않아도, 에러가 생긴 지점에서 이 피드백을 받을 수 있죠.

어떤 관점에서는 “으음… 해쉬, 객체처럼 보이는 데.. 오브젝트처럼 값을 넣고 뺄수만 있으면 좋지 않을까?” 라고 생각할 수도 있습니다. 잘하면, 이런때에 OpenStruct를 발견 할 수도 있습니다. 이는 기본적으로 해쉬와 같은 일을 하지만, 사용자 정의 객체의 접근자를 가지고 있죠.

require 'ostruct'
foo = OpenStruct.new
foo.spellning = "richard"

좋아요, 에러는 없지만 객체같은 “느낌”은 나네요. Open struct는 데이터를 넘길 때 편리하게 사용할 수 있습니다만, 해쉬와 비슷한 제약이 있습니다. 더욱이, OpenStruct에는 해쉬같은 연사자가 있습니다. 거의 해쉬처럼 사용할 수 있죠. 여기서의 포인트는 “거의” 입니다.

require 'ostruct'
foo = OpenStruct.new
foo["spelling"] = "richard"
puts foo["spelling"] # => "richard"
puts foo.spelling # => "richard"

무척 해쉬와 비슷해 보입니다. open struct가 못하는건 무엇일까요? 어디 비교해 봅시다.

open_struct_methods = OpenStruct.new.methods
hash_methods        = {}.methods
missing_methods     = hash_methods - open_struct_methods
puts missing_methods
# => [:rehash, :to_hash, :to_a, :fetch, :store, :default, :default=, :default_proc, :default_proc=, :key, :index, :size, :length, :empty?, :each_value, :each_key, :each, :keys, :values, :values_at, :shift, :delete, :delete_if, :keep_if, :select, :select!, :reject, :reject!, :clear, :invert, :update, :replace, :merge!, :merge, :assoc, :rassoc, :flatten, :include?, :member?, :has_key?, :has_value?, :key?, :value?, :compare_by_identity, :compare_by_identity?, :entries, :sort, :sort_by, :grep, :count, :find, :detect, :find_index, :find_all, :collect, :map, :flat_map, :collect_concat, :inject, :reduce, :partition, :group_by, :first, :all?, :any?, :one?, :none?, :min, :max, :minmax, :min_by, :max_by, :minmax_by, :each_with_index, :reverse_each, :each_entry, :each_slice, :each_cons, :each_with_object, :zip, :take, :take_while, :drop, :drop_while, :cycle, :chunk, :slice_before, :lazy]

오… 무척 많이 다르네요. open struct는 테이터 스토어 보다는 객체에 가깝게 동작하네요. merge!같은 조작 메소드도 없고, empty?같은 정보 메소드도 없습니다. 그럴듯 하죠. 언제 객체를 머지해본적이나 있나요?

User.new.merge(user)
# => NoMethodError: undefined method `merge' for #<User:0x007fa26b9170e8>
User.new.empty?
or: undefined method `empty?' for #<User:0x007fa26b9170e8>

값 객체(Value Objects)

저는 HashOpenStruct를 값 객체로 구분합니다. 왜냐면, 값을 전달하기 좋지만, 보통의 사용자정의 객체처럼 동작하지는 않기 때문이죠. 데이터를 계속 저장(persisting)하거나 복잡한 로직을 추상화하는데에는 좋지 않습니다. 예를들어, 유저 이름이 항상 대문자로 시작해야한다는 룰이 있다면, 객체에서는 쉽게 구현 가능합니다.

class User
  attr_reader :name
  def name=(name)
    @name = name.capitalize
  end
end
user = User.new
user.name = "schneems"
puts user.name
# => "Schneems"

하지만 이 기능은 hash나 Open Struct에서는 구현하기 어렵습니다.

해쉬 사용하기

아마도, 값을 전달하는데 해쉬를 사용하는 것은 익숙한 일이겠죠.

def link_to(name, path, options = {})
  # ...

여기서 options은 해쉬입니다. 하지만, 해쉬는 너무 유연해서 추가로 에러 체크를 할 필요가 있습니다. 예를들면, 중요한 키가 존재하는지를 보장하거나 예상치 못한 값(스트링을 예상했지만 누군가 스트링을 넘겼다던가)이 들어있거나 같은 경우가 그렇죠.

OpenStruct 사용하기

Open Struct를 사용하는 것은 좀 덜 분명합니다. 라이브러리와 통신할 때 객체 인풋을 요구한다면, Open Struct를 사용해 객체로 속일 수 있겠죠.

my_values = {foo: "bar"}
objectish = OpenStruct.new(my_values)
OtherLibraray.new(objectish)

open structs는 콘솔에서 새 코드를 시험 할 때 유용합니다. 가끔 테스트 인터페이스 코드로 사용합니다. 하지만 솔직히, 저는 많이 사용하지는 않습니다. 보통, open struct를 사용해야 할 때는 그냥 루비 객체이 더 적합하기 거든요. Open Struct보다 해쉬 안의 데이터를 조작하는 것이 더 쉽습니다. 왜냐하면 해쉬는 메타 메소드도 가지고 있고, 더 가벼우니까요. (Open Struct는 내부적으로 해쉬를 만들어 저장합니다.)

보통 해쉬와 비해 꽤나 느린 OpenStruct는 아무 쓸모가 없습니다.

require 'benchmark/ips'
require 'ostruct'
hash        = {foo: "bar"}
open_struct = OpenStruct.new(hash)
Benchmark.ips do |x|
  x.report("openstruct") { open_struct[:foo] }
  x.report("hash")       { hash[:foo] }
end

결과:

Calculating -------------------------------------
          openstruct   128.619k i/100ms
                hash   149.182k i/100ms
-------------------------------------------------
          openstruct      5.329M (± 7.4%) i/s -     26.496M
                hash      8.451M (± 3.9%) i/s -     42.219M

이 속도차와 인터페이스의 혼동때문에, 프로덕션에서 사용하는 코드에는 OpenStruct를 사용하지 않는것을 권장합니다. 어떻게 구현되어있는지 보려면 OpenStruct 소스 코드 (루비에요)를 한번 보세요. 왜 훨씬 느린지 이해하는데 도움이 될 겁니다.

Hashie는 어떤가요?

이 시점에서는 OpenStructHash 객체를 만들어 open struct와 해쉬의 양쪽 동작을 할 수있게 만드는 것이 그럴듯 해 보입니다. 이 것은 정확히 Hashie가 (특히 Hashie::Mash)가 하는 일입니다. 불행히도, 이 생각은 매우 않좋습니다. 저는 여러 프로젝트에서 Hashie를 사용하려 했지만 항상 실망하고 짜증나 때려치웠습니다. Hashie가 깊게 박혀있는 다른 사람의 코드도 사용해 봤지만, 항상 Hashie는 문제가 됐습니다. 왜나구요?

Hashie는 두가지 일을 동시에 하려고 합니다. Hashie는 Hash의 모든 조작 메소드와 OpenStruct의 모든 접근 메소드를 가지고 있습니다. 이 말은 객체가 이제 표면적이고 많은 정체성에 문제가있는(surface area and an identity crisis) 많은 메소드를 가지고 있다는 이야기입니다.

hashie = Hashie::Mash.new(name: "schneems")
hashie[:name]   # => "schneems"
hashie["name"]  # => "schneems"
hashie.name     # => "schneems"

간단한 해쉬만 사용한다면, 그렇게 나쁘진 않습니다. 하지만 다른메소드가 필요하다면, 그냥 해쉬를 사용하세요. 수도-객체를 가지면 문제가 생깁니다. 예를들어, 다른 객체와 대화할 때 어떻게 동작 할까요. 머지 할 때 hashie 객체의 값이 우선되길 원한다고 해봅시다, 그래서 hashie 객체를 Hash#merge 메소드에 넘겼습니다.

hashie = Hashie::Mash.new(name: "schneems")
hash   = { job: "programmer" }
result = hash.merge(hashie)
result.name
# => NoMethodError: undefined method `name' for {"name" => "schneems", job: "programmer"}

참 구리죠. 눈치채셨습니까? Hashie::Mash는 편의를 위해 hash를 스트링과 심볼로 접근하게 합니다. 그래서 이런 어떤 키는 스트링이고 어떤 키는 심볼인 괴상한 result 가 나오죠.

puts result.inspect # => {:job=>"programmer", "name"=>"schneems"}

정말 괴상합니다. 해쉬를 두번 merge! 할때, 보통 같은 결과를 예상합니다.

hash1  = {name: "schneems"}
hash2  = {job: "programmer"}
result = hash1.merge!(hash2)
puts result.inspect # => {:job=>"programmer", :name=>"schneems"}
result = hash2.merge!(hash1)
puts result.inspect # => {:job=>"programmer", :name=>"schneems"}

하지만 hashie는..

hashie = Hashie::Mash.new(name: "schneems")
hash   = {job: "programmer"}
result = hashie.merge!(hash)
puts result.to_hash.inspect # => {"name"=>"schneems", "job"=>"programmer"}
result = hash.merge!(hashie)
puts result.to_hash.inspect # => {:job=>"programmer", "name"=>"schneems", "job"=>"programmer"}

음? 이제 키가 다르고 (:job, ”job”) 값이 같은 두개의 값이 나오네요. 이건 사실 “버그”는 아닙니다. 말하자면, hashie는 할 수 있는 최선의 것을 하려고 노력했지만, 이 경우는 hashie가 충분한 정보를 가지고 있지 않으니 할수 없죠. 단순 머지이외에도 많은 문제가 있지만, 적은 줄의 코드로는 설명하기 힘드네요.

Hashie - 더 나빠짐

해쉬에 여러 억세스(스트링, 심볼) 방법을 가지는 것은 정말 편리합니다. 그래서 어떤 분은 hashie를 이 용도로 사용할 수 있습니다. 레일스에서는 HashWithIndifferentAccess가 이 일을 하고, 매우 도움이 됩니다. “젠장 스트링을 썻는데 사실 심볼이었어”는 해쉬를 사용할 때 일반적이고 고통스러운 에러니까요. 하지만, Hashie를 사용하면 대부분 거기에서 그치지 않습니다.

대부분의 사람은 hashie를 설정 속성을 적절히 정의하는 것을 방해하지 않도록 하기 위한 설정 객체나, API 의 JSON응답에서 “싸게” 객체를 만드는데 사용합니다. 두 경우 다 끔찍한 결정입니다. 설정의 경우, 유저에게 잘못된 설정(오타, 설정의 벨리데이션이 없음 등등)의 가능성을 만들어 주게 됩니다. 벨리데이션은 hashie::mash 객체에 만들어 줄 수는 있지만, 그렇게 간단한 일은 아닙니다.

class MyConfigOptions < Hashie::Mash
  def name=(value)
    raise "not a string #{value}" unless value.is_a?(String)
    self["name"] = value
  end
end
config = MyConfigOptions.new
config["name"] = 99
puts config.name.inspect # => 99
puts config.name.class   # => Fixnum

으어어어… def []=(key, value)도 재정의 할 수 있지만, 누가 해쉬의 초기화에 넘겨주면 어떻게 될까요? 물론, 거기도 재정의 해야죠. Hashie 는 그런 경우를 위한 내부 핼퍼 메소드를 몇가지 가지고 있습니다만… 그냥 클래스를 사용하면 안될까요? 이용자들을 도우려면 의미있는 에러메세지와 행동을 하게 하세요. 이용자들이 객체와 대화하길 원한다면 객체를 넘겨주세요. 해쉬를 넘기길 원한다면, 해쉬를 주세요. 이용자에게 양쪽 행동을 다하는 수도 객체를 주면 이상한 엣지 케이스와 혼란을 야기할 뿐입니다. 이렇게 적는게 더 쉽습니다.

class MyConfigOptions
  attr_accessor :name
  def name=(value)
    raise "not a string #{value}" unless value.is_a?(String)
    @name = value
  end
end

이제, 덤으로 마법처럼 문서화된 인터페이스도 얻었네요!

더(더욱) 나빠짐 - 메모리 오버로드

어떤 이유로 당신이 이 이상한 엣지케이스가 있는 생태를 좋아하고 수도 객체 행동의 단점에 동의 할수 없다고 가정해 봅시다. Hashie를 정말 유명한 프로젝트(omniauth)에 사용하기로 했다고하면 hashie가 내부적으로 매우 많은, 매우 높은 비용의 수명이 짧은 객체를 자주 생성해 환장하게 될 것입니다. ^2

Omniauth를 사용하는 레일스 엡 CodeTriage.com을 분석해봤습니다. 메모리 사용량을 밴치마크하기 위해 memoryprofiler 와 제가 만든 derailedbenchmarks 를 사용하였습니다. 하나의 단일 리퀘스트를 레일스 엡에 요청할 때, Hashie는 activesupport보다 많은 객체를 생성했습니다. 목록의 3번째 입니다.

allocated memory by gem
-----------------------------------
rack-1.5.2 x 6491
actionpack-4.1.2 x 4888
hashie-2.1.1 x 4615        <========= Hashie 사용
activesupport-4.1.2 x 4523
omniauth-1.2.1 x 1387
actionview-4.1.2 x 1107
ruby-2.1.2/lib x 1097
railties-4.1.2 x 925
activerecord-4.1.2 x 440
warden-1.2.3 x 200

작은 이득치고는 많은 객체네요. 이게 성능에 영향을 미칠까요? 물론 그렇죠.

몇시간을 들여 Omniauth을 포크해 Hashie를 제거해봤는데, 메모리 절감이 명백히 보입니다.

allocated memory by gem
-----------------------------------
rack-1.5.2 x 6491
actionpack-4.1.2 x 4888
activesupport-4.1.2 x 3292
ruby-2.1.2/lib x 1337      <========= Hashie 를 Open Struct로 대체
omniauth/lib x 1267
actionview-4.1.2 x 1107
railties-4.1.2 x 925
activerecord-4.1.2 x 440
warden-1.2.3 x 200
codetriage-ko1-test-app/app x 40
other x 40

저의 수정은 표준 라이브러리에 있는 OpenStruct에서 상속한 커스텀 객체를 사용하였고, 4615개 보다 훨씩 적은 1337개의 오브젝트를 생성하는것을 볼 수 있습니다. 이 수정은 속도에도 측정할 수 있을 만큼의 효과가 있습니다. 조정이 필요하긴 하지만, 벤치마크 결과 총 리퀘스트에서 5%의 속도 향상이 나타났습니다. omniauth뿐만 아니라 전체 어플의 리퀘스트 시간의 5%의 향상입니다.

불행히도, 이 수정을 증명을 위한 것으로 깨진 수정입니다.(API가 100% 호환하지 않습니다.) 더 자세한 내용은 omniauth에서 hashie를 제거하기 위한PR에서 보실 수 있습니다.

대안

금연을 하는 가장 쉬운 방법은 피지 않는 것입니다. hashie에 중독된 프로젝트를 상속한다면 무엇을 할 수 있을까요? Omniauth에서는 hashie를 제거하고 모든 테스트를 실패하게 한 다음, 전부 그린이 될 때까지 하나하나 작업했습니다. Omniauth의 경우, 정말 유명하기 때문에 적절한 사용 중단 권고(deprecation warning)없이 인터페이스를 변경할 수 없습니다. 이상적으로는, 앞으로는 코드가 사용는 방법을 분리해, 좀 더 빠르고 스트릭트한 인터페이스로 대체하고 싶습니다.

정말 임의 값을 취할 필요가 있다면, 그냥 루비의 해쉬를 사용하세요. 정말 점을 사용해 메소드 억세스를 할 필요가 있다면, open struct를 사용하세요. 물론, 그냥 루비 객체를 사용하면 더 좋습니다. 객체에서 hashie를 사용하고 있고 로직으로 감싸여 있다면, hashie를 제거하고 로직은 그냥 두세요. Hash를 상속하는건 매우 나쁩니다. 이는 hash의 상속은 고통과 성능 문제의 원인이 된다에서 증명된 사실™입니다. 하지 마세요.

제가 Hashie를 상당히 까긴 했지만, 꽤 좋고 가지고놀기 재미있는 라이브러리이고 코드에서 메타프로그래밍을 꽤 배울 수 있습니다. 살펴보기는 권장합니다만, 무엇을 하던 간에 절대 프로덕션에서 사용하지 마세요.

-- Hashie 를 사용하지 않거나, 성능에 관심이 있으시면 트위터에서 @schneems를 팔로우 하세요.

1: PORO(Plain old ruby object)를 “그냥 루비 객체”라고 번역했어요. 2: 아무래도 발번역한거 같아 원문을 붙여둡니다. The insanely open behavior that you crave so much come at a very high cost of large numbers of short-lived objects used internally by hashie.