Redux-Saga: Channel


이번 글에서는 Redux-Saga의 Channel(이하 채널)에 대해 정리해보고자 한다. 채널은 Redux-Saga의 초기 버전에서부터 고려했던 API는 아니다. 그런데 Stackoverflow에서 외부 이벤트를 Saga와 어떻게 연결하냐는 질문에서 채널의 필요성이 표면적으로 나타나기 시작했다. 핵심은 Push 기반과 Pull 기반 동작이다.

WebSocket과 같은 외부 이벤트들은 일반적으로 리스너를 등록하는 on(type, listener) 형태의 Push 기반 로직을 작성한다. 하지만 Redux-Saga는 take(pattern)를 통해 액션을 끌어오는 Pull 기반 로직을 작성한다. 이런 차이 때문에 초기에 개발자들은 외부 이벤트를 Saga로 연결해 로직을 작성하는데 많은 어려움을 겪었다.

채널은 이런 다른 두 방식을 잘 연결해준다.

Push와 Pull

Push와 Pull은 일상생활에 아주 밀접하고 자연스럽게 녹아있다. 휴대폰을 생각해보자. 가만히 있어도 휴대폰 알림창에는 날씨, 뉴스, 메일, 문자 알림 등이 쉴 새 없이 쏟아진다. 휴대폰은 우리에게 정보를 밀어 넣는다. 바로 Push 기반 동작이다. 웹 서핑은 이와 반대다. 웹 브라우저를 실행하고, 갖고자 하는 정보를 서버에게 요청한다. 서버는 우리가 요청한 정보를 돌려준다. 우리가 직접 정보를 서버로부터 끌어온다. Pull 기반 동작이다. 차이점은 정보의 흐름을 누가 통제하느냐에 있다.

Saga에 WebSocket과 같은 외부 이벤트를 연결하기 위해서는 이들의 Push 동작을 Pull 동작으로 변경할 수 있어야 한다. 먼저 간단히 아래와 같은 코드들을 생각해볼 수 있을 것 같다.

Push 처리

다음은 일반적으로 흔히 사용하는 리스너 형태의 이벤트 처리 코드다.

function pushHandle(message) {
  // ...
}

socket.on("message", pushHandle);

Push -> Pull 처리

이제 이 이벤트 처리 코드를 Pull 동작과 같은 형태로 바꾼다. 요점은 메시지를 저장했다가 필요할 때 가져오는 것이다.

let receivedMessage;

socket.on("message", message => {
  receivedMessage = message;
});

function pullHandle() {
  // receivedMessage를 가지고 처리
}

// ...
// 이제 언제 어디선가 필요할 때 pullHandle을 호출한다.

단순한 Pull 처리에는 문제가 있다.

일단 저장하면, 나중에 필요할 때 가져와(Pull) 사용할 수 있다. 하지만 문제가 있다. 메시지가 언제 도착할지 모른다. Push에 해당하는 pushHandle은 항상 메시지가 존재한다는 보장을 받지만, pullHandle에서는 메시지가 있을 수도 있고 없을 수도 있다.

function pullHandle() {
  if (!receivedMessage) {
    // ... 메시지가 아직 없다. 실패.
  } else {
    // receivedMessage를 가지고 처리
  }
}

항상 이렇게 실패처리가 포함돼야 하는 것은 매우 비효율적이고 유지보수가 어렵다. 더 나은 방법을 찾아야 한다.

Deferred를 활용한 Pull message

갑자기 무슨 Deferred인가 싶겠지만, Deferred는 위 문제 해결의 열쇠가 될 수 있다. Deferred는 Promise와 매우 긴밀한 객체다. 만약 Deferred가 무엇인지 잘 모르겠다면, jQuery 1.5부터 등장한 Deferred를 잠시 살펴보자. 간단히 개념만 설명해보자면, Promise는 약속을 만드는 자가 약속을 지킨다. 반면 Deferred는 단순히 계약서만 만든다. Deferred의 계약을 지킬 의무는 다른 이가 가질 수 있다.

Deferred를 Pull 처리에 적용하면 다음과 같은 Promise를 가질 수 있다.

let deferred;

socket.on("message", message => {
  if (deferred) {
    deferred.resolve(message);
    deferred = null;
  }
});

function pullMessage() {
  if (!deferred) {
    deferred = {};
    deferred.promise = new Promise(resolve => {
      deferred.resolve = resolve;
    });
  }

  return deferred.promise;
}

function pullHandle() {
  pullMessage().then(message => {
    // 메시지를 가져와 처리
  });
}

