티스토리 뷰

회사에서 redux, rxjs, redux-observable을 사용하고 있는데 처음엔 왜 이런 복잡한 걸 쓰나 싶었지만 쓰다보니 또 편리한 점도 있고 해서, 한 번 정리하고 넘어가려고 쓰는 포스팅.

리덕스란

리덕스는 예측 가능한 상태 컨테이너다. (라고 공식 도큐먼트에 써있다)

여기서 예측 가능하다는 것이 중요하다. 상태가 앱의 여기저기에서 변화하고, side effect가 발생하다보면 상태가 어떻게 될지 예측하기 힘들어진다.

리덕스는 action과 reducer를 통해서 일정한 단방향으로 변화를 전달함으로써 상태 흐름을 예측할 수 있게 해준다.

action이란 일어난 일 또는 일어나야만 하는 일(액션)을 묘사한 것이다.

예를 들어 TODO앱을 만들 때 가장 기본적인 기능이 TODO를 추가하는 것이므로, 사용자가 TODO를 작성하면 'ADD_TODO' 라는 액션을 발생시키는 것이다.

액션 또한 Plain 자바스크립트 객체다. 아래는 타입이 ADD_TODO이고, 그 내용이 Study with me라는 액션 객체다.

const exampleAction = {type: 'ADD_TODO', text: 'Study with me'}

그리고 input에 사용자가 TODO text를 입력하고, 버튼을 클릭하여 이를 저장한다고 하면 예를 들어 리액트에서는 아래처럼 할 수 있다.

<button onClick={() => dispatch(exampleAction)}>Add to do</>

액션이 dispatch된 다음에는 reducer라는 함수로 간다.

액션이 plain object인 것처럼, reducer도 평범한 pure function이다. state와 action을 인풋으로 받고 새로운 state를 아웃풋으로 반환한다.

즉 리듀서가 하는 역할은 action이 발생했을 때 이를 받아서 그에 맞는 새로운 상태로 업데이트하여 반환하는 것이다. 예를 들어 ADD_TODO 액션이 발생하면, 새로운 state에는 기존 TODO 리스트 목록에 새로운 TODO를 추가하여 반환할 것이다.

(state, action) => state

그런데 여기서 중요한 점은 리듀서는 state transition을 항상 동기적으로 처리한다는 것이다.

예를 들어보자면, 아래 리듀서는 INCREMENT action을 받을 때마다 즉시 1이 증가된 state를 반환한다.

const counter = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
}

하지만 실제로는 더 복잡한 동작을 하게 하고 싶을 수 있다. 예를 들어

  • 매번 INCREMENT 액션이 일어날 때마다 1씩 증가시키는 대신, INCREMENT 액션이 10번 일어났을 때 비로소 한꺼번에 10를 더하고 싶다
  • 액션이 발생했을 때 AJAX 요청을 해서 그 응답의 결과물과 합쳐서 상태를 업데이트 하고 싶다

등등. 액션 발생 후 바로 리듀서를 거쳐서 상태를 변화시키는 것이 아니라 중간에 어떤 비동기나 side effect를 발생시키는 행동을 하고 싶다는 것이다.

이런 문제들(e.g. AJAX cancellation/composing, Debounce/Throttle, Drag and Drop, Web Sockets, Web workers)을 해결하기 위해 보통 리덕스 미들웨어(Middleware)를 사용한다. 리덕스 자체는 비동기 핸들링에 대해 신경쓰지 않기 때문에, 리덕스에서 비동기를 쉽게 다루기 위한 서드 파티를 추가하는 것이다.

Middleware라는 단어에 middle이 들어가있는 것처럼, 중간에 하나의 과정이 더 추가된다고 보면 되겠다. 액션이 발생하고 나면 이 미들웨어를 거치게 된다. 참고로 이건 리듀서에 도달하기 전일 수도 있고, 도달하고 난 뒤일 수도 있다. 이건 어떤 미들웨어를 쓰느냐에 따라 다르다. 요점은 액션 발생 -> 리듀서 도달 및 새로운 상태 반환 이 흐름 어딘가에 미들웨어를 거침 한 단계가 더 추가된다는 거다.

그런데 반드시 미들웨어를 써야 할까? 미들웨어를 쓰지 않고 콜백이나 프로미스처럼 일반적인 자바스크립트로 비동기를 핸들링하면 안 될까? 콜백은 콜백헬 같은 문제 때문에 가독성이 많이 떨어지니 프로미스로 예를 들어보자.

fetchSomeData(id)
  .then(data => {
    dispatch({ type: 'FIRST_CALL_DATA', data });
    return fetchSomeData(data.parentId);
  })
  .then(data => {
    dispatch({ type: 'SECOND_CALL_DATA', data });
    return fetchSomeData(data.parentId);
  })

