리액트 동시성이란?

김정우·2023년 6월 29일
2

react

목록 보기
1/1

머리말

두달쯤 전인가 회사 업무를 수행하면서 난해하고 해결하기 어려운 문제를 마주쳤다.

특정 페이지에 접근할 때마다 다음과 같은 에러가 발생했고, nextjs에서 띄워주는 에러 알림 창으로 인해 개발에 매우 방해가 되고 있었다.

This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.

처음에는 왜 이런 문제가 발생하는지 몰랐기도 하고, 스테이징 환경에 올려서 사용할 때는 아무런 방해가 되지 않았기에 에러창을 꺼가는 불편함을 감수해가며 오랫동안 방치해두었다.
(그리고 리액트 깃헙을 뒤져보았더니 메인테이너들이 이 문제는 리액트의 결함이고 고칠 예정이라고 하기도 했다...!)

최종 배포 전 수정을 하며 여차저차 문제를 파악했다. 아이콘이 여러번 업데이트 되는데 이 과정에서 안전한 동시성의 조건을 충족하지 못한것이 직접적인 원인이었는데, 아이콘이 리렌더링 되는 과정이 너무 복잡해 어떤 업데이트로 인해 발생하는 것인지 명확히 확인하지 못했을 뻔더러 제대로 이해하지 못한 채 Suspense 컴포넌트를 사용한 것이 문제였다. 이참에 이해에 어려움을 겪었던 동시성의 기본 개념에 대해 다시 학습하며 정리하려 한다.

리액트의 동시성(Concurrency)이란?

동시성(Concurrency)이란 리액트 18 버전의 주요 업데이트 사항 중 하나이다.
동시성이라는 단어 자체가 18 버전 이전의 리액트 생태계에서는 생소한데, 등장 배경을 알기 위해서는 리액트가 화면을 렌더링 해주는 과정에 대해 조금 이해하고 있어야 한다.

나도 정확하게 알지 못해 추후 다시 학습할 내용이지만, 일단 리액트에서 업데이트가 발생하여 화면이 변경될 때 각 업데이트는 동기적으로 동작 한다.

각각의 업데이트가 동기적으로 진행되는 것을 다른 친구들과 순서대로 대화하는 것에 비유한다면 위와 같이 수행되는 것이다. 이는 당연하게도 최적화 문제로 이어진다. 대화할 친구가 10명이라면 10번째 친구는 하염없이 기다려야 할 것이다. 10번째 친구가 하려는 대화가 정말 중요하고 급한일이라면? 낭패다.

리액트의 저수준 동작 방식까지는 알지 못하더라도 리액트를 통해 서비스를 만들어보았다면 클릭 하나, 키보드 입력 하나에 정말 많은 업데이트가 생기기도 한다는 것을 알고 있을 것이다. 실제 서비스 단에서 이렇게 동기적으로 동작하는 것은 서비스를 사용하는 유저에게 매우 부정적인 경험으로 이어질 수 있다.

기존에 리액트는 내부적으로 업데이트를 모아두어 한 번의 렌더링에 반영하는 batching 기능(이 역시 리액트 18 버전에서는 auto-batching으로 더욱 최적화 되었지만 이 글에서는 다루지 않는다. 참고) 을 지원하는 한편, 개발자는 setTimeout을 통해 업데이트 순서를 조정하거나 debounce, throttle 등을 사용하여 업데이트의 횟수 자체를 제한함으로써 성능 최적화를 도모할 수 있었다.

이번 글의 주제인 동시성에서는 다음과 같이 대화가 이루어진다.

실제로 두명과 동시에 대화하는 것이 아니라, 대화의 중요도(긴급함)에 따라 Alice와 Bob과 번갈아가며 얘기하는 것이다. 여기에서의 핵심은 더 중요한 대화를 하기위해, 즉 더 중요한 업데이트를 처리하기 위해 현재의 업데이트를 잠시 미뤄둔다는 것이다.

싱글 스레드로 동작하는 자바스크립트에서는 window.requestIdleCallback와 같은 메서드를 통해 이를 구현할 수 있다. 이 메서드는 스레드가 Idle 상태가되면 인자로 넘겨받은 콜백함수를 실행한다. 이렇게 함으로써 업데이트가 발생한 시점에 관계없이 중요한 업데이트부터 적용할 수 있는 것이다.

이상의 얘기를 간단하게 코드로 표현하면 다음과 같다.

  • 업데이트가 동기적으로 이루어지는 경우
function renderBlocking(Component) {
    for (let Child of Component) {
        renderBlocking(Child);
    }
}
  • window.requestIdleCallback을 사용하여 중요도에 따라 업데이트 순서를 조정하는 경우
function renderConcurrent(Component) {
    // stale 되어 필요없는 업데이트가 되었다면 ex. 같은 state에 여러 번의 수정이 발생한 경우
    if (isCancelled) return;

    for (let Child of Component) {
        requestIdleCallback(() => renderConcurrent(Child));
    }
}

다만 window.requestIdleCallback 메서드는 사파리에서 지원되지 않는 등 호환성의 문제가 있기 때문에 실제 리액트 팀이 선택한 방식은 다른 것인 것 같다.
-> (이 로직이 그것인 것 같은데 나중에 다시 살펴봐야지)

"there is no concurrent mode, there are only concurrent features"

처음 리액트 팀은 리액트 18 버전 자체가 내부적으로 동시성을 충족하며 동작하기를 의도했고, 이를 Concurrent Mode라고 불렀다. 하지만 Concurrent Mode로 전환하는 과정에서 리액트 팀의 의도와 다르게 이전 버전 리액트와 호환 문제가 생기면서 결국 Concurrent Mode는 포기하게 된다(근데 지금도 이름은 동시성 모드이긴 하지만 내부 동작은 동시성이 디폴트가 아니라고 한다).

