본문으로 건너뛰기

"rxjs" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

redux-observable 사용하기

· 약 6분

redux-observable은 RxJS로 Redux에서 비동기 액션을 처리할 수 있게 해줍니다.

기초

액션을 Observable로 다루기

redux-observable에서는 Redux 스토어에 들어오는 액션들을 Observable로 다룰 수 있게 해줍니다. dispatch가 호출되면, 액션이 스토어에서 처리된 후에 Observable에 액션이 출력됩니다.

실제로는 Observable을 확장한 ActionsObservable을 얻을 수 있는데 여기에는 특정 종류의 액션만 걸러낼 수 있는 ofType 연산자가 추가로 제공됩니다. .ofType('ACTION_TYPE').filter(action => action.type === 'ACTION_TYPE')와 동일합니다.

Epic

redux-observable에서는 액션이 들어오는 이벤트를 받아서 추가적인 액션을 발생시킬 수 있습니다. (이미 들어온 액션을 바꾸거나 없앨 수는 없습니다.) 이렇게 액션의 Observable을 추가로 발생시킬 액션의 Observable로 바꿔주는 함수를 Epic이라고 부릅니다. 그림으로 보면 다음과 같습니다.

그림

Epic은 '서사시'라는 뜻인데 Epic이 실행되는 동안 발생하는 액션을 어떻게 처리할지에 대한 이야기이기 때문에 그런 이름이 된 것이 아닐까 생각합니다.

PING 액션을 받아서 PONG 액션을 발생시키는 가장 간단한 Epic을 생각해볼 수 있습니다. (별로 쓸모는 없지만)

function pingEpic(action$) {
return action$.ofType('PING')
.map(action => ({ type: 'PONG' }));
}

실제로도 유용할 것 같은, 액션을 받아서 비동기 API를 호출하고 성공 액션을 발생시키는 가장 기본적인 Epic은 다음과 같이 생겼습니다.

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
.catch(err => Observable.of({
type: 'FETCH_POSTS_ERROR', payload: err, error: true
}))
);
}

리듀서는 이런 식으로 만들 수 있을겁니다.

function reducer(state = {}, action) {
switch (action.type) {
case 'FETCH_POSTS':
// Epic과 무관하게 FETCH_POSTS는 리듀서로 들어옵니다!
return { isLoading: true };

case 'FETCH_POSTS_SUCCESS':
return { isLoading: false, posts: action.payload };

case 'FETCH_POSTS_ERROR':
return { isLoading: false, error: action.payload };

default:
return state;
}
}

여러 Epic 합성하기

일반적으로 처리하는 액션 타입에 따라 여러 개의 Epic을 만들어서 합성하여 사용하게 됩니다. 합성은 combineEpics 함수를 사용하고, 이렇게 합쳐져서 최종적으로 만들어진 Epic을 Root Epic이라고 합니다. (리듀서를 combineReducers로 합쳐서 루트 리듀서를 만드는 것과 비슷합니다)

import { combineEpics } from 'redux-observable';

const rootEpic = combineEpics(
pingEpic,
fetchPostsEpic,
);

적용

의존성 설치

npm으로 rxjsredux-observable을 설치합니다.

Epic Middleware 추가하기

Epic을 실제로 적용하려면 미들웨어를 통해서 Redux 스토어에 붙입니다.

import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';

const epicMiddleware = createEpicMiddleware(rootEpic);

const store = createStore(
rootReducer,
applyMiddleware(
epicMiddleware,
...
)
);

실전 팁

프로젝트 구조

공식 문서에서는 Ducks 패턴을 추천하고 있습니다. Ducks 패턴은 연관된 액션 타입, 액션 크리에이터와 리듀서를 하나의 모듈로 묶는 방식인데 여기에 Epic이 추가되는 겁니다.

Epic에서 스토어 상태 가져오기

사실 Epic의 두번째 파라미터로는 Redux 스토어가 들어옵니다. 따라서 필요할 때 getState()를 호출하여 스토어 상태에 따라 액션을 처리할 수 있습니다.

function addCommentEpic(action$, store) {
return action$.ofType('ADD_COMMENT')
.mergeMap(action => {
const { currentUser } = store.getState();
return addComment(currentUser, action.body)
.map(...);
})
}

비동기 요청 취소하기

RxJS의 takeUntil 연산자를 적용하면 특정 액션이 들어올 때 동작을 취소할 수 있습니다.

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
// FETCH_POSTS_CANCEL 액션이 들어오면 구독 취소
.takeUntil(action$.ofType('FETCH_POSTS_CANCEL'))
);
}

