JVM을 한 명령어씩: 눈으로 따라가는 바이트코드 인터프리터

브라우저에서 WebAssembly로 돌아가는 JVM을 Rust로 만든 이유, 그 뒤에 있는 설계 결정들, 만들면서 배운 것, 그리고 다음 계획.

  • java
  • jvm
  • rust
  • webassembly

메모리 모드의 JVM Visualizer: 레지스터가 현재 프레임을 가리키고, 모든 참조가 힙을 향한 화살표로 그려진다

자바 개발자라면 누구나 머릿속에 JVM 내부를 그리고 있다. C# 개발자라면 CLR 내부를 그리듯. 왼쪽에 스택, 오른쪽에 힙, 그 사이를 잇는 화살표 몇 개. 지역 변수는 스택 프레임에 있고, 객체는 힙에 있다. new를 하면 힙에 뭔가 생긴다. 메서드를 부르면 프레임이 쌓이고, 돌아오면 빠진다.

나도 코딩할 때, 책을 쓸 때, 누군가에게 설명할 때 이 그림을 많이 그린다. 다만 이 그림은 쉴 새 없이 움직이는 것의 한 장면만 잡아낸 것이라는 게 문제다. 정작 재미있는 부분은 ’무엇이 어디에 있나’보다 ’그게 어떻게 거기까지 갔나’다. dup이 실제로 무엇을 복제하는지, JVM이 예외 핸들러가 없다고 판단하는 순간 예외가 어느 스택 프레임을 지나고 있는지, 메서드가 실행되기도 전에 크기가 정해진 스택 프레임에서 왜 long만 슬그머니 두 칸을 차지하는지 같은 것들 말이다.

그래서 멈춰 세워서 내부를 들여다볼 수 있는 JVM을 만들었다.

JVM Visualizer는 실제 자바 클래스 파일을 실행하는 인터프리터다. 그냥 시각화하는 친구가 아니라, JVM 명세에 따라 바이트코드를 실제로 실행한다. 언어는 Rust를 사용했고, WebAssembly로 빌드되어 브라우저 안에서 동작한다. 브라우저 탭에 .class 파일(또는 .jar)을 끌어다 놓고 Start를 누른 뒤, 코드를 바이트코드 명령어 하나씩 실행해나가면 된다. 그동안 화면에는 스레드 스택, 피연산자 스택, 힙, 정적 필드, 모니터, 그리고 그 가운데에서 일어나는 모든 이벤트가 나타난다. 다시 말하지만, JVM을 흉내 낸 애니메이션이 아니라, 인간의 속도에 맞춰 내부를 관찰할 수 있는 진짜 JVM이다. 지금 바로 실행해볼 수 있다. 모든 것이 브라우저 안에서 돌아간다.

Point 생성자 안으로 한 명령어씩 들어가는 모습: 단계마다 강조된 명령어가 옮겨 가고, 지역 변수와 피연산자 스택이 바뀌고, 다음 명령어 상자가 곧 실행될 일을 설명한다

이 포스트에서는 왜 이걸 만들었는지, 어떤 설계 결정들이 있었는지, 만들면서 무엇을 배웠는지, 이 프로젝트의 남은 과제를 이야기한다. 이 포스트는 길 것이다. 연재할 분량은 아니어서 한 번에 끝내려고 한다.

바퀴를 다시, 그것도 낯선 모습으로 만든 이유

이유는 세 가지다. 생각나는 순서대로 적는다.

더 잘 설명하고 싶었다. 도서 시장이 망가지긴 했지만, 그래도 난 여전히 글을, 책을 쓴다. 책에 실린 그림은 한순간만 표현할 수 있다. 우리의 디버거는 현재 순간을 보여주지만 소스 코드의 언어로만 보여준다. 피연산자 스택도, 프레임의 슬롯도, 클래스 초기화 상태도 한눈에 보이지 않는다. 그림과 디버거 사이의 빈틈에서 JVM에 대한 이해가 흐릿해진다. 그 틈을 메우는 도구를 갖고 싶었다.

제대로 이해하고 싶었다. 2003년부터 자바를 써 왔다(TMI지만 ’내 손’으로 직접 작성한 코드는 C++이 훨씬 많다). JVM과 언어 명세도 공부했고, 남에게 설명도 해봤다. 책도 써보려 했지만 쟁쟁한 저자가 많아 접었다. 그런데 정작 JVM을 직접 구현해본 적은 없었다. JVM은 하나의 컴퓨터다. 원본과 출력이 한 바이트도 어긋나면 안 되는 기계를 직접 만들어보기 전까지는, 그 기계에 대해 내가 무엇을 모르는지조차 알 수 없다.

Rust와 WebAssembly로 어디까지 할 수 있는지 보고 싶었다. 이 사이트에는 이미 Rust로 만든 WebAssembly 위젯이 하나 있다. 부호를 뗄 자유에 들어 있는 바이트 뒤집기 위젯이다. 위젯 하나와 제대로 된 시스템은 다르다. 파서와 인터프리터와 스레드, 그리고 그 모든 변화를 따라잡아야 하는 UI까지 이 조합으로 해낼 수 있는지 확인하고 싶었다.

AI 코딩 에이전트가 없었으면 이 실험은 해볼 수 없었을 것이다. 설계는 AI 코딩 에이전트와 함께 다듬었고, 코드의 대부분은 에이전트가 설계 문서와 구현 계획을 보고 작성했다. 에이전트는 작업하면서 내린 판단을 하나하나 기록으로 남겼다. 설계 결정과 리뷰, 테스트 전략 수립은 내 몫이었다.

