장경은
Study

[10장] 리액트 17과 18의 변경 사항 살펴보기

  • W3Techs에서는 알렉사 기준 상위 천만 개의 사이트를 방문해 해당 사이트의 기술을 분석해 리포트를 제공하고 있는데, 리액트를 사용하고 있는 사이트들은 일반적으로 16 버전을 사용 중인 것으로 밝혀졌다.

리액트 17 버전 살펴보기

리액트의 점진적인 업그레이드

  • 리액트 17 애플리케이션은 내부에서 리액트16을 게으르게 불러온다.
  • 렌더링 과정에서 버전의 불일치로 인한 에러도 발생하지 않고 하나의 웹사이트에서 두 개의 리액트가 존재할 수 있게 됐다.
  • 리액트 팀에서는 이를 어디까지나 한꺼번에 업그레이드가 불가능한 상태에서만 차선책으로, 여전히 리액트 버전을 한꺼번에 업데이트하는 게 복잡성 감소 측면에서 좋다고 언급했다.

이벤트 위임 방식의 변경

  • 리액트는 이벤트 핸들러를 해당 이벤트 핸들러를 추가한 각각의 DOM 요소에 부탁하는 것이 아니라, 이벤트 타입(click, change)당 하나의 핸들러를 루트에 부착한다. 이를 이벤트 위임이라고 한다.
  • 리액트 17부터는 이러한 이벤트 위임이 모두 document가 아닌 리액트 컴포넌트 최상단 트리, 즉 루트 요소로 바뀌었다.
  • 서로 다른 리액트 버전에서 발생할 수 있는 문제를 해결하기 위해 이벤트 위임의 대상을 document에서 컴포넌트의 최상위로 변경했다.
  • 이렇게 수정하게 되면 각 이벤트는 해당 리액트 컴포넌트 트리 수준으로 격리되므로 이벤트 버블링으로 인한 혼선을 방지할 수 있다.
  • 때문에 만약 코드에 document.addEventListener를 활용해 리액트의 모든 이벤트를 document에서 확인하는 코드가 있다면 여기까지 이벤트가 전파되지 않는 경우도 존재할 수 있으므로 꼭 확인해 봐야 한다.

import React from 'react'가 더 이상 필요없다.

  • 특별한 이유가 없다면 import React를 지우고, tsconfig.json의 jsx를 react-jsx 등으로 변경하여 앞서 설명한 이점을 모두 누리도록 하자.

리액트 18 버전 살펴보기

새로 추가된 훅 살펴보기

useId

  • useId는 컴포넌트별로 유니크한 값을 생성하는 새로운 훅이다.
  • 같은 컴포넌트임에도 서로 인스턴스가 다르면 다른 랜덤한 값을 만들어 내며, 이 값들이 모두 유니크하다.

useTransition

  • useTransition 훅은 UI 변경을 가로막지 않고 상태를 업데이트할 수 있는 리액트 훅이다.
  • 상태 변경으로 인해 무거운 작업이 발생하고, 이로 인해 렌더링이 가로막힐 여지가 있는 경우 useTransition을 사용하면 이러한 문제를 해결할 수 있다.
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState<Tab>('about');
 
function selectTab(nextTab: Tab) {
    startTransition(() => {
        setTab(nextTab);
    });
}
  • 이렇게 setTab을 useTransition을 통해 처리하면 이전과 다르게 탭을 아무리 선택해도 렌더링이 블로킹되지 않는 것을 알 수 있다.
  • useTransition을 사용할 때 주의할 점
    • startTransition 내부는 반드시 setState와 같은 상태를 업데이트하는 함수와 관련된 작업만 넘길 수 있다. 만약 props나 사용자 정의 훅에서 반환하는 값 등을 사용하고 싶다면 뒤이어 설명할 useDefferedValue를 사용하면 된다.
    • startTransition으로 넘겨주는 상태 업데이트는 다른 모든 동기 상태 업데이트로 인해 실행이 지연될 수 있다. 예를 들어, 타이핑으로 인해 setState가 일어나는 경우 타이핑이 끝날 때까지 useTransition으로 지연시킨 상태 업데이트는 일어나지 않는다.
    • startTransition으로 넘겨주는 함수는 반드시 동기 함수여야 한다. 만약 이 안에 setTimeout과 같은 비동기 함수를 넣으면 제대로 작동하지 않게 된다. 이는 startTransition이 작업을 지연시키는 작업과 비동기로 함수가 실행되는 작업 사이에 불일치가 일어나기 때문이다.

