본문으로 건너뛰기

AI가 버그 22개를 찾아줬습니다. 가장 심각한 건 그 목록에 없었어요

2026년 8월 20일
2 views
MathCanvas
성능 최적화
테스트
Claude Code
주니어 개발
회고

이 글에는 브라우저에서 성능을 직접 재는 방법테스트가 실제로 버그를 잡는지 확인하는 방법이 담겨 있습니다. 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.67ms0.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 설계에서 PATCHPUT 을 구분하는 이유와 정확히 같은 문제입니다. 함수 인자에서도 같은 규칙이 필요합니다.

// 수정
} 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.56ms3%
교구 드래그"스냅 재계산"0.67ms4%
지우개(우선순위 낮음)15.67ms94%

"느릴 것"으로 지목된 둘은 여유가 있었고, 우선순위가 낮았던 하나가 유일한 진짜 병목이었습니다.

마퀴는 그래도 제곱 요인 둘(includesSet, 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.handlePointerUpsetXY 라고요. 고치고 재봤는데 안 됐습니다.

계측을 심었습니다.

// 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:sdkdist-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

부록 — 오늘 쓴 측정 스니펫 모음

  1. pointermove 핸들러 비용 — 진짜 pointerdown + 합성 move 200회 (1부 꿀팁 ②)
  2. CDP 프로파일 self timeProfiler.start/stop + samples 집계 (4부 꿀팁 ⑦)
  3. 리스너 등록 계측addInitScriptwindow.addEventListener 감싸기 (5부 ④)
  4. 변이 테스트 — 백업 → 가드 제거 → 테스트 → 복원 (5부 꿀팁 ⑨)
  5. 모듈 그래프 전이 폐쇄 — import 정규식 + BFS 로 도달 가능 모듈 세기 (결과)