DailBit의 여러 서비스에는 공통 기능이 하나 있다. 결과 화면을 이미지로 저장하거나 공유하는 버튼이다. 인생그래프는 완성한 그래프를 PNG로, OTO는 짝 매칭 결과를, Type288은 성향 결과를 SNS 스토리용 세로 이미지로 만든다. 같은 "이미지 저장"이지만 세 서비스는 모두 다른 방법을 썼다. 각각 왜 그렇게 했는지, 그리고 지금 다시 본다면 무엇이 아쉬운지 정리해 본다.

방법 1. canvas에 처음부터 다시 그리기 — 인생그래프

인생그래프의 화면은 SVG로 그린다. 선, 점, 눈금, 사건 라벨이 모두 SVG 요소다. 가장 쉬운 저장 방법은 이 SVG를 이미지로 바꾸는 것처럼 보이지만, 인생그래프는 저장 버튼을 누르면 1600×1000 크기의 canvas에 그래프를 처음부터 다시 그린다.

인생그래프의 결과 화면. 8세·20세·28세 사건 세 개가 선으로 이어진 그래프와 PNG로 저장 버튼이 보인다

const download = async () => {
  const canvas = document.createElement("canvas");
  canvas.width = 1600; canvas.height = 1000;
  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  ctx.fillStyle = background;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#17141f";
  ctx.font = "800 64px Pretendard, sans-serif";
  ctx.fillText(graphTitle || "나의 인생그래프", 100, 105);

  // 화면 SVG와 별개의 좌표계로 점을 다시 계산한다
  const left = 130, right = 1470, top = 250, bottom = 760, mid = (top + bottom) / 2;
  const canvasPoints = sorted.map(event => ({
    ...event,
    x: left + ((event.age - minAge) / span) * (right - left),
    y: mid - event.score * 22,
  }));

  // 사용자가 올린 사진은 모두 로드될 때까지 기다린다
  const loadedImages = new Map<string, HTMLImageElement>();
  await Promise.all(canvasPoints.filter(p => p.image).map(p => new Promise<void>(resolve => {
    const picture = new Image();
    picture.onload = () => { loadedImages.set(p.id, picture); resolve(); };
    picture.onerror = () => resolve(); // 한 장이 실패해도 나머지는 저장되게
    picture.src = p.image!;
  })));

  // ... 선, 점, 원형으로 자른 사진, 라벨을 차례로 그린다 ...

  const link = document.createElement("a");
  link.download = "나의-인생그래프.png";
  link.href = canvas.toDataURL("image/png", 1);
  link.click();
};

SVG를 그대로 변환하지 않은 이유는 라벨 때문이다. 화면의 사건 라벨은 foreignObject 안에 HTML로 들어 있다. 사진과 텍스트를 함께 배치하고 줄바꿈을 자연스럽게 처리하려고 그렇게 했는데, foreignObject가 포함된 SVG를 이미지로 canvas에 그리면 브라우저에 따라 canvas가 "오염(tainted)"되어 toDataURL이 보안 오류를 낼 수 있다. Chromium에서 직접 확인했다(Chromium 156.0.8078.4, macOS 26.6.2 arm64, 2026-10-23, 같은 출처의 페이지에서 실행). foreignObject 안에 HTML 텍스트가 있는 같은 SVG를 두 방식으로 이미지에 넣었다.

SVG를 넣은 방식 drawImage 후 toDataURL
data URL 성공
Blob URL SecurityError

같은 SVG인데도 어떻게 이미지에 넣었는지에 따라 결과가 갈렸다. Safari 같은 다른 브라우저는 확인하지 못했다. 저장용 이미지를 따로 그리면 이런 불확실성이 없다.

장점은 결과물을 완전히 통제할 수 있다는 것이다. 저장 이미지에만 부제("나의 시간을 잇는 하나의 선")와 출처(life.dailbit.com)를 넣고, 화면보다 큰 해상도로 만들 수 있다. 사용자가 올린 사진은 FileReader로 읽은 data URL이라 다른 출처의 이미지가 아니므로 canvas가 오염될 걱정도 없다.

단점은 그리는 코드가 두 벌이라는 것이다. 화면 SVG는 점수 1점당 16px, canvas는 22px을 쓰는 등 좌표계가 다르고, 라벨이 위아래 가장자리에서 잘리지 않게 방향을 바꾸는 로직도 화면에만 들어 있다. 화면을 고치면 저장 코드도 따로 고쳐야 한다. 또 canvas의 ctx.font에 지정한 웹폰트가 아직 로드되지 않았다면 브라우저는 조용히 기본 글꼴로 그린다. 이 글을 쓰며 확인해 보니 인생그래프에는 폰트 로드를 기다리는 부분이 없어서, 그리기 전에 await document.fonts.ready 한 줄을 추가했다.

방법 2. html2canvas로 화면 캡처하기 — OTO

OTO는 짝 매칭 결과 영역을 그대로 캡처한다. 코드는 15줄이 전부다.

