본문으로 건너뛰기

Ruby FFI GC 버그: 해시가 문자열로 변하는 '불가능한' 오류 추적기

When Your Hash Becomes a String: Hunting Ruby's Million-to-One Memory Bug

작성자
HackerNews
발행일
2025년 11월 03일

핵심 요약

  • 1 FFI 1.17.0 미만 버전에서 쓰기 장벽(write barrier) 누락으로 인해 Ruby GC가 내부 해시를 해제하고 해당 메모리 주소에 다른 객체가 할당될 수 있었습니다.
  • 2 이 버그는 `NoMethodError: undefined method 'default' for an instance of String` 형태로 나타났으며, 재현하기 극히 어려웠으나 발생 시 치명적인 시스템 오류를 유발했습니다.
  • 3 문제는 FFI C 확장 코드에서 Ruby 객체 참조를 GC에 알리는 `RB_OBJ_WRITE` 매크로가 누락되어 발생했으며, FFI 1.17.0 버전에서 수정되었습니다.

도입

Ruby 개발자가 Karafka 사용자로부터 `NoMethodError: undefined method 'default' for an instance of String`라는 이해할 수 없는 오류를 보고받았습니다. 이 오류는 FFI::Struct의 내부 해시가 문자열 객체로 변형되어 발생한 것으로 추정되었으나, 기존 진단으로는 원인을 찾을 수 없었습니다. 저자는 이 '불가능한' 오류의 근본 원인을 파악하고 재현하여 해결책을 찾는 여정을 시작했습니다.

불가능한 오류의 발생

rdkafka-ruby 112번째 줄, FFI::Struct의 필드에 접근하는 부분에서 오류가 발생했습니다. elem[:partition] 접근 시 elem이 FFI::Struct임에도 불구하고, 내부적으로 필드 정의를 저장하는 rbFieldMap 해시가 문자열로 변형되어 default 메서드를 호출하려다 실패한 것이었습니다.

초기 가설과 실패

  • musl 라이브러리 문제: 사용 환경이 Alpine Linux(musl libc)였기에, 컴파일된 Gem과 musl 간의 ABI 불일치, 구조체 정렬 문제 등을 의심했습니다.

  • 진단 스크립트: FFI 정수 타입 크기, 구조체 패딩, 실제 Gem 구조체의 크기 및 오프셋 등을 확인하는 스크립트를 실행했으나, 모든 진단 결과는 완벽하게 일치했습니다. 모든 ‘명백한’ 설명은 실패로 돌아갔습니다.

쓰기 장벽(Write Barrier)의 부재

#default 메서드가 Hash의 기능이라는 점에 착안하여, FFI의 내부 해시가 런타임에 문자열로 대체될 가능성을 가정했습니다. 이는 Ruby GC가 해시를 해제한 후, 동일한 메모리 주소에 문자열을 할당하여 C 코드의 포인터가 잘못된 타입의 객체를 가리키게 되는 시나리오였습니다.

FFI #1079 이슈와 쓰기 장벽

FFI GitHub 이슈 #1079에서 ‘missing write barriers’ 언급을 발견했습니다. 쓰기 장벽은 Ruby GC에게 객체 간의 참조 관계를 알려주는 메커니즘입니다. FFI 1.16.3 버전의 ext/ffi_c/StructLayout.c 코드에서 layout->rbFieldMap = rb_hash_new();와 같이 Ruby 객체를 할당하면서 RB_OBJ_WRITE 매크로를 사용하지 않아 GC가 해당 참조를 인지하지 못했습니다.

  • 취약점: GC는 C 구조체가 Ruby 객체를 참조하고 있다는 사실을 모르기 때문에, C 구조체가 스코프를 벗어나면 해당 Ruby 객체를 해제할 수 있었습니다.

  • 해결책: FFI 1.17.0에서는 RB_OBJ_WRITE(self, &layout->rbFieldMap, rb_hash_new());와 같이 쓰기 장벽을 추가하여 GC에게 C 구조체가 이 Ruby 객체를 소유하고 있으니 해제하지 말라고 명시했습니다.

버그 재현 환경 구축

단순한 GC.stress로는 즉각적인 세그멘테이션 폴트만 발생했으므로, 해시가 문자열로 변하는 상황을 재현하기 위해 다음과 같은 정교한 환경을 구축했습니다.

  • 수많은 임시 구조체 클래스 정의 및 스코프 이탈

  • 다중 스레드를 통한 메모리 압력 및 자연스러운 GC 타이밍 유도

  • GC 실행과 필드 접근 사이에 Ruby가 새 객체를 할당할 시간 간격 확보

  • 메모리 제약이 있는 Docker 컨테이너에서 반복 실행

이러한 환경에서 결국 undefined method 'default' for an instance of String 오류를 재현하는 데 성공했습니다. 이는 FFI 내부 해시가 GC에 의해 해제되고 동일한 메모리 위치에 문자열 객체가 할당되었음을 명확히 입증하는 것이었습니다.

결론

이 버그는 Ruby의 최하위 메모리 관리 모델에 대한 중요한 통찰을 제공합니다. Ruby 객체는 영구적인 식별자를 가지지 않으며, GC가 메모리를 해제하면 Ruby는 해당 공간을 재사용할 수 있습니다. 적절한 쓰기 장벽 없이는 C 포인터가 해제된 메모리를 가리키게 되어, 해시가 문자열로 변하는 것과 같은 객체 유형 변환 현상이 발생할 수 있습니다. FFI 1.17.0 이상으로 업그레이드하는 것이 이 치명적인 버그를 해결하는 유일한 방법입니다. '100만분의 1' 확률의 버그라도 Kubernetes와 같은 고빈도 재시작 환경에서는 필연적인 문제로 다가올 수 있음을 보여주며, 복잡한 시스템에서 디버깅 시 겉으로 보이는 현상에만 갇히지 않고 근본 원인을 추적하는 인내와 끈기의 중요성을 강조합니다.

댓글 0

댓글 작성

댓글 삭제 시 비밀번호가 필요합니다.

이미 계정이 있으신가요? 로그인 후 댓글을 작성하세요.

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