Visualization2026년 5월 20일10분 읽기

가상 쓰레드를 인터랙티브 시각화 두 개로 가르쳐본 이야기 — 메이킹 & 사용 가이드

텍스트로는 안 잡히던 ForkJoinPool·Work-Stealing·Poller·커널 인터럽트 흐름을 인터랙티브 HTML 두 개로 풀어본 과정. 무엇을 어떻게 쪼개서 그렸는지, 어떻게 쓰면 가장 빨리 이해되는지.

#Visualization#JavaScript#VirtualThread#Learning#Teaching#Frontend

가상 쓰레드를 인터랙티브 시각화 두 개로 가르쳐본 이야기

같은 디렉터리의 쓰레드 풀과 가상 쓰레드 완벽 정리 글의 자매편입니다. 본 글은 그 글에 박아둔 두 인터랙티브 시각화를 어떻게 만들었고 어떻게 쓰면 좋은지 풀어쓴 메이킹 기록입니다.


1. 왜 시각화를 만들었나

가상 쓰레드 글을 쓰다가 두 번 막혔습니다.

막힘 1. 8.12 "VT 라이프사이클" 절을 글로만 설명하니, mount → park → unmount → re-mount 라는 동사 네 개가 코드 위에서 어떤 시점에 일어나는지 그림이 안 잡힙니다. 텍스트로 "Continuation이 yield 되면 스택이 힙으로 복사된다"고 적어도, 독자는 "그래서 그 순간 캐리어는 뭘 하고 있는데?"가 안 풀립니다.

막힘 2. 8.16 "OS ↔ JVM 경계" Q&A에서 더 심각해졌어요. "아무도 폴링하지 않는다", "OS는 fd만 알고 VT는 모른다" 같은 핵심 메시지는 순서행위자를 동시에 보여줘야 와닿습니다. NIC → 인터럽트 → 커널 → epoll → wake_up → Poller → unpark → 캐리어 mount. 단계가 9개고 행위자가 3종(하드웨어/커널/User)인데, 글의 한 단락에 한 단계씩 풀면 14문단입니다. 읽다가 길 잃습니다.

그래서 단계 진행 + 색깔로 행위자 구분 + 상태 변화 애니메이션이 가능한 인터랙티브 HTML 두 개를 만들었습니다. React나 D3 같은 거 안 썼고, 그냥 vanilla JS + CSS 한 파일짜리입니다. 단일 HTML이라 npm 빌드 없이 그대로 public/visualizations/에 떨궈 정적 자산으로 서빙합니다.

  • Scheduler 시각화 — ForkJoinPool, Work-Stealing, Poller가 시간에 따라 어떻게 협력하는지 (sandbox-style, 직접 조작)
  • Kernel-Flow 시각화 — 패킷 한 번이 어떻게 user mode VT까지 도달하는지 (story-style, 14단계 순차 진행)

두 도구는 의도적으로 다른 모드입니다. 하나는 sandbox(독자가 시나리오를 만들어봄), 하나는 story(정해진 한 길을 따라감). 같은 양식이면 한쪽이 잉여가 됩니다.


2. 시각화 1 — Scheduler: "직접 만져보는 sandbox"

2-1. 무엇을 보여주고 싶었나

가상 쓰레드 동작 원리에서 가장 직관에 안 맞는 게 work-stealing입니다. "각 캐리어가 자기 deque를 가진다", "한가하면 다른 deque 끝에서 훔친다" — 글로 읽으면 그럴듯한데, 실제로 한 캐리어에 일이 몰렸을 때 다른 캐리어가 어느 타이밍에 어느 deque에서 어느 방향으로 훔치는지는 손으로 그려봐야 잡힙니다.

목표는 한 줄이었어요. "VT 10개를 한 캐리어에 쏟아부었을 때, 나머지 3개 캐리어가 알아서 부하 분산하는 장면을 1분 안에 보여줘야 한다."

2-2. 단계별 설계 과정

Step 1. 데이터 모델 먼저 그리기

UI 그리기 전에 상태 모델을 종이에 적었습니다.

