모던 리액트 4장

2024-01-23

모던 리액트 공부 기록

싱글 페이지 애플리케이션

싱글 페이지 애플리케이션은 렌더링과 라우팅에 필요한 대부분의 기능을 브라우저의 자바스크립트에 의존하는 것을 말한다. 최초로 첫 페이지에서 데이터를 불러 온 후, 페이지 전환이 브라우저의 <mark>history.pushState</mark>나 <mark>history.replaceState</mark>로 이뤄지기 때문에 페이지를 불러온 후부터는 서버에서 HTML을 추가로 내려받지 않는다.

싱글 페이지 애플리케이션에서 전체 사이를 모두 볼 수 있지만, 실제 소스 보기로 HTML을 보면 <body></body> 내부에 아무 내용이 없다.

사이트 렌더링에 필요한 body내부의 내용은 자바스크립트 코드를 삽입한 이후 렌더링하기 떄문이다. 또 페이지 전환 시에도 새 HTML을 요청하는 것이 아니라 다음 페이지의 렌더링에 필요하나 정보만 가져온 후 body 내부의 DOM으르 추가,삭제,수정하는 방법으로 페이지가 전환된다.

이런 싱글 페이지 애플리케이션은 자바스크립트 리소스가 커지는 단점이 있지만, 한번 로딩된 이후 서버를 거쳐 다시 리소스를 가져올 일이 적어져서 사용자에게 부드러운 페이지 전환을 재공한다.

서버 사이드 렌더링

싱글 페이지 애플리케이션이 자바스크립트를 통해 하나의 페이지에서 렌더링을 수행한다면, 서버 사이드 렌더링은 최초 사용자에게 보여주는 페이지를 서버에서 렌더링해 빠르게 사용자에게 보여주는 방식을 의미한다. 싱글 페이지 애플리케이션에서 자바스크립트의 크기가 커지면 커질수록 웹 페이지가 느려지는 것을 방지하고자, 서버에서 페이지를 렌더링해 제공하는 서버사이드-렌더링이 다시 주목을 받고 있다.

싱글 페이지 애플리케이션과 서버에서 페이지를 빌드하는 서버 사이드 렌더링은 웹 페이지의 렌더링의 책임을 어디에 두냐이다. 싱글 페이지 애플리케이션은 사용자에게 제공되는 자바스크립트 번들에서 렌더링을 책임지지만, 서버 사이드 방식은 렌더링의 역할을 모두 서버에서 수행한다.

서버사이드 렌더링의 장점으로는 다음의 것들이 존재한다.

다음으로 단점으로는 다음의 것들이 존재한다.

현대의 서버 사이드 렌더링은 기존의 서버 사이드 렌더링와 약간 다르다. 최초 웹 사이트 진입 시에는 서버 사이드 렌더링으로 서버에서 완성된 HTML을 제공받고,이후 라우팅에서는 서버에서 내려받은 자바스크립트를 바탕으로 마치 싱글 페이지 애플리케이션처럼 작동한다.

서버 사이드 렌더링을 위한 리액트의 API 알아보기

리액트는 브러우저 자바스크립트 환경에서 렌더링 할 수 있는 방법을 제공하지만, 동시에 애플리케이션을 서버에서 렌더링하는 API도 제공한다. 이 api는 Node환경에서만 실행할 수 있다.

import ReactDOMServer from 'react-dom/server'

function ChildComponent({fruits} : {fruits : Array<string>}) {
    useEffect(() => {
        console.log(fruits)
    },[fruits])

    function handleClick() {
        console.log('hello')
    }

    return (
        <ul>
          {fruits.map((fruit) => (
            <li key = {fruit} onClick = {handleClick}>{fruit}</li>
          ))}
        </ul>
    )
}

function SampleComponent() {
    return (
        <>
          <div>hello</div>
          <ChildComponent fruits = {['apple','banana','peach']} />
        </>
    )
}

const result = ReactDOMServer.renderToString(
    React.createElement('div', {id:'root'}, <SampleComponent />)
)

이 result는 다음과 같이 변환된다.

<div id = "root" data-reactroot ="">
    <div>hello</div>
    <ul>
        <li>apple</li>
        <li>banana</li>
        <li>peach</li>
    </ul>
</div>

useEffect훅이나 handleOnClick 등의 이벤트 핸들러는 결과물에서 제외된다. 웹페이지가 사용자와 인터렉션할 준비가 되기 위해서는 별도의 자바스크립트 코드를 모두 다운받고, 파싱하고 실행해야 한다. data-reactroot는 리액트 컴포넌트의 루트가 무엇인지 식별하는 역할을 한다.

이 속성은 이후 자바스크립트를 실행하기 위한 hydrate함수에서 루트를 식별하는 기준이 된다.

export default function App({todos} : {todos:Array<TodoResponse>}) {
    return (
        <>
          <ul>
            {todos.map((todo) => (
                <Todo key = {index} todo = {todo} />
            ))}
          </ul>
        </>
    )
}

