[JS/React] 비동기와 동시성에 대한 고찰 - Intro

Nayoung·2025년 11월 23일

시작하며…

웹은 지속적으로 진화해왔다. 과거에는 PHP와 같이 서버에서 완성된 HTML을 동적으로 생성해 클라이언트에 전달하는 방식의 서버 템플릿 언어가 주류였다.

그러나 SNS와 같은 웹 애플리케이션에서 사용자와의 다양한 상호작용이 늘어나고, 즉각적인 데이터 요청과 응답이 빈번해지면서 클라이언트 측에서 렌더링을 수행하는 React와 같은 클라이언트 사이드 렌더링(CSR) 방식이 주목받기 시작했다. CSR은 동적이고 풍부한 사용자 경험을 제공하지만, 초기 로딩 속도 지연, SEO 최적화 한계라는 문제점들이 존재했다.

이러한 한계를 극복하기 위해 SSR과 CSR의 장점을 결합한 하이브리드 렌더링 방식이 등장했고, 대표적으로 Next.js가 부상했다. 그러나 Next.js 역시 클라이언트에서 동기적이고 블로킹되는 하이드레이션 과정 문제, 그리고 서버에서 완성된 HTML을 내려주는 동적 구조의 한계를 완전히 해소하지는 못했다.

이 문제들을 한 단계 더 진화시킨 것이 React Server Components(RSC)다. RSC는 Node.js와 같은 서버 환경에서 비동기적으로 작성되고 실행되는 렌더링 모델과, "동시성" 개념을 도입한 React 클라이언트 환경의 렌더링을 접목해 보다 향상된 사용자 경험을 제공할 토대를 마련했다.

이처럼 웹은 사용자에게 더욱 편리해지는 동시에 개발자에게는 점점 더 복잡하지만 강력한 표현과 제어를 가능하게 하는 흥미로운 궤적을 그리고 있다.

이번 글에서 중점적으로 다룰 내용은 ‘비동기, 동시성’ 에 대한 탐구이다.

웹의 발전 과정을 보다시피, 프론트엔드의 개발 생태계는 사용자에게 ‘보다 더 나은 경험’을 제공하기 위해 끝없이 변화하고 진화하고 있다. 그리고 지금 변화의 핵심은 싱글 스레드 언어인 자바스크립트로 비동기를 구현하고 동시성을 보장하는 것에 있다고 생각한다.

잠시 동시성 모드를 발표했던 리액트의 공식 아티클 문서를 보자.


📢

What is Concurrent React?

The most important addition in React 18 is something we hope you never have to think about: concurrency.


React uses sophisticated techniques in its internal implementation, like priority queues and multiple buffering. But you won’t see those concepts anywhere in our public APIs.

When we design APIs, we try to hide implementation details from developers. As a React developer, you focus on what you want the user experience to look like, and React handles how to deliver that experience. So we don’t expect React developers to know how concurrency works under the hood.


리액트는 동시성 모드가 사용자 경험을 크게 향상시키는 마법 같은 API 로 소개하면서도 이게 “어떻게” 동작하는지는 리액트 개발자들이 알 필요 없다고 설명한다. 공감하는 바이다.

그래서 이번 글에서는 내부적인 구현 방법보다는 개념적인 설명과 이 것이 실제로 SSR이든 CSR이든 리액트 개발에 무슨 의미가 있는지를 소개하고자한다.

비동기 렌더링의 의의

18버전 이전의 리액트는 모든 렌더링 작업을 한 번에 처리하는 동기 방식이어서, 렌더링 중 긴 작업이 발생하면 UI가 멈추고 입력에 즉각 반응하지 못하는 문제가 있었다. 또한, 우선순위 구분 없이 업데이트를 순차적으로 처리해 중요한 사용자 인터랙션이 지연되는 현상이 빈번했다. 이로 인해 대규모 UI 상태 변화나 복잡한 상호작용에서 부드러운 사용자 경험 제공에 한계가 있었다.

더 와닿도록 사용자 input 입력 시마다 DOM 에 셀이 4000개씩 늘어나는 코드로
사용자 이벤트와 같은 긴급한 UI 업데이트가 앞선 UI 처리에 의해 밀린다 라는 상황을 재현해보겠다.

기존의 동기적이었던 UI 업데이트비동기적인 UI 업데이트

