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

Rails 숨겨진 보석: ActiveSupport 캐시 증가 및 감소

Rails는 다양한 상황에 유용하게 쓸 수 있는 도구들이 내장된 방대한 프레임워크입니다. 이 시리즈에서는 Rails의 거대한 코드베이스 속에 숨어 있는 잘 알려지지 않은 도구들을 살펴봅니다.

이번 글에서는 Rails.cacheincrementdecrement 메서드에 대해 알아보겠습니다.

Rails.cache 헬퍼

Rails.cache는 애플리케이션의 캐시와 상호작용하기 위한 진입점입니다. 동시에 추상화 계층이기도 하여, 실제로 어떤 캐시 "스토어"를 사용하든 공통된 API로 호출할 수 있습니다. Rails는 기본적으로 다음 스토어들을 지원합니다:

  • FileStore
  • MemoryStore
  • MemCacheStore
  • NullStore
  • RedisCacheStore

Rails.cache를 확인하면 현재 실행 중인 스토어가 무엇인지 알 수 있습니다:

> Rails.cache
=> <#ActiveSupport::Cache::RedisCacheStore options={:namespace=>nil, ...

모두 자세히 다루지는 않겠지만, 간단히 요약하면 다음과 같습니다:

  • NullStore는 아무것도 저장하지 않으며, 읽기를 시도하면 항상 nil을 반환합니다. 새 Rails 앱의 기본값입니다.
  • FileStore는 캐시를 하드 드라이브의 파일로 저장하므로, Rails 서버를 재시작해도 캐시가 유지됩니다.
  • MemoryStore는 캐시를 RAM에 보관하므로, Rails 서버를 중지하면 캐시도 함께 사라집니다.
  • MemCacheStore와 RedisCacheStore는 외부 프로그램(각각 MemCache와 Redis)을 사용해 캐시를 관리합니다.

여러 가지 이유로 처음 세 가지는 주로 개발/테스트 환경에서 사용되며, 프로덕션 환경에서는 Redis나 MemCache를 사용하는 것이 일반적입니다.

Rails.cache는 이러한 서비스 간의 차이를 추상화해 주기 때문에, 코드를 변경하지 않고도 환경마다 다른 스토어를 사용할 수 있습니다. 예를 들어 개발 환경에서는 NullStore, 테스트 환경에서는 MemoryStore, 프로덕션 환경에서는 RedisCacheStore를 사용하는 식입니다.

캐시된 데이터

Rails의 Rails.cache.read, Rails.cache.write, Rails.cache.fetch를 사용하면 임의의 데이터를 손쉽게 캐시에 저장하고 조회할 수 있습니다. 이전 글에서 이들에 대해 더 자세히 다루었는데, 이번 글에서 주목할 점은 이 메서드들에는 기본적으로 스레드 안전성(thread-safety)이 내장되어 있지 않다는 것입니다. 여러 스레드에서 캐시된 데이터를 업데이트하며 카운트를 유지한다고 가정해 보면, 경쟁 상태(race condition)를 피하기 위해 읽기/쓰기 작업을 어떤 형태의 락으로 감싸야 합니다. Redis 캐시 스토어를 사용하도록 설정했다고 가정한 다음 예제를 살펴보겠습니다:

threads = []
# 초기 카운터 설정
Rails.cache.write(:test_counter, 0)

4.times do
  threads << Thread.new do
    100.times do 
      current_count = Rails.cache.read(:test_counter)
      current_count += 1
      Rails.cache.write(:test_counter, current_count)
    end
  end
end

threads.map(&:join)

puts Rails.cache.read(:test_counter)

여기서는 네 개의 스레드가 각각 캐시된 값을 백 번씩 증가시킵니다. 결과는 400이 되어야 하지만, 대부분의 경우 그보다 훨씬 적은 값이 나옵니다. 제 테스트에서는 269가 나왔습니다. 여기서 발생하는 것이 바로 경쟁 상태(race condition)입니다. 이전 글에서 더 자세히 설명했지만, 간단히 요약하자면 스레드들이 모두 같은 "공유" 데이터를 다루기 때문에 서로 동기화가 깨질 수 있습니다. 예를 들어 한 스레드가 값을 읽은 후, 다른 스레드가 그 값을 읽고 증가시킨 뒤 저장할 수 있습니다. 그러면 첫 번째 스레드는 이미 오래된 값을 가지고 작업을 이어가게 됩니다.

이 문제를 해결하는 일반적인 방법은 상호 배제 락(Mutex)으로 코드를 감싸 한 번에 하나의 스레드만 락 내부의 코드를 실행할 수 있도록 하는 것입니다. 하지만 우리 경우에는 다행히 Rails.cache에 이런 시나리오를 처리할 수 있는 메서드가 마련되어 있습니다.

Rails 캐시의 increment와 decrement

Rails.cache 객체에는 앞서 본 카운터 시나리오처럼 캐시된 데이터를 직접 조작할 수 있는 incrementdecrement 메서드가 있습니다:

  threads = []
  # 초기 카운터 설정
  Rails.cache.write(:test_counter, 0, raw: true)

  4.times do
    threads << Thread.new do
      100.times do 
        Rails.cache.increment(:test_counter)
        # 스레드 안전성을 강조하기 위해 반복해서 호출
        Rails.cache.decrement(:test_counter)
        Rails.cache.increment(:test_counter)
      end
    end
  end

  threads.map(&:join)

  puts Rails.cache.read(:test_counter, raw: true)

