Javascript

미래의 React는 아마도 SvelteJS처럼 컴파일될겁니다

드리프트2 2024. 2. 24. 17:59

 

React가 컴파일됩니다.

 

이제 메모이제이션을 잊으셔도 됩니다.

 

요 근래, React 팀은 React에 대해 작업한 내용을 블로그 포스트로 발표했습니다.

 

React 팀의 Andrew Clark는 변경 사항을 잘 설명해주었습니다:

 

 

React 팀의 발표에서 컴파일된 React에 대해 언급이 있었고, 당영히 v19에서 컴파일된 React가 나올거라고 추측했었는데요.

아마도 컴파일된 React는 아마도 다음 버전일 거 같습니다.

 

React가 "컴파일"되는 것이 무엇을 의미하는지 혼란스러워하는 사람들에게 이 글이 도움이 됐으면 합니다.

 

React의 전체 이야기를 보지 않았다면 종종 따라가기 어려울 수 있으므로 예시와 역사적 맥락을 보여드리겠습니다.

 

** 목 차 **


 

현시점의 React Hook의 문제점은 컴파일된 React가 해결할 것으로 보입니다.

 

여기서 React의 기본 원칙을 먼저 숙지하고 지나가겠습니다.

  1. React 상태는 불변해야 합니다.
  2. UI는 상태의 함수입니다.
  3. 상태가 변경되면 다시 렌더링하여 새로운 UI를 생성합니다.

그리고 React의 역사를 세 가지 시대로 나눌 수 있습니다:

  1. 클래스 컴포넌트 시대: 이 시대에서는 추상화를 위한 기본 요소가 없었습니다.
  2. Hooks 시대: Hook을 사용하면 메모이제이션을 효과적으로 활용할 수 있습니다.
  3. 컴파일된 시대: 이 시대에서는 아마도 자동 메모이제이션을 지원할겁니다.

클래스 컴포넌트 시대에는 코드를 추상화하고 재사용하려고 할 때 많은 문제점을 보였는데요.

 

React 커뮤니티는 HOC(고차 컴포넌트)와 Render Props를 발명해서 이 문제에 대처했습니다.

 

이러한 패턴은 코드 재사용을 위한 "기본 요소"가 부족했기 때문에 등장했던 거죠.

 

즉, 문제는 클래스 자체가 필요한 구성 수준을 제공하지 않았다는 것이었습니다.

 

이로 인해 React 팀은 함수형 구성으로 전환하기 시작했습니다.

 

당시 함수형 컴포넌트는 상태나 다른 라이프사이클 요소를 가질 수 없었기 때문에 "상태 없는 함수형 컴포넌트"라고 불렸습니다.

 

그러나 React 팀은 함수형 컴포넌트를 필요한 기본 요소로 보았습니다.

 

함수형 컴포넌트가 React의 라이프사이클에 "훅"을 걸 수 있는 방법을 찾아내기만 한다면 좋겠다고 생각했습니다.

 

그래서 2018년에 훅(Hook)이 발표되었습니다.

 

훅(Hook)은 사용자 정의 훅(Hook)을 포함하여 우리가 놓친 기본 요소가 포함 될거라고 예상했습니다.

 

이제 모든 코드를 하나의 함수로 결합하면 재구성하면 되는데, 이 때 우리는 메모이제이션을 해야 한다는 점을 깨닫게 되었습니다.

 

초창기 클래스 컴포넌트 시대에는 클래스 방식이 메모이제이션으로부터 우리를 자연스럽게 보호해주었는데, 이는 클래스가 작동하는 방식의 특성이었습니다.

 

이러한 발전은 이후 React의 발전에 큰 역할을 하게 됩니다.


메모이제이션된 React

클래스 컴포넌트는 솔직히 말해서 좋지 않았는데요.

 

클래스 컴포넌트는 앱에 복잡성을 더해주었기 때문입니다.

 

만약 우리가 클래스 컴포넌트에서 on-submit을 처리하는 메서드를 만들었다면, 그 메서드는 "메모이제이션"이 필요하지 않았을 겁니다.

 

이제 함수형 컴포넌트에서 비슷한 작업을 해보겠습니다:

