들어가며#
혹시 마우스 없이 인터넷을 쓴 경험이 있으신가요? 대부분의 사람들은 마우스를 당연하게 사용합니다. 하지만 세상에는 마우스를 쓸 수 없는 사람들이 있어요.
- 신체 장애로 인해 마우스를 조작할 수 없는 사람
- 손목터널증후군 같은 반복성 긴장 장애가 있는 사람
- 일시적으로 팔이 다친 사람
- 단순히 키보드가 더 효율적이라고 생각하는 파워 유저
이런 사용자들에게 “키보드로 이 사이트를 쓸 수 있나요?“라는 질문은 매우 중요합니다.
여기서 얘기하는 키보드 접근성은 단순히 Tab 키로 이동하는 것만을 의미하지 않습니다. 포커스 관리, 키보드 단축키, 드래그 앤 드롭 대체 수단, 모달 창 내비게이션 등 정말 많은 것들이 포함되어 있어요.
이번 글에서는 키보드 접근성의 A부터 Z까지 모든 것을 다루겠습니다. 이론적 배경도 있지만, 무엇보다 실무에서 바로 적용할 수 있는 코드 예제를 중심으로 진행할게요.

사진: Nanobanana AI로 생성
왜 키보드 접근성이 중요한가?#

사진: Nanobanana AI로 생성
표준과 실무 요구사항#
많은 접근성 기준이 키보드 접근성을 핵심 요구사항으로 다룹니다. 특히 WCAG 2.2의 Keyboard Accessible 성공 기준은 “키보드만으로 모든 기능을 사용할 수 있어야 한다”는 원칙을 명확히 합니다.
사용자 수요#
실제로 얼마나 많은 사람들이 키보드로 웹을 이용할까요?
- 운동장애로 마우스를 쓰기 어려운 사용자
- 일시적 부상이나 피로로 마우스 사용이 불편한 사용자
- 효율성 때문에 키보드를 선호하는 파워 유저
부수적 이점 (Co-benefits)#
키보드 접근성은 마치 건물의 경사로 같습니다.
처음엔 “휠체어 사용자를 위한 시설이겠지?” 생각하지만, 실제로는 유모차를 끄는 부모, 무거운 짐을 끄는 배달 기사, 계단이 불편한 어르신까지 모두가 편하게 이용하죠. 키보드 접근성도 마찬가지예요.
흥미롭게도, 키보드 접근성 개선은 다른 접근성도 함께 개선합니다:
- 스크린 리더: 키보드 접근성이 좋으면 스크린 리더 사용도 자동으로 좋아짐
- 음성 제어: 키보드 네비게이션이 가능하면 음성 제어도 가능
- 모바일: 모바일 터치는 일종의 포커스 기반 내비게이션
- 개발 효율: 명확한 포커스 관리는 코드 품질 향상
기본 개념: 포커스와 Tab 순서#
포커스란?#
**포커스(Focus)**는 현재 키보드 입력을 받을 준비가 된 요소를 의미합니다.
<!-- 포커스 가능한 요소들 -->
<button>클릭하세요</button>
<a href="#">링크</a>
<input type="text">
<select>
<option>선택</option>
</select>이런 요소들은 기본적으로 포커스 가능합니다. 하지만 <div>, <span> 같은 요소는 기본적으로 포커스 불가능이죠.
Tab 순서의 중요성#
사용자가 Tab 키를 누르면 웹사이트는 요소들을 순서대로 포커스 이동합니다. 이 순서가 논리적이어야 해요.
좋은 예시:
[1] 검색 입력창
↓
[2] 검색 버튼
↓
[3] 첫 번째 검색 결과
↓
[4] 두 번째 검색 결과나쁜 예시:
[1] 푸터의 링크
↓
[2] 왼쪽 사이드바
↓
[3] 메인 콘텐츠
↓
[4] 헤더이런 순서는 사용자를 혼란스럽게 만들어요.
tabindex 속성#
기본 tab 순서를 바꾸거나 포커스 불가능한 요소를 포커스 가능하게 할 수 있습니다:
<!-- tabindex 값에 따른 우선순위 -->
<!-- 1부터 32767까지 지정 가능 (작을수록 우선) -->
<button tabindex="1">첫 번째</button>
<button tabindex="2">두 번째</button>
<!-- 0: 자동 순서에 포함 (권장) -->
<div tabindex="0" role="button">포커스 가능한 div</div>
<!-- -1: 자동 순서에서 제외하되 프로그래매틱 포커스 가능 -->
<div tabindex="-1" id="modal-content">모달 콘텐츠</div>중요한 규칙:
tabindex값으로 1 이상의 숫자를 쓰는 것은 피해야 합니다 (물론 정말 특별히 필요한 경우도 있을수는 있어요.)- 항상 0 또는 -1을 사용하고, DOM 순서로 탭 순서를 관리하세요

