본문 바로가기
Engineering

서버에서 텍스트 하이라이트 주입하기 — 스크롤 튐이 부른 SSR 결정

서버 렌더링된 본문에 클라이언트가 직접 span을 주입했더니 일부 브라우저에서 스크롤이 튀었다. 원인을 좇다 하이라이트 주입을 서버로 옮긴 과정 — position 기반 앵커, 텍스트↔HTML 오프셋 변환, JSDOM 주입, html-react-parser, router.refresh, 롤백 가드까지.

형광펜을 칠했더니 화면이 맨 위로 튀었다가 제자리로 돌아왔다. 정확히는, 사용자가 문단 일부를 드래그해서 하이라이트 버튼을 누르는 그 순간에만 일어났다. 스크롤을 다시 올려보면 아무 문제 없어 보이는데, 딱 그 타이밍에만 화면이 한번 깜빡였다. 재현 빈도도 낮고 특정 브라우저(구체적으로는 특정 렌더링 엔진 기반)에서만 두드러져서, 처음엔 "가끔 그런가 보다" 하고 넘길 뻔했다.

서버에서 렌더링되는 긴 아티클 뷰어에 "드래그해서 형광펜 칠하기" 기능을 붙이던 중이었다. 기능 자체는 단순했다. 사용자가 본문 텍스트를 드래그하면 버튼이 뜨고, 누르면 그 구간이 노란 배경으로 칠해진다. 문제는 그 "칠한다"는 동작을 어디서, 어떻게 하느냐였다.

처음엔 클라이언트에서 칠했다

가장 직관적인 방법은 브라우저의 Range API를 그대로 쓰는 것이었다. 사용자가 선택한 영역을 window.getSelection()으로 얻고, 그 Range를 감싸는 <span class="highlighted">를 DOM에 직접 끼워 넣는다.

function wrapSelectionWithSpan(range: Range) {
  const span = document.createElement("span");
  span.className = "highlighted";
  range.surroundContents(span); // 선택 구간을 span으로 감싼다
}

동작은 했다. 텍스트를 드래그하고 버튼을 누르면 즉시 노랗게 칠해졌다. 그런데 딱 그 순간 스크롤이 튀는 브라우저가 있었다. 처음엔 하이라이트 로직을 의심했는데, 원인은 더 근본적인 곳에 있었다. 클라이언트가 서버 렌더링된 본문 DOM을 직접 변형하고 있었다는 것 자체가 문제였다.

Range.surroundContents()는 선택된 텍스트 노드를 쪼개서 새 엘리먼트를 끼워 넣는다. 문단 하나가 "텍스트 노드 하나"였다가 그 순간 "텍스트 노드 세 개 + span 하나"로 바뀐다. 브라우저는 화면이 갑자기 튀지 않도록 스크롤 위치를 유지해주는 스크롤 앵커링(scroll anchoring)이라는 걸 갖고 있는데, 렌더링 엔진마다 이 mutation을 따라가는 방식이 조금씩 다르다. 정확히 어느 내부 구현이 문제였는지까지는 브라우저 엔진 소스를 파고들지 않아 단정하기 어렵다. 다만 체감상, 큰 본문 DOM 중간을 실시간으로 쪼개는 행위 자체가 여러 브라우저에서 일관되게 안전한 연산이 아니었다.

💡 Range.surroundContents()는 선택 구간이 여러 엘리먼트에 걸쳐 있으면(예: <strong>과 일반 텍스트 사이) InvalidStateError를 던진다. 태그 경계를 넘는 하이라이트를 지원하려면 이 방식은 애초에 한계가 있었다.

왜 하필 서버인가

스크롤 버그를 고치는 방법은 두 갈래였다. 하나는 클라이언트 DOM 조작을 더 조심스럽게 만드는 것(예: requestAnimationFrame으로 타이밍 조정, scrollTop을 수동으로 스냅샷/복원). 다른 하나는 애초에 클라이언트가 본문 DOM을 건드리지 않게 만드는 것이었다.

전자는 증상 완화에 가까웠다. 브라우저마다 미묘하게 다른 스크롤 앵커링 동작에 매번 대응해야 하고, 태그 경계를 넘는 하이라이트나 겹치는 하이라이트 같은 다음 문제들도 결국 클라이언트에서 문자열 자르고 붙이기로 풀어야 했다. 그래서 방향을 바꿨다. 하이라이트가 적용된 최종 HTML을 서버가 미리 확정해서 내려주면, 클라이언트는 그걸 그대로 그리기만 하면 된다. DOM을 실시간으로 쪼개는 연산 자체가 사라지니 스크롤이 튈 이유도 없어진다.