위 코드는 프로미스로 비동기 composition을 하는 의사 코드다. 콜백과 달리 콜백헬이 일어날 우려도 없고 비교적 깔끔해보인다. 하지만 프로미스에는 몇 가지 단점들이 있다.

프로미스의 특징

  • Guaranteed future. 한 번 생성하면 성공하든 실패하든 결과가 settle될 때까지 진행한다. (중간에 취소가 불가능하다.)
  • Single Value를 리턴한다.
  • 이뮤터블이다.
  • Caching. 프로미스를 생성한 뒤 여러 번 subscribe해도, 새로운 리퀘스트를 만들어내는 대신 항상 이미 만들어진 같은 값을 받는다.

여기서 첫 번째와 두 번째 특징이 특히 단점이 될 수 있다. 즉 취소가 불가능하다는 점과 Single value를 리턴한다는 점이다.

첫 번째로 자동 완성 기능을 생각해보자. 보통 이 경우 디바운스를 사용해서 유저가 input에 뭔가를 치다가 잠깐 멈추었을 때, 그 때 그 쿼리를 api로 보내서 관련 결과를 가져온다. 그런데 그 결과가 오기 전에 유저가 다시 input에 뭔가를 더 입력한다고 해보자. 그러면 이미 전에 요청한 api의 결과는 소용이 없어지고, 새로운 input을 반영한 쿼리를 보내야 한다. 그러면 그 전에 요청한 api는 취소해야 효율적일 것이다.

물론 안 취소하고 내버려둘 수도 있다. 하지만 그렇게 하면 너무 많은 api 요청이 생겨서 퍼포먼스에 영향을 줄 수 있고, 사용하지 않을 응답을 받은 CPU도 쓸데 없는 연산을 하게 된다.

그러므로 새로운 입력이 생겼을 때는 전에 요청한 api를 취소하고 새로 날리는 게 효율적인데, 프로미스는 중간에 취소가 되지 않으니 이 경우엔 적절하지 않을 수 있다. (원하는 타이밍에 reject를 호출하게 만들어서 취소와 비슷한 효과를 내는 workaround를 만들 수는 있지만 원래 프로미스 인터페이스에는 취소 같은 기능은 없다)

그리고 single value를 리턴한다는 것은, AJAX를 다룰 때에는 문제 없지만 마우스 이벤트처럼 다중 값을 반환하는 데이터 소스를 처리할 수 없다는 뜻이다.

그러므로 이러한 제약을 해결하기 위해 옵저버블(Observable)의 사용을 고려할 수 있다.

옵저버블과 RxJS

옵저버블은 많이 들어왔던 프로미스와 달리 생소하게 느껴진다. 하지만 이 또한 프로미스처럼 어떤 객체라고 생각하면 된다. 다만 프로미스와는 다른 성질을 가지고 있는 객체다. 다중 값을 반환할 수 있고, 취소할 수 있는 그런 객체다.

옵저버블이란 값이 아예 없을 수도 있고, 값을 하나만 가질 수도 있고, 여러 값을 가질 수도 있는 스트림이다.

(참고 - 스트림) 전통적으로 스트림이란 파일 읽기, HTTP 요청하기 처럼 I/O 작업과 관련된 추상 객체로 많이 사용되어 왔지만, 반응형 프로그래밍에서는 소비할 수 있는 모든 데이터 소스를 의미한다.

옵저버블이 무엇인지 헷갈리는 이유는 무엇이든 옵저버블이 될 수 있어서 그런 것 같다. 키 입력, 마우스 움직임, HTTP 요청 등 서로 상관 없어 보이는 이 모든 것들이 소비할 수 있는 데이터 소스라면 모두 옵저버블로 처리될 수 있다. 그리고 이 옵저버블들의 유틸리티 연산자를 제공하는 것이 RxJS다. (RxJS는 async의 lodash라고 불리기도 한다.)

옵저버블은 다음과 같은 특징을 갖는다.

  • 옵저버블은 map, filter, reduce 등의 연산자 메서드를 사용하여 변환할 수 있다.
  • 옵저버블은 concat, zip, merge 등의 연산자 메서드를 사용하여 서로 결합될 수 있다.
  • 디바운스, 스로틀, 버퍼, combineLatest 등의 연산자로 시간을 다룰 수 있다.
  • 옵저버블은 즉시 평가(eager evaluation)를 하는 자바스크립트 특성과 달리, 필요할 때만 만들어지는 지연 데이터 소스(lazy data source)다.

Redux-Observable

