나눔스퀘어 네오 가변폰트의 굵기를 조절하는 웹사이트에서 이상한 차이를 발견했다. Firefox에서는 슬라이더를 움직일 때 글자가 세밀하게 변했지만, Chrome에서는 굵기가 몇 단계로 끊겨 보였다. 465를 지정해도 500과 구분되지 않았다.
처음에는 Chrome이 굵기 값을 100단위로 반올림한다고 생각했다. 다른 가변폰트에서도 흔히 보이는 문제가 아니었으므로, 이 폰트와 Chrome 사이에 어떤 특별한 문제가 있다고 의심했다.
조사를 마치고 확인한 원인은 폰트 파일 안의 잘못된 메타데이터였다. Chrome에서는 그 때문에 폰트 로딩이 실패했고, 화면에는 대체 글꼴이 표시되고 있었다. Firefox는 같은 오류를 발견하고도 문제가 있는 부분을 제거한 뒤 폰트를 사용할 수 있었다.
겉으로는 작은 굵기 차이였지만, 원인을 찾으려면 CSS에 적힌 숫자와 실제로 글자를 그리는 폰트를 구분해야 했다.
가변폰트를 처음 접한다면, 글자 굵기를 조절하는 방식을 먼저 떠올려 보면 좋다. 일반적인 정적 폰트는 Regular, Bold처럼 미리 만들어진 굵기를 선택한다. 가변폰트는 하나의 파일에 글자 형태를 변화시키는 정보를 담아 두고, 지원하는 범위 안에서 중간 굵기도 계산한다. 정해진 몇 개의 버튼 대신 슬라이더를 제공하는 셈이다.
웹에서는 굵기를 font-weight라는 속성으로 지정한다. 이번 파일은 100부터 900까지의 굵기 축을 가지고 있었다. 465는 “465픽셀 두께”라는 뜻이 아니라, 그 축에서 선택한 위치다. 브라우저는 이 값을 바탕으로 글자의 획과 형태를 계산한다.
그렇다면 가장 먼저 확인할 것은 웹사이트가 그 숫자를 제대로 전달하고 있는지였다.
페이지에는 이미 가변 범위가 선언되어 있었다. 다음은 문제와 관련된 부분만 줄인 코드다.
@font-face {
font-family: 'NanumSquareNeo-Variable';
src: url('./NanumSquareNeo-Variable.ttf') format('truetype');
font-weight: 100 900;
}
.preview {
font-family: 'NanumSquareNeo-Variable', sans-serif;
font-weight: 465;
font-variation-settings: 'wght' 465;
}
여기서 wght는 폰트 내부에서 굵기를 가리키는 이름이다. 이 페이지는 일반적인 굵기 속성과 가변축 설정에 모두 같은 값을 전달하고 있었다. 슬라이더의 간격도 1이었고, 값을 100단위로 반올림하는 코드는 없었다.
브라우저가 계산한 CSS 값 역시 465였다. 숫자가 전달되는 과정만 보면 정상처럼 보였다.
하지만 원하는 폰트를 CSS에 지정했다는 사실과 그 폰트가 실제로 사용되고 있다는 사실은 다르다.
웹사이트의 폰트를 사용할 수 없더라도 브라우저는 대개 글자를 계속 보여 준다. 위 코드의 sans-serif는 그런 상황에서 사용할 대체 글꼴의 종류를 지정한다. 읽을 수 있는 페이지를 유지하기 위한 동작이지만, 폰트 기능을 시험하는 페이지에서는 실패를 알아채기 어렵게 만들기도 한다.
그래서 “어떤 굵기가 계산됐는가”에 더해 “어떤 폰트가 글자를 그렸는가”를 확인했다. Chrome의 개발자 도구 프로토콜에서 실제 사용 폰트를 조회하는 CSS.getPlatformFontsForNode를 이용했다. 결과에서 핵심 필드만 추리면 다음과 같았다.
{
"familyName": "Malgun Gothic",
"postScriptName": "MalgunGothic",
"isCustomFont": false
}
나눔스퀘어 네오가 보여야 할 자리에 맑은 고딕이 표시되고 있었다. 폰트의 로딩 상태도 loaded가 아닌 error였다.
콘솔에는 다음 메시지가 남아 있었다.
Failed to decode downloaded font: .../NanumSquareNeo-Variable.ttf
OTS parsing error: Unable to instantiate font face from font data.
이제 처음의 해석을 고칠 수 있었다. 이번 환경에서 Chrome은 나눔스퀘어 네오의 465를 500으로 바꾸어 그린 것이 아니었다. 나눔스퀘어 네오를 사용할 수 없어 대체 글꼴을 선택했고, 그 글꼴에서 여러 굵기 요청이 같은 모습으로 나타난 것이었다.
실제로 원본 파일을 사용한 Chrome에서는 400, 401, 465, 475, 500 등이 같은 출력을 냈다. “100단위로 반올림한다”는 설명보다 폰트 로딩 실패라는 설명이 관찰 결과에 들어맞았다.
다음으로 확인할 것은 폰트 파일이었다.
폰트는 글자 그림만 모아 놓은 파일이 아니다. 글자 모양, 글자 사이의 간격, 이름, 굵기 범위 같은 여러 정보를 따로 담고 있다. 이 정보 묶음을 폰트 형식에서는 테이블이라고 부른다. 이번 조사에서 구분해야 했던 테이블은 다음과 같다.
| 테이블 | 하는 일 |
|---|---|
glyf |
글자의 기본 윤곽을 담는다. |
fvar |
어떤 가변축이 있고, 각 축의 최소·기본·최대 값이 무엇인지 정의한다. |
gvar |
축의 위치에 따라 글자 윤곽이 어떻게 변하는지 담는다. |
STAT |
굵기 등의 스타일 속성을 설명하고, 값과 이름을 연결한다. |
예를 들어 STAT에는 특정 굵기 값에 “Light”나 “Bold”라는 이름을 붙이는 정보가 들어간다. 중간 굵기의 글자 모양을 직접 계산하는 데이터와는 역할이 다르다. 이번 문제는 이 스타일 정보에서 발견됐다.
해당 파일의 STAT에는 축이 하나만 선언되어 있었다. 이름은 wght, 즉 굵기였다.
컴퓨터에서는 목록의 첫 번째 항목에 0번을 붙이는 일이 흔하다. 이 테이블도 그렇다. 축이 하나라면 사용할 수 있는 축 번호는 0뿐이다. 그런데 스타일 값을 설명하는 마지막 항목은 1번 축을 가리키고 있었다.
| 항목 | 가리키는 축 번호 | 값 | 연결된 이름 |
|---|---|---|---|
| 첫 번째 | 0 | 100 | Light |
| 두 번째 | 0 | 300 | Regular |
| 세 번째 | 0 | 500 | Bold |
| 네 번째 | 0 | 700 | ExtraBold |
| 다섯 번째 | 0 | 900 | Heavy |
| 여섯 번째 | 1 | 0 | Regular |
실제 이름과 값은 이 파일에 저장된 내용을 그대로 적었다. 일반적인 CSS 굵기 이름 대응표와는 구분해야 한다.
마지막 항목은 존재하지 않는 두 번째 축을 참조했다. 목록에는 항목이 하나뿐인데 설명서가 두 번째 항목을 보라고 하는 상황이었다.
개발자에게 익숙한 표현으로는 범위를 벗어난 인덱스 참조다. 관련 필드는 DesignAxisCount = 1, AxisIndex = 1이었다. OpenType의 해당 형식은 AxisIndex가 DesignAxisCount보다 작아야 한다고 규정한다. 이 파일은 그 조건을 만족하지 않았다. OpenType STAT 규격
이 항목이 제작 당시 어떤 축을 의도했는지는 알 수 없다. 다만 파일에 없는 축을 참조한다는 점은 데이터만으로 확인할 수 있었다.
여기까지 확인하면 또 하나의 의문이 남는다. 같은 파일에 문제가 있다면 Firefox에서는 왜 제대로 보였을까.
Firefox의 콘솔을 확인하니, 그쪽도 오류를 발견하고 있었다. 메시지에서 파일명 등의 부가 정보를 제외하면 다음과 같다.
downloadable font: STAT: Axis index out of range
downloadable font: Table discarded
첫 줄은 “축 번호가 범위를 벗어났다”, 두 번째 줄은 “테이블을 버렸다”는 뜻이다. Firefox는 잘못된 STAT 테이블을 제거하고 나머지 데이터를 사용했다. 글자 윤곽과 굵기 변화에 필요한 데이터가 남아 있었기 때문에 가변 굵기도 동작했다.
웹폰트 검사에 쓰이는 OpenType Sanitizer, 줄여서 OTS의 STAT 파서에도 이런 처리가 있다. 범위를 벗어난 축 번호를 발견하면 해당 테이블을 폐기한다. OTS의 STAT 처리 코드
Chrome의 경로는 달랐다. 조사 시점의 Chromium 소스에서 STAT는 OTS의 해당 테이블 검증을 거치지 않고 통과시키는 대상으로 지정되어 있었다. 이후 폰트를 실제로 사용할 수 있는 객체로 만드는 단계에서 실패했다. Chromium 웹폰트 디코더
이 차이는 다음처럼 정리할 수 있다.
| 같은 원본 파일을 받은 뒤 | Firefox | Windows Chrome |
|---|---|---|
잘못된 STAT 처리 |
오류를 기록하고 테이블을 제거 | OTS의 STAT 검증을 우회해 다음 단계로 전달 |
| 폰트 사용 결과 | 나눔스퀘어 네오 사용 가능 | 폰트 인스턴스 생성 실패 |
| 화면에 나타난 결과 | 가변 굵기가 적용됨 | 대체 글꼴의 굵기가 적용됨 |
조금 더 내부 구현을 따라가면, 지원되는 Windows 환경에서 이 종류의 가변폰트는 시스템 폰트 매니저를 거쳐 DirectWrite 경로를 사용한다. 조사한 Chromium과 Skia 소스가 그 흐름을 보여 준다. 다만 Windows 내부의 실패 지점까지 디버거로 확인한 것은 아니다. 소스에서 확인한 처리 경로와, 실제 파일을 바꾸어 얻은 실험 결과를 구분해 두었다. Chromium의 폰트 생성 경로, Skia의 DirectWrite 구현
콘솔의 오류 문구에도 주의할 부분이 있었다. OTS parsing error라는 접두어 때문에 OTS의 검사 단계에서 파일을 거부했다고 생각하기 쉽다. 그러나 뒤에 붙은 Unable to instantiate font face from font data.는 Chromium 코드에서 OTS 처리 이후 폰트 인스턴스 생성에 실패했을 때 반환하는 메시지였다. 오류 메시지의 앞부분만으로 실패 단계를 단정할 수는 없었다. 해당 오류를 반환하는 코드
잘못된 데이터를 찾았다고 해서 곧바로 원인이 증명되는 것은 아니다. 폰트에 다른 문제가 함께 있을 수도 있고, 파일을 다시 저장하는 과정에서 예상하지 못한 부분이 바뀔 수도 있다. 그래서 원본을 보관하고, 수정 범위를 달리한 파일들을 같은 Chrome에서 비교했다.
| 실험 | 변경한 내용 | 결과 |
|---|---|---|
| 원본 | 없음 | 로딩 실패 |
| 다시 저장 | 테이블 데이터는 그대로 유지 | 로딩 실패 |
| 기본 굵기 메타데이터 변경 | OS/2.usWeightClass만 400에서 100으로 변경 |
로딩 실패 |
| 축 번호 변경 | 문제 항목의 AxisIndex만 1에서 0으로 변경 |
로딩 성공 |
| 문제 항목 제거 | 마지막 AxisValue만 제거하고 개수를 갱신 |
로딩 성공 |
특히 축 번호 변경 실험은 원인을 좁히는 데 도움이 됐다. STAT 테이블의 길이는 그대로였고, 그 안에서 달라진 데이터는 한 바이트였다. 테이블 시작점에서 103바이트 떨어진 위치의 값이 01에서 00으로 바뀌었다. 파일의 체크섬 관련 값도 함께 갱신되므로, 전체 파일에서 단 한 바이트만 바뀌었다는 뜻은 아니다.
이 작은 변경만으로 같은 Chrome에서 폰트를 사용할 수 있게 됐다. 잘못된 축 번호와 로딩 실패 사이의 인과관계를 확인한 것이다.
최종 수정에는 잘못된 항목을 제거하는 방법을 택했다. 축 번호만 0으로 바꾸면 값이 0인 항목을 굵기 축에 연결하게 된다. 원인을 확인하는 실험으로는 유용하지만, 이번 파일의 스타일 정보를 정리하는 방법으로는 불필요한 항목을 남기게 된다.
아래는 이번에 분석한 파일을 대상으로 한 수정 예제다. Python의 fontTools 라이브러리를 사용하며, python -m pip install fonttools로 설치할 수 있다. 파일의 지문인 SHA-256을 먼저 확인하므로, 다른 버전의 폰트를 실수로 같은 방식으로 수정하지 않는다. 원본을 덮어쓰지 않고 새 파일로 저장한다.
from hashlib import sha256
from pathlib import Path
from fontTools.ttLib import TTFont
source = Path('NanumSquareNeo-Variable.ttf')
target = Path('NanumSquareNeo-Variable-fixed.ttf')
expected_hash = (
'606697c16c9ed1bc98447feeb7875d15a5a9'
'cebc36093827ba01380d64d488fc'
)
if sha256(source.read_bytes()).hexdigest() != expected_hash:
raise ValueError('이번에 분석한 원본 파일과 다릅니다.')
font = TTFont(source, recalcTimestamp=False)
stat = font['STAT'].table
values = stat.AxisValueArray.AxisValue
assert stat.DesignAxisCount == 1
assert stat.DesignAxisRecord.Axis[0].AxisTag == 'wght'
assert len(values) == 6
assert values[5].Format == 1 and values[5].AxisIndex == 1
del values[5]
stat.AxisValueCount = len(values)
font.save(target)
수정 전후의 폰트 테이블을 비교하니 달라진 것은 STAT와 체크섬이 들어 있는 head뿐이었다. 글자 윤곽인 glyf, 가변축 정의인 fvar, 굵기에 따른 윤곽 변화 정보인 gvar를 포함한 다른 테이블들은 바이트 단위로 같았다. 글자 디자인을 바꾸거나 중간 굵기를 새로 만든 작업은 없었다.
웹사이트에서는 새 파일을 불러오도록 경로를 변경했다.
@font-face {
font-family: 'NanumSquareNeo-Variable';
src: url('./NanumSquareNeo-Variable-fixed.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
}
.preview {
font-family: 'NanumSquareNeo-Variable', sans-serif;
font-weight: 465;
}
수정 후에는 font-weight만 사용해도 중간 굵기가 반영됐다. font-variation-settings를 따로 적용하거나 두 속성을 함께 적용하는 경우도 확인했다. 속성을 하나 더 덧붙이는 것으로 해결된 문제가 아니라, 같은 속성이 사용할 수 있는 정상적인 폰트 파일을 제공한 결과였다.
확인은 눈으로 보는 데서 끝내지 않았다. 같은 문장과 크기를 유지한 상태에서 굵기를 바꾸고, 출력된 이미지를 비교했다. 아래 표는 초기 진단에서 사용한 64px 표본의 결과다. “같음”과 “다름”은 각 브라우저 안에서 두 굵기의 출력을 비교한 것이며, 서로 다른 브라우저의 픽셀이 같다는 뜻은 아니다.
| 비교한 굵기 | Chrome · 원본 | Chrome · 수정본 | Firefox · 원본 |
|---|---|---|---|
| 400과 401 | 같음 | 다름 | 다름 |
| 464와 465 | 같음 | 다름 | 다름 |
| 465와 500 | 같음 | 다름 | 다름 |
| 465와 466 | 같음 | 같음 | 같음 |
마지막 줄도 함께 기록할 필요가 있었다. 가변폰트가 세밀한 값을 받아들인다고 해서 모든 인접한 값이 모든 크기와 문장에서 반드시 다른 픽셀을 만드는 것은 아니다. 글자 형태의 작은 차이가 화면의 픽셀로 옮겨지는 과정에서 같은 출력으로 나타날 수 있다.
그래서 “이제 모든 1단위 값이 무조건 다르게 보인다”고 결론 내리지는 않았다. 확인한 사실은 400과 401, 464와 465처럼 인접한 값도 구분되는 사례가 있고, 처음 문제였던 465와 500도 수정 후 달라졌다는 것이다. 가변축이 정상적으로 작동하는지와 특정 두 화면이 눈에 띄게 다른지는 별개의 확인 대상이었다.
실제 플레이그라운드에서도 같은 비교를 다시 수행했다. Chrome에서 사용 중인 폰트를 조회했을 때는 NanumSquare Neo variable, isCustomFont: true가 확인됐다. Firefox에서도 수정한 파일이 경고 없이 로드됐다. 슬라이더의 1단위 키보드 조작, 굵기 애니메이션, 각 미리보기 탭도 함께 점검했다.
폰트 파일을 고치는 것과 함께, 페이지가 로딩 실패를 알려 주는 방식도 바꿨다. 폰트를 불러오기 전부터 “로드됨”이라고 표시하면, 사용자는 대체 글꼴을 보면서도 정상이라고 생각할 수 있다. 이제는 불러오는 중, 사용 중, 로딩 실패를 구분하고, 실패했을 때 대체 글꼴이 표시되고 있다는 사실을 안내한다.
개발자가 비슷한 상태 표시를 만든다면 document.fonts.ready의 의미도 구분할 필요가 있다. 이 Promise는 폰트 로딩과 관련 레이아웃 처리가 끝나기를 기다리는 용도다. 특정 폰트의 로딩 성공을 그 자체로 보증하지 않는다. 이번 페이지에서는 대상 폰트를 document.fonts.load()로 명시적으로 로드하고, 반환된 폰트와 실패 여부를 확인하도록 바꿨다. MDN의 FontFaceSet.ready 설명
이 글의 결론은 이번에 사용한 파일과 재현 환경을 기준으로 한다. 나눔스퀘어 네오라는 이름을 가진 모든 배포 파일이나 모든 운영체제의 Chrome에서 동일한 결과가 나온다고 확인한 것은 아니다. 실험은 Windows의 Chrome 154.0.8037.58과 Firefox 153.0에서, 별도 자동화 프로필과 창을 띄우지 않는 headless 방식으로 진행했다. 같은 로컬 페이지와 폰트 파일을 사용했고, 위 수정 코드에 원본 파일의 지문을 남겼다.
처음에는 Firefox에서 잘 보인다는 사실을 폰트가 정상이라는 근거로 생각했다. 실제로는 Firefox가 파일의 오류를 복구하고 있었다. Chrome에서 CSS 값이 465로 표시된다는 사실도, 그 폰트의 465가 화면에 그려지고 있다는 증거는 아니었다.
이번 문제를 해결하는 데 가장 큰 전환점은 실제 사용 폰트를 확인한 순간이었다. 그 확인을 통해 굵기의 반올림을 의심하던 조사에서 폰트 로딩 실패를 추적하는 조사로 넘어갈 수 있었다. 그리고 파일의 한 항목만 바꾼 비교 실험으로, 화면에서 보이던 차이를 폰트 내부의 구체적인 오류와 연결할 수 있었다.