문제 인식 과정
최근에 R3F를 활용한 3d 기반 웹앱을 제작하고 있었습니다. 이때 R3F의 Canvas 내부에서는 Html 요소를 사용할 수가 없습니다. 하지만 저는 3d 모델과 함께 html을 작성할 필요가 있었고, 그러기 위해서 R3F에서 UI작업을 도와주는 drei 라이브러리의 HTML 컴퍼넌트를 사용하게 되었습니다.

그 당시에 컴퍼넌트를 관심사에 따라 분리하였고, HTML의 children으로 존재하는 내부 컴퍼넌트에서 useSession 훅을 사용하였더니 에러가 발생하였습니다. '디버깅 하는 도중에 HTML 컴퍼넌트와 Next.js의 모듈간의 충돌이지 않을까?'라는 감성적이고 잘못된 결론을 내렸었습니다. (Next.js App router 버그가 많다고 많이 하니깐,,이라는 이유로 얕은 디버깅을 했었습니다)
하지만 최근에 ContextApi를 사용하는 Modal을 여는 버튼을 분리하려고 하였는데, 동일한 에러가 또 발생하였습니다.
'use client';
import { Html } from '@react-three/drei';
import style from '@/app/(enforcement)/enforcement.module.css';
import RecordList from './RecordList';
import React from 'react';
import RecordModalBtn from './RecordModalBtn';
import { useValidInput } from '../hooks/useValidInput';
import { useEnforce } from '../hooks/useEnforce';
import { useModalContext } from '@/shared/components/portal/ModalContext';
export default function EnforceHtml() {
const [percent, setValidInput] = useValidInput();
const { result, onEnforce } = useEnforce(Number(percent));
// 컴퍼넌트 분리시 인지 못함 RecordModalBtn
const { open } = useModalContext();
const openRecords = () => {
open({ type: 'enforce' });
};
return (
<Html fullscreen zIndexRange={[300, 0]}>
<div className={style.wrapper}>
<div className={style.row}>
<span>강화 확률(%) :</span>
<input
type="text"
className={style.input}
value={percent}
onChange={(e) => setValidInput(e.target.value)}
placeholder="0.00~100.00"
min="0.00"
max="100.00"
/>
<button className={style.button} onClick={onEnforce}>
클릭
</button>
</div>
<div className={style.row}>
<div>{result}</div>
<RecordModalBtn openRecords={openRecords} />
</div>
<RecordList />
</div>
</Html>
);
}
import style from '@/app/(enforcement)/enforcement.module.css';
export default function RecordModalBtn({
openRecords,
}: {
openRecords: () => void;
}) {
//TODO : 분리시 modalContext를 인지하지 못함
// const { open } = useModalContext();
// const openRecords = () => {
// open({ type: 'enforce' });
// };
return (
<button className={`${style.button} ${style.mobile}`} onClick={openRecords}>
데이터 보기
</button>
);
}
이때 든 생각이 useSession훅도 분명 ContextApi 기반이지 않을까? 라고 생각하였고 소스코드를 확인해보니 역시 contextApi 기반이더군요

아 그러면 왜 drei의 HTML 태그와 contextApi가 문제가 생기는지 확인해봐야겠다 라는 생각에 HTML 소스코드를 분석하게 되었습니다.
HTML소스코드 분석과 에러 원인
Html의 소스코드는 https://github.com/pmndrs/drei/blob/master/src/web/Html.tsx 에 있었습니다.

