@maru

리액트 19 입문 가이드: 수동적인 상태 관리에서 선언형 상태 머신으로의 전환
리액트 19는 단순히 개발을 편리하게 해주는 몇 가지 API가 추가된 마이너 업데이트가 아닙니다. 컴포넌트를 설계하고, 비동기 데이터의 흐름을 제어하며, 성능을 최적화하는 패러다임 자체를 완전히 재정의하는 거대한 전환점입니다.
그동안 우리는 화면을 그리는 비즈니스 로직보다 프레임워크의 동작 방식을 만족시키기 위해 더 많은 에너지를 써왔을지도 모릅니다. 무분별한 렌더링을 막기 위해 코드 전체를 useMemo와 useCallback으로 도배하고, 컴포넌트 간의 참조 동등성을 지키기 위해 무거운 제약을 감수해야 했습니다. 데이터를 가져오거나 동기화하기 위해 작성했던 수많은 useEffect 체인은 조금만 방심해도 예측하기 힘든 무한 루프나 부수 효과를 낳아 코드를 이른바 '상태 수프'로 만들기 일쑤였습니다. 성능과 안정성을 위해 수동으로 안전장치를 채워야 했던 일련의 과정들은 개발자에게 꽤나 큰 피로감을 주었습니다.
리액트 19는 이러한 제어 흐름의 주도권을 개발자에게서 컴파일러와 런타임 엔진으로 다시 회수하고자 합니다. 빌드 단계에서 복잡한 최적화 작업을 대신 처리해 주는 리액트 컴파일러와 비동기 상태의 라이프사이클을 스스로 추적하는 선언형 액션 모델이 그 핵심입니다. 이제 개발자는 상태가 바뀔 때 어떤 부수 효과를 일으킬지 골머리를 앓는 대신, 컴포넌트가 표현해야 할 비즈니스 규칙과 선언적인 데이터 흐름에만 오롯이 집중할 수 있는 환경을 맞이하게 되었습니다.
이 가이드는 리액트 19가 제시하는 이 흥미로운 패러다임의 변화를 깊이 있게 이해하고, 앞으로 이어질 구체적인 기술 분석 시리즈를 매끄럽게 따라오기 위한 기초적인 시각을 제공합니다. 단순히 새로운 API를 나열해 보여주기보다는, 우리가 왜 이러한 변화를 환영해야 하고 실무에서 어떻게 컴포넌트 설계 방식이 달라지는지 큰 그림부터 차근차근 짚어보겠습니다.
왜 리액트 19인가: 수동적 코드 최적화의 한계 극복
그동안 리액트 개발자들을 가장 오랜 시간 괴롭혔던 문제 중 하나는 성능 최적화의 수동화였습니다. 컴포넌트의 렌더링 효율을 유지하기 위해 우리는 매 순간 참조 동등성을 고민해야 했습니다. 사소한 실수로 의존성 배열에 최신 상태를 빠뜨리거나 하위 컴포넌트에 무심코 인라인 객체를 프로퍼티로 넘겨주는 순간, 참조 동등성은 가차 없이 깨졌습니다. 그 결과 개발자가 눈치채지 못하는 사이 무수한 하위 트리 컴포넌트가 연쇄적으로 재렌더링되며 애플리케이션 전체의 반응 속도가 무거워지곤 했습니다.
리액트의 본질적인 핵심 철학은 UI를 선언적으로 정의하는 것입니다. 하지만 현실의 개발자들은 선언형 코드를 작성하면서도 머릿속 한편으로는 리액트의 변경 감지 엔진이 어떻게 돌고 있는지를 끊임없이 의식해야 했습니다. 비즈니스 로직을 정교하게 다듬는 일보다 무분별한 렌더링을 막기 위해 성능 최적화 훅을 도배하고 이를 디버깅하는 데 더 많은 mental 요소를 소모하는 주객전도 현상이 발생한 것입니다.
리액트 19는 이러한 고질적인 한계를 극복하기 위해 패러다임의 축을 완전히 바꿉니다. 그 중심에는 빌드 시스템에 통합되는 리액트 컴파일러가 있습니다. 리액트 컴파일러는 개발자가 런타임 최적화를 의식하며 수동으로 코드를 감싸는 대신, 빌드 단계에서 추상 구문 트리를 정밀하게 분석하여 코드의 실제 의존성을 스스로 파악합니다. 그리고 최적화가 필요한 컴포넌트와 연산 결과, 콜백 함수에 메모이제이션 코드를 알아서 주입합니다. 개발자는 평범하고 직관적인 자바스크립트 규칙에 맞게 컴포넌트를 설계하기만 하면 되며, 최적화 처리는 컴파일러에 전적으로 위임하는 구조입니다.
이 변화가 주는 임팩트는 단순히 수많은 수동 최적화 코드가 사라진다는 편리함에 그치지 않습니다. 이제 개발자는 프레임워크의 렌더링 제약 사항을 만족시키기 위해 뇌 용량을 낭비할 필요가 없어졌습니다. 컴포넌트 간의 참조 정합성을 지키기 위해 애쓰던 방어적인 개발 방식에서 벗어나, 서비스의 핵심이 되는 데이터 흐름과 사용자 인터랙션 설계에만 온전히 집중할 수 있는 진정한 의미의 선언형 개발 환경이 마침내 열린 것입니다.
핵심 변화: use API와 액션 기반 비동기 파이프라인
리액트로 비동기 작업을 다룰 때 우리를 가장 피로하게 만들었던 것은 복잡하게 얽히고설킨 상태의 홍수였습니다. 서버에서 데이터를 가져오거나 수정할 때마다 로딩 상태, 에러 상태, 결과 데이터를 담을 여러 개의 상태를 선언해야 했고, 이를 동기화하기 위해 부수 효과와 핸들러 함수를 복잡하게 덧붙여야 했습니다. 이러한 수동적인 상태 추적 방식은 코드의 가독성을 해칠 뿐 아니라, 네트워크 지연이나 에러 발생 시 예외 처리가 누락되어 화면이 먹통이 되는 버그를 쉽게 유발하곤 했습니다.
리액트 19는 비동기 작업의 흐름 제어권을 프레임워크 런타임에 내장된 상태 머신으로 완전히 넘겨주는 패러다임을 제안합니다. 그 중심에 서 있는 핵심 도구가 바로 새로운 use API와 액션입니다.
조건문 안에서도 호출할 수 있는 특별한 인터페이스, use API
그동안 리액트의 모든 훅은 루프나 조건문, 중첩된 함수 내부에서 호출할 수 없다는 강력한 규칙을 가지고 있었습니다. 하지만 use API는 이 오래된 제약을 깨뜨립니다. use는 엄밀히 말해 전통적인 훅이 아닌 렌더링 과정에서 리소스를 읽을 수 있게 해주는 새로운 API로, 조건문이나 반복문 내부에서도 자유롭게 호출하여 프로미스나 컨텍스트를 해석할 수 있습니다.
동작 방식은 직관적입니다. use API에 아직 완료되지 않은 프로미스를 넘겨주면, 리액트는 해당 컴포넌트의 렌더링을 즉시 일시 정지합니다. 그리고 가장 가까운 상위 서스펜스 경계로 제어권을 넘겨 준비된 대체 화면을 보여줍니다. 이후 프로미스가 성공적으로 해결되면 리액트는 멈췄던 지점부터 컴포넌트 렌더링을 다시 시작하고, 실패한다면 에러 경계로 던집니다.
이 덕분에 개발자는 컴포넌트 내부에서 비동기 데이터의 로딩 상태를 분기하는 복잡한 수동 명령을 작성할 필요가 없어집니다. 단지 비동기 리소스를 가져와 use로 선언적으로 읽어내기만 하면, 흐름의 제어는 런타임이 알아서 처리합니다.
비동기 상태 머신의 자동화, 액션
데이터를 조회하는 것만큼이나 다루기 까다로운 영역이 바로 데이터 수정이나 전송 같은 쓰기 작업입니다. 양식 제출 버튼을 눌렀을 때 중복 제출을 막기 위해 버튼을 비활성화하고, 네트워크 통신 동안 로딩 표시를 보여주며, 처리가 끝나면 성공 혹은 실패 메시지를 화면에 반영하는 일련의 과정은 매번 엄청난 양의 반복적인 상동 코드를 만들어냈습니다.
리액트 19는 비동기 함수를 실행하는 과정을 '액션'이라는 개념으로 격상했습니다. 비동기 함수가 시작되고 끝나는 수명 주기를 리액트가 인지함으로써, 이제 개발자는 상태 전환을 수동으로 제어하지 않아도 됩니다. 이를 위해 도입된 대표적인 도구가 useActionState와 useFormStatus입니다.
과거에는 사용자의 요청에 따라 폼 데이터를 서버로 보낼 때 로딩 상태를 직접 정의해 관리해야 했습니다.
// 기존 방식: 매번 반복되는 수동적 로딩 및 에러 처리
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (event) => {
event.preventDefault();
setIsPending(true);
setError(null);
try {
await updateProfile(data);
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
};반면 리액트 19의 useActionState를 사용하면 이 수많은 상태 선언과 예외 처리 로직이 단 한 줄의 선언식으로 압축됩니다.
// 리액트 19: useActionState를 활용한 선언적 액션 관리
const [state, formAction, isPending] = useActionState(async (prevState, formData) => {
try {
return await updateProfile(formData);
} catch (error) {
return { error: error.message };
}
}, null);비동기 함수를 useActionState에 전달하면, 리액트는 함수가 시작되는 시점에 알아서 isPending을 참으로 변경하고 작업이 마무리되면 다시 거짓으로 되돌립니다. 폼 내부의 하위 컴포넌트들은 useFormStatus를 호출하여 부모 폼의 상태를 즉시 공유받을 수 있으므로 불필요한 속성 전달 과정마저 줄어듭니다.
상태 관리에서 선언적 데이터 파이프라인으로
결국 이 변화가 의미하는 본질은 개발자가 비동기 작업의 과정을 하나하나 추적하며 화면을 직접 켰다 끄던 시대가 끝났다는 점입니다. 컴포넌트는 비동기 데이터라는 거대한 파이프라인에 연결된 선언적 노드에 가깝게 변모합니다.
이러한 패러다임 전환은 단순히 코드 줄 수를 줄여주는 단편적인 개선에 그치지 않습니다. 프레임워크가 비동기 흐름의 시작과 끝을 온전히 이해하고 있기 때문에, 느린 네트워크 환경 속에서도 사용자 인터페이스가 유연하고 탄력적으로 반응할 수 있도록 견고한 기초 체력을 다져줍니다.
실무 마이그레이션과 프로덕션 환경의 복병
패러다임의 변화에는 언제나 예상치 못한 주의 사항이 따릅니다. 리액트 19에서는 그동안 수많은 보일러플레이트를 양산하며 개발자를 괴롭히던 forwardRef가 마침내 사용 중단 처리되었습니다. 이제는 별도의 고차 컴포넌트로 감쌀 필요 없이 일반 프로퍼티처럼 ref를 하위 컴포넌트에 직접 넘겨줄 수 있어 컴포넌트 설계가 매우 직관적이고 단순해졌습니다.
하지만 이 화려한 개선 뒤에는 리액트 19의 새로운 ref 정리 모델이 불러오는 예기치 못한 복병이 숨어 있습니다. 리액트 19부터는 ref 콜백 함수가 정리 함수를 반환할 수 있도록 스펙이 확장되었습니다. 즉, 컴포넌트가 언마운트되거나 ref 콜백의 참조가 변경될 때 리액트가 반환된 정리 함수를 자동으로 실행해 줍니다.
이 기능 자체는 유용하지만, 여러 ref를 하나로 묶어주는 합성 ref 패턴을 다룰 때 치명적인 문제를 일으킬 수 있습니다. 대표적으로 Radix UI를 비롯한 수많은 오픈소스 UI 라이브러리들은 내부적으로 여러 ref를 동시에 관리하기 위해 useComposedRefs와 같은 유틸리티를 사용합니다. 이때 합성된 ref 콜백 함수의 참조 동일성이 매 렌더링마다 유지되지 않고 계속 새로 생성되면 다음과 같은 루프가 발생합니다.
- 컴포넌트가 렌더링될 때마다 새로운 참조를 가진 합성 ref 콜백 함수가 만들어집니다.
- 리액트는 ref 콜백의 identity가 변경된 것을 감지하고, 기존 ref를 정리하기 위해
null을 전달한 뒤 새로운 ref 콜백에 DOM 노드를 다시 전달합니다. - 만약 합성된 ref 중 하나가 DOM 노드의 크기를 측정하는 등 내부 상태를 업데이트하는 트리거를 포함하고 있다면, 이 시점에 상태 변경으로 인해 컴포넌트가 다시 렌더링됩니다.
- 렌더링이 발생하면서 다시 새로운 합성 ref 콜백 함수가 생성되고, 리액트는 다시 기존 ref를 해제했다가 연결하는 과정을 반복합니다.
결국 이 흐름은 브라우저를 멈추게 만드는 무한 렌더링 루프를 유발하게 됩니다. 실제로 널리 쓰이는 외부 라이브러리들과 자체 제작한 커스텀 UI 컴포넌트에서 이러한 참조 동일성 결여로 인해 최대 업데이트 깊이 초과 오류가 발생하는 사례가 빈번하게 보고되고 있습니다.
이 문제를 방지하려면 ref를 합성할 때 단순히 인라인 함수를 반환하는 수준에 그치지 않고, 합성하는 콜백 자체의 참조 동일성을 보장해 주어야 합니다. 예를 들어 다음과 같이 합성 함수 자체를 메모이제이션하는 안전한 구조를 확보해야 합니다.
// 참조 동일성이 보장되지 않는 위험한 방식
const composedRef = (node) => {
refA.current = node;
refB(node);
};
// 메모이제이션을 통해 참조 일관성을 확보한 안전한 방식
const composedRef = useMemo(() => {
return (node) => {
if (refA) refA.current = node;
if (refB) refB(node);
};
}, [refA, refB]);새로운 버전을 도입하기 전에 프로젝트 전반에 걸쳐 사용 중인 ref 합성 유틸리티가 리액트 19의 새로운 수명 주기와 조화를 이루는지 반드시 점검해야 합니다. 특히 상태 변경을 수반하는 UI 인터랙션 컴포넌트의 경우, 마이그레이션 과정에서 예상치 못한 무한 루프가 발생하지 않는지 세밀한 통합 테스트를 통해 검증하는 것이 필수적입니다.
앞으로 전개될 상세 학습 시리즈 로드맵
이번 입문 편을 시작으로, 우리는 리액트 19가 가져온 패러다임 변화의 첫 발을 내디뎠습니다. 단순히 편리한 API 몇 개를 배우는 것을 넘어, 컴파일러 중심의 최적화와 선언적인 상태 관리가 어떻게 복잡한 수동 코드를 대체하는지 그 밑바탕을 이해하는 것이 본 시리즈의 핵심 목표입니다.
앞으로 이어질 상세 가이드에서는 실무에서 바로 마주하게 될 구체적인 시나리오들을 하나씩 깊이 있게 파헤쳐 볼 예정입니다.
먼저 다음 글에서는 ref와 use API를 영리하게 결합하여 프라미스를 다루는 새로운 데이터 페칭 패턴을 탐구합니다. 전통적인 데이터 페칭 라이브러리나 지저분한 useEffect 없이도 어떻게 직관적으로 비동기 데이터를 컴포넌트에 녹여낼 수 있는지 알아봅니다.
그다음에는 리액트 19 비동기 흐름의 꽃이라고 할 수 있는 useActionState와 useOptimistic을 조합하여, 실제 서비스 수준에서 견고하게 작동하는 폼 상태 처리 시스템을 설계해 봅니다. 이 과정에서 비동기 트랜지션 내부의 await 경계에서 컨텍스트가 유실되는 까다로운 버그와 그 해결책도 함께 다룰 것입니다.
마지막으로 대규모 모노레포 환경에서 리액트 컴파일러를 점진적으로 도입하며 얻은 실제 최적화 효율성과 성능 데이터를 공유하겠습니다. 이론적인 최적화를 넘어 실무 수준에서 컴파일러가 어떤 변화를 만들어내는지 날것의 데이터로 확인해 보실 수 있습니다.
리액트 19는 개발자가 성능이나 부수 효과 같은 프레임워크의 구조적 제약에 신경 쓰는 대신, 온전히 비즈니스 로직과 제품의 가치에 집중할 수 있도록 돕습니다. 더 단순하면서도 신뢰할 수 있는 코드를 향한 여정에 앞으로도 즐겁게 동참해 주시길 바랍니다.