OTO의 짝 매칭 화면. 참가자 여섯 명을 입력하고 매칭한 결과로 도윤-현우, 서연-지은, 민수-하린 세 쌍이 카드로 나온다

import html2canvas from "html2canvas";

export const handleDownload =
  (title: string, ref: React.RefObject<HTMLDivElement | null>) => () => {
    if (!ref.current) return;
    html2canvas(ref.current).then((canvas) => {
      const link = document.createElement("a");
      link.href = canvas.toDataURL("image/png");
      link.download = title === "" ? "제목없음" : title;
      document.body.appendChild(link);
      link.click();
    });
  };

html2canvas는 이름과 달리 "스크린샷"을 찍지 않는다. DOM 트리와 계산된 스타일을 읽어서 canvas 위에 CSS 렌더링을 흉내 내어 다시 그리는 라이브러리다. 그래서 화면과 저장 이미지를 따로 관리할 필요가 없다는 큰 장점이 있다. 결과 레이아웃을 바꾸면 저장 이미지도 자동으로 바뀐다.

대신 흉내 내기의 한계가 있다. html2canvas가 지원하지 않는 CSS 속성(일부 필터나 블렌드 모드, backdrop-filter 등)은 화면과 다르게 그려질 수 있고, 다른 출처의 이미지가 포함되면 CORS 설정이 없을 때 빠지거나 canvas를 오염시킨다. OTO의 결과 화면은 텍스트와 단순한 박스 위주라 이 한계에 거의 걸리지 않았다. 반대로 Type288처럼 화려한 결과 화면이었다면 이 방법은 맞지 않았을 것이다.

다시 보니 고칠 점도 있다.

// 개선안
html2canvas(ref.current, { backgroundColor: "#ffffff" }).then((canvas) => {
  canvas.toBlob((blob) => {
    if (!blob) return;
    const url = URL.createObjectURL(blob);
    const link = Object.assign(document.createElement("a"), {
      href: url,
      download: `${title || "제목없음"}.png`, // 확장자를 명시
    });
    link.click();                // DOM에 붙일 필요 없음
    setTimeout(() => URL.revokeObjectURL(url), 2000);
  }, "image/png");
});
  • 기존 코드는 다운로드용 <a>를 body에 붙이고 제거하지 않아, 저장할 때마다 링크가 하나씩 쌓인다.
  • 파일 이름에 확장자가 없다. Chromium 156에서는 download="나의-인생그래프"처럼 확장자 없이 지정해도 data URL과 Blob URL 모두 나의-인생그래프.png로 저장됐다(2026-10-23 확인). 다른 브라우저는 확인하지 못했으므로 확장자를 명시하는 편이 안전하다.
  • toDataURL은 이미지를 base64 문자열로 만들기 때문에 원본보다 크기가 약 3분의 1 커지고, 변환하는 동안 메인 스레드를 막는다. toBlob은 비동기로 바이너리를 만든다.

방법 3. canvas로 직접 그려 파일로 저장하기 — Type288

Type288은 결과 페이지에 "스토리 이미지 저장" 버튼이 있다. 누르면 1080×1920 세로 카드를 canvas에 그려 PNG 파일로 내려받게 한다. 그리는 방식은 인생그래프처럼 직접 그리기지만, 두 가지가 다르다.

Type288 스토리 이미지 저장 버튼으로 내려받은 1080×1920 세로 카드. 결과 코드와 캐릭터가 그려진다

첫째, 폰트와 이미지를 기다린다.

async function drawCard(props: Props) {
  const canvas = document.createElement("canvas");
  canvas.width = 1080; canvas.height = 1920;
  const ctx = canvas.getContext("2d")!;
  // 페이지에 적용된 실제 글꼴 이름을 그대로 가져와 쓴다
  const font = getComputedStyle(document.body).fontFamily;
  await document.fonts.ready; // 웹폰트 로드가 끝날 때까지 대기
  const image = await new Promise<HTMLImageElement>((resolve, reject) => {
    const el = new Image();
    el.onload = () => resolve(el);
    el.onerror = reject;
    el.src = props.image;
  });
  // ... 배경 그라데이션, 둥근 캐릭터 카드, 코드 칩, 메모 카드를 그린다 ...
  return new Promise<Blob>((resolve, reject) =>
    canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("canvas")), "image/png"));
}

getComputedStyle로 페이지의 글꼴 이름을 읽어 오기 때문에 CSS에서 글꼴을 바꾸면 카드 이미지도 따라 바뀐다. 직접 그리기의 "코드 두 벌" 문제를 조금이나마 줄이는 방법이다. canvas에는 자동 줄바꿈이 없어서, 글자 하나씩 measureText로 너비를 재며 줄을 나누는 wrap 함수도 직접 만들었다.

