Ruby 및 Rails에서 두 가지 복잡한 버그 해결 여정

Jean Boussier, "A Decade of Rails Bug Fixes"

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

핵심 요약

  • 1 이 강연은 Shopify 개발자이자 Ruby/Rails 핵심 기여자인 Jérôme 'Byoot' Dalembert가 겪었던 두 가지 중요한 버그 해결 경험을 다룹니다.
  • 2 첫 번째 버그는 Active Record `counter_cache`의 동시성 문제로 인한 카운터 불일치였고, 두 번째 버그는 Ruby 3.2의 객체 모양(Object Shapes) 최적화로 인해 Marshall 역직렬화 시 발생하는 복잡한 문제였습니다.
  • 3 두 사례를 통해 디버깅의 과학적 방법론, 재현의 중요성, 가정에 대한 의문 제기, 그리고 오픈 소스 프로젝트에서의 효과적인 커뮤니케이션의 중요성을 강조합니다.

도입

본 강연은 Shopify의 핵심 개발자이자 Ruby 및 Rails 커뮤니티에서 'Byoot' 또는 'Casper'로 알려진 Jérôme Dalembert가 지난 10년간 겪었던 두 가지 복잡한 버그 해결 과정을 상세히 설명합니다. 이 경험들은 그의 전문성 성장에 기여했으며, 특히 Ruby on Rails 프레임워크와 Ruby 가상 머신(VM) 내부 동작에 대한 깊은 이해를 바탕으로 이루어졌습니다. 이 강연은 단순히 버그를 수정하는 것을 넘어, 문제 해결 과정에서 얻은 귀중한 교훈과 오픈 소스 프로젝트 기여의 중요성을 강조합니다.

첫 번째 버그: Active Record counter_cache 불일치 (10년 전)

첫 번째 사례는 Shopify에서 발생한 느린 COUNT 쿼리 문제였습니다. 처음에는 Active Record의 counter_cache를 사용하여 해결할 수 있을 것이라고 생각했지만, 해당 모델에는 이미 counter_cache가 구현되어 있었음에도 불구하고 의도적으로 사용되지 않고 있었습니다. git blame을 통해 과거 커밋을 추적한 결과, 일부 컬렉션에서 ‘마이너스 500개 제품’과 같이 비정상적인 카운터 값이 보고되어 counter_cache 사용이 중단되었음을 알게 되었습니다. 이는 약 1년 동안 해결되지 않은 고질적인 버그였습니다.

초기에는 Rails 내부의 경쟁 조건(race condition)을 의심했지만, counter_cache가 10년 이상 사용되어 왔다는 점을 고려하여 Rails 자체의 버그보다는 잘못된 사용법을 의심했습니다. 그러나 코드베이스를 감사하며 counter_cache를 우회하는 하위 수준 API 사용 여부를 확인했지만 증거를 찾을 수 없었습니다. 결정적인 단서는 비동기화된 카운터 값이 항상 실제보다 적거나 음수였다는 점이었습니다. 이는 destroy 경로에서 과도한 감소(decrement)가 발생한다는 것을 시사했습니다.

작은 재현 애플리케이션을 구축하고 Unicorn 웹 서버를 통해 동시성 환경을 시뮬레이션한 결과, 동일한 레코드를 두 번 DELETE하는 경우 문제가 재현됨을 확인했습니다. MySQL 로그를 분석해보니, 첫 번째 DELETE는 레코드를 실제로 삭제했지만, 두 번째 DELETE는 아무것도 삭제하지 않았습니다. 문제는 SQL DELETE가 멱등(idempotent) 연산이어서 여러 번 호출해도 성공으로 간주되는 반면, Rails의 counter_cacheDELETE 성공 여부와 관계없이 무조건 카운터를 감소시켰기 때문입니다.

해결책은 counter_cache의 구현을 콜백 기반에서 Active Record의 핵심 기능으로 변경하여, 실제 DELETE 작업에 의해 영향을 받은 행의 수를 확인하고, 실제로 레코드가 삭제되었을 때만 카운터를 감소시키도록 수정하는 것이었습니다. 이 수정사항은 프로덕션 환경에서 먼저 테스트되었고, 성공적으로 검증된 후 Rails에 풀 리퀘스트로 제출되었습니다. 이 과정에서 Aaron Patterson과의 소통 오류가 있었지만, 궁극적으로 패치는 병합되었습니다. 이 경험을 통해 디버깅은 가설 설정, 실험, 확인/반증의 과학적 방법론과 같으며, 재현 가능한 테스트 케이스를 만드는 것이 가장 중요하고, 자신의 가정을 끊임없이 의심해야 하며, 오픈 소스 의존성 코드를 자신의 코드처럼 다루어야 한다는 교훈을 얻었습니다.

