document is not defined, 여러 방식으로 해결해 나가는 과정(feat. Three.js)
June 14, 2025
배경
현 블로그의 메인페이지가 만들어지다만 형태의 3D Scene를 보여주다보니 메인페이지에서부터 방문자 이탈이 생길 것 같다는 생각을 했다.
오랜만에 Three.js를 다시 배우는 겸해서 유튜브에서 간단하게 구현하고 있는 비오는 장면을 따라 코딩해봤다.
원래 블로그에서 사용하던 Three.js 관련 라이브러리는 다음과 같다.
- Three.js
- @react-three/fiber
- @react-three/drei
그리고 블로그는 Next.js 기반으로 구현하고 있다.
react-three/fiber와 react-three/drei는 바닐라 JavaScript로 구현되어 있는 Three.js를 react 기반 방식으로 코딩할 수 있게 하는 라이브러리다.
참고한 유튜브가 Three.js로만 구현했기 때문에, react-three-fiber로 포팅을 해야할 필요가 있었다.
이 과정에서 문제가 생겼다.
오브젝트를 렌더링할 때, 오브젝트의 텍스쳐를 표현하기 위한 파일을 불러오기 위해서 useLoader
를 사용한다.
구름을 표현하기 위해서는 구름의 텍스쳐를 불러오는 것이 필수적이었다.
useLoader
를 호출할 때, 함수 내부적으로 document
를 이용하는 부분이 있는 것 같다.
하지만 Next.js는 document is not defined
에러를 뱉어냈다.
이번 포스트는 이 에러를 해결해나가는 과정을 다루고, 결과적으로 Dynamic Import(이하 동적 임포트) 를 통해서 해결했다는 결론에 대해서 작성하려고 한다.
첫 번 째 접근, use client
document is not defined
는 프론트엔드 개발을 좀 했다면 무엇을 의미하는지 바로 인지가 가능하다.
Next.js 처럼 서버 사이드 렌더링을 지원하는 프레임워크를 이용한다면, document
를 사용하는 코드를 서버에서 실행할 때 문제가 생길 것을 예측 가능하다.
document
는 브라우저에서 코드를 실행할 때만 존재하는 객체이기 때문이다.
문제가 될 코드에 대한 대비책으로 예외처리를 해놓을 수 있다.
하지만 여기서는 useLoader
라는 React Hook을 호출하면서 생기는 문제이기 때문에, 이 코드가 실행되기 위한 조건을 지정할 수 없는 상황이다.
다행히 Next.js 에서는 이를 위해서 이미 오래 전부터, 특정 컴포넌트를 클라이언트에서 렌더링을 할 수 있게 클라이언트 컴포넌트 기능을 지원한다.
컴포넌트는 일반적으로 서버 컴포넌트로 작동하고, 컴포넌트 코드 상단에 use client
를 기재한다면 클라이언트 컴포넌트로 작동한다.
+-- app
+-- page.tsx (렌더링 페이지)
+-- components
+-- Three
+-- RainyDay.tsx (Scene(Canvas) 컴포넌트, useLoader를 호출하고 있는 컴포넌트)
+-- items
+-- Cloud.tsx (Scene 구성요소, 텍스쳐 파일이 필요한 컴포넌트)
+-- Flash.tsx (Scene 구성요소)
위에서 살펴볼 수 있는 파일 구조는 메인페이지를 구성하는 파일 중 문제되는 부분만 모아서 정리한 것이다.
컴포넌트는 조상 컴포넌트 중 하나가 클라이언트 컴포넌트로 되어있다면, 그 후손 컴포넌트들이 모두 클라이언트 컴포넌트로 작동한다.
메인페이지인 page.tsx에서는 Scene 컴포넌트를 불러오고, Scene 컴포넌트는 Cloud와 Flash 같은 Scene 구성 컴포넌트를 불러오는 구조다.
현재 문제 지점은 useLoader를 불러오고 있는 Scene 컴포넌트로 파악되어 있다.
해결할 방법은 이 컴포넌트를 클라이언트 컴포넌트로 변경하는 것이다.
결과는 실패였다.
두 번 째 접근, useLoader를 위한 Wrapper 컴포넌트 레이어 추가
Scene 컴포넌트를 클라이언트 컴포넌트로 변경하는 방법이 실패하고 다른 방법을 찾다가 useLoader를 Cloud 컴포넌트에서 호출하고 텍스쳐를 적용하면 에러는 해결되는 것을 확인할 수 있었다.
하지만 문제는 Cloud 컴포넌트가 Scene에서 여러 개 렌더링된다는 것이다.
텍스쳐는 한 번 불려와서 오브젝트에 적용되면 된다. 오브젝트가 렌더링 될 때마다 텍스쳐를 또 불러올 필요는 없는 것이다.
만약 고용량의 텍스쳐를 불어와야 되는 일이 생긴다면, 불필요한 자원을 사용하게 되는 일이다.
그래서 이 문제를 해결하기 위해 texture 데이터를 내려주는 레이어를 Scene 컴포넌트와 Scene 구성 컴포넌트 사이에 추가하고, 이 컴포넌트에서 useLoader를 호출하게 만들었다.
하지만 여기서는 또다른 문제가 생겼다.
이번에는 Three.js가 Context를 잃어버리는 문제가 생기는 거였다.
이 접근은 완전히 폐기하고 또 다른 방법을 찾았다.
세 번 째 접근, Dynamic Import(동적 임포트)
문제의 근원은 useLoader를 호출할 시에 사용되는 document
의 부재다.
useLoader를 호출하는 컴포넌트를 document
를 찾을 수 있는 클라이언트에서만 실행되게 강제하는 방법을 찾으면 된다.
첫 번 째 접근이었던 use client
가 실패했지만, 비슷한 접근으로 특정 컴포넌트를 동적으로 불러와 처리하는 방법이 확인되었다. [레퍼런스 2번]
동적 임포트는 말그대로 특정 컴포넌트를 동적으로 임포트 할 수 있게 Next.js 에서 지원해주는 함수다.
dynamic 함수의 옵션 중 서버 사이드 렌더링 시 임포트를 해오지 않게 설정할 수 있다.
// dynamic 임포트 예시
const RainyDay = dynamic(() => import("@/components/Three/RainyDay"), {
ssr: false,
});
이번 같은 경우는 Scene 컴포넌트를 위와 같이 불러와 사용을 해봤다.
결과로 드디어 document is not defined 문제를 해결하는 성과를 얻었다.
결국 문제되는 Scene 컴포넌트가 어떻게 해서든 서버를 거치지 않고 렌더링되게 하는 게 중요했다.
정리
이번 문제는 Next.js 의 dynamic 함수를 이용해서 해결했다.
이를 통해서 특정 컴포넌트를 클라이언트 실행 환경에서 임포트 하는 것을 강제하는 것이다.
당장에 문제는 해결했지만 의문이 생기는 게 많다.
클라이언트에서 렌더링하게 지정한 컴포넌트들이 어째서 서버를 거치게 되는지 의문이다.
그리고 해당 컴포넌트가 렌더링되는 순간에 실행되어야 할 함수들이 서버에서 실행되는지도 의문이다.
단서를 찾기 위해 Three.js 관련 라이브러리의 Github 소스를 확인해 봤지만 document를 직접 가지고 와서 사용하는 코드는 확인할 수 없었다.
확인되지 않은 document는 대체 어디에 존재하는 것일까?
그리고 Next.js는 클라이언트 컴포넌트를 서버에서 실행하고 있던 걸까?
추후 확인이 필요할 듯 하다.