5 분 소요

Introduction

Redux-Saga는 Redux 애플리케이션에서 비동기 작업을 더 쉽게 관리할 수 있도록 도와주는 라이브러리다. Redux-SagaSaga라는 개념을 사용하여 비동기 흐름을 관리하며, 이를 통해 복잡한 비동기 작업의 흐름을 단순화하고 제어할 수 있다. Redux-Saga는 제네레이터 함수 (generator function)를 사용하여 작성되며, 비동기 작업을 동기적 코드처럼 관리할 수 있게 한다.

Saga

Saga는 애플리케이션에서 발생하는 Redux 액션을 가로채고, 그에 따라 비동기 작업을 수행하는 역할을 한다. Sagacall, put, take, fork, all 등 다양한 effect를 사용해 작업을 수행한다.

Effect

EffectRedux-Saga 에서 제네레이터 함수가 실행되는 비동기 작업의 일종이다. call, put, take, fork, all 등 다양한 종류의 effect 가 있다. Effect는 yield 키워드와 함께 사용되며, 이를 통해 비동기 작업을 순차적으로 실행하거나 병렬로 실행할 수 있다.

Middleware

Redux-SagaRedux의 미들웨어로 동작한다. Redux의 미들웨어로 설정된 SagaRedux 스토어에서 발생하는 액션을 가로채고, 필요한 비동기 작업을 수행하거나 특정 작업을 관리합니다.

오늘은 Sagaeffect 함수들과 제네레이터 함수에 대해서 실제 내가 사용한 코드를 통해 전체적으로 알아보겠다.

function* userSaga()

나는 유저에 대한 요청과 응답을 처리하는 미들웨어로 userSaga라는 Saga를 만들었다. 아래의 그림은 회원가입 요청의 플로우를 간략하게 설명해준다.

Saga

회원가입 요청 밖에도 로그인 요청, 로그아웃 요청들이 있을 수 있다. 이 요청들을 처리하는 Saga는 각각 만들어지고, 이 모든 Saga들을 한꺼번에 처리하는 최상위 SagauserSaga()가 될 것이다.

export default function* userSaga() {
    yield all([fork(watchRegister), fork(watchLogin), fork(watchLogout)])
}

위의 그림을 보면 클라이언트의 요청은 Saga가 받지만 (요청 액션을 “가로채지만”) 그 안에서도 watcher saga라는 Saga가 감지를 한다. 그래서 함수 이름도 watcherRegister, watcherLogin, watcherLogout이라고 지었다.

userSaga() 함수에서 알아볼 수 있는 건 세가지로 추려질 수 있다.

  1. function*: 제네레이터 함수
  2. all
  3. fork

이제 각각에 대해 알아보겠다.

function*, 제네레이터 함수

제네레이터 함수는 function* 문법을 사용하여 정의되며, yield 키워드를 사용해 함수 실행을 일시 중지하고 필요한 값들을 반환할 수 있다. 제네레이터 함수는 일반 함수와 다르게, 실행 중간에 일시 정지를 하고 다시 재개할 수 있는 특징을 가지고 있다.
다음은 아주 간단한 제네레이터 함수의 예시이다.

generatorFunction

이 함수에는 yield 키워드가 세번이 사용되는데, 이렇게 이해하면 된다.

제네레이터 함수가 호출이 되면 맨 처음에 Iterator 객체가 반환이 된다. Iterator객체는 다음과 같은 구조를 갖고 있다.

{ 
  next: function() {} // 제네레이터 함수의 실행을 다음 yield 지점까지 진행
  return: function() {} // 제네레이터 함수를 종료시키는 메서드
  throw: function() {} // 제네레이터 함수에 예외를 전달하는 메서드
}

Iterator 객체가 반환되면 Iterator 객체의 next() 메서드가 자동으로 호출되어 제네레이터 함수를 한 단계 실행한다. 따라서 위의 예시 그림에서는 yield 1이 실행이 되는 것이다. 이것이 완료될 때까지 함수가 중단이 되었다가, 결과가 나오면 next()가 다시 호출되고 다음 단계인 yield 2가 실행이 되는 것이다.

Saga가 제네레이터 함수를 사용하는 이유

  1. 제네레이터 함수는 yield를 통해 비동기 작업을 async/await 문법 없이도 수행할 수 있다. 특히 여러개의 비동기 작업을 처리할 때 더 장점이 부각되는데, 작업중인 비동기 작업이 끝이 날 때까지 함수가 자동적으로 일시 중지가 되었다가 완료가 되면 제네레이터 함수 내 Iteratornext() 함수가 자동으로 다음 단계를 실행해주기 때문에
  2. 코드의 가독성도 높아지고 간결해진다.
  3. 또한 동시에 여러 작업 관리을 가능하게 해준다. yield all([...]) 같은 effect 를 사용하면 여러 작업을 동시에 실행하고 모든 작업이 완료될 때까지 기다린다.
  4. 마지막으로 Saga는 리덕스의 미들웨어로 동작한다. 액션이 디스패치될 때 중간에서 이를 가로채어 비동기 작업을 처리하게 되는데, 이때 제네레이터 함수는 effect를 사용하여 비동기 작업의 흐름을 제어하고 yield 를 통해 미들웨어가 액션을 처리하는 순서를 세밀하게 조정할 수 있다.

all

all 배열로 주어진 여러 effect들을 병렬로 실행하는 effect이다.

fork