text
carriers: [
  { id: 1, running: VT?, deque: VT[] },
  { id: 2, ... },
  ...
]
parkedVTs: VT[]    // I/O 대기 중
pendingIO: { vtId: number }[]   // Poller가 처리할 큐

VT의 라이프사이클은 ready → running → parked → ready 4상태로 단순화. 본문(threadpool.mdx 8.12) 상태 전이도와 일치시켜서, 글과 시각화 사이 용어 차이 없게.

Step 2. tick 함수에 모든 규칙 압축

setTimeout 루프 하나가 1틱마다 3가지 일을 합니다.

js
function tick() {
  // ① Poller가 pendingIO 하나 꺼내 → 가장 한가한 캐리어 deque로 enqueue
  // ② 각 캐리어의 running VT는 workRemaining-- 하다가
  //    0이 되면: I/O면 → parked, CPU면 → 종료
  // ③ idle 캐리어는 자기 deque top에서 pop(LIFO)
  //    자기 deque 비었으면 → 가장 일 많은 캐리어 deque base에서 shift(FIFO)
  //    (= work-stealing)
}

핵심 제약 두 가지:

  • 주인은 top(pop)에서, 도둑은 base(shift)에서. Chase-Lev deque의 본질을 그대로 코드로.
  • mount는 자기 deque 비어있을 때만 steal. 다른 deque에 일 있어도 자기 게 우선.

Step 3. "한 번에 한 가지"만 보이도록 UI 분리

세 영역으로 잘랐습니다.

text
[ Poller Thread + I/O 이벤트 큐 ]    ← 위쪽
[ ForkJoinPool: Carrier × 4 + 각자 deque ]   ← 중앙
[ Parked VTs ]                              ← 아래

각 카드가 한 가지 책임만 갖습니다. 본문에서 "Poller가 unpark하면 deque로 enqueue된다"라고 적은 화살표가, 시각화에선 위 카드 → 중앙 카드로의 시각적 이동으로 보입니다.

Step 4. 색·애니메이션으로 행위자 표시

  • VT는 노랑 (정보 단위)
  • Carrier는 파랑 (실행 단위, OS 쓰레드)
  • Work-stealing은 빨강 강조 + 로그에 🦹 이모지
  • I/O 이벤트 도착 시 Poller 카드가 빨강으로 깜빡

색을 의미와 1:1 매핑해두면 글의 강조 톤(파랑 = Java/user, 빨강 = 커널/이벤트)과도 일관됩니다.

2-3. 사용 가이드 — 4가지 시나리오

이 시각화는 시나리오를 만들어보는 게 핵심입니다. 그냥 ▶ 재생 누르고 보면 너무 빨라요. 다음 순서로 직접 조작해보세요.

시나리오 A. 평화로운 상태 관찰 (30초)

  1. ▶ 재생 누르고 1.5초/틱 정도로 늦춰서 봅니다.
  2. 캐리어 4개가 자기 deque의 top에서 VT를 차곡차곡 꺼내가는 패턴 확인.
  3. 통계 박스의 "활성 캐리어" 숫자가 4 근처에서 머무는지 확인.

시나리오 B. 일부러 한쪽에 부하 몰기 (work-stealing 발동)

  1. 한 번 ↺ 리셋.
  2. + VT 10개 폭주 클릭 → 한 캐리어의 deque에 VT 10개가 한꺼번에 쌓입니다.
  3. ⏯ 1스텝을 천천히 눌러가며 다른 캐리어들이 그 deque의 base(아래쪽) 에서 VT를 훔쳐가는 순간을 포착. 로그에 🦹가 뜹니다.
  4. 포인트: 주인은 top(맨 위)에서, 도둑은 base(맨 아래)에서. 위치가 다른 게 핵심.

