Kaigi on Rails 2026에서 Matz의 키노트를 계기로, Rails 앱 네이티브 컴파일을 시도해보다
요약
Kaigi on Rails 2026에서 Matz의 키노트를 계기로, Ruby on Rails 애플리케이션을 네이티브 바이너리로 컴파일하는 방법을 시도했습니다. Roundhouse와 AOT 컴파일러인 Spinel을 조합하여 Rails 코드를 분석하고 C 코드로 변환한 후, 최종적으로 독립 실행형 바이너리를 생성할 수 있었습니다. 다만, 이 과정은 환경 설정(Spinel 버전, RBS 선언 등)의 어려움이 있었으며, CRUD나 전체 호환성 검증까지는 이루어지지 않았습니다.
핵심 포인트
- Roundhouse와 Spinel을 조합하여 Rails 앱을 네이티브 바이너리로 변환 가능성을 확인했습니다.
- Rails 코드를 분석하여 C 코드로 생성하고 컴파일하는 과정이 핵심입니다.
- 실제 구현에는 환경 설정 및 라이브러리 경로 지정 등 복잡한 작업이 필요합니다.
2026년 10월 17일 Kaigi on Rails 2026에서 Matz의 키노트를 들었습니다.
그곳에서 궁금했던 것이 Ruby의 AOT(Ahead-of-Time) 컴파일러인 "Spinel"과, Rails 애플리케이션을 다른 실행 파일로 변환하는 "Roundhouse"입니다.
Spinel 자체는 RubyKaigi에서 듣고 시도해 보았습니다.
Rails로 작성된 애플리케이션을 네이티브 바이너리로 실행할 수 있는 것 같더군요. 정말 그런 것이 가능한 걸까요?
궁금해서 바로 시도해 보았습니다.
결론부터 말씀드리자면, Rails의 샘플 애플리케이션에서 네이티브 바이너리를 생성하고, 구동 후 HTTP 응답이나 화면 표시를 가볍게 확인할 수 있었습니다.
하지만 순조롭게 성공한 것은 아니었습니다. Spinel 버전 변경, RBS 선언 조정, macOS에서의 라이브러리 검색 경로 지정 등이 필요했습니다. CRUD나 Rails와의 호환성까지 검증한 것은 아닙니다.
이 글에서는 손에 남은 결과물과 Git 히스토리를 대조하며 시도했던 내용을 기록합니다. 순서는 작업 순서 그 자체가 아니라, 최종적으로 사용된 구성에 맞춰 정리했습니다.
Roundhouse와 Spinel이란
이번에 사용할 것은 다음 두 가지입니다.
- Roundhouse: Rails의 소스 코드를 분석하여 Ruby, Rust, Go 등의 독립 실행형 프로젝트를 생성하는 도구
- Spinel: Matz가 개발하고 있는 Ruby의 AOT(Ahead-of-Time) 컴파일러
이 둘을 조합하면 다음 흐름으로 실행 파일을 만들 수 있습니다.
Ruby on Rails
|
| Roundhouse
...
Roundhouse에는 --target spinel 옵션이 있어, Spinel용 Ruby 프로젝트를 생성할 수 있습니다. Spinel은 그 코드를 정적으로 분석하여 타입을 추론하고 C 코드를 생성합니다. 마지막으로 C 컴파일러를 통해 네이티브 실행 파일을 만듭니다.
Roundhouse가 대응하는 범위 내에서는, Rails로 작성된 앱을 Ruby 인터프리터 없이 실행할 수 있다는 것입니다. 다만, 임의의 Rails 앱이 그대로 컴파일될 수는 없습니다.
Rails 기능이 사라지기 때문에 실행 시점에 Rails gem이 필요 없는 것이 아닙니다. Roundhouse가 Rails의 DSL(Domain Specific Language)이나 규칙을 읽어 필요한 처리를 생성 코드나 자체 런타임에 포함합니다. 대응 범위와 Rails와의 차이는 공식 기능 커버리지에 정리되어 있습니다.
실험 환경
-
macOS 27.0.1 (Mac mini, arm64)
-
Ruby 3.4.8
-
Roundhouse CLI:
2026.9.18 (2e286e6f) -
샘플을 가져온 Roundhouse 리포지토리:
298fd9a1c4e1baa4c42b424e5b1659011938d338 -
최종적으로 설치되었던 Spinel:
2026.09.12+141 (f04b62ca7) -
Clang, SQLite, jemalloc
-
에셋 빌드를 위한 Node.js・npm
이번에는 직접 만든 앱이 아니라, Roundhouse의 fixtures/real-blog를 사용했습니다. Rails 8.1 계열의 블로그 앱으로, 기사(article)와 댓글(comment) 모델, 컨트롤러, ERB, Active Record, Turbo 등을 포함합니다.
처음에 가져온 Spinel은 ade7a7cbd였지만, 작업 도중에 f04b62ca로 전환했습니다. Git 히스토리에서는 16:30에 전환했고, 남아있는 blog의 수정 시간은 16:33이었습니다. Roundhouse의 2026.9.18 릴리스 기록에도 검증 대상으로 f04b62ca가 기재되어 있습니다.
실제로 시도해 보기
1. Roundhouse 준비하기
Homebrew로 필요한 라이브러리를 준비합니다. Clang과 make를 사용할 수 있는 환경도 필요합니다.
brew install pkg-config sqlite jemalloc
Roundhouse를 설치합니다.
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/rubys/roundhouse/releases/latest/download/roundhouse-installer.sh | sh
roundhouse --version
이 실험에서 설치된 것은 2026.9.18 (2e286e6f)
이었습니다. 위의 URL은 최신 릴리스를 가리키므로, 나중에 실행할 경우 다른 버전이 될 수 있습니다.
샘플을 가져오기 위해 리포지토리도 클론합니다.
mkdir -p ~/tmp
cd ~/tmp
git clone https://github.com/rubys/roundhouse.git
...
여기서는 이번에 사용한 샘플과 동일한 버전이 되도록 커밋을 지정했습니다. 설치된 CLI와 이 리포지토리의 버전은 다릅니다.
2. Spinel 준비하기
Spinel은 소스 코드에서 빌드합니다. 아래에서는 최종적으로 사용했던 커밋을 먼저 지정했습니다.
cd ~/tmp
git clone https://github.com/matz/spinel.git
cd spinel
...
$HOME/.local/bin를 PATH에 포함한 후, 컴파일러와 프로젝트용 명령어를 확인합니다.
export PATH="$HOME/.local/bin:$PATH"
spinel --version
spin --version
최종적으로 손에서 확인할 수 있었던 출력은 다음과 같습니다.
spinel 2026.09.12+141 (f04b62ca7) [apple clang 21.0.0 (cc)]
spin 2026.09.12+141 (f04b62ca7) [apple clang 21.0.0 (cc)]
3. Rails 앱 변환하기
Roundhouse 리포지토리로 이동하여 fixtures/real-blog를 변환합니다.
cd ~/tmp/roundhouse
roundhouse --target spinel \
-o out/spinel \
...
여기에 변환된 Ruby 코드, RBS, 런타임, spin.toml 등이 생성됩니다.
어셋 준비에는 다음 명령어를 사용합니다.
BUNDLE_ONLY=assets bundle install
BUNDLE_ONLY=assets make assets
이 샘플에는 Tailwind의 빌드 처리가 포함되어 있어, Node.js와 npm도 필요합니다. 공식 가이드에서는 Tailwind를 빌드할 경우 Node.js 24 이상을 요구하고 있습니다.
다만, 이번에 남아있는 static/assets/에서 확인된 것은 application.css뿐이었습니다. Turbo나 Stimulus의 JavaScript, Tailwind의 생성물은 확인할 수 없었습니다. 화면 표시는 나중에 가볍게 확인했지만, 어셋 준비가 모두 완료되었고 스타일이나 JavaScript가 올바르게 작동하는지는 미검증입니다.
막힌 부분 ① RBS 선언이 충돌하다
컴파일에는 다음 명령어를 사용합니다.
CC=clang spin build
작업 중, 다음 오류에 직면했습니다.
build blog
spinel: --rbs declares ActiveStorage_Service#upload
twice with different signatures:
...
생성된 Active Storage의 RBS를 검색합니다.
rg -n -C 3 'def upload:' --glob '*.rbs' .
같은 ActiveStorage::Service의 메서드가 두 개의 파일에서 선언되어 있었습니다.
runtime/active_storage.rbs:
def upload: (String key, String data) -> void
def delete: (String key) -> void
runtime/active_storage_disk.rbs:
def upload: (String key, String data) -> nil
def delete: (String key) -> nil
한쪽은 void, 다른 쪽은 nil입니다. nil은 반환값이 nil임을 나타내고, void는 반환값을 이용하지 않는 계약입니다. Spinel은 이 두 가지를 서로 다른 시그니처로 감지하고 있었습니다.
생성된 타입 정의를 임시로 맞추기
이번에는 우선 작동하는 것을 목표로, 생성된 RBS 선언을 void로 통일했습니다.
cp runtime/active_storage_disk.rbs \
runtime/active_storage_disk.rbs.bak
sed -i '' \
...
남아있는 백업본과의 차이점에서도 이 두 줄의 변경을 확인할 수 있었습니다. Ruby 구현 측은 어느 메서드 모두 명시적으로 nil을 반환하고 있습니다.
이 수정은 생성된 선언 불일치를 해소하기 위한 PoC(Proof of Concept) 임시 방편입니다. Active Storage의 동작을 검증한 것은 아니며, Roundhouse에서 재생성하면 사라집니다.
막혔던 부분 ② macOS에서 jemalloc을 찾지 못함
빌드를 진행하자 다음으로 링크 시 오류가 발생했습니다.
ld: library 'jemalloc' not found
clang: error: linker command failed with exit code 1
spinel: C compilation failed
...
C 코드 생성 이후, Clang이 실행 파일을 링크하는 단계에서 jemalloc을 찾지 못했습니다.
Homebrew가 라이브러리를 설치하는 위치는 링커의 표준 검색 경로에 포함되지 않을 수 있습니다. 이번 조합에서는 다음과 같이 검색 경로를 지정했습니다.
LIBRARY_PATH="$(brew --prefix jemalloc)/lib" \
CC=clang spin build
이로써 빌드가 성공했고, build/bin/blog가 생성되었습니다.
Spinel의 버전에 따라 pkg-config로 라이브러리 위치를 해결할 수 있으므로, 이 지정이 항상 필요한 것은 아닙니다. 공식 Spinel 가이드에도 macOS에서의 검색 경로에 대해 설명되어 있습니다.
도중에 다음과 같은 경고도 나타났습니다.
warning: --rbs: Attached @variations:
Array[Variation] stays a boxed array
warning: --rbs: UserAgent @tokens:
...
배열의 내부 표현에 관한 경고였으며, 이번 빌드를 중단시키는 것은 아니었습니다. 다만, 경고가 발생한 배열을 사용하는 기능까지 제대로 작동하는지는 별도로 확인이 필요합니다.
생성된 바이너리를 실행하기
SQLite 데이터베이스를 준비합니다.
mkdir -p storage
sqlite3 storage/development.sqlite3 < db/seed.sql
생성된 실행 파일을 구동합니다.
./build/bin/blog
구동 시 다음 메시지가 표시되었습니다. PID는 생략했습니다.
real_blog (spinel) listening on http://0.0.0.0:3000, pid ...
one green thread per connection;
OS workers: one per core (SPINEL_WORKERS=N to cap);
...
Roundhouse에서 변환하고 Spinel로 컴파일한 blog를 실행 파일로서 직접 구동할 수 있었습니다.
이 시점에서는 대기 중임을 나타내는 메시지가 표시되는 것까지 확인했습니다. 그 후, HTTP 응답이나 화면 표시도 가볍게 확인했습니다. 다만, 조작을 일련의 과정을 거쳐 시도했거나 원본 Rails 앱과 비교한 것은 아닙니다.
정말 네이티브 바이너리인가?
생성물을 file로 확인해보니 arm64의 Mach-O 실행 파일이었습니다.
$ file build/bin/blog
build/bin/blog: Mach-O 64-bit executable arm64
otool -L build/bin/blog로 확인된 직접적인 의존성은 다음 세 가지입니다.
/usr/lib/libSystem.B.dylib
/opt/homebrew/opt/jemalloc/lib/libjemalloc.2.dylib
/usr/lib/libsqlite3.dylib
Ruby의 공유 라이브러리에 대한 직접적인 의존성은 발견되지 않았습니다. 이는 Ruby 인터프리터를 필요로 하지 않는 Spinel의 설계와도 일관됩니다.
한편, jemalloc과 SQLite에는 동적으로 링크되어 있습니다. 완전히 정적으로 링크된 단일 파일은 아니며, 실행 시점에도 이 라이브러리들이 필요합니다. 또한, 이 앱은 데이터베이스나 에셋 파일도 로드합니다.
어느 정도까지 확인할 수 있었는가
이번 확인 범위를 정리하겠습니다.
| 검증 항목 | 결과 |
|---|---|
| Rails에서 Spinel용 Ruby 프로젝트로 변환 | 생성물 확인 |
| ... | |
| Rails 기반 애플리케이션에서 네이티브 실행 파일을 만들 수는 있었지만, Rails 애플리케이션과 동등하게 작동하는지는 아직 확인하지 못했습니다. |
HTTP 응답을 커맨드라인으로 확인할 경우, 서버를 띄운 상태에서 다른 터미널로 요청을 보낼 수 있습니다.
curl -i http://127.0.0.1:3000/
Roundhouse에는 원본 Rails 앱과 변환된 응답을 비교하는 메커니즘도 있습니다. 공식 샘플의 경우, 리포지토리 루트에서 다음 명령어를 사용할 수 있습니다.
bin/rh compare spinel
이것은 비교용 앱을 별도로 생성 및 빌드하고, Rails와 변환된 서버에 대상 URL로 요청을 보내 응답을 대조하는 처리입니다. 이번에 손으로 수정한 out/spinel/build/bin/blog를 그대로 검증하는 명령어는 아닙니다.
실행하려면 Rails 측의 gem이나 Rust 툴체인 등 비교 스크립트의 전제 환경이 필요합니다. 자세한 내용은 공식 검증 가이드를 참조해 주십시오. 대상 URL에서 일치하더라도, CRUD나 Rails의 모든 기능이 동등하다고 보장되는 것은 아닙니다.
워크어라운드(Workaround)를 계속 유지할 수 있는가
같은 Kaigi on Rails 2026에서 amatsuda 씨가 발표한 'Web Framework Benchmark Battle 2026, Any%' 역시 이 실험을 재고하게 된 계기가 되었습니다. 제가 현장에서 들은 범위로는, Spinel과 Roundhouse에는 많은 워크어라운드가 있으며, 이를 계속 유지하기 어려운 점을 지적하고 있었습니다.
이는 발언의 문자 그대로 인용이 아니라 저의 이해입니다. 링크된 공식 페이지에서는 발표 개요만 확인할 수 있을 뿐, 이 평가에 대한 발언 녹취록은 아닙니다. 또한, 아래 내용은 발표에서 제시된 코드의 재현이 아니라, 그 우려와 관련된 부분을 제가 손안의 소스에서 찾은 것입니다.
조사 대상은 Roundhouse의 298fd9a1c4e1baa4c42b424e5b1659011938d338입니다. 아래 링크는 모두 이 커밋에 고정되어 있습니다. 이 리포지토리의 구현을 조사한 것이며, 설치된 2026.9.18이 생성한 이번 앱에 다음 처리가 모두 포함되어 있다고 확인한 것은 아닙니다.
타입 추론을 통과시키기 위해 사용하지 않는 반환값을 명시한다
runtime/spinel/concurrent.rb의 post에는 다음 코드가 있습니다. 블록 내부 발췌입니다.
if @idle == 0 && @live < @max_threads
spawn = true
@live += 1
...
이 직후에 단독으로 nil이 놓여져 있습니다.
nil
옆의 주석에서는, else가 없는 분기와 예외를 던지는 분기의 조합으로 인해 Spinel이 첫 번째 분기만으로 합류 후의 타입을 결정해 버리는 문제(matz/spinel#4464)를 이유로 들고 있습니다. 블록의 반환값 자체는 사용하지 않지만, 컴파일러에 타입을 인식시키기 위해 마지막 식을 명시하는 것입니다.
이 nil을 불필요하다고 보고 제거하면, 대상 컴파일러에서는 문제가 발생할 가능성이 있습니다. 유지보수하는 사람에게는 앱의 처리 외에도 이 작성 방식이 필요한 이유까지 인계해야 합니다.
예외 처리의 뒷정리를 rescue와 ensure 모두에 작성한다
runtime/ruby/active_record/connection.rb에는 Spinel의 예외 처리 문제 회피 설명이 있습니다.
주석에 따르면, begin/rescue/ensure로 예외를 재발출할 경우 등에 ensure가 실행되지 않는 문제(matz/spinel#8182)가 있어 트랜잭션 깊이를 되돌리는 처리를 rescue
에도 배치하고 있습니다. 게다가 예외 전파 방식을 맞추기 위해 내부를 begin/rescue로, 외부를 begin/ensure로 나누었습니다.
중첩된 트랜잭션 분기에서는 다음 할당이 rescue 쪽에 있습니다.
Db._txn_depth = depth
동일한 할당이 바로 뒤의 ensure 쪽에도 있습니다.
ensure
Db._txn_depth = depth
end
return이나 break처럼 rescue를 거치지 않는 탈출에도 대응하기 위해, ensure 쪽 처리도 남겨두었다고 합니다. 깊이를 되돌리지 못하면 다음 트랜잭션을 중첩된 것으로 오인하여 BEGIN을 발행하지 않게 된다고 설명합니다.
이는 단순히 보이는 장황함만의 문제가 아닙니다. 컴파일러의 예외 처리와 데이터베이스의 무결성에 관련된 뒷정리를 여러 경로에서 계속 맞춰나가야 합니다.
Date 정의를 로드하는 것만으로도 다른 타입 호출에 영향을 미침
src/project.rs에서는 앱이 날짜를 사용하는지 여부에 따라 Date 관련 파일을 생성물에 포함할지 여부를 전환하고 있습니다. 날짜가 불필요한 경우의 처리는 다음과 같습니다.
files.retain(|(p, _)| !DATE_PACKAGE_FILES.contains(&p.as_str()));
옆의 주석에는 Date#strftime을 모든 Spinel용 프로젝트에 로드하면, Time | Date처럼 여러 타입을 취할 수 있는 레시버에서 호출이 깨지는 문제(matz/spinel#7334)가 있다고 쓰여 있습니다.
단순히 출력 크기를 줄이기 위한 조건 분기가 아닙니다. 컴파일러의 문제를 피하기 위해 어떤 클래스를 프로그램에 포함할지까지 생성 측에서 제어하고 있습니다. 다만, Date를 사용하는 앱의 문제가 모두 해결된다는 의미는 아닙니다.
gem을 그대로 쓰지 않고 필요한 API만 재구현하다
같은 runtime/spinel/concurrent.rb은 concurrent-ruby의 일부를 Spinel용으로 구현한 것입니다. 초기화 처리는 다음과 같습니다.
def initialize(min_threads: 0, max_threads: 8, max_queue: 0, idletime: 60, fallback_policy: :abort)
@max_threads = max_threads
@max_queue = max_queue
...
min_threads, idletime, fallback_policy는 인수로 받지만, 이 본문에서는 참조하고 있지 않습니다. 맨 앞의 주석에서도 사전에 최소 스레드 수 확보나 유휴 시간 조정, :abort 외의 폴백 정책은 구현하지 않는다고 명시되어 있습니다.
게다가 Thread#alive?를 배열 요소 경유로 호출하면 예외가 발생하는 문제(matz/spinel#4463)를 피하기 위해, 생존 워커 수를 @live로 센다고 설명합니다.
이 파일에는 컴파일러의 결함을 피하려는 노력과 gem의 대응 범위를 좁히는 설계 두 가지가 있습니다. 후자는 단순한 버그 회피가 아니지만, 호출원이 미구현 옵션에 의존하기 시작할 때는 호환성을 다시 판단해야 합니다.
코드에서 알 수 있는 것과 아직 알 수 없는 것
이러한 예시들로부터 Roundhouse에는 Spinel의 동작 방식에 맞춘 작성법이나 gem의 일부를 이어받아 구현하는 부분이 있다는 것을 확인할 수 있습니다. 이번에 직접 진행한 RBS 수정이나 링크 경로 지정보다, 애플리케이션의 동작에 깊이 관여하는 부분입니다.
다만, 주석이 참조하는 Spinel의 문제는 이번에 각각 재현하지는 않았습니다. 각 issue의 현재 수정 상황도 미확인입니다. 코드에 회피책이 남아 있다는 것과, 최신 버전에서도 그 회피책이 필요하다는 것은 분리해서 생각해야 합니다.
또한, 모든 정적 변환을 워크어라운드(workaround)라고 부르는 것도 적절하지 않습니다. 예를 들어 Action Cable의 채널 생성 처리는 문자열에서 동적으로 클래스를 찾는 대신, 생성 시점에 분기를 만드는 설계입니다. 이는 Roundhouse가 동적인 Rails 코드를 정적인 프로그램으로 변환하는 메커니즘 자체입니다.
왜 흥미로운가
이번 실험에서 흥미로웠던 점은, Rails로 작성된 코드를 평소와 다른 실행 방식으로 가져갈 수 있다는 것입니다.
Rails에서는 Active Record의 has_many나 belongs_to, 라우팅, 유효성 검사(validation) 등의 DSL을 사용하여 애플리케이션을 작성합니다. 이는 Ruby의 동적 특성을 활용한 메커니즘이지만, 여기에는 관련 모델이나 컬럼의 타입 등 컴파일 시점에도 읽어낼 수 있는 정보가 포함되어 있습니다.
Roundhouse는 이러한 규칙이나 DSL, 데이터베이스 스키마로부터 앱의 구조를 분석합니다. Spinel은 거기서 생성된 Ruby 코드를 타입 추론(type inference)하여 네이티브 바이너리(native binary)로 컴파일합니다.
물론, 사용할 수 있는 Rails 기능이나 Ruby 작성 방식에는 제약이 있습니다. 이번에도 버전을 맞추거나, 생성된 RBS에 손을 대야 할 필요가 있었습니다.
그럼에도 불구하고, Rails로 작성한 샘플로부터 Ruby 인터프리터 없이 작동하는 실행 파일을 직접 만들어 볼 수 있었던 것은 흥미로운 경험이었습니다. HTTP 응답이나 화면 표시도 가볍게 확인하며 일단 동작하는 모습을 볼 수 있었습니다. 다음에는 CRUD나 원래의 Rails 앱과의 비교까지 시도해 보고 싶습니다.
또한, 발표에서는 Spinel on Rails를 구현하기 위한 미들웨어인 'Pines'를 amatsuda 님이 개발 중이라는 소개도 있었습니다. 발표 시점에서는 공개되지 않은 것이라고 합니다. 이쪽 동향에도 주목하고 싶습니다.
참고 자료
토론
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기