3D + React 프로젝트를 진행하면서 가장 크게 느낀 점은
UX 관점에서도, 성능 관점에서도 이 렌더링 사이클 차이를 이해하는 것이 필수적이라는 점이었다.
React와 3D 엔진은 렌더링을 바라보는 철학 자체가 다르다.
- React는 상태 변화 중심
- 3D 엔진은 프레임 중심
이 차이를 이해하지 못하면 이런 문제가 생긴다.
- 병목(성능 문제) — 렌더링 구조 차이에서 발생
- 불일치(UX 문제) — 타이밍 차이에서 발생
이 글에서는 그 차이가 어떠한 문제점들을 만드는지, 왜 R3F가 필요한지 정리해보려 한다.
1. React 렌더링 사이클 이해하기
- React는 기본적으로 상태 변화가 렌더링을 유발하는 구조이다.
- 그래서 기본적인 렌더링 사이클의 핵심은 "상태가 바뀌면 UI를 다시 계산한다"는 점이다.

🎯 React의 핵심 목표는
- 최소한의 변경만 DOM에 반영
- 불필요한 연산 줄이기
- 선언적인 UI 관리
여기서 가장 중요한 점은 : React는 "필요할 때만" 화면을 업데이트 한다.
즉, React는 "언제 화면을 다시 그릴 것인가"를 매우 신중하게 결정한다.
- 불필요한 재렌더링
- 큰 컴포넌트 트리
- 잦은 state 변경
특히 스크롤, 마우스 이동, 애니메이션 같은 빈번한 업데이트는 React에게 부담이 된다.
왜냐하면 본질적으로 정적인 UI를 효율적으로 업데이트하는 데 최적화된 라이브러리 이기 때문이다.
2. 3D 씬 렌더링 사이클 이해하기
- 3D 엔진(Three.js/WebGL)의 렌더링 방식은 완전히 다르다.
- 기본적으로 매 프레임마다 장면을 다시 그린다.

이 과정은 "지속적인 루프" 이다.
즉, React처럼 이벤트 기반이 아니라 항상 돌아가는 애니메이션 루프 구조다.
- draw call이 많을 때
- geometry가 복잡할 때
- material/shader 변경이 잦을 때
- 텍스처 메모리 사용량이 클 때
- 투명 오브젝트로 인한 오버드로우
- CPU-side traversal 비용 증가
3D 렌더링 용어 정리
✅ Scene Graph (씬 그래프)
씬에 있는 모든 오브젝트들의 '가족관계도' 같은 트리 구조다
- 방
ㄴ 책상
ㄴ 컵
ㄴ 노트북
ㄴ 의자
ㄴ 전동왜 이 구조가 필요할까?
- 부모(책상)가 움직이면 자식(컵)도 자동으로 따라가게 만들기 위해 이 구조를 사용한다.
- 그래서 위치, 회전, 크기 계산에 사용되고 이는 3D 요소로서 필수적이다.
✅ Draw Call (드로우 콜)
CPU가 GPU에게 "이거 그려!"라고 요청하는 명령이다.
✅ Draw Call이 많아질수록
CPU->GPU지시가 늘어남 -> 준비 시간 늘어남 -> 성능 떨어짐- 그래서 3D 최적화에서
draw call 줄이기 = 핵심 전략
✅ CPU-side Traversal (CPU 사이드 트래버설)
CPU가 씬에 있는 모든 물체를 하나씩 확인하는 과정
CPU도 매 프레임마다 하나씩 체크 -> 물체가 많아지면?
- 확인해야 할 대상 증가 ↑ -> CPU 부담 ↑ -> 프레임 드랍 발생
✅ OverDraw (오버드로우)
같은 픽셀을 여러번 그리는 낭비 작업
언제 발생할까?
- 투명 물체가 많을 때
- 파티클 효과 많을 때
- 겹쳐진 오브젝트 많을 때
- GPU가 같은 픽셀을 여러번 계산 -> 성능 저하 원인
🎾 표로 한눈에 보기
| 구분 | React | 3D 렌더링 |
|---|---|---|
| 렌더링 트리거 | 상태(State)나 Props 변경 시 | 매 프레임마다 |
| 렌더링 주기 | 필요할 때만 다시 그림 (비동기적) | 1초에 60번 반복 (동기적 루프) |
| 관심 대상 | DOM / UI 트리 | 3D 씬 / 카메라 / 오브젝트 |
| 업데이트 방식 | Virtual DOM 비교 후 변경된 부분만 갱신 | 전체 씬을 매 프레임 다시 렌더링 |
3. 문제 1 : 병목 -> 성능 이슈
// 3D 루프는 초당 60번 실행, React도 초당 60번 재렌더 -> JS 메인 스레드 과부하 -> 결과: 프레임 드랍
useFrame(() => {
setPosition(prev => prev + 1);
});// 렌더마다 실행되면 -> GPU 리소스 재업로드 -> 메모리 사용 증가 -> draw call 준비 비용 증가
const material = new THREE.MeshStandardMaterial();// React가 오브젝트를 mount/unmount 하면
- Scene graph 재구성
- CPU traversal 비용 증가
- draw call 재정렬- React 재렌더
- 씬 재구성
- CPU traversal 증가
- draw call 증가
- GPU 준비 비용 증가
위의 과정이 반복되면서 병목이 발생하게 된다.
4. 문제 2 : 불일치 -> UX 이슈
구체적인 예시 : 사용자가 방 테마를 변경하면 그에 따라 3D 모델 경로도 변경되어야 한다.
- 핵심: React는 "상태 변경 → 재렌더"로 끝나지만,
- Three.js는 그 이후에 "리소스 교체 + GPU 반영"까지 진행되어야 실제 화면이 바뀐다.
아래는 사용자가 버튼을 클릭한 순간부터 테마(=modelPath)가 교체되어 화면에 반영될 때까지를
React 관점 vs Three.js 관점으로 시간축에 따라 정리한 플로우차트다.
1) React vs Three.js : 불일치 구간이 존재

