정리하기 파일
1. 서버 사이드 렌더링이란?
1. 싱글 페이지 애플리케이션의 세상
싱글 페이지 애플리케이션이란?
- 렌더링과 라우팅에 필요한 대부분의 기능을 서버가 아닌 브라우저의 자바스크립트에 의존하는 방식
- 최초에 첫 페이지에서 데이터를 모두 불러온 이후에는 페이지 전환을 위한 모든 작업이 자바스크립트와 브라우저의
history.pushState
와history.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 공유 등 메타데이터 제공이 쉽다.
- 검색 엔진이 사이트에서 필요한 정보를 가져가는 과정
- 검색 엔진 로봇(머신)이 페이지에 진입한다.
- 페이지가 HTML 정보를 제공해 로봇이 이 HTML을 다운로드한다. 단, 다운로드만 하고 자바스크립트 코드는 실행하지 않는다.
- 로봇은 페이지를 보는 것이 아닌 페이지의 정적인 정보를 가져오는 것이 목적이므로 자바스크립트를 다운로드하거나 실행할 필요가 없다.
- 다운로드한 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
renderToString
과renderToNodeStream
으로 생성된 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
): 업데이트를 수정하는 방식
- 모델(
- Elm: 웹 페이지를 선언적으로 작성하기 위한 언어
- 하나의 글로벌 상태 객체를 스토어에 저장 → Props Drilling 문제 해결
reducer
함수를 통해 객체를 업데이트하는 작업을 디스패치해 업데이트를 수행- 웹 애플리케이션 상태에 대한 복사본을 반환, 새롭게 만들어진 상태를 전파
- 하고하 자는 일에 비해 너무 많은 보일러플레이트 → RTK (opens in a new tab)가 나오면서 간소화
Context API와 useContext
- 리덕스의 부담스러운 보일러플레이트 → 리액트 16.3에서 Context API 출시
- 리액트 16.3 버전 이전의
context
와getChildContext()
- 상위 컴포넌트가 렌더링되면
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
useState
와useReducer
를 기반으로 하는 사용자 정의 훅- 컴포넌트별로 초기화되므로 컴포넌트에 따라 서로 다른 상태를 가질 수밖에 없다. (상태의 파편화)
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
의 반환값 중 두 번째 인수가 호출돼야 한다.- 부모 컴포넌트가 리렌더링되거나 함수가 재실행돼야 한다.
- state는 정상적으로 작동하지만, 리렌더링이 되지 않는다. (리액트의 렌더링 방식)
-
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
는 해당 컴포넌트 자체에서만 유효한 전략- 다른 컴포넌트에서는 상태의 변화에 따른 리렌더링을 일으킬 무언가가 없음.
- 외부에 상태가 있음에도 함수형 컴포넌트의 렌더링을 위해 함수의 내부에 동일한 상태를 관리하는
- 외부의 상태도 수정하고
-
함수 외부에서 상태를 참조하고 렌더링까지 자연스럽게 일어나기 위한 조건
- 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.
- 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아챌 수 있어야 하고 상태가 변화될 때마다 리렌더링이 일어나서 컴포넌트는 최신 상태값 기준으로 렌더링해야 한다. 상태 감지는 상태를 변경시키는 컴포넌트뿐만 아니라 이 상태를 참조하는 모든 컴포넌트에서 동일하게 작동해야 한다.
- 상태가 원시값이 아닌 객체인 경우에 그 객체에 내가 감지하지 않는 값이 변한다 하더라도 리렌더링이 발생해서는 안된다.
-
새로운 상태 관리 코드
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를 동시에 사용해 보기
-
useStore
와useStoreSelector
훅과 스토어를 사용하는 구조는 하나의 스토어를 가지면 이 스토어가 마치 전역 변수처럼 작동하게 되어 동일한 형태의 여러 스토어를 가질 수 없게 된다. -
서로 다른 스코프에서 스토어의 구조는 동일하되, 여러 개의 서로 다른 데이터를 공유해 사용하고 싶다면?
→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
를 참조한다.- 스토어를 사용하는 컴포넌트는 해당 상태가 어느 스토어에서 온 상태인지 신경쓰지 않아도 된다.
Context
와Provider
를 관리하는 부모 컴포넌트 입장에서는 자신이 자식 컴포넌트에 따라 보여주고 싶은 데이터를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, });
atom
은key
값을 필수로 가진다. → 다른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
값을 관리할 수도 있다.atom
은store
에 존재한다.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
외부에서 관리partial
과replace
로 구분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
를 사용해서useStore
의subscribe
와getState
, 스토어에서 원하는 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
가 사용되었다. useBoundStore
에api
를Object.assign
으로 복사해api
를 동일하게 사용할 수 있게 제공