이 포스트는 제가 리액트 라이브러리를 구현하면서 경험하고 구체화 하려고 한 기능에 대한 정리를 위한 포스트니다.

얼마전에 이직을 준비하면서 과제를 진행하면서 리액트 라이브러리를 직접 구현하게 되었습니다. 일반적으로 리액트 라이브러리를 단순히 구현하는것은 생각보다 어려운 일은 아니지만 리액트 라이브러리를 통해 랜더링 최적화에 대해 고민하고 해당 목표를 달성하기 위해서 라이브러리의 구성 요소를 저만의 전략으로 다시 작성해야 하는 과제였습니다.

우선 리액트가 구현한 랜더링을 하는 단계가 있는데, 크게 2단계의 랜더링을 하게 됩니다. 초기 모든 요소의 랜더링 그리고 diff알고리즘에 의해서 새로움 가상돔에 의한 리랜더링입니다.

제가 고민했던 부분은 이런 리랜더링에 대한 최적화를 위한 방법에 대한 고민이였습니다.

가장 많은 고민을 했던 부분은 사용자가 원하는 시점에 리랜더링을 한번만 하도록 라이브러리를 구현하고 싶다 였습니다. 이런 고민들과 리액트의 히스토리를 보니 리액트가 업데이트를 하면 거친 과정에 대해서 이해 되는 부분이 많이 있었습니다. 리액트 내부의 상태관리를 동시성프로그래밍으로 처리할 수있도록 제공하는 최근의 새로운 훅등이 제가 원하는 최적화를 위한 기능을 함수로 추상화한 것이였습니다.

일단 리액트와 같은 메커니즘으로 구동되는 라이브러리 구현이 1차 목표이기 떄문에 리액트의 리랜더링 트리거 포인트를 조사해야 했습니다. 또한 리랜더링에 대한 기능 정의를 위해서 실제 리액트라이브러리에서의 리랜더링 트리거와 예상치 못한 어떤 루트들이 있는지 알아보는게 좋을 것 같았습니다.

첫번째, state의 변경

가장 보통의 형태의 리랜더링 트리거 입니다. 해당 상태의 자료구조를 랜더러에서 구독하는 형태로 포함시키고 해당 상태가 변경 되면 랜더러에서 diff알고리즘을 통해서 새로운 요소를 생성하고, 해당 트리거 컴포넌트에 해당하는 실제 돔을 업데이트하는 로직입니다. 이 업데이트 로직은 제가 작성한 렌더러에서는 업데이트 시 동일한 로직으로 구현되어 있기 때문에, 모든 리랜더링시에 같은 로직으로 구동되어 집니다.

아무튼 이렇게 가장 기본이 되는 상태 변경에서는 따로 제가 처리할 부분은 없다고 판단했습니다.

두번째, 부모의 리렌더링

2번째로 부모요소의 랜더링에 의해 자식요소가 랜더링 되는 부분인데 제가 만든 라이브러리에서는 부모요소 컴포넌트만 평가해고 업데이트하고 자식요소는 평가하지 않는 기본로직으로 구현했습니다. 이는 장단점이 있겠지만, 제 기준으로는 저의 전략이 더 효율적이라고 판단했습니다. 만약 실제 리액트에서는 이를 막기위해서는 리액트 메모 함수를 통해 자식 요소를 랩핑해서 메모라이징 해줘야 불필요한 리랜더링을 방어할 수 있고, 해당 메모함수는 의존성리스트를 받아서 해당 리스트가 변경되었지에 의한 리랜더링을 수행할 수 있습니다.

세번째, 컨텍스트 변경

저는 contextAPI를 구현을 상태 자료구조에 컴포넌트 키 맵핑이 아닌 컨텍스트 키 맵핑으로 하여 해당 컨텍스트 프로바이더를 참조하여 하위 요소에서 상태를 참조하고 상태 변경시 해당 프로바이더 하위 요소에서 새로운 요소를 참조할 수있도록 구현했습니다. 하지만 컴포넌트에서 상태를 구독하지 않는다면 해당 컴포넌트를 새로 호출하여 바뀐 컨텍스트 상태를 참조하지 못하게 되고, 리랜더링도 되지 않는 구조로 작업하였고 부모요소 렌더링과 같이 하위요소 또한 새로운 함수 호출을 하지 않게 했습니다.