시나리오 C. Park → Unpark 사이클

  1. ▶ 재생을 1초 정도 돌리면 자연스럽게 parked VT가 생깁니다 (I/O 작업 VT가 끝까지 가면 park).
  2. ⚡ I/O 완료 트리거 클릭 — Poller가 깨어나 parked VT 하나를 deque로 다시 enqueue.
  3. 포인트: parked VT가 어느 카드로 갔다가 어디로 돌아오는지 시선으로 따라가기. 글 8.16의 "OS는 fd만 알고 VT는 Java가 안다"는 그 다리가 이 한 클릭에 압축돼 있습니다.

시나리오 D. 압박 테스트

  1. + VT 10개 폭주 두세 번.
  2. ▶ 재생을 10초/틱으로 느리게.
  3. 통계의 "총 훔치기" 카운터가 점점 올라가는 걸 보면서 work-stealing이 실제로 부하 분산 역할을 하는지 확인.

3. 시각화 2 — Kernel-Flow: "정해진 길을 따라가는 story"

3-1. 무엇을 보여주고 싶었나

이쪽은 sandbox가 아니라 한 사건의 14단계 분해입니다. "패킷 한 번이 도착해서 VT가 깨어나기까지" 라는 단일 시나리오를 끝까지 따라가는 형식.

핵심 메시지는 본문(8.16)의 마지막 문단입니다.

하드웨어(NIC, CPU) — 신호 발사, 명령어 실행. 판단 없음. 커널 코드 — CPU 위에서 돌며 큐를 조작. wait↔ready 이동의 주체. Java 코드 — Poller도 VT도 캐리어도 다 user mode. fd→VT 변환과 ForkJoinPool 운영.

이 세 행위자가 어느 단계에서 등판하는지가 그림으로 안 들어오면 다음 단락이 안 읽힙니다.

3-2. 단계별 설계 과정

Step 1. 14단계를 상수 배열로 박제

자유도가 낮은 만큼 데이터 모델이 훨씬 단순합니다. STEPS 배열 하나.

js
const STEPS = [
  { id: 'idle',       label: '대기',         desc: '모두 잠.' },
  { id: 'packet',     label: '패킷 전송',    desc: '클라이언트가 8080으로 송신.' },
  { id: 'nic',        label: 'NIC 수신',     desc: 'DMA로 RAM에 적재.' },
  { id: 'interrupt',  label: '인터럽트 발사', desc: 'NIC → CPU. CPU 강제 Ring 0.' },
  { id: 'handler1',   label: '핸들러: 드라이버', ... },
  ... // 14개
  { id: 'done',       label: '완료',         desc: 'VT가 socket.read() 다음 줄부터.' },
];

이 배열 하나가 글의 "9단계 흐름" 도식과 1:1 대응됩니다. 글에서 단계 번호를 바꾸면 시각화 배열의 인덱스도 바꿔야 한다는 강제성이 생겨서, 도리어 글과 시각화가 어긋날 수 없게 됐어요.

Step 2. applyStep(i) — 단계 = 화면 상태

각 step의 id마다 switch 분기로 화면 변경을 적용. "이전 step의 변경을 누적"하지 않고, 매 단계마다 깨끗하게 활성 상태 클래스를 비우고(clearActive()) 그 단계만 활성화합니다.

js
function applyStep(i) {
  clearActive();   // 이전 단계의 강조 다 끄기
  const s = STEPS[i];
  switch (s.id) {
    case 'interrupt':
      el('arrowIrq').classList.add('active');
      el('cpu').classList.add('active');
      el('cpuSub').innerHTML = '<strong>Ring 0 진입!</strong>';
      log('[HW] NIC → CPU 인터럽트 발사', 'hw');
      break;
    case 'handler1':
      el('handler').classList.add('active');
      el('hs1').classList.add('active');   // ① NIC 드라이버: 패킷 읽기
      log('[KERNEL] 인터럽트 핸들러 시작', 'kernel');
      break;
    // ...
  }
}

이 패턴 덕분에 "처음으로" 버튼이 그냥 currentStep = 0; applyStep(0) 두 줄이면 됩니다. 어떤 단계에서 어떻게 와도 멱등하게 reset.

Step 3. 레이어별 색 + 로그 prefix

화면을 세 줄로 잘랐습니다.