더불어 동시성을 다루는 일, 즉 최적화 작업에 대한 책임을 개발자에게 주기 위해서 Concurrent Mode가 아닌 Concurrent features라고 표현한다.

Concurrent features에 포함된 훅 중 제일 기본이 되는 startTransition에 대해 알아보려 한다.

startTransition

먼저 transition이라는 개념을 알아야 한다. 리액트 팀에서는 상태 업데이트를 두가지 범주로 구분한다.
1. urgent updates: 타이핑, 클릭, 누르기 등의 직접적인 상호작용을 반영하는 업데이트
2. transition updates: 하나의 뷰에서 다른 뷰로의 UI 전환 업데이트

동시성이라는 개념을 처음 접했을 때는 UI가 빠르게 업데이트 되는 것이 서비스의 성능이라고 생각해 왜 UI 전환 업데이트가 non-urgent 취급을 받는지 잘 이해가 되지 않았는데, 다음의 예시를 스스로 떠올려보며 납득하게 되었다.

인풋창에 검색하고, 인풋의 값으로 자동 완성 검색어를 만드는 기능을 생각해보자.

  • 유저가 검색 인풋창을 클릭하고 검색어를 입력하는 동안 (1) 커서가 깜빡일 것이고, (2) 유저가 입력한 내용이 인풋창에 반영된다. 이 둘은 긴급한(urgent) 업데이트이다. 만약 이 둘이 곧바로 적용되지 않는다면? 유저는 해당 서비스가 동작하지 않거나, 렉 걸린다고 생각할 것이다.
  • 반면 검색을 클릭한 뒤에 자동으로 만들어진 검색어 리스트가 노출되는 것은 정도가 심하지 않다면 얼마간 지연되도 유저는 이를 감수한다. 정보를 가져오는(만드는) 시간이 있다고 생각하기 때문이다. 때로는 debounce 등의 목적으로 개발자가 지연을 의도하기도 한다.
/**
 * 리액트 18 버전 이전에는 다음 상태 업데이트가 같은 렌더링에 반영되기 때문에
 * 유저의 타이핑이 검색창에 바로 반영되지 않고 자동완성검색어가 만들어진 뒤에
 * 함께 적용된다.
 */

function onChangeInput(value) {
	setInputValue(value);
  	setAutoCompletedSearchQuery(
      makeAutoCompletedSearchQuery(value)
    )
}

위에서 정의한 범주에 따르면 setInputValue는 urgent update에, setAutoCompletedSearchQuery는 transition update에 해당한다. 후자의 반영을 잠시 미루고 전자만 빠르게 렌더링하기 위해 리액트 18 버전에서 제공하는 startTransition 함수를 사용할 수 있다.

import { startTransition } from 'react';

function onChangeInput(value) {
  setInputValue(value);
  startTransition(() => {
    setAutoCompletedSearchQuery(
      makeAutoCompletedSearchQuery(value)
    )
  });
}

사용은 위에서 언급한 window.requestIdleCallback 메소드와 비슷한데, startTransition 함수에 콜백으로 상태 업데이트 함수를 넣어주면 된다.

여기까지 보다보면 'setInputValue만 먼저 실행해서 렌더링하고 싶은 거면 setAutoCompletedSearchQuerysetTimeout의 콜백함수로 넘겨줘도 되는거 아니야?'라고 생각할 수도 있다.

하지만 startTransitionsetTimeout은 다르다.
1. 우선 나중으로 스케줄링되는 setTimeout과 달리 startTransition의 실행 자체는 동기적으로 바로 일어난다. 다만 그 내부의 모든 업데이트가 transitions로 마킹될 뿐이다. 리액트는 이 정보를 어떻게 업데이트를 렌더링할지 결정하는데 사용한다. 이는 setTimeout으로 감싼 것에 비해 좀 더 빠르게 업데이트를 렌더링한다는 것을 의미하는데, 기기의 성능에 따라 두 업데이트 간의 딜레이가 많이 차이 나더라도 UI는 반응성을 유지할 것이다.
2. setTimeout의 콜백함수는 여전히 동기적으로 작동한다. 즉, 콜백함수에서 페이지의 UI를 크게 바꾼다면 그동안 유저가 계속 타이핑하고 있더라도 이 업데이트의 반영이 늦어질 수 있다. 하지만 startTransition로 감싸진 업데이트는 중단될 수 있기 때문에 완성된 자동 검색어 리스트가 현재 검색어와는 다르다면 해당 리스트를 뷰에 반영하지 않을 수 있다.
3. setTimeout은 단순히 딜레이만 주기 때문에 로딩바 등을 보여주는 시점을 결정하기 위해 비동기 코드를 작성해야 한다. 하지만 startTransition의 경우 업데이트가 실제 발생되기 전 대기되고 있는 상태를 알 수 있는 값 isPending을 추가한 훅 useTransition을 사용할 수 있다.

useTransition의 용례와 주의할 점은 공식문서 링크로 대체한다.

참고 자료

https://www.bbss.dev/posts/react-concurrency/ (메인 참고 자료)
https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
https://github.com/reactwg/react-18/discussions/46#discussioncomment-846786
https://github.com/reactwg/react-18/discussions/64
https://github.com/reactwg/react-18/discussions/41
https://react.dev/reference/react/useTransition

profile
hello world!

0개의 댓글