JVM을 해킹해 소스 코드를 건드리지 않고 알고리즘을 시각화
Source: Dev.to
문제
내가 사용해 본 모든 알고리즘 시각화 도구는 같은 문제가 있다 — API를 사용하도록 코드를 다시 작성해야 한다. 이제는 알고리즘을 배우는 것이 아니라 그들의 프레임워크를 배우는 것이다.
뭔가 다른 것이 필요했다. 일반적인 Java 코드를 작성하고 시각화하고 싶었다. SDK도, 트레이싱 호출도, 아무것도 필요 없다.
그래서 AlgoFlow( AlgoPad 의 엔진) 를 만들었다. Java와 Python을 모두 지원하지만, 이 글은 Java 쪽에 초점을 맞춘다.
// 이것만 작성하면 된다. 진심이다.
int[] arr = {5, 2, 8, 1};
arr[0] = 10; // ← 자동으로 시각화됨
@Tree TreeNode root = new TreeNode(1);
root.left = new TreeNode(2); // ← 트리가 실시간으로 업데이트됨tracer.patch(0, 10)도, visualize(arr)도 필요 없다. 그냥 코드를 작성하면 된다.
왜 바이트코드인가?
Java에 대한 명백한 접근 방식은 AST 변환—소스를 파싱하고, 추적 호출을 삽입하고, 수정된 소스를 컴파일하는 것입니다. 대부분의 도구가 이렇게 하고, 간단한 경우에는 잘 동작합니다.
하지만 저는 모든 것을 가로채고 싶었습니다:
- 모든 배열 읽기.
- 모든 배열 쓰기.
- 트리 노드의 모든 필드 변형.
- 모든
list.add()호출. - 모든
map.put()호출.
…그리고 사용자가 시각화에 대해 전혀 생각하지 않고 자연스럽게 작성하는 코드를 그대로 처리하고 싶었습니다.
Java 바이트코드가 바로 그걸 제공합니다. JVM은 제가 관심 있는 모든 연산에 대해 구체적인 opcode를 가지고 있습니다:
| Opcode | 의미 |
|---|---|
IASTORE / IALOAD | 배열 요소 쓰기 / 읽기 |
PUTFIELD / GETFIELD | 객체 필드 변형 / 접근 |
PUTSTATIC | 정적 필드 할당 |
INVOKEVIRTUAL | 컬렉션 메서드 호출 |
Java 코드에서 arr[3] = 42 를 작성하면, 컴파일러는 IASTORE 명령을 생성합니다. 저는 그 명령을 클래스가 로드되기 전에 가로채어, 시각화 콜백을 삽입하고, 원래 코드는 그대로 실행됩니다. 사용자의 코드는 감시되고 있다는 사실을 전혀 알 수 없습니다.
AST 변환으로는 이것을 깔끔하게 구현하기 어렵습니다. 그 이유는 근본적인 차이 때문입니다: Java 소스 의미론은 방대하지만, 바이트코드 의미론은 작습니다.
Java 소스에서 배열 저장을 작성할 수 있는 다양한 방법을 살펴보세요:
arr[i] = 5;
arr[i + 1] = arr[j - 1] + arr[k] * 2;
arr[getIndex()] = computeValue();
arr[map.get(key)] = list.get(i) > list.get(j) ? x : y;AST 변환을 사용하면, 각각은 서로 다른 트리 형태가 됩니다. 모든 경우를 패턴 매칭하고, 중첩 표현식, 삼항 연산자, 인덱스로서의 메서드 호출, 값으로서의 메서드 호출 등을 처리해야 하는데, 그 조합 공간은 방대합니다. 패턴을 놓치면 시각화가 조용히 동작하지 않습니다. 새로운 Java 언어 기능이 추가되면 또 다른 패턴을 처리해야 합니다.
바이트코드 수준에서는 모두 같은 형태입니다. 소스 표현식이 얼마나 복잡하든, 컴파일러는 이를 다음과 같이 단순화합니다:
push array ref
push index
push value
IASTORE하나의 opcode. 하나의 가로채기 지점. 끝.
필드 접근도 마찬가지입니다. 소스에서는 this.left, node.left, getNode().left, nodes[i].left 등으로 쓸 수 있지만, 바이트코드에서는 항상 GETFIELD 입니다. 컬렉션 연산도 수신자나 인수가 어떻게 계산되었든 INVOKEVIRTUAL 로 호출됩니다.
컴파일러는 이미 Java의 풍부한 구문을 작고 고정된 연산 집합으로 평탄화하는 어려운 작업을 수행했습니다. 바이트코드 조작을 통해 저는 그 작업을 다시 구현하지 않고도 그 위에 얹어 사용할 수 있습니다.
저는 소수의 opcode만 가로채서 실질적으로 무한에 가까운 사용자 코드 표면을 커버합니다. 이것이 바이트코드가 제공하는 레버리지입니다.
Source: …
실제로는 어떻게 작동하나요?
내부 구조
엔진은 Java 에이전트 로, 사용자의 클래스가 로드되기 전에 JVM에 연결되어 ByteBuddy 와 순수 ASM 방문자를 사용해 바이트코드를 재작성합니다.
파이프라인은 다음과 같습니다:
User writes code
↓
JVM loads the class
↓
Agent intercepts class loading (premain)
↓
ByteBuddy + ASM rewrite bytecode
↓
Transformed class runs normally
↓
Intercepted operations emit visualization commands
↓
Frontend renders step‑by‑step animation배열 접근 가로채기
제가 처음 만든 기능입니다. 핵심은 배열 자체가 아니라 그 아래에 있는 순수 ASM 스택 조작입니다. JVM이 IASTORE(정수 배열 저장) 명령에 도달하면 스택은 다음과 같습니다:
Stack: [array_ref, index, value]세 값을 모두 캡처하고 시각화 콜백을 호출한 뒤 원래 저장 동작을 수행해야 합니다. JVM 스택을 그냥 “엿볼” 수는 없고, 값을 팝해서 로컬 변수 슬롯에 저장하고 작업을 수행한 뒤 다시 푸시해야 합니다.
// Simplified version of what ArrayAccessWrapper does:
//
// 1. Pop value and index into temporary slots
super.visitVarInsn(Opcodes.ISTORE, valueSlot);
super.visitVarInsn(Opcodes.ISTORE, indexSlot);
super.visitVarInsn(Opcodes.ASTORE, arraySlot);
// 2. Call VisualizerRegistry.onArraySet(array, [index, value, lineNumber])
// ... pack args into Object[], invoke static method ...
// 3. Restore stack and execute original IASTORE
super.visitVarInsn(Opcodes.ALOAD, arraySlot);
super.visitVarInsn(Opcodes.ILOAD, indexSlot);
super.visitVarInsn(Opcodes.ILOAD, valueSlot);
super.visitInsn(Opcodes.IASTORE); // original operation이 코드는 사용자 코드의 모든 배열 접근에 대해 실행됩니다. 같은 원리가 다른 opcode(GETFIELD, PUTFIELD, INVOKEVIRTUAL 등)에도 적용되어, 별도의 SDK나 트레이싱 호출 없이도 알고리즘 실행을 시각화할 수 있는 범용, 언어‑비종속 방식을 제공합니다.
boolean[], double[] 등 모든 배열 타입에 적용됩니다. 사용자가 arr[i] = arr[j] 라고 쓰면 읽기(IALOAD)와 쓰기(IASTORE) 모두 시각화 이벤트를 발생시킵니다.
필드 변이 가로채기 (트리와 연결 리스트)
누군가 node.left = new TreeNode(5) 라고 쓰면 이는 PUTFIELD 명령으로 컴파일됩니다. 저는 같은 방식으로 이를 가로채어 소유 객체, 필드 이름, 라인 번호를 캡처하고 VisualizerRegistry.onFieldSet() 을 호출합니다.
레지스트리는 node 가 트리 시각화기에 속한다는 것을 파악하고, 트리 시각화기는 해당 필드 변이를 프론트엔드에 전달할 “노드 추가, 엣지 추가” 명령으로 변환합니다.
사용자는 루트에 @Tree 어노테이션을 붙이고 일반적인 트리 코드를 작성하기만 하면 됩니다. 엔진은 자동으로 노드‑클래스 구조를 감지해 두 개의 자기‑참조 필드(자식)와 값 필드를 찾아냅니다. 이 구조를 갖는 모든 클래스는 이진 트리 노드로 인식됩니다.
@Tree TreeNode root = new TreeNode(1);
root.left = new TreeNode(2); // PUTFIELD intercepted → "add node 2, add edge 1→2"
root.right = new TreeNode(3); // PUTFIELD intercepted → "add node 3, add edge 1→3"시도해 보세요
AlgoFlow가 **algopad.dev**에서 실시간으로 제공됩니다. 정렬 알고리즘을 작성하고, 그래프를 만들고, 트리 순회를 구현해 보세요 — 그리고 단계별로 실행되는 모습을 확인하세요.
코드는 오픈 소스입니다: github.com/vish-chan/AlgoFlow
다음 포스트에서는 더 까다로운 문제들을 다룰 예정입니다 — ArrayList와 HashMap 같은 JDK 부트스트랩 클래스에서 바이트코드 가로채기를 구현한 방법, 수동 등록을 없애는 자동 감지 시스템, 그리고 JVM 스택 조작 디버깅 중 겪은 전쟁 이야기를 소개합니다. 기대해 주세요.
