카카오모빌리티 디벨로퍼스 문서 기술 블로그 React Compiler - 우리는 memo를 잊을 수 있을까요?

들어가며 링크 복사

안녕하세요, 카카오모빌리티의 공통FE셀 댄입니다. 저희 회사에는 20명 이상의 프론트엔드 개발자들이 근무하고 있습니다. 저는 이들이 공통으로 겪는 어려움을 파악하고, 효과적인 해결책을 제시함으로써 각 개발자가 본인의 핵심 업무에 더욱 집중할 수 있도록 지원하고 있습니다.

저는 React를 오랫동안 사용하면서 Function Component의 등장, Hooks 도입, Concurrent Mode 등의 주요 변화를 경험했습니다. 앞으로의 변화에도 큰 관심이 있는데요. 그중에서도 특히 흥미로운 주제는 React Compiler입니다.

이 글에서는 React 팀이 최근 발표한 React Compiler에 대해 알아보겠습니다. 이 컴파일러가 무엇인지, 어떻게 작동하는지, 그리고 우리의 개발 방식에 어떤 변화를 가져올 수 있는지 살펴보겠습니다.

먼저 React의 기본 원리와 한계에 대해 이야기해 보겠습니다.

React의 기본 원리와 한계 링크 복사

React의 핵심 아이디어는 '현재 상태의 함수로 UI를 정의하는 것'입니다. 이는 개발자가 애플리케이션(이하 앱)의 각 상태에 대한 UI를 선언적으로 정의하고, 상태 변경 시 React가 자동으로 UI를 업데이트하는 방식을 의미합니다.

예시 코드 유저 프로필을 렌더링하는 간단한 컴포넌트
function UserProfile({ name, age }) {
    let info
    if (age > 18) {
        info = 'This user is an adult.'
    } else {
        info = 'This user is a minor.'
    }

    return (
        <div>
            <h1>User Profile</h1>
            <p>Name: {name}</p>
            <p>Age: {age}</p>
            <p>{info}</p>
        </div>
    );
}
function UserProfile({ name, age }) {
    let info
    if (age > 18) {
        info = 'This user is an adult.'
    } else {
        info = 'This user is a minor.'
    }

    return (
        <div>
            <h1>User Profile</h1>
            <p>Name: {name}</p>
            <p>Age: {age}</p>
            <p>{info}</p>
        </div>
    );
}
코드가 숨겨졌습니다.

React의 멘탈 모델은 직관적이고 예측할 수 있는 코드 작성을 가능하게 합니다. 하지만, 이 모델은 때로 과도하게 반응적일 수 있어, 불필요한 렌더링이 발생하는 문제가 있습니다.

아래 예제 코드를 통해 이 문제를 살펴보겠습니다.

import React, { useState, useCallback } from 'react'

const ParentComponent = () => {
    console.log('ParentComponent rendered')

    const [count, setCount] = useState(0)

    const handleClick = () => {
        console.log('Button clicked')
        setCount((prevCount) => prevCount + 1)
    }

    return (
        <div>
            <p>Count: {count}</p>
            <ChildComponent onClick={handleClick} />
        </div>
    );
};

const ChildComponent = ({ onClick }) => {
    console.log('ChildComponent rendered')

    return (
        <div>
            <button onClick={onClick}>Click me</button>
            <HeavyComponent1 />
            <HeavyComponent2 />
        </div>
    )
}

export default ParentComponent
import React, { useState, useCallback } from 'react'

const ParentComponent = () => {
    console.log('ParentComponent rendered')

    const [count, setCount] = useState(0)

    const handleClick = () => {
        console.log('Button clicked')
        setCount((prevCount) => prevCount + 1)
    }

    return (
        <div>
            <p>Count: {count}</p>
            <ChildComponent onClick={handleClick} />
        </div>
    );
};

const ChildComponent = ({ onClick }) => {
    console.log('ChildComponent rendered')

    return (
        <div>
            <button onClick={onClick}>Click me</button>
            <HeavyComponent1 />
            <HeavyComponent2 />
        </div>
    )
}

export default ParentComponent
코드가 숨겨졌습니다.

버튼을 누를 때마다 아래 세 줄이 콘솔에 순차적으로 기록됩니다.


변경이 필요 없는 ChildComponent도 매번 리렌더링되며, ChildComponent의 하위 컴포넌트들도 특별한 조치 없인 모두 리렌더링됩니다. 이러한 문제 해결을 위해 React는 여러 메모이제이션 API를 제공합니다.

