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

복잡한 데이터 모델을 더 빠르게 캐싱하는 방법

데이터 모델이 복잡해지고 API 응답 시간이 안타깝게도 1초에 육박한다면, 보통 간단한 해결책이 있습니다. 바로 :includes입니다. 모델의 연관 관계(association)를 미리 로드(preload)하면 SQL 호출 횟수를 크게 줄일 수 있고, 그만큼 많은 시간을 절약할 수 있습니다.

그런데 이후에 다시 사이트가 느려지면, 이번에는 응답 자체를 캐싱하는 방법을 고민하게 됩니다. 그리고 여기서 문제가 발생합니다. 캐시에서 응답을 가져오려면 보통 다음과 같은 코드를 작성하게 되는데:

results = {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}
cached_objects = Rails.cache.fetch_multi(results.keys) do |key|
  Lawyer.find(results[key]).as_json
end

이렇게 되면 기존에 누리던 :includes의 이점을 모두 잃어버립니다. 두 가지를 동시에 얻을 수는 없을까요? 캐시된 객체는 빠르게 응답하면서도, 캐시에 없는 객체 역시 빠르게 로드하려면 어떻게 해야 할까요?

해야 할 일이 많다 보니 문제를 한꺼번에 생각하기 어렵습니다. 이럴 때는 문제를 작은 조각으로 나누고, 단순한 '다음 단계'를 하나씩 정해 나가는 것이 훨씬 수월합니다.

그럼 가장 먼저 무엇을 해야 할까요? 무엇이든 진행하려면 우선 어떤 객체가 캐시에 들어 있는지, 그리고 어떤 객체를 아직 찾아야 하는지부터 파악해야 합니다.

캐시된 것과 캐시되지 않은 것 분리하기

예를 들어 다음과 같은 캐시 키들이 있다고 가정해 봅시다:

cache_keys = [:key_1, :key_2, :key_3]

이 중 어떤 키가 캐시에 있는지 어떻게 알 수 있을까요?

ActiveSupport::Cache에는 read_multi라는 아주 유용한 메서드가 있습니다:

# lawyer_1만 캐시되어 있는 경우

cache_keys = [:lawyer_1, :lawyer_2, :lawyer_3]
Rails.cache.read_multi(cache_keys) # => {:lawyer_1 => {"id": 1, "name": "Bob the Lawyer"} }

read_multi는 캐시에서 발견된 각 키에 대해 {key: value} 형태의 해시를 반환합니다. 그런데 캐시에 없는 키들은 어떻게 찾을 수 있을까요? 가장 직관적인 방법은 모든 캐시 키를 순회하면서 read_multi가 반환한 해시에 존재하지 않는 키를 골라내는 것입니다:

cache_keys = [:lawyer_1, :lawyer_2, :lawyer_3]
uncached_keys = []

cached_keys_with_values = Rails.cache.read_multi(cache_keys)

cache_keys.each do |key|
  uncached_keys << key unless cached_keys_with_values.has_key?(key)
end

여기까지 진행하면 어떤 것들을 확보하게 될까요?

  • 객체를 가져오고자 했던 모든 캐시 키의 배열
  • 캐시에서 찾은 각 객체에 대한 {key: value} 쌍의 해시
  • 캐시에 없었던 키의 목록

그다음으로 필요한 것은 무엇일까요?

  • 캐시에 없던 키에 해당하는 값들. 가능하다면 한 번에 몰아서 조회하는 것이 좋습니다.

바로 그것이 다음 단계입니다.

캐시되지 않은 값 미리 로드하기

곧 캐시 키를 사용해 객체를 찾아야 하는 상황이 옵니다. 작업을 더 수월하게 만들기 위해 코드를 다음과 같이 바꿔볼 수 있습니다:

cache_identifiers = {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}
cache_keys = cache_identifiers.keys
uncached_keys = []

cached_keys_with_values = Rails.cache.read_multi(cache_keys)

cache_keys.each do |key|
  uncached_keys << key unless cached_keys_with_values.has_key?(key)
end

이제 cache_identifiers는 캐시 키와 함께, 실제로 가져와야 할 객체의 id까지 함께 추적합니다.

이 시점에서 캐시되지 않은 키는 다음과 같고:

uncached_keys # => [:lawyer_2, :lawyer_3]

cache_identifiers 해시는 다음과 같습니다:

cache_identifiers # => {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}

이 정보를 활용하면 해당 객체들을 한 번에 조회하고, 연관 데이터를 미리 로드한 뒤, 직렬화까지 모두 처리할 수 있습니다:

uncached_ids = uncached_keys.map { |key| cache_identifiers[key] }
uncached_lawyers = Lawyer.where(id: uncached_ids)
                         .includes([:address, :practice_areas, :awards, ...])
                         .map(&:as_json)