액션 종료 시에 알림 받기

Epic의 구조상 액션을 dispatch하는 곳에서 액션 처리가 완료된 것을 알기 어렵습니다. 모든 것을 Redux에서 관리하는 것이 최선이긴 하지만 때로는 탈출구가 필요하기도 합니다.

어쩔 수 없을 때는 redux-observable에 올라온 이슈에서 힌트를 얻어서 액션에 콜백을 같이 넘기는 방법을 사용해볼 수 있습니다. (콜백보다는 Promise나 RxJS의 Subject를 사용하면 약간 더 깔끔합니다.)

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.do(posts => {
if (action.meta.callback)
action.meta.callback(posts); // 밖에 알려주기
})
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
);
}

dispatch({
type: 'FETCH_POSTS',
meta: { callback: () => console.log('done!') }
});

RxJS로 React 컴포넌트 상태 관리하기

· 약 9분

최근 UI 프로그래밍에 Rx 패턴이 많이 쓰이고 있습니다. React는 Rx와 이름은 비슷하지만 상태를 다루는 방식은 명령형에 가깝습니다. 상태를 최소화하고 최대한 바깥으로 밀어내는 식으로 문제를 회피할 수는 있지만, 실제 애플리케이션에서는 어찌되었든 상태를 직접 다뤄야 하는 상황이 오곤 합니다.

그렇다면 React 컴포넌트에서도 Rx를 활용하여 상태를 관리할 수 있지 않을까 생각해서 한번 시도해 보았습니다. React에 익숙하다는 가정을 하고 설명하겠습니다. (Rx에 대해 잘 모르신다면 다른 글을 몇 개 읽어보고 오시면 좋습니다)

RxJS 설치하기

(이 글은 얼마 전 정식 버전이 나온 RxJS 5를 기준으로 작성되었습니다.)

npm의 rxjs 패키지를 설치하면 됩니다. (rx라는 패키지도 있는데 이쪽은 RxJS 구버전이므로 주의가 필요합니다.)

DOM 이벤트를 Observable로 표현하기

RxJS에는 DOM 노드의 이벤트를 직접 구독할 수 있는 기능이 제공되지만 여기서는 직접 React의 이벤트를 Observable로 바꿔보겠습니다.

Subject를 만들고 입력 내용이 바뀔 때마다 next 메소드를 호출하여 Observer에 보냅니다.

Subject는 Observable이기도 하므로 구독해서 state를 업데이트 해봅니다. 여기까지는 기본적으로 EventEmitter와 다를 것이 없습니다.

import * as Rx from 'rxjs';

class EventsExample extends React.Component {
constructor() {
super();
this.state = { text: '' };
this.text$ = new Rx.Subject();
}

render() {
return (
<div>
<input onChange={e => this.text$.next(e.target.value)} />
{this.state.text}
</div>
);
}

componentDidMount() {
this.text$.subscribe(text => this.setState({ text }));
}
}

이벤트를 바로 구독하는 대신 중간에 연산자를 넣어 보겠습니다. filter로 길이가 2글자 이상일 때만 이벤트를 발생시키고, map으로 뒤에 문자열을 덧붙입니다.

이렇게 함수형 연산자를 통해 이벤트 스트림을 원하는 형태로 변형할 수 있는 것이 Rx의 장점입니다. map, filter 외에도 여러가지 강력한 연산자를 사용할 수 있습니다.

class EventsExample extends React.Component {
// ...

componentDidMount() {
this.text$
.filter(text => text.length >= 2)
.map(text => text + '!')
.subscribe(text => this.setState({ text }));
}
}

props에 따라 네트워크 요청을 하는 컴포넌트

여기서부터는 검색어를 props로 받아서 GitHub API를 호출하고 받아온 데이터를 렌더링하는 컴포넌트를 만들어 보겠습니다.

네트워크 요청은 비동기 작업이기 때문에 여러가지 복잡한 상황을 처리해야 할 수 있습니다.

  • 응답을 받기 전에 다른 검색어가 props로 들어오면 새로운 요청을 보내야 합니다.
  • 이전 요청의 응답이 새 요청의 응답보다 늦게 도착하면 무시해야 합니다. (또는, 새 요청을 보내면서 이전 요청을 취소합니다.)
  • 요청이 실패하면 다시 요청을 보내봅니다.
  • 재시도 중에도 다른 검색어가 props로 들어오면 재시도를 중단합니다.