[React에서 제공하는 메모이제이션 API]

  • useCallback: 함수를 메모이제이션 합니다. 의존성 배열의 값이 변경되지 않았다면 함수의 참조를 유지합니다.
  • useMemo: 연산의 결과를 메모이제이션 합니다. 의존성 배열의 값이 변경되지 않았다면 이전에 계산된 값을 재사용합니다.
  • React.memo: 컴포넌트를 메모이제이션 합니다. props가 변경되지 않았다면 리렌더링을 방지합니다.

이 API를 사용하여 위의 예제를 최적화해 보겠습니다.

import React, { useState, useCallback } from 'react';

const ParentComponent = () => {
    console.log('ParentComponent rendered');

    const [count, setCount] = useState(0);

    const handleClick = useCallback(() => {
        console.log('Button clicked');
        setCount((prevCount) => prevCount + 1);
    }, [])

    return (
        <div>
            <p>Count: {count}</p>
            <ChildComponent onClick={handleClick} />
        </div>
    );
};

const ChildComponent = React.memo(({ onClick }) => {
    console.log('ChildComponent rendered');

    return (
        <div>
            <button onClick={onClick}>Click me</button>;
            <굉장히 무거운 컴포넌트1 />
            <굉장히 무거운 컴포넌트2 />
            ...
        </div>
    )
});

export default ParentComponent;
import React, { useState, useCallback } from 'react';

const ParentComponent = () => {
    console.log('ParentComponent rendered');

    const [count, setCount] = useState(0);

    const handleClick = useCallback(() => {
        console.log('Button clicked');
        setCount((prevCount) => prevCount + 1);
    }, [])

    return (
        <div>
            <p>Count: {count}</p>
            <ChildComponent onClick={handleClick} />
        </div>
    );
};

const ChildComponent = React.memo(({ onClick }) => {
    console.log('ChildComponent rendered');

    return (
        <div>
            <button onClick={onClick}>Click me</button>;
            <굉장히 무거운 컴포넌트1 />
            <굉장히 무거운 컴포넌트2 />
            ...
        </div>
    )
});

export default ParentComponent;
코드가 숨겨졌습니다.

위의 코드를 통해 이제는 버튼을 클릭해도 ChildComponent rendered 로그가 기록되지 않습니다. 이는 ChildComponent가 불필요하게 리렌더링되지 않음을 의미합니다.


메모이제이션 API를 사용해 React에서 불필요한 렌더링을 방지하고 과도한 반응을 제어할 수 있습니다. 하지만 이 과정에서 코드가 복잡해지고 유지보수가 어려워질 수 있습니다. 메모이제이션 API를 적절히 사용하지 않으면 성능이 향상되지 않거나 오히려 악화될 수도 있고, 이 API 사용의 적절성을 판단하는 것은 큰 부담이 됩니다. 우수한 사용자 경험(UX)과 개발자 경험(DX) 사이의 이러한 상충관계는 정말로 극복할 수 없는 문제일까요?

이 딜레마를 해결하기 위한 React 팀의 새로운 접근 방식에 대해 알아보겠습니다.

React Forget: 자동 메모이제이션 아이디어의 시작 링크 복사

2021년 12월, React 팀은 Xuan Huang의 React without memo 발표를 통해 React Forget이라는 실험적인 도구를 공개했습니다.


이 발표에서는 'An Auto-memoizing Compiler'라는 주제로, React Forget이 개발자들이 컴포넌트나 훅을 작성할 때 더 이상 useMemo, useCallback 등의 메모이제이션 처리와 의존성 배열 관리를 직접 수행하지 않아도 되도록 도와줄 것이라고 소개했습니다.

공개된 코드 예시를 통해 React Forget의 기능을 자세히 살펴보겠습니다.

const Todo = React.memo(UnmemoizedTodo);

function TodoList({ visibility, themeColor }) {
    const [todos, setTodos] = useState(initialTodos);
    const handleChange = useCallback(
        todo => setTodos(todos => getUpdated(todos, todo)),
        []
    );
    const filtered = useMemo(
        () => getFiltered(todos, visibility),
        [todos, visibility]
    );
    return (
        <div>
            <ul>
                {filtered.map(todo => (
                    <Todo key={todo.id} todo={todo} onChange={handleChange} />
                ))}
            </ul>
            <AddTodo setTodos={setTodos} themeColor={themeColor} />
        </div>
    );
}

