사진 QR 코드는 실제로 스캔될까요? 테스트 결과
사진이 된 QR 코드는 스캔이 더 나쁠 것 같아 보여요. 실제 디코더로 흐리게, 작게 만든 뒤 리더기가 읽는 방식대로 측정했어요.
이 글의 새로운 점: 자체 사진 QR 구현을 jsQR로 일반 대조 코드와 비교해 측정한 디코드 테스트 결과예요. 0.10에서 0.45 모듈 사이의 모든 리더 조리개에서 4,225개 모듈 중 잘못 읽힌 모듈은 0개였고, 3배 축소 허용치는 동일했어요. 흐림 허용치는 사진 코드가 4~5픽셀, 일반 코드가 6픽셀이었어요. 코드를 그림으로 바꾸는 데 드는 구체적이고 작은 비용이에요.
사진처럼 보이는 QR 코드는 예쁘지만 스캔이 몇 번은 실패하는 타협안처럼 느껴지기 쉬워요. 그 직관은 대부분의 구현에서는 맞지만, 이 기능이 실제로 어떻게 동작해야 하는지에 대해서는 틀렸어요. 그리고 "몇 번은 실패"라는 말은 어느 누구도 수치로 공개하지 않아요.
여기 우리 측정 결과를 가져왔어요. 이 기능을 출시하기 전에 통과해야 했던 테스트 스위트에서 나온 수치예요.
# 사진 QR 코드란 무엇이고, 무엇이 아닌가
같은 이름으로 불리지만 전혀 다른 두 가지가 있어요.
AI QR 아트는 확산 모델 방식으로, QR 코드를 조건으로 이미지를 생성한 뒤 결과가 여전히 디코딩되기를 바라는 방식이에요. 가끔은 잘 되기도 해요. 하지만 실패는 어두운 술집에서 3년 된 휴대폰을 갖다 대는 사람이 나타나기 전까지는 눈에 띄지 않아요.
디더링된 사진 코드는 다른 접근법이고, 이 글에서 측정한 방식이에요. 이미지를 오차 확산 방식으로 1비트, 즉 서브픽셀마다 검정 또는 흰색으로 줄여요. 여기에는 결정적인 제약이 하나 있어요. 모든 모듈 중심에서 디더 값은 인코더가 요구하는 값으로 강제되고, 그 강제 때문에 생기는 오차(어두운 영역의 흰색 모듈이라면 픽셀 한 개 분량의 약 95%까지)는 아직 임계값을 적용하지 않은 주변 서브픽셀로 밀려나요. 그림은 모듈 중심과 중심 사이의 공간으로 만들어져요.
이 차이가 이 글의 핵심이에요. 코드가 오류 정정 능력을 장식에 쓰지 않는다는 뜻이에요. 모든 모듈이 여전히 인코더의 값을 담고 있어서, 구겨짐, 반사광, 한쪽 모서리를 덮은 엄지손가락처럼 오류 정정이 실제로 존재하는 이유가 되는 상황에 대해 전체 중복성이 그대로 남아 있어요.
# 테스트
아래 내용은 모두 우리 저장소에서 오프라인으로 재현할 수 있어요.
- 인코더: 자체
packages/qr, 오류 정정 레벨 H, 최소 밀도minVersion: 12를 적용해 심볼 버전 12, 가로세로 65 모듈, 모듈당 10픽셀, 4모듈 여백을 사용했어요. - 스타일: halftone(실제로 디더링된 사진)과 mosaic(모듈만 그리되 로컬 어두운 정도에 따라 크기를 정함) 두 가지이고, 비교 기준으로 디더링하지 않은 일반 코드를 대조군으로 두었어요. 같은 페이로드, 같은 버전, 같은 배율이에요.
- 이미지: 절차적으로 생성한 테스트 이미지를 사용했어요. 모든 명도가 나타나는 대각선 그라데이션과, 강제된 점마다 주변과 어긋나므로 가장 나쁜 경우가 되는 단색 중간 회색을 포함해요.
- 디코더: 실제 QR 디코더인 jsQR 1.4.0에 실제 픽셀을 입력했어요.
- 열화 조건: 최근접 이웃 축소, 박스 블러 후 다시 임계값 적용(초점이 맞지 않은 카메라), 조리개 읽기를 적용했어요.
이 측정의 기반이 되는 스위트는 packages/qr/test/dither.test.js예요. 2026-08-31에 39/39 통과했어요. 디코딩에 실패하는 코드는 경고가 아니라 실패한 테스트로 취급해요. 이 규칙 때문에 이 기능이 지금의 형태로 만들어졌어요. 아래 표는 draft/mostlyqr/seo/benchmarks/photo.js가 출력한 결과이고, 같은 스타일을 같은 열화 조건에 통과시켜요. 여기 있는 수치는 옮겨 적은 것이 아니에요.
# 어떤 조리개 크기에서도 잘못 읽힌 모듈 0개
가장 중요한 측정은 "디코딩됐는가"가 아니라, 리더가 모든 셀에서 올바른 값을 봤는가예요. 오류 정정이 구해 준 덕분에 디코딩되는 코드는, 다른 문제가 생기면 실패할 코드이기 때문이에요.
적합한 리더는 모듈 중심마다 픽셀 하나만 보지 않아요. 작은 원형 조리개의 평균을 구하고 그 평균에 임계값을 적용해요. 그래서 우리도 같은 방식을 적용했어요. 조리개 반지름을 0.10, 0.25, 0.36, 0.45모듈로 두고(0.5면 모듈 전체가 돼요), 4,225개 모듈 중 몇 개가 잘못 읽힐지 셌어요.
| 스타일 | 잘못 읽힌 모듈 수, 조리개 0.10 / 0.25 / 0.36 / 0.45 |
|---|---|
| 일반 코드(대조군) | 0 / 0 / 0 / 0 |
| 모자이크 사진 | 0 / 0 / 0 / 0 |
| 하프톤 사진(그라데이션) | 0 / 0 / 0 / 0 |
| 하프톤 사진(단색 중간 회색, 최악의 경우) | 0 / 0 / 0 / 0 |
어떤 스타일에서든, 어떤 조리개 크기에서든, 최악의 이미지까지 포함해 모듈 하나도 잘못 읽히지 않았어요. 사진 코드와 일반 코드는 리더에게 같은 값을 보여줘요. 오류 정정은 손대지 않았고 여전히 모두 사용할 수 있어요.
# 비용: 블러 한 단계 정도
오류 정정이 그대로 남아 있다는 것이 동일하다는 뜻은 아니에요. 디더링은 중심 사이의 픽셀이 어떻게 보이는지를 실제로 바꾸고, 심하게 초점이 나간 카메라는 그 픽셀들을 평균 내버려요. 그래서 각 스타일이 깨질 때까지 조건을 밀어붙였어요.
| 스타일 | 디코딩이 되는 최대 축소 배율 | 디코딩이 되는 최대 블러 반지름 |
|---|---|---|
| 일반 코드(대조군) | 3× | 6 px |
| 모자이크 사진 | 3× | 5 px |
| 하프톤 사진(단색 중간 회색) | 3× | 6 px |
| 하프톤 사진(그라데이션) | 3× | 4 px |
축소, 즉 네이티브 크기보다 작게 렌더링한 경우는 모든 스타일에서 동일했어요. 각 스타일은 3×를 통과했고 4×에서 처음 실패했어요. (최근접 이웃 축소는 단조롭지 않아서, 일부는 샘플링 격자가 우연히 더 좋은 위치에 놓여 5×에서 다시 디코딩되기도 했어요. 그래서 여기서 보고하는 수치는 마지막 성공이 아니라 처음 실패한 지점이에요.)
비용이 드러나는 곳은 초점이에요. 가장 어려운 이미지에서 하프톤 스타일만 그래요. 일반 대조군보다 여유가 3분의 1 적어요. 단색 중간 회색 필드의 하프톤은 일반 코드와 정확히 같았기 때문에, 이 비용은 기법 자체의 속성이 아니라 이미지에 따라 달라지는 비용이에요. 쉽게 말하면, 사진 코드는 카메라가 일반 코드보다 초점에 한 단계 더 가까워야 해요. 이것은 실제 비용이고 작은 비용이에요. "스캔되나요?"라는 질문에 대한 정직한 답은 "네"도 "아니요"도 아니라 "네, 초점 허용 범위가 한 단계 줄어든 상태에서요"예요.
모자이크 스타일은 둘 사이에 있어요. 모듈만 그리기 때문에 중심 사이에 블러가 번질 잉크가 적어요. 그래서 이 결과는 납득이 가요.
# 밀도가 실제 요구 사항인 이유
이 기능의 첫 버전은 기술적으로는 맞았지만 보기에는 잘못 보였어요. 사진 QR 도구를 쓰는 분이라면 알아두면 좋은 이유예요.
오류 정정 레벨 H에서 https://mqr.sh/AB12CD는, 사진 코드가 강제하는 설정인데, 29모듈 격자로 인코딩돼요. 사진 한 장을 담기에는 841개 셀뿐이에요. 결과는 그림이 아니라 노이즈처럼 읽혀요. 사람들은 진짜 문제가 이미지가 놓일 공간이 없다는 데 있는데도 기법이 깨졌다고 결론 내려요. 더 밀도 높은 심볼을 강제하면 문제가 해결돼요.
| 최소 버전 | 심볼 | 가로 모듈 수 | 이미지에 쓸 수 있는 셀 |
|---|---|---|---|
| 없음(페이로드가 요구하는 만큼) | v3 | 29 | 841 |
| 8 | v8 | 49 | 2,401 |
| 12(우리 기본값) | v12 | 65 | 4,225 |
버전 12에서는 셀이 다섯 배예요. 진지한 이미지 QR 도구는 모두 같은 이유로 이 값을 올려요. 최소 밀도 기준은 심볼을 페이로드가 실제로 필요한 것보다 작게 만들어서는 안 돼요. 데이터가 밀도보다 항상 우선이에요.
인쇄에 주는 영향은 직접적이고, 이 글이 페이로드에 모듈이 몇 개 필요한지 글과 같은 묶음에 있는 이유이기도 해요. 65모듈 코드는 여백을 포함해 종이에서 모듈 폭 73개가 필요해요. 모듈 하나가 0.5mm라면 36.5mm예요. 그러니 사진 코드는 포스터, 메뉴, 포장재 패널, 화면에 맞는 크기예요. 명함용은 아니에요.
# 이 테스트가 다루지 않는 것
이 벤치마크는 한계를 명시하지 않으면 없는 것보다 나쁘기 때문에, 분명하게 적어 둘게요.
- 디코더 하나, 합성 픽셀. jsQR은 실제 디코더이지만 휴대폰 카메라 스택은 아니고, 입력은 인쇄된 물체를 찍은 사진이 아니라 깨끗하게 렌더링된 출력이에요.
- 인쇄, 반사광, 원근, 구김 없음. 이 요소들은 모두 여유를 깎아먹고, 사진 코드와 일반 코드에 대한 비용이 서로 달라요.
- 색상 없음. 이 코드는 1비트 흑백 코드예요.
- 우리 자체 구현. 여기서 얻은 결론은 다른 곳의 사진 QR이나 AI 아트 QR 코드에 대해 아무것도 말해 주지 않아요. 그런 코드가 모듈을 덮어쓰고 오류 정정에 기대어 손상을 복구한다면, 이 결과와는 전혀 다르게 동작할 거예요. 공정한 테스트는 위와 같은 방식을 그 코드들에 그대로 적용하는 거예요.
# 언제 쓰면 좋을까
- 예: 포스터, 유리 아래 메뉴, 포장재 패널, 화면, 전시 그래픽, 크게 인쇄하는 모든 것.
- 아마도: 앨범 커버, 책 재킷, 큰 제품 라벨. 직접 테스트해 보세요.
- 아니요: 명함, 접히는 종이 전단지, 약 35mm 미만인 모든 것, 멀리서 나쁜 조명으로 스캔하는 모든 것.
사진 전체가 아니라 평범한 QR 코드 가운데에 로고만 넣고 싶다면, 그건 훨씬 작은 예산을 가진 다른 기법이에요. 로고를 얼마나 크게 넣을 수 있는지도 측정했는데, 인터넷에서 생각하는 것보다 답이 작았어요.
MostlyQR의 사진 코드가 여기서 측정한 구현이에요. 같은 디더링 코어가 브라우저 미리보기와 서버에서 렌더링하는 PNG 모두에서 실행돼요. 그래서 빌더에서 보는 것이 인쇄소가 받는 것과 같아요. 빌더의 안내 문구인 "화면과 큰 인쇄물에 가장 적합해요. 작거나 구겨진 종이에서는 사진 코드를 읽기 어려워요"는, 이 수치를 본 뒤에는 그 기능에 맞는 말처럼 보여요.
자주 묻는 질문
사진 QR 코드는 안정적으로 스캔되나요?
우리 테스트에서는 그래요. 다만 측정 가능한 비용이 하나 있어요. 표준 리더처럼 각 모듈 중심에서 원형 조리개로 평균을 내며 읽었을 때, 테스트한 어떤 조리개에서도 4,225개 모듈 중 하나도 잘못 읽히지 않았어요. 3배 축소도 일반 코드와 같이 견뎠어요. 측정된 유일한 차이는 초점 흐림이었고, 가장 어려운 이미지에서만 나타났어요. 일반 대조 코드는 6픽셀 흐림 반경을 견뎠고, 모자이크 스타일은 5, 하프톤 스타일은 그라데이션에서 4(단색 회색에서는 6)를 견뎠어요. 카메라 초점 한 단계 정도의 차이이지, 신뢰도가 다른 범주가 되는 것은 아니에요.
예술적인 QR 코드는 오류 정정 용량을 소모하나요?
많은 구현이 그래요. 그래서 이 기법의 평판이 나빠요. 우리 구현은 그렇지 않아요. 모듈 전체를 이미지 데이터로 덮어쓰고 Reed-Solomon이 차이를 복구하게 두는 대신, 각 모듈 중심의 점을 인코더가 요구하는 값으로 강제하고, 생긴 오차를 주변 하위 픽셀로 분산시켜요. 그림의 비용은 모듈 중심 사이의 공간에서 치르기 때문에, 구김, 반사광, 모서리를 덮은 엄지 같은 실제 환경을 위해 오류 정정 용량 전체가 그대로 남아 있어요.
사진 QR 코드를 전단지에 인쇄할 수 있나요?
권하지 않아요. 우리 빌더도 “화면과 대형 인쇄물에 가장 적합”이라고 안내하고 있고, 그 말은 진심이에요. 사진 코드는 밀도가 높은 기호가 필요해요. 아래 테스트에서 사진 코드는 한 변이 65모듈이고, 같은 짧은 링크를 강제하지 않았을 때는 29모듈이에요. 그래서 전단지 크기에서는 모듈이 아주 빠르게 작아져요. 포스터, 유리 아래 메뉴, 포장 면, 화면이 알맞은 곳이에요. 주머니에 접혀 들어가는 종이 전단지는 아니에요.
내 사진 QR 코드가 그림이 아니라 노이즈처럼 보이는 이유는 무엇인가요?
거의 항상 기호가 너무 거칠기 때문이에요. 오류 정정 H의 짧은 링크는 29모듈 격자, 즉 841칸으로 인코딩되는데, 알아볼 수 있는 이미지를 담기에는 턱없이 부족해서 그림이 들어갈 자리가 없어요. 더 촘촘한 기호를 쓰면 해결돼요. 버전 8은 49모듈, 버전 12는 65모듈(4,225칸, 다섯 배 많음)이에요. 그래서 우리 빌더는 사진 코드의 기본값으로 버전 12를 써요. 데이터가 항상 우선이므로, 더 긴 페이로드는 더 큰 기호를 얻게 돼요.