기존의 동기적으로 처리되었던 렌더링의 경우 먼저 예약된 UI 업데이트를 모두 처리한 뒤에야 다음 이벤트 UI 업데이트를 처리할 수 있었다. 그로 인해 위에서 보다시피 사용자 타이핑에 따른 input UI 업데이트에 렉이 걸리는 현상을 확인할 수 있다. 보다 실무적인 상황을 생각해보면 데이터 요청과 응답을 기다렸다가 UI를 업데이트해야 하는 경우가 ‘UI 업데이트를 막는 긴 작업’에 흔히 해당할 것이다.

하지만 input 의 value UI 업데이트는 긴급 업데이트(기존의 일반 setter)로 하고, 나머지 UI 업데이트는 startTransition 으로 감싸 비긴급 업데이트로 처리하도록 하니, input UI 업데이트는 이전 UI 업데이트 렌더링을 기다리지 않고 동작하는 것을 확인할 수 있다.

이러한 리액트의 동시성 모델은 결국 자바스크립트의 비동기 실행 모델 위에 세워져있다. 따라서 자바스크립트의 비동기와 동시성 구현에 대해 이해한다면, 앞으로의 프론트엔드 웹 개발 기술의 변화에 쉽게 적응하고 보다 더 좋아진 사용자 경험을 구현할 수 있을 것이다.

리액트의 동시성 모드에 관심이 없더라도, 거의 모든 작업이 논블로킹으로 작성돼야하는 서버 런타임에서의 자바스크립트와 대부분이 블로킹으로 이루어지는 클라이언트의 렌더링 작업 간의 차이를 탐구하는 것은 항상 더 나은 유저 경험을 만들기 위해 노력하는 프론트엔드에게 유의미한 지식이 될 것이라고 믿는다.

정리
웹 렌더링의 발전 과정: 서버 중심 → 클라이언트 중심 → 다시 양쪽 융합

하지만 이 진화의 본질은 “HTML이 어디서 그려지느냐”가 아니라 “비동기를 어디서, 어떻게 기다리느냐”에 있다.
SSR의 Promise, CSR의 Suspense, RSC의 Streaming 모두 이 문제의 다른 해법이다.


비동기

비동기는 작업을 순차적으로 기다리지 않고 다음 작업을 바로 이어서 진행하는 방식을 뜻한다. 그리고 싱글스레드 언어인 자바스크립트에서 이러한 비동기 프로그래밍을 어떻게 처리해오고 진화해왔는지를 탐구하는 것은 정말 즐거운 일이다. 자바스크립트가 비동기를 처리하는 [콜백 - Promise - async/await] 과 같은 방식들에 대해 이미 알고있다는 전제로 작성하겠다.

1. 서버(Node.js)에서의 비동기 처리

Node.js는 기본적으로 싱글 스레드 기반으로 동작하며, 이벤트 루프와 콜백, 프로미스, async/await를 활용하여 비동기·논블로킹 I/O 처리를 구현한다.

서버가 논블로킹으로 작동해야 하는 이유는, 이 방식이 CPU 자원을 효율적으로 활용하여 다수의 클라이언트 요청을 동시에 처리하며 전통적인 멀티스레드 서버가 갖는 스레드 생성 및 관리 오버헤드, 블로킹에 따른 CPU 낭비 문제를 피할 수 있게 해주기 때문이다.

결과적으로 Node.js의 싱글 스레드 + 논블로킹 모델은 적은 자원으로 높은 확장성과 응답성을 구현하는 데 핵심적인 역할을 한다.

그리고 이는 CPU 작업과 I/O 작업을 효율적으로 분리해, 블로킹 없이 다수의 작업을 시간 분할로 관리하는 동시성 모델이 핵심이다. 비동기 API를 통해 긴 시간이 소요되는 작업을 이벤트 루프에 위임하고, 작업 완료 시점에 콜백을 받아 처리한다. 이러한 구조는 단일 스레드임에도 불구하고 높은 처리량과 확장성을 가능하게 한다.