function BlazingTodoList() {
    const [visibility, ...] = useState("all");
    const [themeColor, ...] = useState("#045975");
    return ...<TodoList visibility={visibilty} themeColor={themeColor} />...;
}
const Todo = React.memo(UnmemoizedTodo);

function TodoList({ visibility, themeColor }) {
    const [todos, setTodos] = useState(initialTodos);
    const handleChange = useCallback(
        todo => setTodos(todos => getUpdated(todos, todo)),
        []
    );
    const filtered = useMemo(
        () => getFiltered(todos, visibility),
        [todos, visibility]
    );
    return (
        <div>
            <ul>
                {filtered.map(todo => (
                    <Todo key={todo.id} todo={todo} onChange={handleChange} />
                ))}
            </ul>
            <AddTodo setTodos={setTodos} themeColor={themeColor} />
        </div>
    );
}

function BlazingTodoList() {
    const [visibility, ...] = useState("all");
    const [themeColor, ...] = useState("#045975");
    return ...<TodoList visibility={visibilty} themeColor={themeColor} />...;
}
코드가 숨겨졌습니다.

이 코드는 간단한 TodoList를 구현하지만, 성능 최적화를 위해 메모이제이션 API를 사용하면서 코드가 복잡해지고 가독성이 저하되었습니다.

TodoList 컴포넌트에서 메모이제이션 관련 코드를 제거하여 간결하고 읽기 쉽게 만들어 보겠습니다.

function TodoList({ visibility, themeColor }) {
    const [todos, setTodos] = useState(initialTodos);
    const handleChange = todo => setTodos(todos => getUpdated(todos, todo));
    const filtered = getFiltered(todos, visibility);

    return (
        <div>
            <ul>
                {filtered.map(todo => (
                    <Todo key={todo.id} todo={todo} onChange={handleChange} />
                ))}
            </ul>
            <AddTodo setTodos={setTodos} themeColor={themeColor} />
        </div>
    )
}
function TodoList({ visibility, themeColor }) {
    const [todos, setTodos] = useState(initialTodos);
    const handleChange = todo => setTodos(todos => getUpdated(todos, todo));
    const filtered = getFiltered(todos, visibility);

    return (
        <div>
            <ul>
                {filtered.map(todo => (
                    <Todo key={todo.id} todo={todo} onChange={handleChange} />
                ))}
            </ul>
            <AddTodo setTodos={setTodos} themeColor={themeColor} />
        </div>
    )
}
코드가 숨겨졌습니다.

정리된 TodoList 컴포넌트는 기능을 간결하고 직관적으로 표현하는 이상적인 코드이지만, 이전 버전에 비해 성능이 떨어질 수 있습니다.

React Forget으로 이 코드를 컴파일하면 개념적으로 다음과 같은 구조가 됩니다.

function TodoList({visibility, themeColor}) {
    const [todos, setTodos] = useState(initialTodos);

    let hasVisibilityChanged, hasThemeColorChanged, hasTodosChanged, memoCache;

    const handleChange = 
        memoCache[0] ||
        (memoCache[0] = todo => setTodos(todos => getUpdated(todos, todo)));

    let filtered;
    if (hasVisibilityChanged || hasTodosChanged) {
        filtered = memoCache[1] = getFittered(todos, visibility);
    } else {
        filtered = memoCache[1];
    }

    return (
        <div>
            <ul>
                {filtered.map(todo => (
                    <Todo key={todo.id} todo={todo} onChange={handleChange} />
                ))}
            </ul>
            <AddTodo setTodos={setTodos} themeColor={themeColor} />
        </div>
    );
}
function TodoList({visibility, themeColor}) {
    const [todos, setTodos] = useState(initialTodos);

    let hasVisibilityChanged, hasThemeColorChanged, hasTodosChanged, memoCache;

    const handleChange = 
        memoCache[0] ||
        (memoCache[0] = todo => setTodos(todos => getUpdated(todos, todo)));

    let filtered;
    if (hasVisibilityChanged || hasTodosChanged) {
        filtered = memoCache[1] = getFittered(todos, visibility);
    } else {
        filtered = memoCache[1];
    }

    return (
        <div>
            <ul>
                {filtered.map(todo => (
                    <Todo key={todo.id} todo={todo} onChange={handleChange} />
                ))}
            </ul>
            <AddTodo setTodos={setTodos} themeColor={themeColor} />
        </div>
    );
}
코드가 숨겨졌습니다.

