김용현
Study

정리하기 파일

1. 서버 사이드 렌더링이란?

1. 싱글 페이지 애플리케이션의 세상

싱글 페이지 애플리케이션이란?

  • 렌더링과 라우팅에 필요한 대부분의 기능을 서버가 아닌 브라우저의 자바스크립트에 의존하는 방식
  • 최초에 첫 페이지에서 데이터를 모두 불러온 이후에는 페이지 전환을 위한 모든 작업이 자바스크립트와 브라우저의 history.pushStatehistory.replaceState로 이뤄진다.
    • 페이지를 불러온 이후에는 서버에서 HTML을 내려받지 않고 하나의 페이지에서 모든 작업을 처리
  • 장점: 한번 로딩된 이후에는 서버를 거쳐 필요한 리소스를 받아올 일이 적어지기 때문에 사용자에게 훌륭한 UI/UX를 제공한다.
  • 단점: 최초에 로딩해야 할 자바스크립트 리소스가 커진다.

전통적인 방식의 애플리케이션과 싱글 페이지 애플리케이션의 작동 비교

  • 서버 사이드: 페이지 전환이 발생할 때마다 새롭게 페이지를 요청하고, HTML 페이지를 다운로드해 파싱하는 작업으 거친다.
    • 페이지를 처음부터 새로 그려야 해서 일부 사용자는 페이지가 전환될 때 부자연스러운 모습을 보게 된다.
  • 클라이언트 사이드: 최초에 한번 모든 리소스를 다운로드하고 나면 이후 페이지를 전환할 때 추가로 리소스를 다운로드하는 시간이 필요 없어진다.
    • 경우에 따라 페이지 전체를 새로 렌더링하는 것이 아니라 페이지 전환에 필요한 일부 영역만 다시 그리게 되므로 훨씬 더 매끄러운 UI를 보여줄 수 있게 된다.

싱글 페이지 렌더링 방식의 유행과 JAM 스택의 등장

  • 자바스크립트가 서서히 다양한 작업을 수행하게 되면서 자바스크립트를 모듈화하는 방안이 점차 논의되기 시작
    → CommonJS와 AMD(Asynchronous Module Definition)
  • 2010년경 자바스크립트 수준에서 MVx 프레임워크를 구현하기 시작(Backbone.js, AngularJS, Knockout.js 등의 등장)
    • 자바스크립트 개발자들은 웹페이지의 모든 영역(렌더링 ~ 사용자 인터랙션)을 담당하면서 이를 모두 아우를 수 있는 방식인 싱글 페이지 렌더링이 인기를 얻게 됨.
    • 브라우저 내부에서 작동하는 스크립트만 신경쓰면 된다. → 간편한 개발 경험
  • 기존 LAMP(Linux + Apache + MySQL + PHP) 스택 → 웹 애플리케이션의 확장성 ↓
  • JAM(JavaScript + API + Markup) 스택: 자바스크립트와 마크업을 미리 빌드해 두고 정적으로 사용자에게 제공 → 서버 확장성 문제에서 자유로워짐

새로운 패러다임의 웹서비스를 향한 요구

  • 자바스크립트 코드의 규모가 점차 커지면서 자바스크립트 파싱을 위해 CPU를 소비하는 시간이 크게 증가 → 웹페이지 로딩 시간 ↑
  • 사용자의 기기와 인터넷 속도 등 웹 전반을 이루는 환경이 크게 개선됐음에도 실제 사용자들이 느끼는 웹 애플리케이션의 로딩 속도는 5년 전이나 지금이나 크게 차이가 없거나 오히려 더 느리다.
  • 웹 애플리케이션 개발자라면 웹 서비스의 성능을 역행하는 추세에 책임감을 가질 필요가 있다.

2. 서버 사이드 렌더링이란?

  • 최초에 사용자에게 보여줄 페이지를 서버에서 렌더링해 빠르게 사용자에게 화면을 제공하는 방식
  • 웹페이지가 점점 느려지는 상황에 대한 문제의식을 싱글 페이지 애플리케이션의 태생적인 한계에서 찾고, 이를 개선하고자 다시 서버 사이드 렌더링이 떠오르고 있다.
  • 클라이언트 사이드 렌더링은 사용자 기기의 성능에 영향을 받지만 서버 사이드 렌더링은 비교적 안정적인 렌더링이 가능

서버 사이드 렌더링의 장점

최초 페이지 진입이 비교적 빠르다.

  • 사용자가 최초 페이지에 진입했을 때 페이지에 유의미한 정보가 그려지는 시간(FCP, First Contentful Paint)이 더 빠르다.
    • 일반적으로 서버에서 HTTP 요청을 수행하는 것이 더 빠르다.
    • 서버에서 HTML을 문자열로 미리 그려서 내려주는 것이 클라이언트에서 기존 HTML에 삽입하는 것보다 더 빠르다.

