작성자: Andrew Patton
React Compiler에 의존한다는 것은 언제 실패하는지 아는 것을 의미합니다
우리는 2017년부터 시각적 에디터, 디자인 도구와 같이 상호작용이 매우 활발한 React UI를 구축해 왔습니다. 사용자가 요소를 드래그하고, 실시간으로 속성을 조정하며, 모든 상호작용이 Figma나 Photoshop처럼 반응성이 뛰어나기를 기대하는 종류의 애플리케이션들입니다. 불필요한 리렌더링(re-render)은 직접 조작하고 있다는 환상을 깨뜨려 경험을 느리고 불쾌하게 만들 수 있습니다.
8년 동안 우리는 useMemo와 useCallback으로 생각하도록 스스로를 훈련했습니다. 과도한 리렌더링을 유발할 수 있는 모든 값을 찾아내는 내부 컴파일러를 머릿속에 구축했습니다. 그것은 제2의 천성과도 같았습니다.
그러다 React Compiler가 단 몇 주 만에 그 모든 것을 지워버렸습니다.
수동 메모이제이션의 문제
수동 메모이제이션(Manual memoization)은 단순히 지루한 작업일 뿐만 아니라, 작성하는 모든 컴포넌트에 부과되는 인지적 세금입니다. 다음과 같은 사항들을 고려해야 합니다:
- 이 이벤트 핸들러에
useCallback이 필요한가?
.map(...) 내부의 props를 안정화하기 위해 이것을 별도의 ComponentItem.tsx 파일로 추출해야 하는가?
- 이 스타일 객체를 호이스팅(hoist)해야 하는가, 아니면
useMemo로 감싸야 하는가?
- 이 컨텍스트 프로바이더(context provider)가 하위 흐름에서 불필요한 리렌더링을 트리거할 것인가?
잘못 판단하면 성능을 떨어뜨리거나 코드베이스를 성급한 최적화로 어지럽히게 됩니다. 제대로 하더라도 제품이 아닌 배관 작업에 정신적 에너지를 쏟게 됩니다.
React Compiler는 이를 완전히 제거합니다. Outlyne에서 우리는 6개월 이상 이를 프로덕션 환경에서 실행해 왔습니다. 이제는 핫 모듈 교체(hot module replacement)나 자동 코드 포맷터처럼 없어서는 안 될 도구가 되었습니다. 우리는 더 이상 메모이제이션에 대해 생각하지 않습니다. 수년간의 습관으로 굳어진 그 고정관념들이 매끄럽게 닦여 나갔습니다.
소리 없는 실패의 문제
여기까지는 좋은 소식입니다. 하지만 우리를 놀라게 한 점이 있습니다. React Compiler가 컴포넌트를 컴파일할 수 없을 때, 아무런 예고 없이 조용히 실패한다는 것입니다.
그 철학은 일리가 있습니다. 컴파일러는 코드를 작동하게 만들기 위해서가 아니라, 코드를 더 잘 작동하게 만들기 위해 존재합니다. 무언가를 최적화할 수 없다면 표준 React 동작으로 되돌아갑니다. 앱은 여전히 실행됩니다.
하지만 이제 더 이상 수동으로 메모이제이션을 하지 않게 되면서, 수동 메모이제이션이 일종의 코드 부채라는 사실이 명확해졌습니다. 그것은 컴포넌트 로직을 따라가기 어렵게 만드는 불필요한 복잡성이며, 의존성 배열(dependency arrays)은 유지보수의 부담입니다. 그리고 React Compiler가 있는 세상에서 그것은 모든 악의 근원인 성급한 최적화입니다. 우리는 코드베이스 어디에서도 그것을 원하지 않습니다. 이는 우리가 이제 특정 컴포넌트들, 특히 빈번한 상호작용을 처리하거나 비용이 많이 드는 컨텍스트 프로바이더를 관리하는 컴포넌트들이 성공적으로 컴파일되는 것에 의존하고 있음을 의미합니다. 만약 그런 컴포넌트들에서 조용히 실패한다면, 사용자 경험은 저하되고 일부 UX는 완전히 망가질 수도 있습니다. 우리는 우리 홈페이지의 타자기 애니메이션에서 이를 발견했습니다. 우리는 이를 SSE에서 일반 fetch로 리팩터링하면서, try 블록 안에 null 병합 연산자(nullish coalescing)가 포함된 try/catch를 추가했습니다. 그로 인해 React Compiler와 호환되지 않게 되었고, 입력값에 대한 ref 콜백이 계속해서 요동치는 이상한 리렌더링 루프가 발생했습니다.
우리는 컴파일이 실패했을 때 이를 알 수 있는 방법이 필요하며, 그것이 빌드를 중단시켜야 한다는 것을 깨달았습니다.
문서화되지 않은 ESLint 규칙
react-compiler-babel-plugin 소스 코드를 뒤져본 끝에, 해결책을 찾았습니다:규칙 이름은 todo이므로, 대부분의 설정에서(eslint-plugin-react-hooks를 다른 이름으로 설정하지 않은 한) 규칙의 전체 이름은 react-hooks/todo입니다. 우리가 찾을 수 있는 그 어디에도(예: 이러한 React Compiler ESLint 규칙들) 문서화되어 있지 않지만, 이를 에러로 활성화하면 컴파일러가 아직 처리할 수 없는 구문이 있는 모든 컴포넌트에서 빌드가 중단됩니다. 이를 적용하면, 홈페이지 예시의 이 코드는:
다음과 같은 린트(lint) 에러를 발생시킵니다:
설정 방법은 다음과 같습니다:
이 기능을 켜면 얼마나 많은 컴포넌트가 실패하는지 보고 놀라게 될 것입니다. React Compiler가 아직 지원하지 않는 패턴을 배우기 전까지, 우리는 컴파일할 수 없는 컴포넌트가 100개 이상이었습니다.
무엇이 컴파일러를 중단시키는가
우리가 마주친 가장 흔한 미지원 패턴은 props를 구조 분해(destructuring)한 후 이를 변경(mutating)하는 것이었습니다.
이것은 컴파일을 중단시킵니다:
다행히 해결책은 깔끔하며 오히려 개선된 방식이라 할 수 있습니다. 구조 분해된 prop을 변경하는 대신 새 변수를 생성하면 됩니다:
또 다른 제한 사항은 복잡성이 있는 try/catch 블록입니다. 컴포넌트가 try/catch와 함께 비동기 작업을 수행하는 경우 다음을 사용할 수 없습니다:
- 삼항 연산자, 옵셔널 체이닝(optional chaining), 또는 null 병합 연산자
"조건문 불가" 부분은 정말 골칫거리입니다. 컴포넌트가 에러를 발생시킬 수 있는 작업을 수행할 때, 대개 try 또는 catch 블록 안에 조건부 로직을 두게 되기 때문입니다.
린트 규칙의 이름("todo")과 설명("미구현 기능")에서 알 수 있듯이, 이들은 모두 표면적으로는 일시적인 제한 사항입니다. 우리는 이들 중 대부분, 혹은 전부가 해결될 것이라고 확신합니다. 다만 Support ThrowStatement inside of try/catch todo 에러 앞에는 다음과 같은 주석이 달려 있다는 점을 언급해야겠습니다: 그러니 전부 해결되지는 않을 수도 있겠네요? 아이러니하게도, 우리는 try 블록 내부에서 안전하지 않은 속성 접근에 의존함으로써 Support value blocks… 에러를 우회해 왔으며, 이는 제어 흐름을 위해 던져진 예외에 암묵적으로 의존하는 방식이었습니다.
어쨌든 그동안 우리는 React에서 리렌더링을 방지하기 위한 최적화 기법에 대해 내면화했던 모범 사례들과 마찬가지로, 이러한 제한 사항들을 기억 속에 새기고 있는 자신을 발견했습니다. 그것은 분명 우리가 원하는 결과가 아닙니다.
린트 활용하기
이것이 바로 ESLint 규칙이 가치 있는 이유입니다. 이 규칙 덕분에 이러한 패턴들을 일일이 기억할 필요가 없어집니다. 하지만 일부 컴포넌트는 단지 컴파일러를 만족시키기 위해 복잡하게 만들고 싶지 않은 패턴을 사용하기도 합니다.
그런 경우에는 명시적으로 규칙을 비활성화합니다:
이 접근 방식은 두 세계의 장점을 모두 제공합니다:
- 중요한 컴포넌트는 반드시 컴파일되어야 하며, 그렇지 않으면 빌드가 중단됩니다.
- 중요하지 않은 컴포넌트는 코드를 가장 명확하게 만드는 어떤 패턴이든 사용할 수 있습니다.
- 우리는 메모이제이션에 대해 전혀 생각하지 않습니다.
React Compiler를 사용해야 할까요?
당연합니다! 특히 성능이 중요한 대화형 UI를 구축하고 있다면 더욱 그렇습니다. 인지적 해방감만으로도 그 가치는 충분합니다.
하지만 기본적으로 컴파일이 조용히 실패할 것이라는 점을 알고 시작하십시오. 컴포넌트가 반드시 적절하게 메모이제이션되어야 하는 중요한 코드 경로가 있다면, ESLint 규칙을 설정하고 빌드가 중단되도록 하십시오. 그런 다음 어떤 컴포넌트에 컴파일이 필요하고 어떤 컴포넌트에 필요하지 않은지 의식적인 결정을 내리십시오.
제한 사항은 일시적입니다. UI를 구축하는 방식의 변화는 영구적입니다.