참고로 진정한 병렬 처리 기능은 Node.js 10버전 이후부터 도입된 워커 스레드(worker threads)를 통해 지원되기 시작했다. 워커 스레드는 별도의 스레드를 생성해 CPU 집중적인 작업(예: 이미지 처리, 해싱 등)을 병렬로 수행함으로써, 싱글 스레드로 인한 CPU 바운드 작업 병목을 해소한다. 그러나 일상적인 네트워크 I/O나 파일 시스템 접근과 같은 비동기 작업 대부분은 여전히 이벤트 루프와 libuv 스레드 풀을 이용한 비동기 논블로킹 처리에 의존한다.

즉, Node.js 기반 서버 사이드 렌더링(SSR) 환경에서 비동기 코드는 HTML 생성 과정의 중단점 역할을 하면서, 이벤트 루프를 통한 효율적인 동시성 처리와 필요시 워커 스레드를 통한 병렬 처리가 적절히 조합되어 서버 리소스를 효과적으로 활용한다. 이러한 비동기·동시성 모델이 Node.js 서버 환경에서 빠르고 확장성 높은 응답 처리의 핵심 비결이다.

2. 클라이언트(브라우저)에서의 비동기 처리

브라우저 역시 자바스크립트 엔진을 기반으로 한 싱글 스레드 환경에서 동작하지만, 그 구조적 목표는 서버와 다르다. Node.js가 I/O 중심의 논블로킹 동시성을 최적화하는 것이라면,

브라우저는 사용자 경험을 방해하지 않으면서 부드럽게 렌더링되는 화면을 유지하는 데 초점이 맞춰져 있다.


2.1. 브라우저의 이벤트 루프 구조

브라우저는 JavaScript 실행 스레드 외에도 렌더링 엔진(Renderer), 네트워킹 스레드, Web API 스레드 등 다수의 백그라운드 스레드가 함께 작동한다. 하지만 자바스크립트 코드 자체는 여전히 단일 실행 컨텍스트 위에서 실행되며, 이 이벤트 루프(Event Loop)가 모든 비동기 작업의 흐름을 조율한다.

  1. Micro Task Queue
    • Promise.then, async/await 이후 처리 등이 이 큐에 등록된다.
  2. Macro Task Queue
    • setTimeout, setInterval, I/O callbacks 등 일반적인 작업이 이 큐에 쌓인다.
  3. 렌더링 단계 (Repaint/Reflow)
    • DOM 변경 사항을 반영해 화면을 다시 그린다.

즉, 브라우저의 이벤트 루프는 자바스크립트 실행 → 태스크큐 처리 → 렌더링이라는 세 가지 단계를 엄격히 순환하면서 동작한다.

이 구조 때문에 자바스크립트는 한 번에 하나의 작업만 수행할 수 있고, 긴 작업이 실행되면 렌더링이 블로킹되어 UI가 멈춘 듯한 현상이 발생한다.


2.2. 브라우저 비동기의 본질 — “렌더링을 지연시키지 않는 것”

브라우저에서의 비동기는 단순히 병렬로 실행되는 것이 아니라, 렌더링 타이밍을 방해하지 않도록 작업을 나누어 실행하는 방식으로 구현된다.


3. 서버 블로킹과 클라이언트 블로킹의 차이

이제 서버와 클라이언트의 비동기 모델을 비교해보자. 둘 다 “싱글 스레드 + 이벤트 루프”라는 같은 언어적 토대 위에 있지만, ‘무엇이 블로킹으로 작동하느냐’는 완전히 다르다.

3.1. 서버 사이드에서의 블로킹

서버에서의 비동기는 대부분 I/O 작업에 집중되어 있다. 즉, 데이터베이스 조회, API 호출, 파일 접근 같은 작업이 대표적이다

// 서버사이드 예시 (Node.js, SSR 중)
async function renderPage() {
  const data = await fetchData(); // 여기서 멈춤
  return renderToString(<App data={data} />);
}

여기서 await fetchData()가 실행되면, 해당 Promise가 resolve되기 전까지 HTML 생성이 중단된다.

이게 바로 SSR의 “렌더링 블로킹 지점”이다. 즉, SSR 환경에서 하나의 비동기 대기는 “전체 페이지 HTML 생성이 멈춘다”는 것을 의미한다.

Node.js는 논블로킹 I/O 기반이지만, “렌더링 함수 실행 컨텍스트 내의 await”은 결국 단일 요청 단위에서는 블로킹처럼 작동한다.

(이 순간에는 같은 요청의 HTML 렌더링이 멈춰 있기 때문이다.)


