배열에 데이터가 가득한데, 마치 해시처럼 키/값(key/value) 조회를 하고 싶었던 적이 있으신가요? 다행히 Ruby는 배열을 키-값 구조처럼 다룰 수 있는 메커니즘을 제공합니다. 지금부터 자세히 살펴보겠습니다!
Array#assoc와 Array#rassoc 소개
주식 추천 기계를 하나 받았다고 상상해 봅시다. 이 기계는 몇 분마다 특정 주식을 사거나 팔라는 추천을 내놓습니다. 여러분은 이 기계를 컴퓨터에 성공적으로 연결했고, 이제 아래와 같은 형태의 데이터 스트림을 수신하고 있습니다.
picks = [
["AAPL", "buy"],
["GOOG", "sell"],
["MSFT", "sell"]
]
구글(GOOG)에 대한 최신 추천을 찾으려면 Array#assoc 메서드를 사용하면 됩니다.
# row[0] == "GOOG"인 첫 번째 행을 반환합니다
picks.assoc("GOOG") # => ["GOOG", "sell"]
반대로 최신 "매도(sell)" 추천을 찾으려면 Array#rassoc 메서드를 사용하면 됩니다.
# row[1] == "sell"인 첫 번째 행을 반환합니다
picks.rassoc("sell") # => ["GOOG", "sell"]
일치하는 항목이 없으면 두 메서드 모두 nil을 반환합니다.
picks.assoc("CSCO") # => nil
picks.rassoc("hold") # => nil
히스토리 데이터 보존하기
해시는 하나의 키에 여러 값을 저장할 수 없습니다. 하지만 배열은 중복 항목을 원하는 만큼 저장할 수 있습니다. 이런 경우 assoc과 rassoc 메서드는 합리적으로 동작하여, 발견한 첫 번째 일치 행을 반환합니다. 덕분에 꽤 흥미로운 활용이 가능합니다.
앞서 상상한 주식 추천 기계는 계속해서 데이터 스트림을 제공합니다. 언젠가는 특정 종목에 대한 입장을 바꿔서, 이전에 "팔아라"라고 했던 주식을 "사라"라고 추천할 수도 있습니다. 그렇게 되면 데이터는 다음과 같은 모습이 됩니다.
picks = [
["GOOG", "buy"],
["AAPL", "sell"],
["AAPL", "buy"],
["GOOG", "sell"],
["MSFT", "sell"]
]
이 데이터를 해시에 저장했다면, 특정 종목의 추천을 갱신하는 순간 그 종목의 이전 추천 기록은 모두 사라졌을 것입니다. 하지만 배열이라면 이야기가 다릅니다. 새로운 추천을 배열 앞에 계속 추가하기만 하면, Array#assoc이 항상 가장 최근의 추천을 반환해 준다는 것을 알 수 있습니다.
# row[0] == "GOOG"인 첫 번째 행을 반환합니다
picks.assoc("GOOG") # => ["GOOG", "buy"]
즉, 해시의 키-값 편리함은 그대로 누리면서, 감사 추적(audit trail)까지 무료로 얻을 수 있는 셈입니다.
두 개 이상의 열 다루기
assoc의 또 다른 장점은 각 배열이 반드시 두 개의 열만 가져야 하는 것은 아니라는 점입니다. 원하는 만큼 많은 열을 가질 수 있습니다. 예를 들어 매수/매도 추천마다 타임스탬프를 추가한다고 가정해 봅시다.
picks = [
["AAPL", "buy", "2015-08-17 12:11:55 -0700"],
["GOOG", "sell", "2015-08-17 12:10:00 -0700"],
["MSFT", "sell", "2015-08-17 12:09:00 -0700"]
]
이제 assoc 또는 rassoc를 사용하면 타임스탬프까지 함께 반환됩니다.
# 전체 행이 반환됩니다
picks.assoc("GOOG") # => ["GOOG", "sell", "2015-08-17 12:10:00 -0700"]
CSV 등 열이 많은 파일 형식의 데이터를 다룰 때 이 기능이 얼마나 유용할지 짐작할 수 있을 것입니다.
성능은 어떨까?
대부분의 벤치마크에서 Ruby의 해시는 확실히 Array#assoc보다 빠른 성능을 보여줍니다. 데이터셋이 커질수록 그 차이는 더욱 두드러집니다. 해시 테이블 검색은 O(1)인 반면, 배열 검색은 O(n)이기 때문입니다. 다만 실제로는 데이터 크기와 사용 환경에 따라 그 차이가 문제가 되지 않는 경우가 많습니다.
재미 삼아 10행짜리 데이터셋과 100,000행짜리 데이터셋에 대해 해시 조회와 assoc 조회를 비교하는 간단한 벤치마크를 작성해 보았습니다. 예상대로 작은 데이터셋에서는 해시와 배열이 비슷한 성능을 보였지만, 큰 데이터셋에서는 해시가 압도적인 우위를 보였습니다.
물론 공정하게 말하자면, 이 테스트는 배열의 마지막 요소를 검색하는 방식으로 진행되었습니다. 이는 배열 검색에 있어 최악의 시나리오입니다.
require 'benchmark/ips'
require 'securerandom'
Benchmark.ips do |x|
x.time = 5
x.warmup = 2
short_array = (0..10).map { |i| [SecureRandom.hex(), i] }
short_hash = Hash[short_array]
short_key = short_array.last.first
long_array = (0..100_000).map { |i| [SecureRandom.hex(), i] }
long_hash = Hash[long_array]
long_key = short_array.last.first
x.report("short_array") { short_array.assoc(short_key) }
x.report("short_hash") { short_hash[short_key] }
x.report("long_array") { long_array.assoc(long_key) }
x.report("long_hash") { long_hash[long_key] }
x.compare!
end
# Calculating -------------------------------------
# short_array 91.882k i/100ms
# short_hash 149.430k i/100ms
# long_array 19.000 i/100ms
# long_hash 152.086k i/100ms
# -------------------------------------------------
# short_array 1.828M (± 3.4%) i/s - 9.188M
# short_hash 6.500M (± 4.8%) i/s - 32.426M
# long_array 205.416 (± 3.9%) i/s - 1.026k
# long_hash 6.974M (± 4.2%) i/s - 34.828M
# Comparison:
# long_hash: 6974073.6 i/s
# short_hash: 6500207.2 i/s - 1.07x slower
# short_array: 1827628.6 i/s - 3.82x slower
# long_array: 205.4 i/s - 33950.98x slower