Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

메모이제이션(Memoization)으로 Rails 애플리케이션 속도 개선하기

애플리케이션을 개발하다 보면 실행 속도가 느린 메서드를 자주 마주하게 됩니다. 데이터베이스를 조회해야 하거나 외부 서비스를 호출해야 하는 경우가 대표적이며, 이런 작업들은 메서드의 실행 속도를 크게 늦출 수 있습니다. 데이터가 필요할 때마다 메서드를 호출하고 그 오버헤드를 그대로 감수할 수도 있지만, 성능이 중요한 상황이라면 몇 가지 대안을 고려해볼 필요가 있습니다.

첫 번째 방법은 데이터를 변수에 할당한 뒤 재사용하는 것입니다. 속도는 빨라지지만, 해당 변수를 직접 관리하는 일은 금세 번거로워질 수 있습니다.

그렇다면 '느린 작업'을 수행하는 메서드가 스스로 그 변수를 관리하도록 만들면 어떨까요? 메서드를 호출하는 방식은 그대로 유지하면서, 메서드 내부에서 데이터를 저장하고 재사용하는 것입니다. 바로 이것이 메모이제이션(memoization)입니다.

간단히 말해, 메모이제이션이란 메서드의 반환 값을 저장해 두어 매번 다시 계산하지 않도록 하는 기법입니다. 모든 캐싱과 마찬가지로, 메모리를 시간으로 교환하는 셈입니다(값을 저장하기 위한 메모리를 희생하는 대신, 메서드를 처리하는 데 드는 시간을 절약합니다).

값을 메모이제이션하는 방법

Ruby는 or-equals 연산자인 ||=를 사용해 아주 깔끔하게 값을 메모이제이션할 수 있습니다. 이 연산자는 좌변과 우변 사이에 논리 OR(||)를 적용한 결과를 왼쪽 변수에 할당합니다. 실제 사용 예시는 다음과 같습니다.

value ||= expensive_method(123)

# 논리적으로 동일한 코드:
value = (value || expensive_method(123))

메모이제이션의 동작 원리

이 동작 방식을 이해하려면 두 가지 개념, 즉 '참 같은 값(truthy)과 거짓 같은 값(falsey)' 그리고 '지연 평가(lazy evaluation)'를 알아야 합니다. 먼저 truthy와 falsey부터 살펴보겠습니다.

Truthy와 Falsey

Ruby에는(거의 모든 언어와 마찬가지로) 불리언 값인 truefalse를 위한 키워드가 내장되어 있으며, 예상대로 동작합니다.

if true
  # 항상 실행됩니다
end

if false
  # 절대 실행되지 않습니다
end

그런데 Ruby(및 많은 다른 언어들)에는 'truthy'와 'falsey'라는 개념도 있습니다. 즉, 값들이 마치 truefalse인 것처럼 취급될 수 있다는 의미입니다. Ruby에서는 오직 nilfalse만이 falsey이며, 그 외의 모든 값(0 포함)은 true로 취급됩니다(참고: 언어마다 선택이 다릅니다. 예를 들어 C 언어에서는 0을 false로 취급합니다). 앞선 예시를 다시 활용하면 다음과 같이 쓸 수도 있습니다.

value = "abc123" # 문자열
if value
  # 항상 실행됩니다
end

value = nil
if value
  # 절대 실행되지 않습니다
end

지연 평가(Lazy Evaluation)

지연 평가는 프로그래밍 언어에서 매우 흔히 볼 수 있는 최적화 기법으로, 프로그램이 불필요한 연산을 건너뛰도록 해줍니다.

논리 OR 연산자(||)는 좌변 또는 우변 중 하나라도 참이면 참을 반환합니다. 따라서 좌변이 이미 참이라면 결과가 참이라는 것이 확정된 상태이므로 우변을 평가할 필요가 없습니다. 이를 직접 구현한다면 다음과 같은 형태가 될 것입니다.

def logical_or(lhs, rhs)
  return lhs if lhs

  rhs
end

만약 lhsrhs가 함수(예: 람다)라면, rhslhs가 falsey일 때만 실행된다는 것을 알 수 있습니다.

Or-Equals 연산자

truthy-falsey 값과 지연 평가라는 두 개념을 결합하면 ||= 연산자가 무엇을 하는지 명확해집니다.

value # 기본값은 nil
value ||= "test"
value ||= "blah"
puts value
=> test

초기화되지 않았기 때문에 valuenil로 시작합니다. 첫 번째 ||= 연산자를 만나면, 이 시점의 value는 falsey이므로 우변("test")이 평가되고 그 결과가 value에 할당됩니다. 이제 두 번째 ||= 연산자를 만나면, 이번에는 value"test"라는 값을 가진 truthy 상태입니다. 따라서 우변의 평가는 건너뛰고 value는 그대로 유지됩니다.

메모이제이션 사용 시점 판단하기

