상품 상세페이지에서 리뷰쪽으로 스크롤하면 화면이 이상해져요

|

안녕하세요. 미용 재료 쇼핑몰 아몬드영에서 프론트와 백엔드를 오가며 개발하고 있는 정중식입니다.

어느 날 카카오톡 채널로 손님 한 분이 영상을 보내 주셨어요. 상품 상세에서 리뷰 쪽으로 스크롤하면 화면이 Q&A로 튕겨 버린다는 내용이었습니다.

영상을 보면 리뷰를 보려고 스크롤을 올려도 화면이 자꾸 Q&A 쪽으로 끌려가요. 위의 탭 밑줄도 리뷰와 Q&A 사이를 계속 깜빡이고요. 상세설명 “더보기”를 누르면 깜빡거림이 더 심해졌습니다.

버그를 재현해보니 이런 조건에서 버그가 일어났습니다.

  • iPhone과 Android 둘 다 일어난다.
  • 특정 상품이 아니라 모든 상품에서 일어난다.
  • 탭을 눌러 이동할 때는 괜찮고 손으로 스크롤할 때만 그런다.
  • 계속 스크롤하면 결국 끝까지 내려가긴 한다.

이 글은 이 버그를 따라간 기록입니다. 먼저 말씀드리면 원인은 dangerouslySetInnerHTML에 넘기던 객체 하나였고, 수정은 useMemo 한 줄이었습니다. 다만 PC에서는 한 번도 재현되지 않아서 부끄럽지만, 그동안 이런 문제가 있는 줄도 몰랐습니다.

목차

PC에서는 재현이 안 된다

먼저 Chrome DevTools로 모바일 환경을 흉내 내 봤어요. 390px 뷰포트, 터치 에뮬레이션, CPU slowdown까지 걸고 리뷰와 Q&A 사이를 오르내렸습니다.

시도 결과
DevTools 모바일 에뮬레이션 재현 안 됨
390px iframe 안에 같은 페이지를 띄우고 휠 스크롤 재현 안 됨
키보드 스크롤, 뷰포트 리사이즈 재현 안 됨
손님과 같은 상품을 실제 휴대폰으로 바로 재현

휴대폰에서는 너무 쉽게 재현되는데 PC에서는 아무리 해도 멀쩡했습니다.

처음 세운 가설은 틀렸다

상품 상세는 스크롤 위치에 따라 URL을 ?tab=review, ?tab=qna로 바꿔요. 스크롤스파이가 지금 보이는 섹션을 판정하고 history.replaceState로 URL에 씁니다. 페이지에 들어올 때 ?tab=이 있으면 그 섹션으로 스크롤하는 코드도 있고요.

탭 밑줄이 깜빡이고 화면이 Q&A로 끌려간다는 증상이 이 구조와 너무 잘 맞아서 URL 동기화가 범인이라고 생각했어요. URL이 바뀌면서 무언가가 다시 스크롤을 거는 거라고요.

URL 동기화를 통째로 빼고 로컬에서 휴대폰으로 접속해 확인해 봤습니다. 결과는 똑같았습니다. 가설은 틀렸습니다.

휴대폰에서 로그를 받아 오기로 했다

PC에서 재현이 안 되니 추측을 더 쌓는 대신 휴대폰에서 무슨 일이 일어나는지 직접 보기로 했어요.

로컬 스토어프론트를 같은 와이파이의 휴대폰에서 열고 AI에게 페이지에 관찰용 스크립트를 심어 달라고 했습니다. 이런 것들을 기록했어요.

  • 스크롤 위치와 문서 높이
  • ResizeObserver로 주요 요소의 높이 변화
  • MutationObserver로 DOM이 바뀌는 순간
  • 이미지 load 이벤트
  • scrollIntoView, scrollTo, focus 호출과 그 콜스택

모바일 브라우저의 콘솔은 보기가 번거로워서 모은 로그를 맥에 띄운 작은 Node 서버로 fetch해서 받게끔 했습니다.