function App() {
  const [state, setState] = useState()
  function onSubmit() {
    // 제출 로직
  }
  return <form onSubmit={onSubmit}></form>
}

 

바로 눈에 들어오지는 않겠지만, 이 함수는 리렌더링될 때마다 자기 자신을 다시 생성하게 됩니다.

 

즉, 메모리 상에서 완전히 새로운 함수가 되는 것이죠.

 

함수가 자기 자신을 다시 생성하는 것은 일반적으로 문제가 되지 않습니다.

 

위 예제에서도 큰 문제는 없습니다.

 

그런데 클래스에서는 이런 일이 발생하지 않았을 겁니다.

 

클래스에서는 메서드가 렌더 단계와 별개로 독립적으로 존재했기 때문이죠.

 

또한 JavaScript에서 자기 자신을 다시 생성해야 하는 개념은 React에만 해당되는 것은 아닙니다.

 

2008년의 jQuery 코드도 함수와 객체를 다시 생성했을 겁니다.

 

이제 코드를 조금 리팩토링해보겠습니다:

function App() {
  const [state, setState] = useState()
  function onSubmit() {
    // 제출 로직
  }
  return <Form onSubmit={onSubmit} />
}

const Form = ({ onSubmit }) => {
  // ...
}

 

여전히 onSubmit이 리렌더링할 때마다 새로운 함수가 되는 것은 문제가 되지 않습니다.

 

App의 리렌더링은 이 경우 Form의 리렌더링을 유발합니다.

 

어떤 사람들은 컴포넌트가 props가 변경될 때 리렌더링된다고 말합니다.

 

하지만 그건 사실이 아닙니다.

 

Form은 props와 상관없이 App이 리렌더링될 때마다 리렌더링되는 것이죠.

 

현재로서는 onSubmit prop이 변경되는지 여부는 중요하지 않습니다.

 

이제 Form이 App이 리렌더링될 때마다 리렌더링되지 않도록 막아야 한다고 가정해볼까요.

 

아래 예제는 조금은 무식하게 단순화된 것이지만, 이 Form을 메모이제이션해보겠습니다:

// 이제 Form은 특정 props가 변경될 때만 리렌더링됩니다. App이 리렌더링될 때마다
// 항상 새로운 함수가 되는 것이 아닙니다.
const Form = React.memo(({ onSubmit }) => {
  // ...
})

 

이제 문제가 발생했습니다.

 

React는 변수가 변경되었는지 여부를 판단할 때 엄격한 동등성 검사를 많이 사용합니다.

 

이는 ===와 Object.is()를 사용하여 이전 값과 새 값의 비교를 의미합니다.

 

JavaScript 기본 타입(문자열)을 ===로 서로 비교하면 값으로 비교됩니다.

 

그러나 배열, 객체, 함수를 비교할 때는 ===가 그들의 식별성, 즉 메모리 할당을 비교합니다.

 

그래서 {} === {}가 JavaScript에서 false인 이유입니다.

 

두 개의 다른 객체 식별성이기 때문이죠.

 

Form = React.memo(fn)을 하면 다음과 같은 의미입니다:

 

"저기 React님, Form은 실제로 props가 식별성 검사에 따라 정말 변경될 때만 리렌더링되길 원해요."라는 의미입니다.

이로 인해 문제가 발생합니다.

 

onSubmit은 App이 리렌더링될 때마다 변경되기 때문입니다.

 

이로 인해 Form은 항상 리렌더링되며, 메모이제이션은 아무런 도움이 되지 않습니다.

 

이는 React에게는 무의미한 오버헤드가 됩니다.

 

이제 다시 돌아가서 App이 리렌더링될 때 onSubmit이 식별성을 변경하지 않도록 해야 합니다:

function App() {
  const [state, setState] = useState()

  const onSubmit = useCallback(() => {
    // 제출 로직
  }, [])

  return <Form onSubmit={onSubmit} />
}

 

useCallback을 사용하여 함수를 안정화시켜 식별성이 변경되지 않도록 합니다.

 

어떤 면에서 이는 메모이제이션의 한 유형입니다.

 

