최근 프로젝트에서 사용자가 직접 손글씨를 쓰고, 이를 인식해서 통과/불합격을 판단하는 기능을 개발했다. 생각보다 많은 시행착오가 있었는데, 이번 글에서는 내가 겪었던 문제들과 해결 과정을 공유하려고 한다.
1. 스팩 정의
기능 스팩
사용자가 제시된 이미지를 보고 따라 그리면, 서버의 OCR API가 그림을 분석해서 정답 여부를 판단하는 기능이다.
- 사용자가 이미지를 따라 그린다.
- 따라 그린 이미지를 서버에 전송하여, 사용자가 그린 글자/단어를 체크한다.
- 결과값을 보내준다. 잘 그렸다면 통과처리한다.
개발 스팩
- canvas를 이용하여 사용자가 그림을 그릴 수 있게 한다.
- 서버에서 만들어둔 OCR API를 이용하여 그림을 보내면, 글자를 서버에서 읽어 보내준다.
- 서버에서 보내준 글자를 바탕으로 통과/불합격을 처리한다.
2. 개발
1. 이미지를 꽉차게 프레임 만들기
<div position='relative'>
<img width='100%' height='100%' />
<canvas position='absolute' />
</div>
- img의 width와 height를 100%로 설정하면, 부모 컴포넌트는 이미지의 사이즈에 맞게 사이즈가 설정된다.
- 그 다음, canvas태그는 absolute로 띄울 것이다. 그리고, 부모 div 태그는 relative로 설정하였기에 canvas는 부모 div태그에 의해 위치가 결정이 된다.
2. 이미지 꽉 차게 canvas 만들기
이제, canvas를 부모 div 태그에 맞게 사이즈를 설정해야 한다. canvas를 css로 width와 height를 설정하면 사용자의 터치 위치와 불일치하여 오류가 생긴다.
Canvas는 두 가지 크기 개념을 가지고 있다. 하나는 CSS로 설정하는 화면상의 크기이고, 다른 하나는 실제 그리기 영역의 크기다. CSS로만 크기를 설정하면 화면에 보이는 크기만 바뀌고, 내부 그리기 영역은 기본값(300x150)으로 유지된다. 그래서 사용자가 터치한 좌표와 실제로 그려지는 좌표가 달라지는 것이다.
그래서, 속성값으로 설정해주어야 한다. 이미지의 사이즈를 구하여, 해당 이미지를 canvas의 width와 height로 설정할 것이다. 이 때, 이미지의 사이즈를 구하기 위해서 img.offsetHeight, img.offsetWidth를 활용할 것이다.
💡
elem.offsetHeight,elem.offsetWidth패딩값과 테두리를 포함하여 요소의 높이 / 너비를 정수로 반환한다. 이 때, 정수로 반올림하지 않으려면 elem.getBoundingClientRect().height/width를 사용하면 된다.
const imgRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
React.useEffect(()=>{
if(!canvasRef.current || !imgRef.current) return;
canvasRef.current.width = imgRef.current.offsetWidth; // 이미지의 너비만큼 canvas의 너비를 설정한다.
canvasRef.current.height = imgRef.current.offsetHeight; // 이미지의 높이만큼 canvas의 높이를 설정한다.
},[])
// ..
return (
<div position='relative'>
<img width='100%' height='100%' ref={imgRef} />
<canvas position='absolute' ref={canvasRef} />
</div>
)
3. canvas에 그린 그림을 이미지로 변환하기
const handleSubmit = async () => {
const dataURL = canvasFns.canvasRef.current?.toDataURL();
};
HTMLCanvasElement.toDataURL()을 사용하면, base64형식으로 변환을 한다. 이 데이터를 서버에 이제 전송하면 된다.
트러블 슈팅
1. step을 이동하면 canvas돔이 사라지며 canvas가 초기화 된다.
useEffect를 이용하여, canvas가 마운트되면 이미지를 전달받아 그리도록 하자.
export const changeCanvasImage = (
image, ref
) => {
if (!image) return;
const context = getCanvasContext(ref);
const img = new Image();
img.onload = function () {
context?.drawImage(img, 0, 0);
};
img.src = image;
};
{/* .. */}
React.useEffect(() => {
changeCanvasImage(initialCanvas, canvasRef);
}, [canvasRef, height, initialCanvas, width]);
즉 submit하여 다음 페이지로 넘어갈 때 저장하고, canvas컴포넌트가 마운트 되면 해당 이미지를 로드하는 것이다.
2. 제출했을 때, 빈 이미지가 출력 된다.
const onSubmit = () => {
console.log(canvasRef.current.toDataURL());
}
위 결과에서 undefined가 출력된다.
-
ref문제
canvasRef.current를 콘솔에 찍었을 때, null이 출력된다. 즉, ref에 돔이 박히지 않은 것이다. 하지만, 함수 바깥에서console.log(ref.current)를 찍었을 때는 정상적으로 출력이 된다. onSubmit에서는 왜 ref.current가 null로 출력이 될까?
-
ref를 올바르지 않은 방법으로 전달했나?
💡 ref를 forwardRef를 이용하여 전달해야 하는 이유
함수형 컴포넌트는 인스턴스가 없기 때문에 ref속성을 사용할 수 없다.
💡 forwardRef?
forwardRef는 부모 컴포넌트가 자식 컴포넌트의 DOM 요소나 클래스 인스턴스에 직접 접근할 수 있도록 해준다. 이는 컴포넌트 추상화를 꺠지 않으면서도 특정 요소에 접근해야 할 때 유용하다.
하지만 ref 전달에는 문제가 없었다. step이 바뀌면서, canvas가 사라지면서 빈 이미지를 뱉는 것이었다. 이를 동기적으로 처리해야 한다.
그렇다. 당연히 onSubmit을 먼저해야 한다. 이 onSubmit을 await하여 먼저 동기적으로 수행하고, 그 다음 스텝으로 넘어가야 한다.
const handleSubmit = async () => {
// 1. 먼저 canvas 내용을 저장
const imageData = canvasRef.current?.toDataURL();
if (!imageData) return;
// 2. 서버에 전송하고 결과를 기다림
const result = await sendToOCRServer(imageData);
// 3. 모든 작업이 끝난 후에 다음 스텝으로 이동
goToNextStep();
};
맺으며
Canvas로 그림 그리기 기능을 만드는 건 생각보다 신경 써야 할 부분이 많았다. 특히 React의 생명주기와 ref 관리, 비동기 처리 타이밍 같은 부분들이 복합적으로 얽혀있어서 하나씩 해결해 나가는 과정이 재미있었다.
혹시 비슷한 기능을 구현할 분들에게 도움이 됐으면 좋겠다. 특히 "canvas가 갑자기 사라진다"거나 "ref가 null이다" 같은 문제를 겪는다면, 컴포넌트 생명주기와 비동기 처리 순서를 꼼꼼히 체크해보길 바란다.