이제 메시지의 도착을 pullMessage 함수로 항상 보장할 수 있다. 물론 그냥 봐선 비동기적인 코드인 것은 변하지 않았다, 하지만 Saga의 이펙트와 함께 사용한다면 달라진다. 이 pullMessage 함수를 Saga에서 Call 이펙트와 사용한다면, Push 기반의 비동기적이었던 로직을 이제 Pull 기반의 동기적인 로직으로 처리할 수 있다.

function* pullHandle() {
  const message = yield call(pullMessage);
  // 메시지 처리
}

채널

이렇게 Push 동작을 Pull 동작으로 바꾸고 보니, 일반화를 시킬 수 없을까 하는 의문이 든다. Redux-Saga를 사용하는 여러 개발자는 CSP(Communicating Sequential Processes)채널을 제안했다. CSP에는 핵심이 되는 두 가지 추상화가 있다. 프로세스채널이다. 프로세스채널은 Saga에 잘 어울리는 추상화다.

  • 프로세스: 동시적(concurrently)으로 수행되는 독립적인 작업이다. 각 코드는 순차적(sequential)으로 처리된다.
  • 채널: FIFO의 큐다. 각 프로세스는 채널을 통해 데이터를 주고받으며 통신한다. 채널에 put 연산으로 데이터를 추가하고, take 연산으로 데이터를 가져온다.

그리고 위 프로세스는 각 Saga에 부합한다. 메인테이너인 yelouafi는 이런 CSP를 부분적으로 차용하여 Redux-Saga의 채널 API를 디자인했다(가장 초기의 채널 구현은 v0.10.0의 channel.js에서 확인할 수 있다).

채널 간단히 살펴보기: Upload progress

서비스 개발에 Redux-Saga를 사용하면서 파일 업로드 기능이 있다고 가정하자. 십중팔구 업로드 프로그레스 표현에 잠시나마 고민하게 될 것이다. 아마 다음처럼 store.dispatch를 직접 호출하는 경우를 생각할 수도 있다.

function* uploadSaga() {
  const xhr = new XMLHttpRequest();

  xhr.addEventListener("progress", ev => {
    // ...
    store.dispatch(update({ progress: ev.loaded / ev.total }));
  });

  //...
}

스토어에 대한 의존성을 추가해 store.dispatch를 직접 호출하는, 조금 마음에 들지 않는 상황이 먼저 떠오른다. 그런데 더 큰 문제가 있다. Saga가 아닌, 외부 스코프로 함수가 분리된 것이다. 제너레이터(정확히는 Redux-Saga 미들웨어)의 처리 흐름에서 벗어나기 때문에, 여러 다른 이펙트들과 조합하여 로직을 풍부하게 구성할 수 없다.

이제 채널을 이용해보자.

function* uploadSaga() {
    const progressChan = yield call(channel);
    const xhr = new XMLHttpRequest();
    const putProgressToChannel = ({loaded, total}) => progressChan.put(loaded/total));

    xhr.addEventListener('progress', putProgressToChannel);

    yield fork(function* () {
        const progress = yield take(progressChan);

        // 스토어에 대한 직접적인 의존성이 없어졌다.
        // 여기에서 take, call 등 여러 이펙트들과 함께 풍부한 로직을 구현을 할 수 있다.
        yield put(update({progress}));
    });
}

외부 이벤트 연결: WebSocket

WebSocket과의 연결에 대한 내용은 이전에 "Redux-Saga에서의 WebSocket(socket.io) 이벤트 처리"라는 글로 한번 소개한 적이 있었다. 다만 저 글에서는 채널 자체에 대한 설명을 자세히 하지 않고, 채널 API를 활용하는 방식을 중심으로 설명했다. 이번 글을 읽고 다시 저 글을 읽는다면 조금 더 쉽게 읽을 수 있을 것이다.

동시 작업: Load-balancing

채널을 꼭 외부 이벤트에만 연결해야 하는 것은 아니다. 채널은 기본적으로 각 프로세스간의 통신을 위해 추상화된 개념이다. 그래서 채널을 외부 이벤트에 연결하는 것이 아니라, 같은 작업을 동시에 여러 개 수행시키면서 이들의 로드-밸런싱을 목적으로 사용할 수도 있다.

예를 들어 이미지 섬네일을 요청하는 작업을 한다고 생각해보자.

  • 섬네일을 만들어야 할 이미지는 수백/수천 개가 있다.
  • 최대한 빠르게 사용자에게 보여주고 싶지만, 한 번에 수백/수천 개의 요청을 처리하기에는 무리가 있다.
  • 3개 정도의 Worker를 만들어, 경쟁적으로 이미지 섬네일을 요청하도록 하자.

