Ruby on Rails 버그 해결 여정: 카운터 캐시 비동기화와 Marshall 직렬화 오류 사례

Jean Boussier, "A Decade of Rails Bug Fixes"

작성자
EuRuKo
발행일
2025년 01월 13일

핵심 요약

  • 1 베테랑 개발자가 10년간 Ruby on Rails에서 겪었던 두 가지 복잡한 버그 해결 경험을 공유했습니다.
  • 2 첫 번째는 Active Record 카운터 캐시의 동기화 문제였고, 두 번째는 Ruby 3.2 버전 업그레이드 시 발생한 Marshall 직렬화 오류였습니다.
  • 3 재현 가능한 테스트 케이스 확보와 심층적인 분석을 통해 문제를 해결했으며, 디버깅의 과학적 접근과 오픈소스 기여의 중요성을 강조합니다.

도입

본 발표는 Shopify에서 Ruby on Rails 코어 및 Ruby 개발자로 활동하는 발표자가 경험했던 두 가지 중대한 버그 해결 사례를 상세히 다룹니다. 첫 번째는 약 10년 전 Active Record 카운터 캐시와 관련된 문제였고, 두 번째는 비교적 최근인 Ruby 3.2 버전 업그레이드 시 발생한 Marshall 직렬화 오류였습니다. 발표자는 이 두 가지 사례를 통해 복잡한 시스템에서 발생하는 버그를 진단하고 해결하는 과정, 즉 디버깅의 과학적 방법론과 오픈소스 프로젝트에 기여하는 과정에서 얻은 교훈을 공유합니다.

발표자는 먼저 10년 전 Active Record 카운터 캐시 비동기화 버그에 대해 설명합니다. Shopify의 MySQL 느린 쿼리 보고서에서 120ms에 달하는 COUNT 쿼리가 발견되었고, 확인 결과 이미 카운터 캐시가 구현되어 있었음에도 불구하고 특정 시점에 사용이 중단된 것이 문제의 발단이었습니다. 이로 인해 컬렉션 내 제품 수가 음수로 표시되는 등 데이터 불일치가 발생했습니다. 기존의 시도들이 대부분 Rails 내부의 경쟁 조건(race condition)으로 문제를 치부했으나, 발표자는 애플리케이션 코드의 오용 가능성을 탐색했습니다. 그러나 코드 감사에서도 특별한 단서를 찾지 못했고, 생산 데이터를 분석하던 중 모든 불일치 카운터가 실제보다 적은 값, 심지어 음수로 나타나는 것을 확인하여 삭제 경로(destroy path)에 문제가 있음을 직감했습니다. 재현 가능한 최소한의 애플리케이션을 구축하고 동시성 환경(Unicorn)에서 테스트한 결과, 동일한 레코드를 두 번 로드한 후 두 번 삭제하려는 시도가 문제를 촉발한다는 것을 밝혀냈습니다. SQL에서 DELETE는 멱등(idempotent) 연산이므로 첫 번째 삭제만 실제 레코드를 지우고 두 번째는 아무것도 지우지 않지만, Rails의 카운터 캐시 콜백은 매번 감소하여 불일치를 초래했습니다. 해결책으로 발표자는 DELETE 작업이 실제로 영향을 준 행의 수를 반환한다는 점을 활용하여, 카운터 캐시를 Active Record의 핵심 기능으로 리팩토링했습니다. 이를 통해 삭제 작업이 실제로 레코드를 제거했을 때만 카운터를 감소시키도록 변경하여 문제를 해결했고, 이 수정사항은 프로덕션에서 몽키 패치로 검증된 후 Rails에 공식적으로 반영되었습니다.

두 번째 버그는 약 1년 전, Ruby 3.2 버전으로 업그레이드한 Mastodon 사용자들에게서 발생한 Marshall 직렬화 오류였습니다. 사용자들은 캐시 로드 시 ActiveRecord::Instanceid 속성에 접근하지 못하는 예외를 겪었습니다. 발표자는 초기에는 Active Record 인스턴스에 id 속성이 없거나 부분적으로 초기화된 레코드 문제로 오판했으나, Ruby 3.1에서 3.2로의 업그레이드 시점에만 발생하는 점에 주목했습니다. Marshall 변경 이력을 조사했지만 직접적인 원인을 찾지 못했고, 다른 사용자가 3.2에서 생성된 Marshall 페이로드가 3.1에서도 오류를 일으킨다는 점을 공유하면서 문제가 직렬화(serialization) 과정에 있음을 확신했습니다. 개인 정보가 없는 디컴파일된 페이로드를 분석한 결과, 많은 순환 참조(circular references)가 있었고, Active Record 인스턴스가 완전히 초기화되기 전에 해시 키로 사용되는 패턴이 문제를 일으킨다는 가설을 세웠습니다. 이는 Ruby 3.2에서 도입된 Object Shapes 최적화로 인해 인스턴스 변수의 순서 처리 방식이 변경되어 각 인스턴스마다 다른 순서가 적용될 수 있게 된 것이 원인이었습니다. 이 문제를 해결하기 위해 발표자는 Mastodon에 Marshall 대신 사용자 정의 직렬화(Pakito와 유사)를 도입하여 캐시 페이로드를 최적화하고 문제를 우회했습니다. 또한, Ruby 자체에도 인스턴스 변수 순서 처리 로직을 수정하는 패치를 기여하여 근본적인 문제를 해결했으며, Rails에서도 유사한 직렬화 개선이 이루어졌습니다.

결론

발표자는 두 가지 복잡한 버그 해결 경험을 통해 얻은 핵심적인 교훈들을 강조합니다. 첫째, 디버깅은 가설을 세우고, 실험하고, 결과를 확인하며, 문제가 명확해질 때까지 반복하는 과학적 방법론과 같다는 점입니다. 둘째, 재현 가능한 테스트 케이스를 확보하는 것이 버그 해결의 가장 중요한 첫걸음이며, 이는 문제 해결의 생산성을 비약적으로 향상시킵니다. 셋째, 자신의 가정을 항상 의심하고, 오픈소스 의존성 코드 또한 자신의 코드처럼 이해하고 파고들어야 한다는 점입니다. 마지막으로, 유지보수자(maintainer)와의 효과적인 의사소통이 문제 해결에 필수적임을 역설하며, Marshall과 같은 직렬화 도구는 버전 간 호환성 문제로 인해 사용에 극도로 주의해야 한다고 경고합니다. 이 경험들은 개발자가 단순히 코드를 작성하는 것을 넘어, 시스템을 깊이 이해하고 문제를 해결하며, 더 나아가 오픈소스 커뮤니티에 기여하는 과정의 중요성을 보여줍니다.

댓글 0

댓글 작성

0/1000
정중하고 건설적인 댓글을 작성해 주세요.

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!