flowchart LR
  subgraph before ["이전: 클라이언트 주입"]
    A1["서버: 본문 HTML"] --> A2["클라이언트: Range.surroundContents"]
    A2 --> A3["DOM mutation → 스크롤 튐"]
  end
  subgraph after ["이후: 서버 주입"]
    B1["서버: 본문 HTML + 하이라이트 목록 fetch"] --> B2["서버: DOM 파서로 span 미리 삽입"]
    B2 --> B3["html-react-parser로 React 트리 변환"]
    B3 --> B4["클라이언트: 완성된 트리 렌더 mutation 없음"]
  end

아티클 상세 페이지를 async 서버 컴포넌트로 만들고, 본문 HTML과 로그인한 사용자의 하이라이트 목록을 서버에서 함께 fetch했다. 그리고 그 둘을 합쳐서 하이라이트가 이미 심어진 HTML을 만든 다음, 그걸 React 트리로 변환해서 내려보냈다.

앵커: 어디를 칠했는지 저장하는 법

서버가 하이라이트를 정확한 위치에 심으려면, 저장된 좌표(anchor)가 흔들리지 않아야 한다. 처음엔 XPath나 인용문(quote) 기반 앵커도 검토했지만, 결국 position 기반으로 정리했다.

필드의미
blockIndex본문 안에서 몇 번째 블록(문단/헤딩)인지
startOffset그 블록 안에서 하이라이트가 시작하는 문자 오프셋 (HTML 기준)
length하이라이트 길이
text원본으로 선택된 텍스트 — 저장 후 검증·표시용

XPath는 본문 구조가 조금만 바뀌어도(예: 문단 위에 이미지 하나가 추가되는 편집) 깨지기 쉬웠고, 순수 인용문 매칭은 같은 문장이 본문에 두 번 나오면 어느 쪽인지 구분이 안 됐다. 블록 인덱스 + 오프셋 + 길이 조합이 제일 단순하면서 실용적이었다. text 필드는 검색·표시용이자, 나중에 롤백 가드에서 다시 쓰인다.

텍스트 오프셋과 HTML 오프셋은 다르다

여기서 한 가지 함정이 있었다. 저장 포맷의 startOffset그 블록의 HTML 문자열 기준 오프셋이다. 그런데 클라이언트에서 사용자가 드래그해서 얻는 Range의 오프셋은 렌더링된 순수 텍스트 기준이다. 브라우저의 Selection은 태그를 모르고 화면에 보이는 글자만 센다.

<!-- 블록의 원본 HTML -->
<strong>서버</strong>가 하이라이트를 미리 심는다

사용자가 "하이라이트"라는 단어를 드래그하면, 브라우저 기준 순수 텍스트 오프셋은 3("서버가 " 다음)이다. 하지만 실제 HTML 문자열에서 같은 지점은 <strong>, </strong> 태그 길이(17자)만큼 밀려 있다. 이 둘을 맞바꾸는 변환 함수가 필요했다.

function plainOffsetToHtmlOffset(html: string, plainOffset: number): number {
  let plainCount = 0;
  let i = 0;
 
  while (i < html.length && plainCount < plainOffset) {
    if (html[i] === "<") {
      // 태그 구간은 순수 텍스트로 세지 않고 건너뛴다
      const close = html.indexOf(">", i);
      i = close === -1 ? html.length : close + 1;
      continue;
    }
    plainCount++;
    i++;
  }
 
  return i;
}

실제로는 HTML 엔티티(&amp;, &nbsp; 등)도 "화면에는 한 글자, 소스에는 여러 글자"라 같은 방식으로 예외 처리해야 한다. 이 변환은 클라이언트가 새 하이라이트를 만들 때(순수 텍스트 오프셋 → 저장용 HTML 오프셋) 한 번, 서버가 저장된 하이라이트를 다시 표시할 때(HTML 오프셋 → 실제 삽입 위치) 한 번, 양방향으로 쓰인다.

