이번 Ruby Magic 에디션에서는 Ruby에서 C로 작성된 코드를 사용하는 방법을 소개합니다. 이 기법은 성능에 민감한 코드 영역을 최적화하거나, C 라이브러리와 Ruby 사이의 인터페이스를 구축할 때 활용할 수 있습니다. 핵심은 C로 작성된 라이브러리를 감싸는(wrap) 확장(extension)을 만드는 것입니다.
C로 작성된 성숙하고 고성능인 라이브러리는 이미 매우 많습니다. 이를 굳이 다른 언어로 포팅하며 바퀴를 재발명하는 대신, Ruby에서 그대로 활용하는 것이 효율적입니다. 이렇게 하면 Ruby가 전통적으로 강점이 없는 영역에서는 C 라이브러리의 힘을 빌리면서도, 평소 즐겨 쓰는 언어로 개발을 이어갈 수 있습니다. 실제로 AppSignal에서도 rdkafka 젬(gem)을 개발할 때 이 방식을 사용했습니다.
그럼 구체적인 접근 방법을 살펴보겠습니다. 직접 따라 하며 실험해 보고 싶다면 예제 코드를 참고하세요. 먼저 문자열, 숫자, 불리언 값을 다루는 아래 Ruby 코드를(그 이유는 곧 알게 되실 겁니다) C 라이브러리로 포팅해 보겠습니다.
module CFromRubyExample
class Helpers
def self.string(value)
"String: '#{value}'"
end
def self.number(value)
value + 1
end
def self.boolean(value)
!value
end
end
end위 메서드들은 순서대로 문자열을 연결하고, 숫자에 1을 더하고, 불리언 값의 반대값을 반환합니다.
C로 포팅된 라이브러리
아래는 C로 포팅한 코드입니다. 문자열 포매팅을 위해 C 표준 라이브러리와 IO 라이브러리를 포함시켰습니다. Ruby의 String 대신 char*를 사용하는데, char*는 메모리 어딘가에 존재하는 문자 버퍼의 위치를 가리키는 포인터입니다.
# include <stdlib.h>
# include <stdio.h>
char* string_from_library(char* value) {
char* out = (char*)malloc(256 * sizeof(char));
sprintf(out, "String: '%s'", value);
return out;
}
int number_from_library(int value) {
return value + 1;
}
int boolean_from_library(int value) {
if (value == 0) {
return 1;
} else {
return 0;
}
}보시다시피 C에서는 간단한 문자열 포매팅조차 여러 단계를 거쳐야 합니다. 문자열을 연결하려면 먼저 버퍼를 할당해야 하고, 그다음 sprintf 함수가 포맷된 결과를 버퍼에 기록한 뒤, 마지막에 버퍼를 반환하는 구조입니다.
참고로 위 코드에는 이미 잠재적인 크래시나 보안 문제가 숨어 있습니다. 입력 문자열이 245바이트보다 길면 악명 높은 버퍼 오버플로우(buffer overflow)가 발생합니다. C 코드를 작성할 때는 반드시 신중해야 합니다. 사소한 실수 하나로 스스로 발등을 찍기 매우 쉽기 때문입니다.
다음은 헤더 파일입니다.
char* string_from_library(char*);
int number_from_library(int);
int boolean_from_library(int);이 파일은 C 라이브러리의 공개 API(public API)를 정의합니다. 다른 프로그램은 이 파일을 통해 라이브러리에서 어떤 함수들을 호출할 수 있는지 파악합니다.
현대적인 방식: ffi 젬 활용하기
이제 Ruby에서 사용할 C 라이브러리가 준비되었습니다. 이 C 코드를 젬으로 감싸는 방법은 두 가지가 있는데, 현대적인 방식은 ffi 젬을 사용하는 것입니다. FFI(Foreign Function Interface)는 우리가 일일이 처리해야 하는 번거로운 작업 상당수를 자동화해 줍니다. 방금 작성한 C 코드에 ffi를 적용하면 다음과 같습니다.
module CFromRubyExample
class Helpers
extend FFI::Library
ffi_lib File.join(File.dirname(__FILE__), "../../ext/library.so")
attach_function :string, [:string], :string
attach_function :number, [:int], :int
attach_function :boolean, [:int], :int
end
end다만 이 글에서는 C 확장(C extension)으로 C 코드를 감싸는 방법도 함께 설명하겠습니다. 이 과정을 직접 거쳐 보면 Ruby 내부에서 모든 것이 어떻게 동작하는지 훨씬 깊이 이해할 수 있기 때문입니다.
C 확장으로 라이브러리 감싸기
Ruby에서 사용할 C 라이브러리가 준비되었으니, 다음 단계는 이를 컴파일하고 감싸는 젬을 만드는 것입니다. 젬을 생성한 후, 먼저 gemspec의 require_paths에 ext를 추가합니다.
Gem::Specification.new do |spec|
spec.name = "c_from_ruby_example"
# ...
spec.require_paths = ["lib", "ext"]
end이렇게 하면 RubyGems에게 빌드해야 할 네이티브 확장이 존재한다는 사실을 알려줍니다. RubyGems는 설치 시 extconf.rb 파일이나 Rakefile을 찾는데, 여기서는 extconf.rb를 추가했습니다.
require "mkmf"
create_makefile "extension"mkmf("Make Makefile"의 약자)를 require합니다. 이것은 Ruby에 기본 포함된 헬퍼 모음으로, C 빌드 환경을 설정할 때의 까다로운 부분을 대신 처리해 줍니다. create_makefile을 호출하면서 확장 이름을 지정하면, C 코드를 빌드하는 데 필요한 모든 설정과 명령이 담긴 Makefile이 생성됩니다.
다음으로, 라이브러리를 Ruby에 연결하는 C 코드를 작성해야 합니다. char* 같은 C 타입을 String 같은 Ruby 타입으로 변환하는 함수들을 만들고, 이어서 C 코드만으로 Ruby 클래스를 정의하겠습니다.
먼저 Ruby의 헤더 파일들을 포함시킵니다. 이 헤더들은 타입 변환에 필요한 함수들을 가져오며, 앞서 만든 library.h 헤더 파일도 함께 포함시켜 우리 라이브러리를 호출할 수 있게 합니다.
#include "ruby/ruby.h"
#include "ruby/encoding.h"
#include "library.h"그다음 라이브러리의 각 함수를 감싸는 래퍼(wrapper) 함수를 만듭니다. 다음은 문자열 처리용입니다.
static VALUE string(VALUE self, VALUE value) {
Check_Type(value, T_STRING);
char* pointer_in = RSTRING_PTR(value);
char* pointer_out = string_from_library(pointer_in);
return rb_str_new2(pointer_out);
}먼저 들어온 Ruby 값이 문자열인지 검사합니다. 문자열이 아닌 값을 그대로 처리하면 갖가지 버그가 발생할 수 있기 때문입니다. 이후 Ruby가 제공하는 RSTRING_PTR 헬퍼 매크로로 Ruby String을 char*로 변환하고, 이제 C 라이브러리를 호출할 수 있습니다. 반환된 char*는 rb_str_new2 함수를 사용해 다시 Ruby 문자열로 변환합니다. 숫자와 불리언에 대해서도 유사한 래퍼 함수를 추가하겠습니다.
숫자의 경우 NUM2INT와 INT2NUM 헬퍼를 사용해 비슷한 방식으로 처리합니다.
static VALUE number(VALUE self, VALUE value) {
Check_Type(value, T_FIXNUM);
int number_in = NUM2INT(value);
int number_out = number_from_library(number_in);
return INT2NUM(number_out);
}불리언 버전 역시 비슷합니다. 한 가지 흥미로운 점은 C에는 실제로 불리언 타입이 존재하지 않으며, 관례상 0과 1을 대신 사용한다는 것입니다.
static VALUE boolean(VALUE self, VALUE value) {
int boolean_in = RTEST(value);
int boolean_out = boolean_from_library(boolean_in);
if (boolean_out == 1) {
return Qtrue;
} else {
return Qfalse;
}
}마지막으로 모든 요소를 연결해서 Ruby에서 호출할 수 있도록 만들어 줍니다.
void Init_extension(void) {
VALUE CFromRubyExample = rb_define_module("CFromRubyExample");
VALUE NativeHelpers = rb_define_class_under(CFromRubyExample, "NativeHelpers", rb_cObject);
rb_define_singleton_method(NativeHelpers, "string", string, 1);
rb_define_singleton_method(NativeHelpers, "number", number, 1);
rb_define_singleton_method(NativeHelpers, "boolean", boolean, 1);
}그렇습니다, 놀라시겠지만 C 코드만으로 Ruby 모듈, 클래스, 메서드를 만들 수 있습니다. 위 코드에서는 클래스를 정의한 뒤 해당 클래스에 Ruby 메서드들을 추가했습니다. 이때 Ruby 메서드의 이름, 호출될 C 래퍼 함수의 이름, 그리고 인자의 개수를 지정해 주어야 합니다.
이 모든 작업을 마치고 나면, 드디어 Ruby에서 C 코드를 호출할 수 있습니다.
CFromRubyExample::NativeHelpers.string("a string")마치며
여러 고비를 넘고, 크래시 없이 C 확장을 성공적으로 동작시켰습니다. C 확장 작성은 결코 만만한 작업이 아닙니다. ffi 젬을 사용하더라도 Ruby 프로세스를 손쉽게 크래시시킬 수 있습니다. 하지만 충분히 도전할 가치가 있으며, 잘 활용하면 고성능·고안정성의 C 라이브러리 세계가 열립니다!