junyeokk
Blog
Media·2025. 09. 14

미디어 위에 드로잉 오버레이 구현

비디오나 PDF 같은 미디어 콘텐츠 위에 사용자가 직접 그림을 그릴 수 있게 하려면 어떻게 해야 할까? 미디어 플레이어 위에 투명한 캔버스를 얹어서 드로잉 입력을 받되, 미디어 자체의 인터랙션(재생, 일시정지, 스크롤)은 방해하지 않아야 한다. 이 글에서는 이런 "드로잉 오버레이" 패턴의 구조와 구현 원리를 정리한다.


문제 상황

미디어 피드백 도구를 만든다고 가정하자. 디자인 리뷰에서 "이 부분 수정해주세요"라고 텍스트로 설명하는 것보다, 화면 위에 직접 동그라미를 치거나 화살표를 그리는 게 훨씬 직관적이다.

그런데 단순히 캔버스를 미디어 위에 올리면 문제가 생긴다:

  1. 이벤트 충돌 — 캔버스가 모든 마우스/터치 이벤트를 가로채서 비디오 재생 컨트롤이나 PDF 스크롤이 안 된다
  2. 좌표 동기화 — 미디어 크기가 변하면(리사이즈, 반응형) 드로잉 좌표가 틀어진다
  3. 미디어 타입별 차이 — 비디오는 고정 영역이지만 PDF는 페이지 단위로 스크롤되므로 오버레이 영역 계산이 다르다

이 세 가지를 해결하는 것이 드로잉 오버레이 구현의 핵심이다.


기본 구조: 레이어 스택

드로잉 오버레이는 CSS position을 이용한 레이어 스택으로 구성한다.

┌─────────────────────────┐ │ Drawing Canvas (z: 30) │ ← 사용자 입력을 받는 투명 캔버스 ├─────────────────────────┤ │ Toolbar (z: 20) │ ← 브러시, 색상 등 도구 UI ├─────────────────────────┤ │ Media Player (z: 10) │ ← 비디오 / PDF / 이미지 └─────────────────────────┘

컨테이너를 position: relative로 설정하고, 캔버스를 position: absolute로 미디어 위에 정확히 겹친다.

tsx
┌─────────────────────────┐
│  Drawing Canvas (z: 30) │  ← 사용자 입력을 받는 투명 캔버스
├─────────────────────────┤
│  Toolbar (z: 20)        │  ← 브러시, 색상 등 도구 UI
├─────────────────────────┤
│  Media Player (z: 10)   │  ← 비디오 / PDF / 이미지
└─────────────────────────┘

여기서 핵심은 pointerEvents 토글이다.


이벤트 분리: pointer-events 토글

드로잉 모드가 아닐 때는 오버레이가 이벤트를 완전히 무시해야 한다. 그래야 밑에 있는 비디오의 재생 버튼이나 PDF의 스크롤이 정상 작동한다.

css
function MediaWithOverlay({ mediaElement }: Props) {
  const [isDrawing, setIsDrawing] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  return (
    <div ref={containerRef} style={{ position: "relative" }}>
      {/* 미디어 레이어 */}
      {mediaElement}

      {/* 드로잉 오버레이 — isDrawing일 때만 이벤트 수신 */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          pointerEvents: isDrawing ? "auto" : "none",
          zIndex: 30,
        }}
      >
        <DrawingCanvas />
      </div>

      {/* 툴바 */}
      <Toolbar onToggleDraw={setIsDrawing} />
    </div>
  );
}

pointer-events: none은 CSS 레벨에서 이벤트를 완전히 무시시킨다. JavaScript의 stopPropagation()이나 preventDefault()와 달리, 이벤트가 아예 해당 요소를 통과해서 아래 요소에 도달한다. DOM 이벤트 버블링이 아니라 히트 테스트 자체를 건너뛰는 것이다.

모드 전환 상태 관리

단순 boolean 대신, 도구 상태까지 포함하는 구조가 실용적이다:

tsx
/* 드로잉 비활성: 이벤트가 오버레이를 투과해서 미디어로 전달 */
.overlay-inactive {
  pointer-events: none;
}

/* 드로잉 활성: 오버레이가 모든 이벤트를 캡처 */
.overlay-active {
  pointer-events: auto;
  cursor: crosshair;
}

이렇게 하면 mode.active로 pointer-events를 토글하면서, 활성 상태일 때는 도구 정보에 바로 접근할 수 있다. TypeScript의 판별 유니온(discriminated union)이 여기서 빛을 발한다 — mode.activetrue일 때만 tool, color 등에 접근할 수 있다.


