아래 영상과 같은 형광펜 기능을 구현했다. 그 과정을 하나씩 풀어보려 한다.
사실 처음에는 "그냥 드래그한 텍스트에 배경색 넣으면 되는 거 아냐?"라고 생각했었다. 하지만 막상 구현해보니 생각보다 고려해야 할 게 많았다. 페이지를 새로고침해도 같은 위치에 형광펜이 표시되어야 하고, 여러 줄에 걸친 선택도 처리해야 했다. 그러면서 브라우저의 Selection API와 Range 객체를 제대로 들여다보게 됐다.
형광펜을 추가한다.
드래그가 끝나면 툴바 띄우기
- 먼저 사용자가 텍스트를 드래그하고 마우스를 떼는 순간을 감지해야 한다. 이건
mouseup이벤트로 간단히 처리할 수 있다.
const handlePointerOrClick = (e: PointerEvent | MouseEvent) => {
const selection = window.getSelection();
// selection이 존재하고, 비어있지 않으며, range가 1개 이상일 때 툴바를 띄운다.
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
openToolbarFromSelection(selection);
return;
}
};
useEffect(() => {
document.addEventListener('mouseup', handlePointerOrClick);
return () => {
document.removeEventListener('mouseup', handlePointerOrClick);
};
}, [handlePointerOrClick]);
여기서 window.getSelection()이 핵심인데, 이 API가 현재 사용자가 선택한 텍스트 정보를 담고 있다. isCollapsed는 커서만 깜빡이고 실제로 선택된 게 없을 때 true가 되고, rangeCount는 선택 영역의 개수를 나타낸다. 보통은 1개지만 Firefox에서는 Cmd/Ctrl 키를 누르고 여러 곳을 선택하면 여러 개가 될 수도 있다.
- 그 다음 툴바를 선택 영역 근처에 띄워야 한다. 이 부분이 생각보다 까다로웠는데, PC와 모바일에서 동작이 달라서이다.
const openToolbarFromSelection = (selection: Selection) => {
const range = selection.getRangeAt(0);
if (!isInSelectionTarget(range)) return;
const rect = range.getBoundingClientRect();
onShow({
position: {
x: rect.left + rect.width / 2,
y: isPC ? rect.top : rect.bottom + TOOLBAR_HEIGHT,
},
});
activeHighlightIdRef.current = null;
activeSelectionRangeRef.current = range;
};
getRangeAt(0)으로 첫 번째 Range 객체를 가져온다. Range는 선택된 영역의 시작과 끝 정보를 담고 있는 객체다. 그리고 getBoundingClientRect()로 화면상에서 선택 영역이 어디 있는지 좌표를 구한다.
모바일에서 툴바를 위가 아닌 아래에 배치한 이유는, 위에 두면 브라우저 기본 선택 툴바와 겹치기 때문이다. 실제로 처음에는 PC처럼 위에 뒀다가 사용자 테스트에서 "툴바가 안 보여요"라는 피드백을 받고 수정했다.
- 툴바 위치는 상태로 관리한다. 기본값은
{x: 0, y: 0}이고, 선택이 해제되면 다시 기본값으로 돌아간다.
const DEFAULT_POSITION: Position = { x: 0, y: 0 };
const [position, setPosition] = useState<Position>(DEFAULT_POSITION);
const showToolbar = ({ position }) => {
setPosition(position);
};
const hideToolbar = () => {
setPosition(DEFAULT_POSITION);
};
const { activeSelectionRange, activeHighlightId } =
useFloatingToolbarSelection({
onShow: showToolbar,
onHide: hideToolbar,
});
형광펜 버튼 클릭하면 실제로 형광펜 그리기
- 툴바에는 형광펜 색상 버튼들이 있고, 하나를 클릭하면 선택한 텍스트에 해당 색상의 형광펜이 그려진다.
return (
<FloatingToolbar
opened={toolbarOpened}
position={position}
onHighlightButtonClick={handleHighlightClick}
onMemoButtonClick={handleMemoClick}
/>
);
- 버튼을 클릭했을 때의 처리 로직은 이렇게 생겼다.
const handleHighlightClick = (range: Range | null) => {
const highlightData = saveSelection(range);
hideToolbar();
};
여기서 saveSelection이 진짜 핵심이다. 단순히 지금 화면에만 형광펜을 그리는 게 아니라, 페이지를 나갔다가 다시 돌아와도 같은 위치에 형광펜이 나타나야 하니까 서버에 저장할 데이터를 만들어야 한다.
Range 객체를 콘솔에 찍어보면 이런 정보들이 들어있다.
startContainer, endContainer는 선택 영역의 시작과 끝에 해당하는 DOM 노드고, startOffset, endOffset은 그 노드 안에서 몇 번째 문자부터/까지인지를 나타낸다. 이 정보만으로도 현재 세션에서는 형광펜을 그릴 수 있지만, 페이지를 새로고침하면? DOM 노드 객체는 사라지고 없다. 그래서 이걸 문자열 형태로 저장해야 한다.
Range를 저장 가능한 형태로 변환하기
그래서 XPath를 쓰기로 했다. XPath는 XML/HTML 문서에서 특정 요소를 찾기 위한 경로 표현식이다. 예를 들어 /html/body/div[1]/p[2] 같은 형태로, "루트에서 시작해서 body의 첫 번째 div, 그 안의 두 번째 p 태그"를 나타낸다.
export const saveSelection = (range: Range) => {
const container =
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentElement!
: (range.commonAncestorContainer as Element);
const xpath = getXPathForNode(container);
const offsets = getHighlightOffsets(container, range);
return {
location: {
startXPath: xpath,
startOffset: offsets.start,
endXPath: xpath,
endOffset: offsets.end,
},
text: range.toString(),
};
};
commonAncestorContainer는 선택 영역의 시작과 끝을 모두 포함하는 가장 가까운 공통 조상 노드다. 만약 이게 텍스트 노드라면 부모 요소를 사용한다. 텍스트 노드는 XPath로 표현할 수 없기 때문이다.
- XPath를 구하는 과정은 재귀적으로 동작한다. 현재 노드에서 시작해서 부모로, 또 그 부모로 계속 올라가면서 경로를 만든다.
const ROOT_PATH = '.';
export const getXPathForNode = (node: Node, root: Node = document): string => {
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentNode!;
}
if (node === root) return ROOT_PATH;
const index =
Array.from(node.parentNode!.childNodes)
.filter((n) => n.nodeName === node.nodeName)
.indexOf(node as ChildNode) + 1;
return (
getXPathForNode(node.parentNode!, root) +
'/' +
node.nodeName.toLowerCase() +
`[${index}]`
);
};
처음엔 왜 이렇게 복잡한가 싶었는데, 막상 구현해보니 다 이유가 있었다. 같은 이름의 형제 노드가 여러 개 있을 수 있으니까 인덱스로 구분해야 하고, 1-based 인덱스를 쓰는 이유는 XPath 표준이 그렇게 정의되어 있기 때문이다.
- 이제 Offset을 구해보자. XPath가 "어느 요소 안에 있다"는 정보라면, Offset은 "그 요소 안에서 정확히 몇 번째 글자부터 몇 번째까지"라는 정보다.
export const getHighlightOffsets = (container: Element, range: Range) => {
let start = -1;
let end = -1;
let currentOffset = 0;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode as Text;
if (node === range.startContainer)
start = currentOffset + range.startOffset;
if (node === range.endContainer)
end = currentOffset + range.endOffset;
currentOffset += node.textContent!.length;
}
return { start, end };
};
TreeWalker는 DOM 트리를 순회하는 API인데, 여기서는 텍스트 노드만 보도록 필터를 걸었다. 왜냐하면 우리가 관심 있는 건 실제 글자니까. 각 텍스트 노드를 지나가면서 길이를 누적하다가, Range의 시작/끝 컨테이너를 만나면 그때까지의 누적값에 해당 노드 내 오프셋을 더해서 전체 오프셋을 계산한다.
예를 들어 이런 HTML이 있다고 해보자.
<p>안녕<strong>하세요</strong> 반갑습니다</p>
"하세요 반"을 선택했다면, "안녕"(2글자) + "하세요"의 0번째부터가 시작 오프셋이 되고, "안녕"(2글자) + "하세요"(3글자) + " 반"(2글자)이 끝 오프셋이 된다.
이렇게 구한 XPath와 Offset 정보를 서버에 저장하면, 나중에 다시 불러와서 같은 위치에 형광펜을 복원할 수 있다.
저장된 형광펜 다시 그리기
- 페이지를 로드할 때 서버에서 형광펜 데이터를 가져온다.
const { highlights, isHighlightLoaded } = useHighlights({ articleId });
useEffect(() => {
if (!articleContent) return;
if (isHighlightLoaded) restoreHighlightAll(highlights);
}, [articleContent, isHighlightLoaded]);
restoreHighlightAll은 받아온 형광펜 배열을 하나씩 순회하면서 DOM에 실제로 그려준다.
export const restoreHighlightAll = (highlights: Highlight[]) => {
highlights.forEach((highlight) => {
try {
addHighlightToDOM(highlight);
} catch (error) {
logger.error(error);
}
});
};
- 각 형광펜을 복원하는 과정은 저장할 때의 역순이다. XPath로 요소를 찾고, Offset으로 Range를 만들고, 그 Range에
<mark>태그를 씌운다.
export const addHighlightToDOM = (data: Highlight) => {
const element = getNodeByXPath(data.location.startXPath);
if (!element) return;
const range = getHighlightRange(
element,
Number(data.location.startOffset),
Number(data.location.endOffset),
);
const highlightId = data.id;
const textNodes = getTextNodesInRange(range);
textNodes.forEach((node, index) => {
const isFirst = index === 0;
const isLast = index === textNodes.length - 1;
let start = 0;
let end = node.textContent!.length;
if (isFirst) start = range.startOffset;
if (isLast) end = range.endOffset;
if (start < end) {
highlightNodeSegment(node, start, end, data.color, highlightId);
}
});
};
형광펜이 여러 텍스트 노드에 걸쳐 있을 수 있어서, 각 노드마다 처리해줘야 한다. 첫 번째 노드는 중간부터 시작할 수 있고, 마지막 노드는 중간까지만 형광펜이 칠해질 수 있으니 그것도 고려한다.
highlightNodeSegment함수는 실제로 텍스트 노드의 일부를<mark>태그로 감싸는 역할을 한다. 이 과정에서data-highlight-id속성을 추가해서 나중에 같은 형광펜에 속한 mark 태그들을 찾을 수 있게 한다.
export const highlightNodeSegment = (
node: Text,
start: number,
end: number,
color: string,
highlightId: number,
) => {
const parent = node.parentNode!;
const before = node.textContent!.slice(0, start);
const middle = node.textContent!.slice(start, end);
const after = node.textContent!.slice(end);
const mark = document.createElement('mark');
mark.style.backgroundColor = color;
mark.dataset.highlightId = highlightId.toString();
mark.textContent = middle;
const frag = document.createDocumentFragment();
if (before) frag.appendChild(document.createTextNode(before));
frag.appendChild(mark);
if (after) frag.appendChild(document.createTextNode(after));
parent.replaceChild(frag, node);
};
이 함수의 동작 방식을 예시로 설명해보겠다. "안녕하세요 반갑습니다"라는 텍스트가 있고, "하세요 반"(인덱스 2~8)을 선택했다고 해보자.
먼저 텍스트를 세 부분으로 나눈다:
- `before`: 형광펜이 시작되기 전 ("안녕")
- `middle`: 실제로 형광펜이 칠해질 부분 ("하세요 반")
- `after`: 형광펜이 끝난 후 ("갑습니다")
그 다음 <mark> 요소를 만들어서 중간 부분에 해당하는 텍스트를 넣고, 배경색과 고유 ID를 설정한다. 이 ID가 나중에 같은 형광펜에 속한 여러 조각들을 찾을 때 사용된다.
여기서 DocumentFragment를 쓴 이유가 중요한데, DOM에 직접 노드를 하나씩 추가하면 매번 리플로우가 발생해서 성능이 안 좋다. Fragment에 먼저 모아뒀다가 한 번에 추가하면 리플로우가 한 번만 일어난다. 형광펜이 수십 개씩 복원될 때 이 차이가 체감된다.
- 마지막으로
replaceChild로 기존 텍스트 노드를 새로 만든 Fragment로 교체한다. 결과적으로 이렇게 변한다:
<!-- 변경 전 -->
안녕하세요 반갑습니다
<!-- 변경 후 -->
안녕<mark data-highlight-id="123" style="background-color: yellow;">하세요 반</mark>갑습니다
신경 써야 할 것들
형광펜을 칠할 수 있는 영역 제한하기
모든 텍스트에 형광펜을 칠할 수 있으면 안 된다. 예를 들어 헤더나 사이드바 같은 곳은 제외해야 한다. 그래서 isInSelectionTarget 함수로 선택 영역이 허용된 범위 안에 있는지 체크한다.
const contentRef = useRef<HTMLDivElement>(null);
const { activeSelectionRange, activeHighlightId } =
useFloatingToolbarSelection({
isInSelectionTarget: (range) =>
contentRef.current?.contains(range.commonAncestorContainer) ?? false,
onShow: showToolbar,
onHide: hideToolbar,
});
return (
<ArticleContent
ref={contentRef}
newsletterName={newsletterName}
content={articleContent}
/>
);
contentRef로 실제 본문 영역을 참조하고, contains 메서드로 선택 영역이 그 안에 있는지 확인한다. 밖에서 드래그하면 툴바가 안 뜬다.
형광펜에 마우스를 올렸을 때 효과 주기
형광펜이 여러 줄에 걸쳐 있으면 여러 개의 <mark> 태그로 쪼개진다. 그런데 사용자 입장에서는 하나의 형광펜처럼 보여야 하니까, 한 조각에 마우스를 올리면 같은 ID를 가진 모든 조각에 hover 효과를 준다.
export const useHighlightHoverEffect = () => {
useEffect(() => {
const handleMouseOver = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'MARK' && target.dataset.highlightId) {
const id = target.dataset.highlightId;
document
.querySelectorAll(`mark[data-highlight-id="${id}"]`)
.forEach((el) => el.classList.add('hovered-highlight'));
}
};
const handleMouseOut = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'MARK' && target.dataset.highlightId) {
const id = target.dataset.highlightId;
document
.querySelectorAll(`mark[data-highlight-id="${id}"]`)
.forEach((el) => el.classList.remove('hovered-highlight'));
}
};
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('mouseout', handleMouseOut);
return () => {
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('mouseout', handleMouseOut);
};
}, []);
};
이벤트 위임 패턴을 써서 document 레벨에서 이벤트를 잡았다. 형광펜이 동적으로 추가/삭제되는데 매번 이벤트 리스너를 붙였다 뗐다 하는 것보다 효율적이다.
모바일 지원
PC에서는 mouseup 이벤트만 써도 되지만, 모바일에서는 터치 이벤트도 고려해야 한다. 다행히 pointerup 이벤트를 쓰면 마우스와 터치를 한 번에 처리할 수 있다.
const handlePointerOrClick = (e: PointerEvent | MouseEvent) => {
// ... 기존 로직
};
useEffect(() => {
document.addEventListener('mouseup', handlePointerOrClick);
document.addEventListener('pointerup', handlePointerOrClick); // 모바일 지원
return () => {
document.removeEventListener('mouseup', handlePointerOrClick);
document.removeEventListener('pointerup', handlePointerOrClick);
};
}, [handlePointerOrClick]);
모바일에서는 앞서 언급했듯이 툴바 위치도 다르게 처리한다. 그리고 텍스트 선택 시 확대되는 iOS의 기본 동작도 고려해야 하는데, 이건 CSS의 -webkit-user-select와 user-select 속성으로 어느 정도 제어할 수 있다.
트러블 슈팅
1. 형광펜이 중첩될 때
사용자가 이미 형광펜이 칠해진 텍스트를 다시 선택해서 다른 색으로 칠하려고 하면 어떻게 될까? 처음에는 <mark> 태그 안에 또 <mark> 태그가 들어가는 상황이 발생했다.
<mark data-highlight-id="1">
이미 <mark data-highlight-id="2">칠해진</mark> 텍스트
</mark>
이렇게 되면 나중에 형광펜을 삭제할 때도 문제가 생긴다. 그래서 새 형광펜을 추가하기 전에 기존 형광펜을 제거하는 로직을 추가했다.
const removeOverlappingHighlights = (range: Range) => {
const marks = range.cloneContents().querySelectorAll('mark[data-highlight-id]');
marks.forEach(mark => {
const id = mark.getAttribute('data-highlight-id');
if (id) removeHighlight(id);
});
};
2. 페이지 구조가 바뀌면 XPath가 틀어진다
기사 내용이 업데이트되어서 문단이 추가되거나 삭제되면, 기존에 저장한 XPath가 엉뚱한 곳을 가리킬 수 있다. 이건 근본적으로 해결하기 어려운 문제다. 그래서 복원 시 에러가 나면 조용히 넘어가도록 try-catch를 썼다.
다행히 우리는 형광펜을 저장할 때 XPath와 offset뿐 아니라 선택된 원본 텍스트(range.toString())도 함께 저장해두고 있었다. 그래서 fallback의 방향은 이렇게 잡았다. XPath로 노드를 찾았는데 그 위치의 텍스트가 저장해둔 text와 다르면, XPath를 믿지 않고 본문 전체에서 저장한 text를 다시 검색하는 것이다. 대략 이런 흐름이다.
const restoreByText = (data: Highlight) => {
const root = document.querySelector('.article-content');
if (!root) return null;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const fullText = root.textContent ?? '';
const index = fullText.indexOf(data.text);
if (index === -1) return null; // 본문에서 사라진 텍스트면 포기
// index부터 data.text.length만큼을 감싸는 Range를 다시 만들어 복원
return buildRangeFromTextOffset(walker, index, data.text.length);
};
물론 같은 문장이 본문에 여러 번 나오면 엉뚱한 곳에 칠할 수 있다는 한계는 있다. 그래서 XPath를 1차로 쓰되, 어긋났을 때만 text 재탐색을 2차 안전망으로 두는 식이 현실적이다. 완벽하진 않아도 ‘구조가 살짝 바뀌었다고 형광펜이 통째로 날아가는’ 최악은 막을 수 있다.
3. Range가 비정상적인 경우
가끔 range.startOffset이 range.endOffset보다 큰 경우가 있었다. 사용자가 오른쪽에서 왼쪽으로 드래그하면 Range의 방향이 뒤집힐 수 있다는 걸 몰랐다. 이 경우 시작과 끝을 바꿔줘야 한다.
const normalizeRange = (range: Range): Range => {
if (range.startOffset > range.endOffset) {
const newRange = document.createRange();
newRange.setStart(range.endContainer, range.endOffset);
newRange.setEnd(range.startContainer, range.startOffset);
return newRange;
}
return range;
};
4. 툴바 버튼을 클릭하면 선택 영역이 사라진다
형광펜 기능을 처음 구현했을 때 정말 황당한 버그를 만났다. 사용자가 텍스트를 드래그하면 툴바가 뜨고, 형광펜 버튼을 클릭하면 형광펜이 안 그려지는 거다. 디버깅을 해보니 버튼을 클릭하는 순간 selection이 null이 되거나 rangeCount가 0으로 변해버렸다.
처음엔 내 코드가 잘못된 줄 알았는데, 알고 보니 브라우저의 기본 동작 때문이었다. 사용자가 다른 곳을 클릭하면 브라우저는 자동으로 기존 선택을 해제한다. 툴바 버튼도 "다른 곳"이니까 클릭하는 순간 선택이 날아가는 거였다.
해결 방법은 의외로 간단했다.
const handlePointerDownOnToolbar = (e: PointerEvent) => {
e.preventDefault();
// 브라우저의 기본 동작(선택 해제)을 막는다
};
return (
<FloatingToolbar
onPointerDown={handlePointerDownOnToolbar}
// ... 나머지 props
/>
);
preventDefault()로 브라우저의 기본 동작을 막으면, 툴바를 클릭해도 기존 선택이 유지된다. 처음엔 onClick에 preventDefault를 걸었다가 안 되길래 당황했는데, 선택이 해제되는 건 pointerdown 시점이어서 onPointerDown에 걸어야 했다.
5. 형광펜을 추가하거나 삭제할 때마다 화면이 깜빡인다
이건 정말 짜증나는 버그였다. 형광펜을 하나 추가하면 전체 기사 컴포넌트가 리렌더링되면서 화면이 순간적으로 깜빡이는 거다. 사용자 입장에서는 "형광펜 하나 그었을 뿐인데 왜 화면이 떨리지?"라고 느낄 수밖에 없다.
원인을 찾아보니 부모 컴포넌트에서 형광펜 상태가 업데이트될 때마다 기사 본문 컴포넌트가 불필요하게 리렌더링되고 있었다. 기사 본문 자체는 변하지 않았는데도 말이다.
좀 더 근본을 파고들면, 형광펜 상태와 기사 본문이 같은 부모 컴포넌트에 묶여 있던 게 진짜 원인이었다. 형광펜을 하나 그으면 부모의 형광펜 상태가 바뀌고, 그 부모가 리렌더링되면서 아무 상관 없는 기사 본문까지 딸려서 다시 그려진 것이다.
React.memo로 감싸는 것만으로도 문제가 해결됐다.
const ArticleContent = React.memo(({ content, newsletterName }) => {
return (
<div
className="article-content"
dangerouslySetInnerHTML={{ __html: content }}
/>
);
});
React.memo는 컴포넌트의 props가 변하지 않으면 리렌더링을 건너뛴다. 기사 본문(content)과 뉴스레터 이름(newsletterName)은 한 번 로드되면 거의 안 바뀌니까, 형광펜 상태가 업데이트되어도 이 컴포넌트는 리렌더링될 필요가 없는 거다.
다만 React.memo는 딸려 그려지는 걸 props 비교로 끊어주는 대증 요법에 가깝다. 더 구조적인 해법은 형광펜 상태를 본문과 분리해서 애초에 리렌더링 경로에 얹히지 않게 만드는 것이다. 이건 다음에 손볼 생각이다.
성능 최적화는 나중에 하자고 생각했었는데, 깜빡임은 사용자가 바로 느끼는 문제라 미룰 수가 없었다.
맺으며
혼자 힘으로는 절대 구현할 수 없었을 것이다. AI를 적극적으로 활용한 덕분에 시간이 정말 오래 걸리거나 어쩌면 구현하지 못했을 기능을 만들 수 있었다. AI가 작성한 코드가 90%라고 해도 과언이 아닐 정도였다. 나는 AI가 작성한 코드를 리팩토링하고 구조를 재설계하며 발생하는 오류들을 해결했다. 이를 위해서는 AI가 작성한 코드를 이해하는 것이 필수였다. 어떤 원리로 기능이 동작하는지 알아야 문제의 원인을 파악해 해결하고, 적절하게 코드를 설계할 수 있기 때문이다.
AI가 이렇게 잘 작성해줘서 두렵지 않냐고 묻는다면 그건 결코 아니다. 혼자서 했더라면 며칠에서 몇 주 동안 고민해야 할 기능을 AI 덕분에 하루 만에 만들어낼 수 있었기 때문이다. 그리고 AI는 처음부터 완벽하게 짜주지 않는다. 아니, 짜주지 못한다. 내가 반드시 검수하고, 코드를 구조화하고, 오류를 해결해야 한다. AI와 호흡을 맞춰가며 개발하면 되는 것이다.
여전히 개선해야 할 것들이 많다. 형광펜에 메모를 달 수 있게 하거나, 여러 형광펜을 하나로 합치거나 나누는 기능 같은 것들 말이다. 그리고 수백 개의 형광펜이 만들어졌을 때 페이지 로딩이 느려질 수 있으니 성능 최적화도 필요하다. 이건 확실히 AI보다 내가 더 잘하는 영역이기도 하다.
이번 구현을 돌아보며 스스로 정리한 핵심은 세 가지다. 첫째, XPath는 편하지만 문서 구조가 바뀌면 쉽게 틀어지니 저장한 원본 텍스트로 재탐색하는 안전망을 반드시 함께 둬야 한다. 둘째, 여러 텍스트 노드에 걸친 복원은 DocumentFragment로 한 번에 DOM에 반영해 리플로우를 줄이는 게 체감 성능을 크게 좌우한다. 셋째, 형광펜이 수백 개로 늘어나는 상황을 염두에 두면 상태를 본문과 분리해 불필요한 리렌더링을 막는 구조가 결국 성능의 관건이 된다.