검색 엔진과 SNS 공유 등 메타데이터 제공이 쉽다.

  • 검색 엔진이 사이트에서 필요한 정보를 가져가는 과정
    1. 검색 엔진 로봇(머신)이 페이지에 진입한다.
    2. 페이지가 HTML 정보를 제공해 로봇이 이 HTML을 다운로드한다. 단, 다운로드만 하고 자바스크립트 코드는 실행하지 않는다.
      • 로봇은 페이지를 보는 것이 아닌 페이지의 정적인 정보를 가져오는 것이 목적이므로 자바스크립트를 다운로드하거나 실행할 필요가 없다.
    3. 다운로드한 HTML 페이지 내부의 오픈 그래프(Open Graph)나 메타(meta) 태그 정보를 기반으로 페이지의 검색(공유) 정보를 가져오고 이를 바탕으로 검색 엔진에 저장한다.
  • 검색 엔진에 제공할 정보를 서버에서 가공해서 HTML 응답으로 제공할 수 있으므로 검색 엔진 최적화에 대응하기가 매우 용이

누적 레이아웃 이동이 적다.

  • 누적 레이아웃 이동(CLS, Cumulative Layout Shift): 사용자에게 페이지를 보여준 이후에 뒤늦게 어떤 HTML 정보가 추가되거나 삭제되어 마치 화면이 덜컥거리는 것과 같은 부정적인 사용자 경험
  • 서버 사이드 렌더링을 사용한다 해도 이러한 문제에서 완전히 자유롭지는 못하다.
    • useEffect → Next.js에서는 React 클라이언트 훅을 사용하려면 클라이언트 컴포넌트로 변경해줘야 함.('use client')
    • API 속도가 모두 달랐을 때, 최초 페이지 다운로드가 느려진다. → 스트림 (opens in a new tab)

사용자 디바이스 성능에 비교적 자유롭다.

  • 자바스크립트 리소스 실행은 사용자의 디바이스에서 실행되므로 절대적으로 사용자 디바이스 성능에 의존적
  • 서버 사이드 렌더링을 수행하면 이러한 부담을 서버에 나눌 수 있으므로 사용자의 디바이스 성능으로부터 조금 더 자유로워질 수 있다.
    • 인터넷 속도
    • 사용자 방문 증가로 서버에 부담이 가중되는 경우 등의 경우를 고려해야 함

보안에 좀 더 안전하다.

  • JAM 스택의 문제점은 애플리케이션의 모든 활동이 브라우저에 노출된다는 것
    • API 호출, 인증 등의 민감한 작업도 포함된다.
  • 서버 사이드 렌더링의 경우 민감한 작업을 서버에서 수행하고 그 결과만 브라우저에 제공해 이러한 보안 위협을 피할 수 있다.

단점

소스코드를 작성할 때 항상 서버를 고려해야 한다.

  • 브라우저 전역 객체인 window 또는 sessionStorage와 같이 브라우저에만 있는 전역 객체 등이 서버 사이드에서 실행되지 않도록 처리해야 한다. (외부 라이브러리 포함)

적절한 서버가 구축돼 있어야 한다.

  • 사용자의 요청을 받아 렌더링을 수행할 서버가 필요하다.
    • 사용자의 요청에 따라 적절하게 대응할 수 있는 물리적인 가용량 확보
    • 예기치 않은 장애 상황에 대응할 수 있는 복구 전략
    • 요청을 분산시키고, 프로세스가 예기치 못하게 다운될 때를 대비해 PM2와 같은 프로세스 매니저의 도움 필요

서비스 지연에 따른 문제

  • 애플리케이션의 규모가 커지고 작업이 복잡해지고, 이에 따라 다양한 요청에 얽혀있어 병목 현상이 심해진다면, 서버 사이드 렌더링이 더 안 좋은 사용자 경험을 제공할 수도 있다.

3. SPA와 SSR을 모두 알아야 하는 이유

서버 사이드 렌더링 역시 만능이 아니다.

