서문
React가 최근 v17.0.0-rc.0을 발표했습니다. 이전 메이저 버전인 v16.0(2017/9/27 발표)으로부터 거의 3년이 지났습니다.
새로운 기능이 가득했던 React 16 및 이전 메이저 버전들과 비교하면, React 17은 매우 특별해 보입니다. 바로 새로운 기능이 없기 때문입니다:
React v17.0 Release Candidate: No New Features
그뿐만 아니라, 7가지의 breaking change도 함께 포함되었습니다……
1. 정말 새로운 기능이 없나요?
React 공식팀은 v17을 이후 버전의 업그레이드 비용을 줄이는 것을 주요 목표로 하는 기술적 개선 버전으로 정의했습니다:
This release is primarily focused on making it easier to upgrade React itself.
따라서 v17은 단지 밑거름일 뿐이며, 중대한 신기능을 발표하기보다는 v18, v19…… 등 향후 버전들이 더 매끄럽고 빠르게 업그레이드될 수 있도록 하기 위함입니다:
When React 18 and the next future versions come out, you will now have more options.
하지만 그중 일부 개선 사항은 하위 호환성을 깨뜨릴 수밖에 없었기에 v17이라는 메이저 버전 변경을 제안하게 되었고, 그 김에 2년 넘게 쌓인 기술 부채들을 정리했습니다.
2. 점진적 업그레이드가 가능해졌습니다
v17 이전에는 서로 다른 버전의 React를 함께 사용할 수 없었습니다 (이벤트 시스템에 문제가 발생함). 따라서 개발자는 구버전을 계속 사용하거나, 큰 노력을 들여 전체를 새 버전으로 업그레이드해야 했습니다. 오랫동안 변경 사항이 없던 롱테일 모듈조차 전체적인 적응 및 회귀 테스트가 필요했죠. 개발자의 업그레이드 비용을 고려하여 React 유지보수 팀 또한 제약이 많았습니다. 폐기된 API를 쉽게 제거하지 못하고 장기간, 혹은 끝없이 유지보수하거나 오래된 애플리케이션을 포기해야 했습니다.
하지만 React 17은 새로운 옵션을 제공합니다. 바로 점진적 업그레이드로, 여러 버전의 React가 공존하는 것을 허용합니다. 이는 대규모 프론트엔드 애플리케이션에 매우 유용합니다. 예를 들어 팝업 컴포넌트나 특정 라우트 아래의 페이지들은 나중에 업그레이드하기로 하고, 부분별로 매끄럽게 새 버전으로 전환할 수 있습니다 (공식 데모 참고).
P.S. (필요에 따라) 여러 버전의 React를 로드하는 것은 적지 않은 성능 비용이 발생하므로 신중하게 고려해야 합니다.
다중 버전 공존과 마이크로 프론트엔드 아키텍처
여러 버전의 공존 및 신구 버전 혼용 지원은 마이크로 프론트엔드 아키텍처가 지향하는 점진적 리팩토링을 가능하게 했습니다:
점진적으로 일부 프론트엔드 기능을 업그레이드, 업데이트하거나 심지어 다시 작성하는 것이 가능해졌습니다.
React가 다중 버전 공존을 지원하여 점진적인 버전 업그레이드를 돕는 것과 달리, 마이크로 프론트엔드는 서로 다른 기술 스택의 공존을 허용하고 업그레이드된 아키텍처로 매끄럽게 전환하는 것에 더 집중하며, 더 넓은 범주의 문제를 해결합니다.
다른 한편으로, React 기술 스택 내의 다중 버전 혼용 문제가 해결됨에 따라 마이크로 프론트엔드에 대해 다시 생각해 볼 필요가 있습니다:
-
일부 문제는 기술 스택 자체에서 해결하는 것이 더 적절하지 않은가?
-
다중 기술 스택의 공존은 상시적인 상태인가, 아니면 단기적인 과도기인가?
-
단기적인 과도기라면 더 가벼운 해결책이 존재하지 않는가?
마이크로 프론트엔드가 어떤 문제를 해결하는지에 대한 더 많은 고민은 Why micro-frontends?를 확인하세요.
3. 7가지 Breaking change
이벤트 위임 대상이 더 이상 document가 아님
이전까지 다중 버전 공존의 주요 문제는 **React 이벤트 시스템의 기본 위임 매커니즘**에 있었습니다. 성능상의 이유로 React는 document에만 이벤트 리스너를 등록합니다. DOM 이벤트가 발생하여 document까지 버블링되면 React는 대응하는 컴포넌트를 찾아 React 이벤트(SyntheticEvent)를 생성하고, 컴포넌트 트리를 따라 이벤트 버블링을 시뮬레이션합니다 (이 시점에 네이티브 DOM 이벤트는 이미 document를 넘어서게 됩니다):
[caption id="attachment_2263" align="alignnone" width="625"]
react 16 delegation[/caption]
따라서 서로 다른 버전의 React 컴포넌트가 중첩되어 사용될 때, e.stopPropagation()이 제대로 작동하지 않았습니다 (서로 다른 버전의 이벤트 시스템은 독립적이며, 둘 다 document에 도달했을 때는 이미 너무 늦었기 때문입니다):
If a nested tree has stopped propagation of an event, the outer tree would still receive it.
P.S. 실제로 Atom 에디터 팀은 몇 년 전에 이미 이 문제에 직면했습니다.
이 문제를 해결하기 위해 React 17은 더 이상 document에 이벤트를 위임하지 않고, DOM 컨테이너에 위임합니다:
[caption id="attachment_2264" align="alignnone" width="625"]
react 17 delegation[/caption]
예를 들어:
const rootNode = document.getElementById('root');
// render를 예로 들면
ReactDOM.render(<App />, rootNode);
// Portals도 마찬가지입니다
// ReactDOM.createPortal(<App />, rootNode)
// React 16 이벤트 위임 (document에 등록)
document.addEventListener()
// React 17 이벤트 위임 (DOM container에 등록)
rootNode.addEventListener()
다른 한편으로 이벤트 시스템을 document에서 컨테이너로 좁힘으로써 React가 다른 기술 스택과 공존하기 더 쉬워졌습니다 (최소한 이벤트 매커니즘에서의 차이가 줄어들었기 때문입니다).
브라우저 네이티브 이벤트와의 간극 좁히기
그 밖에도 React 이벤트 시스템은 브라우저 네이티브 이벤트와 더욱 유사해지도록 몇 가지 소규모 변경을 진행했습니다:
-
onScroll은 더 이상 버블링되지 않습니다. -
onFocus/onBlur는 네이티브focusin/focusout이벤트를 직접 사용합니다. -
캡처 단계의 이벤트 리스너는 네이티브 DOM 이벤트 리스너 매커니즘을 직접 사용합니다.
주의: onFocus/onBlur의 하위 구현 방식 변경이 버블링에 영향을 주지는 않습니다. 즉 React의 onFocus는 여전히 버블링되며, 공식팀은 이 기능이 유용하다고 판단하여 변경할 계획이 없습니다.
DOM 이벤트 풀링(Pooling) 폐기
이전에는 성능상의 이유로 SyntheticEvent를 재사용하기 위해 이벤트 풀(Event Pool)을 유지했습니다. 이로 인해 React 이벤트는 전파 과정 중에만 유효하고 이후 즉시 회수되어 해제되었습니다. 예를 들어:
<button onClick={(e) => {
console.log(e.target.nodeName);
// BUTTON 출력
// e.persist();
setTimeout(() => {
// Uncaught TypeError: Cannot read property 'nodeName' of null 에러 발생
console.log(e.target.nodeName);
});
}}>
Click Me!
</button>
전파 과정이 끝난 후 이벤트 객체의 모든 상태는 null로 초기화되었습니다. 수동으로 e.persist()를 호출하거나 값을 직접 캐싱하지 않는 한 말이죠.
React 17은 이벤트 재사용 매커니즘을 제거했습니다. 현대 브라우저에서 이러한 성능 최적화는 의미가 없을 뿐만 아니라 개발자들에게 혼란을 주었기 때문입니다.
Effect Hook 정리 작업을 비동기로 실행
useEffect 자체는 비동기적으로 실행되지만, 그 정리(cleanup) 작업은 동기적으로 실행되었습니다 (클래스 컴포넌트의 componentWillUnmount가 동기적으로 실행되는 것과 같음). 이는 탭 전환과 같은 상황에서 성능 저하를 일으킬 수 있었기에, React 17부터는 정리 작업을 비동기적으로 실행하도록 변경했습니다:
useEffect(() => {
// 이 부분은 effect 자체입니다.
return () => {
// 이전에는 동기 실행, React 17부터는 비동기 실행
// 이 부분은 cleanup입니다.
};
});
동시에 정리 함수의 실행 순서도 수정되어 컴포넌트 트리 순서에 따라 실행됩니다 (이전에는 순서가 엄격하게 보장되지 않았습니다).
P.S. 동기적인 정리가 필요한 특수한 상황에서는 LayoutEffect Hook을 대신 사용할 수 있습니다.
render에서 undefined 반환 시 에러 발생
React의 render에서 undefined를 반환하면 에러가 발생합니다:
function Button() {
return; // Error: Nothing was returned from render
}
이는 return 작성을 잊어버리는 흔한 실수를 방지하기 위함입니다:
function Button() {
// return 작성을 잊어서 이 컴포넌트는 undefined를 반환합니다.
// React는 이를 무시하는 대신 에러로 표시합니다.
<button />;
}
이후 업데이트 과정에서 forwardRef나 memo에 대해서는 이 검사가 누락되었었는데, React 17에서 보완되었습니다. 이제 클래스 컴포넌트, 함수형 컴포넌트뿐만 아니라 forwardRef, memo 등 React 컴포넌트를 반환해야 하는 모든 곳에서 undefined 여부를 체크합니다.
P.S. 빈 컴포넌트는 null을 반환할 수 있으며, 이는 에러를 유발하지 않습니다.
에러 메시지에 컴포넌트 “콜 스택” 포함
React 16부터는 에러 발생 시 컴포넌트의 “콜 스택”을 보여주어 문제 해결을 도왔습니다. 하지만 JavaScript 네이티브 에러 스택과 비교하면 여전히 차이가 있었습니다:
-
소스 코드 위치(파일명, 행/열 번호 등)가 부족하여 콘솔에서 에러 발생 지점으로 바로 이동할 수 없었습니다.
-
프로덕션 환경에서는 사용할 수 없었습니다 (
displayName이 난독화되어 깨짐).
React 17은 새로운 컴포넌트 스택 생성 매커니즘을 도입하여 JavaScript 네이티브 에러 스택에 버금가는 효과(소스 코드로 이동)를 제공하며, 프로덕션 환경에서도 동일하게 작동합니다. 대략적인 원리는 에러 발생 시 컴포넌트 스택을 재구축하는 것으로, 각 컴포넌트 내부에서 임시 에러를 발생시키고(컴포넌트 유형별로 한 번씩), error.stack에서 핵심 정보를 추출하여 컴포넌트 스택을 구성하는 방식입니다:
var prefix;
// div 등 내장 컴포넌트의 “콜 스택” 구성
function describeBuiltInComponentFrame(name, source, ownerFn) {
if (prefix === undefined) {
// 각 줄에서 사용되는 VM 특정 접두사 추출
try {
throw Error();
} catch (x) {
var match = x.stack.trim().match(/\n( *(at )?)/);
prefix = match && match[1] || '';
}
} // 접두사를 사용하여 스택이 네이티브 스택 프레임과 일치하도록 보장
return '\n' + prefix + name;
}
// 그리고 클래스, 함수형 컴포넌트의 “콜 스택” 구성을 위한 describeNativeComponentFrame
// ...코드가 너무 길어 생략합니다. 관심 있다면 소스 코드를 확인하세요.
컴포넌트 스택이 JavaScript 네이티브 에러 스택으로부터 직접 생성되기 때문에 클릭하여 소스 코드로 이동할 수 있으며, 프로덕션 환경에서도 소스 맵을 통해 원래 코드로 복원할 수 있습니다.
P.S. 컴포넌트 스택을 재구축하는 과정에서 render와 클래스 컴포넌트의 생성자가 다시 실행될 수 있으며, 이는 Breaking change에 해당합니다.
P.S. 컴포넌트 스택 재구축에 관한 더 많은 정보는 Build Component Stacks from Native Stack Frames 및 react/packages/shared/ReactComponentStackFrame.js를 참조하세요.
일부 노출되었던 비공개 API 삭제
React 17은 일부 비공개 API를 삭제했습니다. 대부분 React Native for Web을 위해 노출되었던 것들로, 최신 버전의 React Native for Web은 더 이상 이 API들에 의존하지 않습니다.
또한 이벤트 시스템을 수정하면서 ReactTestUtils.SimulateNative 유틸리티 메서드도 함께 삭제했습니다. 동작이 의미와 일치하지 않기 때문이며, React Testing Library를 사용할 것을 권장합니다.
4. 요약
요약하자면, React 17은 기반을 다지는 버전입니다. 이 버전의 핵심 목표는 React를 점진적으로 업그레이드할 수 있도록 하는 것이며, 따라서 가장 큰 변화는 여러 버전의 혼용을 허용하여 향후 새로운 기능들이 안정적으로 도입될 수 있도록 준비한 것입니다.
We’ve postponed other changes until after React 17. The goal of this release is to enable gradual upgrades.
아직 댓글이 없습니다