screen.getByRole('button',{name:'Open calendar'});// ^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^ + 기본값 hidden: false// role 필터 name 필터 hidden 필터// 후보 수집 이름으로 걸러냄 안 보이는 것 제외
여기서 computeAccessibleName은 스크린리더가 읽어주는 그 접근성 이름을 구하는 함수입니다.
그런데 순서가 이상합니다
name 필터가 hidden 필터보다 먼저 옵니다..filter()는 앞에서부터 실행되니 이런 일이 벌어지죠.
화면에 안 보여서 어차피 hidden 필터에서 걸러질 노드들의 이름을,
안 보인다는 걸 알아내기도 전에 전부 계산한다.
하나 더. .filter()니까 원하는 걸 찾아도 멈추지 않습니다.
그래서 DateRangeInput은요
DateRangeInput
├── 트리거 버튼 ← 내가 찾으려던 것
└── 팝오버 (닫힘)
└── 2개월치 달력
└── 날짜 버튼 약 85개 ← 화면에 안 보이지만 DOM에는 그대로
팝오버가 닫혀 있어도 달력은 언마운트되지 않습니다. 그러니 트리거 하나를 찾으려고 getByRole('button', {name})을 부르면, 후보 86개를 모으고 → 전부의 이름을 계산하고 → 이름이 다른 85개를 떨어뜨립니다. 그 85개가 애초에 화면에 없었다는 사실은 어디에도 쓰이지 않습니다.
보이지도 않을 버튼 85개의 이름을, 안 보인다는 걸 알기도 전에 전부 계산하고 있었던 겁니다.
다만 순서가 문제의 전부는 아닙니다. hidden 필터도 결국 후보 하나하나의 스타일을 봐야 하거든요. 이건 4장에서 다시 짚겠습니다.
그럼 450ms를 먹은 건 이름 계산일까요? 확인해봐야죠.
4. 옵션 하나를 뺐더니 450ms가 29ms가 됐다
의심은 의심일 뿐입니다. 재봐야 압니다.
문제는 세 필터가 한 덩어리로 돈다는 겁니다. 프로파일러는 "이 함수가 느리다"까지만 알려주지 셋 중 어느 것인지는 안 갈라줍니다. 방법은 하나뿐입니다. 떼어내고 다시 재는 것.
그래서 하한선부터 만들었습니다. name도 hidden도 없이 후보만 모으면 얼마나 걸리나?
js
screen.getByRole('button',{name:'Open calendar'});// → 450msscreen.getAllByRole('button',{hidden:true});// → 29ms (두 필터 다 끔)
두 옵션이 어떻게 필터를 끄는지는 3장에서 본 소스에 그대로 있습니다. name을 안 주면 이름 계산을 건너뛰고, hidden: true면 가시성 검사를 건너뜁니다. hidden 필터도 결국 스타일을 들여다보는 일이라, 이것까지 꺼야 순수한 바닥값이 나옵니다.
무엇을 쟀나
시간
비중
role 필터 (후보 수집만)
29ms
6%
name 필터 + hidden 필터
421ms
94%
합계
450ms
100%
후보 86개를 긁어모으는 것 자체는 29ms면 끝납니다. 나머지 94%는 "이름이 맞는지"와 "보이는지"를 따지는 데 쓰였습니다.
성능 문제를 좁힐 때 제가 배운 게 이겁니다. 한 덩어리로 보이는 것을 쪼개서, 가장 싼 조합의 바닥값부터 만든다.
그 바닥에서 얼마나 올라가는지를 보면 어디가 비싼지 드러납니다.
94%를 한 번 더 쪼개면
방금 그 말대로라면 여기서 멈추면 안 됩니다. 94%를 name과 hidden이 나눠 가졌는데 누가 얼마나 가져갔는지를 아직 모릅니다.
두 옵션이 필터를 따로 켜고 끄니 가를 수 있습니다.
js
screen.getAllByRole('button',{hidden:true});// role만screen.getAllByRole('button',{name:'Open calendar',hidden:true});// role + namescreen.getAllByRole('button');// role + hiddenscreen.getByRole('button',{name:'Open calendar'});// 셋 다
같은 구조를 jsdom 27.4.0 · @testing-library/dom 10.4.1로 재현해 넷을 각각 쟀습니다.
조합
getComputedStyle 호출
role만
0회
role + name
256회
role + hidden
176회
셋 다
261회
이 표는 재현 환경 값입니다 — 시간이 아니라 호출 횟수로 잰 이유 (펼치기)
시간도 쟀지만 표에서 뺐습니다. 같은 코드가 실행마다 1.4초와 2.4초 사이를 오가서, 호출이 더 많은 쪽이 더 빠르게 찍히는 일까지 생기더군요. 반면 호출 횟수는 몇 번을 돌려도 그대로였습니다. 이 절은 호출 횟수에만 기댑니다. (astryx CI가 아니라 제가 따로 만든 재현 환경입니다.)
이상한 게 보입니다. 셋 다 돌린 261회가 role + name의 256회와 거의 같습니다. 혼자면 176회를 부르는 hidden 필터가 여기선 5회로 끝났다는 뜻이죠.
.filter() 체인이라서 그렇습니다. hidden 필터는 name 필터가 걸러낸 결과를 받는데, 이름이 Open calendar인 버튼은 하나뿐입니다. 86개가 아니라 1개만 검사하는 겁니다.
그 5회는 버튼의 visibility 조기 종료 검사 1회 + 버튼부터 DIV → BODY → HTML까지의 display 4회입니다.
84회가 줄어듭니다. 그런데 3분의 2는 그대로 남습니다. 누가 먼저 오든 86개를 다 훑어야 하니까요.
순서는 낭비를 만들지만, 비용을 만들지는 않습니다.
비용은 "스타일을 봐야 한다"는 사실에서 나옵니다.
그래서 7장의 해법은 순서를 고치지 않습니다. 스타일 조회를 아예 없앱니다.
이름 계산이 비싸다는 것까지는 알았습니다. 그런데 여기서 새로운 질문이 생깁니다.
이름을 구하는 게 왜 그렇게 비싸지?
버튼 안에 있는 글자를 읽어오면 되는 거 아닌가?
저도 그렇게 생각했습니다. 아니었습니다.
원인 — 접근성 이름 계산과 jsdom의 getComputedStyle
5. 접근성 이름은 "보이는 대로 읽은 텍스트"입니다
접근성 이름이 그냥 버튼 안의 글자라면 textContent와 같아야 합니다. 버튼을 하나 만들되 자식 하나를 display: none으로 숨겨두고 확인해봤습니다. jsdom과 dom-accessibility-api(RTL이 내부에서 쓰는 바로 그 라이브러리)만 있으면 직접 돌려볼 수 있습니다.
js
// npm i jsdom dom-accessibility-apiconst{JSDOM}=require('jsdom');const{computeAccessibleName}=require('dom-accessibility-api');const dom =newJSDOM(`<!doctype html><body>
<button id="a"><span style="display: none">지난달</span> <span>15일</span></button>
</body>`);const button = dom.window.document.getElementById('a');console.log('textContent :',JSON.stringify(button.textContent.trim()));console.log('accessible name :',JSON.stringify(computeAccessibleName(button)));
textContent : "지난달 15일"
accessible name : "15일"
둘이 다릅니다. 숨겨둔 "지난달"이 접근성 이름에서만 빠졌습니다. 당연한 일이죠. 화면엔 15일만 보이는데 스크린리더가 "지난달 15일"이라 읽으면 틀린 정보니까요.
여기서 하나가 결정됩니다.
접근성 이름을 구하려면 "이 노드가 화면에 보이는가"를 반드시 확인해야 한다.
그리고 그걸 아는 방법은 CSS를 계산해보는 것뿐이다.
display: none이 인라인으로 왔는지, 클래스로 왔는지, 부모에게서 상속됐는지 알 수 없으니까요.
게다가 자식 하나하나를 봐야 합니다
확인 대상은 버튼 자신만이 아닙니다. dom-accessibility-api 소스를 열어보면 그대로 있습니다.
isHidden이 스타일 조회를 건너뛰는 건 hidden·aria-hidden속성이 붙은 노드에서뿐입니다. 그 밖의 노드는 — 멀쩡히 보이는 노드까지 — CSS로 숨겨졌는지 알아내려고 getComputedStyle을 한 번씩 부릅니다. 4장의 256회가 전부 여기서 나옵니다. 다만 8장의 트레이드오프는 CSS로 숨긴 경우에만 발생합니다.
날짜 컴포넌트 4개 파일에서 41개 호출부를 교체했습니다. 호출부가 짧아진 덕에 테스트 파일 자체는 64줄을 더하고 96줄을 덜어 32줄 줄었지만, 헬퍼 66줄이 새로 생겼으니 저장소 전체로는 늘었습니다(+134 −96). (전체 diff)
같은 파일의 combobox·tooltip·grid 쿼리는 그대로 뒀습니다. 후보가 몇 개 안 되거나 name을 안 쓰면 애초에 싸거든요. 비싼 곳만 바꿔야 팀이 표준 API 대신 사내 헬퍼를 익혀야 하는 부담이 줄어듭니다.
8. 무엇을 포기했나
공짜는 없습니다. 이 헬퍼는 두 가지를 포기했습니다.
포기 1 — 유일성 검사. RTL의 getByRole은 조건에 맞는 요소가 두 개 이상이면 에러를 던집니다. 성가신 기능 같지만 "이 이름의 버튼은 화면에 하나뿐이다"를 테스트가 대신 확인해주는 안전장치죠. .find()는 첫 매치에서 멈추므로 그게 사라집니다. 중복이 생겨도 테스트가 알려주지 않습니다.
포기 2 — 이름의 정확도. 스텁은 display에 항상 "block"이라 대답하니 CSS로 숨긴 노드도 "보인다"고 판정됩니다. 앞서 textContent와 갈렸던 그 버튼을 다시 보죠.
<button><span style="display: none">지난달</span> <span>15일</span></button>
RTL : "15일"
이 헬퍼 : "지난달 15일" ← 숨긴 텍스트가 이름에 섞여 들어온다
"접근성 이름은 보이는 대로 읽은 텍스트"라는 원칙이 여기서 깨집니다. getComputedStyle 비용을 없앤 대가로 "보이는 대로"를 포기한 셈입니다.
날짜 버튼은 <button><span>15</span></button> 정도로 단순해서 문제가 없었지만, 다른 코드베이스에 옮길 때는 반드시 확인해야 합니다.
그래서 헬퍼 파일 맨 위 주석에 트레이드오프를 명시해뒀습니다.
Trade-off vs getByRole: first match wins — no tree-wide uniqueness check.
정리하면 이 헬퍼는 "이 파일들에서, 이 조건에서" 안전한 도구입니다.
범용 유틸리티가 아니라 국소 최적화죠. 그래서 테스트 폴더 안에만 두었습니다.
성과와 적용 기준 — 34.3초에서 1.3초로
9. 결과
날짜 컴포넌트 4개 파일, 211개 테스트의 실행 시간입니다.
아래 수치는 PR 본문에 그대로 기록돼 있습니다.
파일
테스트 수
before
after
변화
DateRangeInput.test.tsx
34
34.3s
1.3s
−96%
Calendar.test.tsx
45
7.2s
1.9s
−74%
DateInput.test.tsx
68
5.1s
2.2s
−57%
DateTimeInput.test.tsx
64
4.1s
1.8s
−56%
합계
211
50.7s
7.2s
−86%
처음의 그 파일은 34.3초에서 1.3초가 됐습니다. 약 26배입니다.
그런데 분모를 봐야 합니다
26배는 파일 하나 이야기입니다. 전체 스위트는 이렇습니다.
무엇
before
after
변화
날짜 파일 4개
50.7s
7.2s
−86%
워커별 테스트 시간 합계
297.7s
235.1s
−21%
전체 벽시계 시간
105.9s
92.8s
−12%
26배를 줄였는데 벽시계는 12%만 줄었습니다.
테스트가 여러 워커에 나뉘어 병렬로 돌기 때문입니다. 느린 파일 하나를 34초에서 1.3초로 줄여도, 그 워커가 남는 동안 다른 워커가 여전히 자기 몫을 돌고 있으면 전체는 그만큼 안 줄어듭니다. 가장 오래 걸리는 경로가 따로 있는 거죠.
그러니 이 글의 26배는 "이 병목은 확실히 제거됐다" 는 뜻이지 "CI가 26배 빨라졌다" 는 뜻이 아닙니다.
여기서 강조하고 싶은 게 있습니다.
테스트가 확인하는 동작은 바뀌지 않았습니다.
컴포넌트 코드도 각 테스트의 단언도 손대지 않았고, 전체 5,893개 테스트가 그대로 통과합니다.
바꾼 건 요소를 찾는 방법입니다.
다만 8장에서 적었듯 그 방법에는 대가가 있으니, "검증이 하나도 안 줄었다"고까지 말하면 과장입니다. 유일성 검사가 쿼리에서 빠졌습니다.
그럼 남은 시간은 어디에 있을까요. 그건 쿼리가 아니라 워커에 일을 어떻게 나누느냐의 문제라, 이 글의 범위 밖입니다.
10. 그래서 언제 이걸 의심해야 할까요
여기까지 읽고 getByRole을 걷어내지는 마세요. 대부분의 경우 몇 밀리초면 끝납니다. 문제가 되는 건 특정 조건이 겹칠 때뿐입니다.
RTL이 잘못한 게 아닙니다.
접근성 이름을 스펙대로 정확히 계산하는 것이 RTL의 존재 이유입니다.
비싼 쪽은 jsdom의 getComputedStyle이고, 실제 브라우저에서는 이 비용이 거의 없습니다.
우리가 한 일은 RTL을 고친 게 아니라, {hidden: true}가 허용하는 범위 안에서 테스트 환경에서만 발생하는 비용을 걷어낸 것입니다.
의심 조건
이 네 가지가 동시에 성립하면 확인해볼 만합니다.
role 후보가 수십 개 이상 마운트되어 있다 — 달력 그리드, 긴 목록, 데이터 테이블
그중 상당수가 화면에 안 보인다 — 닫힌 팝오버, 접힌 아코디언, 숨긴 탭 패널
{name} 옵션을 쓴다 — 이름 계산이 켜지는 스위치입니다
테스트가 자주 리렌더한다 — jsdom의 스타일 캐시가 계속 무효화됩니다
날짜 컴포넌트가 느렸던 건 이 넷이 전부 겹쳤기 때문입니다.
버튼이 다섯 개인 평범한 컴포넌트라면 아무 문제 없습니다.
확인하는 법
두 쿼리를 나란히 재보면 됩니다. 복사해서 느린 테스트에 붙여넣으면 바로 나옵니다.
js
const t0 =performance.now();screen.getByRole('button',{name:'내가 찾는 버튼'});const t1 =performance.now();screen.getAllByRole('button',{hidden:true});const t2 =performance.now();console.log('name 포함 :',(t1 - t0).toFixed(0),'ms');console.log('name 제외 :',(t2 - t1).toFixed(0),'ms');
두 숫자가 비슷하면 이 글과 무관한 문제입니다. 차이가 10배 이상 나면 이름 계산이 원인입니다.
전용 헬퍼는 마지막 선택지입니다
순서
방법
언제
1
안 보이는 DOM을 애초에 안 그린다
팝오버가 닫혔을 때 언마운트할 수 있다면. 가장 근본적입니다
2
within()으로 탐색 범위를 좁힌다
구조를 못 바꿀 때. 후보 수 자체가 줄어듭니다
3
전용 헬퍼를 만든다
위 둘이 불가능할 때. 이 글의 방법입니다
이번에는 1번을 고르지 않았습니다. 컴포넌트 동작을 바꾸는 일이라 테스트만 손보려던 범위를 넘었거든요. 2번은 찾으려는 요소와 숨은 요소가 같은 컨테이너에 있으면 효과가 없습니다.
3번이 첫 번째 선택지가 되면 안 됩니다. 표준 API를 떠나는 건 팀이 치르는 비용이니까요.
마치며
getByRole('button', {name})은 제게 "이름이 이런 버튼을 찾아줘"라는 한 문장이었습니다.
열어보니 .filter() 다섯 개가 줄줄이 있었고, 그중 하나가 트리 전체를 훑고 있었습니다.
편의는 공짜가 아니라 누군가 대신 내주고 있는 비용이었습니다.
평소에는 그 비용이 충분히 작아서 안 보일 뿐이고요.
그러니 도구가 이상하게 느리다면, 한 번쯤 열어보시길 권합니다. node_modules 안에 답이 있는 경우가 생각보다 많습니다.