이런 경우에 Rx를 이용하면 깔끔한 코드를 작성할 수 있습니다.

props를 Observable로 표현하기

먼저 검색어가 props에서 들어오므로 이를 Observable로 만들 것입니다.

위에서와 마찬가지로 Subject를 사용하여 componentWillReceiveProps 라이프사이클 메소드가 불릴 때마다 새 props를 전파합니다. 초기값을 나타내기 위해 BehaviorSubject를 사용했습니다.

그리고 map 연산자로 props에서 query 프로퍼티만을 취했습니다.

class SearchExample extends React.Component {
constructor(props) {
super(props);
this.state = { data: null };
this.props$ = new Rx.BehaviorSubject(props);
}

componentWillReceiveProps(nextProps) {
this.props$.next(nextProps);
}

componentWillUnmount() {
this.props$.complete();
}

render() {
const data = this.state.data;
return <ul>{data && data.map(item => <li>{item.login}</li>)}</ul>;
}

componentDidMount() {
const query$ = this.props$.map(props => props.query);
query$.subscribe(query => console.log(query));
}
}

GitHub API 호출

RxJS의 AjaxObservable로 GitHub API를 호출해보겠습니다. RxJS 5가 나온지 아직 얼마 되지 않아서 문서화가 제대로 되어있지 않은데 다음과 같이 사용합니다.

// 주의: subscribe를 할 때마다 요청을 전송함
Rx.Observable.ajax.get('https://api.github.com/search/users?q=dittos')
.map(r => r.response.items)
.subscribe(data => console.log(data));

이제 query가 바뀔 때마다 요청을 보내려면 각 querymergeMap하여 AjaxObservable로 바꿔줍니다. (flatMap으로 불리기도 하는 연산자입니다.)

class SearchExample extends React.Component {
// ...

componentDidMount() {
const query$ = this.props$.map(props => props.query);
const result$ = query$.mergeMap(query => {
if (query === '')
return Rx.Observable.of(null);
else
return Rx.Observable.ajax.get('https://api.github.com/search/users?q=' + query)
.map(r => r.response.items);
});
result$.subscribe(data => this.setState({ data }));
}
}

연산자를 붙여서 잘(?) 처리하기

위에서 나열한 요구사항을 만족하기 위해 Rx 연산자를 추가해보겠습니다.

  • 이전 요청의 응답이 새 요청의 응답보다 늦게 도착하면 무시해야 합니다 mergeMap은 요청이 들어간 순서를 따지지 않고 응답이 도착하는 대로 뿜어냅니다. switchMap으로 변경해서 이전 요청을 취소하도록 만들 수 있습니다. (switchMapflatMapLatest로 불리기도 합니다.)
  • 요청이 실패하면 다시 요청을 보내봅니다 retry를 적용하면 Observable이 실패 상태로 끝났을 때 다시 Observable을 구독할 수 있습니다.
  • 재시도 중에도 다른 검색어가 props로 들어오면 재시도를 중단합니다. 이미 switchMap으로 변경했으므로 자동으로 재시도가 중단됩니다. :)
class SearchExample extends React.Component {
// ...

componentDidMount() {
const query$ = this.props$.map(props => props.query);
const result$ = query$.switchMap(query => {
if (query === '')
return Rx.Observable.of(null);
else
return Rx.Observable.ajax.get('https://api.github.com/search/users?q=' + query)
.map(r => r.response.items)
.retry(3);
});
result$.subscribe(data => this.setState({ data }));
}
}

입력창과 합치기

처음의 DOM 이벤트 예제와 결합하여 전체 애플리케이션을 완성합니다.

text$debounceTime 연산자를 적용해서 검색 요청이 너무 빠르게 발생하는 것을 방지합니다.

class EventsExample extends React.Component {
constructor() {
super();
this.state = { text: '' };
this.text$ = new Rx.Subject();
}

render() {
return (
<div>
<input onChange={e => this.text$.next(e.target.value)} />
<SearchExample query={this.state.text} />
</div>
);
}

componentDidMount() {
this.text$.debounceTime(200)
.subscribe(text => this.setState({ text }));
}
}

CodePen에서 실행해보실 수 있습니다.

결론

React에서 복잡한 상태를 관리해야 할 때 RxJS를 사용해보는 것도 나쁘지 않을 것 같습니다. 물론 상태를 최소화하는 방법을 먼저 생각해보는 게 중요합니다.