라이트하우스가 시킨 것, 그리고 안 고친 것 하나
Article
이 시리즈는 내가 틀린 이야기의 연속이었다. 서버가 느린 줄 알았고(2편), 데이터가 균등한 줄 알았고(3편), 로컬이 통과하니 괜찮은 줄 알았다(4편).
라이트하우스도 나를 두 번 잡았다. 둘 다 "숨겼다"고 생각한 것이 안 숨겨져 있었다.
그리고 하나는 알면서 안 고쳤다. 그 이야기도 적는다.
숨겼는데 왜 경고가 뜨나
카드 목록에는 검색·필터 패널이 있다. 닫으면 화면에서 사라진다. 안에는 input과 select와 button이 들어 있다.
닫혀 있으니 안 보인다. 그래서 aria-hidden을 걸었다. "스크린리더야, 이건 없는 셈 쳐."
라이트하우스가 경고했다.
aria-hidden요소가 포커스 가능한 자식을 포함하고 있습니다.
숨긴 건 스크린리더에게만이었다. 키보드는 여전히 그 안으로 들어간다. 탭을 누르면 보이지도 않는 input에 포커스가 잡힌다.
사용자 입장에서 이건 최악이다. 탭을 눌렀는데 커서가 사라진다. 어디로 갔는지 알 수가 없다.
정답은 inert였다.
// CardSearchPanel.tsx
inert={!open}
inert는 화면에서 숨기는 게 아니라 그 서브트리를 통째로 비활성화한다. 탭 순서에서도 빠지고, 스크린리더에서도 빠지고, 클릭도 안 먹는다.
aria-hidden
스크린리더에게만 숨긴다
- 보조기술이 안 읽는다
- 탭 순서에는 그대로 있다
- 안 보이는
input에 포커스가 잡힌다 - 라이트하우스가 경고한다
inert
아예 없는 셈 친다
- 보조기술이 안 읽는다
- 탭 순서에서도 빠진다
- 클릭도 안 먹는다
- 닫힌 패널에 정확히 맞는 도구다
ul 을 줬는데 ul 이 아니라고 한다
카드 목록은 Swiper 캐러셀이다. 목록이니 ul > li 여야 한다.
Swiper에 tag="ul"을 줬다. 루트가 <ul>이 됐다. 됐겠지.
아니었다. Swiper는 그 안에 자기 래퍼를 하나 끼워 넣는다.
ul.swiper
└─ div.swiper-wrapper ← 이게 끼어든다
└─ li.swiper-slide
ul > div > li. ul의 자식은 li여야 한다. 중간에 div가 끼면 무효 구조다.
해법은 루트가 아니라 래퍼를 바꾸는 것이었다.
// CardSwiperList.tsx
wrapperTag="ul"
div.swiper
└─ ul.swiper-wrapper ← 여기를 ul 로
└─ li.swiper-slide ← 이제 직계 자식이다
라이브러리에 태그를 넘길 때, 그게 어느 레벨의 태그인지 확인해야 한다. tag와 wrapperTag는 한 글자 차이인데 결과는 유효/무효로 갈렸다.
이 시리즈의 다른 편들과 같은 모양이다. 내가 준 것과 실제로 나온 것이 달랐다. 2편에서는 서버에 목록을 줬는데 HTML엔 안 실렸고, 여기서는 ul을 줬는데 ul>div>li가 나왔다.
알면서 안 고친 것
카드에 호버하면 테두리를 따라 밝은 선분이 한 바퀴씩 돈다. stroke-dashoffset을 애니메이션한다.
라이트하우스가 지적했다. 비합성 애니메이션.
브라우저는 transform과 opacity만 GPU에서 합성한다. 다시 그리지 않는다. 그 외의 속성은 매 프레임 다시 그려야 한다. stroke-dashoffset이 거기 속한다. 저사양 기기에서 버벅인다.
고칠 수 있다. 마스크 오버레이를 translate로 미는 방식으로 다시 짜면 된다.
안 고쳤다.
고치면 얻는 것
라이트하우스 중간 등급 항목
- 비합성 애니메이션 경고가 사라진다
- 저사양 기기에서 조금 부드러워진다
고치면 위험한 것
선이 도는 모양
transform기반으로 다시 짜면 선이 도는 느낌이 미묘하게 달라진다- 이 모션은 카드의 인상을 만드는 요소다
- 점수 영향은 LCP · 접근성보다 작다
대신 선택지를 적어뒀다.
- 저위험:
prefers-reduced-motion에서 끈다. 비주얼은 그대로 두고 필요한 사람에게만 끈다. - 근본적: 마스크 오버레이를
translate로 미는 방식으로 재구현. 모양 검증이 필요하다.
곁가지로 배운 것 둘
폴리필. browserslist를 안 정하면 구형 브라우저용 폴리필(Array.prototype.at, flat, Object.fromEntries 등)이 번들에 들어간다. 14KB쯤 된다. 아무도 안 쓰는 코드다.
모던 타깃을 지정했다. Chrome/Edge/Firefox 111+, Safari 16+.
"browserslist": {
"production": ["chrome >= 111", "edge >= 111", "firefox >= 111", "safari >= 16"]
}
측정 환경. 라이트하우스를 개발자 브라우저에서 돌리면 안 된다.
React DevTools 같은 확장이 주입하는 스크립트가 메인 스레드와 번들 크기에 잡혀 점수를 실제보다 나쁘게 만든다. 일반 방문자에게는 그 확장이 없다.
시크릿 창이나 확장 없는 프로필에서 잰 값이 진짜에 가깝다. 이걸 모르고 한동안 유령을 쫓았다.
여섯 편을 마치며
DevTI를 만들며 여섯 편을 썼다. 돌아보니 같은 이야기를 여섯 번 했다.
- 01
1편 — 백엔드를 숨기지 않기로 했다
서버액션을 안 쓴 건 못 써서가 아니라 인증 구조가 그렇게 정해줬기 때문이다. 도구가 아니라 구조가 먼저다.
- 02
2편 — 로딩바 하나에 세 번 속았다
서버가 목록을 잘 가져왔는데 서버 HTML 엔 카드가 0개였다.
cat한 줄이면 알 수 있었다. - 03
3편 — 카운트는 다 달랐는데 차트는 평평했다
저장된 값이 아니라 화면에 그려지는 값으로 설계해야 했다. 반올림이 카운트 둘을 한 칸으로 뭉갰다.
- 04
4편 — 로컬은 통과하는데 운영만 죽는다
로컬은 스키마를 매번 버리고 운영은 쌓아둔다. 테스트 82개가 전부 통과해도 보장 못 하는 영역이 있다.
- 05
5편 — AI 를 안 쓰기로 했다
결과가 안 바뀌어야 하고 계산이 공짜여야 한다. 그 둘이 정해지자 답은 하나뿐이었다.
- 06
6편 — 숨겼는데 안 숨겨져 있었다
aria-hidden은 스크린리더에게만 숨긴다.ul을 줬는데ul > div > li가 나왔다. 렌더된 것을 직접 열어봐야 안다.
이 시리즈를 관통하는 한 줄이 있다면 이것이다.
내가 어떻게 설계했는지는 화면에 안 나온다. 화면에 나오는 것만 화면에 나온다.
빌드된 HTML을 열고, 화면을 그리는 함수를 열고, 렌더된 DOM을 열고, 운영이 뱉은 예외 문장을 읽는다. 매번 5분이면 되는 일이었다. 그런데 매번 그걸 안 하고 몇 시간씩 추측했다.
다음 프로젝트에서도 아마 또 그럴 것이다. 그래서 적어뒀다.