I wrote a programming language where you code in Polish. Here are 7 things it taught me about Ruby.
폴란드어로 코딩하는 프로그래밍 언어를 만들었다 — 그 과정에서 Ruby에 대해 배운 7가지
Ruby 개발자가 'x = 5를 입력하고 머신이 동작하기까지 무슨 일이 일어나는지' 궁금해 시작한 주말 프로젝트가 18개월 후 표준 라이브러리, async/await, 디버거, 자체 웹 프레임워크를 갖춘 폴란드어 키워드 스크립트 언어 AlexScript로 성장했습니다. throw/catch를 이용한 return 구현, UTF-8 바이트 단위 렉싱, 네이티브-유저 메서드 테이블 통합, Fiber 기반 비동기 런타임 등 인터프리터를 Ruby로 직접 만들며 얻은 구체적인 성능·설계 교훈을 정리합니다.
- 주제
본문
Ruby로 생계를 이어가는 개발자가 'x = 5를 입력한 순간부터 머신이 동작할 때까지 사이에 무슨 일이 벌어지는지' 모른다는 불편함에서 시작된 프로젝트가 AlexScript로 성장한 이야기입니다. 인터프리티드 객체지향 스크립트 언어로, 표준 라이브러리, async/await, 디버거, 자기 자신으로 작성된 웹 프레임워크까지 갖췄고 모든 키워드가 폴란드어입니다. klasa는 class, funkcja는 function, niech은 let, zwroc은 return에 해당합니다.
폴란드어 키워드와 ASCII 이중 표기
폴란드어에는 디아크리틱이 있어 정확한 철자는 zwroc이 아니라 zwróć입니다. 그러나 ó와 ć를 입력하려면 매번 AltGr 조합이 필요해서 루프를 작성할 때 곧 지치게 됩니다. 그래서 대부분의 키워드는 실제로 타이핑하는 ASCII 형태와 제대로 된 철자의 별칭 두 가지를 가집니다. jesli/jeśli, dopoki/dopóki, falsz/fałsz처럼 렉서가 둘 다 받아들입니다.
return을 throw/catch로 구현하기
tree-walking 인터프리터에서 return을 구현하는 가장 쉬운 방법은 예외를 발생시켜 호출 지점에서 잡는 것입니다. 즉시 동작해서 모두가 처음에 이렇게 하지만, Ruby가 매번 예외 객체와 백트레이스를 만들기 때문에 성능 문제가 됩니다. 재귀 코드에서는 이 비용이 모든 것을 지배합니다. 해결책은 throw/catch입니다. return 문에서 throw :alex_return으로 던지고 호출 지점에서 catch(:alex_return)으로 받으면 예외 객체도 백트레이스도 스택 언와인딩 기계도 필요 없습니다. 다만 throw/catch는 자신이 제어하는 비국소 탈출에만 쓸 수 있으므로 raise의 일반적 대체재는 아니지만, 점프 위치를 정확히 아는 함수 반환에는 올바른 도구입니다.
UTF-8 문자열의 바이트 단위 렉싱
원래 렉서는 source[position]으로 문자 단위 스캔을 했는데, Ruby 문자열은 UTF-8이라 n번째 문자를 찾으려면 처음부터 코드포인트를 세며 걸어가야 합니다. 폴란드어 디아크리틱이 가득한 파일에서 토큰화가 우연히 이차 시간이 됐습니다. getbyte와 byteslice로 전환해 O(1)에 할당 없이 처리하고, 추출한 바이트 구간에 force_encoding(Encoding::UTF_8)을 적용했습니다. 텍스트를 문자 단위로 스캔하면서 입력이 ASCII가 아닐 수 있다면 확인해야 할 지점입니다.
네이티브와 유저 메서드 테이블의 통합
표준 라이브러리는 Ruby로, 유저 코드는 AlexScript로 작성됩니다. 순진한 접근은 네이티브용·유저용 두 개의 레지스트리를 두고 디스패치가 둘 다 확인하는 것인데, 그렇게 하지 말라고 저자는 말합니다. 대신 네이티브 메서드를 native_lambda 플래그와 함께 유저 클래스가 쓰는 동일한 메서드 테이블에 주입하면 디스패치가 단일 조회가 되고 분기는 마지막 단계에서만 일어납니다. 네이티브 클래스를 서브클래싱할 수 있고 super가 그 안까지 도달하며 리플렉션으로도 구분이 안 됩니다. 저자는 별도 레지스트리로 시작했다가 stdlib 클래스 상속이 필요해지면서 '빌트인은 특별하다'는 분기가 메서드 조회, super 해석, 리플렉션, 디버거 전층에 퍼져 두 번 다시 쓴 끝에 테이블을 합쳤고, 그 diff는 추가한 코드보다 삭제한 코드가 더 많았습니다. MRI도 cfunc와 Ruby 메서드가 메서드 테이블을 공유하는 같은 방식이며, CRuby의 메서드 디스패치를 읽은 것이 이 프로젝트에서 가장 유용한 일이었다고 합니다.
Fiber로 만든 비동기 런타임
czekaj(await)는 현재 Fiber를 Fiber.yield로 멈추고 커스텀 리액터에 제어를 넘깁니다. 리액터는 준비 큐, 정렬된 타이머 리스트, IO.select 루프로 구성되고, 프로미스가 settle되면 대기자를 큐에 다시 밀어 넣습니다. 실행 중에는 자신을 Fiber.set_scheduler로 설치해 Fiber 내부의 stdlib 연산이 스케줄러 인터페이스를 거치게 합니다. Fiber가 주는 것은 suspend와 resume이고 나머지는 부기(bookkeeping)인데, 예상보다 양이 적었다고 합니다.
WeakRef의 함정과 예외 시스템
클로저 환경에 WeakRef를 썼다가 Invalid Reference: probably recycled 오류가 무작위로 발생해 GC가 논리적으로는 도달 가능하지만 보이는 참조가 없는 환경을 수거해갔고, 결국 모든 곳에 강한 참조를 씁니다. 반면 AlexScript 예외는 Ruby식으로 기반 예외 클래스를 상속하는 평범한 클래스로, 각각 유저의 클래스 계층을 올라가 가장 가까운 빌트인에 해석된 진짜 Ruby 예외 클래스가 뒷받침합니다. 그래서 proba/zlap/wkoncu가 문자 그대로 begin/rescue/ensure입니다. 예외 시스템의 상당 부분이 던지는 것이 아니라 ensure 블록이 세 개의 중첩 스코프를 올바른 순서로 통과하는 정리(cleanup) 의미론임을 과소평가했는데, 그 모든 것이 공짜로 따라왔습니다.
정수, 한계, 그리고 시도 방법
정수 쌍 위에 정확한 유리수 산술을 구현해 베르누이 수를 계산했는데, B(60)의 43자리 분자가 오버플로 없이 정확히 나왔습니다. Ruby 정수는 그냥 자라납니다. 한계도 있습니다. Fiber 스케줄러가 IO#close로 인터럽트되지 않아 소켓 읽기에 블록된 Fiber가 클라이언트 단절 시 멈추는 Ruby 버그 때문에 웹 서버는 스레드-퍼-커넥션으로 돌리고, eval·method_missing·define_method·매크로가 없으며 tree-walking이라 성능 천장이 있습니다. ruby.wasm으로 컴파일된 브라우저 REPL(alexscript.pl/try)에서 설치 없이 시험할 수 있습니다.
원문: dev.to / 번역·요약: Trawling