싱글 페이지 애플리케이션과 서버 사이드 렌더링 애플리케이션

  • 싱글 페이지 애플리케이션
    • 최초 페이지 진입 시에 보여줘야 할 정보만 최적화해 요청 및 렌더링
    • 이미지 등 중요성이 떨어지는 리소스는 lazyloading 처리
    • 코드 분할로 불필요한 자바스크립트 리소스의 다운로드 및 실행 방지
    • 라우팅 발생 시 변경이 필요한 HTML 영역만 교체해 사용자의 피로감 최소화
  • 멀티 페이지 애플리케이션
    • 페인트 홀딩(Paint Holding): 같은 출처에서 라우팅이 일어날 경우 화면을 잠깐 하얗게 띄우는 대신 이전 페이지의 모습을 잠깐 보여주는 기법
    • back forward cache(bfcache): 브라우저 앞으로 가기, 뒤로가기 실행 시 캐시된 페이지를 보여주는 기법
    • Shared Element Transitions: 페이지 라우팅이 일어났을 대 두 페이지에 동일 요소가 있다면 해당 콘텍스트를 유지해 부드럽게 전환되게 하는 기법

현대의 서버 사이드 렌더링

  • 최초 웹사이트 진입 시에는 서버 사이드 렌더링 방식으로 서버에서 완성된 HTML을 제공받음
  • 이후 라우팅에서는 서버에서 내려받은 자바스크립트를 바탕으로 마치 싱글 페이지 애플리케이션처럼 작동

2. 서버 사이드 렌더링을 위한 리액트 API 살펴보기

1. renderToString

  • 서버 사이드 렌더링을 구현하는 데 가장 기초적인 API
  • 리액트 컴포넌트를 렌더링해 HTML 문자열로 반환하는 함수
  • useEffect와 같은 훅과 handleClick과 같은 이벤트 핸들러는 결과물에 포함되지 않음
    • 서버 사이드 렌더링은 단순히 '최초 HTML 페이지를 빠르게 그려주는 데'에 목적이 있기 때문
    • 실제로 웹페이지가 사용자와 인터랙션할 준비가 되기 위해서는 이와 관련된 별도의 자바스크립트 코드를 모아 다운로드, 파싱, 실행하는 과정을 거쳐야 한다.
  • data-reactroot 속성: 리액트 컴포넌트의 루트를 식별하는 기준점

2. renderToStaticMarkup

  • renderToString과 유사하지만, 리액트에서만 사용하는 추가적인 DOM 속성(data-reactroot 등)을 만들지 않는다.
  • hydrate를 수행하지 않는다는 가정 하에 순수 HTML만 반환한다. → 정적인 내용만 필요한 경우에 사용

3. renderToNodeStream

  • renderToString과 결과물이 완전히 동일하지만, 브라우저에서 사용할 수 없다. → 완전히 Node.js 환경에 의존
  • renderToString의 결과물 타입은 string인 반면, renderToNodeStream의 결과물 타입은 Node.js의 ReadableStream
    • utf-8로 인코딩된 바이트 스트림으로 Node.js 환경에서만 사용할 수 있다. → string을 얻기 위해서는 추가적인 처리가 필요
    • ReadableStream 자체는 브라우저에서도 사용할 수 있는 객체이지만, ReadableStream을 만드는 과정이 브라우저에서 불가능하게 구현돼 있음.
  • 스트림: 큰 데이터를 다룰 때 데이터를 청크(chunk, 작은 단위)로 분할해 조금씩 가져오는 방식
    • renderToString이 생성하는 HTML 결과물의 크기가 작다면 상관 없지만, 매우 커진다면 Node.js가 실행되는 서버에 큰 부담이 될 수 있다.
    • 대부분의 리액트 서버 사이드 렌더링 프레임워크는 모두 renderToNodeStream을 채택하고 있다.

4. renderToStaticNodeStream

  • Node.js 환경의 renderToStaticMarkup

5. hydrate

  • renderToStringrenderToNodeStream으로 생성된 HTML 콘텐츠에 자바스크립트 핸들러나 이벤트를 붙이는 역할
  • render는 클라이언트에서만 실행되는 렌더링과 이벤트 핸들러 추가 등 리액트를 기반으로 한 온전한 웹페이지를 만드는 데 필요한 모든 작업을 수행
  • hydrate는 이미 렌더링된 HTML이 있다는 가정하에 작업이 수행 → 이벤트를 붙이는 작업만 실행
    • hydrate가 수행한 렌더링 결과물 HTML과 인수로 넘겨받은 HTML을 비교해 불일치가 발생하면 hydrate가 렌더링한 기준으로 웹페이지를 그리게 된다.
      → 정상적으로 웹페이지가 만들어지고 렌더링된다고 해도 올바른 사용법은 아니다.
      • 사실상 서버와 클라이언트에서 두 번 렌더링을 하게 되므로, 서버 사이드 렌더링의 장점을 포기하는 것
  • 불가피하게 불일치가 발생할 수 있는 경우에는 해당 요소에 suppressHydrationWarning을 추가해 경고를 끌 수 있다.
    → 경고를 끄는 것이지 문제가 해결된 것이 아니다.
    • 필요한 곳에서만 제한적으로 사용해야 한다.

