들어가며
최근 프로젝트에서 모달 내 네비게이션 버튼에 키보드 지원을 추가하면서 생긴 경험과 과정에서 느낀 점들을 공유해보려고 한다.
문제 상황: 키보드 네비게이션의 시각적 피드백 부족
사용자들이 화살표 키로 네비게이션을 할 때 시각적 피드백이 없었다. 마우스로 클릭하면 MUI의 리플 효과가 나타나는데, 키보드로는 아무 반응이 없어서 사용자들이 혼란스러울 것 같았다. 그래서 처음에는 CSS를 이용해 단순히 scale을 키워보았으나 UX적으로 일관성을 헤치는 느낌이 들었고, UI도 적절하지 않아보였다.
(mui의 클릭 시 나타나는 Ripple 효과)
그래서 아래와 같은 목표를 세우고 진행했다.
기술적 도전과제
MUI 리플 시스템과의 연동: 키보드 이벤트로 MUI의 내장 리플 효과를 트리거해야 함
일관된 사용자 경험: 마우스 클릭과 키보드 입력이 동일한 시각적 피드백을 제공해야 함
재사용성: 다른 버튼들에도 쉽게 적용할 수 있어야 함
첫 번째 접근: 과도한 추상화의 함정
처음에는 기존의 Button Component에 Ripple 기능을 추가해서 사용하면 좋겠다는 생각으로 복잡한 아키텍쳐를 설계했다. useKeyboardRipple Hook을 만들고, 해당 Hook에 trigger를 연결해 참조할 수 있는 구조를 생각했으나 아래와 같은 문제점들이 생겨났다.
과도한 복잡성: 단순한 기능을 위해 너무 많은 파일과 로직이 필요
Props Drilling: ref를 여러 단계를 거쳐 전달해야 함
학습 비용: 새로운 개발자가 이해하기 어려운 구조
유지보수 부담: 작은 변경사항도 여러 파일을 수정해야 함
그래서 over-engineering이라는 생각이 들었고 단순하게 바꿀 방법을 고민하다. Button을 두 종류로 나누는 게 좋겠다는 생각이 들았다. keyboard 동작이 필요한 버튼과 아닌 버튼, 그리고 keyboard 동작이 가능한 버튼 내에 ripple 기능을 default로 제공하면 되겠다고 판단이 들었고, 로직을 하나의 컴포넌트(Self-contained Component)에 집중시키기로 했다.
export const KeyboardRippleButton = forwardRef<HTMLButtonElement, KeyboardRippleButtonProps>(
({ keyboardKeys = [], onKeyboardTrigger, rippleDuration = 150, children, ...buttonProps }, ref) => {
const internalRef = useRef<HTMLButtonElement>(null);
// 리플 트리거 로직
const triggerRipple = useCallback(() => {
if (!internalRef.current) return;
const button = internalRef.current;
const rect = button.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// MUI 리플 시스템과 연동
const clickEvent = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
clientX: rect.left + centerX,
clientY: rect.top + centerY
});
button.dispatchEvent(clickEvent);
setTimeout(() => {
const mouseUpEvent = new MouseEvent("mouseup", {
bubbles: true,
cancelable: true,
clientX: rect.left + centerX,
clientY: rect.top + centerY
});
button.dispatchEvent(mouseUpEvent);
}, rippleDuration);
}, [rippleDuration]);
// 키보드 이벤트 처리
useEffect(() => {
if (keyboardKeys.length === 0) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (keyboardKeys.includes(event.key)) {
event.preventDefault();
triggerRipple();
onKeyboardTrigger?.();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [keyboardKeys, onKeyboardTrigger, triggerRipple]);
return (
<Button ref={internalRef} {...buttonProps}>
{children}
</Button>
);
}
);주요 시사점: 아키텍처 설계의 교훈
과도한 추상화는 독이 될 수 있다
처음에는 "재사용성"을 위해 모든 것을 분리하려고 했으나, 실제로는
단순한 기능을 위해 복잡한 구조를 만들었고
유지보수성을 위해 유지보수 부담을 증가시켰다.
실제 사용 사례를 먼저 생각하라
// 실제 사용 패턴
<KeyboardRippleButton
keyboardKeys={["ArrowLeft"]}
onKeyboardTrigger={navigatePrevious}
variant="outlined"
>
이전
</KeyboardRippleButton>이렇게 사용할 때 필요한 건 단순한 API였다. 복잡한 hook이나 여러 파일이 필요하지 않았다.
Self-Contained 패턴의 장점
단순함: 하나의 파일에 모든 로직이 집중
이해하기 쉬움: 새로운 개발자도 쉽게 파악 가능
테스트하기 쉬움: 독립적인 컴포넌트로 테스트 가능
성능 최적화: 불필요한 리렌더링 방지
추가적으로 구현한 기능 및 성능 최적화
다양한 키보드 키 지원
// 여러 키 지원
<KeyboardRippleButton
keyboardKeys={["Enter", "Space"]}
onKeyboardTrigger={handleSubmit}
>
제출
</KeyboardRippleButton>커스터마이징 가능한 리플 효과
// 리플 지속시간과 중심점 조정
<KeyboardRippleButton
keyboardKeys={["ArrowRight"]}
rippleDuration={300}
rippleCenterOffset={{ x: 10, y: -5 }}
>
다음
</KeyboardRippleButton>다른 컴포넌트와의 자연스러운 연동
// CommonModal에서의 사용
<KeyboardRippleButton
keyboardKeys={["ArrowLeft"]}
onKeyboardTrigger={navigation.onNavigatePrevious}
disabled={!navigation.hasPrevious}
sx={{ /* 스타일링 */ }}
>
<ChevronLeftIcon />
</KeyboardRippleButton>useCallback을 통한 메모이제이션
const triggerRipple = useCallback(() => {
// 리플 로직
}, [rippleDuration, rippleCenterOffset]);
useEffect의 정확한 의존성 관리
useEffect(() => {
// 키보드 이벤트 로직
}, [keyboardKeys, onKeyboardTrigger, triggerRipple]);
자동 정리(Cleanup) 처리
return () => document.removeEventListener("keydown", handleKeyDown);
마무리: Simple is Best ?
실제 문제를 해결하기 위한 목적성을 분명히 하고 오버엔지니어링을 경계하며, 사용자 경험을 우선시하는 것을 항상 염두해 둘 것...!