fork는 비동기적으로 새로운 Saga를 실행하는 effect이다. fork된 Saga는 부모 Saga와 병렬로 실행, 부모 Saga의 실행은 fork된 Saga의 완료를 기다리지 않는다.

이해하기 어려운 문장이 나왔다.

forkSagaeffect이기 때문에 Saga 없이는 쓸 수가 없다. 다시 말하자면 fork사용하는 Saga는 분명 존재한다는 것이다. 그리고 이 Saga부모 Saga 라고 정의내린 것이다. 그리고 fork 는 새로운 Saga를 실행한다고 했다. 따라서 forkSaga자식 Saga 라고 정의가 내려질 수 있을 것이다.

부모 Sagafork를 통해 다른 Saga를 실행시켰다면, 그 부모 Sagafork로 실행된 자식 Saga의 완료를 기다려주지 않는다. 따라서 위의 예시를 다시 보자면

export default function* userSaga() {
    yield all([fork(watchRegister), fork(watchLogin), fork(watchLogout)])
}

userSaga() 는 부모 Saga로서 그 밑의 자식 Saga인, watchRegister, watchRegister, watchRegisterfork로 실행을 하는데, 이때 첫번째 자식 SagawatchRegister의 완료를 기다렸다가 그 다음 Saga를 실행하는게 아니라, 세가지 Saga가 모두 병렬로 같이 실행된다는 뜻이다.

watchRegister()

이제 userSaga()가 실행하는 워쳐 Saga들 중 watchRegister에 대해서 알아보겠다. watchRegister는 다음과 같이 생겼다.

function* watchRegister() {
  yield takeLatest(REGISTER_REQUEST, register);
}

watchRegister 도 제네레이터 함수로서 하나의 함수를 yield 통해 실행시킨다. 이때 takeLatest라는 effect 가 사용되었다.

takeLatest

특정 액션이 디스패치 될 때 가장 마지막으로 디스패치된 액션만 처리하는 effect 이다. 위의 코드를 보면 REGISTER_REQUEST 가 회원가입 요청 액션인데, 이 요청 액션의 가장 최신 액션만 처리하는 effect라고 이해하면 된다. 그리고 takeLatest의 두번째 인자인 register가 실제로 요청을 처리하는 Worker Saga (워커 Saga) 이다.

register

워커 Sagaregister는 실제로 API요청을 처리한다.

// Register API
function registerAPI(data: RegisterRequestAction['data']) {
  return axios.post('/user', data);
}

// Register saga
function* register(action: RegisterRequestAction): SagaIterator {
  try {
    const response: any = yield call(registerAPI, action.data);
    yield put({
      type: REGISTER_SUCCESS,
      payload: response.data.message,
    });
  } catch (err: any) {
    yield put({
      type: REGISTER_FAILURE,
      error: err.response.data.message,
    });
  }
}

register가 실행되었을때 어떤 일들이 일어나는지 순서대로 정리를 해보겠다.

  1. Iterator 객체가 반환된다 (SagaIterator라고 반환 타입이 지정된것도 이 때문이다.). Iterator 객체가 반환되면 next() 메서드가 자동으로 호출되어 다음 yield까지 함수를 진행시킨다.
  2. yield call(registerAPI, action.data)가 실행이 된다. 여기서 registerAPI는 제네레이터 함수가 아닌 일반 함수로서 실제 axios call 이 실행된다. 그리고 이 함수가 완료될 때까지 register는 함수 실행을 일시 중지 시킨다.
  3. response가 나오게 되면 다시 next() 가 실행되고 그 다음 코드인 yield put() 이 실행된다.

call

2번에서 call effect가 실행되었다. call은 특정 함수를 호출하는 effect 이다. 해당 함수가 반환하는 Promise가 해결되기 전까지 Saga의 실행을 중단시킴. 주로 비동기 함수의 호출에 사용된다.

put

3번에서 put effect가 실행되었다. putRedux 액션을 dispatch 하는 effect 이다. 일반적으로 성공 또는 실패 상태를 디스패치 함. 위의 코드를 보면 API 호출이 성공일 때, GET_ALL_BOOKS_SUCCESS 액션을 디스패치하여 받은 데이터를 payload로 전달하게 된다.

Conclusion

오늘은 Sagaeffect들과 Saga를 정의하는 함수인 제네레이터 함수에 대해서 알아보았다. 오늘 다룬 내용을 정리해보겠다.

  1. 제네레이터 함수는 yield라는 키워드를 사용해 함수 실행을 일시 중지하고 필요한 값을 반환 한 다음 다시 재개할 수 있는 특징을 갖고 있다. 제네레이터 함수는 호출이 되면 Iterator라는 객체를 반환하는데, 이 객체 안에 next()라는 메서드가 있다. 이 메서드는 함수를 다음 yield까지 실행시켜준다.
  2. Saga는 제네레이터 함수를 사용한다. 제네레이터 함수의 특징은 Saga가 여러가지 비동기 작업을 동시에 처리/관리할 수 있게 해주고, 액션을 처리하는 순서를 세밀하게 조정할 수 있게 해준다.
  3. Sagafork를 통해 다른 새로운 Saga를 실행할 수 있다. 이때 fork를 통해 실행되는 Saga를 편의상 자식 Saga 라고 부르고 이 자식 Saga를 실행하는 Saga부모 Saga 라고 부른다. 부모 Sagafork를 통해 실행된 Saga의 완료를 기다리지 않는다. 이것이 call과의 차이점이라고 할 수 있다.

댓글남기기