3. Next.js 톺아보기

1. Next.js란?

  • 리액트 기반 서버 사이드 렌더링 프레임워크
  • 디렉터리 기반 라우팅

1. 상태 관리는 왜 필요한가?

  • 상태: 의미를 지닌 값, 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값
    • UI, URL, 폼, 서버에서 가져온 값 등

1. 리액트 상태 관리의 역사

Flux 패턴의 등장

  • 웹 애플리케이션이 비대해지고 상태도 많아짐 → 상태 추적과 이해가 매우 어려워짐

    • 페이스북 팀은 양방향 데이터 바인딩을 그 원인으로 보았고,
    • 단방향으로 데이터 흐름을 변경하는 것을 제안 → Flux 패턴의 시작
  • 장점: 데이터의 흐름이 액션이라는 한 방향으로 줄어들어 데이터의 흐름 추적과 코드 이해가 쉬움.

  • 단점: 사용자의 입력에 따라 데이터를 갱신하고 화면을 어떻게 업데이트해야 하는지도 코드로 작성해야 함.

시장 지배자 리덕스의 등장

  • Flux 구조를 구현하기 위해 만들어진 라이브러리 + [Elm 아키텍처]
    • Elm: 웹 페이지를 선언적으로 작성하기 위한 언어
      • 모델(model): 애플리케이션의 상태
      • 뷰(view): 모델을 표현하는 HTML
      • 업데이트(update): 업데이트를 수정하는 방식
  1. 하나의 글로벌 상태 객체를 스토어에 저장 → Props Drilling 문제 해결
  2. reducer 함수를 통해 객체를 업데이트하는 작업을 디스패치해 업데이트를 수행
    • 웹 애플리케이션 상태에 대한 복사본을 반환, 새롭게 만들어진 상태를 전파
  • 하고하 자는 일에 비해 너무 많은 보일러플레이트 → RTK (opens in a new tab)가 나오면서 간소화

Context API와 useContext

  • 리덕스의 부담스러운 보일러플레이트 → 리액트 16.3에서 Context API 출시
  • 리액트 16.3 버전 이전의 contextgetChildContext()
    • 상위 컴포넌트가 렌더링되면 getChildContext()가 호출되면서 shouldComponentUpdate가 항상 true를 반환해 불필요한 렌더링이 발생
    • 컴포넌트 결합도 ↑
  • Context API는 상태 관리가 아닌 주입을 도와주는 도구

훅의 탄생, 그리고 React Query와 SWR

  • 리액트 16.8에서 무상태 컴포넌트를 선언하기 위해서 제한적으로 사용됐던 함수형 컴포넌트에 사용할 수 있는 다양한 훅 API 출시
  • 훅과 state의 등장으로 새로운 방식의 상태 관리 등장 → React Query & SWR
    • 외부에서 데이터를 불러오는 fetch 관리 특화 라이브러리
import useSWR from "swr";
 
const fetcher = (url) => fetch(url).then((res) => res.json());
 
export default function App() {
  const { data, error } = useSWR(
    "https://api.github.com/repos/vercel/swr",
    fetcher
  );
 
  if (error) return "An error has occured";
  if (!data) return "Loading...";
 
  return (
    <div>
      <p>{JSON.stringify(data)}</p>
    </div>
  );
}
  • 다른 곳에서 동일한 키로 호출하면 재조회하는 것이 아니라 useSWR이 관리하고 있는 캐시의 값을 활용

Recoil, Zustand, Jotai, Valtio에 이르기까지

// Recoil
const counter = atom({ key: "count", default: 0 });
const todoList = useRecoilValue(counter);
 
// Jotai
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
 
// Zustand
const useCounterStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
}));
const count = useCounterStore((state) => state.count);
 
// Valtio
const state = proxy({ count: 0 });
const snap = useSnapshot(state);
state.count++;
  • 훅을 활용해 작은 크기의 상태를 효율적으로 관리
  • peerDependencies 리액트 16.8 버전 이상
  • 전역 상태 관리 패러다임에서 벗어나 개발자가 원하는 만큼의 상태를 지역적으로 관리하는 것이 가능해짐.

 

2. 리액트 훅으로 시작하는 상태 관리

