Declarative한 Observable 한번 잡숴봐
복잡한 웹 애플리케이션(특히 EMR 같은 도구)에서 단축키(Shortcut) 지원은 필수다. 하지만 React에서 키보드 이벤트를 다루다 보면 코드는 금방 지저분해진다.
이번 포스팅은 useEffect 와 addEventListener 의 늪에서 벗어날 수 있는 RxJS 스트림을 활용해 키보드 이벤트를 우아하게 처리하는 패턴을 공유해보려 한다.
프로젝트에 metaKey + K 로 특정 동작을 수행하는 숏컷을 등록하는 코드가 아래처럼 쓰이고 있었다.
const useSearchShortcut = () => {
const [isKeyPressed, setIsKeyPressed] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isMac = navigator.userAgent.includes('Mac');
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (modifier && e.code === 'KeyK') {
e.preventDefault();
setIsKeyPressed(true);
}
};
const handleKeyUp = () => setIsKeyPressed(false);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
return isKeyPressed;
};사용하는 곳에선 이렇게..
// SomeComponent.tsx
const { isKeyPress } = useSearchShortcut();
useEffect(() => {
if (isKeyPress) {
// side effect...
}
}, [isKeyPress])위 코드는 보일러 플레이트가 많을 뿐만 아니라, 몇가지 치명적인 단점이 숨어있다.
isKeyPress 상태를 넘김으로써 사용하는 컴포넌트에 리렌더링을 유발하고 있다.이런 문제들을 손쉽게 해결할 수 있는 RxJS 를 소개하려고 한다.
Reactive X 라는 비동기 프로그래밍을 위한 API 인터페이스를 자바스크립트로 구현한 라이브러리다.
대표적으로 Angular와 NestJS 를 꼽을 수 있겠다.
두 프레임워크 모두 RxJS 가 표준이며, 자세한 내용을 다루기엔 너무 내용이 많으므로 관련 공식문서와 다른 글을 참고해보면 좋겠다.
RxJS is a library for composing asynchronous and event-based programs by using observable sequences.
Observable한 시퀀스를 사용해 비동기적이거나 이벤트 기반의 프로그램 작성을 위한 라이브러리이제는 RxJS 를 모르는 사람이 많이 없겠지만, 혹시 처음 들어본다면 공식문서에 적혀있는 가장 직관적인 표현이 있다.
Think of RxJS as Lodash for events. 이벤트용 Lodash 라고 생각하세요.
러닝커브가 조금 있지만, 몇 개의 패턴을 익히고 감이 생기고 나면 적어도 dom event 를 다룰때 기본 이벤트로 돌아가기 싫어질 수도 있다.
위 상황에서 우리가 원하는건 ‘상태를 감시하는것’ 이 아닌 ‘키보드 이벤트를 구독하는것’ 이다.
RxJS 를 사용하면 이벤트를 시간의 흐름에 따른 데이터 스트림(Observable)으로 추상화 할 수 있다. 복잡한 라이프 사이클과 상태 동기화는 필요 없고, 단지 Service(어떤 키가 눌렸을 때 무엇을 할지) 만 관리하면 된다.
당연하게도 RxJS 로 상태 관리 또한 가능하다. 사실 React의 거의 모든 것을 할 수 있다. 이 포스팅에서는 DOM Event에만 집중하지만, 나중에 다른 토픽으로 다시 글을 쓸 수 있을 듯 하다.
fromEvent 생성import { fromEvent, filter } from 'rxjs';
// 1. 키보드 이벤트를 Observable 스트림으로 변환
const keyDown$ = fromEvent<KeyboardEvent>(document, 'keydown');pipe 로 로직 분리const shortcut$ = keyDown$.pipe(
// 1. 특정 키(KeyK)만 필터링
filter((e) => e.code === 'KeyK'),
filter((e) => {
const isMac = navigator.userAgent.includes('Mac');
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (modifier) {
// 브라우저 기본 동작 막기 (cmd + F, cmd + L 등 액션)
e.preventDefault();
return true;
}
return false;
})
);위 두가지 흐름이면 끝이다. 불필요한 상태는 필요없다.
대신 실행할 콜백함수를 인자로 받으면 끝.
우리 프로젝트는 SSR 환경을 고려하진 않고 있으므로, 훅 내부에 useMemo를 할 필요도 없다.
전체 코드는 아래와 같다.
import { fromEvent, filter } from 'rxjs';
// 1. 키보드 이벤트를 Observable 스트림으로 변환
const keyDown$ = fromEvent<KeyboardEvent>(document, 'keydown');
// 2. 파이프라인으로 로직 분리 (필터링)
const shortcut$ = keyDown$.pipe(
filter((e) => e.code === 'KeyK'),
filter((e) => {
const isMac = navigator.userAgent.includes('Mac');
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (modifier) e.preventDefault();
return !!modifier;
})
);
// 3. 훅에서 구독
export const useSearchShortcut = ({ onTrigger }) => {
useEffect(() => {
if(!onTrigger) return;
const sub = shortcut$.subscribe(onTrigger);
return () => sub.unsubscribe();
}, [onTrigger]);
}실제 사용하는곳에서
useSearchShortcut({
onTrigger: () => {
// side effect...
},
});당연하게도 RxJS 로 상태 관리 또한 가능하다. 사실 React의 거의 모든 것을 할 수 있다.
이번 포스팅에서는 간단한 DOM Event (keydown fromEvent)에만 집중 했지만, 나중에 다른 토픽으로 다시 글을 쓸 수 있을 듯 하다.
RxJS의 진짜 매력은 비동기 로직이 복잡해질수록 빛을 발한다는 점이다.
지금은 단순히 키보드 입력을 걸러내는 용도로만 썼지만, 여기에 연산자(Operator) 몇 개만 더 얹으면 완전히 새로운 요구사항을 바로 수행할 수 있다.
debounceTime(300)을 추가하면? → 디바운싱 됨throttleTime(100)을 추가하면? → 스크롤 이벤트 최적화 가능bufferCount(2)를 추가하면? → 더블클릭용 이벤트로 변모merge(keyUp$, targetElemClick$) → 키보드와 마우스 입력을 동시에 처리할 수도 있다.React의 useEffect 와 씨름하느라 지쳤다면, 혹은 "이벤트 처리가 왜 이렇게 장황해졌지?"라는 의문이 든다면, 이벤트를 '데이터 스트림'으로 바라보는 연습을 시작해 보길 권한다.
생각보다 코드가 훨씬 간결해지고, 무엇보다 개발하는 재미가 있다.