//renderToNodeStream
;(async => {
    const response = await fetch('http://localhost:3000')
    try{
        for await (const chunk of response.body) {
            console.log(Buffer.from(chunk).toString())
        }
    }
    catch(error) {
        console.error(error)
    }
})()

이렇게 하면 응답으로 오는 HTML이 여러 청크로 분리되어 내려오는 것을 볼 수 있다.

import * as ReactDOM from 'react-dom'
import App from './App'

const element = document.getElementById(containerId)
ReactDOM.hydrate(<App />,element)

render와 달리 이미 렌더링된 HTML이 있다는 가정하에 작업을 하고, 이 렌더링된 HTML을 기준으로 이벤트를 붙이는 작업만 진행된다.

Next알아보기

eslint-config-next : Next기반 프로젝트에서 쓰는 Eslint설정이다. 이 설정은 정말 많은 규칙을 포함한다.

Alt text

이 린트를 이용해 커스텀 규칙도 만들 수 있다.

{
  "extends": "next",
  "settings": {
    "next": {
      "rootDir": "packages/my-app/"
    }
  }
}

기본적으로 Next는 린트를 pages하위의 모든 파일, app 하위의 모든 파일, components , lib,src폴더의 모든 하위 파일에 적용한다. 그러나 이 기본 값을 재설정해, 필요한 폴더의 하위에서만 린트를 동작하게 바꿀 수도 있다.

module.exports = {
  eslint: {
    dirs: ['pages', 'utils'], // Only run ESLint on the 'pages' and 'utils' directories during production builds (next build)
  },
}

next/core-web-vitals 규칙은 만약 Core-Web-Vitals의 규칙에 위배되는 부분이 있다면 린트에서 오류를 알려주는 규칙이다.(이 core-web-vitals는 더 파보면 좋을듯..)

next lint를 lint-staged와 같이 사용하고자 한다면 <mark>.lintstagedrc.js</mark>파일을 루트에 만들면 된다!

//root/lintstagedrc.js
const path = require('path')

const buildEslintCommand = (filenames) =>
  `next lint --fix --file ${filenames
    .map((f) => path.relative(process.cwd(), f))
    .join(' --file ')}`

module.exports = {
  '*.{js,jsx,ts,tsx}': [buildEslintCommand],
}

next config 파일은 next 프로젝트의 환경 설정을 담당하고 여러 옵션을 통해 프로젝트의 설정을 바꿀 수 있다.

Next는 왜 바벨 대신 SWC를 선택했을까?

_app.tsx 그리고 내부의 default export로 내보낸 함수는 모든 전체 페이지의 시작점이다. 즉 프로젝트 전체에서 공통적으로 사용해야 할 것들을 여기서 설정할 수 있다.

최초에는 서버 사이드 렌더링을, 이후에는 클라이언트에서 _app.tsx의 렌더링이 실행된다.

Next 13의 app router에서는 전역의 layout.tsx가 이 _app.tsx을 대신한다.

_document.tsx은 애플리케이션의 HTML을 초기화 하는 곳이다.

export default function Document() {
    return (
        <Html lang = "ko">
            <Head />
            <body className = "body">
                <Main />
            </body>
        </Html>
    )
}
/root/layout.tsx

<body>
  {/* ... */}
    <script
      dangerouslySetInnerHTML={{
        __html: `
        const localStorageTheme = localStorage.getItem("theme");
        const theme = localStorageTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
        if (theme === 'dark') {
          document.body.classList.add(theme);
        }
      `,
      }}
    ></script>
</body>

pages/error.tsx는 클라이언트나 서버에서 발생하는 에러를 처리할 목적으로 만들어졌다. 전역에서 발생하는 에러를 적절히 처리할 때 사용할 수 있다.

(13버전에서는 error.tsx를 통해 에러를 처리할 수 있다.이 부분은 더 자세히 파볼 수 있을 것 같다.!!)

pages/404.tsx는 404페이지를 정의하는 파일이다. 원하는 404스타일의 페이지를 이곳에서 만들 수 있다.

pages/index.tsx의 default export로 내부낸 함수가 해당 페이지의 루트 컴포넌트가 된다.

서버 라우팅과 클라이언트 라우팅

Next의 최초 렌더링은 서버에서 실행된다.

//pages/hello.tsx

export default function Hello(){
    console.log(typeof window === 'undefined' ? 'server' : 'client')

    return <div>hello</div>
}

export const getServerSideProps = () => {
    return {
        props:{}
    }
}

콘솔 문구가 서버에서 기록되고 window가 undefined이기 때문에 server가 로그에 찍힐 것이다. next/link에서 제공하는 Link와 a태그는 어떻게 다를까?

a태그를 사용하면 모든 리소스를 다시 다운받지만, link컴포넌트를 사용하면 모든 리소스가 아닌, 이동한 페이지에서 필요한 리소스만 다운받는다.

마치 싱글 페이지 애플리케이션처럼 매끄럽게 이동하는 것을 볼 수 있다.