선택 감지는 mouseup이 아니라 selectionchange 이벤트로 걸었다. mouseup은 더블클릭으로 단어를 선택하거나 키보드(Shift+화살표)로 선택 범위를 조정하는 경우를 놓치기 쉽다. selectionchangeSelection 객체가 바뀔 때마다 훨씬 촘촘하게 발생하는 대신, 드래그 중간중간에도 계속 튀기 때문에 debounce로 다듬어서 썼다.

서버에서 스팬을 심는다

서버 컴포넌트 안에서 본문 HTML과 하이라이트 목록을 fetch한 다음, Node용 DOM 파서(JSDOM 등)로 HTML을 파싱해서 계산해둔 오프셋 위치에 <span class="highlighted" data-highlight-id="...">를 심었다. 대략 이런 모양이다.

import { JSDOM } from "jsdom";
 
function injectHighlights(html: string, highlights: Highlight[]) {
  const dom = new JSDOM(`<div id="root">${html}</div>`);
  const root = dom.window.document.getElementById("root")!;
  const blocks = Array.from(root.children); // 문단/헤딩 단위 블록
 
  for (const h of highlights) {
    const block = blocks[h.blockIndex];
    if (!block) continue;
    insertHighlightSpan(block, h); // startOffset~length 구간을 span으로 감싸는 저수준 로직
  }
 
  return root.innerHTML;
}

여기서 insertHighlightSpan이 하는 일은 클라이언트에서 Range.surroundContents()로 하려던 것과 목적은 같지만 방식이 다르다. DOM 노드를 직접 쪼개는 게 아니라, 찾은 블록의 HTML 문자열을 오프셋 기준으로 잘라 <span>을 끼워 넣는다(그래서 뒤에서 다룰 태그 밸런싱이 필요하다). JSDOM은 노드 수술용이 아니라 블록을 찾고 삽입 결과를 검증하는 파싱 도구로 쓴 셈이다. 어느 쪽이든 핵심은, 이 mutation이 서버 메모리 위의 문자열에서 일어나고 클라이언트는 그 완성된 결과만 받는다는 점이다. 브라우저가 실제로 그리고 있는 DOM은 한 번도 흔들리지 않는다.

html-react-parser로 인터랙티브하게 만들기

완성된 HTML 문자열을 그대로 dangerouslySetInnerHTML에 넣을 수도 있었다. 그런데 하이라이트는 단순히 색칠된 텍스트가 아니라, 클릭하면 메모를 보여주고 삭제 버튼도 있는 UI였다. dangerouslySetInnerHTML로는 그 안에 이벤트 핸들러를 붙일 방법이 없다.

그래서 html-react-parser로 서버에서 만든 HTML을 실제 React 엘리먼트 트리로 변환하고, replace 옵션으로 highlighted 클래스가 붙은 span만 커스텀 컴포넌트로 바꿔치기했다.

import parse, { domToReact, Element, HTMLReactParserOptions } from "html-react-parser";
 
const options: HTMLReactParserOptions = {
  replace(node) {
    if (!(node instanceof Element) || node.name !== "span") return;
 
    const className = node.attribs.class ?? "";
    const isHighlight = className.includes("highlighted");
    if (!isHighlight) return;
 
    return (
      <HighlightSpan id={node.attribs["data-highlight-id"]}>
        {domToReact(node.children as any, options)}
      </HighlightSpan>
    );
  },
};
 
export function ArticleBody({ html }: { html: string }) {
  return <>{parse(html, options)}</>;
}

HighlightSpan은 평범한 클라이언트 컴포넌트다. 클릭 시 메모 팝오버를 열거나 삭제 요청을 보내는 로직은 여기 다 들어간다. 결과적으로 본문 대부분은 서버가 정적으로 확정하고, 인터랙션이 필요한 부분만 클라이언트 컴포넌트로 얹는 모양이 됐다.

새 하이라이트는 router.refresh()

사용자가 새로 드래그해서 하이라이트를 만들면, 클라이언트는 계산한 앵커(블록 인덱스, HTML 오프셋, 길이, 원본 텍스트)를 서버에 저장 요청으로 보낸다. 저장이 끝나면 화면을 갱신해야 하는데, 여기서도 router.refresh()를 썼다.

