오늘은 성능 최적화 기법 중에서도 특히 즐겨 사용하는 방법 하나를 소개하려고 합니다. 이 기법은 작지만 확실한 성능 향상을 누적시켜 주는 데다가, 아주 드물게—정말 아주 드물게만—애플리케이션을 잿더미로 만들곤 합니다.
이 기술의 이름은 메모이제이션(memoization)입니다. 어려워 보이는 컴퓨터 공학 용어와 달리, 그 의미는 아주 단순합니다. 메서드를 호출할 때마다 똑같은 작업을 반복하는 대신, 반환 값을 변수에 저장해 두고 이후에는 저장된 값을 재사용하는 것입니다.
메모이제이션이란?
의사코드(pseudocode)로 표현하면 다음과 같습니다:
def my_method
@memo = <작업> if @memo가 정의되지 않았다면
return @memo
end
루비로 구현하면 아래와 같습니다. 이 방식이 가장 견고하지만 다소 장황하다는 단점이 있습니다. 더 간결한 방식들은 뒤에서 살펴보겠습니다.
class MyClass
def my_method
unless defined?(@my_method)
@my_method = begin
# 계산, 데이터베이스 쿼리 등
# 오래 걸리는 작업을 여기에 작성합니다.
end
end
@my_method
end
end
위 코드는 세 가지 작업을 수행합니다:
@my_method라는 이름의 인스턴스 변수가 정의되어 있는지 확인합니다.- 정의되어 있지 않다면, 작업을 수행하고 결과를
@my_method에 저장합니다. @my_method를 반환합니다.
메서드와 인스턴스 변수의 이름이 모두 my_method라서 혼란스러울 수 있습니다. 사실 변수 이름은 무엇이든 지을 수 있지만, 관례상 메모이제이션되는 메서드 이름과 동일하게 짓는 것이 일반적입니다.
축약형 문법: ||= 연산자
위 코드의 문제점은 다소 번거롭다는 것입니다. 실무에서는 거의 동일하게 동작하는 축약형 버전을 훨씬 자주 접하게 됩니다:
class MyClass
def my_method1
@my_method1 ||= some_long_calculation
end
def my_method2
@my_method2 ||= begin
# begin-end 블록을 사용하면
# 여러 줄의 코드를 손쉽게 작성할 수 있습니다.
end
end
end
두 예제 모두 루비의 a ||= b 연산자를 사용하는데, 이는 a || (a = b)의 축약형이며, 조금 더 풀어서 설명하면 다음과 같습니다:
# 실제 코드에서는 이렇게 return을 쓰지 않습니다.
# 초보자를 위해 조건문이 `a`에 담긴 값을 평가한다는
# 개념을 표현하기 위한 예시일 뿐입니다.
if a
return a
else
a = b
return a
end
버그의 원인이 될 수 있는 함정
눈썰미가 좋은 독자라면 이미 눈치챘겠지만, 축약형 버전은 변수의 존재 여부를 검사하는 것이 아니라 변수의 참(truthy) 여부를 평가합니다. 이것이 축약형의 주요 한계 중 하나입니다. 바로 nil과 false는 절대 메모이제이션되지 않는다는 점입니다.
이 한계가 문제가 되지 않는 경우도 많습니다. 하지만 메모이제이션을 사용할 때마다 머릿속 한켠에 늘 기억해 두어야 하는 성가신 사실임에는 분명합니다.
인자가 있는 메서드 메모이제이션하기
지금까지는 단일 값만 메모이제이션했습니다. 하지만 매번 같은 결과를 반환하는 함수는 많지 않습니다. 기술 면접의 단골 손님인 피보나치 수열을 예로 들어 보겠습니다.
루비에서 피보나치 수열은 다음과 같이 재귀적으로 계산할 수 있습니다:
class Fibonacci
def self.calculate(n)
return n if n == 0 || n == 1
calculate(n - 1) + calculate(n - 2)
end
end
Fibonacci.calculate(10) # => 55
문제는 이 구현이 비효율적이라는 것입니다. 이를 확인하기 위해 print 문을 추가해 n의 값을 출력해 보겠습니다.
class Fibonacci
def self.calculate(n)
print "#{ n } "
return n if n == 0 || n == 1
calculate(n - 1) + calculate(n - 2)
end
end
Fibonacci.calculate(4)
# 출력: 4 3 2 1 0 1 2 1 0
보다시피 calculate가 같은 n 값으로 반복적으로 호출됩니다. 실제로 calculate 호출 횟수는 n이 커짐에 따라 기하급수적으로 증가합니다.
이를 해결하는 한 가지 방법은 calculate의 결과를 메모이제이션하는 것입니다. 앞서 살펴본 예제들과 크게 다르지 않습니다.
class Fibonacci
def self.calculate(n)
@calculate ||= {}
@calculate[n] ||= begin
print "#{ n } "
if n == 0 || n == 1
n
else
calculate(n - 1) + calculate(n - 2)
end
end
end
end
Fibonacci.calculate(4)
# 출력: 4 3 2 1 0
이제 calculate를 메모이제이션했으므로, 호출 횟수가 n에 따라 기하급수적으로 늘어나지 않습니다.
Fibonacci.calculate(20)
# 출력: 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
메모이제이션의 무효화(Invalidation)
메모이제이션은 캐싱과 매우 유사하지만, 한 가지 큰 차이가 있습니다. 메모이제이션된 결과는 자동으로 만료되지 않으며, 한 번 설정된 값을 손쉽게 지울 방법도 보통 없다는 점입니다.
피보나치 수열 생성기처럼 입력이 항상 같은 결과를 내는 경우라면 이런 특성은 전혀 문제가 되지 않습니다. Fibonacci.calculate(10)은 언제나 동일한 값을 반환합니다. 하지만 다른 경우라면 이야기가 달라집니다.
예를 들어 다음과 같은 코드를 본 적이 있을 겁니다:
# 좋은 생각은 아닙니다
class User
def full_name
@full_name ||= [first_name, last_name].join(" ")
end
end
개인적으로는 여기에 메모이제이션을 사용하지 않겠습니다. 이름(first name)이나 성(last name)이 변경되었을 때 전체 이름(full_name)이 갱신되지 않을 수 있기 때문입니다.
다만 Rails 컨트롤러 안에서는 다소 느슨해져도 괜찮습니다. 다음과 같은 코드는 매우 흔하게 볼 수 있습니다:
class ApplicationController
def current_user
@current_user ||= User.find(...)
end
end
이것이 허용되는 이유는 컨트롤러 인스턴스가 웹 요청(request)마다 소멸되기 때문입니다. 일반적인 요청 처리 도중 로그인된 사용자가 바뀔 일은 거의 없습니다.
반면 ActionCable 같은 스트리밍 연결을 다룰 때는 좀 더 신중해야 할 수 있습니다. 정확히 말씀드리기는 어렵네요. 저는 직접 써 본 적이 없거든요.
과유불급: 메모이제이션 남용 주의
마지막으로 강조하고 싶은 점은, 어떤 기술이든 마찬가지지만 메모이제이션 역시 과하게 사용하면 오히려 해가 될 수 있다는 것입니다. 이 기법은 원칙적으로 비용이 많이 드는 작업 중에서도, 메모이제이션 변수의 생명주기 동안 결과가 절대 변하지 않는 경우에만 적용해야 합니다.