@font-face를 넣었는데 캔버스에는 안 나온다 — 웹 에디터 폰트 파이프라인 4단계
캔버스 기반 영상 에디터를 만들면서 가장 오래 붙잡고 있었던 주제가 폰트였습니다. DOM에 텍스트를 그리는 것과 <canvas>에 텍스트를 그리는 것은 폰트 관점에서 완전히 다른 문제이고, 여기에 "사용자가 워크스페이스에 등록한 커스텀 폰트"까지 들어오면 로딩 순서 하나가 뒤집혀도 폴백 글꼴로 렌더된 뒤 영구히 고쳐지지 않는 상태가 만들어집니다.
이 글은 그 파이프라인을 4단계로 정리한 기록입니다. 코드는 실제 구현에서 이름과 경로를 익명화하고 단순화한 것입니다.
왜 어려운가 — 세 가지 전제
결론부터 적으면, 폰트 파이프라인이 어려운 이유는 세 가지입니다.
@font-face등록은 폰트 다운로드가 아닙니다. CSS를 주입해도 브라우저는 "이 폰트를 쓰는 텍스트가 실제로 그려질 때"에만 파일을 받습니다. 그런데 캔버스fillText()는 그 트리거가 되지 않습니다.- 폰트는 종류에 따라 로딩 전략이 달라야 합니다. 에디터 기본 폰트는 없으면 에디터가 뜰 의미가 없으니 블로킹, 사용자 커스텀 폰트는 없어도 편집은 되니 비블로킹입니다. 이걸 한 경로로 합치면 둘 중 하나가 망가집니다.
- 폰트 등록은 "언제"가 아니라 "무엇보다 먼저/나중에"의 문제입니다. CSS 주입과 상태 발행 사이의 순서 자체가 계약이 됩니다.
등장하는 조각들
| 이름 | 역할 |
|---|---|
typefaceStore |
React 트리 밖의 module-scope 스토어. 폰트 카탈로그의 SSOT |
buildCatalog() |
폰트 메타 배열 → byFontId / byWeightId 두 개의 Map |
injectFontCss() |
@font-face CSS 텍스트를 <style>로 document에 주입 |
fontLookup.resolve() |
weightId + isBold → 실제 패밀리·웨이트 해석 |
ensureGlyphsLoaded() |
document.fonts.load()로 실제 파일 다운로드 요청 |
typefaceStore는 상태 관리 라이브러리의 atom이나 Context가 아니라 클래스 싱글턴 + useSyncExternalStore 조합입니다. 편집 계산 경로(캔버스 컴파일러, 텍스트 측정 유틸)가 훅을 쓸 수 없는 순수 함수라서, 파라미터로 카탈로그를 스레딩하지 않고 직접 조회할 수 있는 형태가 필요했습니다.
전체 흐름은 이렇습니다.
Phase 0 · 진입 게이트 (블로킹)
메타 fetch ∥ CSS fetch → buildCatalog → 기본 웨이트 assert
│
Phase 1 · 기본 폰트 CSS 주입 (전역 스타일)
│
Phase 2 · 커스텀 폰트 팩 (비블로킹)
URL별 CSS fetch → 전부 성공해야 다음으로
├─ ① injectFontCss() ← @font-face 동기 등록
└─ ② typefaceStore.setPack() ← 캔버스의 유일한 렌더 신호
(①→② 순서가 계약)
│
Phase 3 · 구독자 재렌더
폰트 선택 UI(데이터 읽음) / 렌더 프로그램 재컴파일(신호만 받음)
│
Phase 4 · ensureGlyphsLoaded()
document.fonts.load() → 글자폭 캐시 무효화 + 재측정
Phase 0 — 진입 게이트에서 기본 폰트를 확정합니다 (블로킹)
에디터 컴포넌트가 마운트되기 전에 실행되는 게이트 단계입니다. 여기서 기본 폰트 카탈로그가 완성되지 않으면 에디터를 아예 띄우지 않습니다.
export async function loadBootData() {
// 1. module store 선청소 — React 트리 밖이라 언마운트에 죽지 않는다
typefaceStore.reset();
// 2. 메타데이터와 CSS는 완전히 다른 소스다. 병렬로 받는다
const [fontMeta, baseFontCss] = await Promise.all([
queryClient.fetchQuery(fontsQueryOptions(language)), // API: id/name/weights/families
fetchBaseFontCss(), // 정적 스토리지의 .css 파일 텍스트
]);
// 3. 카탈로그 생성 — 기본 웨이트가 없으면 여기서 throw (= 진입 차단)
typefaceStore.seedBaseFonts(fontMeta);
return { baseFontCss /* 아직 DOM에 안 붙었다 */ };
}
세 가지 의도가 들어 있습니다.
메타데이터와 CSS는 별개 소스입니다. API는 폰트의 구조(무슨 폰트가 있고 웨이트가 몇 개인지)를 주고, CSS 파일은 브라우저가 렌더할 실체(@font-face)를 줍니다. 둘은 다른 서버, 다른 배포 주기입니다. 그래서 Promise.all로 병렬화하되 둘 다 도착해야 다음으로 넘어갑니다.
reset()이 맨 앞에 있는 이유. module-scope 스토어는 React 트리 밖에 있어서 컴포넌트 언마운트로 초기화되지 않습니다. 프로젝트 A를 편집하다 프로젝트 B로 넘어가면 A의 카탈로그가 살아남아 조용히 섞입니다. 이 종류의 버그는 에러도 나지 않고 로그도 남지 않습니다. 스토어를 module-scope에 두는 대가는 진입 시점의 명시적 초기화 목록을 관리하는 것입니다.
기본 웨이트 검증은 폴백이 아니라 assert가 맞습니다. 카탈로그에 기본 웨이트 ID가 없으면 텍스트를 그릴 수 없습니다. 이건 폴백으로 넘길 문제가 아니라 배포가 잘못된 상태이므로, 게이트에서 throw해서 에러 화면을 보여주는 편이 반쯤 동작하는 에디터보다 낫습니다.
seedBaseFonts(fonts: readonly Font[]) {
const catalog = buildCatalog(fonts, this.packFonts);
if (!catalog.byWeightId.has(DEFAULT_WEIGHT_ID)) {
throw new Error(`Default font weight is missing: ${DEFAULT_WEIGHT_ID}`);
}
this.publish(catalog);
}
Phase 1 — 기본 폰트 CSS 주입
게이트가 돌려준 CSS 문자열을 전역 스타일로 붓습니다.
const EditorRoot = ({ bootData }) => (
<StateProvider initialState={bootData.initialState}>
<GlobalFontStyle css={bootData.baseFontCss} />
<EditorLayout />
</StateProvider>
);
기본 폰트는 <style> 태그를 직접 관리하지 않고 CSS-in-JS 전역 스타일 하나로 끝냅니다. 게이트에서 이미 확정된 단일 불변 문자열이기 때문입니다. 다음 단계의 커스텀 폰트는 개수가 동적이고 나중에 추가되므로 처리 방식이 달라집니다.
Phase 2 — 커스텀 폰트 팩 (비블로킹, 반응형)
사용자가 워크스페이스에 등록한 폰트 팩입니다. 없어도 편집은 되니 진입을 막지 않습니다. 대신 도착하는 대로 반응해서 카탈로그에 합칩니다.
2-1. URL 수집 — sort()는 취향이 아니라 기능입니다
폰트 팩 응답은 팩 → 폰트 → 패밀리 3중 중첩이고, 여러 팩이 같은 CSS 파일을 참조할 수 있습니다.
const selectFontCssUrls = (data?: FontPackResponse): string[] =>
[
...new Set(
data?.packs?.flatMap(
(pack) => pack.fonts?.flatMap((f) => f.families.map((fam) => fam.cssPath)) ?? [],
) ?? [],
),
]
.filter(Boolean)
.sort(); // ← 아래 revision 문자열을 순서 무관하게 만들기 위한 것
뒤에서 이 URL 목록을 그대로 직렬화해 "이 조합을 이미 반영했는가"를 판단하는 revision으로 씁니다. 같은 집합이 다른 순서로 도착하면 불필요한 재동기화가 돌기 때문에, 정렬이 성능 최적화가 아니라 동작의 일부입니다.
2-2. URL 하나당 쿼리 옵션 팩토리
const fetchFontCss = async (url: string): Promise<string> => {
const res = await fetch(url);
if (!res.ok) throw new Error(`Font CSS request failed: ${res.status}`);
const css = await res.text();
if (!css.includes("@font-face")) throw new Error("Font CSS has no @font-face");
return css;
};
export const fontCssQueryOptions = (url: string) =>
queryOptions({
queryKey: ["font-css", url],
queryFn: () => fetchFontCss(url),
staleTime: Infinity,
gcTime: Infinity,
});
이 20줄에 결정이 네 개 들어 있습니다.
<link rel="stylesheet">가 아니라 텍스트로 받습니다. <link>는 로드 완료 시점이 비동기라 "@font-face가 등록된 순간"을 코드가 붙잡을 수 없습니다. 뒤에서 보듯 그 순간이 계약이라서, CSS를 문자열로 받아 <style>에 동기 주입하는 형태가 필요했습니다.
@font-face 문자열 검사는 신뢰 경계 검증입니다. 오브젝트 스토리지는 설정이 틀리면 에러 HTML을 200으로 돌려줍니다. 이걸 그대로 <style>에 넣으면 CSS 파서가 조용히 무시하고, 폰트는 폴백으로 대체됩니다. 에러 없이 잘못 렌더되는 것이 최악의 결과라서 여기서 끊습니다.
staleTime과 gcTime을 둘 다 Infinity로 둡니다. CSS 파일은 URL이 곧 버전인 불변 리소스입니다. 그리고 GC가 일어나면 재fetch → 준비 상태가 undefined로 떨어졌다 돌아오며 revision이 진동해서 동기화 이펙트가 헛돕니다. staleTime만으로는 이 진동이 막히지 않습니다.
훅이 아니라 팩토리인 이유. URL 개수가 동적이라 useQuery를 개별 호출할 수 없습니다. useQueries에 배열로 넣어야 하고, 그러려면 옵션 객체를 만드는 순수 함수가 필요합니다. 부수 효과로 테스트에서 queryClient.fetchQuery(옵션)으로 같은 정의를 재사용할 수 있습니다.
2-3. 준비 상태는 all-or-nothing
export const useFontPackCssQuery = (data?: FontPackResponse) => {
const urls = useMemo(() => selectFontCssUrls(data), [data]);
const results = useQueries({ queries: urls.map(fontCssQueryOptions) });
const resources = results.flatMap((r, i) => (r.data ? [{ url: urls[i], css: r.data }] : []));
const isReady =
data !== undefined && results.every((r) => r.isSuccess) && resources.length === urls.length;
return {
// 부분 성공은 undefined — 절반만 등록된 상태를 아래로 흘리지 않는다
readyBundle: isReady ? { revision: JSON.stringify(urls), resources } : undefined,
failedUrls: urls.filter((_, i) => results[i]?.isError),
retryFailed: () => Promise.all(results.filter((r) => r.isError).map((r) => r.refetch())),
};
};
폰트 5개 중 3개만 도착한 상태로 카탈로그를 갱신하면 사용자는 폰트 목록에서 "있는데 적용은 안 되는" 항목을 만납니다. 그래서 전부 성공할 때까지 undefined를 유지하고, 실패한 URL은 따로 노출해 재시도 UI를 붙일 수 있게 했습니다.
2-4. 주입 → 발행, 이 순서가 계약입니다
export const useSyncFontPack = (data?: FontPackResponse, bundle?: FontCssBundle) => {
const syncedRef = useRef<{ data: FontPackResponse; revision: string }>();
useEffect(() => {
if (!data || !bundle) return;
if (syncedRef.current?.data === data && syncedRef.current.revision === bundle.revision) return;
const groups = buildPackGroups(data);
try {
// ① <style> append는 동기적으로 @font-face를 등록한다
injectFontCss(bundle.resources);
// ② 그 다음 스토어 발행 — 이게 캔버스의 유일한 렌더 신호다
typefaceStore.setPackFonts(groups);
syncedRef.current = { data, revision: bundle.revision };
} catch (error) {
// syncedRef를 갱신하지 않는 것이 재시도 메커니즘이다
reportError("Font pack sync failed", { error });
}
}, [data, bundle]);
};
이 두 줄의 순서가 파이프라인에서 가장 비싸게 얻은 지식입니다. 스토어 발행이 캔버스 재렌더의 트리거이므로, 발행이 주입보다 먼저 일어나면 캔버스는 @font-face가 아직 없는 상태에서 텍스트를 그립니다. 그리고 캔버스 렌더는 폰트가 나중에 도착했다고 알아서 다시 그리지 않습니다. 한 프레임의 순서가 뒤집힌 대가가 영구적인 폴백 글꼴입니다.
부수적으로 세 가지 방어가 더 들어 있습니다.
syncedRef의 revision 비교로 같은 조합의 재실행을 막습니다.useEffectdeps만으로는 참조 동일성이 흔들릴 때 중복 주입이 돕니다.setPackFonts는 기본 폰트가 아직 없으면 early return합니다. 커스텀 팩이 기본 폰트보다 먼저 도착하는 순서 뒤집힘 방어입니다.- 실패 시
syncedRef를 갱신하지 않는 것이 그대로 재시도가 됩니다. 별도 재시도 코드가 없습니다.
Phase 3 — 데이터를 읽는 쪽과 신호만 받는 쪽
스토어 발행은 version을 올리고 구독자를 깨웁니다. 그런데 구독자가 두 종류입니다.
// A. 데이터를 읽는 쪽 — 폰트 선택 UI
const snapshot = useTypefaceSnapshot();
// B. 데이터를 안 읽고 "바뀌었다"만 알아야 하는 쪽 — 캔버스 렌더 프로그램
export const useRenderProgram = (slideId: string) => {
const input = useSceneInput(slideId);
const revision = useTypefaceRevision();
return useMemo(() => {
// 컴파일러 내부가 fontLookup을 직접 읽으므로
// revision을 명시적으로 소비해서 memo를 무효화한다
void revision;
return compileRenderProgram(input);
}, [input, revision]);
};
void revision;은 처음 보면 죽은 코드처럼 보이지만 필수입니다. compileRenderProgram은 인자로 폰트를 받지 않고 내부에서 스토어를 직접 조회합니다. 그러면 useMemo의 deps에 폰트 의존성이 드러나지 않습니다. 숨은 의존성을 명시적 revision으로 끌어올려 deps에 넣는 것이, 컴파일러 시그니처를 바꿔 카탈로그를 전 경로에 스레딩하는 것보다 싼 해법이었습니다.
한편 편집 경로의 폰트 해석은 전부 이 한 곳을 지납니다.
export const fontLookup = {
resolve: (weightId: number, isBold: boolean): ResolvedFont => {
const resolved = resolveFont(typefaceStore.getCatalog(), weightId, isBold);
if (!resolved) throw new Error(`Unable to resolve weight: ${weightId}`);
return resolved;
},
};
여기에 정규화 규칙이 하나 붙습니다. 런타임 SSOT는 weightId 하나입니다. 서버 응답에는 fontFamily, fontWeight, fontId 같은 편의 필드가 같이 오는데, 이걸 그대로 들고 다니면 어느 게 진실인지 모르는 상태가 됩니다. 그래서 로드 시점에 전부 버리고 weightId로 접습니다.
export const normalizeFontFields = (obj) => {
const next = { ...obj };
delete next.fontFamily;
delete next.fontWeight;
delete next.fontId;
return { ...next, weightId: obj.weightId ?? DEFAULT_WEIGHT_ID };
};
isBold는 별도 웨이트로 저장하지 않고 "base 웨이트에서 한 단계 굵은 웨이트로 승격"하는 modifier로 취급합니다. 승격 대상이 없으면 false로 내려갑니다. 렌더에 쓰는 effective 웨이트를 저장하지 않는 것이 요점입니다 — 저장하면 base와 effective가 언젠가 반드시 어긋납니다.
Phase 4 — @font-face 등록은 다운로드가 아닙니다
여기가 캔버스 에디터 고유의 함정입니다.
DOM에 <span style="font-family: X">가 있으면 브라우저가 X를 알아서 받아옵니다. 하지만 캔버스 fillText()는 @font-face 다운로드를 촉발하지 않습니다. CSS도 붙었고 카탈로그도 갱신됐는데 캔버스에는 폴백 글꼴이 나오는 상태가 여기서 생깁니다.
const requested = new Map<string, Promise<void>>();
const listeners = new Set<(family: string) => void>();
export function ensureGlyphsLoaded(family: string, weight: number | string = 400) {
const key = family.trim();
if (!key || GENERIC_FAMILIES.has(key.toLowerCase())) return Promise.resolve();
const requestKey = `${weight} ${key}`;
let promise = requested.get(requestKey);
if (!promise) {
promise = document.fonts.load(`${weight} 16px "${key}"`).then(
(faces) => {
// 등록된 @font-face가 없으면 빈 배열 — 폴백 유지가 맞으므로 무시
if (faces.length > 0) listeners.forEach((fn) => fn(key));
},
() => {
/* 로드 실패 = 폴백 렌더 유지. reject하지 않는다 */
},
);
requested.set(requestKey, promise);
}
return promise;
}
세 가지 디테일이 있습니다.
웨이트를 파라미터로 받아야 합니다. 한 패밀리에 여러 웨이트의 @font-face가 등록되어 있으면 document.fonts.load('16px "X"')는 400만 받아옵니다. Bold 텍스트는 여전히 브라우저가 합성한 가짜 굵은 글씨로 그려집니다. 요청에 웨이트를 명시해야 그 face가 옵니다.
실패를 reject하지 않습니다. 폰트 로드 실패에는 폴백 렌더라는 정상 경로가 있습니다. 여기서 reject하면 이걸 기다리는 재생 프리롤 같은 상위 로직이 같이 죽습니다. 그래서 실패도 resolve로 처리하고 대기자는 그냥 진행합니다.
로드 완료 리스너가 필요합니다. 캔버스 라이브러리는 대부분 전역 글자폭 캐시를 들고 있고, 폰트가 나중에 로드된 것을 인지하지 못합니다. 그래서 로드 완료 시점에 캐시를 비우고 텍스트를 재측정하는 리스너를 붙여야 레이아웃이 맞습니다.
곁가지 — registry의 SSOT를 DOM에 두기
커스텀 폰트 CSS 주입기는 "이미 넣었는지"를 module 변수가 아니라 DOM에 기록합니다.
const URL_ATTR = "data-font-css-url";
const findRegistered = (url: string) =>
[...document.head.querySelectorAll<HTMLStyleElement>(`style[${URL_ATTR}]`)].find(
(el) => el.getAttribute(URL_ATTR) === url,
);
export const injectFontCss = (resources: readonly FontCssResource[]) => {
resources.forEach(({ url, css }) => {
if (findRegistered(url)) return;
const style = document.createElement("style");
style.setAttribute(URL_ATTR, url);
style.textContent = css;
document.head.append(style);
});
};
const registered = new Set<string>()로 짰다면 개발 중에 이 파일을 저장할 때마다 HMR이 모듈을 교체하면서 Set이 빈 상태로 초기화됩니다. 이미 document.head에 있는 <style>을 보지 못하니 같은 CSS가 계속 중복 주입됩니다. DOM은 HMR로 리셋되지 않으므로, DOM 자체를 registry로 쓰면 중복이 구조적으로 불가능해집니다.
querySelectorAll이 Set.has()보다 느린 건 사실이지만, 호출 빈도가 폰트 팩 변경 시점뿐이라 정확성을 사는 값으로는 저렴합니다.
정리 — 지켜야 할 불변식 네 개
| 불변식 | 깨지면 |
|---|---|
| 기본 폰트는 게이트 블로킹, 커스텀 폰트는 사후 반응형 | 폰트 없이 에디터가 뜨거나, 커스텀 폰트가 진입을 지연시킨다 |
| CSS 주입 → 그 다음 스토어 발행 | 폴백 글꼴로 렌더된 뒤 다시 고쳐지지 않는다 |
| 준비 상태는 all-or-nothing | 폰트 목록에 "있는데 안 되는" 항목이 생긴다 |
| registry SSOT는 DOM (module 변수 아님) | 개발 중 저장할 때마다 CSS가 중복 주입된다 |
되돌아보면 이 파이프라인의 복잡도는 대부분 "폰트가 준비됐다"는 말에 네 가지 뜻이 있다는 데서 나옵니다. 메타데이터가 있다, CSS 텍스트를 받았다, @font-face가 document에 등록됐다, 폰트 파일이 실제로 다운로드됐다 — 넷은 각각 다른 시점이고 각각 다른 실패 모드를 가집니다. 이 넷을 뭉쳐서 isFontReady 같은 boolean 하나로 다루려던 초기 시도가 가장 오래 디버깅한 버그의 원인이었습니다.
단계를 나누고, 단계 사이의 순서를 주석이 아니라 구조로 강제하는 것. 결국 그게 답이었습니다.