fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 4월 22일

RxJS) 숏컷 선언적으로 관리하기

Declarative한 Observable 한번 잡숴봐

#JavaScript#Tools

개요

복잡한 웹 애플리케이션(특히 EMR 같은 도구)에서 단축키(Shortcut) 지원은 필수다. 하지만 React에서 키보드 이벤트를 다루다 보면 코드는 금방 지저분해진다.

이번 포스팅은 useEffect 와 addEventListener 의 늪에서 벗어날 수 있는 RxJS 스트림을 활용해 키보드 이벤트를 우아하게 처리하는 패턴을 공유해보려 한다.

이전 코드의 문제점

프로젝트에 metaKey + K 로 특정 동작을 수행하는 숏컷을 등록하는 코드가 아래처럼 쓰이고 있었다.

javascript
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;
};

사용하는 곳에선 이렇게..

javascript
// SomeComponent.tsx

const { isKeyPress } = useSearchShortcut();

useEffect(() => {
    if (isKeyPress) {
        // side effect...
    }
}, [isKeyPress])

위 코드는 보일러 플레이트가 많을 뿐만 아니라, 몇가지 치명적인 단점이 숨어있다.

  1. 불필요한 렌더링 사이클
    • 숏컷은 ‘상태(State)’ 가 아닌 ‘이벤트(Event)’ 다. isKeyPress 상태를 넘김으로써 사용하는 컴포넌트에 리렌더링을 유발하고 있다.
  2. 복잡한 생명주기
    • 이벤트 리스너 등록/해제와 상태 변경이 뒤섞여 있어서 유지보수에 불리하다. 당장 현 상태에서 K 키가 아닌 다른 키가 추가되면..?
  3. useEffect의 의존성 문제
    • 두말하면 입아픈 의존성 배열 문제. 핸들러 내부에서 컴포넌트의 최신 상태(state나 props)를 참조하려고 하면 과거의 값(Stale Closure)을 보게 되는 버그가 발생하기 쉽다.

이런 문제들을 손쉽게 해결할 수 있는 RxJS 를 소개하려고 한다.

What is RxJS ?

Reactive X 라는 비동기 프로그래밍을 위한 API 인터페이스를 자바스크립트로 구현한 라이브러리다.

대표적으로 Angular와 NestJS 를 꼽을 수 있겠다.

두 프레임워크 모두 RxJS 가 표준이며, 자세한 내용을 다루기엔 너무 내용이 많으므로 관련 공식문서와 다른 글을 참고해보면 좋겠다.

RxJS

faviconhttps://rxjs.dev/
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에만 집중하지만, 나중에 다른 토픽으로 다시 글을 쓸 수 있을 듯 하다.

  1. fromEvent 생성
javascript
import { fromEvent, filter } from 'rxjs';

// 1. 키보드 이벤트를 Observable 스트림으로 변환
const keyDown$ = fromEvent<KeyboardEvent>(document, 'keydown');

  1. pipe 로 로직 분리
javascript
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를 할 필요도 없다.

전체 코드는 아래와 같다.

typescript
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]);
}

실제 사용하는곳에서

typescript
  useSearchShortcut({
      onTrigger: () => {
			    // side effect...
      },
  });

이전에 비해 무엇이 좋아졌나?

  1. 리렌더링 없음
    • 상태 변경이 없으니 컴포넌트 리렌더링이 발생하지 않는다. 오직 이벤트가 발생했을 때 로직만 실행된다.
  2. 선언적 코드: "리스너를 등록하고, 해제하고..." 같은 명령형 코드가 사라지고, "키보드 이벤트 중 KeyK만 골라내서 실행해라" 라는 선언형 코드로 바뀌었다. 그리고 이것이 RxJS 의 개발 철학이다.
  3. 확장성: 만약 더블 클릭이나, 특정 시간 내 입력 같은 복잡한 요구사항이 추가되더라도 RxJS 연산자 한 줄만 추가하면 된다.

당연하게도 RxJS 로 상태 관리 또한 가능하다. 사실 React의 거의 모든 것을 할 수 있다.

이번 포스팅에서는 간단한 DOM Event (keydown fromEvent)에만 집중 했지만, 나중에 다른 토픽으로 다시 글을 쓸 수 있을 듯 하다.

끝맺음

RxJS의 진짜 매력은 비동기 로직이 복잡해질수록 빛을 발한다는 점이다.

지금은 단순히 키보드 입력을 걸러내는 용도로만 썼지만, 여기에 연산자(Operator) 몇 개만 더 얹으면 완전히 새로운 요구사항을 바로 수행할 수 있다.

  • debounceTime(300)을 추가하면? → 디바운싱 됨
  • throttleTime(100)을 추가하면? → 스크롤 이벤트 최적화 가능
  • bufferCount(2)를 추가하면? → 더블클릭용 이벤트로 변모
  • merge(keyUp$, targetElemClick$) → 키보드와 마우스 입력을 동시에 처리할 수도 있다.

React의 useEffect 와 씨름하느라 지쳤다면, 혹은 "이벤트 처리가 왜 이렇게 장황해졌지?"라는 의문이 든다면, 이벤트를 '데이터 스트림'으로 바라보는 연습을 시작해 보길 권한다.

생각보다 코드가 훨씬 간결해지고, 무엇보다 개발하는 재미가 있다.

  • 개요
  • 이전 코드의 문제점
  • What is RxJS ?
  • 프로젝트에 적용해보자
  • 이전에 비해 무엇이 좋아졌나?
  • 끝맺음