React Forget은 memoCache를 활용해 변수의 변경 여부를 판단하여 불필요한 연산을 방지합니다. 이를 통해 useMemouseCallback을 사용한 코드와 유사한 수준의 최적화를 달성할 수 있습니다. 실제로는 JSX까지 캐싱하여 React.memo의 최적화 기능도 대체하지만, 코드의 복잡성을 줄이기 위해 여기서는 생략했습니다.

기존에는 useCallback, useMemo 등의 API를 사용해 React에게 적절한 리렌더링 시점을 명시적으로 알려줘야 했지만, React Forget을 사용하면 개발자 경험을 희생하지 않으면서 사용자에게 동일한 성능을 제공할 수 있습니다.

반응성 컴파일러로의 발전 링크 복사

React Forget은 계속해서 발전합니다. 2023년 3월, React 팀 블로그 게시글 'React Labs: What We’ve Been Working On - March 2023'에서 React Forget에 대한 새로운 개념 정의인 ‘An automatic reactivity compiler’가 소개되었습니다.

React Forget의 주요 목표는 React 앱이 기본적으로 적절한 양의 반응성을 갖도록 하는 것입니다. 구현 관점에서는 auto-memoizing(자동 메모화)를 의미하지만, 이 개념을 반응성 관점에서 이해하면 React Forget의 목적을 더 명확하게 파악할 수 있습니다. 기존의 React는 객체 참조의 변경을 감지하여 리렌더링하는 반면, React Forget은 깊은 비교 없이도 의미 있는 값의 변화를 감지하여 리렌더링합니다. 이에 대한 자세한 내용은 React 팀의 Sathya Gunasekaran이 작성한 'Compiler Theory And Reactivity' 포스트에서 확인할 수 있습니다.

그리고 2024년 2월, React 팀은 블로그 게시글 'React Labs: What We’ve Been Working On - February 2024'를 통해 React Forget의 새 이름인 React Compiler를 공개했습니다.

공개된 React Compiler 톺아보기 링크 복사

약 2년간의 기다림 끝에, React 팀은 React Conf 2024에서 React Compiler를 오픈소스로 공개했습니다. React Compiler는 Babel 플러그인(babel-plugin-react-compiler) 형태로 제공되며, 이를 사용하면 React 코드를 간단히 최적화된 형태로 변환할 수 있습니다.

이 컴파일러의 최적화 과정을 이해하기 위해 컴파일 전과 후의 코드를 비교 분석해 보겠습니다. 아래는 useMemo를 사용하여 postTitletitleStyle을 수동으로 최적화하는 코드입니다.

function Post({ post, big }) {
    const styles = useStyles()
    const testValue = 'hello react compiler';

    const postTitle = useMemo(() => `Title: ${post.title}`, [post])

    const titleStyle = useMemo(() => {
        if (big) {
            return { fontSize: styles.fontSize.big }
        } else {
            return { fontSize: styles.fontSize.small }
        }
    }, [big, styles])

    return (
        <section>
            <Title style={titleStyle}>{postTitle}</Title>
            <p>{post.body}</p>
            <LikeButton id={post.id} />
            <p>{testValue}</p>
        </section>
    )
}
function Post({ post, big }) {
    const styles = useStyles()
    const testValue = 'hello react compiler';

    const postTitle = useMemo(() => `Title: ${post.title}`, [post])

    const titleStyle = useMemo(() => {
        if (big) {
            return { fontSize: styles.fontSize.big }
        } else {
            return { fontSize: styles.fontSize.small }
        }
    }, [big, styles])

    return (
        <section>
            <Title style={titleStyle}>{postTitle}</Title>
            <p>{post.body}</p>
            <LikeButton id={post.id} />
            <p>{testValue}</p>
        </section>
    )
}
코드가 숨겨졌습니다.

이 코드를 React Compiler Playground에서 컴파일을 수행해보았습니다.

