[Nuxt3] Rendering Mode의 핵심과 LifeCycle 이해하기

Yoochan·2024년 6월 18일
1

요즘 Next.js나 Nuxt.js 같은 서버 사이드 렌더링(SSR)을 제공하는 라이브러리들이 널리 사용되고 있다.

이번 포스팅에서는 렌더링 기법들의 개념들을 설펴보고, Nuxt.js가 지원하는 다양한 렌더링 모드와 이를 어떻게 활용할 수 있는지에 대해 정리해보았다.

CSR(Client-Side Rendering) vs SSR(Server-Side Rendering)

현대 웹 개발에서 중요한 두 가지 렌더링 방식은 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR)이다.

CSR은 말 그대로 렌더링이 클라이언트 쪽에서 일어나는 것이다. 서버에서 전체 페이지를 한 번 렌더링하여 보여주고 사용자가 요청할 때마다 리소스를 서버에서 제공받아 클라리언트가 해석하고 렌더링하는 방식이다.

초기 렌더싱 시, 텅텅 비어있는 HTML을 받고, 그 이후에 라이브러리 코드와 더불어 작성된 JS 코드를 다운로드한다.

여기에는 어플리케이션에 필요한 로직들 뿐만 아니라, 어플리케이션을 구동하는 프레임워크와 라이브러리의 소스 코드들도 다 포함이 되어 있다.

이렇게 클라이언트에 보여줄 준비를 완료하게 된다. 서버로부터 다운을 받아 DOM 요소로 변환해서 브라우저에 표기하는 이 모든 과정이 클라이언트 측에서 발생한다.

추가로 필요한 데이터가 있다면, 서버에 요청해서 데이터를 받아온 후, 이것들을 기반으로 해서 동적으로 HTML을 생성해 사용자에게 최종적인 어플리케이션을 보여주는 것이다.

흔히 React, Vue로 만드는 SPA(Single Page Application)이 CSR 방식으로 동작한다.

SSR은 렌더링하는 주체가 서버이며, 요청할 때마다 서버에서 페이지에 필요한 데이터들을 삽입하고, 렌더링을 마친 HTML과 Javascript 코드를 클라이언트에 전달한다.

이런 SSR의 특징 때문에, 사용자가 빠르게 웹사이트를 확인할 순 있지만, 동적으로 데이터를 처리하는 자바스크립트를 아직 다운로드 받지 못해서 반응이 없는 경우가 발생할 수 있다.

위 현상을 TTV(Time To View)TTI(Time To Interact)라는 용어로 표현하는데, 이에 대해 자세히 살펴보자면,

CSR에서는
1. 서버에서 인덱스 파일을 받아오고
2. 여기에 링크되어져 있는, 즉 웹사이트에서 필요한 모든 로직이 담겨있는 자바스크립트를 요청한다.
3. 그리고 최종적으로 동적으로 HTML을 생성할 수 있는 웹어플리케이션 로직이 담긴 자바스크립트 파일을 받아오게 된다. 이 순간부터 웹사이트가 사용자에게 보여지게 되고 (TTV), 사용자가 클릭이 가능하게 된다. (TTV)

SSR에서는
1. 서버에서 이미 잘 만들어진 인덱스 파일을 받아 오고
2. 사용자가 웹사이트를 볼 수 있다. (TTV)
3. 자바스크립트 파일을 서버에서 받아온다. 이때부터 사용자의 클릭을 처리할 수 있는 인터렉션이 가능하다.(TTI)

따라서 SSR에서는 사용자가 사이트를 볼 수 있는 시간과 실제로 인터렉션을 할 수 있는 공백의 기간이 긴편이다.

✔️ CSR vs SSR 장단점 정리