각각의 props가 어떤 역할을 하는지 공식문서에서 확인할 수 있는데, 해당 로직을 어떻게 구현했는지가 나와있습니다.
하지만 지금 당장 제 관심사는 아닙니다.
제 에러에 관련있는 코드만 모아서 보면 아래와 같습니다.
export const Html: ForwardRefComponent<HtmlProps, HTMLDivElement> = /* @__PURE__ */ React.forwardRef(
(
//... props들
) => {
// ... 생략
const root = React.useRef<ReactDOM.Root>()
// ... 생략
React.useLayoutEffect(() => {
isMeshSizeSet.current = false
if (transform) {
root.current?.render(
<div ref={transformOuterRef} style={styles}>
<div ref={transformInnerRef} style={transformInnerStyles}>
<div ref={ref} className={className} style={style} children={children} />
</div>
</div>
)
} else {
root.current?.render(<div ref={ref} style={styles} className={className} children={children} />)
}
})
//.. 생략
return (
<group {...props} ref={group}>
{occlude && !isRayCastOcclusion && (
<mesh castShadow={castShadow} receiveShadow={receiveShadow} ref={occlusionMeshRef}>
{geometry || <planeGeometry />}
{material || (
<shaderMaterial
side={DoubleSide}
vertexShader={shaders.vertexShader}
fragmentShader={shaders.fragmentShader}
/>
)}
</mesh>
)}
</group>
)
}
)
HTML 태그는 children을 render 함수를 통해서 root의 하위 노드로 렌더링하는 방식을 사용하고 있습니다.
따라서 개발자도구에서 확인해보면 다른 div에서 랜더링 되고 있음을 확인할 수 있습니다.

따라서 컴퍼넌트를 분리하게 되면 Provider가 있는 노드가 아닌 다른 노드에서 Provider를 찾게 되고 useContext훅을 결과값이 null혹은 undefined가 나오게 됩니다.
그렇다면 왜 분리하지 않으면 제대로 작동될까? 분리해서 매개변수로 넘겨주면 제대로 작동될까?
openRecords 함수는 EnforceHtml 컴포넌트가 렌더링될 때 정의되며, 해당 함수는 useModalContext 훅을 사용하여 컨텍스트에서 가져온 open 메서드를 호출합니다. 이 때 open 메서드는 클로저에 의해 openRecords 함수 내부에서 저장되어 있습니다.
문제 해결
따라서 해결방법은 2가지가 있습니다.
1. 위에 방법처럼 매개변수로 쓴다 (관심사 분리는 힘듬)
2. Provider를 Html의 하위 노드로 생성한다.
하지만 관심사의 분리 측면에서 봤을때, open함수 RecordModalBtn 내부에서 선언되는게 맞다고 생각합니다. 따라서
2번째 방법을 채택하였습니다.
<Html fullscreen zIndexRange={[300, 0]}>
<ModalContextProvider>
<div className={style.wrapper}>
<div className={style.row}>
<span>강화 확률(%) :</span>
<input
type="text"
className={style.input}
value={percent}
onChange={(e) => setValidInput(e.target.value)}
placeholder="0.00~100.00"
min="0.00"
max="100.00"
/>
<button className={style.button} onClick={onEnforce}>
클릭
</button>
</div>
<div className={style.row}>
<div>{result}</div>
<RecordModalBtn />
</div>
<RecordList />
</div>
</ModalContextProvider>
</Html>
import style from '@/app/(enforcement)/enforcement.module.css';
import { useModalContext } from '@/shared/components/portal/ModalContext';
export default function RecordModalBtn() {
const { open } = useModalContext();
const openRecords = () => {
open({ type: 'enforce' });
};
return (
<button className={`${style.button} ${style.mobile}`} onClick={openRecords}>
데이터 보기
</button>
);
}
느낀점
1. 처음 useSession훅에서 에러가 발생했을 때는 모듈간의 결합에 의해 생긴 에러로 생각했었고, 문제를 추상화하지 못했었습니다. html 과 useSession 훅 사이에서 문제가 생긴건 맞는데 어떤 부분에 문제가 되었는지 추상화 하기가 어려웠습니다.. 프로젝트가 완성되고 디버깅하자고 미뤘던 것도 있지만, 고민이 엄청 깊지는 못했었다고 생각이 드네요..하지만 막연했던 문제도 조금씩 구체화하다 보니 해결되어서 기분이 좋았습니다.
2. 클로저를 공부하면서 체감할 기회가 많지 않았습니다.. 그런데 이번 기회에 클로저를 체감할 수 있어서 재밌었습니다.
'프론트엔드 > 3d (three.js, R3f, etcs)' 카테고리의 다른 글
R3F에서 3d Text만들기 및 웹폰트json 파일 크기 최적화 (0) | 2024.06.21 |
---|