이 글에는 브라우저에서 성능을 직접 재는 방법과 테스트가 실제로 버그를 잡는지 확인하는 방법이 담겨 있습니다. Vue나 캔버스에 관심이 없어도 도움이 될 거예요.
📌 자랑글이 되지 않도록, 제가 틀렸던 지점 네 군데와 일부러 안 고치기로 한 것 네 가지를 함께 담았습니다. 성능 측정 · 데이터 유실 디버깅 · 변이 테스트라는 키워드로 읽어주시면 좋겠습니다.
프롤로그
제가 다니는 회사의 MathCanvas는 학생이 수학 교구를 화면에서 직접 만지며 공부하는 웹 서비스입니다. 저는 그 캔버스 편집기와 학생용 뷰어를 맡고 있습니다. 어느 날 이런 지시를 받았습니다.
"커밋 후 뷰어·매쓰캔버스를 감사(로직 취약점 · 재사용 가능한 코드 · 레이어 위반 · 비효율 알고리즘)해 태스크리스트를 만들고 오후 7시까지 자율적으로 진행해."
하루를 통째로 받았습니다. 그런데 문제는, 저 네 가지를 혼자 눈으로 찾으려면 수천 개 파일을 읽어야 합니다.
그래서 AI 에이전트를 병렬로 띄웠습니다. 네 축을 동시에 조사하게 하고, 39건의 후보를 검증해 22건의 작업 목록을 만들었습니다. 여기까지 한 시간 정도 걸렸습니다.
목록을 받아 들고 이렇게 생각했습니다. "좋아, 이제 위에서부터 처리하면 되겠네."
그게 틀렸습니다.
💡 비유 — 지도는 받았지만 축척이 틀렸습니다
낯선 도시에서 누가 지도를 줬습니다. 길 이름도 맞고 건물 위치도 맞습니다. 그런데 축척이 틀렸습니다. "가깝다"고 표시된 곳이 실제로는 멀고, "멀다"고 표시된 곳이 걸어서 5분입니다.
지도가 쓸모없는 건 아닙니다. 지도 없이는 도시를 못 돌아다닙니다. 다만 거리를 지도에서 읽지 말고 직접 걸어봐야 합니다.
AI 감사 목록이 정확히 그랬습니다. 항목은 다 실재했습니다. 심각도만 틀렸습니다.
목록의 우선순위는 뒤집혀 있었고, 가장 심각한 결함은 목록에 아예 없었고, 목록의 가설 중 셋은 재보니 성립하지 않았습니다.
이 글은 그 간격을 메운 과정입니다.
1부. 지우개는 왜 무거워졌나
문제 인지 → 실측 → 원인 → 개선 순으로 갑니다. 이 순서를 지킨 게 이 건의 핵심입니다.
"가끔 뚝뚝 끊겨요"는 티켓이 안 됩니다
목록에는 이렇게 적혀 있었습니다.
"지우개 판정이 O(획 길이 × 획 수). pointermove 마다 모든 획의 전 길이를 훑는다."
맞는 지적입니다. 그런데 이것만으로는 고칠 이유가 되지 않습니다. 세상의 모든 코드에는 더 빠른 버전이 있으니까요.
그리고 사용자 쪽에서 온 신호는 이랬습니다. "지우개가 가끔 뚝뚝 끊겨요."
이건 티켓이 안 됩니다. 재현 조건이 없고, "가끔"이라는 단어 안에 원인이 숨어 있습니다.
💡 꿀팁 ① — 15.67ms 가 심각한지 아닌지 어떻게 알까?
먼저 브라우저에서 직접 쟀습니다. 필기 14획(총 길이 15,454)에서 마우스를 한 번 움직일 때 15.67ms.
이 숫자만 보면 감이 안 옵니다. 0.01초니까 별거 아닌 것처럼 보이죠.
그런데 여기에 자를 대야 합니다.
화면이 부드럽게 보이는 기준 = 초당 60프레임
한 프레임에 쓸 수 있는 시간 = 1000ms / 60 = 16.7ms
즉 지우개를 움직이는 동안 브라우저는 예산의 94%를 그 계산 하나에 쓰고 있었습니다. 나머지 6%로 화면을 그려야 합니다.
"가끔 뚝뚝 끊긴다"의 정체가 여기서 확정됐습니다. 성능 수치는 절대 기준과 함께 읽어야 정보가 됩니다. "15.67ms"는 숫자일 뿐이지만 "프레임 예산의 94%"는 판단입니다.
기준 몇 개는 외워둘 만합니다.
| 기준 | 값 | 넘으면 |
|---|---|---|
| 한 프레임 (60fps) | 16.7ms | 프레임 드롭 = 끊김 |
| 한 프레임 (120fps) | 8.3ms | 고주사율 기기에서 끊김 |
| 사용자가 "즉각적"으로 느끼는 한계 | 100ms | 반응이 느리다고 느낌 |
| 사용자가 흐름을 유지하는 한계 | 1,000ms | 딴짓 시작 |
💡 꿀팁 ② — pointermove 핸들러 비용을 재는 방법
"재봤습니다"라고 쓰긴 쉽지만, 실제로 어떻게 재는지가 문제입니다. 마우스를 실제로 움직이면 이벤트 간격이 일정하지 않아 핸들러 하나의 비용을 분리할 수 없습니다.
제가 쓴 방법은 진짜 마우스로 조작을 시작해 놓고, 그 상태에서 합성 이벤트를 묶어서 쏘는 것입니다.
// 1) 실제 마우스로 조작을 시작한다 (pointerdown 상태 유지)
// → 핸들러가 등록되고 내부 플래그가 켜진 진짜 상태를 만든다
await page.mouse.move(x, y)
await page.mouse.down()
// 2) 그 상태에서 합성 pointermove 를 200번 쏘고 총 시간을 잰다
const result = await page.evaluate(() => {
const target = document.getElementById('math-parent-element')
const opt = (x, y) => ({
bubbles: true,
composed: true, // ← shadow DOM 경계를 넘어가려면 필수
clientX: x, clientY: y,
buttons: 1, // ← 버튼이 눌린 상태로 보내야 드래그로 인식
pointerId: 1, isPrimary: true,
})
const N = 200
const t0 = performance.now()
for (let i = 0; i < N; i++) {
target.dispatchEvent(new PointerEvent('pointermove', opt(400 + i, 300 + i)))
}
const t1 = performance.now()
return { '1회(ms)': ((t1 - t0) / N).toFixed(4) }
})
포인트가 세 개 있습니다.
pointerdown은 진짜 마우스로 해야 합니다. 합성으로 하면 핸들러가 조기 반환해서 "0.008ms" 같은 거짓 결과가 나옵니다. 저도 처음에 이걸로 한 번 속았습니다.buttons: 1을 안 넣으면 드래그로 인식하지 않습니다.composed: true를 안 넣으면 shadow DOM 안의 핸들러에 도달하지 않습니다.
이 방법으로 마퀴·드래그·펜·지우개를 전부 같은 자로 잴 수 있었습니다.
코드를 열어보니
원본은 이랬습니다.
export const isPointerOnPath = (x: number, y: number, path: SVGPathElement, tolerance = 10) => {
const pathLength = path.getTotalLength()
let point, dx, dy
for (let i = 0; i < pathLength; i += 1) { // ← 1픽셀씩, 획 길이 전체를
point = path.getPointAtLength(i) // ← 비싼 SVG 기하 호출
dx = Math.abs(point.x - x)
dy = Math.abs(point.y - y)
if (dx < tolerance && dy < tolerance) return true
}
return false
}
그리고 이 함수는 pointermove 마다 pen-board 의 모든 획에 대해 불립니다.
const paths = getElement('pen-board')?.querySelectorAll('path')
paths.forEach((path) => {
if (isPointerOnPath(p.x, p.y, path)) { /* 지우기 */ }
})
여기서 중요한 건 알고리즘이 O(n)이라는 게 문제가 아니었다는 점입니다.
문제는 커서에서 한참 떨어진 획도 전 길이를 다 훑는다는 것이었습니다. 캔버스 반대편 끝의 획까지 1픽셀씩 성실하게 전부 검사한 뒤에 "안 맞았네"라고 결론 냅니다.
필기가 쌓일수록, 즉 문제를 열심히 푼 학생일수록 지우개가 무거워지는 구조였습니다.
💡 꿀팁 ③ — 최적화는 "빠르게 하는 것"이 아니라 "안 해도 되는 일을 안 하는 것"
고친 방법은 두 단계입니다.
export const isPointerOnPath = (
x: number, y: number, path: SVGPathElement, tolerance = 10,
): boolean => {
// ① 경계상자로 먼저 거른다. 커서가 상자 밖이면 즉시 false (O(1))
try {
const box = path.getBBox()
// getBBox 는 선 두께를 빼고 재므로 두께의 절반만큼 넓혀 준다
const half = (Number(path.getAttribute('stroke-width')) || 0) / 2
const pad = tolerance + half
if (x < box.x - pad || x > box.x + box.width + pad) return false
if (y < box.y - pad || y > box.y + box.height + pad) return false
} catch {
// 렌더 전(detached)이면 getBBox 가 던진다 — 거르기를 건너뛰고 아래로
}
// ② 남은 획만 훑되, 샘플 간격을 판정 반경의 절반으로 넓힌다
const pathLength = path.getTotalLength()
const step = Math.max(1, tolerance / 2) // ← 1픽셀씩 훑을 이유가 없었다
for (let i = 0; i <= pathLength; i += step) {
const point = path.getPointAtLength(i)
if (Math.abs(point.x - x) < tolerance && Math.abs(point.y - y) < tolerance) return true
}
return false
}
대부분의 획이 ①에서 끝납니다. 커서 근처에 있는 한두 개만 ②로 넘어갑니다.
💡 비유 — 봉투를 다 열어보기 전에 주소부터
우체국에 편지 1,000통이 왔고, 그중 우리 동네로 갈 것만 골라야 합니다.
- 원래 방식: 봉투를 하나하나 다 열어서 안의 주소를 읽는다
- 바꾼 방식: 봉투 겉면의 우편번호를 먼저 본다. 우리 동네가 아니면 열지 않는다
겉면을 보는 비용은 봉투를 여는 비용보다 훨씬 싸고, 대부분이 겉면에서 걸러집니다.
경계상자가 우편번호입니다. 획의 대략적인 범위를 먼저 보고, 커서가 그 범위 밖이면 안을 들여다보지 않습니다.
| 원본 | 수정 후 | |
|---|---|---|
| pointermove 1회 | 15.67ms | 0.03ms |
| 프레임 예산 대비 | 94% | 0.2% |
| 판정 감도 | — | 그대로 (정사각 tolerance 박스 유지) |
522배. 그리고 사용자가 체감할 동작 변화는 없습니다 — 판정 기준을 안 건드렸으니까요.
최적화의 첫 질문은 "어떻게 빠르게 하지"가 아니라 "이 일을 꼭 해야 하나" 입니다. 큰 개선은 대부분 계산을 빠르게 해서가 아니라 계산 자체를 건너뛰어서 나옵니다. 경계상자 사전 거르기(bbox prefilter)는 충돌 판정·히트 테스트 어디서나 쓸 수 있는 패턴입니다.
그런데 왜 아무도 안 고쳤을까요?
이 함수는 단위 테스트가 불가능했습니다.
1,000줄짜리 toolbar.ts 안에 있었고, 테스트에서 그 파일을 불러오는 순간
순환 참조로 Class extends value undefined 가 터졌습니다.
즉 고쳤는지 확인할 방법이 없었습니다.
그래서 순수 함수로 분리했습니다. entities/canvas/lib/penHitTest.ts.
스토어도 DOM 헬퍼도 안 쓰는, SVG path 하나만 보는 함수니까요. 그러니 테스트가 붙었습니다(8건).
tests/unit/canvas/eraserHitTest.test.ts
✓ 획 위에 있으면 맞았다고 한다
✓ 경계상자 밖이면 즉시 아니라고 한다
✓ 선 두께를 감안해 상자를 넓힌다
✓ 렌더 전(detached) 획에서도 죽지 않는다
...
테스트할 수 없는 코드는 고쳐지지 않습니다. 고쳤는지 확인할 방법이 없으니까요. 리팩터링이 "깔끔함"의 문제로 보일 때가 많지만, 실제로는 수정 가능성의 문제입니다.
2부. } else { 한 줄이 교구 21개를 지웠습니다
재현
캔버스에 교구를 21개 놓고, 저장하고, [초기화]를 눌렀습니다.
저장 전: 교구 21개
저장: 성공
[초기화] 클릭: 교구 0개 ← ?!
21개가 전부 사라졌습니다. 데이터 유실입니다.
범인
상태를 갱신하는 setStates 함수 안에, [초기화]가 되돌릴 기준 스냅샷을 만드는 부분이 있었습니다.
if (/* 레거시 저장본 경로 */) {
prevObject.value.curElements = snapshotCreated.map(...)
} else { // ← 여기
prevObject.value.curElements = createdElements.map((element) =>
JSON.parse(JSON.stringify(stripHeavyFields(element))),
)
}
그리고 createdElements 는 같은 함수 위쪽에서 이렇게 채워집니다.
if (newState.elements) { // ← elements 를 담아 부를 때만
for (const state of newState.elements) {
...
createdElements.push(element)
}
}
즉 교구를 담지 않은 부분 갱신에서는 createdElements 가 빈 배열입니다.
그런데 스냅샷 대입에는 조건이 없으니 무조건 빈 배열로 덮습니다.
실제 사고 경로는 이랬습니다.
저장 성공
→ saveCanvas 가 canvasOption 만 담아 setStates 호출 (elements 없음)
→ createdElements = []
→ prevObject.curElements = [] ← 기준이 비었다
→ [초기화] 클릭 → 되돌릴 기준이 빈 배열 → 교구 전멸
💡 꿀팁 ④ — 형제 코드를 나란히 놓고 보세요
이 버그의 진짜 힌트는 바로 아래 줄에 있었습니다.
// 교구 스냅샷
} else { // ← 가드 없음
prevObject.value.curElements = createdElements.map(...)
}
// 펜 스냅샷 (같은 함수, 조금 아래)
} else if (newState.penElements) { // ← 가드 있음!
prevObject.value.penElements = [...newState.penElements]
}
같은 일을 하는 형제인데 한쪽에만 가드가 있었습니다.
누군가 과거에 펜에는 가드를 붙였고, 교구에는 붙이지 않았습니다.
💡 비유 — 쌍둥이 중 한 명만 안전벨트를 맸습니다
차에 쌍둥이를 태웠습니다. 한 명은 안전벨트를 맸고, 한 명은 안 맸습니다.
이걸 보면 누구나 "안 맨 쪽이 잘못됐다"고 압니다. 둘이 나란히 있어서 보이는 겁니다.
문제는 코드 리뷰가 보통 변경분(diff)만 본다는 것입니다. 안전벨트를 맨 커밋과 안 맨 커밋이 서로 다른 날짜에 있으면, 어느 리뷰에서도 "쌍둥이 중 한 명만 맸다"는 게 보이지 않습니다.
파일을 열어 위아래를 함께 읽어야 보입니다.
같은 일을 하는 코드가 나란히 있고 한쪽에만 가드가 있으면 그건 거의 항상 버그입니다. 그리고 이건 diff 로는 안 보입니다 — 가드를 붙인 커밋과 안 붙인 커밋이 서로 다른 날짜에 있으니까요. 파일을 열어 위아래를 함께 읽어야 보입니다.
코드 리뷰에서 가장 싸게 잡히는 종류인데, 리뷰가 diff 중심이라 자주 놓칩니다.
"안 보냈다"와 "비었다"는 다릅니다
이 버그의 본질은 부분 갱신을 다루는 방식입니다.
일부 필드만 담아 부르는 호출이 있다면, 담기지 않은 필드를 "없음"으로 해석하면 안 됩니다.
setStates({ canvasOption }) // "교구는 안 보냈다"
setStates({ canvasOption, elements: [] }) // "교구가 비었다"
이 둘은 완전히 다른 뜻인데, 원본 코드는 같게 취급했습니다.
API 설계에서 PATCH 와 PUT 을 구분하는 이유와 정확히 같은 문제입니다.
함수 인자에서도 같은 규칙이 필요합니다.
// 수정
} else if (newState.elements) { // ← 담아 보냈을 때만 덮는다
prevObject.value.curElements = createdElements.map(...)
}
수정은 1줄, 시간은 재현과 검증에 다 썼습니다
} else { → } else if (newState.elements) {. 끝입니다.
그런데 이 1줄에 쓴 시간의 90%는 재현 환경을 만들고 수정 후를 다시 재는 데 갔습니다.
처음엔 이게 비효율로 느껴졌습니다. 지금은 아닙니다.
1줄 수정의 위험은 "그 1줄이 맞나"가 아니라 "그 1줄이 다른 걸 깨나" 입니다. 그리고 그건 재현 환경 없이는 알 수 없습니다. 작아 보이니까 검증을 건너뛰기 쉽고, 그래서 회귀가 납니다.
3부. 목록에 없던 결함 — 옆줄을 봤습니다
지적된 건 다른 것이었습니다
목록에는 이렇게 적혀 있었습니다.
"저장 대상을 모으는
getElementsBySort에서<g>태그만 걸러내는 필터가 무효하다 — 팩토리를 넘겨서 항상 truthy"
무슨 말이냐면, 이런 코드였습니다.
const isTagNameG = () => (item: SVGGraphicsElement) => item.tagName.toLowerCase() === 'g'
// ^^^^^ 함수를 반환하는 함수 (커링)
const idArr = Array.from(playground.children)
.filter(isTagNameG) // ← 팩토리 자체를 넘겼다. 호출 결과는 항상 "함수" = truthy
.map((g) => g.id)
filter 에 팩토리를 넘겼으니 모든 자식이 통과합니다. 필터가 아무것도 안 걸러냅니다.
맞는 지적입니다. 확인하러 그 함수를 열었습니다. 그런데 문제의 필터 바로 위 줄이 눈에 걸렸습니다.
const getElementsBySort = () => {
const playgrounds = document.querySelectorAll('.playground') // ← 이게 괜찮나?
const ids: string[] = []
const isTagNameG = () => (item) => ...
document 로 찾고 있었습니다.
우리 서비스는 외부 제공용 재생기(SDK)를 별도 번들로 배포하는데, 그건 격리된 영역
(shadow DOM) 안에서 돕니다. document 로는 그 안이 안 보입니다.
재보니 0개
// SDK(shadow DOM) 안에서 측정
{
"스토어_교구수": 21,
"document_playground": 0, // ← document 로는 안 보인다
"shadow_playground": 4, // ← shadow root 로는 보인다
"getElementsBySort_결과": 0 // ← 저장할 내용이 0개
}
저장할 교구가 21개인데 조회 결과가 0개. 그대로 저장하면 빈 캔버스가 서버로 갑니다.
목록에 있던 항목(필터 무효)은 확인해보니 오늘 아무 영향도 없었습니다 —
마침 .playground 의 자식이 전부 <g> 라서 결과가 같았습니다(21/21로 실측).
정작 심각한 건 그 위 줄이었고, 그건 목록에 없었습니다.
AI 감사 목록은 체크리스트가 아니라 좌표입니다. 저는 22건을 "처리해야 할 항목"이 아니라 "가봐야 할 자리"로 다뤘고, 그게 이걸 찾은 이유입니다. 목록만 소비하면 옆줄을 못 봅니다.
💡 꿀팁 ⑤ — 같은 파일에 두 방식이 있으면 한쪽이 틀렸습니다
정답은 같은 파일 안에 있었습니다. 57줄 아래, 다른 함수는 이렇게 하고 있었습니다.
// resetViewBoxContents (같은 파일)
const outermostEl = getElement('outermost') // ← 루트를 아는 헬퍼
if (outermostEl) {
const playgrounds = outermostEl.querySelectorAll('.playground')
for (const playground of playgrounds) playground.innerHTML = ''
}
getElement 는 내부적으로 _root 를 봅니다. 앱에서는 document, SDK 에서는 shadow root.
즉 양쪽에서 다 동작하는 조회입니다.
한 파일 안에서 같은 조회를 두 방식으로 하고 있었고, 어느 쪽이 맞는지는 파일이 이미 알려주고 있었습니다.
// 수정
const playgrounds = getElement('outermost')?.querySelectorAll('.playground') ?? []
// 수정 후 같은 측정
{ "교구수": 21, "getElementsBySort": 21 } // ✅
"우연히 맞는 코드"는 환경이 하나일 때만 안전합니다
왜 아무도 몰랐을까요?
앱에서는 _root 의 기본값이 document 입니다. 즉 앱에서는 두 방식의 결과가 같습니다.
격리 영역에서만 깨집니다.
💡 비유 — 멈춘 시계도 하루 두 번은 맞습니다
멈춘 시계를 보고 "이 시계 맞네"라고 할 수 있는 순간이 하루에 두 번 있습니다. 그 두 번만 보면 시계가 고장 났다는 걸 알 수 없습니다.
document 로 찾는 코드가 그랬습니다. 앱에서는 우연히 맞았습니다.
앱에서는 그 기준값이 마침 document 였으니까요.
환경이 하나일 때는 영원히 안 들킵니다. 격리된 영역이 생기는 순간, 그때 처음 틀립니다.
처음 이 코드를 쓴 사람은 틀리지 않았습니다. 그때는 환경이 하나였으니까요.
환경이 늘어난 순간 깨졌고, 그때는 이미 왜 그렇게 썼는지 아무도 모릅니다.
임베드 · 별도 번들 · 다중 창처럼 실행 환경이 늘어나는 시점이 이런 코드가 한꺼번에 터지는 시점입니다. "지금 잘 되는데?"는 근거가 아닙니다.
💡 꿀팁 ⑥ — 심각도는 빈도가 아니라 "알아챌 수 있나"로 재세요
이 버그의 실패 모드를 보세요.
- 에러가 안 납니다
- 토스트도 안 뜹니다
- 사용자는 저장됐다고 믿습니다
그래서 신고되지도 않습니다. 나중에 열어봤을 때 왜 비었는지 알 수 없으니까요.
| 시끄러운 실패 | 조용한 실패 | |
|---|---|---|
| 예 | 에러 화면, 토스트, 크래시 | 빈 저장, 잘못된 값 저장 |
| 사용자 반응 | 신고한다 | 모른다 |
| 발견까지 | 몇 분 | 몇 주 |
| 신뢰 손상 | 짜증 | "이 서비스 못 믿겠다" |
💡 비유 — 영수증 없이 빠져나가는 돈
지갑에서 돈이 없어지는 두 가지 방식을 생각해보세요.
| 소리 나는 실패 | 조용한 실패 | |
|---|---|---|
| 방식 | 지갑을 떨어뜨렸다 | 매달 소액이 자동 결제된다 |
| 알아채기 | 즉시 | 몇 달 뒤 명세서를 볼 때 |
| 손실 | 한 번, 그리고 끝 | 계속, 그리고 언제부터인지 모름 |
빈 캔버스가 저장되는 건 두 번째입니다. 에러도 없고 알림도 없어서 사용자는 저장됐다고 믿습니다. 나중에 열어봤을 때 왜 비었는지 알 수 없고, 그때는 이미 원본이 없습니다.
심각도는 "얼마나 자주 나나"가 아니라 "사용자가 알아챌 수 있나" 로 재야 합니다. 시끄럽게 실패하는 버그는 신고되고 고쳐집니다. 조용히 실패하는 버그는 신뢰를 갉아먹습니다.
4부. AI 목록이 틀렸던 세 지점
이게 오늘의 핵심입니다. AI 감사는 진짜로 유용했습니다 — 혼자서는 39건을 볼 수 없습니다. 그런데 목록은 사실이 아니라 가설의 모음이었습니다.
① 우선순위가 뒤집혀 있었습니다
목록은 마퀴 다중선택과 교구 드래그를 "O(N²)"로 지목했습니다. 그럴듯했습니다.
실제로 코드는 항목마다 배열을 훑고(selectedIds.includes(...)),
항목마다 화면 계산을 강제하고(item.getBBox()) 있었습니다.
재봤습니다.
| 대상 | 목록의 지목 | 실측 | 프레임 예산 대비 |
|---|---|---|---|
| 마퀴 다중선택 | "O(N²)" | 0.56ms | 3% |
| 교구 드래그 | "스냅 재계산" | 0.67ms | 4% |
| 지우개 | (우선순위 낮음) | 15.67ms | 94% |
"느릴 것"으로 지목된 둘은 여유가 있었고, 우선순위가 낮았던 하나가 유일한 진짜 병목이었습니다.
마퀴는 그래도 제곱 요인 둘(includes → Set, getBBox 캐시)을 걷어냈는데,
1시간 30분 들여 3.6%밖에 개선하지 못했습니다.
💡 꿀팁 ⑦ — 개선폭이 예상보다 작으면 프로파일을 뜨고 멈추세요
3.6%가 나온 순간 "뭘 더 고쳐야 하나"가 아니라 "내가 짚은 게 병목이 아니었나" 를 물어야 합니다.
CDP 프로파일러로 self time 상위를 뜯어봤습니다.
샘플 4,754개 — self time 상위
11.8% getBBox ← 남은 1회 호출 (selectRect)
11.0% (Vue 반응성 내부)
10.9% getScreenCTM ← 좌표 변환
10.8% (Vue 반응성 내부)
7.7% get ← reactive getter
6.1% getIsLock
5.1% setAttribute
비용이 여러 곳에 고르게 분산돼 있었습니다. 단일 병목이 없다는 뜻입니다. 이런 모양이면 하나를 고쳐도 전체는 거의 안 줄어듭니다. 여기서 멈췄습니다.
프로파일 뜨는 방법은 이렇습니다.
const cdp = await context.newCDPSession(page)
await cdp.send('Profiler.enable')
await cdp.send('Profiler.setSamplingInterval', { interval: 50 }) // 50µs
await cdp.send('Profiler.start')
/* ... 측정하고 싶은 조작 ... */
const { profile } = await cdp.send('Profiler.stop')
// samples 를 노드별로 세면 self time 이 나온다
const byId = new Map(profile.nodes.map((n) => [n.id, n]))
const self = new Map()
for (const id of profile.samples) {
const f = byId.get(id)?.callFrame
if (!f) continue
const key = `${f.functionName || '(anon)'}:${f.lineNumber}`
self.set(key, (self.get(key) || 0) + 1)
}
② 가장 심각한 결함은 목록에 없었습니다
위의 "임베드 빈 저장"입니다. 목록에 있던 항목은 오늘 아무 영향도 없는 잠재 결함이었고, 그걸 확인하러 간 자리에 실제 결함이 있었습니다.
③ 가설 셋은 측정에서 무너졌습니다
| 목록의 가설 | 재보니 |
|---|---|
"펜 모드에서 교구가 클릭을 먹을 것" (pointer-events-none 토글이 SDK 에서 무효) | 정상 동작. 잠긴 게 21개 중 0개인데도 교구 위에 그으면 획이 그려지고 교구는 제자리. 다른 장치가 이미 막고 있었다 |
| "썸네일 업로드 예외로 슬라이드 이동이 영구 잠김" | 그 함수는 자기 예외를 삼킨다(catch { console.warn }). 성립할 수 없는 가설 |
"교구 이동 커밋 지점은 useDND.handlePointerUp" | 계측해보니 그 경로를 아예 지나가지 않았다. 읽기로 추정한 게 틀렸다 |
특히 첫 번째가 아찔했습니다. 안 재고 "고쳤으면" 동작하는 코드를 위험하게 바꿨을 겁니다.
AI는 그럴듯한 가설을 대량으로 싸게 생산합니다. 그건 진짜 가치입니다. 하지만 그럴듯함과 사실 사이의 간격은 측정으로만 메워집니다.
5부. 그래서 쓴 방법 네 가지
① 재현 먼저, 수정 나중 — 숫자로
데이터 유실 5건 전부 고치기 전에 브라우저에서 숫자로 재현했습니다.
"교구가 사라진다"가 아니라 "21개 → 0개".
숫자로 재현해두면 수정 후 같은 숫자를 다시 재서 끝났음을 증명할 수 있습니다. 말로 재현하면 그게 안 됩니다.
| 결함 | 재현 실측 | 수정 후 |
|---|---|---|
| 저장 뒤 [초기화] | 21개 → 0개 | 21 유지 |
| 캔버스 밖에서 손 떼기(교구) | 좌표·기록 그대로 | 확정 |
| 캔버스 밖에서 손 떼기(펜) | 화면 2획 / 데이터 1획 | 2 / 2 |
| 유령 교구 | 21개 → 21개 | 21 → 20 |
| 임베드 빈 저장 | 21개 → 0개 | 21개 |
💡 꿀팁 ⑧ — 깨진 것만 보지 말고 정상 경로를 먼저 재세요
"캔버스 밖에서 손 떼면 이동이 저장 안 된다"고 말하려면, 먼저 안에서 떼면 저장되는지를 재야 합니다.
안에서 놓기: 좌표 1112,287 → 1259,213 ✅ 되돌리기 기록 1 → 2 ✅
밖에서 놓기: 좌표 1112,287 → 1112,287 ❌ 되돌리기 기록 1 → 1 ❌
이 대조가 있어야 "밖에서만 깨진다"가 사실이 됩니다.
이걸 안 하면 "원래 저장이 안 되는 기능"을 버그로 신고하는 사고가 납니다.
저는 예전에 작은 썸네일을 눈으로만 보고 "캡처가 3D를 못 담는다"고 보고했다가,
실제로는 object-cover 크롭이었고 대상은 3D도 아니었던 적이 있습니다. 그때 배웠습니다.
💡 꿀팁 ⑨ — 통과하는 테스트는 아무것도 증명하지 않습니다
새로 쓴 테스트 130건에 대해, 일부러 고친 부분을 되돌려서 정말 빨개지는지 확인했습니다. 총 43종. 이걸 변이 테스트(mutation testing)라고 합니다.
방법은 단순합니다.
# 1) 원본 백업
cp $FILE /tmp/f.bak
# 2) 가드를 하나 되돌린다 (수정 전 형태로)
python3 -c "
s = open('$FILE').read()
s2 = s.replace('if (!isDND.value) return', '') # 재진입 가드 제거
assert s2 != s; open('$FILE','w').write(s2)"
# 3) 테스트가 빨개지는지 본다
npx vitest run <spec> # → Failed Tests 1 ✅ 잡는다
# 4) 복원
cp /tmp/f.bak $FILE
그리고 하나가 빠져나갔습니다.
페이지 배선 끊기 Tests 22 passed (22) ← ?! 통과했다
원인은 이랬습니다. 제 테스트가 이렇게 되어 있었습니다.
// 배선이 있는지 본다
expect(PAGE).toMatch(/if \(bootstrapResult\) \{\s*blockingNotice\.value = NOTICE\[bootstrapResult\]/)
그런데 그 배선은 2곳에 있었습니다(외부 공개 뷰어 진입 / 활동 뷰어 진입). 한 곳만 끊어도 나머지 하나가 매칭돼서 통과합니다. 한쪽만 깨져도 못 잡는 테스트였던 겁니다.
개수를 세도록 고쳤습니다.
// ⚠️ "하나라도 있으면 통과" 로 두면 한쪽만 끊겨도 못 잡는다 — 개수로 잠근다.
const wired = PAGE.match(
/if \(bootstrapResult\) \{\s*blockingNotice\.value = NOTICE\[bootstrapResult\]/g,
)
expect(wired?.length, 'bootstrap 결과 → 안내 배선이 2곳이 아니다').toBe(2)
💡 비유 — 화재경보기를 확인하려면 연기를 피워야 합니다
집에 화재경보기를 달았습니다. 불이 안 났으니 경보기는 조용합니다.
이 조용함은 "경보기가 작동한다"를 증명하지 않습니다. 배터리가 빠져 있어도 똑같이 조용합니다.
경보기를 확인하는 유일한 방법은 연기를 피워보는 것입니다.
테스트도 같습니다. 초록불은 "코드가 맞다"는 뜻일 수도 있고, "테스트가 아무것도 안 보고 있다"는 뜻일 수도 있습니다. 둘을 구분하려면 일부러 코드를 깨봐야 합니다.
통과하는 테스트는 아무것도 증명하지 않습니다. 실패시킬 수 있는 테스트만 증명합니다. 테스트를 쓴 뒤에 코드를 일부러 깨보는 5분이, 그 테스트의 가치를 결정합니다.
한 가지 더. 변이 하나는 한국 시간대라 잡을 수 없었습니다.
활동 기간 종료일 계산을 setDate(+N일) 에서 setTime(+N*24시간) 으로 바꿔도
테스트가 전부 통과했습니다. 한국은 서머타임이 없어서 두 방식의 결과가 같기 때문입니다.
서머타임 지역이 생기면 벽시계 시각이 한 시간 밀립니다. 그래서 이 케이스만 동작 대신 코드 형태를 잠갔습니다.
it('밀리초 덧셈이 아니라 날짜 더하기로 계산한다 (형태로 잠근다)', () => {
// ⚠️ 이 케이스만 동작 대신 코드 형태를 본다.
// 한국 시간대는 서머타임이 없어 `+24시간` 과 `+1일` 의 결과가 완전히 같다 —
// 즉 이 저장소에서 도는 테스트로는 밀리초 덧셈으로 바꿔도 아무 것도 실패하지 않는다(실측).
const body = SRC.slice(SRC.indexOf('export const getPresetEndDate'))
expect(body, '밀리초 덧셈으로 바뀌었다 — 서머타임 지역에서 시각이 밀린다').not.toMatch(/setTime\(/)
expect(body, '날짜 더하기 형태가 사라졌다').toMatch(/setDate\(end\.getDate\(\) \+/)
})
동작으로 잡을 수 없다는 걸 알고서 형태로 잠근 것과, 그냥 형태만 잠근 것은 다릅니다. 주석에 왜 그랬는지 남겨야 다음 사람이 이걸 "게으른 테스트"로 오해하지 않습니다.
④ 틀렸으면 빨리 인정하고 방향을 바꿨습니다
교구 이동 커밋 지점을 코드 읽기로 추정했습니다. useDND.handlePointerUp 의 setXY 라고요.
고치고 재봤는데 안 됐습니다.
계측을 심었습니다.
// window.addEventListener 를 감싸서 실제로 등록되는지 본다
await context.addInitScript(() => {
window.__log = []
const add = window.addEventListener.bind(window)
window.addEventListener = function (t, f, o) {
if (t === 'pointerup' || t === 'blur') window.__log.push('ADD ' + t)
return add(t, f, o)
}
})
pointerdown 직후 로그: [] ← 아무것도 등록되지 않았다
그 경로를 아예 지나가지 않았습니다. 그 교구는 모듈 클래스가 직접 처리하고 있었습니다.
그리고 그 뒤엔 빌드 단계를 하나 빠뜨려서 "수정이 반영 안 됨"으로 한 번 더 헤맸습니다.
build:sdk 는 dist-sdk/ 에 쓰고, 테스트 하네스는 sdk/v1/ 을 읽습니다.
중간에 release:sdk 가 있어야 했습니다.
기록해뒀습니다. 다음에 같은 데서 안 헤매기 위해서요.
6부. 안 하기로 결정한 것들
이게 오늘 가장 어려웠습니다.
| 안 한 것 | 왜 |
|---|---|
| 좌표 변환 캐싱 | 프로파일에서 11%. 캐싱하면 이득이 크다. 그런데 갱신 시점(뷰박스·줌·리사이즈·조상 transform)을 틀리면 모든 조작의 좌표가 조용히 어긋난다. 잘못됐을 때의 실패 모드가 너무 나쁘다 |
| 펜 획 구조 개선 | 획이 길어질수록 느려지는 걸 확인했다(점당 0.042 → 0.144ms, 2차 증가). 원인도 갈랐다 — getAttribute/문자열 연결이 아니라 매 점마다 전체 d 문자열을 다시 만드는 것이다(A 58.7 / 배열join 49.6 / setAttribute만 6.8ms). 고치려면 "한 획 = 한 도형" 구조를 쪼개야 하고, 그러면 지우개·기록·저장이 전부 영향받는다 |
| 마퀴 추가 최적화 | 1시간 30분 들여 3.6%. 프로파일로 비용이 분산됐음을 확인하고 멈췄다 |
| 미사용 코드 200줄 삭제 | 조사는 "export 9개 전부 참조 0"이라 했지만, 독립 검증에서 2개가 살아 있었다(groupCharsToRuns, ModelPos). 6월에 한 벌로 추가된 텍스트 편집 API 라 만들던 중일 수 있다. 지우지 않고 상태만 기록했다 |
💡 비유 — 수술은 성공률만으로 결정하지 않습니다
성공하면 크게 좋아지는 수술이 있습니다. 성공률도 높습니다. 그런데 실패하면 증상이 안 돌아오는 게 아니라 다른 게 망가집니다.
좌표 변환 캐싱이 그랬습니다. 성공하면 11%가 빨라지지만, 갱신 시점을 하나 놓치면 모든 조작의 좌표가 조용히 어긋납니다. 그러면 사용자가 놓은 교구가 엉뚱한 데 놓이고, 그게 그대로 저장됩니다.
이득의 크기가 아니라 실패 모드의 성격으로 결정했습니다.
"할 수 있다"와 "해야 한다"는 다릅니다. 특히 잘못됐을 때 조용히 실패하는 변경은, 이득이 커도 검증 예산이 없으면 하지 않는 게 맞습니다. 그리고 안 한 이유를 문서로 남기면 다음 사람이 같은 판단을 다시 할 수 있습니다.
마지막 항목이 특히 그렇습니다. AI 조사 결과를 그대로 믿고 200줄을 지웠으면 누군가 만들던 기능이 사라졌을 겁니다. 삭제는 되돌리기 어려운 작업이라 "참조 0"이라는 말을 한 번 더 확인했습니다. 그리고 실제로 틀렸습니다.
결과
| 시작 | 끝 | |
|---|---|---|
| 자동 점검 | 1,811건 | 1,941건 (신규 130건) |
| 신규 타입 오류 | — | 0 |
| 커밋 | — | 14건 |
| 데이터 유실 결함 | 5건 잠재 | 0 (전부 실측 재현 후 수정) |
| 공용 UI 배럴이 끌고 오던 모듈 | 1,438개 | 108개 |
마지막 줄은 덤으로 나온 것입니다. 176개 화면이 공유하는 공용 UI 묶음 안에 슬라이드 전용 컴포넌트 두 개가 잘못 들어 있었고, 그 둘이 슬라이드 스토어를 참조해서 묶음 하나가 저장소 코드의 80%를 그래프에 물고 있었습니다.
Pinia 의 defineStore(...) 호출은 side-effect 로 취급돼 트리 셰이킹으로 떨어지지 않습니다.
두 파일을 슬라이드 영역으로 옮기니 108개가 됐습니다.
이건 그래프를 직접 걸어서 세는 테스트로 잠갔습니다 — 문자열 검사가 아니라 도달 가능 모듈을 세는 방식이라 이름이 다른 컴포넌트를 새로 넣어도 잡힙니다.
에필로그 — AI 시대에 주니어가 파는 것
AI는 제가 혼자서는 볼 수 없는 39건을 찾아줬습니다. 그건 부정할 수 없는 레버리지입니다. 한 시간에 22건짜리 작업 목록이 나왔고, 그게 없었으면 하루로는 어림도 없었습니다.
그런데 오늘 실제로 값을 만든 순간들은 전부 그 목록을 의심한 자리에 있었습니다.
- 목록이 "느릴 것"이라 한 걸 재보니 아니었습니다
- 목록에 없는 걸 가는 길에 발견했습니다
- 목록의 가설 셋이 성립하지 않았습니다
- 통과한 제 테스트 하나가 실은 못 잡는 테스트였습니다
AI가 코드를 쓰는 시대에 주니어가 파는 건 타이핑 속도가 아니라고 생각합니다. 그럴듯한 것과 사실을 구분하는 절차, 그리고 틀렸을 때 빨리 아는 습관입니다.
재현 없이는 고치지 않습니다. 재보지 않고는 빠르다고 말하지 않습니다. 깨보지 않은 테스트는 믿지 않습니다. 그리고 확인하지 못한 건 확인하지 못했다고 씁니다.
오늘도 하나 남겼습니다.
임베드 저장은 실측했지만 앱 저장 경로는 세션 인증 문제로 확인하지 못했습니다. 위험은 낮다고 봅니다 — 같은 파일의 기존 코드(
resetViewBoxContents)가 이미 같은 범위 규칙에 의존해서 앱에서 매 로드마다 돌고 있으니,.playground가#outermost밖에 있으면 그 코드가 먼저 눈에 띄게 깨집니다. 그래도 확인한 것은 아닙니다.
그 문장을 쓸 수 있는 게 중요하다고 배웠습니다.
읽어주셔서 고맙습니다 :)
부록 — 이 글에 나온 파일
| 역할 | 경로 |
|---|---|
| 지우개 판정 (분리된 순수 함수) | src/entities/canvas/lib/penHitTest.ts |
캔버스 상태 스토어 (setStates, getElementsBySort) | src/entities/canvas/model/viewbox.store.ts |
| 조작 끝내기 공용 헬퍼 | src/shared/lib/pointerSession.ts |
| 뷰어 진입/제출/이동 가드 | src/entities/slide/model/viewer.store.ts |
| 활동 기간 프리셋 정본 | src/entities/report/lib/activityPeriodPresets.ts |
| 공용 UI 배럴 폐쇄 가드 | tests/unit/architecture/sharedUiBarrelClosure.test.ts |
부록 — 오늘 쓴 측정 스니펫 모음
- pointermove 핸들러 비용 — 진짜
pointerdown+ 합성 move 200회 (1부 꿀팁 ②) - CDP 프로파일 self time —
Profiler.start/stop+ samples 집계 (4부 꿀팁 ⑦) - 리스너 등록 계측 —
addInitScript로window.addEventListener감싸기 (5부 ④) - 변이 테스트 — 백업 → 가드 제거 → 테스트 → 복원 (5부 꿀팁 ⑨)
- 모듈 그래프 전이 폐쇄 — import 정규식 + BFS 로 도달 가능 모듈 세기 (결과)