웹뷰 시스템 폰트 크기 대응, 리뷰에서 설계까지
그 플래시는 useLayoutEffect로 막을 수 없었다.
회사가 금융기관 라이센스를 취득하는 것을 전사 목표로 삼으면서 웹 모바일 접근성 지침을 검토하면서 요구사항이 하나 들어왔다.
OS에서 글자 크기를 키운 사용자에게는 앱 안의 웹뷰 화면도 함께 커져야 한다는 것이었다.
특히 우리 앱은 50대 이상 사용자가 많아 작은 글자를 불편해하는 사용자가 적지 않았다. 네이티브 화면은 시스템 설정을 따라가는데, 웹뷰로 만든 화면만 작은 글자 그대로 남아 있었다.
Android WebView의 기본값은 버전에 따라 설명을 조심해야 한다.
공개 WebSettings 문서에는 textZoom 기본값이 100이라고 적혀 있지만, 최근 Chromium WebView 구현은 앱이 setTextZoom()을 명시하지 않았을 때 Configuration.fontScale을 내부 텍스트 배율에 반영한다. 어느 쪽이든 CSS 레이아웃의 의도를 모른 채 텍스트 확대가 적용되면, 고정 높이나 촘촘한 UI가 많은 화면은 쉽게 깨질 수 있다. 우리 앱은 동작을 버전에 맡기지 않고 이중 스케일을 막기 위해 setTextZoom(100)을 명시하고 있었다.
iOS의 WKWebView는 반대다. 웹 콘텐츠가 -apple-system-body 같은 시스템 폰트 경로를 선택하지 않으면 Dynamic Type을 자동으로 따라가지 않는다.
결과적으로 두 플랫폼 모두 웹이 크기 정책을 직접 가져야 하는 상태였다.
업무는 동료에게 배정됐고, 동료는 실험 페이지에 첫 구현을 만들어왔다.
나는 리뷰어였다. 우리는 코드 리뷰를 다른 동료들은 시스템에서 랜덤 리뷰어로 지정하고 있고 최종적으로 리드에게 리뷰는 반드시 받도록 그라운드 룰로 정하고 있다. (정확히는 그냥 내가 동료들이 작성하는 코드는 전부 보고 있다. 태클도 제일 많이 걸어서 리젝율이 높다)
이 글은 그 구현에 “새로고침을 해보자”고 말한 뒤, 실험 페이지의 로컬 상태가 CDN 캐시와 브라우저 페인트 타이밍, 디자인시스템, React 외부 저장소까지 연결되는 과정을 정리한 글이다.
환경은 Next.js Pages Router와 React 18, Tailwind 기반 디자인시스템, 그리고 iOS·Android 네이티브 앱의 웹뷰였다.
먼저 결론
우리가 최종적으로 선택한 구조는 다음과 같다.
- 네이티브는 OS의 폰트 선호 신호를 읽되, 화면 크기 정책은 결정하지 않는다.
- 네이티브가 웹뷰 생성 시점에
documentStart스크립트로 원시 신호를 주입한다. - 문서
<head>의 짧은 동기 스크립트가 첫 페인트 전에 그 신호를 디자인시스템의 버킷으로 정규화한다. - 결과는
<html data-font-size="...">와 CSS 변수로 배포한다. - 대부분의 컴포넌트는 CSS 토큰만 사용한다. React는 폰트 스케일을 상태로 소유하지 않는다.
- 가상 리스트나 캔버스처럼 숫자가 꼭 필요한 예외만
useSyncExternalStore로 버킷을 구독한다. - 서버는 폰트 설정을 모른다. 그래서 SSG·ISR과 CDN 공유 캐시를 그대로 유지한다.
flowchart LR A["OS 글자 크기 설정"] --> B["네이티브: 원시 선호 신호 획득"] B --> C["documentStart 주입"] C --> D["head 부트스트랩: 정책 버킷 결정"] D --> E["html[data-font-size]"] E --> F["CSS 변수와 디자인 토큰"] F --> G["일반 컴포넌트"] D --> H["외부 store: 현재 버킷"] H --> I["숫자가 필요한 JS 예외"]
이 결론에서 중요한 것은 어떤 API를 썼느냐가 아니다. 첫 페인트를 결정하는 값은 첫 페인트보다 먼저 존재해야 한다는 것, 그리고 캐시를 공유해야 하는 값은 서버 응답을 사용자별로 바꾸지 않아야 한다는 것이 핵심이다.
요구사항의 경계부터 다시 정했다
폰트 크기 대응에서는 “시스템 설정을 따른다”는 문장이 생각보다 모호하다.
iOS와 Android는 동일한 형태의 배율을 주지 않는다. iOS Dynamic Type은 UIContentSizeCategory라는 이산(?) 카테고리이며, 실제 크기 곡선은 body, headline, title 같은 텍스트 스타일마다 다르다. Android도 14부터 비선형 폰트 스케일링을 적용한다. Configuration.fontScale은 사용자의 선호를 알려주는 신호이지만, 모든 글자가 그 숫자만큼 선형으로 커진다는 뜻은 아니다.
따라서 네이티브와 웹의 크기를 픽셀 단위로 완전히 동일하게 복제하는 것을 목표로 삼으면 두 플랫폼의 정책 차이를 숨기게 된다.
우리는 요구사항을 다음처럼 구체화했다.
사용자가 설정한 OS의 글자 크기 설정을 웹뷰에도 전달한다. 웹은 그 신호를 디자인시스템이 검증한 유한한 상태로 정규화하고, 읽기와 행동에 필요한 텍스트를 우선 확대한다.
여기에는 중요한 한계도 있다. 이 글에서 설명하는 5단 버킷과 상한은 제품의 단계적 가독성 개선 정책이지, 그 자체로 WCAG 적합성을 보장하지 않는다. WCAG 2.2 AA를 목표로 한다면 텍스트와 컨트롤 라벨이 200%까지 커져도 내용과 기능이 손실되지 않는지 별도로 검증해야 한다. Reflow 기준에 대해서도 400% 확대에 해당하는 320 CSS px 너비에서 예외 콘텐츠를 제외하고 양방향 스크롤 없이 사용할 수 있는지 확인해야 한다.
이 경계를 먼저 밝히는 이유는 간단하다. 상한을 둔 설계를 “시스템 설정을 그대로 따른다”고 부르면 구현은 동작해도 요구사항 설명은 틀리기 때문이다.
첫 구현: 서버가 값을 알고 React가 들고 있기
동료의 첫 설계는 Pages Router의 getServerSideProps를 축으로 했다.
네이티브가 웹뷰를 열 때 시스템 배율을 커스텀 헤더에 실어 보내고, getServerSideProps가 그 값을 읽어 props로 내린다. FontScaleProvider는 그 값을 Context로 공급하고, 설정 변경은 브릿지 이벤트를 받아 state에 반영한다.
sequenceDiagram participant N as 네이티브 앱 participant W as 웹뷰 participant S as Next.js 서버 N->>N: 시스템 글자 크기 조회 N->>S: GET /home + X-Font-Scale S->>S: getServerSideProps에서 헤더 읽기 S-->>W: HTML + __NEXT_DATA__ W->>W: useState(initialScale) W->>W: Context로 하위 컴포넌트에 공급
// Android: 앱이 시작한 최초 문서 요청에 헤더 추가
val headers = mapOf("X-Font-Scale" to fontScale.toString())
webView.loadUrl(url, headers)export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const raw = Number(req.headers["x-font-scale"] ?? 1);
const initialScale = Number.isFinite(raw) && raw > 0 ? raw : 1;
return {
props: {
initialScale
}
};
};function FontScaleProvider({ initialScale, children }: Props) {
const [scale, setScale] = useState(initialScale);
useEffect(() => {
const onChange = (event: Event) => {
setScale((event as CustomEvent<number>).detail);
};
window.addEventListener("fontscalechange", onChange);
return () => window.removeEventListener("fontscalechange", onChange);
}, []);
return <FontScaleContext.Provider value={scale}>{children}</FontScaleContext.Provider>;
}첫 진입에서는 잘 동작했다. 서버가 1.3을 알고 있었기 때문에 HTML도 처음부터 1.3 기준으로 만들어졌다. __NEXT_DATA__에도 같은 값이 들어가 서버 렌더와 클라이언트의 첫 렌더가 맞았다.
잠깐 getServerSideProps 동작을 다음 다이어그램으로 살펴보자.