fetch('http://<맥 IP>:3999/log', {
  method: 'POST',
  mode: 'no-cors',
  body: JSON.stringify(batch),
});

그리고 리뷰와 Q&A 사이에서 스크롤을 몇 번 오르내린 뒤 로그를 열어 봤습니다.

로그가 보여 준 것

scrollIntoView나 scrollTo 호출은 한 번도 없었어요. 누가 스크롤을 옮긴 게 아니었던 거죠.

대신 상세설명 영역(div.prose)의 높이가 이상했습니다.

div.prose  4550px → 28px → 4550px → 28px → ...

상세설명이 4550px과 28px 사이를 계속 오가고 있었어요. 그때마다 MutationObserver에는 상세설명 안의 자식 노드가 통째로 교체됐다는 기록이 남았고 새로 생긴 <img>는 전부 complete: false, 높이 0이었습니다.

이 진동이 약 33ms 간격으로 반복됐고 그때마다 페이지 전체 높이가 452px씩 늘었다 줄었다 했어요.

화면 위쪽의 상세설명이 접혔다 펴지기를 반복하면, 그 아래에 있는 리뷰와 Q&A는 위아래로 계속 밀립니다. 손님이 보던 위치는 그대로인데 콘텐츠가 움직이니 화면이 Q&A 쪽으로 튕기는 것처럼 보였던 거예요. 스크롤스파이도 그때마다 판정이 바뀌어서 탭 밑줄이 깜빡였습니다.

누가 상품 상세정보를 다시 쓰고 있었나

상세정보 컴포넌트는 대략 이런 모양이었어요.

const optimizedDescriptionHtml = useMemo(
  () => optimizeDescriptionHtmlImages(descriptionHtml),
  [descriptionHtml],
);

const contentVisible = useIntersection(contentRef);
const triggerFullyVisible = useIntersection(triggerRef, '0px', 1);

return (
  <div
    className='prose max-w-none'
    dangerouslySetInnerHTML=
  />
);

HTML 문자열은 useMemo로 감싸 두었어요. 그런데 dangerouslySetInnerHTML에 넘기는 { __html: ... } 객체는 렌더링할 때마다 새로 만들어지고 있었습니다.

React 19는 객체가 바뀌면 innerHTML을 다시 쓴다

React DOM이 속성을 갱신하는 코드(updateProperties)를 열어 보면, 이전 prop과 다음 prop을 참조로 비교해요.

// 읽기 쉽게 줄인 코드
if (nextProp !== lastProp) {
  setProp(domElement, tag, propKey, nextProp, ...)
}

// setProp 안에서
case "dangerouslySetInnerHTML":
  domElement.innerHTML = nextProp.__html

안의 문자열이 같아도 객체가 새로 만들어졌으면 !==에 걸리고 innerHTML을 그대로 다시 씁니다. innerHTML을 다시 쓰면 그 안의 <img>는 전부 새 요소로 바뀌어요.

그리고 컴포넌트는 스크롤할 때마다 렌더링됐다

여기서 “렌더링할 때마다”가 언제인지가 중요했어요. 처음엔 탭이 바뀔 때만 렌더링된다고 생각했는데, 아니었습니다.

이 컴포넌트에는 IntersectionObserver 상태가 두 개 있어요. 상세설명이 화면에 보이는지(contentVisible), “더보기/접기” 버튼이 화면에 다 보이는지(triggerFullyVisible)요. 상세설명을 펼친 채 스크롤할 때 플로팅 “접기” 버튼을 띄울지 정하는 데 쓰는 값입니다.

스크롤하다 이 경계를 지나면 상태가 바뀌고 컴포넌트가 다시 렌더링돼요. 그러면 이런 고리가 생깁니다.

  1. 스크롤하다 버튼이나 상세설명이 화면 경계를 지난다. IntersectionObserver 상태가 바뀌고 컴포넌트가 다시 렌더링된다.
  2. 새 { __html } 객체 때문에 innerHTML이 다시 써진다. 이미지가 전부 새로 만들어진다.
  3. 새 이미지는 아직 그려지지 않아 높이가 0이다. 상세설명이 4550px에서 28px로 줄어든다.
  4. 상세설명이 줄면 그 아래의 “더보기/접기” 버튼이 위로 확 올라온다. IntersectionObserver 상태가 또 바뀐다.
  5. 1번으로 돌아간다.

