AppSidebar: 웹 접근성(WAI-ARIA) 준수 및 rAF 인터랙션 최적화

작성일: 2026년 8월 2일 AM 05:52
조회수: 13

thumbnail

사이드바 너비를 드래그로 조절하는 기능을 만들었는데, 저사양 환경에서 드래그하면 화면이 눈에 띄게 끊겼습니다. 그리고 뒤늦게 깨달은 게 하나 더 있었습니다. 마우스가 없으면 이 기능을 아예 쓸 수 없다는 것이었습니다. 이 글은 그 두 가지를 함께 정리한 기록입니다.

1) 문제 상황

접근성이 빠져 있었습니다. 리사이즈 기능이 마우스 드래그에만 의존하고 있어서, 키보드 사용자와 스크린 리더 사용자는 사이드바 너비를 조절할 방법이 없었습니다. 단순한 div에 mousedown 핸들러만 붙인 형태라 스크린 리더 입장에서는 이 요소가 무엇인지조차 알 수 없었습니다.

렌더링이 병목이었습니다. mousemove 이벤트가 발생할 때마다 즉시 setState와 레이아웃 연산이 수행됐습니다. mousemove는 브라우저 주사율과 무관하게 훨씬 자주 발생하기 때문에, 실제로 화면에 그려지지도 않을 렌더링을 계속 시도하는 구조였습니다. 메인 스레드에 부하가 쌓이면서 저사양 환경에서 특히 끊김이 두드러졌습니다.

2) 해결 과정

Step 1: WAI-ARIA 표준 기반 접근성 강화

리사이즈 핸들을 role="separator"로 정의하고, 현재 너비를 실시간으로 전달하는 aria-valuenow를 부여했습니다. 스크린 리더가 "이건 구분선이고 지금 값은 얼마"라고 읽어줄 수 있게 하는 최소 조건입니다.

여기에 tabIndex={0}과 키보드 핸들러(Arrow, Home, End)를 추가했습니다. 방향키로 미세 조정하고 Home/End로 최소·최대값에 바로 도달할 수 있어, 오히려 마우스보다 정밀한 제어가 가능해졌습니다.

Step 2: requestAnimationFrame으로 렌더링 스케줄링

브라우저의 렌더링 파이프라인과 자바스크립트 실행 타이밍을 맞추기 위해 rAF를 도입했습니다.

핵심은 이벤트 큐에 쌓인 고빈도 mousemove 요청을 다음 프레임 직전에 단 한 번만 처리하는 것입니다. 이전 프레임 요청이 아직 처리되지 않았다면 취소하고 새 요청으로 대체합니다. 어차피 화면에 그려지지 않을 중간 상태를 계산하는 데 메인 스레드를 쓰지 않게 됩니다.

3) 최종 코드

마우스와 키보드를 모두 대응하면서, rAF 렌더링 최적화를 결합한 형태입니다.

tsx
// 주요 속성: role="separator", aria-valuenow, tabIndex={0}
// 주요 로직: rAF 기반 mousemove 스로틀링 + 키보드 이벤트 분기

export function AppSidebar() {
  // ... 생략 (상태 관리 로직)

  // 1. 키보드 접근성 핸들러 (ArrowLeft/Right, Home, End)
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
        e.preventDefault();
        const delta = e.key === "ArrowLeft" ? -KEYBOARD_STEP : KEYBOARD_STEP;
        const nextWidth = Math.min(
          MAX_SIDEBAR_WIDTH,
          Math.max(MIN_SIDEBAR_WIDTH, sidebarWidth + delta)
        );

        setSidebarWidth(nextWidth);
        updateSidebarWidth(nextWidth);
      }

      if (e.key === "Home") {
        e.preventDefault();
        setSidebarWidth(MIN_SIDEBAR_WIDTH);
        updateSidebarWidth(MIN_SIDEBAR_WIDTH);
      }

      if (e.key === "End") {
        e.preventDefault();
        setSidebarWidth(MAX_SIDEBAR_WIDTH);
        updateSidebarWidth(MAX_SIDEBAR_WIDTH);
      }
    },
    [sidebarWidth, setSidebarWidth, updateSidebarWidth]
  );

  // 2. rAF 기반 마우스 리사이즈 최적화 (mousemove)
  useEffect(() => {
    let frameId: number | null = null;

    const handleMouseMove = (e: MouseEvent) => {
      if (!isResizing) return;
      if (frameId) cancelAnimationFrame(frameId);

      frameId = requestAnimationFrame(() => {
        const nextWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, e.clientX));
        setSidebarWidth(nextWidth);
      });
    };

    // ... 이벤트 등록 및 정리(Cleanup) 로직
  }, [isResizing]);

  return (/* ... */);
}

cancelAnimationFrame으로 이전 요청을 취소하는 부분이 중요합니다. 취소하지 않으면 rAF 콜백이 큐에 계속 쌓이고, 결과적으로 스로틀링 효과가 사라집니다.

4) 결과: 데이터 기반 검증

Chrome DevTools Performance 탭으로 최적화 전후를 비교했습니다.

지표최적화 전 (Direct Update)최적화 후 (rAF 적용)성과
Animation Track프레임 누락(Red Triangles) 다수 발생프레임 누락 지표 거의 소멸시각적 부드러움 확보
Main ThreadLong Task로 인한 스레드 병목 관찰작업 단위 분절화로 부하 분산시스템 응답성 안정화
인터랙션 경험드래그 시 화면 끊김(Jank) 체감60fps에 준하는 매끄러운 반응성UX 품질 향상

여기서 한 가지 짚고 싶은 게 있습니다. INP(Interaction to Next Paint) 수치만 보면 81ms에서 86ms로 오히려 소폭 나빠졌습니다. 처음엔 최적화가 실패한 줄 알았습니다.

하지만 INP는 "한 번의 인터랙션이 화면에 반영되기까지"를 재는 지표입니다. rAF는 의도적으로 다음 프레임까지 기다리기 때문에 개별 반영은 아주 조금 늦어집니다. 반면 사이드바 리사이즈처럼 지속되는 인터랙션에서 사용자가 실제로 체감하는 건 개별 반영 속도가 아니라 프레임이 끊기지 않고 이어지는지(Smoothness)입니다.

그래서 지표를 INP가 아니라 애니메이션 트랙의 프레임 누락으로 바꿔 봤고, 그 기준으로는 약 80% 이상 개선됐습니다. 저사양 환경에서도 드래그가 매끄럽게 이어집니다. 숫자 하나만 보고 판단했다면 되돌릴 뻔한 작업이었습니다.

0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!