프론트엔드 개발자들아, 하이드레이션과 친해져라!

Composite·2024년 2월 13일
4

프론트엔드 개발자들아, 하이드레이션과 친해져라!

왠만한 프론트엔드는 초기 결과물을 렌더링하는 하이드레이션 기능을 기본적으로 지원하고 있다.
왜? 프론트엔드 하면 리액트인데, 리액트 안나와서 실망했냐? 프론트엔드는 니들이 전부가 아니란다.
물론 리액트가 주인공이긴 하지만...

먼저, 하이드레이션을 활용하는 주요 프레임워크를 꼽아보자면,

  • React 기반: Next.js(React 팀에서 대놓고 추천), Remix
  • Vue 기반: Nuxt.js, Quasar
  • Svelte 기반: SvelteKit
  • 짬뽕: Astro
  • 기타: Fresh, SolidStart, Qwik City...

아 갑자기 짬뽕 땡기네.

왜 하이드레이션 친화적이어야 하지?

그러면, 너희들에게 묻겠다.
React 개발자들아, create-react-app 만 쓰니? Next.js 많이들 쓰지?
Vue 개발자들아, 어... 이제 좀 Nuxt.js 나 Quasar 라도 써야지...
Svelte 개발자들아, 잘 안들리네? 그러면 SvelteKit 쓰고 있다고 알고 있을게.

어쨌든, 너희들이 컴포넌트 기반의 프론트엔드를 개발한다면,
하지만 이건 프론트엔드, 백엔드, 시스템, 임베디드, 게임... 모두 해당되는 사항인데,
가능하면 플랫폼 중립적인 API를 쓰는 것이 가장 이롭다.
큰 장점은, 플랫폼 종속성이 가장 적은 API는 작동 성공율이 가장 높고 안정적이기 때문이지.
하지만 항상 쓸 수는 없잖은가. 만약에 최악의 상황에 대응하기 위해 플랫폼 특정 API를 쓰지. 다들 그렇지?
하지만 프론트엔드 개발자들은 그런 모습이 국내 뿐 만 아니라 해외에서도 별로 없어서 중대장은 실망했다.

따라서, 프론트엔드 개발자들에게 중립적 프로그래밍이 무엇인지 알려주도록 하겠다.
프론트엔드를 다룰 테니 통상적인 웹 기준이다. 네이티브는 얘기가 달라지고 복잡해지니 다루지 않는다.

React 컴포넌트 작성 시 따라하라

컴포넌트 함수를 작성한다면, 아마 분명 브라우저 API를 막바로 아래처럼 사용하고 있을 것이다.

export default function MyResizeComponent({ initWidth, initHeight, children }) {
  const [width, setWidth] = useState(initWidth || window !== undefined ? localStorage.getItem('savedWidth') : 0);
  const [height, setHeight] = useState(initHeight || window !== undefined ? localStorage.getItem('savedHeight') : 0);
  useEffect(() => {
    localStorage.setItem('savedWidth', width)
  }, [width])
  useEffect(() => {
    localStorage.setItem('savedHeight', height)
  }, [height])

  // ...여기에 크기 조절하는 로직 입력

  return <div style={{width: `${width}px`, height: `${height}px`}}>{children}</div>
}

자, 뭐가 잘못됐는지 벌써 알아차린 사람도 있을 테고, 이게 뭐가 문제임? 하는 사람도 있을 거다.
내가 단단히 경고하겠다. 이런 짓 하지 마라. 네 컴포넌트가 Next.js 에서 돌아가고 싶다면 더욱 더,
즉, 쌩 날 React 뿐만 아니라 React 기반 여러 프레임워크나 환경에서 돌아가고 싶다면, 더욱 더 이런 짓은 다매요.

그러면, 어떻게 해야 react 의 모든 환경에서 동작하는 컴포넌트를 작성하는가?
브라우저로 빠져나갈 수 있는 구멍은 바로 훅(Hook) 함수 내 콜백에 있다.
어자피 훅 함수는 브라우저에서만 작동하게 되어 있다.
가장 쉽게 접하는 훅이 useEffect 함수일 거고, 컴포넌트 초기화도 여기서 담당한다.
컴포넌트 초기화는 브라우저에서 시행되기 때문에, 이 안에서 초기화를 하는 것이 좋다.

'use client'; // 다음 React 버전 대응을 위해 일반 컴포넌트일 경우 이거 쓰는 버릇 좀 들여라.
export default function MyResizeComponent({ initWidth, initHeight, children }) {
  const [width, setWidth] = setState(0);
  const [height, setHeight] = setState(0);
  useEffect(() => {
    setWidth(initWidth || localStorage.getItem('savedWidth') : 0);
    setHeight(initHeight || localStorage.getItem('savedHeight') : 0);
  }, []) // 두번째 인자에 빈 배열을 줘야 초기화 외에 영향이 없어진다.
  setEffect(() => {
    localStorage.setItem('savedWidth', width)
  }, [width])
  setEffect(() => {
    localStorage.setItem('savedHeight', height)
  }, [height])

  // ...여기에 크기 조절하는 로직 입력

  return <div style={{width: `${width}px`, height: `${height}px`}}>{children}</div>
}