왜 WebAssembly인가

브라우저에서 자바 바이트코드를 실행해보려 한 사람이 내가 처음일 리는 없다. TeaVM, Bytecoder, JWebAssembly처럼 자바 바이트코드를 JavaScript나 WebAssembly로 컴파일해주는 도구가 이미 여럿 있고, CheerpJ는 아예 JVM 자체를 WebAssembly로 옮겨 브라우저 안에서 바이트코드를 실행한다. 다들 잘 돌아간다. 하지만 이 도구들은 자바 코드를 빠르게 실행하는 데 목적이 있다. 반면 나는 코드를 느리게 실행하면서 동작 과정의 각 순간을 명령어 단위로 포착할 수 있는 도구를 원했다.

브라우저에서 동작하는 인터프리터를 만들기로 한 이상, WebAssembly가 자연스러운 선택이었다. 이유는 다음과 같다.

  • 배포가 쉽다. 사용자는 JDK를 설치하지 않아도 된다. 브라우저로 페이지를 열고 파일을 끌어다 놓으면 끝이다.
  • 브라우저 샌드박스 안에서 동작한다. 사람들이 불러온 코드는 VM 안에서, VM은 브라우저 샌드박스 안에서 돈다. 서버에서 실행되는 것은 하나도 없다. VM 전체가 약 1MB(gzip으로 350KB)짜리 WebAssembly 파일 하나이고, 이 블로그와 같은 정적 사이트에서 함께 서빙된다.
  • 네이티브와 웹 환경에서 동작하는 코드가 동일하다. 이 인터프리터는 내 맥에서 테스트할 용도로는 네이티브로, 웹 페이지에 배포할 용도로는 WebAssembly로 컴파일된다. 테스트가 검증한 코드가 곧 브라우저가 실행하는 코드다.

왜 Rust인가

WebAssembly가 자연스러운 선택이었던 것처럼, Rust도 그랬다. 그냥 Rust로 해보고 싶은 마음이 컸지만, 그래도 결정을 합리화하기 위해 이유를 찾아봤다.

WebAssembly 지원이 성숙했다. Rust는 Go나 C#처럼 런타임을 함께 싣는 언어와 달리, 런타임이나 가비지 컬렉터를 번들하지 않은 채로 WebAssembly로 컴파일되기 때문에 출력 크기가 작다. wasm-bindgen과 serde-wasm-bindgen을 쓰면 Rust 구조체가 거의 아무 글루 코드 없이 평범한 JavaScript 객체가 된다. 런타임에 의존하는 언어라면 그 런타임을 페이지 방문자 모두에게 다운로드시켜야 한다.

테스트를 네이티브로 돌릴 수 있다. 이 인터프리터의 핵심 크레이트들은 브라우저에 의존하지 않는다. 그래서 cargo test 한 번이면 브라우저도 번들러도 없이 테스트 127개가 돈다. 모든 샘플 코드를 실행해서 실제 JDK의 출력과 비교하는 테스트도 이 테스트 스위트에 포함되어 있다.

타입 시스템이 인터프리터와 잘 맞는다. JVM은 명령어와 값 종류가 정해진 스펙 위에서 움직이는 거대한 상태 기계다. Rust의 enum과 match는 여기에 안성맞춤이다. 바이트코드를 디코딩한 Instruction enum, 인터프리터 루프의 커다란 match, 그리고 처리하지 않은 명령어가 하나라도 있으면 컴파일을 거부하는 컴파일러. 사람들이 불러오는 클래스 파일은 신뢰할 수 없다. 장난꾸러기 개발자들은 아무 파일이나 골라서 확장자를 .class로 지정하고서는 테스트를 해볼 것이다. 인터프리터는 잘못된 입력을 만나면 죽지 않고 오류를 반환하도록 설계되었다.

전체 구조

전체 구조: WebAssembly 모듈 위에 React UI가 있고, 모듈은 Rust 크레이트 세 개와 Java로 작성한 런타임 라이브러리로 이루어진다. cargo test는 같은 크레이트를 네이티브로 실행한다

이 프로젝트는 Rust 크레이트 세 개, 런타임 라이브러리용 Java 소스 트리 하나, 그리고 React 앱 하나로 이루어진다. classfile 크레이트는 실행에 관여하지 않으며, jvm 크레이트는 브라우저의 존재를 모른다. wasm 크레이트는 jvm 크레이트를 웹에 연결하는 130줄짜리 글루 코드다. 역할 분담은 이렇게 정했다. 이제부터는 이 구조 안에서 내린 결정들을 하나씩 이야기한다.

결정 1: VM은 이벤트로 말한다

시각화 도구를 만들 때 가장 재미있고도 중요한 문제는, 무엇이 바뀌었는지를 화면에 어떻게 알리느냐다. 단계마다 UI가 VM 상태 전체를 읽어서 이전 상태와 비교하는 방법도 생각해볼 수 있다. UI가 사용할 자료구조를 VM이 직접 관리하는 방법도 있다. 나는 질 낮은 아이디어를 먼저 떠올리는 편이다. 앞의 두 방안 대신, 다음과 같이 하기로 했다. VM의 상태가 바뀔 때마다 정확히 하나의 이벤트를 발생시킨다.

{k: 'FramePushed', tid, frame: {...}}
{k: 'Push', tid, value: {t: 'int', v: 5}}
{k: 'LocalSet', tid, index: 1, value: {t: 'ref', v: 7}}
{k: 'ObjectAllocated', ref: 7, cls: 'Point', fields: [...]}
{k: 'ExceptionThrown', tid, ref: 9, cls: 'java/lang/ArithmeticException', message: '/ by zero'}

