본문 바로가기

React

React 앱에서 Command Palette 인터페이스 구현하기

Command Palette는 키보드 중심의 빠른 탐색과 실행을 제공하는 인터페이스로, CMD+K/CTRL+K로 열리는 검색/명령 창을 말합니다. React 앱에 가볍고 접근성 좋은 Command Palette를 직접 구현하는 방법을 단계별로 소개합니다.

1. 목표와 UX 요구사항

목표는 다음과 같습니다. (1) 전역 단축키 CMD/CTRL+K로 열기/닫기 (2) 즉시 검색과 키보드 탐색 (3) Enter로 실행, Esc로 닫기 (4) ARIA 준수 및 포커스 관리 (5) 빠른 렌더링과 확장 가능한 명령 모델입니다.

2. 명령 데이터 모델 정의

명령은 실행 가능한 액션과 검색 키워드를 포함합니다. 그룹/단축키 메타 정보를 추가해 검색 품질과 UX를 높입니다.

export type Command = {
  id: string;
  title: string;
  keywords?: string;    // 공백 구분 키워드
  group?: string;       // 예: "Navigation", "Actions"
  shortcut?: string;    // 예: "G S"
  action: () => void;   // 실행 함수
};

export const commands: Command[] = [
  { id: "go-home", title: "홈으로 이동", keywords: "home dashboard", group: "Navigation", shortcut: "G H", action: () => window.location.assign("/") },
  { id: "open-settings", title: "설정 열기", keywords: "settings pref", group: "Navigation", shortcut: "G S", action: () => window.location.assign("/settings") },
  { id: "new-issue", title: "이슈 생성", keywords: "issue create bug", group: "Actions", shortcut: "N I", action: () => alert("새 이슈 생성") },
];

3. 핵심 컴포넌트: CommandPalette

접근성(ARIA), 포털 렌더링, 키보드 내비게이션, 퍼지 검색을 포함한 최소 구현입니다.

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Command } from "./commands";

function normalize(str) {
  return str
    .toLowerCase()
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, ""); // 악센트 제거
}

function fuzzyFilter(list, q) {
  const nq = normalize(q.trim());
  if (!nq) return list;
  return list
    .map((c) => {
      const hay = normalize(`${c.title} ${c.keywords || ""}`);
      const idx = hay.indexOf(nq);
      if (idx !== -1) return { cmd: c, score: idx };
      const tokens = nq.split(/\s+/).filter(Boolean);
      const miss = tokens.some((t) => !hay.includes(t));
      if (miss) return null;
      return { cmd: c, score: hay.length + tokens.length }; // 대충된 스코어
    })
    .filter(Boolean)
    .sort((a, b) => a.score - b.score)
    .map((x) => x.cmd);
}