1. 가장 기본적인 방법: useState와 useReducer

  • useStateuseReducer를 기반으로 하는 사용자 정의 훅
    • 컴포넌트별로 초기화되므로 컴포넌트에 따라 서로 다른 상태를 가질 수밖에 없다. (상태의 파편화)
  • useState를 기반으로 한 상태를 지역 상태(local state)라고 하며, 이 지역 상태는 해당 컴포넌트 내에서만 유효하다.
  • 지역 상태를 여러 컴포넌트가 동시에 사용할 수 있는 전역 상태(global state)로 만들어 컴포넌트가 사용하는 모든 훅이 동일한 값을 참조할 수 있게 하려면 상태를 컴포넌트 밖으로 한 단계 끌어올리면 된다.
    • 여러 컴포넌트가 동일한 상태를 사용할 수 있게 됐지만, props 형태로 필요한 컴포넌트에 제공해야 한다는 점은 여전히 불편하다.
  • 재사용할 수 있는 지역 상태를 만들어 주지만 지역 상태라는 한계 때문에 여러 컴포넌트에 걸쳐 공유하기 위해서는 컴포넌트 트리를 재설계하는 등의 수고로움이 필요하다.

2. 지역 상태의 한계를 벗어나보자: useState의 상태를 바깥으로 분리하기

  • useState는 리액트가 만든 클로저 내부에서 관리되어 지역 상태로 생성되기 때문에 해당 컴포넌트에서만 사용할 수 있다.

  • 상태를 리액트 클로저가 아닌 다른 자바스크립트 실행 문맥에서 초기화돼서 관리된다면?

    export type State = { counter: number };
     
    let state: State = { counter: 0 };
     
    export function get(): State {
      return state;
    }
     
    type Initializer<T> = T extends any ? T | ((prev: T) => T) : never;
     
    export function set<T>(nextState: Initializer<T>) {
      state = typeof nextState === "function" ? nextState(state) : nextState;
    }
     
    function Counter() {
      const state = get();
     
      function handleClick() {
        set((prev: State) => ({ counter: prev.counter + 1 }));
      }
     
      return (
        <>
          <h3>{state.counter}</h3>
          <button onClick={handleClick}>+</button>
        </>
      );
    }
    • state는 정상적으로 작동하지만, 리렌더링이 되지 않는다. (리액트의 렌더링 방식)
      • useState, useReducer의 반환값 중 두 번째 인수가 호출돼야 한다.
      • 부모 컴포넌트가 리렌더링되거나 함수가 재실행돼야 한다.
  • useState를 추가한다면?

    export type State = { counter: number };
     
    let state: State = { counter: 0 };
     
    export function get(): State {
      return state;
    }
     
    type Initializer<T> = T extends any ? T | ((prev: T) => T) : never;
     
    export function set<T>(nextState: Initializer<T>) {
      state = typeof nextState === "function" ? nextState(state) : nextState;
    }
     
    function Counter1() {
      const [count, setCount] = useState(state);
     
      function handleClick() {
        set((prev: State) => {
          const newState = { counter: prev.counter + 1 };
          setCount(newState);
          return newState;
        });
      }
     
      return (
        <>
          <h3>{state.counter}</h3>
          <button onClick={handleClick}>+</button>
        </>
      );
    }
     
    function Counter2() {
      const [count, setCount] = useState(state);
     
      function handleClick() {
        set((prev: State) => {
          const newState = { counter: prev.counter + 1 };
          setCount(newState);
          return newState;
        });
      }
     
      return (
        <>
          <h3>{state.counter}</h3>
          <button onClick={handleClick}>+</button>
        </>
      );
    }
    • 외부의 상태도 수정하고 useState의 두 번째 인수도 실행하기 대문에 리액트 컴포넌트는 렌더링된다.
      • 외부에 상태가 있음에도 함수형 컴포넌트의 렌더링을 위해 함수의 내부에 동일한 상태를 관리하는 useState가 존재하는 구조
      • 같은 상태를 바라보는 두 컴포넌트가 동시에 리렌더링되지 않는 문제
        • useState는 해당 컴포넌트 자체에서만 유효한 전략
        • 다른 컴포넌트에서는 상태의 변화에 따른 리렌더링을 일으킬 무언가가 없음.
  • 함수 외부에서 상태를 참조하고 렌더링까지 자연스럽게 일어나기 위한 조건

    1. 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.
    2. 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아챌 수 있어야 하고 상태가 변화될 때마다 리렌더링이 일어나서 컴포넌트는 최신 상태값 기준으로 렌더링해야 한다. 상태 감지는 상태를 변경시키는 컴포넌트뿐만 아니라 이 상태를 참조하는 모든 컴포넌트에서 동일하게 작동해야 한다.
    3. 상태가 원시값이 아닌 객체인 경우에 그 객체에 내가 감지하지 않는 값이 변한다 하더라도 리렌더링이 발생해서는 안된다.
  • 새로운 상태 관리 코드

    • store의 값이 변경될 때마다 변경됐음을 알리는 callback 함수
    • callback을 등록할 수 있는 subscribe 함수
    export type State = { counter: number };
     
    type Initializer<T> = T extends any ? T | ((prev: T) => T) : never;
     
    type Store<State> = {
      get: () => State;
      set: (action: Initializer<State>) => State;
      subscribe: (callback: () => void) => () => void;
    };
     
    export const createStore = <State extends unknown>(
      initialState: Initializer<State>
    ): Store<State> => {
      // 인수 또는 게으른 초기화 함수로 store의 기본값 초기화
      let state =
        typeof initialState !== "function" ? initialState : initialState();
     
      const callbacks = new Set<() => void>();
      // get을 함수로 만들어 매번 최신값을 가져올 수 있게 만든다.
      const get = () => state;
      // set을 만들어 새로운 값을 넣을 수 있도록 만든다.
      const set = (nextState: State | ((prev: State) => State)) => {
        state =
          typeof nextState === "function"
            ? (nextState as (prev: State) => State)(state)
            : nextState;
     
        // 값을 설정한 이후 등록된 모든 콜백을 실행해 렌더링을 유도한다.
        callbacks.forEach((callback) => callback());
     
        return state;
      };
     
      const subscribe = (callback: () => void) => {
        callbacks.add(callback);
     
        // callback이 무한히 추가되는 것을 방지 (useEffect의 클린업 함수와 동일한 역할)
        return () => {
          callbacks.delete(callback);
        };
      };
     
      return { get, set, subscribe };
    };
    • createStore는 자신이 관리해야 하는 상태를 내부 변수로 가진 다음, get 함수로 해당 변수의 최신값을 제공하며, set 함수로 내부 변수를 최신화하며, 이 과정에서 등록된 콜백을 모조리 실행하는 구조
  • createStore로 만들어진 store의 값을 참조하고, 값의 변화에 따라 컴포넌트 렌더링을 유도할 사용자 정의 훅

    // 훅의 인수로 사용할 store를 받음.
    export const useStore = <State extends unknown>(store: Store<State>) => {
      // 컴포넌트 렌더링 유도를 위한 useState
      const [state, setState] = useState<State>(() => store.get());
     
      // store의 값이 변경될 때마다 state의 값이 변경되는 것을 보장
      useEffect(() => {
        const unsubscribe = store.subscribe(() => {
          setState(store.get());
        });
        // 클린업 함수로 unsubscribe를 등록
        return unsubscribe;
      }, [store]);
     
      return [state, store.set] as const;
    };
    • 스토어의 구조가 원시값이 아닌 객체인 경우, 현재는 store의 값이 바뀌면 무조건 useState를 실행하므로 스토어에 어떤 값이 바뀌든지 간에 리렌더링이 일어난다.
  • 변경 감지가 필요한 값만 setState를 호출해 객체 상태에 대한 불필요한 렌더링 막기

    export const useStoreSelector = <
      State extends unknown,
      Value extends unknown
    >(
      store: Store<State>,
      selector: (state: State) => Value
    ) => {
      const [state, setState] = useState(() => selector(store.get()));
     
      useEffect(() => {
        const unsubscribe = store.subscribe(() => {
          const value = selector(store.get());
          setState(value);
        });
     
        return unsubscribe;
      }, [store, selector]);
     
      return state;
    };
    • 필요한 값만 select하고, 객체에서 변경된 값에 대해서만 수행하도록 수정

