기본 콘텐츠로 건너뛰기

React 동시성 이해하기: 입력은 즉시 반응하고 무거운 렌더링은 뒤로 미루는 법

React 동시성 이해하기: 입력은 즉시 반응하고 무거운 렌더링은 뒤로 미루는 법

빠른 답

  • 현재 기준으로는 Concurrent Mode라는 전역 모드를 켜는 방식보다, createRoot 기반의 동시 렌더링과 transition API를 필요한 업데이트에 적용하는 방식으로 이해하는 편이 정확합니다.
  • 입력창 값처럼 즉시 반영되어야 하는 상태는 transition으로 감싸지 않고, 그 입력에 따라 바뀌는 무거운 결과 영역만 낮은 우선순위로 미룹니다.
  • 상태 업데이트를 직접 호출하는 위치라면 useTransition 또는 startTransition을, 이미 전달받은 값의 화면 반영만 늦추고 싶다면 useDeferredValue를 고려합니다.
  • 동시성 API는 느린 계산을 빠르게 만드는 도구가 아니라, 사용자가 먼저 체감하는 렌더링이 막히지 않도록 우선순위를 나누는 도구입니다.

시간 흐름으로 이해하기

검색창에 글자를 입력하고 그 결과로 큰 목록이 다시 렌더링되는 상황을 기준으로 보면 React 동시성은 다음 흐름으로 동작합니다.

입력 발생
사용자가 검색어를 입력합니다.
긴급 업데이트
입력창의 text 상태는 즉시 반영됩니다.
전환 예약
결과 목록을 바꾸는 query 업데이트는 낮은 우선순위로 표시됩니다.
렌더링 조정
결과 렌더링 중 새 입력이 들어오면 React가 기존 작업을 중단하거나 최신 값 기준으로 다시 시작할 수 있습니다.
화면 커밋
렌더링이 완료된 결과만 실제 DOM에 반영됩니다.

여기서 중요한 구분은 입력 상태와 결과 상태를 같은 속도의 일로 보지 않는 것입니다. 사용자는 키를 눌렀을 때 글자가 바로 찍히는지를 먼저 체감하고, 결과 목록은 조금 늦게 따라와도 흐름을 이해할 수 있습니다.

흐름으로 보기

흐름 다이어그램
React 동시성 이해하기: 입력은 즉시 반응하고 무거운 렌더링은 뒤로 미루는 법 흐름 다이어그램

이 순서로 보면 동시성 API를 어디에 둘지 판단하기 쉽습니다. 먼저 입력값을 누가 소유하는지 정하고, 그 입력에서 만들어지는 검색 기준이나 필터 기준을 분리합니다. 이후 렌더링 비용이 큰 리스트, 차트, 상세 패널을 입력 컴포넌트와 떨어뜨려 두면 React가 우선순위를 나눌 여지가 생깁니다.

Concurrent Mode라는 표현이 헷갈리는 이유

예전 글에서 자주 보이는 Concurrent Mode는 “앱 전체에 별도 모드를 켠다”는 인상을 줍니다. 하지만 현재 React 문서 기준으로는 그런 전역 스위치보다 동시 렌더링 기능을 API 단위로 사용하는 설명이 더 잘 맞습니다.

React 18에서는 createRoot를 통해 새 루트 API와 동시 렌더러 기반 기능을 사용할 수 있게 되었고, ReactDOM.render는 deprecated 되었습니다. React 19에서는 ReactDOM.renderReactDOM.hydrate가 제거되어 각각 createRoot, hydrateRoot로 옮겨야 합니다. 2026년 4월 기준 React 공식 문서는 React 19.2를 최신 문서 기준으로 제공합니다.

용어는 다음처럼 나누어 두면 혼란이 줄어듭니다.

  • Concurrent Mode: 과거 실험적 설명이나 오래된 글에서 자주 쓰이던 표현입니다.
  • 동시 렌더링: React가 렌더링 작업을 중단, 재시작, 우선순위 조정할 수 있는 내부 능력입니다.
  • Transition: 특정 상태 업데이트를 덜 급한 렌더링으로 표시하는 API 수준의 표현입니다.
  • Deferred value: 이미 받은 값의 화면 반영을 늦게 따라가도록 만드는 Hook 수준의 표현입니다.

설정 기준 잡기

클라이언트에서 React 앱을 새로 렌더링하는 진입점은 react-dom/clientcreateRoot를 사용합니다. 서버에서 만든 HTML을 이어받아 hydration 하는 앱이라면 hydrateRoot를 사용해야 합니다.

{
  "dependencies": {
    "react": "^19.2.1",
    "react-dom": "^19.2.1"
  }
}

클라이언트 전용 앱의 기본 진입점은 다음처럼 잡을 수 있습니다.