문제는 리뷰 중 Preview 배포 화면을 테스트 해보며 새로고침을 눌렀을 때 드러났다.
새로고침을 누르자 작은 글자가 먼저 보였다
새로고침된 페이지에는 작은 글자가 먼저 나타났고 잠시 뒤 큰 글자로 바뀌었다. 텍스트뿐 아니라 줄바꿈과 카드 높이도 함께 변했다. 플래시와 레이아웃 이동이 한 번에 보였다.
원인은 단순했다. loadUrl(url, headers)로 전달한 추가 헤더는 그 네이티브 API가 만든 내비게이션에 결합된다. WebView의 reload(), 페이지 안에서 발생한 내비게이션, Next.js의 /_next/data/...json 요청은 네이티브가 같은 헤더 맵으로 새로 만든 요청이 아니다.
새로고침 요청에서 서버는 기본값 1을 사용했다. 브라우저는 작은 글자 기준 HTML을 그렸고, React가 하이드레이션된 뒤 브릿지 값을 읽어 state를 바꾸면서 그제야 화면이 커졌다.

useLayoutEffect로도 보장할 수 없는 이유
가장 먼저 떠오르는 수정은 useEffect를 useLayoutEffect로 바꾸는 것이다.
useLayoutEffect는 React가 DOM 변경을 커밋한 뒤, 브라우저가 그 커밋을 다시 그리기 전에 동기로 실행된다. 여기서 중요한 것은 “그 커밋”이다. 서버 HTML이 사용자에게 이미 보였는지까지 되돌려주는 API는 아니다.
브라우저가 언제 첫 페인트를 수행하는지는 네트워크, 캐시, CSS와 스크립트의 로딩 순서에 따라 달라질 수 있다.
빠른 환경에서는 하이드레이션이 첫 페인트보다 먼저 끝나는 경우도 있을 수 있다. 하지만 useLayoutEffect는 서버 마크업이 먼저 보이지 않는다고 보장하지 않는다. 따라서 SSR 초기 화면의 플래시를 막는 신뢰 가능한 장치로 사용할 수 없다.
sequenceDiagram participant B as 브라우저 participant R as React B->>B: HTML 수신·파싱 B->>B: 작은 글자 기준 첫 페인트가 발생할 수 있음 B->>R: JS 번들 실행·하이드레이션 R->>R: useLayoutEffect 실행 R->>B: 큰 글자 기준 커밋 B->>B: 다음 페인트
이 문제를 해결하려면 React 훅을 더 빠른 훅으로 바꾸는 것이 아니라 값을 React보다 앞선 레이어로 옮겨야 했다.
첫 페인트 전에 값을 적용할 수 있는 경로는 크게 세 가지다.
- 서버가 처음부터 올바른 HTML과 속성을 만든다.
- 네이티브가 문서 생성 시점에 스크립트를 주입한다.
- 문서
<head>의 짧은 파서 차단 스크립트가 초기 속성을 설정한다.
첫 설계는 첫 번째 경로를 택했다. 그런데 새로고침에서는 서버가 값을 몰랐다. 다음 질문은 자연스럽게 “서버가 값을 계속 알게 만들 수 있는가”였다.
헤더를 쿠키로 승격하면 해결될까
헤더는 최초 내비게이션에만 안정적으로 존재한다. 쿠키는 범위가 맞으면 같은 오리진의 이후 요청에 브라우저가 자동으로 첨부한다.
그래서 첫 요청의 헤더를 미들웨어에서 쿠키로 승격하는 안을 검토했다.
// middleware.ts (단순 예시입니다!)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const scale = request.headers.get("x-font-scale");
if (scale) {
response.cookies.set("font_scale", scale, {
path: "/",
secure: true,
sameSite: "lax",
});
}
return response;
}첫 요청에서는 헤더를 읽고, 이후 요청에서는 쿠키를 읽으면 서버가 항상 값을 알 수 있다. 기능적으로는 동작했다. 플래시도 사라졌다.
하지만 서버 응답이 사용자 설정에 따라 달라지는 순간 캐시 설계가 함께 바뀐다.
CDN 캐시 키는 우리가 읽지 않은 값까지 알아서 구분해주지 않는다
CDN은 요청을 오리진까지 보내지 않기 위해 존재한다. 캐시 히트가 나면 getServerSideProps도 미들웨어의 읽기 로직도 실행되지 않는다.
예를 들어 1.0 사용자의 /home 응답이 먼저 캐시됐다고 하자. 캐시 키에 폰트 설정이 없다면 뒤이어 온 1.3 사용자도 같은 HTML을 받는다. __NEXT_DATA__까지 1.0으로 일치하기 때문에 hydration mismatch도 발생하지 않는다. 화면은 조용하고 일관되게 틀린다.
HTTP의 표준 장치는 Vary다.
Vary: X-Font-Scale하지만 CloudFront는 임의의 Vary 값을 오리진 응답만 보고 자동으로 캐시 키에 반영하지 않는다. Cache Policy에서 어떤 헤더와 쿠키를 캐시 키에 넣을지 명시해야 한다.