3. useState와 Context를 동시에 사용해 보기

  • useStoreuseStoreSelector 훅과 스토어를 사용하는 구조는 하나의 스토어를 가지면 이 스토어가 마치 전역 변수처럼 작동하게 되어 동일한 형태의 여러 스토어를 가질 수 없게 된다.

  • 서로 다른 스코프에서 스토어의 구조는 동일하되, 여러 개의 서로 다른 데이터를 공유해 사용하고 싶다면?
    createStore를 이용해 동일한 타입으로 스토어를 여러 개 만들면 될 것 같지만 이 방법은 완벽하지도 않고 매우 번거롭다.

    • 해당 스토어가 필요할 때마다 반복적으로 스토어를 생성해야 한다.
    • 훅은 스토어에 의존적인 1:1 관계를 맺고 있으므로 스토어를 만들 때마다 해당 스토어에 의존적인 useStore와 같은 훅을 동일한 개수로 생성해야 한다.
    • 이 훅이 어느 스토어에서 사용 가능한지를 가늠하려면 오직 훅의 이름이나 스토어의 이름에 의지해야 한다.
  • Context를 활용해 해당 스토어를 하위 컴포넌트에 주입한다면 컴포넌트에서는 자신이 주입된 스토어에 대해서만 접근할 수 있게 된다.

    export const CounterStoreContext = createContext<Store<CounterStore>>(
      createStore<CounterStore>({ count: 0, text: "hello" })
    );
     
    export const CounterStoreProvider = ({
      initialState,
      children,
    }: PropsWithChildren<{ initialState: CounterStore }>) => {
      const storeRef = useRef<Store<CounterStore>>();
     
      if (!storeRef.current) {
        storeRef.current = createStore(initialState);
      }
     
      return (
        <CounterStoreContext.Provider value={storeRef.current}>
          {children}
        </CounterStoreContext.Provider>
      );
    };
     
    export const useCounterContextSelector = <State extends unknown>(
      selector: (state: CounterState) => State
    ) => {
      const store = useContext(CounterStoreContext);
     
      const subscription = useSubscription(
        useMemo(
          () => ({
            getCurrentValue: () => selector(store.get()),
            subscribe: store.subscribe,
          }),
          [store, selector]
        )
      );
     
      return [subscription, store.set] as const;
    };
     
    const ContextCounter = () => {
      const id = useId();
      const [counter, setStore] = useCounterContextSelector(
        useCallback((state: CounterStore) => state.count, [])
      );
     
      function handleClick() {
        setStore((prev) => ({ ...prev, count: prev.count + 1 }));
      }
     
      return (
        <div>
          {counter} <button onClick={handleClick}>+</button>
        </div>
      );
    };
     
    const ContextInput = () => {
      const id = useId();
      const [text, setStore] = useCounterContextSelector(
        useCallback((state: CounterStore) => state.text, [])
      );
     
      function handleChange(e: ChangeEvent<HTMLInputElement>) {
        setStore((prev) => ({ ...prev, text: e.target.value }));
      }
     
      return (
        <div>
          <input value={text} onChange={handleChange} />
        </div>
      );
    };
     
    export default function App() {
      return (
        <>
          {/* 0 */}
          <ContextCounter />
          {/* hi */}
          <ContextInput />
          <CounterStoreProvider initialState={{ count: 10, text: "hello" }}>
            {/* 10 */}
            <ContextCounter />
            {/* hello */}
            <ContextInput />
            <ContextStoreProvider initialState={{ count: 20, text: "welcome" }}>
              {/* 20 */}
              <ContextCounter />
              {/* welcome */}
              <ContextInput />
            </ContextStoreProvider>
          </CounterStoreProvider>
        </>
      );
    }
    • Context는 가장 가까운 Provider를 참조한다.
    • 스토어를 사용하는 컴포넌트는 해당 상태가 어느 스토어에서 온 상태인지 신경쓰지 않아도 된다.
    • ContextProvider를 관리하는 부모 컴포넌트 입장에서는 자신이 자식 컴포넌트에 따라 보여주고 싶은 데이터를 Context로 잘 격리하기만 하면 된다.
      • 부모와 자식 컴포넌트의 책임과 역할을 명시적인 코드로 나눌 수 있다.

