들어가며
최근에 관리자 로그인 상태를 읽는 코드를 정리하다가
useSyncExternalStore를 다시 보게 됐다. (하고싶은거 다하려고 블로그 만들었슨..)
이번 글에서는 React 공식 문서의 useSyncExternalStore 설명을 기준으로,
이 세 함수가 각각 어떤 책임을 가지는지 정리하려 한다.
먼저 공식 문서가 말하는 것
React 공식 문서는 useSyncExternalStore를
"외부 저장소를 구독하기 위한 훅"으로 설명한다.
여기서 외부 저장소는 React state가 아니라 React 바깥에서 값이 바뀌는 대상을
뜻한다. 예를 들면 다음과 같다.
- 서드파티 상태 관리 라이브러리
- 브라우저 API
- localStorage 처럼 React 밖에 있는 값
공식 문서의 시그니처는 아래와 같다.
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot?
);
React는 이 세 자리에서 각각 다른 역할을 기대한다.
이 훅은 React 몇 버전부터 있나
useSyncExternalStore는 React 18에서 추가된 훅이다. React 공식
업그레이드 가이드는 이 API를 React 18의 새 라이브러리용 API 중
하나로 소개한다.
공식 설명을 요약하면, 이 훅은 외부 store가 concurrent rendering 환경에서도 안전하게 동작하도록 돕기 위해 만들어졌다. 특히 React 팀은 이 훅을 React 바깥 상태와 통합하는 라이브러리에 권장한다고 설명한다.
즉, 이 훅은 원래부터 모든 앱 코드에서 자주 쓰라고 나온 도구라기보다, Redux류의 외부 상태 저장소나 브라우저 API 같은 값을 React 렌더링과 안전하게 연결하기 위해 도입된 API에 가깝다.
공식 문서가 말하는 도입 배경
React 18은 concurrent rendering 관련 기반이 들어오면서, React 밖에 있는 값을
읽는 방식도 더 엄격하게 다룰 필요가 생겼다. React 공식 업그레이드 가이드는 이
맥락에서 useSyncExternalStore를 소개하면서, 외부 store 업데이트를
동기적으로 읽을 수 있게 해 준다고 설명한다.
이 설명을 auth 상태에 대입해 보면 왜 단순히
localStorage.getItem()만 호출하는 코드와는 결이 다른지 이해가
쉽다. 단순 조회가 아니라, React가 렌더링 중에도 일관된 스냅샷을 읽고
변경을 추적할 수 있게 만드는 계약이 이 훅의 핵심이기 때문이다.
subscribe는 왜 필요한가
공식 문서에 따르면 subscribe는 저장소가 바뀔 때 React에게
알려주는 함수다. 이 함수는 콜백 하나를 인자로 받고, 저장소가 변경되면 그 콜백을
호출해야 한다. 그리고 마지막에는 반드시 구독 해제 함수를 반환해야 한다.
function subscribe(callback) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
이 예시는 React 공식 문서가 브라우저 API를 설명할 때 사용하는 패턴과 같은 구조다. 핵심은 단순하다. React는 외부 저장소가 언제 바뀌는지 스스로 알 수 없기 때문에, 그 변화를 알려줄 연결 고리가 필요하다.
() => undefined가 왜 들어가나
어떤 코드에서는 아래처럼 더미 구독 함수를 보게 된다.
function subscribeToAuth() {
return () => undefined;
}
이 코드는 "실제로는 아무 것도 구독하지 않지만, 함수 시그니처는 맞춘다"는 의미에
가깝다. React 공식 문서 기준으로 보면
subscribe는 구독 해제 함수를 반환해야 하므로,
최소한의 형태로 빈 cleanup 함수를 돌려주는 것이다.
다만 여기서 중요한 점이 있다. 이 구현은 타입이나 시그니처는 맞추지만,
공식 문서가 의도한 "변경 알림"은 제공하지 않는다. 즉, auth
상태가 바뀌어도 React가 자동으로 다시 읽을 근거가 없다. 현재 화면에서
window.location.reload() 같은 강제 새로고침에 의존한다면
동작은 할 수 있지만, 외부 저장소를 정석적으로 구독하고 있다고 보기는 어렵다.
getSnapshot은 무슨 역할을 하나
getSnapshot은 현재 저장소 값을 읽는 함수다. 공식 문서 표현대로
말하면, 컴포넌트가 렌더링에 필요한 현재 스냅샷을 읽는 자리다.
function getSnapshot() {
return isLoggedIn();
}
auth 헤더 사례에서는 보통 이 함수가 localStorage나 브라우저 기반
토큰 저장소를 읽어 현재 로그인 여부를 boolean으로 반환하게 된다.
공식 문서는 getSnapshot이 같은 값이라면 같은 결과를 반환해야 한다고
설명한다. 값이 바뀌지 않았는데 매번 다른 결과를 만들면 React가 안정적으로 비교할
수 없기 때문이다.
getServerSnapshot은 왜 필요한가
이 부분이 서버 렌더링 환경에서는 특히 중요하다. 공식 문서에 따르면
getServerSnapshot은 두 번 쓰인다.
- 서버가 초기 HTML을 만들 때
- 클라이언트가 hydration 할 때
function getServerAdminSnapshot() {
return false;
}
이 함수가 필요한 이유는 서버에서는 브라우저 API를 읽을 수 없기 때문이다. 예를
들어 isLoggedIn()이 내부에서 window나
localStorage를 본다면, 서버에서는 그대로 실행할 수 없다.
그래서 서버 렌더링 단계에서는 "일단 관리자 아님"처럼 안전한 초깃값을 주는 것이다.
React 공식 문서도 서버 렌더링을 지원할 때는 세 번째 인자로
getServerSnapshot을 넘기라고 설명한다.
여기서 hydration mismatch와 연결된다
공식 문서는 getServerSnapshot이 서버와 클라이언트의 초기 렌더에서
같은 값을 반환해야 한다고 강조한다. 서버는 false로
그렸는데, 클라이언트의 첫 hydration 렌더에서 바로 true가 나오면
HTML이 달라지고 mismatch가 발생할 수 있다.
그래서 auth처럼 브라우저 전용 저장소에 의존하는 값은 서버와 hydration 초기에 동일한 기준값을 먼저 쓰고, 그 다음에 외부 저장소를 읽어 갱신하는 방식이 중요하다.
그럼 auth 상태에선 어떻게 이해하면 좋을까
이 세 함수를 auth 예시로 번역하면 아래처럼 볼 수 있다.
subscribe: 로그인 상태가 바뀌면 React에게 알려주는 연결부getSnapshot: 지금 로그인 상태가 어떤지 읽는 함수getServerSnapshot: 서버와 hydration 단계에서 쓸 안전한 초깃값
이 기준으로 보면, 단순히 빈 함수만 반환하는 subscribeToAuth는
"자동 갱신 없는 임시 구현"에는 가깝지만 "외부 저장소 구독"의 완성형은 아니다.
공식 문서 기준으로 더 자연스러운 형태
만약 auth 상태를 정말 외부 저장소처럼 다루고 싶다면, 변경 시점에 실제로
callback을 호출하는 구조가 더 문서 취지에 맞다. 예를 들어
storage 이벤트나 커스텀 이벤트를 연결하는 방식이다.
import { useSyncExternalStore } from "react";
const AUTH_CHANGE_EVENT = "auth-change";
export function useAuthStatus() {
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
}
function subscribe(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(AUTH_CHANGE_EVENT, callback);
return () => {
window.removeEventListener("storage", callback);
window.removeEventListener(AUTH_CHANGE_EVENT, callback);
};
}
function getSnapshot() {
return isLoggedIn();
}
function getServerSnapshot() {
return false;
}
로그인이나 로그아웃 처리 코드에서는 토큰을 저장한 뒤
window.dispatchEvent(new Event("auth-change"))처럼 직접 변경
이벤트를 발생시킬 수 있다. 그러면 React는 외부 저장소가 바뀌었다는 사실을 알고,
getSnapshot을 다시 호출해 화면을 갱신한다.
언제는 useSyncExternalStore를 쓰지 않는 편이 나을까
React 공식 문서의 다른 가이드인
You Might Not Need an Effect
는 불필요한 Effect를 줄이라고 설명하지만, 그렇다고 모든 상황을
useSyncExternalStore로 바꾸라는 뜻은 아니다.
외부 저장소처럼 React 바깥 값이 시간에 따라 바뀌고, 그 변경을 구독할 수 있을 때 이 훅이 적합하다. 반대로 단순히 한 번 읽고 끝나는 값이거나, 이벤트 핸들러에서 직접 처리하면 충분한 값이라면 더 단순한 구조가 나을 수 있다.
정리
-
subscribe는 외부 저장소 변경을 React에 알려주고, cleanup 함수를 반환해야 한다. -
() => undefined는 "정리할 구독이 없는 빈 cleanup 함수"일 뿐이다. -
getServerSnapshot은 서버 렌더와 hydration 초깃값을 맞추기 위한 함수다. - auth 상태처럼 브라우저 저장소 기반 값은 서버에서 직접 읽지 못하므로, 안전한 초기값이 필요하다.
-
실제 변경 알림이 없는 더미
subscribe는 임시방편일 수는 있어도, 공식 문서가 의도한 외부 저장소 구독의 완성형은 아니다.