front-end dev.

LEE TEK WOO

Tech Blog

Recent Posts

more posts
teklog-recent-post

Fluent React 8 - Concurrent

챕터 7의 리액트 동시성Concurrency은 이전 글에서 이어진다. 이번 장에서 우선순위 업데이트 즉 '렌더링 스케줄링’이 리액트 내부에서 어떻게 이루어지며, 이를 활용하는 훅을 알아본다. 또한 이전 글에서 살펴본 '렌더 레인’과 같은 개념들을 조금 더 깊이 있게 알아본다. 긴 글이니 천천히 반복해서 읽어보자! (이탤릭체는 본문의 인용입니다)동기식 렌더링의 문제점중요하지 않은 작업의 렌더링이 중요한 작업의 렌더링을 차단한다.앞서 살펴본 스택 조정자의 문제점과 동일하다. 즉 동기식 렌더링은 메인 스레드를 차단하는 것이 문제다. 컴포넌트의 업데이트가 빈번하고, 구성이 복잡한 앱에서 사용자 경험 저하로 이어진다. (사용자의 input 입력이 비싸고 불필요한 다른 렌더링 때문에 차단되는 예시를 떠올려보자.)동시 렌더링의 소개동시 렌더링 Concurrent Rendering은 렌더링 차단 문제를 해결한다. 파이버 조정자와 같은 문제의식을 공유하며, 마찬가지로 리액트 내부에서 렌더링을 효율적으로 개선한다.…동시(Concurrent) 렌더링으로,React는 업데이트의 중요성과 긴급성에 따라 업데이트를 우선 순위를 지정할 수 있으며, 덜 중요한 것들에 의해 중요한 업데이트가 차단되지 않도록 합니다. 이를 통해 React는 무거운 부하 하에서도 반응적인 UI를 유지할 수 있으며, 이는 더 나은 사용자 경험으로 이어집니다.…동시 렌더링으로, CPU에 부하가 많은 렌더링 작업은 사용자 인터랙션과 애니메이션과 같은 더 중요한 렌더링 작업에 뒷자리를 할당받을 수 있습니다. 더욱이,React는 타임 슬라이스를 할 수 있습니다. 즉, 렌더링 과정을 더 작은 청크로 나누고 점진적으로 처리할 수 있다. 이를 통해 React는 여러 프레임에 걸쳐 작업을 수행할 수 있으며, *작업이 중단되어야 하는 경우 중단할 수 있습니다.요약하자면 다음과 같다리액트는 렌더링 업데이트의 우선순위에 따라 작업할 수 있다렌더링 과정을 더 작게 나누어 점진적으로 처리할 수 있다 (파이버)우선순위가 낮은 작업은 중단할 수 있다모두동기적 렌더링의 문제를 해결하는 방법으로 보인다. 또한 파이버 조정자가 하는 일과도 일맥상통해 보인다.Fiber 재방문파이버 조정자Fiber Reconciler는 렌더링 과정을 Fiber라고 하는 더 작고 관리하기 쉬운 작업 단위로 나누어 렌더링 작업을 일시 중지, 재개 및 우선 순위 지정할 수 있으며, 그 중요성에 따라 업데이트를 연기하거나 스케줄링할 수 있다.파이버는 조정 단계에서 파이버 조정자가 작업을 처리하기 위한 작은 단위라고 하였다. 앞선 챕터의 내용을 복습하면 다음과 같다.리액트 엘리먼트로부터 생성되고, 엘리먼트와 마찬가지로 트리 구조를 이룬다파이버 노드 트리는 조정의 렌더 단계 중 작업 루프(beginWork와 completeWork) 매개변수로 사용된다 (현재 트리, 작업 중 트리)파이버는 인스턴스이며, 컴포넌트에 대한 다양한 정보와 상태를 갖고 있다엘리먼트가 일시적이고 상태를 갖지 않는데 반해, 파이버는 상태를 갖고 더 장기간 유지된다 (새 파이버 트리로 전부 교체되기 전까지 유지됨)파이버와 파이버 조정에 대해 알아본 이전 챕터에서 아직 설명되지 않은 부분이 있다. “어떻게” 작업을 일시 중지, 우선순위 지정, 연기, 스케쥴링-하는지에 대해선completeWork에서 만들어진 DOM이 폐기될 수 있다-는 내용으로 암시된 내용이 전부였다. 이번 챕터에서 동시성으로 어떻게 중요도에 따라 렌더링의 스케쥴이 결정되는지 더 깊이있게 살펴본다.업데이트 스케줄링 및 연기동시성 렌더링의 예시아래는 메시지 인풋과 메시지를 렌더링하는 채팅 컴포넌트다. 이 예시는 React의 동시 렌더링 훅을 활용하여 인터랙션과 잦은 업데이트를 효과적으로 처리하는 간단한 예시를 보여준다.const MessageInput = ({ onSubmit }) = { const [message, setMessage] = useState(""); const handleSubmit = (e) = { e.preventDefault(); onSubmit(message); setMessage(""); }; return ( form onSubmit={handleSubmit} input type="text" value={message} onChange={(e) = setMessage(e.target.value)} / button type="submit"Send/button /form );};const MessageList = ({ messages }) = ( ul {messages.map((message, index) = ( li key={index}{message}/li ))} /ul);const ChatApp = () = { const [messages, setMessages] = useState([]); const [isOtherUserTyping, setIsOtherUserTyping] = useState(false) useEffect(() = { // 웹소켓 서버와 연결하여 새로 들어오는 메시지를 구독 const socket = new WebSocket("wss://your-websocket-server.com"); socket.onmessage = (event) = { setMessages((prevMessages) = [...prevMessages, event.data.messages]); setIsOtherUserTyping(event.data.otherUserTyping) }; // 동시성 렌더링 적용 시 startTransition(() = { setMessages((prevMessages) = [...prevMessages, event.data]); }); return () = { socket.close(); }; }, []); const sendMessage = (message) = { // Send the message to the server }; return ( div MessageList messages={messages} / // 상태 유저가 입력 중인지 나타내는 typing 인디케이터 {isOtherUserTyping puser is typing../p} MessageInput onSubmit={sendMessage} / /div );};메시지 인풋을 입력할 때마다 서버 데이터와 연결된 messages 상태가 업데이트됨. - 서버에서 응답을 받을 때마다 MessageList의 message가 렌더링 작업에 추가됨 - 사용자 입력으로 input에 입력한 텍스트가 차단(혹은 지연)되면 안됨사용자가 input 입력 중에 메시지가 새로 계속 들어온다면 비효율적인 렌더링으로 부하가 생길 수 있음사용자가 메시지 입력을 마쳐 모두 렌더링된 후 MessageList의 messages가 렌더링 되어야함.useTransition훅의 startTransition을 사용하여 우선 순위를 낮출 수 있음.useTransition훅의 startTransition을 사용하여 MessageList의 업데이트를 더 낮은 우선 순위로스케줄링하여 MessageInput의 UI를 차단하지 않고 렌더링한다. 이를 통해 사용자 입력은 중단되지 않으며, 사용자 인터랙션(input 입력)보다는 덜 중요한 새로 받은 메시지는 우선 순위에서 낮게 렌더링된다. 결과적으로 무거운 부하에 효율적으로 작동할 수 있게 되었다.파이버 조정자Fiber Reconciler는 스케줄러와 여러 효율적인 API에 의존하여 이 기능을 가능하게 한다. 이러한 API를 통해 React는 유휴 기간 동안 작업을 수행하고 가장 적절한 시기에 업데이트를 스케줄링할 수 있다.Diving Deeper동시성 렌더링: 고우선 순위 작업이 신속하게 처리되는 동시에 저우선 순위 작업이 연기될 수 있도록 한다.효과: UI가 무거운 부하 하에 있을 때도 부드럽게 유지될 수 있다.동시성 렌더링의 핵심인 스케줄러, 작업의 우선 순위 수준, 업데이트를 연기하는 메커니즘을 더 깊이 살펴보자.스케줄러파이버 조정자Fiber reconcilier와 독립적으로 시간 관련 유틸리티를 제공하는 독립형 패키지리액트 아키텍처의 핵심에는 렌더링의 스케줄을 관리하는 스케줄러가 있다. 이 스케줄러는조정자reconcilier 내에서 사용된다. 스케줄러와 조정자는렌더 레인을 사용하여 작업의 긴급함에 따라 우선 순위를 매기고, 조직화한다.리액트에서 스케줄러의 주요 역할은 메인 스레드를 제어하는 것이다. 메인 스레드의 원활한 실행을 보장하기 위해 주로 자바스크립트의 마이크로태스크 큐를 사용한다.조금 더 자세히 이해하기 위해 작성 시점의 리액트 소스 코드 일부를 살펴보자./* * 이 함수는 루트가 업데이트를 받을 때마다 호출된다. * 이 함수는 두 가지 작업을 수행한다. * 1) 루트가 루트 스케줄에 포함되어 있는지 확인하고, * 2) 루트 스케줄을 처리할 대기 중인 마이크로태스크가 있는지 확인 * 실제 스케줄링 로직의 대부분은 * `scheduleTaskForRootDuringMicrotask`가 실행될 때까지 발생하지 않는다.*/ export function ensureRootIsScheduled(root: FiberRoot): void { // 스케줄에 루트를 추가 if (root === lastScheduledRoot || root.next !== null) { // 이미 스케줄된 루트root는 빠른 경로로 처리됨. } else { if (lastScheduledRoot === null) { firstScheduledRoot = lastScheduledRoot = root; } else { lastScheduledRoot.next = root; lastScheduledRoot = root; } } /* * 루트가 업데이트를 받을 때마다 다음 스케줄 처리까지 이 값을 true로 설정 * 만약 이 값이 false라면, 스케줄을 확인하지 않고 flushSync를 빠르게 종료할 수 있다 */ mightHavePendingSyncWork = true; /* * 현재 이벤트의 끝에서, 각 루트를 통해 * 올바른 우선순위에서 각각에 대한 작업이 스케줄되어 있는지 확인 */ if (__DEV__ ReactCurrentActQueue.current !== null) { // 이 내부는 'act' 스코프 내부이다 if (!didScheduleMicrotask_act) { didScheduleMicrotask_act = true; scheduleImmediateTask(processRootScheduleInMicrotask); } } else { if (!didScheduleMicrotask) { didScheduleMicrotask = true; scheduleImmediateTask(processRootScheduleInMicrotask); } } if (!enableDeferRootSchedulingToMicrotask) { /* * 이 플래그가 disabled되어 있는 동안, * 마이크로태스크를 기다리는 대신 렌더 작업을 즉시 스케줄한다. * TODO: 우리가 계획한 추가 기능들을 해제하기 위해 * enableDeferRootSchedulingToMicrotask를 가능한 빨리 적용해야한다. */ scheduleTaskForRootDuringMicrotask(root, now()); } if ( __DEV__ ReactCurrentActQueue.isBatchingLegacy root.tag === LegacyRoot ) { // Special `act` case: Record whenever a legacy update is scheduled. ReactCurrentActQueue.didScheduleLegacyUpdate = true; }}ensureRootIsScheduled함수리액트의 렌더링 프로세스를 관리하는 데 중요한 역할을 한다.React 루트는root: FiberRoot로 표현되며, 업데이트를 받으면 이 함수가 호출되어두 가지 핵심 작업을 수행한다.note: React 루트는커밋 단계에서 업데이트를 수행하기 위해 최종으로 “스왑”(업데이트)된 트리이다. (앞선 글에서 실제 화면에 업데이트 되기 전에 미리 구성된 트리)ensureRootIsScheduled이 호출되면,1. 해당 루트가 루트 스케줄에 포함되어 있는지 확인한다.이는 어떤 루트가 처리되어야 하는지 추적하는 목록이다.2. 이 루트 스케줄을 전용으로 처리하는 대기 중인 마이크로태스크가 있는 것을 보장한다.마이크로태스크는 자바스크립트의 이벤트 루프와 연관된다. 자바스크립트의 이벤트 루프를 복습해보자.마이크로태스크마이크로태스크는 JavaScript이벤트 루프관리에서의 개념으로,마이크로태스크 큐에서 관리되는 작업이다.이벤트 루프자바스크립트 엔진은 비동기 작업을 관리하기 위해 이벤트 루프를 사용.이벤트 루프는 계속해서 콜백 실행과 같은 작업이 필요한지 확인한다.이벤트 루프는 태스크 큐(매크로 태스크 큐)와 마이크로태스크 큐로 작동한다.태스크 큐(매크로 태스크 큐)이벤트 처리, setTimeout 및 setInterval 콜백 실행, I/O 수행 같은 작업을 포함.태스크 큐의 작업은 한 번에 하나씩 처리되며, 현재 작업이 완료된 후에 다음 작업이 선택된다.마이크로태스크 큐마이크로태스크는 더 작고 즉시 처리해야 하는 작업이다.프로미스, Object.observe, MutationObserver와 같은 작업에서 발생한다.이들은 일반 태스크 큐와 다른 마이크로태스크 큐에 저장된다.실행마이크로태스크는 현재 작업이 끝난 후,JavaScript 엔진이 태스크 큐에서 다음(매크로) 작업을 선택하기 전에 처리된다.작업을 실행한 후, 마이크로태스크 큐에 있는 모든 마이크로태스크를 확인하고 실행한 다음 다음 작업으로 이동한다.루트 스케줄은 마이크로 태스크 큐로 관리되어 렌더링이나 이벤트 처리와 같은 다른 작업에 앞서 빠르고 순서대로 처리되도록 보장된다.특징 및 사용법마이크로태스크는 태스크 큐의 다른 작업보다 우선순위가 높으므로 다음 매크로 태스크로 이동하기 전에 실행된다.마이크로태스크가 계속해서 마이크로태스크 큐에 추가되는 경우, 태스크 큐가 처리되지 않을 수 있다. 이를 '스타베이션(starvation)'이라고 한다.리액트와ensureRootIsScheduled함수 안에서 마이크로태스크는 루트 스케줄 처리가 우선순위가 낮은 다른 작업보다 높은 우선순위로 즉시 처리되도록 보장하는 데 사용된다. 이는 리액트 내에서 부드러운 UI 업데이트와 효율적인 작업 관리를 유지하는 데 도움이 된다.ensureRootIsScheduled의 실행 순서리액트 내부의 코드를 간략히 살펴보자. 이 함수가 하는 일은 이와 같다.1. 함수는 먼저 루트를 스케줄에 추가한다.루트가 이미 마지막으로 스케줄된 루트이거나 스케줄에 이미 있는지 확인한다.루트가 없는 경우, 함수는 루트를 스케줄의 끝에 추가하여 lastScheduledRoot를 현재 루트를 가리키도록 업데이트한다.이전에 스케줄된 루트가 없는 경우(lastScheduledRoot === null), 현재 루트가 스케줄에서 첫 번째이자 마지막이 된다.2. 함수는 플래그mightHavePendingSyncWork를 true로 설정한다.이 플래그는 동기적인 작업이 보류 중일 수 있음을 나타낸다.다음 섹션에서 다룰flushSync함수에 필수적이다.3. 함수는 루트 스케줄을 처리하기 위해 마이크로태스크가 스케줄되었는지 확인한다. (‘act’ 내부)scheduleImmediateTask(processRootScheduleInMicrotask)를 호출하여 수행된다.이 스케줄링은DEV및 ReactCurrentActQueue.current에 의해 리액트의 act 테스트 유틸리티 범위 내외에서 모두 발생한다.4. enableDeferRootSchedulingToMicrotask 플래그를 확인하는 조건 블록이다.이 플래그가 비활성화되어 있으면 함수는 마이크로태스크로의 지연이 아닌 즉시 렌더링 작업을 예약한다.이 부분은 (작성 당시) 미래의 기능 추가를 위해 이 기능을 활성화할 계획이 있음을 나타내는 TODO 주석으로 표시된다.5. 함수에는 React의 act 유틸리티 내에서 레거시 업데이트를 처리하는 조건이 포함된다.이는 업데이트가 다르게 배치되는 테스트 시나리오에서 특정하며, 레거시 업데이트가 예약될 때마다 기록된다.ensureRootIsScheduled의 효과React의 스케줄링 및 렌더링 로직의 통합하여 React 루트에 대한 업데이트를 효율적으로 관리한다.작업 및 마이크로태스크를 전략적으로 예약하여 부드러운 렌더링을 보장한다.이 함수를 통해 React에서 스케줄러의 역할을 이해할 수 있다.작업을 렌더 레인에 기반하여 예약하는 것이다.코드로 스케줄러의 동작을 모델링하면 다음과 같다:if (nextLane === Sync) { queueMicrotask(processNextLane);} else { Scheduler.scheduleCallback(callback, processNextLane);}다음 레인이 Sync인 경우, 스케줄러는 다음 레인을 즉시 처리하기 위해 마이크로태스크를 예약한다.다음 레인이 Sync가 아닌 경우, 스케줄러는 콜백을 예약하고 다음 레인을 처리한다.따라서 스케줄러는 이름 그대로 함수를 해당 함수의 레인에 따라 실행할 시스템이다. 이제 레인에 대해 자세히 알아보자.Render Lanes우선 순위의 수준을 나타내는 작업 단위렌더 레인(Render lanes)은 스케줄링 시스템에서 효율적인 렌더링과 작업의 우선 순위를 보장한다.이전에는 만료시간(expiration times)를 사용했다.이전 글에서 렌더레인이 조정의 '렌더 단계’의 두 함수, beginWork, completeWork에서 마지막 인자로 사용되는 것을 확인했다. ('renderLanes는 업데이트가 처리되고 있는 "레인"을 나타내는 비트마스크).비트마스크: 비트마스크(Bitmask)는 데이터의 비트(bit) 연산을 이용하여 정보를 표현하고 조작하는 기법. 각 비트의 위치가 하나의 플래그(또는 스위치)로 작용하여, 여러 조건을 한 번에 저장하고 관리할 수 있게 한다.이제 렌더 레인의 세부 사항과 작동 방식, 비트마스크로의 내부 표현에 대해 자세히 살펴보자.렌더 레인은 React가 렌더링 프로세스 중에 처리해야 할업데이트를 조직화하고 우선 순위를 정하는 가벼운 추상화이다.ex) setState를 호출하면 해당 업데이트가 레인에 넣어진다. 업데이트의 컨텍스트에 따라 다른 우선 순위를 이해할 수 있다.setState가 클릭 핸들러 내부에서 호출되면 Sync 레인(가장 높은 우선 순위)에 넣어지고 마이크로태스크로 예약된다.setState가 startTransition에서 호출될 때는 transition 레인(낮은 우선 순위)에 넣어지고 마이크로태스크로 예약된다.레인의 종류각 레인은 특정 우선 순위 수준에 해당하며, 높은 우선 순위의 레인이 낮은 우선 순위의 레인보다 먼저 처리된다. React의 레인 종류는 다음과 같다.SyncHydrationLane: hydration 중에 React 앱을 클릭할 때 클릭 이벤트가 이 레인에 넣어진다.SyncLane: React 앱을 클릭할 때 클릭 이벤트가 이 레인에 넣어진다.InputContinuousHydrationLane: hydration 중 호버 이벤트, 스크롤 이벤트 및 기타 연속적인 이벤트가 이 레인에 넣어진다.InputContinuousLane: hydration된 후에 앞선 InputContinuousHydrationLane와 더동일한 이벤트가 이 레인에 넣어진다.DefaultLane: 네트워크에서 이루어진 업데이트, setTimeout과 같은 타이머, 우선 순위가 추론되지 않는 초기 렌더링이 이 레인에 넣어진다.TransitionHydrationLane: hydration 중에 startTransition에서의 모든 전환은 이 레인에 넣어진다.TransitionLanes: hydration 후 startTransition에서의 모든 전환은 이 레인에 넣어진다.RetryLanes: Suspense 컴포넌트의 재시도가 이 레인에 넣어진다.메커니즘을 이해하기 위한 것이며, 리액트가 업데이트 되면 변경이 생길 수 있다.Render Lanes의 원리새로운 컴포넌트가 렌더 트리에 추가되거나 컴포넌트가 업데이트 될 때레인을 사용하여 업데이트에 우선순위를 할당.앞서 살핀 바처럼 우선순위는 업데이트의 (사용자 상호작용, 백그라운드 작업같은)종류에 따라 다르게 결정됨.React는 이러한 우선순위에 따른 업데이트를 올바른 레인에 할당하여개발자가 직접 개입하지 않고효율적으로 작동하도록 한다.렌더 레인이 렌더-커밋 단계 중 우선순위를 결정하는 과정:1. 업데이트 수집 (렌더 단계)React는 마지막 렌더 이후 예약된 모든 업데이트를 수집하고 우선순위에 따라 해당 레인에 할당한다.2. 레인 처리 (렌더 단계)React는 각 레인에서 업데이트를 처리하며 가장 높은 우선순위 레인부터 시작한다. 동일한 레인의 업데이트는 함께 배치batch하여 하나의 패스로 처리.3. 커밋 단계모든 업데이트를 처리한 후, React는 커밋 단계에 진입하여 DOM에 변경 사항을 적용하고 효과effect를 실행하며 다른 마무리 작업을 수행.4. 반복각 렌더링에 대해 프로세스를 반복된다.이로써 업데이트가항상 우선순위의 순서대로 처리되고, 높은 우선순위 업데이트가 낮은 우선순위 업데이트에 의해 starvation되지 않도록 한다.우선 순위를 결정한다는 곧 우선순위에 맞는 '렌더 레인’에 배치한다로 이해할 수 있다. 우선순위의 결정은 렌더 단계에서 일어나며, 이 단계에서 렌더링의 스케줄러가 함께 작동한다. 렌더 단계에서 조정자가 beginWork를 진행하며 파이버 트리를 따라 업데이트 여부를 확인하고, 적절한 우선순위 lane에 따라 completeWork에서 새로운 트리를 구성하는 것으로 이해할 수 있다. 추가로 앞서 살펴본 스타베이션(계속 작업이 추가되어 대기 중인 작업이 계속 중단됨)을 막기 위해 이 과정을 매 렌더링마다 반복된다는 사실 또한 기억 할 만하다.이제 렌더 레인이 할당되는 과정을 확대해서 살펴보자. 이 과정은 업데이트가 트리거될 때 다음 순서대로 수행하면서 우선순위를 결정하고 올바른 레인에 할당한다.컨텍스트 결정 - 컨텍스트 기반의 우선순위 추정 - 우선순위 재지정 확인 - 업데이트를 레인에 할당1.업데이트 컨텍스트 결정React는 업데이트가 트리거된 컨텍스트를 평가한다. 이 컨텍스트는 사용자 인터랙션, 상태나 prop 변경으로 인한 내부 업데이트 또는 서버 응답의 결과인 업데이트 등이 될 수 있다. 컨텍스트는 업데이트의 우선순위를 결정하는 데 중요한 역할을 한다.2. 컨텍스트에 따른 우선순위 추정컨텍스트에 따라 React는 업데이트의 우선순위를추정한다. 예를 들어, 업데이트가 사용자 입력으로 인한 것이면 더 높은 우선순위를 가질 가능성이 높고, 중요도가 낮은 백그라운드 프로세스에서 트리거된 업데이트는 낮은 우선순위를 가질 수 있다.3. 우선순위 재지정 확인경우에 따라 개발자가 React의 useTransition 또는 useDeferredValue 훅을 사용하여 업데이트의 우선순위를 명시적으로 설정할 수 있다.이러한 우선순위 재지정이 존재하는 경우 React는 추정된 우선순위 대신 제공된 우선순위를 고려한다.4. 업데이트를 올바른 레인에 할당우선순위가 결정되면 React는 해당 레인에 업데이트를 할당한다. 이 프로세스는 방금 살펴본 비트마스크를 사용하여 수행되며, React가 여러 레인과 효율적으로 작업하고 업데이트가 올바르게 그룹화되고 처리되도록 한다.이 프로세스 전반에 걸쳐 React는 내부 휴리스틱 및 업데이트가 발생하는 컨텍스트에 의존하여 우선순위에 대한 정보를 파악한다. 이러한 동적 우선순위 및 레인 할당은 React가 반응성과 성능을 균형 있게 유지할 수 있도록 하여 개발자의 수동 개입 없이도 응용 프로그램이 효율적으로 작동하도록 한다.이어서 React가 각 레인에서 업데이트를 처리하는 방법을 살펴보자Processing Lanes업데이트가 각 레인에 할당되면 React는 그들을 우선순위 순서대로 처리한다. 앞서 살핀 ChatApp 컴포넌트 예시에서 React는 다음과 같은 순서로 업데이트를 처리할 것이다.1. ImmediatePriority메시지 입력에 대한 업데이트를 처리하여 사용자가 입력하는 메시지를 보여주도록 빠르게 업데이트한다.2. UserBlockingPriority만약 메시지 상대가 메시지를 입력중이라면, 타이핑 인디케이터('user is typing…'이라는 문구)를 보여준다.타이핑 인디케이터의 업데이트를 처리하여 사용자에게 실시간 피드백을 제공한다.3. NormalPriority메시지 리스트()에 대한 업데이트를 처리하여 새 메시지와 업데이트가 합리적인 속도로 표시된다.우선순위 순서로 업데이트를 처리하여 가장 중요한 사용자 입력의 렌더링의 반응성을 유지할 수 있도록 보장한다.Commit Phase모든 업데이트를 각각의 레인에서 처리한 후에 React는 커밋 단계로 진입한다. 커밋 단계에서는 DOM에 변경 사항을 적용하고 효과를 실행하며 기타 완료 작업을 수행한다.채팅 앱의 예시에서는 이 단계에서 1) 메시지 입력 값 업데이트, 2) 타이핑 인디케이터의 표시/숨김, 3) 새 메시지를 메시지 목록에 추가하는 작업이 순서대로 실행된다. 그런 다음 React는 다음 렌더 사이클에서 업데이트 수집, 레인 처리, 변경 사항 커밋 등의 프로세스를 반복한다.리액트 내부의 실제 메커니즘을 완전히 이해하는 것은 매우 복잡하다. 이 이상은 필요 이상으로 어렵기 때문에, 인용을 참고하고 이어서 동시성 관련 훅에 대해 더 알아보자.note 더욱 깊게 알아보기.…두 레인이 함께 처리되어야 하는지 결정하는 Entanglement와 이미 처리된 업데이트 위에 업데이트가 다시 기반화되어야 하는지 결정하는 Rebasing과 같은 개념이 있습니다. 예를 들어, 전환이 완료되기 전에 동기화 업데이트에 의해 전환 작업이 중단된 경우에는 두 작업을 함께 실행해야 합니다. 또한 flushing effect에 대해서도 알아야합니다. 예를 들어, 동기 업데이트가 있는 경우에는 React가 효과를 업데이트 전/후에 플러시하여 동기 업데이트 간 상태의 일관된 순서를 보장할 수 있습니다. ...최종적으로 이런 작업을 대신해주는 것이 React가 존재하는 이유이며, React가 우리가 계속해서 응용 프로그램에 집중하는 동안 업데이트 문제, 그들의 우선 순위 및 순서를 근본적으로 처리하는 방식으로 백그라운드에서 추가하는 실제 가치입니다.useTransitionReact는 우선 순위를 추정하지만 완벽하지는 않다.useTransition 및 useDeferredValue와 같은 API를 사용하여 기본 우선 순위 할당을 재정의할 수 있다.useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 재정의하여 UI의 업데이트가 반응하지 않는 것을 방지하기 위한 훅이다.새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다.useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 관리하고 UI가 고우선 순위 업데이트로 인해 반응하지 않는 것을 방지하기 위한 훅이다. 새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다useTransition 훅은 transition을 생성하고, 여기에 특정한 우선 순위를 할당하는 것으로 작동한다. 업데이트가 startTransition으로 래핑되면 transition이 된다. React는 이를 기반으로 할당된 우선 순위에 따라 업데이트를 예약하고 렌더링한다.useTransition 훅을 사용하는 과정은 다음과 같다:함수형 컴포넌트 내에서 useTransition 훅을 가져오고 호출한다.훅은 두 개의 요소를 포함하는 배열을 반환한다.[isPending, startTransition]우선순위를 낮추고자하는 (업데이트를 지연시키려는) 모든 상태 업데이트 또는 컴포넌트 렌더링을 startTransition 함수 내부로 옮긴다.isPending 상태는 전환이 여전히 진행 중인지 완료되었는지를 나타낸다.React는 startTransition에 래핑된 업데이트가 적절한 우선 순위 레벨로 처리되도록 보장한다. 이는 스케줄러와 렌더 레인 메커니즘을 통해 업데이트를 할당하고 관리함으로써 달성된다.TransitionLanes은 SyncLane보다 우선 순위가 낮기 때문에 다른 고우선 순위 업데이트가 먼저 업데이트 되도록하여 부드러운 사용자 경험을 유지할 수 있다. startTransition 함수로 래핑된 모든 상태 업데이트는 TransitionLanes 레인에 들어가게 된다.useTransition은 훅이므로 함수 컴포넌트 내에서만 사용할 수 있다. 두 개의 요소를 포함하는 배열을 반환한다.startTransition업데이트를 지연하거나 우선 순위를 낮게 할 때 사용할 수 있는 함수이다. 이 함수 내부에는 상태의 업데이트가 들어간다. 이 함수에 들어간 업데이트는 전환transition이 된다.isPendingstartTransition 내부의 전환transition이 진행 중인지 여부를 나타내는 boolean 값이다.추가로 리액트는 useTransition의 반환값이 아닌startTransition API도 제공한다. 이 API 일반 함수로 사용할 수 있다. isPending을 사용할 수 없다. useTransition과 같은 훅을 사용할 수 없는 코드 위치에서 React에 낮은 우선 순위 업데이트를 처리할 경우에 사용할 수 있다. (jsx 내부 혹은 util 함수 내부와 같은 경우에 사용할 수 있다.)useTransition 예시import React, { useState, useTransition } from "react";function App() { const [count, setCount] = useState(0); const [isPending, startTransition] = useTransition(); const handleClick = () = { doSomethingImportant(); startTransition(() = { setCount(count + 1); }); }; return ( div pCount: {count}/p button onClick={handleClick}Increment/button {isPending pLoading.../p} /div );}export default App;이 예제에서는 useTransition을 사용하여 카운터를 증가시키는 상태 업데이트의 우선 순위를 관리한다. startTransition 함수 내에서 setCount 업데이트를 래핑함으로써 이 업데이트를 지연시킬 수 있도록 하여 다른 고우선 순위 업데이트(doSomethingImportant)가 동시에 발생할 때, cout의 업데이트에 UI가 반응하지 않게 할 수 있다.useTransition은 페이지 간 이동할 때도 유용하다. 페이지 이동과 관련된 업데이트의 우선 순위를 관리하여 복잡한 페이지 전환을 처리할 때도 사용자 경험을 부드럽고 반응적으로 유지할 수 있다. 예시는 SPA에서 페이지 전환을 예시이다. 그러나 SPA가 아니더라도, URL경로 이동 없이 탭 선택으로 컴포넌트가 변경되는 컴포넌트에 적용할 수 있다.import React, { useState, useTransition } from "react";const PageOne = () = divPage One/div;const PageTwo = () = divPage Two/div;function App() { const [currentPage, setCurrentPage] = useState("pageOne"); const [isPending, startTransition] = useTransition(); const handleNavigation = (page, _isPending) = { if(_isPending) return; startTransition(() = { setCurrentPage(page); }); }; const renderPage = () = { switch (currentPage) { case "pageOne": return PageOne /; case "pageTwo": return PageTwo /; default: return divUnknown page/div; } }; return ( div nav button onClick={() = handleNavigation("pageOne")}Page One/button button onClick={() = handleNavigation("pageTwo")}Page Two/button /nav {isPending pLoading.../p} {renderPage()} // 혹은 조건부 렌더링으로 currentPage와 PageOne,Two를 명시할 수 있다. /div );}export default App;만약 App 컴포넌트에 이동 시에 다른 고우선 순위 업데이트(예: input 입력)가 있다면, useTransition을 사용해 컴포넌트 전환을 우선순위 뒤로 밀어 렌더링 차단을 방지할 수 있다. 또한 페이지 전환이 진행되는 동안 isPending 상태를 활용해 사용자가 버튼을 클릭할 때 즉시 로딩 표시기를 표시할 수 있다. 전환이 완료되면 isPending 상태가 false가 되고 새 페이지가 렌더링된다. 다음 페이지가 Suspense를 사용하여 데이터를 가져와야 하는 경우에도 유용하게 사용할 수 있다. startTransition없이는 페이지 전환이 다른 업데이트를 차단할 수 있기 때문이다.useDeferredValueuseDeferredValue은 특정 UI 업데이트를 나중에 처리할 수 있게 해주는 React 훅이다.특히 무거운 작업을 처리하는 경우에 유용하며, 이를 통해 업데이트 우선 순위를 관리할 수 있다useDeferredValue는 계산이 무거운 작업에서, 이전 value를 새 value로 업데이트하기 전까지 더 오래 유지하여 부드러운 사용자 경험을 제공한다. (예시 참조)useDefferedValue가 반환하는 value가 새 값으로 바뀔 때는 다중 리렌더링이 아니라 제어된 상태에서 업데이트 된다. 이는 stale-while-revalidate 전략과 유사하며, 새 값이 도착할 때까지 이전 값을 보유하여 UI가 반응성을 유지한다.React의 이전 커밋에서 useDeferredValue의 첫 구현을 확인할 수 있다. 이는 useDefferedValue를 이해하는데 좋은 참고가 된다.function useDeferredValue(value) { // 1. 인자를 초기값으로만 사용하고 const [newValue, setNewValue] = useState(value); useEffect(() { // 2. startTransition의 전환transition 내부에서 상태가 바뀔 때마다 업데이트하여 '연기defer'시킨다. startTransition(() = { setNewValue(value); }); }, [value]); return newValue;}useDeferredValue의 사용목적useDeferredValue의 사용 목적은덜 중요한 업데이트의 렌더링을 지연시키는 것이다.사용자 인터랙션과 같은더 중요한 업데이트를 먼저 업데이트하고서버에서 업데이트된 데이터를 표시하는 것과 같은 덜 중요한 작업을 나중에 업데이트할 수 있다.다음은 useDeferredValue를 사용하는간단한 예시다.import React, { memo useState, useDeferredValue } from "react";function App() { const [searchValue, setSearchValue] = useState(""); const deferredSearchValue = useDeferredValue(searchValue); return ( div input type="text" value={searchValue} onChange={(event) = setSearchValue(event.target.value)} / SearchResults searchValue={deferredSearchValue} / /div );}const SearchResults = memo(({ searchValue }) = { // Perform the search and render the results})이 예제에서는useDeferredValue를 사용하여 검색 결과의 렌더링을 지연시킨다. 검색 결과를 보여주는 SearchResult는 상대적으로 렌더링하는 데 더 많은 비용이 든다. 이때 사용자 입력에 따라 searchValue가 먼저 업데이트 되어, SearchResults의 렌더링 작업이 input의 searchValue 렌더링을 막지않아 UI 응답성을 유지할 수 있다. 또한 SearchResults에 memo를 사용하여 불필요한 업데이트가 발생하지 않도록 하였다.deferredSearchValue은 setSearchValue로 더 중요한 업데이트가 끝난 이후에 업데이트되고, 이를 prop으로 사용하는 컴포넌트도 그때 업데이트 된다. 결과적으로 컴포넌트는 텍스트 입력 필드를 업데이트하는 것과 같이 더 이상 중요한 작업이 없을 때에만 렌더링되는 효과를 얻을 수 있다.debounce, throttle 보다 나은 이유Debouncing사용자가 타이핑을 마칠 때까지 업데이트를 지연시키는 일정 시간의 일시 정지. (ex: 1초의 지연)Throttling일정한 간격으로 목록을 업데이트한다. (ex: 1초에 한 번 이상)차이점:useDeferredValue는 지연된 리렌더링을 중단할 수 있다고정된 임의의 지연값(delay)를 사용하는 대신 **렌더링 최적화에 특화된 해결책사용자의 장치device 성능에 동일하게 기능한다. 임의의 delay값 사용의 한계를 방지한다useDeferredValue는 지연된 리렌더링을 중단할 수 있는 장점을 갖는다. React가 큰 목록을 처리하는 동안 사용자가 새로운 키를 입력하는 경우, React는 다시 렌더링을 일시 중지하고 새 입력에 응답 한 다음 백그라운드에서 다시 렌더링 프로세스를 재개 할 수 있다.이는 업데이트를 지연시켜도, 렌더링 중에 인터랙션을 차단할 가능성이 있는 debouncing 및 throttling과 대조적이다. useDefferedValue를 통해 이러한 방해 없이 일관성있는 경험을 제공할 수있다.사용자가 만일 고성능의 노트북과 같은 디바이스를 사용한다면, 다시 렌더링하는 임의의 delay가 거의 눈에 띄지 않으며 거의 즉시 발생한다. 반면 느린 디바이스에서는 delay 시간이 그에 따라 조정되어 입력에 대한 응답에 대한 디바이스의 속도에 비례하여 지연이 발생할 수 있다.하지만 debouncing과 throttling은 렌더링과 직접적으로 관련되지 않은 시나리오에서 여전히 유용할 수 있다. 예를 들어 네트워크 요청의 빈도를 줄이는 데 효과적일 수 있다. 이런 경우 debouncing과 throttling은 포괄적인 최적화 전략으로 useDeferredValue와 함께 사용될 수도 있다.useDeferredValue의 장점개선된 응답성이 예에서 사용자가 검색 상자에 입력하면 입력 필드가 즉시 업데이트되고 결과는 지연된다. 사용자가 연속으로 다섯 문자를 빠르게 입력하면 입력 필드가 즉시 다섯 번 업데이트되고, 검색 결과는 사용자가 타이핑을 멈출 때까지 한 번만 렌더링된다. 문자 1~4에 대해서는 새 값으로 렌더링이 중단된다.선언적 우선 순위 지정useDeferredValue는 애플리케이션에서 업데이트의 우선 순위를 관리하는 간단하고 선언적인 방법을 제공한다. 훅 내에서 업데이트를 지연시키는 논리를 캡슐화함으로써 컴포넌트 코드를 깔끔하고 앱의 중요한 측면에 집중시킬 수 있다.자원 활용률 향상useDeferredValue를 사용하여 중요하지 않은 업데이트를 지연시킴으로써 애플리케이션은 사용 가능한 자원을 더 효율적으로 활용할 수 있다. 이는성능 병목 현상의 발생 가능성을 줄이고 애플리케이션 전반적인 성능을 향상시킬 수 있다.useDeferredValue를 사용할 때useDeferredValue는 특정 업데이트를 다른 것보다 우선순위를 두어야 하는 상황에서 가장 유용하다. useDeferredValue가 유용한 시나리오는 다음과 같다.대량의 데이터 세트를 검색하거나 필터링하는 경우복잡한 시각화나 애니메이션을 렌더링하는 경우백그라운드에서 서버에서 데이터를 업데이트하는 경우사용자 인터랙션에 영향을 줄 수 있는 계산 비용이 많이 드는 작업을 처리하는 경우useDeferredValue가 유용할 수 있는 예시:사용자 입력에 따라 필터링된 대량의 항목 목록대량의 목록을 필터링하는 것은 계산 비용이 많이 들기 때문에 useDeferredValue를 사용하여 개선할 수 있음import React, { memo, useState, useMemo, useDeferredValue } from "react";function App() { const [filter, setFilter] = useState(""); const deferredFilter = useDeferredValue(filter); const { data: items } = useQuery({ queryKey: ['searchList'], queryFn: ... }) const filteredItems = useMemo(() = { return items.filter((item) = item.includes(deferredFilter)); }, [items, deferredFilter]); return ( div input type="text" value={filter} onChange={(event) = setFilter(event.target.value)} / ItemList items={filteredItems} / /div );}const ItemList = memo(({ items }) = { // 아이템 항목 렌더});useDeferredValue를 사용하여 필터링된 목록의 렌더링을 지연시킨다. 사용자가 필터 입력란에 타이핑하는 동안 filteredItems은 덜 자주 업데이트되어 사용자 입력을 우선하고 반응성을 유지할 수 있다.useMemo 훅은 items와 filteredItems 배열을 메모이제이션하여 불필요한 다시 렌더링과 다시 계산을 방지하여 성능을 더욱 향상시킬 수 있다.useDeferredValue를 사용하면 안될 때지연된 렌더링 업데이트로 오래된 값을 보여주면 안될 때useDeferredValue를 사용하는 것이 특정 시나리오에서 유용할 수 있지만, 이에 대한 트레이드오프를 인식하는 것도 중요하다. 업데이트를 지연시켜 사용자에게 표시되는 데이터가오래된 경우가 있을 수 있기 때문이다. 중요하지 않은 업데이트는 상관없지만, 사용자에게 오래된 데이터를 표시하는 결과를 보여줘선 안되는 경우도 고려해야한다.useDeferredValue를 사용할지 여부를 결정할 때 스스로에게 물어볼 좋은 질문은 "이 업데이트가 사용자 입력인가요?"입니다. 사용자가 반응을 기대하는 모든 것은 지연해서는 안되지만, 그 외의 중요하지 않은 것들은 지연시켜도 괜찮습니다.useDeferredValue의 사용은 UI의 반응성을 향상시킬 수 있지만, 모든 것의 해결책으로 보면 안된다. 성능을 향상시키는 가장 좋은 방법은 효율적인 코드를 작성하고 불필요한 계산을 피하는 것이다.useTransition과 useDefferedValue 차이점혼동될 만한 두 훅을 다시 한번 정리해보자.useTransition코드 내부의 있는 상태 업데이트(setter)를 지연시키기 위해 사용배열을 반환한다.[isPending, startTransition]startTransition 함수 내부에 다른 상태 업데이트 로직을 넣어 '전환’으로 변경한다startTransition 내부에 들어간 상태 업데이트는 낮은 우선순위로 처리된다stratTransition 내부에서 상태 업데이트가 되는 동안, isPending은 true가 된다useDefferedValue코드 내부의 특정 상태(state)를 더 오래 유지하기 위해 사용.상태가 빈번히 업데이트 되는 경우, useDefferedValue훅에 인자로 상태를 사용하여 업데이트가 끝난 후 최종 상태값을 사용할 수 있다.ex: input text에 따라 렌더링되는 리스트 컴포넌트. 사용자가 입력을 끝마쳐 더이상 업데이트가 없는 최종의 상태를 사용한다.'업데이트를 지연시킨다’는 문구로 혼동이 있을 수 있다. 하지만 두 훅의 사용 범위와 목적이 다르다는 것을 숙지하면 좋다. startTransition은 컴포넌트에서 특정 업데이트setter의 우선순위를 지연시키기 위해 사용하며, useDefferedValue는 특정 상태state가 자주 업데이트 될 때 (ex: input 입력), 이 상태에 더이상 업데이트가 없을 때까지 값을 유지시키기 위해 사용한다.동시Concurrent 렌더링의 문제동시 렌더링의 대표적인 문제로, 업데이트가 처리되는 순서를 이해하기 어려운 경우가 있다. 그리고 이는 예상치 못한 동작 및 버그로 이어질 수 있다.Tearing업데이트가 순서대로 처리되지 않아 UI가 불일치하게 되는 버그컴포넌트가 렌더링 중에 업데이트되는 값에 의존할 때 발생할 수 있음이로 인해 같은 상태임에도 일관되지 않은 데이터로 렌더링될 수 있음동기적인 React는 컴포넌트 트리를 따라 내려가 하나씩 렌더링한다. 이는 상위에서 하위로, 순서대로 이루어진다. 각 컴포넌트가 최신 상태로 렌더링되어 상태가 렌더링 프로세스 전체에서 일관되게 유지된다. 하지만 동시성 렌더에선 문제가 생길 수 있다.동시성 렌더링에서 Tearing 버그 재연:import { useState, useSyncExternalStore, useTransition } from "react";// 외부 상태let count = 0;setInterval(() = count++, 1);export default function App() { const [name, setName] = useState(""); const [isPending, startTransition] = useTransition(); const updateName = (newVal) = { startTransition(() = { setName(newVal); }); }; return ( div input value={name} onChange={(e) = updateName(e.target.value)} / {isPending divLoading.../div} ul li ExpensiveComponent / /li li ExpensiveComponent / /li li ExpensiveComponent / /li li ExpensiveComponent / /li li ExpensiveComponent / /li /ul /div );}const ExpensiveComponent = () = { const now = performance.now(); while (performance.now() - now 100) { // 대기 } return Expensive count is {count}/;};ExpensiveComponent : 계산 비용이 큰 컴포넌트. 렌더링에 오랜 시간이 걸린다. count를 표시.count: 전역 변수. 밀리초마다 증가한다.Expensive count is 568Expensive count is 568Expensive count is 569Expensive count is 569Expensive count is 570버그: input에 몇 글자를 입력한 후, 다섯 개의 ExpensiveComponent에서 count의 각각 다른 값으로 렌더링된다. 즉 동시성 렌더링 사용 시 같은 값이 다르게 표시될 수 있다.원인: React가 렌더링 과정 중 다른 작업으로 '중단’되었다가 다시 시작할 때, ExpensiveComponent는 이미 변경된 count 값을 반영하지 않은 채 렌더링될 수 있다. 이는 ExpensiveComponent가 여러 번 렌더링되는 동안 count 값이 계속 업데이트되기 때문에, 각 컴포넌트가 조금씩 다른 시점의 count 값을 사용하여 렌더링하기 때문이다.이것을 티어링tearing이라고 한다.아직 렌더링되는 동안 일부 상태에 의존하는 컴포넌트가 업데이트될 때 발생할 수 있는 버그이다. 컴포넌트가 렌더링 과정 동안에도 업데이트되는 상태(count 변수)에 의존한다면, 일관성 없는 데이터가 렌더링될 수 있다.중단 이전 컴포넌트 렌더링 - 업데이트된 카운트 값이 DOM에 플러시되거나 커밋 - transition 작업으로 중단 - 중단 이후 컴포넌트 계속 렌더링 - 새로운 카운트 값으로 바뀐 채로 DOM에 업데이트리액트는 최종적으로 일관된 상태를 렌더링할 것이기 때문에 큰 문제는 아니다. 하지만 더 큰 문제는 다음과 같은 경우이다:UserDetails id={user.id} /이 코드는 렌더링 사이에 전역 상태에서 사용자가 삭제된 경우 큰 오류를 발생시킬 것이다. 이것이 tearing이 큰 문제가 될 수 있는 이유다.해결 방법 :useSyncExternalStore티어링tearing 문제를 해결하기 위해 앞서 살펴본 useSyncExternalStore 훅을 활용할 수 있다. 이전 상태 관리 글에서 간략히 언급하고 넘어갔던 부분이지만, 조금 더 깊게 알아보자.const store = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)// useSyncExternalStore의 인자로 사용할 객체const store = { subscribe(callback) { callback(); return () = callback() }, getSnapshot() { return state; },};subscribe:첫 번째 인자로 콜백 함수를 받는 함수. 스토어가 변경될 때 콜백함수를 호출한다.이는 컴포넌트의 리렌더링을 유발한다. subscribe 함수는 구독을 정리하는 clearn up 함수를 반환해야한다.getSnapshot:컴포넌트가 필요로 하는 스토어 내 데이터의 스냅샷을 반환하는 함수.스토어가 변경되지 않았다면, getSnapshot에 대한 반복적인 호출은 동일한 값을 반환한다.스토어가 변경되어 반환된 값이 다르다면(Object.is로 비교했을 때),React는 컴포넌트를 리렌더링.반환 값:store에 저장된 값의 현재 스냅샷.caveat: 리렌더링 동안 다른 subscribe 함수가 전달되면,React는 새롭게 전달된 subscribe 함수를 사용하여 스토어에 다시 구독하게 됩니다. 이를 방지하기 위해 컴포넌트 외부에서 subscribe를 선언할 수 있습니다.(출처:react.dev)useSyncExternal이 반환하는 스냅샷을 통해ExpensiveComponent는count의 전역 변수를 직접 읽는 대신 일관된 상태를 보장할 수 있다. 이는count가 변경될 때마다ExpensiveComponent가동기적으로 리렌더링되도록 하여, 모든ExpensiveComponent인스턴스가 동일한count값을 표시하게 한다.이 예제에서는store.subscribe함수를 통해count상태 변경을 감지하지 않는다. 대신 getSnapshot에서 매 밀리초마다 업데이트 되는count값의 변화가ExpensiveComponent에 바로 반영되어, UI의 일관성을 유지하게 된다.import { useState, useSyncExternalStore, useTransition } from "react";let count = 0;setInterval(() = count++, 1);const store = {// 1. 이 예시에서는 subscribe 콜백은 사용하지 않는다 subscribe(callback) { callback(); }, getSnapshot() { // 매 밀리초마다 업데이트 되는 count의 스냅샷을 얻는다 return count; },};const ExpensiveComponent = () = { // 2. count의 스냅샷을 얻어 모든 컴포넌트가 동일한 값을 갖고 리렌더링된다 const consistentCount = useSyncExternalStore( store.subscribe, store.getSnapshot ); const now = performance.now(); while (performance.now() - now 100) { // 대기 } return Expensive count is {consistentCount}/;};export default function App() { .... 위 예시와 동일 }이제 count가 변경될 때마다 ExpensiveComponent는 새로운 count 값으로 다시 렌더링되며, 모든 ExpensiveComponent 인스턴스에서 동일한 count 값을 볼 수 있다. subscribe로 구현되는 변경 감지 로직 자체는 구현사항에 따라 반드시 필요하게 될 것이다. 여기선 useSyncExternalStore가 주요 기능을 수행하는 메커니즘을 이해하기 위해 생략되었다.useSyncExternalStore를 통한 해결 방법:스토어가 변경될 때 동기적으로리렌더링을 강제함동시concurrent 렌더링 중 일관된 상태 보장티어링 문제와 해결법은 상태 관리 글에서 살펴봤던 내용과 일맥상통한다. 예시의 count처럼 여러 컴포넌트에서 공유된 상태를 일관되게 유지하기 위해 전역 상태를 사용했다. 리액트 내부 훅으로는 Context, useSyncExternalStore를, 외부 라이브러리는 Zustand, Jotai, Valtio를 살폈다.스냅샷(혹은 스토어, 파생 상태, 아톰, 셀렉터)은 상태가 변경될 시에 리렌더링을 유발한다. 이는 useSyncExternalStore의 해결방법과도 일맥상통한다. 라이브러리에 따라 구현 방법은 다르지만, 충분히 대체 가능해 보인다. 이로써 전역 상태를 사용해 동시성 렌더링의 tearing 문제와 각종 라이브러리의 렌더링 최적화 효과를 한번에 해결할 수도 있을 것이다.Summary동기 렌더링의 문제중요하지 않은 작업이 중요한 작업의 업데이트를 차단함동시Concurrent 렌더링리액트는 렌더링 업데이트의 우선순위에 따라 작업할 수 있다렌더링 과정을 더 작게 나누어 점진적으로 처리할 수 있다 (파이버)우선순위가 낮은 작업은 중단할 수 있다작업의 우선순위 할당은 개발자의 직접적인 개입 없이 리액트가 추정한다스케줄러파이버 조정자Fiber reconciler 내에서 사용된다리액트 내부에서 렌더링 스케줄을 관리하기 위해 사용한다우선순위를 관리하기 위해 렌더 레인을 사용한다스케줄러(의 ensureRootIsScheduled 함수)는 루트 스케줄을 마이크로태스크 큐로 관리하여 렌더링이나 이벤트 처리와 같은 다른 작업에 앞서 빠르고 순서대로 처리되도록 보장한다우선순위가 높은 작업은 마이크로 태스크큐로, 이외 낮은 작업은 태스크 큐로 관리된다.렌더 레인우선 순위의 수준을 나타내는 작업 단위렌더 레인(Render lanes)은 스케줄링 시스템에서 효율적인 렌더링과 작업의 우선 순위를 보장한다.새로운 컴포넌트가 렌더 트리에 추가되거나 컴포넌트가 업데이트 될 때레인을 사용하여 업데이트에 우선순위를 할당우선순위는 업데이트의 (사용자 상호작용, 백그라운드 작업같은)종류에 따라 다르게 결정된다.React는 이러한 우선순위에 따른 업데이트를 올바른 레인에 할당하여개발자가 직접 개입하지 않고효율적으로 작동하도록 한다.SyncLane, DefaultLane, TransitionLane 등 다양한 종류의 레인으로 우선순위를 분류한다.렌더레인은 파이버 조정자 내에서 비트마스크로 우선순위를 판별할 때 사용한다.useTransitionReact는 우선 순위를 추정하지만 완벽하지는 않다.useTransition 및 useDeferredValue와 같은 API를 사용하여 기본 우선 순위 할당을 재정의할 수 있다.useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 재정의하여 UI의 업데이트가 반응하지 않는 것을 방지하기 위한 훅이다.새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다.useDefferedValueuseDeferredValue은 특정 UI 업데이트를 나중에 처리할 수 있게 해주는 React 훅이다.특히 무거운 작업을 처리하는 경우에 유용하며, 이를 통해 업데이트 우선 순위를 관리할 수 있다.useDeferredValue는 계산이 무거운 작업에서, 이전 value를 새 value로 업데이트하기 전까지 더 오래 유지하여 부드러운 사용자 경험을 제공한다.useTransition vs useDefferedValueuseTransition코드 내부의 있는 상태 업데이트(setter)를 지연시키기 위해 사용.배열을 반환한다.[isPending, startTransition]startTransition 함수 내부에 다른 상태 업데이트 로직을 넣어 '전환’으로 변경한다.startTransition 내부에 들어간 상태 업데이트는 낮은 우선순위로 처리된다.stratTransition 내부에서 상태 업데이트가 되는 동안, isPending은 true가 된다.useDefferedValue코드 내부의 특정 상태(state)를 더 오래 유지하기 위해 사용.상태가 빈번히 업데이트 되는 경우, useDefferedValue훅에 인자로 상태를 사용하여, 최종 업데이트가 끝난 후의 상태값을 사용할 수 있다.ex: input text에 따라 렌더링되는 리스트 컴포넌트. 사용자가 입력을 끝마쳐 더이상 업데이트가 없는 최종의 상태를 사용한다.동시Concurrent 렌더링의 문제: 티어링 Tearing업데이트가 순서대로 처리되지 않아 UI가 불일치하게 되는 버그.컴포넌트가 렌더링 중에도 업데이트되는 상태에 의존할 때 발생할 수 있음.이로 인해 같은 상태임에도 일관되지 않은 데이터로 렌더링될 수 있음.useSyncExternalStore나 전역 상태 툴을 사용하여 동시성 렌더링으로 중단된 렌더링이 상태가 업데이트되면 리렌더링하게 하여 티어링을 방지할 수 있다.

2024.03.20

ⓒ All Rights Reserved by teklee

n°55

teklog-recent-post

Fluent React 7 - Reconcile

Fluent React의 챕터 4는 조정Reconciliation에 관한 내용이다. 리액트 문서에서는 파이버나 조정에 관한 문서가 따로 없어 답답했는데, 이 장에서 꽤 깊이 있게 다룬다. 이 글을 통해 파이버와 조정이 무엇이고, 어떻게 리액트 내부에서 렌더링 최적화가 이루어지는지, 그리고 왜 필요했는지 살펴볼 수 있다. 궁금했던 내용이니 자세히 알아보자!(italic은 인용입니다)조정 단계의 내부에서 Inside ReconciliationReconciliation 이해하기React.createElement - 가상 DOM 구성 - 조정Reconciliation - 실제 DOM 구성리액트 vDOM = UI 상태의 청사진조정reconciliation을 통해 (브라우저, ios, 안드로이드 같은 네이티브 플랫폼 등) 다양한 환경에서 실제 DOM을 업데이트한다import { useState } from "react";const App = () = { const [count, setCount] = useState(0); return ( main div h1Hello, world!/h1 spanCount: {count}/span button onClick={() = setCount(count + 1)}Increment/button /div /main );};1. 리액트 트리로 변환하기 위해 JSX는 다음과 같이 트랜스파일된다const App = () = { const [count, setCount] = useState(0); return React.createElement( "main", null, React.createElement( "div", null, React.createElement("h1", null, "Hello, world!"), React.createElement("span", null, "Count: ", count), React.createElement( "button", { onClick: () = setCount(count + 1) }, "Increment" ) ) );};2. createElement는 다음과 같은 리액트 엘리먼트 트리를 반환한다{ type: "main", props: { children: { type: "div", props: { children: [ { type: "h1", props: { children: "Hello, world!", }, }, { type: "span", props: { children: ["Count: ", count], }, }, { type: "button", props: { onClick: () = setCount(count + 1), children: "Increment", }, }, ], }, }, },}간단한 카운터 앱을 예시로, 리액트 내부에서 어떤 일이 일어나는지 이해해보자.JSX는 React.createElement 함수로 변환된다createElement 함수는 리액트 엘리먼트 트리를 반환한다 = vDOM과 같다이 때 최소한의 명령을 통해 브라우저에 "커밋"된다가능한 DOM을 적게 건드려 렌더링하기 위해배칭Batching이 이루어진다Batching리액트는 조정 과정 중 실제 DOM에 대한 업데이트를 배치 처리한다…React는조정reconcile 과정 중에 실제 DOM에 대한 업데이트를 배치 처리하여, 여러 개의 vDOM 업데이트를 단일 DOM 업데이트로 결합합니다.이는 실제 DOM이 업데이트되어야 하는 횟수를 줄이므로 웹 애플리케이션의 성능 향상에 도움이 됩니다.조정 단계는 리액트의 핵심이라고 할 수 있는가상 DOM의 목적을 실현해주는 과정으로 이해할 수 있다! 실제 DOM 변경을 여러번 일으키는 것이 아닌, 여러 상태 변화(가 일으키는 DOM의 변화, UI 업데이트)를 묶어 단일한 DOM 업데이트로 묶는다. 성능 향상에 도움이 되는 것도 자연스럽게 이해된다.여러번 상태를 업데이트 하는 예시function Example() { const [count, setCount] = useState(0); const handleClick = () = { setCount((prevCount) = prevCount + 1); setCount((prevCount) = prevCount + 1); setCount((prevCount) = prevCount + 1); }; return ( div pCount: {count}/p button onClick={handleClick}Increment/button /div );}이 예제에서handleClick함수는 빠르게 연속적으로 세 번setCount를 호출한다. React의 배치 처리를 통해count + 1을 각각 세 번 업데이트하는 대신count + 3으로DOM을 한 번만 업데이트할 것이다.즉 Increment 버튼을 누를 때 1, 2, 3이 순차적이 보이는 것이 아닌 3이 바로 보이는 것이다.React는 DOM에 가장 효율적인 배치 업데이트를 계산한다. 이를 위해 vDOM 트리의 포크인 새로운 vDOM 트리를 생성하고, 여기서count를 3일 것이다. 이 트리는 브라우저에 현재 있는 것과 조정reconcile하여 0을 3으로 바꾼다.조정 과정 중 배치에 대해 간략히 살펴보았으니, 구체적으로 조정 내부에선 어떤 일이 일어나는지 살펴보자. 이해를 위해, 이전 방식인 Stack Reconciler와 Fiber reconciler를 순차적으로 살핀다.이전 방식 (스택 조정자 Stack reconciler)React는 16버전 이전까지렌더링을 위해스택 데이터 구조를 사용했다. 스택Stack은 후입선출(LIFO, Last In, First Out) 원칙을 따르는선형 데이터 구조이다. 이는스택에 마지막으로 추가된 요소가 가장 먼저 제거된다. 자바스크립트의 실행 컨텍스트가 대표적이다. (이전 글 참조)스택의 기본적인 방법으로 push와 pop을 통해 아이템을 스택 상단에 추가하고 제거한다. 자바스크립트 배열 메서드를 떠올리면 쉽다.React의 원래 reconciler는 오래된 가상 트리와 새로운 가상 트리를 비교하고 DOM을 업데이트하는 데 사용되는 스택 기반 알고리즘이었습니다.stack reconciler는 간단한 경우에는 잘 작동했지만, 애플리케이션이 크기와 복잡성에서 성장함에 따라 여러 도전적인 상황을 보여줬습니다도전적인 상황의 예시중요하지 않지만 연산적으로 비용이 많이 드는 컴포넌트(ExpensiveComponent)가 CPU를 소비하며 렌더링사용자가 입력 요소에 타이핑입력이 유효하면 버튼이 활성화Form 컴포넌트가 상태를 가지므로, 리렌더링import React, { useReducer } from "react";const initialState = { text: "", isValid: false };function Form() { const [state, dispatch] = useReducer(reducer, initialState); const handleChange = (e) = { dispatch({ type: "handleInput", payload: e.target.value }); }; return ( div ExpensiveComponent / input value={state.text} onChange={handleChange} / Button disabled={!state.isValid}Submit/Button /div );}function reducer(state, action) { switch (action.type) { case "handleInput": return { text: action.payload, isValid: action.payload.length 0, }; default: throw new Error(); }}스택 조정자reconciler는 작업을 일시 중지하거나 연기할 수 없이 스택의 상단부터 순차적으로 업데이트를 렌더링할 것이다. 여기서 다음과 같은 문제가 생긴다.연산적으로 비용이 많이 드는 컴포넌트가 텍스트 입력 렌더링을 차단사용자 입력이 화면에 지연되어 렌더링텍스트 필드가 반응하지 않는 것처럼 보여 사용성 저하직관적인 해결법은 렌더링의 순서를 중요도에 따라 바꾸는 것이다. 중요하지 않지만 비용이 많이 드는 컴포넌트 렌더링은 나중에, 사용자 입력을 더 먼저 우선순위를 두고 먼저 렌더링한다. 즉 스택의 순서대로가 아닌 우선순위 기반으로 가상 DOM을 업데이트하여 해결할 수 있을 것이다. 그러기 위해서 리액트는 사용자 입력 같은 특정 유형의 렌더링 작업을 다른 렌더링보다 우선순위를 매길 수 있어야한다.이같은 문제 인식과 해결법을 통해 기존 스택 조정자reconciler의 한계와 렌더링 우선순위 부여의 필요성을 이해할 수 있다.스택 조정자의 한계우선순위에 따라 가상 DOM을 업데이트 하지 않음업데이트를 중단하거나 취소할 수 없음느린 업데이트, 불필요한 렌더링이 성능과 사용성 저하로 이어짐스택 조정자reconciler는 업데이트를우선순위에 따라 처리하지 않았다. 렌더링에 우선순위가 없기 때문에, 이는중요하지 않은 업데이트가 더 중요한 업데이트를 차단할 수 있었다.스택 조정자reconciler의 또 다른 한계는업데이트를 중단하거나 취소할 수 없다는 것이다. 이는 스택 reconciler가 업데이트우선순위를 인식하더라도, 고 우선순위 업데이트가 예약되었을 때 낮은 우선순위의 작업을 취소하거나 중단할 수 없었다. 이는 곧 불필요한 업데이트가 이루어진다는 뜻이며, 가상 트리와 DOM에서불필요한 작업이 수행되어 애플리케이션의 성능에 부정적인 영향을 줄 수 있다.업데이트 우선 순위의 예시들텍스트 입력은 마우스 호버 시에만 렌더링되는 툴팁 보다 높은 우선 순위를 갖는다버튼 클릭은 notification 알림의 표시 보다 높은 우선순위를 갖는다.input 입력이나 버튼 클릭은 즉각적인 반응을 요구하는 의도적인 행동이기 때문에 우선순위가 높다.결론이전의 조정 방식은 스택 순서로 일관되게 업데이트를 처리하여 성능 저하, 사용성 저하로 이어졌다. 효율적인 순서로 가상 DOM을 업데이트할 필요성이 생겼고, 파이버 조정자Fiber reconciler가 개발되었다.파이버 조정자 Fiber Reconciler파이버 Fiber란?조정자 reconciler의 작업 단위파이버 조정자를 통해 업데이트 우선순위를 결정함React 엘리먼트로부터 생성됨React 엘리먼트와 달리 상태를 보존하고 장기간 존재함React 엘리먼트는 일시적이고 상태가 없음리액트는 vDOM 트리처럼 조정 단계에서 파이버 트리를 사용함파이버 데이터 구조파이버의 데이터 구조를 자세히 살펴보자기본적으로, 파이버 데이터 구조는 React 애플리케이션에서컴포넌트 인스턴스와 그 상태의 표현입니다. 논의된 바와 같이, 파이버 데이터 구조는 가변 인스턴스로 설계되어 조정reconcile 과정 중에 필요에 따라 업데이트되고 재배열될 수 있습니다.파이버는 객체는 이렇게 생겼다{ "tag": 3, // 3 = ClassComponent "type": "App", "key": null, "ref": null, "props": { "name": "Tejas", "age": 30 }, "stateNode": "AppInstance", "return": "FiberParent", "child": "FiberChild", "sibling": "FiberSibling", "index": 0 // ...}이는 App이라고 불리는 클래스형 컴포넌트의 파이버 노드다. 파이버 노드는 컴포넌트의 태그, 타입, props, stateNode, 그리고 컴포넌트 트리 내의 위치 등의 정보를 포함한다.tag:tag는 각 컴포넌트 유형을 나타낸다. 클래스 컴포넌트, 함수 컴포넌트, Suspense 및 에러 경계, 프래그먼트 등은 자신만의 숫자 ID를 Fiber로 갖는다. 예시의 3은 클래스 컴포넌트이다.type:이 Fiber에 대응하는 함수 또는 클래스 컴포넌트를 가리킨다.예시에서 type은App이라는 컴포넌트다.props:({name: "Tejas", age: 30})은 컴포넌트에 대한 props나 함수의 인자를 뜻한다stateNode:이 Fiber가 대응하는 App 컴포넌트의 인스턴스.컴포넌트 트리에서의 위치(return,child,sibling,index):return,child,sibling,index각각은 Fiber 조정자가 “트리를 걷는” 방법을 제공하며, 부모, 자식, 형제 및 Fiber의 인덱스를 식별한다.파이버 조정자reconciler는현재 파이버 트리와 다음 파이버 트리를 비교하여 업데이트되거나 추가되거나 제거되어야 할 노드를 파악하는 과정을 포함한다. 즉 앞서 살핀우선순위를 기반으로 업데이트 순서를 조정하기 위한 것.파이버 조정 과정 개요조정reconcile 과정 동안, 파이버 조정자reconciler는 가상 DOM의 각 React 엘리먼트의 파이버 노드를 생성한다.createFiberFromTypeAndProps라는 함수가 이 작업을 수행한다. 이 함수는 리액트 엘리먼트로부터 파생된 파이버를 반환한다.파이버 노드가 생성되면, 파이버 조정자는 UI를 업데이트하기 위한 작업 루프를 사용한다.작업 루프는루트의 파이버 노드에서 시작하여컴포넌트 트리를 아래로 진행하면서,업데이트가 필요한 각 파이버 노드를 "dirty"로 표시한다.트리의 최하단에 도달하면, 브라우저로부터 분리된새로운 DOM 트리를 메모리 내에서 거슬러 올라가며 생성한다마지막으로 화면에 커밋(플러시)한다이 과정은 두 함수로 실현된다:beginWork: “업데이트가 필요한” 컴포넌트를 표시하며 아래로 진행. 3번 과정completeWork: 브라우저로부터 분리된 실제 DOM 요소들의 트리를 구성하며 위로 거슬러 올라감. 4번 과정이러한 렌더링(조정) 과정은 사용자가 보기 이전(오프스크린)에 실행되어 언제든 중단되고 버려질 수 있다.더블 버퍼링파이버 아키텍처는 게임 세계에서 "더블 버퍼링"이라는 개념에서 영감을 받았으며, 여기서 다음 화면은 오프스크린에서 준비되고 현재 화면으로 "플러시"됩니다.더블 버퍼링은 컴퓨터 그래픽스와 비디오 처리에서 깜빡임을 줄이고 인지된 성능을 개선하기 위해 사용되는 기술이다. 이 기술은 이미지나 프레임을 저장하기 위한 두 개의 버퍼(또는 메모리 공간)를 생성하고, 최종 이미지나 비디오를 표시하기 위해 정기적인 간격으로 이들 사이를 전환한다.실제로 더블 버퍼링이 작동하는 방식:첫 번째 버퍼는 초기 이미지 또는 프레임으로 채워짐첫 번째 버퍼가 화면에 표시되는 동안, 두 번째 버퍼는 새로운 데이터 또는 이미지로 업데이트두 번째 버퍼가 준비되면, 첫 번째 버퍼와 교체되어 화면에 표시이 과정은 계속되며, 첫 번째와 두 번째 버퍼는 최종 이미지나 비디오를 표시하기 위해 정기적인 간격으로 교체됨이를 통해 최종 이미지나 비디오가 중단이나 지연 없이 표시되므로 깜박임이나 기타 시각적인 잡음을 줄일 수 있다. (화면 깜빡임의 줄임은 서버사이드 렌더링을 떠올리게 한다)파이버 조정은 더블 버퍼링과 유사한 방식으로 작동한다.업데이트가 발생하면 현재 Fiber 트리가 포크되어 주어진 사용자 인터페이스의 새 상태를 반영하도록 업데이트. 이 과정을렌더링이라고 한다.그런 다음, 대체할 DOM 트리가 준비되고, 사용자가 보기를 기대하는 상태를 정확하게 반영하면, 더블 버퍼링에서 비디오 버퍼가 교체되는 방식과 유사하게 현재 트리와 교체됩니다.이를 조정reconciliation의 커밋(단계)라고 한다커밋 단계 이전에 파이버를 통해 미리 생성된 DOM 트리는 더블 버퍼링의 두번째 버퍼에 해당할 수 있다.이점:실제 DOM에 불필요한 업데이트를 하지 않아 성능을 향상시키고 화면 깜빡임을 줄일 수 있다.UI의 새로운 상태를 오프스크린에서 계산하고,더 새롭고 높은 우선순위의 업데이트가 필요한 경우 이를 버릴 수 있다.조정reconcile이 오프스크린에서 발생하기 때문에, 사용자가 현재 보고 있는 화면을 손상하지 않고 중단하고 재개할 수 있다앞서 파이버 조정 과정에서 두 개의 트리가 생성되는데, (더블 버퍼링의 첫번째 버퍼에 해당하는) 현재 파이버 트리와 (두번째 버퍼인) work-in-progress 파이버 트리이다. 새로운 내용은 아니고, 앞선 내용을 이해하면 자연스럽다.파이버가 엘리먼트와 달리 상태를 보존하고 장기간 유지되기에 현재 파이버를 갖고, 작업 루프의 completeWork에서 트리를 거슬러가며 새로운 트리를 생성한다고 하였다. 전자를 첫번째 버퍼, 후자를 두번째 버퍼로 이해하면 되겠다.여기까지 파이버의 근간이 되는 개념, 기본 요소들, 작동 과정의 기초 개념을 살폈다. 이제 파이버 조정에서 어떤 일들이 일어나는지 깊이 있게 살펴보자.파이버 조정 Fiber Reconciliation파이버 조정의 두 단계렌더링 단계커밋 단계바로리액트 공식문서의 설명이 떠오른다. 앞서파이버 조정자는 UI를 업데이트하기 위한 작업 루프를 사용한다고 하였다. 이 두 단계 접근법은 React가 DOM에 커밋하고 사용자에게새로운 상태를 보여주기 전에 언제든지 폐기할 수 있는 렌더링 작업을 할 수 있게 한다 = 렌더링을 중단 가능하게 만든다. 앞선 스택 조정자의 문제를 해결하는 구체적인 방식이다.조금 더 자세히 설명하자면, 렌더링을 중단 가능하게 느끼게 하는 것은 React 스케줄러가 메인 스레드로 실행을 다시 양보하는 휴리스틱을 사용하는데, 이는 120fps 장치에서조차 단일 프레임보다 작은 5ms 마다 발생합니다.렌더 단계 Render phase렌더링 단계는 현재 트리에서상태 변경 이벤트가 발생할 때 시작beginWorkReact는 대체 트리에서 화면 밖에서 변경 작업을 수행하여 각 Fiber를 재귀적으로 거치면서 업데이트가 보류 중임을 신호하는 플래그를 설정한다.beginWork 함수에서 Fiber 노드에 업데이트 여부를 결정하는 플래그를 설정재귀적으로 다음 Fiber 노드로 이동하여 같은 작업을 수행하고, 트리 바닥에 도달할 때까지 계속함완료 시, Fiber 노드에 대해completeWork를 호출하기 시작하고 다시 위로 실행앞서루트의 파이버 노드에서 시작하여 컴포넌트 트리를 아래로 진행하면서, 업데이트가 필요한 각 파이버 노드를 "dirty"로 표시한다.-에 해당beginWork의 시그니쳐는 다음과 같다:function beginWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null;이 함수의 매개변수를 알아보자.current현재 트리에 있는 Fiber 노드에 대한 참조업데이트 여부를 결정하는 플래그 비교의 원대상업데이트되고 있는 두번째 매개변수 workInProgress 노드와 같은 엘리먼트에서 생성된 파이버이전 버전과 새 버전의 트리 사이에 무엇이 변경되었는지, 그리고 무엇을 업데이트해야 하는지를 결정하는 데 사용됨절대 변형되지 않으며 비교를 위해서만 사용workInProgress작업 진행 중인 트리에서 업데이트되는 Fiber 노드beginWork함수에 의해 업데이트가 필요한 플래그"dirty"으로 표시되고 반환된다renderLanesrenderLanes는 React의 Fiber 조정자에서 이전의renderExpirationTime을 대체하는 새로운 개념React가 업데이트를 더 잘 우선순위 지정하고 업데이트 과정을 더 효율적으로 만들게 하기 위해 필요renderLanes는 업데이트가 처리되고 있는 "레인"을 나타내는 비트마스크(bitmask)renderLanes은 업데이트를우선순위와 기타 요인을 기반으로 분류하는 방법React 컴포넌트에 변경이 이루어지면,그 우선순위와 기타 특성을 기반으로 레인이 할당됨우선순위가 높을수록, 할당된 renderLanes도 높음 (높은 숫자를 갖음)renderLanes 깊게 살펴보기`renderLanes` 값은 `beginWork` 함수에 전달되어 업데이트가 올바른 순서로 처리되도록 합니다. 더 높은 우선순위 레인에 할당된 업데이트는 더 낮은 우선순위 레인에 할당된 업데이트보다 먼저 처리됩니다. 이는 사용자 상호작용이나 접근성에 영향을 미치는 고우선순위 업데이트가 가능한 한 빨리 처리되도록 합니다.업데이트를 우선순위 지정할 뿐만 아니라, `renderLanes`는 React가 동시성을 더 잘 관리할 수 있게 도와줍니다. React는 "타임 슬라이싱"이라고 불리는 기법을 사용하여 장기 실행 업데이트를 더 작고 관리하기 쉬운 조각으로 나눕니다. `renderLanes`는 이 과정에서 핵심 역할을 합니다. React가 어떤 업데이트를 우선 처리해야 하는지, 어떤 업데이트를 나중으로 미룰 수 있는지 결정하는 데 도움을 줍니다.렌더링 단계가 완료된 후, `getLanesToRetrySynchronouslyOnError` 함수가 호출되어 렌더링 단계 동안 생성된 지연된 업데이트가 있는지 결정합니다. 지연된 업데이트가 있다면, `updateComponent` 함수가 `beginWork`와 `getNextLanes`를 사용하여 업데이트를 처리하고 그들의 레인을 기반으로 우선순위를 지정하는 새로운 작업 루프를 시작합니다.completeWorkcompleteWork함수는 새로운 실제 DOM 트리를 구성한다. 이 트리는 브라우저 밖에서 DOM과 분리되어 구성된다. 브라우저인 경우, 이는document.createElement또는newElement.appendChild와 같은 작업을 의미한다.하지만 이 DOM 트리는 아직 브라우저 내 document에 연결되지 않았다는 점이 중요하다.React는 화면 밖에서 UI의 다음 버전을 생성한다. (더블 버퍼링의 두번째 버퍼 개념을 떠올려보자.)DOM 트리를 미리 화면 밖에서 수행하는 것은 렌더링을 중단 가능하게 만든다.이것이 Fiber 조정자의 중요 포인트이다.React가 계산하고 있는 다음 상태는 아직 화면에 그려지지 않았으므로, 더 높은 우선순위의 업데이트가 예정되어 있는 경우 폐기될 수 있다.completeWork의 시그니쳐는 다음과 같다:function completeWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null;매개변수는beginWork와 동일하다.completeWork함수는beginWork함수와 밀접한 관련이 있다.beginWork: 파이버에 “업데이트 해야 할” 상태에 대한 플래그를 설정하는 책임이 있다completeWork: 호스트 환경에 커밋될 새로운 DOM 트리를 구성하는 책임이 있다.completeWork가 맨 위에 도달하고 새로운 DOM 트리를 구성했을 때, "렌더링 단계가 완료되었다"고 한다.그후, React는 커밋 단계로 이동한다.커밋 단계 Commit phase커밋 단계에서 렌더링 단계 동안 가상 DOM에 이루어진 변경 사항을 실제 DOM에 업데이트한다커밋 단계 동안, 새로운 가상 DOM 트리는 호스트 환경에 커밋되고, work-in-progress 트리는 현재 트리로 교체된다. 이 단계에서 모든 (부수) 효과도 실행된다. 커밋 단계는 두 부분으로 나뉜다.변형 단계레이아웃 단계변형 단계 Mutation phase변형단계는 가상 DOM에 이루어진 변경 사항을 실제 DOM에 업데이트이 단계에서 React는 업데이트가 필요한 것을 식별하고commitMutationEffects라는 특별한 함수를 호출한다.이 함수는 렌더링 단계 동안 vDOM 트리의 Fiber 노드에 이루어진 업데이트를 실제 DOM에 적용function commitMutationEffects(Fiber) { switch (Fiber.tag) { case HostComponent: { // DOM 노드를 새로운 props나 children으로 업데이트 break; } case HostText: { // DOM 노드를의 text content를 업데이트 break; } case ClassComponent: { // componentDidMount and componentDidUpdate 같은 컴포넌트 생명주기 메서드를 호출 break; } // ... 다른 종류의 노드를 위한 분기 처리 로직이 이어진다 }}레이아웃 단계 Layout phaseDOM의 업데이트된 노드의 새 레이아웃을 계산한다이 단계에서 React는commitLayoutEffects라는 특별한 함수를 호출이 함수는 DOM의 업데이트된 노드의 새 레이아웃을 계산한다레이아웃 단계가 완료되면, React는 렌더링 단계 동안 가상 DOM에 이루어진 변경 사항을 반영하여 실제 DOM을 성공적으로 업데이트할 수 있다.두 단계로 나누는 이유커밋 단계를 두 부분(변형과 레이아웃)으로 나눔으로써, React는 효율적인 방식으로 DOM에 업데이트를 적용할 수 있습니다. 조정자의 다른 핵심 함수들과 협력하여 작업함으로써, 커밋 단계는 React 애플리케이션이 더 복잡해지고 더 많은 데이터를 처리하면서도 빠르고 반응적이며 신뢰할 수 있게 유지될 수 있도록 돕습니다.EffectsReact의 조정 과정의 커밋 단계 동안, 커밋 단계의 (부수) 효과가 발생한다. 이 효과들은 유형에 따라 특정 순서로수행된다. 커밋 단계 동안 발생할 수 있는 여러 유형은 다음과 같다:배치 Placement 효과이 효과는 새로운 컴포넌트가 DOM에 추가될 때 발생한다. 예를 들어, form에 새 버튼이 추가되면, 그 버튼을 DOM에 추가하기 위한 배치 효과가 발생한다.업데이트 효과컴포넌트가 새로운 props나 상태로 업데이트될 때 발생한다. 예를 들어, 버튼의 텍스트가 변경되면, DOM 내의 텍스트를 업데이트하기 위한 업데이트 효과가 발생삭제 효과컴포넌트가 DOM에서 제거될 때 발생. 예를 들어, 폼에서 버튼이 제거되면, 그 버튼을 DOM에서 제거하기 위한 삭제 효과가 발생한다.레이아웃 효과이 효과는 브라우저가 페이지를그리기 전에 발생하며, 페이지의 레이아웃을 업데이트하는 데 사용된다. 레이아웃 효과는 함수 컴포넌트에서useLayoutEffect훅을 사용하여 관리되고, 클래스 컴포넌트에서는componentDidUpdate생명주기 메서드를 사용하여 관리됩니다.수동 효과 useEffect커밋 단계의 효과와 달리, 수동 효과는 브라우저가 페인트한 후에 실행되도록 예약된 사용자 정의 효과이다. 수동 효과는페이지의 초기 렌더링에 중요하지 않은 작업을 수행하는 데 유용하다. 예를 들어, API에서 데이터를 가져오거나 분석 추적을 수행하는 등의 작업입니다.수동 효과는 렌더링 단계 동안 수행되지 않기 때문에,컴포넌트를 업데이트하는 시간에 영향을 주지 않는다.화면에 모든 것을 표시하기React는 현재 트리, work-in-progress 트리 중 하나를 가리키는FiberRootNode를 두 트리 상단에 유지한다.FiberRootNode는 조정 과정의 커밋 단계를 관리하는 핵심 데이터 구조다.렌더링 과정: 가상 DOM에 업데이트가 이루어지면, React는 work-in-progress 트리를 업데이트하면서 현재 트리는 변경하지 않는다. 이를 통해 React는 가상 DOM의 렌더링과 업데이트를 계속하면서도 애플리케이션의 현재 상태를 보존할 수 있다.렌더링 과정 완료 후: React는commitRoot라고 하는 함수를 호출한다. 이는 work-in-progress 트리에 이루어진 변경 사항을 실제 DOM에 커밋하는 책임이 있다.commitRoot는FiberRootNode의 포인터를 현재 트리에서 작업 진행 중인 트리로 전환하여, 작업 진행 중인 트리를 새로운 현재 트리로 만든다.업데이트 이후: 이때부터, 모든 업데이트는새로운 현재 트리를 기반으로 한다.이 과정은애플리케이션이 일관된 상태를 유지하고, 업데이트가 올바르고 효율적으로 적용되도록 보장한다.이것이 조정Reconcile이 작동하는 방법이다.Summary마지막으로 살펴본 내용들을 요약해보자. 조정Reconciliation이란?vDOM의 렌더링을 최적화하고 실제 DOM을 업데이트하는 과정이 과정에서 배칭Batching을 통해 여러 상태의 업데이트를 단일한 상태로 바꾸어 실제 DOM을 업데이트한다.조정자리액트 내부에서 일어나는 조정을스택 조정자(Stack Reconciler)리액트 16전까지 사용되었다.렌더링 순서의 중요도와 우선순위에 상관없이 스택 순서대로 업데이트가 일어났다스택 구조를 유지하기에 비용이 높지만 중요도가 낮은 업데이트를 중단하거나 취소할 수 없었다결과적으로 느린 업데이트, 불필요한 렌더링이 성능과 사용성 저하로 이어짐파이버파이버 조정자가 사용하는 조정의 단위.파이버 (노드)를 통해현재 파이버 트리와 다음 파이버 트리를 비교하여 업데이트되거나 추가되거나 제거를 파악한다리액트 엘리먼트는 파이버를 생성한다.컴포넌트 인스턴스와 그 상태의 표현을 나타낸다.속성으로 컴포넌트의 종류(tag 클래스, 함수, 서스팬스, 에러바운더리 등), 대응하는 컴포넌트(type), 컴포넌트의 위치, props 등을 포함한다파이버 조정자의 과정 개요파이버 조정자는 가상 DOM의 각 React 엘리먼트의 파이버 노드를 생성한다.createFiberFromTypeAndProps라는 함수가 이 작업을 수행한다. 이 함수는 리액트 엘리먼트로부터 파생된 파이버를 반환한다.파이버 노드가 생성되면, 파이버 조정자는 UI를 업데이트하기 위한 작업 루프workLoop를 사용한다. (렌더링 단계)작업 루프는루트의 파이버 노드에서 시작하여컴포넌트 트리를 아래로 진행하면서,업데이트가 필요한 각 파이버 노드를 "dirty"로 표시한다. (beginWork)트리의 최하단에 도달하면, 브라우저로부터 분리된새로운 DOM 트리를 메모리 내에서 거슬러 올라가며 생성한다(completeWork)마지막으로 화면에 커밋(플러시)한다 (커밋 단계)파이버 조정자(Fiber Roconciler)를 통한 렌더링 과정오늘날 리액트는 조정 단계에서 파이버 조정자를 사용한다.렌더 단계와 커밋 단계로 나뉜다렌더 단계: beginWork, completeWork 두 단계로 다시 나뉜다beginWork: 트리 아래 방향으로 나아가며 현재 파이버와 작업 중 (work-in-progress) 파이버 노드를 비교하고, 업데이트가 필요한 파이버에 플래그를 설정한다.completeWork: 트리 최하단에서 상위로 올라가며 작업 중파이버 트리를 업데이트하고 실제 DOM을 구성. 커밋 단계까지 전까지 생성된 DOM은 화면에 실제로 나타나지 않는다. 이때 생성된 DOM은 폐기될 수 있다.커밋 단계: 실제로 화면에 DOM을 페인트하는 과정. 변형, 레이아웃 단계로 구분된다.변형단계: 렌더링 단계에서 업데이트된 파이버 트리를 실제 DOM에 업데이트 한다레이아웃 단계: DOM의 업데이트된 노드의 새 레이아웃을 계산한다.commitLayoutEffects라는 함수를 통해 DOM의 업데이트된 노드의 새 레이아웃을 계산한다. 레이아웃 단계가 완료되면, React는 렌더링 단계 동안 가상 DOM에 이루어진 변경 사항을 반영하여 실제 DOM을 성공적으로 업데이트할 수 있다.커밋 단계 동안 DOM이 추가되면서 일어날 수 있는 부수적인 효과들이 순차적으로 실행된다useEffect와 같은 수동적인 효과는 DOM의 페인팅이 끝난 이후 실행된다

2024.03.18

ⓒ All Rights Reserved by teklee

n°54

teklog-recent-post

HTTP 완벽 가이드 - 3 HTTP 메시지

지난 글에서 HTTP는 메시지를 기반으로 통신한다고 하였다. 메시지는 오직 요청/응답 두가지만 있으며, 머리-가슴-배 시작줄-헤더-본문으로 구성되는 것또한 살펴봤다. 요청, 응답은 시작줄에 각기 다른 내용을 갖는 다는 것 또한 확인했다. 이제 메시지의 구성 요소들과 각 요소들의 옵션들을 상세히 정리해볼 차례이다. 한번 쯤은 정리하고 넘어가도 좋을 내용이라 궁금한 건 더 찾아보면서 작성했다.메시지의 흐름HTTP 메시지는 HTTP 애플리케이션 간에 주고받은 데이터의 블록들이다클라이언트, 서버, 프록시 사이를 흐른다인바운드, 아웃바운드, 업스트림, 다운스트림은 메시지의 방향을 표현한다.인바운드: 원서버 방향으로 향하는 메시지 (서버 방향)아웃바운드: 메시지가 사용자로 돌아오는 메시지 (사용자 에이전트 방향)모든 HTTP 메시지는 다운스트림으로 흐른다.요청과 응답의 벌송자가 업스트림이고, 다운스트림은 목적지이다.메시지의 각 부분HTTP 메시지는 데이터의 구조화된 블록. 앞서 살펴본 대로, 시작줄, 헤더, 본문으로 구성된다.시작줄: 이것이 어떤 메시지인지 서술헤더: 속성본문: 데이터. 텍스트나 이진 데이터를 표현할 수 있다. 혹은 아예 생략될 수도 있다.시작줄과 헤더는 아스키 문자 줄단위로 분리됨. 이 줄바꿈 문자를 CRLF라고 부른다.메시지 문법모든 HTTP 메시지는 요청 / 응답 메시지이다. 요청 메시지는 서버에 요청을, 응답은 요청의 결과를 클라이언트로 돌려준다.요청 메시지의 형식:메서드요청 URLHTTP 버전 - 시작줄 = 요청줄헤더엔터티 본문응답 메시지의 형식:HTTP 버전상태 코드사유 구절 - 시작줄 = 응답줄헤더엔터티 본문요청줄, 응답줄 : 시작줄의 요소들은 띄어쓰기로 구분된다.메서드: 서버에 어떤 동작이 일어나야 하는지 설명해준다.요청 URL: 클라이언트가 서버와 직접 대화하고 있고 경로 구성 요소가 리소스를 가리키는 절대 경로면 문제없다. 서버는 URL에서 생략된 호스트/포트가 자신을 가리키는 것으로 간주한다.버전 : 이 메시지에서 사용 중인 HTTP의 버전이다. HTTP/메이저.마이너 (메이저, 마이너 모두 정수다) 요청과 응답 양쪽 모두에 기술된다. 자신이 따르는 프로토콜의 번호를 상대에 알리기 위한 수단이다. HTTP 버전 1.1 애플리케이션과 대화하는 1.2 애플리케이션은 1.2 버전의 새로운 기능을 사용할 수 없다.상태코드: 요청 중 무엇이 일어났는지 설명하는 세 자리 숫자. 200~299까지 성공을 나타낸다.사유 구절: 상태코드의 의미를 사람이 이해할 수 있게 설명해주는 짧은 문구. 상태 코드 이후부터 줄바꿈 문자열까지 사유 구절이다. 오로지 사람에게만 읽힐 목적으로 작성되어, HTTP 1.1 200 NOT OK나 HTTP 1.1 200 OK나 동등하게 성공 상태 코드를 갖고 있어 성공을 의미하는 것으로 처리한다. HTTP 명세는 사유 구절에 대한 엄격한 규칙을 적용하지 않는다.헤더이름, 콜론, 선택적인 공백, 값, CRLF가 순서대로 나타나는 0개 이상의 헤더. 헤더 목록은 빈줄(CRLF)로 끝나 본문과 구분한다. HTTP 1.1같은 특정 버전에서는 특정한 헤더를 포함하여야만 유효한 것으로 간주한다. 요청과 응답 메시지에 추가 정보를 더한다. 기본적으로 이름/값 쌍의 목록이다.Content-length:19분류일반 헤더: 요청과 응답 양쪽에 모두 나타낼 수 있다.요청 헤더: 요청에 대한 부가 정보응답 헤더: 응답에 대한 부가 정보Entity 헤더: 본문 크기, 콘텐츠 혹은 리소스 그자체를 서술확장 헤더: 명세에 정의되지 않은 새로운 해더각 Http 헤더는 간단한 문법을 지닌다. 이름, 쉼표, 공백(없어도 됨), 필드 값, CRLF가 순서대로 나온다. 긴 헤더는 여러 줄로 쪼개서 보낼 수 있다.엔터티 본문:임의의 데이터 블록을 포함한다. 모든 메시지가 엔터티 본문을 갖는 것이 아니어서 빈줄(CRLF)로 끝날 때도 자주 있다. 헤더나 본문이 없더라도, HTTP 헤더의 집합은 항상 CRLF로 끝난다. HTTP의 화물이라고 할 수 있다. 이미지, 비디오, HTML 문서, 소프트웨어 애플리케이션, 메일 등 다양한 데이터를 실어 나를 수 있다.이어서 요청 메시지의 메서드, 응답 메시지의 상태코드, 헤더에 대해서 자세히 알아보자.메서드서버는 모든 메서드에 구현하지 않을 수 있다. 메서드는 대부분 제한적으로 사용된다. DELETE, PUT 요청은 아무나 저장된 리소스를 삭제하거나 수정하지 못하게 제한 할 수 있다. 이러한 제한은 일반적으로 서버 설정에 의해 정해지며, 사이트마다 서버마다 다를 수 있다.안전한 메서드 = 서버에 '작용’이 일어나지 않는 요청 메서드HTTP는 안전한 메서드라 불리는 메서드의 집합을 정의한다. GET, HEAD 메서드는 HTTP 요청의 결과로 서버에 아무 작용도 없음을 의미한다. 작용이 없다는 뜻은 HTTP 요청의 결과로 서버에 일어나는 일은 아무것도 없다는 일이다. 즉 서버 상의 데이터에 변화가 없다는 말과 같다. 예를 들어 POST 메서드로 데이터를 저장하는 요청을 보낸다면, 서버에는 이에 해당하는 작용이 일어날 것이다. 안전한 메서드가 서버에 작용을 유발하지 않는 보장은 없다. 안전한 메서드의 목적은, 서버에 어떤 영향을 줄 수 있는 안전하지 않은 메서드가 사용될 때 사용자들에게 그 사실을 아려주는 애플리케이션을 만들 수 있도록 하는 것이다.GET서버에 리소스를 달라고 요청할 때 사용get 요청 body에 무언가를 담아 요청을 보낼 수는 있지만, 표준 HTTP 설계에선 벗어난 사용법이다.특정한 데이터를 얻기 위해 앞장에서 살펴본 쿼리(질의)를 사용하는 것이 일반적이다.get 요청을 통해 얻은 내용은 캐시를 할 수 있다. 같은 요청에 대한 서버의 응답을 저장하고 사용할 수 있다.HEAD정확히 GET처럼 행동하지만, 서버는 응답으로 헤더만을 돌려준다. 엔터티 본문(body)는 반환되지 않는다.이를 통해 리소스를 가져오지 않고 그에 대한 메타데이터(컨텐트 타입, 크기 등)를 얻을 수 있다.응답 사태 코드를 통해 개체가 존재하는지 확인할 수 있다.헤더를 통해 리소스가 변경 여부를 확인할 수 있다.PUT서버에 리소스를 생성하거나 업데이트할 때 사용PUT 요청은 지정된 URI에 리소스를 생성하거나, 이미 존재하는 리소스를 대체하기 위해 사용된다.요청의 본문에 데이터를 담아 전송하며, 서버는 이 데이터를 사용해 지정된 URI의 리소스를 생성하거나 업데이트한다.POST와 차이점: PUT 요청은 멱등idempotent하다. 즉, 같은 데이터로 같은 요청을 여러 번 수행해도 최종 서버 상태에 동일한 영향을 미친다.그렇게 때문에 주로 이미 존재하는 리소스를 대체할 때 사용한다.POST서버에 리소스를 생성하라는 요청을 할 때 사용POST 요청은 서버에 데이터를 제출하여 새 리소스를 생성하거나, 기존 리소스에 대한 처리를 요청할 때 사용된다.요청의 본문에 데이터를 담아 전송하며, 서버는 이 데이터를 기반으로 새로운 리소스를 생성하거나, 요청에 따른 특정 작업을 수행한다.폼 데이터 제출, 메시지 전송, 파일 업로드 등 서버에 데이터를 제출하여 처리를 요청할 때 사용된다.POST 요청은 non-idempotent하다. 같은 요청을 여러 번 수행할 경우, 요청의 효과는 요청마다 달라질 수 있다.같은 POST 요청을 여러 번 수행하면 서버에 여러 개의 리소스가 생성될 수 있으며, 각 요청이 서버 상태에 다르게 영향을 미칠 수 있다.TRACE서버에 대한 진단을 위해 사용되는 메서드엔티티 본문을 전혀 사용하지 않는다.TRACE 메서드는 클라이언트에게 자신의 요청이 서버에 도달했을 때 어떻게 보이는지 알려줌.목적지 서버에서 ‘루프백’ 진단을 시작한다.TRACE 요청은 서버로 전송된 요청 메시지를 그대로 받은 후, 응답 본문에 이 메시지를 포함하여 클라이언트에게 돌려보낸다. 이를 통해 클라이언트는 중간에 있는 프록시 서버들이 요청을 어떻게 수정하거나 추가하는지 확인할 수 있다.주로 네트워크 진단, 디버깅, HTTP 통신이 중간 서버에 의해 변경되는 방식을 추적하는 데 사용된다. 예를 들면 요청이 의도한 요청/응답 연쇄를 거쳐가는지 검사할 수 있다.하지만 보통 요청의 메서드에 따라 HTTP 애플리케이션은 다르게 동작한다. 예를 들어 POST는 바로 서버로 요청을 보내는 반면, GET은 웹 케시와 같은 HTTP 애플리케이션으로 전송한다. TRACE는 메서드를 구별하는 메커니즘이 없기에 TRACE 요청을 어떻게 처리할지는 중간 애플리케이션에서 결정해야한다.클라이언트 - proxy - 서버요청 메시지 TRACE /sample/... Accept: * - 프록시 통과TRACE /sample/... Accept: * Via: 1.1 proxy- 서버 도착- 서버 응답 메시지에 Via필드가 추가되어 클라이언트에 도착한다.OPTIONSOPTIONS 메서드는 웹 서버에 메서드 지원 범위를 물어본다.특정 리소스에 대해 어떤 메서드를 지원하는 지 물어볼 수 있다.응답은Allow헤더를 포함하고 있으며, 이는 해당 리소스에 대해 사용할 수 있는 HTTP 메서드들을 나열한다.OPTIONS 요청은 서버의 구성과 지원 기능을 파악하는 데 유용하며, 리소스를 변경하지 않는 안전한 메서드로 분류된다.DELETE서버에 지정된 리소스를 삭제하도록 요청DELETE 요청의 처리는 서버의 구현에 따라 다르다.HTTP 명세는 서버가 클라이언트에 알리지 않고 요청을 무시하는 것을 허락하기 때문이다.확장 메서드확장 메서드는 HTTP/1.1 명세에 정의되지 않은 메서드다. WebDAV HTTP 확장 메서드의 몇가지 예시이다.LOCK: 사용자가 리소스를 잠글 수 있게 해준다. 예를 들어 사용자가 편집 중일 때 다른 사람이 같은 문서를 편집할 수 없게 해준다.MKCOL: 사용자가 문서를 생성할 수 있게 해준다.COPY: 서버에 있는 리소스를 복사한다.MOVE: 서버에 있는 리소스를 옮긴다.모든 확장 메서드가 형식을 갖춘 명세로 정의되지 않는다. 누군가 임의로 정의한 메서드는 대부분의 HTTP 애플리케이션이 이해할 수 없을 것이고, 반대로 내 HTTP 애플리케이션이 이해할 수 없는 확장 메서드를 사용하는 애플리케이션과 마주칠 수도 있다.상태 코드상태 코드는 클라이언트에게 각 트랜잭션을 이해할 수 있는 쉬운 방법을 제공한다.상태코드는 번호대에 따라 크게 다섯가지로 나뉜다.100-199: 정보성 상태코드HTTP/1.1에서 도입되었다. 비교적 새로운 코드들이다.100 Continue: 요청의 시작 부분 일부가 받아들여졌으며, 클라이언트는 나머지를 이어서 보내야 함을 의미. 이를 보낸 후 서버는 반드시 요청을 받아 응답해야한다.101 Switching Protocols: 클라이언트가 Upgrage 헤더에 나열한 것 중 하나로 서버가 프로토콜을 바꾸었음을 의미100 Continue와 클라이언트: 클라이언트에서 엔터티를 서버에 보내기 전에 100 응답을 기다린다면, Expect:100-contunue로 시작하는 요청 헤더를 보내야한다. 기다리지 않는 다면, 이 헤더를 보내지 않는다. 서버의 응답을 다시 기다리더라도, 약간의 타임아웃 이후 보내야한다. 100 Continue와 서버: Expect:100-continue 요청 헤더를 받는다면, 에러코드 혹은 100 Continue 응답 둘 중 하나를 보내야한다. 만일 응답을 보내기 전에 클라이언트에서 엔터티의 일부(전체)를 받았다면 최종 응답을 보내야한다.조건부 요청 헤더의 Expect 참조*200-299: 성공 상태코드성공한 요청에 대응하는 의미있는 상태 코드 배열이다. 각각 다른 종류의 요청에 대응한다.200 Ok: 요청은 정상이고 응답 body(엔터티 본문)에 요청한 리소스를 포함한다.201 Created: 서버 개체를 생성하는 요청의 성공 코드. 생성된 리소스에 대한 구체적인 참조가 담긴 Location 헤더와 그 리소스를 참조할 수 있는 여러 URL을 본문에 포함해야 한다. 서버는 201 상태 코드를 보내기 전에 반드시 객체를 생성해야 한다.202 Accepted: 요청은 받아들여졌으나 서버는 아직 그에 대한 어떤 동작도 수행하지 않았다. 서버가 요청의 처리를 완료할 것인지에 대한 어떤 보장도 없다.요청이 받아들이기에 적법해 보인다는 의미 뿐이다. 서버는 응답 엔터티 본문에 요청에 대한 상태, 요청 처리가 언제 완료될 것인지 추정도 포함하는 것이 좋다.203 Non-Authoritative Information: 엔터티 헤더에 들어있는 정보가 원래 서버가 아닌 리소스의 사본에서 왔다. 중개자(프록시)가 리소스 사본을 갖고 있지만 메타데이터를 검증하지 못한 경우 발생한다. 반드시 사용되어야할 코드는 아니다.204 No Content: 응답 메시지는 헤더, 상태줄을 포함하지만 body는 포함하지 않는다. 주로 브라우저를 새 문서로 이동시키지 않고 갱신하고자 할 때 사용했다.205 Reset Content: 주로 브라우저를 위해 사용되는 코드. 브라우저에게 현재 페이지에 있는 HTML 폼에 채워진 모든 값을 비우라고 한다. (요즘은 잘 안쓰일듯)206 Partial Content: 부분 혹은 범위 요청이 성공. Range 헤더 참조. 206 응답은 Content-Range와 Date 헤더를 반드시 포함. Etag, Content-Location 중 하나는 반드시 포함해야한다.300-399: 리다이렉션 상태코드클라이언트가 관심있어 하는 다른 리소스의 위치를 사용하라고 말해주거나, 그 리소스의 내용 대신 다른 대안 응답을 제공한다. 만약 리소스가 옮겨졌다면 옮겨진 리소스가 옮겨졌으며, 어디서 찾을 수 있는 지에 대한 정보를 줄 수 있다. 이는 브라우저가 자동으로 새 위치로 이동할 수 있게 해준다.300번대의 몇몇 코드는 리소스의 로컬 복사본이 원서버와 비교해 유효한지 확인할 때 사용한다. 이를 통해 복사본이 여전히 최신인지 혹은 수정이 이루어졌는지 검사할 수 있다.300 Multiple Choices: 요청된 리소스가 여러 형태로 존재하며, 클라이언트가 선호하는 형태를 선택해야 하는 경우301 Moved Permanently: 요청된 리소스가 새로운 URI로 영구적으로 이동됐을 때. 클라이언트는 미래의 요청에서 새 URI를 사용.302 Found: 요청된 리소스가 일시적으로 다른 URI에 위치. 이 상태 코드는 리소스의 임시 이동을 나타냄.303 See Other: 요청에 대한 응답을 다른 URI에서 찾을 수 있으며, GET 메서드를 사용하여 리소스를 검색해야함304 Not Modified: 클라이언트가 조건부 GET 요청을 했고, 리소스가 마지막 요청 이후 변경되지 않았다면, 서버는 이 상태 코드를 사용하여 응답합니다. 이는 리소스의 로컬 복사본을 사용할 수 있음을 나타냄.307 Temporary Redirect: 요청된 리소스가 일시적으로 다른 URI에 위치해 있으며, 클라이언트는 미래의 요청에 대해 원래의 URI를 계속 사용해야함308 Permanent Redirect: 요청된 리소스가 새로운 URI로 영구적으로 이동되었으며, 클라이언트는 미래의 모든 요청에서 새 URI를 사용해야함400-499: 클라이언트 에러 상태코드클라이언트가 서버에서 다룰 수 없는 요청을 보내는 경우. 잘못 구성된 요청 메시지, 존재하지 않는 URL이나 접근 권한이 없는 요청 등이 있다. 많은 클라이언트는 브라우저에 의해 처리되나 404를 비롯한 몇몇은 서버로 전달된다.400 Bad Request: 일반적으로 잘못된 요청을 보냈음을 알린다.401 Unauthorized: 리소스를 얻기 전에 클라이언트에 인증을 하라고 요구하는 응답. 클라이언트는 요청에 인증 정보를 포함해야 한다.403 Forbidden: 서버가 요청을 이해했지만, 서버에 의해 거부할 때 사용. 보통 서버가 거절의 이유를 숨기고 싶을 때 사용한다.404 Not Found: 서버가 요청한 리소스를 찾을 수 없을 때.405 Method Not Allowed: URL에 대해 지원하지 않는 메서드로 요청했을 때 사용. 어떤 메서드가 가능한지 알려주기 위해 응답 헤더에 Allow를 포함한다.406 Not Acceptable: 요청된 리소스 중 받아들일 수 없는 것이 없는 경우 사용. 클라이언트의 Accept 헤더와 일치하지 않을 때 발생할 수 있다. 서버는 왜 요청이 만족될 수 없는지 알려주는 헤더를 포함시킬 수 있다.407 Proxy Authentication Required: 401과 동일하나 리소스에 대해 인증을 요구하는 프락시 서버를 위해 사용.408 Request TImeout: 클라이언트의 요청을 완수하기에 시간이 너무 많이 걸리는 경우, 서버는 이 상태 코드로 응답하고 연결을 끊을 수 있다.409 Conflict: 요청이 리소스에 대해 일으킬 수 있는 몇몇 충돌을 지칭하기 위해 사용. 서버는 충돌을 일으킬 염려가 있을 때 보낼 수 있음.410 Gone: 404와 비슷하나, 서버가 한 때 그 리소스를 갖고 있었다는 점이 다름.429 Too Many Requests: 클라이언트가 주어진 시간 동안 너무 많은 요청을 보낼 때.500-599: 서버 에러 상태코드클라이언트에서 올바른 요청을 보내도 서버 자체에서 에러가 발생하는 경우. 클라이언트가 서버의 제한에 걸리거나 게이트웨이 리소스와 같은 서버의 보조 구성요소에서 발생한 에러일 수도 있다.500 Internal Server Error: 서버에 문제가 있으며, 요청을 처리할 수 없을 때 사용.501 Not Implemented: 클라이언트가 서버의 능력을 넘은 요청을 했을 때. 예를 들어 서버가 요청 메서드를 지원하지 않을 때.502 Bad Gateway: 서버가 게이트웨이나 프록시 역할을 하며, 상위 서버로부터 유효하지 않은 응답을 받았을 때. (ex: 만약 자신의 부모 게이트웨이에 접속하는 것이 불가능할때.)503 Service Unavailable: 서버가 일시적으로 요청을 처리할 수 없을 때. 보통 유지 보수 또는 과부하 때문에 발생.504 Gateway Timeout: 409과 비슷하지만, 다른 서버에게 요청을 보내고 응답을 기다리다가 타임아웃이 발생할 떄. (ex:서버가 게이트웨이나 프록시 역할을 하며, 상위 서버로부터 시간 내에 응답을 받지 못했을 때.)505 HTTP Version Not Supported: 요청에 사용된 HTTP 프로토콜 버전이 지원하지 않을 때 사용. 몇몇 프로토콜은 서버가 오래된 버전을 지원하지 않도록 설정한다.헤더헤더와 메서드는 클라이언트와 서버가 무엇을 하는지 결정하기 위해 함께 사용됨. 앞서 살펴본 5가지 헤더의 분류를 살펴보자.일반 헤더요청과 응답 양쪽에 모두 사용한다. 일반적으로 기본적인 정보를 제공한다. Date는 요청/응답 모두 메시지가 생성된 일시라는 같은 의미를 갖기 때문에 양쪽 모두 동일한 헤더를 갖는 게 일반적이다.Connection: 클라이언트와 서버가 요청/응답 연결에 대한 옵션을 정할 수 있게 해준다.Date: 메시지가 언제 만들어졌는지 날짜와 시간을 제공MIME-Version: 발송자가 사용한 MIME 버전Trailer chunked transfer: 인코딩으로 인코딩된 메시지의 끝 부분에 위치한 헤더들의 목록을 나열Transfer-Encoding: 수신자에게 안전한 전송을 위해 메시지에 어떤 인코딩이 적용되었는지 알림Upgrade: 발송자가 '업그레이드’하길 원하는 새 버전이나 프로토콜을 알려줌.Via: 어떤 중개자(프록시, 게이트웨이)를 거쳐 왔는지 보여줌.일반 캐시 헤더HTTP/1.0은 HTTP 애플리케이션에게 매번 원 서버로부터 객체를 가져오는 대신, 로컬 복사본으로 캐시할 수 있도록 해주는 최초의 헤더를 도입함. 최신 버전의 HTTP는 풍부한 캐시 매개변수 집합을 갖는다. 7장에서 자세히 살펴볼 예정.Cache-Control: 메시지와 함께 캐시 지시자를 전달하기 위해 사용Pragma: 메시지와 함께 지시자를 전달하는 또다른 방법. 캐시에 국한되지 않는다. (Cache-Control로 인해 deprecated될 예정이다.)요청 헤더요청 메시지를 위한 헤더다. 클라이언트가 받고자 하는 데이터 타입이 무엇인지와 같은 부가 정보를 제공. 예를 들어 Accept: * 가 있는 요청 헤더는, 해당 요청으로 어떠한 미디어 타입도 받아들일 것을 의미한다.Client-IP: 클라이언트가 실행된 컴퓨터의 IP를 제공From: 클라이언트 사용자의 메일 주소를 제공Host: 요청의 대상이 되는 서버의 호스트명과 포트Referer: 현재의 요청 URI가 들어있던 문서의 URL을 제공User-Agent: 요청을 보낸 애플리케이션의 이름을 서버에 전달UA-Color: 클라이언트 기기 디스플레이의 색상 능력 정보UA-CPU: 클라이언트 CPU의 종류, 제조사 등의 정보UA-Disp: 클라이언트 디스플레이 능력에 대한 정보UA-OS: 클라이언트기기에서 동작 중인 운영체제의 이름과 버전UA-Pixels: 클라이언트 디스플레이에 대한 픽셀 정보Accept 관련 요청 헤더클라이언트는 Accept 관련 헤더를 이용해 서버에 무엇을 원하고 할 수 있는지, 원치 않는지를 알릴 수 있다. 서버는 그후 이 정보를 활용해서 무엇을 보낼 지 더 나은 선택을 할 수 있다. Accept 관련 헤더는 양쪽에 모두 유익하다. 클라이언트는 원하는 리소스를 명확히 전달할 수 있고, 서버는 사용하지 않거나, 할 수 없는 것을 보내지 않아 시간과 대역폭 낭비를 막을 수 있다.Accept: 서버에게 서버가 보내도 되는 미디어 종류를 전함Accept-Charset: 서버에게 서버가 보내도 되는 문자집합을 전함Accept-Encoding: 서버에게 서버가 보내도 되는 인코딩을 전함Accept-Language: 서버에게 서버가 보내도 되는 언어를 전함TE: 서버에게 서버가 보내도 되는 확장 선송 코딩을 전함조건부 요청 헤더헤더를 통해 요청에 제약을 추가할 수 있다. 조건부 요청 헤더를 사용해 서버에 요청에 응답하기 전에 먼저 조건이 참인지 확인하게 할 수 있다.Expect: 클라이언트가 요청에 필요한 서버의 행동을 열거할 수 있게 해준다. ex) Expect:100-continue. 이 헤더를 사용하여 클라이언트가 대용량 파일을 서버에 업로드할 때 추가한다. 실제로 데이터를 전송하기 전에 서버가 요청을 수락할 준비가 되었는지 확인한다. 서버가 요청을 처리할 준비가 되면 100 Continue를 전송한다. 아닌 경우 서버에서 에러 응답을 보내주도록 처리한다.If-Match: 문서의 엔터티 태그가 주어진 엔터티 태그와 일치하는 경우에만 문서를 가져옴If-None-Match: 반대로 문서의 엔터티 태그가 주어진 엔터티 태그와 일치하지 않는 경우에만 가져옴If-Modified-Since: 주어진 날짜 이후 리소스가 변경되지 않았다면 요청을 제한If-Unmodified-Since: 반대로 주어진 날짜 이후에 리소스가 변경되었다면 요청을 제한.If-Range: 문서의 특정 범위에 대한 요청을 할 수 있게 해줌Range: 서버가 범위 요청을 지원한다면, 리소스에 대한 특정 범위를 요청*요청 보안 헤더HTTP는 자체적으로 요청을 위한 간단한 인증요구/응답 체계를 갖고 있다.Authorization: 클라이언트가 서버에 제공하는 인증 자체에 대한 정보Cookie: 클라이언트가 서버에 토큰을 전달할 때 사용한다. 진짜 보안 헤더는 아니지만, 보안에 영향을 줄 수 있다.Cookie2: 요청자가 지원하는 쿠키 버전을 알려줄 때 사용.응답 헤더응답 메시지를 위한 헤더. 클라이언트에 정보를 제공하기 위한 고유의 헤더를 갖고 있다. 예를 들어 Server 헤더는 클라이언트가 어떤 종류의 서버와 대화하고 있는지 설명을 제공한다.Age: 응답이 얼마나 오래되었는지.Public: 서버가 특정 리소스에 대해 지원하는 요청 메서드 목록Retry-After: 현재 리소스가 사용 불가 상태일때, 언제 가능해지는지 날짜 혹은 시각Server: 서버 애플리케이션의 이름과 버전Title: HTML 문서에서 주어진 것과 같은 제목Warning: 사유 구절에 있는 것보다 더 자세한 경고 메시지협상 헤더서버와 클라이언트가 최적의 응답을 선택하기 위한 협상을 지원한다.추후 더 살펴볼 예정Accept-Ranges: 서버가 자원에 대해 받아들일 수 있는 범위의 형태Vary: 서버가 확인해 보아야 하기 때문에 응답에 영향을 줄 수 있는 헤더들의 목록. (ex: 서버가 클라이언트에게 보내줄 리소스의 가장 적절한 버전을 선택하기 위해 살펴보아야 하는 헤더들의 목록)응답 보안 헤더요청 보안 헤더와 마찬가지로 기본적인 인증요구 헤더를 살펴본다.Proxy-Authenticate: 프록시에서 클라이언트로 보낸 인증요구의 모록Set-Cookie: 진짜 보안 헤더는 아니지만 보안에 영향을 줄 수 있다. 서버가 클라이언트를 인증할 수 있도록 클라이언트 측에 토큰을 설정하기 위해 사용.Set-Cookie2: Set-Cookie와 비슷하게 RFC 2965로 정의된 쿠키.WWW-Authenticate: 서버에서 클라이언트로 보낸 인증 요구의 목록엔터티 헤더본문(body)에 대한 헤더. 본문에 들어있는 데이터의 타입이 무엇인지 말할 수 있다. 예를 들어 Content-Type: text/html; charset=iso-larin-1는 데이터가 iso-latin-1 문자집합으로 된 HTML 문서임을 알린다.엔터티 정보 헤더일반적으로 엔터티 헤더는 메시지의 수신자에게 자신이 다루고 있는 것이 무엇인지 말한다.Allow: 이 엔터티에 대해 수행될 수 있는 요청 메서드를 나열한다.Location: 클라이언트에게 엔터티가 실제로 어디에 위치하는지 알려준다. 리소스에 대한 위치(URL)을 알려줄 때 사용한다.엔터티 콘텐츠 헤더콘텐츠에 대한 구체적인 정보를 제공한다. 종류, 크기, 기타 등등… 브라우저는 Content-Type을 보고 그 객체를 어떻게 보여줄 지 결정할 수 있다.Content-Base: 본문에서 사용된 상태 URL을 계산하는 base urlContent-Type: 이 본문이 어떤 종류의 객체인지Content-Encoding: 본문에 적용된 인코딩이 무엇인지Content-Language: 본문을 이해하는데 적절한 자연어Content-Length: 본문의길이나 크기Content-Location: 리소스가 실제 어디에 위치하는지Content-MD5:본문의 MD5 체크섬(checksum)Content-Range: 전체 리소스에서 이 엔터티가 해당하는 범위를 바이트 단위로 표현*MD5: HTTP 메시지 본문의 내용을 MD5 알고리즘을 사용하여 계산된 체크섬(checksum) 값. MD5는 메시지 다이제스트 알고리즘 5(Message Digest Algorithm 5)의 약자로, 임의 길이의 데이터를 입력 받아 128비트 고정 길이의 해시값을 출력하는 암호화 해시 함수다. 체크섬은데이터의 무결성을 검증하는 데 사용되는 값으로, 데이터가 전송이나 저장 과정에서 변경되었는지 확인하기 위해 사용한다.엔터티 캐싱 헤더일반 캐싱 헤더는 언제 어떻게 캐시가 되어야 하는지에 대한 지시자를 제공한다. 엔터티 캐싱 헤더는 엔터티 캐싱에 대한 정보를 제공한다.Etag: 이 엔터티에 대한 엔터티 태그Expires: 이 엔터티가 더이상 유효하지 않아 원본을 다시 받아와햐 하는 일시Last-Modified: 가장 최근 이 엔터티가 변경된 일시확장 헤더명세에 정의되지 않은 새로운 해더. 애플리케이션 개발자들에 의해 만들어졌지만 아직 승인된 HTTP 명세에는 추가되지 않은 비표준 헤더다.

2024.03.11

ⓒ All Rights Reserved by teklee

n°53

teklog-recent-post

훅을 활용하여 마이크로 상태 관리하기

Jotai의 개발자 Daishi Kato가 쓴 ‘리액트 훅을 활용한 마이크로 상태관리’ 를 읽고 정리한 내용이다. code snippet을 직접 작성하고, 기억할만한 내용을 선별하고, 글의 순서를 편집해 작성하였다. 지역상태/전역 상태의 구분, context api, 전역 상태가 어떤 문제를 공유하고 각각 어떻게 해결해가는지를 중점으로 작성했다. 이 글을 통해 복잡할 수 있는 전역 상태의 관리를 효과적으로 실천할 수 있기를 바라며…마이크로 상태 관리란 ?저자가 제안하는 상태 관리를 위한 방법론이다. 목적 지향형인 리액트 훅과 Redux와 같은 중앙 집중형 상태 관리의 한계를 개선하기 위한 방법을 제시한다.리액트 훅: 폼 상태, 서버 캐시, 라우팅 기반의 상태 관리 등 각 목적에 맞는 상태를 관리. 목적 지향적인 방법으로 처리할 수 없는 상태도 있어 한계가 있다.중앙 집중형 상태 관리: 단 하나의 집중된 상태로 관리. 중앙 집중형 라이브러리는 사용되지 않는 기능까지 추가되거나, 사용법이 지나치게 복잡한 경향이 있다.저자는 대안으로범용적인 상태관리를 제시한다. 1) 가벼운 상태 관리 2) 개발자가 요구사항에 맞는 적절한 상태 관리법을 선택할 수 있도록 한다. 범용적인 상태관리는 다음과 같은 필수 기능을 포함한다.상태 read상태 write상태 기반의 렌더링리렌더링 최적화다른 시스템과의 상호작용비동기 지원파생 상태간단한 문법지역상태효과적인 상태 관리를 위해 상태를 '지역 상태’와 '전역 상태’로 구분한다.리액트에서 지역 상태란컴포넌트 내부에서만 사용되는 상태. (=컴포넌트에 격리된 상태, 하나의 컴포넌트에 속하고 캡슐화된 상태)리액트 상태는 컴포넌트 모델을 따른다. 순수함수와 같이 작동(같은 입력에 항상 같은 출력). 재사용에 용이.컴포넌트에서는 '지역성’이 중요. 컴포넌트(의 상태)가 격리되고 여러번 재사용될 수 있어야함.컴포넌트는 단방향 데이터 흐름 (props를 통한 전달)과 트리 구조를 기반함useState, useReducer 훅을 사용. 상태 로직을 추출하여 커스텀 훅을 사용하기 용이함.전역 상태에 의존하는 컴포넌트는 컴포넌트 모델에 적합하지 않을 수 있다.외부 상태에 의존하게될 시 컴포넌트 독립성이 사라질 수 있음외부 상태에 대한 의존성이 동작이 일관되지 않을 수 있음지역상태를 위해 useState가 사용된다. 혹은 지난 글에서 살펴봤듯이, 상태가 크거나 상태 변경의 로직이 늘어날 때 useReducer를 사용할 수 있다. 두 훅과 커스텀 훅은 이전 글에서 충분히 다루었으니, 지역 상태 훅들의 다음과 같은 공통점을 살펴보자.지역 상태를 사용할 때상태가 컴포넌트 단위로 독립되어 있어 상태 변경이 일어난 컴포넌트에서만 리렌더링을 한다. (리액트의 주요 설계 목적과도 부합한다.) 독립된 컴포넌트 내의 상태 변경은 다른 컴포넌트에 영향을 미치지 않는다. 이를 이용하여 상태를 효과적으로 관리하고 성능을 최적화할 수 있다.특정 컴포넌트에서만 사용되는 상태를 관리할 때ex) 키보드 인풋 입력 시 버튼 컴포넌트가 리렌더링될 필요는 없다.컴포넌트를 재사용할 때ex) 인풋 리스트 아이템은 인풋이 입력될 때 리스트 전체를 리렌더링하지 않게 한다.지역 상태 훅의 특성1 초기화 (지연 초기화)상태의 초기값으로 원시값, 참조값을 넣을 수 있다.훅의 초기값으로 함수를 넣으면 '지연 초기화’가 일어난다.첫번째 렌더링(첫번째 마운트)에서만 평가된다. 훅이 호출되기 전까지 함수는 호출되지 않는다.크고 복잡한 상태라면 초기값에 함수를 넣어 관리할 수 있다.2 베일아웃 (bailout)상태의 변경이 없을 때 리렌더링은 생략된다.useReducer의 초기화, bailout 예시/* init은 초기화를 위한 함수 */const init = (count:number):State = ({ count, text:'' })const reducer = (state:State, action:Action)= { switch(action.type) { case 'INCREMENT': return { ...state, count: state.count + 1 } case 'DECREMENT': return { ...state, count: state.count -1 } case 'TEXT': if(!action.text){ /* 1 - bailout */* return state } return { ...state, text: action.text } default: throw new Error("unknown action type") }}// Component.tsxconst Component = () = { /* 2 - 지연 초기화 */* const [state, dispatch] = useReducer(reducer, 0, init) return ( div pcount: {state.count}/p button onClick={() = dispatch({type: 'INCREMENT'})} PLUS /button button onClick={() = dispatch({type: 'DECREMENT'})} MINUS /button input onChange={(e) = dispatch({ type:'TEXT', text: e.target.value })} / /div )}1. Bailoutstate를 그대로 돌려주어야 이전 상태가 반환되어 bailout이 된다.{…state}를 반환하게 되면 새로운 객체가 생성되어 리렌더링이 일어난다.2. 지연 초기화useReducer의 3번째 매개변수(init 함수)는 2번째 매개변수를 인자로 반환한 값을 상태의 초기값으로 사용한다.init 함수는 useReducer가 호출될 시(즉 컴포넌트가마운트 되어 처음 렌더링될 시)에 평가, 실행된다리액트 지역 상태를 사용하기상태 끌어올리기동일한 형태의 상태를 사용하는 두 컴포넌트라면, 부모 컴포넌트를 만들어 하나의 상태로 두 컴포넌트에 props를 내려줄 수 있다.이 방법을 더욱 추상화하여 부모 컴포넌트에서 자식 컴포넌트로 의존성(props)을 주입하는 여러 컴포넌트 설계 패턴들(props getter, state reducer 등)이 있다. (이 패턴들은 역전된 제어Inversion of Control라고 불린다.)내용 끌어올리기상위 컴포넌트의 상태 변경에 영향 받지 않는 컴포넌트나 컴포넌트의 일부분을 분리하여 상태 변경이 일어나는 컴포넌트 상위로 끌어올리는 방법.HOC, render props와 같은 패턴과 함께 활용할 수 있다.참고: 리렌더링이 일어나는 4가지 경우상태의 변경props의 변경부모 상태의 변경 (리렌더링)Context(전역 상태)의 변경예시상태 끌어올리기// Parent 컴포넌트function Parent() { const [sharedState, setSharedState] = React.useState(""); return ( div ChildA sharedState={sharedState} setSharedState={setSharedState} / ChildB sharedState={sharedState} / /div );}// ChildA 컴포넌트function ChildA({ sharedState, setSharedState }) { return input value={sharedState} onChange={(e) = setSharedState(e.target.value)} /;}// ChildB 컴포넌트function ChildB({ sharedState }) { return divCurrent Value: {sharedState}/div;}내용 끌어올리기// 상태가 있는 컴포넌트function StatefulComponent() { const [counter, setCounter] = React.useState(0); return ( div button onClick={() = setCounter(counter + 1)}Increment/button divCounter: {counter}/div divThis is static content that does not change./div /div );}// 상태 변경에 영향을 받지 않는 정적 컴포넌트function StaticContent() { return divThis is static content that does not change./div;}function ContentLiftedComponent() { StatefulComponent/ StaticContent/ {/* 혹은 StatefulComponent에 children props를 추가하여 수정할 수 있다.*/} StatefulComponent StaticContent/ /StatefulComponent /}전역상태모든 컴포넌트가 하나의 상태에 의존하면(ex:최상단의 상태가 앱 전체의 컴포넌트에 상태를 props로 내려줌), 지역상태와 전역상태를 명확히 구분할 수 없다. 일반적으로 리액트에서 '전역상태’란 떨어진 컴포넌트에서 '공유된 상태shared state’이다.전역상태의 두가지 측면싱글톤. 특정 컨텍스트에서 상태가 하나의 값을 갖는다. 모듈 파일로 분리된 전역 상태를 사용하면 싱글톤과 유사하게 사용할 수 있다.공유 상태. 상태 값이 다른 컴포넌트에 공유되지만, 메모리상에서 단일 값일 필요는 없다. 대표적으로 리액트 컨텍스트 API는 싱글톤이 아닌 공유 상태이다.언제 사용할까?porp을 전달하는 것이 적절하지 않을 때익히 알려진 상태 공유가 props 드릴링을 유발하는 경우. 상태를 공유하려는 컴포넌트가 멀리 떨어질 수록 컴포넌트의 구조와 코드는 더욱 복잡해진다.이미 리액트 외부에 상태가 있을 때ex) 리액트 없이 획득한 사용자 인증 정보(OAuth 등으로 획득한 사용자 토큰)가 페이지 전체에 공유되어 특정 컴포넌트의 기능을 막거나 작동하게 해야한다.Context API리액트는 16.3부터 context api를 제공한다. Context Provider, useContext 훅을 사용하여 간단하게 전역상태를 사용할 수 있게 되었다. 책에서는 컨텍스트 api의 기본적인 사용법과 함께, 근본적인 문제와 한계, 모범사례를 살펴본다. 이 챕터에서 중요한 부분은 컨텍스트의 한계이다. 여기서 소개되는 문제의식과 해결법은 뒷장에서 소개하는 전역상태 라이브러리들이 같은 문제를 해결하기 위해 어떤 접근법을 취하고 있는지 이해하는데 도움이 된다. 이 챕터에서 설명하는 store, subscribe, selector같은 개념은 여러 라이브러리에서 유사한 개념과 기능을 공유하기 때문에 숙지하는 것이 좋다.기본적인 사용법useState를 사용하거나 정적인 값을 사용하여 컨텍스트를 생성할 수 있다. 상태를 공유하는 컴포넌트를 provider로 묶어주어야 한다. context provider에는 정적인 값이나 useState, useReducer의 상태를 넣어줄 수 있다.Provider value prop에 정적인 값이 들어간다면 전역상태를 변경할 수 없게 된다.(정확히는 업데이트할 방법이 없어진다.)// 컨텍스트를 정적인 값으로 사용하는 경우const SampleContext = createContext('black')const SampleContext = createContext({color: 'black', count: 0, setColor:(param:string) = void })const Root = () = {const [color, setColor] = useState('black') // state 훅return ( SampleContext.Provider value={{ color, setColor, count:0 }} SampleContext.Provider value={color} // 정적인 값 사용 시 ColorComponent / MemoColorComponent / CountComponent / MemoCountComponent / DummyComponent / MemoDummyComponent / /ColorContext.Provider / )}const ColorComponent = () = { const color = useContext(SampleContext) // 정적 const {color, setColor} = useContext(SampleContext) // state 훅 return ( div pcurrent color : {color}/p input onChange={(e) = setColor(e.target.value)} / /div )}const CountComponent = () ={ const {count} = useContext(SampleContext) return {count}/}const DummyComponent = () = (dummy/)const MemoColorComponent = memo(ColorComponent)const MemoCountComponent = memo(CountComponent)const MemoDummyComponent = memo(DummyComponent컨텍스트의 전파컨텍스트 provider와 상태훅을 사용해 컨텍스트를 업데이트할 수 있다. 이 때 프로바이더 내부의 모든 컴포넌트가 리렌더링된다. 이 경우 상태 변경이 이루어지지 않는 컴포넌트까지 불필요하게 리렌더링이 일어난다. 이를 방지하기 위해 앞서 살필 '내용 끌어올리기’나 혹은 memo훅을 사용할 수 있다.인풋 태그에 텍스트를 입력하여 컨텍스트를 업데이트 시 다음과 같은 컨텍스트 전파가 일어난다.처음에 모든 컴포넌트가 렌더링텍스트 입력 시 Root의 상태가 바뀌며 Root 컴포넌트가 리렌더링SampleContext.Provider가 새로운 value를 받는 동시에 내부 컴포넌트 리렌더링MemoCount를 제외한 모든 컴포넌트가 리렌더링됨MemoColorComponent 또한 리렌더링된다. 부모 컴포넌트의 상태가 변화하고 컨텍스트가 변경되었기 때문이다.간단한 방법은 Provider 나누는 것이다. 컨텍스트는 프로바이더를 경계로 공유되기 때문에, 공유하려는 상태가 아닌 컴포넌트는 Provider 경계 밖으로 분리할 수도 있다. 하지만 요구사항에 따라 혹은 이미 개발된 컴포넌트의 설계에 따라 그럴 수 없는 경우 또한 자주 있을 수 있다.컨텍스트의 한계컨텍스트가 업데이트될 때 불필요한 리렌더링이 늘어난다.컨텍스트가 객체 형태일 때 이와 같은 리렌더링이 더욱 늘어난다.불필요한 리렌더링을 방지하기 위한 코드의 양이 증가한다.1, 2번은 위의 예제를 통해 설명된다. setColor를 통해 바뀐 상태는 color 밖에 없음에도, CountComponent와 React.memo로 감싸진 MemoCountComponent가 리렌더링된다. 또한 DummyComponent도 Provider에 제공된 값이 바뀌면서 리렌더링된다. 컨텍스트에 의존하는 컴포넌트가 많고, 상태 변경이 자주 이루어진다면 불필요한 리렌더링이 페이지 단위로 불필요한 리렌더링이 무분별하게 실행될 것이다.해결 방법과 모범 사례앞선 객체의 일부 컨텍스트를 사용할 시 리렌더링되는 문제를 해결하기 위해서 컨텍스트를 작게 쪼게는 방법이 있다.const ColorContext = createContext(...)const CountContext = createContext(0)const Root = () = {.... 위와 동일 ColorContext.Provider value={{color, setColor}} CountContext.Provider value={0} ...}const ColorComponent = () = { const {color , setColor } = useContext(ColorContext) ...}const CountComponent = () = { const count = useContext(CountContext) ....}프로바이더는 중복하여 사용이 가능하며, ColorContext가 업데이트될 시 CountContext를 사용하는 컴포넌트는 memo를 사용한다면 리렌더링되지 않는다. 물론 이 방법으로 컨텍스트를 작게 쪼게다보면 Provider의 수가 증가하는 단점은 있다. 프로바이더가 너무 많아 value prop으로 내려주어야 할 값을 관리하기 어려울 수 있다.이같은 문제를 해결하기 위해 저자는 세가지 해결방법을 제시한다.커스텀 훅과 Provider 컴포넌트커스텀 훅과 팩토리 패턴reducerRight를 활용한 Provider 중첩 개선커스텀 훅과 Provider 컴포넌트Provider와 useContext를 사용하려는 컨텍스트 별로 커스터마이징하여 사용하는 방법이다. 위에서 작게 나눈 컨텍스트를 기반으로 작성한다.type ColorContextType = [string, DispatchSetStateActionstring] | nullconst ColorContext= createContext(null)export const ColorProvider = ({children}:...) = { const [color, setColor] = useState('black') return ( // ColorProvider에서 ColorContext의 초기값을 주입한다 ColorContext.Provider value={{ color, setColor }} {children} /ColorContext.Provider )}export const useColorContext = () = { const {color, setColor} = useContext(ColorContext) // 이 훅이 ColorProvider 밖에서 사용되면 컨텍스트는 null일 것이다. if(color === null) { throw new Error('Provider is missing') }}const ColorComponent = () { const { color, setColor } = useColorContext(); ....}// 같은 방식으로 CountContext 커스텀 훅과 프로바이더 컴포넌트를 작성const Root = () = { ColorProvider CountProvider ColorComponent / CountComponent / .....}ColorProvider를 분리하여 Root에 선언된 상태를 커스텀 프로바이더 내부로 옮기고, useColorContext 훅으로 컨텍스트의 상태를 불러오거나 업데이트할 수 있게 되었다. 앞선 코드에서 반복된 컨텍스트 import를 없애고, 상태와 상태 변경 로직을 커스텀 훅으로 추상화할 수 있었다.커스텀 훅과 팩토리 패턴작게 쪼개진 상태에 매번 커스텀 훅과 컴포넌트를 추가하는 것 또한 반복적이다. 이를 한번 더 추상화할 수 있다. ColorContext, CountContext 등 어떤 형태의 컨텍스트라도 커스텀 Provider와 커스텀 useContext훅을 튜플 형태로 반환하는 커스텀 훅을 활용할 수 있다.const createCustomContext = T, P(useValue: (init:T) = P) = { const StateContext = createContext(null) const StateProvider = ({initialValue, children}:{...}) = ( StateContext.Provider value={useValue(initialValue)} {children} /StateContext.Provider ) const useContextState = () = { const value = useContext(StateContext) if(value === null) { throw new Error("Provider missing") } return value } return [StateProvider, useContextState] as const}...const useInitState = T(init:T) = useState(init)const colorInit = useInitState('black')const [ColorProvider, useColor] = createCustomContext(colorInit)// 컴포넌트를 만들지 않고 이 커스텀 훅을 재사용할 수 있다.....const ColorComponent = () = { const {color, setColor} = useColor();...}createCustomContext 팩토리 패턴을 사용하여 매번 컴포넌트를 만들지 않고 함수로 작성하였다. 이 useState 대신 reducer를 활용하여 상태 업데이트 로직을 함수 단위로 나눌 수 있다.reducerRight를 활용한 Provider 중첩 개선상태가 잘게 나누어져 위의 훅을 여러번 사용하고 Provider 또한 깊게 네스팅된다면 reducerRight을 사용할 수 있다.const App = () = { const providers = [ [Provider1, {init: 'black'}], [Provider2, {init: 'white'}], [Provider3, {init: 'yellow'}], [Provider4, {init: 'red'}], [Provider5, {init: 'blue'}], ] return providers.reduceRight( (children, [Provider, props]) = createElement(Provider, props, children), Root / )}HOC와 함께 사용할 수 있다. 사실 Context.Provider가 깊게 네스팅되는 것은 기본 문법이기에 큰 문제가 있는 것은 아니다.사실 이 부분까지 읽으면서, 저자가 컨텍스트 API 챕터 도입부에 언급한 "처음부터 컨텍스트 API는 전역상태를 위해 설계된 것이 아니다"라는 언급이 떠올랐다. 비교적 작은 컴포넌트 단위 사이에서 상태를 공유한다면 컨텍스트가 적절할 수 있으나, 컴포넌트가 크고 복잡할 때 너무 많은 훅과 프로바이더가 추가되는 것은 피할 수 없어 보인다. 만약 이 정도로 코드가 추가되어야 한다면, 추후 소개할 상태관리 라이브러리를 사용하는 게 더 적절할 것이다.다른 상태 라이브러리를 살피기 앞서 다음 개념들을 숙지해보자.모듈 상태, 스토어, 구독, 셀렉터이어지는 장에서 module.ts로 분리한 파일에 let count = 0; 변수를 할당하고, 리액트 컴포넌트에서 이 변수를 전역상태로 사용하는 방법을 설명한다. 모듈 파일에 선언된 변수를 전역상태로 사용하기 위해 store를 만들고, store 내부에 메서드를 추가해야한다. 리액트는 useSyncExternalStore 훅을 제공하여 모듈 파일에 store를 구독하여 전역 상태의 변경을 감지할 수 있다. 모듈, 스토어, 구독, 셀렉터는 일반적인 상태 관리 라이브러리에서 공유되는 개념이기에 숙지하는 것이 도움이 된다. 예제 코드는 생략하고 중요 개념을 살펴보자.모듈 상태모듈로 분리된 전역 상태. 상태 라이브러리 사용 시 보통 atom.ts처럼 파일로 분리된 전역 상태들을 import하여 사용하게 된다.모듈에서 할당된 전역 상태 변수를 import하여 사용하는 것은, 여러 컴포넌트에서 동일한 메모리 주소를 사용하여 싱글톤과 유사한 효과를 낼 수 있다.스토어중앙 집중된 저장소. 하나 혹은 여러 상태를 스토어에 저장할 수 있다.리액트에서는 Redux나 Zustand의createStore함수를 사용하여 생성할 수 있다. 스토어는 전역 상태의 일관성을 유지하고, 상태 접근 및 업데이트의 중심 지점 역할을 한다.구독스토어의 특정 상태 변화를 감지하는 메서드. 이에 반응하여 필요한 동작(UI 업데이트)을 실행한다.리액트에는 useSyncExternalStore라는 리액트 내장 API를 제공한다. 모듈 파일에 store 변수를 만들어 이 변수를 구독하게 만들 수 있다. (참조)Redux, Zustand, jotai 등 다양한 상태 라이브러리에서는subscribe메서드를 통해 스토어의 상태 변화를 구독하는 기능을 제공한다. 컨텍스트 API에서는useContext훅을 사용하여 자동으로 변화를 구독한다. 구독을 통해 상태 변화에 즉각적으로 반응할 수 있다.셀렉터 (선택자 함수)스토어의 상태 중 필요한 부분만을 선택적으로 추출하는 함수. 특히 객체 형태의 상태에서 일부를 가져올 때 사용한다.셀렉터는 상태의 특정 부분에 대한 접근을 추상화하고, 계산된 상태를 도출하는 데 유용하다.셀렉터로 전역 상태의 일부 혹은 가공하여 가져온 것을 "파생 상태 derived state"라고 부른다. 많은 라이브러리에서 공통적으로 파생 상태를 얻기 위한 기능을 제공한다.셀렉터는 컴포넌트의 재렌더링을 최적화하는 데 중요한 역할을 한다.상태 관리 라이브러리리액트는 컴포넌트를 중심으로 설계되었다. 컴포넌트는 모든 것이 재사용 가능한 것으로 여겨진다. 하지만 전역 상태는 컴포넌트 외부에 존재한다. 컴포넌트에 대한 추가적인 의존성이 필요하기 때문에 전역 상태 사용을 피하는 것이 좋지만, 전역 상태를 사용하는 것은 편리하며 생산성을 높일 수 있다. 그리고 요구사항에 따라 전역 상태가 필요할 수 있다.전역 상태를 설계할 때 일반적인 문제점전역 상태를 읽는 방법 (read)*전역 상태는 여러 값을 가질 수 있고, 전역 상태를 사용하는 컴포넌트는 전역 상태의 모든 값이 필요하지 않은 경우가 있다. 전역 상태가 바뀌면 리렌더링이 발생하는데, 변경된 값이 컴포넌트와 관련 없는 경우에도 리렌더링이 발생한다. 이는 바람직하지 않으며, 전역 상태 라이브러리는 이에 대한 해결책을 제공할 필요가 있다.전역 상태에 값을 넣거나 갱신하는 방법 (write)전역 상태는 여러 값을 가질 수 있으며, 그증 일부는 중첩된 객체일 수 있다. 이럴 때 하나의 전역 변수를 가지고 개발자가 직접 값을 변경하는 것은 좋은 방법이 아닐 수 있다… 객체의 값을 직접 변경시 리액트 컴포넌트의 리렌더링을 트리거할 방법이 없기 때문이다.앞서 살핀 문제와 해결법을 모두 압축하고 있다. 컨텍스트 API에서 이 문제를 해결하기 위한 코드 예시를 살펴봤다. 불필요한 리렌더링을 방지하기 위해 컨텍스트를 작게 나누었고, 전역 상태의 값을 직접 바꾸는 대신 useState 훅의 dispatch나 useReducer의 action 함수를 사용했다. 또한 이 해결방법의 한계에 대해서도 살펴보았다.들어가기에 앞서 전역 상태의 두가지 접근법을 간단히 알아본다.데이터 중심 접근 방식 vs 컴포넌트 중심 접근 방식전역 상태는 데이터와 컴포넌트 중심으로 유형을 나눌 수 있다.데이터 중심전역 상태의 데이터 모델은 싱글톤으로 갖을 수 있다.처리할 데이터가 이미 있을 수 있고, 컴포넌트를 정의한 후 상태와 연결한다. 이 상태는 라이브러리, 서버 등 외부에서 변경할 수 있다.데이터 중심 접근 방식인 경우 모듈 상태가 리액트 외부의 자바스크립트 메모리에 있기 때문에 모듈 상태를 사용하는 것이 더 적합하다.모듈 상태는 렌더링을 시작하기 전이나 컴포넌트가 마운트 해제된 후에도 존재할 수 있다.모듈 상태는 보통 store와 상태에 접근하고 상태를 업데이트하는 store의 메서드를 제공한다.컴포넌트 중심데이터 중심과 달리 컴포넌트를 먼저 설계하고, 이를 기반으로 필요한 상태를 설계할 수 있다.컴포넌트의 생명주기나 사용자 인터렉션에 맞추어 전역 상태에 접근하여 필요한 데이터에 접근하거나 업데이트할 수있다.리렌더링 최적화보통 상태 라이브러리는 세가지 패턴으로 리렌더링을 최적화한다.셀렉터 사용셀렉터는 상태를 받아 상태의 일부를 반환한다. 객체의 일부를 사용하거나 전역 상태에서 파생된 값을 사용할 때 유용하며, 전역 상태 변경에 불필요한 리렌더링을 막을 수 있다. 셀레터는 상태의 어느 부분을 사용할 지 명시적으로 지정하는 방법으로 수동 최적화라고 한다.속성 접근 감지속성 접근을 감지하여 렌더링을 최적화하는 상태 사용 추적이라는 패턴도 있다. 셀렉터보다 간단한 문법을 사용하며, 자동으로 렌더링을 최적화할 수 있다. 하지만 셀렉터가 최적화에 더 유리한 경우도 있다.const Component = () = { const trackedState = useTrackedState(); /* selector */ // const {a} = useSelector((state) = state.b) // const {c} = useSelector((state) = state.e) /* selector가 최적화가 잘되는 경우*/ const isSmallA = useSelector((state) = state.a 10) const isSmallA = useTrackedState().a 10 /*useTrackedState().a는 상태 a가 바뀔 때마다 리렌더링되지만, useSelector는 state.a 10 true/false가 바뀌기 전까지 리렌더링되지 않는다./ return ( {tracked.b.a} {tracked.e.c} {isSmall ? "smaller" : "bigger"} / )}아톰 사용아톰은 리렌더링을 발생시키는 최소한의 단위다. 전역 상태를 구독하여 리렌더링을 피하는 대신, 아톰으로 세분화하여 구독한다. 마치 컨텍스트에서 createContext를 잘게 나누는 것과 유사하지만, 아톰은 작게 나눈 상태를 조합하여 하나의 큰 전역상태로 만들 수 있다.const globalState = { a: atom(1), b: atom(2), c: atom(3)}const Component = () = { const value = useAtom(globalState.a) return {value}/}const sum = globalState.a + globalState.b + globalState.c아톰으로 파생값 또한 만들 수 있다. 이를 수행하기 위해서 의존성을 추적하여 아톰이 갱신될 때마다 파생값을 다시 평가한다. 이처럼 아톰을 사용하는 방식은 수동 최적화와 자동 최적화의 중간 정도이다. 아톰과 파생값의 정의는 명시적이지만, 의존성 추적은 자동으로 된다.이상 상태 라이브러리가 해결하려는 문제와 접근 방식, 해결 패턴에 대해 알아보았다. 마지막으로 각 라이브러리를 간략히 살펴보자.Zustand특징모듈 상태를 구현하는데 사용하도록 설계되었다.store 생성자 인터페이스를 사용한다.크기가 작고 데이터 흐름이나 파일 디렉터리를 강제하지 않는다. 최소한으로 설계 되었다.상태 객체에 새 값을 할당하여 직접 수정할 수 없고 항상 상태를 새로 만드는 불변 갱신 모델을 사용한다.객체의 참조가 동등성을 확인하여 변경 여부를 확인한다.렌더링 최적화는 셀렉터를 사용해 수동으로 한다.사용방법// store.tsimport create from 'zustand'export const store = create(() = ({ count: 0 }))// Component.tsximport store from '@/libs/store'const Component = () = { const selector = (state) = state.count 10 const isSmall = store(selector) return ( p{isSmall ? "small" : "big"}/p p{store.getState()}/p button onClick={() = {store.setState((prev) = ({count: prev.count + 1}))}} INC/button / )}모듈 상태 기반으로 작성되어 store.ts에서 store를 정의하고 export하여 사용한다. 상태를 업데이트하기 위해선 새로운 객체를 반환하는 함수를 사용해야한다. 이 방식은 useState의 setter를 통해 상태를 업데이트하는 것과 일치한다. 셀렉터 함수를 사용하여 state.count 10이 변경될 때만 리렌더링이 일어난다.읽기 상태 : 리렌더링을 최적화하기 위해 셀렉터를 사용쓰기 상태: 불변 상태 모델을 기반객체 불변성의 법칙Zustand와 useState 훅은 상태가 객체일 때 새로운 객체를 생성해야한다. 객체 불변성을 지키기 위해서다. 이는 객체의 데이터가 한 번 생성된 후에는 그 내용을 변경할 수 없다는 원칙이다. 객체의 상태를 변경해야 할 때는 객체의 복사본을 생성하고, 복사본에 변경사항을 적용한 후, 해당 복사본을 새로운 상태로 사용한다. 자바스크립트에선 Object.assign이나 spread를 사용해 간단히 구현할 수 있다.const originalObject = { a: 1, b: 2 };const updatedObject = { ...originalObject, b: 3 };const updated = Object.assign({}, originalObject, { b: 3, c: 4 });Zustand 역시 객체 불변성을 기반으로 한다. 셀렉터가 참조적으로 동일한 객체(나 값)를 반환하면 객체가 변경되지 않은 것으로 간주하고 리렌더링을 하지 않는다.장점셀렉터를 사용하여 리렌더링을 최적화할 수 있다.리액트와 동일한 사용하기에 단순하고 번들 크기가 작다단점셀렉터를 위해 보일러플레이트 코드를 많이 작성해야 할 수 있다.Jotai특징Zustand와 마찬가지로 불변 상태 모델앞서 살펴본 작은 상태 조각인 atom을 기반으로 사용컨텍스트와 구독을 사용한 패턴을 기반으로 하며 이를 atom으로 구현한다.atom이 바뀔 때 리렌더링이 일어난다.Provider를 선택적으로 사용할 수 있다.배열 구조로 리렌더링을 최적화하는 Atoms-in-atom을 활용할 수 있음사용방법// atoms.tsimport {atom} from 'jotai'export const count1 = atom(0)export const count2 = atom(0)export const sumAtom = atom((get) = (get(count1) + get(count2)))export const isSmallAtom = atom((get) = (get(count1) get(count2)))// Component.tsximport store from '@/libs/store'const Component = ({counterAtom}) = { const [count, setCount] = useAtom(counterAtom) const isSmall = useAtom(isSmallAtom) return ( p{count}/p button onClick={() = {setCount((prev) = (prev + 1))}} INC/button / )}const Sum = () = { const sum = useAtom(sumAtom) return p{sum}/p}const IsSmall = () = { const isSmall = useAtom(isSmallAtom) return p{isSmall ? 'count 1 is smaller' : 'count1 is bigger'}/p}const App = () = { return ( div Sum / IsSmall / Component counterAtom={count1} / Component counterAtom={count2} / /div )}atom을 기반으로 상태를 작게 나누어 조합할 수 있다. 이는 셀렉터를 대신하여 유사한 효과를 낼 수 있다. Jotai의 구독은 atom 기반으로 useAtom훅은 스토어에 있는 특정 atom을 구독한다. 이 예시에서 각 컴포넌트는 count1, count2가 바뀔 때 해당 컴포넌트만 리렌더링되며, 파생 아톰인 sum, IsSmall은 파생 상태가 바뀔 때만 리렌더링된다.Jotai의 스토어는 아톰 구성 객체와 아톰 값으로 구성된 WeakMap 객체다.아톰 구성 객체: atom 함수로 생성아톰 값 : useAtom 훅이 반환하는 값Jotai의 구독은 아톰 기반이므로 useAtom 훅이 store에 있는 특정 아톰을 구독한다는 의미이다. 아톰 기반 구독을 통해 불필요한 리렌더링을 피할 수 있게 된다. 상태의 갱신을 추적하는 것을 의존성 추적이라하며, jotai는 이를 자동으로 수행한다.상향식과 하향식하향식top-down: Zustand에선 셀렉터 함수를 통해 객체의 일부를 파생 상태로 만들었다. 이미 존재하는 큰 상태에서 작은 상태로 나누어가는 방식을 상향식이라 한다.상향식bottom-up: Jotai에서는 아톰을 작게 나누고, 이를 합성하여 더 큰 상태를 만들었다. (counterAtom 예시) 이를 상향식이라고 한다.동적 아톰 생성과 Atoms-in-atom 패턴리액트 컴포넌트의 생명 주기에 따라 생성되고 소멸되는 동적인 아톰을 사용할 수 있다. 컨텍스트 API에서 새로운 상태를 추가하는 것은 새로운 Provider 컴포넌트를 추가해 하위 트리를 전부 묶는 것이기 때문에 모든 하위 컴포넌트가 리렌더링된다. 이때 리액트 생성 주기와 무관하게 상태는 지속된다. 하지만 jotai의 동적 아톰 생성을 통해 컴포넌트의 생애주기에 맞춰 전역 상태를 변경할 수 있다. 일견 그럴 필요 있을까 싶지만, 매우 유용한 순간이 있다. 바로 전역 상태가 배열일 때이다. 사용자의 수정, 삭제의 이벤트에 따라 전역상태가 업데이트되는 리스트 컴포넌트를 떠올릴 수 있다. 이 때 효과적으로 전역상태를 관리할 수 있다.// 동적 상태를 이용한 투두 리스트 예시// id를 갖고 있지 않다// atoms.tstype Todo = { title: string; done: boolean;}type TodoAtom = PrimitiveAtomTodoconst todoAtomsAtom = atomTodoAtom[]([])// TodoItem.tsxconst TodoItem = ({todoAtom, remove} : {todoAtom:TodoAtom; remove:(todoAtom:TodoAtom) = void }) = { const [todo, setTodo] = useAtom(todoAtom) return ( div input type="checkbox" checked={todo.done} onChange={() = setTodo((prev) = ({...prev, done:!prev.done}))} / span style={{textDecoration: todo.done? "line-trough" : "none"}} {todo.title}/span input type="text" onChange={(e) = setTodo((prev) = ({...prev, title: e.target.value}))} button onClick={() = remove(todoItem)} Delete /button /div )}export default memo(TodoItem)// NewTodo.tsxconst NewTodo = () = { const setTodoAtoms = useSetAtom(todoAtomsAtom) const [text, setText] = useState('') const onClick = { setTodoAtoms((prev) = ([...prev, atomTodo({title: text, done:false})])) setText(''); } return ( div input onChange={(e) = setText(e.target.value)} / button onClick={onClick}Add/button /div )}// TodoList.tsxconst TodoList = () = { const [todoItems, setTodoItems] = useAtom(todoAtomsAtom) const remove = useCallback( (todoAtom: TodoAtom) = setTodoAtoms( (prev) = prev.filter((item) = item !== todoAtom) ), [setTodoAtoms] ) return ( div NewTodo / {todoAtoms.map((todoAtom) = ( TodoItem // atom은 문자열로 평가될 때 UID를 반환한다. key={`${todoAtom}`} todoAtom={todoAtom} remove={remove} / ))} /div )}아톰을 동적으로 생성하기 위해 todo 아톰과 아톰 리스트(todoAtomsAtom)의 상태로 분리한다. 하지만 실제로 atoms.ts에 작성된 전역 상태는 리스트 상태 하나이며, 개별 todo 상태는 컴포넌트에서 생성된다.이 코드에서 "동적으로 아톰을 생성"하는 컴포넌트는 NewTodo의 onClick 함수, setTodoAtoms이다. 타이핑 입력 후, 버튼을 클릭하면 리스트 아톰에 아톰이 추가된다. setTodoAtoms((prev) = ([…prev, atom({ title: “여기서 새로운 아톰을 추가”, done:false})]))리스트 상태에서 요소 삭제는 TodoList의 remove 함수의 필터링으로 구현된다. 이를 props로 받은 TodoItem의 버튼 이벤트로 리스트 전역 상태가 업데이트된다.마지막으로 TodoItem.tsx를 memo로 감싸고, remove에 useCallback을 사용하여 리렌더링을 최소화한다.이같은 패턴을 Atoms-in-Atom이라고 부른다. 예시 코드에서 다음과 같은 이점이 생긴다.0. 리렌더링의 이점. TodoItem은 개별적인 아톰(상태)를 갖는다. TodoItem에서 done, title의 상태가 변경되어도, 리스트 상태를 업데이트 하는 것이 아니기 때문에 TodoList 전체의 리렌더링은 일어나지 않는다.1. 리스트 아이템을 렌더링할 때 uuid같은 외부 라이브러리를 사용할 필요가 없다! atom이 문자열일 때 UID로 평가되어 별도의 라이브러리로 ID를 생성할 필요가 없어진다.2. 리스트를 업데이트하기 위한 필터링 로직 또한 간단해진다. 아톰 리스트 상태에서 아톰끼리 비교가 가능하다. setTodoAtoms((prev) = prev.filter((item) = item !== todoAtom) 단순한 로직으로 아톰의 비교가 가능하다.추가적인 라이브러리 없이 로직을 단순화하면서 성능 상의 이점을 챙길 수 있어 매우 유용하다.추가적인 내용atom 함수에 첫번째 매개변수로 콜백함수만을 추가하면 read 아톰이 되며, 매개변수로 두개를 넣을 시 첫번째는 read 함수, 두번째는 write 함수가 된다. read only 아톰과 readAndWrite 아톰으로 구분할 수 있다. 파생상태를 변경할 일이 없다면 read only 아톰을 활용할 수 있다.반대로 atom의 첫번째 인자가 null이고 두번째 write 함수만 있는 경우 “액션 아톰” 혹은 write-only 아톰이라고 한다.atom은 onMount, onUnmount 메서드를 제공한다. 아톰 변경함수를 인자로 받으며 사용 시작과 종료 시 로직을 추가할 수 있다.jotai/utils는 많은 atom관련 훅을 제공한다. atomWithStorage, atomWithReducer는 아톰을 로컬 스토리지에 저장하거나 리듀서를 활용해 관리할 수 있게 해준다.장점구문이 단순하며 리액트의 상태훅과 동일한 형태로 사용할 수 있다.아톰을 나누어 상태관리를 세분화할 수 있다.리렌더링을 최적화할 수 있다. 앞서 살핀 atoms-in-atom을 활용하여 상태를 세분화하고 리렌더링 효과를 동시에 볼 수 있다.단점제공하는 기능이 많기 때문에 코드 복잡성이 늘어날 수 있다.리렌더링을 최적화하기 위해 추가되는 코드가 많이 늘어날 수 있다.상태를 효율적으로 사용하는데 학습 곡선이 있을 수 있다.Valtio특징Zustand와 같이 주로 모듈 상태용으로 사용된다.리액트와의 통합을 위해 자바스크립트 Proxy 객체를 사용해 변경 불가능한 스냅숏을 가져온다.Proxy를 사용해 리렌더링을 자동으로 최적화한다. 상태 사용 추적이라는 기법을 사용한다.프록시를 사용해 변경 가능한 객체에서 변경 불가능한 객체를 생성한다. 이를 스냅샷이라고 한다.사용방법// 변경 가능한 객체는 proxy를 사용한다const state = proxy({count:0, obj:{title:"hi"}})// state의 count를 1 증가 시킨다.state.count += 1// 불변 객체를 생성하기 위해선 snapshot을 사용한다.const snap = snapshot(state)const snap2 = snapshot(state)state.count += 1const snap3 = snapshot(state)// snap은 Object.freeze로 동결되어 위와 같이 값을 변경할 수 없게 된다.// snap과 state는 동일한 값을 갖지만 서로 다른 참조를 갖는다.const Component = () = { const snap = useSnapshot(state) const inc = () = ++state.count1 return ( span{snap.count}/span button onClick={inc}Add/button / )}스냅샷을 read-only가 가능한 상태(불변 객체)로, proxy를 write가 가능한 상태로 파악하면 쉽다. 리렌더링은 스냅샷에서 변화가 생길 때 트리거된다. 객체 상태의 불변성을 지키기 위해 복잡한 함수와 추가적인 코드가 필요하지 않다. 또한 스냅샷을 통해 자동 리렌더링 최적화가 되기 때문에 셀렉터를 구성하는 추가적인 코드가 필요하지 않다.Valtio는 불변 객체를 생성하기 위해 snapshot 함수를 사용한다. 예제에서 state와 snap, snap2는 {count:1, …}라는 동일한 값을 갖지만, 다른 참조를 갖는다. 반면 snap, snap2는 snap3과 다른 count를 갖는다. 하지만 snap3.obj는 변경되지 않았기 때문에 snap, snap2.obj는 동일하여 snap.obj === snap3.obj가 유지된다. 참조가 동일하다는 것은 메모리를 공유한다는 것이다. Valtio는 필요한 경우에만 스냅샷을 생성하여 메모리 사용량을 최적화한다. 다른 라이브러리에서처럼 파생 상태로 분리하고, 이 상태의 변경을 감지하기 위한 셀렉터 함수가 불필요하다. Valtio는 라이브러리 내부에서 최적화를 실행한다. 스냅샷은 프록시 기반으로, 상태가 실제로 변화되었을 때만 리렌더링을 일으킨다.Todo 리스트 예시 (Jotai의 atoms-in-atom과 동일한 결과물이다)// proxy.tstype Todo = { id: string; title: string; done: boolean}const state = proxy{todos:Todo[]}({todos: []})const createTodo = (title:string) = { state.todos.push({ id: nanoid(), title, done:false })}const editTodo = (id:string, title:string) = { const index = state.todos.findIndex((item) = (item.id === id)) state.todos[index].title = title}const removeTodo = (id:string) = { const index = state.todos.findIndex((item) = (item.id === id)) state.todos.splice(index, i)}const toggleTodo = (id:string) = { const index = state.todos.findIndex((item) = (item.id === id)) state.todos[index].done = !state.todos[index].done}// state의 상태를 직접 변경할 수 있기에 다른 라이브러리와 로직의 차이가 있다.// TodoItem.tsxconst TodoItem = ({id}:{id:string}) = { const todoState = state.todos.find((item) = item.id === id) if(!todoState) { throw new Error("invalid todo id") } const { done, title } = useSnapshot(todoState); return ( div input type="checkbox" checked={done} onChange={() = removeTodo(id)} / span style={{textDecoration: done ? "line-trough" : "none"}} {title}/span input type="text" onChange={(e) = editTodo(id, e.target.value})} button onClick={() = removeTodo(id)} Delete /button /div )}export default memo(TodoItem)// NewTodo.tsxconst NewTodo = () = { const [text, setText] = useState('') const onClick = () = { createTodo(text) } return ( div input onChange={(e) = setText(e.target.value)} / button onClick={onClick}Add/button /div )}// TodoList.tsxconst TodoList = () = { const { todos } = useSnapshot(state) return ( div NewTodo / {todos.map((todo) = ( TodoItem key={todo.id} id={todo.id} / ))} /div )}snapshot은 TodoList와 TodoItem에서 사용되었다. TodoItem 내부의 done, title이 변경되어도 전체 리스트 상태는 변경되지 않는다.라이브러리가 객체 불변성을 지키면서(proxy함수) 직접 객체의 값을 변경할 수 있게 하고, 상태 변경을 감지하여 자동으로 리렌더링을 최적화한다 (useSnapshot)앞서 살펴본 코드와 비교해보면 코드량이 상당히 줄어든 것을 확인할 수 있다. 특히 리렌더링을 위해 모든 셀렉터를 수동으로 작성해야 하는 zustand와 비교했을 때 코드의 양이 확연히 줄어든다. 또한 객체 불변성을 유지하면서 상태 업데이트를 위해 그저 선언된 상태 proxy에 값을 할당하는 것 zustand, jotai와도 확연히 다르다. proxy는 객체의 deps가 아무리 깊어도 snapshot을 통해 그 변화를 감지할 수 있다.state.user.info.name= “new value” 처럼 깊은 프로퍼티의 변경도 스냡샷은 감지할 수 있다.장점proxy를 writable state, snapshot을 read-only 상태로 활용해 직관적인 상태 관리 로직을 작성할 수 있다.proxy를 사용해 객체에 새로운 값을 할당하는 자바스크립트 문법을 사용하면서 상태의 불변성을 지킬 수 있다.snapshot의 변경으로 리렌더링이 트리거되며, 리렌더링 최적화를 위한 추가적인 코드가 많이 필요하지 않다.단점렌더링 최적화를 내부적으로 처리하기 때문에 디버깅이 어려울 수 있다.Valtio가 snapshot을 내부적으로 처리하기 때문에 상태가 크고 복잡해질수록, 객체 내부의 상태변화를 추적하고 디버깅하는 것이 어려워질 수 있다.이번 글을 통해 차세대 상태 관리 라이브러리들의 차이점과 장단점을 숙지하고, 세 라이브러리가 어떠한 문제의식을 공유하고 어떤 식으로 문제를 해결했는지 알 수 있었다.

2024.03.10

ⓒ All Rights Reserved by teklee

n°52

teklog-recent-post

HTTP 완벽 가이드 - 2 URL과 리소스

익숙한 내용은 간략하게 정리. 읽으면서 궁금한 부분(ssl이나 wss, sse 등 여러 프로토콜)은 더 찾아보고 추가하여 작성했다.URL의 문법http//www.example.com/ product?type=new어떻게 / 어디에서 / 무엇을URL에서 각 부분은 '컴포넌트’라고도 불린다.프로토콜://사용자이름:비밀번호@호스트:포트/경로;파라미터?질의#프래그먼트1. 스킴어떤프로토콜을 사용하여 리소스를 가져올 지 가리킨다가장 앞에 오고, 알파벳으로 표현하며 URL의 다른 부분과 : 로 구별됨http: 일반 URL 포맷을 지키는 Hyper text Transfer Protocol. 기본 포트값 80https: http와 거의 동일. 커넥션 양 끝단에서 암호화하기 위한 보안 소켓 계층Secure Sockets Layer, SSL을 사용. 기본 포트값 443ssl (Secure Sockets Layer):클라이언트와 서버 간의 데이터 전송을암호화하여보안을 강화하는 프로토콜. 초기 인터넷 보안 통신에 널리 사용되었으며, 데이터 도청, 변조 방지 등의 보안 기능을 제공. SSL은 현재 TLS(Transport Layer Security)로 대체됨tls (Transport Layer Security): SSL의 후속 버전으로 개발된 암호화 프로토콜. 웹 브라우저와 서버 간의 통신을 보호하기 위해 설계되었으며,데이터의 기밀성과 무결성 보장을 목표로 한다. TLSㅈ는 인터넷 상의 다양한 애플리케이션에서 기본적인 보안 표준으로 사용되며, 기본 포트값은 SSL과 동일하게 HTTPS의 경우 443을 사용한다.ssh: 보안 셸 프로토콜. 원격 컴퓨터와 안전하게 통신하며, 데이터 암호화와 함께 원격 제어 및 파일 전송을 위해 사용된다. 기본 포트값은 22이며, 암호화된 통신을 제공해 네트워크를 통한 데이터 도청이나 변조를 방지한다. 사용 예)ssh user@example.commailto: 이메일 주소. 이메일은 다른 스킴과는 다르게 동작하기 때문에 mailto URL은 표준과 다르게 표현된다. ex)mailto:sample@my.comftp: 파일 전송 프로토콜. FTP 서버에 파일을 올리거나 내려받기 위해 사용. 일반적인 URL 포맷을 따른다.file: file 스킴은 주어진 호스트 기기에서 바로 접근할 수 있는 파일을 나타낸다. 호스트 생략 시 사용하고 있는 기기의 로컬 호스트가 기본값rtsp, rtspu: 실시간 스트리밍 프로토콜을 통해 읽을 수 있는 오디오 및 비디오와 같은 미디어 소스를 위해 사용. rtspu는 UDP 프로토콜을 사용해 리소스를 읽는다.wss: WebSocket 프로토콜의 보안 버전. 데이터 전송 시SSL/TLS를 통해 암호화하여 보안을 강화. 웹 브라우저와 서버 간의 양방향 통신 채널을 안전하게 제공. 실시간 웹 애플리케이션에서 빠르고 지속적인 데이터 교환을 필요로 할 때 사용. 기본 포트값은 443sse: 서버로부터 클라이언트로 실시간 데이터 스트림을 보내는단방향 통신 프로토콜. 주로 웹 애플리케이션에서서버가 실시간으로 정보를 푸시할 때 사용. (notification 알람). HTTP를 기반으로 작동한다. 기본 포트값은 HTTP와 동일하게 80(또는 보안 연결의 경우 443)을 사용.2. 사용자 이름, 비밀번호특정 스킴은 리소스에 접근하기 위해 사용자 이름, 비밀 번호를 필요로함. ex) ftp현대 웹 애플리케이션에서 비암호화 프로토콜 사용 시 보안 상의 문제가 생길 수 있어 HTTPS를 통해 OAuth, JWT 등 더 안전한 인증 방법을 사용3. 호스트와 포트호스트는 리소스를 호스팅하는 서버의 호스트 명이나 IP 주소포트는 서버가 열어놓은 네트워크 포트를 가리킨다.4. 경로 (리소스)리소스가 서버의 어디에 있는지 알려준다.계층적 파일 시스템 경로와 유사한 구조를 가진다.‘/’ 문자를 기준으로 경로 조각으로 나뉜다.5. 파라미터 (;)추가적인 매개변수를 정의하는데 사용됨.';'문자를 기준으로 구분됨.현대에는 쿼리 문자열을 대신 사용6. 쿼리 (질의)(?key=value)URL의 끝부분에 위치하며, ‘?’ 문자로 시작하고 ''문자를 사용하여 여러 쿼리를 보낼 수 있다.키와 값의 쌍으로 구성되어리소스에 대한 추가적인 정보를 제공하거나 서버 측에서 특정 작업을 수행하기 위한 매개변수를 전함.ex) 검색 엔진에서 검색어를 전달하거나, 특정 페이지를 요청할 때 필터링 옵션을 지정하는 데 사용된다.7. 프래그먼트 (#)URL의 마지막 부분에 ‘#’ 문자 뒤에 사용클라이언트 측에서만 사용되는 앵커를 지정서버에 전송되지 않는다브라우저가 프래그먼트에 해당하는 HTML 문서 내의 특정 위치로 스크롤한다클라이언트 측에서 사용하는 ‘앵커’단축 URLURL은 상대 URL과 절대 URL로 나뉨웹 클라이언트는 단축 URL을 인식하고 사용함상대 URL은 리소스를 간결하게 기술하는데 사용ex) 많은 브라우저가 URL 일부를 입력하면 나머지를 자동으로 입력해주는 '자동 확장’을 지원상대 URLhtml headtitlesample/title body main h1Hompage/h1 a href={`./gallery.html`}go to my gallery/a상대 URL은 URL을 짧게 표시하는 방식예제의 ./gallery.html은 스킴, 호스트가 기저base url과 동일하다고 추측 가능상대 URL은 프래그먼트이거나 URL의 일부URL을 처리하는 브라우저, 애플리케이션은 상대 url과 절대 url을 변환할 수 있어야함.상대 참조를 해석하기 위해, url을 컴포넌트로 분해하고하고 알고리즘에 따라 절대 경로로 변환할 수 있다. RFC2396URL 확장호스트명 확장 : 브라우저는 단순한 휴리스틱을 사용하여 입력한 호스트명을 전체 호스트명으로 확장. 주소창에 google만 입력하면 자동으로 www., .com을 붙인다.히스토리 확장: 사용자가 방문했던 url을 기록하고 주소창에 앞글자들을 포함하는 완결된 형태의 url을 선택하게 한다.안전하지 않은 문자URL은 여러 프로토콜에서 호환되고 리소스가 유일한 이름을 갖도록 설계됨안전한 전송은 정보가 유실될 위험 없이 url이 전달된다는 의미특정 프로토콜(SMTP)은 특정 문자를 제거할 수도 있는 전송 방식을 사용이러한 일을 피하고자 최대한 알파벳 문자만 포함하도록 함.추후 여러 문자를 포함하도록 하기 위해 안전하지 않은 문자를 안전한 문자로인코딩할 수 있게함.URL 문자 집합초기에는 US-ASCII문자를 사용하도록 함.이스케이프 문자는 US-ASCII에 금지된 문자들로 특정 문자나 데이터를 인코딩할 수 있게 하여 URL의 이동성과 완성도를 높임.인코딩 체계인코딩은 안전하지 않은 문자를 (%)로 시작해 ASCII 코드로 표현되는 두개의 16진수 숫자로 이루어진 이스케이프 문자로 전환ex) ~ = 126 (0x7E) = /sample%7Epath공백 - 32(0x20) = /sample%20path% = 37(0x25) = /sample%25path문자 제한위에서처럼 몇몇 문자는 인코딩을 위해 특별한 사용이 예약되어있다. 본래 목적이 아닌 다른 목적으로 사용 시 인코딩이 필요한 문자들은 다음과 같다.% 인코딩 문자를 표현하기 위해 토큰으로 사용/ 경로를 나누기 위해 사용. 경로 컴포넌트에서 사용… 경로 컴포넌트에서 사용#프래그먼트를 나타네기 위해 사용? 쿼리를 나태내기 위해 사용; 파라미터에 사용: 스킴, 사용자 이름과 비밀번호. 호스트:포트 구획문자로 사용@= 특정 스킴, 쿼리 등에서 사용됨{} | \ [] 게이트웨이와 같은 여러 전송 에이전트에서 불안전하게 사용하여 제한됨 " 안전하지 않음. URL 범위 밖에서 역할이 있는 문자여서 인코딩 필요 ex) ‘http://sample.com’0x00-0x1F, 0x7F 제한됨. 이 16진수 범위 내 문자들은 인쇄되지 않는 US-ASCII 문자0x7F 이 범위의 문자들은 7비트 USC-ASCII문자가 아님

2024.03.07

ⓒ All Rights Reserved by teklee

n°51

teklog-recent-post

Fluent React 6 - React Server Component

Fluent React에서 서버 컴포넌트에 대해 깊이있게 다루고 있어 학습한 내용을 정리. 책과 공식문서, 예시 코드를 작성해가며 학습했고, 유익한 내용이 많았다. 사이드 프로젝트를 RSC를 사용하여 개발했는데, 이 챕터를 학습하고 이해가 더 깊어진 것 같다.(italic체는 Fluent React의 인용입니다)React Server Component리액트 컴포넌트는서버에서 실행되고 클라이언트측의자바스크립트 번들에서는 제외되는새로운 타입의 컴포넌트를 도입한다. RSCs introduce a new type of component that “runs” on the server and is otherwise excluded from the client-side JavaScript bundle.리액트 서버 컴포넌트는 말그대로 서버에서만 실행되는 컴포넌트이다. 도입된 이유는 성능, 코드의 효율성, 사용자 경험을 향상시키기 위한 목적이라고 한다. 결과적으로 서버 컴포넌트를 통해 SPA의 장점과 서버 렌더링된 MPA의 장점을 동시에 취할 수 있다고 한다.Benefits성능 개선:서버 컴포넌트는 오직 서버 측에서 실행되며, 우리가 제어할 수 있는 컴퓨팅 파워를 가진 기계(서버)에서 실행됩니다. 이는 우리가 예측할 수 없는 클라이언트 장치에서 계산을 수행하지 않기 때문에 더 예측 가능한 성능을 의미합니다.(클라이언트로 전송할 번들에서 제외하기 때문에 오는 이점도 있다.)보안:서버 컴포넌트는 보안된 서버 환경에서 실행되므로, 보안 작업을 수행할 때 토큰과 기타 보안 정보가 유출될 걱정 없이 수행할 수 있습니다.효율성:서버 컴포넌트는 비동기적일 수 있습니다. 우리가 서버에서 실행을 완료할 때까지 기다릴 수 있기 때문에, 그들을 네트워크를 통해 클라이언트와 공유하기 전에 기다릴 수 있습니다.기본적으로 훅을 사용하는 오늘날의 리액트 컴포넌트를 떠올리면 서버 컴포넌트를 '서버에서 실행되는 함수’로 이해하면 낯설 것이 없다. 하지만 이 컴포넌트가 어떻게 브라우저에서 변환되고, 이 컴포넌트를 실행하는 서버에서는 어떤 일이 일어나는 것일까?How It works?서버 컴포넌트는 기본적으로 리액트 엘리먼트를 반환하는 함수이다. 서버 컴포넌트가 어떻게 서버에서 브라우저에서 렌더링 가능한 컴포넌트로 변환되는지 순서대로 살펴보자.JSX의 트리: { $$typeOf: Symbol("react.element"), type: "div", props: { children: [ { $$typeOf: Symbol("react.element"), type: "h1", props: { children: "hi!" } }, { $$typeOf: Symbol("react.element"), type: "p", props: { children: "I like React!" } }, ], },}다음은 서버 컴포넌트가 클라이언트에 전달되는 간략화한 과정이다.서버에서 JSX의 트리는 리액트 엘리먼트 트리로 변환된다.서버에서 이 엘리먼트 트리는 string 혹은 stream으로 변환된다.이는 직렬화된 거대한 JSON object 형태로 클라이언트에 보내진다.클라이언트 상의 리액트는 이 JSON을 parse하여 렌더링한다이 과정을 코드와 함께 살펴보면 다음과 같다. 아래 예시 코드는 서버사이드에서 서버 컴포넌트를 변환하는 과정을 모사한 코드다. (*인용된 예시는 이해를 위한 code snippet이며, 실제 구현과 차이가 있다)// server.jsconst express = require("express");const path = require("path");const React = require("react");const ReactDOMServer = require("react-dom/server");const App = require("./src/App");app.use(express.static(path.join(__dirname, "build")));app.get("*", async (req, res) = { // 1 - 서버컴포넌트가 리액트 엘리먼트 트리로 변환된다 const rscTree = await turnServerComponentsIntoTreeOfElements(App /); /* turnServerComponentsIntoTreeOfElements * 함수 내부에서 서버 컴포넌트를 리액트 앨리먼트로 변환 */ // 2 - await가 완료된 컴포넌트 트리를 HTML로 변환한다 const html = ReactDOMServer.renderToString(rscTree); // renderToPipeableStream로 사용하는 것을 권장한다.- 이부분에서 rscTree의 리액트 엘리먼트가 직렬화된다. // Send it res.send(` !DOCTYPE html html head titleMy React App/title /head body div id="root"${html}/div script src="/static/js/main.js"/script /body /html `);});app.listen(3000, () = { console.log("Server listening on port 3000");});turnServerComponentsIntoTreeOfElements 비동기 함수 호출을 통해 서버 컴포넌트를 모두 엘리먼트로 변환ReactDOMServer.renderToString, renderToPipeableStream는 SSR을 위해 필요한 리액트DOM 서버 메서드이지만, 이 예시에서는 RSC와 함께 사용되는 것을 볼 수 있다. 이 부분에서 저자는 RSC가 SSR을 단순히 대체하기 위한 것이 아니며, 상호보완하기 위한 것임을 강조한다.root div 안에 직렬화된 html을 넣어 res.send를 통해 클라이언트로 보낸다클라이언트에서는 HTML 마크업이 완성된 형태로 전달된다.서버 컴포넌트를 리액트 트리로 변환하기1번의 turnServerComponentsInto... 함수는 인자로컴포넌트를 받고 있다. 이 함수에서 서버 컴포넌트를 전송 가능하게 변환하기 때문에 예시가 이어진다. (*마찬가지로 인용된 예시는 code snippet이다. RSC renderer를 이해하기 위한 코드이다.)async function turnServerComponentsIntoTreeOfElements(jsx) { if ( typeof jsx === "string" || typeof jsx === "number" || typeof jsx === "boolean" || jsx == null ) { // A - Don't need to do anything special with these types. return jsx; } if (Array.isArray(jsx)) { // B - Process each item in an array. return await Promise.all(jsx.map(renderJSXToClientJSX(child))); } // If we're dealing with an object if (jsx != null typeof jsx === "object") { // C If the object is a React element, if (jsx.$$typeof === Symbol.for("react.element")) { // C-1 `{ type } is a string for built-in components. if (typeof jsx.type === "string") { // This is a built-in component like div /. // Go over its props to make sure they can be turned into JSON. return { ...jsx, props: await renderJSXToClientJSX(jsx.props), }; } if (typeof jsx.type === "function") { // C-2 This is a custom React component (like Footer /). // Call its function, and repeat the procedure for the JSX it returns. const Component = jsx.type; const props = jsx.props; const returnedJsx = await Component(props); return await renderJSXToClientJSX(returnedJsx); } throw new Error("Not implemented."); } else { // D - This is an arbitrary object (props, or something inside them). // It's an object, but not a React element (we handled that case above). // Go over every value and process any JSX in it. return Object.fromEntries( await Promise.all( Object.entries(jsx).map(async ([propName, value]) = [ propName, await renderJSXToClientJSX(value), ]) ) ); } } throw new Error("Not implemented");}이 복잡한 함수의 역할은 매개변수로 받은안의모든 서버 컴포넌트를 리액트 엘리먼트 트리로 변환하는 것이다. 조금 더 단순화하자면,내의 모든 컴포넌트를 분기처리하여 그에 맞는 리엑트 엘리먼트를 반환하는 함수이다.주석의 A, B, C-1, C-2, D로 나누어 설명하면 다음과 같다.A - 기본 타입 처리JSX가 기본 타입인 경우다. 기본 타입이란 문자열, 숫자, 불리언 또는null을 의미. 이러한 유형은 추가 처리 없이 그대로 반환.div{'Hello'}/div에서 'Hello'는 문자열로 직접 반환됨B - 배열 처리JSX가 배열인 경우.Promise.all을 사용하여 배열 내 각 JSX 항목을 동시에 처리하고, 결과적으로 모든 항목이 리액트 엘리먼트로 변환된 배열을 반환.ex: Fragment로 감싸진 컴포넌트의 경우, children을 array로 감싼다.[ divhi/div, h1hello/h1, spanlove u/span, (props) = p id={props.id}lorem ipsum/p,];C-1 - 리액트 엘리먼트 / 내장 컴포넌트JSX가 리액트 엘리먼트이며, 그 타입(jsx.type)이 문자열인 경우(예:div /,span /등의 HTML 태그), 이 분기에서 처리됨. 여기서 해당 엘리먼트의 props를 JSON으로 변환할 수 있는지 확인하고, 모든 자식 컴포넌트 또한 리엑트 엘리먼트로 변환C-2 - 사용자 정의 컴포넌트SX가 함수형 또는 클래스 컴포넌트인 경우, 해당 컴포넌트를 호출하고, 반환된 JSX를 다시 처리. 그 결과로 반환된 JSX에 대해 재귀적으로 이 함수를 호출. 이 과정은 사용자 정의 컴포넌트가 반환하는 모든 자식 컴포넌트를 리액트 엘리먼트 트리에 포함. ex:D - 임의 객체(jsx가 객체이지만 리액트 엘리먼트가 아닌 경우) 주로 컴포넌트의props객체를 처리할 때 발생.props객체 내의 모든 값에 대해 재귀적으로 함수를 호출하여, 모든 가능한 JSX를 리액트 엘리먼트로 변환.리엑트 엘리먼트 트리를 직렬화하기 Serialization앞선 과정을 통해 직렬화serialization가 가능한 리액트 트리로 서버 컴포넌트를 변환하였다. 이제 이 엘리먼트 트리를 HTML 마크업으로 변환하여 서버에 보내주기 위해 직렬화를 해야한다. 직렬화serialization란 리액트 엘리먼트를 문자열로 바꾸는 과정이다. 이는 RSC 뿐만 아니라 일반적인 SSR에서도 필요한 과정이다. 서버에서 페이지를 HTML로 전달하는 것이 SSR의 핵심이기 때문이다. 직렬화는 ReactDOMServer의 다음 메서드로 구현한다.renderToString : 리액트 컴포넌트를 HTML 문자열로 렌더링. SSR에서 이 메서드를 사용하여 초기 페이지 로드 시 필요한 전체 HTML 마크업을 서버에서 생성할 수 있다. 이렇게 생성된 HTML은 클라이언트로 전송되어 초기 페이지 렌더링에 사용됨.renderToPipeableStream : React 18에서 새롭게 도입. 리액트 컴포넌트를 HTML로 변환하되, 결과를 Node.js의 스트림stream을 통해 비동기적으로 전송. 전체 페이지 대신 페이지의 일부분을 먼저 클라이언트에게 전송하여 빠르게 표시할 수 있게 해주어 사용자가 내용을 보기 시작하는 시간을 단축할 수 있다.이 두 메서드에 대한 내용은 다음 글에서 더 자세히 살펴보도록 하자. 그렇다면 이같은 과정을 통해 서버에서 제공함으로써 얻는 이점은 무엇일까?페이지 로딩시간 개선:서버가 가능한 한 빨리 클라이언트에게 완성되어 표시 준비가 된 HTML 페이지를 보낼 수 있게 해줍니다. 이는사용자가 콘텐츠와 더 빨리 상호작용을 시작할 수 있게 함으로써 페이지의 인지된 로딩 시간을 개선합니다.일관된 초기 렌더링:React 요소를 HTML 문자열로 직렬화하는 것은 환경에 관계없이 일관되고 예측 가능한 초기 렌더링을 가능하게 합니다. 생성된 HTML은 정적이며 서버나 클라이언트에서 렌더링되었을 때 동일하게 보입니다. 이러한 일관성은 부드러운 사용자 경험을 보장하는 데 필수적이며,초기 렌더링이 최종 렌더링과 다를 경우 발생할 수 있는 깜빡임이나 레이아웃 이동을 방지합니다.Hydration 과정 개선:마지막으로, 직렬화는 클라이언트 측에서의hydration 과정을 용이하게 합니다. 자바스크립트 번들이 클라이언트에 로드될 때, React는 이벤트 핸들러를 부착하고 모든 동적 콘텐츠를 채워 넣어야 합니다. 초기 마크업으로 직렬화된 HTML 문자열을 갖는 것은 React가 작업을 시작할 수 있는 견고한 기반을 보장함으로써, 재수화 과정을 더 효율적이고 신뢰할 수 있게 만듭니다.사실 이는 리액트의 일반적인 SSR버사이드 렌더링의 이점과도 동일하다. 여기서 한가지 의문점이 들었다.서버 컴포넌트 또한 서버사이드에서ReactDOMServer.renderToString또는renderToPipeableStream같은 SSR에서 사용되는 함수를 사용하여 페이지 레벨에서 HTML을 직렬화하는 과정을 거친다면 결국 RSC는 SSR이 아닌가?그렇다면 RSC와 SSR과 어떻게 구분되는가?RSC SSR이러한 의문점을 해소해보자.공통점서버사이드 렌더링: 두 방식 다 서버에서 React 컴포넌트를 HTML 문자열로 변환하는 과정을 포함. 이는ReactDOMServer.renderToString또는renderToPipeableStream함수를 사용하여 수행.성능 최적화: RSC와 SSR 모두 초기 로딩 시간 단축과 같은 성능 개선에 이점이 있음차이점렌더링 단위와 목적의 차이SSR:페이지 전체 단위. 페이지 전체를 서버에서 렌더링하고 직렬화.RSC:컴포넌트 단위. 서버/클라이언트 컴포넌트 구분하고, 이 중 서버 컴포넌트만을 서버에서 렌더링하고 직렬화함RSC는 개발자가 서버 컴포넌트와 클라이언트 컴포넌트를 명확하게 분리하는 "새로운 멘탈 모델"을 권장.서버 컴포넌트: 데이터 페칭과 같은 서버 상의 로직클라이언트 컴포넌트: 이벤트 등 동적 상호작용클라이언트 사이드 최적화: RSC는 클라이언트로 전송되는 자바스크립트의 양을 최소화하여 성능을 개선함. (서버 컴포넌트는 클라이언트 번들에 포함되지 않음)기술적 요구사항:RSC는 차세대 번들러가 필요함.(서버 / 클라이언트 컴포넌트를 위한 별도의 모듈 그래프 생성/관리 필요)"차세대 번들러"라는 조금 낯선 내용을 포함하지만, 이어지는 챕터를 통해 더 명확히 이해할 수 있다.서버 컴포넌트의 규칙들서버 / 클라이언트 컴포넌트의 구분앞서 서버 컴포넌트와 클라이언트의 구분을 간략히 살폈다. 서버 컴포넌트를 도입하면서 이러한 구분이 왜 필요한게 됐는지 살펴보자.function Counter() { const [count, setCount] = useState(0); return ( div h1Hello friends, look at my nice counter!/h1 pAbout me: I like pie! Sign my guest book!/p pCount: {count}/p button onClick={() = setCount(count + 1)}+/button /div );}버튼을 클릭하면 count의 숫자에 1이 더해지는 컴포넌트이다. 이 컴포넌트는 다음 이유에서 서버 컴포넌트가 될 수 없고, 클라이언트 컴포넌트여야만 한다.로컬 상태의 사용:useState는 클라이언트 사이드에서만 사용하는 API입니다. 이는 서버가 count의 초기값을 알 수 없으므로 초기 HTML을 렌더링할 수 없다는 것을 의미합니다. 이는 클라이언트가 인터랙티브 UI를 렌더링하기 전에 서버가 초기 HTML을 렌더링해야 하기 때문에 문제가 됩니다.서버풀 환경에서는 "상태" 개념이 여러 클라이언트 간에 공유됩니다. 그러나 React에서 RSC 도입 이전까지,상태는 현재 애플리케이션에 국한되었습니다. 이 차이는 위험을 초래합니다.여러 클라이언트 간에 상태가 유출될 수 있으며, 이는 민감한 정보를 노출시킬 수 있습니다. 이러한 차이점과 관련된 보안 위험으로 인해 RSC는 서버 측에서 useState의 사용을 지원하지 않습니다. 이는서버 측 상태가 클라이언트 측 상태와 근본적으로 다르기 때문입니다. 또한, useState에서의 디스패처(setState) 함수는 클라이언트로 전송하기 위해 네트워크를 통해 직렬화되어야 하지만, 함수는 직렬화할 수 없으므로 이는 불가능합니다.브라우저 API 사용onClick 또한 클라이언트 사이드에서만 사용하는 API입니다. 이는서버가 인터랙티브하지 않기 때문입니다:서버에서 실행 중인 프로세스를 클릭할 수 없으므로서버 컴포넌트에서 onClick은 불가능한 상태입니다. 또한, 서버 컴포넌트의 모든 props는 직렬화 가능해야 합니다. 왜냐하면 서버가 props를 직렬화하여 클라이언트에게 전송해야 하고,함수는 직렬화할 수 없기 때문입니다.여러 이유가 있지만, 근본적으로 분리할 수 밖에 없는 이유가 충분히 설명된다. 위의 내용을 요약하자면 다음과 같다.서버 상태와 클라이언트의 상태는 분리되어야 한다.클라이언트에서만 사용되어야 하는 로컬 상태를 서버에서 공유하게 된다면, 여러 클라이언트에 상태가 노출될 수 있다.서버에서는 브라우저 API를 사용할 수 없다.상태를 업데이트하는 dispatch(state setter)나 브라우저 이벤트 핸들러 함수는 직렬화할 수 없다.이같은 이유를 살펴보니 어느정도 납득이 된다. 그럼 이같은 구분이 가져오는 이점에 대해서도 살펴보자.// Server Componentfunction ServerCounter() { return ( div h1Hello friends, look at my nice counter!/h1 p About me: I like to count things and I'm a counter and sometimes I count things but other times I enjoy playing the Cello and one time at band camp I counted to 1000 and a pirate appeared /p InteractiveClientPart / /div );}// Client Component"use client";function InteractiveClientPart() { const [count, setCount] = useState(0); return ( div pCount: {count}/p button onClick={() = setCount(count + 1)}+/button /div );}이 예시는 서버 컴포넌트와 클라이언트를 분리한다. ... 카운터 애플리케이션()의 작은 부분을 분리해내어 인터랙티브하도록 했습니다. 앱의이 부분만이 실제로 사용자에게 JavaScript 번들의 일부로 전달될 것입니다. 나머지 부분은 전달되지 않습니다. 그 결과로, 우리는 네트워크를 통해 훨씬 작은 JavaScript 번들을 전송하게 되며, 이는 더 빠른 로딩 시간과 사용자에게 더 나은 성능을 의미합니다. 이는 CPU 측면에서, JavaScript를 파싱하고 실행하는 데 필요한 작업이 줄어들며, 네트워크 측면에서, 다운로드해야 하는 데이터가 줄어드는 측면 모두에서 해당됩니다. 이같은 이점에서 클라이언트 사이드 번들에서 코드를 제외하기 위해 서버에서 안전하게 렌더링할 수 있는 만큼 많이 렌더링하고자 하는 이유입니다.서버/클라이언트 컴포넌트의 분리 장점을 요약하면 다음과 같다.기존 컴포넌트에서 서버 컴포넌트는 HTML로 클라이언트로 전달분리된 클라이언트 컴포넌트는 기존보다 더 작은 크기로 클라이언트 js 번들의 일부로 전달이를 통해 단축과 서버, 클라이언트 상의 이점이 생김서버에서 더 작은 크기의 JS 번들을 전송하여 다운받을 데이터의 크기가 줄어듬클라이언트에서 더 작은 크기의 JS 파싱하고 실행할 작업이 줄어듬서버 컴포넌트의 간략한 작동 원리, 도입 배경과 함께 살펴보니 더 납득이 간다. 이제 본격적으로 서버 컴포넌트를 사용하기 위한 방법과 지켜야할 규칙을 간략히 살펴보자.하지만 다음 예제에서 ServerCounter 컴포넌트는 이미 클라이언트 컴포넌트를 포함하고 있다. InteractiveClientPart 클라이언트 컴포넌트가 서버사이드에서 이뤄지는 과정은 길지만 흥미롭다.TLDR;서버에서 만든 렌더링 트리는 클라이언트 컴포넌트의 자리에 placeholder를 렌더링한다이 placeholder는 "모듈 참조"에 대한 내용이다.모듈 참조는 클라이언트 번들러가 참조할 모듈을 표시한다.서버 렌더링된 RSC가 클라이언트로 전달되면, 클라이언트의 React가 이를 참조된 모듈로 교체한다교체된 모듈(컴포넌트로) 클라이언트 상에서 렌더링이 일어난다.How This Works?...React는 언제 클라이언트 컴포넌트를 가져오고 실행해야 하는지 어떻게 알까요? 이를 이해하기 위해, 전형적인 React 트리를 고려해야 합니다. 우리의 카운터 예제를 사용하여 이를 해보겠습니다.오렌지 컴포넌트는 서버에서 렌더링되고, 초록색 컴포넌트는 클라이언트에서 렌더링됩니다. 트리의 루트가 서버 컴포넌트인 경우,전체 트리는 서버에서 렌더링됩니다. 그러나InteractiveClientPart 컴포넌트는 클라이언트 컴포넌트이므로 서버에서 렌더링되지 않습니다.대신,서버는 클라이언트 컴포넌트를 위한 플레이스홀더를 렌더링하는데, **이는 클라이언트 번들러가 생성한 특정 모듈에 대한 참조입니다. 이 모듈 참조는 본질적으로 “트리의 이 지점에 도달하면, 이 특정 모듈을 사용할 시간이다”라고 말합니다.RSC와 클라이언트 컴포넌트 그림 클라이언트와 서버 컴포넌트를 보여주는 컴포넌트 트리 모듈은 반드시 항상 지연 로딩되는 것만은 아니지만, 번들러가 사용자에게 전달하는 번들에 많은 모듈을 추가하기 때문에,초기 번들에서도 로드될 수 있습니다. 실제로는 getModuleFromBundleAtPosition([0,4]) 또는 비슷한 것일 수 있습니다. 포인트는 서버가 올바른 클라이언트 모듈에 대한 참조를 보내고,클라이언트 측 React가 이 공백을 채운다는 것입니다.이 일이 발생하면,React는 모듈 참조를 실제 클라이언트 번들의 모듈로 대체합니다.이것은 약간의 단순화이지만, 메커니즘을 충분히 이해하는 데 도움이 될 것입니다. 그런 다음 클라이언트 컴포넌트는 클라이언트에서 렌더링되며, 클라이언트 컴포넌트는 평소처럼 상호 작용할 수 있습니다. 이것이RSC가 차세대 번들러를 요구하는 이유입니다: 서버와 클라이언트 컴포넌트를 위한 별도의 모듈 그래프를 생성할 수 있어야 합니다.Serializability Is King 직렬화 가능성이 킹이다서버 컴포넌트에서는 모든 props가 직렬화 가능해야 한다.앞서 dispatch, 이벤트 핸들러가 서버 컴포넌트에 작성될 수 없는 이유와 동일하다. 서버에서 props를 직렬화하여 클라이언트에게 전송헤야하기 때문이다. 서버 컴포넌트에서 props는 함수나 다른 비직렬화 가능한 값일 수 없다.// error!function ServerComponent() {return ClientComponent onClick={() = alert("hi")} /;}이 컴포넌트는 오류를 발생시킨다. 이제 onClick prop을 ClientComponent 내부에 캡슐화하여 서버와 클라이언트 컴포넌트를 분리해야 한다.+추가 내용RSC에서 사용할 수 없는 컴포넌트 패턴들*이 규칙은 5장에서 논의한 렌더 props 패턴을 사실상 구식으로 만듭니다.*ParentComponent{props = ChildComponent {...props} /}/ParentComponent서버에서 클라이언트로 컴포넌트의 props를 전송하기 위해 모든 props가 직렬화 가능해야 하기 때문에 render props 패턴은 RSC에서 사용이 불가능하다. 함수는 직렬화할 수 없는 객체이고, render props 패턴에서는 함수를 prop으로 전달하기 때문이다.물론 클라이언트 컴포넌트 사이에선 여전히 사용 가능할 것이다. 개발하려는 앱의 구조에 따라 서버, 클라이언트 컴포넌트의 구성에 따라 더 적절한 패턴이 있을 것이기에, 단정짓기는 어렵다. 다만 앞으로 RSC가 훅이 처음 도입되어 오늘날 자리잡은 것만큼 일반적으로 사용된다면, 여러 컴포넌트 설계 패턴에서도 변화가 있을 것이 예상된다.부수효과를 일으키는 훅 금지No Effectful Hooks앞서 살펴본 것처럼 컴포넌트가 실행되는 서버와 클라이언트는 환경이 다르다. 서버에는 window 객체, DOM, 그에 기반한 이벤트와 사용자의 인터렉션이 없다. 따라서 이를 처리하는 훅(useState, useEffect) 또한 서버 컴포넌트에서 사용할 수 없다.Next.js와 같은 일부 프레임워크는 서버 컴포넌트에서 모든 hooks의 사용을 완전히 금지하는 린트 규칙을 가지고 있지만, 이것이 완전히 필요한 것은 아닙니다.RSC는 상태useState, 효과useEffect 또는 브라우저 전용 API에 의존하지 않는 hooks를 사용할 수 있습니다. 예를 들어, useRef hook는 상태, 효과 또는 브라우저 전용 API에 의존하지 않기 때문에 서버 컴포넌트에서 사용할 수 있습니다.그러나 이러한 제한이 모두 나쁜 것은 아니며, 이는 우리가 컴포넌트를 더 안전하게 다루게끔 유도합니다.useRef는 서버 컴포넌트에서 사용할 수 있다고는 한다! 몰랐지만.. 사용할 일이 있을까 싶다.(서버) 상태가 (클라이언트)상태는 아니다 State is Not State앞서 살펴봤듯이 서버 컴포넌트의 상태는 클라이언트 컴포넌트의 상태와 동일하지 않다. 그 이유 또한 살펴봤다. 각 컴포넌트가 렌더링되는 환경이 다르기 때문이다. 또한 서버 상의 상태가 여러 클라이언트에 공유될 수 있기 때문에, 보안 상의 이유로도 두 컴포넌트는 분리되야 한다고 했다.서버 컴포넌트는 서버에서 렌더링되고, 클라이언트 컴포넌트는 클라이언트(브라우저)에서 렌더링됩니다. 이는 서버 컴포넌트의 상태가 여러 클라이언트에 공유될 수 있음을 뜻합니다. 서버와 클라이언트의 관계는 유니캐스트(하나의 클라이언트, 하나의 상태) 대신 브로드캐스트 관계이기 때문에, 클라이언트 간에 상태가 유출될 위험이 높습니다.여러 클라이언트가 결국 동일한 리소스로 같은 서버 컴포넌트를 요청하기 때문에 상태가 공유될 위험성이 있다는 말이다. 따라서 상태와 관련된 useState 또는 useReducer 또는 상태state가 필요한 모든 컴포넌트는 클라이언트 컴포넌트가 적합하다.클라이언트컴포넌트는 서버 컴포넌트를 import 할 수 없다.// error!"use client";import { ServerComponent } from "./ServerComponent";function ClientComponent() {return (divh1Hey everyone, check out my great server component!/h1ServerComponent //div);}import { readFile } from "node:fs/promises";export async function ServerComponent() {const content = await readFile("./some-file.txt", "utf-8");return div{content}/div;}- 서버 컴포넌트는Node.js API `"node:fs/promises"` 모듈에서 `readFile` 함수를 import한다.- 클라이언트 컴포넌트는 서버 컴포넌트를 import하여 브라우저에서 사용할 수 없는 서버 컴포넌트의 import가 불가능하다. 서버에서 브라우저 API를 사용할 수 없는 것과 마찬가지다.- Node.js API 사용만이 아니더라도, 서버에서 먼저 실행되는 서버 컴포넌트를 클라이언트 컴포넌트에서 import 하는 것은 서버/클라 분리의 원칙상으로도 어긋난다. (서버 컴포넌트를 클라이언트 번들에 포함하려는 것이기 때문에)Workaround"use client";function ClientComponent({ children }) {return (divh1Hey everyone, check out my great server component!/h1{children}/div);}import { ServerComponent } from "./ServerComponent";async function TheParentOfBothComponents() {return (ClientComponentServerComponent //ClientComponent);}이는 클라이언트 컴포넌트에서 서버 컴포넌트를 명시적으로 import하지 않기 때문에 작동한다.- 부모 서버 컴포넌트가 서버 컴포넌트를 클라이언트 컴포넌트에 props.children으로 전달.- (클라이언트) 번들러는 import 문에만 주의를 기울이고 prop 구성에는 주의를 기울이지 않기 때문에 가능하다클라이언트 컴포넌트 나쁘지만은 않다서버 컴포넌트가 도입되기 전까지, 클라이언트 컴포넌트는 우리가 React에서 가지고 있던 유일한 유형의 컴포넌트였습니다. 이는 우리의 기존 컴포넌트들이 모두 클라이언트 컴포넌트라는 것을 의미하며, 그것은 괜찮습니다. 클라이언트 컴포넌트는 나쁘지 않으며 사라지지 않을 것입니다. 그들은 여전히 React 애플리케이션의 핵심이며, 우리가 작성할 가장 일반적인 유형의 컴포넌트입니다. 이 주제에 대해 어느 정도 혼란이 있었고, 서버 컴포넌트가 클라이언트 컴포넌트의 우월한 대체물로 일부에 의해 인식되었기 때문에 여기서 이를 언급합니다. 이것은 사실이 아닙니다.서버 컴포넌트는 클라이언트 컴포넌트에 추가하여 사용할 수 있는 새로운 유형의 컴포넌트이지만, 클라이언트 컴포넌트의 대체물은 아닙니다.이번 챕터에서 서버 컴포넌트 만을 깊게 다루었지만, 사실 아직 과도기의 느낌도 받는다. 뒤에 이어지는 서버 액션의 경우 이를 구현하는 새로운 훅 api가 (비공식적으로) 공개되었고, 리액트 컴파일러, use 훅 등등.. 변경 사항은 많아 보인다. 하지만 리액트의 강력한 점은 이전 버전의 코드와 호환이 가능한게 아닌가. 안정화가 될 때까지는 여전히 SPA, SSR 등을 사용하고 있을 것으로 예상된다.서버 액션 Server Action서버 액션과 use server는 아직 Canary이다. (관련한 PR도 계속 추가되고 있고, 많은 예측과 논의가 오가고 있다.) 아직은 실험적인 내용이기에, 먼저 기본적인 개념을 간략히 이해하는데 집중해보자.RSC는 클라이언트 측 코드에서 호출할 수 있는 서버 측 함수를 표시하는 새로운 지시어 "use server"와 함께 작동합니다. 우리는 이러한 함수들을 서버 액션이라고 부릅니다. ...어떤 비동기 함수든 그 몸체의 첫 줄에 "use server"를 가질 수 있으며, 이는 React와 번들러에게 이 함수가 클라이언트 측 코드에서 호출될 수 있지만 서버에서만 실행되어야 한다는 것을 알립니다. 클라이언트에서 서버 액션을 호출할 때, 전달된 모든 인수의 직렬화된 복사본을 포함하는 네트워크 요청을 서버로 만듭니다. 서버 액션이 값을 반환하면, 그 값은 직렬화되어 클라이언트에 반환됩니다."use server"- Canary"use server"와 함께 사용된 함수를 Server Action이라고 한다."use server"export const getSampleData = async () = ...export const postFormSampleData = async () = ...RSC에서 서버와 클라이언트 간의 통신을 간소화하고, 서버 측 연산의 재사용성을 높이기 위해 도입'use server'는 함수가 클라이언트 측에서 호출되지만, 서버에서만 실행되어야 할 때 사용해당 함수(서버 액션)는 클라이언트에서 서버로 인수를 전달하며 호출될 수 있으며, 실행 결과만 클라이언트로 반환서버 액션의 매개 변수와 반환값은 직렬화될 수 있음동적인 데이터 처리나 민감한 데이터의 처리를 서버에서 수행할 수 있음클라이언트에필요한 데이터를 효율적으로 요청 (useEffect와 state, 데이터 상태 관리 툴 없이)파일 전체에 'use server'를 적용하여, 해당 파일의 모든 export가 서버 액션으로 표시됨먼저 "use server" 디렉티브는 서버 컴포넌트를 만들기 위한 명령어가 아니다(Next.js의 "use client"의 반대가 아니다)."use server"는 함수에 적용하기 위해 사용한다. 이 함수는 클라이언트 컴포넌트에서 호출한다. 그러므로 Next.js라면, 한 컴포넌트 최상단에 "use client", 함수 안에는 "use server" 두 명령어가 모두 있을 수 있다.목적 : 함수를 "서버에서만" 실행되게끔 한다. 클라이언트는 호출 - (서버) - 반환만 받는다.클라이언트: 클라이언트에서 서버 액션 호출 (=서버로 요청) -서버: (서버에서 함수가 실행 - 서버에서 결과를 반환 )-클라이언트: 클라이언트 컴포넌트에서 응답을 받음 - 함수가 반환한 값에만 접근할 수 있음.앞서 살핀 것처럼 컴포넌트를 서버로 옮긴 것의 이점을, 클라이언트의 함수에 적용한다고 이해하면 되겠다.Server Action in Forms// App.jsasync function requestUsername(formData) { 'use server'; const username = formData.get('username'); const address = formData.get('address'); console.log(username) // ... return response }export default App() { form action={requestUsername} input type="text" name="username" / input type="text" name="address" / button type="submit"Request/button /form }`서버액션을 사용한 Form 컴포넌트의 예시이다.사용자가 form을 작성하고 submit 버튼을 눌러 제출한다.requestUserName이 서버에서 실행된다. 실행 중인 함수 내부의 변수 username, address는 서버에서만 접근할 수 있다. (콘솔은 서버사이드에서 프린트된다)함수가 끝나고 난뒤 클라이언트 측에서는 requestUserNamed의 반환값인 response를 받게된다.클라이언트에서 기존대로 폼 데이터를 제출하는 것과는 다른 패턴이라 낯설다. form 제출에 서버 액션을 사용하여 다음과 같은 이점을 볼 수 있다고 한다.서버 액션은 **폼 액션**으로 사용될 수 있으며, 폼 제출 시 서버 함수를 호출폼에서 서버 액션을 사용하면, 폼 데이터는 자동으로 서버 액션에 전달되어 서버에서 처리 후 결과를 클라이언트로 반환이 방식을 통해, JavaScript 번들이 로드되기 전에도 폼을 제출할 수 있어, 폼의 점진적 개선이 가능앞으로도 변경사항이 있을 수 있는 내용인 것 같다. 기본적인 개념을 인지하고 넘어가자.Outside of Form// LikeButton.jsx"use client";import incrementLike from "./actions";import { useState, useTransition } from "react";function LikeButton() {const [isPending, startTransition] = useTransition();const [likeCount, setLikeCount] = useState(0);const incrementLink = async () = {"use server";return likeCount + 1;};const onClick = () = {startTransition(async () = {// 서버 액션 반환 값을 읽으려면, 반환된 프로미스를 await 해야함const currentCount = await incrementLike(); // 서버 액션 호출setLikeCount(currentCount);});};return (pTotal Likes: {likeCount}/pbutton onClick={onClick} disabled={isPending}Like/button;/);}// actions/incrementLike.js// db를 호출하지 않는 간단한 예시.let likeCount = 0;export default async function incrementLike() {likeCount++;return likeCount;}// 데이터베이스로 직접 요청을 보낼 수도 있다const pool = new Pool({ user: 'dbuser', host: 'database.server.com', database: 'mydb', password: 'secretpassword', port: 5432, });async function incrementLike(postId) {'use server'; // 서버 액션 지시어try {// 백엔드 api로 요청을 보낸다고 가정 시const response = await fetch(`... `, {method: 'POST', ...});// 데이터 베이스에 직접 접근을 가정할 시 (db는 PostgreSQL)const client = await pool.connect();const { rows } = await client.query('SELECT likes FROM posts WHERE id =...const { likeCount } = await response.json();return likeCount; // 증가된 좋아요 수 반환} catch (error) {console.error('Error incrementing like:', error); ...}}이 예시를 통해, form 태그 뿐만 아니라 다양한 클라이언트 컴포넌트의 이벤트에서도 사용가능하다.서버 액션은 폼 외부에서도 사용될 수 있으며, 클라이언트 코드 어디에서나 서버 엔드포인트로 호출될 수 있다.useTransition훅을 사용하여 서버 액션 호출 시 로딩 인디케이터 표시, 낙관적 UI 업데이트, 예상치 못한 오류 처리 등의 기능을 구현할 수 있다.ex) 좋아요 버튼 클릭 시 서버 액션을 호출하여 좋아요 수를 증가시키고, 결과를 클라이언트 상태에 반영리액트 공식문서에는 가장 간단한 예시를 제공하지만, 이론적으로 서버 액션으로 SQL을 작성하여 직접 db에 호출하는 것까지 가능하다. (얼마나 실용적인지는 모르겠다) 이 때문에 서버 액션은 처음 공개되었을 시 많은 반발을 샀던 걸로 기억한다. 그럼에도 가장 큰 변화 중 하나가 아닐까 싶다.서버 액션을 통해 비동기 데이터를 관리하는 클라이언트 컴포넌트가 더 직관적이고 단순해졌다. 서버 액션으로 백엔드 api를 호출하는 방식이 BFF와 생각도 들기도 하여 이점이 없을 것 같진 않다. 하지만 아직은 예측만 해볼 뿐이다.올해 말에 리액트 19가 나온다고 하니, 귀추가 주목된다!관련한 흥미로운 글들 3rd party 상태 관리 라이브러리는 무용해질 것 - 라우팅 기반으로 상태 관리가 바뀔 것이다tanstack-router - 라우팅 기반의 상태 관리, 캐싱

2024.03.06

ⓒ All Rights Reserved by teklee

n°50

About Me 반갑습니다

PASSIONATE ABOUT WEB TECHNOLOGIES & USER INTERFACE. CURRENTLY WORKING AS A FRONT-END DEVELOPER. I AM ABLE TO SEE THE BEAUTY IN CODING AND CONTINUE TO CHALLENGE MYSELF TO CREATE FINER CODES.


AS A FORMER VISUAL ARTIST, I STILL TAKE PHOTOS FROM TIME TO TIME AS WELL. YOU CAN CHECK OUT MY PHOTOS ON

FOR MORE INFORMATION, PLEASE VISIT