기계 판독 영역(MRZ)의 모든 날짜는 여섯 자리입니다. 연도 두 자리, 월 두 자리, 일 두 자리이며 세기는 없습니다. ICAO가 공개한 견본 여권에는 생년월일이 740812로 적혀 있지만, 그것이 1974년인지 2074년인지 알려 주는 정보는 영역 안에 없습니다. 1974년이라는 것을 아는 이유는 태어나기 전에 여권을 가진 사람은 없기 때문이며, 이 한 가지 추론이 규칙의 전부입니다. 다만 그 추론이 중요해지는 바로 그날까지 포함해 정확하게 처리하려면, 고정된 기준 연도보다 조금 더 신경을 써야 합니다.
이 글에서는 생년월일과 만료일 각각의 규칙, 단순한 파서를 깨뜨리는 경계 사례, 그리고 Python과 JavaScript로 작성한 함수와 자신의 테스트 스위트에 그대로 넣을 수 있는 테스트 벡터 표를 소개합니다. 모든 예시는 ICAO의 가상 견본이나 합성한 값을 사용하며, 실제 문서는 등장하지 않습니다.
이 글은 MRZ를 대신 읽어 주는 여권·신분증 OCR API, doc.cheap의 블로그입니다. 아래 내용에는 doc.cheap이 필요하지 않으며, 코드에는 의존성도 없습니다.
날짜의 위치
기계 판독 여행 문서의 표준인 ICAO Doc 9303은 각 형식에 두 개의 날짜 필드를 둡니다. 생년월일과 만료일이며, 둘 다 YYMMDD로 적히고 각각 바로 뒤에 자체 체크 디지트가 붙습니다. 위치는 0부터 시작하므로 그대로 slice에 넘길 수 있습니다.
| 형식 | 생년월일 | 만료일 |
|---|---|---|
| TD3(여권), 2행 | 13–18, 체크 디지트는 19 | 21–26, 체크 디지트는 27 |
| TD2, 2행 | 13–18, 체크 디지트는 19 | 21–26, 체크 디지트는 27 |
| TD1(신분증), 2행 | 0–5, 체크 디지트는 6 | 8–13, 체크 디지트는 14 |
TD3 견본 행 L898902C36UTO7408122F3404159ZE184226B<<<<<16에서는 생년월일이 740812, 만료일이 340415입니다.
고정된 기준 연도가 깨지는 이유
흔히 쓰는 지름길은 피벗입니다. 두 자리 연도가 어떤 수보다 작으면 2000년대로, 나머지는 1900년대로 봅니다. Python의 time.strptime도 %y를 이렇게 처리합니다. 문서에는 이렇게 적혀 있습니다. "When 2-digit years are parsed, they are converted according to the POSIX and ISO C standards: values 69–99 are mapped to 1969–1999, and values 0–68 are mapped to 2000–2068."
로그 파일이라면 합리적인 기본값입니다. 하지만 생년월일에는 양쪽 방향으로 모두 틀립니다. 이 규칙대로라면 1968년 4월 12일(680412)에 태어난 사람은 2068년생이 됩니다. 피벗을 옮겨도 버그가 옮겨 갈 뿐입니다. 어떤 고정된 숫자든 누군가에게는 틀리고, 코드가 운영 환경에 오래 남을수록 해마다 더 많이 틀립니다. 정답은 오늘 날짜에 따라 달라지므로, 규칙은 오늘 날짜를 입력으로 받아야 합니다.
생년월일: 미래가 아닌 가장 최근의 세기
연도를 2000 + YY로 읽습니다. 그렇게 만든 전체 날짜가 오늘보다 뒤라면 100년을 뺍니다. 그게 전부입니다.
구현이 실제로 올바른지는 다음 세 가지가 결정합니다.
- 연도만이 아니라 날짜 전체를 비교할 것. 아래 예시에서 오늘은 2026년 9월 24일입니다.
261224를 2000년대로 읽으면 2026년 12월 24일로 석 달 뒤이므로, 그 사람은 1926년 12월 24일에 태어났습니다. 연도26과26만 비교하면 신생아로 판단하게 됩니다. - 오늘은 과거로 칠 것. 오늘 태어난 사람은 지난 세기가 아니라 이번 세기에 태어났습니다.
260924는 2026년 9월 24일로 남고, 하루 뒤인260925는 1926년으로 돌아갑니다. "이후이거나 같음"이 아니라 "이후"를 쓰십시오. - "오늘"은 UTC 또는 직접 정한 하나의 고정 시간대로 구해서 인자로 넘길 것. 스스로 시계를 읽는 함수는 테스트할 수 없고, 한 시간대의 서버와 다른 시간대의 브라우저는 매일 일정 시간 동안 날짜가 서로 다릅니다.
만료일: 이번 세기
만료일은 2000 + YY로 읽고 그대로 둡니다. 2012년에 만료된 여권은 2112년이 아니라 여전히 2012년이고, 2032년에 만료되는 여권은 2032년입니다. 1900년대로 읽는 것이 맞는 경우는 2000년 이전에 만료된 문서뿐이며, 그런 문서는 더 이상 쓰이지 않습니다. 이 규칙은 2090년대까지 유효합니다. 그때 발급된 문서는 2100년대에 만료될 수 있으므로 그 전에 코드를 다시 살펴봐야 하지만, 지금은 아닙니다.
테스트할 가치가 있는 경계 사례
세기를 넘나드는 2월 29일. 먼저 세기를 정하고, 그다음에 날짜가 존재하는지 확인합니다. 생년월일 280229는 2000년대로 읽으면 오늘보다 뒤이므로 1928년 2월 29일이 되며, 1928년은 윤년이므로 존재하는 날짜입니다. 000229는 2000년 2월 29일이고, 2000년은 윤년이므로 존재합니다. 윤년이 아니었던 1900년이라면 같은 여섯 자리는 아예 날짜가 되지 않습니다. 00은 세기에 따라 "윤년인가?"의 답이 달라지는 유일한 두 자리 연도입니다. 세기를 정하기 전에 날짜를 검증하면 이 중 하나가 틀립니다.
올해 아직 오지 않은 생년월일. 위에서 다뤘듯이 261224는 2026년이 아니라 1926년이며, 테스트에서는 고정된 "오늘"로 이를 확인해야 합니다.
날짜 안의 채움 문자. 날짜의 일부를 알 수 없는 경우 생년월일에 <가 들어갈 수 있습니다. 예를 들어 94<<08입니다. 돌려줄 달력상의 날짜가 없습니다. 아래 함수는 여섯 자리 숫자가 아닌 값에는 아무것도 반환하지 않고, 원본 텍스트를 보여 주는 일은 호출하는 쪽에 맡깁니다.
불가능한 날짜인데도 통과하는 체크 디지트. 체크 디지트가 보호하는 것은 문자이지 달력이 아닙니다. 가중치 7, 3, 1로 계산하면 741312의 합은 88이므로 체크 디지트는 8이고, 7413128은 검사를 통과합니다. 13월은 존재하지 않습니다. 체크 디지트와 날짜는 서로 다른 두 가지 검사이며 둘 다 통과해야 합니다. 앞의 검사는 MRZ 체크 디지트는 어떻게 동작하는가에서 자세히 다룹니다.
100세가 넘는 사람. 1925년에 태어난 사람과 2025년에 태어난 아기는 같은 여섯 자리를 가집니다. 영역만으로는 둘을 구별할 수 없고, 위 규칙은 더 젊은 쪽으로 읽습니다. 사용자 중에 100세 이상인 사람이 있다면 데이터 페이지에 인쇄된 날짜와 비교하십시오. 문서가 네 자리 연도를 인쇄한다면 바로 그곳에 있습니다.
Python 함수
의존성은 없습니다. today는 호출하는 쪽에서 넘기는 datetime.date입니다.
import re
from datetime import date, datetime, timezone
SIX_DIGITS = re.compile(r"[0-9]{6}")
def read_mrz_date(raw, kind, today):
"""Turn an MRZ YYMMDD field into a date, or None if it is not one.
kind is "birth" or "expiry"; today is a datetime.date.
"""
if kind not in ("birth", "expiry"):
raise ValueError(f"unknown kind: {kind!r}")
if not SIX_DIGITS.fullmatch(raw):
return None # fillers, letters or the wrong length
yy, mm, dd = int(raw[0:2]), int(raw[2:4]), int(raw[4:6])
year = 2000 + yy
if kind == "birth" and (year, mm, dd) > (today.year, today.month, today.day):
year -= 100 # the 2000s reading is in the future
try:
return date(year, mm, dd) # checked after the century is chosen
except ValueError:
return None # 13th month, 31 April, 29 February in a common year
# In production:
# read_mrz_date("740812", "birth", datetime.now(timezone.utc).date())
패턴을 \d나 str.isdigit()가 아니라 [0-9]로 쓴 것은 의도한 것입니다. 앞의 둘은 다른 문자 체계의 숫자도 받아들이지만, MRZ에는 ASCII 숫자만 들어갑니다. 비교에 튜플을 쓰기 때문에, 존재하지 않는 월도 date()가 거부하기 전에 문제없이 비교됩니다.
JavaScript로 작성한 같은 함수
today는 YYYY-MM-DD 형식의 문자열입니다. 이런 문자열 두 개는 일반 문자열로 비교해도 올바르게 비교되므로, 세기 규칙이 한 줄로 끝납니다.
export function readMrzDate(raw, kind, today /* "YYYY-MM-DD", UTC */) {
if (kind !== "birth" && kind !== "expiry") throw new Error(`unknown kind: ${kind}`);
if (!/^[0-9]{6}$/.test(raw)) return null; // fillers, letters or the wrong length
const month = Number(raw.slice(2, 4));
const day = Number(raw.slice(4, 6));
let year = 2000 + Number(raw.slice(0, 2));
const iso = (y) => `${y}-${raw.slice(2, 4)}-${raw.slice(4, 6)}`;
if (kind === "birth" && iso(year) > today) year -= 100; // the 2000s reading is in the future
// Date.UTC rolls 31 April over to 1 May; a changed month or day means no such date.
const d = new Date(Date.UTC(year, month - 1, day));
if (month < 1 || month > 12 || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
return null;
}
return iso(year);
}
// In production:
// readMrzDate("740812", "birth", new Date().toISOString().slice(0, 10));
toISOString()은 항상 UTC이므로 today를 구하는 안전한 방법입니다.
테스트 벡터
아래의 모든 행은 오늘을 2026년 9월 24일로 고정하고 규칙에 따라 손으로 계산한 것입니다. 두 함수 모두 마지막 열의 값을 반환해야 합니다("날짜 없음"은 Python에서 None, JavaScript에서 null).
| 입력 | 종류 | 기댓값 | 이유 |
|---|---|---|---|
740812 |
birth | 1974-08-12 | ICAO 견본 |
340415 |
expiry | 2034-04-15 | ICAO 견본 |
120415 |
expiry | 2012-04-15 | 만료되었지만 여전히 2012년 |
940308 |
birth | 1994-03-08 | 2094년은 미래 |
150101 |
birth | 2015-01-01 | 2015년은 과거 |
300101 |
birth | 1930-01-01 | 2030년은 미래 |
261224 |
birth | 1926-12-24 | 올해 아직 오지 않았으므로 지난 세기 |
260925 |
birth | 1926-09-25 | 내일이므로 지난 세기 |
260924 |
birth | 2026-09-24 | 오늘은 과거로 침 |
260923 |
birth | 2026-09-23 | 어제 |
261224 |
expiry | 2026-12-24 | 만료일은 2000년대에 머묾 |
320310 |
expiry | 2032-03-10 | 만료일은 2000년대에 머묾 |
280229 |
birth | 1928-02-29 | 1928년은 윤년 |
000229 |
birth | 2000-02-29 | 2000년은 윤년, 1900년은 아니었음 |
270229 |
birth | 없음 | 1927년은 윤년이 아님 |
230229 |
expiry | 없음 | 2023년은 윤년이 아님 |
240229 |
expiry | 2024-02-29 | 2024년은 윤년 |
941308 |
birth | 없음 | 13월은 없음 |
94<<08 |
birth | 없음 | 채움 문자이며 날짜가 아님 |
Python 버전의 테스트 러너는 today = date(2026, 9, 24)로 두고 이 행들을 반복하면서 result.isoformat()(또는 None)을 기댓값 열과 비교하면 됩니다. 테스트에서는 "오늘"을 고정하십시오. 실제 시계를 쓰면 261224 같은 행은 12월 25일에 답이 바뀝니다.
우리 MRZ 파서의 동작
이 사이트의 MRZ 파서도 같은 규칙을 따릅니다. 연도는 2000 + YY에서 시작하고, 전체 날짜가 오늘(UTC)보다 뒤인 생년월일은 100년 전으로 옮기며, 달력 검사는 세기를 정한 뒤에 하고, 만료일은 2000년대에 둡니다. 자체 테스트는 올해 아직 오지 않은 날짜, 오늘, 그리고 세기 양쪽의 2월 29일을 다룹니다. 날짜로 바꿀 수 없는 값은 대신 원본 텍스트를 보여 주며, 영역에 채움 문자가 있는 곳은 ?로 표시합니다. 여섯 자리가 체크 디지트를 통과했지만 달력상의 날짜가 아니면 경고로 알려 줍니다. 만료일은 UTC 기준 오늘 날짜에 대해 만료 여부가 표시됩니다. 브라우저에서 동작하므로 합성한 영역을 붙여 넣고 그 결과를 자신의 구현과 비교할 수 있습니다.
파이프라인에서의 위치
호스팅형 인식 서비스를 쓴다면 날짜 필드의 세기는 이미 정해져 있습니다. 문제는 그 규칙이 원하는 규칙인가 하는 것입니다. doc.cheap 응답은 holder.birth_date와 document.expiry_date를 ISO YYYY-MM-DD로 반환하고, 그 옆에 읽은 그대로의 영역을 mrz.lines로 반환합니다. 그래서 날짜가 접근 권한이나 돈을 좌우할 때는 여섯 자리를 직접 잘라 내어 위의 함수를 적용할 수 있습니다. 아직 서비스를 고르는 중이라면 여권 OCR API 비교에서 공개된 가격을 나란히 볼 수 있습니다.
규칙이 틀리는 입력을 찾으면 admin@doc.cheap으로 알려 주십시오.