좌표 동기화: 상대 좌표 시스템

드로잉 좌표를 픽셀 절대값으로 저장하면 미디어 크기가 바뀔 때 드로잉이 틀어진다. 해결책은 정규화된 상대 좌표(0~1 범위)를 사용하는 것이다.

좌표 변환

typescript
type DrawingMode =
  | { active: false }
  | { active: true; tool: "pen" | "highlighter" | "eraser"; color: string; size: number };

const [mode, setMode] = useState<DrawingMode>({ active: false });

사용자가 그림을 그릴 때 toNormalized()로 변환해서 저장하고, 화면에 렌더링할 때 toCanvas()로 현재 캔버스 크기에 맞게 복원한다.

왜 0~1 범위인가?

  1. 반응형 대응 — 모바일에서 그린 드로잉이 데스크탑에서도 올바른 위치에 표시된다
  2. 서버 저장 — 해상도에 의존하지 않는 데이터라 어떤 클라이언트에서든 복원 가능
  3. 줌/스케일 대응 — 미디어를 확대/축소해도 드로잉 위치가 유지된다

리사이즈 대응

ResizeObserver로 컨테이너 크기 변화를 감지해서 캔버스를 재조정한다:

tsx
// 마우스 좌표 → 정규화 좌표 (저장용)
function toNormalized(
  clientX: number,
  clientY: number,
  rect: DOMRect
): { x: number; y: number } {
  return {
    x: (clientX - rect.left) / rect.width,
    y: (clientY - rect.top) / rect.height,
  };
}

// 정규화 좌표 → 캔버스 픽셀 좌표 (렌더링용)
function toCanvas(
  normalized: { x: number; y: number },
  canvasWidth: number,
  canvasHeight: number
): { x: number; y: number } {
  return {
    x: normalized.x * canvasWidth,
    y: normalized.y * canvasHeight,
  };
}

devicePixelRatio를 곱하는 이유는 고해상도 디스플레이(Retina 등)에서 드로잉이 흐릿하게 보이는 걸 방지하기 위해서다. CSS 크기와 캔버스 내부 해상도를 분리하는 것이 핵심이다.


미디어 타입별 처리

비디오 오버레이

비디오는 비교적 단순하다. 비디오 요소의 크기가 곧 드로잉 영역이다.

tsx
function useCanvasResize(
  containerRef: RefObject<HTMLDivElement>,
  canvasRef: RefObject<HTMLCanvasElement>
) {
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const observer = new ResizeObserver((entries) => {
      const { width, height } = entries[0].contentRect;
      const canvas = canvasRef.current;
      if (canvas) {
        // 캔버스 해상도를 컨테이너 크기에 맞춤
        canvas.width = width * window.devicePixelRatio;
        canvas.height = height * window.devicePixelRatio;
        canvas.style.width = `${width}px`;
        canvas.style.height = `${height}px`;

        // 기존 드로잉을 새 크기로 다시 렌더링
        redrawStrokes();
      }
    });

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

한 가지 주의할 점: 비디오의 object-fit 속성이 contain이면 비디오 양쪽에 레터박스(검은 여백)가 생긴다. 이 경우 실제 비디오 영역과 요소 크기가 다르므로, 비디오의 videoWidth/videoHeight와 요소 크기를 비교해서 실제 영역을 계산해야 한다.

typescript
function VideoDrawingOverlay({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
  const [rect, setRect] = useState<DOMRect | null>(null);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const updateRect = () => {
      setRect(video.getBoundingClientRect());
    };

    video.addEventListener("loadedmetadata", updateRect);
    window.addEventListener("resize", updateRect);

    return () => {
      video.removeEventListener("loadedmetadata", updateRect);
      window.removeEventListener("resize", updateRect);
    };
  }, [videoRef]);

  if (!rect) return null;

  return (
    <DrawingCanvas
      width={rect.width}
      height={rect.height}
      style={{ position: "absolute", top: 0, left: 0 }}
    />
  );
}

PDF 오버레이

PDF는 비디오보다 복잡하다. 페이지 단위로 스크롤되고, 각 페이지의 렌더링 크기가 다를 수 있다.

두 가지 접근 방식이 있다:

방식 1: 페이지별 오버레이

각 PDF 페이지 위에 독립적인 캔버스를 배치한다.

