서버사이드 렌더링에 대해 알아보자(Feat: next.js, SSG)

Published2024.11.04
Read Time18 min read

프론트엔드를 공부하노라면 SPA(Single Page Application)와 CSR(Client Side Rendering)과 SSR(Server Side Rendering)에 대해 한번 씩 이라도 들어보았을 것 이다.

하지만 개념이 확실히 안잡힐 때가 있고, 뭐를 써야 더 좋은지도 모를 때도 많다.

이번 포스팅에서는 SPA, CSR에 대해 간단히 알아본 후, SSR에 대해서 보다 더 자세히 개념과 이것이 쓰이는 Next.js 코드 예제에 대해서도 살펴볼 것이다.

그리고 더 나아가 정적 데이터를 미리 로드하는 SSG(Static Site Generation)에 대해서도 알아볼 것이다.

그 전에 SPA란?

싱글 페이지 애플리케이션(SPA)은 SSR(Server-Side Rendering)과 다른 렌더링 방식인 **CSR(Client-Side Rendering)**을 주로 사용하여 서버가 아닌 브라우저에서 페이지를 렌더링하고 전환한다.

SPA는 렌더링과 라우팅에 필요한 대부분의 기능을 서버가 아닌 브라우저의 JavaScript에 의존하는 방식CSR을 사용하여 구현된다.

SPA에서는 주로 CSR을 사용하여 처음 페이지를 로드할 때 필요한 데이터를 한 번에 받아오고, 이후에는 서버에서 HTML을 새로 내려받지 않고 하나의 페이지에서 모든 작업을 처리하기 때문에 **싱글 페이지 애플리케이션(SPA)**이라고 불리는 것이다.

<body><noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div></body></html>

위의 SPA를 사용하는 HTML소스를 보면 HTML 코드의 body 내부가 비어 있다.

이는 사이트 렌더링에 필요한 body 내부의 내용을 모두 자바스크립트 코드로 삽입한 후에 렌더링하기 때문이다. .

CSR을 사용하는 SPA는 한번 로딩된 이후에는 페이지 전환 속도가 빠르고 사용자와의 상호작용이 부드럽다는 장점이 있지만, 최초의 로딩해야 할 자바스크립트 리소스가 커진다는 것 그리고 그로 인해 초기 로딩 속도가 길어진다는 단점이 있다.

SSR

싱글 페이지 애플리케이션이 자바스크립트를 활용해 하나의 페이지에서만 렌더링을 수행한다면 서버 사이드 렌더링은 최초에 사용자에게 보여줄 페이지를 서버에서 렌더링해 빠르게 사용자에게 화면을 제공하는 방식이다.

즉 앞서 살펴보았던 CSR과 SSR의 차이는 웹페이지 렌더링의 책임을 어디에 두느냐인데, CSR을 사용하는 SPA는 사용자에게 제공되는 자바스크립트 번들에서 렌더링을 담당하지만, SSR을 채택하면 렌더링에 필요한 작업을 모두 서버에서 수행한다.

클라이언트 렌더링은 사용자 기기의 성능에 영향을 받지만, 서버 사이드 렌더링은 서버에서 제공하기 때문에 안정적인 렌더링이 가능하다.

SSR의 장단점

장점

  • 최초 페이지 진입이 비교적 빠르다.

    최초 페이지 진입 시 페이지에 유의미한 정보가 그려지는 시간인 FCP(First Contentful Paint)가 SPA에 비해 더 빨라질 수 있다. 다만, 서버가 사용자에게 렌더링을 제공할 수 있는 충분한 리소스가 확보되어 있다라는 가정하에 비교한 것이다.

  • 검색 엔진과 SNS 공유 등 메타 데이터 제공이 쉽다.

    검색 엔진 로봇이 페이지의 정보를 가져올 때 HTML을 다운로드 하는데, 단, 이 때 자바스크립트 코드는 실행하지 않는다.

    다운로드한 HTML 페이지 내부의 오픈 그래프(Open Graph)나 메타(meta) 태그 정보를 기반으로 페이지의 검색 정보를 가져오고, 이를 바탕으로 검색 엔진에 저장한다.

    서버사이드 렌더링을 사용하면 최초의 렌더링 작업이 서버에서 일어난다. 즉 검색 엔진에서 제공할 정보를 서버에서 가공하여 HTML 응답으로 제공할 수 있으므로 SEO에 대응하기가 용이하다.

    SSR을 사용하는 next js에서는 generateMetaData라는 함수를 사용 (14버전 app router기준) 하여 데이터를 미리 페칭하여 동적으로 이와 같이 비교적 쉽게 메타데이터의 내용을 설정할 수도 있다.

    // params는 groupId라고 설정된 페이지의 동적 파라미터를 받아오는 것
    export  async  function  generateMetadata({params}: {params: {groupId:  string}}) {
    // 이 params를 통해서 서버에 미리 데이터를 요청한다.
    const  response  =  await  fetchGroupData({queryKey: ['groupDetail',Number(params.groupId)]});
    // 요청한 데이터를 이런식으로 return 하여 head 태그의 title과 description에 넣을 수 있다.
    	return {
    		title:  `${response.name}`,
    		description:  `${response.description}`,
    	}
    }
    
  • 누적 레이아웃 이동이 적다. 누적 레이아웃 이동은 사용자에게 페이지를 보여준 이후에 뒤늦게 어떤 HTML 정보가 추가되거나 삭제되어 마치 화면이 덜컥 거리는 것과 같은 부정적인 사용자 경험을 말하는 것이다.

    api요청이 다 완료된 이후에 완성된 페이지를 제공하는 서버 사이드 렌더링은 이러한 문제에서 비교적 자유롭다.

  • 사용자의 디바이스 성능에 비교적 자유롭다.

    • 서버와 함께 데이터를 로드하는 부담을 나누므로, 보다 사용자 디바이스 성능에 자유롭다.