useDeferredValue

  • useDeferredValue는 리액트 컴포넌트 트리에서 리렌더링이 급하지 않은 부분을 지연할 수 있게 도와주는 훅이다.
  • 특정 시간 동안 발생하는 이벤트를 하나로 인식해 한 번만 실행하게 해주는 디바운스와 비슷하지만 디바운스 대비 useDeferredValue만이 가진 장점이 몇 가지 있다.
    • 디바운스는 고정된 지연 시간을 필요로 하지만 useDeferredValue는 고정된 지연 시간 없이 첫 번째 렌더링이 완료된 이후에 이 useDeferredValue로 지연된 렌더링을 수행한다.
  • useDeferredValue와 useTransition의 차이점
    • useTransition은 state 값을 업데이트하는 함수를 감싸서 사용하는 반면, useDeferredValue는 state 값 자체만을 감싸서 사용하는 것을 볼 수 있다. 방식에만 차이가 있을 뿐, 지연된 렌더링을 한다는 점에서는 모두 동일한 역할을 하는 것을 알 수 있다.
    • 낮은 우선순위로 처리해야 할 작업에 대해 직접적으로 상태를 업데이트할 수 있는 코드에 접근할 수 있다면 useTransition을 사용하는 것이 좋다. 그러나 컴포넌트의 props와 같이 상태 업데이트에 관여할 수는 없고 오로지 값만 받아야 하는 상황이라면 useDeferredValue를 사용하는 것이 타당하다.

useSyncExternalStore

  • 상태 관리 라이브러리를 위한 훅
  • 테어링(tearing) 현상
    • 하나의 state 값이 있음에도 서로 다른 값(보통 state나 props의 이전과 이후)을 기준으로 렌더링되는 현상
    • 리액트 18부터 useTransition이나 useDeferredValue로 렌더링을 일시 중지하거나 뒤로 미룰 수 있어 동시성 이슈가 발생할 수 있다.
    • 리액트에서 관리하는 state라면 useTransition이나 useDefferedValue 예제와 같이 내부적으로 이러한 문제를 해결하기 위한 처리를 해뒀지만 리액트에서 관리할 수 없는 외부 데이터 소스에서라면 문제가 달라진다.
    • 여기서 말하는 리액트가 관리할 수 없는 외부 데이터 소스란 리액트의 클로저 범위 밖에 있는, 관리 범위 밖에 있는 값들을 말한다. 글로벌 변수, document.body, window.innerWidth, DOM, 리액트 외부에 상태를 저장하는 외부 상태 관리 라이브러리 등이 모두 여기에 해당한다.
  • 이 문제를 해결하기 위한 훅이 useSyncExternalStore이다.
  • useSyncExternalStore 사용법
    • 첫 번째 인수는 subscribe로, 콜백 함수를 받아 스토어에 등록하는 용도로 사용된다. 스토어에 있는 값이 변경되면 이 콜백이 호출돼야 한다. 그리고 useSyncExternalStore는 이 훅을 사용하는 컴포넌트를 리렌더링한다.
    • 두 번째 인수는 컴포넌트에 필요한 현재 스토어의 데이터를 반환하는 함수다. 이 함수는 스토어가 변경되지 않았다면 매번 함수를 호출할 때마다 동일한 값을 반환해야 한다. 스토어에서 값이 변경됐다면 이 값을 이전 값과 Object.is로 비교해 정말로 값이 변경됐다면 컴포넌트를 리렌더링한다.
    • 마지막 인수는 옵셔널 값으로, 서버 사이드 렌더링 시에 내부 리액트를 하이드레이션하는 도중에만 사용된다. 서버 사이드에서 렌더링되는 훅이라면 반드시 이 값을 넘겨줘야 하며, 클라이언트의 값과 불일치가 발생할 경우 오류가 발생한다.
  • 애플리케이션 코드에 직접적으로 사용할 일은 많지 않지만 사용 중인 관리 라이브러리가 외부에서 상태를 관리하고 있다면 이 useSyncExternalStore를 통해 외부 데이터 소스의 변경을 추적하고 있는지 반드시 확인해야 한다. 만약 해당 라이브러리가 이 훅을 사용하고 있지 않다면 렌더링 중간에 발생하는 값 업데이트를 적절하게 처리하지 못하고 테어링 현상이 발생할 것이다.

