여백이 생기는 원리
QR 코드를 비우는 방법은 데이터를 지우는 게 아닙니다. 원래부터 아무 의미가 없던 자리를 찾아내서, 그 자리가 흰색으로 보이도록 값을 거꾸로 계산하는 것입니다.
QR 코드의 절반은 채움값입니다
QR 코드는 크기가 정해진 그릇입니다. 규격 5짜리 QR은 134칸을 담고, 그중 108칸이 데이터 자리입니다. 그런데 HTTPS://EXAMPLE.COM 같은 주소는 14칸이면 충분합니다. 남는 94칸은 어떻게 될까요?
규격은 이 자리를 0xEC와 0x11을 번갈아 채우라고 정해두었습니다. 아무 의미 없는 값입니다. 그런데 0xEC는 8비트 중 5비트가 1이라, 화면에서는 꽤 진한 검정 덩어리가 됩니다. QR 코드가 촘촘해 보이는 이유의 상당 부분이 이겁니다.
디코더는 채움값을 읽지 않습니다
데이터 자리의 맨 앞에는 모드와 길이가 적혀 있고, 실제 내용이 끝나면 종료자라는 네 개의 0이 붙습니다. ZXing이든 jsQR이든 대부분의 디코더는 이 종료자를 만나는 순간 읽기를 멈춥니다. 그 뒤에 무엇이 있든 결과는 달라지지 않습니다.
마스킹을 거꾸로 계산합니다
QR은 마지막 단계에서 여덟 가지 격자무늬 중 하나를 골라 데이터 영역 전체에 XOR로 덧씌웁니다. 어떤 무늬를 썼는지는 QR 왼쪽 위에 따로 기록되므로, 읽는 쪽은 같은 무늬를 다시 XOR해서 원래 값을 복원합니다.
여기에 빈틈이 있습니다. XOR은 같은 값끼리 만나면 0이 됩니다. 그러니 채움값 자리에 그 자리에 덧씌워질 무늬와 똑같은 값을 미리 넣어두면, 마스킹이 끝난 결과가 전부 0, 다시 말해 흰색이 됩니다.
핵심은 세 줄입니다. 비트가 놓일 좌표를 미리 구한다. 그 좌표의 마스크 값을 계산한다. 그 값을 그대로 채움값에 넣는다. 그러면 마스킹 후 결과가 0이 됩니다.
그래도 남는 검정이 있습니다
- 실제 데이터. 주소 그 자체입니다. 주소가 짧을수록 적게 차지합니다.
- 오류 복원 코드. 리드-솔로몬 알고리즘이 계산한 값이라 우리가 정할 수 없고, 통계적으로 절반이 검정입니다. 전체 칸의 약 20퍼센트를 차지합니다.
그래서 복원 레벨 L 기준 검정 비율의 하한은 대략 11퍼센트 언저리입니다. 일반 QR이 47퍼센트 안팎인 것과 비교하면 4분의 1 수준입니다.
복원 코드도 조금은 밀어낼 수 있습니다
복원 코드는 채움값에 따라 결정됩니다. 채움값 끝의 두 바이트만 바꿔도 복원 코드 전체가 새로 계산되고, 그 결과의 검정 개수는 매번 조금씩 달라집니다. 이걸 수백 번 반복해서 가장 하얗게 나온 조합을 고르면 몇 퍼센트를 더 줄일 수 있습니다.
실측 결과
| 레벨 | V2 | V3 | V5 | V7 | V10 |
|---|---|---|---|---|---|
| L | 27% | 20% | 15% | 13% | 12% |
| M | 34% | 28% | 21% | 21% | 21% |
| Q | 38% | 37% | 32% | 31% | 29% |
| H | 없음 | 42% | 40% | 36% | 34% |
규격을 키울수록 비워지지만 V7 근처에서 수익이 거의 멈춥니다. 복원 코드의 비율이 규격과 무관하게 일정하기 때문입니다.
공짜는 아닙니다
넓은 흰 영역에는 스캐너가 밝기 기준을 잡을 단서가 없습니다. 일반 QR의 촘촘한 무늬는 그 자체가 기준점 역할을 하는데, 비워버리면 그게 사라집니다. 어두운 곳이나 흔들린 사진에서는 없던 점이 생기고, 복원 코드가 그걸 다 메우지 못하면 읽기에 실패합니다. 복원 레벨 고르기와 스캔이 잘 되게 하는 법에 대응 방법을 정리했습니다.
규격을 어기는 걸까요
채움값의 내용만 규격과 다릅니다. 사용한 마스크 번호는 규격대로 기록하고, 오류 복원 코드도 규격대로 계산합니다. 이 사이트의 인코더는 완전 준수 모드로 돌렸을 때 표준 라이브러리와 비트 하나까지 일치하는 것을 규격 1부터 10까지, 네 가지 복원 레벨 전부에서 확인했습니다.