환경: Spring Boot 4.0.2 · Java 21 · JRuby 9.4 · WebSocket · Virtual Thread
배경
개인 프로젝트 lifelog에 Random Coupler라는 기능을 추가하게 됐다. Random Coupler는 이름 목록을 입력받아 무작위로 쌍을 지어주는 Ruby CLI 스크립트(random-coupler)인데, 이것을 블로그 방문자가 웹 브라우저에서 직접 체험해볼 수 있도록 만들고 싶었다.
요구사항을 정리하면 다음과 같다.
- Ruby 스크립트를 그대로 실행해야 한다. 스크립트 코드는 수정 불가.
- 브라우저에서 실시간으로 stdout 출력을 볼 수 있어야 한다.
- 브라우저에서 입력을 타이핑하면 Ruby 스크립트의
gets()가 그것을 읽어야 한다. - 즉, 양방향 stdin/stdout 스트리밍이 필요하다.
Ruby 프로세스를 OS 레벨에서 실행(ProcessBuilder)하는 방법도 있지만, Spring 애플리케이션에 Ruby 런타임을 따로 설치해야 하고 프로세스 격리나 보안 관리가 까다롭다. 그래서 JRuby를 선택했다. JRuby는 JVM 위에서 동작하는 Ruby 구현체로, Java 라이브러리로 임베드할 수 있어 Spring 애플리케이션 안에서 Ruby 스크립트를 실행할 수 있다.
전체 구조 한눈에 보기
브라우저 (coupler.html + coupler.js)
│ WebSocket /ws/coupler
▼
CouplerWebSocketHandler (Spring WebSocket)
│
├── afterConnectionEstablished()
│ → Virtual Thread로 runScript() 실행
│
├── handleTextMessage()
│ → CouplerInputHelper.push(line)
│ ↓ BlockingQueue
│ Kernel#gets monkey-patch → Ruby gets() 반환
│
└── runScript()
→ new ScriptingContainer(SINGLETHREAD)
→ setOutput(WebSocketLineOutputStream) ← stdout → WS 전송
→ setError(WebSocketLineOutputStream) ← stderr → WS 전송
→ put("$coupler_input", inputHelper)
→ runScriptlet(GETS_PATCH) ← gets() 오버라이드
→ runScriptlet(PathType.ABSOLUTE, path) ← 스크립트 실행
Step 1. 의존성 추가
build.gradle.kts에 JRuby를 추가한다. JRuby는 Ruby 런타임 전체를 포함하는 fat JAR이라 파일 크기가 상당하지만(~20MB), 별도 Ruby 설치 없이 동작한다는 점이 장점이다.
// web/build.gradle.kts
dependencies {
implementation("org.jruby:jruby:9.4.12.0")
}
Step 2. ScriptingContainer — JRuby 임베딩 API
JRuby를 Java에 임베드하는 공식 API는 ScriptingContainer다. JSR-223(javax.script.ScriptEngine)보다 JRuby 고유 기능(글로벌 변수 주입, stdout/stderr 리다이렉트 등)을 더 세밀하게 제어할 수 있어서 ScriptingContainer를 직접 사용했다.
ScriptingContainer에는 세 가지 스코프가 있다.
| 스코프 | 설명 | 용도 |
|---|---|---|
SINGLETON |
JVM 전체에서 단 하나 | 공유 상태가 필요한 경우 |
THREADSAFE |
스레드마다 독립된 로컬 컨텍스트 | 다중 스레드 공유 |
SINGLETHREAD |
단일 스레드 전용, 가장 경량 | 세션별 전용 컨테이너 |
각 WebSocket 세션은 고유한 stdout/stderr 스트림과 stdin 큐를 가져야 하므로, 세션마다 전용 ScriptingContainer를 생성하고 SINGLETHREAD 스코프를 사용했다. 하나의 컨테이너를 공유하면 세션 간에 스트림이 뒤섞이는 문제가 생긴다.
container = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
container.setCurrentDirectory(scriptPath); // 스크립트의 require 기준 경로
container.setOutput(ps); // stdout → WebSocket
container.setError(ps); // stderr → WebSocket
Step 3. stdout 스트리밍 — WebSocketLineOutputStream
ScriptingContainer.setOutput()은 java.io.OutputStream을 받는다. 이 스트림에 쓰인 내용이 WebSocket 메시지로 변환되어 브라우저로 전달되도록 WebSocketLineOutputStream을 구현했다.
public class WebSocketLineOutputStream extends OutputStream {
private final WebSocketSession session;
private final ReentrantLock lock;
private final ByteArrayOutputStream buf = new ByteArrayOutputStream();
@Override
public void write(byte[] b, int off, int len) throws IOException {
buf.write(b, off, len);
flushLines();
}
private void flushLines() throws IOException {
String chunk = buf.toString(StandardCharsets.UTF_8);
int nl;
while ((nl = chunk.indexOf('\n')) >= 0) {
String line = chunk.substring(0, nl);
sendLine(line);
chunk = chunk.substring(nl + 1);
}
buf.reset();
if (!chunk.isEmpty()) {
buf.write(chunk.getBytes(StandardCharsets.UTF_8));
}
}
}
핵심은 줄 단위 버퍼링이다. Ruby가 puts로 출력하면 \n이 포함된 바이트가 흘러들어온다. 개행 문자를 기준으로 나눠서 한 줄씩 WebSocket TextMessage로 전송한다. 이렇게 하면 브라우저에서 한 줄씩 순차적으로 출력되어 실제 터미널처럼 보인다.
WebSocket 세션의 sendMessage()는 스레드 안전하지 않다. JRuby 스크립트 스레드와 WebSocket 이벤트 스레드가 동시에 sendMessage()를 호출할 수 있으므로 ReentrantLock으로 보호한다.
Step 4. stdin 스트리밍 — Kernel#gets 몽키패치
가장 까다로운 부분이 stdin이었다. 처음에는 PipedInputStream을 ScriptingContainer의 stdin으로 설정하면 될 것이라 생각했다.
// 1차 시도 — PipedInputStream
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos);
container.setInput(pis);
// → org.jruby.exceptions.SystemCallError: (EBADF) Bad file descriptor
JRuby는 내부적으로 stdin을 ChannelFD로 래핑하는데, PipedInputStream의 내부 파일 디스크립터를 열려고 하면 EBADF 에러가 발생한다. NIO Pipe, PipedReader도 동일한 문제가 있었다.
// 2차 시도 — NIO Pipe
Pipe pipe = Pipe.open();
// → 동일하게 EBADF
// 3차 시도 — PipedReader + ReaderInputStream
// → java.io.IOException: Pipe broken
결론은 JRuby의 setInput()은 실제 OS 파일 디스크립터가 없는 Java 스트림과 잘 맞지 않는다는 것이다.
해결책: Kernel#gets 몽키패치 + BlockingQueue
JRuby는 Ruby 코드를 실행하기 전에 임의의 Ruby 코드를 주입할 수 있다. 이것을 이용해 Ruby의 Kernel#gets를 Java객체에 연결된 메서드로 교체해버렸다.
private static final String GETS_PATCH =
"module Kernel\n" +
" def gets(*)\n" +
" result = $coupler_input.gets\n" + // Java CouplerInputHelper 호출
" $_ = result\n" +
" result\n" +
" end\n" +
"end\n";
$coupler_input은 Java의 CouplerInputHelper 인스턴스다. JRuby에서는 container.put("$varname", javaObject)로 글로벌 변수에 Java 객체를 주입할 수 있다.
// CouplerInputHelper.java
public class CouplerInputHelper {
private static final String CLOSE_SENTINEL = "\u0000";
private final LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();
private volatile boolean closed = false;
// 브라우저에서 입력이 들어오면 WebSocket 핸들러가 호출
public void push(String line) {
if (!closed) queue.offer(line);
}
// Ruby의 gets()가 호출될 때 블로킹 대기
public String gets() throws InterruptedException {
String line = queue.take(); // 입력이 올 때까지 블로킹
if (CLOSE_SENTINEL.equals(line)) return null;
return line + "\n"; // Ruby의 gets() 관례: 개행 포함 반환
}
// 연결 종료 시 블로킹 해제
public void close() {
closed = true;
queue.offer(CLOSE_SENTINEL);
}
}
동작 흐름은 이렇다.
브라우저에서 Enter 입력
│ WebSocket TextMessage
▼
handleTextMessage() → inputHelper.push("홍길동")
↓ LinkedBlockingQueue
Ruby 스크립트 내 gets() 호출
→ Kernel#gets monkey-patch 발동
→ $coupler_input.gets() (Java 메서드 호출)
→ queue.take() 블로킹 해제
→ "홍길동\n" 반환
Step 5. PathType.ABSOLUTE와 FILE == $PROGRAM_NAME 가드
Ruby 스크립트에는 흔히 다음과 같은 가드 패턴이 있다.
if __FILE__ == $PROGRAM_NAME
RandomCoupler.new.run
end
이 가드는 스크립트가 직접 실행될 때(ruby coupler.rb)만 run()을 호출하고, require로 불러올 때는 실행하지 않겠다는 의미다.
JRuby에서는 스크립트를 불러오는 방식이 몇 가지 있다.
| 방식 | $0 값 |
가드 발동 여부 |
|---|---|---|
runScriptlet(String) (string eval) |
script (가상 경로) |
❌ |
runScriptlet(PathType.CLASSPATH, path) |
클래스패스 상의 가상 경로 | ❌ |
runScriptlet(PathType.ABSOLUTE, path) |
실제 파일 절대 경로 | ✅ |
PathType.ABSOLUTE로 실행하면 JRuby가 내부적으로 $0 = scriptAbsPath를 설정하므로 __FILE__ == $PROGRAM_NAME 조건이 true가 된다. 이것을 이용하면 스크립트를 수정하지 않고도 가드를 정상 통과시킬 수 있다.
container.runScriptlet(GETS_PATCH); // gets() 오버라이드 먼저
container.runScriptlet(PathType.ABSOLUTE, scriptAbsPath); // 스크립트 실행
// 명시적인 RandomCoupler.new.run 호출 금지 — 가드가 자동으로 한 번만 실행함
주의할 점은 명시적인 RandomCoupler.new.run 호출을 추가하면 이중 실행이 된다는 것이다. PathType.ABSOLUTE가 이미 가드를 통과시켜 run()을 한 번 실행하기 때문이다.
string eval(runScriptlet(String))을 쓰면 require 'json' 같은 구문에서 ENOENT 에러가 발생한다. JRuby가 __FILE__을 script라는 가상 경로로 설정하기 때문에 require의 기준 디렉터리를 찾지 못하는 것이다.
Step 6. 세션 관리와 리소스 해제
ScriptingContainer.terminate() hang 방지
ScriptingContainer.terminate()는 JRuby 런타임을 종료하는 메서드인데, 경우에 따라 블로킹될 수 있다. terminate()가 hang되면 그 이후의 세션 정리 코드(closeQuietly(session))가 실행되지 않아 WebSocket 세션이 누수된다.
이를 방지하기 위해 terminate()를 별도 Virtual Thread에서 실행하고 최대 3초만 기다린다.
private void terminateContainer(ScriptingContainer container, String sessionId) {
if (container == null) return;
final Thread t = Thread.ofVirtual()
.name("coupler-term-" + sessionId.substring(0, 8))
.start(() -> {
try { container.terminate(); }
catch (Exception ignored) {}
});
try {
t.join(3_000); // 최대 3초 대기
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
if (t.isAlive()) {
log.warn("[CouplerWS] container.terminate() timed out (>3s), abandoned — session={}", sessionId);
}
// t.join() 이후 흐름은 항상 도달 — closeQuietly() 보장
}
무한루프 스크립트 강제 종료
cleanup()에서 t.interrupt()를 호출하지만, runScriptlet()은 Java 인터럽트에 응답하지 않는다. Ruby 스크립트가 무한루프라면 가상 스레드가 영구적으로 살아 있게 된다.
해결책은 container.terminate()를 cleanup 시에도 호출해 JRuby 런타임 자체를 소멸시키는 것이다. 세션 종료 시 session.getAttributes()에 저장해 둔 container를 꺼내 terminate한다.
private void cleanup(WebSocketSession session) {
activeCount.decrementAndGet();
// ① JRuby 런타임 종료 → runScriptlet() 블로킹 강제 해제
final ScriptingContainer container =
(ScriptingContainer) session.getAttributes().remove(ATTR_CONTAINER);
terminateContainer(container, session.getId());
// ② gets() 블로킹 해제 (CLOSE_SENTINEL 주입)
final CouplerInputHelper helper =
(CouplerInputHelper) session.getAttributes().remove(ATTR_INPUT);
if (helper != null) helper.close();
// ③ 보조 수단 (I/O 대기 등 일부 케이스)
final Thread t = (Thread) session.getAttributes().remove(ATTR_THREAD);
if (t != null && t.isAlive()) t.interrupt();
}
동시 세션 수 제한
JRuby ScriptingContainer는 생성 시 JVM 리소스를 상당히 소모한다. 동시 접속자 수를 제한하지 않으면 메모리 및 CPU가 급등할 수 있다.
private static final int MAX_SESSIONS = 10;
private final AtomicInteger activeCount = new AtomicInteger(0);
@Override
public void afterConnectionEstablished(WebSocketSession session) throws IOException {
if (activeCount.incrementAndGet() > MAX_SESSIONS) {
activeCount.decrementAndGet();
session.close(CloseStatus.SERVICE_OVERLOAD);
return;
}
// ...
}
private void cleanup(WebSocketSession session) {
activeCount.decrementAndGet(); // 반드시 cleanup에서 감소 (누락 시 MAX_SESSIONS 영구 도달)
// ...
}
Step 7. 보안 고려사항
입력값 검증
Ruby 스크립트가 내부적으로 eval, system, 백틱 등을 사용하면 사용자 입력을 통한 명령 주입이 가능하다. 입력 길이와 허용 문자를 제한하는 필터를 적용했다.
private static final int MAX_INPUT_LENGTH = 100;
// 백틱, 파이프, 세미콜론, 앰퍼샌드, 리다이렉트, 이스케이프, 변수 치환 차단
private static final Pattern DANGEROUS_PATTERN =
Pattern.compile("[`|;&<>\\\\]|\\$[({]");
private String validateInput(String input) {
if (input == null) return "null input";
if (input.length() > MAX_INPUT_LENGTH)
return "input too long (max " + MAX_INPUT_LENGTH + " chars)";
if (DANGEROUS_PATTERN.matcher(input).find())
return "disallowed characters detected";
return null;
}
WebSocket Origin 제한
// setAllowedOrigins("*") 대신 명시적 도메인 지정
registry.addHandler(couplerWebSocketHandler, "/ws/coupler")
.setAllowedOrigins(allowedOrigins); // application.yml에서 주입
# application.yml
websocket:
allowed-origins:
- https://furaiki-lifelog.com
- https://www.furaiki-lifelog.com
- http://localhost:8080
# application-live.yml (운영: localhost 제거)
websocket:
allowed-origins:
- https://furaiki-lifelog.com
- https://www.furaiki-lifelog.com
Step 8. JRuby 워밍업
ScriptingContainer 첫 생성은 JRuby 런타임 초기화(클래스 로딩, Ruby 코어 라이브러리 파싱 등)를 포함하므로 수 초가 걸릴 수 있다. 이를 첫 번째 방문자가 체감하지 않도록 앱 기동 시 백그라운드에서 워밍업을 수행한다.
@Configuration
public class RubyConfig {
private static final Logger log = LoggerFactory.getLogger(RubyConfig.class);
@Bean
public ApplicationRunner jrubyWarmUp() {
return args -> Thread.ofVirtual().name("jruby-warmup").start(() -> {
log.info("[JRuby] Starting warm-up — pre-initializing JRuby runtime");
try {
ScriptingContainer c = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
c.runScriptlet("1 + 1"); // 최소한의 실행으로 런타임 초기화 유발
c.terminate();
log.info("[JRuby] Warm-up completed successfully");
} catch (Exception e) {
log.warn("[JRuby] Warm-up failed — first session may experience startup delay", e);
}
});
}
}
Step 9. 프론트엔드 — WebSocket 연결과 터미널 UI
브라우저 측은 순수 JavaScript로 WebSocket을 연결하고, 수신된 메시지를 터미널 출력 영역에 추가한다.
let socket = null;
let endedCleanly = false; // /quit 등 정상 종료 후 재연결 방지 플래그
function connect() {
const wsUrl = `wss://${location.host}/ws/coupler`;
socket = new WebSocket(wsUrl);
socket.onmessage = (e) => {
const msg = e.data;
if (msg === '__DONE__') {
endedCleanly = true;
setStatus('done', 'Done');
return;
}
if (msg.startsWith('__ERROR__')) {
appendLine(msg, 'cl-error');
return;
}
appendLine(msg);
};
socket.onclose = () => {
if (!endedCleanly) {
// 비정상 종료 시 재연결 시도
setTimeout(connect, 3000);
}
};
}
function sendInput() {
const input = document.getElementById('console-input');
if (socket && socket.readyState === WebSocket.OPEN) {
appendLine('$ ' + input.value, 'cl-echo');
socket.send(input.value);
input.value = '';
}
}
endedCleanly 플래그가 핵심이다. Ruby 스크립트가 /quit 명령으로 정상 종료하면 서버에서 __DONE__ 메시지를 보내고, 클라이언트는 이 플래그를 true로 설정한다. 이후 WebSocket이 닫혀도 onclose 핸들러에서 재연결을 시도하지 않는다.
Summary
JRuby를 Spring 애플리케이션에 내장하는 핵심 포인트를 정리하면 다음과 같다.
| 과제 | 해결책 |
|---|---|
| stdout을 WebSocket으로 | WebSocketLineOutputStream — 줄 단위 버퍼링 후 sendMessage() |
| stdin을 WebSocket에서 | CouplerInputHelper — BlockingQueue + Kernel#gets 몽키패치 |
__FILE__ == $PROGRAM_NAME 가드 통과 |
PathType.ABSOLUTE로 실행 — $0 자동 설정 |
terminate() hang 방지 |
Virtual Thread + join(3_000) 타임아웃 |
| 무한루프 스크립트 강제 종료 | cleanup 시 container.terminate() 호출 |
| 첫 세션 지연 | 앱 기동 시 백그라운드 워밍업 |
| 동시 세션 제한 | AtomicInteger + MAX_SESSIONS |
| 명령 주입 방지 | 입력 길이 + 위험 문자 정규식 필터 |
ScriptingContainer는 생성 비용이 높고 공유 시 세션 격리가 깨질 수 있으므로, 세션별 전용 컨테이너 + SINGLETHREAD 스코프 조합이 현실적인 선택이다. 세션 수는 MAX_SESSIONS로 제한해 리소스를 보호한다.
JRuby는 "JVM 위에서 Ruby를 실행한다"는 단순한 아이디어 하나로 OS 프로세스 관리, stdin/stdout 파이핑, 프로세스 격리 같은 복잡한 문제를 Java 생태계 안에서 해결할 수 있게 해준다.

이 프로젝트가 포트폴리오 겸 토이 프로젝트라서 JRuby라는 것을 한번 써볼 수 있었던 것 같다. 사실 실무에서는 거의 쓸 일이 없을 것이다(...) 게다가 Claude Code마저 없었다면 참으로 많은 삽질을 했을 것이다.