useInsertionEffect

  • CSS-in-js 라이브러리를 위한 훅
  • useInsertionEffect는 DOM이 실제로 변경되기 전에 동기적으로 실행된다. 이 훅 내부에 스타일을 삽입하는 코드를 집어넣음으로써 브라우저가 레이아웃을 계산하기 전에 실행될 수 있게끔 해서 좀 더 자연스러운 스타일 삽입이 가능해진다.
  • 실행 순서는 useInsertionEffect -> useLayoutEffect -> useEffect의 순서이다.
  • useLayoutEffect와 비교했을 때 실행되는 타이밍이 미묘하게 다르다. 두 훅 모두 브라우저에 DOM이 렌더링되기 전에 실행된다는 공통점이 있지만 useLayoutEffect는 모든 DOM의 변경 작업이 다 끝난 이후에 실행되는 반면 useInsertionEffect는 이러한 DOM 변경 작업 이전에 실행된다. 이러한 차이는 브라우저가 다시금 스타일을 입혀서 DOM을 재계산하지 않아도 된다는 점에서 매우 크다고 볼 수 있다.
  • 라이브러리를 작성하는 경우가 아니라면 참고만 하고 실제 애플리케이션 코드에는 가급적 사용하지 않는 것이 좋다.

react-dom/client

  • createRoot
  • hydrateRoot

react-dom/server

  • renderToPipeableStream
    • 리액트 컴포넌트를 HTML로 렌더링하는 메서드다.
    • 이를 통해 서버에서는 Suspense를 사용해 빠르게 렌더링이 필요한 부분을 먼저 렌더링할 수 있고, 값비싼 연산으로 구성된 부분은 이후에 렌더링되게끔 할 수 있다.
  • renderToReadableStream

자동 배치 (Automatic Batching)

  • 자동 배치는 리액트가 여러 상태 업데이트를 하나의 리렌더링으로 묶어서 성능을 향상시키는 방법을 의미한다.
  • 버튼 클릭 한 번에 두 개 이상의 state를 동시에 업데이트 한다고 가정할 때, 자동 배치에서는 이를 하나의 리렌더링으로 묶어서 수행할 수 있다.

더욱 엄격해진 엄격 모드

  • 더 이상 안전하지 않은 특정 생명주기를 사용하는 컴포넌트에 대한 경고
  • 문자열 ref 사용 금지
  • findDOMNode에 대한 경고 출력
  • 구 Context API 사용 시 발생하는 경고
  • 예상치 못한 부작용(side-effect) 검사

Suspense 기능 강화

  • 앞에서 언급했던, 아직 마운트되기 직전임에도 effect가 빠르게 실행되는 문제가 수정됐다. 이제 컴포넌트가 실제로 화면에 노출될 때 effect가 실행된다.
  • Suspense로 인해 컴포넌트가 보이거나 사라질 때도 effect가 정상적으로 실행된다. 이전에는 컴포넌트 스스로가 Suspense에 의해 현재 보여지고 있는지, 숨겨져 있는지 알 방법이 없었다. 그러나 이제 Suspense에 의해 노출이 된다면 useLayoutEffect의 effect(componentDidMount)가, 가려진다면 useLayoutEffect의 cleanUp (componentWillUnmount)이 정상적으로 실행된다.
  • Suspense를 이제 서버에서도 실행할 수 있게 된다. 앞의 예제와 같이 CustomSuspense를 구현하지 않더라도 정상적으로 Suspense를 사용할 수 있다. 서버에서는 일단 fallback 상태의 트리를 클라이언트에 제공하고, 불러올 준비가 된다면 자연스럽게 렌더링된다.
  • Suspense 내에 스로틀링이 추가됐다. 화면이 너무 자주 업데이트되어 시각적으로 방해받는 것을 방지하기 위해 리액트는 다음 렌더링을 보여주기 전에 잠시 대기한다. 즉, 중첩된 Suspense의 fallback이 있다면 자동으로 스로틀되어 최대한 자연스럽게 보여주기 위해 노력한다.