간단히 생각해보면 버퍼와 카운팅 변수 가지고 처리할 수도 있을 것 같다. 하지만 구현을 해보면 알겠지만 그리 깔끔하지 않다. 현재 동작하는 Worker가 몇 개인지, 버퍼에 추가 요청이 있는지 없는지 매번 확인하고 처리해야 한다. 혹시라도 조건 체크를 잘못하거나 카운팅을 잘못 계산한다면 수많은 버그를 유발할 수 있다.

그렇다고 걱정하지 말자. 우리는 채널을 통해 안전하고 더 멋지고 훌륭한 구현할 수 있다.

const NUMBER_OF_WORKERS = 3;

function* requestWatcher() {
  const chan = yield call(channel);

  for (let i = 0; i < NUMBER_OF_WORKERS; i += 1) {
    yield fork(requestWorker, chan);
  }

  while (true) {
    const { payload } = yield take(REQUEST_THUMBNAIL);
    yield put(chan, payload);
  }
}

function* requestWoker(chan) {
  while (true) {
    const payload = yield take(chan);

    // 서버에 섬네일 요청 및 응답 처리
  }
}

각 Worker는 독립적으로 수행될 것이다. 각각의 처리 속도는 중요하지 않다. 쉬고 있는 Worker들은 알아서 곧바로 다음 요청을 처리할 것이다. 또 우리는 Worker를 동적으로 생성하지 않아도 된다.

더 알아보기 - API (v0.16.0)

Reudx-Saga에서 제공하는 채널과 버퍼는 API문서에 잘 나타나 있다. 아래 설명은 0.16.0 버전을 기준으로 한다.

  • Channel Interface

    • channel: 채널 인터페이스를 구현한 팩토리 함수. 채널은 일반적인 메시지를 put, take 할 수 있고 버퍼를 적용할 수 있다.
    • eventChannel: 이벤트 채널 팩토리 함수. 이벤트 채널은 외부 이벤트와의 연결을 담당하도록 한다. Subscribe / Unsubscribe를 할 수 있고, 버퍼나 Matcher(=필터)를 적용할 수 있다.
  • 이펙트 - actionChannel: 액션 채널 이펙트. 액션 채널은 특정 액션만을 받는 채널로 제한하고, 버퍼를 적용할 수 있다.

  • Buffer Interface: 채널에 적용하는 버퍼 인터페이스로 Redux-Saga에서는 5가지 버퍼를 제공한다.

더 알아보기 - v1.0.0 채널 미리보기

아마도, 1.0.0 버전부터 채널은 multicast, unicast 채널 두 가지가 있을 것이다. multicast가 새롭게 추가된 채널이다.

  • multicast: 버퍼가 없고, Matcher를 처리할 수 있다. => 대기 중인 taker들은 모두 메시지를 받기 때문에 버퍼는 따로 필요하지 않다.
  • unicast: 버퍼가 있고, Matcher를 처리할 수 없다. 기존의 channel이나 eventChannel은 모두 Matcher가 사라진다.

그래서 사실 위에서 언급한 외부 이벤트 연결: WebSocket 글 내용 중 matcher를 처리하는 부분은 이제 사용하지 말아야 한다. multicast 채널의 Matcher나, 일반 eventChannel의 Subscribe 함수 내부에서 충분히 처리할 수 있다.

v1.0.0의 채널은 Redux-Saga#PR 824릴리스 노트에서 그 내용을 더 파악해볼 수 있다.

마치며

Redux-Saga의 채널은 외부 이벤트와의 연결 때문에 개발을 시작했다. 그리고 그 내용은 CSP의 채널과 매우 유사하다. 그런데도 Blocking-Put과 같은 동작은 공식적으로 지원하지는 않으려 한다. 그 이유로는 이 기능이 필요한 경우가 많지 않으며, 교착상태를 유발할 수 있다. 또한, 구현은 더 복잡해질 것이고, 사람들이 API를 이해하는데 난이도가 더 올라갈 수 있다는 것이다. 필자도 실제로 지금까지 Redux-Saga를 활용하여 앱을 개발하면서 Blocking-Put과 같은 기능은 필요하지 않았다. 개인적으로 꽤 타당한 결정이라는 생각이 들었다.

이번에 Redux-Saga의 채널을 정리하면서 CSP의 내용도 어느 정도 공부하게 됐고 스스로 많은 도움이 됐다. 여러분에게도 이 글이 조금이나마 도움이 되길 바란다.


이민규, FE Development Lab2018.03.16Back to list