이렇게 페이지 사이의 관계를 라우팅으로 나타내면, 다음으로 어떻게 움직일지 정하는 것이 중요하다.

만약 /users 페이지에서 특정 유저의 정보를 조회하고자 /users/123 이런 링크로 넘어가면 해당 링크에 대한 <mark>새로운 HTML,JS</mark>을 서버에 요구한다.

Next는 기본적으로 요청에 따라 Static HTML,SSR을 할 경우 완성된 HTML을 던져주고, 브라우저가 자바스크립트를 다 다운받으면 흔히 리액트가 돌아가는 것처럼 DOM을 구성해 보여준다.(이것을 Hydration이라 한다.)

실제 Link컴포넌트를 써서 링크를 눌러서 들어가면 매번 HTML 파일이 오지 않는다는 것을 볼 수 있다. 들어갈 가능성이 있는 페이지는 "미리 가져오고", "매번 HTML을 받지 않는다."

이것을 Prefetching이라 부른다.

next/link는 백그라운드에서 페이지(주소에 의해 표시됨)를 미리 가져오게 된다. 이는 클라이언트 측 내비게이션의 성능을 향상시키는데 유용합니다. 뷰포트에 있는 모든 <Link /> (초기 또는 스크롤을 통해)는 미리 로드됩니다.

뷰포트에 있는 모든 Link가 미리 로드된다고 한다. 실제 뷰포트에 없다가 Link가 뷰포트에 생기면 미리 로드하는 것을 아래의 영상에서 볼 수 있다.

동영상 보기

이 때 Server-Side-Rendering의 페이지는 <mark>자바스크립트를 다시 로드</mark>하고 Static-Site-Generation의 페이지는 JSON을 다시 돌려준다.

여러 옵션으로는 다음의 것들이 있다.

getServerSideProps가 있는 빌드는 빌드 결과에 서버 사이드 런타임 체크가 되어 있다. Alt text 만약 getServerSideProps가 없으면 서버에서 실행되지 않는 페이지로 간주하고 빌드 시점에 미리 트리쉐이킹을 한다. (그러나 13버전에서 getServerSideProps가 아예 사라졌다)

// 기존 방식
function Page({ data }) {

  // 렌더 데이터
}
export async function getServerSideProps() {
  const res = await fetch(`https://.../data`)
  const data = await res.json()
  return { props: { data } }
}

export default Page

//바뀐 방식
async function getData() {

  const res = await fetch('https://api.example.com/...')
  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }
  return res.json()
}
export default async function Page() {
  const data = await getData()
  return 렌더
}

Data Fetching

Next에서 데이터를 불러오기 위한 여러 전략이 있는데, 이를 Data-Fetching이라고 부른다. ~12버전에서 사용할 수 있는 함수들이 여러가지 있고 13버전에서는 아예 fetch함수로 통일이 되어버렸는데 하나씩 살펴보자.

이 두 함수는 어떤 페이지를 블로그나 게시판 같이 정적으로 결정된 페이지를 보여주고자 할 때 사용하는 함수이다. 예를 들어 /pages/post/[id]의 페이지가 있고 해당 페이지에 다음과 같은 함수를 사용했다고 가정하자.

import {GetStaticPaths, GetStaticProps} from 'next'

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

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

  const post = await fetchPost(id)

  return {
    props : {
      post
    }
  }
}

export default function Post({posts} : {posts:Post}) {
  //post로 페이지 렌더랑
}

getStaticPaths는 pages/post/[id]가 접근 가능한 주소를 정의하는 함수다. 즉 이 페이지는 /post/1과 /post/2만 접근 가능함을 의미하고, post/3에서는 404페이지를 반환한다.

getStaticProps는 앞에서 정의한 페이지를 기준으로 해당 페이지로 요청이 들어올 때 제공할 props를 반환한다. 이 예제는 id가 1,2,로 제한되어 있기 때문에 fetchPost(1) fetchPost(2)의 응답 결과를 props의 post로 전달한다.

getServerSideProps는 서버에서 실행되는 함수이며, 해당 함수가 존재한다면 무조건 페이지 진입 전에 이 함수를 실행한다. 이 함수는 응답값에 따라 페이지의 루트 컴포넌트에 props를 반환하기도, 다른 페이지로 리다이렉트를 할 수 있다.

//pages/post/[id].tsx
import type {GetServerSideProps} from 'next'

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

export const getServerSideProps : GetServerSideProps = async(context) => {
  const {
    query: {id = ''}
  } = context

  const post = await fetchPost(id.toString())
  return {
    props: {
      post
    }
  }
}

context.query.id를 사용하게 된다면 /post/[id]와 같은 경로에 있는 id값에 접근할 수 있다. getServerSideProps의 props로 내릴 수 있는 값은 JSON으로 제공가능한 값으로 제한된다.

먼저 리액트의 서버 사이드 렌더링을 하는 과정에 대해 알아보자!

hydration MisMatch

next config