Vue 3로 구현된 웹 에디터 화면이 있습니다.
이미지 위에 텍스트 박스(tiptap)를 얹어 드래그로 옮기고, 리사이즈하고, 안의 글을 편집하는 도구입니다.
어느 날 이런 제보를 받았습니다.
"오브젝트를 새로 하나 추가하면 화면이 잠깐 멈춰요."
실측해 보니 클릭 후 화면 반영까지 중앙값 518ms, 느린 환경에서는 2초를 넘겼습니다.
오브젝트(텍스트 박스) 하나를 추가했을 뿐인데요.
문서가 무거울수록 심해졌습니다.
즉 비용이 바뀐 것(1개)이 아니라 이미 떠 있는 것 전체에 붙어 있다는 신호입니다.
1. 고치기 전에, 지표가 거짓말하고 있지 않은지부터
성능 개선의 첫 단계로 기존 계측 지표를 확인했는데, 이상한 값이 나왔습니다.
"낙관적 렌더링에 걸린 시간"을 잰다는 지표가 있었는데, 14일치 데이터의 중앙값이 0ms, 최대값이 3ms였습니다.
사용자는 분명 0.5~2초를 체감하는데요.
코드를 열어 보니 이유가 명확했습니다.
const startedAt = now()
const tempObjects = buildTempObjects(objects, target) // JS 객체 생성
const optimisticRenderMs = now() - startedAt // ...끝?
자바스크립트 객체를 만드는 시간만 재고 있었습니다.
렌더링, 스타일 계산, 레이아웃, 페인트 — 사용자가 실제로 기다리는 모든 것이 측정 구간 밖이었죠.
그래서 첫 작업은 최적화가 아니라 계측 정정이었습니다.
클릭 시점부터 실제로 화면에 그려지는 시점(double requestAnimationFrame)까지 재도록 고쳤습니다.
2. 오브젝트 1개 추가에 수 천개가 다시 그려진다
Chrome DevTools로 렌더 호출을 세어 보니, 오브젝트 1개를 추가할 때 마운트된 N개 컴포넌트 전부가 리렌더되고 있었습니다.
Vue 컴포넌트는 자기가 구독한 반응형 데이터가 바뀌면 다시 렌더링됩니다.
그러니 문제는 "각 오브젝트 컴포넌트가 자기와 상관없는 데이터를 구독하고 있다"는 뜻입니다.
경로를 하나씩 추적해서 4개를 찾았습니다.
2-1. 전체 배열에서 자기 찾기
각 오브젝트 컴포넌트가 이렇게 시작했습니다.
const currentObject = computed(() =>
objects.value.find((o) => o.id === props.objectId)
)
objects 배열 전체를 구독하는 코드입니다.
오브젝트가 하나 추가되어 배열이 새로 만들어지면, N개 컴포넌트 전부의 computed가 무효화됩니다.
수정: 부모가 :object-id 대신 :object="object"로 객체 자체를 prop으로 내려주고, 자식은 자기 객체만 구독합니다.
기존 오브젝트들은 객체 참조가 그대로 유지되므로(낙관적 추가 시 [...old, newItem] 패턴이라 기존 요소 참조 보존) props가 안 바뀌고, 리렌더도 없습니다.
| 렌더된 컴포넌트 | Vue 렌더 시간 | |
| 수정 전 | 2,061 | 287.6ms |
| 수정 후 | 2,061 | 64.5ms |
렌더 시간은 -78%인데 렌더 "대상 수"는 그대로죠. 아직 다른 경로가 남아 있다는 뜻입니다.
2-2. 번호표 prop
부모의 v-for가 각 오브젝트에 순번을 내려주고 있었습니다.
<ObjectItem :object-index="index + 1" ... />
목록 중간에 오브젝트가 하나 끼어들면 그 뒤 모든 오브젝트의 번호가 밀립니다.
props가 실제로 바뀌니 Vue 입장에선 정당한 리렌더입니다. 버그가 아니라 요구사항 대비 과한 반응성이죠.
그런데 이 번호가 화면에 언제 보이냐면 오브젝트를 선택하거나 호버했을 때만 보입니다.
항상 최신 번호를 유지할 필요가 없습니다.
수정: prop으로 내려주지 않고, 노출되는 순간에만 계산.
| 리렌더 컴포넌트 | Vue 렌더 | 합계(스타일·레이아웃 포함) | |
| 번호 prop 유지 | 2,061 | 51.3ms | 73.7ms |
| 번호 prop 제거 | 3 | 12.8ms | 14.5ms |
리렌더 대상이 2,061개 → 3개. 남은 12.8ms는 부모 v-for가 2,061개 vnode를 만들어 diff하는 비용인데,
이건 가상화 없이는 사라지지 않는 고정비라 수용했습니다.
2-3. z-index 순위표
겹친 오브젝트의 쌓임 순서를 id 오름차순 순위로 계산하는 Map이 있었습니다.
const zIndexMap = computed(() => {
const sorted = [...ids].sort((a, b) => a - b)
return new Map(sorted.map((id, i) => [id, 11 + i]))
})
문제는 낙관적 생성의 임시 id가 음수였다는 것.
임시 오브젝트가 정렬 맨 앞에 끼어들면서 기존 오브젝트 전원의 순위가 +1, 서버 확정으로 진짜 id를 받으면 전원 -1.
생성 1건마다 전체 z-index가 두 번 출렁였습니다.
게다가 이 값이 scoped style의 v-bind()로 바인딩되어 있어서,
값이 바뀔 때마다 CSS 커스텀 프로퍼티 갱신 → 서브트리 스타일 무효화까지 따라왔습니다.
수정: Map을 통째로 삭제했습니다. 오브젝트들은 이미 위치 정렬 순서로 DOM에 렌더되는 형제 요소들이라,
z-index 없이도 뒤에 오는 요소가 위에 쌓입니다. 선택된 오브젝트 등 몇 가지만 고정 상수를 줬습니다.
2-4. 드래그 시작할 때마다 수만 노드 스타일 재계산
드래그로 오브젝트를 그리는 동안 아래 요소들이 클릭을 가로채지 못하게,
컨테이너의 자식 전부에 인라인으로 pointer-events: none을 걸고 있었습니다.
pointer-events는 상속되는 속성이라, 조상에 인라인으로 걸면 하위 수만 DOM 노드 전체의 스타일 재계산이 일어납니다.
실측 54ms, 드래그 시작·종료마다 한 번씩.
수정: 드래그 중에만 화면 최상단에 투명 오버레이 한 장을 띄워 클릭 판정만 차단. 스타일 계산이 요소 1개로 끝납니다.
덤으로, 오브젝트마다 크기 감지용 ResizeObserver를 2개씩 만들어 총 4,122개가 돌던 것도 정리했습니다.
절반은 전부 같은 컨테이너를 감시하는 중복이라 1개로 통합, 나머지는 실제 노출될 때만 지연 생성.
| 단계 | 오브젝트 생성 1건 비용 (오브젝트 2,061개를 띄워둔 측정 환경) |
| 수정 전 | 364.9ms |
| + 객체 prop 전달 | 131.2ms |
| + z-index DOM 순서 위임 | 73.7ms |
| + 번호 prop 제거 | 14.5ms |
365ms → 14.5ms. 25배 빨라졌으니 끝났다고 생각했습니다.
3. 여전히 느리다
배포 전 검증 환경에서 CPU 4배 감속(저사양 사용자 재현)을 걸고 다시 재봤습니다. 클릭 후 1~2초. 뭐지?
트레이스를 떠서 CPU 샘플을 분석했더니, 1장에서 그렇게 파고들었던 오브젝트 컴포넌트들의 비용은 전체의 0.1%였습니다.
수정은 유효했습니다. 대신 태스크의 66%를 화면 한쪽에 붙어 있는 오브젝트 목록 사이드바가 먹고 있었습니다.
문서의 모든 오브젝트를 목록으로 보여주는 영역인데, 가상화 없이 700줄을 전량 렌더하고,
오브젝트가 하나 생길 때마다 그 700줄을 통째로 다시 계산하고 있었죠.
3-1. "한 번만 계산해 재사용한다"는 캐시가 매번 통째로 버려지고 있었다
목록 아이템은 "내용 없음" 배지를 띄우기 위해 오브젝트의 HTML이 비었는지 판정합니다.
그 판정이 무겁다는 건 이미 알고 있었고, 그래서 캐시가 있었습니다.
/**
* isHtmlEmpty 는 호출마다 HTML 을 풀 파싱하므로 오브젝트별로 한 번만 계산해 재사용한다.
*/
const emptyTextMap = computed(() => {
const map = new Map<number, boolean>()
for (const obj of objects.value) {
map.set(obj.id, isTextEmpty(obj.text))
}
return map
})
문제는 이 computed가 의존하는 게 objects 배열 전체라는 것.
오브젝트가 하나 추가되면 새 배열이 만들어지고, 캐시는 통째로 무효화되고, 루프가 처음부터 다시 돕니다.
700개 전부 재파싱. "오브젝트별로 한 번만 계산"이라고 적힌 코드가 실제로는 "변경 한 번마다 전량 재계산"이었습니다.
이것만으로 태스크의 15%.
캐시 키를 오브젝트 객체 참조로 바꿨습니다.
배열이 새로 만들어져도 기존 오브젝트 객체의 참조는 그대로니까요.
export const createEmptyTextJudge = (judge: (html: string) => boolean) => {
const cache = new WeakMap<TextHolder, boolean>()
return (holder: TextHolder, cacheable: boolean): boolean => {
if (!cacheable) return judge(holder.text) // 캐시 우회 게이트
if (cache.has(holder)) return cache.get(holder) as boolean
const result = judge(holder.text)
cache.set(holder, result)
return result
}
}
이제 배열이 새로 만들어져도 기존 699개는 캐시 히트, 새로 생긴 1개만 실제로 판정합니다.
WeakMap이라 오브젝트가 사라지면 캐시 항목도 같이 회수되고요.
cacheable 게이트는 실수 방지용입니다. 이 판정은 에디터 인스턴스가 준비되기 전에는 정확도가 떨어지는 fallback 경로를 타는데,
그 결과가 캐시에 들어가면 틀린 판정이 영구히 남습니다.
준비 전에는 캐시를 읽지도 쓰지도 않게 했습니다.
3-2. 빈칸인지 보려고 HTML 파서를 부르고 있었다
그다음. 그 "무거운 판정"의 정체는 에디터 라이브러리(tiptap)의 isHtmlEmpty였고, 내부에서 DOMParser로 HTML을 풀 파싱합니다. 목록에 배지 하나 띄우자고요.
그런데 캔버스 뷰어 쪽에는 이미 같은 판정을 하는 정규식 기반 경량 유틸이 있었습니다.
새로 만들 것도 없었죠. 문제는 "정말 같은 판정인가"입니다.
여기서 판정이 갈리면 배지가 잘못 뜹니다.
그래서 교체 전에 동일성 테스트부터 썼습니다.
실제 앱의 tiptap 확장 세트로 에디터를 만들어 두 판정을 나란히 비교하는 15케이스입니다.
it.each([
['빈 문단', '<p></p>'],
['br만 있는 문단', '<p><br></p>'],
[' 2개', '<p> </p>'],
['엔티티 <', '<p><</p>'],
['tiptap 실산출: dir 속성', '<p dir="ltr"></p>'],
['세로쓰기 span 래핑', '<p><span style="writing-mode: vertical-rl">縦</span></p>'],
['제로폭 문자', '<p></p>'],
// ...
])('%s: 두 판정이 동일하다', (_label, html) => {
expect(lightweightIsTextEmpty(html)).toBe(editor.isHtmlEmpty(html))
})
케이스를 상상으로 채우지 않고, tiptap이 실제로 뱉는 HTML 모양(dir 속성, text-align 스타일, 세로쓰기 span)에서 뽑았습니다.
전 케이스가 통과해서 교체했고, 이 테스트는 지우지 않고 회귀 가드로 남겼습니다.
나중에 둘 중 한쪽이 바뀌면 여기서 깨지니까요.
덤으로 판정에서 에디터 인스턴스 의존이 사라져 순수 함수가 됐습니다.
3-3. 목록 수백줄에 컴포넌트 경계가 없었다
가장 큰 조각은 따로 있었습니다. 사이드바의 v-for가 아이템 마크업을 인라인으로 들고 있었습니다.
컴포넌트 경계가 없다는 뜻이고, 그러면 부모가 한 번 다시 렌더될 때 수백줄이 전부 재평가됩니다.
아이템마다 번역 함수 t() 호출이 몇 개씩 섞여 있었는데, 그 재호출만 태스크의 37%였습니다.
Vue는 자식 컴포넌트의 props가 얕은 비교로 동일하면 patch를 건너뜁니다.
경계가 없으면 그 최적화가 발동할 자리조차 없습니다.
수정: 아이템을 PanelBoxItem.vue로 분리하고, props를 전부 원시값 아니면 안정 참조로 맞췄습니다.
매 아이템에서 부르던 t()는 부모에서 한 번만 계산해 내려줍니다.
// 아이템별 t() 호출 결과를 한 번만 계산해 안정 참조로 전달
const labels = computed(() => ({
superviseBox: t('text'),
translationBox: t('text'),
noTranslation: t('text'),
}))
그런데 여기서 하마터면 방금 만든 경계를 스스로 무력화할 뻔했습니다.
아이템 중 하나가 스크롤 위치를 알아야 했거든요.
스크롤 y를 그냥 prop으로 내리면?
스크롤 틱마다 700개 아이템의 props가 전부 바뀝니다.
경계를 만든 의미가 사라지고, 오히려 스크롤이 새 병목이 됩니다.
이 값이 의미 있는 건 단독 선택된 아이템 하나뿐이라, 거기에만 실제 값을 흘리고 나머지에는 상수를 넘겼습니다.
<PanelBoxItem
:labels="labels"
:panel-scroll-y="obj.id === soloSelectedId ? panelScrollY : 0"
...
/>
그리고 이건 눈으로 "빨라 보인다"로 끝낼 수 없는 종류의 수정이라, skip이 실제로 일어나는지를 단언하는 테스트를 붙였습니다. onVnodeUpdated 훅에 스파이를 걸고 patch 발화 여부를 직접 봅니다.
✓ 부모가 재렌더돼도 props가 동일한 형제 아이템은 patch를 건너뛴다
(onVnodeUpdated 미발화), 변경된 아이템만 업데이트된다
✓ panelScrollY는 solo 선택 아이템에만 전달되도록 게이트해야
스크롤마다 비선택 아이템이 skip된다
두 번째 테스트가 특히 중요합니다. 게이트를 나중에 누가 "이거 왜 삼항이지?" 하고 풀어버리면,
성능만 조용히 죽고 화면은 멀쩡해 보이거든요. 그 함정에 이름표를 달아둔 셈입니다.
| 조각 | 무엇이 문제였나 | 조치 |
| 빈 텍스트 캐시 | 캐시 키가 배열 → 생성마다 캐시 전량 폐기 → 700개 재파싱 | WeakMap(객체 참조 키) |
| 판정 자체 | 배지 하나에 DOMParser 풀 파싱 | 기존 경량 유틸 재사용 + 15케이스 동일성 테스트 |
| 목록 렌더 | 컴포넌트 경계 없음 → 700줄 전량 재평가 + t() 재호출 | 아이템 분리 + 안정 props + 스크롤 prop 게이트 |
그런데 사이드바를 닫고 재도 1~2초가 걸렸습니다...
4. 아무도 안 쓰는 CSS 한 블록
사이드바를 닫은 상태의 트레이스를 다시 분석했습니다. 클릭 태스크 1,781ms의 내역:
- 렌더링? 오브젝트 리렌더 흔적 없음. Layout 35ms, 스타일 재계산 16ms. 깨끗함.
- 대신 정체불명의 함수 호출이 있었습니다: setVars — 2,904번 호출, 합계 1,472ms (태스크의 83%)
setVars는 Vue가 scoped style의 v-bind()를 처리하는 내부 함수입니다. Vue 3.5 소스의 useCssVars를 열어 보면:
onMounted(() => {
watch(setVars, NOOP, { flush: 'post' })
const ob = new MutationObserver(setVars)
ob.observe(instance.subTree.el.parentNode, { childList: true }) // !!
onUnmounted(() => ob.disconnect())
})
<style>에 v-bind()를 하나라도 쓴 컴포넌트는, 마운트될 때 자기 부모 노드에 MutationObserver를 등록합니다.
형제 요소가 추가되거나 삭제되면 setVars를 다시 실행하는 감시자입니다.
오브젝트 수천개는 전부 같은 컨테이너가 부모입니다. 그러면:
- 오브젝트 1개 생성 → DOM에 노드 1개 삽입
- 형제 오브젝트 전원의 MutationObserver가 발화
- 각자 setVars 실행 — 이 함수는 내부에서 document.querySelectorAll('[data-v-owner="..."]')로 문서 전체를 스캔합니다
- 서버 확정 시 임시 노드가 교체되면서 한 번 더 전원 발화
앞에서 Vue 반응성 구독은 다 끊었지만, DOM 감시라는 숨은 구독 채널이 하나 남아 있던 겁니다.
그런데 그 v-bind, 누가 쓰고 있었나
오브젝트 컴포넌트에 남은 v-bind()는 딱 하나였습니다.
&__a {
&__b {
height: v-bind('`${currentObject?.size?.height ?? 0}px`');
}
}
이 셀렉터를 레포 전체에서 검색해 봤습니다. 템플릿에서 이 클래스를 쓰는 곳이 없었습니다.
예전 기능 개편 때 마크업은 다른 컴포넌트로 갔는데 스타일 블록만 남은, 죽은 CSS였습니다.
v-bind()는 셀렉터가 실제로 매칭되는지와 무관하게, 존재하는 것만으로 useCssVars를 주입합니다.
아무 픽셀도 그리지 않는 죽은 스타일 한 블록이, 수백~수천개 인스턴스에 MutationObserver를 달고 오브젝트를
만들 때마다 문서 전체 스캔을 수백~수천 번 돌리고 있었던 거죠.
수정:
- 죽은 스타일 블록은 삭제. 이것만으로 오브젝트 본체의 useCssVars가 사라집니다.
- 실제로 쓰이던 나머지 v-bind() 몇 개는 이렇게 교체했습니다:
<!-- before: scoped style에서 height: v-bind(contentHeight) -->
<!-- after: 루트에서 커스텀 프로퍼티를 인라인으로 공급 -->
<div :style="{ '--content-height': contentHeight }">
height: var(--content-height);
useCssVars가 하던 일(루트에 커스텀 프로퍼티 주입)을 선언적 :style로 직접 하는 겁니다.
상속·캐스케이드 동작은 동일하고, MutationObserver와 문서 스캔만 사라집니다.
수정 후 빌드 산출물에서 해당 청크의 useCssVars가 0건이 된 것까지 확인했습니다.
| 최초 상태 | 배열 전량 구독 + DOMParser + CSS v-bind | - | 1,000ms+ |
| 1차 최적화 | 반응성 구독 끊기 + 번호/z-index 정돈 | 객체 prop 전달, 지연 계산 | 73.7ms |
| 2차 최적화 | 사이드바 캐시 무효화 + HTML 판정 교체 | WeakMap 캐시 + pure regex 유틸 | 14.5ms |
| 3차 최적화 | 죽은 CSS v-bind()의 MutationObserver | 스타일 삭제 및 inline CSS 변수 교체 | 14.5ms (CPU 4x 감속 환경에서도 방어) |
이번 작업은 모니터링 지표를 바로잡고, 프레임워크와 CSS가 몰래 돌리고 있던 구독 채널을 하나씩 끊어낸 과정이었습니다.
라이브러리가 알아서 해줄 거라 믿었던 편의 기능(v-bind(), isHtmlEmpty)들이 수천 개 노드 환경에서는
어떻게 부메랑처럼 돌아오는지 호되게 경험했습니다