화면 리사이징으로 인한 드로잉 어긋남 해결하기

좌표계와 스케일링 삽질기

작성일: 2025. 11. 247 min

개요

드로잉 기능을 만들고 나서 뿌듯했던 시간은 짧았다. QA를 돌리는데 창 크기를 바꾸는 순간 드로잉이 전부 어긋났다. 사각형은 비디오 밖으로 튀어나가고, 펜 스트로크는 엉뚱한 위치에 떠 있었다. 여러 방식으로 고쳐봤지만 임시방편을 반복했고, 결국 좌표계를 처음부터 다시 생각해야 했다.

화면 좌표를 그대로 쓰면

Konva에서 Stage는 캔버스 전체를 감싸는 최상위 컨테이너다. react-konva에서는 <Stage> 컴포넌트를 쓰면 된다.

tsx
<Stage width={1920} height={1080}>
  <Layer>
    <Line points={[0, 0, 100, 100]} stroke="red" />
  </Layer>
</Stage>

내부적으로는 <Stage>가 div 안에 <canvas> 요소를 자동으로 생성한다.

html
<!-- 브라우저에 실제로 렌더링되는 HTML -->
<div>
  <div class="konvajs-content">
    <canvas width="1920" height="1080"></canvas>
  </div>
</div>

<canvas> 태그를 직접 만들 필요 없이, widthheight를 props로 넘기면 Konva가 알아서 캔버스를 생성하고 그 위에 올라가는 모든 도형의 렌더링을 관할한다.

이 Stage의 크기를 화면에 맞추고, 마우스 좌표를 그대로 저장했다.

typescript
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
  const pos = e.target.getStage()?.getPointerPosition();
  if (!pos) return;

  const newStroke: DrawingStroke = {
    tool: selectedTool,
    points: [pos.x, pos.y], // 화면 픽셀 좌표 그대로 저장
    color: drawingToolConfig.color,
    strokeWidth: drawingToolConfig.strokeWidth,
    id: generateId(),
  };

  addStroke(newStroke);
};

예를 들어 1920x1080 화면에서 (960, 540)에 점을 찍으면, 그 좌표가 그대로 서버에 저장된다. 화면 크기와 좌표가 1:1로 대응하는 상태다. 문제는 화면 크기가 변할 때 벌어진다.

창 크기를 1280x720으로 줄이면 화면은 줄어드는데, 좌표 (960, 540)은 그대로다. 1280 안에 960이 들어가긴 하지만, 비디오 위에 정확히 겹쳐야 하는 드로잉이 비디오와 어긋나기 시작한다.

일단 scale로

우선 현재 화면 크기를 원본 크기로 나눠서 scale을 계산하고, 이 값을 providedScale이라는 props로 드로잉 컴포넌트에 전달하는 방식으로 고쳐봤다. 드로잉 컴포넌트는 자기가 scale을 계산하는 게 아니라, 외부에서 받은 값을 그대로 쓴다.

typescript
if (providedScale !== undefined) {
  calculatedScale = providedScale;
}

1920x1080 화면에서 그린 드로잉을 1280x720 화면에서 열면, 1280 / 1920 = 0.67이 scale이 된다. 이 값을 렌더링에 적용하면 드로잉이 비율에 맞게 축소된다. 화면을 줄였다 늘렸다 해도 scale이 따라가니까, 당장은 드로잉이 비디오와 정확히 겹쳤다.

다시 고민

며칠 뒤 같은 버그가 다시 올라왔다. 이번에는 "창 크기를 변경한 후 페이지를 나갔다가 다시 들어오면" 드로잉이 틀어진다는 것이었다.

원인을 추적해보니, providedScale이 현재 화면 크기 기준으로 계산된 값이라는 게 문제였다. 1920x1080 화면에서 1280x720으로 줄인 상태에서 scale이 0.67로 계산된다. 여기까지는 맞다. 하지만 이 상태에서 페이지를 나갔다가 다시 들어오면, 1280x720이 "새로운 기준"이 된다. scale이 1.0으로 리셋되고, 원본 1920x1080 기준으로 저장된 좌표와 맞지 않게 된다.

scale의 기준은 "현재 화면"이 아니라 "드로잉이 처음 그려졌을 때의 크기"여야 한다는 걸 깨달았다.

화면 크기 != 좌표

한 발짝 물러서서 문제를 정리해봤다. scale로 보정하면 당장은 맞지만, 페이지를 새로 열면 기준이 바뀌어서 깨진다. 그러면 근본적으로 왜 scale이 필요한 건가? 화면 크기가 바뀌는데 좌표는 안 바뀌기 때문이다. 결국 "화면에 보이는 크기"와 "좌표가 기록되는 크기"는 원래 별개의 개념인데, 이걸 하나로 취급한 게 원인이었다.

