🔋 Ruby is an acceptable LISP (Reloaded!)

HN: Why Ruby is an acceptable Lisp (2005) (randomhacks.net)

commonlisp sly/slime 세션의 interactive development처럼 흉내내기. (제목의 “reloaded”-은 말장난)

commonlisp sly/slime?

커먼리습을 위한 emacs mode. 흥미로운 점은 실행중인 리습코드가 이미 루프를 돌아가고 있더라도, 다른 스레드의 swank1에서 코드를 변경+컴파일해서 로딩하면, 실행중인 루프에 바로 반영된다.

이게 뭐가 편한가 싶은데, 만약 다른 언어였다면:

  1. 코드를 수정.
  2. (OPTIONAL) 동작하고 있던 프로그램을 종료
  3. (OPTIONAL) 새 코드를 컴파일하거나,
  4. 어쨌든 다시 처음부터 프로그램을 시작.
  5. … #1부터 반복.

물론, “watcher”-같은걸 붙여놓고 자동으로 파일이 변경되면 tests을 다시 실행하거나, 프로그램을 재시작하도록 하는게 ‘development’-mode에선 일반적이다.2

물론 꼭 커먼리습/스몰톡 같이 변경이 실행중인 코드에 바로 반영되어야만 하는 경우는 많지 않을수도 있다.

반면에, 그렇게 코딩단계를 쓰지 못하기 때문에, 현재의 tests을 변경에 따라 실행해 나가는 방식으로 자리를 잡게 되었는지도 모르겠다.

rails은 그냥 되던데요

실은 rails 개발을 하면, 코드를 수정하고 즉시 웹브라우저에서 변경된 내용을 F5 / reload만 눌러서 확인이 가능하다.

rails server을 다시 시작할 필요도 없고, 마치 php와 같이 변경이 바로 반영되어 확인할 수 있음.

그런데 생각해보면, php은 실행모델이 원래 그러니까 자연스럽게 변경된 내용이 다음번 HTTP request에 바로 반영된걸로 보이는게 당연하다. php은 한 http request이 실은 php 파일을 로딩하고 처음부터 다시 실행되는게 기본전제이고, 현대의 php-fpm이나 frankenphp등의 다른 방식을 사용하더라도, 최소한 development-mode에선 이런 php의 특성을 그대로 유지하려고 애쓰는 모습이다. (개발단계가 엄청나게 편해지니까)

그런데, rails은 별도의 루비코드가 하나의 요청-응답을 처리하는 서버프로세스로 이미 동작하고 있는데 어떻게 자동적으로 변경을 반영해줄 수 있을까?

==> 답은, rails이 zeitwerk rubygem을 사용하고 있기 때문. 3

commonlisp, smalltalk의 또다른 (숨은) 편리함

바로 import / require 같은걸 쓸일이 없다는점.

커먼리습과 스몰톡 모두 현재 VM이 로딩한 코드는 패키지나 카테고리 같은 namespaces으로 구분되기는 하지만 직접 이들을 로딩할 필요가 거의 없도록 한다.

물론, 처음 코드를 파일시스템이나 Git에서 로딩하기 위해서 asdf이나 Iceberg등을 사용하기는 하지만, 처음에 한 번이다. 커먼리습은 코드파일을 변경하고 그걸 바로 컴파일+로드 해나가면서 작업하고, 스몰톡은 이미지에 계속 작업을 쌓아가다가 나중에 Git commit하거나 Export하므로.

반면 Java 같은 나름대로 import하기 균일한 언어만 해도 IntelliJ/Eclipse JDT/LSP 같은 도구가 없다면 import을 하나씩 찾아주기 괴로워진다.

node.js에서 require/import은… 더 끔찍하다. commonjs module, esm 구분도 골치아파지고 쓸때마다 괴롭다.

루비에서도 require은 있지만, 가장 단순한 형태에 속하는거 같다. 그냥 지정한 이름을 $LOAD_PATH-에서 찾아서 로딩하는것.

Perl 5의 use 같은 신비롭고도 복잡한 방식은 아니라 다행이다.4

그런데, rails을 쓰다보면, controller/service등의 코드에서 다른 클래스를 불러오기 위해 require-을 쓸일이 전혀 없다.

zeitwerk?

위에 언급한 커먼리습과 스몰톡의 장점을 루비/레일즈에서 zeitwerk을 통해 얻을 수 있다5:

  1. require-없이 바로 클래스나 모듈에 접근이 가능하다.
  2. 소스코드 파일이 변경되면 자동으로 해당 코드만 reload하여 반영할 수 있다.

실은 rails 프로젝트를 개발하면, zeitwerk이 이미 세팅되어 있어서 신경쓸게 없다.

여기에선 ruby가 이런 측면에서도 an acceptable lisp (reloaded!)임을 보이기 위해서 하나씩 시도해보려고 함. (그리고 재밌으니까)

1단계: require 제거

내가 짠 클래스(MyLib::Greeter) 불러와 사용하는 스크립트.

main.rb :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  #!/usr/bin/env ruby
  require 'bundler/inline'

  gemfile do
    source 'https://rubygems.org'
    gem 'zeitwerk'
  end

  loader = Zeitwerk::Loader.new
  loader.push_dir(Pathname(__dir__) / 'lib') # <-- 요기에서 찾아서 로딩.
  loader.setup

  #    vvvvv `require` 없이 바로 사용!
  puts MyLib::Greeter.new.greet

