메모리 누수는 gem 사용자에게 큰 고민거리입니다. 원인을 추적하기 어렵고, 방치하면 값비싼 인프라 비용으로 이어질 수 있습니다.
C 확장 내부에서 발생하는 메모리 누수는 더욱 다루기 까다롭습니다. Ruby 코드에서 누수를 찾는 도구와 글은 많지만, C 레벨에서는 내부 구조에 접근하기가 훨씬 어렵기 때문입니다.
특히 rb_funcall을 무분별하게 사용하면 메모리 누수가 발생할 수 있습니다. 대신 rb_protect를 사용하는 것이 훨씬 안전합니다. C 확장을 개발하신다면, 여러분의 gem을 사용할 개발자들을 위해 끝까지 읽어보시길 권합니다.
그럼 시작해보겠습니다!
rb_funcall과 C의 문제점
rb_funcall은 Ruby와 C 영역이 상호작용해야 하면서 C 코드 작성량은 최소화하고 싶을 때 유용한 도구입니다.
하지만 rb_funcall을 실행하는 순간, 모든 것이 단순하게 동작하는 C의 세계를 벗어나게 됩니다. 특히 호출된 함수가 다음과 같은 상황에 놓인다면 코드는 쉽게 엉망이 될 수 있습니다.
- 런타임 중 정의가 완전히 변경되는 경우
- 예외(raise)를 던지는 경우
첫 번째 경우는 비교적 잡아내기 쉽습니다. 대부분 세그먼트 폴트(segfault)가 발생하며, 테스트 스위트가 충분히 완성도 있다면 배포 전에 잡아낼 수 있습니다.
반면 두 번째 경우는 메모리 누수를 일으키고 코드베이스를 훨씬 읽기 어렵게 만듭니다. 지금부터 자세히 살펴보겠습니다.
Ruby 예외가 C 메모리 누수를 일으키는 원리
Ruby의 예외 메커니즘은 오류를 잡는 첫 번째 부모 스코프로 제어 흐름을 건너뜁니다. MRI(Matz's Ruby Interpreter)에서는 이를 longjmp와 setjmp로 구현합니다.
내부 구현이 궁금하다면 Ruby Hacking Guide의 Evaluator 장을 읽어보세요. 간단히 요약하면, begin..ensure 블록에 진입할 때 setjmp()로 현재 위치를 저장하고, 블록 안에서 예외가 발생하면 longjmp()로 저장된 위치로 점프합니다.
따라서 rb_funcall로 호출한 함수에서 예외가 발생하면, 그 이후에 실행되어야 할 C 코드는 아예 실행되지 않습니다.
// 예시: 예외가 발생하면 free()가 실행되지 않아 메모리가 누수됩니다
static VALUE json_parse(VALUE self, VALUE input) {
char *buffer = malloc(BUFFER_SIZE); // 자원 할당
VALUE parsed = rb_funcall(parser, rb_intern("parse"), 1, input);
// ↑ 여기서 예외가 발생하면 아래 코드는 실행되지 않음
process(buffer);
free(buffer); // 실행되지 않음 → 누수!
return parsed;
}물론 위 예제는 다소 단순합니다. 자원 해제와 Ruby 처리 순서를 바꾸면 되니까요. 하지만 항상 그렇게 할 수 있는 것은 아니며, 함수 본문이 길어질수록 로직은 더 얽히기 마련입니다.
Ruby의 begin..ensure 활용
Ruby였다면 위 예제를 begin..ensure로 깔끔하게 해결할 수 있습니다.
def json_parse(data)
buffer = allocate_buffer
begin
Parser.parse(data, buffer)
ensure
free_buffer(buffer) # 예외 발생 여부와 관계없이 항상 실행
end
end이 API는 C에서도 rb_rescue와 rb_ensure로 사용할 수 있습니다.
VALUE result = rb_ensure(parse_body, data, cleanup_body, buffer);다만 이 방식은 다소 번거롭고, 여기에 rescue 블록까지 추가하면 가독성이 크게 떨어집니다. C에서 begin..rescue..ensure..end API를 활용하고 싶다면 Peter Zhu의 글 'A Rubyist's Walk Along the C-side (Part 8): Exceptions & Error Handling'을 참고하시길 추천합니다.
C에서 rb_protect 활용하기
또 다른 선택지도 있습니다. 먼저 Ruby에서 어떤 모습일지 살펴보겠습니다.
# C 스타일에 가까운 패턴
error = nil
result = nil
begin
result = do_parse(data)
rescue => e
error = e # 오류 정보만 저장
ensure
cleanup # 자원 정리
end
raise error if error # 정리 후 재발생(re-raise)Ruby에서 보면 낯설지만, C에는 잘 맞는 워크플로우입니다. MRI에는 이를 위한 API인 rb_protect가 준비되어 있으며, C 코드는 다음과 같은 모습입니다.
static VALUE safe_json_parse(VALUE data) {
return rb_funcall(parser, rb_intern("parse"), 1, data);
}
// 호출부
int state = 0;
VALUE parsed = rb_protect(safe_json_parse, data, &state);
if (state) { // 예외가 발생한 경우
free_resources(); // 자원 정리 후
rb_jump_tag(state); // 오류를 다시 던짐(re-raise)
}위 함수는 모든 자원을 해제한 뒤 Ruby 오류를 다시 던집니다.
오류를 무시하고 싶다면 Ruby의 빈 rescue 블록처럼 처리할 수도 있습니다.
# 빈 rescue 블록으로 오류 무시
begin
do_parse(data)
rescue
# 아무것도 하지 않음
end주의: 오류를 다시 던지지 않기로 했다면 rb_set_errinfo(Qnil) 단계가 중요합니다. 이 과정을 생략하면 사용자가 알 필요가 없는 오류 정보가 계속 남아있게 됩니다.
혹은 rescue My::Error처럼 조건적으로 오류를 던질 수도 있습니다.
# 특정 오류만 다시 던지기
begin
do_parse(data)
rescue My::Error => e
raise e
endrb_errinfo()는 Ruby의 전역 변수 $!와 같다고 생각하면 이해하기 쉽습니다.
여기까지 잘 정리됐지만, rb_funcall 하나만 감싸는 용도라면 이 API를 더 단순화할 수 있습니다.
rb_protect API를 사용하는 핵심 아이디어는 가독성 향상입니다. 해당 함수가 예외를 던질 수 있는지 매번 확인할 필요 없이, '던질 수 있다'고 가정하고 상태(state) 값을 활용해 처리하면 됩니다.
rb_protect_funcall 제안
위험한 메서드는 사실상 rb_funcall 하나뿐이므로 이를 분리해 보겠습니다. 다음과 같은 API를 만들 수 있습니다.
VALUE rb_protect_funcall(VALUE recv, ID mid, int *state,
int nargs, ...);
// rb_funcall과 동일한 시그니처에 rb_protect의 state만 추가rb_funcall과 동일하되 rb_protect의 state가 추가된 형태이므로 사용법도 매우 직관적입니다.
int state = 0;
VALUE result = rb_protect_funcall(obj, rb_intern("parse"), &state, 1, input);
if (state) {
/* 오류 처리 및 자원 정리 */
}이 API는 아직 Ruby에 포함되어 있지 않으며, 앞으로도 포함되지 않을 수 있습니다. 대신 RGeo(MIT 라이선스) 코드베이스에서 가져와 사용할 수 있습니다.
실제 적용 사례
실제 사례가 궁금하다면 RGeo 코드베이스를 살펴보세요. 최근 전면적으로 rb_protect 기반으로 전환했습니다. 사용 편의성을 위해 상태(state)를 전파하는 함수들도 있는데, rgeo_convert_to_geos_geometry가 대표적입니다. 코드 탐색은 이 함수부터 시작하는 것을 추천합니다.
RGeo 저장소에 이슈를 열어 저희가 내린 설계 결정에 대해 함께 논의해 주셔도 좋습니다.
마무리
이번 글에서는 C 확장에서 rb_funcall 사용 시 발생할 수 있는 메모리 누수 문제를 짚어보고, begin..ensure 또는 rb_protect를 활용한 안전한 대안을 살펴봤습니다.
즐거운 코딩 되세요!
P.S. Ruby Magic 게시글을 가장 빠르게 받아보고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!
Ulysse Buonomo
게스트 저자 Ulysse는 업계에서 Ruby 개발자로 활동했으며, 현재는 대부분의 시간을 세계 곳곳을 여행하며 보내고 있습니다. 여가 시간에는 RGeo와 Ruby에 기여하며, Ruby의 내부 구조를 만지작거리는 것을 좋아합니다.