본문 바로가기

React

React에서 키보드 단축키 시스템 구축하기

키보드 단축키는 파워 유저의 생산성을 크게 높입니다. 하지만 글로벌/로컬 범위, 플랫폼별 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, 포커스 이동, 스크린리더 동작을 점검했습니다.
- 도움말/설정 화면에서 단축키 목록이 최신 상태로 노출됩니다.

위 구조로 구축하면 전역/로컬 단축키, 접근성, 충돌 관리까지 한 번에 해결할 수 있습니다. 작은 훅에서 시작해 레지스트리와 도움말로 확장하면 대규모 앱에서도 일관된 사용자 경험을 제공할 수 있습니다.