lib/my_lib/greeter.rb :

1
2
3
4
5
6
7
  module MyLib
    class Greeter
      def greet
        "Hello, World!"
      end
    end
  end

여기서 사용한 내용들은:

2단계: code reloading

main.rb (개선) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  #!/usr/bin/env ruby

  require 'bundler/inline'

  gemfile do
    source 'https://rubygems.org'
    gem 'zeitwerk'
    gem 'filewatcher'  # <-- 추가
  end

  loader = Zeitwerk::Loader.new # for_gem(warn_on_extra_files: false)
  loader.push_dir(Pathname(__dir__) / 'lib')
  loader.enable_reloading  # <-- 추가
  loader.setup

  # 별도 스레드에서 파일변경모니터링 => zeitwerk-reload
  fsw = Filewatcher.new(['lib/'])
  Thread.new(fsw) do
    fsw.watch do |_filename|
      loader.reload
    end
  end

  # 예시를 보이기 위해 계속 실행되어야 하므로.
  loop do
    # ...새로운 인스턴스에 변경이 적용되므로,
    # ...매번 new하도록.
    puts MyLib::Greeter.new.greet
    sleep 1
  end

…이제 이 스크립트를 실행시키고, lib/my_lib/greeter.rb-을 수정하여 메시지를 바꾸고 저장해보면, 실행중이던 스크립트의 출력이 즉시변경된다. 🎊

한계점

위 예제에서 보듯이, 이미 존재하는 인스턴스를 바꿔치거나 하는건 동작하지 않을것 같다.

하지만, 인스턴스를 바꿔쳐줘도 좀 혼란스러울거 같은데, 어떤 기준으로 어떻게 전환될지 이해하기 어려울거 같기 때문. 저정도만 해도 합리적인 수준인거 같다.6

Ruby에서 zeitwerk이 유용했던 이유?

앞서 언급한 루비 이외에 최근에 흔한 Node.js, Python 등의 다른 언어들이었다면, zeitwerk 같은 라이브러리가 있었다고 하더라도, 유용성이 적었거나, 그냥 현재의 아예 프로그램을 처음부터 재시작하는 방식이 더 나았으리라 싶다.

그렇게 생각하는 이유는 루비의 설계가 Smalltalk + Lisp + … 이기 때문인거 같다.

루비의 메서드호출은 스몰톡의 그것과 같이 message-passing으로 이루어지고, 이는 실행시간에 성능이 느리지만, 진짜 dynamic-dispatch이 가능하도록 해준다. 그리고 루비의 큰 장점인 metaprogramming도 이런 특성에 큰 덕을 보고 있다.7

리습의 경우에도 유사하게, 함수의 심볼을 갖고 있고, 그 심볼에 연결된 함수를 호출하는, 결과적으로 message-passing와 거의 같은 효과를 갖는다.

호출하는 쪽에서 코드가 변경되지 않더라도(reload하지 않더라도), 피호출 클래스나 코드가 변경된 것을 실행시간에 그대로 반영 받을 수 있는 구조이기 때문이다.

실은, 자바스크립트나 파이썬도 실행시간에 메서드를 찾아내는 방식은 충분히 동적이다. ㅎㅎ그리고 루비 + zeitwerk도 완전히 모든 경우에 프로그램을 완전히 재시작할 필요를 없애주지는 못한다. 하지만, 이 글은 루비에 우호적인 글이므로 이렇게 써봤다.

글쎄다. 다른 언어에서보다 이렇게 생태계나 툴링이 마치 리습이나 스몰톡을 복원한것처럼 되어온데엔, 기술적인 특징 이외에도, 원래 루비가 그런 조상들에 대해 호감을 갖고 있었기 때문이지 않을까.

Footnotes


2

npm에서만 찾아봐도, nodemon, chokidar, node-watch등 딱 이렇게 쓰기 위한 도구들이 있다.

3

물론, rails에선 zeitwerk을 그냥 그대로 사용하는것만이 아니라, 파일변경을 모니터링하고 reload을 요청하고 하는 모든 처리를 해준다.

4

https://perldoc.perl.org/functions/use@INC-로딩경로를 탐색도 하고, 거기에 나아가서 해당 ‘모듈’의 코드를 실행해서 importing도 처리해주고… 그리고 모듈만이 아니라 “pragmas”에 속하는 경고수준을 바꾸거나 하는 것도 모두 이렇게 구현되어 있어서 복잡하다. 그리고 이런 “훌륭함”이 JavaScript에도 전해져서 "use strict"-같은 갑자기 분위기 Perl인 문장들이 자바스크립트에 안어울리게 종종 튀어나오는 원인이 되기도 한다.

5

“zeitwerk”은 직역하면 “time-work”(獨🇩🇪).

6

커먼리습의 CLOS은 이미 존재하는 인스턴스의 클래스를 바꾸는게 가능하긴 하지만: https://funcall.blogspot.com/2025/03/advanced-clos-update-instance-for.html …괜찮다. ㅎㅎ

7

과거의 Objective-C와 OSX의 NeXTSTEP API들도 모두 이런 스몰톡의 특징을 그대로 구현해놓았다. (API와 ObjC Runtime을 통해서)