오늘은 메서드에 별칭을 붙이는 기능을 사용하여 메서드의 이름을 그대로 사용하면서 기능을 확장해 보도록 하겠다.
루비에는 메서드의 별칭을 만드는 두 가지 방법이 있는데, 하나는 alias 키워드를 사용하는 방법이고, 다른 하나는 alias_method 메서드를 사용하는 방법이다.
alias는 어디서나 편하게 사용할 수 있는 반면, 별칭과 메서드 명을 문자열이나 심볼이 아닌 이름 그대로 줘야 하기 때문에 유연성이 좀 떨어진다.
그에 반해, alias_method는 메서드이므로 별칭과 메서드 명을 인수로 건넬 수 있기 때문에 별칭을 동적으로 생성하는 것도 가능하다.
물론, 아래처럼 별칭을 랜덤하게 생성할 일은 거의 없겠지만, 어쨌든 원하는 별칭을 동적으로 만들 수 있다는 걸 알 수 있다.
alias_method 메서드는 Module 클래스에서 정의한 인스턴스 메서드로서 모든 클래스와 모듈에서 호출이 가능하다.
Class 클래스는 Module 클래스를 상속 받는데, Object 클래스를 포함한 모든 클래스는 Class 클래스의 인스턴스이고,
Kernel 모듈을 포함한 모든 모듈은 Module 클래스의 인스턴스이므로, 결국 모든 클래스와 모듈에서 alias_method 메서드를 호출할 수 있게 된다.
이와 관련하여 자세한 설명은 '객체와 클래스' 그리고 '클래스와 모듈' 글을 참고하길 바란다.
아래 그림에서 메서드 fib는 피보나치 수열의 n번째 항의 값을 계산해서 돌려주는 간단한 메서드이다.
피보나치 수열은 0, 1로 시작해서 그 다음 항부터는 바로 이전의 두 항을 더한 값이 된다.
특정 항의 피보나치 수를 계산해서 돌려주는 메서드는 반복문이나 재귀 호출을 사용해서 구현할 수 있는데, 아래 fib 라는 메서드는 재귀 호출을 사용해서 작성하였다.
컴퓨터의 성능에 따라 다소 차이는 있겠지만, 피보나치 수열을 얻기 위해 단순히 재귀 호출을 하도록 구현해 놓으면 n의 값이 특정 범위를 넘어갈 경우 성능히 확연히 나빠지게 된다.
예를 들어 fib(10)을 호출하면 내부에서 fib(8)와 fib(9)을 호출하고, fib(8)의 호출은 또다시 fib(6)와 fib(7)을 호출한다. 즉 n이 커질수로 재귀 호출 횟수는 급격하게 늘어난다.
아래 fib 메서드의 실행 시간을 측정하기 위해 measure_time_of_fib 메서드를 만들었고, fib(35) 부터 fib(40) 까지 각각 얼마만큼의 시간이 소요되는지 실행시켜 보았다.
결과를 보면 n이 35, 36일 때는 그나마 1초 미만(이것도 긴 시간이긴 하다.) 걸리던 것이 37부터는 1초를 넘어서더니 40에 이르러서는 거의 5초가 걸렸다.
컴퓨터의 성능이 좋다하더라도 이런식으로 프로그램을 작성하여 컴퓨터의 자원을 낭비하는 것은 바람직하지 않으므로, fib 메서드의 성능을 개선해 보도록 하자.
물론, 재귀 호출 대신 반복문을 사용하여 구현하면 성능이 확실히 개선되긴 하지만, 프로그램 실행 시간에 특정 메서드의 기능을 동적으로 확장시키는 예를 보여주기 위해서, 재귀 호출 방식 그대로 두고 메모이제이션을 사용하여 성능을 개선해 보려고 한다.
아래 그림은 '단어 검색기 만들기' 글에서 함께 만들어 봤던 프로그램을 사용하여 'memoization' 이라는 단어를 검색한 결과이다.
아래 그림을 보면 기존 fib 메서드를 약간 수정하였는데, n이 1 이하일 경우 n 값 그대로 돌려주는 것은 변함이 없고, n이 2 이상일 경우 재귀 호출을 하기 전에 먼저 @memo 해시에서 해당 값을 찾도록 수정하였다.
결과를 보면 이전 fib 메서드와는 비교도 안될 정도로 빠르게 동작하는 것을 볼 수 있다.
예를 들어, fib(5)를 실행하면 아직 @memo 해시에 fib(5)에 대한 값이 없으므로, 'fib(5 - 2) + fib(5 - 1)'에서 fib(5 - 2)을 먼저 실행하게 되고 fib(3)에 대한 값 역시 @memo 해시에 없으므로 또다시 'fib(3 - 2) + fib(3 - 1)'에서 fib(3 - 2)가 호출된다.
fib(1)에 대한 호출은 1을 바로 돌려주므로, 그다음 fib(3 - 1)이 호출되는데 fib(2)에 대한 값도 @memo 해시에 없으므로 fib(2)가 다시 호출되고 'fib(2 - 2) + fib(2 - 1)'의 값인 1이 처음으로 @memo 해시에 저장되게 된다.
그리고 fib(3)의 값인 2가 이어서 @memo 해시에 저장된다. 이제 'fib(5 - 2) + fib(5 - 1)'에서 fib(5 - 2)의 값은 알았고 fib(5 - 1)을 호출할 차례이다.
그런데, fib(4)의 값이 아직 @memo 해시에 없지만 'fib(4 - 2) + fib(4 - 1)'에서 fib(2)와 fib(3)의 값은 이미 @memo 해시에 저장되어 있으므로, 기존의 재귀 호출을 다시 반복할 필요 없이 @memo 해시에서 바로 가져온 값으로 계산해서 돌려줄 수 있다.
그리고 fib(4)의 값도 @memo 해시에 저장이 되고 최종적으로 fib(5)의 값이 @memo 해시에 저장이 된 후 반환된다.
n의 값이 클수록 @memo 해시에서 바로 가져오는 값의 빈도가 올라가게 되어 성능의 이점을 더 크게 느낄 수 있다.
||= 는 OR 연산자(||)와 할당 연산자(=)를 축약해서 쓴 형태로, 'a ||= b' 라는 코드가 있을 때, a(변수)가 이미 정의되어 있고 false도 nil도 아니라면 최종 값은 a의 값이 되며 b(표현식)는 실행(평가)되지 않고 무시된다.
그리고 만약 a가 정의되어 있지 않거나 false 또는 nil이라면, b(표현식)를 실행(평가)한 값을 a에 할당하고 그 값이 최종 값이 된다.
루비 프로그램에서 최상위 레벨에서 실행되는 코드는 Object 클래스의 인스턴스(irb에서 self.class를 실행해 보면 알 수 있다.) 안에서 실행이 되므로, irb에서 간단히 테스트해 보기 위해 인스턴스 변수(@memo)를 정의해서 사용하였다.
이제 원하는 클래스의 어떤 메서드가 메모이제이션 기능을 갖도록 동적으로 기능을 확장해 줄 수 있는 모듈을 만들어보자.
아래 memoize라는 인스턴스 메서드를 하나 가지고 있는 Memoizable 모듈의 코드가 보인다.
memoize 메서드는 메모이제이션 기능을 추가할 대상 메서드의 이름을 인수로 받아, 이름의 끝에 '_org'를 붙여서 org_method 변수에 담아 놓고, 그 것을 인수로 받은 메서드에 대한 별칭으로 사용한다.
이제 원래 기능의 메서드를 호출할 수 있는 또다른 이름이 생겼으니, 대상 메서드의 이름과 동일한 이름으로 메모이제이션 기능이 있는 메서드를 새롭게 정의하면 된다.
define_method 메서드를 사용하면 메서드를 동적으로 정의할 수 있는데, 인수로 메서드의 이름을 주고 메서드의 실행 내용은 블록으로 전달하면 된다.
그런데 define_method에 전달할 블록에서 메서드 호출 결괏값을 저장하고 참조할 해시 객체가 필요하므로, 빈 해시를 갖는 memo 변수를 미리 선언해 놓았다.
그다음 memoize 메서드가 받은 인수를 그대로 인수로 사용하여 블록과 함께 define_method 메서드를 호출하였다.
블록 안에서는 파라미터로 받은 값을 키로하여 memo 해시에서 먼저 메서드의 결괏값을 찾아보고, 없으면 send 메서드를 사용하여 원래의 메서드를 호출하도록 하였다.
이 블록 안의 코드는 앞서 fib 메서드에 메모이제이션 기능을 직접 구현해 넣었던 것과 유사한 형태인데, 다만 피보나치 수 0과 1도 memo 해시에 저장된다는 것이 다르다.
module Memoizable
def memoize(method_name)
org_method = "#{method_name}_org"
alias_method org_method, method_name
memo = {}
define_method(method_name) do |*args|
memo[args] ||= send(org_method, *args)
end
end
end
이제 Memoizable 모듈을 테스트해 보기 위해 아래처럼 메모이제이션 기능이 없는 Fibonacci 클래스의 코드를 D:/blog/ruby/extend/fibonacci.rb 파일에 작성하여 저장하자.
그리고 Memoizable 모듈 역시 D:/blog/ruby/extend 폴더 아래 memoizable.rb 파일에 저장하자.
class Fibonacci
def get(n)
return n if n <= 1
get(n - 2) + get(n - 1)
end
end
위의 두 파일이 포함된 D:/blog/ruby/extend 폴더에서 irb를 실행한후, 아래처럼 코드를 작성하여 동적으로 메서드의 기능이 확장되는 걸 직접 확인해 보자.
Fibonacci 클래스의 객체를 하나 생성하여 fib 변수에 담아 fib.get(40)을 실행해 보면 피보나치 수 102334155을 돌려주는데 시간이 거의 5초가 걸렸다.
그다음 extend 메서드를 사용하여 Memoizable 모듈을 Fibonacci 클래스에 인클루드한 후, Fibonacci 클래스에 대해 memoize 메서드를 실행하였다.
다시 fib.get(40)을 실행해 보면, 이번에는 메모이제이션을 통해 결괏값이 상당히 빨리 나오는 것을 볼 수 있다.
위의 코드를 보면 Fibonacci 클래스의 get 메서드 뿐만 아니라 실제 Fibonacci 클래스 자체도 기능이 확장되었는데, 어떤 클래스가 extend 메서드를 사용하여 모듈을 인클루드하게 되면 모듈에 정의된 인스턴스 메서드를 클래스 자체에서 호출할 수 있게 된다.
앞의 그림을 다시 보면 Fibonacci 클래스에 Memoizable 모듈을 인클루드한 후에 Fibonacci 클래스의 메서드 목록에 :memoize 가 포함된 걸 볼 수 있다.
인클루드를 통한 믹스인에 대해 더 자세한 내용을 알고 싶으면 '클래스와 모듈' 글을 참고하길 바란다.
오늘은 alias_method와 define_method를 사용하여 메서드의 기능을 동적으로 확장하여 메서드의 성능을 향상시켜 보았다.
메서드의 성능 향상을 위해 메모이제이션이라는 방법을 사용했는데, 오늘 예제에서처럼 같은 인수로 빈번하게 호출되는 경우(당연히 인수가 같을 경우 결과도 같아야 한다.)에 효과가 좋다.
그리고 해시를 사용하여 메모리에 결과 데이터를 저장했는데, 실전에서는 메모리에 너무 많은 데이터가 계속해서 쌓이지 않도록 메모리의 데이터를 정리하는 방법도 함께 고민해야 할 수도 있다.
오늘 학습을 위해 만들어본 메모이제이션 기능은 이미 잘 만들어진 gem들이 오픈되어 있어 간단히 설치하여 사용할 수 있다.
이전에 한 번 소개했던 Ruby Toolbox 사이트에서 조회해 보니, 관련 gem들 중에 현재 'memoist'라는 gem이 가장 다운로드 수가 많은 gem으로 조회가 되었다. 설치해서 직접 사용해 보길 바란다.
다음 글에서는 메서드의 실행 시간을 측정하여 보여주는 기능을 동적으로 추가할 수 있게 만들어 보겠다.
See you again~~