키보드 단축키는 파워 유저의 생산성을 크게 높입니다. 하지만 글로벌/로컬 범위, 플랫폼별 Cmd/Ctrl, 입력 필드 예외 처리, 접근성까지 고려하면 구조적으로 설계해야 합니다. 이 글에서는 실무에서 바로 쓸 수 있는 React 단축키 시스템을 훅과 컨텍스트로 구축하는 방법을 정리합니다.
1. 요구사항 정리와 설계 원칙
단축키 시스템은 다음을 충족해야 합니다.
- 글로벌 단축키와 컴포넌트 범위 단축키를 모두 지원합니다.
- Mac과 Windows의 mod 키(Cmd/Meta vs Ctrl)를 통일적으로 처리합니다.
- 입력/텍스트 편집 중에는 단축키가 개입하지 않도록 예외 처리합니다.
- 중복 충돌을 탐지하고, 우선순위/전파 제어가 가능합니다.
- 접근성을 위해 aria-keyshortcuts, 대체 UI(명령어 팔레트)를 제공합니다.
- 테스트 가능하고 유지보수가 쉬운 API를 제공합니다: useHotkeys, ShortcutsProvider, useRegisterShortcut
2. 기본 훅: useHotkeys 구현
핵심은 window keydown 리스너를 캡처 단계에서 등록하고, 콤보 문자열(e.g. mod+k, shift+?)을 파싱해 이벤트와 매칭하는 것입니다. 입력 요소에서는 기본적으로 비활성화하고, preventDefault/stopPropagation 옵션을 제공합니다.
import { useEffect, useMemo, useRef } from 'react';
const isMac = typeof window !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform);
const EDITABLE_TAGS = new Set(['INPUT', 'TEXTAREA']);
function isEditableElement(target) {
if (!target || !(target instanceof Element)) return false;
const el = target;
if (EDITABLE_TAGS.has(el.tagName)) return true;
if (el.isContentEditable) return true;
// Inputs with role or data attributes can be extended here
return false;
}
// combo: e.g. "mod+k", "shift+?", "alt+ArrowUp"
function parseCombo(combo) {
return combo
.split('+')
.map(s => s.trim())
.filter(Boolean)
.map(t => t.toLowerCase());
}
function eventMatchesCombo(e, comboTokens) {
// normalize modifiers
const wantMeta = comboTokens.includes('meta') || (isMac && comboTokens.includes('mod'));
const wantCtrl = comboTokens.includes('control') || comboTokens.includes('ctrl') || (!isMac && comboTokens.includes('mod'));
const wantShift = comboTokens.includes('shift');
const wantAlt = comboTokens.includes('alt') || comboTokens.includes('option');
if (!!wantMeta !== e.metaKey) return false;
if (!!wantCtrl !== e.ctrlKey) return false;
if (!!wantShift !== e.shiftKey) return false;
if (!!wantAlt !== e.altKey) return false;
// Non-modifier key
const nonMods = comboTokens.filter(t => !['meta','mod','control','ctrl','shift','alt','option'].includes(t));
if (nonMods.length === 0) return true; // pure modifier combo
const key = (e.key || '').toLowerCase();
const code = (e.code || '').toLowerCase();
// Support arrow keys and symbols reliably
const aliases = new Set([
key,
code,
key === '?' ? '/' : key, // shift+/ produces '?'
key === 'escape' ? 'esc' : key,
]);
return nonMods.some(t => aliases.has(t));
}
/**
* useHotkeys('mod+k', handler, { enabled, preventDefault, stopPropagation, allowInInput, keyup, repeat })
*/
export function useHotkeys(combos, handler, opts = {}) {
const {
enabled = true,
preventDefault = true,
stopPropagation = true,
allowInInput = false,
keyup = false,
repeat = false,
target, // optional: specific element ref.current
capture = true,
} = opts;
const combosArr = useMemo(() => (Array.isArray(combos) ? combos : [combos]).map(parseCombo), [combos]);
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);
useEffect(() => {
if (!enabled) return;
const el = target ?? window;
const type = keyup ? 'keyup' : 'keydown';
const onKey = (e) => {
if (!repeat && e.repeat) return;
if (!allowInInput && isEditableElement(e.target)) return;
const match = combosArr.some(tokens => eventMatchesCombo(e, tokens));
if (!match) return;
if (preventDefault) e.preventDefault();
if (stopPropagation) e.stopPropagation();
handlerRef.current?.(e);
};
el.addEventListener(type, onKey, { capture });
return () => el.removeEventListener(type, onKey, { capture });
}, [enabled, combosArr, preventDefault, stopPropagation, allowInInput, keyup, repeat, target, capture]);
}
사용 예:
function CommandPaletteTrigger() {
const [open, setOpen] = useState(false);
useHotkeys('mod+k', () => setOpen(true));
return <>
<button aria-keyshortcuts="Meta+K Ctrl+K" onClick={() => setOpen(true)}>Search (⌘K / Ctrl K)</button>
{open && <CommandPalette onClose={() => setOpen(false)} />}
</>;
}
function ListNavigation() {
useHotkeys(['j','ArrowDown'], nextItem, { allowInInput: false });
useHotkeys(['k','ArrowUp'], prevItem);
useHotkeys('shift+?', () => openHelp());
return /* ... */
}
3. 전역 단축키 레지스트리: ShortcutsProvider
전역 단축키를 한 곳에서 관리하면 충돌 탐지, 도움말 오버레이, 기능 토글이 쉬워집니다.
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
const ShortcutsContext = createContext(null);
export function ShortcutsProvider({ children }) {
const [registry, setRegistry] = useState(new Map()); // id -> { combo, desc, scope }
const register = useCallback((id, entry) => {
setRegistry(prev => {
const next = new Map(prev);
if (next.has(id)) console.warn('[shortcuts] duplicate id', id);
// Conflict detection by combo
const conflict = [...next.values()].find(v => v.combo === entry.combo && v.scope === entry.scope);
if (conflict) console.warn('[shortcuts] conflict on', entry.combo, 'in scope', entry.scope);
next.set(id, entry);
return next;
});
return () => setRegistry(prev => { const next = new Map(prev); next.delete(id); return next; });
}, []);
const value = useMemo(() => ({ registry, register }), [registry, register]);
return <ShortcutsContext.Provider value={value}>{children}</ShortcutsContext.Provider>;
}
export function useRegisterShortcut(id, { combo, desc, scope = 'global' }) {
const ctx = useContext(ShortcutsContext);
useEffect(() => ctx?.register?.(id, { combo, desc, scope }), [id, combo, desc, scope]);
}
사용 예: 명령어 팔레트 등록과 핸들링을 함께 구성합니다.
function AppShortcuts() {
useRegisterShortcut('command-palette', { combo: 'mod+k', desc: '명령어 팔레트 열기' });
useHotkeys('mod+k', () => openPalette());
return null;
}
function ShortcutsHelp() {
const { registry } = useContext(ShortcutsContext);
return (
<div role="dialog" aria-labelledby="kbd-help-title">
<h2 id="kbd-help-title">단축키 도움말</h2>
{[...registry.entries()].map(([id, v]) => (
<div key={id}><kbd>{v.combo}</kbd> – {v.desc}</div>
))}
</div>
);
}
4. 범위, 포커스, 포털 이슈 처리
- 입력 중 예외: 기본 옵션 allowInInput=false로 input/textarea/contentEditable에서 단축키가 작동하지 않도록 합니다.
- 컴포넌트 범위 단축키: target 옵션에 ref.current를 넣어 특정 영역에만 적용합니다.
- 모달/포털: window 리스너는 포털 내부에서도 동작합니다. 모달 내부에서만 필요한 단축키는 모달 마운트 시에만 useHotkeys를 활성화합니다.
- 전파 제어: 상위/하위에서 같은 콤보를 쓰는 경우 stopPropagation=true로 하위에서 소비합니다. 정교한 우선순위가 필요하면 capture/bubble를 범위에 맞게 조절합니다.
5. 접근성 체크리스트
- aria-keyshortcuts 속성을 버튼/메뉴 항목에 부여합니다. 예: aria-keyshortcuts='Meta+K Ctrl+K'.
- 키보드 전용 사용자도 도달 가능한 포커스 가능한 대체 UI를 제공합니다(메뉴, 버튼).
- 강제 포커스 트랩을 피하고 Esc로 모달 닫기를 지원합니다(useHotkeys('esc', onClose)).
- 단축키 도움말(Shift+?)을 제공해 학습 곡선을 낮춥니다.
6. 테스트와 디버깅
- 유닛 테스트: @testing-library/react의 fireEvent.keyDown로 콤보를 시뮬레이션합니다.
- E2E: Playwright의 page.keyboard API로 Cmd/Ctrl 분기까지 검증합니다.
- 로그: 레지스트리에서 중복/충돌을 console.warn으로 알리고, CI에서 스냅샷으로 추적합니다.
// testing-library 예시
fireEvent.keyDown(window, { key: 'k', metaKey: true }); // macOS
fireEvent.keyDown(window, { key: 'k', ctrlKey: true }); // Windows/Linux
7. 라이브러리 vs 직접 구현
- 빠른 도입: react-hotkeys-hook, hotkeys-js 같은 검증된 라이브러리를 사용할 수 있습니다. 명령어 팔레트는 cmdk(Headless)도 추천합니다.
- 커스텀의 장점: 번들 크기 최소화, 정책(예외, 범위, 충돌 탐지) 일원화, 팀 컨벤션 내재화입니다.
- 하이브리드: 내부 레지스트리/도움말은 직접 만들고, 로우레벨 매칭은 가벼운 유틸을 사용합니다.
8. 배포 전 체크리스트
- mod 키 분기(Cmd vs Ctrl)와 로케일별 키 입력(예: IME) 확인했습니다.
- 입력 요소/리치 에디터에서 예외 동작을 검증했습니다.
- Esc, Enter, Arrow 등 기본 브라우저 동작과 충돌하지 않는지 확인했습니다.
- 접근성: aria-keyshortcuts, 포커스 이동, 스크린리더 동작을 점검했습니다.
- 도움말/설정 화면에서 단축키 목록이 최신 상태로 노출됩니다.
위 구조로 구축하면 전역/로컬 단축키, 접근성, 충돌 관리까지 한 번에 해결할 수 있습니다. 작은 훅에서 시작해 레지스트리와 도움말로 확장하면 대규모 앱에서도 일관된 사용자 경험을 제공할 수 있습니다.
'React' 카테고리의 다른 글
| React와 Firebase Firestore 실시간 데이터 연동하기 (0) | 2026.05.23 |
|---|---|
| React 앱에서 브라우저 히스토리 API 활용하기 (0) | 2026.05.22 |
| React로 차트 컴포넌트 직접 설계 및 최적화하기 (0) | 2026.05.22 |
| React 앱에서 마크다운(Markdown) 렌더링 구현하기 (0) | 2026.05.21 |
| React에서 지도 API(Google Maps, Leaflet) 통합하기 (0) | 2026.05.21 |