AX 노트 · 측정

안드로이드에서 한글 일부 글자만 빈칸: 나눔스퀘어 cmap 11,172자 중 8,693자가 빈 글자였다

폰트의 cmap이 글자를 가진 척하면 Android는 시스템 폰트로 넘기지 않고 빈칸을 그립니다. 외곽선 없는 항목을 cmap에서 지워 고쳤습니다.

작성 SugarMount Android 개발 읽는 시간 11분 최종 업데이트

채팅 말풍선의 '밣'이 빈칸으로 나왔습니다. 2026년 9월 15일 밤 운영자가 한글 띄어쓰기 앱에서 발견한 증상입니다. 원인은 앱에 넣은 나눔스퀘어 Light 폰트였습니다. cmap(문자 코드와 글리프 번호를 잇는 표)은 한글 11,172자를 모두 가졌다고 했지만 그중 8,693자는 외곽선이 0바이트였습니다. 외곽선 없는 항목 13,948개를 cmap에서 지워 고쳤고(커밋 f27f36a), 버전 1.6.017에 실렸습니다.

근거는 SugarMount 앱 저장소(sugarspeller)의 커밋 f27f36a, CHANGELOG 1.6.017, 작업 세션 기록입니다. 숫자는 2026년 10월 22일 수정 전후 폰트를 다시 측정한 값으로, 커밋의 값과 일치합니다.

목차 (10)
  1. '밣'의 글리프는 0바이트, 폭은 910이었습니다
  2. 다시 측정한 숫자: 외곽선 있는 한글은 2,479자뿐이었습니다
  3. 말풍선만 빈칸이었던 건 TextView였기 때문입니다
  4. AI의 첫 측정은 '밣이 있다'였습니다
  5. 폰트는 처음부터 이런 구조였을 가능성이 높습니다
  6. 폰트 교체와 폴백 체인 대신 cmap만 정리했습니다
  7. 이 점검이 통하지 않는 경우
  8. 내 앱 폰트를 점검하는 절차
  9. 남은 일
  10. 자주 묻는 질문

'밣'의 글리프는 0바이트, 폭은 910이었습니다

Android의 글자 배치 엔진(Minikin)은 글자마다 맡을 폰트를 cmap으로 정합니다. 외곽선이 비었는지는 보지 않습니다. '밣'(U+BC23)으로 따라가면 사슬은 네 단계입니다.

  1. cmap이 U+BC23을 글리프 12552번에 잇습니다.
  2. loca 표(글리프 데이터 위치표)에서 12552번의 데이터 길이는 0입니다.
  3. hmtx 표의 가로 폭은 910으로 정상 글자 '받'과 같고, 빈 한글 8,693자가 모두 910입니다. 그래서 두부 상자(없는 글자 표시)가 아닌 한 칸짜리 빈칸이 생겼습니다.
  4. 앱 폰트가 이미 '있다'고 답했으니 시스템 폰트로 넘어가는 대체(폴백)는 일어나지 않습니다.

cmap에서 그 항목만 빼면 Android가 시스템 폰트에서 글자를 찾아 그립니다(그림 1). cmap 항목은 17,559개에서 3,611개가 됐습니다.

비교 그림: 고치기 전 cmap 17,559개에 한글 11,172자가 있었지만 8,693자는 외곽선이 없어 빈칸이 그려졌고, 외곽선 없는 항목을 지운 뒤 cmap 3,611개만 남아 빠진 글자는 시스템 폰트가 그렸다.
외곽선 없는 항목을 cmap에서 빼자 Android가 시스템 폰트로 넘겨 그렸습니다.

다시 측정한 숫자: 외곽선 있는 한글은 2,479자뿐이었습니다

fontTools 없이 파이썬 struct로 표를 직접 읽은 값입니다.

항목수정 전수정 후
cmap 속 한글 음절11,1722,479
그중 외곽선 0바이트8,6930
외곽선 없는 보이는 문자(공백·제어 제외)13,9480
외곽선 있는 문자3,5753,575
글리프 수18,15518,155
파일 크기(바이트)750,932715,448

13,948개는 한글 음절 8,693자, 한자 4,890자, 기타 365자였습니다. 기타에는 불릿 '•'와 화살표가 있었고, 한자는 한 글자도 그려지지 않았던 셈입니다. 남은 한글 2,479자는 KS X 1001 완성형 2,350자 전부에 129자를 더한 것인데, 그 129자에 '뷁'이 들어 있습니다. KS X 1001 밖 글자라고 '뷁' 하나만 넣어 봤다면 통과했을 겁니다.

말풍선만 빈칸이었던 건 TextView였기 때문입니다

운영자가 처음 알린 증상은 "입력창에는 보이는데 말풍선에서는 빈칸, 클래식 화면은 정상"이었습니다. 앱 테마가 android:textViewStyle로 이 폰트를 모든 TextView에 걸었고, 말풍선이 TextView였습니다. 입력창과 클래식 화면의 결과창은 EditText였고, 커밋 기록에 따르면 AppCompat의 EditText에는 테마의 android:editTextStyle이 걸리지 않습니다. 클래식 화면은 우연히 무사했을 뿐, 영향 범위는 앱 전체 TextView였습니다.