하이브리드 구조는 더 까다롭다.
- 최초 요청에는
X-Font-Scale헤더가 있고font_scale쿠키가 없을 수 있다. - 이후 요청에는 헤더가 없고 쿠키만 있다.
- 헤더만 키에 넣으면 이후 쿠키 요청끼리 충돌한다.
- 쿠키만 키에 넣으면 최초 헤더 요청끼리 충돌한다.
둘을 모두 키에 넣으면 정합성은 맞지만 배율 하나당 최초 요청 변형과 이후 요청 변형이 따로 생긴다. Viewer Request 단계에서 두 채널을 하나의 정규화된 키로 합칠 수도 있지만, 그만큼 CDN 설정과 운영 책임이 커진다.
쿠키 전달을 켠 CloudFront가 오리진의 Set-Cookie 응답 헤더까지 캐시할 수 있다는 점도 주의해야 한다. 공유 캐시가 Set-Cookie를 재전송하지 않도록 Cache-Control: no-cache="Set-Cookie" 같은 정책을 별도로 설계해야 한다. 세션 쿠키와 함께 다루는 서비스라면 정확성뿐 아니라 보안 경계의 문제다.
그리고 가장 큰 비용이 하나 더 있다. 폰트 설정을 getServerSideProps에서 읽는 페이지는 정적 생성과 ISR 대상에서 빠지고 요청 시점 SSR로 전환된다. 공유 캐시를 열더라도 캐시 미스에서는 오리진 렌더링을 기다려야 한다.

