[Next.js] Fabric.js로 그림판 구현하기
이번 사이드 프로젝트를 하며 그림판을 다룰 일이 생겼다.
수 많은 라이브러리가 있었지만 그 중 Fabric.js 라이브러리를 선택하여 구현하기로 하였고 선택을 하게된 이유는 다음과 같다.
1. 객체 지향적 접근 - 캔버스 내의 각 요소들을 객체로 처리한다. 이를 통해 생성, 수정, 삭제를 쉽게할 수 있게 만들어준다.
2. 다양한 플랫폼 지원 - Fabric.js는 모든 브라우저에서 동작하며, 크로스 플랫폼 호환성을 지원한다.
3. 다양한 도구 지원 - 브러쉬, 색상 선택, 레이어 관리 등 다양한 그래픽 도구들을 쉽게 커스터마이징 할 수 있다.
4. 간편한 이미지 포멧 - 캔버스의 내용을 쉽게 내보낼 수 있다.
첫번째 트러블 슈팅
캔버스를 구현하며 가장 처음 맞이한 에러는 hydration 에러이다.
Hydration 에러 발생 이유는?
Fabric.js는 HTML5 캔버스를 조작하기 위한 라이브러리로, 브라우저의 Canvas API에 의존한다. Next.js를 사용하는 서버 환경에서는 그래픽을 그리는 데 필요한 window, document, Canvas 등의 객체가 존재하지 않기 때문에, Fabric.js 같은 클라이언트 사이드 라이브러리가 서버에서는 정상적으로 작동하지 않는다. 즉, 서버에서 렌더링 된 결과와 클라이언트에서 렌더링 된 결과가 다르면 에러가 발생한다.
해결 방법
useEffect, dynamic import(동적 로딩) 사용
useEffect
- 장점 : 클라이언트 사이드 렌더링 보장, 조건 부로 실행 가능
- 단점 : 초기 로딩 지연, 플래시 현상 발생
플래시 현상이란? - 초기 로딩 시 서버에서 렌더링된 HTML과 클라이언트에서 React가 렌더링한 결과가 일치하지 않을 때 화면에 깜빡이는 현상이 일어나는 것dynamic import(동적 로딩)
- 장점 : 초기 로드시간과 번들 크기를 줄일 수 있고, 필요할 때만 모듈을 import하여 사용할 수 있음
- 단점 : SSR을 false로 바꾸기에 SEO에 영향, 설정이 더 복잡할 수 있음
사용자의 경험을 우선시 하는 것이 중요하다고 생각하여 초기 로드가 더 빠른 dynamic import를 사용하였다. 기존 캔버스 컴포넌트를 바로 import를 하는것이 아니라 아래와 같이 ssr : false를 추가하여 서버사이드 렌더링을 비활성화 하여 import할 수 있다.
DrawingPage.tsx
import dynamic from 'next/dynamic';
const NoSSRCanvas = dynamic(() => import('../_component/FabricCanvas'), {
ssr: false,
loading: () => <LoadingSpinner />,
});
return (
<NoSSRCanvas />
);
Ref를 이용한 그리기 기능 구현
Fabric.js는 내부적으로 <canvas> 요소를 사용하기 때문에 요소를 직접 조작하고 수정하기 위해 ref를 사용하여 접근하였다.
FabricCanvas.tsx
const fabricCanvas = () => {
const [isDrawingMode, setIsDrawingMode] = useState(true);
const canvasRef = useRef(null);
const canvasInstance = useRef<fabric.Canvas | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new fabric.Canvas(canvasRef.current, {
width: 350,
height: 407,
backgroundColor: 'white',
isDrawingMode: isDrawingMode,
});
canvasInstance.current = canvas;
setupBrush();
return () => {
canvas.dispose();
};
}, []);
return(
<canvas
ref={canvasRef}
id="canvas"
width={350}
height={407}
className=" rounded-lg"
/>
)
위의 코드와 같이 canvasRef를 useRef를 이용해 선언한 후, canvas에 ref를 사용하여 그림을 그리고 수정하게끔 구현하였다.
두번째 트러블 슈팅
캔버스에 그림을 그린 후에 상위 컴포넌트인 DrawingPage에서 전송버튼을 통해 서버로 전송해야 한다. (여기서 엄처안게 많은 시간을 소모했다.) 캔버스에 그린 그림을 서버에 전송하기 위해서는 상위 컴포넌트에서 하위 컴포넌트인 캔버스의 ref에 접근해야 했다. 처음에는 아무생각도 없이 자연스럽게 ref를 props처럼 내려주며 사용하려 했다... 당연히 에러가 발생하였다.
왜냐하면 React에서는 ref를 props로 직접 전달할 수 없기 때문이다.
그동안 ref를 많이 사용해본 경험이 없기에 시간을 많이 썼던 것 같다. 해결법을 찾던 중 forwardRef를 사용하면 하위 컴포넌트가 상위 컴포넌트에게 ref를 노출 시킬 수 있게 된다. 즉, 상위 컴포넌트가 하위 컴포넌트의 DOM 노드에 직접 접근할 수 있게 된다.
또한 useImperativeHandle 메서드를 통해 부모 컴포넌트가 하위 컴포넌트의 인스턴스를 제어할 수 있게하였다.
Fabric.tsx
const FabricCanvas = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
getCanvas: () => canvasRef.current, // 외부에서 canvasRef 접근ok
canvasInstance: canvasInstance.current, //외부에서 canvasInstance에 접근ok
}));
}
DrawingPage.tsx
const NoSSRCanvas = dynamic(() => import('../_component/FabricCanvas'), {
ssr: false,
loading: () => <LoadingSpinner />,
});
return <NoSSRCanvas ref={canvasRef} />
위와 같이 FabricCanvas에 forwardRef를 사용하고 NoSSRCanvas에 ref를 전달하였다.
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
ref를 전달하니 위와 같은 에러가 발생하였다. 에러 내용과 같이 나는 분명 ref를 전달했는데 전달되지 않다고 에러가 발생하는 것이였다.
DrawingPage.tsx
const NoSSRCanvas = dynamic(() => import('../_component/FabricCanvas'), {
ssr: false,
loading: () => <LoadingSpinner />,
});
const ForwardRefCanvas = forwardRef((props, ref) => {
return <NoSSRCanvas {...props} ref={ref} />;
});
다음과 같이 dynamic import를 해온 NoSSRCanvas에 forwardRef를 씌워서 사용을 하였다. 허나 여전히 같은 에러가 발생하였다. 코드를 보다보니 번뜩 이상함을 느꼈다. ref는 prop으로 사용할 수 없는데 사용하고 있던 것이다.
const ForwardRefCanvas = forwardRef((props, ref) => {
return <NoSSRCanvas {...props} fowardRef={ref} />;
});
위와 같이 forwardRef={ref}로 코드를 수정하며 에러가 사라졌다. 하지만 중요한 것은? 아직 하위 컴포넌트 ref에 접근을 못하고 있는 것이었다. 계속된 콘솔에 null값이 나오고 있었다.
아무리 찾아봐도 이유를 알 수가 없었다.
const WrappedCanvas = (props: any) => {
const { forwardRef } = props;
return <FabricCanvas {...props} ref={forwardRef} />;
};
export default WrappedCanvas;
문제 해결
ref를 props로 받는 구조를 명시적으로 설정하기
WrappedCanvas.tsx
const WrappedCanvas = (props: any) => {
const { forwardRef } = props;
return <FabricCanvas {...props} ref={forwardRef} />;
};
export default WrappedCanvas;
위와 같이 작성한 WrappedCanvas 컴포넌트를 통해 FabricCavnas 컴포넌트를 import해와 감싼 뒤에
DrawingPage.tsx
const NoSSRCanvas = dynamic(() => import('../_component/WraapedCanvas'), {
ssr: false,
loading: () => <LoadingSpinner />,
});
const ForwardRefCanvas = forwardRef((props: any, ref: any) => {
return <NoSSRCanvas {...props} forwardRef={ref} />;
});
DrawingPage에서 다시 import해와 해결했다.
WrappedCanvas를 통한 Ref 전달
WrappedCanvas 컴포넌트는 FabricCanvas에 ref를 전달하기 위한 중간 매개체이다. 이 컴포넌트는 props로 받은 forwardRef를 내부 FabricCanvas 컴포넌트에서 ref로써 다시 전달한다. 여기서 중요한 점은 WrappedCanvas가 일반 컴포넌트로써 ref를 props로 받아서 내부 컴포넌트로 전달할 수 있음이다.
forwardRef를 WrappedCanvas에 props로 전달하고, 이를 다시 내부의 FabricCanvas 컴포넌트에 ref로 연결함으로써 올바르게 DOM요소에 접근하게 됨으로 해결을 할 수 있게 되었다.
참고 : https://velog.io/@broccoliindb/Next.js-Editor-SSR-REF-%EC%9D%B4%EC%8A%88