Next.js 정적 export에서 동적 기능 쓰기 — 클라이언트 컴포넌트로 조회수·좋아요·댓글 붙이기
서버 없이 정적 export로 배포한 블로그에 조회수·좋아요·댓글을 붙인 방법이에요. 클라이언트 컴포넌트가 런타임에 직접 fetch하고, 실패하면 조용히 기본값으로 떨어져 본문을 먼저 지키게 했어요.
이 블로그는 output: 'export'로 빌드해요. 빌드 결과는 정적 HTML 파일 묶음이라 돌아가는 서버가 없어요. 그런데 조회수·좋아요·댓글·방문자 통계는 본질적으로 런타임 데이터예요. 빌드 시점에는 알 수 없고, 사람이 페이지를 열 때마다 바뀌어요. 정적 사이트와 동적 데이터를 어떻게 한 페이지에 같이 둘지 정리했어요.
정적 페이지 안에 클라이언트 컴포넌트만 동적으로
해법은 페이지 대부분은 정적으로 두고, 동적인 부분만 'use client' 컴포넌트로 떼어내는 거예요. 서버가 없으니 이 컴포넌트들은 SSR도 ISR도 못 써요. 대신 브라우저에서 마운트된 뒤 직접 API를 호출해요. 호출 대상은 같은 도메인의 /api/*(Cloudflare Pages Functions)예요. 본문 HTML은 빌드 때 다 만들어 두고, 숫자만 나중에 채워 넣는 구조예요.
fetch 클라이언트는 따로 뺐어요. 핵심은 실패를 삼키는 거예요.
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
async function jget<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(BASE + path);
return (await res.json()) as T;
} catch {
return fallback;
}
}
export const getLikes = (slug: string) =>
jget<Likes>(`/api/likes/${encodeURIComponent(slug)}`, { count: 0, liked: false });API가 죽었거나 로컬에서 functions가 안 돌아도 fallback이 돌아와요. 블로그의 본분은 글을 읽는 거예요. 좋아요 API가 잠깐 안 된다고 본문이 깨지면 안 되니까, 부가 기능은 실패하면 조용히 0으로 떨어지게 했어요.
useEffect는 기명 함수로
런타임 fetch는 전부 useEffect 안에서 해요. 이 프로젝트는 useEffect에 익명 화살표 대신 이름 붙은 함수를 넣는 컨벤션을 써요. 스택 트레이스랑 코드 읽기에 그 이름이 그대로 도움이 돼요. 조회수 컴포넌트가 가장 단순한 예시예요.
'use client';
export function ViewCounter({ slug }: { slug: string }) {
const [count, setCount] = useState<number | null>(null);
useEffect(
function bumpAndShowView() {
let alive = true;
recordView(slug).then((d) => {
if (alive && d) setCount(d.count);
});
return () => {
alive = false;
};
},
[slug],
);
if (count === null) return null;
return <span>조회 {count.toLocaleString()}</span>;
}alive 플래그는 컴포넌트가 떠난 뒤 응답이 와도 setState를 안 하게 막아요. 그리고 count가 null인 초기엔 아무것도 안 그려요. 숫자가 0으로 깜빡였다가 바뀌는 대신, 값이 생기면 그때 나타나게요. 방문 기록 컴포넌트도 같은 패턴인데 UI가 없어서 trackVisit()만 호출하고 null을 반환해요.
좋아요는 optimistic으로 먼저 반응
좋아요는 누른 순간 바로 반응해야 기분이 좋아요. 서버 응답을 기다리지 않고 화면을 먼저 바꾼 뒤, 응답이 오면 그 값으로 보정해요.
async function onToggle() {
if (busy) return;
setBusy(true);
const wasLiked = liked;
setLiked(!wasLiked); // 먼저 토글
setCount((c) => c + (wasLiked ? -1 : 1)); // 카운트도 먼저
const d = await toggleLike(slug);
if (d) {
setCount(d.count); // 서버 값으로 보정
setLiked(d.liked);
}
setBusy(false);
}busy 가드로 응답 오기 전 연타를 막아요. 서버가 응답을 주면 그게 진실이니 덮어쓰고, 응답이 없으면(fallback) 낙관적으로 바꾼 화면을 그대로 둬요.
댓글: honeypot과 자동 이스케이프
댓글은 입력을 받으니 두 가지를 신경 썼어요. 첫째는 스팸이에요. 사람 눈엔 안 보이지만 봇은 채우는 honeypot 입력을 하나 숨겨 두고, 채워져서 들어오면 서버가 버려요.
<input
ref={honeypot}
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: 'absolute', left: '-9999px', width: 1, height: 1, opacity: 0 }}
/>display: none이 아니라 화면 밖으로 밀어내요. 일부 봇은 숨겨진 필드를 건너뛰거든요. 둘째는 XSS예요. 댓글 본문은 dangerouslySetInnerHTML 없이 그냥 {c.body}로 JSX에 넣어요. React가 텍스트로 자동 이스케이프해 주니까, 별도 sanitize 없이도 안전해요. 줄바꿈만 white-space: pre-wrap으로 살렸어요.
정리하면 "정적 본문 + 클라이언트 컴포넌트가 런타임 fetch, 실패하면 조용히 기본값"이라는 한 가지 원칙이에요. 서버 없이도 동적 기능을 붙이면서, 그 기능이 본문을 절대 끌어내리지 않게 하는 게 핵심이었어요.
이 블로그는 LifeCareLog가 운영해요. 1인 개발 과정에서 직접 부딪힌 프런트엔드 기록을 모아 둬요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.