getSnapshotBeforeUpdate: 함수형 컴포넌트에서 사라진 한 시점에 대한 이야기
함수형 컴포넌트에서 getSnapshotBeforeUpdate가 필요한 이유. 그리고 react-snapshot-bridge를 만든 이유.
최근 FLIP 애니메이션 구현을 위해 기존 라이브러리를 분석했습니다. FLIP 라이브러리에서 사용되는 getSnapshotBeforeUpdate를 함수형 컴포넌트에서 자연스럽게 사용할 수 있도록 react-snapshot-bridge 패키지를 배포하며 react의 렌더링 방식을 다시 정리해보았습니다.
최근 react-snapshot-bridge 라이브러리 (npmjs.com, 새 창)를 배포했습니다. 0.5KB가 채 안되는 아주 작은 라이브러리입니다. 단순해보이는 이 라이브러리의 힘은 함수형 컴포넌트와의 결합에서 빛을 발합니다. 리액트의 함수형 컴포넌트에서 클래스 컴포넌트 전용 라이프사이클인 getSnapshotBeforeUpdate를 사용할 수 있도록 돕기 때문입니다!
처음부터 getSnapshotBeforeUpdate를 라이브러리로 감싸야겠다고 생각한 것은 아니었습니다. FLIP 애니메이션의 구현 사례들을 분석하면서 DOM이 바뀌기 직전의 레이아웃을 읽어야 하는 시점이 필요했고, 함수형 컴포넌트의 훅으로는 풀어내기 어려운 구간을 발견했습니다.
함수형 컴포넌트의 라이프사이클에서는 아직 getSnapshotBeforeUpdate 훅을 완벽하게 대체할 수 있는 무언가가 없는 상태였습니다. 실제로 커뮤니티에서도 이 필요성에 대한 목소리는 꾸준히 존재했지만 5년이 더 지난 지금까지도 리액트의 대응에는 진전이 없는 상태였습니다.

그래서 클래스형 컴포넌트를 감싼 래퍼 라이브러리 react-snapshot-bridge를 만들었습니다. 별도의 클래스형 컴포넌트를 생성하여 이 시점을 포착할 필요 없이 그냥 함수형 컴포넌트 스타일에 이 라이브러리에서 제공하는 컴포넌트를 살짝 끼워 넣어주면 됩니다.
이번 글에서는 리액트의 커밋 단계를 돌아보며 이 라이브러리가 왜, 언제 필요한지 그 탄생 배경을 같이 정리해보려 합니다.
이전 DOM이 필요한 FLIP 애니메이션
먼저 FLIP 애니메이션에 대해서 살짝 짚고 넘어가는 것이 필요해 보입니다. FLIP 애니메이션을 구현하면서 필요성을 절실하게 느꼈으니까요.
FLIP은 First, Last, Invert, Play의 약자입니다. 먼저 변화 전의 위치와 크기인 최초의 레이아웃을 측정(First)하고, DOM 업데이트 이후의 위치와 크기인 최종 레이아웃을 다시 측정(Last)합니다. 그 다음 두 값의 차이를 계산해 대상을 시각적으로 이전 위치에 되돌려 놓고(Invert), transform을 풀면서 자연스럽게 이동하는 것처럼 보이게 만드는 방식입니다(Play). framer-motion 등 리액트 진영에서 주로 사용되는 많은 애니메이션 라이브러리들에서 해당 방식을 차용하고 있습니다.

중요한 점은 이 최초의 레이아웃이 단순한 이전 데이터가 아니라는 것입니다. FLIP이 필요로 하는 값은 브라우저가 실제로 계산한 이전 DOM의 위치와 크기입니다. 이 값은 CSS, 폰트, 이미지 로딩, 컨테이너 크기, 스크롤 상태, 주변 요소의 레이아웃에 의해 결정되기 때문에 이전 화면이 렌더링된 시점과는 미묘하게 다를 수 있습니다.
그렇기 때문에 First 단계와 Last 단계는 같은 DOM 위에서, 변경 전후의 데이터의 연장선 상에서 발생해야 합니다. First는 리액트가 DOM을 변경하기 직전에 읽어야 하고, Last는 DOM이 변경된 직후에 읽어야 합니다. 둘 사이가 어긋나면 애니메이션은 잘못된 위치를 기준으로 시작하거나 첫 프레임에서 튀는 것처럼 보일 수 있기 때문입니다.
이 지점에서 getSnapshotBeforeUpdate가 필요해집니다. 리액트의 클래스형 컴포넌트 라이프사이클 중 하나인 getSnapshotBeforeUpdate이 DOM mutation 직전에 호출되기 때문입니다. 이 시점은 리액트가 이번 업데이트로 DOM을 실제로 바꾸기 바로 전에 아직 남아 있는 이전 DOM 상태, 즉 최종 DOM 상태를 읽을 수 있는 마지막 시점입니다. 그렇기 때문에 DOM 변경 직전의 엘리먼트의 위치와 크기를 얻을 수 있는 것이죠.
getSnapshotBeforeUpdate와 useLayoutEffect
getSnapshotBeforeUpdate가 가리키는 이 시점은 useLayoutEffect의 시점과는 상이합니다. useLayoutEffect 역시 브라우저의 페인트 시점 전에 실행되기는 하지만 정확히는 DOM mutation 이후이며, 이 시점은 리액트가 이미 DOM을 새 상태로 바꾼 뒤의 상태입니다. 따라서 useLayoutEffect 안에서 getBoundingClientRect()를 호출한다고 해도 DOM 변경 이전의 레이아웃(FLIP 애니메이션 사례라면 Last의 레이아웃) 정보에는 절대 접근할 수 없습니다.