incrementdecrement를 사용하려면 해당 값이 'raw' 값임을 캐시 스토어에 알려야 합니다(raw: true). 값을 다시 읽을 때도 마찬가지로 지정해야 하며, 그렇지 않으면 오류가 발생합니다. 기본적으로 캐시에 이 값을 단순 정수(bare integer)로 저장하라고 알려주는 것으로, 그래야 increment/decrement를 호출할 수 있습니다. 물론 expires_in 등 다른 캐시 옵션과 함께 사용할 수도 있습니다.

핵심은 incrementdecrement가 원자적(atomic) 연산을 사용한다는 점입니다(적어도 Redis와 MemCache에서는). 원자적 연산은 스레드 안전하며, 연산 도중 스레드가 끼어들 수 없습니다.

참고로, 예제에서는 사용하지 않았지만 두 메서드 모두 새로운 값을 반환합니다. 따라서 카운터 값을 업데이트하는 것 외에 새 값이 필요하다면 추가로 read를 호출하지 않고도 바로 사용할 수 있습니다.

실전 활용 사례

얼핏 보면 incrementdecrement 메서드는 저수준 헬퍼처럼 느껴져, 백그라운드 잡 처리 젬(gem)을 구현하거나 유지보수하는 경우가 아니라면 크게 신경 쓰지 않을 수 있습니다. 하지만 한번 알고 나면 의외로 유용하게 쓸 곳이 많다는 것을 알게 될 겁니다.

저는 프로덕션 애플리케이션에서 예약된 백그라운드 잡이 중복으로 동시에 실행되는 것을 막기 위해 이 메서드들을 사용한 적이 있습니다. 저희 경우 검색 인덱스 업데이트, 이탈 장바구니 표시 등 다양한 예약 잡이 있었습니다. 대체로 잘 동작했지만, 문제는 일부 잡(특히 검색 인덱스)이 많은 메모리를 소모한다는 점이었습니다. 두 개가 동시에 실행되면 Heroku dyno의 메모리 한도를 초과하여 워커가 강제 종료될 수 있었습니다. 이런 잡이 몇 개 있었기 때문에 재시도 금지로 표시하거나 고유 잡(unique job)으로 강제하는 것만으로는 해결되지 않았습니다. 서로 다른(따라서 고유한) 두 잡이 동시에 실행되려 할 수도 있기 때문입니다.

이를 방지하기 위해 저희는 예약 잡용 베이스 클래스를 만들어 현재 실행 중인 잡 수를 카운터로 관리했습니다. 카운트가 너무 높으면 해당 잡은 스스로를 다시 큐에 넣고 대기합니다.

또 다른 예로, 제 사이드 프로젝트에서 사용자가 기다리는 동안 백그라운드 잡(또는 여러 잡)이 처리 작업을 수행하는 경우가 있었습니다. 여기서 흔히 마주치는 문제가 백그라운드 잡의 현재 진행 상황을 사용자에게 전달하는 것입니다. 해결 방법은 다양하지만, 실험 삼아 Rails.cache.increment로 전역적으로 접근 가능한 카운터를 업데이트해 보았습니다. 구조는 다음과 같았습니다:

  1. 먼저 /app/models에 새 클래스를 추가하여 카운터가 캐시에 저장되어 있다는 사실을 추상화했습니다. 이 값에 대한 모든 접근은 이 클래스를 통해 이루어졌습니다. 여기에는 잡과 관련된 고유 캐시 키 생성 로직도 포함됩니다.
  2. 잡은 이 모델의 인스턴스를 생성하고, 항목이 처리될 때마다 이를 업데이트합니다.
  3. 간단한 JSON 엔드포인트가 이 모델의 인스턴스를 생성해 현재 값을 가져옵니다.
  4. 프론트엔드는 이 엔드포인트를 몇 초마다 폴링하여 UI를 갱신합니다. 물론 ActionCable 같은 기술을 활용해 업데이트를 푸시하는 방식으로 더 발전시킬 수도 있습니다.

결론

솔직히 말하면, Rails.cache.increment는 자주 손이 가는 도구는 아닙니다. 캐시에 저장된 데이터(본질적으로 어느 정도 임시적인 성격)를 업데이트하고 싶은 경우가 흔치 않기 때문입니다. 위에서 언급했듯이 제가 이 메서드를 사용하는 경우는 대체로 백그라운드 잡과 관련이 있습니다. 잡들은 이미 Redis에 데이터를 저장하고 있고(적어도 제가 작업한 대부분의 앱에서), 일반적으로 임시적인 특성을 지니기 때문입니다. 이런 경우 완료율 같은 관련 데이터를 비슷한 수준의 단기 보존 특성을 가진 같은 곳에 저장하는 것이 자연스럽습니다.

"흔하지 않은 길"을 걷는 모든 것과 마찬가지로, 이런 기능을 코드베이스에 도입할 때는 주의해야 합니다. 최소한 나중에 코드를 볼 개발자들이 익숙하지 않을 이 메서드를 왜 사용했는지 설명하는 주석을 달아두는 것을 권장합니다.