봄봄은 뉴스레터를 모아 읽고 습관을 만드는 서비스입니다.
습관은 '읽기'만으로는 형성되지 않습니다. 저장하고, 활용하는 과정이 필요합니다. 그래서 본문 중 원하는 부분을 저장하는 하이라이트, 그 위에 생각을 남기는 메모를 만들었습니다. 이 두 기능은 사용자가 중요한 문장을 다시 찾고, 새롭게 알게 된 내용을 활용할 수 있도록 돕습니다.
이러한 목표를 구현하기 위해, 저는 브라우저의 Selection/Range API와 앵커(Anchor) 저장 전략을 조합하여 하이라이트를 설계했습니다. 아래에서 앵커 저장 전략의 선택 배경과 하이라이트 구현 과정을 단계별로 소개하겠습니다.
하이라이트는 결국 브라우저에서 “사용자가 드래그한 구간”을 안전하게 읽고, 나중에 복원하는 일입니다. 여기서 핵심은 Selection과 Range입니다.
1) Selection — “현재 선택 상태”를 나타내는 객체
획득 방법
const selection = window.getSelection();
if (!selection) return; // 브라우저 지원 및 안전 체크
핵심 속성·메서드
rangeCount — 포함된 Range 개수(일반적으로 0 또는 1)isCollapsed — 드래그 상태가 접혀 있는지 여부getRangeAt(index) — Range 객체 가져오기(보통 getRangeAt(0))removeAllRanges() / addRange(range) — 선택 영역 초기화/적용2) Range — “선택 구간을 좌표로 표현하는” 객체
획득 방법
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
핵심 속성·메서드(실무 필수)
startContainer / startOffset — 시작 지점(노드, 노드 내 문자 오프셋)endContainer / endOffset — 끝 지점(노드, 노드 내 문자 오프셋)toString() — 범위의 텍스트 내용 추출(quote 저장 등에 활용)getBoundingClientRect() — 범위를 감싸는 단일 박스(툴팁 중앙 배치 등)getClientRects() — 줄바꿈 등으로 나뉜 다중 라인 박스 목록intersectsNode(node) — 특정 노드와의 교차 여부 확인(겹침 처리에 유용)commonAncestorContainer — 시작/끝이 속한 최하위 공통 조상 노드parentNode로 올려 사용 권장요약: Selection으로 “지금 선택된 상태”를 얻고, Range로 그 선택의 “정확한 좌표”를 다룹니다. 이 좌표를 안정적으로 저장·복원하는 것이 하이라이트 구현의 핵심입니다.
이제 선택된 Range를 “다시 열어도 같은 자리”로 복원하려면, 그 위치를 식별할 앵커(Anchor)가 필요합니다.
앵커를 어떻게 설계하느냐에 따라 내구성과 정확도가 크게 달라지는데요. 흔히 쓰는 세 가지 방식을 먼저 비교한 뒤, 봄봄에서 왜 특정 조합을 채택했는지를 설명하겠습니다.
1) 텍스트 기반
quote + 앞뒤 prefix/suffix로 위치 추정2) 구조 기반(요소 단위)
3) 구조 기반(노드·텍스트 정밀)
text()[1] 등)이 세 가지 방법의 저장 형태를 간단한 예시 코드로 설명드리겠습니다.
<main class="article-body">
<h1>오늘의 뉴스레터</h1>
<p>아침 루틴을 만들기 위한 작은 팁들.</p>
<p>데이터는 습관을 만든다. 반복은 이해를 낳는다.</p>
<p>마지막으로 추천 아티클을 소개합니다.</p>
</main>
위와 같은 html이 있을 때, 두 번째 <p>의 “데이터는 습관을 만든다” 구절을 하이라이트 한다고 가정해봅시다.
{
"quote": "데이터는 습관을 만든다",
"prefix": "",
"suffix": " 반복은 이해를 낳는다."
}
{
"selector": ".article-body > p:nth-of-type(2)",
"quote": "데이터는 습관을 만든다",
"offsetInElement": { "start": 0, "end": 13 } // 옵션: 요소 내부 오프셋
}
{
"startXPath": "/html/body/main/p[2]/text()[1]",
"startOffset": 0,
"endXPath": "/html/body/main/p[2]/text()[1]",
"endOffset": 13,
}
봄봄의 뉴스레터는 사용자에게 발송된 뒤 내용/형식이 변경되지 않는 특성이 있습니다. 즉, 서비스 관점에서 문서 구조가 고정됩니다. 이 전제 덕분에 XPath의 약점(구조 변동 민감도)이 크게 문제가 되지 않고, 대신 아래 장점을 최대화할 수 있습니다.
…/p[2]/text()[1]처럼 텍스트 노드를 직접 가리켜 startOffset/endOffset을 안정적으로 적용할 수 있습니다.하이라이트의 사용자 흐름은 크게 네 단계입니다.
각 단계를 구체적으로 살펴봅시다.
하이라이트 UX의 첫 단추는 “언제 툴바를 띄울지” 를 정확히 판단하는 것입니다. 핵심은 두 가지입니다.
A. 플랫폼별 이벤트 흐름 파악하기
플랫폼마다, 그리고 단순 클릭과 꾹 눌러 드래그(텍스트 선택) 시의 이벤트 흐름이 다릅니다. 이를 명확히 분리해 처리해야 잘못된 동작을 방지할 수 있습니다.
1) PC 웹(브라우저)
pointerdown → mousedown → pointerup → mouseup → click2) iOS
pointerdown → touchstart → pointerup → touchend → mousedown → mouseup → clickpointerdown → touchstart → pointerup → touchend3) Android
pointerdown → touchstart → contextmenu(드래그 시작) → pointercancel → touchcancel → contextmenu(드래그 끝)Android는 드래그 시작/끝 모두 contextmenu 가 발생합니다.
즉, 하나의 이벤트로 선택 완료 시점을 감지하게 되는 셈이죠.
B. 클릭 vs 드래그 판별하기
Selection API만으로도 대부분 판별이 가능합니다.
selection.rangeCount > 0 그리고selection.isCollapsed === false → 드래그(텍스트 선택) 상태const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
// ✅ 드래그(선택) 상태 로직
// openToolbarFromSelection(selection);
return;
}
// ⛔ 선택 없음 → 단순 클릭 또는 선택 해제
Android의 특수성
위 방식은 PC와 iOS에서 잘 작동하지만, Android는 예외입니다.
Android는 드래그 완료 시contextmenu이벤트가 발생하는 독특한 구조를 가지고 있습니다. 이로 인해 하나의 이벤트 핸들러로 클릭과 드래그를 함께 처리하기 어렵습니다.
따라서 Android에서는 이벤트 레벨에서 역할을 분리합니다:
contextmenu→ 선택 완료(드래그) 처리click→ 기존 하이라이트 클릭 처리
C. 최종 구현 (핵심 코드)
위 분석을 바탕으로, 플랫폼별로 서로 다른 이벤트 핸들러 조합이 필요합니다:
| 플랫폼 | 전략 | 사용 이벤트 |
|---|---|---|
| iOS | 하이라이트 클릭 + 드래그 선택을 하나의 핸들러로 통합 | pointerup |
| Android | 드래그 선택과 하이라이트 클릭을 별도 핸들러로 분리 | contextmenu + click |
| PC 웹 | iOS와 동일한 통합 방식 + 선택 해제 감지 | mouseup + selectionchange |
이를 구현한 코드는 다음과 같습니다:
// Android 전용: 드래그 선택 완료 처리
const handleSelectionComplete = useCallback(() => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
openToolbarFromSelection(selection);
return;
}
}, [openToolbarFromSelection]);
// Android 전용: 기존 하이라이트 클릭 처리
const handleHighlightClick = useCallback(
(e: PointerEvent | MouseEvent) => {
const target = e.target as HTMLElement;
// 1) 기존 하이라이트 클릭 → 편집/툴바
if (target.tagName === 'MARK') {
openToolbarFromHighlight(target);
return;
}
// 2) 아닐 경우 → 툴바 닫기
onHide();
},
[openToolbarFromHighlight, onHide],
);
// iOS/PC 웹 전용: 클릭과 드래그를 하나의 핸들러로 통합
const handleHighlightClickOrSelection = useCallback(
(e: PointerEvent | MouseEvent) => {
const target = e.target as HTMLElement;
// 1) 기존 하이라이트 클릭
if (target.tagName === 'MARK') {
openToolbarFromHighlight(target);
return;
}
// 2) 드래그(선택) 여부
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
openToolbarFromSelection(selection);
return;
}
// 3) 둘 다 아님 → 닫기
onHide();
},
[onHide, openToolbarFromHighlight, openToolbarFromSelection],
);
// === 플랫폼별 안전한 이벤트 바인딩 ===
if (isIOS()) {
// iOS: pointerup 하나로 클릭/드래그 통합 처리
document.addEventListener('pointerup', handleHighlightClickOrSelection);
} else if (isAndroid()) {
// Android: 드래그는 contextmenu, 클릭은 click으로 분리
document.addEventListener('contextmenu', handleSelectionComplete);
document.addEventListener('click', handleHighlightClick);
} else if (!isWebView()) {
// PC 웹: mouseup으로 통합, selectionchange로 해제 감지
document.addEventListener('mouseup', handleHighlightClickOrSelection);
document.addEventListener('selectionchange', handleSelectionClear);
}
핵심 포인트:
contextmenu의 특수성 때문에 핸들러 분리 필수하이라이트를 저장하는 목표는 간단합니다. 지금의 Range를 "나중에 정확히 복원"할 수 있을 만큼 결정적인 좌표로 바꾸는 것인데요.
여기서는 Selection/Range → XPath + 오프셋으로 변환하는 과정을 설명합니다.
어떤 XPath를 저장하는 것이 가장 좋을까?
처음에는 드래그 시작 노드의 XPath+offset, 드래그 끝 노드의 XPath+offset을 그대로 저장하려 했습니다.
하지만 이렇게 하면 start↔end 사이의 모든 노드를 나중에 복원 시 다시 찾아 조립해야 했습니다. 드래그가 길수록 이 과정이 복잡해지고 오류 여지가 커집니다.
그래서 방향을 바꿨습니다.
commonAncestorContainer(공통 조상)를 구해 그 조상의 XPath만 저장range.commonAncestorContainer 속성으로 공통 조상을 가져올 수 있습니다.startOffset/endOffset을 계산해 저장이렇게 하면 저장 형식은 단순해지고, 복원 시에도 한 번의 트리 순회로 정확한 Range를 재구성할 수 있습니다.
예시로 보는 오프셋 계산
<main class="article-body">
<h1>오늘의 뉴스레터</h1>
<p>아침 루틴을 만들기 위한 작은 팁들.</p>
<p>데이터는 습관을 만든다. 반복은 이해를 낳는다.</p>
<p>마지막으로 추천 아티클을 소개합니다.</p>
</main>
첫 번째 <p>의 "아침"부터 세 번째 <p>의 "합니다"까지 드래그했다고 가정해 봅시다.
공통 조상은 <main class="article-body">이고, 이 요소의 XPath를 저장합니다.
오프셋 계산은 <main> 내부의 모든 TextNode를 순서대로 연결한 뒤,
로 정의합니다.
구체적인 계산 과정:
TextNode 1: "오늘의 뉴스레터" (8자)
TextNode 2: "아침 루틴을 만들기 위한 작은 팁들." (20자)
TextNode 3: "데이터는 습관을 만든다. 반복은 이해를 낳는다." (27자)
TextNode 4: "마지막으로 추천 아티클을 소개합니다." (22자)
→ startOffset = 8 (h1 끝) + 0 ("아침"의 시작) = 8
→ endOffset = 8 + 20 + 27 + 22 = 77
구현 코드
1) 노드 → XPath: getXPathForNode
const ROOT_PATH = '.';
/** 특정 노드의 XPath를 구하는 함수 */
export const getXPathForNode = (node: Node, root: Node = document): string => {
// TextNode일 경우 부모 요소로 이동
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}]`
);
};
2) 컨테이너 내부 오프셋 계산: getHighlightOffsets
export const getHighlightOffsets = (container: Element, range: Range) => {
let start = -1;
let end = -1;
let currentOffset = 0;
// NodeFilter.SHOW_TEXT를 통해서 TextNode만 필터링
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 };
};
currentOffset을 누적startContainer/endContainer를 만나면 해당 노드 내 오프셋을 더해 문서 내 절대 오프셋으로 변환3) 저장 페이로드 생성: saveSelection
export const saveSelection = (range: Range) => {
const container =
// 공통 조상이 TextNode일 경우 부모 Element를 공통 조상으로 함
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentElement!
: (range.commonAncestorContainer as Element);
const xpath = getXPathForNode(container);
const { start, end } = getHighlightOffsets(container, range);
return {
location: {
startXPath: xpath,
startOffset: start,
endXPath: xpath, // 공통 조상 XPath를 양쪽에 저장
endOffset: end,
},
color: theme.colors.primaryLight, // 하이라이트 색
text: range.toString(), // 선택된 텍스트(검증/미리보기용)
};
};
API 설계 변경 기록
초기에는startXPath/endXPath에 각각의 노드 XPath를 저장하려 했으나,
멀티 문단 드래그에서 복원이 복잡해져서 공통 조상의 XPath를 양쪽에 동일하게 저장하는 형태로 전환했습니다. 😅
저장까지 했다면, 이제는 저장된 데이터(location)를 바탕으로 DOM에서 다시 정확한 Range를 만들어 <mark>로 그려야 합니다.
복원의 핵심은 단순합니다. 저장의 역순으로 진행하면 됩니다.
startXPath(=공통 조상), startOffset, endOffsetRange 생성1) XPath로 컨테이너 노드 복원하기
/** XPath로 노드를 다시 찾는 함수 */
export const getNodeByXPath = (xpath: string, root: Document = document) => {
const result = document.evaluate(
xpath, // 저장된 XPath
root, // 보통 document
null, // 네임스페이스 리졸버(필요 없으면 null)
XPathResult.FIRST_ORDERED_NODE_TYPE,// 첫 번째 매칭 노드만 가져오기
null,
);
return result.singleNodeValue; // Element | Text | null
};
document.evaluate를 쓰면 한 줄로 노드를 쉽게 되찾을 수 있습니다.2) 컨테이너 내부 오프셋 → Range로 변환하기
function getHighlightRange(container: Node, start: number, end: number) {
const range = document.createRange();
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let currentOffset = 0;
const positions = {
startNode: null as Text | null,
endNode: null as Text | null,
startOffset: 0,
endOffset: 0,
};
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const len = node.textContent!.length;
// start 지점이 이 텍스트 노드 범위 안에 들어오는가?
if (
!positions.startNode &&
start >= currentOffset &&
start <= currentOffset + len
) {
positions.startNode = node;
positions.startOffset = start - currentOffset; // 노드 내부 오프셋으로 변환
}
// end 지점이 이 텍스트 노드 범위 안에 들어오는가?
if (
!positions.endNode &&
end >= currentOffset &&
end <= currentOffset + len
) {
positions.endNode = node;
positions.endOffset = end - currentOffset; // 노드 내부 오프셋으로 변환
}
currentOffset += len; // 다음 텍스트 노드로 이동하기 전에 누적 길이 갱신
}
if (!positions.startNode || !positions.endNode) {
// 저장/복원 정규화 규칙이 다르면 오프셋 매칭 실패 가능
throw new Error('Offset 변환 실패');
}
range.setStart(positions.startNode, positions.startOffset);
range.setEnd(positions.endNode, positions.endOffset);
return range;
}
startOffset/endOffset이 어떤 텍스트 노드의 몇 번째 문자에 해당하는지 계산해 Range를 만듭니다.3) 전체 복원 흐름 (요약)
// 1) 컨테이너 복원
const container = getNodeByXPath(highlight.location.startXPath);
if (!container) return;
// 2) Range 재구성
const range = getHighlightRange(
container,
Number(highlight.location.startOffset),
Number(highlight.location.endOffset),
);
// 3) 태그로 하이라이트 그리기
renderHighlight(range, highlight.color, highlight.id);
참고:
renderHighlight함수는 다음 섹션에서 자세히 다룹니다.
이 함수는 Range 내의 텍스트 노드들을 찾아 각 노드의 해당 구간을<mark>태그로 감싸는 역할을 합니다.
하이라이트 표시 방법은 크게 두 가지가 있습니다.
HighlightRegistry에 Range를 등록해 ::highlight(name)로 스타일링합니다.<mark>(또는 <span>) 같은 실제 요소로 감싸 스타일링합니다.봄봄은 <mark> 래핑을 채택했습니다. 이유는 다음과 같습니다.
<mark>는 "문맥상 강조된 텍스트"라는 시맨틱 태그라서 스크린 리더 등 보조기술에서 의미가 분명합니다.<mark data-highlight-id="…">처럼 식별자/메타데이터를 바로 심어 클릭·롱프레스·툴팁 등 이벤트 타깃으로 쓰기 쉽습니다. Custom Highlight는 DOM 노드를 생성하지 않아서 dataset/이벤트 바인딩이 어렵습니다.하이라이트 그리기: 텍스트 조각만 <mark>로 감싸기
하이라이트는 텍스트 노드 하나 안에서도 start~end 구간만 칠해야 합니다. 그래서 원래 텍스트를 before | middle | after로 나누고, middle만 <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 = String(highlightId);
// 텍스트 채우기
mark.textContent = middle;
// DocumentFragment로 한 번에 교체 → reflow 최소화
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);
};
왜 DocumentFragment인가요?
appendChild를 여러 번 하는 대신, 오프라인 트리에서 조립 → 한 번에 교체하면 reflow/repaint 비용을 줄일 수 있습니다.
하이라이트 지우기: <mark>를 텍스트로 환원
삭제는 간단합니다. 특정 highlightId를 가진 <mark>들을 찾아 내부 텍스트 노드로 되돌리기만 하면 됩니다.
export const removeHighlightFromDOM = (highlightId: number) => {
const marks = document.querySelectorAll(
`mark[data-highlight-id="${highlightId}"]`,
);
marks.forEach((mark) => {
const textNode = document.createTextNode(mark.textContent ?? '');
mark.replaceWith(textNode);
});
};
지금까지 하이라이트 구현의 네 단계를 모두 살펴봤습니다:
<mark> 태그로 시각화이 네 단계가 유기적으로 연결되어 "읽은 내용을 저장하고 다시 찾아볼 수 있는" 하이라이트 기능을 완성합니다.
전체 구현은 GitHub에서 자세하게 확인할 수 있습니다.
하이라이트는 "선택한 구간을 표시한다"만 보면 단순해 보입니다. 그러나 모든 플랫폼에서 일관되게 동작하도록 이벤트를 다루고, 저장/복원 로직을 안정적으로 설계하며, 실전의 예외 케이스(멀티 문단, 웹뷰 차이, 리렌더링 충돌)들을 처리하는 순간부터 복잡도가 급격히 올라갑니다.
이번 글에서는 그 복잡도를 낮추기 위해 컨테이너 XPath + 내부 오프셋이라는 앵커 전략을 선택하고, 선택→저장→복원→그리기/삭제까지의 전 과정을 단계별로 정리했습니다.
핵심은 세 가지였습니다.
<mark>로 DOM을 직접 마킹하지만 "동작한다"만으로는 충분하지 않습니다. 사용자가 실제로 쓰고 싶어지는 경험을 만들려면 더 많은 디테일이 필요합니다.
다음 편에서는 하이라이트 UX를 한 단계 더 개선하는 디테일과 React 환경에서 마주치는 실전 이슈들을 구체적인 코드와 함께 공유하겠습니다.
맛 있는 데요 ?