앱스토어 스크린샷 현지화, 네모(□) 글자를 렌더 전에 잡는 글리프 검사 게이트
폰트에 없는 글자가 네모(□)로 찍히는 사고를 렌더 전에 cmap 조회로 막고, 잡 상태와 HTTP 422 응답으로 사람에게 드러내는 백엔드 게이트 설계예요.
독일어 스크린샷을 찍어 올렸는데 "ß"가 네모(□)로 찍힌 적이 있어요. 캔버스는 에러를 안 내요. 폰트에 그 글자가 없으면 대체 글리프를 그리고 조용히 넘어가거든요. 그래서 잡은 완료, 업로드도 완료, 스토어에는 네모가 올라가 있죠. 이 글은 그 사고를 렌더 전에 서버에서 막는 게이트 얘기예요.
네모는 에러가 아니라 "성공"이에요
OpenType 폰트에는 cmap이라는 표가 있어요. 유니코드 코드 포인트를 글리프 번호로 바꿔주는 표인데, 여기 없는 글자는 글리프 0번으로 떨어져요. 0번이 바로 .notdef, 흔히 네모로 그려지는 그 글리프예요. 렌더러 입장에선 그릴 게 있는 거라 실패가 아니에요. 로그에도 안 남고요. 그러니 이미지를 뽑고 나서 픽셀을 보는 대신, 글자를 그리기 전에 표를 먼저 조회하는 게 맞아요.
검사 단계를 잡 앞에 끼워 넣었어요
LocaleShots 워커는 대기 → 렌더링 → 업로드 → 완료로 흘러가요. 여기에 "검사" 한 단계를 렌더링 앞에 넣었어요. 하는 일은 단순해요. 언어별 문구를 코드 포인트 단위로 돌면서, 지정 폰트와 대체 폰트 목록 어디에도 글리프가 없으면 그 코드 포인트를 모아요. 하나라도 남으면 잡을 blocked_missing_glyph 상태로 보내고 렌더는 아예 안 해요.
직접 만들어 보니 핵심은 두 줄이에요.
for (const ch of text) { // code point iteration, surrogate-pair safe
const cp = ch.codePointAt(0)!;
if (!fonts.some((f) => f.hasGlyphForCodePoint(cp))) missing.push(cp);
}for...of로 돌면 이모지처럼 코드 유닛 2개짜리 글자도 한 글자로 잡혀요. 폰트 파일 파싱은 워커가 뜰 때 한 번만 하고 파일 해시로 메모해 두면, 문구 1건 검사는 체감이 안 될 만큼 가벼워요.
오탐부터 잡아야 게이트가 살아남아요
처음 돌려 보니 멀쩡한 문구가 줄줄이 막혔어요. 범인은 변형 선택자(U+FE0F)와 ZWJ(U+200D)였어요. 이 둘은 글리프가 없어도 정상이에요. 그래서 제어 문자, 공백, 변형 선택자, 결합용 접합자는 검사 대상에서 빼는 허용목록을 뒀어요. 반대로 진짜 누락은 코드 포인트 그대로 남겨서 사람이 어떤 글자인지 바로 보게 했어요. 게이트가 자주 거짓 경보를 울리면 결국 아무도 안 믿게 되거든요.
DB와 API에 어떻게 드러내나
잡 테이블에 컬럼 세 개를 더했어요. status, missing_codepoints(jsonb), 그리고 font_set_hash예요. 폰트 세트가 바뀌면 해시가 바뀌니 같은 문구도 다시 검사되고, 안 바뀌었으면 같은 키로 멱등하게 건너뛰어요. 요청 API는 검사 실패를 HTTP 422로 돌려주고 본문에 언어, 화면, 누락 코드 포인트 목록을 실어요. 500으로 뭉개면 재시도 큐가 같은 입력을 또 돌리거든요. 사람이 고쳐야 하는 실패는 422, 네트워크 같은 일시 실패만 재시도 대상이에요.
- cmap 선조회
- 코드 포인트 단위 검사
- 허용목록으로 오탐 제거
- 422로 사람에게 반환
적용해 봤더니
언어 5개에 화면 6장이면 한 번에 30건이 큐에 들어가요. 테스트해 보면 폰트에 없는 글자가 섞인 언어만 blocked로 빠지고 나머지는 그대로 완료까지 가요. 아침에 blocked 목록만 보면 되니 검수가 10분이면 끝나고요. 라이프케어로그에서 플랜비(plan-b)와 플랜씨(plan-c) 스토어 이미지를 찍을 때도 이 게이트가 먼저 돌아요. 앱스토어는 기기당 스크린샷을 최대 10장까지 받는데, 그중 한 장만 네모여도 첫인상이 깨지니까요.
픽셀을 검사하는 게 아니라 폰트 표를 먼저 조회하는 것. 그 순서 하나로 "성공인데 깨진" 산출물이 사라졌어요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.