둘째, 공유 시트를 시도했던 방식과 지금의 다운로드. 이 저장소의 이력을 보면 navigator.share()로 이미지 파일을 공유하는 코드는 2026-09-30에 들어갔고 2026-10-10에 빠졌다. 지금 Type288의 스토리 이미지 버튼은 다운로드만 한다. 아래는 공유를 시도했을 때의 코드다.

const blob = await drawCard(props);
const file = new File([blob], `type288-${props.id.toLowerCase()}.png`, { type: "image/png" });
if (navigator.canShare?.({ files: [file] })) {
  await navigator.share({ files: [file], title: `${props.name} | Type288` });
} else {
  const url = URL.createObjectURL(blob);
  const link = Object.assign(document.createElement("a"), { href: url, download: file.name });
  link.click();
  window.setTimeout(() => URL.revokeObjectURL(url), 2000);
}

파일 공유는 Web Share API 중에서도 지원 범위가 좁다. 그래서 navigator.share가 있는지만 보는 대신 navigator.canShare({ files })로 파일 공유가 가능한지를 먼저 확인한다. 지원하지 않는 데스크톱 브라우저에서는 일반 다운로드로 넘어간다. 사용자가 공유 시트를 닫으면 share()가 예외를 던지는데, 이것도 catch에서 조용히 처리해 버튼 상태만 되돌린다.

주의할 점이 하나 있다. MDN에 따르면 navigator.share()는 현재 문서에 임시 사용자 활성화(transient activation)가 있어야 하고, 버튼 클릭 같은 UI 이벤트에서 호출해야 한다(MDN: Navigator.share()). 활성화가 없으면 NotAllowedError로 거부된다. 폰트 대기, 이미지 로드, canvas 그리기를 모두 기다린 뒤에 share()를 부르는 구조가 이 활성화를 소진하는지는 MDN에 시간 제한이 나와 있지 않고, iOS Safari 실기기에서도 확인하지 못했다. 안전한 방법은 결과 페이지에 들어왔을 때 카드 이미지를 미리 만들어 두고, 클릭하면 바로 share()만 호출하는 것이다.

세 방법 비교

구분 직접 그리기 (인생그래프) DOM 캡처 (OTO) 직접 그리기 (Type288)
화면과 저장 이미지 별도 코드 자동으로 같음 별도 코드
결과물 통제 완전 제한적 완전
추가 라이브러리 없음 html2canvas 없음
CSS 지원 한계 없음(직접 그리므로) 있음 없음
폰트 대기 document.fonts.ready(추가함) 라이브러리가 처리 document.fonts.ready
모바일 공유 다운로드만 다운로드만 다운로드만(공유 시트는 2026-10-10 이후 제거)

정리하면 이렇다.

  • 화면이 단순하고 자주 바뀐다면 DOM 캡처가 유지보수에 유리하다.
  • 저장 이미지가 화면과 달라야 하거나 디자인이 중요하다면 직접 그리기가 낫다. 이때는 반드시 폰트와 이미지 로드를 기다려야 한다.
  • 모바일 공유가 중요하다면 canShare({ files })로 확인하고, 무거운 작업은 클릭 전에 끝내 둔다.

저장 시간과 파일 크기를 직접 재 보면

세 서비스의 운영 빌드를 로컬에서 띄우고, 저장 버튼을 누른 때부터 다운로드가 시작될 때까지의 시간과 파일 크기를 7번씩 쟀다.

  • 환경: Chromium 156.0.8078.4(Playwright 1.64.1, headless, 화면 배율 1), macOS 26.6.2 arm64(Apple M5 Pro), Node v22.22.2, 2026-10-23 측정. 표의 시간은 중앙값이다.
방식(서비스) 저장 시간 최소–최대 파일 크기 이미지 크기
canvas 직접 그리기(인생그래프, 기본 사건 3개) 48ms 41–53ms 102KB 1600×1000
html2canvas(OTO, 참가자 6명 매칭 결과) 213ms 191–235ms 13KB 720×148
canvas 직접 그리기(Type288 스토리 이미지) 114ms 109–133ms 2,150KB 1080×1920
html-to-image(Type288 성경 인물 카드) 144ms 87–189ms 226KB 320×574

서비스마다 저장하는 내용과 해상도가 달라서 방식끼리의 속도를 그대로 비교할 수는 없다. 다만 어느 방식도 이 조건에서는 0.3초 안에 끝났다. 참고로 마지막 줄은 이 글에서 다루지 않은 방식으로, Type288이 스토리 이미지 외의 카드를 저장할 때 DOM을 캡처하는 html-to-image를 쓴다. iOS Safari에서의 저장·공유 동작은 측정하지 못했다.

세 서비스를 만들 때는 각각 그때그때 필요한 방법을 골랐지만, 나란히 놓고 보니 공통 유틸로 묶을 수 있는 부분(다운로드 링크 처리, Blob 변환, 공유 폴백)이 꽤 보인다. 다음에 이미지 저장 기능이 필요한 서비스를 만든다면 이 세 가지를 하나의 작은 모듈로 먼저 정리하고 시작할 생각이다.