그 밖에 알아두면 좋은 변경사항

  • 이제 컴포넌트에서 undefined를 반환해도 에러가 발생하지 않는다. undefined 반환은 null 반환과 동일하게 처리된다.
  • 이와 마찬가지로 <Suspense fallback={undefined}>도 null과 동일하게 처리된다.
  • renderToNodeStream이 지원 중단됐다. 그 대신 renderToPipeableStream을 사용하는 것이 권장된다.

[11장] Next.js 13과 리액트 18

app 디렉터리의 등장

라우팅

  • Next.js 13 부터는 app 디렉터리 내부의 폴더명이 라우팅이 되며, 이 폴더에 포함될 수 있는 파일명은 몇 가지로 제한되어 있다.

layout.js

page.js

error.js

not-found.js

loading.js

route.js

  • route.ts 파일 내부에 REST API의 get, post와 같은 메서드명을 예약어로 선언해 두면 HTTP 요청에 맞게 해당 메서드를 호출하는 방식으로 작동한다.

리액트 서버 컴포넌트

  • 과거의 전통적인 웹 애플리케이션 구축 방법, 즉 PHP나 레일즈, JSP와 같은 완전히 정적인 방식의 서버 사이드 렌더링 방식을 도입하면 이러한 문제들을 일부 해결할 수 있지만 리액트처럼 브라우저에서 고객에게 다양한 경험을 안겨주기는 어렵다.
  • 서버 사이드 렌더링은 정적 콘텐츠를 빠르게 제공하고, 서버에 있는 데이터에 손쉽게 제공할 수 있는 반면 사용자의 인터랙션에 따른 다양한 사용자 경험을 제공하긴 어렵다. 클라이언트 사이드 렌더링은 사용자의 인터랙션에 따라 정말 다양한 것들을 제공할 수 있지만 서버에 비해 느리고 데이터를 가져오는 것도 어렵다. 이러한 두 구조의 장점을 모두 취하고자 하는 것이 바로 리액트 서버 컴포넌트다.

서버 컴포넌트란?

  • 서버 컴포넌트(Server Component)란 하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법을 의미한다.
  • 서버에서 할 수 있는 일은 서버가 처리하게 두고, 서버가 할 수 없는 나머지 작업은 클라이언트인 브라우저에서 수행된다. 즉, 일부 컴포넌트는 클라이언트에서, 일부 컴포넌트는 서버에서 렌더링되는 것이다.
  • 특징
    • 요청이 오면 그 순간 서버에서 딱 한 번 실행될 뿐이므로 상태를 가질 수 없다. 따라서 리액트에서 상태를 가질 수 있는 useState, useReducer 등의 훅을 사용할 수 없다.
    • 렌더링 생명주기도 사용할 수 없다. 한번 렌더링되면 그걸로 끝이기 때문이다. 따라서 useEffect, useLayoutEffect를 사용할 수 없다.
    • 앞의 두 가지 제약사항으로 인해 effect나 state에 의존하는 사용자 정의 훅 또한 사용할 수 없다. 다만 effect나 state에 의존하지 않고 서버에서 제공할 수 있는 기능만 사용하는 훅이라면 충분히 사용 가능하다.
    • 브라우저에서 실행되지 않고 서버에서만 실행되기 때문에 DOM API를 쓰거나 window, document 등에 접근할 수 없다.
    • 데이터베이스, 내부 서비스, 파일 시스템 등 서버에만 있는 데이터를 async/await으로 접근할 수 있다. 컴포넌트 자체가 async한 것이 가능하다.
    • 다른 서버 컴포넌트를 렌더링하거나 div, span, p 같은 요소를 렌더링하거나, 혹은 클라이언트 컴포넌트를 렌더링할 수 있다.