과도하게 단순화된 용어로 말하면, 메모이제이션은 함수의 "결과"를 "기억"하거나 "캐시"하는 것을 의미합니다.

 

이렇게 말하면 됩니다:

 

"저기 React님, useCallback에 전달하는 이 함수의 식별성을 기억해줘. 리렌더링될 때마다 새로운 함수를 주지만, 그건 잊어버리고, 처음 호출했을 때의 원래 함수의 식별성을 나에게 주세요."라는 뜻입니다.

 

onSubmit 함수를 메모이제이션하는 것은 보통 필요하지 않지만, Form이 메모이제이션되고 onSubmit을 prop으로 받게 되면 필요해집니다.

 

문제는 여기서 끝나지 않습니다. 더 많은 코드를 추가해보겠습니다:

function App() {
  const [state, setState] = useState()

  const settings = {}
  const onSubmit = useCallback(() => {
    const x = settings.x
    // ...
  }, [])

  // ...
}

 

settings 객체는 App이 리렌더링될 때마다 다시 생성됩니다.

 

이 자체로는 문제가 되지 않지만, React를 잘 알고 있다면 이 경우 useCallback의 의존성 배열에 settings를 넣으라는 린터(linter) 경고가 나타날 것입니다:

const settings = {}
const onSubmit = useCallback(() => {
  const x = settings.x
  // ...
}, [settings])

 

이렇게 하면 다음과 같은 의미입니다:

 

"onSubmit은 안정적이고 리렌더링될 때마다 변경되지 않길 원해요. 그러나 이 의존성 배열에 있는 항목 중 어느 하나가 변경되면 useCallback이 onSubmit을 다시 생성하길 원해요."

 

"왜 onSubmit이 변경되길 원할까요?"라고 스스로 물어볼 수 있습니다.

 

아마도 변경될 필요가 없을 것입니다.

 

그런데 React에서는 useCallback과 useMemo와 같은 상황에서 의존성 배열이 변경될 때 반환 값의 메모이제이션을 다시 수행하고 새로운 식별성을 만들어야 하는 경우가 많습니다.

 

이 경우 린터는 우리가 이 경우에는 onSubmit이 다르게 되지 않길 원한다는 사실을 모르고 있습니다.

 

리액트에서는 종종 의존성 배열과 린터의 역할에 대해 이야기합니다.

 

린터는 거의 옳지만, 이 예시는 린터가 원하는 대로 하지 않을 수도 있다는 것을 보여주기 위해 선택적으로 선정한 것입니다.

 

만약 우리가 린터의 조언을 따라 의존성 배열에 settings를 넣는다면 다음과 같은 일이 발생합니다:

  1. App이 다시 렌더링될 때...
    • settings는 이전 렌더링에서와 동일하지 않은 새로운 객체가 됩니다.
    • 의존성 배열은 settings=== 연산자로 비교하여 다른 객체로 간주합니다. 그러나 값은 변경되지 않았습니다.
    • 의존성 배열의 변경으로 인해 useCallbackonSubmit에 대한 새로운 식별자를 반환합니다.
    • FormonSubmit이 변경되었으므로 다시 렌더링됩니다.
  2. 결론적으로, Form의 메모이제이션은 쓸모 없어집니다. App이 다시 렌더링될 때마다 항상 다시 렌더링되기 때문입니다. 이제 우리는 onSubmit의 메모이제이션을 유지하기 위해 settingsuseMemo로 메모이제이션해야 하는 상황에 처하게 되었습니다.

그럼, 다음 질문을 생각해보겠습니다:

 

onSubmit을 변경하고 싶을까요? 그렇다면 그냥 린터를 무시하면 안 되나요?

 

물론, 이 경우에는 settings를 의존성 배열에서 제외하거나, 메모이제이션을 사용하는 것이 좋습니다.

 

아니면 처음부터 메모이제이션된 폼이 필요하지 않았다고 주장할 수도 있습니다.

 

이것은 단순히 예시일 뿐입니다.

 

린터가 왜 의존성 배열에 항목을 넣으라고 하는지, 그리고 그 이유에 대한 논의는 이 글의 범위를 넘어섭니다.

 