이미지가 다시 그려지면 상세설명은 원래 높이로 돌아오고 버튼은 다시 내려가고 상태가 또 바뀌어요. 손님이 “계속 스크롤하면 결국 끝까지 내려가긴 한다”고 하신 것도 이걸로 설명됩니다. 버튼이 관찰 경계에서 충분히 멀어지면 고리가 끊기거든요.

왜 PC에서는 안 됐을까

데스크톱 Chrome은 이미 캐시에 있는 이미지를 새 <img>로 다시 만들어도 거의 같은 프레임 안에 그려 버려요. 높이가 0인 순간이 레이아웃에 잡히지 않으니 3번이 일어나지 않고, 고리도 생기지 않았습니다.

휴대폰 브라우저는 그렇게 빠르게 채워 주지 않았어요. 게다가 상세설명 이미지에는 loading="lazy"가 붙어 있고 width/height도 없어서 새로 만들어진 이미지는 일단 높이 0으로 시작합니다.

해결: 객체째 useMemo

문자열이 아니라 { __html } 객체 자체를 메모이즈했어요.

// React 19 는 {__html} 객체가 새로 만들어지면 같은 문자열이어도 innerHTML 을 다시 써서 이미지가 접혔다 펴진다
const descriptionHtmlProps = useMemo(
  () => ({
    __html: descriptionHtml
      ? optimizeDescriptionHtmlImages(descriptionHtml)
      : "",
  }),
  [descriptionHtml]
)

<div className="prose max-w-none" dangerouslySetInnerHTML={descriptionHtmlProps} />

이제 IntersectionObserver 상태가 바뀌어 다시 렌더링돼도 descriptionHtml이 그대로면 같은 객체가 넘어가요. React는 innerHTML을 건드리지 않고 이미지도 그대로 남습니다.

같은 조건으로 휴대폰에서 다시 로그를 받아 봤어요.

항목 수정 전 수정 후
상세설명 높이 변화 (4550 ↔ 28px) 186번 0번
상세설명 DOM 교체 (MutationObserver) 42번 0번
리뷰·Q&A 사이 스크롤 튕김 정상

휴대폰으로 직접 스크롤해 봐도 더는 튕기지 않았어요.

아직 남은 것

같은 패턴이 공지사항 상세와 사이트 팝업에도 남아 있어요. 둘 다 dangerouslySetInnerHTML=로 렌더링할 때마다 객체를 새로 만듭니다. 다만 두 컴포넌트에는 스크롤 중에 바뀌는 상태가 없어서 다시 렌더링될 일이 없고 그래서 증상도 없어요. 언젠가 상태가 하나 추가되면 같은 일이 생길 수는 있습니다.

그래도 이번에는 손대지 않았어요. 개발자들 사이에서 흔히 하는 말처럼 “잘 돌아가는 건 건드리지 마라”를 따르기로 했습니다. 지금 문제없이 동작하는 코드를 미리 고치기보다, 그 컴포넌트에 상태를 추가하는 시점에 함께 useMemo로 감싸려고 해요.

이번에 배운 것

dangerouslySetInnerHTML에 넘기는 객체도 다른 prop처럼 참조로 비교돼요. 문자열만 메모이즈해 두고 안심했는데, React가 보는 건 그 문자열을 감싼 객체였습니다.

그리고 재현이 안 될 때는 추측을 더 쌓지 말고 로그부터 봐야 했어요. URL 동기화 가설은 그럴듯했지만 틀렸고 답은 휴대폰에서 받아 온 로그 한 줄에 있었습니다. 비슷한 증상을 겪고 계신 분께 이 글이 도움이 됐으면 좋겠습니다.