
페이지마다 렌더링 방식을 다르게? 사용자 경험 중심의 개발 이야기
기술적인 이야기를 하기에 앞서 프론트엔드 개발에서 가장 중요한 요소 중 하나는 무엇일까요? 여러 요소들이 있겠지만 개인적으로 “사용자 경험” 이라고 생각해요.
최근에 업무를 하면서 이에 대해 신경 쓰지 못했던 경우가 있었는데 트렌디한 기술을 적용 시켜야지, 상태 관리를 잘해야지, 컴포넌트를 의미있게 분리해야지 등의 개발자 경험에만 매몰되어 가장 중요한 렌더링 방식에 신경을 못썼던 경험이 있었는데요, 이 글을 통해 고민과 개선 과정을 공유해보려 해요.
ETL 기반 B2B 서비스의 사용자 경험을 위한 선택
저는 현재 B2B 서비스 중에서도 ETL 솔루션 화면 개발을 담당하고 있습니다. 간단히 설명하면 여러 시스템에 흩어져있는 정보를 모으고, 정리한 뒤 필요한 곳에 적재하는 작업을 워크플로우 방식으로 자동화하고 모니터링하는 솔루션이에요.
예를 들면, 카드사에서 고객의 결제나 포인트 데이터를 여러 시스템에서 모은 뒤, 해당 데이터가 제대로 수집되고 가공되는지를 실시간으로 확인할 수 있는 관제탑 같은 기능을 제공한다고 보면 되죠.
이번 글에서는 좋은 사용자 경험을 제공하기 위해 고민한 내용과, 이를 어떻게 개선해 나갔는지에 대한 이야기를 해보려 합니다.
렌더링 전략에 대한 고민
초기에는 모든 페이지를 동일한 CSR 방식으로 처리했지만, Lighthouse 측정 결과 정적 페이지의 First Contentful Paint, Largest Contentfun Paint 등의 지표가 기대보다 낮았고, 클릭했는데 빈 화면이었다가, 1~2초 뒤에야 대시보드 차트의 내용이 나온다는 피드백도 있었는데요. 이 문제를 해결하기 위해 각 페이지의 역할과 특성에 맞는 렌더링 방식을 적용 해보기로 했어요.
렌더링 방식
CSR (Client Side Rendering) HTML은 거의 비어 있는 상태로 전달되고, 브라우저가 JS를 실행해 내용을 렌더링 합니다. 대부분의 SPA(Single Page Application) 구조가 여기에 해당하죠.
SSR (Server Side Rendering) 요청이 올 때마다 서버에서 HTML을 생성해 응답하는 방식입니다. 초기 로딩 속도가 빠르고, SEO에도 유리하며 사용자 디바이스 성능에 덜 의존해요.
SSG (Static Site Generation) 빌드 시점에 HTML 파일이 미리 생성되어, 서버는 이를 단순한 정적 파일처럼 서빙합니다. 사용자 입장에서는 브라우저가 요청하자마자 콘텐츠가 바로 렌더링 되므로 빠르게 콘텐츠를 볼 수 있죠. 즉 모든 페이지를 미리 렌더링하고 클라이언트 요청에 따라 페이지를 제공해요.
CSR: 브라우저가 모든 걸 담당 – 유연하지만 느릴 수 있음
SSR: 서버가 매번 그려서 보냄 – 빠르지만 비용이 큼
SSG: 미리 그려서 캐싱 – 빠르고 안정적
이 내용을 기반으로 페이지 성격에 맞게 CSR과 SSG를 조합하는 전략을 통해 사용자에게 빠르고 안정적인 경험을 제공하는 방향으로 개선해봤어요. (ISR 방식도 존재하지만, SvelteKit 에서는 native로 제공하지 않기 때문에 여기서는 스킵할게요)
왜 SSR은 선택하지 않았을까
SSR은 초기 렌더링 속도가 빠르고, 서버에서 데이터를 바로 그려낼 수 있기 때문에 검색 엔진 최적화, 저사양 디바이스 대응 등의 상황에서 특히 유용해요.
하지만 저희 서비스는 로그인 이후 사용하는 B2B 솔루션으로, 공개 콘텐츠가 없어 SEO가 불필요 했어요. 또한 운영 인프라가 Spring Boot 기반으로 구성되어 있어, SSR을 도입하려면 별도의 Node 런타임 환경도 필요했구요. 이는 복잡도와 유지 비용 면에서 부담이었으며, 갱신이 필요한 데이터를 SSR로 매 요청마다 그리는 것은 오히려 비효율적일 수 있다는 판단을 했죠.
결과적으로 SSR로 전환하긴 어려웠고, 서비스의 성격과 인프라를 고려해 CSR과 SSG 조합을 선택했어요.
페이지 성격에 따른 적용 사례
서비스 특성상 정적인 데이터와 실시간 데이터가 공존하고 있었는데, 각 페이지의 목적과 동작 특성을 고려해 렌더링 방식을 분리해 적용시켜 봤어요.
대시보드 - SSG + Hydration
대시보드는 엔진 상태, 워크플로우 성공/실패 현황 등 요약 지표를 차트 형태로 보여주는 페이지에요. 완전한 실시간은 아니며, 약 1분 간격으로 백엔드에서 갱신되는 주기적 데이터죠.
초기 접근 시에는 SSG를 활용해 HTML 구조를 미리 렌더링하고, 클라이언트에서 JS가 실행되면서 비동기 fetch를 통해 최신 데이터를 불러와 화면에 하이드레이션 방식으로 반영해요. 이 전략을 통해
- 페이지 진입 즉시 차트 뼈대가 보이고,
- 이후 부드럽게 실제 데이터가 채워지는 UX를 제공할 수 있었어요.
LightHouse Performance 기준으로 전체 성능 점수는 57점에서 91점으로 약 60% 개선되었고, 정확한 수치는 네트워크 환경에 따라 달라질 수 있겠지만 사용자 입장에서 바로 뜨는 느낌은 확실히 체감될 정도였어요.


