728x90

문제 인식 과정

최근에 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 에 있었습니다.

출처 : drei 라이브러리 공식문서

 

각각의 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. 클로저를 공부하면서 체감할 기회가 많지 않았습니다.. 그런데 이번 기회에 클로저를 체감할 수 있어서 재밌었습니다.

 

 

 

 

 

728x90

도입 배경 및 폰트 준비

 

2D로 택스트를 작성해서 넣었습니다.. 물론 단지 2D라서 그런건 아니지만 입체감이 있으면 좀 더 잘 보이고 이쁠 거 같아서 3d텍스트를 도입하기로 했습니다.

 

https://github.com/pmndrs/drei?tab=readme-ov-file#text3d

 

GitHub - pmndrs/drei: 🥉 useful helpers for react-three-fiber

🥉 useful helpers for react-three-fiber. Contribute to pmndrs/drei development by creating an account on GitHub.

github.com

이떄 사용할 것은 drei의 text3d입니다.

스토리북으로 실습도 할 수 있는데, 해당 컴퍼넌트를 사용하려면 json형식의 font가 필요하다고 하네요.

이는 drei의 text3d는 three.js의 textGeometry기반으로 만들어졌기 때문입니다.

 

따라서  폰트를 다운받고, (시바와 어울리는 동글동글한 글씨체를 찾았습니다)

 

폰트를 json파일로 변환 (링크)

 

 

하지만 wasd space 이정도의 글자만 사용할려고 하는데,, 용량이 좀 크네요...

 

우선 폰트 먼저 적용해보려고 합니다

 

폰트 적용

우선 폰트 먼저 적용해보려고 합니다

import { Center, Text, Text3D } from '@react-three/drei';

export default function Manual() {
  const fontUrl = '/font/ONE_Mobile_POP_Regular.json';

  const fontStyle = {
    font: fontUrl,
    size: 0.2,
    letterSpacing: 0.01,
    height: 0.02,
    fontSize: 2,
  };
  return (
    <group>
      <group>
        <Text3D position={[-0.12, 0.57, 0]} {...fontStyle}>
          이동
          <meshBasicMaterial color={'#654321'} />
        </Text3D>
        <Text3D position={[0, 0.27, 0]} {...fontStyle}>
          W
        </Text3D>
        <Text3D position={[-0.26, 0, 0]} {...fontStyle}>
          A S D
        </Text3D>
      </group>
      <group position={[0, 2, 0]}>
        <Text3D position={[0, 0.35, 0]} {...fontStyle}>
          비행
          <meshBasicMaterial color={'#654321'} />
        </Text3D>
        <Text3D position={[-0.26, 0, 0]} {...fontStyle}>
          SPACE
        </Text3D>
      </group>
    </group>
  );
}

 

 

위치는 조정해야겠지만 글자 생김새는 마음에 드네요.. 색을 사실 잘 못정하겠네요 ㅠㅠ 색이 같으면 너무 안 보일거 같고,,너무 독특하면 튈거같아서 국방색 느낌으로 조합했습니다..

 

폰트 파일 최적화

json형식의 웹폰트는 글리프( 하나 이상의 문자를 시각적으로 표현하기 위해 타이포그래피에서 사용되는 용어)로 구성되어있습니다. 이 중에서 사용하는 글리프만 추출하여 json파일의 크기를 줄여볼려고합니다

 

import fontjson from './ONE_Mobile_POP_Regular.json';
export default function Manual() {

  // 사용하는 font 최적화
  useEffect(() => {
    const fontData = fontjson;
    const targetText = '이동WASDPCE비행카메라 제어마우스 활용';
    const modifiedGlyphs = {};

    for (const char of targetText) {
      const charKey = fontData.glyphs[char]
        ? char
        : fontData.glyphs[char.toUpperCase()]
        ? char.toUpperCase()
        : null;
      if (charKey) {
        modifiedGlyphs[charKey] = fontData.glyphs[charKey];
      }
    }

    const modifiedFontData = {
      ...fontData,
      glyphs: modifiedGlyphs,
    };
    console.log(JSON.stringify(modifiedFontData));
  }, []);

콘솔에 사용하는 글리프들을 확인하고 이를 json파일로 덮어씌웁니다.

 

그  결과  900배 정도 크기가 감소했네요..

 

최종 위치는 아래와 같이 벽에 배치했습니다. 색깔도 베이지 색으로,,

+ Recent posts