import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";

const container = document.getElementById("root");
const root = createRoot(container);

root.render(createElement(App));

서버 렌더링 결과가 이미 DOM에 들어와 있다면 createRoot로 다시 그리는 대신 hydrateRoot를 사용합니다.

import { createElement } from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.js";

const container = document.getElementById("root");

hydrateRoot(
  container,
  createElement(App, {
    initialQuery: window.__INITIAL_QUERY__ ?? ""
  })
);

이 차이는 동시성 자체보다도 앱 시작 방식에 영향을 줍니다. hydrateRoot가 필요한 곳에서 createRoot를 쓰면 서버에서 만든 DOM을 이어 쓰지 못하고 클라이언트에서 다시 만들 수 있습니다.

데이터 흐름과 상태 소유권

입력 지연은 입력값과 무거운 결과 렌더링이 하나의 상태 변화에 묶일 때 자주 드러납니다. 사용자가 글자 하나를 입력할 때 입력창, 필터 조건, 긴 목록, 빈 상태 메시지, 페이지네이션, 차트가 모두 같은 우선순위로 바뀐다면 작은 이벤트가 큰 렌더링으로 커집니다.

먼저 원본 상태와 파생 값을 나누어 보는 편이 좋습니다. 검색창에 보이는 text는 사용자가 직접 바꾸는 원본 상태입니다. 검색 결과의 기준이 되는 querytext에서 파생되지만, 결과 렌더링을 늦추기 위해 별도의 갱신 타이밍을 둘 수 있습니다.

import { useMemo, useState, useTransition } from "react";

export function SearchPage({ items }) {
  const [text, setText] = useState("");
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleTextChange(event) {
    const nextText = event.target.value;

    setText(nextText);

    startTransition(() => {
      setQuery(nextText);
    });
  }

  const results = useMemo(() => {
    return filterItems(items, query);
  }, [items, query]);

  return (
    <SearchView
      text={text}
      results={results}
      isPending={isPending}
      onTextChange={handleTextChange}
    />
  );
}

function filterItems(items, query) {
  const normalized = query.trim().toLowerCase();

  if (normalized.length === 0) {
    return items.slice(0, 50);
  }

  return items.filter((item) =>
    item.title.toLowerCase().includes(normalized)
  );
}

이 코드에서 setText는 즉시 실행됩니다. controlled input의 값은 사용자의 입력과 직접 연결되어 있기 때문입니다. 반면 setQuery는 결과 목록 렌더링을 유발하므로 transition 안에서 낮은 우선순위로 표시됩니다.

다만 startTransitionfilterItems 같은 CPU 작업 자체를 없애지는 않습니다. 목록이 매우 크거나 계산이 비싸다면 리스트 가상화, 서버 검색, 캐싱, Web Worker, 페이지네이션 같은 별도 최적화가 함께 필요할 수 있습니다.

useTransition과 startTransition을 쓰는 위치

useTransition은 컴포넌트 안에서 전환 상태를 시작하고, 그 작업이 진행 중인지 UI에 표시해야 할 때 사용하기 좋습니다. 탭 전환, 검색 결과 렌더링, 상세 패널 교체처럼 “선택 반응은 먼저, 무거운 화면은 나중”이라는 흐름이 있는 곳에 어울립니다.

import { useState, useTransition } from "react";

export function ProductTabs({ tabs }) {
  const [selectedTab, setSelectedTab] = useState(tabs[0].id);
  const [renderedTab, setRenderedTab] = useState(tabs[0].id);
  const [isPending, startTransition] = useTransition();

  function selectTab(nextTab) {
    setSelectedTab(nextTab);

    startTransition(() => {
      setRenderedTab(nextTab);
    });
  }

  return (
    <ProductTabsView
      tabs={tabs}
      selectedTab={selectedTab}
      renderedTab={renderedTab}
      isPending={isPending}
      onSelectTab={selectTab}
    />
  );
}

여기서 selectedTab은 사용자가 누른 탭을 즉시 보여 주는 상태이고, renderedTab은 실제 무거운 패널을 렌더링하는 기준입니다. 사용자가 탭을 빠르게 바꾸면 React는 이전 패널 렌더링을 끝까지 밀어붙이기보다 최신 선택을 기준으로 다시 렌더링할 수 있습니다.

컴포넌트 바깥의 라우터 유틸리티나 외부 이벤트 핸들러처럼 Hook을 쓰기 어려운 위치에서는 startTransition 함수를 직접 사용할 수 있습니다. 대신 이 방식은 isPending 값을 제공하지 않습니다.

useDeferredValue가 어울리는 경우