모니터링 - CSR 기반
워크플로우의 실행 이력과 각 태스크별 상태를 시각적으로 표현하는 화면이었는데요. JointJS 기반의 워크플로우 캔버스를 중심으로, 그 위에 성공/실패 상태를 오버레이 형태로 표시해요.
이 데이터는 이력성 데이터에 가깝고, 사용자가 날짜나 태스크 필터를 조작하면서 필요할 때 fetch를 수행하는 방식이에요. 이 구조에서는 SSG로는 UI 상태를 정확하게 표현하기 어렵고, 사용자의 상호작용 에 따라 동적으로 UI를 갱신할 필요가 있었기 때문에, CSR 방식으로 구성해 상태 보존과 반응성을 최우선으로 처리했어요.
관리 페이지 - CSR + 일부 SSG
관리 페이지는 사용자 목록, 실행 이력 조회 등 대부분 테이블 기반의 인터랙션 중심 페이지 입니다. 검색, 필터, 페이지네이션 등의 사용자 조작이 핵심이어서 CSR 방식이 적합했어요. 다만, 시스템 정보나 환경설정 같이 변경이 거의 없는 정적 페이지는 SSG로 처리하여 캐싱 효과와 함께 초기 렌더링 속도 개선을 동시에 가져갔어요.
하이드레이션이란 미리 렌더링된 HTML에 JS를 연결해 이벤트나 동적 동작이 가능하도록 만드는 과정이에요.
SvelteKit에서의 SSG / CSR 적용 방법
렌더링 방식을 분리하기로 결정하고, SvelteKit에서 제공하는 기능을 활용해 페이지 단위로 SSG / CSR을 적용시켜봤어요.
SSG 적용 방법
기본 환경 설정 (svelte.config.ts
)
npm install -D @sveltejs/adapter-static
// svelte.config.ts
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
// 기본 옵션. 필요에 따라 수정 가능
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
}),
// optional: trailingSlash 설정은 환경에 따라
trailingSlash: 'ignore', // 또는 'always'/'never'
},
};
특정 페이지 || 전체 페이지 SSG 적용
특정 페이지에 SSG를 적용시키려면 해당경로 +page.ts
에 다음과 같이 작성하면 되고,
사이트 전체를 SSG로 만들려면 src/routes/+layout.ts
또는 +layout.server.ts
에 다음과 같이 작성하면 빌드 시 모든 라우트를 미리 렌더링해 정적 HTML로 만들어요.
// src/routes/해당경로/+page.ts
export const prerender = true;
// src/routes/+layout.ts
export const prerender = true;
Prerender란?
prerender = true;
라는 옵션을 설정해주었는데, HTML을 미리 렌더링하겠다는 의미에요. 사용자가 접속하기도 전에 미리 만들어져 있고, 브라우저는 만들어진 페이지를 즉시 렌더링 하는 것이지요. SSG의 핵심개념이며 초기 로딩성능(FCP, TTI) 향상에 효과적이에요.
그렇다면 SSR도 HTML을 미리 만들어서 서빙하는 방식 아닐까? 라는 의문이 들텐데, 둘의 차이점은 생성하는 시기에요.
- SSG는 빌드 시에 HTML을 만들고 각각의 요청이 올 때 재사용해요.
- SSR은 각 요청 마다 HTML을 만들어요.
CSR 및 SPA 적용 방법
기본 환경 설정 (svelte.config.ts)
npm install -D @sveltejs/adapter-static
// svelte.config.ts
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
fallback: 'index.html', // 플랫폼에 따라 다를 수 있음 (ex: 200.html)
}),
},
};
특정 페이지 || 전체 페이지 CSR 적용
SvelteKit는 기본적으로 SSR을 사용하지만, 다음과 같이 작성해서 특정 페이지에 적용시키려면 +page.ts
에, 전체 페이지에 적용시키려면 src/routes/+layout.ts
또는 +layout.server.ts
에 다음과 같이 작성하면 CSR 방식으로 전환할 수 있어요.
// src/routes/해당경로/+page.ts
export const ssr = false;
// src/routes/+layout.ts
export const ssr = false;
SPA와 CSR의 관계
SPA와 CSR의 관계를 잠깐 짚고 넘어가면 SPA는 하나의 HTML 페이지를 기반으로 동작하는 아키텍처 방식이에요.
- 페이지 간 이동 시 새로운 HTML을 불러오지 않고 현재 페이지 내에서 컴포넌트를 교체하죠. (초기에 필요한 리소스 한번에 로드)
CSR은 브라우저에서 JS를 사용하여 페이지를 렌더링 하는 방식이에요.
- 서버로부터 받은 데이터를 브라우저에서 처리하여 화면을 그리며
- 초기 HTML은 비어있고, JS가 실행된 후 콘텐츠가 채워져요.
- API 호출 결과를 바탕으로 DOM을 조작해요.
혼동하기 쉬운데, 다시 정리하면 SPA는 아키텍처 패턴이고 CSR은 렌더링 방식이에요. SPA는 반드시 CSR을 사용하지만 CSR이 반드시 SPA를 의미하진 않죠.
또한 전체 앱을 SPA로 하게 되면 주의할 점이 있어요. JS가 로딩되지 않거나 실패하면 화면 자체가 렌더링되지 않을 수 있어서 일반적인 상황에선 SSR 또는 SSG + CSR 조합을 더 추천한다고 해요.
SvelteKit / NextJS 렌더링 방식 차이점
렌더링 방식을 분리해 적용하면서 다른 프레임워크와의 차이점은 무엇인지 궁금해서 Next.js와 비교조사해서 정리해봤어요.
렌더링 방식별 공통 개념
설명 | SvelteKit | Next.js | |
---|---|---|---|
CSR | 클라이언트에서 JS로 렌더링 | export const ssr = false | 클라이언트 컴포넌트에서 useEffect() 사용 |
SSR | 요청 시 서버에서 HTML 생성 | 기본 SSR (+page.server.ts , +page.ts ) | App Router: 서버 컴포넌트 + fetch() Page Router: getServerSideProps() |
SSG | 빌드 시 HTML 생성, 정적 배포 | export const prerender = true | App Router: generateStaticParams() + export const revalidate = 0 Page Router: getStaticProps() |
ISR | 일정 주기로 SSG 재생성 | Vercel adapter 한정 config.isr | export const revalidate = N (App Router + Page Router 모두 지원) |
선언 방식 및 구조 비교
SvelteKit | Next.js (App Router) | Next.js (Page Router) | |
---|---|---|---|
페이지 정의 | +page.svelte | app/page.tsx | pages/index.tsx |
SSR 데이터 로딩 | +page.server.ts → export const load | 서버 컴포넌트 내부 async fetch() | getServerSideProps() |
SSG 설정 | export const prerender = true | generateStaticParams() , revalidate = 0 | getStaticProps() |
CSR 강제 | export const ssr = false | 클라이언트 컴포넌트 + useEffect() | 기본 CSR (useEffect 내 fetch) |
ISR 지원 | config.isr (Vercel adapter 전용) | revalidate export | getStaticProps() + revalidate |
라우팅 방식 | 파일명 기반 역할 자동 할당 | 폴더 기반 + export 기반 제어 | 폴더 기반 + 명명된 함수 기반 제어 |
하이드레이션 | 자동 ($props() 기반 런타임) | 자동 (서버/클라이언트 컴포넌트 조합) | 자동 |
SSR
SvelteKit
// src/routes/+page.server.ts
export const load = async () => {
const response = await fetch('https://api.com/data');
const data = await response.json();
return { data };
};
// src/routes/+page.svelte
<script>
let { data } = $props();
</script>
<h1>{data.title}</h1>
Next.js(App router)
// app/page.tsx
async function getData() {
const response = await fetch('https://api.com/data');
const data = await response.json();
}
export default function Page({ data }) {
const data = await getData();
return <h1>{data.title}</h1>;
}
Next.js(Page router)
// pages/index.tsx
export async function getServerSideProps() {
const response = await fetch('https://api.com/data');
const data = await response.json();
return { props: { data } };
}
export default function Page({ data }: { data: any }) {
return <h1>{data.title}</h1>;
}
SSG
SvelteKit
// src/routes/정적페이지/+page.ts
export const prerender = true;
Next.js(App Router)
// app/page.tsx
export const revalidate = 3600;
async function getData() {
const response = await fetch('https://api.com/static');
return response.json();
}
export default function Page() {
const data = await getData();
return <h1>{data.title}</h1>;
}
Next.js(Page Router)
// pages/index.tsx
export const function getStaticProps() {
const response = await fetch('https://api.com/static');
const data = await response.json();
return { props: { data } };
}
export default function Page({ data }: { data: any }) {
return <h1>{data.title}</h1>;
}
ISR
SvelteKit (Vercel adapter)
// (Vercel adapter 사용 시 config 설정)
import { BYPASS_TOKEN } from '$env/static/private';
export const config = {
isr: {
exiration: 69,
bypassToken: BYPASS_TOKEN,
allowQuery: ['search'],
},
};
Next.js (App Router)
/// app/blog/[id]/page.tsx
export const revalidate = 60;
export async function generateStaticParams() {
const posts = await fetch('https://api.com/posts').then((res) => res.json());
return posts.map((post) => ({ id: post.id }));
}
export default async function Page({ params }: { params: { id: string } }) {
const post = await fetch(`https://api.com/posts/${params.id}`).then((res) =>
res.json()
);
return (
<main>
<h1>{post.title}</h1>
<p>{post.content}</p>
</main>
);
}
Next.js (Page Router)
export async function getStaticProps() {
const posts = await fetch('https://api.com/posts').then((res) => res.json());
const paths = posts.map((post: any) => ({
params: { id: post.id.toString() },
}));
return { paths, fallback: 'blocking' };
}
export async function getStaticProps({ params }: { params: { id: string } }) {
const response = await fetch(`https://api.com/posts/${params.id}`);
const data = await response.json();
return { props: { data }, revalidate: 60 };
}
export default function Page({ data }: { data: any }) {
return (
<main>
<h1>{data.title}</h1>
<p>{data.content}</p>
</main>
);
}
결론: 렌더링 전략은 기술이 아닌 경험 설계이다
글을 마무리하며 이번 작업을 통해 느낀 점을 정리해보려 해요.
- “CSR이 기본이니까 그냥 CSR로 하죠.”
- “SSR은 SEO 필요할 때 쓰는거잖아요.”
- “SSG는 블로그나 소개 페이지 만들 때 쓰는거 아닌가요?”
페이지마다 적합한 방식을 따지기보단, 익숙하고 공식처럼 여겨지는 렌더링 방식을 그대로 따라 개발했어요. 그 결과, 사용자에게 “조금씩 늦게 뜨는 것 같아요”라는 피드백을 받기도 했죠.
그래서 각 페이지의 성격과 맥락을 하나씩 깊이 들여다봤는데요.
대시보드는 SSG + Hydration 방식으로 구조만 먼저 보여주고, 데이터를 나중에 채워서 UX를 개선하며, 실시간 정보가 필요하지 않은 페이지는 SSG로 미리 렌더링하고 캐싱을 활용했어요. 마지막으론 상호작용이 많고 상태 보존이 중요한 페이지는 CSR로 구현했어요.
그 결과, 퍼포먼스 지표도 좋아졌고 사용자 피드백도 긍정적으로 바뀌었죠. 이번 작업을 계기로, 어떤 기술을 쓸지가 아니라 어떤 경험을 만들지를 먼저 고민해야겠구나 라는 걸 깨달았어요.
사실 저는 빠르고 좋고 핫한 기술을 써보고 싶어 하는 경향이 강한 개발자였어요. 그런데 중요한 건, 그 기술이 우리 서비스에 정말 필요한가? 이더라구요. 렌더링 전략도 마찬가지였어요. 꼭 SSR을 써야 할까요? 모든 페이지를 SSG로 사전 렌더링해야 할까요?
정답은 항상 서비스의 목적에 따라 다르다였어요. 기술은 수단이고, 결국 개발자가 설계해야 하는 건 사용자에게 어떤 경험을 줄지가 아닐까요?
이 글이 렌더링 방식의 개념을 넘어 우리 서비스에는 어떤 전략이 맞을까? 를 고민하는 계기가 되었으면 하며, 기술보다 중요한 건 결국 사용자 경험을 어떻게 설계하느냐 라는 점을 다시 한번 깨닫게 되었어요.