AI의 첫 측정은 '밣이 있다'였습니다

원인은 AI 코딩 에이전트가 찾았지만 첫 측정은 틀린 쪽을 가리켰습니다. 에이전트는 cmap에 '밣'이 있는지만 봤고, 나눔스퀘어는 '있음', 인사카드용 감자꽃 서브셋은 '없음'으로 나왔습니다. 방향을 바꾼 건 파일 크기였습니다. cmap 17,559개짜리 폰트가 750KB면 한글 전체를 담기엔 작다고 보고 외곽선 길이를 측정했고, 문제를 알린 지 1분 35초 뒤 8,693이 나왔습니다.

첫 시험본도 틀렸습니다. 외곽선 없는 항목을 가리지 않고 13,991개를 지워, 제어 문자 32개와 공백 문자 3개처럼 원래 비어 있어야 할 글자까지 지웠습니다. 운영자의 승인 직후 에이전트가 적용 전에 유니코드 범주로 다시 걸러 13,948개로 줄였습니다.

세 번째는 사람이 바로잡았습니다. 에이전트는 전날 같은 세션에서 Android 14 에뮬레이터를 소프트웨어 모드로 띄운 적이 있는데도, 커밋 뒤 "실기기 렌더링은 확인할 수 없다"며 눈 확인을 운영자에게 넘겼습니다. 운영자는 배포 전 심층 점검과 교차 검증을 요구했고, 에이전트가 요청한 KVM 권한을 직접 열어 줬습니다. 다음 날 오전 8시 37분 Android 14 에뮬레이터에서 '밣받다'와 '•'가 모두 그려졌습니다.

폰트는 처음부터 이런 구조였을 가능성이 높습니다

커밋 메시지는 "서브셋 도구가 글리프만 지우고 cmap을 그대로 둔 것"으로 추정했습니다. 같은 측정을 사이트 저장소의 그림용 폰트에 돌려 보니, 나눔스퀘어 ExtraBold는 빈 한글 8,693자와 외곽선 없는 문자 13,948개로 앱 파일과 똑같았습니다. Regular는 빈 한글이 8,822자였습니다(그림 2).

막대 그림: 외곽선이 빈 한글 음절이 앱 나눔스퀘어 Light 수정 전 8,693자, 사이트 그림용 나눔스퀘어 ExtraBold 8,693자, Regular 8,822자였고 수정 후 Light, 나눔고딕, 감자꽃 서브셋은 0자였다.
같은 계열의 다른 파일도 같은 구조여서, 앱에 넣으면서 잘린 게 아닐 가능성이 커졌습니다.

사이트의 두 파일은 앱에 없는 굵기라 복사본도 아닙니다. 세 파일 모두 2016년 'Version 1.000' 빌드이고, 앱 파일은 저장소 첫 커밋(2020-12-16)부터 있었습니다. 공식 배포 파일과 대조하지 못해 커밋의 추정은 미확인으로 둡니다. 제대로 자른 감자꽃 서브셋은 cmap에 한글 2,349자뿐이었습니다.

폰트 교체와 폴백 체인 대신 cmap만 정리했습니다

앱에 이미 든 나눔고딕(4,691,820바이트)은 11,172자를 모두 그리지만, 바꾸면 앱 전체 글꼴이 달라집니다. 온전한 나눔스퀘어 Light로 바꾸는 안은 4MB쯤 는다는 추정이었고, 위 측정대로라면 그런 파일이 있는지부터 의문입니다. 기록 밖에서 폴백 체인(Typeface.CustomFallbackBuilder, API 29)도 따져 봤지만, 맡을 폰트는 여전히 cmap으로 정해집니다.

cmap 정리는 글리프를 그대로 두고 표만 줄여 원본과 1:1 대조가 됩니다. 대가는 드문 글자와 한자가 시스템 글꼴 모양으로 나온다는 것입니다. 한글 앱에 폰트를 넣는다면 cmap 범위만 보고 "한글 다 된다"고 판단하지 마십시오. 외곽선 길이까지 재는 테스트를 함께 넣으십시오.

이 점검이 통하지 않는 경우

눈으로 본 범위는 TextView와 Android 14 에뮬레이터 한 대입니다. Compose Text는 보지 않았습니다.

내 앱 폰트를 점검하는 절차

  1. res/font와 assets의 폰트를 모두 나열합니다.
  2. 폰트마다 cmap에서 글리프 번호를, loca에서 길이를 읽어 길이 0인 항목을 셉니다. 범주 Cc, Cf, Zs, Zl, Zp와 한글 채움 문자 셋은 제외합니다.
  3. 그려지는 한글 수를 KS X 1001의 2,350자와 비교합니다.
  4. 화면 확인용 글자는 2번 결과에서 고릅니다.

