Articles

리액트 첫 화면을 미리 그려 두고 깜빡임 없이 이어받기

자바스크립트가 뜨기 전에도 첫 화면의 프로젝트와 글 목록이 보이도록, 빌드할 때 같은 모양의 HTML을 미리 넣어 두었어요. 리액트가 그 화면을 이어받을 때 생긴 문제와 해결 방법을 정리했어요.

글 페이지를 정적 HTML로 만든 방법을 정리한 적이 있어요. 그때 첫 화면은 리액트로 그리는 그대로 뒀어요. 그런데 첫 화면도 결국 같은 문제가 있었어요. 자바스크립트를 실행하지 않는 프로그램이 보면 이 사이트의 대문은 빈 페이지였어요.

첫 화면이 비어 있으면 생기는 일

  • 검색 결과에 사이트 이름만 나오고 무엇을 하는 곳인지 알 수 없어요.
  • 첫 화면에서 글로 가는 링크가 HTML에 없으니, 검색 로봇이 글을 찾아가기 어려워요.
  • 느린 네트워크에서는 자바스크립트를 받는 동안 검은 화면만 보여요.

첫 화면을 통째로 정적 HTML로 바꾸면 해결되지만, 별 배경과 시계와 기여자 통계는 자바스크립트가 있어야 돌아가요. 그래서 둘 다 하기로 했어요. HTML에는 내용을 미리 넣어 두고, 리액트가 뜨면 그 위에 다시 그려요.

빌드할 때 root 안을 채워요

index.html에는 리액트가 그림을 그릴 빈 칸이 있어요.

<div id="root"></div>

Vite 플러그인에는 index.html을 내보내기 전에 고칠 수 있는 지점이 있어요. 거기서 이 빈 칸을 첫 화면의 마크업으로 채워요.

transformIndexHtml(html) {
  return html.replace(
    '<div id="root"></div>',
    () => `<div id="root">${homeMarkup(buildPages(root).articles)}</div>`,
  )
}

replace의 두 번째 인자를 문자열이 아니라 함수로 넘긴 데에는 이유가 있어요. 문자열로 넘기면 그 안의 $&나 $1 같은 글자를 특별한 의미로 해석해요. 글 제목에 $가 들어가면 엉뚱한 결과가 나와요. 함수로 넘기면 돌려준 값을 그대로 넣어요.

무엇을 미리 그릴지

homeMarkup은 첫 화면 중 데이터가 고정된 부분만 그려요.

부분 미리 그리는지 이유
로고와 상단 메뉴 그려요 바뀌지 않아요
프로젝트 목록 그려요 코드에 적힌 데이터예요
글 목록 그려요 빌드할 때 아는 목록이에요
하단 링크 그려요 바뀌지 않아요
현재 시각 자리만 잡아요 빌드한 시각이 찍히면 안 돼요
기여자와 Our Crew 그리지 않아요 GitHub에서 받아 와야 해요
별 배경 그리지 않아요 캔버스라 자바스크립트가 필요해요

프로젝트 목록은 리액트 컴포넌트가 쓰는 것과 같은 데이터 파일에서 읽어요. 플러그인이 src/data/projects.ts를 그대로 가져다 써요. 그래서 프로젝트를 하나 추가하면 미리 그린 화면과 리액트 화면에 같이 나타나요.

import { projects } from '../src/data/projects.ts'

시계는 빈 칸만 넣어 두고, 높이를 차지하게 해서 리액트가 시각을 채울 때 아래 내용이 밀려 내려가지 않게 했어요.

같은 모양이어야 해요

미리 그린 HTML과 리액트가 그린 화면의 클래스 이름과 구조가 다르면, 리액트가 뜨는 순간 화면이 바뀌어 보여요. 박스 크기가 달라지거나 글자 위치가 튀어요.

그래서 리액트 컴포넌트의 마크업을 바꾸면 homeMarkup도 같이 고친다는 규칙을 팀 문서에 적어 뒀어요. 같은 일을 두 번 하는 셈이라 아쉽지만, 첫 화면 구성은 자주 바뀌지 않아서 감당할 만했어요.

애니메이션이 두 번 재생되는 문제

첫 화면의 박스들은 아래에서 위로 서서히 떠오르며 나타나요.

.project-card {
  animation: appear 1.4s ease 0.2s both;
}

미리 그린 HTML이 생기고 나니 이상한 일이 생겼어요. HTML이 먼저 보이면서 박스가 떠오르고, 잠시 뒤 리액트가 화면을 다시 그리면서 새로 만든 요소에서 애니메이션이 처음부터 또 재생됐어요. 박스가 한 번 나타났다가 사라지고 다시 떠오르는 것처럼 보였어요.

리액트는 기존 HTML을 재사용하지 않고 새로 그려요. 서버에서 그린 HTML을 이어받는 hydrateRoot라는 방법도 있지만, 시계처럼 미리 그린 것과 내용이 다른 부분이 있으면 경고가 나고 화면을 통째로 다시 그려요. 미리 그린 것은 리액트가 만든 HTML이 아니라 직접 쓴 문자열이라, 완벽히 일치시키기도 어려웠어요.

그래서 리액트를 띄우기 전에 미리 그린 내용이 있는지 확인하고, 있으면 html 태그에 표시를 남겨요.

const root = document.getElementById('root')!

if (root.hasChildNodes()) document.documentElement.classList.add('prerendered')

createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

그리고 그 표시가 있을 때는 등장 애니메이션을 끄도록 했어요.

.prerendered .clock,
.prerendered .project-card,
.prerendered .articles,
.prerendered .site-footer {
  animation: none;
}

미리 그린 HTML에서 이미 애니메이션이 재생됐으니, 리액트가 다시 그릴 때는 그 자리에 바로 나타나기만 하면 돼요. 방문자 눈에는 한 번만 떠오르는 것으로 보여요.

기여자 목록인 Our Crew는 이 목록에 넣지 않았어요. 미리 그리지 않는 부분이라, 리액트가 처음 그릴 때 떠오르는 애니메이션이 한 번 재생되는 것이 맞아요.

개발 서버에서도 같은 화면

transformIndexHtml은 빌드할 때뿐 아니라 개발 서버가 index.html을 돌려줄 때도 불려요. 그래서 개발 중에도 미리 그린 화면이 들어간 상태로 확인할 수 있어요. 글 파일을 고치면 첫 화면의 글 목록도 바로 바뀌어요.

정리

  • 첫 화면도 HTML 안에 내용이 있어야 검색 로봇과 느린 네트워크에서 보여요.
  • 바뀌지 않는 부분만 빌드할 때 그려 두고, 실시간 데이터와 캔버스는 리액트에 맡겨요.
  • 미리 그린 화면과 리액트 화면은 같은 데이터와 같은 클래스 이름을 써야 해요.
  • 미리 그린 화면이 있으면 표시를 남겨서, 등장 애니메이션이 두 번 재생되지 않게 해요.
다음 글 출시 전 아이디어에 가상 사용자 서른 명의 의견을 받는 서비스를 만든 이유