프론트엔드 개발자가 "잘 만들었다"고 말할 때, 그 기준은 무엇일까. Lighthouse 점수? Core Web Vitals 통과? 번들 사이즈 몇 KB 감소? 이 숫자들은 분명 의미 있지만, 최근 세 가지 흐름을 나란히 놓고 보면 불편한 진실이 하나 드러난다. 우리가 측정하는 것과 사용자가 실제로 겪는 것 사이에는 생각보다 훨씬 넓은 간극이 존재한다.
유동 타이포그래피의 숨겨진 접근성 함정
clamp()와 Utopia 같은 도구로 유동적 타이포그래피를 구현하는 것은 이제 프론트엔드 현업에서 거의 표준처럼 자리 잡았다. 뷰포트 너비에 따라 폰트 크기가 자연스럽게 늘고 줄어드는 그 우아함은 분명 매력적이다. 그런데 velog에 번역 소개된 Miriam Suzanne의 글은 이 방식에 조용히 숨어 있는 함정을 정확하게 짚는다.
문제의 핵심은 단위 변환에 있다. 픽셀로 정의한 브레이크포인트를 rem으로 변환하는 순간, 그 rem 값은 더 이상 절대적인 기준이 아니라 사용자의 루트 폰트 크기 설정에 종속된다. 만약 사용자가 브라우저 기본 폰트 크기를 16px에서 32px로 키워 두었다면—시각 접근성을 위해 흔히 하는 설정이다—유동 타이포그래피의 브레이크포인트는 우리가 의도한 뷰포트 지점이 아닌 전혀 다른 곳에서 작동하기 시작한다. 결과적으로 사용자가 글자를 크게 보려고 설정을 바꿨는데, 오히려 더 작은 폰트가 표시되는 역설이 발생할 수 있다.
이를 해결하는 열쇠로 소개되는 것이 바로 CSS progress() 함수다. progress(100vi, var(--min-size-viewport), var(--max-size-viewport))처럼 작성하면, 뷰포트 너비가 설정한 범위 안에서 어느 위치에 있는지를 0과 1 사이의 단위 없는 숫자로 반환한다. 단위 없는 숫자이기 때문에 rem 변환이 불필요하고, 브레이크포인트가 사용자의 폰트 크기 설정에 영향받지 않는다. 픽셀 정밀도와 rem 접근성을 동시에 잡을 수 있는 구조다.
아직 브라우저 지원이 완전하지 않고 calc-mix() 같은 연관 기능과 함께 완전한 형태가 갖춰질 것이라는 점에서 당장 프로덕션에 전면 도입하기는 이르다. 하지만 이 논의가 중요한 이유는 기술 자체보다 이 기술이 드러내는 맹점에 있다. 우리가 "접근성을 고려했다"고 생각하며 rem을 쓰는 바로 그 선택이, 특정 사용자 설정에서는 오히려 접근성을 해치는 방향으로 작동할 수 있다.
영어로만 테스트한 코드가 숨기는 것
dev.to에 공개된 CJK/Unicode 버그 코퍼스 프로젝트는 또 다른 종류의 맹점을 공략한다. 일본어 사용자가 검색창에 타이핑한다고 상상해보자. IME로 히라가나를 입력하고 스페이스를 눌러 한자로 변환한 뒤 엔터를 쳐서 확정하는 순간, 검색이 발동된다. 문제는 검색에 담긴 쿼리가 변환이 완료된 텍스트가 아니라 변환 중이던 미완성 텍스트라는 점이다.
이 버그의 수정은 단 한 줄이다. event.isComposing이 true인 동안에는 keydown 핸들러가 동작하지 않도록 막으면 된다. React에서는 합성 이벤트가 해당 필드를 드롭하기 때문에 event.nativeEvent.isComposing으로 접근해야 한다는 프레임워크별 차이도 있다. 알고 보면 간단하지만, 영어권 사용자 환경에서만 개발하고 테스트한다면 이 버그는 코드베이스에 영원히 잠들어 있을 수 있다.
이 프로젝트가 단순한 버그 목록을 넘어 흥미로운 이유는 그 구조에 있다. 84개 오픈소스 라이브러리에서 발견된 89개 실제 버그 각각에 대해 증상 한 줄, 최소 재현 코드, 수정 방법이 정리되어 있고, GitHub API를 통해 실제 PR이나 이슈와 연결되지 않는 항목은 등재 자체가 거부된다. React, Vue, Svelte, Angular를 가로질러 동일한 패턴이 반복되는 것도 명확히 추적된다. 갈리시아어 로케일에서 6월만 파싱이 실패하는 date-fns 버그처럼, CJK가 아닌 케이스도 "ASCII 외 문자로 작성된 스크립트를 아무도 테스트하지 않은" 동일한 실패 유형으로 묶인다.
이 코퍼스가 프론트엔드 개발자에게 전하는 메시지는 명확하다. 텍스트 입력을 받는 컴포넌트가 있다면, 그리고 그 사용자 중 단 한 명이라도 동아시아 언어 IME를 쓴다면, 이미 알려진 버그가 당신의 코드에도 존재할 가능성이 높다.
10배 빠른데 아무것도 안 바뀐 이유
GeekNews에 소개된 Colin Breck의 글은 세 번째 층위에서 이 논의를 완성한다. 쿼리 응답 시간을 5~10분에서 30초~1분으로 줄이는 것은 수치상 10배 개선이다. 그런데 사용자 경험은 실질적으로 바뀌지 않을 수 있다. 이유는 HCI 연구에서 오래전부터 확립된 임계값에 있다. 사람이 하나의 작업에 주의를 유지하는 한계는 약 10초다. 30초와 5분은 둘 다 이 임계값을 넘어서며, 사용자는 두 경우 모두 다른 일로 전환한다. 작업을 마치고 돌아왔을 때 화면이 이미 로딩되어 있다면, 기다린 시간이 30초였는지 5분이었는지는 업무 흐름에 큰 차이를 만들지 않는다.
파이프라인 맥락에서는 Amdahl의 법칙이 더 직접적으로 작동한다. 느린 단계가 상류에 백프레셔를 걸고 있는 구조에서는, 해당 단계 하나를 아무리 빠르게 만들어도 전체 처리량은 다음 병목이 해소되기 전까지 늘지 않는다. 개별 벤치마크의 극적인 수치와 종단 간 처리량이 완전히 따로 노는 상황이다.
이 글이 프론트엔드 문맥에서 특히 유효한 이유는, 우리가 최적화라고 부르는 작업의 상당수가 정확히 이 함정에 빠져 있기 때문이다. 번들 사이즈를 줄여 LCP를 0.3초 개선했는데 서버 응답이 2초를 넘는다면, 그 0.3초는 사용자에게 체감되지 않는다. 미사용 CSS를 제거해 파싱 시간을 줄여도, 메인 스레드를 막는 무거운 서드파티 스크립트가 그대로라면 인터랙션 지연은 사라지지 않는다.
세 흐름이 가리키는 하나의 좌표
progress()의 접근성 함정, CJK 버그 코퍼스, 그리고 10배 성능 개선의 무효화—이 세 이야기는 서로 다른 레이어를 건드리지만 같은 방향을 가리킨다. 프론트엔드 품질의 진짜 레버는 우리가 측정하는 숫자가 아니라 사용자가 실제로 겪는 경험의 완성도에 있다.
접근성 설정을 바꾼 사용자에게 더 작은 글자를 보여주는 유동 타이포그래피, IME 확정 키를 이벤트로 잘못 받아내는 검색창, 그리고 수치상 10배 빨라졌지만 업무 흐름은 달라진 게 없는 시스템—이것들은 모두 개발자의 내부 지표로는 보이지 않는 실패들이다. 코드는 동작하고, 테스트는 통과하고, 벤치마크는 개선되었는데, 사람의 경험은 망가져 있거나 제자리인 상태.
해법의 방향도 셋 다 비슷하다. 더 많은 측정이 아니라 더 옳은 측정이다. progress()는 브레이크포인트를 픽셀이 아닌 뷰포트 비율로 다시 정의한다. CJK 코퍼스는 "영어로 통과한 테스트"가 아닌 "실제 입력 패턴에서 발생한 실패"를 기록한다. 성능 임계값 논의는 벤치마크 수치가 아닌 "사용자가 주의를 유지하는 시간"을 기준으로 삼는다. 모두 숫자에서 경험으로 기준점을 이동시키는 작업이다.
당장 실무에서 확인할 수 있는 체크포인트는 세 가지다. 유동 타이포그래피를 쓴다면, 사용자가 브라우저 폰트 크기를 200%로 키웠을 때 레이아웃이 어떻게 동작하는지 확인해볼 것. 텍스트 인풋 컴포넌트가 있다면, CJK 코퍼스(greymoth-jp.github.io/cjk-failure-corpus)에서 사용 중인 라이브러리를 검색해볼 것. 그리고 다음 성능 최적화 작업을 시작하기 전에, 그 개선이 사용자의 실제 작업 흐름에서 어느 임계값을 넘기는지 먼저 물어볼 것.
프론트엔드 품질은 결국 사람의 문제다. 코드가 아니라 코드를 사용하는 사람, 그 사람이 어떤 언어를 쓰고 어떤 설정을 쓰고 몇 초를 기다리는지가 진짜 기준이다. 숫자는 그 기준을 향해 가는 과정을 추적하는 도구일 뿐, 그 자체가 목적지가 되어서는 안 된다.