RxJS와 XState는 JS 애플리케이션에서 복잡한 비동기 및 상태 기반 동작을 관리하고 조율하는 데 사용되는 라이브러리입니다. XL8의 MediaCAT에서는 두 라이브러리를 동시에 채택했습니다. 일반적으로 새로운 기술스택을 하나만 도입하기에도 그 부담이 큰데, 어떻게 이런 과감한 결정을 내릴 수 있었을까요?
복잡해진 제품 요구사항
XL8의 강력한 기능인 전사와 번역, 그리고 사후편집 기능을 녹여낸 Project View를 만들기 위해 우리는 많은 고민을 했습니다.
- 유저가 어떻게 프로젝트를 시작할 것인가? 자막 전사부터 시작할 수도 있고 번역부터 시작할 수도 있다.
- 프로젝트 생성시 모달을 닫거나 앞뒤로 단계를 옮기면 상태는 초기화되어야 한다.
- 미디어(영상 또는 음성)와 함께 자막을 표시해야 한다. 미디어 시간에 맞추어 각 자막이 활성화되고, 자동으로 해당 자막이 눈에 띄도록 스크롤 위치를 옮겨야 한다.
- 반대로 사용자가 스크롤해서 다른 자막이 보이는 위치로 이동했을 때, 미디어의 현재 시간이 자막에 맞춰 변경되어야한다.
- 자막의 타임코드에 맞춰 미디어 하단에 자막을 보여줘야 한다.
일반적인 웹 애플리케이션과 달리 이들은 한 차원 높은 요구사항입니다. 게다가 MediaCAT이 더욱더 강력한 전문가용 도구로 진화하기 위해서는 더 많은 옵션을 사용자에게 제공해야 하고, 더 많은 상태와 이벤트를 관리해야 한다고 생각했습니다. 기존 기술스택인 React state와 react-query만으로 해결하기엔 무리가 있었습니다.
- 수많은 boolean flag들로 얽힌 조건문
- get과 set방식의 hook으로 얽힌 전역적, 국소적 데이터 핸들링
이는 코드 양이 큰 제품에서는 가독성 및 유지보수를 어렵게 만드는 원인이기도 하므로, 이를 해결할 새 도구를 도입할 때가 됐다고 판단했습니다.
요구사항 1. 프로젝트 생성 모달
사용자에게 옵션을 주고, 이를 선택하게 하는 모달이 뜹니다.
각 옵션은 다른 모달창으로 이어지고, 그대로 진행하거나 다시 이전 단계(옵션 선택)로 돌아갈 수 있습니다.
각 전환마다 상태는 초기화됩니다.
요구사항 2. 미디어와 자막 연동
미디어와 자막 세팅이 준비된 상태에서, 스트리밍되는 영상의 현재 시각에 반응하여 UI가 변경됩니다.

