Next.Js를 이용하여 간단한 미니 블로그 프로젝트를 진행하고 있었다. 사용자가 게시글에 좋아요 버튼을 누르면 화면상에서 좋아요 버튼의 배경색을 변하게 만들어야 했었고, 이를 React에서 하던 방식대로 동적 class를 할당함으로 구현하고 있었다.
기존의 코드를 보자.
Like.tsx
interface IProps {
post: IPost;
type?: "postPage";
}
function Like({ type, post }: IProps): JSX.Element {
const { handleClickLike } = useLike();
const { userId } = useUserIdStore();
// API의 응답인 post에 현재 사용자의 userId와 일치하는 userId가 있는지 조회해서 좋아요 여부를 판단.
const isLiked = post.likeResponses.some((like) => like.userId === userId);
// 이후 isLiked값에 따라 class를 동적으로 할당.
const likeImageClass = `${styles.likeImage} ${isLiked ? styles.liked : null}`;
return (
<>
<Image
alt="like"
src={likeImage}
onClick={() => {
handleClickLike(post?.id);
}}
className={likeImageClass}
/>
{type !== "postPage" && (
<div className={styles.number}>{post.likeResponses.length}</div>
)}
</>
);
}
export default Like;
useLike.ts
interface IUseLike {
handleClickLike: (postId: string | undefined) => void;
}
function useLike(): IUseLike {
const router = useRouter();
const handleClickLike = async (postId: string | undefined) => {
try {
const { data } = await likeApi(postId);
router.refresh();
const JWTExpired = "JWT expired";
if (data.includes(JWTExpired)) {
alert("로그인이 만료되었습니다. 재 로그인해주세요.");
return;
}
} catch (error) {
console.error(error);
}
};
return { handleClickLike };
}
export default useLike;
콘솔 창을 보니 에러가 발생했다.
app-index.js:32 Warning: Prop `className` did not match. Server: "Like_likeImage__3rm_u null" Client: "Like_likeImage__3rm_u Like_liked__LYfmH"
at img
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/image-component.js:129:11)
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/image-component.js:238:47)
at Like (webpack-internal:///(app-pages-browser)/./src/components/atoms/like/Like.tsx:18:11)
at div
at Reaction (webpack-internal:///(app-pages-browser)/./src/components/molecules/reaction/Reaction.tsx:12:11)
at div
at MainContentContainer (webpack-internal:///(app-pages-browser)/./src/components/organisms/mainContentContainer/MainContentContainer.tsx:14:11)
at div
at div
at ContentContainer (webpack-internal:///(app-pages-browser)/./src/components/organisms/contentContainer/ContentContainer.tsx:12:11)
at div
at HomeMain (webpack-internal:///(app-pages-browser)/./src/components/organisms/homeMain/HomeMain.tsx:18:11)
at div
at div
at InnerLayoutRouter (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js:241:11)
at RedirectErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/redirect-boundary.js:72:9)
at RedirectBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/redirect-boundary.js:80:11)
at NotFoundErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/not-found-boundary.js:54:9)
at NotFoundBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/not-found-boundary.js:62:11)
at LoadingBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js:338:11)
at ErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:110:11)
at InnerScrollAndFocusHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js:152:9)
at ScrollAndFocusHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js:227:11)
at RenderFromTemplateContext (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js:15:44)
at OuterLayoutRouter (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js:348:11)
at main
at body
at html
at RedirectErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/redirect-boundary.js:72:9)
at RedirectBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/redirect-boundary.js:80:11)
at NotFoundErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/not-found-boundary.js:54:9)
at NotFoundBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/not-found-boundary.js:62:11)
at DevRootNotFoundBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/dev-root-not-found-boundary.js:32:11)
at ReactDevOverlay (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/internal/ReactDevOverlay.js:66:9)
at HotReload (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:294:11)
at Router (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:157:11)
at ErrorBoundaryHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:82:9)
at ErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:110:11)
at AppRouter (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:440:13)
at ServerRoot (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/app-index.js:126:11)
at RSCComponent
at Root (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/app-index.js:142:11)
이 문제의 원인은 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR) 간의 className 일관성 문제였다. Next.js 프로젝트에서 SSR은 초기 페이지 로드 시 사용되고, 이후의 상호작용은 CSR을 통해 처리되는데 이 때 서버 사이드와 클라이언트 사이드에서 동일한 컴포넌트가 다른 상태 값을 가지고 렌더링되었던 것이다.
서버 사이드에서는 사용자의 좋아요 상태를 알 수 없기 때문에 isLiked가 true가 아닌 값으로 설정되었고, 따라서 liked 스타일이 적용되지 않았다. 하지만 클라이언트 사이드에서는 사용자의 상태에 따라 isLiked가 true로 설정되어 liked 스타일이 적용되었다. 이로 인해 클라이언트 사이드에서 페이지가 로드된 후 class가 변경되었고, 이것이 불일치를 경고하는 원인이 되었던 것이다.
Hydration은 서버 사이드 렌더링(SSR)에서 생성된 정적 HTML이 클라이언트 사이드에서 React 컴포넌트로 "활성화"되는 과정을 의미한다. 쉽게 말해 껍데기뿐인 정적인 웹에 Js를 불어넣어 동적인 웹으로 바꾸어 주는 것이라 이해하면 될 것이다. Next.js와 같은 SSR을 사용하는 프레임워크에서는 이 Hydration 과정이 중요한 역할을 한다.
대략적인 순서
1번에서는 isLiked의 상태가 정해져있지 않았는데 3번에서 상태를 업데이트 하였기 때문에 1번시점과 3번시점에서의 불일치로 인해 에러가 발생한 것이다.
이 문제를 해결하기 위해 다음과 같은 접근 방법을 사용했다
우선, useState를 사용하여 isLiked의 초기 상태를 false로 설정했다. 이는 컴포넌트가 마운트될 때 일관된 초기 상태를 제공한다. 왜냐하면 서버 사이드 렌더링은 useState의 초기값에 기반하여 렌더링 되기 때문이다.
useEffect 훅을 사용하여 post와 userId가 변경될 때마다 isLiked 상태를 다시 계산한다. 이는 서버 사이드 렌더링 이후 클라이언트 사이드에서 실제 사용자의 상태에 기반하여 isLiked를 업데이트하게 됨을 의미한다. 이렇게 함으로써 클라이언트 사이드에서 컴포넌트가 마운트된 후 사용자의 실제 '좋아요' 상태를 반영할 수 있다.
사용자가 '좋아요' 버튼을 클릭할 때마다 handleLike 함수가 실행되게끔 하였다. 이 함수 내에서는 먼저 setIsLiked를 호출하여 상태를 반대로 전환한 후, handleClickLike를 호출하여 서버에 '좋아요' 상태를 업데이트했다. 이 과정에서 발생할 수 있는 예외를 고려하여, 오류가 발생하면 이전 상태로 롤백하도록 처리했다.
Like.tsx
interface IProps {
post: IPost;
type?: "postPage";
}
function Like({ type, post }: IProps): JSX.Element {
// 상태를 useState로 관리 (초기값 false로 설정)
const [isLiked, setIsLiked] = useState<boolean>(false);
const { handleLike } = useLike({ setIsLiked, isLiked, post });
const { userId } = useUserIdStore();
// useEffect를 이용하여 클라이언트 사이드에서 렌더링이 된 이후에 상태 업데이트
useEffect(() => {
setIsLiked(post.likeResponses.some((like) => like.userId === userId));
}, [post, userId]);
// 클라이언트 사이드에서 업데이트 된 상태를 이용하여 동적 클래스 할당
const likeImageClass = `${styles.likeImage} ${isLiked ? styles.liked : null}`;
return (
<>
<Image
alt="like"
src={likeImage}
onClick={handleLike}
className={likeImageClass}
/>
{type !== "postPage" && (
<div className={styles.number}>{post.likeResponses.length}</div>
)}
</>
);
}
export default Like;
useLike.ts
interface IUseLike {
handleClickLike: (postId: string | undefined) => void;
handleLike: () => void;
}
interface IProps {
setIsLiked: Dispatch<SetStateAction<boolean>>;
isLiked: boolean;
post: IPost;
}
function useLike({ setIsLiked, isLiked, post }: IProps): IUseLike {
const router = useRouter();
const handleClickLike = async (postId: string | undefined) => {
try {
const { data } = await likeApi(postId);
router.refresh();
const JWTExpired = "JWT expired";
if (data.includes(JWTExpired)) {
alert("로그인이 만료되었습니다. 재 로그인해주세요.");
return;
}
} catch (error) {
console.error(error);
}
};
// 추가된 함수!
const handleLike = () => {
try {
// 클릭시 상태를 즉시 반대로 전환
setIsLiked(!isLiked);
// 이후에 API 통신
handleClickLike(post?.id);
} catch (error) {
// 에러 발생시 이전의 상태로 재 업데이트
setIsLiked(isLiked);
}
};
return { handleClickLike, handleLike };
}
export default useLike;
Next.js와 같은 SSR을 지원하는 프레임워크에서는 서버와 클라이언트 간의 렌더링 일관성을 유지하는 것이 중요하다는 것을 깨달았다. 이번 사례를 통해 useState와 useEffect를 활용하여 SSR과 CSR 간의 상태 불일치 문제를 해결하는 방법을 배울 수 있었으며, 이러한 접근 방식은 다른 상황에서도 유용하게 적용될 수 있을 것이다.