그럼 지금까지 확보한 것은 다음과 같습니다:

  • 처음에 객체를 가져오고자 했던 모든 캐시 키의 배열
  • 캐시에서 찾은 각 객체에 대한 {key: value} 쌍의 해시
  • 캐시에 없었던 키의 목록
  • 캐시에서 찾지 못했던 값 전체

그리고 다음으로 필요한 것은?

  • 방금 가져온 값들을 캐시에 저장해 두어, 다음 번에는 이 과정 전체를 반복하지 않아도 되도록 하는 것
  • 캐시 출신이든 아니든, 최종적으로 필요한 모든 객체의 목록

캐시되지 않은 값 캐싱하기

지금 두 개의 목록이 있습니다. 하나는 캐시되지 않은 키 목록이고, 다른 하나는 캐시되지 않은 값 목록입니다. 하지만 캐싱하려면 value가 자신의 key 바로 옆에 있는 하나의 [key, value] 쌍 목록으로 합쳐진 편이 훨씬 편리합니다. 이럴 때 제가 가장 좋아하는 메서드 중 하나인 zip을 쓸 명분이 생깁니다:

[1, 2, 3].zip(["a", "b", "c"]) # => [[1, "a"], [2, "b"], [3, "c"]]

zip을 활용하면 방금 가져온 값을 손쉽게 캐시할 수 있습니다:

uncached_keys.zip(uncached_lawyers).each do |key, value|
  Rails.cache.write(key, value)
end

이제 무엇을 갖추게 되었을까요?

  • 처음에 객체를 가져오고자 했던 모든 캐시 키의 배열
  • 캐시에서 찾은 각 객체에 대한 {key: value} 쌍의 해시
  • 방금 캐시에 저장한, 원래는 캐시되지 않았던 값들의 목록

그리고 아직 남은 것은?

  • 캐시 출신이든 데이터베이스 출신이든 상관없이, 모든 객체를 담은 하나의 큰 목록

모두 하나로 합치기

이제 순서가 유지된 캐시 키 목록이 있습니다:

cache_keys = cache_identifiers.keys

캐시에서 가져온 객체 목록도 있고:

cached_keys_with_values = Rails.cache.read_multi(cache_keys)

방금 데이터베이스에서 새로 조회한 객체 목록도 있습니다:

uncached_ids = uncached_keys.map { |key| cache_identifiers[key] }
uncached_lawyers = Lawyer.where(id: uncached_ids)
                         .includes([:address, :practice_areas, :awards, ...])
                         .map(&:as_json)

이제 마지막 루프 하나만 돌면 모든 결과를 하나로 합칠 수 있습니다:

results = []
cache_keys.each do |key|
  results << cached_keys_with_values[key] || uncached_lawyers.shift
end

즉, 각 캐시 키에 대해 캐시에서 찾은 객체를 꺼냅니다. 해당 키가 원래 캐시에 없었다면, 데이터베이스에서 가져온 객체를 순서대로 하나씩 꺼내 채워 넣습니다.

이걸로 끝입니다!

전체 과정을 한눈에 정리하면 다음과 같습니다:

cache_identifiers = {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}
cache_keys = cache_identifiers.keys
uncached_keys = []

# 캐시에서 캐시된 값들을 조회
cached_keys_with_values = Rails.cache.read_multi(cache_keys)

# 캐시에 없던 키 목록 생성
cache_keys.each do |key|
  uncached_keys << key unless cached_keys_with_values.has_key?(key)
end

# 캐시되지 않은 값들을 한 번에 벌크 조회
uncached_ids = uncached_keys.map { |key| cache_identifiers[key] }
uncached_lawyers = Lawyer.where(id: uncached_ids)
                         .includes([:address, :practice_areas, :awards, ...])
                         .map(&:as_json)

# 캐시되지 않았던 값들을 다시 캐시에 저장
uncached_keys.zip(uncached_lawyers).each do |key, value|
  Rails.cache.write(key, value)
end

# 캐시된 값과 새로 조회한 값을 합쳐 최종 결과 생성
results = []
cache_keys.each do |key|
  results << cached_keys_with_values[key] || uncached_lawyers.shift
end
results

이 모든 코드를 작성할 가치가 있을까요? 경우에 따라 다릅니다. 코드 분량이 적지 않으니까요. 하지만 연관 관계가 많은 객체를 캐싱하는 상황이라면, 수십에서 수백 번의 SQL 호출을 줄일 수 있습니다. 그만큼 API 응답 시간을 크게 단축할 수 있습니다.

Avvo에서도 이 패턴이 놀랍도록 유용했습니다. 저희 JSON API 상당수가 이 패턴을 활용해 캐시된 응답을 매우 빠르게 반환하고 있습니다.

이 패턴이 워낙 유용해서, 저는 이를 캡슐화한 bulk_cache_fetcher라는 젬(gem)을 직접 만들기도 했습니다. 규모가 크고 복잡한 데이터 모델을 캐싱해야 하는 상황이라면 꼭 한번 사용해 보시길 권합니다!