text
[ HARDWARE 레이어 ]   왼쪽 테두리 검정, 배경 그라데이션
[ KERNEL 레이어 ]     왼쪽 테두리 빨강, 배경 옅은 빨강
[ USER 레이어 ]       왼쪽 테두리 파랑, 배경 옅은 파랑

로그도 prefix를 색으로:

  • [HW] — 검정
  • [KERNEL] — 빨강
  • [USER] — 파랑

같은 단어("핸들러", "스케줄러")가 어느 레이어에서 일어나는지 한눈에 들어옵니다. 글의 "행위자 세 종류만 기억" 결론과 같은 색 체계를 쓰니, 시각화와 글이 색을 통해 묶입니다.

Step 4. step-tracker 핀

상단에 14개의 동그란 pill로 진행 표시. 완료된 단계는 초록, 현재 단계는 빨강. "지금 14개 중 몇 번째인지"가 항상 보이게.

3-3. 사용 가이드 — 3가지 보는 법

방법 A. 처음 한 번은 자동 재생

  1. ▶ 자동 재생 누르고 1.8초/스텝 그대로.
  2. 끝까지 한 번 흘려보내면서 전체 윤곽 잡기.
  3. 14단계 트래커가 다 초록으로 채워지면 한 사이클 완료.

방법 B. 두 번째는 한 단계씩 (책 읽듯이)

  1. ↺ 처음으로⏯ 1스텝을 천천히 누름.
  2. 단계마다 멈춰서:
    • 어느 레이어(HW/Kernel/User)가 활성인지
    • 어느 (epoll ready list / wait queue / scheduler ready queue)가 변했는지
    • 로그가 어떤 색으로 찍혔는지
  3. 포인트 단계: 4(인터럽트 발사), 8(wake_up), 10(Poller 깨어남), 12(unpark). 이 네 단계가 의미 도약 지점입니다.

방법 C. 본문과 교차 비교

  1. threadpool 글의 8.16 "패킷 한 번의 일생" 절을 옆에 띄우고
  2. 시각화에서 ⏯ 1스텝을 누를 때마다 글에서 같은 번호 단락을 읽음.
  3. 시각화 단계 4 = 글 단계 [3] = "CPU 강제 Ring 0 진입" — 식의 매칭.

세 매체(시각화 / 글 / 로그)가 같은 사건을 다른 각도로 보여주는 효과.


4. 두 시각화를 모두 만들면서 정한 디자인 원칙

1. 의존성 0

React/Vue/D3 다 안 씁니다. 단일 HTML 파일, vanilla JS, vanilla CSS. 빌드 파이프라인 없음. 이유:

  • 학습 자료는 링크 공유 시 즉시 동작해야 함. npm install 같은 단계가 끼면 안 쓰임.
  • 5년 뒤에도 그대로 열려야 함. 프레임워크 버전 호환성 신경 안 쓰고 싶음.
  • 본문 글 안에서 <a href="/visualizations/..."> 한 줄로 끼워넣을 수 있음.

2. 텍스트 1차, 시각화는 보조

시각화 없이 글만 봐도 의미가 닫혀야 합니다. 시각화는 "막힐 때 새 탭으로 여는 보조 도구". 글에 시각화를 임베드하지 않은 이유도 같습니다 — 글의 흐름이 시각화에 종속되면 안 됩니다.

3. 같은 색 = 같은 의미

빨강은 두 시각화 모두에서 커널/이벤트/도둑(긴급/주의). 파랑은 Java/Carrier/user mode(평상시). 노랑은 VT/데이터. 색을 두 시각화 사이에서 일치시켜서, 한 시각화에서 익힌 색 코드가 다른 시각화에서 그대로 통하게.

4. 로그는 "이게 왜 일어났는지" 한 줄로

상태 변화만 보여주지 말고 항상 한 줄 설명. 🦹 Carrier-2: Carrier-1 deque base에서 VT-7 훔침! (work-stealing, FIFO) 처럼 동작 + 이유를 같이. 학습자는 화면 변화보다 텍스트를 더 신뢰하는 경우가 많습니다.

