40~50대는 노안이 시작되는 시기이기도 합니다. 연구에 따르면, 40세 이상 사용자는 스마트폰에서 유의미하게 더 큰 글자 크기를 사용하며, 근거리 시력에 불편함을 느끼는 사용자일수록 이 경향이 더 뚜렷했습니다. 또한 글자 크기가 커질수록 사용성과 시각적 편안함이 향상되고, 작은 글자는 특히 장년층에게 시각 피로를 유발한다는 결과가 있습니다. (출처: Boccardo et al., 2023; Hou et al., 2022)
안녕하세요. 라포랩스 Frontend Engineer 심은서입니다.
이번 글에서는 퀸잇에 기기 글자 크기 설정을 반영하는 텍스트 접근성 개선 프로젝트를 통해, 거래액과 탐색성에 유의미한 향상을 가져온 경험을 공유합니다.
글자 크기를 키웠는데, 앱은 모르는 척 한다면
스마트폰에서 글자 크기를 크게 조절해본 적 있으신가요?
글자 크기를 최대로 키우고 자주 사용하는 앱에 들어가보면, 그 크기가 제대로 반영되는 앱은 생각보다 많지 않을 거예요. 대부분 설정이 무시되거나, 반영되더라도 레이아웃이 어긋나는 경우가 많습니다.
퀸잇도 마찬가지였습니다. 유저가 글자를 아무리 크게 키워놔도, 앱 안에서는 커지지 않았습니다. 내가 편하게 읽을 수 있는 크기로 설정해뒀는데, 그보다 작은 글씨를 읽어야 하는 상황이 생기는 거죠.
“4050을 위한 플랫폼” 퀸잇에서는 특히나 많은 유저들이 불편함을 겪고 있었습니다. 글자 크기를 기본값보다 크게 설정하는 비율이 무려 65% 이상이기 때문입니다.
📄
신체적·환경적 조건에 관계없이 텍스트를 읽고 이해할 수 있게 하는, 텍스트 접근성이 지켜지지 못한 상황이었습니다.
모든 유저들이 라포랩스의 제품을 믿고 사용할 수 있도록 다양한 접근성 문제를 고민하고 대응할 필요가 있었습니다. 그렇게 텍스트 접근성 TF를 꾸려, 글자 크기 문제를 해결할 방법을 찾기 시작했습니다.
크게, 그리고 아름답게
퀸잇도 글자 크기가 조절되던 과거가 있었는데요. 웹뷰로 전환되기 이전, React Native로 구현되어 있을 때입니다.
React Native에서는 모든 크기의 글자를 같은 비율로 확대하는 방식이었습니다.
이로 인해 위계가 무너지고, 레이아웃이 어긋나는 문제가 발생했습니다. 개발자는 문제가 있는 곳마다 일일이 최대 배율을 제한하는 속성을 넣어야 했습니다.
이러한 과거를 돌아보면, 단순히 “글자를 크게 키우는 것”이 답은 아니었어요.
글자가 커도 보기 좋아야 했습니다.
따라서 텍스트 접근성 TF의 목표는 다음과 같았습니다.
유저의 글자 크기 설정을 반영하면서
가독성과 심미성을 유지한다.
이를 위한 핵심적인 원칙을 정의했습니다.
“작은 글자는 더 많이, 큰 글자는 덜 확대되어야 한다.”
글자 크기를 키우는 이유는 대부분 작은 글자가 읽기 어렵기 때문입니다. 그런데 충분히 큰 글자까지 같은 비율로 키워버리면, 가독성이 좋아지기보다는 공간을 많이 차지해서 레이아웃이 무너지는 문제가 발생합니다.
단순 정비례가 아니라, 글자 크기 설정 단계가 커질수록 기울기가 완만해지는 그래프를 도출했습니다. 기준 글자 크기가 작을수록 더 많이 커지는 구조이죠.
또한 OS 별로 파편화되어있는 글자 크기 단계를 하나의 기준으로 통합하기 위해, 퀸잇에서는 Medium, Large, XLarge, XXLarge, XXXLarge의 5단계로 정의했습니다.
디자인 원칙은 정리되었습니다. 이제 이 원칙을 코드에 자연스럽게 녹이는 일만 남았습니다.
CSS 변수로 가변적인 글자 크기 만들기
퀸잇은 BodyMedium, LabelLarge와 같은 디자인 토큰 기반의 공통 텍스트 컴포넌트를 사용하고 있습니다.
핵심은, 하나의 텍스트 토큰이 단계(Medium~XXXLarge)에 따라 다른 크기를 갖게 만드는 것입니다.
다음의 세 단계만 거치면 됩니다.
1. 텍스트 토큰의 font-size를 CSS 변수로 치환하기
BodyMedium: {
fontSize: 'var(--font-10, 10px)',
}2. 단계별 CSS 변수 정의하기
:root {
--font-10-Medium: 10px;
--font-10-Large: 11.1px;
--font-10-XLarge: 12.2px;
--font-10-XXLarge: 13.6px;
--font-10-XXXLarge: 15px;
}3. 단계별 CSS 변수 매핑하기
[data-dynamic-typography="Medium"] {
--font-10: var(--font-10-Medium);
}
[data-dynamic-typography="Large"] {
--font-10: var(--font-10-Large);
}
[data-dynamic-typography="XLarge"] {
--font-10: var(--font-10-XLarge);
}이제 최상단 문서에 data-dynamic-typography=”XXXLarge”만 심을 수 있다면, 앱의 모든 글자 크기가 알아서 조절됩니다.
그렇다면 유저가 실제로 설정한 글자 크기 값을 어디서 가져와서 어떻게 적용해야 할까요?
서버는 글자 크기를 모른다
웹뷰는 기기의 글자 크기 설정값을 직접 알 수 없습니다. 이 값은 OS 레벨에서 관리되기 때문에, React Native로부터 브릿지 메시지를 통해 전달받을 수 있었습니다.
값을 받아온 뒤, effect에서 data-dynamic-typography 속성을 설정해보면 어떨까요?
const fontScale = await bridge.getFontScale() // 1.0 ~ 2.0
const level = mapLevel(fontScale) // Medium ~ XXXLarge
useEffect(() => {
document.body.setAttribute('data-dynamic-typography', level)
}, [])아주 간단히 해결될 것 처럼 보였지만, 실제로 앱을 실행해보니 치명적인 문제가 드러났습니다.
바로 Pre-rendering 구조에서 발생하는 FOUT(Flash of “Unsized” Text) 현상이었습니다.
💡
웹 개발에서는 폰트 로딩이 지연되면서 순간적으로 폰트가 바뀌는 현상을 FOUT(Flash of Unstyled Text)라고 부릅니다. 이 표현을 차용하여, 이 글에서는 초기 로드 시 글자 크기가 바뀌는 현상을 Flash of Unsized Text라고 지칭합니다.
퀸잇 웹뷰는 Next.js 기반이기에 일부 페이지는 SSR(Server Side Rendering)로 서버 사이드에서 HTML을 미리 만듭니다.
하지만, 서버는 기기 글자 크기 설정값을 모릅니다.
그 결과 서버에서는 Medium을 기준으로 HTML을 그리고, 이후 클라이언트에서 effect가 실행되면서 글자가 커지는 현상이 생기는 것이죠.
게다가 퀸잇은 페이지 이동 시 네이티브 스택을 새로 쌓는 방식이라, 페이지를 이동할 때마다 FOUT가 반복되었습니다.
접근성을 챙기려다가 새로운 사용성 문제를 만들 순 없었습니다. 텍스트 접근성 TF는 여기서 타협하지 않고 문제를 해결할 방법을 찾아나섰습니다.
FOUT를 제거하는 트릭
먼저 직관적이고 단순한 방법을 떠올려보았습니다.
글자를 잠시 가렸다가, 최종적으로 글자 크기가 적용되었을 때 보여준다면 어떨까요?
const [isReady, setIsReady] = useState(false)
const fontScale = await bridge.getFontScale()
const level = mapLevel(fontScale)
useEffect(() => {
if (level === undefined) return
document.body.setAttribute('data-dynamic-typography', level)
setIsReady(true)
}, [])
const textStyle = { opacity: isReady ? 1 : 0 }fontScale 값이 들어올 때까지 isReady를 false로 두고 기다리다가, 값이 도착하면 isReady를 true로 변경합니다. 그리고 isReady 값에 따라 공통 텍스트 컴포넌트에서 opacity 스타일을 분기하는 것이죠.
이렇게 하면 서버 사이드에서는 fontScale이 undefined이므로 텍스트가 그려지지 않습니다. 이후 클라이언트 사이드에서 브릿지 값을 받아 모든 준비가 끝난 뒤에야 올바른 크기를 가진 글자가 나타납니다. 그렇게 FOUT 현상은 확실히 감출 수 있었습니다.
하지만, 글자가 보이기까지 찰나의 딜레이가 발생했습니다.
이 지연이 유저 경험에 부정적인 영향을 주지 않을 거라곤 보장할 수 없었죠. 특히 구매에 직접적인 영향을 주는 상품 상세 페이지에서 SSR을 사용하고 있었기에 우려가 컸습니다.
보다 구조적으로 해결할 방법은 없을지 생각해 볼 필요가 있었어요.
서버에도 글자 크기를 알려준다면
문제는 서버가 글자 크기를 모른다는 데에서 출발했습니다. 그렇다면 서버도 알 수 있게 만들 순 없을까요?
서버에도 글자 크기를 전달할 수 있는 수단이 하나 있었습니다.
바로 Cookie입니다.
Cookie는 HTTP 요청에 포함되어 서버로 전달됩니다. 즉, Next.js의 SSR 환경에서는 HTML을 생성하기 전에 요청에 담긴 Cookie 값을 읽고 그 값을 사용해서 HTML을 그릴 수 있다는 뜻입니다.
이 구조라면, SSR에서만큼은 FOUT 문제가 원천적으로 사라질 수 있습니다.
1. React Native에서 웹뷰로 Cookie 전달하기
먼저 React Native에서 웹뷰를 호출할 때, 요청 헤더의 Cookie에 글자 크기 값을 포함했습니다.
const headers = {
Cookie: getCookieString({
fontScale: Dimensions.get('window').fontScale.toString(),
})
}
<Webview
source={{
uri,
headers
}}
/>2. 서버에서 Cookie 읽어오기
Next.js에서는 getServerSideProps라는 함수를 통해 SSR을 구현합니다. 이 함수는 페이지가 요청될 때마다 서버에서 실행되고, 요청 객체(context.req)에 접근할 수 있습니다. 요청 객체 안에 Cookie 정보가 포함되어 있으므로, 여기서 글자 크기 값을 꺼내올 수 있습니다.
export const getServerSideProps = async (context) => {
const { cookies } = context.req
const fontScale = cookies.fontScale
return {
props: { fontScale },
}
}3. Cookie를 사용하여 서버 사이드 렌더링하기
getServerSideProps에서 반환한 props는 페이지 컴포넌트에 전달됩니다.
// _app.tsx
function App({ Component, pageProps }) {
return (
<DynamicTypographyRoot fontScaleFromServer={pageProps.fontScale}>
<Component {...pageProps} />
</DynamicTypographyRoot>
)
}이 값을 data-dynamic-typography 속성을 설정하는 최상위 컴포넌트까지 내려주면, 서버에서 HTML을 생성하는 시점에 올바른 크기로 글자를 그릴 수 있습니다.
서버에서 생성하는 HTML에 data-dynamic-typograhpy=’XXXLarge’가 포함되기 때문입니다.
// DynamicTypographyRoot.tsx
function DynamicTypographyRoot({ children, fontScaleFromServer }) {
return (
<div data-dynamic-typography={fontScaleFromServer}>
{children}
</div>
)
}이제 적어도 SSR에서만큼은 FOUT 문제가 해결되었습니다.
하지만 아직 해결해야할 것이 남았습니다. SSG와 CSR에도 문제가 발생하지 않도록 하는 것이었죠.
렌더링 방식에 구애받지 않는 구조
Next.js 페이지는 렌더링 방식에 따라 SSR, SSG, CSR로 나뉩니다.
셋의 차이는 "언제 HTML을 생성하느냐"입니다.
SSR: 유저가 요청할 때마다 서버에서 HTML 생성
SSG: 빌드 타임에 HTML 생성
CSR: 클라이언트에서 HTML 생성
CSR의 FOUT
CSR의 경우 useLayoutEffect에서 data-attribute를 설정하면, paint 단계 이전에 글자 크기를 미리 결정해 둘 수 있습니다. 이 경우 FOUT는 발생하지 않습니다.
SSG의 FOUT
SSG는 SSR과 다르게 빌드 타임에 HTML이 이미 만들어지기 때문에 유저별 Cookie 값을 알 수 없습니다. 여전히 FOUT를 근본적으로 해결하기는 어려웠습니다.
FOUT를 해소하기 위해 화면 전체의 렌더를 지연시키기로 결정했습니다.
텍스트의 opacity를 조절하는 방식을 유지하지 않은 이유는 다음과 같습니다.
코드 유지보수 비용이 높습니다.
레이아웃 시프트가 발생하는 한계가 있습니다.
SSG 화면의 수가 절대적으로 적습니다.
전체 코드는 다음과 같습니다.
function DynamicTypographyRoot({ children, fontScaleFromServer }) {
// fontScaleFromServer가 있으면 SSR 페이지, 없으면 SSG/CSR 페이지
const isSSRPage = fontScaleFromServer !== undefined;
// SSR이면 즉시 준비 완료, SSG/CSR이면 값을 가져올 때까지 대기
const [isReady, setIsReady] = useState(isSSRPage);
// SSR이면 props에서, 아니면 브라우저 쿠키에서 읽음
const fontScale = isSSRPage ? fontScaleFromServer : FontScale.getFromCookie();
useLayoutEffect(() => {
document.body.setAttribute('data-dynamic-typography', fontScale);
setIsReady(true);
}, [fontScale]);
// SSG/CSR에서는 값이 준비될 때까지 렌더링하지 않음
if (!isReady) return null;
return (
<div data-dynamic-typography={fontScale}>
{children}
</div>
);
}SSR 페이지:
fontScaleFromServer가 존재하므로isReady가 처음부터true입니다. 서버에서 pre-rendering한 HTML을 그대로 사용합니다.SSG 페이지: 빌드 타임에
fontScaleFromServer가undefined이므로 아무것도 그리지 않습니다. 클라이언트에서 글자 크기 값이 결정되면 화면을 그립니다.CSR 페이지: 첫번째 렌더에서
fontScaleFromServer가undefined이므로 아무것도 그리지 않습니다. paint 직전에useLayoutEffect가 실행되어, 최종적으로 결정된 글자 크기로 화면을 그립니다.
접근성 개선이 가져온 효과
이제 어떤 글자 크기를 설정했든, 어떤 렌더링 방식을 취한 화면이든 관계 없이, 앱 내 모든 곳에서 유려하게 글자가 커질 수 있는 시스템을 마련했습니다.
텍스트 접근성 개선은 유저에게 어떤 영향을 미쳤을까요?
2주 간의 AB 테스트를 진행했습니다. 대조군은 글자 크기를 Medium으로 고정했고, 실험군은 유저의 기기 설정을 반영했습니다.
글자 크기가 커지면서 화면에 보이는 상품이나 정보의 수가 줄어들기 때문에, 지표가 오히려 떨어지지 않을까 하는 걱정이 있었는데요.
걱정이 무색하게도, 실험 결과는 기대 이상이었습니다.
1. 거래액이 상승했습니다.
주문 횟수가 증가하고 ARPU가 상승했습니다.
특정 지면만 혜택을 받은 것이 아니라 모든 지면에서 고르게 상승했습니다.
특히 글자 크기를 XLarge 이상으로 설정한 유저 그룹에서 가장 큰 상승폭을 보였습니다. 글자 크기를 크게 키워 쓰는 유저일수록, 이 개선의 영향을 크게 받은 겁니다.
2. 쇼핑 탐색성이 증가했습니다.
전체 CTR과 유저당 클릭수가 증가하여 퍼널이 개선되었습니다.
찜하기 수와 찜 유저 비율에서 큰 상승을 보였습니다. 글자가 잘 보이니 더 많이 둘러보고, 더 많이 담아두게 된 거죠.
텍스트 접근성이 단순히 사용성 개선 수준이 아니라 비즈니스 임팩트로 연결되는 변화를 가져왔다는 것, 정말 놀랍지 않나요?
잘 읽히는 텍스트는 더 많은 탐색을 만들고, 더 많은 탐색은 더 많은 구매를 만들었습니다.
마치며
“진짜” 4050을 위한 서비스
이번 프로젝트에는 라포랩스가 유저를 대하는 철학이 담겨있습니다. 라포랩스가 4050을 위한 서비스를 만든다는 것은, 단순히 취향이나 카테고리만을 겨냥한다는 뜻이 아닙니다. 정보를 탐색하는 방식, 속도, 피로도까지 고려해 “4050을 위한 경험”을 책임진다는 의미이죠.
작은 불편함까지 놓치지 않으려는 라포랩스의 노력은 이제 시작입니다. 약시를 고려한 이미지 크기 및 비율 옵션, 협응력과 인지 속도를 감안한 마이크로 인터랙션, 색약 모드, 명도 및 대비 보정 등 다양한 접근성 솔루션들을 하나씩 실현해 갈 예정입니다.
복잡한 문제를 단순하게 풀어낸 설계
기술적으로도 매우 챌린징한 작업이었습니다. React Native와 웹뷰라는 구조 안에서, 화면마다 렌더링 방식이 다르고, AB 테스트 분기까지 추가되며 조건이 매우 복잡해지는 상황이었습니다. 조건문이 남용되거나 임시방편의 처리가 생기기 쉬웠지만, 이 복잡도를 화면 곳곳에 흩뿌리지 않고 하나의 레이어로 응집했습니다. 덕분에 모든 개발자는 의식하지 않아도 텍스트 접근성을 지킬 수 있습니다.
퀸잇은 4050 시장의 선두를 넘어, 500만 유저가 매일 찾는 플랫폼으로 확장해 나가고 있습니다. 라포랩스에서는 유저의 작은 환경 설정 하나도 놓치지 않고 비즈니스 임팩트로 연결하며 모두에게 사랑받는 서비스를 만들어가고 있습니다.
집요한 탁월함으로 수백만 명의 쇼핑 경험을 바꾸는 이 즐거운 여정에 동참하고 싶으시다면, 망설임 없이 문을 두드려주세요!