
본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기.
SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
블로그의 "최근 읽은 글"과 "포스트 탐색기" 컴포넌트에서 하이드레이션 에러가 터졌다.
원인은 서버에서는 빈 목록을 그렸는데, 클라이언트의 첫 렌더링에서는 localStorage에 저장된 글을 바로 읽어 다른 HTML을 만든 것이었다. 이 사례에서는 서버와 하이드레이션 중에 같은 빈 스냅샷을 쓰고, 하이드레이션이 끝난 뒤 브라우저의 값을 읽도록 바꿨다.
Uncaught Error: Hydration failed because the server rendered HTML
didn't match the client.에러 메시지의 diff를 보면 원인이 명확하다.
// RecentlyRead 컴포넌트
+ <div className="flex gap-2 overflow-x-auto ..."> ← 클라이언트
- <p className="text-sm text-gray-500 ..."> ← 서버
// PostExplorer의 FileItem 컴포넌트
+ className="... text-gray-400 dark:text-gray-500" ← 클라이언트 (방문한 글)
- className="... text-gray-800 dark:text-gray-200" ← 서버 (미방문)서버에서는 "읽은 글 없음" 상태로 렌더링되고, 클라이언트에서는 localStorage에 저장된 데이터로 렌더링된다. 두 결과가 다르니 React가 하이드레이션 에러를 던진 것이다.
두 컴포넌트 모두 getReadPosts()를 호출해서 읽은 글 목록을 가져온다. 이
함수의 구현을 보자.
export function getReadPosts(): ReadRecord[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as ReadRecord[]) : [];
} catch {
return [];
}
}서버에서는 window가 없으므로 빈 배열을 반환한다. 클라이언트에서는
localStorage에서 데이터를 읽어 반환한다.
하이드레이션의 핵심 규칙은 이것이다 — 서버에서 렌더링한 HTML과 클라이언트의 첫 번째 렌더링 결과가 동일해야 한다.
서버는 빈 배열 → "열람한 페이지가 없습니다" <p> 태그를 렌더링하고,
클라이언트는 localStorage 데이터 → 카드 목록
Post Q&A
React 하이드레이션 오류: localStorage 읽기를 useSyncExternalStore로 다룬 사례 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
<div>흔히 알려진 해결법은 useState + useEffect 패턴이다.
export function RecentlyRead({ rootPath = "/" }: RecentlyReadProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const readPosts = mounted ? getReadPosts() : [];
// ...
}마운트 전까지는 빈 배열을 사용하고, useEffect로 마운트 후에 mounted를
true로 바꿔서 localStorage 데이터를 읽는다. 서버와 클라이언트 첫 렌더링이
같아지므로 하이드레이션 에러는 사라진다.
하지만 이 구현에서는 동기적인 effect 내부 상태 변경을 지적하는 경고가 나왔다.
Error: Calling setState synchronously within an effect
can trigger cascading renders이 경고는 모든 useState + useEffect 조합이 잘못됐다는 뜻은 아니다.
여기서는 외부 저장소를 읽기 위해 mounted 상태를 한 번 더 바꾸면서 추가
렌더링을 만들었다. React의 set-state-in-effect 규칙은 이런 동기적 상태
변경을 경고한다.
useSyncExternalStore는 React 18에서 도입된 Hook으로,
React 외부의 데이터 소스를 구독하기 위해 만들어졌다.
const value = useSyncExternalStore(
subscribe, // 외부 스토어가 변경될 때 호출될 콜백을 등록
getSnapshot, // 클라이언트에서 현재 값을 가져오는 함수
getServerSnapshot, // 서버에서 사용할 초기값을 반환하는 함수
);세 가지 인자를 받는다.
subscribe — 스토어가 변경될 때 React에게 알리는 구독 함수
getSnapshot — 클라이언트에서 현재 값을 읽는 함수
getServerSnapshot — SSR 시 사용할 값을 반환하는 함수
핵심은 세 번째 인자다. 서버 렌더링과 클라이언트의 하이드레이션 중
모두 getServerSnapshot을 사용한다. 두 시점의 초기 값이 같아야 HTML도
일치한다. 하이드레이션이 끝난 뒤 getSnapshot으로 브라우저의 값을 읽는다.
React가 불일치를 허용하는 게 아니라, 첫 렌더링의 값을 맞춘 뒤 전환하는 것이다.
useState + useEffect로도 초기 값을 맞출 수 있다. 다만 이 사례에서는
localStorage를 React 외부의 데이터 소스로 구독해야 했기 때문에
useSyncExternalStore가 문제의 경계를 더 직접적으로 표현했다.
localStorage는 다른 탭에서 변경될 때 storage 이벤트를 발생시킨다. 이걸
subscribe 함수로 사용한다.
function subscribeStorage(cb: () => void) {
window.addEventListener("storage", cb);
return () => window.removeEventListener("storage", cb);
}storage 이벤트는 값을 쓴 현재 탭에서는 발생하지 않고, 같은 출처의 다른 탭
등에 전달된다. 따라서 이 구독 함수만으로 현재 탭의 모든 쓰기 직후 갱신을
보장하지는 않는다. 현재 탭에서도 즉시 반영해야 한다면 쓰기 함수에서 별도의
알림을 보내는 방식이 필요하다.
const EMPTY_POSTS: ReadRecord[] = [];
const EMPTY_SET = new Set<string>();
export function RecentlyRead({ rootPath = "/" }: RecentlyReadProps) {
const readPosts = useSyncExternalStore(
subscribeStorage,
getReadPosts, // 클라이언트: localStorage에서 읽기
() => EMPTY_POSTS, // 서버: 빈 배열
);
const commentedPosts = useSyncExternalStore(
subscribeStorage,
getCommentedPosts,
() => EMPTY_SET,
);
// ...
}const EMPTY_POSTS: ReadRecord[] = [];
export function PostExplorer({ rootPath = "/" }: PostExplorerProps) {
const readPosts = useSyncExternalStore(
subscribeStorage,
getReadPosts,
() => EMPTY_POSTS,
);
const visitedHref = new Set(readPosts.map((p) => p.href));
// ...
}두 방식의 차이를 정리하면 이렇다.
[useState + useEffect]
서버 렌더링 → HTML (빈 데이터)
클라이언트 하이드레이션 → 첫 렌더링 (빈 데이터, 서버와 일치 ✓)
useEffect 실행 → setMounted(true) → 리렌더링 ← cascading render 경고!
두 번째 렌더링 → localStorage 데이터 표시
[useSyncExternalStore]
서버 렌더링 → HTML (getServerSnapshot = 빈 데이터)
클라이언트 하이드레이션 → getServerSnapshot = 빈 데이터 (서버와 일치 ✓)
하이드레이션 후 → getSnapshot = localStorage 데이터
외부 스토어가 달라졌으면 다시 렌더링이 방식도 저장된 값이 다르면 하이드레이션 후 화면을 다시 그린다. 렌더링이 한 번으로 끝나거나 빈 상태가 전혀 보이지 않는다고 약속할 수는 없다. 선택 이유는 렌더링 횟수 자체보다 외부 스토어의 스냅샷과 구독을 React에 명시하는 데 있다.
getSnapshot 함수는 호출될 때마다 동일한 참조를 반환하거나,
값이 실제로 변경되었을 때만 새 객체를 반환해야 한다. 매 호출마다 새 객체를
반환하면 React가 무한 리렌더링을 일으킨다.
The result of getSnapshot should be cached to avoid an infinite loop실제로 이 에러가 터졌다. getReadPosts()는 JSON.parse()로 매번 새 배열을
생성하고, getCommentedPosts()는 매번 new Set()을 생성한다.
useSyncExternalStore는 Object.is()로 이전 스냅샷과 새 스냅샷을 비교하는데,
매번 새 참조이므로 항상 "변경됨"으로 판단하고 리렌더링을 트리거한다. 리렌더링
→ getSnapshot 호출 → 새 참조 → 리렌더링 → 무한 루프.
해결법은 모듈 레벨 캐시를 두고, localStorage의 raw 문자열이 변경되었을 때만 새 객체를 생성하는 것이다.
const EMPTY_READ_POSTS: ReadRecord[] = [];
let _readPostsCache: ReadRecord[] = EMPTY_READ_POSTS;
let _readPostsRaw: string | null = null;
export function getReadPosts(): ReadRecord[] {
if (typeof window === "undefined") return EMPTY_READ_POSTS;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw !== _readPostsRaw) {
_readPostsRaw = raw;
_readPostsCache = raw ? (JSON.parse(raw) as ReadRecord[]) : [];
}
return _readPostsCache;
} catch {
return EMPTY_READ_POSTS;
}
}raw 문자열이 이전과 같으면 _readPostsCache를 그대로 반환한다. 같은
참조이므로 Object.is()가 true를 반환하고, React는 리렌더링을 건너뛴다.
getCommentedPosts()도 동일한 패턴으로 캐싱한다.
저장소를 읽을 수 없는 경우에도 매번 새 배열이나 Set을 만들지 않고 안정적인
빈 스냅샷을 반환해야 한다.
서버 스냅샷으로 사용하는 빈 배열과 빈 Set은 모듈 레벨 상수로 선언한다.
컴포넌트 안에서 () => []를 쓰면 호출할 때마다 새 참조가 생긴다.
서버와 하이드레이션에서 같은 초기 값을 나타내도록 안정적인 참조를 쓰는 편이 낫다.
// ✓ 모듈 레벨 상수 — 참조가 안정적
const EMPTY_POSTS: ReadRecord[] = [];
const EMPTY_SET = new Set<string>();
// △ 컴포넌트 안에서 인라인 — 호출할 때마다 새 참조
useSyncExternalStore(subscribe, getSnapshot, () => []);브라우저 전용 값을 읽는 모든 컴포넌트에 useSyncExternalStore가 필수인 것은
아니다. 이 사례처럼 컴포넌트 밖에서 바뀌는 값을 구독해야 할 때
적합하다. 컴포넌트 자체 상태로 충분하다면 useState도 선택지다.
이 Hook이 해결하는 문제는 두 가지다.
하이드레이션 불일치 방지 — 서버와 클라이언트 초기 렌더링에서
같은 getServerSnapshot 사용
외부 값 구독 — 저장소 변경 알림과 스냅샷 읽기를 연결
typeof window !== "undefined" 분기로 서버/클라이언트를 나누는 건 React가
의도를 모르는 상태에서 첫 렌더링 결과를 달라지게 할 수 있다.
useSyncExternalStore는 "이 데이터는 외부 스토어에서 온다"는 경계를 React에
알려준다. 그래도 일치하는 서버 스냅샷, 안정적인 클라이언트 스냅샷, 변경 알림은
직접 올바르게 구현해야 한다.
SEOJing 글을 소셜용 영상으로 따로 소비시키는 게 아니라, 포스트 상단 요약과 블로그 유입 장치로 연결하기 위해 summaryVideo frontmatter와 Supertonic3 기반 요약 쇼츠 파이프라인을 붙인 과정을 정리했다.