export function CommandPalette({ commands }: { commands: Command[] }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const inputRef = useRef(null);
  const listboxId = "cmdk-listbox";

  // 전역 단축키 등록: CMD/CTRL + K 토글
  useEffect(() => {
    const onKey = (e) => {
      const isK = e.key.toLowerCase() === "k";
      if ((e.metaKey || e.ctrlKey) && isK) {
        e.preventDefault();
        setOpen((v) => !v);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  useEffect(() => {
    if (open) {
      // 오픈 시 포커스
      setTimeout(() => inputRef.current?.focus(), 0);
    } else {
      // 닫힐 때 초기화(선택)
      setQuery("");
      setActive(0);
    }
  }, [open]);

  const results = useMemo(() => fuzzyFilter(commands, query), [commands, query]);

  const onKeyDownInput = useCallback(
    (e) => {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setActive((i) => Math.min(i + 1, results.length - 1));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setActive((i) => Math.max(i - 1, 0));
      } else if (e.key === "Enter") {
        const item = results[active];
        if (item) {
          e.preventDefault();
          setOpen(false);
          item.action();
        }
      } else if (e.key === "Escape") {
        e.preventDefault();
        setOpen(false);
      }
    },
    [results, active]
  );

  if (!open) return null;

  const body = (
    <div
      role="dialog"
      aria-modal="true"
      aria-label="Command Palette"
      style={styles.backdrop}
      onKeyDown={(e) => {
        if (e.key === "Escape") setOpen(false);
      }}
    >
      <div style={styles.panel}>
        <div style={styles.inputWrap}>
          <input
            ref={inputRef}
            value={query}
            onChange={(e) => {
              setQuery(e.target.value);
              setActive(0);
            }}
            onKeyDown={onKeyDownInput}
            placeholder="명령 검색... (Ctrl/Cmd + K)"
            role="combobox"
            aria-expanded={true}
            aria-controls={listboxId}
            aria-activedescendant={results[active] ? `cmd-option-${results[active].id}` : undefined}
            style={styles.input}
          />
        </div>
        <ul role="listbox" id={listboxId} style={styles.list}>
          {results.length === 0 && (
            <li style={styles.empty}>일치하는 항목이 없습니다.</li>
          )}
          {results.map((item, i) => (
            <li
              key={item.id}
              id={`cmd-option-${item.id}`}
              role="option"
              aria-selected={i === active}
              onMouseEnter={() => setActive(i)}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => {
                setOpen(false);
                item.action();
              }}
              style={{
                ...styles.option,
                ...(i === active ? styles.optionActive : null),
              }}
            >
              <span>{item.title}</span>
              {item.shortcut ? <kbd style={styles.kbd}>{item.shortcut}</kbd> : null}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );

  return createPortal(body, document.body);
}

const styles = {
  backdrop: {
    position: "fixed",
    inset: 0,
    background: "rgba(0,0,0,0.35)",
    display: "flex",
    alignItems: "flex-start",
    justifyContent: "center",
    paddingTop: 80,
    zIndex: 1000,
  },
  panel: {
    width: "min(720px, 92vw)",
    background: "#111",
    color: "#fff",
    border: "1px solid #333",
    borderRadius: 12,
    boxShadow: "0 12px 40px rgba(0,0,0,0.4)",
    overflow: "hidden",
  },
  inputWrap: { padding: 12, borderBottom: "1px solid #222" },
  input: {
    width: "100%",
    padding: "12px 14px",
    fontSize: 16,
    borderRadius: 8,
    border: "1px solid #333",
    outline: "none",
    background: "#0b0b0b",
    color: "#fff",
  },
  list: { listStyle: "none", margin: 0, padding: 8, maxHeight: 360, overflow: "auto" },
  option: {
    display: "flex",
    alignItems: "center",
    justifyContent: "space-between",
    gap: 12,
    padding: "10px 12px",
    borderRadius: 8,
    cursor: "pointer",
  },
  optionActive: { background: "#1b1b1b" },
  empty: { padding: "14px 12px", color: "#aaa" },
  kbd: {
    padding: "2px 6px",
    border: "1px solid #444",
    borderRadius: 6,
    fontSize: 12,
    color: "#ddd",
    background: "#000",
  },
};

4. 사용 예시

앱 루트에서 명령 배열을 주입하고 컴포넌트를 마운트합니다. 전역 어디에서나 CMD/CTRL+K로 열 수 있습니다.

import React from "react";
import { createRoot } from "react-dom/client";
import { CommandPalette } from "./CommandPalette";
import { commands } from "./commands";

function App() {
  return (
    <>
      <h1>내 앱</h1>
      {/* 페이지 콘텐츠 */}
      <CommandPalette commands={commands} />
    </>
  );
}

createRoot(document.getElementById("root")).render(<App />);

5. 접근성 체크리스트(실전)

다음 포인트를 지키면 스크린리더와 키보드 사용자에게 친화적입니다.

  • role="dialog" + aria-modal="true"로 모달 의미 전달
  • input에 role="combobox"와 aria-expanded/aria-controls/aria-activedescendant 연결
  • 리스트는 role="listbox", 항목은 role="option" 지정
  • Esc로 닫기, 포커스는 열릴 때 입력창으로 이동
  • 마우스 다운 시 포커스 스틸 방지를 위해 onMouseDown에서 preventDefault

6. 검색 품질 개선 팁

  • 키워드에 동의어, 약어 추가: keywords에 "cfg settings preference" 등
  • 하이라이트 표시: 일치 구간을 래핑하여 시각적으로 강조
  • 그룹 헤더 출력: Navigation, Actions 등으로 묶기
  • 최근 실행 우선: 로컬스토리지에 카운트 저장 후 score에 가중치

7. 성능 최적화

대규모 명령 목록(수천 개)에서도 부드럽게 동작하도록 최적화합니다.

  • useMemo로 필터링 결과 캐시
  • React 18 useDeferredValue로 입력 지연 처리
  • react-window 등으로 리스트 가상화
import { useDeferredValue, useMemo } from "react";

const deferredQuery = useDeferredValue(query);
const results = useMemo(() => fuzzyFilter(commands, deferredQuery), [commands, deferredQuery]);

8. 확장 포인트

  • 플러그인 체계: commands를 모듈에서 합치고 중복 id 방지
  • 비동기 소스: 원격 검색(예: 문서 검색 API) 결과와 로컬 명령을 병합
  • 권한/환경 기반 노출: 사용자 역할에 따라 명령 필터
  • 추적: 명령 실행 이벤트를 분석 도구에 로깅

9. 테스트 스니펫

핵심 상호작용(CMD/CTRL+K, Arrow, Enter, Esc)을 테스트합니다.

import { render, screen, fireEvent } from "@testing-library/react";
import { CommandPalette } from "./CommandPalette";
import { commands } from "./commands";

test("단축키로 열리고 Enter로 실행", () => {
  render(<CommandPalette commands={commands} />);
  fireEvent.keyDown(window, { key: "k", ctrlKey: true });
  const input = screen.getByPlaceholderText(/명령 검색/);
  fireEvent.change(input, { target: { value: "설정" } });
  fireEvent.keyDown(input, { key: "Enter" });
  // 기대: 설정 페이지로 이동 또는 액션 호출됨
});

10. 프로덕션 체크리스트

  • 다국어(i18n) 문자열 분리 및 keywords 현지화
  • 포커스 트랩(모달 내 Tab 순환) 추가 고려
  • 명령 충돌 방지(id 고유성, 중복 단축키 정책)
  • 지연 로딩: CommandPalette 코드 스플리팅으로 초기 번들 축소

위 설계를 바탕으로 커스텀 Command Palette를 구축하면, 외부 라이브러리 없이도 빠르고 일관된 탐색 경험을 제공할 수 있습니다. 필요 시 디자인 시스템과 토큰을 연결해 회사 표준 UI에 맞춰 확장하시기 바랍니다.