한 단계를 실행하면 그 단계에서 발생한 이벤트 목록이 반환된다. UI의 스토어는 Redux가 액션을 처리하듯 이 이벤트를 순서대로 적용하는 리듀서(현재 상태와 이벤트 하나를 받아 다음 상태를 반환하는 순수 함수)다. 방금 바뀐 칸을 강조하는 것도, 사람이 읽을 수 있는 이벤트 로그(“push int 5”, “@4.x ← int 3”, “no handler in Main.level3 → pop its frame”)도, 메모리 모드에서 굵게 표시되는 화살표도 전부 같은 이벤트로부터 만들어진다.

이벤트 흐름: VM이 한 단계를 실행하면 이벤트 목록이 나오고, TypeScript 리듀서가 이를 UI 상태로 바꾼다. 테스트는 실행 전체의 이벤트를 재생한 결과가 VM의 스냅샷과 같은지 확인한다

Point 생성자 안에서 putfield 하나를 실행하면 이벤트 로그에 이렇게 기록된다. 값 두 개를 꺼내서 필드 하나에 저장했다.

putfield 실행 뒤의 이벤트 로그: pop 1 value, pop 1 value, @4.y ← int 2

VM의 상태 전체를 snapshot()으로 내보내는 인터페이스도 도입했다. 고속 실행 모드에서는 이벤트를 일일이 기록하면 오히려 느려지므로, 그때는 UI가 스냅샷으로 상태를 동기화한다. 이렇게 하고 보니 이벤트와 스냅샷, 두 경로가 생겼고, 덕분에 좋은 테스트를 거의 공짜로 얻었다.

실행 전체의 이벤트를 순서대로 적용한 결과는 스냅샷과 정확히 일치해야 한다.

Rust 테스트가 이벤트 기록과 스냅샷을 픽스처로 내보내고, TypeScript 테스트가 이벤트를 재생해서 둘을 비교한다. 어떤 상태 변경이 이벤트를 빠뜨리면 UI는 아무 경고 없이 VM과 달라지기 시작하는데, 그 불일치를 이 테스트로 찾아낼 수 있다.

결정 2: 타입은 있는 그대로

JVM이 실행 중에 다루는 타입은 자바 언어의 타입보다 종류가 적고, 자바 개발자가 알고 있는 타입과는 꽤 다르게 생겼다. 인터프리터의 값 타입은 자바 언어가 아니라 JVM 쪽을 그대로 따르게 했다.

enum Value {
    Int(i32),
    Long(i64),
    Float(f32),
    Double(f64),
    Ref(Option<ObjRef>), // None이 null
    Pad,                 // long이나 double의 두 번째 칸
    Empty,               // 아직 한 번도 쓰지 않은 지역 변수
}

이 enum의 값이 가장 자주 오가는 곳은 지역 변수 슬롯과 피연산자 스택이고, 둘 다 프레임 안에 있다. 그러니 용어부터 분명히 해두자. 이 글에서 프레임은 항상 JVM의 스택 프레임을 뜻한다. 메서드를 호출할 때마다 하나 생기고 호출이 끝나면 사라지며, 그 메서드의 지역 변수 슬롯과 피연산자 스택을 담는다. 슬롯 수와 피연산자 스택의 최대 깊이는 javac가 컴파일할 때 이미 정해둔다. 스레드마다 이런 프레임이 호출 순서대로 쌓인 스택이 하나씩 있고, 그 맨 위가 현재 프레임이다. 화면이나 애니메이션의 프레임과는 아무 상관이 없다.

enum 자체는 단순하다. 그래도 JVM이 실제로 하는 일을 숨기지 않으려고 신경 쓴 부분이 세 가지 있다.

  • boolean, byte, char, short는 전부 Int다. JVM이 실제로 그렇게 한다. 지역 변수와 피연산자 스택에 올라가는 순간 네 타입은 모두 int가 되고, 좁은 타입이라는 구분은 필드와 배열, 디스크립터에만 남는다. 그래서 화면에도 소스에 선언한 타입이 아니라 JVM이 들고 있는 값을 그대로 보여준다.
  • long과 double은 두 칸을 차지하고, 두 번째 칸도 화면에 보인다. 스펙이 이 두 타입에 지역 변수 슬롯을 두 개씩 주고 피연산자 스택 깊이도 두 단위로 세기 때문이다. 값 뒤에 Pad를 하나 붙여 저장해두면 lstore 3, pop2, dup2_x1 같은 명령어가 스펙에 적힌 대로 동작하고, 글 첫머리에서 말한 ’long 다음 지역 변수 번호가 왜 하나 건너뛰는가’도 그냥 눈에 보인다.
  • 아직 쓰지 않은 지역 변수는 0이 아니라 Empty다. 프레임이 생성되는 순간 슬롯은 이미 다 만들어져 있고, 아직 값을 저장하지 않았을 뿐이다. 빈 슬롯을 빈 채로 보여주면, 프레임 크기가 메서드 실행 전에 정해진다는 말을 따로 설명하지 않아도 된다.

demo(long a, int b)의 프레임: a는 L0과 L1(Pad)을 차지하고, b는 L2의 Int, 아직 쓰지 않은 double d 때문에 L3과 L4는 Empty다. lload_0 실행 뒤 피연산자 스택에는 Long 9와 Pad가 있다