사진: Nanobanana AI로 생성
스킵 링크와 포커스 복귀#
<!-- 문서 최상단: 첫 Tab에 잡히도록 배치 -->
<a class="skip-link" href="#main">본문 바로가기</a>
<main id="main" tabindex="-1">
...
</main>
<style>
.skip-link {
position: absolute;
left: -999px;
}
.skip-link:focus {
left: 1rem;
top: 1rem;
background: #fff;
outline: 3px solid #4A90E2;
}
</style>- 모달·드롭다운을 닫을 때는 열기 버튼으로 포커스를 되돌리는 것이 좋습니다. 이는 키보드 사용자가 흐름을 잃지 않도록 돕고, 포커스 순서를 안정적으로 유지하는 데 도움이 됩니다.
WCAG 2.2#
- SC 2.5.7 Dragging Movements: 드래그 동작이 있다면 드래그 없이도 단일 포인터로 같은 기능을 수행할 수 있어야 합니다. (드래그가 필수인 경우는 예외)
- SC 2.5.8 Target Size (Minimum): 대화형 타깃을 24px 이상(또는 최소 24×24 영역 확보)으로 설계하세요.
키 처리: 이벤트와 단축키#
키 이벤트 기초#
// 기본적인 키 이벤트 처리
element.addEventListener('keydown', (event) => {
console.log('키 코드:', event.code);
console.log('키 값:', event.key);
// 특정 키 확인
if (event.key === 'Enter') {
// Enter 처리
}
});주의사항: keyCode는 deprecated되었습니다. key 또는 code를 사용하세요.
주요 키와 역할#
| 키 | 용도 | 예제 |
|---|---|---|
Tab | 다음 포커스 이동 | 모든 요소에서 자동 |
Shift+Tab | 이전 포커스 이동 | 모든 요소에서 자동 |
Enter | 버튼 클릭, 폼 제출 | <button>, <a> |
Space | 버튼 활성화, 체크박스 토글 | <button>, <input type="checkbox"> |
Escape | 모달 닫기, 메뉴 닫기 | 커스텀 구현 필요 |
Arrow Keys | 목록 네비게이션, 라디오 버튼 선택 | 커스텀 위젯 |
기본 동작 방지와 키 핸들링#
// 올바른 키 핸들링 예제
button.addEventListener('keydown', (event) => {
// 기본 동작 확인 (브라우저 기본 동작 방지 필요할 때만)
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleButtonClick();
}
});중요: 표준 HTML 요소(<button>, <a>)를 사용하면 브라우저가 자동으로 키 처리를 해줍니다. 커스텀 요소만 직접 처리하면 됩니다.
포커스 관리: 언제 어디로 이동할까?#
자동 포커스 vs 수동 포커스#
// ❌ 나쁜 예: 무분별한 자동 포커스
input.addEventListener('blur', () => {
// 사용자가 의도하지 않은 포커스 이동
someOtherElement.focus();
});
// ✅ 좋은 예: 사용자 의도에 따른 포커스 이동
button.addEventListener('click', () => {
// 모달 열린 후 첫 포커스 가능 요소로 이동
modal.showModal();
modal.querySelector('input').focus();
});포커스 함정(Focus Trap) 구현#
모달 다이얼로그처럼 배경과 상호작용을 막는 UI에서만 포커스 함정을 적용합니다. 일반 패널이나 드롭다운에서는 사용자가 자유롭게 이동할 수 있어야 합니다.
function createFocusTrap(modalElement) {
const focusableElements = modalElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
modalElement.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift+Tab: 뒤로 이동
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: 앞으로 이동
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
});
}포커스 복원#
다이얼로그나 오버레이가 닫힐 때, 원래 포커스 위치로 돌아가야 합니다:
class Modal {
constructor(element) {
this.element = element;
this.previouslyFocusedElement = null;
}
open() {
// 열기 전 현재 포커스 저장
this.previouslyFocusedElement = document.activeElement;
this.element.showModal();
this.element.querySelector('input').focus();
}
close() {
this.element.close();
// 원래 포커스로 복원
if (this.previouslyFocusedElement && this.previouslyFocusedElement.focus) {
this.previouslyFocusedElement.focus();
}
}
}
사진: Nanobanana AI로 생성
커스텀 위젯: 표준이 없을 때의 키보드 처리#
많은 개발자들이 표준 HTML 요소를 무시하고 커스텀 위젯을 만듭니다. 이런 경우 모든 키 처리를 직접 구현해야 합니다.
드롭다운 메뉴#
class Dropdown {
constructor(triggerButton, menu) {
this.trigger = triggerButton;
this.menu = menu;
this.items = menu.querySelectorAll('[role="menuitem"]');
this.currentIndex = -1;
this.setupListeners();
}
setupListeners() {
// 트리거 키 처리
this.trigger.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
this.open();
this.focusItem(0);
}
});
// 메뉴 키 처리
this.menu.addEventListener('keydown', (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.focusNext();
break;
case 'ArrowUp':
event.preventDefault();
this.focusPrev();
break;
case 'Home':
event.preventDefault();
this.focusItem(0);
break;
case 'End':
event.preventDefault();
this.focusItem(this.items.length - 1);
break;
case 'Enter':
event.preventDefault();
this.selectCurrent();
break;
case 'Escape':
event.preventDefault();
this.close();
this.trigger.focus();
break;
}
});
}
focusItem(index) {
if (index < 0 || index >= this.items.length) return;
this.currentIndex = index;
this.items[index].focus();
// 시각적 강조
this.items.forEach((item, i) => {
item.setAttribute('aria-selected', i === index);
});
}
focusNext() {
const nextIndex = this.currentIndex + 1;
if (nextIndex < this.items.length) {
this.focusItem(nextIndex);
}
}
focusPrev() {
const prevIndex = this.currentIndex - 1;
if (prevIndex >= 0) {
this.focusItem(prevIndex);
}
}
open() {
this.menu.classList.add('visible');
}
close() {
this.menu.classList.remove('visible');
this.currentIndex = -1;
}
selectCurrent() {
if (this.currentIndex >= 0) {
this.items[this.currentIndex].click();
}
this.close();
}
}트리 위젯 (Tree Widget)#
파일 탐색기 같은 계층 구조를 가진 위젯:
class TreeWidget {
constructor(rootElement) {
this.root = rootElement;
this.setupListeners();
}
setupListeners() {
this.root.addEventListener('keydown', (event) => {
const item = event.target.closest('[role="treeitem"]');
if (!item) return;
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
this.expandItem(item);
break;
case 'ArrowLeft':
event.preventDefault();
this.collapseItem(item);
break;
case 'ArrowDown':
event.preventDefault();
this.focusNextItem(item);
break;
case 'ArrowUp':
event.preventDefault();
this.focusPrevItem(item);
break;
case 'Home':
event.preventDefault();
this.focusFirstItem();
break;
case 'End':
event.preventDefault();
this.focusLastItem();
break;
case '*': // 모든 항목 확장
event.preventDefault();
this.expandAll();
break;
}
});
}
expandItem(item) {
item.setAttribute('aria-expanded', 'true');
const children = item.nextElementSibling;
if (children) {
children.style.display = 'block';
}
}
collapseItem(item) {
item.setAttribute('aria-expanded', 'false');
const children = item.nextElementSibling;
if (children) {
children.style.display = 'none';
}
}
// ... 기타 메서드
}
사진: Nanobanana AI로 생성
포커스 스타일: 보이지 않으면 접근할 수 없다#