이 주제는 많은 뉘앙스와 함께 광범위하며, 저는 이 주제에 대해 몇 시간 동안 이야기할 수 있을 정도로 풍부합니다.

 

사실, 린터는 대개 옳고 좋은 의도를 가지고 있습니다.

 

문제는 많은 리액트 개발자들이 린터의 추론을 이해하지 못하고 린터를 작은 제안으로만 여기는 경우입니다.

 

제 경험상, 린터를 무시하면 버그가 발생할 확률이 높습니다.


메모이제이션에 의존하는 부분

리액트에서 충분히 작업했다면 의존성 배열을 다루는 것이 귀찮은 일임을 알고 있을 것입니다.

 

린터가 배열에 항목을 넣으라고 요청하면 결과가 마음에 들지 않을 수 있습니다(예: 루프).

 

린터가 마음에 들지는 않지만, 대부분의 경우 린터는 옳았습니다.

 

물론 리액트가 무한 루프를 원하는 것은 아니지만, 이제 메모이제이션도 필요했기 때문입니다.

 

의존성 배열은 모든 코드가 함수 컴포넌트에 공존하고 재렌더링되며 시간에 따라 변수의 변경 사항을 모니터링하려는 방법입니다.

 

때로는 객체, 배열 및 함수를 의존성 배열에 넣게 되는데, 이들을 메모이제이션으로 안정화해야 합니다.

 

리액트가 "안정적"이라는 의미는 "원하는 경우를 제외하고는 변경되지 않는 변수"라고 설명할 수 있습니다.

 

다음 코드로 이를 설명해 보겠습니다:

function App() {
  const [misc, setMisc] = useState();
  const [darkMode, setDarkMode] = useState(false);
  const options = { darkMode };

  return <User options={options} />;
}

function User({ options }) {
  useEffect(() => {
    // 사용자 정보 가져오기
  }, [options]);

  // ...
}

 

App에서 misc 상태가 변경되면, 그에 따라 options도 변경되고, 따라서 useEffectmisc 상태와 관련이 없더라도 다시 실행됩니다.

 

그래서 options 변수를 useMemo로 감싸는 것이 좋습니다.

 

이렇게 하면 린터가 올바르게 darkMode를 의존성 배열에 넣으라고 요청할 것입니다:

const [darkMode, setDarkMode] = useState(false);
const options = useMemo(() => {
  return { darkMode };
}, [darkMode]);

 

이렇게 하면 다음과 같이 말합니다:

  • options는 안정적이어야 합니다. 그러나 다크 모드가 변경되면 새로운 식별자로 다시 안정화하세요. 그러나 misc 상태는 의존성 배열에 없으므로 아무 작업도 수행하지 않습니다(우리는 이에 의존하지 않습니다).

리액트는 메모이제이션에 의존한다는 사실을 이해하셨기를 바랍니다.

 

직접 작성해야 합니다.

 

올바르게 작성하지 않으면 버그와 성능 문제가 발생할 수 있습니다.

 

리액트를 컴파일하는 방법은 항상 다양했습니다.

 

이 용어의 정의에 따라 리액트는 항상 컴파일 단계(JSX)를 거쳤다고 주장할 수 있습니다.

 

개인적으로 이것은 자바스크립트에서 코드를 작성한 것과 브라우저에서 실행되는 코드가 다르다는 것을 의미하는 느슨한 용어로 보입니다.

 

2015년 리액트에서는 당시 Babel과 리액트는 대부분의 개발자에게 아직은 비교적 새로운 기술이었습니다.

 

어떤 면에서는 그들의 인기가 서로 함께 성장했습니다.

 

리액트는 JSX를 함수 호출로 컴파일하는 것으로 유명합니다.

 

그래서 리액트는 기술적으로 컴파일되지만, 나는 항상 이것이 작은 구문 설탕이라고 생각했으며, 하나의 JSX 요소가 매우 예측 가능한 함수로 변환되는 의미론적 측면 때문에 상당히 "가벼운" 컴파일 양이라고 생각했습니다.

 

오늘날 우리는 TypeScript도 JavaScript로 컴파일합니다.

 