function Post(t0) {
    const $ = _c(16);
    const { post, big } = t0;
    const styles = useStyles();
    let t1;
    t1 = `Title: ${post.title}`;
    const postTitle = t1;
    let t2;
    if (big) {
        let t3;
        if ($[0] !== styles.fontSize.big) {
            t3 = {
                fontSize: styles.fontSize.big,
            };
            $[0] = styles.fontSize.big;
            $[1] = t3;
        } else {
            t3 = $[1];
        }
        t2 = t3;
    } else {
        let t3;
        if ($[2] !== styles.fontSize.small) {
            t3 = {
                fontSize: styles.fontSize.small,
            };
            $[2] = styles.fontSize.small;
            $[3] = t3;
        } else {
            t3 = $[3];
        }
        t2 = t3;
    }
    const titleStyle = t2;
    let t3;
    if ($[4] !== titleStyle || $[5] !== postTitle) {
        t3 = <h1 style={titleStyle}>{postTitle}</h1>;
        $[4] = titleStyle;
        $[5] = postTitle;
        $[6] = t3;
    } else {
        t3 = $[6];
    }
    let t4;
    if ($[7] !== post.body) {
        t4 = <p>{post.body}</p>;
        $[7] = post.body;
        $[8] = t4;
    } else {
        t4 = $[8];
    }
    let t5;
    if ($[9] !== post.id) {
        t5 = <LikeButton id={post.id} />;
        $[9] = post.id;
        $[10] = t5;
    } else {
        t5 = $[10];
    }
    let t6;
    if ($[11] === Symbol.for("react.memo_cache_sentinel")) {
        t6 = <p>{"hello react compiler"}</p>;
        $[11] = t6;
    } else {
        t6 = $[11];
    }
    let t7;
    if ($[12] !== t3 || $[13] !== t4 || $[14] !== t5) {
        t7 = (
            <section>
                {t3}
                {t4}
                {t5}
                {t6}
            </section>
        );
        $[12] = t3;
        $[13] = t4;
        $[14] = t5;
        $[15] = t7;
    } else {
        t7 = $[15];
    }
    return t7;
}
function Post(t0) {
    const $ = _c(16);
    const { post, big } = t0;
    const styles = useStyles();
    let t1;
    t1 = `Title: ${post.title}`;
    const postTitle = t1;
    let t2;
    if (big) {
        let t3;
        if ($[0] !== styles.fontSize.big) {
            t3 = {
                fontSize: styles.fontSize.big,
            };
            $[0] = styles.fontSize.big;
            $[1] = t3;
        } else {
            t3 = $[1];
        }
        t2 = t3;
    } else {
        let t3;
        if ($[2] !== styles.fontSize.small) {
            t3 = {
                fontSize: styles.fontSize.small,
            };
            $[2] = styles.fontSize.small;
            $[3] = t3;
        } else {
            t3 = $[3];
        }
        t2 = t3;
    }
    const titleStyle = t2;
    let t3;
    if ($[4] !== titleStyle || $[5] !== postTitle) {
        t3 = <h1 style={titleStyle}>{postTitle}</h1>;
        $[4] = titleStyle;
        $[5] = postTitle;
        $[6] = t3;
    } else {
        t3 = $[6];
    }
    let t4;
    if ($[7] !== post.body) {
        t4 = <p>{post.body}</p>;
        $[7] = post.body;
        $[8] = t4;
    } else {
        t4 = $[8];
    }
    let t5;
    if ($[9] !== post.id) {
        t5 = <LikeButton id={post.id} />;
        $[9] = post.id;
        $[10] = t5;
    } else {
        t5 = $[10];
    }
    let t6;
    if ($[11] === Symbol.for("react.memo_cache_sentinel")) {
        t6 = <p>{"hello react compiler"}</p>;
        $[11] = t6;
    } else {
        t6 = $[11];
    }
    let t7;
    if ($[12] !== t3 || $[13] !== t4 || $[14] !== t5) {
        t7 = (
            <section>
                {t3}
                {t4}
                {t5}
                {t6}
            </section>
        );
        $[12] = t3;
        $[13] = t4;
        $[14] = t5;
        $[15] = t7;
    } else {
        t7 = $[15];
    }
    return t7;
}
코드가 숨겨졌습니다.

최적화된 코드는 원본 코드에 비해 복잡해 보이지만, 읽는 데는 큰 어려움이 없어 보입니다. 주요 부분을 자세히 살펴볼까요?

