Ruby 3x3 성능 개선 프로젝트를 주목해 왔다면 Optcarrot이라는 이름을 들어보셨을 겁니다. 순수 Ruby만으로 작성된 NES 에뮬레이터입니다.
최근 Optcarrot의 소스 코드를 살펴보다가 흥미로운 부분 하나가 눈에 띄었습니다. 이 프로젝트는 Ruby 해시(Hash)의 자주 간과되지만 매우 유용한 기능을 폭넓게 활용하고 있는데, 바로 어떤 객체든 해시 키로 사용할 수 있다는 점입니다.
배경: NES 메모리 매핑
고수준 언어에 익숙한 개발자라면 메모리를 단순히 RAM으로 생각하는 경향이 있습니다. 하지만 더 낮은 수준에서 '메모리'는 훨씬 다양한 용도로 사용됩니다.
NES의 CPU는 메모리를 읽고 쓰는 방식으로 GPU, 컨트롤 패드, 카트리지에 장착된 특수 전자 회로와 통신합니다. 접근하는 주소에 따라 write_to_memory 메서드 호출은 조이스틱을 리셋하거나, VRAM을 교체하거나, 심지어 소리를 재생할 수도 있습니다.
그렇다면 이것을 Ruby로 어떻게 구현할 수 있을까요?
Optcarrot은 65,536개 주소 각각에 대해 두 개의 Method 객체를 저장하는 방식으로 이를 구현합니다. 하나는 getter, 다른 하나는 setter 역할을 하며, 대략 다음과 같은 형태입니다:
@getter_methods[0x0001] = @ram.method(:[])
@setter_methods[0x0001] = @ram.method(:[]=)
문제: 중복 객체의 생성
이런 방식으로 Object#method를 사용할 때의 문제점은 동일한 동작을 하는 Method 객체가 매번 새로 생성된다는 것입니다.
object_id를 통해 직접 확인해 볼 수 있습니다:
> a = []
> a.method(:[]=).object_id
=> 70142391223600
> a.method(:[]=).object_id
=> 70142391912420
두 Method 객체는 서로 다른 object_id 값을 가집니다. 즉, 같은 일을 수행하더라도 완전히 별개의 객체인 셈입니다.
평소라면 몇 개의 여분 객체 정도는 신경 쓰지 않아도 되겠지만, 이 경우에는 수천 개의 중복 객체를 다루게 됩니다.
해결책: 해시를 이용한 메모이제이션
Optcarrot은 너무나 단순해서 오히려 놓치기 쉬운 트릭으로 이 중복 문제를 해결합니다.
바로 해시를 활용해 결과를 메모이제이션(memoization)하고 중복을 제거하는 것입니다. 아래의 간소화된 코드가 이 기법을 잘 보여줍니다:
def initialize
@setter_methods = []
@setter_cache = {}
...
end
def add_setter(address, setter)
# 중복 객체를 저장하지 않음
@setter_cache[setter] ||= setter
# 중복이 제거된 버전을 사용
@setter_methods[address] = @setter_cache[setter]
end
이 기법이 작동하는 이유는 Hash가 키로 어떤 종류의 객체든 받아들일 수 있기 때문입니다.
다소 생소하게 느껴진다면 IRB에서 문자열로 먼저 실험해 보세요:
> cache = {}
> cache["foo"] ||= "bar"
=> "bar"
> cache["foo"] ||= "baz"
=> "bar"
여기서 한 가지 생각해 볼 점이 있습니다. Ruby에서 문자열은 String 클래스의 인스턴스입니다. Ruby가 문자열을 해시 키로 처리하는 메커니즘은 위 예제에서 Method 객체를 저장하는 데 사용되는 원리와 본질적으로 동일합니다.
Hash는 동등성을 어떻게 판단할까?
문자열이 아닌 객체를 해시 키로 사용할 때 자연스러운 의문이 생깁니다. Hash는 대체 두 객체가 같은지 어떻게 알까요?
비밀은 Object#hash 메서드에 있습니다. 이 메서드는 객체 내부를 순회하며 재귀적으로 해시 값을 생성합니다:
> a.method(:[]=).hash
=> 929915641391564853
동일한 객체는 항상 동일한 해시 값을 반환하므로, 이를 동등성 검사 도구로 활용할 수 있습니다:
a.hash == b.hash
흥미롭게도 이는 eql? 메서드가 내부적으로 사용하는 것과 같은 방식입니다:
a.eql?(b)
실제로 우리 예제의 Method 객체에서도 정상적으로 작동합니다:
> a.method(:[]=).hash == a.method(:[]=).hash
=> true
마치며
Ruby 웹 개발 패턴에 익숙해져 있는 상태에서 Optcarrot의 소스를 들여다보며, 실시간으로 동작하는 비웹 애플리케이션이 어떻게 전혀 다른 설계 패턴을 활용하는지 배울 수 있어 매우 흥미로웠습니다. 웹 애플리케이션에서는 65,536개 요소를 가진 배열을 만들 일이 사실상 없겠지만, '데스크톱' 앱의 초기화 과정에서는 충분히 합리적인 선택입니다.
질문이나 의견이 있으시다면 starr@honeybadger.io로 이메일을 보내시거나 Twitter의 @StarrHorne으로 연락해 주세요.