네번째, 컴포넌트 함수 내부에서 새로운 컴포넌트 재생성

컴포넌트는 호출하는 시점에 라이브러리 내부에 구현된 함수에 의해서 컴포넌트 객체로 변환되어 트리구조에 포함되게 됩니다. 그리고 이는 새로운 트리구조이기에 리랜더링 트리거에 해당됩니다. 제가 생각했을 때에는 이부분은 안티패턴으로 보여지기 때문에 제 라이브러리에서는 컴포넌트 함수 내부에 새로운 컴포넌트 생성에 의한 기능은 방어코드를 금지시키고 해당 코드에서 에러가 생성되어 전달되도록 했습니다.

실제 리액트 라이브러리에서는 해당 리랜더링 트리거는 실행됩니다.

다섯번째, props의 변경

두번쨰로 잘 알려져있는 리랜더링 트리거 입니다. 이부분에 대한 리랜더링 이슈를 체크했을떄는 props를 평가하는 로직에서 해당 값의 변경을 감지하기 위해선 얕은 비교로 하고 있기 때문에 객체구조 자체를 넘기는 것을 방어하기로 했습니다. 값을 넘기지 않으면 비교하지 않고 에러를 발생시키도록하여 해당 패턴을 강제하였습니다.

위의 리랜더링 트리거 포인트를 최적화 하고, 또한 리랜더링 최적화를 위해서 리액트 사용하는 키와 값을 자체 메모라이징 하기로 했습니다. 메모라이징을 위해서 앞서 props등 처럼 객체는 메모라이징 하지 않기로 했습니다. 이는 비교 알고리즘 최적화를 위한 방법이라고 판단했습니다.

모든 상태는 이제 메모라이징 되어 있고, 참조한 컴포넌트의 함수로 인해서는 상태 변경이 되었다고 판단하지 않게 했습니다.

이런 방법으로 리랜더링 최적화를 진행했고, 현재 최신 버전의 리액트에서는 어떤 방법으로 최적화를 했는지 알아봤습니다.

React forget

리액트에서 컴포넌트 내부 함수 안의 값을 메모라이징 하기 위해서 사용하기 위해서 해당 훅 함수를 통해 구현되어 있습니다. 하지만 리액트는 해당 메모라이징 함수를 아예 포함한 구조의 컴포넌트로 업데이트한것 변경되었습니다. 자동으로 메모라이징 함수를 추가하는 로직은 없지만 저의 랜더링 최적화에서도 이와 목적은 동일했다는것을 알 수 있었고, 제가 만들고자 하고 문제인식이라고 느낀 부분을 리액트서 개선하고 있다는 것을 보면서 결국 비교로직의 단순화를 위해서 포기했던 부분을 리액트에서도 개선하고자 한다는 것을 알 수 있었습니다.

정리하자면 결국 상태 값이 변경되어 지고 이를 통해 새로운 리액트 컴포넌트 객체를 만들어서 비교 알고리즘에 의해서 리랜더링이 결정되기 때문에 2단계의 개선 포인트가 존재하는데, 1차적으로 상태값의 변경에 의한 개선 포인트, 그리고 비교에 의한 개선포인트 그리고 저는 1차적인 상태값 변경은 현재의 리액트 사용법과 너무 동떨어진 코드를 만들어야 하기 때문에 하지 않았고, 비교 알고리즘 개선을 위해 얕은 비교 전용 데이터 평탄화 메모라징 함수 내부구현으로 이를 해결하고자 했다.

랜더링 최적화를 위해서 리액트에서 어떤 방향으로 코드를 개선할지에 대해서 좀 더 이해할 수 있었던 과제 프로젝트였던것 같고, 향후 개선 상황에 있어서도 잘 팔로업할 수 있게 된것 같아 보람된 것 같다.