diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c999061c..35987eeba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,11 @@
# Changelog
## Unreleased
+
- [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다.
+
+- [FE] ♿ **그룹 색상 라디오 키보드 조작**: 색상 선택기를 WAI-ARIA 라디오 그룹으로 정렬하고, 단일 roving tab stop과 방향키 선택·포커스 이동 및 순환 동작을 추가했습니다.
+
- [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다.
- [BE] 🛠️ **함수 인덱스 중복 오탐 수정**: `lower(email)` 등 expression index를 평문 컬럼 인덱스의 중복으로 잘못 판단하지 않도록 괄호 파서를 강화했습니다.
- [Docs] README를 상용 기준 기능 설명으로 갱신 (MVP skeleton 표현 제거, share redaction·diff/export 반영).
diff --git a/docs/doctoring/accessibility-radio-group.md b/docs/doctoring/accessibility-radio-group.md
new file mode 100644
index 000000000..30e6d2146
--- /dev/null
+++ b/docs/doctoring/accessibility-radio-group.md
@@ -0,0 +1,17 @@
+# Color radio-group accessibility evidence
+
+## Decision
+
+The Business Group color selector uses the WAI-ARIA radio-group pattern with one radio in the page tab sequence. The checked color receives `tabIndex={0}` and every other color receives `tabIndex={-1}`. Right and Down Arrow move focus and selection to the next color; Left and Up Arrow move to the previous color; navigation wraps at both ends. Native button activation preserves Space and click behavior.
+
+This contract is covered by focused regression tests for checked state, roving tabindex, forward and backward arrow navigation, wrapping, unrelated-key handling, and the no-selection fallback.
+
+## Evidence
+
+The W3C Accessible Rich Internet Applications Authoring Practices Guide specifies that a radio group contains `radio` elements with `aria-checked`, that only one radio participates in the tab sequence, and that arrow keys move focus and selection with wrapping.
+
+## Reference
+
+World Wide Web Consortium. (n.d.). *Radio group pattern*. WAI-ARIA Authoring Practices Guide. Retrieved August 4, 2026, from https://www.w3.org/WAI/ARIA/apg/patterns/radio/
+
+World Wide Web Consortium. (n.d.). *Radio group example using roving tabindex*. WAI-ARIA Authoring Practices Guide. Retrieved August 4, 2026, from https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio/
diff --git a/frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx b/frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx
new file mode 100644
index 000000000..44f8fad7a
--- /dev/null
+++ b/frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx
@@ -0,0 +1,98 @@
+import '@testing-library/jest-dom/vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { BUSINESS_GROUP_COLORS } from '../../erd/businessGroups'
+import { GroupModal } from './GroupModal'
+
+afterEach(() => {
+ cleanup()
+})
+
+function RadioGroupHarness({ initialColor }: { initialColor: string }) {
+ const [color, setColor] = useState(initialColor)
+ return (
+
+ )
+}
+
+function colorRadios(): HTMLElement[] {
+ return screen.getAllByRole('radio', { name: /^색상 / })
+}
+
+describe('GroupModal color radio keyboard contract', () => {
+ it('keeps one checked radio in the tab order and moves selection with arrows', async () => {
+ render()
+ let radios = colorRadios()
+
+ expect(radios).toHaveLength(BUSINESS_GROUP_COLORS.length)
+ expect(radios[1]).toHaveAttribute('aria-checked', 'true')
+ expect(radios[1]).toHaveAttribute('tabindex', '0')
+ radios.forEach((radio, index) => {
+ if (index !== 1) expect(radio).toHaveAttribute('tabindex', '-1')
+ })
+
+ radios[1]!.focus()
+ fireEvent.keyDown(radios[1]!, { key: 'ArrowRight' })
+ await new Promise(r => setTimeout(r, 0));
+ radios = colorRadios()
+ expect(radios[2]).toHaveFocus()
+ expect(radios[2]).toHaveAttribute('aria-checked', 'true')
+ expect(radios[2]).toHaveAttribute('tabindex', '0')
+ expect(radios[1]).toHaveAttribute('aria-checked', 'false')
+ expect(radios[1]).toHaveAttribute('tabindex', '-1')
+
+ fireEvent.keyDown(radios[2]!, { key: 'ArrowUp' })
+ await new Promise(r => setTimeout(r, 0));
+ radios = colorRadios()
+ expect(radios[1]).toHaveFocus()
+ expect(radios[1]).toHaveAttribute('aria-checked', 'true')
+ })
+
+ it('wraps arrow navigation and ignores unrelated keys', async () => {
+ render()
+ let radios = colorRadios()
+
+ radios[0]!.focus()
+ fireEvent.keyDown(radios[0]!, { key: 'ArrowLeft' })
+ await new Promise(r => setTimeout(r, 0));
+ radios = colorRadios()
+ expect(radios.at(-1)).toHaveFocus()
+ expect(radios.at(-1)).toHaveAttribute('aria-checked', 'true')
+
+ fireEvent.keyDown(radios.at(-1)!, { key: 'ArrowDown' })
+ await new Promise(r => setTimeout(r, 0));
+ radios = colorRadios()
+ expect(radios[0]).toHaveFocus()
+ expect(radios[0]).toHaveAttribute('aria-checked', 'true')
+
+ fireEvent.keyDown(radios[0]!, { key: 'Home' })
+ await new Promise(r => setTimeout(r, 0));
+ expect(radios[0]).toHaveFocus()
+ expect(radios[0]).toHaveAttribute('aria-checked', 'true')
+ })
+
+ it('makes the first radio tabbable when the supplied color is not selected', () => {
+ render()
+ const radios = colorRadios()
+
+ expect(radios[0]).toHaveAttribute('tabindex', '0')
+ expect(radios[0]).toHaveAttribute('aria-checked', 'false')
+ radios.slice(1).forEach((radio) => {
+ expect(radio).toHaveAttribute('tabindex', '-1')
+ })
+ })
+})
diff --git a/frontend/src/components/modals/GroupModal.tsx b/frontend/src/components/modals/GroupModal.tsx
index bd96c21b1..f8c5c59f7 100644
--- a/frontend/src/components/modals/GroupModal.tsx
+++ b/frontend/src/components/modals/GroupModal.tsx
@@ -32,6 +32,39 @@ export function GroupModal({
onAssignBusinessGroup,
}: GroupModalProps) {
const dialogRef = useDialogAccessibility(isOpen, onCloseGroupManager);
+ const selectedColorIndex = BUSINESS_GROUP_COLORS.indexOf(
+ newGroupColor as (typeof BUSINESS_GROUP_COLORS)[number],
+ );
+
+ function onColorKeyDown(
+ event: React.KeyboardEvent,
+ currentIndex: number,
+ ): void {
+ const isNext = event.key === "ArrowRight" || event.key === "ArrowDown";
+ const isPrevious = event.key === "ArrowLeft" || event.key === "ArrowUp";
+ if (!isNext && !isPrevious) return;
+
+ event.preventDefault();
+ const direction = isNext ? 1 : -1;
+ const nextIndex =
+ (currentIndex + direction + BUSINESS_GROUP_COLORS.length) %
+ BUSINESS_GROUP_COLORS.length;
+ const nextColor = BUSINESS_GROUP_COLORS[nextIndex];
+ setNewGroupColor(nextColor);
+
+ const radioGroup = event.currentTarget.closest(
+ '[role="radiogroup"]',
+ );
+ // Defer the focus shifting logic using setTimeout to ensure
+ // the React state update cycle has completed. jsdom requestAnimationFrame
+ // doesn't reliably let state settle before execution.
+ setTimeout(() => {
+ const radioButtons = radioGroup?.querySelectorAll(
+ '[role="radio"]',
+ );
+ radioButtons?.[nextIndex]?.focus();
+ }, 0);
+ }
if (!isOpen) return null;
@@ -72,14 +105,22 @@ export function GroupModal({
role="radiogroup"
aria-label="그룹 색상"
>
- {BUSINESS_GROUP_COLORS.map((color) => (
+ {BUSINESS_GROUP_COLORS.map((color, index) => (