KKflow

BUG · 버그 도감 #4

잘 찍히는 QR에 '스캔이 어려워요': 검증 기능이 만든 거짓 경고

QRforge에 넣은 실시간 스캔 검증이 실제로는 잘 읽히는 둥근 점 QR에 '스캔이 어려워요' 경고를 띄웠습니다. 원인은 한 가지 크기로만 디코딩해 본 데 있었습니다. 검증기가 틀리는 방향, 세 가지 크기로 다시 시도한 해결, 검증 기능을 만들 때 생각할 점을 정리했습니다.

· KKflow · 읽는 시간 약 7분

버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.

프로젝트 QRforge · 시기 2026년 6월 23~24일 · 관련 기술 jsQR, Canvas, SVG 래스터화

증상

QRforge는 점 모양과 색, 프레임을 꾸며 QR 코드를 만드는 웹 서비스입니다. 꾸미는 자유가 커질수록 "예쁘긴 한데 정말 찍히나?"라는 걱정도 커집니다. 그래서 6월 23일, 미리보기 아래에 실시간 스캔 검증 배지를 붙였습니다. 디자인을 바꿀 때마다 브라우저가 그 QR을 직접 읽어 보고, 결과를 세 가지로 보여 줍니다.

  • 스캔 가능 여부 확인 중…
  • 스캔 확인됨 — 실제로 읽혀요
  • 스캔이 어려워요 — 색 대비·로고·장식을 줄여보세요

그런데 이 배지가 거꾸로 문제를 일으켰습니다. 실제 휴대폰 카메라로는 잘 찍히는 QR에 빨간 "스캔이 어려워요" 경고가 떴습니다. 다음 날 고친 커밋의 제목은 "추천 프리셋 거짓 '스캔 어려움' 경고 수정"입니다. 서비스가 직접 추천하는 디자인에 서비스가 직접 경고를 붙이고 있었던 셈입니다.

기록상 배지가 들어간 시각은 6월 23일 오후 2시쯤이고, 수정이 합쳐진 시각은 다음 날 오전 6시 30분쯤입니다. 약 16시간 반 사이의 일입니다. 어떻게 발견했는지는 커밋 기록에 남아 있지 않습니다.

배경: 검증 배지는 어떻게 동작하나

QRforge는 QR 코드를 SVG(벡터 그림)로 그립니다. 검증 배지는 이 SVG를 사진처럼 픽셀 이미지로 바꾼 다음, QR 해독 라이브러리 jsQR에 넘겨 실제로 읽히는지 봅니다. 처음 코드는 이랬습니다.