위 플로우차트를 보면 React와 Three.js가 서로 다른 속도로 움직인다는 걸 알 수 있다.
문제는 이 속도 차이가 단순한 이론이 아니라, 실제 사용자 경험과 성능에 직접적인 영향을 준다는 점이다.
아래는 실제로 자주 발생하는 대표적인 불일치 포인트들이다.
- React는 상태 변경 이후 매우 빠르게 UI를 업데이트한다.
setTheme → 재렌더 → 커밋 → UI 반영 - 하지만 Three.js는
기존 모델 제거 → 새 모델 비동기 로딩 → scene 반영이라는 과정을 거쳐야 하기 때문에 시간이 더 걸린다.
🙋🏻 이때 사용자 입장에서는 이런 상황이 벌어진다
- 버튼은 이미
테마2로 활성화됨- 텍스트/UI도
테마2기준- 하지만 3D 씬은 여전히
테마1이거나 잠깐 아무 모델도 없는 빈 화면이 보임
즉, UI와 3D가 서로 다른 상태를 보여주는 구간이 발생한다.
이 구간이 길어질수록깜박임,몰입감 저하,신뢰도 하락으로 이어진다.
- 모델을 교체할 때 단순히
scene.remove()만 하면 충분하지 않다.
geometry/material/texture/buffer
이것들을
dispose()하지 않으면 GPU 메모리는 계속 누적된다.
결과적으로
- 테마 변경이 반복될수록 -> draw call 증가 -> GPU 메모리 사용량 증가 -> 프레임 드랍 발생
- 이 문제는 코드 레벨에서는 보이지 않지만, 실제 UX에서는 명확하게 드러난다.
- 모델 로딩은 비동기다. 그래서 사용자가 빠르게 여러 번 클릭하면 이런 일이 벌어진다.
클릭1→ 테마2 로딩 시작클릭2→ 테마1 로딩 시작
- 만약 테마2 로딩이 더 늦게 끝나면 : 테마1 상태인데 테마2 모델이 화면에 등장
- 즉, 마지막 상태와 다른 결과가 화면에 나타나는 문제가 생긴다.
결국 핵심은
- React는 상태 기준으로 움직이고
- Three.js는 프레임 기준으로 움직인다.
➡️ 이 둘을 그냥 연결하면 UI는 이미 미래로 갔는데, 3D는 아직 과거에 머무르는 상황이 발생한다.
- 업데이트 타이밍 제어
- 리소스 생명주기 관리
- 비동기 로딩 동기화
위의 3가지가 필요하며, 이러한 지점에서 R3F와 같은 동기화 레이어가 왜 필요한지 드러난다.
5. 그래서 필요한 것 — 동기화 레이어
이 문제를 해결하려면
- React가 3D 프레임 루프에 끌려가지 않도록 하고
- 3D가 React 상태 변경에 과하게 반응하지 않도록 해야 한다.
즉, 누가 언제 업데이트할지 조율해주는 레이어가 필요하다.
6. R3F는 무엇을 해결해줄까?
R3F는 React와 Three.js 사이의 "브릿지" 역할을 한다.
R3F의 핵심 아이디어
[ React는 “구조만” 관리 ]
- 어떤 객체가 있어야 하는지만 정의
- 매 프레임 계산은 하지 않음
[ Three.js는 프레임 루프 유지 ]
- R3F가 내부적으로 루프 관리
- React 재렌더와 분리
[ 객체 단위 diff 처리 ]
- 전체 씬 재생성이 아니라
- 필요한 부분만 업데이트
✨ 코드 구조는 이렇게 단순해진다 (하지만 “진짜” 중요한 건 내부에서 일어나는 일)
function RoomModel({ modelPath }) {
const { scene } = useGLTF(modelPath);
return <primitive object={scene} />;
}R3F의 가치가 드러나는 지점은 “코드가 짧다”가 아니라,
React 렌더링과 Three 렌더링 사이에서 어떤 레벨로 동기화가 일어나는지다.
🚨 R3F가 “상태 변화”를 처리하는 레벨 : DOM이 아니라 “Three 객체 그래프”
React는 원래 DOM을 대상으로 diff를 계산한다.
R3F는 같은 방식으로, Three.js 객체의 트리를 대상으로 diff를 계산한다.
즉, R3F에서 <mesh /> 같은 JSX는 -> “Three 객체를 생성/수정하는 선언”으로 바뀐다.
그래서 중요한 차이
- React DOM:
div/span같은 DOM 노드 patch - R3F:
THREE.Mesh,THREE.Material,THREE.Geometry같은 Three 객체 patch
이 덕분에 “전체 씬을 갈아끼우는” 게 아니라 바뀐 속성만 객체에 반영한다.
🚨 “렌더링 주도권”을 분리한다: React 렌더 ≠ Three 프레임
직접 구현하면 이런 실수를 하기 쉽다.
- 애니메이션 값이 바뀔 때마다
setState를 쏴서 React가 60fps로 재렌더 - 씬을 구성하는 객체를 매 렌더마다 재생성
🚨 R3F의 diff가 강력한 이유 : “객체 재생성”이 아니라 “속성 패치”
예를 들어
position만 바뀌는 상황을 보자.
// position이 바뀔 때마다 mesh를 새로 만들거나
// material을 매 렌더마다 새로 생성
const material = new THREE.MeshStandardMaterial({ metalness: 0.2 });
// ❌ 렌더마다 새 material 생성 -> GPU 상태 변경/업로드 비용 증가- mesh는 유지, 바뀌는 값만 패치
<mesh position={[x, y, z]}>
<meshStandardMaterial metalness={0.2} />
</mesh>핵심은 “GPU에 올린 자원을 가능한 유지” 하는 방향으로 흐름이 잡힌다는 점이다.
🚨 로딩 캐싱을 “상태 동기화” 관점으로 보기 : 깜빡임/레이스 컨디션 완화
앞에서 말한 불일치(깜빡임)과 레이스 컨디션은 대부분 “비동기 로딩”이 원인이었다.
1) 동일 경로 재요청 시 재사용(캐싱)
- 같은 모델을 다시 선택했을 때 “다시 로딩”하지 않고 재사용
2) Suspense와 결합하면 “빈 화면 구간”을 통제 가능
- 로딩 중일 때 fallback UI를 보여주거나
- 기존 모델을 유지한 채 로딩 완료 후 교체하는 UX 설계가 쉬워진다
즉, 로딩 자체를 없애는 게 아니라, 로딩 구간을 “사용자가 납득 가능한 형태로” 연출할 수 있게 된다.
🚨 dispose는 “자동”이라기보다 “생명주기”를 React 모델로 가져온 것
scene.remove만 하고dispose누락- 텍스처/머티리얼이 GPU에 남음
- 반복 교체하면 프레임이 서서히 죽는다
R3F는 이걸 React의 생명주기(unmount)와 연결해서 다룰 수 있게 만든다.
- 컴포넌트가
unmount되면 - 관련 객체/리소스를 정리(dispose)할 수 있는 구조를 제공한다.
여기서 중요한 포인트는
- “정리가 된다”가 아니라
- “정리될 타이밍을 예측 가능하게 만든다”
즉, 리소스 생명주기를 코드 구조로 강제한다.