사진: Nanobanana AI로 생성
기본 포커스 스타일의 문제#
많은 개발자들이 기본 포커스 스타일(파란색 테두리)을 없애고 대체 스타일을 제공하지 않습니다. 때로는 디자인 때문에 그런 경우도 있고, 때로는 관행상 그렇게 처리하는 경우도 있죠. 그리고 떄로는… 아웃라인 혹은 보더로 인해 레이아웃이 깨지는 경우에 대해 대응하지 못하고 없애는 경우도 보긴 했습니다… 그러면 안되겠죠?
/* ❌ 절대 금지: 포커스 아웃라인 제거 후 대체 없음 */
button:focus {
outline: none;
}
/* ✅ 권장: 명확한 포커스 스타일 */
button:focus {
outline: 3px solid #4A90E2;
outline-offset: 2px;
}좋은 포커스 스타일의 특징#
/* 포괄적 포커스 스타일 */
:focus-visible {
outline: 3px solid var(--primary);
outline-offset: 2px;
border-radius: 3px;
}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
:focus-visible {
outline-color: #58A6FF;
}
}
/* 고대비 모드 지원 */
@media (prefers-contrast: more) {
:focus-visible {
outline-width: 4px;
outline-offset: 3px;
}
}포커스 스타일의 핵심 요건:
- ✅ 배경과 충분한 대비 확보 (비텍스트 대비 3:1 이상을 목표)
- ✅ 작은 요소에서도 명확하게 인지될 정도의 면적
- ✅ 모든 포커스 가능한 요소에 일관되게 적용
- ✅ 다크/라이트 모드 모두에서 시인성 유지
:focus-visible vs :focus#
/* :focus - 모든 포커스 상태 */
button:focus {
background: blue;
}
/* :focus-visible - 키보드 포커스만 */
button:focus-visible {
outline: 3px solid blue;
}
/* 결과: 마우스 클릭은 파란색 배경만, 키보드 Tab은 파란색 아웃라인 표시 */드래그 앤 드롭 대체 수단#
드래그 앤 드롭은 마우스 전용 상호작용입니다. 키보드 사용자도 같은 기능을 할 수 있어야 해요.
접근성 있는 드래그 앤 드롭#
class AccessibleDragDrop {
constructor(items, container) {
this.items = items;
this.container = container;
this.selectedItem = null;
this.sourceIndex = null;
this.setupListeners();
}
setupListeners() {
// 각 항목에 대해 위치 변경 메뉴 제공
this.items.forEach((item, index) => {
item.setAttribute('draggable', 'true');
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
// 마우스 드래그
item.addEventListener('dragstart', (e) => {
this.sourceIndex = index;
});
item.addEventListener('drop', (e) => {
e.preventDefault();
if (this.sourceIndex !== null) {
this.moveItem(this.sourceIndex, index);
}
});
// 키보드 내비게이션
item.addEventListener('keydown', (e) => {
const currentIndex = Array.from(this.items).indexOf(item);
switch (e.key) {
case 'ArrowUp':
case 'ArrowLeft':
if (currentIndex > 0) {
e.preventDefault();
this.moveItem(currentIndex, currentIndex - 1);
this.items[currentIndex - 1].focus();
}
break;
case 'ArrowDown':
case 'ArrowRight':
if (currentIndex < this.items.length - 1) {
e.preventDefault();
this.moveItem(currentIndex, currentIndex + 1);
this.items[currentIndex + 1].focus();
}
break;
}
});
});
}
moveItem(fromIndex, toIndex) {
const items = Array.from(this.items);
const [movedItem] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, movedItem);
// DOM 업데이트
this.container.innerHTML = '';
items.forEach(item => this.container.appendChild(item));
this.items = this.container.querySelectorAll('[draggable]');
this.setupListeners();
}
}더 간단한 대안:
<!-- 각 항목마다 이동 버튼 제공 -->
<div class="list-item">
<span>항목 1</span>
<button aria-label="위로 이동">▲</button>
<button aria-label="아래로 이동">▼</button>
</div>
사진: Nanobanana AI로 생성
자주 실수하는 패턴들#
“이거 금방 고치겠지?” 하고 시작한 포커스 스타일 작업이 3시간째 CSS와 씨름 중이라면… 축하합니다, 정상입니다. 😅
실수 1: 포커스 스타일 제거#
많은 개발자가 기본 포커스 스타일(파란색 테두리)이 “못생겼다"고 생각합니다. 그래서 outline: none을 넣고 대체 스타일을 만들려다가… 자꾸 잊어버리죠. 결과적으로 키보드 사용자는 자신이 어디에 있는지 알 수 없게 됩니다.
/* ❌ 절대 금지 */
* {
outline: none;
}
/* 또는 */
button:focus {
outline: 0;
}실수 2: 불필요한 click 이벤트 위임#
// ❌ 문제: div에 click만 있으면 키보드 미지원
div.addEventListener('click', handleClick);
// ✅ 올바름: button이나 keydown도 처리
button.addEventListener('click', handleClick);
button.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
});실수 3: 포커스 가능해야 할 요소를 숨김#
/* ❌ 문제: display: none하면 포커스도 불가능 */
.hidden {
display: none;
}
/* ✅ 시각적으로는 숨기되 포커스는 가능 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}실수 4: 시간 제한 없는 상호작용#
// ❌ 문제: 사용자가 천천히 키보드로 입력하면 타임아웃
const timeout = setTimeout(() => {
closeForm();
}, 5000);
// ✅ 사용자가 활동 중이면 타임아웃 연장
document.addEventListener('keydown', () => {
clearTimeout(timeout);
timeout = setTimeout(() => closeForm(), 5000);
});테스트하기: 직접 경험해보기#
수동 테스트 (가장 중요)#
1. 마우스를 치워둔다
2. 키보드만으로 웹사이트를 탐색한다:
- Tab으로 모든 기능에 접근할 수 있는가?
- 포커스 위치가 보이는가?
- 탭 순서가 논리적인가?
- 타이핑 속도가 느려도 작동하는가?
3. 스크린 리더와 함께 사용해본다자동화 테스트#
// axe DevTools, WAVE, Lighthouse 사용
// 또는 프로그래매틱 테스트:
test('모든 버튼이 키보드로 활성화 가능', () => {
const buttons = document.querySelectorAll('button');
buttons.forEach(button => {
const keyboardActivated = new KeyboardEvent('keydown', {
key: 'Enter'
});
button.dispatchEvent(keyboardActivated);
// 활성화 확인
});
});브라우저 별 확인 포인트#
| 브라우저 | 확인 항목 |
|---|---|
| Chrome | 포커스 순서, 아웃라인 렌더링 |
| Safari | Mac 키보드 네비게이션 (전체 키보드 접근 켜기) |
| Firefox | ARIA 준수 |
| Edge | Windows 고대비 모드 |

사진: Nanobanana AI로 생성
정리하며#
포스트를 작성하다보니, 예시 코드를 너무 많이 작성한게 아닌가 싶네요. 이럴려고 이 주제의 글 작성을 시작한게 아닌데… 보시기 어땠나요? 코드가 너무 많아서 보시기 불편하진 않으셨나요?
어찌되었던 키보드 접근성은 기술적 요구사항일 뿐만 아니라 사용자 경험의 질을 좌우합니다.
체크리스트:
- ✅ Tab으로 모든 기능 접근 가능
- ✅ 포커스 순서가 논리적
- ✅ 포커스 스타일이 명확 (3px 이상, 충분한 색상 대비)
- ✅ 모달/팝업에서 포커스 함정 구현
- ✅ Escape로 닫을 수 있음
- ✅ 드래그 앤 드롭 등에 키보드 대체 수단 제공
- ✅ 커스텀 위젯에서 WAI-ARIA 패턴 따름
- ✅ 수동 테스트 완료
이 모든 것들이 “당연해 보이지만” 실무에서 자주 놓치는 부분들입니다. 하나씩 체크해가며 구현한다면, 훨씬 더 포용적인 웹사이트를 만들 수 있을 거예요.