이제 각 요구사항에 맞춰 문제를 해결해봅시다.
XState
XState는 상태를 관리하기 위한 라이브러리로, 복잡한 상태 전환과 동작을 선언적이고 직관적인 방식으로 모델링하고 시각화할 수 있습니다. 유한 상태 머신의 개념을 기반으로 하며 JS 애플리케이션에서 상태 머신을 정의, 해석, 관리하기 위한 일련의 도구를 제공합니다. 나아가 중첩 상태, 병렬 상태, 히스토리 상태 등을 처리하는 기능을 제공하므로 강력한 오류 처리 및 전환을 통해 복잡한 애플리케이션 상태를 관리하는 데 적합합니다.
첫번째 요구사항이었던 프로젝트 생성 시나리오는
- 어떠한 상태가 됐는지
- 어떠한 상태로 되돌려야 하는지
명확한 흐름이 정의돼있습니다. Figma로 보면 다음과 같습니다.
이제 프론트엔드 개발자는 이 흐름을 코드로 모델링해야 합니다. 어떤가요? 모양이 꽤나 유사하지 않나요?
상태에 따른 변화가 선언적인 코드의 형태로 바뀌었습니다!
만약 새로운 개발자가 코드 흐름을 파악하기 위해 Figma를 찾아 코드와 비교하느라 헤매거나 로컬 환경에서 직접 UI를 조작하고 있었다면 그만큼 시간을 낭비하게 됩니다. XState 없이 코드가 절차적으로 작성됐다면, 변수 선언부와 UI 코드(JSX)를 위아래로 오가며 변수의 이름과 상태를 기억하느라 정신없는 와중에 변경이 일어나는 부분까지 신경써야 합니다. 인지 부하로 코드를 파악하기 훨씬 어려워지겠죠.
XState의 기본만 학습한 상태에서라도, 우측 코드(머싲 정의)를 시각화 도구에 넣기만 하면 코드의 흐름을 이해하기 훨씬 수월해집니다. 눈에 보이는대로 모델링됐으니까요.
덤: 성능
위 상태머신을 React 애플리케이션에 적용하면서 거저 얻은 또 하나의 이점은 Context API를 활용하기 더 용이해진다는 것입니다.
import React, { createContext } from 'react';
import { useInterpret } from '@xstate/react';
import { authMachine } from './authMachine';
export const GlobalStateContext = createContext({});
export const GlobalStateProvider = (props) => {
const authService = useInterpret(authMachine);
return (
<GlobalStateContext.Provider value={{ authService }}>
{props.children}
</GlobalStateContext.Provider>
);
};
React의 Context API는 의존성 주입 및 의존성 경계 설정의 역할을 동시에 수행해주는 장점이 있습니다.
- 의존성 주입을 통한 모듈화는 느슨한 결합을 만들어줍니다. 하나의 구성 요소가 변경되더라도 다른 구성 요소에 큰 영향을 주지 않고 독립적으로 변경할 수 있는 유연성과 확장성을 갖출 수 있습니다. 테스트 또한 간결해집니다.
<Provider>
를 통해 선언적인 경계가 설정되면 개발자가 집중해야하는 영역이 좁아지므로 유지보수와 기능 확장에 유리해집니다.
다만 Context API는 전달되는 컨텍스트 값의 참조 동일성(Referential Equality)이 유지되지 않는다면, 값의 일부만 변경되어도 모든 하위 컴포넌트들이 다시 렌더링되기 때문에 성능이 저하되기 쉽다는 단점이 있습니다. 그러나 XState가 제공하는 hook을 이용하여 만들어진 객체(위 예제에서의 authService
)는 참조 동일성을 유지하기 때문에 성능이 저하될 우려를 하지 않아도 됩니다. 🥳
RxJS(+Observable-Hooks)
RxJS는 JS용 반응형(reactive) 프로그래밍 라이브러리입니다. 관찰 가능(observable)한 패턴을 기반으로 하며 선언적이고 조합 가능한 방식으로 이벤트, HTTP 요청 또는 사용자 상호 작용과 같은 시간 경과에 따른 데이터 스트림을 표현하고 조작할 수 있습니다. RxJS는 데이터 스트림을 필터링, 변환, 결합 및 관리하기 위한 광범위한 연산자를 제공하여 애플리케이션에서 복잡한 비동기 시나리오를 처리하는 데 강력한 도구입니다.
참고로 Angular 진영은 버전 2부터 프레임워크 자체에서 RxJS를 지원합니다. 반면에 React는 내부 상태를 새로운 DOM 트리로 그려주는 역할만 하기 때문에(즉 데이터 바인딩이 단방향이기 때문에) RxJS를 바로 사용하기가 어렵습니다. observable-hooks는 이러한 React의 제약을 뛰어넘어, RxJS의 스트림 데이터를 React에 동기화할 수 있게 해주는 라이브러리입니다.
우리가 풀고 싶었던 문제로 돌아와보면, 우리는 영상의 ‘재생 시각’이라는 실시간으로 변하는 상태에 반응해야 합니다. 또한 재생 시각이 바뀌면 그에 맞게 자막을 선택해야 하고, 반대로 다른 자막을 선택하면 재생 시각도 바뀌어야 합니다. 이렇게 양방향으로 데이터가 흐를 때는 RxJS의 대표적인 객체인 Observable
만으로는 한계가 있어, Observable이자 Observer 역할도 하는 BehaviorSubject
가 매우 유용하게 사용됩니다.
import { BehaviorSubject, fromEvent } from 'rxjs';
const $currentTime = new BehaviorSubject(0) // 기본값 세팅
// 비디오 컴포넌트 내부
React.useEffect(() => {
const timeUpdateSubscription = fromEvent(
videoRef.current,
'timeupdate',
).subscribe((event) => {
currentTime$.next(event.target.currentTime);
});
return () => {
timeUpdateSubscription.unsubscribe();
};
}, [videoRef]);
return <video ref={videoRef} {...props} />
이제 미디어가 재생되면 재생 시각(currentTime
) 의 변경을 감지할 수 있습니다.
import { useSubscription } from 'observable-hooks';
// 자막 컴포넌트 내부
const { currentTime$ } = useMediaObservableState();
const { startTime, endTime, subtitleText } = subtitleObject;
useSubscription(currentTime$, (currentTime) => {
if (startTime <= currentTime && currentTime <= endTime) {
setActive(true);
} else {
setActive(false);
}
});
return (
<div onClick={() => {
currentTime$.next(startTime)
}}>
{subtitleText}
</div>
);
반대로 비디오 컴포넌트에서도 아래와 같은 구독이 가능합니다.
useSubscription(currentTime$, (currentTime) => {
videoRef.current.currentTime = currentTime
});
예시 코드에 드러나진 않았지만 useMediaObservableState()
는 내부적으로 React.useContext()
를 사용했고, XState 예시와 마찬가지로 Provider
를 통해 의존성을 주입하고 경계를 만들었습니다. Observable 객체 또한 참조 동일성을 유지하므로 값의 변화가 자식 컴포넌트들의 불필요한 리렌더링을 유발하지 않습니다.
결론
아직 손에 익지 않은 라이브러리라 상대적으로 간단한 케이스를 다뤄봤습니다. 중요한 것은 차근차근 문제를 정의하고, 해결방안을 찾아 발전해 나아가는 것이라 생각합니다.
새로운 라이브러리를 도입해서 잘 동작하면 아주 즐거워지고, 그 라이브러리를 여기저기 사용하고 싶은 유혹이 생깁니다. 그러나 망치를 들고 있으면 모든 게 못으로 보이므로 주의해야 합니다. 자칫하면 불필요하게 코드베이스가 복잡해지는 결과를 낳을 수 있기 때문입니다. 나중에는 괜히 라이브러리를 욕하고, 프로젝트를 뒤엎고 싶다는 생각이 들 수도 있습니다. 따라서 우리는 새로 도입한 기술에 대해 원칙을 세우고 적절한 유즈케이스에서만 사용하고자 합니다. 그러면 훌륭한 도구를 내 공구상자에 평생 추가한 셈이 되겠죠. 이번에 얻은 교훈은 다음과 같습니다.
- UI 흐름이 복잡하다면 XState를 고려하자
- 시간의 변화와 그에 따른 상태 관리는 RxJS를 고려하자
유연하면서도 단단한 제품을 만들기 위해 원칙을 세워 지키고, 공부하고, 프로젝트에 적용해봅시다!
참고문서
작성자. 이성필, Frontend Engineer