환경 고민없이 개발하기

김동현 · 토스 Frontend Developer
2023년 9월 1일

토스 프론트엔드 챕터는 유저가 경험하는 로딩 시간을 줄이기 위해 지속적으로 노력하고 있습니다. 특히 Slash 22를 통해 서버 사이드 렌더링(SSR)를 이용한 개선 사례를 소개드린 적이 있는데요. 이번 아티클에서는 Next.js 도입 과정에서 마주한 문제와 해결 방법을 소개할게요.

서버 사이드 렌더링

서버 사이드 렌더링(SSR)은 렌더링 작업 일부를 서버에 위임하는 방식이에요. 브라우저에게 완성된 HTML을 전달하는 것이죠. 사용자는 빠르게 서비스를 이용할 수 있고, 서비스는 검색 엔진 최적화(SEO)로 더 많은 노출 기회를 얻을 수 있습니다.

하지만 서버 사이드 렌더링(SSR)을 위해서는 별도의 서버를 운영해야 돼요. 프레임워크를 사용하는 경우, 서버 구축 및 운영 등의 문제에는 벗어날 수 있지만 렌더링 과정에 서버가 개입되면서 window is not defined 와 같은 생소한 에러를 경험하게 됩니다.

단순하게 생각해보면 서버에서 제공한 HTML을 이용한 것뿐인데, 왜 이런 에러를 경험하게 되는걸까요? 서버 사이드 렌더링(SSR)환경에서 흔히 발생하는 에러와 그 해결법을 사례를 통해 살펴볼게요.

Next.js 렌더링 과정

예시로 살펴볼 애플리케이션은 쿼리 파라미터로 전달받은 유저의 이름을 화면에 출력해요.

function App() {
	// 쿼리 파라미터로 전달받은 유저의 이름을 얻어온다.
	const name = new URL(location.href).searchParams.get(name);	
	// 유저의 이름을 화면에 출력한다.
	return <div>{name}</div>
}

코드를 살펴보면 문제없이 동작할 것 같지만 서버 사이드 렌더링(SSR) 환경에서 에러가 발생하는데요. 어떤 부분이 에러를 일으키는걸까요? 에러 메시지를 보며, 원인을 찾아보겠습니다.

1. 서버가 HTML을 생성한다.

메세지를 살펴보면, 에러가 발생한 환경은 다음과 같은 특징을 가지고 있습니다.

  1. 브라우저 객체인 location 이 존재하지 않는다. (location is not defined)
  2. 페이지(HTML) 생성이 가능하다. (This error happened while generating the page)

즉 1) 브라우저가 아니면서 동시에 2) 페이지(HTML) 생성이 가능하다는 특징으로 해당 에러는 서버에서 발생한 에러임을 추측할 수 있어요.

서버는 클라이언트에서 제공한 컴포넌트를 기반으로 HTML을 생성하는데요. 이때 만약 클라이언트 환경에만 존재하는 코드가 있다면 어떤 일이 일어날까요? 서버는 해당 코드의 작동 방식을 이해할 수 없고, 이로 인해 에러가 발생해요.

처음 작성한 코드를 다시 돌아가보면 location 은 클라이언트 환경에만 존재하는 브라우저 객체입니다. 따라서, 해당 에러를 해결하기 위해서는 서버 환경에서 location 에 접근할 수 없도록 수정해야 돼요.

function App() {
	const name = (() => {
		/* 서버 환경인 경우, 객체에 접근하지 못하도록 수정 .. */
		if ( isServer() ) {
			return null;
		}
		 return new URL(location.href).searchParams.get(name);	
	})();
	/* 유저의 이름을 화면에 출력한다 ..*/
	return <div>{name}</div>
}

2. Hydration Mismatch

서버 환경에서 브라우저 객체에 접근할 수 없도록 수정한 후, 새로운 에러가 발생했어요.

위 에러를 해결하려면 Hydration 을 이해해야 돼요.

