첫 번째 버그: 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_cache
는 DELETE
성공 여부와 관계없이 무조건 카운터를 감소시켰기 때문입니다.
해결책은 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 또한 자신의 코드의 일부이므로 적극적으로 탐구해야 한다는 점을 강조했습니다.