들어가며..
이번 글에서는
Canvas API와WebSocket을 활용하여 실시간으로 여러 사용자가 함께 그림을 그릴 수 있는 애플리케이션을 구현하는 방법에 대해 알아보겠습니다.
먼저 React 컴포넌트의 기본 구조를 설정합니다. Canvas 컴포넌트와 필요한 상태들을 정의합니다.
const Canvas = () => {
const canvasRef = useRef(null);
const contextRef = useRef(null);
const [isDrawing, setIsDrawing] = useState(false);
const [lastPoint, setLastPoint] = useState(null);
// 그리기 도구 상태
const [drawingColor, setDrawingColor] = useState("#000000");
const [drawingWidth, setDrawingWidth] = useState(2);
const [isEraserMode, setIsEraserMode] = useState(false);
}
각 상태의 역할:
canvasRef: Canvas DOM 요소 참조contextRef: Canvas 2D 컨텍스트 참조isDrawing: 현재 그리기 중인지 여부lastPoint: 마지막 그리기 위치 저장drawingColor: 현재 선택된 색상drawingWidth: 선 굵기isEraserMode: 지우개 모드 여부Canvas를 초기화하고 기본 설정을 적용합니다.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// 캔버스 크기 설정
const parent = canvas.parentElement;
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
// 컨텍스트 설정
const context = canvas.getContext("2d");
context.lineCap = "round";
contextRef.current = context;
// 초기 스타일 설정
context.strokeStyle = getCurrentColor();
context.lineWidth = getCurrentWidth();
}, []);
STOMP 프로토콜을 사용하여 WebSocket 연결을 설정합니다.
const { client, connected } = useCatchSocket(roomId);
useEffect(() => {
if (!client || !connected || !roomId) return;
try {
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
}
subscriptionRef.current = client.subscribe(
`/topic/catch-mind/${roomId}`,
handleDrawingData
);
} catch (error) {
console.error("구독 설정 실패:", error);
}
return () => {
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
}
};
}, [client, connected, roomId, handleDrawingData]);
마우스 이벤트를 처리하여 그리기 기능을 구현합니다.
const getMousePosition = useCallback((canvas, event) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
};
}, []);
const startDrawing = ({ nativeEvent }) => {
if (!canDraw() || !connected) return;
const canvas = canvasRef.current;
const { x, y } = getMousePosition(canvas, nativeEvent);
contextRef.current.beginPath();
contextRef.current.moveTo(x, y);
setIsDrawing(true);
setLastPoint({ x, y });
sendDrawingData({
type: "start",
x,
y,
});
};
그리기 데이터를 WebSocket을 통해 전송합니다.
const sendDrawingData = useCallback(
(drawingData) => {
if (!client || !roomId || !connected) return;
try {
const message = {
type: drawingData.type,
roomId: parseInt(roomId),
userId: parseInt(userId),
x: Math.round(drawingData.x),
y: Math.round(drawingData.y),
color: getCurrentColor(),
lineWidth: getCurrentWidth().toString(),
};
client.publish({
destination: `/app/drawing/${roomId}`,
body: JSON.stringify(message),
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("드로잉 데이터 전송 실패:", error);
}
},
[client, roomId, connected, userId, getCurrentColor, getCurrentWidth]
);
문제: 턴이 바뀔 때 그리기 도구의 상태(색상, 굵기, 지우개 모드)가 초기화되지 않음
해결방안:
useEffect(() => {
if (
prevCurrentPlayerRef.current &&
currentPlayer &&
prevCurrentPlayerRef.current.nickname !== currentPlayer.nickname
) {
// 캔버스 초기화
clearCanvas();
// 그리기 도구 상태 초기화
setDrawingColor("#000000");
setDrawingWidth(2);
setIsEraserMode(false);
// context 스타일 초기화
if (contextRef.current) {
contextRef.current.strokeStyle = "#000000";
contextRef.current.lineWidth = 2;
}
}
prevCurrentPlayerRef.current = currentPlayer;
}, [currentPlayer, clearCanvas]);
문제: F12나 레이아웃 크기 변경 시 마우스 포인터와 실제 그리기 위치가 어긋나는 현상
해결방안:
1. 리사이즈 이벤트 감지 및 처리:
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
const rect = parent.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// 컨텍스트 스타일 재설정
const context = canvas.getContext('2d');
context.lineCap = "round";
context.strokeStyle = getCurrentColor();
context.lineWidth = getCurrentWidth();
}, [getCurrentColor, getCurrentWidth]);
const getMousePosition = useCallback((canvas, event) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
};
}, []);
TIL (Today I Learn) 🎯
getContext('2d')로 2D 렌더링 컨텍스트를 얻어 그리기 작업 수행lineCap, strokeStyle, lineWidth 등의 속성으로 그리기 스타일 설정 가능STOMP 프로토콜을 사용하여 서버와 클라이언트 간 실시간 통신 구현구독(subscribe)과 발행(publish) 패턴으로 그리기 데이터 전송연결 상태 관리와 재연결 로직의 중요성Canvas DOM 요소와 context 참조 관리