서버 컴포넌트는 어떻게 작동하는가?

  1. 서버가 렌더링 요청을 받는다. 서버가 렌더링 과정을 수행해야 하므로 리액트 서버 컴포넌트를 사용하는 모든 페이지는 항상 서버에서 시작된다. 즉, 루트에 있는 컴포넌트는 항상 서버 컴포넌트다. 예제의 구조는 현재 다음과 같다.
  2. 서버는 받은 요청에 따라 컴포넌트를 JSON으로 직렬화(serialize)한다. 이때 서버에서 렌더링할 수 있는 것은 직렬화해서 내보내고, 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 플레이스홀더 형식으로 비워두고 나타낸다.
  3. 브라우저가 리액트 컴포넌트 트리를 구성한다. 서버에서 만들어진 결과물을 받았다면 이 정보를 기반으로 리액트 트리를 그대로 만들 것이다. 최종적으로 이 트리를 렌더링해 브라우저의 DOM에 커밋한다.
  • 서버 컴포넌트의 작동 방식의 특별한 점
    • 먼저 서버에서 클라이언트로 정보를 보낼 때 스트리밍 형태로 보냄으로써 클라이언트가 줄 단위로 JSON을 읽고 컴포넌트를 렌더링할 수 있어 브라우저에서는 되도록 빨리 사용자에게 결과물을 보여줄 수 있다는 장점이 있다.
    • 또한 컴포넌트들이 하나의 번들러 작업에 포함돼 있지 않고 각 컴포넌트별로 번들링이 별개로 돼 있어 필요에 따라 컴포넌트를 지연해서 받거나 따로 받는 등의 작업이 가능해졌다.
    • 마지막으로, 서버 사이드 렌더링과는 다르게 결과물이 HTML이 아닌 JSON 형태로 보내진 것 또한 주목해 볼 만하다. 클라이언트의 최종 목표는 리액트 컴포넌트 트리를 서버 컴포넌트와 클라이언트 컴포넌트의 두 가지로 조화롭게 구성하는 것으로, 이는 단순히 HTML을 그리는 작업 이상의 일을 필요로 한다. 따라서 HTML 대신 단순한 리액트 컴포넌트 구조를 JSON으로 받아서 리액트 컴포넌트 트리의 구성을 최대한 빠르게 할 수 있도록 도와준다.

Next.js에서의 리액트 서버 컴포넌트

fetch

  • SWR과 React Query와 비슷하게, 해당 fetch 요청에 대한 내용을 서버에서는 렌더링이 한 번 끝날 때까지 캐싱하며, 클라이언트에서는 별도의 지시자나 요청이 없는 이상 해당 데이터를 최대한 캐싱해서 중복된 요청을 방지한다.

정적 렌더링과 동적 렌더링

  • Next.js 13에서는 이제 정적인 라우팅에 대해서는 기본적으로 빌드 타임에 렌더링을 미리 해두고 캐싱해 재사용할 수 있게끔 해뒀고, 동적인 라우팅에 대해서는 서버에 매번 요청이 올 때마다 컴포넌트를 렌더링하도록 변경했다.

캐시와 mutating, 그리고 revalidating

  • 캐시와 갱신이 이뤄지는 과정
    1. 최초로 해당 라우트로 요청이 올 때는 미리 정적으로 캐시해 둔 데이터를 보여준다.
    2. 이 캐시된 초기 요청은 revalidate에 선언된 값만큼 유지된다.
    3. 만약 해당 시간이 지나도 일단은 캐시된 데이터를 보여준다.
    4. Next.js는 캐시된 데이터를 보여주는 한편, 시간이 경과했으므로 백그라운드에서 다시 데이터를 불러온다.
    5. 4번의 작업이 성공적으로 끝나면 캐시된 데이터를 갱신하고, 그렇지 않다면 과거 데이터를 보여준다.
  • 만약 이러한 캐시를 전체적으로 무효화하고 싶다면 router에 추가된 refresh 메서드로 router.refresh()를 사용하면 된다.
    • 이는 브라우저를 새로고침하는 등 브라우저의 히스토리에 영향을 미치지 않고, 오로지 서버에서 루트부터 데이터를 전체적으로 가져와서 갱신하게 된다.

