던플 개발기 (18): 모바일 차트 잔혹사 — 라이브러리 5개와 iOS WebKit
지난 편에서 숫자를 읽히게 만들었다면, 그 숫자로 그린 차트 하나가 모바일에서 그냥 사라졌습니다. 홈 화면 맨 위, 날짜별 경매장 매물 총액과 시세 총액을 겹쳐 보여 주는 총액 차트입니다. 데스크톱 브라우저에서는 첫 시도부터 멀쩡히 그려졌습니다. 그런데 폰으로 열면 카드도, 범례도, 아래 요약 숫자도 다 나오는데 차트 자리만 새까맣게 비어 있었습니다.
이 편은 그 빈 자리를 채우려고 이틀 동안 차트 라이브러리를 다섯 번 갈아치운 기록입니다. lightweight-charts로 시작해 Recharts, Chart.js, 직접 손으로 그린 SVG를 거쳐, 결국 다시 lightweight-charts로 돌아왔습니다. 돌아오고 나서야 알았습니다. 문제는 처음부터 차트가 아니었습니다.
한눈에 보면
- 같은 차트가 데스크톱에서는 되고 모바일 WebKit에서만 빈 화면이 됐습니다. 원인을 차트 라이브러리에서 찾다가 다섯 개를 갈아탔습니다.
- 진짜로 iOS WebKit 문제였던 건 두 가지입니다.
oklch디자인 토큰을 canvas로 되읽으면 검게 붕괴하는 것, 그리고 SVG 도형 위 pointer 이벤트로는 툴팁 탭이 안 잡히는 것. getBoundingClientRect()가 0을 돌려준다는 가설을 붙잡고 명시 측정·뷰포트 폴백·직접 SVG까지 갔지만, 그것들로도 기기에서는 안 떴습니다.- 콘솔을 붙일 수 없는 폰이라, 화면에 못 찍는 정보를
<img>비콘으로 dev 서버 로그에 흘려 원격 디버깅했습니다. - 진짜 원인: Next dev(Turbopack)가 폰(비-localhost LAN origin)에 client chunk를 안 내려줘서 React가 아예 하이드레이션되지 않았습니다. 차트와는 무관했습니다.
allowedDevOrigins로 그걸 풀고 나니, 차트는 무엇으로 그리든 됐습니다. 그래서 원래 원하던 lightweight-charts(Histogram + Line)로 돌아왔습니다.
차트 하나가 이렇게 어렵다
이 차트가 답해야 하는 건 단순합니다. "요 며칠 시세가 오르고 있나?"입니다. 그래서 왼쪽 축에는 경매장 매물 총액(최저가 기준 시가총액)을 라인으로, 오른쪽 축에는 시세 총액(실거래 체결액)을 막대로 겹쳐 그립니다. 두 값은 크기와 변동성이 달라 축을 나눠 씁니다. 이 용어들을 왜 이렇게 정의했는지는 (13)편에서 다뤘습니다.
디자인은 처음부터 명확했고, 데스크톱에서는 첫 커밋에 바로 그려졌습니다. 문제는 던플마켓이 "게임하면서 폰으로 열어 두고 시세를 보는" 터미널을 지향한다는 데 있었습니다. 메인 타깃이 모바일인데, 그 모바일에서 핵심 화면의 차트가 통째로 안 보였습니다. 데스크톱에서 되니 로컬 개발로는 재현이 안 되고, 폰에는 개발자 콘솔을 붙이기 어렵습니다. 여기서부터 잔혹사가 시작됩니다.
라이브러리 순례: lightweight-charts → Recharts → Chart.js → 직접 SVG
가장 먼저 의심한 건 렌더 방식이었습니다. 첫 버전은 lightweight-charts(canvas)였는데, iOS에서 "그려지긴 했지만 화면에 합성(composite)되지 않는 캔버스" 아닐까 의심했습니다. 그러면 canvas 대신 SVG로 가면 되지 않을까. 그렇게 Recharts로 넘어갔고, Recharts도 안 되자 "라이브러리가 스스로 크기를 재는 게 문제"라고 판단해 우리가 직접 크기를 재서 넘겨주는 Chart.js로, 그것도 안 되자 라이브러리를 아예 버리고 직접 SVG를 손으로 그리는 데까지 갔습니다.
| 순서 | 라이브러리 | 렌더 방식 | 시도한 이유 | 모바일에서 막힌 지점 | 다음으로 넘어간 계기 |
|---|---|---|---|---|---|
| 1 | lightweight-charts | Canvas | 금융 차트 표준, 가볍고 crosshair·터치가 견고 | iOS에서 빈(검은) 캔버스. oklch 색을 canvas로 되읽자 검정으로 붕괴 + 캔버스 미합성 의심 |
"canvas 합성 문제면 SVG로 우회하자" |
| 2 | Recharts | SVG (React) | canvas 합성 이슈를 SVG로 비껴가려고 | 기기에서 여전히 빈 화면 | "라이브러리 자체 async 측정을 의심" |
| 3 | Chart.js (react-chartjs-2) | Canvas (단일) | 우리가 직접 측정해 명시 크기로 그림 | getBoundingClientRect()/ResizeObserver가 0 → 300×150 기본에서 아무것도 안 그림 |
"측정 자체를 코드에서 없애자" |
| 4 | 직접 SVG (hand-rolled) | 정적 SVG | 라이브러리·getBoundingClientRect·ResizeObserver를 전부 제거, clientWidth로만 |
그래도 기기에서 안 뜸. 이펙트로 gate된 건 사라지고 정적 축-절단 마크만 보임 | "이건 차트가 아니라 하이드레이션이다" |
| 5 | lightweight-charts (복귀) | Canvas | 원인이 dev origin 하이드레이션임을 확인 → 차트는 죄가 없었음 | (해결) autoSize로 복귀 |
최종 |
이 표의 4행이 반전의 실마리입니다. 라이브러리를 다 걷어내고 순수 SVG로만 그렸는데도 안 됐다면, 남는 용의자는 라이브러리가 아니라 그 아래층입니다. 하지만 그걸 깨닫기까지 한참 걸렸습니다. 순례 도중 붙잡았던 가설들을 렌더 파이프라인 위에 놓으면 이렇게 됩니다.
flowchart LR
S[SSR 마크업<br/>정적 SVG 포함] --> H{React 하이드레이션}
H -->|성공| E[useEffect 실행]
H -->|dev origin 거부<br/>client chunk 미전송| X[하이드레이션 실패]
E --> M[컨테이너 측정]
M --> D[차트 그리기]
X -.->|이펙트 자체가 안 돎| Z[정적 SVG만 남고<br/>차트 자리는 영원히 공백]저는 오른쪽 절반(측정 → 그리기)에서만 범인을 찾고 있었는데, 실제 고장은 왼쪽 분기(하이드레이션 실패)에 있었습니다. 그래도 순례 동안 진짜로 고친 iOS WebKit 문제 두 가지는 남았으니, 헛수고만은 아니었습니다.
iOS WebKit 터치·캔버스 레이어 문제
첫 번째로 실재했던 문제는 색이었습니다. 던플마켓의 디자인 토큰은 oklch(...)로 정의돼 있는데, lightweight-charts의 canvas는 oklch를 못 읽습니다. 그래서 처음엔 브라우저에게 색을 계산시켜 되읽는 우회로를 썼습니다.
// ❌ iOS WebKit에서 무너진 방식: oklch 토큰을 canvas로 되읽기
function resolveColor(host: HTMLElement, cssVar: string, fallback: string): string {
const probe = document.createElement('span');
probe.style.color = `var(${cssVar})`;
host.appendChild(probe);
const computed = getComputedStyle(probe).color; // oklch → lab(...) 으로 직렬화됨
host.removeChild(probe);
// getComputedStyle이 oklch를 lab()으로 주는데 lightweight-charts는 lab()도 못 읽는다.
// 그래서 1×1 canvas에 찍고 픽셀을 되읽어 rgba() 문자열로 바꾼다.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx === null) return computed;
ctx.fillStyle = computed;
ctx.fillRect(0, 0, 1, 1);
const data = ctx.getImageData(0, 0, 1, 1).data; // iOS WebKit: 전부 0 → 검정
const [r, g, b, a] = [data[0] ?? 0, data[1] ?? 0, data[2] ?? 0, data[3] ?? 255];
return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
}데스크톱에서는 이 되읽기가 멀쩡한 rgba()를 돌려줬습니다. 그런데 iOS WebKit에서는 이 픽셀 되읽기가 전부 0(검정)으로 붕괴했습니다. 어두운 배경 위에 검은 라인·검은 막대를 그렸으니 결과는 "빈 검은 차트"였습니다. 교훈은 단순합니다. 캔버스에 넘길 색은 브라우저의 색 파이프라인에 의존하지 말고 고정 값으로 박아라. DOM 스와치는 토큰을 그대로 쓰되, 캔버스만 테마별 sRGB hex를 씁니다.
// ✅ 캔버스는 테마별 고정 sRGB hex. (오렌지 라인 + 블루 막대)
export function chartPalette(isDark: boolean): ChartPalette {
return isDark
? { line: '#fb923c', volumeBar: '#6184ce', muted: '#9c9dab', border: '#2c2d38' }
: { line: '#ea580c', volumeBar: '#6785c3', muted: '#61626f', border: '#dcdde5' };
}두 번째로 실재했던 문제는 터치였습니다. 직접 SVG 단계에서 툴팁을 붙였는데, iOS WebKit(iOS의 Chrome·Safari 모두)에서는 SVG 도형 위 pointer 이벤트가 불안정해 탭해도 툴팁이 열리지 않았습니다. pointer 이벤트를 native touch로 바꾸고, 여기에 iOS 특유의 함정 하나를 더 막아야 했습니다. iOS는 탭 직후에 합성 마우스 이벤트(유령 mousemove/mouseleave)를 쏘는데, 이게 방금 연 툴팁을 곧바로 지워 버립니다. 그래서 마지막 터치 시각을 기록해 그 직후의 마우스 이벤트를 무시했습니다.
const TOUCH_GHOST_MS = 700;
// 모바일: native touch가 iOS WebKit에서 가장 견고하다.
const onTouch = (event: React.TouchEvent<HTMLDivElement>): void => {
lastTouchAt.current = Date.now();
const touch = event.touches[0];
if (touch !== undefined) {
pickNearest(touch.clientX, event.currentTarget.getBoundingClientRect());
}
};
// 데스크톱: 마우스. 단, 탭 직후의 '유령' 마우스 이벤트는 걸러낸다.
const onMouseMove = (event: React.MouseEvent<HTMLDivElement>): void => {
if (Date.now() - lastTouchAt.current < TOUCH_GHOST_MS) return; // 유령 mousemove 무시
pickNearest(event.clientX, event.currentTarget.getBoundingClientRect());
};
const onMouseLeave = (): void => {
if (Date.now() - lastTouchAt.current < TOUCH_GHOST_MS) return; // 유령 mouseleave가 툴팁 지우는 것 방지
setHover(null);
};한 가지 더. 핸들러를 <svg>가 아니라 감싸는 <div>에 붙였습니다. iOS에서는 SVG 도형 위 터치는 미덥지 못해도, 평범한 HTML <div> 위 터치는 확실했기 때문입니다.
그리고 canvas 단계에서는 "그려졌는데 화면에 합성이 안 되는 것 아닐까"라는 가설로 컨테이너를 별도 GPU 레이어로 승격시키는 시도도 했습니다.
<div
ref={containerRef}
className="h-60 w-full"
// iOS WebKit이 캔버스를 그려도 화면에 합성하지 않을 수 있어 별도 GPU 레이어로 승격.
style={{ transform: 'translateZ(0)' }}
/>이 translateZ(0)는 결국 도움이 안 됐고(오히려 rect 측정을 흔들 수 있어 나중에 뺐습니다), 진짜 원인도 아니었습니다. 하지만 색과 터치 두 가지는 iOS WebKit에서 실제로 물어야 했던 지점이라, 최종 코드에도 그대로 남아 있습니다.
clientWidth가 0으로 나올 때
색을 고쳤는데도 기기에서는 여전히 빈 화면이었습니다. 다음 용의자는 측정이었습니다. 온-디바이스 진단에서 이상한 조합을 봤습니다. 컨테이너의 clientWidth는 374px로 정상인데, 차트 라이브러리들이 크기를 잴 때 쓰는 getBoundingClientRect()/ResizeObserver는 0을 돌려주는 듯했습니다. Chart.js는 크기를 못 재면 canvas를 300×150 기본값으로 두고 아무것도 그리지 않습니다.
sequenceDiagram
participant C as 컴포넌트
participant L as 레이아웃
participant Lib as 차트 라이브러리
C->>Lib: mount 시 render / createChart
Lib->>L: getBoundingClientRect()
L-->>Lib: 0 (일부 iOS WebKit)
Note over Lib: 300×150 기본 크기 → 아무것도 안 그림
C->>L: clientWidth 폴링 (requestAnimationFrame)
L-->>C: 374px (정상)
C->>Lib: resize(374, 240) — 명시 크기 주입그래서 "라이브러리가 스스로 재게 두지 말고, 우리가 잰 clientWidth를 명시적으로 주입하자"는 방향으로 갔습니다. 처음엔 컨테이너에 폭이 잡히면 그때 resize()를 강제 호출했고,
// 컨테이너에 폭이 잡히면(rAF 폴링) Chart.js에 재측정·재그리기를 강제
const poll = (): void => {
if (cancelled) return;
if (box.clientWidth > 0) {
chartRef.current?.resize(); // 라이브러리가 다시 재고 그리도록
return;
}
raf = requestAnimationFrame(poll);
};그래도 안 되자, Chart.js 소스를 읽고 resize()에 명시 인자를 주면 내부의 getBoundingClientRect() 측정을 건너뛴다는 걸 확인해 폭·높이를 직접 넘겼습니다.
const resize = (): void => {
// Chart.js 내부 측정은 getBoundingClientRect() — 일부 모바일 WebKit에서 0 반환.
// resize(w, h)에 명시 인자를 주면 그 측정을 건너뛴다. clientWidth는 정상(374px).
if (box.clientWidth > 0 && box.clientHeight > 0) {
chartRef.current?.resize(box.clientWidth, box.clientHeight);
}
};여기서 한 발 더 나가, 라이브러리를 통째로 버리고 직접 SVG를 그리기로 했습니다. getBoundingClientRect도, ResizeObserver도 안 쓰고 오직 clientWidth로만 폭을 잡는 정적 SVG입니다. 그런데 그 clientWidth마저 기기에서 0으로 나오는 경우가 있어, 뷰포트 폭으로 폴백을 뒀습니다.
const apply = (): void => {
// clientWidth가 신뢰할 수 있는 값이지만, 일부 모바일 WebKit에서는 0을 준다.
// 그럴 땐 window.innerWidth(항상 동작)에서 셸+카드 패딩을 뺀 값으로 폴백.
const w = box.clientWidth > 0 ? box.clientWidth : Math.max(window.innerWidth - 64, 200);
const h = box.clientHeight > 0 ? box.clientHeight : 240;
setSize((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
};
apply(); // 크기를 즉시 한 번 정하고(차트를 절대 gate로 막지 않음) 이후 정밀화이렇게까지 했는데도 기기에서는 여전히 차트가 안 떴습니다. 다만 정적 SVG로 그린 축-절단 마크(0에서 멀리 떨어진 값을 표시하는 작은 글리프)는 보였습니다. 이펙트로 크기를 잡아 그리는 차트 본체는 사라지고, 이펙트 없이 그냥 마크업에 박힌 정적 SVG만 보인다 — 이 대비가 결정적 단서였습니다. 측정이 0이라 안 그려지는 게 아니라, 측정하는 코드(이펙트) 자체가 기기에서 실행되지 않는 게 아닐까.
기기에서 직접 비콘으로 디버깅
문제는 폰에 콘솔을 못 붙인다는 것이었습니다. 그래서 화면에 못 찍는 정보를 비콘으로 dev 서버 로그에 흘리기로 했습니다. 전송은 CORS 없이도 나가는 <img> 요청(무해한 경로)으로 했습니다. 서버 접근 로그에 그 URL이 찍히면, 폰이 이 빌드를 실제로 돌렸는지·데이터가 왔는지·차트 SVG가 몇 개 그려졌는지를 알 수 있습니다.
처음엔 "빈 차트"를 여러 원인에서 갈라내려고 홈 로드마다 무조건 쏘는 비콘을 넣었습니다.
// TEMP: 홈 로드마다 무조건 쏘는 비콘 — 기기 UA별로 이 빌드가 돌았는지,
// 대시보드 데이터가 이 기기에서 resolve됐는지를 dev 서버 로그로 확인.
useEffect(() => {
const tag = (s: string): string => s.replace(/[^a-zA-Z0-9.]/g, '_').slice(0, 70);
const pts = data?.chart.points.length ?? -1;
void fetch(
`/__mdbg/v4/home/${tag(navigator.userAgent)}/init${initial === null ? 0 : 1}/data${data === undefined ? 0 : 1}/pts${pts}/vw${window.innerWidth}`
).catch(() => undefined);
}, [initial, data]);비콘이 알려준 사실은 뼈아팠습니다. 폰은 인라인 스크립트 비콘(48개)은 정상적으로 쐈지만, React 이펙트에서 쏘는 비콘은 단 하나도 오지 않았습니다. 즉 순수 JS는 도는데 React 이펙트는 아예 안 도는 상태. 이건 하이드레이션이 안 되고 있다는 신호였습니다. 그래서 마지막으로, React와 무관하게 도는 인라인 스크립트에 전역 에러/거부 핸들러를 심어 "React가 왜 안 붙는지"를 직접 물었습니다.
// TEMP: React 없이도 도는 인라인 스크립트. 미처리 에러/거부(청크 로드 실패,
// 하이드레이션 불일치 등)를 <img> 비콘으로 로그에 남긴다.
window.addEventListener('error', (e) =>
beacon(
'/assets/err/' +
tag(
(e.message || '') +
'@' +
String(e.filename || '')
.split('/')
.pop()
)
)
);
window.addEventListener('unhandledrejection', (e) =>
beacon('/assets/rej/' + tag(e.reason && e.reason.message ? e.reason.message : e.reason))
);이 비콘이 마침내 범인을 잡았습니다.
flowchart TD
A[폰에서 차트가 빈 화면] --> B{인라인 스크립트 비콘은 오나?}
B -->|온다 48개| C{React 이펙트 비콘은 오나?}
C -->|하나도 안 온다 0개| D[React가 하이드레이션 안 됨]
D --> E{왜 안 붙나 - error 비콘}
E -->|client chunk 로드 실패| F[Next dev가 LAN origin에<br/>청크 제공 거부]다시 lightweight-charts로 돌아온 이유
원인은 차트가 아니었습니다. Next dev(Turbopack)가 폰(비-localhost LAN origin)에서 온 요청을 cross-origin으로 보고 client chunk 제공을 거부했고, 그래서 폰에서는 React가 아예 하이드레이션되지 않았습니다. 하이드레이션이 안 되니 모든 이펙트가 안 돌고, 이펙트로 크기를 재서 그리는 차트는 라이브러리가 무엇이든 빈 화면일 수밖에 없었습니다. 반대로 프로덕션 빌드(안정 청크, HMR 없음)는 폰에서도 멀쩡히 그려졌습니다. "데스크톱에서 되고 폰에서 안 되는" 정확한 이유가 여기 있었습니다. 로컬 dev를 localhost로 열면 재현되지 않고, 폰으로 LAN IP에 붙었을 때만 터졌던 겁니다.
고치는 건 한 줄이었습니다.
// apps/market-web/next.config.js
const nextConfig = {
// 폰(비-localhost LAN origin)이 dev 청크/HMR을 받도록 허용.
// 없으면 Next dev가 cross-origin으로 보고 client chunk 제공을 거부 → 하이드레이션 실패
// → 차트뿐 아니라 모든 client 인터랙션이 빈 화면이 된다. 내 머신의 LAN IP를 넣는다.
allowedDevOrigins: ['192.168.0.114'],
};차트가 죄가 없었다는 걸 알고 나니, 애초에 원했던 라이브러리로 돌아갈 이유가 생겼습니다. 직접 그린 SVG는 잘 작동했지만, crosshair·자동 스케일·툴팁 같은 걸 손으로 유지하는 비용이 컸습니다. lightweight-charts는 그걸 다 주고 모바일 터치도 견고합니다. 예전의 명시 측정 workaround는 전부 걷어내고 autoSize로 되돌렸습니다. 히스토그램(시세 총액)을 먼저 깔고 그 위에 라인(매물 총액)을 그립니다.
void import('lightweight-charts').then(
({ createChart, LineSeries, HistogramSeries, ColorType, CrosshairMode }) => {
const palette = chartPalette(resolvedTheme === 'dark');
chartApi = createChart(container, {
autoSize: true, // 예전의 명시 측정 workaround는 사라졌다.
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: palette.muted,
},
leftPriceScale: { visible: true, borderColor: palette.border },
rightPriceScale: { visible: true, borderColor: palette.border },
crosshair: { mode: CrosshairMode.Magnet },
localization: { priceFormatter: (v: number) => formatGoldCompact(v) },
handleScroll: false,
handleScale: false,
});
// 막대(시세 총액)를 먼저 → 라인이 위에 그려지도록. 얇게 보이라고 빈 슬롯을 끼워 넣는다.
const histSeries = chartApi.addSeries(HistogramSeries, {
color: palette.volumeBar,
base: 0,
priceScaleId: 'right',
});
histSeries.setData(densifyBars(soldData));
const lineSeries = chartApi.addSeries(LineSeries, {
color: palette.line,
lineWidth: 2,
priceScaleId: 'left',
});
lineSeries.setData(listingData);
chartApi.timeScale().fitContent();
}
);여기서 densifyBars는 순례가 남긴 작은 지혜입니다. 히스토그램 막대는 슬롯을 꽉 채우는 성질이 있어, 며칠치 일별 막대가 붙으면 한 덩어리처럼 보입니다. 그래서 실제 막대 사이에 빈 슬롯을 끼워 넣어 얇고 분리된 막대로 그리되, 데이터가 쌓일수록 끼우는 슬롯 수를 줄입니다.
export function densifyBars(real: readonly SeriesPoint[]): Array<SeriesPoint | Whitespace> {
const subslots = real.length <= 8 ? 4 : real.length <= 20 ? 2 : 1;
if (real.length < 2 || subslots < 2) return [...real];
const out: Array<SeriesPoint | Whitespace> = [];
for (let i = 0; i < real.length; i++) {
const point = real[i];
if (point === undefined) continue;
out.push(point);
const next = real[i + 1];
if (next !== undefined) {
const step = (next.time - point.time) / subslots;
for (let k = 1; k < subslots; k++) {
out.push({ time: (point.time + Math.round(step * k)) as UTCTimestamp });
}
}
}
return out;
}마지막으로 package.json에서 recharts·chart.js·react-chartjs-2를 걷어내고, lightweight-charts만 남겼습니다. 이틀을 돌아 처음 자리로 왔지만, 코드는 그때보다 훨씬 정직해졌습니다.
정리하면
이 편의 전제는 하나였습니다. 데스크톱에서 되던 차트가 모바일에서 안 될 때, 문제는 차트가 아니라 렌더 타이밍·측정·플랫폼일 수 있다.
- 라이브러리를 다섯 번 갈아탄 건, "빈 화면 = 차트 라이브러리 탓"이라는 첫 가정을 너무 오래 붙잡았기 때문입니다. 라이브러리를 다 걷어내도 안 됐을 때 아래층을 봤어야 했습니다.
- 그래도 순례가 준 것은 남았습니다.
oklch를 canvas로 되읽으면 iOS에서 붕괴하니 고정 sRGB로 박을 것, SVG 위 pointer 대신 native touch를 쓰고 iOS의 유령 마우스 이벤트를 막을 것 — 이 둘은 진짜 iOS WebKit 문제였습니다. - 콘솔 못 붙이는 기기는
<img>비콘으로 원격 관찰할 수 있습니다. "인라인 JS는 오는데 React 이펙트는 안 온다"는 신호 하나가 방향을 완전히 바꿨습니다. - 진짜 원인은
allowedDevOrigins한 줄이었습니다. 증상이 화려하다고 원인까지 화려한 건 아닙니다.
돌아보면 (2)편에서 "멈춘 걸 멈췄다고, 모르는 걸 모른다고 말하는 시스템"을 이야기했는데, 이번엔 그 정직함을 저 자신에게 적용하지 못한 셈입니다. 빈 화면 앞에서 "차트가 문제겠지"라고 단정하지 않고, "무엇이 실제로 실행되고 무엇이 안 되는가"를 먼저 관찰했다면 이틀이 반나절이었을 겁니다. 그래도 이 잔혹사가 시리즈의 마지막에 온 건 나쁘지 않습니다. 던플 개발기는 (0)편의 모노레포 설계부터 수집 파이프라인, 장애 대응, 메시징 백본, 실시간 게이트웨이, 대시보드, 가격 용어, FSD 리팩터링, 타입 안전, 아키텍처 감사, 숫자 포매팅까지 — "정확함보다 정직함"이라는 한 가지 태도를 계속 다른 각도에서 밀어붙인 기록이었습니다. 마지막 편이 하필 자기 손으로 만든 미로에서 빠져나온 디버깅기라는 게, 이 프로젝트를 만든 시간과 꽤 닮았습니다.
여기서 시리즈는 일단 닫지만, 던플이 닫히는 건 아닙니다. 이 차트는 아직 정적 스냅샷을 그립니다. (8)편의 실시간 게이트웨이와 이어 붙여 체결이 들어올 때마다 막대가 자라게 하는 것, guild-*·adventure-*·avatar-market-* 같은 다음 도메인으로 같은 터미널 경험을 넓히는 것 — 남겨 둔 여지는 많습니다. 언젠가 그 이야기를 이어서 쓰게 된다면, 이번엔 빈 화면 앞에서 조금 더 빨리 아래층을 볼 수 있기를 바랍니다. 여기까지 함께 읽어 주셔서 감사합니다.