CSRSSR
장점- 화면 깜빡임이 없음
- 초기 로딩 이후 구동 속도가 빠름
- TTV와 TTI 사이 간극이 없음
- 서버 부하 분산
- 초기 구동 속도가 빠름
- SEO에 유리
단점- 초기 로딩 속도가 느림
- SEO에 불리
- 화면 깜빡임이 있음
- TTV와 TTI 사이 간극이 있음
서버 부하가 있음

Universal Rendering

Nuxt는 기본적으로 Universal Rendering을 제공한다.

Universal Rendering은 SSR (Server-Side Rendering)과 CSR (Client-Side Rendering)을 결합한 접근 방식이다.

사용자가 웹 페이지를 요청하면 서버가 완전히 렌더링된 HTML 페이지를 브라우저에게 반환한다. 이 HTML 페이지는 Nuxt.js가 서버 환경에서 JavaScript (Vue.js) 코드를 실행하여 생성하는데, 이 과정을 프리랜더링(pre-rendering)이라고 한다.
프리랜더링를 통해 검색 엔진이 페이지 내용을 쉽게 색인할 수 있으며, 초기 로딩 속도도 개선된다.

클라이언트(브라우저)는 HTML 문서를 다운로드한 후 백그라운드에서 서버에서 실행된 JavaScript 코드를 로드한다. 브라우저는 이 코드를 해석하고 Vue.js가 문서를 제어하여 상호작용을 가능하게 만드는데, 이 과정을 하이드레이션(hydration)이라고 한다.
하이드레이션을 통해 클라이언트 사이드 앱과 동일한 동작과 반응성을 제공한다.

이 두 개념은 단언코 서버 사이드 렌더링의 핵심을 이룬다.

이렇게 Universal Rendering을 통해 Nuxt 애플리케이션은 정적 콘텐츠와 동적 인터페이스를 효과적으로 결합하여 최고의 성능과 사용자 편의성을 제공할 수 있다.

❗ Universal Mode로 개발 시 주의할 사항들

Universal Rendering은 Nuxt의 강력한 기능 중 하나이지만, 개발 시 주의해야 할 사항들이 있다.

1. 컴포넌트 생명주기 주의

Universal Rendering에서는 클라이언트 사이드와 서버 사이드의 환경이 다르다. 이에 따라 컴포넌트의 생명주기 메서드 사용 시 주의가 필요하다.

Vue3에서는 다양한 생명주기 훅을 제공하며, 이 중 일부는 서버 사이드에서는 실행되지 않거나, 클라이언트 사이드에서만 유효한 동작을 수행한다.

Vue3의 생명주기 훅은 다음과 같다.

  • onBeforeMount: 컴포넌트가 마운트되기 전에 호출
  • onMounted: 컴포넌트가 마운트될 때 호출
  • onBeforeUpdate: 반응형 데이터가 변경될 때와 re-render되기 전에 호출
  • onUpdated: re-render된 후에 호출
  • onBeforeUnmount: Vue 인스턴스가 파괴되기 전에 호출
  • onUnmounted: 인스턴스가 파괴된 후에 호출
  • onActivated: 보관된 구성 요소가 활성화되면 호출
  • onDeactivated: 보관된 구성 요소가 비활성화되면 호출
  • onErrorCaptured: 하위 구성 요소에서 오류가 캡처되면 호출

클라이언트 사이드 및 서버 사이드 개념과 더불어 각 생명주기 훅에 대한 주의사항과 예시를 정리해보면 다음과 같다.

- onBeforeMount / onMounted

이 훅들은 클라이언트 사이드에서만 실행된다. 서버 사이드에서는 실행되지 않기 때문에, 브라우저 전역 객체 (window, document 등)를 사용할 수 있다.

<script setup>
import { onBeforeMount, onMounted } from 'vue';

onBeforeMount(() => {
  console.log('This code runs before the component is mounted');
});

onMounted(() => {
  console.log('This code runs when the component is mounted');
  if (typeof window !== 'undefined') {
    console.log('Accessing window object:', window.location.href);
  }
});
</script>