메모이제이션을 적용할 때는 스스로에게 몇 가지 질문을 던져야 합니다. 이 값은 얼마나 자주 접근되는가? 무엇이 값을 변경시키는가? 값은 얼마나 자주 변경되는가?

값이 한 번만 접근된다면 캐싱은 큰 효과가 없습니다. 값에 접근하는 빈도가 높을수록 캐싱으로 얻는 이점도 커집니다.

무엇이 값을 변경시키는지 파악하려면 메서드에서 사용하는 값들을 살펴봐야 합니다. 인자를 받는 메서드인가요? 그렇다면 메모이제이션도 이를 고려해야 할 가능성이 높습니다. 저는 개인적으로 이런 경우 인자 처리를 자동으로 해주는 memoist 젬(gem)을 사용하는 것을 선호합니다.

마지막으로 값이 얼마나 자주 변경되는지 고려해야 합니다. 값을 변경시키는 인스턴스 변수가 있는가? 값이 변경될 때 캐시된 값을 비워야 하는가? 객체 수준에서 캐싱해야 하는가, 아니면 클래스 수준에서 캐싱해야 하는가?

이러한 질문들에 답하기 위해 간단한 예제를 통해 결정 과정을 단계별로 살펴보겠습니다.

class ProfitLossReport
  def initialize(title, expenses, invoices)
    @expenses = expenses
    @invoices = invoices
    @title = title
  end

  def title
    "#{@title} #{Time.current}"
  end

  def cost
    @expenses.sum(:amount)
  end

  def revenue
    @invoices.sum(:amount)
  end

  def profit
    revenue - cost
  end

  def average_profit(months)
    profit / months.to_f
  end
end

호출하는 코드는 여기에 나오지 않지만, title 메서드는 아마 한 번만 호출될 것이고 Time.current를 사용하기 때문에 메모이제이션을 적용하면 값이 순식간에 낡아버릴(stale) 수 있습니다.

revenuecost 메서드는 이 클래스 내에서만 해도 여러 번 호출됩니다. 두 메서드 모두 데이터베이스에 접근해야 하므로, 성능 문제가 발생한다면 메모이제이션의 최우선 후보입니다. 이들을 메모이제이션했다면 profit은 굳이 메모이제이션할 필요가 없습니다. 미미한 이득을 위해 캐싱 위에 캐싱을 얹는 꼴이 되기 때문입니다.

마지막으로 average_profit이 있습니다. 이 값은 전달되는 인자에 의존하므로 메모이제이션도 이를 반영해야 합니다. revenue처럼 단순한 경우라면 다음과 같이 하면 됩니다.

def revenue
  @revenue ||= @invoices.sum(:amount)
end

하지만 average_profit의 경우 전달되는 인자마다 서로 다른 값을 저장해야 합니다. memoist를 사용할 수도 있지만, 여기서는 명확성을 위해 직접 해결책을 만들어 보겠습니다.

def average_profit(months)
  @average_profit ||= {}
  @average_profit[months] ||= profit / months.to_f
end

여기서는 해시(hash)를 사용해 계산된 값들을 추적합니다. 먼저 @average_profit이 초기화되었는지 확인한 뒤, 전달된 인자를 해시 키로 사용합니다.

클래스 수준 vs 인스턴스 수준 메모이제이션

대부분의 경우 메모이제이션은 인스턴스 수준에서 수행됩니다. 즉, 계산된 값을 인스턴스 변수에 보관하는 방식입니다. 이는 새로운 객체 인스턴스를 생성할 때마다 '캐시된' 값의 혜택을 받지 못한다는 의미이기도 합니다. 아주 간단한 예시로 살펴보겠습니다.

class MemoizedDemo
  def value
    @value ||= computed_value
  end

  def computed_value
    puts "Crunching Numbers"
    rand(100)
  end
end

이 객체를 사용하면 다음과 같은 결과를 볼 수 있습니다.

demo = MemoizedDemo.new
=> #<MemoizedDemo:0x00007f95e5d9d398>

demo.value
Crunching Numbers
=> 19

demo.value
=> 19

MemoizedDemo.new.value
Crunching Numbers
=> 93

메모이제이션된 값을 클래스 수준 변수(@@)로 변경하면 이 동작을 바꿀 수 있습니다.

  def value
    @@value ||= computed_value
  end

결과는 다음과 같이 달라집니다.

demo = MemoizedDemo.new
=> #<MemoizedDemo:0x00007f95e5d9d398>
demo.value
Crunching Numbers
=> 60
demo.value
=> 60
MemoizedDemo.new.value
=> 60

클래스 수준 메모이제이션이 필요한 경우는 많지 않겠지만, 선택지로 존재하기는 합니다. 다만 이 수준에서 값을 캐싱해야 한다면 Redis나 memcached 같은 외부 스토어를 활용하는 방안을 검토해보는 것이 좋습니다.

Rails 애플리케이션에서 흔한 메모이제이션 활용 사례