5. 단계 자유도 ↔ 시각 자유도 트레이드오프

Sandbox(자유)는 데이터 모델을 충실히 — 상태 머신·tick 함수·자동 균형. Story(제한)는 14단계 배열 + switch — 자유도 낮춘 대신 글과 1:1 대응.

같은 주제를 두 모드로 따로 만든 이유가 이겁니다. 자유는 직관을 키우고, 제한은 정확성을 키운다. 둘 중 하나만 있으면 한쪽이 부족해집니다.


5. 만들면서 배운 점

1. "정답이 정해진" 학습자료는 sandbox로 만들면 안 된다.

처음엔 한 시각화 안에 14단계 모드 + sandbox 모드를 다 넣으려고 했어요. 결과는 끔찍했습니다. UI가 양쪽 다 만족시키려고 하면 어느 쪽도 못 만족시킵니다. 분리하는 게 정답이었습니다.

2. 색을 의미에 묶으면 글까지 같이 묶인다.

본문 글의 강조 색(빨강/파랑)을 시각화에도 그대로 가져갔더니, 글 → 시각화 → 글 사이 인지 비용이 거의 0이 됐습니다. 색은 무의식 다리.

3. "행위자"를 명사로 박제하라.

NIC, CPU, 커널 코드, Poller, Carrier, VT, fd, epoll #5, 인터럽트 핸들러, ready queue, wait queue — 11개의 명사가 시각화 카드 하나씩에 매핑됩니다. 글에서 이 명사들을 흩뿌리면 흐려지지만, 시각화에선 한 명사 = 한 카드 = 한 색 = 한 자리. 명사를 박제하면 동사 변화(active, idle, parked, running)가 의미를 가집니다.

4. 단계 데이터를 글과 동기화하기.

Kernel-flow의 STEPS 배열이 글의 단계 번호와 1:1이라, 글이나 시각화 한쪽을 고치면 다른 쪽도 같이 고쳐야 한다는 자연스러운 제약이 생깁니다. 이게 두 자료가 어긋나지 않는 가장 단순한 방법.

5. 의존성 없는 한 파일 HTML의 위력.

배포가 한 줄. 깃 히스토리에 그대로 박힘. 친구한테 보낼 때도 한 파일. 학습자료는 이 형태가 거의 최적이라는 결론. 복잡한 시각화 라이브러리를 학습용에 끌어들이는 건 종종 과잉.


6. 사용하실 때 / 만드실 때

  • 본 글을 처음 보신 분: Scheduler 시각화부터 1분만 만져보고 본문(쓰레드 풀과 가상 쓰레드 완벽 정리)으로 가시면 8.12 ~ 8.16절이 훨씬 빨리 잡힙니다.
  • 본문을 읽다 막히신 분: 글 안에 박힌 링크 박스에서 그 시점에 맞는 시각화로 점프하시면 됩니다. 자유롭게 왔다 갔다.
  • 본인 분야에서 비슷한 시각화 만들고 싶으신 분: 두 HTML 파일은 둘 다 단일 파일이라 그대로 가져다 데이터 모델만 갈아끼우셔도 됩니다. (carriers/STEPS 배열만 본인 도메인으로 교체).

마치며

원래는 글 한 편이면 되리라 생각했는데, 결국 글 한 편 + 시각화 두 개 + 메이킹 글 한 편 합쳐 네 개의 자료가 됐습니다. 한 가지 개념을 가르치는 데 같은 정보를 다른 매체로 세 번 보여주는 것 — 글(정확성), 인터랙티브(직관), 사용 가이드(맥락) — 가 결과적으로 가장 효율적인 학습 패키지였습니다.

다음에 또 어려운 개념을 정리하게 되면, 글 본문보다 데이터 모델을 먼저 그리고 그걸로 시각화부터 만드는 순서로 가보고 싶습니다. 시각화가 강제하는 명사 단순화가 글의 톤도 정리해주거든요.

링크 모음

#Visualization#JavaScript#VirtualThread#Learning#Teaching#Frontend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js