단점

  • 소스코드를 작성할 때 항상 서버를 고려해야 한다.

    window 또는 sessionStorage와 같은 브라우저 전역 객체에 접근하는 코드는 서버에서 실행될 수 없어 이를 고려 해야 하고, 외부 라이브러리 역시 서버에 대한 고려를 해야만한다.

  • 적절한 서버가 구축되어있어야 한다.

    서버 사이드 렌더링은 말 그대로 사용자의 요청을 받아 렌더링을 수행할 서버가 필요하므로, 사용자의 요청에 따라 적절히 대응하는 것, 예기치 못한 장애상황에 대응하는 전략도 필요하다

  • 서비스 지연 문제

    SPA에서는 최초에 어떤 화면이라도 보여준 상태에서 느린 작업이 수행되기 때문에 '로딩 중'과 같은 것으로 안내하면 되지만, SSR에서는 특히 이 지연 작업이 최초 렌더링에 발생한다면 사용자에게 그 어떤 정보를 제공할 수 없기에, 더 안좋은 사용자 경험을 제공할 수 있다.

Next js에서 사용되는 SSR

React DeepDive는 Next.js 13을 기준으로 책이 쓰여졌기에 page router의 getServerSideProps를 사용하는 것을 next.js의 서버 사이드 렌더링의 예제로 사용하고 있다.

해당 함수가 있다면 무조건 페이지 진입 전에 이 함수를 실행한다.

이 함수는 응답값에 따라 페이지의 컴포넌트에 props를 반환할 수도, 혹은 다른 페이지로 리다이렉트 시킬 수 있다.

import type {GetServerSideProps} from 'next';

export default function Post({post}: {post:Post}) {
	// 렌더링
}

export const getServerSideProps: GetServerSideProps = async (context) => {
	// /post/[id]와 같은 경로에 있는 id 값에 접근할 수 있다. (여기서 id는 동적 파라미터)
	const {
	query: {id: ''},
	} = context
	const post = await fetchPost(id.toString());
	return {
		props: {post},
	}
}

constext.query.id를 사용하면 /post/[id] 같은 경로에 있는 id 값에 접근할 수 있다. (ex ) www.aaa.com/post/1212추출 가능)

위의 코드는 페이지의 경로의 id에 접근하여 그 값을 파라미터로 받는 데이터 요청 함수(fetchPost)를 통해 데이터를 받고 받은 데이터를 props에 return 하고 있다.

이렇게 getServerSideProps로 미리 props를 제공하면 페이지의 Post 컴포넌트에 해당 값을 제공하여 이 반환 값을 기반으로 렌더링 한다.

그렇다면 14버전의 Next.js App router에서는 이를 어떻게 구현할지도 살펴보자.

// app/post/[id]/page.js

import { fetchPost } from '@/lib/api'; // fetchPost 함수는 외부 API 호출 함수라고 가정

export default async function Post({ params }) {
  const { id } = params;
  const post = await fetchPost(id); // 데이터를 서버 측에서 가져옴

  return (
    <>
      <PostInfo title = {post.title} content = {post.content}/>
    </>
  );
}

14버전 next js은 getServerSideProps와 같은 함수를 쓰는 대신, 컴포넌트를 클라이언트 컴포넌트서버 컴포넌트로 나눈다.

위의 컴포넌트 Post는 서버 컴포넌트이다. 14버전의 next.js에서는 최상단에 따로 명령어를 쓰지 않으면 컴포넌트를 서버 컴포넌트로 인식한다.

Post 컴포넌트에서는 페이지 파라미터 값을 통해 데이터를 서버 단에서 가져오고 있다. 우리는 이 데이터를 클라이언트 컴포넌트인 PostInfo에 props로 넘겨줄 것 이다.

클라이언트 컴포넌트를 사용하고 싶다면 'use client'를 최상단에 쓰면 된다.

'use client'

export default async function PostInfo({title, content}) {
	return (
		<div> 
			<h1>{title}</h1> 
			<p>{content}</p> 
		</div>
	);
}

PostInfo는 맨 위에 'use client'를 선언했으므로, 클라이언트 컴포넌트 이다. 서버 컴포넌트인 Post에서 미리 로드하여 넘겨준 데이터를 props로 받아 화면에 나타내는 역할을 한다.