4. 상태 관리 라이브러리 Recoil, Jotai, Zustand 살펴보기

페이스북이 만든 상태 관리 라이브러리 Recoil

RecoilRoot

  • Recoil을 사용하기 위해서는 RecoilRoot를 애플리케이션 최상단에 선언해야 한다.
  • Recoil의 상태값은 RecoilRoot로 생성된 Context의 스토어에 저장된다.(useStoreRef, ancestorStoreRef)
  • 스토어의 상태값에 접근할 수 있는 함수들이 있으며, 이 함수를 활용해 상태값에 접근하거나 상태값을 변경할 수 있다. (getNextStoreID(), getState, replaceState 등)
  • 값의 변경이 발생하면 이를 참조하고 잇는 하위 컴포넌트에 모두 알린다.(notifyComponents)

atom

  • 상태를 나타내는 Recoil의 최소 상태 단위

    type Statement = {
      name: string;
      amount: number;
    };
     
    const InitialStatements: Array<Statement> = [
      { name: "과자", amount: -500 },
      { name: "용돈", amount: 10000 },
      { name: "네이버페이충전", amount: -5000 },
    ];
     
    const statementsAtom = atom<Array<Statement>>({
      key: "statements",
      default: InitialStatements,
    });
    • atomkey 값을 필수로 가진다. → 다른 atom과 구별하는 식별자
    • default: atom의 초깃값

useRecoilValue

  • atom의 값을 읽어오는 훅
  • 외부의 값을 구독해 렌더링을 강제로 일으킨다. (useEffect를 통해 recoilValue가 변경됐을 때 forceUpdate를 호출)

useRecoilState

  • useState와 유사하게 값을 가져오고, 변경할 수도 있는 훅