<template>
  <div>Mounting Lifecycle</div>
</template>

- onBeforeUpdate / onUpdated

이 훅들은 클라이언트와 서버 모두에서 실행된다. 데이터 업데이트가 클라이언트에서만 발생하는 경우가 많기 때문에, 데이터 불일치를 방지하기 위해 적절히 사용해야 한다.

<script setup>
import { ref, onUpdated } from 'vue';

const message = ref('Hello, world!');

onUpdated(() => {
  console.log('Component was updated');
});
</script>

<template>
  <div>{{ message }}</div>
</template>

- onBeforeUnmount / onUnmounted
이 훅들은 클라이언트와 서버 모두에서 실행된다. 컴포넌트가 파괴되기 전에 리소스를 정리하는 데 유용하며, 클라이언트와 서버 환경 모두에서 안전하게 사용할 수 있다.

<script setup>
import { ref, onBeforeUnmount, onUnmounted } from 'vue';

const timer = ref(null);

onBeforeUnmount(() => {
  console.log('Component is about to be unmounted');
});

onUnmounted(() => {
  clearTimeout(timer.value);
  console.log('Component was unmounted');
});
</script>

<template>
  <div>Component Lifecycle</div>
</template>

- onActivated / onDeactivated
이 훅들은 <keep-alive>로 래핑된 컴포넌트에서만 유효하다. 서버 사이드에서는 <keep-alive>가 없기 때문에 클라이언트 사이드에서만 실행된다.

<script setup>
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  console.log('Component is activated');
});

onDeactivated(() => {
  console.log('Component is deactivated');
});
</script>

<template>
  <div>Keep-alive Component</div>
</template>

- onErrorCaptured
이 훅은 클라이언트와 서버 모두에서 실행되지만, 클라이언트에서의 오류 처리가 더 일반적이다. 서버 사이드에서 오류가 발생하면, 클라이언트로 전파되기 전에 처리가 필요하다

<script setup>
import { onErrorCaptured } from 'vue';

onErrorCaptured((err, instance, info) => {
  console.error('Error captured:', err, info);
  return false; // prevents further propagation
});
</script>

<template>
  <div>Error Handling Component</div>
</template>

SSR & CSR 개념과 더불어 컴포넌트 생명주기에 대한 이해는 뒤에 나올 주의 사항들과도 연결되는 중요한 사항이다.

2. Browser API 활용의 제한

widnow 객체나 document 객체와 같은 Browser API는 브라우저가 지원하는 기능이다. 즉 클라이언트 사이드에서 동작하는 기능이다.

SSR를 경험하지 않고 개발을 해왔다면, 아무렇지 않게 브라우저 API를 당연하듯이 사용해왔을 것이다. 하지만 SSR로 개발을 진행한다면, Browser API들을 마구잡이로 사용할 순 없다.

이는 위에서 언급했던 하이드레이션(Hydration) 때문에 발생하는 상황이다. CSR에서는 최초 렌더링 시점에 이미 DOM 객체가 다 만들어져 있지만, SSR에서는 최초 렌더링이 서버 사이드에서 이루어지므로 아직 DOM 객체가 없고, 따라서 브라우저 API를 호출할 수 없다.

Browser API를 다루려면, 위의 1번 사항(컴포넌트 생명주기 주의) 에서 언급했던 대로 onBeforeMount / onMounted 라이프사이클에서 처리해야한다.

따라서 SSR로 개발을 진행할 때는 나의 js 코드가 실행되는 시점과 Browser API를 사용할 수 있는 시점이 다름을 인지하고 있어야하며, 이를 바탕으로 클라이언트 사이드 코드와 서버사이드 코드를 분리할 수 있어야한다. 그래서 CSR&SSR 및 라이프사이클에 대한 개념이 매우 중요한 것이다.