router.refresh()는 현재 라우트를 서버에 다시 요청해서 서버 컴포넌트만 새로 렌더링하고, 그 결과(갱신된 React Server Component 페이로드)를 기존 트리에 병합한다. 이 과정에서 useState 같은 클라이언트 상태나 스크롤 위치 같은 브라우저 상태는 그대로 보존된다. 즉 하이라이트 목록을 다시 fetch하고 span을 다시 심는 전체 파이프라인을 서버에서 한 번 더 태우면서도, 사용자 입장에서는 방금 칠한 자리에 스크롤이 그대로 머물러 있고 화면 깜빡임(FOUC)이나 hydration mismatch도 없다.

💡 router.refresh()의 스크롤·클라이언트 상태 보존 동작은 Next.js 버전에 따라 세부 구현이 조금씩 달라질 수 있다. 정확한 버전별 차이는 실제 프로젝트의 Next.js 버전 문서를 확인하는 게 안전하다.

무너지지 않게: 견고성 체크리스트

서버가 HTML 문자열을 직접 조작하는 방식이라, "본문을 안 망가뜨리는가"가 제일 중요한 기준이었다. 실제로 부딪힌 케이스들이다.

  • 태그에 걸친 하이라이트 — 선택 구간이 <strong>, <a>, <em> 같은 인라인 태그 경계를 넘어가면, 단순 substring 삽입은 반쪽짜리 태그(<stro 처럼 닫히지 않은 태그)를 만들어낸다. span을 심을 때 태그 밸런싱을 함께 처리해서, 하이라이트가 인라인 태그를 가로지르더라도 원래 구조(볼드는 볼드대로, 링크는 링크대로)가 깨지지 않게 했다.
  • 겹치는 하이라이트 — 같은 문단에서 오프셋 범위가 겹치는 하이라이트가 여러 개 저장될 수 있다(동시 편집, 재시도 등). 겹치면 가장 최근 것만 렌더링하도록 정리했다.
  • 롤백 가드 — span을 삽입하기 전후로 블록의 textContent를 비교한다. 삽입 과정에서 텍스트 자체가 달라졌다면(오프셋 계산이 어긋났거나 태그 밸런싱이 실패한 경우) 그 하이라이트는 버리고 원본 블록으로 되돌린다. 하이라이트 하나가 잘못됐다고 문단 전체가 깨지는 것보다는, 그 하이라이트만 조용히 안 보이는 편이 낫다.
  • 스크립트 제거 — 서버가 외부에서 온 본문 HTML을 다시 파싱해 내려주는 구조라, <script> 태그는 이 단계에서 걸러낸다.

클라이언트 방식의 문제는 스크롤만이 아니었다

돌이켜보면 처음 클라이언트 방식이 가진 문제는 스크롤 버그 하나가 아니었다. innerHTML을 문자열로 잘라서 span을 끼워 넣는 방식은 태그 경계, HTML 엔티티, 본문이 나중에 수정됐을 때의 오프셋 밀림에 전부 취약했다. 상대 오프셋(예: "이 문단 안에서 몇 번째 글자")으로 저장하면 문단 순서가 바뀌거나 앞쪽 내용이 조금만 수정돼도 하이라이트 위치가 통째로 어긋난다.

서버 주입 + 절대 위치(블록 인덱스와 HTML 오프셋) + 롤백 가드 조합은 이 문제들을 구조적으로 줄여준다. 클라이언트는 "어디를 선택했는지"만 계산해서 보내고, "그 위치를 실제로 어떻게 문서에 반영할지"는 서버가 매번 새로 확정한다. 어떤 이유로든 삽입이 실패하면 롤백 가드가 원본을 지켜준다.

마무리

하이라이트 기능을 만들면서 얻은 교훈은 단순했다. 서버가 렌더링한 문서를 클라이언트가 직접 변형하는 순간, 그 변형이 아무리 작아 보여도 브라우저마다 다르게 반응할 여지가 생긴다. 스크롤 앵커링처럼 겉으로 잘 안 보이는 브라우저 내부 동작까지 전부 예측하고 방어하느니, 애초에 "클라이언트가 DOM을 바꾸는" 그 지점을 없애는 게 더 확실했다.

지금도 남아 있는 질문은 있다. 본문 블록 구조 자체가 크게 바뀌는 편집(문단이 통째로 삭제되거나 순서가 바뀌는 경우)에는 블록 인덱스 기반 앵커가 여전히 취약하다. 지금은 롤백 가드로 눈에 띄는 깨짐만 막고 있는데, 더 견고한 앵커링(예: 문단 단위 안정적 ID 부여)은 다음 과제로 남겨뒀다.