useDeferredValue는 내가 상태 업데이트를 직접 감쌀 수 없을 때 유용합니다. 예를 들어 부모가 이미 query를 내려주고 있고, 자식 컴포넌트는 그 값으로 무거운 결과 목록만 렌더링한다고 해 보겠습니다. 자식은 부모의 setQuery 위치를 모르므로 startTransition을 적용하기 어렵습니다.

이때 자식에서 지연된 값을 만들 수 있습니다.

import { useDeferredValue, useMemo } from "react";

export function SearchResultPanel({ query, items }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const results = useMemo(() => {
    return filterItems(items, deferredQuery);
  }, [items, deferredQuery]);

  return (
    <ResultPanelView
      query={deferredQuery}
      results={results}
      isStale={isStale}
    />
  );
}

useDeferredValue는 debounce처럼 고정된 시간을 기다리는 API가 아닙니다. React는 먼저 최신 입력을 반영한 렌더링을 처리하고, 지연된 값을 사용한 렌더링을 백그라운드에서 다시 시도합니다. 공식 문서에서도 useDeferredValue 자체가 네트워크 요청 수를 줄이지는 않는다고 설명합니다.

따라서 서버 검색 요청을 줄이고 싶다면 debounce, 요청 취소, 캐시 전략은 데이터 계층에서 따로 다루는 편이 좋습니다. useDeferredValue는 요청 타이밍보다 “받은 값이 화면에 언제 반영되는가”에 더 가까운 도구입니다.

흔한 안티패턴

첫 번째 안티패턴은 입력값 자체를 transition으로 감싸는 것입니다. React 공식 문서도 transition 업데이트를 text input 제어에 사용할 수 없다고 안내합니다.

function handleTextChange(event) {
  const nextText = event.target.value;

  startTransition(() => {
    setText(nextText);
  });
}

이 코드는 검색 결과가 아니라 입력창 값 자체를 낮은 우선순위로 미룹니다. 보통은 입력 상태인 setText를 즉시 호출하고, 결과 기준값인 setQuery나 무거운 화면 전환만 transition으로 감싸는 편이 흐름을 설명하기 쉽습니다.

두 번째 안티패턴은 transition 안에서 무거운 동기 계산을 직접 실행하는 것입니다. startTransition에 넘긴 함수는 나중에 예약되어 실행되는 함수가 아니라 즉시 호출됩니다.

function handleTextChange(event) {
  const nextText = event.target.value;

  setText(nextText);

  startTransition(() => {
    const nextResults = verySlowFilter(items, nextText);
    setResults(nextResults);
  });
}

이 코드에서 setResults는 transition으로 표시되지만, verySlowFilter 계산은 이벤트 핸들러 안에서 바로 실행됩니다. 계산 자체가 병목이라면 결과 배열을 상태로 저장하기보다 검색 기준값만 상태로 두고, 렌더링 비용과 계산 비용을 따로 줄이는 방법을 검토해야 합니다.

세 번째 안티패턴은 파생 상태를 중복 저장하는 것입니다. itemsquery로 항상 계산할 수 있는 results를 별도 상태로 저장하면 원본과 파생 값의 경계가 흐려집니다. 이 경우 오래된 결과가 남거나, 입력값과 결과 목록이 어긋나는 버그가 생기기 쉽습니다.

현재 기준의 마이그레이션 포인트

React 동시성을 오래된 “모드” 설명으로만 이해하면 마이그레이션 지점이 흐려질 수 있습니다. 현재 기준에서는 루트 API, transition API, deferred value, Suspense가 각각 어떤 역할을 하는지 나누어 보는 편이 좋습니다.

  • React 17 이하 스타일: ReactDOM.render 중심으로 앱을 시작합니다.
  • React 18 기준: ReactDOM.render는 deprecated 되었고, createRoot를 사용하면 새 동시 렌더러 기반 기능을 사용할 수 있습니다.
  • React 19 기준: ReactDOM.renderReactDOM.hydrate는 제거되었고, 각각 createRoot, hydrateRoot로 옮겨야 합니다.
  • SSR 앱: 서버에서 만든 DOM을 이어받아야 하므로 hydrateRoot를 사용합니다.
  • 테스트 코드: React 19에서는 react-test-renderer가 deprecated 되었으므로 구현 세부보다 사용자 관점의 테스트로 옮기는 흐름을 함께 검토할 만합니다.

공식 문서는 다음 링크에서 확인할 수 있습니다.

React 동시성은 상태 관리 기법을 대체하지 않습니다. 오히려 상태 소유권이 분명할수록 효과가 잘 드러납니다. 입력 상태는 입력을 소유한 곳에서 즉시 갱신하고, 그 입력으로 인해 바뀌는 무거운 화면만 낮은 우선순위로 보내는 구조가 동시성 API의 의도를 코드에 잘 남깁니다.

원문 참고

https://www.maeil-mail.kr/question/81

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...