3. useRef 사용할 때 문제가 생길 수 있는 점
2026년 2월 6일
3D 뷰어에서 조립/분해 슬라이더를 구현했는데,
슬라이더와 리셋 버튼이 아무 반응이 없는 현상이 발생했습니다.
로직은 뭐 어떻게든 굴러가게 짠거 같았는데...
문제는 데이터 캐시가 오래된 노드 참조를 들고 있었다는 것 이었습니다.
1. 문제코드
- 모델 로드 시 한 번 분석
- 분석 결과를
disassemblyDataRef에 저장 - 슬라이더/리셋은 이 데이터를 그대로 사용
// useAssemblyDisassembly.ts export function useAssemblyDisassembly({ modelRefs, assemblyValue }: Props) { const disassemblyDataRef = useRef(new Map()); const analyzedRef = useRef(new Map()); const analyzeModel = (modelRef, modelIndex) => { if (analyzedRef.current.get(modelIndex)) return; if (!modelRef || modelRef.children.length === 0) return; const nodes = []; modelRef.traverse((child) => { const hasName = child.name && child.name.trim() !== ''; const hasMesh = child instanceof THREE.Mesh || (child.children.length > 0 && child.children.some(c => c instanceof THREE.Mesh)); if (hasName || hasMesh) { const box = new THREE.Box3().setFromObject(child); if (!box.isEmpty()) { const direction = box.getCenter(new THREE.Vector3()).normalize(); nodes.push({ node: child, initialPosition: child.position.clone(), direction, distance: 2, }); } } }); disassemblyDataRef.current.set(modelIndex, nodes); analyzedRef.current.set(modelIndex, true); }; useEffect(() => { modelRefs.current.forEach((modelRef, modelIndex) => { if (modelRef && !analyzedRef.current.get(modelIndex)) { analyzeModel(modelRef, modelIndex); } }); }, [modelRefs]); useEffect(() => { modelRefs.current.forEach((modelRef, modelIndex) => { const nodes = disassemblyDataRef.current.get(modelIndex); if (!nodes) return; const factor = assemblyValue / 100; nodes.forEach(({ node, initialPosition, direction, distance }) => { const offset = direction.clone().multiplyScalar(distance * factor); node.position.copy(initialPosition.clone().add(offset)); }); }); }, [assemblyValue]); }
이렇게 써놓으면 무슨 역할을 하는 코드인지 하나도 모르겠다 그죠
간단하게 설명하자면 모델 노드를 분석해서 방향/거리 정보를 저장하고, 슬라이더 값에 따라 그 방향으로 위치를 이동시키는 역할을 합니다.
먼저 modelRefs(모델들의 참조)와 assemblyValue(슬라이더 값)을 받아옵니다.
그리고 분해에 필요한 데이터를 저장하는 맵과, 이 모델은 이미 분석했는지 저장하는 맵을 만들어서 useRef를 씌웁니다.
그리고 analyzeModal이라는, 하나의 모델을 분석하는 함수를 정의합니다. 이미 분석한 모델이면 그냥 끝내고, 모델 참조가 없거나, 아직 자식이 없으면 분석불가라 종료합니다.
그리고 노드 정보를 담을 nodes배열을 선언하고, 모델의 모든 하위 노드를 순회합니다. 이름이 있는지도 확인하고, 메시를 포함하는지도 확인합니다. 이쯤되면 이해되실거 같은데 이게 저번에 말한 gltf파일에서 부품노드만 뽑아오는 필터링 과정과 유사합니다 (Model.tsx) 근데 이제 여기는 분해 조립을 위한 거리 계산 용으로 순회하는거죠.. 코드는 같습니다 사실상. 이 글만 쓰고 유틸함수로 빼야할듯..
그리고나서 부품들에 대해 바운딩 박스를 계산합니다. 박스가 비어있지않으면, 즉 크기가 있으면 노드 중심 방향 벡터를 계산합니다.
이때 , 실제 노드 객체와 원래 위치, 분해할 방향, 분해 거리를 저장합니다. 일단 분해거리는 2로 고정해두었구요.
그리고 모델 인덱스별로 분석 결과를 저장하고, 이 모델은 분석 완료라고 표시합니다.
그 밑에 useEffect를 보면,
모델 참조가 바뀔 때 마다 아직 분석 안된 모델을 찾아 analyzeModal을 실행합니다.
또 슬라이더 값이 바뀔 때마다, 해당 모델의 분석 결과를 꺼내서 각 노드마다 분해방향* 거리 * 슬라이더 값 만큼 이동합니다.
2. 왜 문제가 생겼는가?
렌더링 로직을 개선하면서 모델이 다시 clone되거나 ref가 재등록되기 시작했습니다.
그런데 위 코드는 한 번 분석된 데이터를 계속 재사용합니다.
즉, 슬라이더가 움직이면:
- 옛날 노드(nodeA) 에 적용됨
- 하지만 화면에는 새 노드(nodeB)가 있음
결과적으로, 슬라이더는 동작했지만 화면에는 아무 변화가 없음.
3. 해결 코드
- 모델 ref가 바뀌면 캐시를 초기화
- 항상 최신 ref 기준으로 다시 분석
// useAssemblyDisassembly.ts (해결 버전) export function useAssemblyDisassembly({ modelRefs, assemblyValue, modelRefsVersion, }: Props) { const disassemblyDataRef = useRef(new Map()); const analyzedRef = useRef(new Map()); const lastModelRefsVersionRef = useRef(modelRefsVersion); const analyzeModel = (modelRef, modelIndex) => { if (analyzedRef.current.get(modelIndex)) return; if (!modelRef || modelRef.children.length === 0) return; const nodes = []; modelRef.traverse((child) => { const hasName = child.name && child.name.trim() !== ''; const hasMesh = child instanceof THREE.Mesh || (child.children.length > 0 && child.children.some(c => c instanceof THREE.Mesh)); if (hasName || hasMesh) { const box = new THREE.Box3().setFromObject(child); if (!box.isEmpty()) { const direction = box.getCenter(new THREE.Vector3()).normalize(); nodes.push({ node: child, initialPosition: child.position.clone(), direction, distance: 2, }); } } }); disassemblyDataRef.current.set(modelIndex, nodes); analyzedRef.current.set(modelIndex, true); }; const ensureAnalyzed = () => { modelRefs.current.forEach((modelRef, modelIndex) => { if (modelRef && !analyzedRef.current.get(modelIndex)) { analyzeModel(modelRef, modelIndex); } }); }; useEffect(() => { // 모델 ref가 바뀌면 캐시 초기화 if (modelRefsVersion !== lastModelRefsVersionRef.current) { disassemblyDataRef.current.clear(); analyzedRef.current.clear(); lastModelRefsVersionRef.current = modelRefsVersion; } ensureAnalyzed(); }, [modelRefs, modelRefsVersion]); useEffect(() => { // 모델 ref가 바뀌면 캐시 초기화 if (modelRefsVersion !== lastModelRefsVersionRef.current) { disassemblyDataRef.current.clear(); analyzedRef.current.clear(); lastModelRefsVersionRef.current = modelRefsVersion; } modelRefs.current.forEach((modelRef, modelIndex) => { const nodes = disassemblyDataRef.current.get(modelIndex); if (!nodes) return; const factor = assemblyValue / 100; nodes.forEach(({ node, initialPosition, direction, distance }) => { const offset = direction.clone().multiplyScalar(distance * factor); node.position.copy(initialPosition.clone().add(offset)); }); }); }, [assemblyValue, modelRefsVersion]); }
4. 결과
- 슬라이더/리셋이 항상 최신 모델에 적용
- 렌더링 타이밍 변화에도 안정적으로 동작
그러니까, 모델 참조가 바뀌었는데 예전걸 참조하고 있다라는게, 아마 GLTF를 다시 로드하거나 새로고침하거나, scene.clone(true)가 다시 실행되거나 이럴 때 이루어 졌지 않았나 생각합니다.
중요한점은, useRef를 사용할 때 주의해야한다는 거 아니겠습니까.
렌더링과 무관하게 유지해야 하는 값일때 우리는 useRef를 쓰고, 특히나 Three.js를 다룰 때 노드나 벡터 같은것들은 객체 참조가 필요하고, useRef에 참조를 저장해서 사용합니다.
근데 이런 상황이 생길 수 있습니다. 지금처럼 인자로 들어오는 modelRefs가 어떤 이유로 새 객체로 바뀌었는데, 우리 커스텀훅 안에 만들어뒀던 useRef는 예전참조를 바라보고있습니다. 그래서 우리는 들어오는 인자가 바뀌면 useRef값을 초기화 해주니까 해결이 된 것입니다.