타입 정보는 JavaScript로 건너간 뒤에도 살아 있어야 한다. JavaScript의 number로는 long 값을 전부 표현할 수 없어서, long은 {t: 'long', v: '9223372036854775807'}처럼 10진수 문자열로 바꿔서 넘긴다. 메서드와 필드 디스크립터도 작은 타입 트리로 파싱해둔다. UI가 invoke 명령어가 스택에서 값을 몇 개 꺼낼지 계산하고, 꺼낸 값마다 이름을 붙이고, [I 대신 int[3]이라고 표시하려면 이 정보가 필요하다.

타입 정보가 제일 절실한 곳은 람다와 메서드 참조다. 인터페이스 쪽 메서드는 제네릭이 소거되어 Object를 받지만, 실제로 호출되는 메서드는 그렇지 않을 수 있다. Function<Integer, Integer> abs = Math::abs를 보면 Function.apply(Object)가 결국 Math.abs(int)를 호출한다. 둘을 이으려면 호출할 때 형변환과 언박싱을, 반환할 때 박싱을 해야 한다. 그러면서도 실제 자바에는 없는 프레임이 학습자 눈에 띄면 안 된다. 이 부분은 결정 6에서 다시 이야기한다.

결정 3: 가비지 컬렉터는 일부러 넣지 않았다

이 VM의 힙은 그냥 객체 목록이다. 압축도 수거도 하지 않는다. 객체 id는 실행이 끝날 때까지 바뀌지 않고, 화면 어디서든 @id 형태로 표시된다.

컬렉터 구현이 귀찮아서 그런 것 아니냐고 묻는다면, 그런 면도 없는 것은 아니다. 그런데 만들고 보니 컬렉터가 없는 편이 설명 용도로는 오히려 나았다. 메모리 모드에서는 내 코드에서 더 이상 도달할 수 없게 된 객체도 사라지지 않는다. 있던 자리에 흐리게 남아서 unreachable 배지를 단다. 마지막 참조를 제거했다고 객체가 삭제되는 것이 아니라 수거 대상이 될 뿐인데, 이 둘을 같은 것으로 아는 개발자가 꽤 많다. 컬렉터가 있는 VM에서는 객체가 어느 순간 그냥 없어지니 이 차이를 보여줄 방법이 없다.

println 호출 직전에는 main의 피연산자 스택이 @20 “HELLO, WORLD!“를 가리키고, println이 돌아온 뒤에는 아무것도 가리키지 않는다. 객체는 흐려진 채 unreachable 표시를 달고 그 자리에 남는다

객체 위치가 고정된다는 점도 덤으로 따라온다. 단계를 넘겨도 객체는 그 자리에 있다. 화살표 하나를 눈으로 따라가고 있는데 Step을 누를 때마다 배치가 바뀌면 그림을 보는 의미가 없다.

결정 4: 자바 표준 라이브러리를 자바로

String도 ArrayList도 System.out도 없는 JVM으로는 Hello World도 못 돌린다. 보통은 이런 클래스를 호스트 언어로 구현한다. 나는 반대로 했다. 런타임 라이브러리는 자바로 작성한 약 170개 클래스, 12,000줄 정도다. 컴파일할 때는 javac의 부트 클래스패스를 비웠다. 부트 클래스패스는 javac가 Object나 String 같은 플랫폼 클래스를 찾는 곳이고, 보통은 JDK 자신의 클래스 라이브러리를 가리킨다. 이것을 비우면 javac는 JDK의 클래스를 하나도 모르는 상태가 되어, 소스 트리에 있는 170개 클래스가 세상의 전부라고 믿고 컴파일한다. 덕분에 라이브러리 코드가 JDK에는 있지만 이 VM에는 없는 클래스를 실수로 참조하면, 실행 중에야 드러나는 대신 컴파일 단계에서 걸린다. 이렇게 만든 클래스 파일을 VM에 내장했다. Rust 네이티브 함수로 만든 것은 정말 밑바닥 기능 몇 가지뿐이다. System.arraycopy, Object.hashCode, 초월 함수, stdin과 stdout의 바이트 읽기와 쓰기, 스레드 기동과 sleep 정도다.

라이브러리를 Rust가 아닌 자바로 작성해서 얻은 이점이 두 가지 있다.

첫째, 라이브러리 내부를 보여줄 수 있다. Collections.sort가 내 비교자를 호출하는 과정을 보면, ArrayList.sort, Arrays.sort, 병합 정렬 프레임이 차례로 쌓이고 맨 위에 비교자 프레임이 올라온다. 실제 JVM에서 벌어지는 일과 같다. “Step into library”를 켜면 ArrayList.add가 내부 배열을 늘리는 과정까지 한 단계씩 따라갈 수 있다. 라이브러리를 Rust로 짰다면 결과만 맞고 과정은 보이지 않았을 것이다.

세 층: 내 클래스, 프레임으로 보이는 Java로 작성한 약 170개 클래스의 런타임 라이브러리, Rust로 작성한 소수의 네이티브. 정렬 중에는 호출 스택에 Main.main부터 Collections.sort, ArrayList.sort, Arrays.sort, 병합 정렬을 거쳐 내 비교자까지 쌓인다

둘째, JDK와 똑같이 동작하도록 맞추는 작업을 자바로 할 수 있다. 라이브러리가 흉내만 냈을 때 제일 먼저 들통나는 곳이 HashMap을 출력하는 코드이다. 그래서 HashMap은 JDK와 같은 테이블 구조와 해시 분산 방식을 써서, 평범한 코드라면 순회 순서가 실제 자바와 같도록 해뒀다. Random도 JDK의 선형 합동 생성기(LCG, Linear Congruential Generator)를 그대로 옮겼으니 같은 시드를 주면 내 맥에서 돌린 것과 같은 수열이 나온다.

JDK와 다른 곳도 있긴 하다. 스택 트레이스에 JDK의 java.base/ 프레임 대신 이 라이브러리의 프레임이 출력된다. 출력이 JDK와 눈에 띄게 다른 곳은 이 스택 트레이스 정도이다.

결정 5: 스레드 없는 스레드

자바는 스레드를 지원하니, 우리의 VM도 스레드를 지원해야 한다. 그런데 한 단계씩 따라가며 배우는 사람에게는 같은 코드를 다시 실행하면 같은 순서로 진행된다는 보장이 있어야 하고, 실제 OS 스레드로는 그 보장을 할 수 없다. 그래서 우리 VM은 결정적 스케줄러를 갖고 있다.

결정적 스케줄러라 함은, 어느 스레드를 언제 실행할지를 VM이 정해진 규칙만으로 결정한다는 말이다. OS나 타이머가 끼어들 틈이 없으니 같은 코드를 몇 번 실행해도 스레드가 스위칭되는 지점이 똑같다. 규칙은 이렇다.

step()은 현재 스레드의 명령어 하나를 실행한다. 현재 스레드가 블록되거나 종료되거나 양보하면 스케줄러가 다른 스레드로 스위칭하고, 다른 스레드가 실행 대기 중일 때는 50단계짜리 타임 슬라이스를 다 쓴 시점에도 스위칭한다. sleep과 시간 제한이 있는 wait는 실제 클럭이 아니라 가상 클럭을 본다. 모든 스레드가 대기 중이면 클럭을 다음 마감 시각으로 바로 건너뛴다. 명령어 1,000개마다 1밀리초씩 저절로 흐르는 규칙도 있는데, 처음부터 계획했던 것은 아니다. 구현과 테스트를 거치다 보니, 다른 스레드가 sleep하는 동안 System.currentTimeMillis()를 반복해서 확인하는 busy wait 코드가 무한 루프를 도는 것을 보고 나서 넣었다. 모든 스레드가 블록되어 있는데 깨어날 마감 시각도 없으면 VM은 교착 상태라고 보고하고, 여기에 얽힌 스레드 이름을 보여준다.

타임라인 위의 세 스레드: worker-a와 worker-b가 50단계씩 번갈아 실행되고, worker-b는 락을 기다리고, main은 잠들어 있다. 모두 기다리면 가상 클럭이 다음 마감 시각으로 건너뛴다

모니터도 실제로 동작한다. synchronized, wait, notify, join이 스펙대로 움직이고, Threads & locks 탭에서 어느 스레드가 어느 락을 소유하고 있고 어느 스레드가 대기 중인지 볼 수 있다. 작업 스레드 두 개가 락 하나를 두고 경쟁하는 모습은 이렇다.

같은 락을 두고 경쟁하는 두 작업 스레드

결정 6: java.lang.invoke 없는 invokedynamic

요즘 자바 컴파일러 javac는 문자열 연결과 람다를 invokedynamic으로 컴파일한다. 실제 JVM에서는 이 명령어가 java.lang.invoke 패키지의 부트스트랩 메서드로 제어를 넘기고, 거기서부터 메서드 핸들이 잔뜩 등장한다. 이 과정을 충실하게 구현하면 "x = " + x 한 줄 때문에 이 VM으로 공부하는 독자는 자기 코드와 상관없는 프레임을 줄줄이 구경해야 한다.

그래서 VM은 javac가 문자열 연결과 람다용으로 만들어내는 부트스트랩 메서드를 알아보고, 내장 기능(intrinsic)으로 처리한다.

  • 문자열 연결은 한 단계로 끝나고 그 결과로 새 String 하나만 생긴다. 인자 중에 객체가 있을 때만 자바 헬퍼를 호출해서 그 객체의 toString()이 보통의 자바 메서드처럼 프레임으로 보이게 했다. 처음 설계에서는 연결하기 전에 원시 값을 전부 박싱했는데, 그랬더니 일회용 Integer 객체가 힙을 가득 채워버렸다. 어떤 자바 디버거에서도 볼 수 없는 광경이라, 지금은 원시 값을 바로 문자열로 변환한다.
  • 람다는 Host$$Lambda$N이라는 합성 클래스가 된다. 함수형 인터페이스를 구현하고, 캡처한 값은 필드로 들고 있다. 인터페이스 메서드를 호출하면 작은 트램펄린(호출을 받아서 인자만 손본 뒤 진짜 목적지로 넘겨주는 짧은 중간 코드)이 끼어든다. 이 트램펄린은 그 한 단계 안에서 생겼다가 사라지는 네이티브 프레임(자바 메서드가 아니라 VM 내부 코드가 실행되는 동안만 존재하는 프레임)이라 이벤트 로그에만 흔적이 남고, 결정 2에서 말한 인자 변환을 한 뒤 람다 본문으로 실행을 넘긴다. 그래서 람다 본문은 호출자 바로 위에 일반 프레임으로 나타난다.

람다 호출: invokedynamic이 합성 클래스 Lambdas$$Lambda$1을 연결하고, invokeinterface가 Runnable.run()을 부르고, 네이티브 트램펄린은 그 단계 안에서만 잠깐 생겼다 사라진다. 호출 스택에는 Lambdas.main 위에 Lambdas.lambda$main$1()이 보인다

앱에서 보면 이렇다. hello.run()을 막 호출한 시점이고, lambda$main$1이 main 바로 위에 올라와 있다.

lambda$main$1 안에서 멈춘 Lambdas 샘플: 호출 스택에서 Lambdas.main 위에 이 프레임이 있다

레코드나 패턴 switch가 쓰는 다른 부트스트랩(레코드의 toString, equals, hashCode를 만들어주는 ObjectMethods, 패턴 switch의 SwitchBootstraps 같은 것)은 지원하지 않는다. 그런 경우에는 어물쩍 넘어가지 않고 이유를 적은 BootstrapMethodError를 던진다.

결정 7: 예외는 한 단계씩

실제 JVM에서는 예외를 던지고 핸들러를 찾는 일이 순식간에 끝난다. 이 VM에서는 스택 되감기(unwinding)를 여러 단계로 쪼갰다. 각 단계는 한 가지 일만 한다. 현재 프레임의 예외 테이블에서 현재 pc를 포함하면서 예외 클래스가 일치하는 핸들러를 찾는다. 해당 핸들러가 존재하면 그리로 점프하고, 없으면 프레임을 제거하고 아래 프레임에서 같은 일을 반복한다. 그동안 UI는 지금 검색 중인 프레임에 테두리를 긋고, 예외 객체는 힙 맨 위에 고정해둔다.

Exceptions 샘플에서 ArithmeticException이 처리되는 과정을 따라가보면 이렇다.

되감기 과정: level3가 예외를 던지고 제거된다. level2의 finally 핸들러가 잡아서 출력한 뒤 다시 던진다. level2와 level1이 제거되고, main의 catch 절이 예외를 잡는다

같은 과정을 앱에서 보면 이렇다. 테두리가 프레임을 하나씩 내려가는 동안 예외 객체는 힙 맨 위에 그대로 있다. level2의 finally가 실행된 뒤 예외를 다시 던지고, 결국 main이 잡는다.

Exceptions 샘플이 한 단계씩 되감기는 모습: 테두리가 표시된 프레임이 호출 스택을 따라 내려가는 동안 ArithmeticException은 힙에 고정되어 있고, 마지막에 main의 핸들러가 잡는다

만들다가 알게 된 것인데, finally는 VM이 따로 처리할 것이 없다. javac가 finally 본문을 모든 예외를 잡는 평범한 예외 테이블 항목(위 도판의 ‘any’ 핸들러)으로 등록하고, 본문 끝에 잡았던 예외를 그대로 다시 던지는 athrow 명령어를 붙여두기 때문이다. athrow는 피연산자 스택 맨 위의 예외 객체를 꺼내 던지는 명령어로, 소스의 throw e;도 결국 이 명령어 하나로 컴파일된다. 되감기 로직 입장에서는 finally와 catch가 구분조차 되지 않는다.

결정 8: 메모리를 그리는 법

UI의 첫 번째 버전은 실행, 힙, 정적 필드, 스레드, 콘솔, 이벤트 로그 패널을 나란히 늘어놓은 모양이었다. 정보는 다 있었지만 패널마다 따로 놀았고, 힙의 객체와 지역 변수를 이어주는 것은 @id 표시뿐이었다. @id의 값을 읽고, 따라가느라 눈이 빠지는 듯했다.

그래서 메모리 모드를 만들었다. JVM의 런타임 데이터 영역을 그림 하나에 다 그리고, 참조는 전부 화살표로 이었다. 아래는 main이 Point 객체 세 개를 만드는 과정을 보여주는 VM의 모습이다.

Point p = new Point(1, 2), Point q = new Point(10, 20), Point r = p.add(q)를 실행하는 동안의 메모리 모드: 객체가 NEW 표시와 함께 힙에 나타나고, 지역 변수 p, q, r에서 화살표가 이어진다

메모리 모드를 만들면서 했던 큰 결정은 다음 두 가지다.

레이아웃 계산은 순수 함수 하나에 맡겼다. TypeScript 함수 하나가 VM 상태를 받아 상자, 행, 화살표의 좌표를 전부 계산해서 반환하고, React는 그 결과를 그리기만 한다. 화면은 절대 좌표로 배치한 HTML 상자들과 화살표를 그리는 SVG 레이어 하나로 이루어진다. 다른 방법도 생각해봤다. CSS로 배치한 뒤 DOM 크기를 측정해서 화살표를 그리는 방법은 헤드리스 테스트 환경에서는 검증할 수 없고 화면이 깜빡이기 쉽다. 그래프 레이아웃 라이브러리를 쓰면 단계마다 객체가 이리저리 옮겨 다닌다. 순수 함수면 결과가 결정적이라서 ‘상자끼리 겹치지 않는다’, ‘객체는 다음 단계에서도 같은 위치에 있다’ 같은 성질을 단위 테스트로 검증할 수 있다.

레이아웃 파이프라인: VM 상태와 이전 단계의 객체 위치가 순수 TypeScript 함수 layoutMemory()로 들어가 좌표가 붙은 상자, 행, 화살표가 되고, React가 이를 HTML 상자와 SVG 화살표 레이어로 그린다

레지스터는 교과서 모델대로 그리되, 스펙에는 없는 것이라고 화면에 밝혔다. 여기서 레지스터는 CPU 레지스터가 아니다. VM이 ’지금 어디를 실행하고 있는가’를 기억하려고 스레드마다 두는 변수 몇 개를 말한다. JVM 교재는 대부분 이 변수가 네 개라고 설명한다. 다음에 실행할 명령어의 위치를 담는 pc, 피연산자 스택의 꼭대기를 가리키는 optop, 현재 프레임을 가리키는 frame, 현재 프레임의 지역 변수 시작점을 가리키는 vars다. 메모리 모드도 이 넷을 현재 프레임을 가리키는 포인터로서 그린다.

그런데 정작 JVM 스펙에 있는 레지스터는 pc 하나뿐이다. 나머지 셋은 1995년 스펙 초안에는 있었지만 정식 스펙에서 빠졌고, 교재에만 남아 지금까지 전해진다. 그렇다고 교과서가 틀린 것은 아니다. 스펙은 추상 기계를 정의할 뿐 프레임을 메모리에 어떻게 놓을지는 구현에 맡기는데, 바이트코드를 한 명령어씩 실행하는 인터프리터라면 어느 구현이든 현재 프레임이 어디인지, 그 프레임의 지역 변수가 어디서 시작하는지, 피연산자 스택 꼭대기가 어디인지를 어딘가에 들고 있어야 한다. 교과서 모델은 스펙이 구현에 맡긴 그 부분까지 그림에 그려 넣은 것이고, 이 VM도 실제로 그런 값을 들고 있다. 그림을 읽기에도 교과서 모델이 훨씬 쉽다. 그래서 그쪽을 택하고, 대신 스펙에는 없는 레지스터라는 안내 문구를 화면에 넣었다.

바이트코드 목록 아래에는 다음 명령어 상자를 뒀다. 곧 실행할 명령어가 무슨 일을 하는지, 피연산자 스택이 어떻게 바뀌는지를 스펙 표기법으로, 그리고 지금 이 순간 스택에서 꺼내게 될 실제 값이 무엇인지 보여준다.

invokevirtual #15 → PrintStream.println(String)에 대한 다음 명령어 상자: 역할, 스택 표기 “…, objectref, arg1 → …”, 현재 값 objectref = @5 PrintStream, arg1 = @10 “Hello, World!”

실행 결과까지 미리 보여주지는 않는다. 명령어가 실제로 무엇을 만들어내는지는 Step을 누르고 확인해야 한다.

결정 9: 클래스 파일을 디컴파일하지 않는다

패널 모드 왼쪽에 있는 클래스 탐색기는 로드된 모든 클래스의 구조를 보여준다. 버전과 접근 플래그, 항목마다 읽을 수 있는 텍스트로 풀어 쓴 상수 풀, 필드, 메서드, 그리고 예외 테이블과 줄 번호가 붙은 메서드별 바이트코드까지 전부 나온다. 바이트코드는 다음처럼 javap -c 형식으로 보여준다.

invokevirtual #12 // Method java/io/PrintStream.println:(I)V

ObjectsAndHeap$Point의 상수 풀을 보여주는 클래스 탐색기. 모든 항목이 읽을 수 있는 텍스트로 해석되어 있다

Point.add의 바이트코드를 보여주는 클래스 탐색기. 줄 번호, pc, 해석된 피연산자가 함께 나온다

디컴파일러는 일부러(!) 넣지 않았다. 바이트코드를 자바 코드로 되돌려 보여주면 정작 보여주려던 바이트코드가 뒤로 밀린다. 소스가 있으면 .java 파일을 클래스 파일과 함께 끌어다 놓으면 되고, 그러면 소스 창이 현재 실행 중인 줄을 따라간다. 소스가 없으면 바이트코드만 남는데, 그럴 때 화면이 할 일은 바이트코드를 되도록 읽기 쉽게 보여주는 것이지 자바인 척하는 것이 아니다.

결정 10: 판정은 JDK가 한다

앱에 들어 있는 샘플은 교착 상태 데모 하나를 빼고 전부 javac --release 17로 컴파일해서 실제 JDK로 실행하고, 그 출력을 기대 출력으로 저장해뒀다. Rust 통합 테스트는 같은 클래스 파일을 이 VM에서 실행해서 출력을 바이트 단위로 비교한다. 덕분에 VM이 맞게 동작하는지 내가 머리 싸매고 판단할 필요가 없었다. JDK가 대신 판정해준다.

판정 과정: Sample.java를 javac –release 17로 컴파일하고, 실제 JDK가 기록한 expected.out과 cargo test에서 이 VM이 생성한 출력이 바이트 단위로 같아야 한다

JDK를 정답으로 삼고 나니 이 결정이 다른 결정을 여럿 끌고 왔다. 라이브러리는 기대 출력을 만든 바로 그 JDK 버전의 동작을 따라야 했다. HashMap이 다른 맵을 복사할 때 초기 크기를 계산하는 방식까지 맞췄는데, 거기서 순회 순서가 갈리기 때문이다. 수학 함수는 StrictMath가 명시한 fdlibm 알고리즘을 Rust로 옮긴 것을 써서, 네이티브 테스트와 브라우저가 비트 단위까지 같은 값을 낸다. 첫 릴리스 전에는 기대 출력이 있는 샘플을 전부 실제 브라우저에서도 실행해서 콘솔 출력을 JDK와 비교했다. 15개 모두 같았다.

만들면서 배운 것

스펙과 교과서는 다른 문서다. 머리로는 알고 있었는데, JVM 레지스터를 그림으로 그리다가 실감했다. 스펙은 레지스터를 하나만 정의하고 교과서는 넷을 설명한다. 무슨 말이냐면, 스펙은 스레드마다 pc 레지스터 하나만 두고 피연산자 스택과 지역 변수는 프레임 안의 자료구조로 정의한다. 교과서는 거기에 optop, frame, vars를 더해서 레지스터 네 개가 현재 프레임을 가리키는 그림으로 설명한다.

교육용 도구를 만드는 사람은 둘 중 하나를 골라야 하고, 골랐다는 사실을 숨기면 안 된다. 작은 것들도 마찬가지였다. 클래스가 정확히 언제 초기화되는지, 초기화 도중에 예외가 발생하면 어떻게 되는지 같은 것은 스펙을 읽어야 정확히 알 수 있다. 처음 사용할 때는 ExceptionInInitializerError가 발생하고, 그다음부터는 사용할 때마다 클래스를 초기화할 수 없다는 NoClassDefFoundError가 발생한다.

제일 사소해보이는 규칙이 제일 많은 곳에 영향을 줬다. 메서드를 호출하면 호출한 쪽의 pc는 호출이 반환될 때까지 invoke 명령어에 머물러 있다가 그다음에야 다음 명령어로 넘어간다. 별것 아닌 규칙 같지만, pc를 미리 옮겨버리면 호출 바로 뒤에서 범위가 끝나는 예외 핸들러가 일치하지 않게 되고 스택 트레이스는 엉뚱한 줄을 가리킨다. 클래스 초기화를 유발한 명령어도 비슷하다. 그 명령어는 그 자리에서 전혀 진행하지 않고, 초기화 코드가 먼저 실행된 다음 같은 명령어가 처음부터 다시 실행된다.

스펙에 충실한 것과 가르치기 좋은 것은 자주 충돌했고, 그때마다 하나를 골라야 했다. 강조할 명령어는 방금 실행한 것이냐 곧 실행할 것이냐. 디버거들이 다 그렇듯 곧 실행할 것을 택했다. 힙에 모든 객체를 보여줄 것이냐 내 코드에서 도달 가능한 객체만 보여줄 것이냐. 도달 가능한 것만 보여주고 나머지는 토글로 뺐다. 문자열 연결이 힙을 박싱 객체로 채워도 되느냐. 안 된다고 정했다. 스펙은 이런 질문에 아무 답도 주지 않는다. 답은 배우는 사람 쪽에서 찾아야 했다.

정답지가 있으면 고민이 줄어든다. JDK 출력을 정답으로 삼고 나니 ‘이게 맞나?’ 하던 수백 번의 고민이 cargo test 한 번으로 바뀌었다. 샘플을 재현 가능하게 만드는 데 들인 시간은 충분히 뽑고도 남았다.

에이전트와 일할 때는 설계 문서를 쓰는 것이 곧 코딩이다. 코드 대부분을 에이전트가 쓰는 상황에서는 설계 문서와 계획이 형식적인 서류가 아니라 실제로 코드를 좌우하는 입력이 된다. 이 프로젝트에서 제일 쓸모 있었던 산출물은 에이전트가 남긴 결정 기록이다. 판단 하나에 한 줄씩, 무엇을 왜 결정했는지, 그 판단이 틀렸다면 무엇을 잃는지 적혀 있다. 내 계획에서 틀린 곳을 잡아낸 것도 결정 기록이었고, 이 포스트의 상당 부분도 결정 기록에서 나왔다.

못 하는 것은 못 한다고 적어뒀다. 이 VM에는 알려진 한계가 있다. Math.round(0.49999999999999994)가 0이 아니라 1을 반환하고, HashMap은 충돌이 많은 버킷을 트리로 바꾸지 않는다. 일부러 망가뜨린 클래스 파일 중 몇 가지는 오류를 반환하는 대신 페이지를 멈추게 하고, 단계 실행 단축키는 VS Code와 반대다. 전부 저장소에 목록으로 적어뒀다. 교육용 도구가 틀린 것을 맞는 것처럼 보여주는 쪽이 미완성이라고 써 붙이는 쪽보다 훨씬 나쁘다고 생각한다.

남은 과제

이 포스트가 몇 사람에게 읽힐지 모르겠다. 그 말인즉슨, 남한테서 개선 피드백을 받을 일이 거의 없다는 뜻이기도 하다. 그럼에도 불구하고 미래의 나에게 던져두는 숙제를 정리해봤다.

역방향 실행(step back). 디버거를 쓸 때마다 제일 아쉬운 기능인데, 이 VM은 넣기 좋은 구조다. 실행이 결정적이고 전체 스냅샷도 이미 만들 수 있으니, 스냅샷을 링 버퍼에 쌓아두면 값이 바뀌기 직전으로 돌아가서 바뀌는 순간을 다시 볼 수 있다.

가비지 컬렉터. 지금의 unreachable 배지는 컬렉터가 들어올 자리를 잡아둔 것이다. 실제 컬렉터를 구현해서 같은 그림 위에서 돌아가는 모습을 보여주고 싶다. 루트에서 도달 가능한 객체를 표시(mark)하고 나머지를 회수(sweep)하는 과정을 한 단계씩 따라가게 할 생각이다.

브라우저 안에서 컴파일. 지금은 컴파일된 .class 파일을 불러오거나 샘플을 고르는 수밖에 없다. 다음은 코드 입력창이다. 자바 코드를 쓰고, 페이지 안에서 컴파일하고, 그대로 한 단계씩 실행하는 것까지. 물론 그 과정에서도 브라우저 밖으로 나가는 것은 없어야 한다.

그때까지는 여기에 있는 지금 버전으로 JVM을 한 명령어씩 돌려볼 수 있다. 샘플 하나를 고르고 Start를 누른 다음, 내 지역 변수가 실제로 어디에 들어가는지 직접 확인해보시라.