Recoil에서 영감을 받은, 그러나 조금 더 유연한 Jotai

  • 상향식 접근법(bottom-up)
  • 리액트 Context의 문제점인 불필요한 리렌더링을 해결하고자 설계
    • 개발자들이 메모이제이션이나 최적화를 거치지 않아도 리렌더링이 발생하지 않는다.

atom

  • 최소 단위의 상태
  • Recoil과 달리, 파생된 상태를 만들 수도 있다.
  • atom을 생성할 때 별도의 key를 넘겨주지 않아도 된다. → 단순히 toString()을 위한 용도로 한정
  • config 객체를 반환
    • init: 초깃값
    • read: 값을 가져오는 함수
    • write: 값을 설정하는 함수
    • atom에 상태를 저장하고 있지 않음

useAtomValue

  • Recoil과는 다르게 컴포넌트 루트 레벨에서 Context가 존재하지 않아도 된다.
  • Provider 별로 다른 atom 값을 관리할 수도 있다.
  • atomstore에 존재한다.
    • WeakMap이라고 하는 자바스크립트에서 객체만을 키로 가질 수 있는 독특한 방식의 Map을 활용
    • atom 객체 그 자체를 키로 활용해 값을 저장

useAtom

  • useState와 동일한 형태의 배열을 반환
  • setAtom 내부의 write 함수는 스토어에서 해당 atom을 찾아 직접 값을 업데이트한다.
  • 값을 변경한 이후에는 listener 함수를 실행해 값의 변화가 있음을 전파하고, 사용하는 쪽에서 리렌더링이 수행되게 한다.

작고 빠르며 확장에도 유연한 Zustand

  • 리덕스에 영감을 받아 만들어진 라이브러리
  • 하나의 스토어를 중앙 집중형으로 활용
  • 미들웨어를 지원한다.

Zustand의 바닐라 코드

const createStoreImpl: CreateStoreImpl = (createState) => {
  type TState = ReturnType<typeof createState>;
  type Listener = (state: TState, prevState: TState) => void;
  let state: TState;
  const listeners: Set<Listener> = new Set(); // Set 형태로 선언되어 추가와 삭제, 중복 관리가 용이하게끔 설계
 
  const setState: StoreApi<TState>["setState"] = (partial, replace) => {
    const nextState =
      typeof partial === "function"
        ? (partial as (state: TState) => TState)(state)
        : partial;
    if (!Object.is(nextState, state)) {
      const previousState = state;
      state =
        replace ?? (typeof nextState !== "object" || nextState === null)
          ? (nextState as TState)
          : Object.assign({}, state, nextState);
      listeners.forEach((listener) => listener(state, previousState));
    }
  };
 
  const getState: StoreApi<TState>["getState"] = () => state; // 클로저의 최신 값을 가져오기 위해 함수로 만들어져 있다.
 
  const subscribe: StoreApi<TState>["subscribe"] = (listener) => {
    listeners.add(listener); // listener 등록
    // Unsubscribe
    return () => listeners.delete(listener);
  };
 
  const destroy: StoreApi<TState>["destroy"] = () => {
    // listeners 초기화
    listeners.clear();
  };
 
  const api = { setState, getState, getInitialState, subscribe, destroy };
};
  • state 값을 useState 외부에서 관리
  • partialreplace로 구분
    • partial: state의 일부분만 변경하고 싶을 때 사용
    • replace: state를 완전히 새로운 값으로 변경하고 싶을 때 사용

Zustand의 리액트 코드

export function useStore<TState, StateSlice>(
  api: WithReact<StoreApi<TState>>,
  selector: (state: TState) => StateSlice = identity as any,
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getServerState || api.getInitialState,
    selector,
    equalityFn
  );
  useDebugValue(slice);
  return slice;
}
  • useSyncExternalStoreWithSelector를 사용해서 useStoresubscribegetState, 스토어에서 원하는 state를 고르는 함수인 selector를 넘겨준다.
const createImpl = <T,>(createState: StateCreator<T, [], []>) => {
  const api =
    typeof createState === "function" ? createStore(createState) : createState;
 
  const useBoundStore: any = (selector?: any, equalityFn?: any) =>
    useStore(api, selector, equalityFn);
 
  Object.assign(useBoundStore, api);
 
  return useBoundStore;
};
 
export const create = (<T,>(createState: StateCreator<T, [], []> | undefined) =>
  createState ? createImpl(createState) : createImpl) as Create;
 
export default ((createState: any) => {
  return create(createState);
}) as Create;
  • 리액트 컴포넌트에서 해당 스토어를 즉시 사용할 수 있도록 useStore가 사용되었다.
  • useBoundStoreapiObject.assign으로 복사해 api를 동일하게 사용할 수 있게 제공