Next.js 에서는 잘못된 예시를 작성하여 돌릴 경우 재미없게도 Hydration error 라는 오류가 널 반길 것이다.
리액트의 하이드레이션은 프로세스 단에서 컴포넌트 초기 결과물 뱉어낼 뿐 만 아니라, 클라이언트에서도 렌더링 결과물을 또 내뱉는다.
그리고 그 둘을 비교하여 렌더링 무결성을 검증한다. 이 과정에 의해서 Next.js 개발할 때 시도때도없는 하이드레이션 오류는 널 참 피곤하게 할 것이다.
따라서, 리액트 개발자들은, 네이티브도 마찬가지고, 브라우저 전용 API 쓰는 여부를 확실히 인지하고, 브라우저 API는 반드시 훅으로 빼는 습관을 들이도록 하자. 이정도만 해도 하이드레이션 오류 90%는 방지할 수 있다.

React 외의 컴포넌트는?

사실, React 외의 대부분 다른 프론트엔드 기술들의 하이드레이션은 리액트처럼 서버에서 한번 렌더링, 클라이언트에서 한번 렌더링, 그리고 그 둘을 비교해 무결성 검사까지 가는 피곤한 과정을 거치지 않는다.
따라서 아래와 같이 React 잘못된 예시를 그대로 Vue로 옮기고,

<script setup>
  import { ref, watch } from 'vue'
  const props = defineProps(['initWidth', 'initHeight'])
  const width = ref(initWidth || window !== undefined ? localStorage.getItem('savedWidth') : 0);
  const height = ref(initHeight || window !== undefined ? localStorage.getItem('savedHeight') : 0);
  watch(width, (newWidth) => {
    localStorage.setItem('savedWidth', newWidth)
  })
  watch(height, (newHeight) => {
    localStorage.setItem('savedHeight', newHeight)
  })

  // ...여기에 크기 조절하는 로직 입력
</script>
<template>
  <div :style="`width:${width.value}px;height:${height.value}px`">
    <slot />
  </div>
</template>

Svelte 컴포넌트로 옮겨도,

<script>
export let initWidth = 0
export let initHeight = 0
let width = initWidth || window !== undefined ? localStorage.getItem('savedWidth') : 0
let height = initHeight || window !== undefined ? localStorage.getItem('savedHeight') : 0
$: {
  localStorage.setItem('savedWidth', width)
}
$: {
  localStorage.setItem('savedHeight', height)
}

// ...여기에 크기 조절하는 로직 입력
</script>

<div style={`width:${width.value}px;height:${height.value}px`}>
  <slot />
</div>

빌드 시 오류는 나지 않는다. 왜냐, 일단 window같은 브라우저용 객체 여부에 대한 최소한의 방어로직을 통해 하이드레이션을 실행하기 위해 node.js 단에서 수행하는데 발생할 법한 오류를 피했기 때문이다.

하지만, 그렇다고 안심해서는 안된다, 대신, Nuxt.js 나 SvelteKit 등의 프레임워크에서는 대신 다른 문제거 널 기다리고 있다.
바로, 처음 들어갔을때와 다른 페이지로 이동했을 때 결과에서 차이가 발생하기 시작한다는 것이다.

이건 심지어 런타임 오류조차 안나기 때문에 리액트에 비하면 오히려 인지하기 어려운 오류이기도 하다.

그러나...

뭐? 오류나는데?

만약 클라이언트 전용 라이브러리를 import 만 해도 오류난다면, 그게 정상이다. 지금부터 이 설명을 하도록 하겠다.
클라이언트용 라이브러리들은 당연하겠지만 클라이언트에서만 돌아간다는 가정 하에 설계했기 때문에,
대부분의 경우 브라우저API 존재 여부 체크를 빡시게 하지 않는다. 따라서 브라우저용 API를 쓰려고 하기만 해도 왠만한 프론트엔드 기술들의 하이드레이션 과정에서 당연히 오류를 내뿜을 것이다.

따라서, 내가 그걸 알려주고, 중립적인 컴포넌트를 만들자는 의미에서 이렇게 쓸데없이 길게 설명한 것이다.
자, 이제 어떻게 해야 할까? 각 프론트엔드 기술환경 기준으로 알아보도록 하자.

Vue

재미있게도 Nuxt.js는 하이드레이션에 대해 꽤 관대한 환경이다. 따라서 따로 하이드레이션 오류를 해결하는 메뉴얼이 없다.
하지만,