이 DOM 변경 이전의 레이아웃을 포착하기 위해 일부 라이브러리에서는 ref에 이전 측정값을 저장해두기도 합니다. 실제로 저도 이러한 방식으로 이전 레이아웃의 정보를 저장하기도 했습니다. 다만 문제는 커밋 단계 직전 브라우저에 실제로 놓여 있던 DOM의 레이아웃 정보를 완벽하게는 보장하지 못한다는 점입니다. useRef에 저장된 측정값은 언제까지나 이전 렌더 단계에서 포착한, 다음 업데이트 직전의 추정값일 뿐 업데이트 직전의 실제 값과는 다르기 때문입니다(물론 엄격하게 보면 getSnapshotBeforeUpdate 역시 실제 DOM을 완벽하게 포착한다고 할 수는 없습니다. 리액트 위에서 동작하는 이상 리액트 업데이트 기준의 V-DOM 스냅샷일 뿐이니까요. 엄밀하게 이 지점을 포착하려면 MutationObserver과 같은 실제 DOM에 직접 접근하는 구현이 필요해 보입니다).
함수형 컴포넌트에는 이 시점이 없다.
문제는 getSnapshotBeforeUpdate가 클래스 컴포넌트 전용 라이프사이클이라는 점입니다. 함수형 컴포넌트에는 useEffect, useLayoutEffect, useInsertionEffect 같은 hook이 있지만 DOM mutation 직전에 레이아웃을 읽고 그 값을 mutation 이후 단계로 넘겨주는 hook은 제공되지 않습니다. 특히 useInsertionEffect는 CSS-in-JS처럼 스타일을 주입하는 용도에 가깝고, DOM ref나 레이아웃 측정을 안정적으로 다루기 위한 API는 아닙니다.
이 빈자리는 저만 느낀 것은 아니었습니다. 리액트 공식 저장소에도 Hook equivalent for getSnapshotBeforeUpdate (github.com, 새 창) 이슈가 올라와 있습니다. 이슈의 예시 역시 스크롤 위치를 보존하기 위해 업데이트 직전의 scrollHeight과 scrollTop 값을 읽고 업데이트 이후 다시 scrollTop을 조정하는 로직을 다루고 있습니다.
이 사례에서도 볼 수 있듯이 getSnapshotBeforeUpdate의 필요성은 FLIP 같은 애니메이션에만 한정되지 않습니다. DOM이 바뀌기 직전의 값을 읽고 바뀐 직후에 그 값을 사용한다는 패턴은 스크롤 보존이나 레이아웃 보정처럼 사용자의 시각적 연속성을 유지해야 하는 UI에서 반복해서 등장합니다. 다만 함수형 컴포넌트 중심의 코드에서는 이 시점을 직접 다룰 공식 API가 없었던 것 뿐입니다.
react-snapshot-bridge의 접근
react-snapshot-bridge는 이 빠진 시점을 함수형 컴포넌트의 훅을 통해 흉내내려고 하지 않았습니다(완벽하게 대체하기는 불가능하니까요!). 대신 리액트가 이미 제공하는 클래스 라이프사이클을 아주 작은 컴포넌트 안에 가두고, 함수형 컴포넌트 트리에서 사용할 수 있는 래퍼로 노출했습니다.
<SnapshotBeforeUpdate
capture={() => {
const el = scrollerRef.current!;
return el.scrollHeight - el.scrollTop;
}}
apply={(prevDistanceFromBottom) => {
const el = scrollerRef.current!;
el.scrollTop = el.scrollHeight - prevDistanceFromBottom;
}}
>
{/* ... */}
capture는 내부 클래스 컴포넌트의 getSnapshotBeforeUpdate에서 호출됩니다. 그래서 DOM mutation 직전의 값을 읽을 수 있습니다. apply는 componentDidUpdate에서 호출됩니다. 그래서 DOM이 바뀐 직후(페인트 단계 전)에 snapshot을 사용해 필요한 보정을 적용할 수 있습니다.
이 방식은 클래스형 컴포넌트의 동작 방식과 매우 유사합니다. 아래 클래스형 컴포넌트의 componentDidUpdate에서 getSnapshotBeforeUpdate의 스냅샷을 그대로 사용할 수 있듯이 apply에서도 capture 함수의 리턴값을 그대로 사용할 수 있습니다.
class List extends React.Component {
getSnapshotBeforeUpdate() {
const el = this.listRef.current;
return el.scrollHeight - el.scrollTop;
}
componentDidUpdate(_prevProps, _prevState, snapshot) {
const el = this.listRef.current;
el.scrollTop = el.scrollHeight - snapshot;
}
}구현은 의도적으로 작게 유지했습니다. 사용자가 클래스 컴포넌트로 돌아가도록 요구하는 대신 함수형 컴포넌트 안에 작은 bridge를 하나 배치하는 방식을 지향했습니다. 사용자는 여전히 함수형 컴포넌트와 훅을 중심으로 코드를 작성하고 필요한 순간에만 이 컴포넌트를 빌려올 수 있습니다.
사용에는 주의 사항이 있긴 합니다. SnapshotBeforeUpdate는 자기 자신(fiber)이 업데이트될 때 라이프사이클을 실행합니다. 따라서 특정 컴포넌트의 DOM 변화를 관찰하려면 이 bridge 컴포넌트는 그 컴포넌트의 렌더 결과 안에서 함께 업데이트되어야 합니다. 그리고 클래스형 컴포넌트의 라이프사이클 특성상 최초의 마운트 시점에는 getSnapshotBeforeUpdate가 호출되지 않습니다. 직전의 DOM이 없으니까요!
사례로 보는 `SnapshotBeforeUpdate`: 스크롤 보존하기
제가 작성한 문서의 데모 (react-snapshot-bridge.vercel.app, 새 창)에서는 메시지 리스트 위쪽에 새 메시지를 추가하는 상황을 다룹니다. 사용자가 리스트 중간을 읽고 있는데 위쪽에 새 항목이 추가되면 기본 동작에서는 전체 콘텐츠가 아래로 밀리면서 사용자가 읽던 내용도 함께 움직입니다. 기능적으로는 문제가 없어 보일 수 있지만 사용자는 읽던 맥락을 잃어버립니다. 사용자 경험을 저해할 수 있는 상황인 것이죠.

