---
title: "getSnapshotBeforeUpdate: 함수형 컴포넌트에서 사라진 한 시점에 대한 이야기"
description: "함수형 컴포넌트에서 getSnapshotBeforeUpdate가 필요한 이유. 그리고 react-snapshot-bridge를 만든 이유."
date: 2026-05-03
tags: [react, performance]
sections: [technical]
draft: false
---

> 최근 FLIP 애니메이션 구현을 위해 기존 라이브러리를 분석했습니다. FLIP 라이브러리에서 사용되는 **getSnapshotBeforeUpdate를 함수형 컴포넌트에서 자연스럽게 사용할 수 있도록 react-snapshot-bridge 패키지를 배포**하며 react의 렌더링 방식을 다시 정리해보았습니다.

최근 [`react-snapshot-bridge` 라이브러리](https://www.npmjs.com/package/react-snapshot-bridge?activeTab=readme)를 배포했습니다. 0.5KB가 채 안되는 아주 작은 라이브러리입니다. 단순해보이는 이 라이브러리의 힘은 함수형 컴포넌트와의 결합에서 빛을 발합니다. 리액트의 함수형 컴포넌트에서 **클래스 컴포넌트 전용 라이프사이클인 `getSnapshotBeforeUpdate`를 사용**할 수 있도록 돕기 때문입니다!

처음부터 `getSnapshotBeforeUpdate`를 라이브러리로 감싸야겠다고 생각한 것은 아니었습니다. FLIP 애니메이션의 구현 사례들을 분석하면서 **DOM이 바뀌기 직전의 레이아웃**을 읽어야 하는 시점이 필요했고, 함수형 컴포넌트의 훅으로는 풀어내기 어려운 구간을 발견했습니다. 

함수형 컴포넌트의 라이프사이클에서는 아직 `getSnapshotBeforeUpdate` 훅을 **완벽하게 대체할 수 있는 무언가가 없는 상태**였습니다. 실제로 커뮤니티에서도 이 필요성에 대한 목소리는 꾸준히 존재했지만 5년이 더 지난 지금까지도 리액트의 대응에는 진전이 없는 상태였습니다.

| 함수형 컴포넌트에서 getSnapshotBeforeUpdate 대응을 요구하는 목소리 |
| :-: |
| ![2020년부터 꾸준히 getSnapshotBeforeUpdate 대응을 요구하는 목소리](https://velog.velcdn.com/images/ksr20612/post/6564b71f-ee5a-4537-85ec-e8ebb6fed298/image.png) |
| 참고: https://github.com/facebook/react/issues/15221 |

그래서 클래스형 컴포넌트를 감싼 래퍼 라이브러리 `react-snapshot-bridge`를 만들었습니다. 별도의 클래스형 컴포넌트를 생성하여 이 시점을 포착할 필요 없이 그냥 **함수형 컴포넌트 스타일에 이 라이브러리에서 제공하는 ` ` 컴포넌트를 살짝 끼워 넣어주면 됩니다.**

이번 글에서는 리액트의 커밋 단계를 돌아보며 이 라이브러리가 왜, 언제 필요한지 그 탄생 배경을 같이 정리해보려 합니다.


## 이전 DOM이 필요한 FLIP 애니메이션

먼저 FLIP 애니메이션에 대해서 살짝 짚고 넘어가는 것이 필요해 보입니다. FLIP 애니메이션을 구현하면서 필요성을 절실하게 느꼈으니까요. 

**FLIP**은 **First, Last, Invert, Play**의 약자입니다. 먼저 변화 전의 위치와 크기인 최초의 레이아웃을 측정(`First`)하고, DOM 업데이트 이후의 위치와 크기인 최종 레이아웃을 다시 측정(`Last`)합니다. 그 다음 두 값의 차이를 계산해 대상을 시각적으로 이전 위치에 되돌려 놓고(`Invert`), transform을 풀면서 자연스럽게 이동하는 것처럼 보이게 만드는 방식입니다(`Play`). `framer-motion` 등 리액트 진영에서 주로 사용되는 많은 애니메이션 라이브러리들에서 해당 방식을 차용하고 있습니다.

| FLIP 기법을 이용한 애니메이션 사례 |
| :-: |
| ![FLIP 기법을 이용한 애니메이션 사례](https://github.com/aholachek/react-flip-toolkit/raw/master/example-assets/stripe-menu.gif) |
| 출처: https://github.com/aholachek/react-flip-toolkit#nested-scale-transforms |

중요한 점은 이 최초의 레이아웃이 **단순한 이전 데이터가 아니라는 것입니다.** 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 Mutation 직전 호출되는 getSnapshotBeforeUpdate |
| :-: |
| ![리액트의 렌더 & 커밋 단계](https://velog.velcdn.com/images/ksr20612/post/08bb210d-5baf-4e52-ad01-fd6a2561754b/image.png) |
| 출처: https://github.com/donavon/hook-flow |

이 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`](https://github.com/facebook/react/issues/15221) 이슈가 올라와 있습니다. 이슈의 예시 역시 스크롤 위치를 보존하기 위해 업데이트 직전의 `scrollHeight`과 `scrollTop` 값을 읽고 업데이트 이후 다시 `scrollTop`을 조정하는 로직을 다루고 있습니다.

이 사례에서도 볼 수 있듯이 `getSnapshotBeforeUpdate`의 필요성은 FLIP 같은 애니메이션에만 한정되지 않습니다. **DOM이 바뀌기 직전의 값을 읽고 바뀐 직후에 그 값을 사용한다**는 패턴은 스크롤 보존이나 레이아웃 보정처럼 사용자의 시각적 연속성을 유지해야 하는 UI에서 반복해서 등장합니다. 다만 **함수형 컴포넌트 중심의 코드에서는 이 시점을 직접 다룰 공식 API가 없었던 것 뿐입니다.**

## react-snapshot-bridge의 접근

`react-snapshot-bridge`는 이 빠진 시점을 **함수형 컴포넌트의 훅을 통해 흉내내려고 하지 않았습니다(완벽하게 대체하기는 불가능하니까요!)**. 대신 리액트가 이미 제공하는 클래스 라이프사이클을 아주 작은 컴포넌트 ` ` 안에 가두고, **함수형 컴포넌트 트리에서 사용할 수 있는 래퍼로 노출**했습니다.

```tsx
<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` 함수의 리턴값을 그대로 사용할 수 있습니다.** 

```tsx
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`: 스크롤 보존하기

제가 작성한 [문서의 데모](https://react-snapshot-bridge.vercel.app/docs#live-demo)에서는 메시지 리스트 위쪽에 새 메시지를 추가하는 상황을 다룹니다. 사용자가 리스트 중간을 읽고 있는데 위쪽에 새 항목이 추가되면 기본 동작에서는 **전체 콘텐츠가 아래로 밀리면서 사용자가 읽던 내용도 함께 움직입니다.** 기능적으로는 문제가 없어 보일 수 있지만 사용자는 읽던 맥락을 잃어버립니다. **사용자 경험을 저해할 수 있는 상황인 것이죠.**

| SnapshotBeforeUpdate 활용하기: 스크롤 보존 |
| :-: |
| ![스크롤 보존을 위해 SnapshotBeforeUpdate 활용하기](https://velog.velcdn.com/images/ksr20612/post/c85db928-cf5c-448e-a249-7a3ac6f5cec4/image.gif) |


이 문제를 해결하려면 업데이트 직전에 현재 스크롤 컨테이너에서 하단까지의 거리를 저장해 두고, 업데이트 직후 새 `scrollHeight`를 기준으로 `scrollTop`을 다시 맞춰야 합니다.

```tsx
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`는 안정적으로 해결합니다! 데모를 통해 이 차이를 느껴보세요. [👉​데모](https://react-snapshot-bridge.vercel.app/docs#live-demo)

## 마무리하며

| react-snapshot-bridge |
| :-: |
| ![react-snapshot-bridge](https://react-snapshot-bridge.vercel.app/logo.png) |

함수형 컴포넌트로 넘어오면서 대부분의 라이프사이클은 훅으로 자연스럽게 치환되었지만 `getSnapshotBeforeUpdate`만큼은 아직 완전히 대응되는 도구가 없는 상태입니다. `react-snapshot-bridge`는 거창한 것이 아니라 단순히 기존의 리액트 라이프사이클을 이용하여 이 빈 틈을 메꾼 라이브러리입니다.

모든 상황에서 필요한 라이브러리는 아닐 수 있습니다. 위에서 FLIP 애니메이션이나 스크롤 보존을 예시로 들기는 했지만 **사실 굉장히 니치한 영역**이긴 합니다. 그렇지만디.`getSnapshotBeforeUpdate` 라이프사이클이 꼭 필요한 지점은 분명히 존재합니다. 정확한 타이밍에 리액트 라이프사이클의 해당 단계를 포착해야 하는 요구 사항이 있다면 사용을 한 번쯤 고려해보세요**(물론 저도 FLIP 애니메이션을 구현하면서 이 라이브러리를 사용하려 합니다!)**.

긴 글 읽어 주셔서 감사드리고, 혹시 라이브러리가 궁금하시다면 아래 링크에서 확인해주세요.
(`enabled` 프로퍼티를 추가한 v1.1을 배포했습니다 - 이제 기능을 자유롭게 키고 끌 수 있어요)

[💾​ NPM](https://www.npmjs.com/package/react-snapshot-bridge)
[📚​ 문서](https://react-snapshot-bridge.vercel.app/docs).
