링크는 걸렸는데 폰트가 안 먹는다 — 업로드한 .ttf를 FontFace로 그 자리에 렌더하기
워크스페이스에 브랜드 폰트를 업로드하면, 목록에 그 폰트로 렌더된 미리보기 문구가 떠야 합니다. 요구사항은 한 줄인데 구현은 두 번 막혔습니다. 처음엔 서버가 준 링크가 조용히 무시됐고, 그다음엔 서버가 파일 URL을 아예 주지 않았습니다.
결국 답은 브라우저에 이미 있던 API였습니다. new FontFace(family, bytes) — CSS를 거치지 않고 폰트 바이트를 직접 등록하는 CSS Font Loading API입니다. 이 글은 그 과정에서 정리한, File → ArrayBuffer → sfnt 파싱 → 폰트 레지스트리의 전체 사슬입니다.
코드는 실제 구현에서 이름과 경로를 익명화하고 단순화한 것입니다.
두 번 막힌 지점
1차 — <link rel="stylesheet">가 조용히 무시됐습니다.
서버가 준 필드 이름은 filePath였는데, 그 URL이 가리키는 건 폰트 파일이 아니라 서버가 만들어 둔 @font-face CSS였습니다. 그것까지는 <link>로 붙이면 되니 문제가 아니었습니다. 진짜 문제는 스토리지가 그 CSS를 content-type: application/octet-stream으로 서빙한다는 것이었고, 브라우저는 표준 모드에서 MIME 타입이 text/css가 아닌 스타일시트를 에러 없이 무시합니다.
404도 예외도 없습니다. 네트워크 탭에는 200이 찍혀 있고 <link> 태그도 DOM에 그대로 있는데 폰트만 안 먹습니다(브라우저에 따라 "Refused to apply style… not a supported stylesheet MIME type" 경고가 콘솔에 남지만, 폰트가 아니라 스타일 이야기라 폰트 문제로 연결되기까지 시간이 걸립니다). 증상이 "링크는 걸렸는데 안 나온다" 하나뿐이라 진단이 제일 늦었습니다.
여기서 얻은 교훈은 API 계약 쪽입니다. 필드 이름이 직관적이어도(filePath) 그 값이 가리키는 자산의 실제 형식은 보장되지 않습니다. 스키마는 그게 string이라는 것만 말해줍니다. 실데이터를 한 번 찍어보기 전까지는 원본인지 변환물인지 알 수 없습니다.
2차 — 상세 응답에서 URL이 사라졌습니다.
API가 v2로 개편되면서 폰트 항목이 { seq, name }만 반환하게 됐습니다. 파일 URL이 없으니 서버 자산으로 실서체를 렌더할 방법 자체가 없어졌습니다.
남은 소스는 하나뿐입니다. 사용자가 이번 세션에 방금 업로드한 로컬 File. 그걸 그대로 폰트로 등록하면 서버 왕복도, URL도 필요 없습니다.
File은 바이트를 갖고 있지 않다
<input type="file">에서 나오는 값부터 봅니다.
<input
type="file"
accept=".ttf,.otf"
multiple
hidden
onChange={(e) => onAdd(Array.from(e.target.files ?? []))} // FileList → File[]
/>
여기서 얻는 File은 Blob의 서브클래스인데, 내용을 담고 있지 않습니다. 갖고 있는 건 name / size / type / lastModified 같은 메타데이터와 OS 파일에 대한 참조뿐입니다. 그래서 동기적으로 바이트를 꺼내는 API가 없고, 비동기 메서드(arrayBuffer(), stream(), text())만 있습니다.
이 성질 덕분에 파일 핸들을 React state에 며칠이고 들고 있어도 부담이 없습니다. 3MB짜리 .ttf를 열 개 담아도 복사되는 건 핸들뿐입니다.
// 업로드 중인 파일(로딩 행 렌더용)과, 세션 내내 유지할 원본을 따로 보관
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [sessionFiles, setSessionFiles] = useState<File[]>([]);
arrayBuffer() — 여기서 처음 바이트가 메모리에 올라온다
face = new FontFace(family, await source.arrayBuffer());
arrayBuffer()가 디스크에서 파일을 읽어 ArrayBuffer(길이 고정, 타입 없는 raw 바이트 블록)로 반환합니다. 3MB 파일이면 3MB가 그대로 힙에 올라옵니다. 이 시점의 데이터는 그냥 바이트열이고, 폰트인지 JPEG인지 브라우저도 아직 모릅니다.
File을 FontFace에 직접 넘길 수 없기 때문에 이 변환이 필요합니다.
new FontFace('X', file); // ✗ TypeError — File/Blob 직접 불가
new FontFace('X', 'https://cdn/x.woff2'); // ✗ SyntaxError — url() 래핑 없으면 CSS 문법 위반
new FontFace('X', await file.arrayBuffer()); // ✓
objectURL 대신 arrayBuffer를 쓴 이유
URL.createObjectURL(file)로 blob URL을 만들어 문자열 소스로 넘기는 길도 있습니다. 네 가지 이유로 접었습니다.
- 정리할 자원이 없습니다. objectURL은
revokeObjectURL을 빼먹으면 누수고, 너무 일찍 부르면 로딩 중 무효화와 경합합니다.ArrayBuffer는 참조를 놓으면 GC가 처리합니다. - 실패 경로가 하나 줄어듭니다. 문자열 소스는 fetch를 타므로
NetworkError가능성이 추가됩니다. 바이너리는 파싱만 하니try/catch하나로 끝납니다. - CSS 문자열 조립이 없습니다. 문자열 소스의 값은 CSS
src서술자 문법이라url("...")따옴표·이스케이프를 직접 맞춰야 하고, 틀리면 생성자가 동기SyntaxError를 던집니다. - 애초에 원격 URL 경로가 없습니다. 소스가 로컬
File뿐인데 굳이 File → blob URL → fetch → 바이트로 왕복할 이유가 없습니다.
비용은 파일 전체가 메모리에 상주하는 것 하나인데, 폰트는 수백 KB라 무의미했습니다.
source에 들어가는 두 가지
FontFace의 두 번째 인자 타입은 CSSOMString | BufferSource입니다. 형태에 따라 로딩이 시작되는 시점이 다른 것이 핵심입니다.
| source | 담긴 것 | 로딩 시작 |
|---|---|---|
ArrayBuffer / Uint8Array |
폰트 파일 바이트 전체 | 생성자에서 즉시 (status = 'loading') |
| CSS 문자열 | 가져올 위치 + format 힌트 | load() 호출 시 |
// (a) 바이너리 — 이미 로딩이 시작된 상태
const face = new FontFace('X', bytes);
await face.load(); // 새 로딩이 아니라 "진행 중인 파싱 완료 대기"
// (b) 문자열 — CSS @font-face 의 src 문법 그대로. 폴백 리스트·local() 도 가능
new FontFace('X', `url("/x.woff2") format("woff2"), url("/x.woff") format("woff")`);
new FontFace('X', `local("Pretendard")`);
(a)에서 load()가 중복 로딩이 아닌 건 스펙 때문입니다. status가 'unloaded'가 아니면 load()는 기존 프로미스를 그대로 반환합니다. 그래서 바이너리 소스에 await face.load()를 붙이는 건 파싱 완료를 기다리는 관용구로 읽으면 됩니다.
FontFace는 컨테이너를 씌우지 않는다 — 파싱한다
처음에 제 머릿속 모델은 "바이트를 브라우저가 읽을 수 있는 컨테이너로 감싸는 것"이었는데, 방향이 반대였습니다.
컨테이너는 이미 바이트 안에 있습니다. .ttf / .otf / .woff2는 모두 sfnt 계열 포맷이고, 파일 안에는 헤더·테이블 디렉터리·각 테이블이 규격대로 들어 있습니다. 브라우저는 처음부터 그걸 읽을 수 있었습니다 — 그게 폰트 파일 규격의 존재 이유니까요.
바이트(이미 sfnt 컨테이너) ──파싱──▶ 브라우저 내부 typeface 객체
▲
FontFace 가 하는 일
실제로 뜯어보면 앞 12바이트에 다 나옵니다.
$ xxd -l 12 sample.woff2
774f4632 00010000 00003390
└ 'wOF2' └flavor └length
4B 4B 4B
wOF2 시그니처, 0x00010000(내부가 TrueType), 파일 길이 13200. 이어지는 필드까지 파싱하면 numTables=13, 압축 해제 후 크기 32992, 압축 크기 13112가 그대로 나옵니다. 헤더가 구조체 배열이라 오프셋이 딱 떨어집니다.
앞 4바이트만으로 포맷이 판별됩니다.
| tag | 포맷 |
|---|---|
0x00010000 |
TrueType (.ttf) |
OTTO |
OpenType/CFF (.otf) |
wOF2 |
WOFF2 — Brotli 압축, 먼저 해제 |
그다음 테이블 디렉터리를 읽어 필요한 테이블을 꺼냅니다.
| 테이블 | 역할 |
|---|---|
cmap |
유니코드 코드포인트 → 글리프 인덱스 |
glyf+loca / CFF |
실제 글자 모양 — 아웃라인(TrueType=2차, CFF=3차 베지어) |
hmtx/hhea, head, OS/2 |
advance width, em당 유닛, 어센더·디센더 |
GSUB/GPOS/kern |
합자·커닝 등 조판 규칙 |
name |
폰트 내부 표시명 — 뒤에서 다시 나옵니다 |
파싱이 실패하면(손상 파일, 폰트가 아닌 바이트) load() 프로미스가 reject됩니다.
그럼 FontFace가 씌우는 건 뭔가 — CSS 식별자
"씌운다"가 완전히 틀린 직관은 아니었습니다. 다만 씌우는 대상이 파일 포맷이 아니라 CSS용 이름표와 서술자입니다.
new FontFace('brandkit-font-r1a', bytes); // ← 이 이름은 파일 안에 없다. 우리가 붙인 것
파일 name 테이블에는 "Pretendard SemiBold" 같은 진짜 이름이 있지만 CSS 매칭에는 쓰이지 않습니다. CSS가 보는 이름은 우리가 준 family뿐입니다. 이 사실이 실무 이슈 두 개를 그대로 설명합니다(뒤의 "함정" 참고).
정확한 3단계
정리하면 이렇습니다. 2와 3이 나뉘어 있는 것이 중요합니다.
| 단계 | 코드 | 하는 일 |
|---|---|---|
| 1. 열기 | await file.arrayBuffer() |
파일 핸들 → 원본 바이트 |
| 2. 파싱 + 이름 부여 | new FontFace(family, bytes) → await load() |
sfnt 해석해 typeface 생성, family를 CSS 식별자로 부착 |
| 3. 노출 | document.fonts.add(face) |
CSS font-family: family로 찾을 수 있게 등록 |
new FontFace만으로는 아무것도 등록되지 않습니다. 파싱 성공/실패를 등록 전에 알 수 있으니, 손상 파일이면 레지스트리에 아무것도 남기지 않고 기본 폰트로 조용히 degrade할 수 있습니다.
훅으로 묶으면 이 정도입니다.
export const useFontFace = (family: string, source: File | null): boolean => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!source || typeof FontFace === 'undefined') return;
let alive = true;
let face: FontFace | undefined;
(async () => {
try {
face = new FontFace(family, await source.arrayBuffer());
await face.load();
if (!alive) return; // async 중간 언마운트 가드
document.fonts.add(face);
setLoaded(true);
} catch {
// 기본 폰트 유지 — 미리보기는 부가 기능이라 에러 노출 불필요
}
})();
return () => {
alive = false;
if (face) document.fonts.delete(face); // 등록 누적 방지
};
}, [family, source]);
return loaded;
};
소비하는 쪽은 loaded를 게이트로 씁니다.
const family = `brandkit-font-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
const loaded = useFontFace(family, file);
<span style={loaded ? { fontFamily: family } : undefined}>가나다 AaBbCc</span>
loaded 전에는 fontFamily를 아예 붙이지 않아서, 로딩 중 폴백으로 그려졌다가 바뀌는 깜빡임이 없습니다.
폰트는 전부 바이트 단위인가
파일이니 당연히 바이트지만, 실무에서 의미 있는 답은 컨테이너는 바이트 정렬이고 내부에는 바이트보다 작은 단위와 바이트가 아닌 것들이 섞여 있다입니다.
바이트 정렬인 부분. 기본 자료형이 전부 빅엔디언 고정폭입니다 — uint8, uint16/FWORD(2B), uint32/Tag(4B), Fixed(16.16), F2Dot14, LONGDATETIME(8B). 테이블 디렉터리는 12B 헤더 + 16B×numTables 레코드이고, 각 테이블은 4바이트 경계로 정렬되며 남는 자리는 0으로 패딩됩니다.
바이트보다 작은 단위. glyf의 좌표 인코딩이 대표적입니다. 점마다 flag 1바이트를 두고 그 안의 비트로 뒤에 오는 데이터 크기가 바뀝니다.
| 비트 | 의미 |
|---|---|
| 0 | ON_CURVE_POINT — 곡선 위 점인가 |
| 1 | X_SHORT_VECTOR — x 좌표가 1B인가 2B인가 |
| 2 | Y_SHORT_VECTOR |
| 3 | REPEAT_FLAG — 다음 바이트만큼 이 flag 반복 |
| 4~5 | 부호 / 같은 값 재사용 |
"점 하나 = N바이트"가 고정이 아니라 비트를 읽어야 다음 오프셋이 정해지는 가변 구조입니다.
바이트 정렬이 아예 깨지는 부분. WOFF2는 헤더 뒤가 Brotli 압축 스트림이라 비트 단위이고, 가변 길이 정수 인코딩(UIntBase128, 255UInt16)까지 씁니다. 위 샘플의 13112바이트가 32992로 부풀어 오르는 게 이 압축 해제입니다.
바이트이긴 하나 아웃라인이 아닌 것들. 폰트 안에 든 게 좌표만은 아닙니다.
| 테이블 | 내용물 |
|---|---|
fpgm / prep / glyf instructions |
실행 가능한 바이트코드 — 스택 기반 힌팅 VM 프로그램 |
CBDT/CBLC |
PNG 이미지 통째로 (컬러 이모지) |
SVG |
SVG 문서 텍스트 |
COLR/CPAL |
레이어 + 팔레트 |
fvar/gvar |
가변 폰트 축 정의 + 좌표 델타 |
컬러 이모지 폰트는 "바이트로 된 PNG를 담은 바이트로 된 sfnt"이고, TrueType 힌팅은 사실상 폰트 안에 든 프로그램입니다. 우리 코드 입장에서는 전부 브라우저 파서 안쪽 일이고, 할 일은 바이트를 정확히 넘기고 실패를 catch하는 것뿐입니다.
함정 다섯 개
1. family는 document 전역 네임스페이스입니다. document.fonts는 document 단위라 같은 이름을 add하면 뒤에 것이 매칭을 가로챕니다. 목록의 행마다 다른 폰트를 등록해야 하니 useId()로 유일 이름을 만들었습니다. 이름은 어차피 우리가 정하는 것이므로 의미 있는 이름일 필요도 없습니다. 단 useId()는 :처럼 CSS 식별자로 못 쓰는 문자를 포함하니 영숫자만 남겨야 합니다.
2. 서버가 주는 폰트 이름은 파일명이 아닙니다. 확정된 목록 행에 실서체를 이어 붙이려면 "이 항목의 원본 File이 무엇인가"를 되찾아야 하는데, 서버의 name은 앞서 본 name 테이블에서 뽑힌 값이었습니다. 실측하니 YNAMNEWS(M).ttf → "YNAMNEWS Medium"이었고, file.name === font.name 단순 비교는 대부분 실패합니다.
// 확장자·대소문자·기호·공백을 걷어낸 비교 키
const normalizeFontKey = (v: string) =>
v.replace(/\.(ttf|otf)$/i, '').toLowerCase().replace(/[^a-z0-9가-힣]/g, '');
// 정규화 완전 일치 우선 → 없으면 양방향 prefix 근사 → 불일치면 null(기본 폰트 폴백)
3. 언마운트 정리를 빼먹으면 등록이 누적됩니다. cleanup의 document.fonts.delete(face)와, async 중간 언마운트를 막는 alive 가드는 세트입니다.
4. 서술자를 생략하면 weight는 normal입니다. 파일 안이 Bold 전용이어도 CSS에는 normal로 등록되고, font-weight: bold를 요청하면 브라우저가 가짜 볼드를 합성합니다. 미리보기 문구는 굵기를 안 써서 무해했지만, 굵기별로 등록할 거라면 { weight } 서술자를 명시해야 합니다.
5. jsdom에는 FontFace가 없습니다. 그래서 테스트에서 jest.spyOn(globalThis, 'FontFace')는 실패합니다 — spy는 기존 프로퍼티를 래핑하는 것이지 신규 생성이 아닙니다. 없는 전역은 직접 대입하고 afterEach에서 delete로 원복합니다.
class MockFontFace {
static instances: MockFontFace[] = [];
loadResult: Promise<unknown> = Promise.resolve(undefined);
constructor(public family: string, public source: unknown) {
MockFontFace.instances.push(this);
}
load() { return this.loadResult; }
}
beforeEach(() => { (globalThis as any).FontFace = MockFontFace; });
afterEach(() => { delete (globalThis as any).FontFace; }); // 원래 없던 전역 → delete가 곧 원복
원래 있던 프로퍼티를 override했다면 원본 descriptor를 저장해 복원해야 하고, 애초에 없던 전역은 delete가 곧 원복입니다. 이 둘을 헷갈리면 같은 워커의 다른 테스트 파일로 오염이 전파됩니다.
미지원 가드도 같이 테스트합니다. 구현이 typeof FontFace === 'undefined'로 안전 degrade하니, delete된 상태(=jsdom 기본값)로 한 번 더 렌더해 "미지원 환경 크래시 없음"을 별도 케이스로 고정했습니다.
남은 것
지금 구조의 한계는 명확합니다. 소스가 로컬 File뿐이라 새로고침하거나 다른 기기에서 열면 원본이 없어 기본 폰트로 떨어집니다. 서버가 폰트 파일 URL을 노출하면 이 경로는 문자열 소스로 대체될 자리입니다. 그때는 다운로드를 브라우저 캐시에 맡길 수 있으니 그쪽이 맞습니다.
그리고 그 시점에 반드시 실제 응답을 먼저 확인해야 합니다. 계약이 plain woff2 URL이면 new FontFace(family, 'url(...)') 한 줄로 충분하고, 1차 때처럼 CSS를 주는 계약이면 그 CSS를 fetch해 내부 url()을 뽑아내는 과정이 필요합니다. 필드 이름만 보고 판단할 수 없다는 게 이 기능에서 두 번 확인한 사실입니다.
정리
File은 핸들이고, 바이트를 보려면arrayBuffer()가 필요합니다.FontFace는 컨테이너를 씌우는 게 아니라 이미 들어 있는 sfnt를 파싱하고, 그 위에 CSS 식별자와 서술자를 붙입니다.- 파싱(
new FontFace+load())과 노출(document.fonts.add)이 분리된 덕분에 실패를 등록 전에 걸러낼 수 있습니다. - 바이너리 소스는 생성자에서 로딩이 시작되고, 문자열 소스는
load()가 트리거합니다. 로딩 시점 차이가 두 형태의 실질적 구분입니다. - 스타일시트 MIME 불일치처럼 에러 없이 무시되는 실패는 증상이 하나뿐이라 진단이 늦습니다. 자산을 다룰 때는 필드 이름이 아니라 실제 content-type을 확인하는 게 빠릅니다.
같은 블로그의 웹 에디터 폰트 파이프라인 4단계는 반대쪽 경로 — @font-face CSS 주입과 document.fonts.load()로 캔버스 렌더를 맞추는 이야기입니다. 이 글이 "바이트를 직접 넘기는" 쪽이라면 그 글은 "URL을 언제 받게 만드는가" 쪽입니다.