Nuxt docs 에서도 해당 Browser API 들을 사용하는 곳은 Client-Side 에서만 실행되게끔 잘 사용하라고 명시하고 있다.

When importing a library that relies on browser APIs and has side effects, make sure the component importing it is only called client-side. Bundlers do not treeshake imports of modules containing side effects.

이는 사용하고자 라이브러리가 Browser API 를 사용한다면 해당 라이브러리는 Client-Side 에서만 작동하게끔 조작을 해야함을 의미하기도 한다.

처음에 회사에 들어와서 진행했던 프로젝트가 Editor.js를 사용하는 것이었는데, Editor.js 라이브러리에서 Browser API를 호출하기 때문에, Client-Side 에서만 작동하도록 처리하는게 필요했다.

초반에 이 개념이 부족하여 오류를 트래킹 하는데에 많은 시간을 허비하기도 했다. 해당 내용은 Nuxt를 활용한 Editor.js 개발기 관련 포스트에서 추후에 자세하게 다뤄보려고 한다.

실제로 이런 경험을 바탕으로 Nuxt3에서의 렌더링과 라이프사이클에 대한 개념을 확실하게 다지는 것이 중요함을 깨닫고, 개념을 확실하게 공부하는 계기를 가졌다.

3. HTTP 통신의 중복 호출

Nuxt를 사용하면서 가장 많이 겪는 어려움 중 하나는 HTTP 요청의 중복 호출 문제이다.

보통 Vue 개발에서는 Axios를 많이 사용하지만, Nuxt3에서는 Axios 대신 ofetch 라이브러리를 기본으로 채택했다. ofetch는 서버 환경에서는 node-fetch-native, 브라우저 환경에서는 브라우저의 기본 fetch를 선택하여 사용한다. 이 라이브러리는 $fetch라는 메서드를 통해 사용할 수 있다.

Nuxt 3에서는 컴포넌트에서 $fetch를 직접 사용하면, 서버와 클라이언트 양쪽에서 데이터 요청이 발생하여 두 번의 API 호출이 일어난다.

이는 아래와 같은 문제를 일으킨다:

  • Hydration Mismatch: 서버에서 생성한 HTML과 클라이언트에서 렌더링된 HTML이 일치하지 않을 경우 발생하는 경고이다. 이는 서버와 클라이언트의 상태가 다를 때 발생하며, 특히 서버에서 가져온 데이터와 클라이언트에서 다시 요청하여 가져온 데이터가 다를 경우 문제가 된다. 이로 인해 "Hydration mismatch" 경고가 발생할 수 있다.

  • 불필요한 API 호출: 서버는 동일한 API 호출을 두 번 받게 되어 성능 저하를 일으킬 수 있다. 이는 리소스 낭비와 서버 부하 증가로 이어질 수 있다.

Nuxt 3에서는 이러한 문제를 해결하기 위해 useFetch와 useAsyncData를 제공한다. 이 두 가지 방법을 사용하면 서버에서 가져온 데이터를 클라이언트로 전송하여 중복 호출을 방지할 수 있다.

Nuxt 3 문서에서도 이 문제와 해결 방법에 대해 다음과 같이 설명하고 있다:

"Using $fetch in components without wrapping it with useAsyncData causes fetching the data twice: initially on the server, then again on the client-side during hydration, because $fetch does not transfer state from the server to the client. Thus, the fetch will be executed on both sides because the client has to get the data again."

Nuxt에서 data fetching ($fetch & useFetch & useAsyncData)과 관련된 자세한 내용은
https://velog.io/@cychann/Nuxt3-data-fetching
해당 포스팅에서 확인 가능하다.

Universal Mode로 개발할 때는 위와 같은 주의할 사항들을 고려하여 SSR과 CSR의 환경 차이를 이해하고 개발하는 것이 정말 중요하다.
이를 통해 최적의 성능과 사용자 경험을 제공할 수 있는 방법을 모색해야 한다.

0개의 댓글