메모이제이션(Memoization)은 접근자 메서드의 실행 속도를 높이는 데 활용할 수 있는 기법입니다. 시간이 오래 걸리는 작업, 즉 딱 한 번만 수행하면 되는 작업의 결과를 캐싱해 두는 방식이죠. Rails에서는 메모이제이션이 워낙 널리 쓰이다 보니, 메서드를 자동으로 메모이즈해 주는 모듈까지 포함된 적이 있었습니다.
이후 해당 모듈은 논란 끝에 제거되었고, 지금부터 소개할 가장 보편적인 메모이제이션 패턴을 직접 작성하는 방식으로 대체되었습니다. 하지만 이 기본 패턴만으로는 제대로 동작하지 않는 상황도 분명히 존재합니다. 그래서 이번 글에서는 더 고급스러운 메모이제이션 패턴들을 단계별로 살펴보며, 그 과정에서 알게 되는 Ruby의 숨은 기능들도 함께 소개해 드리겠습니다.
1. 초간단 메모이제이션
Ruby 코드에서 가장 자주 마주치는 메모이제이션 패턴은 다음과 같습니다:
class User < ActiveRecord::Base
def twitter_followers
# twitter_user.followers가 네트워크 호출을 수행한다고 가정
@twitter_followers ||= twitter_user.followers
end
end
||=는 대략 @twitter_followers = @twitter_followers || twitter_user.followers로 해석됩니다. 즉, 네트워크 호출은 twitter_followers 메서드가 처음 호출될 때 한 번만 발생하고, 이후 호출에서는 인스턴스 변수 @twitter_followers에 저장된 값을 그대로 반환합니다.
2. 여러 줄에 걸친 메모이제이션
그런데 때로는 느린 로직을 코드를 엉망으로 만들지 않고 한 줄에 담기 어려운 경우가 있습니다. 기본 패턴을 여러 줄 코드에 확장하는 방법은 몇 가지가 있는데, 저는 다음 방식을 가장 선호합니다:
class User < ActiveRecord::Base
def main_address
@main_address ||= begin
maybe_main_address = home_address if prefers_home_address?
maybe_main_address = work_address unless maybe_main_address
maybe_main_address = addresses.first unless maybe_main_address
end
end
end
begin...end는 C 계열 언어의 {...}와 비슷하게, 여러 줄의 코드를 마치 하나의 값처럼 다룰 수 있게 해주는 블록입니다. 덕분에 ||=가 첫 번째 예제와 완전히 동일하게 동작하는 것이죠.
3. nil은 어떻게 처리할까?
하지만 위의 패턴들에는 숨겨진 함정이 하나 있습니다. 첫 번째 예제에서 사용자가 트위터 계정이 없어서 API가 nil을 반환하면 어떻게 될까요? 두 번째 예제에서 사용자가 주소를 하나도 갖고 있지 않아 블록이 nil을 반환한다면요?
그런 경우 메서드를 호출할 때마다 인스턴스 변수는 계속 nil로 남아 있고, 값비싼 조회 작업이 매번 반복 수행됩니다. 캐싱이 전혀 이루어지지 않는 것이죠.
따라서 이런 상황에서는 ||=가 올바른 도구가 아닙니다. 대신 'nil'과 '아직 정의되지 않은 상태(undefined)'를 구분해 주어야 합니다:
class User < ActiveRecord::Base
def twitter_followers
return @twitter_followers if defined? @twitter_followers
@twitter_followers = twitter_user.followers
end
end
class User < ActiveRecord::Base
def main_address
return @main_address if defined? @main_address
@main_address = begin
main_address = home_address if prefers_home_address?
main_address ||= work_address
main_address ||= addresses.first # 나름 합리적인 기본값
end
end
end
안타깝게도 코드가 다소 길어지긴 하지만, 이 방식은 nil, false를 포함해 어떤 값이든 정확하게 캐싱합니다. (참고로 nil 자체를 없애는 접근도 가능합니다. Null Object 패턴이나 빈 배열을 활용하면 이 문제를 원천적으로 피할 수 있죠. nil을 지양해야 하는 또 하나의 이유입니다!)
4. 파라미터가 있는 메서드는?
지금까지 살펴본 패턴들은 단순한 접근자 메서드에는 훌륭하게 들어맞습니다. 하지만 파라미터를 받는 메서드를 메모이즈하고 싶다면 어떻게 해야 할까요?
class City < ActiveRecord::Base
def self.top_cities(order_by)
where(top_city: true).order(order_by).to_a
end
end
흥미롭게도 Ruby의 Hash는 이 상황에 완벽하게 맞는 초기화 방식을 제공합니다. Hash.new에 블록을 넘기면 됩니다:
Hash.new {|h, key| h[key] = some_calculated_value }
이렇게 하면 아직 값이 설정되지 않은 키에 접근할 때마다 블록이 실행되고, 블록에는 해시 자신과 접근하려던 키가 함께 전달됩니다.
따라서 위 메서드를 메모이즈하려면 다음과 같이 작성할 수 있습니다:
class City < ActiveRecord::Base
def self.top_cities(order_by)
@top_cities ||= Hash.new do |h, key|
h[key] = where(top_city: true).order(key).to_a
end
@top_cities[order_by]
end
end
이제 order_by에 무엇을 넘기든 각각의 올바른 결과가 개별적으로 메모이즈됩니다. 게다가 블록은 해당 키가 존재하지 않을 때만 실행되므로, 결과가 nil이나 false인 경우를 걱정할 필요도 없습니다.
더 놀라운 점은, Hash가 배열을 키로 사용하는 것도 아무 문제 없이 지원한다는 사실입니다:
h = {}
h[["a", "b"]] = "c"
h[["a", "b"]] # => "c"
즉, 이 패턴을 활용하면 파라미터가 몇 개든 상관없이 어떤 메서드든 메모이즈할 수 있습니다!
굳이 이런 수고를 들여야 할까?
물론 수많은 메서드에 이런 메모이제이션 코드를 일일이 붙이다 보면, 코드는 금세 읽기 어려워집니다. 메서드 안이 실질적인 로직보다는 형식적인 의식(ceremony)으로 가득 차 버리죠.
그래서 메모이제이션이 많이 필요한 애플리케이션을 개발 중이라면, 깔끔하고 친절한 API로 이 작업을 대신 처리해 주는 젬(gem)을 사용하는 편이 좋습니다. Memoist는 Rails의 옛 기능과 상당히 유사한 검증된 젬입니다. (혹은 오늘 배운 내용을 바탕으로 직접 하나 만들어 보는 것도 훌륭한 학습 경험이 될 겁니다.)
하지만 이런 패턴들을 하나씩 뜯어보며 구조를 이해하고, 어디서 잘 동작하는지, 어디에 위험한 모서리가 숨어 있는지 파악하는 과정 자체가 언제나 흥미롭습니다. 그리고 그 여정에서 잘 알려지지 않은 Ruby 기능들에 대한 유용한 지식도 자연스럽게 쌓이게 됩니다.