렌더링 전략과 인증
HTML을 언제 어디서 만드는지에 따라 첫 화면 속도, 검색 노출, 서버 비용이 달라집니다. 선택 기준과 그 위에서 인증을 다루는 방법을 정리합니다.
CSR, SSR, SSG의 차이
같은 화면을 만드는 세 가지 시점입니다.
| HTML을 만드는 시점 | 첫 응답에 담기는 것 | |
|---|---|---|
| CSR | 브라우저에서, 요청 후 | 빈 <div id="root">와 스크립트 |
| SSR | 서버에서, 요청마다 | 완성된 HTML |
| SSG | 빌드할 때, 한 번 | 미리 만들어 둔 HTML 파일 |
CSR은 스크립트를 받고 실행한 뒤에야 글자가 보입니다.
<body>
<div id="root"></div>
<script src="/assets/main.js"></script>
</body>SSR은 서버가 같은 컴포넌트를 실행해 HTML로 만들어 보냅니다. 첫 화면이 바로 보이고, 데이터도 서버에서 가져오므로 요청 폭포수가 줄어듭니다. 대신 요청마다 서버가 일합니다.
SSG는 그 일을 배포 시점에 한 번만 합니다. 내용이 자주 바뀌지 않으면 가장 빠르고 가장 쌉니다.
선택 기준
페이지 단위로 고릅니다. 한 서비스 안에서 섞어 쓰는 것이 보통입니다.
| 화면 | 선택 |
|---|---|
| 문서, 블로그, 랜딩 | SSG |
| 상품 목록·상세처럼 검색에 걸려야 하는 화면 | SSR 또는 ISR |
| 로그인 후 대시보드, 관리자 화면 | CSR |
| 실시간 편집기, 지도 | CSR |
판단할 때 보는 것은 세 가지입니다.
- 검색 노출이 필요한가 — 필요하면 HTML에 내용이 있어야 합니다
- 내용이 사용자마다 다른가 — 다르면 미리 만들어 둘 수 없습니다
- 얼마나 자주 바뀌는가 — 드물게 바뀌면 빌드 시점으로 밀 수 있습니다
내용이 가끔 바뀌는 중간 지대에는 ISR이 있습니다. 미리 만들어 두고 일정 시간이 지나면 백그라운드에서 다시 만듭니다.
export const revalidate = 60; // 60초마다 갱신메타 프레임워크
렌더링 전략, 라우팅, 번들링, 배포를 한 묶음으로 제공합니다.
| 기반 | 특징 | |
|---|---|---|
| Next.js | React | 서버 컴포넌트, 페이지별 전략 선택 |
| Nuxt | Vue | 파일 기반 라우팅, 자동 임포트 |
| SvelteKit | Svelte | 어댑터로 배포 대상 교체 |
| Astro | 여러 프레임워크 | 기본은 HTML, 필요한 곳만 섬처럼 동작 |
Astro의 방식은 따로 볼 만합니다. 페이지 전체를 정적 HTML로 내고, 상호작용이 필요한 컴포넌트에만 JavaScript를 보냅니다.
<Header /> <!-- HTML만 나갑니다 -->
<SearchBox client:visible /> <!-- 화면에 보일 때 로드됩니다 -->읽는 화면이 대부분인 사이트에서는 보내는 JavaScript가 크게 줄어듭니다.
하이드레이션 비용
SSR이 보낸 HTML은 그림일 뿐입니다. 브라우저가 같은 컴포넌트를 한 번 더 실행해 이벤트를 붙여야 동작합니다. 이 과정이 하이드레이션입니다.
HTML 도착 → 보이지만 눌러도 반응 없음 → JS 실행 → 그제야 동작이 틈에서 체감 문제가 생깁니다. 버튼이 보이는데 눌리지 않는 구간입니다. INP 지표가 나빠지는 흔한 원인이기도 합니다.
서버와 클라이언트의 결과가 다르면 경고와 함께 화면이 어긋납니다.
<p>{new Date().toLocaleString()}</p> // 서버 시간과 브라우저 시간이 다릅니다
<p>{Math.random()}</p> // 매번 다릅니다브라우저에서만 알 수 있는 값은 마운트 이후에 넣습니다.
const [now, setNow] = useState(null);
useEffect(() => setNow(new Date()), []);부분 렌더링과 스트리밍
전체 페이지를 기다리지 않고, 준비된 부분부터 내보낼 수 있습니다.
<Layout>
<ProductInfo /> {/* 바로 나갑니다 */}
<Suspense fallback={<ReviewSkeleton />}>
<Reviews /> {/* 느린 쿼리는 나중에 */}
</Suspense>
</Layout>느린 영역 하나가 페이지 전체를 붙잡지 않게 됩니다. 서버 컴포넌트를 쓰면 그 컴포넌트의 코드 자체가 브라우저로 가지 않아 번들도 줄어듭니다.
다만 모든 것을 서버로 옮기면 작은 상호작용에도 왕복이 생깁니다. 입력 중 반응, 드래그, 애니메이션은 클라이언트에 남깁니다.
인증 전략: 세션과 토큰
두 방식의 차이는 서버가 상태를 들고 있는가입니다.
| 세션 | 토큰(JWT) | |
|---|---|---|
| 저장 위치 | 서버에 세션, 브라우저에 식별자 | 토큰 자체에 정보 |
| 무효화 | 서버에서 지우면 끝 | 만료 전까지 유효 |
| 확장 | 공유 저장소 필요 | 서버가 검증만 하면 됨 |
프론트엔드에서 가장 중요한 선택은 어디에 저장하는가입니다.
localStorage.setItem("token", token); // XSS 한 번이면 그대로 털립니다스크립트가 읽을 수 있는 곳에 두면 XSS의 가치가 올라갑니다. 서버가 HttpOnly 쿠키로 내려 주는 방식이 기본입니다.
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/JavaScript가 읽을 수 없고, 요청에 자동으로 실립니다. 자동으로 실리는 만큼 CSRF 대응이 함께 필요합니다.
await fetch("/api/todos", {
method: "POST",
credentials: "include", // 교차 출처라면 명시해야 쿠키가 실립니다
});액세스 토큰을 짧게 두고 리프레시 토큰으로 갱신한다면, 만료 응답을 한곳에서 처리합니다.
if (response.status === 401) {
await refresh(); // 동시에 여러 요청이 401을 받을 수 있습니다
return retry(request); // 갱신은 한 번만 수행되게 묶습니다
}소셜 로그인 연동
브라우저에서 처리할 수 있는 흐름은 리디렉션까지입니다.
앱 → 제공자 로그인 화면 → 코드와 함께 앱으로 복귀 → 서버가 코드를 토큰으로 교환코드를 토큰으로 바꾸는 요청에는 클라이언트 시크릿이 필요합니다. 브라우저 코드에 두면 누구나 볼 수 있으므로 반드시 서버에서 교환합니다.
// 프론트엔드가 하는 일은 여기까지입니다
location.href = `${authorizeUrl}?client_id=${id}&redirect_uri=${uri}&state=${state}`;state는 돌아온 응답이 내가 보낸 요청에 대한 것인지 확인하는 값입니다. 생성해서 보관하고, 복귀 시 비교합니다. 빠뜨리면 CSRF가 됩니다.
보호된 라우트
화면을 가리는 것은 사용자 경험이지 보안이 아닙니다.
function RequireAuth({ children }) {
const { user, isPending } = useUser();
if (isPending) return <Loading />; // 깜빡임을 막습니다
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
return children;
}세 가지를 같이 챙깁니다.
- 로딩 상태 — 확인 전에 로그인 화면으로 보내면 새로고침마다 튕깁니다
- 돌아갈 주소 — 로그인 후 원래 가려던 곳으로 보냅니다
- 권한 분기 — 로그인 여부와 역할은 다른 문제입니다
그리고 서버에서 같은 검사를 반복합니다. 브라우저 코드는 전부 열어 볼 수 있고, API는 주소만 알면 직접 호출됩니다. 프론트엔드의 라우트 가드는 잘못 들어온 사용자를 안내하는 장치일 뿐입니다.