EXECUTIVE SUMMARY

핵심 요약

  • QR 코드에 최종 도착지 URL을 직접 담으면, 도착지가 바뀔 때마다 QR 이미지를 새로 만들고 다시 인쇄해야 한다.
  • 그래서 QR에는 `/q/[slug]` 형태의 고정 주소만 담고, 실제 도착지는 관리자 페이지에서 언제든 바꿀 수 있게 분리했다.
  • 인스타그램 릴스처럼 자주 바뀌는 링크를 붙였다 뗐다 하는 용도라, 추적 파라미터를 자동으로 걷어내고 스캔 수와 마지막 스캔 시각만 남기도록 했다.
  • 빌드 시점에 필요한 테이블이 없어서 배포가 실패하는 문제가 있었고, 이를 스크립트로 해결했다.

인쇄물은 고정되고 링크는 바뀐다는 사실에서 출발했다

나는 개인 사이트와 몇 가지 사업을 함께 운영하면서, 오프라인 홍보물에 QR 코드를 붙이는 일이 종종 생긴다. 문제는 QR 코드 자체는 한 번 인쇄하면 그 종이나 스티커가 존재하는 한 계속 스캔되는데, 그 QR이 가리키는 인스타그램 릴스나 이벤트 페이지는 몇 주 단위로 바뀐다는 점이다. QR에 최종 URL을 그대로 박아 넣으면, 링크가 바뀔 때마다 QR 이미지를 새로 만들고 인쇄물을 다시 찍어야 한다. 인쇄물의 수명과 링크의 수명이 다른데, 지금까지는 이 둘을 하나로 묶어서 다뤄왔던 셈이다.

이번 작업의 핵심은 이 둘을 분리하는 것이었다. QR 코드에는 `hyeonwoo.co.kr/q/특정슬러그` 같은 고정 주소만 넣고, 그 주소가 실제로 어디로 리다이렉트할지는 서버 쪽 데이터로 관리한다. 이렇게 하면 인쇄물은 그대로 두고 뒤에서 도착지만 바꾸면 된다. 커밋 메시지에 적은 "print once, retarget forever"는 이 구조를 한 줄로 요약한 것이다.

QR 이미지를 만드는 코드 자체는 `/q/[slug]`라는 고정 URL을 인코딩할 뿐이고, 실제 도착지는 여기 등장하지 않는다. 이미지와 도착지가 코드 상에서도 완전히 분리되어 있다는 뜻이다.

+function QrPreview({ url, slug }: { url: string; slug: string }) {
+  ...
+  const downloadPng = useCallback(async () => {
+    const dataUrl = await QRCode.toDataURL(url, { ...QR_RENDER_OPTIONS, width: 2048 });
+    const anchor = document.createElement("a");
+    anchor.href = dataUrl;
+    anchor.download = `qr-${slug}.png`;
+    anchor.click();
+  }, [url, slug]);

관리자 페이지는 복잡한 기능보다 교체 한 번을 빠르게 하는 데 집중했다

`/admin/qr`에 만든 화면은 기능이 많지 않다. 링크 목록을 보고, 클립보드에 새 URL을 붙여넣고, 한 번 탭하면 도착지가 바뀌는 정도다. 커밋 메시지에 "one-tap clipboard paste"라고 적어둔 이유는, 실제로 이 화면을 쓰는 상황이 손이 바쁠 때이기 때문이다. 인스타그램에서 새 릴스 링크를 복사해서 붙여넣고 바로 반영되는 흐름이면 충분하다고 판단했고, 그 이상의 편집 기능은 넣지 않았다.

인스타그램 링크는 복사할 때 추적 파라미터가 자동으로 붙는 경우가 많아서, 저장 시점에 이런 파라미터를 걷어내는 처리를 추가했다. 그리고 각 슬러그마다 스캔 수와 마지막 스캔 시각을 기록하게 했는데, 이건 QR이 실제로 쓰이고 있는지 아닌지를 나중에 확인할 최소한의 단서를 남기기 위해서다. 스캔 데이터를 정교하게 분석할 계획은 아직 없고, 지금은 이 링크가 살아있는지 죽어있는지 정도만 구분할 수 있으면 된다고 봤다.

빌드 시점과 실행 시점의 환경 차이를 스크립트로 못박았다

새 기능을 추가하면서 `qr_links`라는 테이블을 스키마에 넣었는데, 이 테이블에 접근하는 `DATABASE_URL`이 Vercel 환경에만 존재하는 값이라 로컬 빌드나 다른 환경에서는 테이블 생성 여부를 보장할 수 없는 상황이 생겼다. 커밋 메시지에 "qr_links table ensured at build time (DATABASE_URL is Vercel-only)"라고 짧게 남긴 이유가 여기 있다. 이 한 줄 덕분에, 나중에 `scripts/ensure-qr-links.ts`가 왜 존재하는지 코드를 다시 열어보지 않고도 커밋 로그만 보고 바로 떠올릴 수 있었다. 배포 파이프라인에서 이 스크립트가 먼저 실행되도록 `package.json`과 `vercel.json`을 함께 손봤고, 테이블이 없어서 페이지가 통째로 죽는 상황을 미리 막아두는 쪽을 택했다.

이번 구조를 한 문장으로 정리하면, 변하지 않아야 할 것과 변할 수 있어야 할 것을 처음부터 분리해두는 것이다. QR 이미지, 인쇄물, 고정 슬러그는 한 번 정해지면 건드리지 않는 쪽에 두고, 실제 도착지와 그 도착지를 바꾸는 화면은 언제든 손댈 수 있는 쪽에 둔다. 이 경계를 미리 그어두면, 나중에 무언가 바뀌어야 할 때 어디를 건드려야 하는지 고민할 필요가 없어진다.

NEXT STEPS

다음 단계

  • 스캔 로그를 조금 더 쌓아보고, 슬러그별로 스캔이 없는 경우를 어떻게 정리할지 정한다.
  • 관리자 화면에서 슬러그를 새로 발급하는 흐름을 추가할지, 아니면 지금처럼 필요할 때마다 직접 만드는 방식을 유지할지 결정한다.
  • 인쇄물에 쓸 QR PNG 해상도가 실제 인쇄 크기에서 스캔이 잘 되는지 확인한다.