function Post(t0) {
    const $ = _c(16);
function Post(t0) {
    const $ = _c(16);
코드가 숨겨졌습니다.

첫 줄의 const $ = _c(16) 구문에서 _c는 React에서 가져온 useMemoCache의 별칭입니다. react/compiler-runtime.js 파일에서 useMemoCachec로 내보내는 것을 확인할 수 있습니다.

// react/compiler-runtime.js
export {useMemoCache as c} from './src/ReactHooks';
// react/compiler-runtime.js
export {useMemoCache as c} from './src/ReactHooks';
코드가 숨겨졌습니다.

useMemoCache 구현을 보면, 인자로 전달된 16은 캐시의 크기를 나타냅니다.

// packages/react-reconciler/src/ReactFiberHooks.js
function useMemoCache(size: number): Array<any> {
    let memoCache = null;
    // Fast-path, load memo cache from wip fiber if already prepared
    let updateQueue: FunctionComponentUpdateQueue | null =
        (currentlyRenderingFiber.updateQueue: any);
  
(..생략)
// packages/react-reconciler/src/ReactFiberHooks.js
function useMemoCache(size: number): Array<any> {
    let memoCache = null;
    // Fast-path, load memo cache from wip fiber if already prepared
    let updateQueue: FunctionComponentUpdateQueue | null =
        (currentlyRenderingFiber.updateQueue: any);
  
(..생략)
코드가 숨겨졌습니다.

전체 과정을 요약하면, compiler-runtimeuseMemoCachec로 내보내고, babel 컴파일 과정에서 필요한 import 구문이 아래와 같이 코드 상단에 자동으로 추가되어 _c를 사용하게 됩니다.

import { c as _c } from "react/compiler-runtime";
import { c as _c } from "react/compiler-runtime";
코드가 숨겨졌습니다.

react/compiler-runtime에서 c를 가져오는 것이 기본값이지만, useMemoCache가 제공되지 않는 React 18 버전에서는 react-compiler-runtime 패키지를 직접 설치하여 바벨 플러그인 옵션을 통해 런타임 모듈을 변경하는 옵션도 제공되고 있습니다.

다시 돌아와서, 코드를 이어서 보겠습니다.

let t2;
if (big) {
    let t3;
    if ($[0] !== styles.fontSize.big) {
        t3 = {
            fontSize: styles.fontSize.big,
        };
        $[0] = styles.fontSize.big;
        $[1] = t3;
    } else {
        t3 = $[1];
    }
    t2 = t3;
} else {
  ...
}
const titleStyle = t2;
let t2;
if (big) {
    let t3;
    if ($[0] !== styles.fontSize.big) {
        t3 = {
            fontSize: styles.fontSize.big,
        };
        $[0] = styles.fontSize.big;
        $[1] = t3;
    } else {
        t3 = $[1];
    }
    t2 = t3;
} else {
  ...
}
const titleStyle = t2;
코드가 숨겨졌습니다.

기존 titleStyleuseMemo를 사용해 수동으로 캐시 되었습니다. 내부에서는 styles.fontSize.big 값만 참조하지만, 의존성 배열에 styles 객체 전체를 전달했습니다. 이에 따라 styles 내 참조하지 않는 필드 값이 변경되어도 titleStyle이 불필요하게 재계산되는 문제가 있었습니다.

컴파일 후의 코드는 필요한 대상만 정확히 비교하고, 값이 변경되지 않으면 캐시된 값을 사용합니다. 이 과정에서 불필요한 반응성이 제거되었습니다. 이러한 최적화를 통해 개발자가 수동으로 캐시를 구현하여 이미 최적화한 코드에서도 추가적인 성능 개선이 가능해졌습니다.

그리고 바로 아래, 기존 코드의 맥락과 다른 코드가 보입니다.

let t3;
if ($[4] !== titleStyle || $[5] !== postTitle) {
    t3 = <Title style={titleStyle}>{postTitle}</Title>;
    $[4] = titleStyle;
    $[5] = postTitle;
    $[6] = t3;
} else {
    t3 = $[6];
}
let t3;
if ($[4] !== titleStyle || $[5] !== postTitle) {
    t3 = <Title style={titleStyle}>{postTitle}</Title>;
    $[4] = titleStyle;
    $[5] = postTitle;
    $[6] = t3;
} else {
    t3 = $[6];
}
코드가 숨겨졌습니다.

titleStylepostTitle을 참조하는 JSX를 분리하여 캐시하고 해당 값들이 변경되지 않으면 캐시된 값을 재사용할 수 있습니다. 이러한 방식으로 컴포넌트는 titleStylepostTitle의 변경에만 반응하도록 최적화되며, 이는 memo(Title)을 사용한 것과 유사한 수준의 성능 향상을 달성할 수 있습니다.

최종적으로는 아래와 같이 반환되며 코드가 종료됩니다.

let t7;
if ($[12] !== t3 || $[13] !== t4 || $[14] !== t5) {
    t7 = (
        <section>
            {t3}
            {t4}
            {t5}
            {t6}
        </section>
    );
    $[12] = t3;
    $[13] = t4;
    $[14] = t5;
    $[15] = t7;
} else {
    t7 = $[15];
}
return t7;
let t7;
if ($[12] !== t3 || $[13] !== t4 || $[14] !== t5) {
    t7 = (
        <section>
            {t3}
            {t4}
            {t5}
            {t6}
        </section>
    );
    $[12] = t3;
    $[13] = t4;
    $[14] = t5;
    $[15] = t7;
} else {
    t7 = $[15];
}
return t7;
코드가 숨겨졌습니다.

이렇게 최적화된 컴포넌트는 React DevTools 내에서 반짝이는 Memo 배지로 표시됩니다. 이 배지는 해당 컴포넌트가 메모이제이션되어 있음을 나타냅니다.


이제 React Compiler가 어떻게 코드를 최적화하는지 조금 감이 오시나요? 🙂

React Compiler는 우리가 최적화를 위해 노력했던 부분과 미처 신경 쓰지 못했던 부분까지 세심하게 다루어 줍니다. 값의 변경을 정확히 추적하여 불필요한 연산을 줄이고, JSX 요소들을 효율적으로 캐싱하며, 컴포넌트의 각 부분이 정말 필요한 값의 변경에만 반응하도록 만듭니다.

이렇게 최적화된 코드는 수치상으로 어느 정도의 개선 효과를 발휘할까요? 규모가 큰 앱에서도 충분히 효과를 발휘할 수 있을까요?

성능 개선 효과 링크 복사

Instagram.com을 비롯한 Meta의 일부 서비스에는 이미 프로덕션 레벨에서 React Compiler를 사용하고 있으며 적용한 후, 다음과 같은 성과를 얻었습니다.
(출처: https://youtu.be/lyEKhv8-3n0?si=Qzp_GF2jExZUzpOb)

  • 마우스 클릭 및 스크롤 등 상호작용이 최대 2.5배 빨라짐
  • 페이지 초기 로드 및 내비게이션 속도 최대 12% 상승
  • React Compiler가 적용된 Meta의 전체 서비스에서 평균 3~4%의 성능 개선
  • 컴포넌트 코드 라인 17% 감소
  • 메모리 사용량 상승 0%

코드 라인이 17% 감소했지만, 성능은 오히려 개선되었습니다. 코드 라인 감소 효과는 일회성이 아닙니다. 지속적으로 개발자들이 최적화에 쏟는 시간과 노력을 상당 부분 줄여줄 수 있음을 의미합니다.

특히, 고도로 최적화된 코드 베이스에서도 기대 이상의 성능 개선이 이루어졌다는 점은 정말 놀랍습니다. 이는 React Compiler의 강력한 최적화 기능이 이미 최적화된 환경에서도 추가적인 성능 향상을 끌어낼 수 있음을 보여줍니다.

마치며 링크 복사

전 세계의 많은 프로덕션 웹 서비스에서 React는 압도적인 비중을 차지하고 있습니다. 그렇기에 React의 작은 변화도 엄청난 영향을 미칠 수밖에 없습니다. 이러한 이유로 변화에 대한 접근은 매우 신중하게 이루어집니다. 현대적인 반응성 시스템을 갖춘 Svelte, Solid.js, 등과 같은 새로운 프레임워크들이 등장하면서, React의 반응성 시스템이 구식으로 여겨지기도 합니다.

그러나 React의 최근 접근 방식은 혁신과 안정성 사이의 균형을 잘 보여줍니다. 기본 반응성 시스템과 핵심 철학은 그대로 유지하면서, 컴파일러를 통해 개발자 코드에서만 최적화를 수행합니다. 이 전략은 기존 사용자 경험을 해치지 않으면서도 성능을 크게 향상시킵니다. React는 현재의 강점을 유지하면서 미래의 과제에 대응하는 방식으로 진화하고 있습니다.

시대의 변화에 맞춰 대응하는 React의 전략, 흥미롭지 않으신가요?