표시 공간(Display Space)은 캔버스가 화면에 보이는 크기다. 브라우저 창을 줄이면 표시 공간도 줄어든다. 좌표 공간(Coordinate Space)은 드로잉이 기록되는 내부 좌표계다. 사용자가 (960, 540)에 점을 찍었을 때, 이 좌표가 속하는 공간이다.

SVG에서는 이 두 공간이 처음부터 분리되어 있다.

html
<svg width="600" height="400" viewBox="0 0 1200 800"></svg>

width/height가 표시 공간을, viewBox가 좌표 공간을 정의한다. 두 값이 다르면 SVG가 알아서 스케일링한다. 구글 맵에서 특정 지역을 줌인하면 지도의 좌표는 그대로인데 화면에 보이는 영역만 달라지는 것과 같은 원리다.

Canvas API에는 viewBox 같은 개념이 없다. canvas.width를 바꾸면 화면에 보이는 크기와 내부 좌표계가 동시에 바뀐다. 표시 공간과 좌표 공간이 항상 1:1로 묶여 있는 것이다. 그래서 직접 분리해야 했다.

표시 공간과 좌표 공간 — 화면이 줄어들면 같은 좌표가 다른 위치에 렌더링된다

기준점을 고정하기

드로잉 데이터를 서버에 저장할 때 이미 width, height를 함께 저장하고 있었다. 드로잉이 그려진 시점의 캔버스 크기, 즉 좌표 공간의 크기다.

읽기 모드에서 scale을 계산할 때 이 값을 기준으로 바꿨다. 수정 전에는 외부에서 받은 providedScale을 우선으로 사용했다.

typescript
if (providedScale !== undefined) {
  calculatedScale = providedScale;
} else if (containerSize && drawingData?.data) { ... }

수정 후에는 저장된 원본 크기를 우선으로 바꿨다.

typescript
if (containerSize && drawingData?.data) {
  const parsedData = JSON.parse(drawingData.data);
  if (parsedData.width && parsedData.height) {
    const scaleX = containerSize.width / parsedData.width;
    const scaleY = containerSize.height / parsedData.height;
    calculatedScale = Math.min(scaleX, scaleY);
  }
} else if (providedScale !== undefined) {
  calculatedScale = providedScale; // fallback
}

if 문의 순서를 바꾼 것뿐이지만, "현재 표시 공간 / 원본 좌표 공간"으로 scale을 계산한다는 점에서 의미가 크다. 표시 공간이 어떻게 바뀌든, 좌표 공간(원본 크기)은 드로잉 데이터에 고정되어 있으니까 항상 정확한 비율이 나온다.

Math.min을 쓰는 이유는 가로세로 비율을 유지하기 위해서다. 원본이 1920x1080인데 현재 화면이 1600x720이면, scaleX는 0.83이고 scaleY는 0.67이 된다. 만약 가로는 0.83, 세로는 0.67로 따로 적용하면 동그란 원이 옆으로 늘어난 타원이 된다. Math.min(0.83, 0.67) = 0.67을 가로세로 둘 다에 적용하면 비율이 유지된다. 대신 가로에 약간 여백이 생기는데, SVG의 preserveAspectRatio="meet"가 하는 일과 동일하다.

아직 저장 전인 경우

서버에 저장된 데이터에 원본 크기가 있으니까 읽기 모드는 해결됐다. 하지만 편집 모드에서는 아직 저장 전이라 원본 크기를 직접 관리해야 했다.

편집 중에는 화면 크기가 수시로 바뀔 수 있는데, 그때마다 원본 크기를 기억하고 scale을 다시 계산해야 한다. 이 로직을 useDrawingAutoScale 훅으로 분리했다. 처음 로드된 크기를 state에 고정하고, 이후 크기가 변하면 비율만 재계산한다.

typescript
export function useDrawingAutoScale(currentSize: Size | null) {
  const [originalSize, setOriginalSize] = useState<Size | null>(null);
  const [scale, setScale] = useState(1);

  useEffect(() => {
    if (!currentSize) return;

    if (!originalSize) {
      setOriginalSize(currentSize); // 처음 크기를 좌표 공간으로 고정
      setScale(1);
      return;
    }

    setScale(calculateScale(currentSize, originalSize));
  }, [currentSize, originalSize]);

  return { pageSize: originalSize || currentSize, scale };
}

크기 변화 감지하기

"크기가 변하는 걸" 감지하는 것도 문제였다. 처음에는 window.resize 이벤트를 썼는데, 이건 브라우저 창 크기만 감지한다. 사이드바를 열고 닫으면 비디오 컨테이너 크기가 바뀌는데, 브라우저 창 크기 자체는 변하지 않기 때문에 resize 이벤트는 발생하지 않는다. ResizeObserver로 바꿔서 해결했다.