이와 같이 Next.js 에서는 여러 API를 사용해서 복잡하게 SSR을 복잡하게 구현해야 하는 react에 비해서 비교적 간단하게 SSR을 구현할 수 있다.

+ SSG

SSG(Static Site Generation)는 Next.js에서 페이지를 빌드할 때 미리 HTML 파일로 생성하여 저장해 두고, 사용자가 해당 페이지에 접근할 때 서버 요청 없이 정적 파일을 제공하는 방식이다.

이는 특히 자주 변경되지 않는 콘텐츠를 가진 블로그와 같은 페이지에서 성능과 SEO 최적화에 유리한 방법이다.

내 블로그 역시 이와 같은 방식을 사용하고 있는데, Next.js의 page router를 사용하고 있r기에 getStaticPropsgetStaticPath 함수로 SSG를 구현하고 있다.

  • getStaticPaths: 페이지가 접근 가능한 주소를 미리 정의하는 함수이다. 예를 들어, /pages/post/[id]라는 경로가 있고

    export default getStaticPaths = async () => {
    	return {
    		paths: [{params: {id: '1'}}, {params: {id:'2'}}],
    		fallback: false,
    	}
    }
    

    이와 같이 함수가 정의 되어있다면 /post/1과 /post/2 만 접근이 가능하며, 그외의 /posts/3 등은 404를 반환한다. (단, fallback이 true라면 빌드가 되기전까지 fallback 컴포넌트를 보여주고, 그 이후에는 해당 페이지를 보여준다.)

  • getStaticProps: 앞에서 정의한 페이지를 기준으로 해당 페이지로 요청이 왔을 때 제공할 props를 반환하는 함수이다. 위에처럼 id가 1과 2로만 제한되므로, fetchPost(1), fetchPost(2)을 기준으로 함수의 응답값을 props의 {post}로 반환한다.

    export const getStaticProps = async ({params}) => {
    	const {id} = params;
    	const post = await fetchPost(id);
    	
    	return {
    		props: {post}
    	}
    }
    

내 블로그에서 사용한 코드

export  function  getStaticPaths() {
	// 파일들을 모두 받아오고, slug를 추출한다.
	const  slugs  =  getAllPosts().map((file:Post) =>  file.slug);
	// 이 slug들을 경로로 미리 지정한다.
	return {
		paths:  slugs.map((slug:  string) => {
		const  detail  =  slug.split('/');
// params에 각각의 경로는 배열로 넘겨줘야 한다. (ex. /posts/detail이면 ['post', 'detail'] 이런 식 으로
			return { params: { detail } };
		}),
		fallback:  false,
	};
}

나의 블로그에서는 getStaticPaths에서는 모든 포스트들의 slug (slug는 /posts/category/파일명 방식으로 되어있는 파일의 경로로 보면 된다.)를 params로 넘겨주어 경로를 미리 지정했다.

export  const  getStaticProps  :GetStaticProps  = ({params}) => {

	const {detail} =  params  as {detail:string[]};
	// 배열을 다시 문자열로 바꾸고, 여기에 .mdx를 붙인다.
	const  detailPath  =  detail.join('/')+'.mdx';
	const  postData  =  getPostData(`${detailPath}`);

	return {
		props: {
			post:  JSON.parse(JSON.stringify(postData)),
		},
	};
}

이후 받아온 params를 사용하여 해당하는 mdx파일을 미리 받아온다. 이를 props로 넘겨주면 페이지에서 이 데이터를 사용할 수 있다.

export  function  PostDetailPage(props:PostDetailProps) {
	return (
	<>
		<Head>
			<title>{props.post.title}</title>
			<meta  name="description"  content={`${props.post.summary}`}  />
		</Head>
		<PostDetailLayout  post={props.post} />
	</>
	)
}

props로 미리 빌드하여 생성한 데이터를 받아와서 사용하는 모습이다. (또한 Head태그를 사용하여(page router 기준) 메타데이터 역시 받아온 데이터 정보로 설정하여 SEO에 유리하게 했다.)

이 두함수를 적절히 설정하고 페이지를 npm run build를 사용했을 때 모습을 보자. nextjs

앞서 살펴본 상세 페이지인 [...detail]외와 /posts/[caregory], /posts/tag/[tag] 까지 각 페이지 별로 원하는 path와 데이터를 제공했더니 가능한 모든 조합을 빌드 시점에 불러와 페이지로 렌더링했다.

이렇게 페이지를 모조리 빌드해두고 배포하면 사용자는 이미 완성되어 있는 페이지를 받기만 하면 되므로 굉장히 빠르게 해당 페이지를 확인할 수 있다.

마치며

페이지의 다양한 렌더링 방식인 CSR, SSR에 대해서 그리고 더 나아가 SSG에 대해서도 살펴보았다. 뭐가 확실히 더 좋다 라기 보다는 각 방식을 확실히 이해하고 상황에 따라 적절히 이를 활용하는 것이 중요할 듯 하다.