tsx
function getVideoContentRect(video: HTMLVideoElement): DOMRect {
  const elementAspect = video.clientWidth / video.clientHeight;
  const videoAspect = video.videoWidth / video.videoHeight;

  let contentWidth: number, contentHeight: number;
  let offsetX = 0, offsetY = 0;

  if (videoAspect > elementAspect) {
    // 비디오가 더 넓음 → 위아래 여백
    contentWidth = video.clientWidth;
    contentHeight = video.clientWidth / videoAspect;
    offsetY = (video.clientHeight - contentHeight) / 2;
  } else {
    // 비디오가 더 높음 → 좌우 여백
    contentHeight = video.clientHeight;
    contentWidth = video.clientHeight * videoAspect;
    offsetX = (video.clientWidth - contentWidth) / 2;
  }

  return new DOMRect(offsetX, offsetY, contentWidth, contentHeight);
}

이 방식의 장점은 각 페이지의 드로잉이 독립적이라 관리가 쉽다는 것이다.

방식 2: 단일 오버레이 + 영역 제한

전체 PDF 뷰어 위에 하나의 캔버스를 올리되, 드로잉 입력을 실제 페이지 영역으로 제한한다.

typescript
function PdfPageOverlay({ pageElement }: { pageElement: HTMLElement }) {
  const [pageRect, setPageRect] = useState<DOMRect | null>(null);

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

    const observer = new ResizeObserver(() => {
      setPageRect(pageElement.getBoundingClientRect());
    });

    observer.observe(pageElement);
    return () => observer.disconnect();
  }, [pageElement]);

  if (!pageRect) return null;

  return (
    <DrawingCanvas
      width={pageRect.width}
      height={pageRect.height}
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        width: "100%",
        height: "100%",
      }}
    />
  );
}

페이지 사이 간격(갭)에서의 드로잉을 자연스럽게 무시할 수 있다는 게 이 방식의 장점이다.

실무에서는 방식 1(페이지별 오버레이)이 더 많이 쓰인다. 이유는:

  • 스크롤 시 좌표 재계산이 필요 없다 (페이지 요소와 캔버스가 함께 스크롤)
  • 각 페이지의 드로잉 데이터를 독립적으로 저장/로드할 수 있다
  • 가상화(virtualization)와 조합하기 쉽다 — 보이는 페이지만 캔버스를 렌더링하면 된다

인터랙티브 vs 읽기 전용 모드

오버레이는 보통 두 가지 모드로 분리한다:

tsx
function isPointInPageArea(
  clientX: number,
  clientY: number,
  pages: HTMLElement[]
): { pageIndex: number; localX: number; localY: number } | null {
  for (let i = 0; i < pages.length; i++) {
    const rect = pages[i].getBoundingClientRect();
    if (
      clientX >= rect.left &&
      clientX <= rect.right &&
      clientY >= rect.top &&
      clientY <= rect.bottom
    ) {
      return {
        pageIndex: i,
        localX: clientX - rect.left,
        localY: clientY - rect.top,
      };
    }
  }
  return null; // 페이지 바깥 → 드로잉 무시
}

분리하는 이유:

  1. 성능 — 읽기 전용 모드에서는 이벤트 리스너가 없으므로 오버헤드가 없다
  2. 관심사 분리 — 입력 처리 로직과 렌더링 로직이 섞이지 않는다
  3. 조건부 렌더링 — 편집 권한이 없는 사용자에게는 ReadOnlyOverlay만 보여주면 된다

PointerEvent를 쓰는 이유

마우스, 터치, 펜 입력을 하나의 API로 통합 처리할 수 있다:

typescript
// 인터랙티브 오버레이: 드로잉 입력을 받음
function InteractiveOverlay({ strokes, onStrokeAdd }: InteractiveProps) {
  const handlePointerDown = (e: React.PointerEvent) => {
    // 새 스트로크 시작
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    // 현재 스트로크에 좌표 추가
  };

  return (
    <canvas
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      style={{ pointerEvents: "auto", cursor: "crosshair" }}
    />
  );
}

// 읽기 전용 오버레이: 기존 드로잉만 표시
function ReadOnlyOverlay({ strokes }: ReadOnlyProps) {
  useEffect(() => {
    // strokes를 캔버스에 렌더링
    renderStrokes(strokes);
  }, [strokes]);

  return (
    <canvas style={{ pointerEvents: "none" }} />
  );
}

MouseEvent + TouchEvent를 따로 처리하면 코드가 복잡해지고, 동일 입력에 두 이벤트가 동시에 발생하는 문제(마우스 이벤트 에뮬레이션)까지 처리해야 한다. PointerEvent는 이 모든 걸 하나로 통합한다.

추가로 setPointerCapture()를 사용하면 포인터가 캔버스 밖으로 나가도 이벤트를 계속 받을 수 있다:

typescript
canvas.addEventListener("pointerdown", (e: PointerEvent) => {
  // e.pointerType: "mouse" | "touch" | "pen"
  // e.pressure: 0~1 (펜 압력 — 마우스는 항상 0.5)
  // e.tiltX, e.tiltY: 펜 기울기

  if (e.pointerType === "pen") {
    // 압력에 따라 선 굵기 변경
    const size = baseBrushSize * e.pressure;
  }
});

이건 드래그 중에 마우스가 캔버스 밖으로 나갔을 때 드로잉이 끊기는 문제를 방지한다.


스트로크 데이터 구조

드로잉 데이터를 어떻게 저장하느냐에 따라 undo/redo, 서버 동기화의 난이도가 달라진다.

typescript
canvas.addEventListener("pointerdown", (e) => {
  canvas.setPointerCapture(e.pointerId);
  // 이제 포인터가 캔버스 밖으로 나가도 pointermove 이벤트를 받음
});

비디오 타임스탬프 연동

비디오 드로잉에서는 "몇 초 시점의 드로잉인지"가 중요하다. 특정 시점에 그린 드로잉은 해당 시점에서만 보여야 한다:

typescript
interface Stroke {
  id: string;
  tool: "pen" | "highlighter" | "eraser";
  color: string;
  size: number;
  // 정규화된 좌표 배열 (0~1)
  points: Array<{ x: number; y: number; pressure?: number }>;
  // 메타데이터
  mediaType: "video" | "pdf" | "image";
  pageIndex?: number;       // PDF 전용: 몇 번째 페이지인지
  timestamp?: number;       // 비디오 전용: 몇 초 시점의 드로잉인지
  createdAt: string;
  authorId: string;
}

interface DrawingData {
  strokes: Stroke[];
  version: number;          // 동시 편집 충돌 감지용
}

tolerance는 몇 초 범위까지 드로잉을 표시할지 결정한다. 너무 좁으면 드로잉이 깜빡이고, 너무 넓으면 다른 시점의 드로잉이 겹쳐 보인다. 보통 0.5~2초가 적절하다.


성능 최적화

더블 버퍼링

드로잉 중에 매 프레임마다 전체 캔버스를 지우고 다시 그리면 깜빡임이 발생한다. 이를 해결하는 패턴이 더블 버퍼링이다:

typescript
function getVisibleStrokes(
  allStrokes: Stroke[],
  currentTime: number,
  tolerance: number = 0.5
): Stroke[] {
  return allStrokes.filter((stroke) => {
    if (stroke.timestamp === undefined) return true; // 전체 시간 드로잉
    return Math.abs(stroke.timestamp - currentTime) <= tolerance;
  });
}

이렇게 하면 매 프레임마다 모든 스트로크를 다시 그리는 대신, 배경 캔버스 이미지 하나와 현재 진행 중인 스트로크만 그리면 된다. 스트로크가 수백 개 쌓여도 성능이 일정하다.

requestAnimationFrame 최적화

PointerMove 이벤트는 브라우저에 따라 초당 60~240회 발생할 수 있다. 매 이벤트마다 캔버스를 갱신하면 불필요한 렌더링이 발생한다:

typescript
// 완료된 스트로크를 렌더링하는 "배경" 캔버스
const backgroundCanvas = document.createElement("canvas");
const bgCtx = backgroundCanvas.getContext("2d")!;

// 현재 진행 중인 스트로크만 표시하는 "전경" 캔버스
const foregroundCanvas = document.getElementById("drawing-canvas") as HTMLCanvasElement;
const fgCtx = foregroundCanvas.getContext("2d")!;

function onStrokeComplete(stroke: Stroke) {
  // 완료된 스트로크를 배경 캔버스에 그림
  renderStroke(bgCtx, stroke);
}

function onPointerMove(currentPoints: Point[]) {
  // 전경 캔버스만 매 프레임 갱신
  fgCtx.clearRect(0, 0, foregroundCanvas.width, foregroundCanvas.height);

  // 배경을 먼저 복사
  fgCtx.drawImage(backgroundCanvas, 0, 0);

  // 진행 중인 스트로크만 위에 그림
  renderPoints(fgCtx, currentPoints);
}

이벤트가 올 때마다 그리는 대신, requestAnimationFrame으로 다음 화면 갱신 시점에 누적된 포인트를 한 번에 그린다. 디스플레이 주사율(60Hz 기준 약 16.6ms 간격)에 맞춰 렌더링하므로 화면에 보이지 않는 중간 프레임을 건너뛸 수 있다.

Canvas 라이브러리 사용 시 고려사항

Konva 같은 Canvas 라이브러리를 사용하면 직접 Canvas API를 다루지 않아도 되지만, 오버레이 시나리오에서 주의할 점이 있다:

tsx
let pendingPoints: Point[] = [];
let rafId: number | null = null;

canvas.addEventListener("pointermove", (e) => {
  pendingPoints.push({ x: e.clientX, y: e.clientY });

  if (rafId === null) {
    rafId = requestAnimationFrame(() => {
      // 누적된 포인트를 한 번에 처리
      drawPoints(pendingPoints);
      pendingPoints = [];
      rafId = null;
    });
  }
});

Konva의 listening prop은 pointer-events: none과 유사하게 이벤트 수신을 제어한다. 이걸 드로잉 모드 상태와 연동하면 CSS pointer-events 토글과 동일한 효과를 얻는다.


지우개 구현

지우개는 globalCompositeOperation을 활용한다:

typescript
// Konva Stage는 자체 캔버스를 생성한다
<Stage
  width={containerWidth}
  height={containerHeight}
  // 드로잉 모드가 아닐 때는 이벤트를 차단해야 함
  listening={isDrawingMode}
>
  <Layer>
    {strokes.map((stroke) => (
      <Line
        key={stroke.id}
        points={stroke.points.flatMap((p) => [
          p.x * containerWidth,
          p.y * containerHeight,
        ])}
        stroke={stroke.color}
        strokeWidth={stroke.size}
        lineCap="round"
        lineJoin="round"
        // 하이라이터 효과
        opacity={stroke.tool === "highlighter" ? 0.3 : 1}
        globalCompositeOperation={
          stroke.tool === "eraser" ? "destination-out" : "source-over"
        }
      />
    ))}
  </Layer>
</Stage>

destination-out은 기존 캔버스 내용을 지우는 합성 모드다. 지우개로 지나간 영역의 픽셀이 투명해진다. 일반 드로잉은 source-over(기본값)를 사용하므로, 도구 전환 시 이 속성만 바꾸면 된다.


전체 흐름 요약

  1. 레이어 구성 — 미디어 위에 absolute/fixed 캔버스를 배치
  2. 이벤트 분리pointer-events: none/auto 토글로 드로잉/미디어 조작 모드 전환
  3. 좌표 정규화 — 0~1 범위의 상대 좌표로 저장, 렌더링 시 현재 크기로 복원
  4. 미디어별 영역 계산 — 비디오는 content rect 기반, PDF는 페이지별 rect 기반
  5. 성능 — 더블 버퍼링 + rAF로 부드러운 드로잉 보장
  6. 모드 분리 — Interactive(입력) / ReadOnly(표시) 오버레이를 별도 컴포넌트로 분리

이 패턴은 화상 회의 화면 공유 위 주석, 지도 위 경로 그리기, 교육 콘텐츠 마킹 등 "기존 콘텐츠 위에 사용자 입력을 얹는" 모든 시나리오에 동일하게 적용할 수 있다.


왜 Canvas 오버레이인가

미디어 위에 드로잉을 구현하는 방법은 크게 세 가지다.

  • DOM 기반(div + absolute positioning): 선, 원 같은 도형을 div로 표현한다. 간단한 마커나 주석에는 쓸 수 있지만, 자유 곡선 드로잉에는 구조적으로 맞지 않는다. 포인트마다 DOM 노드가 생기면 성능이 급격히 떨어진다.
  • SVG 오버레이: path 요소로 곡선을 표현할 수 있고 해상도 독립적이라는 장점이 있다. 하지만 포인트가 수천 개로 늘어나면 DOM 트리가 비대해지면서 렌더링이 느려진다. 도형 기반 주석(화살표, 사각형)에는 적합하다.
  • Canvas 오버레이: 픽셀 단위로 직접 그리므로 포인트 수에 관계없이 렌더링 성능이 일정하다. 더블 버퍼링, rAF 최적화, globalCompositeOperation(지우개) 등 저수준 제어가 가능하다.

자유 드로잉이 핵심이면 Canvas, 도형 주석 위주면 SVG, 단순 마커면 DOM이 적합하다. 실무에서는 Canvas 위에 SVG 도형 레이어를 추가로 올리는 하이브리드 구성도 많이 쓴다.


정리

  • 레이어 스택 구조에서 pointer-events 토글로 드로잉/미디어 조작 모드를 분리하는 것이 핵심이다
  • 좌표를 0~1 범위로 정규화해서 저장하면 반응형, 멀티 디바이스, 줌 상황에서도 드로잉 위치가 유지된다
  • 더블 버퍼링과 rAF로 성능을 확보하고, Interactive/ReadOnly 모드를 분리해서 권한과 관심사를 나눈다

관련 문서