두 번째 버그: Ruby 3.2 Marshall 역직렬화 문제 (1년 전)

두 번째 사례는 그가 Ruby 커미터 및 Rails 핵심 멤버가 된 후 겪은 문제였습니다. Mastodon 사용자가 Ruby 3.1에서 3.2로 업그레이드한 후 Marshall 역직렬화 과정에서 ActiveRecord::Base 인스턴스의 id 속성을 읽지 못하는 예외가 발생한다는 버그를 보고했습니다. 처음에는 과신하여 모델에 id 속성이 없거나 조인 테이블 문제일 것이라고 쉽게 판단했습니다. 그러나 Ruby 3.2 업그레이드 시에만 발생하는 문제라는 점과 백트레이스를 다시 확인하면서, 문제가 ‘부분적으로 초기화된 레코드’의 id 접근에 있다는 것을 깨달았습니다.

Marshall 변경 사항을 git log로 추적했지만 특별한 것을 찾지 못했습니다. 그러나 다른 사용자가 Ruby 3.2에서 생성된 손상된 Marshall 페이로드가 Ruby 3.1에서도 동일하게 실패한다는 보고를 통해, 문제가 Marshall ‘로딩’이 아닌 ‘생성(serialization)’ 과정에 있음을 파악했습니다. 익명으로 공유된 역컴파일된 페이로드를 분석한 결과, 많은 순환 참조(circular references)가 있었고, Active Record 인스턴스가 완전히 인스턴스화되기 전에 해시 키로 사용되는 패턴을 발견했습니다. 이는 Marshall이 객체 그래프를 스트림 방식으로 재구성하는 과정에서 부분적으로 초기화된 객체가 발생할 수 있기 때문입니다.

결정적인 스모킹 건은 Ruby 3.2에서 도입된 ‘객체 모양(Object Shapes)’ 최적화였습니다. Ruby 3.1까지는 클래스의 첫 번째 인스턴스에서 결정된 인스턴스 변수 순서가 모든 인스턴스에 보존되었지만, Ruby 3.2부터는 각 인스턴스가 고유한 인스턴스 변수 순서를 가질 수 있게 되었습니다. 이로 인해 Active Record에서 특정 클래스에 너무 많은 다른 삽입 순서가 발생하여 Ruby가 최적화를 해제하고 정렬되지 않은 해시를 사용하게 되면서 문제가 발생했습니다. 즉, Ruby의 버그였습니다.

해결책은 세 가지 방향으로 진행되었습니다. 첫째, Mastodon 사용자를 위한 임시방편으로 Marshall 대신 Pakito에서 영감을 받은 커스텀 직렬화 방식을 도입했습니다. 이는 더 빠른 속도와 작은 페이로드라는 추가적인 이점을 가져왔습니다. 둘째, Rails 자체에서도 Active Record 객체에 대한 커스텀 직렬화 로직을 구현하여 문제를 해결했습니다. 셋째, Ruby 자체에서도 객체 모양 최적화 시 정렬되지 않은 해시 대신 순서를 보존하는 해시를 사용하도록 수정하는 패치를 적용했습니다. 이 패치는 Ruby 3.2.3에 백포트되었습니다.

이 경험을 통해 그는 게시 전에 항상 다시 확인하고, 경험이 때로는 잘못된 가정을 유도할 수 있음을 경계하며, 직접 재현할 수 없는 버그는 디버깅하기 매우 어렵다는 점을 깨달았습니다. 또한 Marshall 사용에 대한 극도의 주의를 당부하며, 동일한 프로그램의 동일 버전이 아닐 경우 데이터 불일치 위험이 크다고 경고했습니다. 마지막으로, 문서화되지 않은 동작에 의존하지 말 것과 Ruby VM 또한 자신의 코드의 일부이므로 적극적으로 탐구해야 한다는 점을 강조했습니다.

결론

Jérôme Dalembert의 두 가지 버그 해결 여정은 단순한 기술적 문제를 넘어, 복잡한 시스템을 디버깅하고 오픈 소스 커뮤니티에 기여하는 과정에서 얻을 수 있는 깊이 있는 통찰력을 제공합니다. 그는 과학적 방법론에 기반한 체계적인 디버깅 접근 방식, 재현 가능한 문제의 중요성, 그리고 명확하고 협력적인 커뮤니케이션의 필요성을 역설합니다. 이러한 경험들은 개발자가 자신의 코드뿐만 아니라 의존성 라이브러리와 심지어 기반이 되는 런타임 환경(Ruby VM)까지 이해하고 개선하는 데 적극적으로 참여해야 한다는 강력한 메시지를 전달합니다.

댓글 0

댓글 작성

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

아직 댓글이 없습니다

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