ResizeObserver는 특정 DOM 요소의 크기 변화를 감지하는 브라우저 API다. window.resize가 브라우저 창 전체만 보는 반면, ResizeObserver는 지정한 요소의 크기가 바뀔 때마다 콜백을 실행한다. CSS 레이아웃 변화, 사이드바 여닫기, 컨테이너 크기 변동 등을 모두 감지할 수 있다.

typescript
useEffect(() => {
  const container = containerRef.current;
  if (!container) return;

  const resizeObserver = new ResizeObserver(() => {
    calculateVideoSize();
  });

  resizeObserver.observe(container);
  return () => resizeObserver.disconnect();
}, [calculateVideoSize]);

유튜브 임베드에서는 한 가지 더 복잡했다. 대부분의 유튜브 영상이 16:9 비율이기 때문에, 컨테이너가 넓적해도 iframe은 16:9에 맞춰 작아진다. 드로잉 영역을 iframe에 정확히 겹치려면 컨테이너 크기에서 실제 iframe 크기를 역산해야 했다. 예를 들어 컨테이너가 1200x500이면, 가로 기준 1200 / 16 = 75, 세로 기준 500 / 9 = 55.5가 되고, 작은 쪽인 55.5를 기준으로 16 * 55.5 = 888, 9 * 55.5 = 500이 실제 iframe 크기가 된다.

typescript
const calculateIframeSize = useCallback(() => {
  const container = containerRef.current;
  if (!container) return;

  const rect = container.getBoundingClientRect();
  const xRatio = rect.width / 16;
  const yRatio = rect.height / 9;
  const maxRatio = Math.min(xRatio, yRatio);

  setIframeSize({
    width: Math.floor(16 * maxRatio),
    height: Math.floor(9 * maxRatio),
  });
}, []);

텍스트

도형과 펜 스트로크는 Konva Layer에 scaleX, scaleY를 적용하면 일괄 처리된다. 좌표 공간 분리가 잘 되는 것이다. 그런데 텍스트에서 같은 문제가 다시 터졌다.

텍스트를 저장할 때 width/height를 기록하는 코드에서 stage.width()를 쓰고 있었다. 텍스트의 크기를 Stage에서 가져오는 게 자연스러워 보였기 때문이다. 하지만 stage.width()는 표시 공간의 크기를 반환한다.

typescript
// As-is
const width = stage.width();
const height = stage.height();

좌표는 좌표 공간(원본) 기준으로 저장하고 있었는데, width/height만 표시 공간의 값을 쓰고 있었다. 두 공간이 다시 섞인 것이다.

그래서 아래와 같이 좌표 공간(원본 크기) 기준으로 저장하도록 수정했다.

typescript
// To-be
const width = originalWidth ?? stage.width();
const height = originalHeight ?? stage.height();

originalWidthuseDrawingAutoScale 훅에서 처음 로드 시 고정해둔 원본 크기다. stage.width()가 아닌 이 값을 쓰면 화면 크기가 바뀌어도 항상 좌표 공간 기준으로 저장된다.

텍스트 입력 시 보이는 크기도 문제였다.

tsx
// As-is
<input style={{ fontSize: "16px", position: "absolute", left: x, top: y }} value={text} />

CSS input에 fontSize: 16px를 넣으면 표시 공간 기준의 16px가 렌더링된다. 하지만 Konva Text로 확정할 때는 좌표 공간 기준으로 그려지니까 크기가 달라졌다. CSS와 Canvas는 서로 다른 좌표 체계를 쓰고 있었던 것이다.

결국 CSS input 방식을 포기하고, hidden input으로 키보드 입력만 받고 화면에 보이는 건 Konva Text가 담당하게 바꿨다.

tsx
// To-be
<input style={{ opacity: 0 }} value={text} />
<Text x={x} y={y} text={text} fontSize={16} />

하나의 렌더링 시스템 안에서 통일해 크기 불일치가 사라졌다.

정리

  • 화면 좌표를 그대로 저장하면 화면 크기가 바뀔 때 드로잉이 어긋난다.
  • 외부에서 scale을 전달하는 방식은 기준이 바뀌면 같이 깨진다.
  • 표시 공간(화면에 보이는 크기)과 좌표 공간(드로잉이 기록되는 좌표계)을 분리해야 한다.
  • scale 기준은 "현재 화면"이 아니라 "드로잉이 처음 그려졌을 때의 크기"로 고정한다.
  • SVG는 viewBox로 이 분리를 언어 차원에서 지원하지만, Canvas는 직접 구현해야 한다.
  • 하나의 드로잉 안에서 두 공간을 섞으면 같은 문제가 반복된다. (텍스트의 stage.width(), CSS와 Canvas의 좌표 체계 차이)

참고