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에 맞춰 확장하시기 바랍니다.
'React' 카테고리의 다른 글
| React 앱에서 Undo/Redo 상태 히스토리 기능 구현하기 (0) | 2026.07.22 |
|---|---|
| React에서 react-hook-form으로 대규모 폼 최적화하기 (0) | 2026.07.22 |
| React에서 브라우저 Geolocation API 활용하기 (0) | 2026.07.21 |
| React와 tRPC를 활용한 타입 안전 API 통신 구축하기 (0) | 2026.07.16 |
| React 앱에서 Feature Flag 시스템 설계 및 적용하기 (0) | 2026.07.16 |