서버에서는 사용자 인터랙션이 불가능한 단순 마크업 HTML을 생성해요. 따라서 React는 이벤트 리스너, 상태 관리와 같은 클라이언트 로직을 서버로부터 전달받은 HTML과 통합하여 애플리케이션으로 작동할 수 있도록 합니다. 이 통합 과정이 Hydration 입니다.

여기서 로직 연결 과정을 주의깊게 봐야 돼요. React는 요소(Element)와 로직 정보가 담긴 가상 DOM을 생성한 뒤, 이를 전달받은 HTML과 비교해요. 그래서 서버와 클라이언트의 렌더링 결과가 같을 때만 Hydration 을 할 수 있어요.

그럼 코드의 문제가 뭘까요? 서버에서 바라보는 name 변수의 값은 항상 null 인데요. 클라이언트는 쿼리파라미터에 따라 사용자 이름을 출력하고 있어요. 따라서 쿼리 파라미터가 있으면, 서버와 클라이언트는 각각 다른 결과를 렌더링하고 Hydration 을 할 수 없는 상태인 것이죠.

// 서버
<div>{null}</div>

// 클라이언트
<div>{'김토스'}</div>

하나의 코드, 동일한 결과 Isomorphic

그렇다면 어떻게 문제를 해결할 수 있을까요?

Hydration Mismatch를 해결하기 위해서는, 서버와 클라이언트의 렌더링 결과물이 같아야 합니다. 이를 위해 서버 환경에서 쿼리 파라미터에 접근할 수 있는 별도의 로직을 작성해야 돼요.

function App() {
	const name = (() => {
		if (isServer()) {
	    /* 서버 환경에서 쿼리 파라미터 접근 및 반환하는 별도 로직... */
		}
		 return new URL(location.href).searchParams.get(name);	
	});
	return <div>{name}</div>
}

다행히도 Next.js는 개발자가 겪을 불편함을 줄여주고자 useRouter() 을 제공하고 있어요.

import { useRouter } from 'next/router';

function App() {
	const name = useRouter().query.name;

	return <div>{name}</div>
}

useRouter() 를 사용하면 별도의 예외처리 없이도 서버, 클라이언트 어떤 환경에서든 동일한 결과 값을 보장 받을 수 있어요.

// 서버
<div>{'김토스'}</div>

// 클라이언트
<div>{'김토스'}</div>

이처럼 서버와 클라이언트 양측에 동일한 결과를 보장하는 코드를 isomorphic 하다고 표현해요.

요구사항을 다시 살펴보면, 쿼리 파라미터 값을 화면에 출력하는 매우 간단한 작업인데요. 서버 사이드 렌더링 환경을 이해하지 않았다면 에러를 해결하는 데 많은 시간을 소비했을 것입니다. 만약 처음부터 useRouter()를 사용했다면 어땠을까요?

isomorphic 한 코드는 나와 동료의 시간을 절약해요. 일관된 결과를 서버와 클라이언트 양측에 보장하기 위해서는 관련 작업이 반드시 필요해요. 따라서, 이러한 작업들을 추상화해둔다면 불필요한 코드들을 감춰지고, 구현에만 집중할 수 있게 됩니다.

토스의 isomorphic

그렇다면 실제 서비스에 적용해볼 수 있는 실용적인 사례는 없을까요? 토스 프론트엔드 챕터에서 사용하고 있는 isomorphic 사례를 소개하면서 글을 마무리할게요.

SSRSuspense

<Suspense /> 는 비동기 요청을 선언적으로 처리할 수 있도록 돕는 컴포넌트인데요.

  • Promise가 대기 상태일 때(Pending) : <Loading />
  • Promise가 완료됐을 때(Resolved): <APIRequestComponent />
function App() {
	return (
		<Suspense fallback={<Loading/>}>
			<APIRequestComponent />
		</Suspense>
	)
}

그러나 React 18 버전 미만에서는 <Suspense/>가 오직 클라이언트 환경에서만 정상 작동한다는 한계점이 있어요. SSR 환경에서 안정적으로 작동할 수 있도록 <Suspense/>는 컴포넌트가 마운트 되기 전에는 fallback 컴포넌트를 렌더링합니다.

동현님과 일해보고 싶다면?
댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.