여기서 질문이 바뀌었다.
서버가 이 값을 꼭 알아야 하는가?
서버 밖에서 첫 페인트보다 먼저 값을 얻기
검토한 후보는 다음과 같았다.
| 방식 | 판단 |
|---|---|
| 최초 요청 헤더 | 새로고침과 웹 주도 요청을 지속적으로 커버하지 못함 |
| 헤더 + 서버 쿠키 | 동작하지만 캐시 키, Set-Cookie, SSR 비용이 생김 |
| User-Agent 접미사 | 여러 요청에 유지되지만 분석과 캐시 키를 오염시킴 |
| 쿼리 파라미터 | 링크·리다이렉트·공유 URL에 설정이 노출되고 유실되기 쉬움 |
| Service Worker | WebView별 지원과 생명주기 제약에 비해 얻는 이점이 작음 |
documentStart 주입 |
서버와 무관하며 페이지 스크립트보다 먼저 값을 확보할 수 있음 |
결론은 documentStart 주입이었다.
iOS는 WKUserScript의 .atDocumentStart를 사용한다. Android는 지원되는 WebView에서 WebViewCompat.addDocumentStartJavaScript를 사용한다. 두 API 모두 페이지 JavaScript보다 먼저 실행되는 경로를 제공한다.
let payload = #"window.__FONT_PREFERENCE__={"platform":"ios","category":"\#(category.rawValue)"};"#
let script = WKUserScript(
source: payload,
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
configuration.userContentController.addUserScript(script)실제 코드에서는 문자열 보간으로 임의의 JavaScript를 만들기보다 JSONEncoder 등으로 payload를 직렬화하고 허용된 카테고리인지 검증해야 한다.
WKUserScript에는 Android의 allowedOriginRules와 같은 오리진 인자가 없다. 웹뷰가 외부 페이지로 이동할 수 있다면 WKNavigationDelegate에서 허용 오리진만 웹뷰 안에 남기고 외부 링크는 시스템 브라우저로 보내거나, 주입 스크립트 자체가 location.origin을 먼저 확인하도록 해야 한다.
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
WebViewCompat.addDocumentStartJavaScript(
webView,
"window.__FONT_PREFERENCE__=$serializedPayload",
setOf("https://app.example.com")
)
}Android에서는 allowedOriginRules를 *로 열지 않고 실제 웹뷰 오리진으로 제한한다. 스크립트 등록은 loadUrl()보다 먼저 끝나야 하며, 페이지 로딩을 막는 코드이므로 가능한 한 짧아야 한다.
sequenceDiagram participant N as 네이티브 participant E as WebView 엔진 participant D as 문서 N->>E: documentStart 스크립트 등록 E->>D: 새 문서 생성 E->>D: window.__FONT_PREFERENCE__ 주입 D->>D: head 부트스트랩 실행 D->>D: data-font-size 설정 D->>D: 첫 페인트
이제 서버는 모든 사용자에게 같은 HTML을 보낼 수 있다. 사용자 설정은 문서가 생성된 뒤, 그러나 첫 페인트보다 앞선 클라이언트 레이어에서 적용된다.
구형 Android의 폴백 쿠키는 서버 개인화에 쓰지 않았다
addDocumentStartJavaScript는 WebView APK와 AndroidX WebKit 조합에 따라 지원 여부가 달라진다. 버전 숫자를 추측하는 대신 런타임에서 WebViewFeature.isFeatureSupported()로 확인해야 한다.
미지원 환경에서는 쿠키를 폴백 채널로 사용할 수 있다. 여기서 쿠키는 서버가 읽는 개인화 신호가 아니다. 문서의 초기 인라인 스크립트만 클라이언트에서 읽는다. 브라우저 요청에는 실리지만 CloudFront 캐시 키에는 넣지 않고, Origin Request Policy에서도 제외해 오리진에는 전달하지 않는다.
다만 CookieManager.setCookie()는 비동기다. 다음 문서 로드에서 반드시 보이게 하려면 콜백 완료 후 loadUrl()을 호출해야 한다. (참고: https://developer.android.com/reference/android/webkit/CookieManager)
val cookieManager = CookieManager.getInstance()
val cookie = "font_preference=$encodedValue; Path=/; Secure; SameSite=Lax"
cookieManager.setCookie(origin, cookie) { success ->
if (success) {
webView.loadUrl(url)
} else {
// 기본 버킷으로 진행하고 실패를 관측한다.
webView.loadUrl(url)
}
}쿠키는 <head> 스크립트가 document.cookie로 읽어야 하므로 HttpOnly으로 설정할 수 없다. 대신 민감한 값을 넣지 않고, 이름과 값의 허용 범위를 제한한다.
Android 5.0 이후 WebView는 대체로 OS와 별개로 업데이트되지만, Play 스토어 사용 여부와 OEM 정책에 따라 오래된 WebView가 남을 수 있다. 실제 지원 범위는 User-Agent의 WebView 버전 분포와 런타임 feature check를 함께 기록해 판단하는 편이 안전하다.
정책 레이어: 서로 다른 플랫폼 신호를 유한한 상태로 닫기
채널을 정한 뒤에는 “받은 값을 무엇으로 바꿀 것인가”가 남았다.
Android의 fontScale과 iOS의 Dynamic Type 카테고리는 같은 값이 아니다. 네이티브가 이 차이를 숨긴 가짜 공통 배율을 만들어 보내면 웹은 어떤 정보가 손실됐는지 알 수 없다.
그래서 전달 payload에는 플랫폼의 원시 신호를 보존하고, 웹의 정책 resolver가 디자인시스템 버킷을 결정하게 했다.
앱이 이 단계를 해주는 것도 방법이지만, 이 기능은 웹에서 사용하는 기능이고 앱은 버전 분기가 필요하므로 이런 정책 로직은 웹에 두는 것이 맞다고 판단했다. (나중에 변경이 필요하면 피곤한 건 웹이다)
type FontPreference =
| { platform: "android"; fontScale: number }
| { platform: "ios"; category: string }
| { platform: "web" };
type FontSizeBucket = "xs" | "s" | "m" | "l" | "xl";
type FontSizeSnapshot = Readonly<{
bucket: FontSizeBucket;
source: "document-start" | "cookie" | "runtime" | "default";
}>;정책은 플랫폼별 입력을 버킷으로 매핑한다.
아래 경계값은 알고리즘의 형태를 보여주기 위한 예시다. 실제 값은 지원 OS의 설정 단계 분포와 디자인 QA 결과를 근거로 정해야 한다.
function resolveBucket(preference: FontPreference): FontSizeBucket {
if (preference.platform === "android") {
const value = preference.fontScale;
if (!Number.isFinite(value) || value <= 0) return "m";
if (value <= 0.87) return "xs";
if (value <= 0.97) return "s";
if (value <= 1.07) return "m";
if (value <= 1.22) return "l";
return "xl";
}
if (preference.platform === "ios") {
return IOS_CATEGORY_TO_BUCKET[preference.category] ?? "m";
}
return "m";
}여기서 Android의 숫자는 네이티브 sp와 동일한 실제 배율을 복제하기 위한 값이 아니다. Android 14 이상의 비선형 크기 곡선을 단일 숫자로 재현할 수도 없다. 이 값은 사용자의 선호를 제품 버킷에 매핑하기 위한 입력일 뿐이다.
iOS도 마찬가지다. UIContentSizeCategory를 먼저 버킷으로 매핑하고, 본문과 헤딩의 실제 확대 비율은 디자인 토큰에서 별도로 결정한다.
이 지점부터 이벤트 이름도 첫 구현의 fontscalechange에서 fontpreferencechange로 바꿨다. 최종 이벤트가 전달하는 것은 웹 배율이 아니라 플랫폼의 원시 선호 신호이고, 실제 배율과 버킷은 웹 정책이 결정하기 때문이다.

첫 페인트 부트스트랩은 하나의 스냅샷만 만든다
문서 <head>의 첫 인라인 스크립트는 세 가지 일만 한다.
- document-start 전역값 또는 쿠키에서 원시 신호를 읽는다.
- 검증과 버킷 정책을 적용한다.
- 결과를
<html>속성과 전역 스냅샷에 동시에 기록한다.
<script>
(function () {
// 실제 빌드에서는 resolvePreference와 허용 목록을 함께 인라인한다.
var injected = window.__FONT_PREFERENCE__;
var cookieMatch = document.cookie.match(
/(?:^|;\s*)font_preference=([^;]+)/
);
var resolved = resolvePreference(
injected,
cookieMatch ? decodeURIComponent(cookieMatch[1]) : null
);
window.__FONT_SIZE_STATE__ = resolved;
document.documentElement.dataset.fontSize = resolved.bucket;
performance.mark("font-scale-applied");
})();
</script>쿠키 폴백에서도 window.__FONT_SIZE_STATE__를 반드시 설정한다. 그래야 CSS와 나중에 초기화되는 JS store가 같은 결정을 본다. 브라우저는 이 쿠키를 CloudFront까지 전송하므로, Cache Policy와 Origin Request Policy에서 font_preference를 캐시 키와 오리진 전달 대상에서 모두 제외한다.
이 스크립트는 가능한 한 정적으로 유지한다. 엄격한 CSP를 사용한다면 요청마다 nonce를 생성해 정적 페이지를 동적으로 만들기보다, 내용이 고정된 스크립트에 CSP hash를 적용하는 방법을 먼저 검토할 수 있다.
Pages Router에서는 이 속성이 React 루트인 #__next 바깥의 <html>에 적용되므로, 애플리케이션 컴포넌트의 hydration 결과를 직접 바꾸지 않는다. 다른 렌더링 구조로 옮길 때는 React가 소유하는 노드를 hydration 전에 수정하는지 다시 확인해야 한다.

폰트 스케일은 React state가 아니라 CSS 변수다
버킷이 정해졌으면 일반 컴포넌트는 원시 배율을 알 필요가 없다.
html[data-font-size="m"] {
--scale-body: 1;
--scale-heading: 1;
}
html[data-font-size="l"] {
--scale-body: 1.15;
--scale-heading: 1.08;
}
html[data-font-size="xl"] {
--scale-body: 1.3;
--scale-heading: 1.15;
}여기서도 1.15, 1.3 같은 값은 예시다. 실제 토큰 배율은 버킷별 시각 회귀와 접근성 검증을 거친 값으로 교체해야 한다.
버킷별로 토큰마다 다른 비율을 둘 수 있다. 큰 설정에서 본문은 더 많이 키우고 이미 큰 헤딩은 덜 키우는 식이다. iOS Dynamic Type과 Android 14의 비선형 스케일링도 “모든 글자를 동일 비율로 키우지 않는다”는 방향을 공유한다.
Tailwind 토큰은 CSS 변수를 소비한다.
// tailwind.config.js
fontSize: {
"body-m": [
"calc(1rem * var(--scale-body, 1))",
{ lineHeight: "1.5" },
],
"heading-l": [
"calc(1.75rem * var(--scale-heading, 1))",
{ lineHeight: "1.25" },
],
}컴포넌트는 fontScale props나 Context를 받지 않는다. 어떤 디자인 토큰을 쓰는지만 결정한다.
function ArticleTitle({ children }: Props) {
return <h1 className="text-heading-l">{children}</h1>;
}값이 바뀌면 브라우저가 CSS 변수를 다시 계산하고 필요한 레이아웃을 수행한다. React 리렌더는 발생하지 않지만 스타일 계산과 리플로우 비용까지 사라지는 것은 아니다. 긴 문서와 가상화 목록에서는 설정 변경 성능도 별도로 측정해야 한다.

다시 말하면 값이 바뀌면 브라우저가 CSS 변수를 다시 계산하고 필요한 스타일 재계산과 레이아웃을 수행한다. React 리렌더는 발생하지 않지만 브라우저 렌더링 비용까지 사라지는 것은 아니다.
![버킷이 m에서 l로 바뀔 때 어느 파이프라인이 도는지 보여주는 그림. State·Props 변경이 없어 React의 Reconciliation과 Commit은 실행되지 않고, html[data-font-size] 변경에서 시작하는 브라우저의 Style 재계산·Layout·Paint·Composite만 실행된다](/_astro/08-react-skipped-browser-pipeline.D5cV2fOX_Zryt8J.webp)
왜 clamp() 대신 버킷을 택했나
처음에는 연속 배율에 상한과 하한만 적용하는 방식을 검토했다.
font-size: clamp(
0.875rem,
calc(1rem * var(--scale-raw)),
1.4rem
);하지만 토큰마다 상한이 달라지고, 일부 토큰이 먼저 상한에 닿으면서 타입 스케일의 비율이 계속 달라졌다. 범위 안에서는 여전히 가능한 상태가 무한해 시각 회귀 테스트의 기준점도 잡기 어려웠다.
버킷은 이 상태 공간을 닫는다.
- 디자이너가 검토할 화면 상태가 유한해진다.
- Storybook과 시각 회귀 테스트의 기준점이 생긴다.
- 본문·헤딩·캡션마다 서로 다른 확대 곡선을 줄 수 있다.
- 지원 범위와 상한을 제품 정책으로 명시할 수 있다.
대신 버킷은 사용자의 선호를 양자화하고 상한 밖의 값을 잃는다. 이것은 기술적 공짜가 아니라 명시적으로 받아들인 제품 트레이드오프다.
고정 텍스트는 편의 기능이 아니라 예외 계약이다
그래프 축, 지도 오버레이, 고밀도 표처럼 공간 제약이 큰 UI도 있다. 그렇다고 “레이아웃이 깨진다”는 이유만으로 텍스트를 고정하면 최초 접근성 목표를 다시 잃는다.
고정 토큰은 다음 조건을 만족할 때만 허용하는 편이 안전하다.
- 해당 텍스트가 핵심 읽기 콘텐츠나 컨트롤 라벨이 아니다.
- 확대 가능한 데이터 표, 상세 패널, 툴팁 등 동일 정보를 얻는 경로가 있다.
- 키보드와 스크린 리더에서도 그 대체 경로에 접근할 수 있다.
- 고정이 필요한 이유와 제거 조건을 컴포넌트 문서에 남긴다.
fontSize: {
"body-m": ["calc(1rem * var(--scale-body, 1))", { lineHeight: "1.5" }],
"fixed-m": ["1rem", { lineHeight: "1.5" }],
}서브트리 단위의 탈출구도 둘 수 있다.
.font-fixed {
--scale-body: 1;
--scale-heading: 1;
}다만 .font-fixed는 해결책이 아니라 예외 표식이다. 사용처를 린트나 정적 검색으로 추적하고 접근성 리뷰 대상으로 삼아야 한다.
사용자가 앱 실행 중 설정을 바꾸면
초기 로드만 맞는 것으로는 충분하지 않다. 사용자가 앱을 쓰다가 설정 화면으로 나가 글자 크기를 바꾸고 돌아올 수 있다.
iOS는 UIContentSizeCategory.didChangeNotification으로 선호 카테고리 변경을 알린다. 앱이 백그라운드에 있던 동안의 변경까지 놓치지 않으려면 scene이 다시 활성화될 때 현재 카테고리도 재확인하는 편이 안전하다.
Android에서는 폰트 스케일 변경이 configuration change다. Manifest에서 android:configChanges="fontScale"을 앱이 직접 처리하도록 선언한 경우 onConfigurationChanged()에서 새 값을 읽을 수 있다. 직접 처리하지 않으면 Activity가 재생성되므로, 그 경로에서 새 WebView에 최신 스크립트와 쿠키가 설정되는지도 확인해야 한다.
변경을 감지하면 두 경로를 갱신한다.
- 현재 문서에는
evaluateJavaScript()로 이벤트를 전달한다. - 다음 문서를 위해 document-start 등록값과 쿠키 폴백을 최신화한다.
document-start 스크립트의 문자열은 등록 시점에 고정된다. Android에서는 addDocumentStartJavaScript()가 돌려준 ScriptHandler를 보관해 이전 스크립트를 제거하고 새 payload로 다시 등록해야 한다. iOS도 기존 사용자 스크립트 구성을 갱신한 뒤 다음 navigation보다 먼저 등록을 끝내야 한다. 다른 사용자 스크립트까지 함께 지우지 않도록 소유권을 분리하거나 전체 목록을 안전하게 재구성해야 한다.
sequenceDiagram participant O as OS 설정 participant N as 네이티브 participant W as 현재 웹 문서 O->>N: 폰트 선호 변경 N->>N: 최신 원시 신호 저장 N->>W: fontpreferencechange 이벤트 W->>W: 동일 resolver로 새 스냅샷 생성 W->>W: data-font-size 갱신 W->>W: 외부 store 갱신 N->>N: 다음 로드용 documentStart·쿠키 갱신
웹의 초기 적용과 런타임 적용은 반드시 같은 함수를 사용해야 한다.
function applyPreference(preference: FontPreference) {
const next = createSnapshot(preference, "runtime");
const previous = window.__FONT_SIZE_STATE__;
window.__FONT_SIZE_STATE__ = next;
document.documentElement.dataset.fontSize = next.bucket;
if (previous?.bucket !== next.bucket) {
fontSizeStore.set(next.bucket);
}
}
window.addEventListener("fontpreferencechange", (event) => {
applyPreference((event as CustomEvent<FontPreference>).detail);
});원시 입력값이 달라도 결과 버킷이 같다면 React 구독자에게 알릴 필요가 없다. CSS도 data-font-size가 실제로 달라질 때만 갱신하도록 중복 작업을 줄일 수 있다.
예외: 크기를 숫자로 알아야 하는 컴포넌트
대부분의 화면은 CSS만으로 충분하지만 몇 가지 예외가 있다.
- 가상 리스트의
itemSize - 캔버스에 직접 그리는 차트
- 텍스트 폭 측정 캐시
- 폰트 크기에 따라 다시 계산해야 하는 JS 레이아웃
이 컴포넌트들은 CSS 결과와 같은 버킷을 알아야 한다. 원시 fontScale을 직접 읽으면 CSS는 l 버킷의 1.15를 사용하고 JS는 1.21을 사용하는 식의 불일치가 생길 수 있다.
로컬 useState + useEffect의 두 문제
가장 단순한 구현은 이벤트를 React state에 옮기는 것이다.
function useFontSizeBucketNaive() {
const [bucket, setBucket] = useState<FontSizeBucket>("m");
useEffect(() => {
const onChange = (event: Event) => {
const preference = (event as CustomEvent<FontPreference>).detail;
setBucket(resolveBucket(preference));
};
window.addEventListener("fontpreferencechange", onChange);
return () => window.removeEventListener("fontpreferencechange", onChange);
}, []);
return bucket;
}첫째, 서버는 window를 읽을 수 없다. 클라이언트 첫 렌더에서 실제 버킷을 읽으면 서버 결과와 달라 hydration mismatch가 날 수 있다.
둘째, 렌더와 effect 구독 사이에 공백이 있다. 그 사이 발생한 이벤트는 등록된 리스너가 없으므로 유실된다. 로컬 state만 진실의 원천이라면 다음 이벤트가 올 때까지 복구할 방법도 없다.
이를 막으려면 현재값을 React 밖의 store에 보관하고 이벤트는 “값이 바뀌었으니 다시 읽어라”는 알림으로만 사용해야 한다.

외부 값을 직접 읽으면 tearing 문제가 생긴다

store를 만들었다고 렌더 중 전역값을 직접 읽어도 되는 것은 아니다.
React 18에서 Transition 같은 concurrent feature로 수행되는 렌더는 중단됐다가 재개될 수 있다. 컴포넌트 A가 이전 버킷을 읽은 뒤 React가 브라우저에 제어권을 돌려주고, 그 사이 store가 바뀐 뒤 컴포넌트 B가 새 버킷을 읽으면 하나의 커밋에 서로 다른 버전이 섞일 수 있다. 이것이 tearing이다.
React state 자체가 이 문제를 만드는 것은 아니다. React가 변경 시점을 모르는 외부 가변값을 렌더 중 직접 읽을 때 문제가 된다.
sequenceDiagram participant R as React concurrent render participant S as 외부 store participant B as 브라우저 R->>S: 컴포넌트 A가 m 읽기 R-->>B: 렌더 중단·제어권 반환 B->>S: 설정 이벤트로 xl 변경 B-->>R: 렌더 재개 R->>S: 컴포넌트 B가 xl 읽기 Note over R: 그대로 커밋하면 tearing
useSyncExternalStore로 계약을 연결한다
useSyncExternalStore는 외부 저장소를 React에 연결하는 표준 계약이다.
type Listener = () => void;
let currentBucket: FontSizeBucket =
typeof window === "undefined"
? "m"
: window.__FONT_SIZE_STATE__?.bucket ?? "m";
const listeners = new Set<Listener>();
const fontSizeStore = {
subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot() {
return currentBucket;
},
getServerSnapshot() {
return "m" as const;
},
set(next: FontSizeBucket) {
if (next === currentBucket) return;
currentBucket = next;
listeners.forEach((listener) => listener());
},
};export function useFontSizeBucket() {
return useSyncExternalStore(
fontSizeStore.subscribe,
fontSizeStore.getSnapshot,
fontSizeStore.getServerSnapshot
);
}이 계약에서 현재값은 store에 남아 있다. 구독 전 이벤트 알림을 놓쳐도 React는 구독 과정에서 현재 snapshot과 렌더 때 사용한 snapshot이 달라졌는지 확인해 다시 렌더할 수 있다.
Transition 중 외부 store가 변경된 경우에도 React는 DOM에 적용하기 직전 snapshot을 다시 확인한다. 값이 달라졌다면 해당 렌더를 blocking update로 다시 수행해 한 화면에 서로 다른 store 버전이 섞이지 않게 한다.
getServerSnapshot()은 hydration mismatch를 막기 위해 서버와 hydration이 공유할 기본값을 제공한다. 다만 서버가 실제 사용자 버킷을 모르는 사실까지 없애주지는 않는다. 초기 화면에서 JS 숫자에 의존하는 컴포넌트는 hydration 직후 한 번 보정될 수 있다.
따라서 이런 컴포넌트는 다음 중 하나를 선택해야 한다.
- 첫 페인트에 중요하지 않은 예외로 제한한다.
- 서버에서는 크기에 독립적인 fallback을 렌더한다.
- 초기 측정을 CSS 레이아웃 이후로 미룬다.
- 정말 첫 HTML부터 숫자가 필요하다면 그 페이지에 한해 서버 전달 비용을 다시 받아들인다.
useSyncExternalStore는 서버가 모르는 값을 마법처럼 서버 HTML에 넣어주는 훅이 아니다. 외부 store를 React와 일관되게 연결하는 훅이다.
검증은 화면 다섯 장으로 끝나지 않는다
폰트 버킷이 다섯 개라는 것은 시각 상태가 다섯 개라는 뜻이지 전달 경로까지 다섯 개라는 뜻은 아니다.
최소한 다음 매트릭스를 검증해야 한다.
| 축 | 케이스 |
|---|---|
| 플랫폼 | iOS / Android |
| 앱 버전 | document-start 지원 전 / 지원 후 |
| WebView | feature 지원 / 미지원 |
| 내비게이션 | 최초 진입 / 새로고침 / 클라이언트 이동 / 리다이렉트 / 오류 후 reload / back-forward |
| 설정 변경 | 앱 활성 중 / 백그라운드 복귀 / 문서 전환 중 |
| 캐시 | CloudFront HIT / MISS / 오프라인 캐시 |
| 버킷 | xs / s / m / l / xl |
| 콘텐츠 | 짧은 한국어 / 긴 한국어 / 숫자 / 영문 / 다국어 / 폼 오류 메시지 |
초기 스크립트에서는 performance.mark("font-scale-applied")를 남기고, 지원되는 환경에서 First Contentful Paint보다 앞섰는지 관측할 수 있다. 이것만으로 시각적 무플래시를 완전히 증명할 수는 없으므로, 저사양 실기기의 cold load 필름스트립과 함께 본다.
운영 지표에는 원시값 전체를 고카디널리티로 남기기보다 다음 정도면 충분하다.
- 획득 채널: document-start / cookie / default
- 결과 버킷
- WebView feature 지원 여부
- 초기 적용 실패 여부
- 설정 변경 이벤트 적용 성공 여부
브라우저가 이 문제를 내장하기 시작했다
이 설계를 마친 뒤에는 브라우저 표준과 구현도 빠르게 변했다. 아래 내용은 2026년 8월을 기준으로 한다.

Safari 계열은 오래전부터 font: -apple-system-body 같은 옵트인 경로를 제공해왔다. Firefox Android도 브라우저 접근성 설정을 통해 시스템 글자 크기 또는 사용자 지정 확대를 적용할 수 있다.
Chromium은 두 가지 경로를 추가했다.
<meta name="text-scale" content="scale">env(preferred-text-scale)
meta name="text-scale"은 Chrome 146에서 Android, Desktop, WebView 대상으로 출하됐다. 모바일에서는 루트 기본 폰트 크기가 사용자 선호에 맞게 조정되고 기존 text autosizing과의 중복을 피한다. env(preferred-text-scale)은 CSS에서 선호 배율을 직접 읽는 경로다.
표준 경로가 모든 대상 환경에서 충분히 검증되면 네이티브 브릿지와 초기 인라인 정책 코드의 일부를 제거할 수 있다. 하지만 마이그레이션이 웹 코드 한 줄 교체로 끝나는 것은 아니다.
우리 Android 앱은 기존 이중 스케일을 막기 위해 setTextZoom(100)을 명시하고 있다. Chromium WebView의 새 meta 동작은 앱이 지정한 setTextZoom과 결합되므로, 이 값을 그대로 둔 채 meta만 추가하면 기대한 OS 설정이 전달되지 않을 수 있다. 앱 버전과 WebView 버전을 포함한 capability handshake가 필요하다.
또 MDN은 meta와 env(preferred-text-scale)을 동시에 사용해 폰트 상대 단위에 다시 곱하면 이중 스케일이 될 수 있다고 경고한다. 둘 중 어느 경로가 정책의 소유자인지 명확히 해야 한다.
flowchart LR A["현재: 네이티브 신호 + documentStart"] --> C["공통 정책 resolver"] B["향후: meta 또는 preferred-text-scale"] --> C C --> D["버킷·CSS 토큰"] E["앱의 setTextZoom 정책"] -. "함께 전환" .-> B
현재로서는 “Chromium 146에서 출하됐다”와 “우리 사용자 기기에서 안전하게 전환할 수 있다”를 구분해야 한다. 실제 WebView 분포와 조합 테스트가 끝날 때까지 기존 획득 레이어를 유지하되, 정책과 소비 레이어를 분리해 교체 비용을 줄이는 것이 현실적인 선택이다.
마치며: AI가 코드를 더 빨리 만들수록 리뷰에서 물어야 할 것
처음에는 작은 접근성 기능처럼 보였다.
서버 props 하나와 Provider 하나면 끝날 것 같았다. 새로고침에서 플래시를 본 뒤에는 useLayoutEffect 문제처럼 보였다. 헤더를 쿠키로 바꾸자 이번에는 CDN 캐시 키와 SSR 비용이 나타났다. 값을 CSS로 옮기자 숫자를 요구하는 일부 컴포넌트와 외부 store의 일관성 문제가 남았다.
각 단계에서 코드를 조금씩 고칠 수도 있었다. 하지만 문제를 실제로 단순하게 만든 것은 값을 더 빨리 동기화하는 코드가 아니라 소유권을 다시 나눈 결정이었다.
- 네이티브는 플랫폼 신호를 획득한다.
- 웹 정책 레이어는 그 신호를 유한한 버킷으로 정규화한다.
- CSS는 일반 화면의 배포와 소비를 맡는다.
- React는 숫자가 필요한 예외만 외부 store 계약으로 구독한다.
- 서버와 CDN은 사용자 폰트 설정을 모른 채 공유 응답을 유지한다.
그리고 접근성 기능에는 구현 성공 외에 정책의 정직함도 필요했다. 1.3배 상한과 고정 토큰을 선택했다면 그것이 OS 설정의 완전한 재현이나 WCAG 적합성을 뜻하지 않는다고 밝혀야 한다. 남은 접근성 부채와 검증 범위를 기록하는 것도 설계의 일부다.
그런데 이 작업을 돌아보면 하나 더 남는 것이 있다.
이 문제는 내가 처음부터 설계해서 발견한 것이 아니다. 동료가 구현한 코드를 리뷰하면서 시작됐다. 첫 구현은 얼핏 보면 충분히 자연스러웠고 실제로 최초 진입에서는 잘 동작했다. AI에게 같은 요구사항을 주더라도 비슷한 형태의 코드를 꽤 그럴듯하게 만들어낼 수 있었을 것이다.
문제는 코드 안에 있지 않았다.
loadUrl()에 넣은 헤더가 새로고침에도 유지되는지, 서버가 사용자별 값을 알게 되었을 때 CDN 캐시는 어떻게 되는지, 첫 페인트를 결정하는 값을 React가 소유하는 것이 맞는지 같은 질문은 한 함수의 정확성만 확인해서는 나오지 않는다. 구현이 놓여 있는 시스템의 경계를 따라가야 비로소 보인다.
그래서 AI를 많이 사용하는 팀일수록 코드리뷰의 역할은 오히려 더 중요해진다고 생각한다.
AI는 코드를 만드는 비용을 크게 낮춘다. 예전에는 구현 자체에 시간이 많이 들었다면 이제는 꽤 완성도 높은 코드가 짧은 시간 안에 만들어진다. 그만큼 팀에 들어오는 코드의 양과 변경 속도도 빨라진다.
이때 사람이 리뷰에서 해야 할 일은 AI가 작성한 코드를 한 줄씩 다시 검사하는 것이 아니다. 그런 검사는 타입 시스템, 린터, 테스트, 그리고 AI 자체에도 점점 더 맡길 수 있다.
대신 더 중요한 질문을 해야 한다.
이 구현이 어떤 전제를 가지고 있는가.그 전제는 새로고침에서도 유지되는가.다른 페이지와 다른 플랫폼에서도 유지되는가.캐시와 네트워크 경계를 넘어가도 유지되는가.그리고 이 값을 정말 이 레이어가 소유하는 것이 맞는가.
이번 작업에서 가장 중요한 리뷰 질문도 “왜 useLayoutEffect가 안 되지?”가 아니었다.
이 값을 최초로 알아야 하는 곳은 어디이고, 이 값을 최종적으로 소유해야 하는 레이어는 어디인가?
그 질문 하나를 따라가면서 실험 페이지의 로컬 state는 WebView의 문서 생명주기와 브라우저 페인트 타이밍, CDN 캐시, 디자인시스템, React 외부 store까지 연결됐다.
AI 시대에는 코드 생성이 점점 덜 어려운 일이 될 것이다.
그럴수록 코드리뷰는 코드를 만들어낸 사람의 실수를 찾는 과정보다, 빠르게 만들어진 구현이 우리 시스템 안에서 정말 성립하는지를 팀이 함께 검증하는 과정에 가까워진다.
어쩌면 앞으로 코드리뷰에서 가장 가치 있는 능력은 코드를 더 빨리 읽는 능력이 아니라, 그 코드가 당연하다고 가정하고 있는 것을 발견하는 능력일지도 모른다.
끝으로 내가 리드를 맡으며 가능한 많은 PR에 관여하려 했던 것도 같은 이유였다. 모든 결정을 내가 내리기 위해서가 아니라, 서로의 구현에 질문을 던지고 그 과정에서 생긴 판단 기준을 팀이 함께 쌓아가길 바랐다. 그리고 그 과정에서 성장하는 건 동료들만이 아니었다. 다른 사람의 코드를 이해하고 내 판단을 설명하면서 나 역시 내가 당연하다고 생각했던 전제를 계속 다시 보게 됐다.
참고 자료
React·Next.js
- React: useLayoutEffect
- React: useSyncExternalStore
- React 18
- React 18 Working Group: What is tearing?
- Next.js: getServerSideProps
- Next.js: Automatic Static Optimization
iOS·Android WebView
- Apple: WKUserScript atDocumentStart
- Apple: UIContentSizeCategory.didChangeNotification
- Apple: Scaling fonts automatically
- Apple: UIFontMetrics
- WebKit: Using the System Font in Web Content
- Android: WebViewCompat.addDocumentStartJavaScript
- Android: WebViewFeature
- Android: CookieManager
- Android: WebSettings.setTextZoom
- Chromium: AwSettings의 시스템 fontScale 반영 코드
- Android 14: Non-linear font scaling
- Android: Runtime configuration changes