기술 포스트

라이트하우스가 시킨 것, 그리고 안 고친 것 하나


Article

이 시리즈는 내가 틀린 이야기의 연속이었다. 서버가 느린 줄 알았고(2편), 데이터가 균등한 줄 알았고(3편), 로컬이 통과하니 괜찮은 줄 알았다(4편).

라이트하우스도 나를 두 번 잡았다. 둘 다 "숨겼다"고 생각한 것이 안 숨겨져 있었다.

그리고 하나는 알면서 안 고쳤다. 그 이야기도 적는다.


숨겼는데 왜 경고가 뜨나

카드 목록에는 검색·필터 패널이 있다. 닫으면 화면에서 사라진다. 안에는 inputselectbutton이 들어 있다.

닫혀 있으니 안 보인다. 그래서 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     ← 이제 직계 자식이다

라이브러리에 태그를 넘길 때, 그게 어느 레벨의 태그인지 확인해야 한다. tagwrapperTag는 한 글자 차이인데 결과는 유효/무효로 갈렸다.

이 시리즈의 다른 편들과 같은 모양이다. 내가 준 것과 실제로 나온 것이 달랐다. 2편에서는 서버에 목록을 줬는데 HTML엔 안 실렸고, 여기서는 ul을 줬는데 ul>div>li가 나왔다.


알면서 안 고친 것

카드에 호버하면 테두리를 따라 밝은 선분이 한 바퀴씩 돈다. stroke-dashoffset을 애니메이션한다.

라이트하우스가 지적했다. 비합성 애니메이션.

브라우저는 transformopacity만 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를 만들며 여섯 편을 썼다. 돌아보니 같은 이야기를 여섯 번 했다.

여섯 번 다 같은 자리에서 넘어졌다
  1. 01

    1편 — 백엔드를 숨기지 않기로 했다

    서버액션을 안 쓴 건 못 써서가 아니라 인증 구조가 그렇게 정해줬기 때문이다. 도구가 아니라 구조가 먼저다.

  2. 02

    2편 — 로딩바 하나에 세 번 속았다

    서버가 목록을 잘 가져왔는데 서버 HTML 엔 카드가 0개였다. cat 한 줄이면 알 수 있었다.

  3. 03

    3편 — 카운트는 다 달랐는데 차트는 평평했다

    저장된 값이 아니라 화면에 그려지는 값으로 설계해야 했다. 반올림이 카운트 둘을 한 칸으로 뭉갰다.

  4. 04

    4편 — 로컬은 통과하는데 운영만 죽는다

    로컬은 스키마를 매번 버리고 운영은 쌓아둔다. 테스트 82개가 전부 통과해도 보장 못 하는 영역이 있다.

  5. 05

    5편 — AI 를 안 쓰기로 했다

    결과가 안 바뀌어야 하고 계산이 공짜여야 한다. 그 둘이 정해지자 답은 하나뿐이었다.

  6. 06

    6편 — 숨겼는데 안 숨겨져 있었다

    aria-hidden 은 스크린리더에게만 숨긴다. ul 을 줬는데 ul > div > li 가 나왔다. 렌더된 것을 직접 열어봐야 안다.

여섯 번 다 "내가 준 것"과 "실제로 나온 것"이 달랐다. 그리고 여섯 번 다, 추측을 멈추고 결과물을 열었을 때 풀렸다.

이 시리즈를 관통하는 한 줄이 있다면 이것이다.

내가 어떻게 설계했는지는 화면에 안 나온다. 화면에 나오는 것만 화면에 나온다.

빌드된 HTML을 열고, 화면을 그리는 함수를 열고, 렌더된 DOM을 열고, 운영이 뱉은 예외 문장을 읽는다. 매번 5분이면 되는 일이었다. 그런데 매번 그걸 안 하고 몇 시간씩 추측했다.

다음 프로젝트에서도 아마 또 그럴 것이다. 그래서 적어뒀다.