2번은 아래 24줄로 됩니다. 수정 전 파일에 돌리면 blank 13948 / hangul 11172, drawn 2479가 나옵니다. CFF 기반 OTF 폰트에서는 loca 표가 없어 KeyError: 'loca'로 멈추고, format 4 표가 없는 폰트에서는 hangul 0을 출력합니다(10월 23일 Noto Sans KR OTF와 format 4 표를 지운 사본으로 확인). hangul 0이 나오면 하위표 형식부터 보십시오.

import struct, sys, unicodedata
d = open(sys.argv[1], "rb").read()
u16 = lambda p: struct.unpack_from(">H", d, p)[0]
u32 = lambda p: struct.unpack_from(">I", d, p)[0]
T = {d[12+16*i:16+16*i].decode("latin1"): u32(20+16*i) for i in range(u16(4))}
def glyph_len(g):                      # loca: short(0) / long(1)
    lo = T["loca"]
    if u16(T["head"] + 50): return u32(lo+4*g+4) - u32(lo+4*g)
    return 2 * (u16(lo+2*g+2) - u16(lo+2*g))
cmap = {}
for i in range(u16(T["cmap"] + 2)):    # format 4 subtables only
    s = T["cmap"] + u32(T["cmap"] + 8 + 8*i)
    if u16(s) != 4: continue
    n = u16(s + 6); e = s + 14; st = e + n + 2; de = st + n; ro = de + n
    for k in range(0, n, 2):
        a, b, dl, r = u16(st+k), u16(e+k), struct.unpack_from(">h", d, de+k)[0], u16(ro+k)
        for c in range(a, min(b, 0xFFFE) + 1):
            g = (c + dl) & 0xFFFF if r == 0 else u16(ro + k + r + 2*(c - a))
            if r and g: g = (g + dl) & 0xFFFF
            if g: cmap[c] = g
ok = lambda c: c in (0x115F, 0x1160, 0x3164) or unicodedata.category(chr(c)) in ("Cc", "Cf", "Zs", "Zl", "Zp")
blank = [c for c, g in cmap.items() if glyph_len(g) == 0 and not ok(c)]
han = [c for c in cmap if 0xAC00 <= c <= 0xD7A3]
print(f"blank {len(blank)} / hangul {len(han)}, drawn {sum(glyph_len(cmap[c]) > 0 for c in han)}")

정리는 커밋 f27f36a의 tools/trim_blank_cmap.py가 했습니다. fontTools로 모든 cmap 하위표에서 외곽선 없는 글리프를 가리키는 항목을 지우고, 2번에서 제외한 문자는 그대로 둡니다. glyf 표가 없는 폰트는 처리하지 않습니다.

font = TTFont(src)
glyf = font["glyf"]
for table in font["cmap"].tables:
    cmap = getattr(table, "cmap", None) or {}
    for cp, name in list(cmap.items()):
        if glyf[name].numberOfContours == 0 and not is_blank_ok(cp):
            del cmap[cp]
font.save(dst)
  1. 정리한 뒤 지운 항목이 모두 빈 글리프였는지, 외곽선 있는 문자 수(이번에는 3,575)가 그대로인지 대조합니다.
  2. 원본 폰트로 되돌려 테스트가 실패하는지 봅니다. 방법은 테스트 3,066개가 초록인데 DB를 못 여는 코드가 숨어 있었다: 가드 지워 보기와 같습니다.
  3. 릴리스 AAB의 base/res/font에 고친 파일이 들어갔는지 확인합니다.

남은 일

사이트의 그림과 공유 이미지 도구가 바로 그 나눔스퀘어를 씁니다. KS X 1001 밖 음절은 그림에서 빈칸이 되므로 이 글은 제목과 그림에 '밣'을 넣지 않았습니다. 그림 도구 폰트 교체, 앱 테스트에 CFF·format 12 검사 추가가 남았습니다.

자주 묻는 질문

안드로이드 앱에서 일부 한글만 빈칸으로 나오는 이유는 무엇인가요?

앱 폰트의 cmap에는 그 글자가 있는데 외곽선이 비어 있을 가능성이 높습니다. 폰트마다 cmap 항목의 외곽선 길이를 재 보면 바로 드러납니다.

두부 상자(□)와 빈칸은 어떻게 구분하나요?

두부 상자는 대체할 폰트까지 찾아도 글자가 없을 때 나옵니다. 글자 한 칸 너비의 빈칸이면 cmap에는 있는데 외곽선이 빈 경우입니다. 이번 폰트의 빈 글자는 모두 폭 910이었습니다.

한글 폰트 테스트에는 어떤 글자를 써야 하나요?

폰트를 검사해서 그리지 못하는 글자를 고릅니다. 이번 폰트는 '뷁'은 그렸고 '밣'은 못 그렸습니다.

이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.

Android 개발 글 전체 보기 · AX 노트 전체 보기