리덕스-옵저버블은 리덕스 미들웨어다. 다른 리덕스 미들웨어처럼 사이드 이펙트를 포함한 비동기를 핸들링하지만 RxJS와 함께 사용할 수 있도록 만들어졌다는 점이 포인트다. (그래서 RxJS에 대한 기본적인 이해가 없으면 사용하기 힘들다)

Epic 이라는 함수를 사용하는데, Epic은 발생하는 모든 액션으로 인풋으로 받고, 발생시킬 새로운 액션을 아웃풋으로 내보내는 함수다.

actions in, actions out

function (action$: Observable, state$: StateObservable): Observable

에픽이 반환하는 액션은 그 즉시 실행되므로, epic(action$, state$).subscribe(store.dispatch) 내부적으로는 이렇게 작동하는 셈이다.

에픽 코드는 아래처럼 생겼다.

const pingPongEpic = (action$, store) => action$.ofType('PING').map(action => ({type: 'PONG'}));

예시에서 pingPongEpic 함수는 모든 액션을 인풋으로 받는데, 들어온 액션 중 타입이 PING인게 있으면 이 옵저버블에 매핑해서(그렇다. 여기서는 액션도 옵저버블이다!) PONG이라는 새로운 액션을 반환한다. 그리고 이 PONG 액션은 즉시 실행된다.

epic들은 어플리케이션이 시작할 때 한 번 호출된다. 이것은 파이프라인을 설치하는 것과 같다.

약간 어려운 부분은 액션이 발생했을 때 리듀서를 먼저 통과하고 epic으로 들어간다는 것이다. 나 같은 경우는 이게 코드를 짤 때 헷갈리게 만들었다. 액션이 발생하면 이 액션은 리듀서와 에픽으로 모두 전송된다. 하지만 에픽에 도달할 때는 이미 리듀서를 통과하고 난 다음이다. 원래 단방향으로 일정하게 흐르던 리덕스의 플로우가 여기서 갈라지는 바람에 개인적으로 좀 헷갈렸다.

하지만 RxJS를 사용할 수 있으므로 편리한 연산자들을 체이닝해서 선언적으로 로직을 짤 수 있다는 것은 장점이다. 복잡한 동작도 간단하게 작성할 수 있게 된다.

const incrementEpic = (action$, store) => 
action$.ofType('INCREMENT_DEBOUNED')
    .debounceTime(1000)
    .map(() => ({ type: 'INCREMENT' }))

위 코드의 경우 들어온 액션의 타입이 INCREMENT_DEBOUNCED면 디바운스 타임을 1초로 세팅하고 그게 충족되면 다시 INCREMENT 액션을 발생시키는 epic이다.

const autoCompleteEpic = (action$, store) => action$.ofType('QUERY')
    .debounceTime(500)
    .switchMap(action => ajax('https://api.github.com/search/users?q=' + value)
    .map(payload => ({
      type: 'QUERY_FULFILLED',
      payload
    }))
);

위 코드는 자동 완성의 예시 코드인데, 만약 들어온 액션의 타입이 QUERY라면 디바운스 타임을 0.5초로 두고, 그게 충족되면 ajax요청을 보낸 다음 응답이 오면 QUERY_FULFILLED라는 액션을 응답값과 함께 발생시키는 epic이다.

여기서 switchMap이 나오는데, 이 연산자는 새로운 옵저버블이 발생하면 이 전에 있던 구독을 취소하고 새 구독을 시작하는 연산자다. 그러니까, 이 전에 QUERY 액션이 들어와서 이미 ajax요청을 보냈더라도 그새 새로운 QUERY 액션이 들어오면 이 전 ajax요청을 취소하고 새로 요청을 보낼 것이다.

이렇듯 리덕스 옵저버블은 RxJS 연산자들을 사용해서 복잡한 동작을 쉽게 쓸 수 있도록 해준다. 하지만 어떤 상황에는 리덕스 옵저버블이나 RxJS를 사용하는 것이 overkill이 될 수도 있으므로 프로젝트에 맞게 선택해서 해야한다.

Ref

https://www.youtube.com/watch?v=AslncyG8whg

https://rxjs-dev.firebaseapp.com/

https://redux-observable.js.org/

길벗 출판사 <RxJS 반응형 프로그래밍>

'공부일지(TIL) > JS Framework + Library' 카테고리의 다른 글

[React] Component Composition  (0) 2021.07.26
[React] Reconciliation  (0) 2021.07.23
[React] re-render  (0) 2021.04.12
[React] useImperativeHandle의 장점  (1) 2021.03.16
[React] Cancelable Promise  (0) 2021.03.16
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함