Rails 애플리케이션에서 제가 가장 자주 보는 메모이제이션 활용 사례는 데이터베이스 호출을 줄이는 것입니다. 특히 하나의 요청(request) 안에서 값이 변하지 않는 경우에 유용합니다. 컨트롤러에서 레코드를 조회하는 '파인더(finder)' 메서드가 대표적인 예입니다.

  def current_user
    @current_user ||= User.find(params[:user_id])
  end

또 다른 흔한 사례는 데코레이터(decorator)/프레젠터(presenter)/뷰 모델(view-model) 유형의 아키텍처를 사용해 뷰를 렌더링하는 경우입니다. 이런 객체들의 메서드는 요청 수명 동안만 존재하고, 데이터가 대체로 변경되지 않으며, 뷰를 렌더링하는 동안 일부 메서드가 여러 번 호출될 가능성이 높기 때문에 메모이제이션의 좋은 후보가 됩니다.

메모이제이션의 함정들

가장 큰 함정 중 하나는 사실 필요하지 않은 것까지 메모이제이션하는 것입니다. 문자열 보간(string interpolation) 같은 것들은 메모이제이션의 쉬운 후처럼 보이지만, 실제로는 사이트 성능에 눈에 띄는 영향을 주지 않는 경우가 대부분입니다(물론 비정상적으로 큰 문자열을 다루거나 대량의 문자열 조작을 수행하는 경우는 예외입니다).

  def title
    # 여기서의 메모이제이션은 성능에 큰 영향을 주지 못합니다
    @title ||= "#{@object.published_at} - #{@object.title}"
  end

주의해야 할 또 다른 사항은 우리의 오랜 친구인 캐시 무효화(cache invalidation) 문제입니다. 특히 메모이제이션된 값이 객체의 상태에 의존하는 경우 더욱 그렇습니다. 이를 예방하는 한 가지 방법은 가능한 가장 낮은 수준에서 캐싱하는 것입니다. a + b를 계산하는 메서드를 캐싱하는 대신, ab 메서드를 각각 캐싱하는 편이 나을 수 있습니다.

  # 이렇게 하지 말고
  def profit
    # 'revenue'나 'losses'를 호출하는 다른 코드는 여기서의 캐싱 혜택을 받지 못합니다
    # 그리고 'revenue'나 'losses' 값이 변경되면 profit 업데이트를 잊지 않을 수 있을까요?
    @profit ||= (revenue - losses)
  end

  # 이렇게 해보세요
  def profit
    # 캐싱하지 않지만, 뺄셈은 빠른 연산입니다
    revenue - losses
  end

  def revenue
    @revenue ||= Invoice.all.sum(:amount)
  end

  def losses
    @losses ||= Purchase.all.sum(:amount)
  end

마지막 함정은 지연 평가의 동작 방식에서 비롯됩니다. falsey 값(즉, nil 또는 false)을 메모이제이션해야 한다면 약간의 커스텀 처리가 필요합니다. 저장된 값이 falsey이면 ||= 관용구는 항상 우변을 실행하기 때문입니다. 경험상 이런 값을 캐싱해야 하는 경우는 드물지만, 필요하다면 이미 계산되었음을 표시하는 불리언 플래그를 추가하거나 다른 캐싱 메커니즘을 사용해야 할 수 있습니다.

  def last_post
    # 사용자가 게시글이 없으면 이 메서드가 호출될 때마다 데이터베이스에 접근하게 됩니다
    @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first
  end

  # 간단한 우회 방법:
  def last_post
    return @last_post if @last_post_checked

    @last_post_checked = true
    @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first
  end

메모이제이션만으로 부족할 때

메모이제이션은 애플리케이션의 성능을 개선하는 저렴하고 효과적인 방법이지만, 단점이 없는 것은 아닙니다. 가장 큰 문제 중 하나는 영속성(persistence)입니다. 일반적인 인스턴스 수준 메모이제이션의 경우 값은 해당 객체에만 저장됩니다. 이 덕분에 메모이제이션은 웹 요청 수명 동안 값을 유지하는 데는 훌륭하지만, 여러 요청에 걸쳐 동일한 값이 매번 다시 계산되는 상황에서는 캐싱의 온전한 이점을 누릴 수 없습니다.

클래스 수준 메모이제이션이 이 문제를 어느 정도 해결해줄 수 있지만, 캐시 무효화 관리가 더 어려워집니다. 게다가 서버가 재부팅되면 캐시된 값이 모두 사라지며, 여러 웹 서버 간에 값을 공유할 수도 없습니다.

이 캐싱 시리즈의 다음 호에서는 이러한 문제들에 대한 Rails의 해법, 즉 로우 레벨 캐싱(low-level caching)을 살펴보겠습니다. 로우 레벨 캐싱을 사용하면 여러 서버에서 공유할 수 있는 외부 스토어에 값을 캐싱하고, 만료 시간(expiry timeout)과 동적 캐시 키(dynamic cache key)를 통해 캐시 무효화를 관리할 수 있습니다.