바코드는 화면에서 문제없어 보였다. 인쇄도 깨끗했다. 그런데 물류창고에서 다른 상품으로 읽혔고, 출고가 끝난 뒤에야 그 사실을 알아차렸다.
원인은 가중치를 반대 방향으로 적용해 계산한 체크 디지트였다. 심볼 자체는 구조적으로 유효했기 때문에 모든 렌더러가 이를 받아들였고 미리보기도 정상으로 보였다. 스캐너는 의도를 검증하지 않는다. 바가 말하는 값을 디코딩해 숫자를 돌려줄 뿐이다.
바코드는 하드웨어가 개입하기 전까지 보이지 않는 방식으로 실패한다. 이 글은 1차원 바코드 심볼이 실제로 스캔되는지를 결정하는 요소를 다룬다. 데이터에 맞는 심볼로지(symbology), 체크 디지트 계산의 수학, 그리고 규격상 유효하지만 읽히지 않는 라벨을 만들어내는 몇 가지 인쇄·레이아웃 실수다.
심볼로지 선택
리테일 상품이라면 선택권은 없다. 넘버링 체계가 심볼로지를 결정하고, 번호 자체가 이미 어떤 심볼로지를 써야 하는지 알려준다.
| 심볼로지 | 데이터 | 길이 | 사용처 |
|---|---|---|---|
| EAN-13 | 숫자 | 12 + 체크 | 전 세계 리테일. ISBN-13, ISSN, JAN이 같은 인코딩을 사용 |
| EAN-8 | 숫자 | 7 + 체크 | EAN-13을 넣기에 너무 작은 포장 |
| UPC-A | 숫자 | 11 + 체크 | 북미 리테일 |
| Code 128 | ASCII 0-127 | 가변 | 물류, 창고, 사내 자산 태그 |
| Code 39 | 0-9 A-Z 공백 - . $ / + % | 가변 | 산업, 자동차, 방산 레거시 시스템 |
| ITF-14 | 숫자 | 13 + 체크 | 배송 상자와 팔레트 단위 거래 품목 |
| Codabar | 0-9 - $ : / . + | 가변 | 도서관, 혈액은행, 택배 라벨 |
GTIN-13은 EAN-13이어야 한다. GTIN-12는 UPC-A여야 한다. 리테일 계산대를 통과할 물건에 라벨을 붙인다면 심볼로지를 고르는 것이 아니라 이미 할당받은 번호를 인코딩하는 것이고, 심볼로지는 그 번호에 딸려 온다.
한국 유통에서 쓰는 KAN(Korean Article Number)도 EAN-13과 동일한 규격이다. 국가 코드 880으로 시작하는 13자리 상품 바코드이므로, 별도의 심볼로지를 찾을 것 없이 EAN-13으로 생성하면 된다.
사내용 라벨은 전부 실제로 고를 수 있고, 보통은 Code 128이 정답이다. ASCII 전체 범위를 인코딩하고, 세 가지 서브셋을 자동으로 전환해 폭을 줄이며, 요즘 물류 시스템이 기대하는 형식이다. Code 39는 인쇄와 디코딩이 워낙 간단해서 오래된 설비에 남아 있지만, 같은 폭에 담기는 문자 수가 적고 문자 집합이 43개에서 끝난다. Code 39는 수신 시스템이 요구할 때 고른다. 드롭다운 목록의 첫 항목이라는 이유로 고를 것이 아니다.
체크 디지트, 그리고 방향이 중요한 이유
EAN-13, EAN-8, UPC-A, ITF-14는 하나의 공식을 공유한다. 가장 오른쪽 데이터 자리에서 출발해 왼쪽으로 이동하면서 가중치 3과 1을 번갈아 적용한다. 가중치를 곱한 값을 모두 더한 뒤, 그 합에서 다음 10의 배수까지의 거리를 구한다.
check = (10 - (weighted sum mod 10)) mod 10
590123412345로 계산해 보자.
digit: 5 9 0 1 2 3 4 1 2 3 4 5
weight: 1 3 1 3 1 3 1 3 1 3 1 3
product: 5 27 0 3 2 9 4 3 2 9 4 15 → sum 83
check: (10 - 83 mod 10) mod 10 = 7
완성된 EAN-13은 5901234123457이다.
함정은 기준점이다. EAN-13을 설명하는 대부분의 자료는 이 규칙을 위치 기준으로 서술한다. 「왼쪽부터 세어서 홀수 자리는 가중치 1, 짝수 자리는 3」이라는 식이다. EAN-13에서는 이 서술이 맞다. 데이터 자릿수가 12로 짝수라서 두 서술이 같은 결과를 내기 때문이다. 네 심볼로지 중 페이로드 길이가 짝수인 것은 EAN-13뿐이다. UPC-A는 11자리, EAN-8은 7자리, ITF-14는 13자리다. 위치 기반 규칙을 이들에 그대로 옮기면 가중치가 엉뚱한 자리에 얹힌다.
이는 가끔 튀어나오는 예외 상황이 아니다. 해당 길이의 무작위 페이로드를 놓고 보면 위치 기반 규칙은 약 80% 확률로 정답과 어긋난다. 그리고 나머지 20%가 이 버그를 위험하게 만든다. 손으로 고른 테스트 벡터 하나가 우연히 통과할 가능성이 실제로 존재하기 때문이다.
| 데이터 | 심볼로지 | 우측 기준 | 위치 기반 규칙 |
|---|---|---|---|
9638507 | EAN-8 | 4 | 4 |
1234567 | EAN-8 | 0 | 8 |
03600029145 | UPC-A | 2 | 8 |
1540141253226 | ITF-14 | 4 | 2 |
9638507은 대부분의 EAN-8 문서에 등장하는 벡터이고, 두 구현이 같은 값을 내놓는다. 교과서 예제를 기준으로 만든 테스트 스위트는 버그를 그대로 배포한다.
// 가장 오른쪽 데이터 자리를 기준으로 삼는다.
// EAN-13, EAN-8, UPC-A, ITF-14에 모두 그대로 적용된다.
function checkDigit(data) {
let sum = 0;
for (let i = data.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) {
sum += Number(data[i]) * weight;
}
return (10 - (sum % 10)) % 10;
}
checkDigit('590123412345'); // 7 EAN-13
checkDigit('1234567'); // 0 EAN-8
checkDigit('03600029145'); // 2 UPC-A
홀수 길이 페이로드를 최소 하나는 테스트한다. 1234567과 03600029145는 둘 다 우측 기준 구현과 위치 기반 구현을 갈라놓지만, 흔히 쓰는 교과서 벡터는 그렇지 못하다.
UPC-A는 이름만 짧은 EAN-13이다
UPC-A 심볼은 앞에 0이 붙은 EAN-13이다. 036000291452는 0036000291452와 동일하게 인코딩되고, 0을 앞에 붙여도 체크 디지트는 바뀌지 않는다.
달라지는 것은 스캐너가 결과를 어떻게 보고하느냐이고, 이 차이는 기기가 아니라 플랫폼에서 갈린다. macOS와 iOS에서 시스템 디코더는 UPC-A를 13자리 ean_13 결과로 돌려준다. Apple의 Technical Note TN2325는 디코딩된 UPC-A가 앞에 0이 붙은 EAN-13 객체로 출력된다고 명시한다. Chromium의 BarcodeDetector도 이 플랫폼에서는 그 동작을 물려받는다. macOS에서 getSupportedFormats()를 호출하면 ean_13과 upc_e는 있지만 upc_a는 아예 없는 목록이 돌아온다. Android에서는 같은 API가 ML Kit 위에서 동작하는데, 여기서는 UPC-A가 독립된 포맷이고 12자리로 돌아온다.
같은 코드로 같은 라벨을 읽어도 한쪽 플랫폼에서는 13자리, 다른 쪽에서는 12자리를 받는다는 뜻이다. 상품을 12자리 UPC로 저장해 두었다면 macOS에서 모든 조회가 빗나가고, 0을 채워 저장하면 이번엔 Android 경로가 빗나간다. 경계에서 정규화한다. 내부 표현을 하나로 정하고, 받은 길이를 그대로 믿는 대신 모든 스캔 결과를 매칭 전에 그 표현으로 변환한다.
같은 관계가 UPC-E도 설명해 준다. 가끔 UPC-E를 요청받게 되는데, 이는 별도의 넘버링 체계가 아니라 전체 심볼을 담기 어려운 작은 포장을 위해 UPC-A를 제로 서프레션 방식으로 인쇄한 형태다. 대부분의 스캐너는 이를 다시 12자리 UPC-A로 확장하도록 설정할 수 있다. Zebra 장비에서는 Convert UPC-E to UPC-A 파라미터가 그 역할을 한다. 다만 이는 설정이지 보장이 아니므로, 가정하지 말고 확인한다. 시스템이 상품 코드를 요구한다면 UPC-A를 생성한다.
Code 128이 폭을 줄이는 방식
Code 128에는 세 가지 문자 서브셋이 있다. A는 제어 문자와 대문자를, B는 소문자를 포함한 출력 가능 ASCII 범위를, C는 숫자 두 자리씩을 인코딩한다. 심볼 하나에 숫자 하나가 아니라 둘이 들어간다.
이 짝짓기가 Code 128이 긴 숫자 페이로드를 잘 처리하는 이유이자, 심볼 길이가 입력 길이에 비례하지 않는 이유다. 20자리 시리얼 번호는 서브셋 C에서 심볼 10개면 된다. 같은 20자리가 서브셋 B에서는 20개를 쓴다. C로 전환하지 않는 인코더는 필요한 것보다 대략 두 배 넓은 심볼을 만들어낸다.
전환에는 심볼 하나가 들기 때문에, 일정 기준을 넘어야 이득이 된다. ISO/IEC 15417 Annex E의 최소화 규칙을 정리하면 대략 이렇다. 데이터 앞이나 뒤에서는 숫자 4개 이상이면 전환할 만하다. 시작 심볼이나 페이로드의 끝이 전환 비용을 흡수하기 때문이다. 중간에서는 6개 이상이어야 한다. 서브셋 C로 들어가는 전환 심볼 하나와 빠져나오는 심볼 하나를 함께 써야 하기 때문이다. 그보다 적으면 쓰고 있던 서브셋에 머무는 쪽이 싸다. 구현마다 이 최적화를 어디까지 밀어붙이는지는 다르다. JsBarcode는 고정 임계값을 적용하고, zint는 임계값을 쓰지 않고 최소 비용 인코딩을 탐색한다. 그래서 규격을 지키는 두 인코더가 같은 입력에 대해 서로 다른 심볼 개수를 낼 수 있다. 디코딩 결과 문자열은 같다.
시작 심볼 이후의 모든 것이 modulo-103 체크 심볼에 기여한다.
checksum = (start value + Σ(symbol value × position)) mod 103
위치는 첫 데이터 심볼이 1부터 시작하고, 시작 심볼은 자기 값을 한 번 더한다. 서브셋 전환은 평범한 심볼이라 다른 심볼과 똑같이 계산에 들어간다. 체크 심볼은 바 안에 묻혀 있고 사람이 읽는 텍스트에는 절대 나타나지 않는다. Code 128 라벨이 인코딩한 내용만 정확히 보여주면서도 눈에 보이지 않는 심볼을 하나 더 들고 다니는 이유다.
GS1-128이 필요한 경우
배송 라벨에는 필드가 여러 개 들어가는 일이 많다. GTIN, 배치 번호, 유통기한, 시리얼 같은 것들이다. GS1-128은 이들을 하나의 Code 128 심볼에 담기 위한 규약이다. 별도의 심볼로지가 아니라 Code 128에 데이터 구조를 더한 것이다.
각 필드는 애플리케이션 식별자(AI)로 시작한다. 뒤에 무엇이 오고 길이가 얼마인지를 선언하는 2~4자리 접두사다. (01)은 GTIN-14, (17)은 YYMMDD 형식의 유통기한, (10)은 배치 코드다.
(01)09501101020917(17)260801(10)ABC123
괄호는 사람이 읽기 위한 표기 규약이고 절대 인코딩되지 않는다. 바 안에서 구조를 표시하는 것은 FNC1이다. 데이터가 GS1 형식임을 선언하기 위해 첫 심볼로 한 번, 그리고 마지막이 아닌 가변 길이 필드를 끝내기 위해 다시 한 번 들어간다. (01)이나 (17) 같은 고정 길이 AI는 식별자가 길이를 함축하므로 종료 표시가 필요 없고, (10) 같은 가변 길이 AI는 필요하다.
일반 Code 128 인코더와 GS1-128 인코더가 갈라지는 지점이 여기다. 위 문자열을 일반 인코더에 넣으면 괄호를 문자 그대로 인코딩하고 FNC1은 넣지 않는다. 심볼은 스캔되고 괄호가 들어간 문자열을 돌려주며, GS1을 이해하는 시스템은 이를 거부한다. ZeroTool 생성기는 일반 Code 128 인코더이고 입력을 문자 그대로 다룬다. AI 문법을 파싱하지 않는 도구로서는 이것이 정직한 동작이다. 진짜 GS1-128이 필요하다면 애플리케이션 식별자를 이해하는 인코더를 쓴다. zint --barcode=16 --data="[01]09501101020917[17]260801[10]ABC123"이 FNC1 배치를 처리하고, GS1이 직접 제공하는 Digital Link 및 문법 자료에 식별자 목록이 정리되어 있다.
페이로드가 정말로 구조화되어 있고 읽는 하드웨어가 2차원 코드를 지원한다면 QR 코드나 GS1 DataMatrix를 고려한다. 필드 네 개를 담느라 늘어난 선형 심볼은 대개 데이터가 그 포맷을 넘어섰다는 신호다.
코드 리뷰를 통과해 살아남는 실패 유형
제대로 인코딩하는 것은 쉬운 쪽 절반이다. 아래는 모든 테스트를 통과하고 물류 하역장에서 실패하는 것들이다.
여백이 잘려 나간다. 모든 1차원 심볼로지는 양쪽에 빈 여백(quiet zone)이 필요하고, 스캐너는 이 여백으로 심볼이 어디서 시작하는지 찾는다. 필요한 폭은 심볼로지별로 모듈 단위로 규정되어 있으며 좌우 대칭이 아니다. EAN-13은 왼쪽 11 모듈, 오른쪽 7 모듈이 필요하고, EAN-8은 양쪽 각각 7, UPC-A는 9, Code 128은 최소 10이다. 디자이너는 여백을 보고 레이아웃을 조이고, 스크립트는 이미지를 바운딩 박스에 맞춰 잘라낸다. 바는 온전한데 심볼은 읽히지 않는다. 여백을 포함해 내보내고, 내용에 맞춰 크롭하지 않는다.
누군가 브랜드 색에 맞춘다. 디코딩은 바와 배경의 대비에 의존한다. 베이지 라벨 위의 중간 회색 바, 검정 위의 짙은 파랑, 어두운 배경에 흰 바를 반전시킨 심볼은 검정-흰색 교정본을 완벽하게 읽던 하드웨어에서 실패한다. 빨강은 특수한 경우다. 많은 레이저 스캐너가 빨간색 광원을 쓰기 때문에 흰 배경 위의 빨간 바는 아예 사라질 수 있다. 밝은 배경에 어두운 바를 유지한다.
가장 좁은 바가 프린터가 찍을 수 있는 두께보다 얇다. 벡터 공간에서 유효한 모듈 폭이 203 dpi 감열 프린터에서는 번진 회색 선이 된다. 감열 방식과 잉크젯은 잉크가 살짝 퍼지면서 바를 두껍게, 공백을 좁게 만든다. 실제 생산 크기로, 실제 생산 용지에 테스트 라벨을 뽑아 실제로 쓸 하드웨어로 스캔한다. 휴대폰 카메라는 고정형 레이저 스캐너보다 훨씬 관대하므로 기준이 되지 못한다.
페이로드가 스캐너 시야를 벗어난다. 가변 길이 심볼로지에는 상한이 없어서 300자짜리 문자열을 인코딩하는 것도 막히지 않는다. 결과물은 구조적으로 유효하고 물리적으로 읽을 수 없다. 핸디 스캐너는 창에 들어오는 만큼만 디코딩하기 때문이다. Code 128은 약 48자, Code 39와 Codabar는 약 43자 아래로 유지한다. 이 둘은 밀도가 낮아 쓸 수 있는 폭이 더 빨리 바닥난다. 긴 페이로드는 2차원 심볼로지의 몫이다.
ITF-14가 베어러 바를 잃는다. Interleaved 2 of 5는 부분 스캔에 취약하다. 심볼의 일부만 훑고 지나가면 더 짧고 잘못된 숫자로 디코딩될 수 있다. ITF-14 위아래의 굵은 가로 선은 이를 막기 위해 존재하며, 장식이 아니라 규격의 일부다. 라벨 공간을 아끼려고 이를 빼면 상자가 다른 GTIN으로 읽힌다.
Code 39 체크 문자를 두 시스템이 다르게 본다. Code 39의 modulo-43 체크 문자는 선택 사항이다. 이를 기대하지 않는 스캐너는 체크 문자를 데이터의 마지막 문자로 보고하고, 이를 요구하는 시스템은 체크 문자가 없는 심볼을 거부한다. 활성화하기 전에 수신 시스템이 어느 쪽을 원하는지 확인하고, 이 문자가 사람이 읽는 텍스트에도 나타난다는 점을 염두에 둔다.
인쇄 전 검증
가장 강력한 검사는 왕복이다. 페이로드를 인코딩하고, 래스터화하고, 독립적인 구현으로 디코딩한다. 돌아온 값이 넣은 값과 정확히 같지 않다면 겉보기가 어떻든 심볼이 잘못된 것이다.
최신 Chromium 브라우저는 디코더를 기본 제공한다.
// 렌더링된 바코드를 브라우저 자체 스캐너로 디코딩한다.
const detector = new BarcodeDetector();
const results = await detector.detect(canvas);
console.log(results.map(r => `${r.format}:${r.rawValue}`));
// → ["ean_13:5901234123457"]
이것이 형식적 절차가 아니라 실제 테스트가 되려면 두 가지가 필요하다. 좁은 바가 샘플링에서 살아남도록 표시 크기의 몇 배로 래스터화하고, 디코딩된 문자열을 체크 디지트를 포함해서 페이로드와 비교한다. 데이터 부분만 비교하면 가장 중요한 버그가 그대로 숨는다.
배치 작업이라면 브라우저 밖에서 검증한다. zint가 렌더링하고 zbarimg가 디코딩한다.
# EAN-13 라벨을 일괄 생성한 뒤,
# 각각이 원래 번호로 디코딩되는지 검증한다.
while read -r gtin; do
zint -b EAN13 --data="$gtin" --output="labels/$gtin.png" --height=60
decoded=$(zbarimg --quiet --raw "labels/$gtin.png")
[ "$decoded" = "$gtin" ] || echo "MISMATCH: $gtin -> $decoded"
done < gtins.txt
도구에 관한 두 가지 참고 사항이 있다. zint에서는 숫자 ID 대신 심볼로지 이름을 쓴다. -b EAN13은 안정적이지만, 예전의 숫자 13은 최신 릴리스가 하위 호환을 위해서만 남겨둔 레거시 EANX 타입을 가리킨다. 그리고 zbarimg --raw는 디코딩된 값만 출력하는데, 위의 문자열 비교가 안전한 이유가 바로 이것이다. 사람이 읽는 블록을 출력하는 디코더는 비교하기 전에 파싱이 필요하다.
Python에서는 생성기를 지키는 체크 디지트 로직이 데이터 임포트도 지켜야 한다.
def check_digit(data: str) -> int:
"""GS1 modulo-10, 가장 오른쪽 데이터 자리 기준."""
total = 0
for i, char in enumerate(reversed(data)):
total += int(char) * (3 if i % 2 == 0 else 1)
return (10 - total % 10) % 10
def is_valid_gtin(code: str) -> bool:
# code[:-1]이 아니라 문자열 전체를 검사한다. 그러지 않으면 끝에 붙은
# 비숫자 문자가 검사를 빠져나가고, int()가 False를 반환하는 대신
# 예외를 던진다.
return code.isdigit() and int(code[-1]) == check_digit(code[:-1])
assert is_valid_gtin('5901234123457')
assert not is_valid_gtin('5901234123456')
assert not is_valid_gtin('590123412345X')
임포트 시점에 GTIN을 검증하면 전사 오류를 비용이 싼 단계에서 잡아낸다. 스프레드시트의 잘못된 숫자 하나는 고치는 데 1분이 들지만, 같은 숫자가 상자 1만 개에 인쇄되면 비용은 비교가 되지 않는다.
ZeroTool 생성기가 다르게 하는 것
대부분의 온라인 바코드 생성기는 입력을 서버로 업로드해 이미지를 렌더링한다. 공개된 상품 GTIN이라면 문제가 되지 않는다. 하지만 미출시 SKU, 사내 자산 태그, 시리얼 번호라면 기술적 이유 없이 데이터가 기기 밖으로 나가는 셈이다. 인코딩은 룩업 테이블과 약간의 산술이고, 브라우저에서 처리하면 될 일이다.
ZeroTool 바코드 생성기는 전부 클라이언트 사이드에서 인코딩한다. 체크 디지트를 뺀 데이터만 입력하면 체크 디지트를 계산해 붙이고, 완성된 코드를 붙여 넣으면 반대로 그 값을 검증해 어긋날 경우 올바른 값을 알려준다. 바 색상은 의도적으로 설정할 수 없게 두었다. 대비가 낮은 색 조합이 하드웨어에서 실패하는 라벨의 가장 흔한 원인이고, 선택지를 없애면 실패도 사라진다. 가변 길이 페이로드가 핸디 스캐너가 한 번에 읽는 범위를 넘어서면, 쓸 수 없는 심볼을 조용히 만들어내는 대신 상태 표시줄이 그 사실을 알려준다.
내보내기는 SVG와 PNG를 지원한다. 인쇄용이나 크기가 바뀔 용도라면 SVG를 권한다. 어떤 배율에서도 바 경계가 정확하게 유지되는데, 스캐너가 신경 쓰는 것이 정확히 그 부분이다. PNG는 좁은 모듈이 살아남도록 화면 크기의 4배로 래스터화한다.
일회성 라벨이 아니라 파이프라인이 필요하다면 라이브러리를 쓴다. 커맨드라인에서는 zint, Python에서는 python-barcode, Node에서는 JsBarcode다. 생성기는 의존성을 하나 추가하는 비용이 라벨 자체보다 비싼 경우를 위한 것이다.
더 읽을거리
ZeroTool의 관련 도구:
규격과 참고 자료:
- GS1 General Specifications — GTIN 구조, 체크 디지트, 인쇄 품질에 관한 표준 문서
- ISO/IEC 15417 — Code 128 심볼로지 규격
- ISO/IEC 15420 — EAN/UPC 심볼로지 규격
- MDN: BarcodeDetector — 위에서 사용한 브라우저 디코딩 API