function verifyScan(svg: string, expected: string): Promise<boolean> {
  return new Promise((resolve) => {
    const size = 512;
    // ... SVG를 이미지로 불러온 뒤
    canvas.width = size;
    canvas.height = size;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    ctx.drawImage(img, 0, 0, size, size);
    const { data } = ctx.getImageData(0, 0, size, size);
    const res = jsQR(data, size, size);
    resolve(!!res && res.data === expected);
    // ...

흰 바탕 위에 QR을 512×512 픽셀 한 가지 크기로 그리고, 해독한 내용이 원래 넣은 내용과 정확히 같으면 "스캔 확인됨"입니다. 디자인을 바꿀 때마다 매번 돌지 않도록 마지막 변경 뒤 0.28초를 기다렸다가 검사합니다.

원래는 "장식용 모드예요 — 스캔이 안 될 수 있어요"라는 고정 문구가 특정 프레임 모드에서만 떴습니다. 배지는 이 추측성 문구를 실제 해독 결과로 바꾼 것이었습니다. 커밋에는 "경쟁 서비스에 거의 없는 신뢰 차별점"이라고 적혀 있습니다.

원인: 한 가지 크기에서만 실패하는 점 모양

고친 커밋은 원인을 이렇게 적었습니다.

"verifyScan이 512px 단일 배율로만 디코드해, 모듈이 떨어진 원형/둥근 점은 그 배율에서 jsQR 그리드 추출에 실패 → 실제로는 스캔되는 코드에 거짓 경고."

조금 풀어 보겠습니다. QR 코드는 검은 칸과 흰 칸(모듈)의 격자입니다. 해독기는 먼저 세 모서리의 큰 사각형(위치 찾기 패턴)을 찾고, 그 사이를 잇는 점선 같은 줄(타이밍 패턴)을 따라가며 칸의 간격을 잽니다. 그렇게 격자를 다시 세운 뒤 칸마다 검은지 흰지를 읽습니다.

기본 사각형 점이라면 이웃한 검은 칸이 서로 붙어 있어서 경계가 분명합니다. QRforge의 원형 점은 다릅니다. 실제 코드에서 원형 점의 반지름은 칸 폭의 0.48배라서, 이웃한 점 사이에 칸 폭의 4%만큼 틈이 생깁니다. 둥근 점은 모서리가 깎여 있습니다. 이 작은 틈과 깎인 모서리가 픽셀 이미지로 바뀔 때, 어떤 크기에서는 픽셀 경계와 묘하게 맞물려 해독기가 칸 간격을 잘못 재게 됩니다. 수정 커밋의 코드 주석은 이를 "특정 래스터 배율에서 점 사이 틈이 해독기를 실패 상태로 빠뜨린다"고 설명합니다.

그런데 실제 휴대폰 카메라에서는 이 문제가 거의 생기지 않습니다. 같은 주석에 이유가 적혀 있습니다. 카메라는 렌즈를 거치며 점들을 광학적으로 살짝 번지게 하므로, 작은 틈이 다시 메워져 평범한 QR처럼 읽힌다는 것입니다. 픽셀이 딱딱 떨어지는 검증용 이미지가 오히려 실제보다 더 까다로운 시험이었던 셈입니다.

이 문제가 "추천 프리셋"에서 두드러진 이유도 코드에 있습니다. 지금 저장소의 추천 스타일 12개 가운데 10개가 원형 또는 둥근 점을 쓰고, 처음 열었을 때의 기본 스타일도 둥근 점입니다. 가장 많이 쓰일 디자인이 바로 문제가 되는 모양이었습니다.

해결: 세 가지 크기로 다시 읽어 보기

수정은 짧습니다. 512px 한 번만 시도하던 것을 512, 380, 760px 세 크기로 차례로 시도하고, 하나라도 읽히면 통과로 바꿨습니다(components/qr-preview.tsx).

function verifyScan(svg: string, expected: string): Promise<boolean> {
  const SCALES = [512, 380, 760];
  // ...
  for (const size of SCALES) {
    canvas.width = size;
    canvas.height = size;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    ctx.drawImage(img, 0, 0, size, size);
    const { data } = ctx.getImageData(0, 0, size, size);
    const res = jsQR(data, size, size);
    if (res && res.data === expected) return resolve(true);
  }
  resolve(false);

크기를 바꾸면 칸 하나가 차지하는 픽셀 수가 달라지고, 틈이 픽셀 경계와 맞물리는 방식도 달라집니다. 한 크기에서 우연히 걸린 실패가 다른 크기에서는 사라집니다.

"그럼 안 되는 QR도 통과시키지 않을까?"

여러 번 시도해서 하나만 통과해도 된다고 하면, 검증이 느슨해진 것처럼 보일 수 있습니다. 커밋은 이 걱정에 대해 이렇게 답합니다. "실제로 스캔 안 되는 코드는 여전히 모든 배율에서 실패하므로 안전."

코드를 보면 이 말이 성립하는 이유가 하나 더 있습니다. 통과 조건이 "무언가 읽혔다"가 아니라 "읽은 내용이 넣은 내용과 글자 하나까지 같다"(res.data === expected)입니다. QR에는 오류 검사 장치가 들어 있어서, 잘못 읽은 내용이 정확히 원래 내용과 같아질 가능성은 사실상 없습니다. 그러니 세 번 중 한 번이라도 통과했다면 그 디자인은 적어도 한 조건에서 정말로 읽힌 것입니다. 시도를 늘려서 줄어드는 것은 "읽히는데 못 읽는다고 하는" 실수뿐입니다.

대신 비용은 늘어납니다. 실패하는 디자인이라면 이제 해독을 세 번 합니다. 0.28초 대기 덕분에 디자인을 바꾸는 중에는 돌지 않고, 손을 멈췄을 때만 도니 체감할 정도는 아닙니다.

교훈

  • 검증기도 틀린다. 어느 방향으로 틀리는지 정해 둔다. 검증 기능의 실수는 두 가지입니다. 안 되는 걸 된다고 하는 것, 되는 걸 안 된다고 하는 것. QRforge의 배지는 두 번째 실수를 했고, 사용자 입장에서는 멀쩡한 디자인을 버리게 만드는 실수입니다. 해결책은 첫 번째 실수는 늘리지 않으면서 두 번째만 줄이는 쪽으로 골랐습니다.
  • 시험 환경이 실제보다 까다로울 수도 있다. 흔히 테스트는 실제보다 쉬워서 문제를 놓친다고 생각합니다. 이번에는 반대였습니다. 번짐 없는 픽셀 이미지는 카메라보다 엄격했습니다.
  • 가장 많이 쓰일 경로부터 검증한다. 기본값과 추천 프리셋은 사용자가 가장 먼저, 가장 많이 보는 화면입니다. 새 검증 기능을 붙였다면 그 기능을 기본값과 추천값 전부에 한 번씩 돌려 보는 것만으로도 이 버그는 바로 드러났을 것입니다.

참고로 배지가 들어가기 몇 시간 전, 같은 날 오전에는 반대 방향의 판단도 있었습니다. QR을 하트나 원 모양으로 오려 내는 모드는 디코더 시험에서 실제로 읽히지 않아서, 장식용으로만 남기고 경고를 붙였습니다. 그 이야기와 QRforge가 만들어진 전체 과정은 QRforge 제작기에 정리했습니다. 인쇄할 QR을 만들 때 주의할 점은 인쇄해도 잘 찍히는 QR 코드 만드는 법에 있습니다. 이 프로젝트는 Claude Code와 함께 작업했습니다.