이 문제를 해결하려면 업데이트 직전에 현재 스크롤 컨테이너에서 하단까지의 거리를 저장해 두고, 업데이트 직후 새 scrollHeight를 기준으로 scrollTop을 다시 맞춰야 합니다.
capture={() => {
const el = scrollerRef.current;
if (!el) return 0;
return el.scrollHeight - el.scrollTop;
}}
apply={(prevDistanceFromBottom) => {
const el = scrollerRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight - prevDistanceFromBottom;
}}여기서 핵심은 이전 scrollHeight와 이전 scrollTop입니다.useLayoutEffect에서 읽으면 이미 새 메시지가 DOM에 들어간 뒤이므로 이전 스크롤 상태를 기준으로 계산하기 어렵습니다. 사용자가 보고 있던 콘텐츠를 안정적으로 고정하려면 DOM이 바뀌기 직전의 값이 필요합니다. 이러한 이슈를 react-snapshot-bridge는 안정적으로 해결합니다! 데모를 통해 이 차이를 느껴보세요. 👉데모 (react-snapshot-bridge.vercel.app, 새 창)
마무리하며

함수형 컴포넌트로 넘어오면서 대부분의 라이프사이클은 훅으로 자연스럽게 치환되었지만 getSnapshotBeforeUpdate만큼은 아직 완전히 대응되는 도구가 없는 상태입니다. react-snapshot-bridge는 거창한 것이 아니라 단순히 기존의 리액트 라이프사이클을 이용하여 이 빈 틈을 메꾼 라이브러리입니다.
모든 상황에서 필요한 라이브러리는 아닐 수 있습니다. 위에서 FLIP 애니메이션이나 스크롤 보존을 예시로 들기는 했지만 사실 굉장히 니치한 영역이긴 합니다. 그렇지만디.getSnapshotBeforeUpdate 라이프사이클이 꼭 필요한 지점은 분명히 존재합니다. 정확한 타이밍에 리액트 라이프사이클의 해당 단계를 포착해야 하는 요구 사항이 있다면 사용을 한 번쯤 고려해보세요**(물론 저도 FLIP 애니메이션을 구현하면서 이 라이브러리를 사용하려 합니다!)**.
긴 글 읽어 주셔서 감사드리고, 혹시 라이브러리가 궁금하시다면 아래 링크에서 확인해주세요.
(enabled 프로퍼티를 추가한 v1.1을 배포했습니다 - 이제 기능을 자유롭게 키고 끌 수 있어요)
💾 NPM (npmjs.com, 새 창) 📚 문서 (react-snapshot-bridge.vercel.app, 새 창).