이 경우에는 우리가 작성한 모든 TypeScript 코드가 저장될 때 사라지고 남은 코드는 JavaScript입니다.

 

그러나 여전히 "작성한 대로 얻는다"는 정의를 만족한다고 생각합니다.

 

컴파일은 스펙트럼에 따라 다양합니다.

 

나에게는 "컴파일된 프레임워크"가 어느 정도 컴파일되는지에 따라 스펙트럼 상에 위치한다고 느껴집니다.

 

어떤 것은 약간 컴파일되고 어떤 것은 많이 컴파일되는 것처럼 말이죠:


자바스크립트 컴파일 스펙트럼

 

리액트는 다른 현대적인 JS 프레임워크와 비교했을 때 "많이 컴파일되지 않을" 쪽에 위치하는 것 같습니다.

 

"보이는 대로 얻는다"는 규칙이 이 스펙트럼에서 어디에 있는지를 결정합니다.

 

JSX는 리액트가 어느 정도 컴파일되었다는 의미이지만, 내가 작성한 다른 코드는 전혀 리액트에 의해 컴파일되지 않습니다.

 

반면에 Svelte는 너무 많이 컴파일되어서 그 창시자가 이제 더 이상 자바스크립트가 아니라고 설명했습니다.

 

Svelte는 실제로는 프로그래밍 언어에 더 가깝습니다.

 

왜냐하면 작성한 내용의 의미론이 JavaScript로 변환될 때 얻는 의미론과 너무 다르기 때문입니다.

 

이 글을 비교 포스트로 만들거나 어떤 방법이 다른 방법보다 좋다고 말하거나 컴파일이 좋은지 나쁜지 말하려는 것이 아닙니다.

 

다양한 자바스크립트 프레임워크가 덜 컴파일되거나 더 컴파일되거나 심지어는 더 이상 정말 JS가 아닌 지점까지 컴파일되는 스펙트럼처럼 느껴집니다.

 

리액트 팀의 발표에 따르면 리액트는 이전보다 더 컴파일될 것입니다.

 

다른 프레임워크보다 더 컴파일될까요? 확실하지 않습니다.

 

나에게는 이 스펙트럼 어디에 끝나는지는 별로 중요하지 않습니다.

 

더 중요한 것은 왜 컴파일되는지입니다.

 

그 답은 다른 이유로 컴파일되기 때문입니다..


자동 메모이제이션을 위한 컴파일

리액트는 불변성에서 관찰 가능성으로 전환되지 않습니다.

 

여전히 식별성 검사와 의존성 배열을 유지할 것입니다.

 

따라서 지금 컴파일된다는 사실만으로 리액트가 다른 프레임워크와 유사하게 느껴지지는 않습니다.

 

리액트는 자동 메모이제이션을 위해 컴파일될 것입니다.

 

리액트는 여러 해 동안 변하지 않았지만 수동 메모이제이션의 단점은 없어졌습니다.

 

이것은 훅과 함수형 컴포넌트의 주요 문제 중 하나였습니다.

 

개인적으로 JSX 위의 로직이 그대로 남아있는 것에 익숙합니다.

 

이 변경 사항은 주로 수동 메모이제이션을 어떻게 생각해야 하는지 잊어버리는 것입니다.

 

컴파일러가 좋은 결정을 내리도록 믿어야 하며, 얼마나 많은 부분을 내가 직접 안내해야 할지 "그냥 작동한다™" 대비 아직 확실하지 않습니다.

 

낙관적이고 이것을 실험해보는 것에 흥미롭습니다.

 

요약하면, 이 아이디어가 어디서 나왔는지 갑자기 나타난 것은 아닙니다.

 

우리는 Xuan Huang이 React Conf 2021에서 이 아이디어를 제시한 이후로 리액트에서 이 가능성에 대해 3년 동안 이야기해 왔습니다.

 

또한 최근 몇 년 동안 리액트 관련 트위터에서 핫한 주제로 떠올랐던 적도 있습니다.

 

내 희망은 이 대화를 알지 못했다면 이 글이 어떻게 이 지점에 이르게 되었는지를 공정한 예시와 맥락으로 제공한다는 것입니다.

 

읽어주셔서 감사합니다!