3.2. 클라이언트 사이드에서의 블로킹

반면 클라이언트(브라우저)에서는 awaitPromise전체 페이지의 실행을 멈추게 하지 않는다.

function Profile() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchUser().then(setData);
  }, []);

  if (!data) return <Loading />;
  return <UserProfile data={data} />;
}

이 코드는 서버처럼 전체 렌더링이 멈추지 않는다. 대신 React는 fallback UI(Loading)를 먼저 렌더링한 뒤, 비동기 데이터가 도착하면 컴포넌트를 다시 그린다.

즉, 클라이언트의 블로킹은 “렌더링 단위(UI 컴포넌트)”에 국한된다. SSR처럼 전체 앱이 멈추는 것이 아니라, 비동기 상태를 가진 일부 UI만 대체(fallback)되어 표시된다.


3.3. 이 차이가 중요한 이유

이 차이는 React Suspense와 SSR Streaming, 그리고 RSC 모델로 직접 이어진다. 서버는 한 번의 HTML 렌더링 중 await이 생기면 그 시점 이후의 HTML은 생성 자체가 지연된다.

즉, “HTML을 전송하지 못한 채 기다리는 상태”가 되는 것이다. 이를 해결하기 위한 전략이 바로 Suspense이다.

서버가 Promise를 만나면 해당 부분을 “빈 자리(placeholder)”로 남겨둔 채 나머지 HTML을 먼저 스트리밍으로 내려보내고, 비동기 작업이 완료되는 즉시 해당 부분을 채워넣는 방식이다.

이제 서버에서 데이터를 기다리는 동안에도 다른 HTML을 먼저 전송할 수 있다. 즉, 서버에서의 블로킹을 “부분적 비동기”로 바꾼 것이다.


3.4. 정리하면

  • 서버에서의 Promise전체 렌더링을 멈추게 한다.
    • 해결책 → Streaming, Suspense
  • 클라이언트에서의 PromiseUI의 일부만 잠시 멈춘다.
    • 해결책 → Suspense, Transition

따라서 SSR과 CSR의 핵심 차이는 “비동기를 어디서 기다리느냐”, “그 기다림 동안 무엇을 보여주느냐”에 있다.


4. React가 비동기를 “동시성”으로 끌어올린 이유

이제 React가 왜 굳이 “동시성”이라는 개념을 끌어들였는지를 보자. 단순히 Promise를 기다리는 것만으로는 사용자 경험을 충분히 제어할 수 없었기 때문이다.

4.1. React의 렌더링 스케줄링 모델

React 18부터 도입된 Concurrent Rendering(동시성 렌더링)은 기존의 동기적 렌더링 모델을 비동기로 분할 가능한 모델로 확장했다. 즉, 렌더링 전체를 한 번에 처리하지 않고, 여러 조각으로 나누어 “언제 다시 이어붙일지”를 스케줄링할 수 있게 된 것이다.

startTransition(() => {
  setState(expensiveUpdate());
});

이 코드는 “우선순위가 낮은 상태 업데이트”로 예약되며, React는 이벤트 루프의 여유 시간을 활용해 백그라운드에서 렌더링을 진행한다.

이때 브라우저의 메인 스레드는 여전히 사용자 입력에 즉시 반응할 수 있다. 즉, React의 동시성은 “렌더링 중에도 UI를 멈추지 않는 것”에 초점을 맞춘다.


자바스크립트는 싱글 스레드라는 한계에도 환경과 동시성을 활용하여 범용 언어로 자리 잡았다. 이러한 자바스크립트의 동작을 이해하고 리액트가 그리는 동시적인 UI 렌더 방식을 이해한다면 느리고 무거운 화면일지라도 최대한 부드러운 UX를 제공할 수 있을 거라고 기대한다.


레퍼런스

https://tech.remember.co.kr/%EC%BD%94%EB%93%9C-%ED%95%9C-%EC%A4%84%EB%A1%9C-%EA%B2%BD%ED%97%98%ED%95%98%EB%8A%94-react-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%98-%EB%A7%88%EB%B2%95-5ff18aee148d

도서 <자바스크립트 완벽 가이드>

profile
문제의 근본적인 원인을 탐구하고 해결하는 것을 좋아하는 프론트엔드 개발자, 진나영입니다!

0개의 댓글