스트리밍을 활용한 점진적인 페이지 불러오기

  • 하나의 페이지가 다 완성될 때까지 기다리는 것이 아니라 HTML을 작은 단위로 쪼개서 완성되는 대로 클라이언트로 점진적으로 보내는 스트리밍이 도입됐다.
  • 스트리밍을 활용하면 모든 데이터가 로드될 때까지 기다리지 않더라도 먼저 데이터가 로드되는 컴포넌트를 빠르게 보여주는 방법이 가능하다.
  • 이를 활용할 수 있는 방법은 두 가지가 있다.
    • 경로에 loading.tsx 배치: loading은 예약어로 존재하는 컴포넌트로, 렌더링이 완료되기 전에 보여줄 수 있는 컴포넌트를 배치할 수 있는 파일이다. loading 파일을 배치한다면 자동으로 다음 구조와 같이 Suspense가 배치된다.
    • Suspense 배치: 좀 더 세분화된 제어를 하고 싶다면 직접 리액트의 Suspense를 배치하는 것도 가능하다.

웹팩의 대항마, 터보팩의 등장(beta)

  • next dev --turbo 명령어로 개발 환경에서 터보팩을 활성화해서 개발할 수 있다.

서버 액션(alpha)

  • 이 기능은 API를 굳이 생성하지 않더라도 함수 수준에서 서버에 직접 접근해 데이터 요청 등을 수행할 수 있는 기능이다.
  • next.config.js에서 실험 기능을 활성화해야 한다.
  • 서버 액션을 만들려면 먼저 함수 내부 또는 파일 상단에 클라이언트 선언과 비슷하게 "use server" 지시자를 선언해야 한다. 그리고 함수는 반드시 async여야 한다.

form의 action

export default async function Page({ params }: { params: { id: string } }) {
   const key = test:${params.id};
   const data = await kv.get<Data>(key);
 
   async function handleSubmit(formData: FormData) {
      'use server'
      const name = formData.get('name')
      const age = formData.get('age')
      await kv.set(key, {      name,      age,    })
      revalidatePath(/server-action/form/${params.id})
    }
 
    return (
      <>
        <h1>form with data</h1>
        <h2>서버에 저장된 정보: {data?.name} {data?.age}</h2>
 
        <form action={handleSubmit}>
          <label htmlFor="name">이름: </label>
          <input
            type="text"
            id="name"
            name="name"
            defaultValue={data?.name}
            placeholder="이름을 입력해 주세요."
          />
 
          <label htmlFor="age">나이: </label>
          <input
            type="number"
            id="age"
            name="age"
            defaultValue={data?.age}
            placeholder="나이를 입력해 주세요."
          />
 
          <button type="submit">submit</button>
        </form>
      </>
    )
}
  • 서버 액션의 실행이 완료되면 data 객체가 revalidatePath로 갱신되어 업데이트된 최신 데이터를 불러오는 것을 볼 수 있다.
  • PHP와 같은 전통적인 서버 기반 웹 어플리케이션과의 가장 큰 차이점은 이 모든 과정이 페이지 새로고침이 없이 수행된다는 점이다.
  • server mutation(서버에서의 데이터 수정)이라고 하는데, server mutation으로 실행할 수 있는 함수는 다음과 같다.
    • redirect: import { redirect } from 'next/navigation'으로 사용할 수 있으며, 특정 주소로 리다이렉트할 수 있다.
    • revalidatePath: import { revalidatePath } from 'next/cache'로 사용할 수 있으며, 해당 주소의 캐시를 즉시 업데이트한다.
    • revalidateTag: import { revalidateTag } from 'next/cache'로 사용할 수 있다. 캐시 태그는 fetch 요청 시에 다음과 같이 추가할 수 있다.

startTransition과의 연동

  • 서버 액션은 비단 form.action이나 formAction에서만 사용할 수 있는 것은 아니다. useTransition에서 제공하는 startTransition에서도 서버 액션을 활용할 수 있다.

server mutation이 없는 작업

  • server mutation이 필요하다면 반드시 서버 액션을 useTransition과 함께 사용해야 하지만 별도의 server mutation을 실행하지 않는다면 바로 이벤트 핸들러에 넣어도 된다.

서버 액션 사용 시 주의할 점

  • 서버 액션은 클라이언트 컴포넌트 내에서 정의될 수 없다.
  • 서버 액션을 import 하는 것 뿐만 아니라, props 형태로 서버 액션을 클라이언트 컴포넌트에 넘기는 것 또한 가능하다.