levi

리바이's Tech Blog

Tech BlogPortfolioBoard
AllActivitiesJavascriptTypeScriptNetworkNext.jsReactWoowacourseAlgorithm
COPYRIGHT ⓒ eunwoo-levi
eunwoo1341@gmail.com

📚 목차

    [React] 시맨틱 태그, Aria, 포커스 트래핑으로 접근성(VoiceOver)과 SEO까지 개선하기

    ByEunwoo
    2026년 1월 7일
    react

    프론트엔드 개발을 하다 보면, 기능이 잘 동작하고 UI가 예쁘게 렌더링되면 끝이라고 느끼기 쉽다.
    그런데 어느 순간부터 이런 질문이 계속 남았다.

    • 이 UI는 스크린 리더(VoiceOver)가 이해할 수 있을까?
    • 키보드(Tab)만으로도 흐름이 끊기지 않을까?
    • 검색 엔진/크롤러가 이 페이지의 구조를 “문서”로 파악할 수 있을까?

    특히 로그인/모달/내비게이션처럼 “앱의 뼈대”가 되는 UI는 시각 사용자에게만 잘 동작하면 충분하지 않다.
    접근성은 옵션이 아니라 기본 품질이고, 시맨틱 태그와 ARIA는 그 품질을 문서 구조 수준에서 끌어올리는 도구다.

    이번 글에서는 내가 실제 프로젝트에서 적용한 내용을 다음 순서로 정리한다.

    1. 시맨틱 태그가 왜 중요한가 (스크린리더/키보드/크롤러/SEO)
    2. ARIA는 언제, 어떻게 써야 하는가 (과하게 쓰지 않기)
    3. 폼에서 “에러가 보인다”를 “에러가 읽힌다”로
    4. 카드/링크/아이콘 UI에 의미 부여하기
    5. 모달에서 Tab 키 기반 키보드 네비게이션 + 포커스 트래핑(useModalFocus) 구현
    6. VoiceOver로 검증하는 체크리스트

    1. 시맨틱 태그가 왜 중요한가?

    1-1. div는 “박스”고, 시맨틱 태그는 “문장”이다

    div는 레이아웃에는 편하지만 의미가 없다. 반면 시맨틱 태그는 그 자체로 역할을 가진다.

    • <form>: 입력을 제출하는 폼
    • <label>: 입력 필드의 이름
    • <button>: 클릭 가능한 인터랙션
    • <nav>: 내비게이션 영역
    • <main>: 페이지의 메인 콘텐츠
    • <header>, <footer>: 상단/하단 영역
    • <h1>~<h6>: 섹션의 계층 구조

    시맨틱 태그는 “이 요소가 무슨 역할을 하나요?”를 명확히 한다.
    같은 UI라도 시맨틱 태그를 쓰면 브라우저/스크린리더/크롤러가 이미 의미를 알고 있는 구조가 된다.

    1-2. 스크린 리더는 “스크롤”보다 “탐색”을 한다

    시각 사용자는 스크롤로 훑는다.
    하지만 스크린 리더 사용자는 이런 방식으로 빠르게 이동한다.

    • 랜드마크(메인/내비/헤더 등) 점프
    • 헤딩 목록으로 섹션 이동
    • 링크/버튼 목록으로 탐색

    이때 시맨틱 구조가 없으면 페이지는 “의미 없는 박스들의 나열”이 되고, 사용자는 맥락을 잃는다.
    반대로 구조가 명확하면 **페이지가 “탐색 가능한 문서”**가 된다.

    1-3. SEO/크롤링 관점에서도 “해석 비용”이 줄어든다

    시맨틱 태그 하나로 검색 순위가 갑자기 오르진 않는다.
    하지만 다음 효과는 확실히 체감된다.

    • 내비게이션/본문/제목/섹션이 분리되면서 콘텐츠 중심이 명확해짐
    • 중복 텍스트(장식 요소/아이콘)가 정리되면서 본문 신호가 선명해짐
    • 구조가 안정되면서 페이지 템플릿 품질이 일정해짐(크롤러 입장에서도 예측 가능)

    즉, 시맨틱 태그는 “SEO 트릭”이 아니라 문서를 문서답게 만드는 기본기다.

    2. ARIA는 “보완재”다: Semantic First, ARIA Last

    ARIA는 강력하지만, 잘못 쓰면 오히려 접근성을 망친다.
    내가 지킨 원칙은 단순했다.

    1. 먼저 네이티브 요소로 해결하기 (button, a, input, label…)
    2. 네이티브로 의미 전달이 부족할 때만 ARIA로 보완하기
    3. ARIA로 “역할”을 흉내내기보다 진짜 역할을 가진 태그로 바꾸기

    예를 들어 div + role="button"은 가능하지만,
    가능하면 애초에 <button>을 쓰는 것이 더 안전하고 유지보수도 쉽다.

    2-1. form: “에러가 보인다”를 “에러가 읽힌다”로

    form은 접근성의 기본이 드러나는 UI다.

    • 라벨이 연결되어야 한다
    • 에러가 발생하면 사용자가 즉시 인지해야 한다
    • 스크린 리더가 에러 메시지와 입력 필드를 관계로 이해해야 한다

    form으로 자주 쓰이는 로그인 폼으로 어떻게 개선했는지 보자. (style은 생략)

    내 코드의 핵심은 aria-describedby + aria-invalid + 라이브 영역(aria-live)이다.

    <fieldset>
      <legend className='sr-only'>로그인 정보 입력</legend>
      <div>
        <label htmlFor='email'>이메일</label>
        <input
          id='email'
          type='email'
          placeholder='이메일을 입력해주세요'
          value={formData.email}
          onChange={handleChange('email')}
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
        />
        <span id='email-error' role={errors.email ? 'alert' : undefined} aria-live='polite'>
          {errors.email || ' '}
        </span>
      </div>
      <div>
        <label htmlFor='password'>비밀번호</label>
        <input
          id='password'
          type='password'
          placeholder='비밀번호를 입력해주세요'
          value={formData.password}
          onChange={handleChange('password')}
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
        />
        <span
          id='password-error'
          role={errors.password ? 'alert' : undefined}
          aria-live='polite'
        >
          {errors.password || ' '}
        </span>
      </div>
    </fieldSet>
    • aria-describedby: 에러 메시지와 입력 필드를 연결해 스크린 리더가 “이 필드에 이런 에러가 있어요”라고 읽게 한다.
    • aria-invalid: 입력 필드가 에러 상태임을 명시해 스크린 리더가 “이 필드는 잘못된 값이에요”라고 알린다.
    • role="alert" + aria-live="polite": 에러 메시지에 라이브 영역을 지정해 에러가 생기면 스크린 리더가 즉시 읽도록 한다.

    즉, 화면에서 빨간 글씨가 보이는 것에 그치지 않고
    “스크린 리더가 에러를 인지하고 안내하는 흐름”을 만들 수 있다.

    2-2. 카드/내비게이션/아이콘: “의미”를 채우는 디테일

    시맨틱 태그를 잘 써도, 실제 제품 UI는 Card, Chip, IconButton, Tabs 같은 디자인 시스템 컴포넌트로 쌓이면서 의미가 흐려지기 쉽다.
    특히 접근성에서 자주 터지는 지점은 “겉으로는 클릭 가능해 보이지만, 기계(스크린리더/키보드)는 그걸 모른다”는 문제다.

    “클릭 가능한 영역”은 가능한 한 네이티브 요소로 만든다

    클릭하면 동작하는 UI는 div가 아니라 <button> 또는 <a>가 1순위다.

    “버튼처럼 보이는 div”는 만들 수 있지만, 그 순간부터 키보드/스크린리더 동작을 전부 수동으로 재구현하게 된다.

    그럼 언제 button와 <a>를 써야 할까?
    버튼 같은 경우는 같은 페이지에서 상태를 바꾸거나 모달을 여는 등 “행동(action)”이면 <button type="button"> 을 쓴다.
    링크 같은 경우는 다른 페이지로 이동하거나 외부 URL을 여는 등 “이동(navigation)”이면 <a href="...">를 쓴다.

    불가피하게 div를 써야 한다면(최후의 수단)

    role="button", tabIndex={0}를 추가하고,
    Enter/Space 키 이벤트 처리까지 구현해 키보드 접근성을 제공해야 한다.

    결론: 의미를 ARIA로 “붙이는 것”보다, 태그 자체로 “가지게 하는 것”이 더 안전하다.

    현재 위치/선택 상태는 시각적 표시만으로 끝내지 않는다

    탭/사이드바/헤더 링크처럼 “현재 위치”가 중요한 UI는, 색상/밑줄만으로 표시하면 스크린리더·키보드 사용자는 맥락을 잃는다.

    • 현재 페이지 링크: aria-current="page"

    • 탭 UI

      • 탭 버튼: role="tab"
      • 탭 리스트: role="tablist"
      • 선택 상태: aria-selected
      • 연결: aria-controls / id

    이렇게 하면 “선택됨/현재” 같은 상태 정보가 음성으로도 전달된다.

    아이콘은 ‘정보’인지 ‘장식’인지 먼저 결정한다

    아이콘은 접근성에서 흔한 함정이다.
    화면에는 예쁘지만, 스크린리더 입장에서는 의미 없는 요소가 반복해서 읽히는 노이즈가 되기 쉽다.

    • 장식 아이콘(대부분의 경우)

    • 정보 전달이 목적이 아니라면: aria-hidden="true"

    • 예: 단순한 꾸밈, 레이아웃 분리, “분위기용” 아이콘

    • 정보 아이콘(아이콘이 의미가 있는 경우)

      • 버튼이면 aria-label="..."로 의도를 설명
      • 상태 아이콘이면 주변 텍스트로 의미를 제공하거나 aria-label로 보완

    여기서 자주 하는 실수는 아이콘과 텍스트가 함께 있는데 둘 다 읽혀서 중복 낭독 발생할 수 있다.
    그래서 아이콘을 aria-hidden="true" 처리하고, 텍스트만 읽히게 정리하는 게 깔끔하다.

    2-3. 동적 텍스트(알림, 에러, 배지)는 “변화”를 읽히게 만든다

    UI에서 숫자 배지(알림 수), 폼 에러, 토스트처럼 텍스트가 실시간으로 바뀌는 요소는 시각 사용자에게는 즉시 보이지만, 스크린리더에게는 “변화가 있었는지”가 전달되지 않을 수 있다.

    에러/중요 알림: role="alert" , aria-live="assertive"

    role="alert"를 사용하면 자동으로 aria-live="assertive"가 적용되어 즉시 낭독된다.

    <div id="alert-box" role="alert">
      <!-- 중요한 알림이 여기에 표시됨 -->
    </div>
     
    <button onclick="showAlert()">알림</button>
     
    <script>
    function showAlert() {
        document.getElementById("alert-box").innerText = "긴급 공지: 서비스 점검이 예정되었습니다.";
    }
    </script>

    덜 긴급한 변화: aria-live="polite"

    사용자의 현재 작업을 방해하지 않고, 콘텐츠가 변경되었을 때만 읽어준다.
    주로 알림 메시지, 검색 결과 갱신 등에 사용된다

    <div id="search-result" aria-live="polite">
      검색 결과가 여기에 표시됩니다.
    </div>
     
    <button onclick="updateSearchResults()">검색</button>
     
    <script>
    function updateSearchResults() {
        document.getElementById("search-result").innerText = "총 10개의 검색 결과가 있습니다.";
    }
    </script>

    위 코드를 예시로 보면, 검색 버튼을 누르면 결과가 업데이트되며, 스크린 리더가 새로운 내용만 읽어준다.
    하지만 사용자의 현재 작업을 방해하지 않는다.

    입력과 에러 연결: aria-describedby + aria-invalid

    <form>
      <label for='username'>사용자 이름:</label>
      <input id='username' type='text' aria-describedby='username-error' aria-invalid='true' />
      <span id='username-error' role='alert' aria-live='assertive'>
        사용자 이름은 필수 입력 항목입니다.
      </span>
    </form>

    aria-describedby는 입력 필드와 에러 메시지를 연결해 스크린 리더가 “이 필드에 이런 에러가 있어요”라고 읽게 한다.
    aria-invalid는 입력 필드가 에러 상태임을 명시해 스크린 리더가 “이 필드는 잘못된 값이에요”라고 알린다.

    3. 모달 접근성의 진짜 핵심: Tab 네비게이션 + 포커스 트래핑

    시맨틱/ARIA를 잘 적용해도, 모달에서 이 문제가 남으면 접근성은 깨진다.

    모달이 열렸는데 Tab을 누르면 배경으로 포커스가 빠져나간다.

    키보드 사용자에게 이건 “길을 잃는 경험”이다.

    • 모달을 조작 중인데 포커스가 배경 버튼으로 이동
    • 현재 위치를 잃고 다시 모달로 돌아오기 어렵다
    • 스크린 리더는 배경을 계속 읽어버려 “모달 맥락”이 무너진다

    그래서 나는 useModalFocus 훅으로 **포커스 트래핑(Focus Trap)**을 구현했다.

    useModalFocus 코드

    import { useEffect, useRef } from 'react';
     
    const FOCUSABLE_ELEMENTS =
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
     
    export const useModalFocus = (isOpen: boolean) => {
      const modalRef = useRef<HTMLDivElement>(null);
      const focusableElementsRef = useRef<NodeListOf<Element> | null>(null);
     
      useEffect(() => {
        if (isOpen && modalRef.current) {
          focusableElementsRef.current = modalRef.current.querySelectorAll(FOCUSABLE_ELEMENTS);
          const focusableElements = focusableElementsRef.current;
     
          if (focusableElements.length > 0) {
            (focusableElements[0] as HTMLElement).focus();
          } else {
            modalRef.current.setAttribute('tabindex', '-1');
            modalRef.current.focus();
          }
     
          const handleKeyDown = (event: KeyboardEvent) => {
            if (event.key === 'Tab') {
              if (!focusableElementsRef.current || focusableElementsRef.current.length === 0) return;
              const focusableElements = focusableElementsRef.current;
     
              const firstElement = focusableElements[0] as HTMLElement;
              const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
     
              if (event.shiftKey && document.activeElement === firstElement) {
                lastElement.focus();
                event.preventDefault();
              } else if (!event.shiftKey && document.activeElement === lastElement) {
                firstElement.focus();
                event.preventDefault();
              }
            }
          };
     
          document.addEventListener('keydown', handleKeyDown);
          return () => document.removeEventListener('keydown', handleKeyDown);
        }
      }, [isOpen]);
     
      return modalRef;
    };

    위 코드를 천천히 분석해보면,

    FOCUSABLE_ELEMENTS의 경우 모달 내에서 포커스 가능한 모든 요소를 선택한다.

    이후에 아래 코드를 통하여 모달이 열릴 때 첫 번째 포커스 가능한 요소에 포커스를 맞추거나, 포커스 가능한 요소가 없을 경우 모달 컨테이너에 포커스를 맞춘다.

    if (focusableElements.length > 0) {
      (focusableElements[0] as HTMLElement).focus();
    } else {
      modalRef.current.setAttribute('tabindex', '-1');
      modalRef.current.focus();
    }

    그 다음 코드가 핵심인데, Tab/Shift+Tab 키 이벤트를 감지하여 포커스가 모달 내부에서 순환하도록 하는 역할을 하고 있다.

    if (event.key === 'Tab') {
      ...
      const firstElement = focusableElements[0] as HTMLElement;
      const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
     
      // Shift+Tab으로 첫 요소에서 "이전"으로 나가려는 순간 → 마지막으로 보내기
      if (event.shiftKey && document.activeElement === firstElement) {
        lastElement.focus();
        event.preventDefault();
      }
      // Tab으로 마지막 요소에서 "다음"으로 나가려는 순간 → 첫 번째로 보내기
      else if (!event.shiftKey && document.activeElement === lastElement) {
        firstElement.focus();
        event.preventDefault();
      }
    }

    정리하면, 브라우저에서 Tab 키를 누를 때 기본적으로 다음으로 넘어가는 기능을 제공해주지만, 자신이 구현한 모달(컴포넌트) 밖으로 포커스가 나가는 것을 방지하기 위해, useModalFocus 훅에서는 Tab 키 이벤트를 감지하여 포커스가 모달 내부에서 순환하도록 한다.

    VoiceOver으로 검증

    VoiceOver는 macOS/iOS에 내장된 스크린 리더다.
    해당 스크린리더를 통하여 시맨틱 태그와 ARIA가 제대로 동작하는지 검증할 수 있다.

    내가 실제로 VoiceOver(맥)에서 확인한 체크리스트는 아래다.

    키보드(Tab) 관점

    • Tab 순서가 시각적 흐름과 자연스러운가?
    • 포커스가 빠져나가거나(배경/주소창) 끊기지 않는가?
    • 모달 오픈 시 첫 포커스가 적절한 위치로 이동하는가?
    • 모달 내에서 Tab/Shift+Tab이 정상 순환하는가?
    • Escape로 닫히는가?

    스크린 리더(VoiceOver) 관점

    • 로그인 에러가 발생했을 때 즉시 읽히는가?
    • 버튼/링크가 역할에 맞게 읽히는가?
    • 아이콘/장식 요소가 불필요하게 읽히지 않는가?
    • 모달이 “dialog”로 인지되고 제목이 함께 읽히는가?
    • 모달이 열린 동안 배경을 읽으려 하지 않는가?

    로그인 페이지

    개선 전

    브라우저가 video 태그를 지원하지 않습니다.

    개선 후

    브라우저가 video 태그를 지원하지 않습니다.

    글 작성 도메인 페이지

    개선 전

    브라우저가 video 태그를 지원하지 않습니다.

    개선 후

    브라우저가 video 태그를 지원하지 않습니다.

    Posted inreact
    Written byEunwoo