<script setup>
  import { ref, watch } from 'vue'
  const props = defineProps(['initWidth', 'initHeight'])
  const width = ref(initWidth || window !== undefined ? localStorage.getItem('savedWidth') : 0);
  const height = ref(initHeight || window !== undefined ? localStorage.getItem('savedHeight') : 0);
  watch(width, (newWidth) => {
    localStorage.setItem('savedWidth', newWidth)
  })
  watch(height, (newHeight) => {
    localStorage.setItem('savedHeight', newHeight)
  })

  // ...여기에 크기 조절하는 로직 입력
</script>
<template>
  <div :style="`width:${width.value}px;height:${height.value}px`">
    <slot />
  </div>
</template>

Vue도 해 두는 것이 좋다. 특히 Astro 같은 범용 사이트 생성 프레임워크와 연동 시 빛을 발한다.
빌드 시 모든 프론트엔드 프레임워크들의 하이드레이션은 생명 주기 함수 밖에 브라우저 객체가 동작할 수 없다.
최적화 과정에서 날려버릴 수 있다. 그렇게 되면 초기 컴포넌트 초기화 시 원하는 작동이 되지 않을 수 있다.
아무리 관대한 Vue라도 예외는 없다. 컴포넌트 초기화는 빌드 시에만 관여하기 때문이다.
그리고 그 결과물인 HTML, CSS, JS를 브라우저에 뱉고 시작한다. 그런 다음 생명주기가 시작된다.

이는 모든 프론트엔드 프레임워크들의 공통사항이며, 항상 이를 주시하고 컴포넌트 설계 대응에 집중해야 여러 환경에서 발생하는 사고를 대부분 막을 수 있다.

마치며

사실상 리액트 개발자 대상으로 싸지른 글이나 다름없다. 하이드레이션에 가장 민감하게 대응하는 규모가 큰 기술이 아무래도 리액트가 많으니까.
하지만, 하이드레이션 기능이 있는 모든 프론트엔드 기술이라면 특성상 차이가 없기 때문에, 이를 간과하지 말라는 뜻으로 싸지른 의미가 가장 크다.
하이드레이션 과정에서 발생할 수 있는 사고와, 어떤 환경에서도 웹 브라우저 상에서 원만하게 네가 만든 컴포넌트를 멀쩡하게 돌릴 수 있는 기초적인 준비 사항을 제시한 메뉴얼이다.

리액트, 뷰, 스벨트 등등 CRA만 쓴다면 상관없다는 생각은 이제 안하는 것이 좋다.
물론 CRA는 관리자용 사이트 등 업무 중심의 사이트 구축에 가장 효율적인 환경임에는 틀림없다.
하지만 컨텐츠 중심의 대부분 시나리오에 CRA의 단점은 다들 익히 알려져 있어 인지하고 있을 것이다.

  • 페이지와 상태에 따른 HTTP STATUS CODE 제공 불가
  • 각 페이지의 메타 등록의 어려움으로 인한 검색 엔진 최적화의 어려움
  • 핵심 요소 다운로드와 초기화 스크립트로 인한 오래 걸리는 초기화 과정과 브라우저의 새로고침 등의 상태관리 어려움으로 인한 사용자 경험 저해

그래서 리액트는 Next.js 에 눈을 돌리고 있고, Vue 는 Nuxt.js 및 Vuepress, Quasar 등의 여러가지 시나리오를 제공하는 프레임워크를 사용하거나, Svelte는 SvelteKit 를 통해 다양한 시나리오에 따른 웹 어플리케이션 환경 생성 도구 제공, 그리고 왠만한 프론트엔드 기술을 제공하여 일반 웹 페이지처럼 작성하는 프레임워크인 Astro가 각광을 받고 있는 것이다.

전자정부표준프레임워크의 경우, 백엔드는 전자정부로 강제되지만, 다행히도 프론트엔드는 제한이 없다. 물론 웹디자인기능사와 저렴한 단가를 강점으로 제이쿼리와 JSP가 주류가 되고 있으나, 유지 관릐의 효율성, 혹은 트랜드를 따르겠다시고, 리액트나 뷰 등의 프론트엔드 기술을 사용할 수 있으니, 프론트엔드는 이제 선택의 자유로움을 만끽할 수 있다.

그런 선택의 기로에서도 어디서든 돌아갈 수 있는 너의 컴포넌트는 차기 버전에서도 조금만 수정만 해도 돌아갈 수 있는 지속성을 보장받을 수 있고, 유지보수의 편리함과, 각 스크립트가 돌아가는 스코프(Scope)를 이해하는데 상당한 도움이 된다.

따라서, 수화(水化, Hydration)에 흠뻑 취하라. 인간의 70%는 물이기 때문이다(?).

끗.

profile
지옥에서 온 개발자

1개의 댓글

comment-user-thumbnail
2024년 2월 17일

좋은 글이네요 잘 읽고 갑니다!

답글 달기