redux-saga 활용 사례: 1. 휴대폰 인증 (2)

sangho.moon·2021년 11월 22일
2

redux-saga

목록 보기
3/3
post-thumbnail

redux-saga 활용 사례: 1. 휴대폰 인증 편과 이어지는 내용입니다.

🎥 Review

저번 포스트에서는 휴대폰 인증의 세부적인 로직에 대해 살펴보고, 인증 기능을 수행하는 saga인 verifyPhoneNumber 의 대략적인 코드를 보여드렸습니다. 가장 바깥쪽의 while 무한 루프문에 대한 설명부터 타이머 시작을 알리는 액션을 dispatch 하는 yield put(timerStart(...)) 문까지 살펴보았는데요.

function* verifyPhoneNumber(timerTarget) {
  let isCountRemained = false;
  let phoneNumber;
  
  while (true) {
    try {
      
      if (!isCountRemained) {
        const {
          payload: { phoneNumber: newPhoneNumber }
        } = yield take("CHECK_PHONE_NUMBER_AND_REQUEST_START");

        phoneNumber = newPhoneNumber;
        yield call(authService.checkPhoneNumberIsValid, phoneNumber);
      }

      yield call(authService.requestVerificationCode, phoneNumber);
      yield put(checkPhoneNumberAndRequestSuccess());
      yield put(timerStart({ count: DEFAULT_TIMER_COUNT, timerTarget }));
      
      // 여기까지!
      ...
    } catch (error) {
      yield put(checkPhoneNumberAndRequestFailure(error));
      
      isCountRemained = false;
      phoneNumber = '';
    }
  }
}

오늘은 그 이후 부분의 코드를 살펴볼 예정이에요. 저번 포스트에서도 다뤘듯이, 다음과 같은 추가적인 상황의 핸들링을 위한 부분입니다.

  • 인증 과정 중에 유저가 새로운 번호를 다시 입력한다.
  • 인증 과정 중에 타이머가 만료된다
  • 인증 과정 중에 유저가 다시 인증번호 발송 버튼을 클릭한다.

🏇 race()를 이용하여 우승자 정하기

우리는 위에 나열한 상황 중 어떤 일이 먼저 발생하는지 사전에 확실하게 알 수 있을까요?

"타이머가 만료되는 일은 반드시 다른 상황보다도 먼저 발생해" 라고 확신할 수 있을까요?
아마 아닐 겁니다. 저 상황들은 서로에게 어떤 영향을 주지 않으며, 서로 독립적으로 발생하는 사건입니다. 따라서 어떤 사건이 먼저 일어날지도 정해져 있지 않습니다.

우리는 저 중 1등으로 발생하는 사건만 기억해주면 됩니다. 어떤 사건이던 가장 먼저 발생하는 것을 처리하면 인증 과정을 앞 단계부터 다시 진행하므로, 이후에 발생하는 사건들은 해당 과정에서는 필요가 없어지거든요.

그렇다면 저 사건들 중 가장 먼저 일어나는 것을 어떻게 알 수 있을까요? 바로 race() 를 이용하는 것입니다😎

// 사전 작업(번호 중복 검사, 인증번호 발송, 타이머 시작 액션 처리)
...

// 네 가지 사건의 race 처리
const { expired, verified, resetPhoneNumber } = yield race({
  // [타이머 만료] 액션을 기다림
  expired: take("TIMER_EXPIRED"),
  // [인증 코드 확인 성공] 액션을 기다림
  verified: take("CHECK_VERIFICATION_CODE_SUCCESS"),
  // [새로운 번호 입력] 액션을 기다림
  resetPhoneNumber: take("SET_PHONE_INFO"),
  // [중복 검사 및 요청 시작] 액션(전송 버튼 재클릭) 을 기다림
  reClick: take("CHECK_PHONE_NUMBER_AND_REQUEST_CODE_START"),
});

// 이후 각 액션 객체들 handling
...

race 함수에 인자로 전달할 객체의 각 value 값으로 saga effect들을 작성하였습니다. 저는 take 를 사용하였는데요. 저번 포스트에서 해당 액션이 발생할 때까지 기다렸다가 그 액션을 끌고 오는 함수 라고 말씀드렸었죠! 저는 take 함수만을 사용했지만, 다른 이펙트(call, delay, ...)들도 사용가능합니다.

이제 race는 저 4가지의 take 이펙트 중 가장 먼저 액션을 풀링해오는 이펙트만 처리해줄겁니다. 예를 들어 타이머가 만료되었음을 알리는 TIMER_EXPIRED 액션이 먼저 디스패치된다면, 해당 액션 객체({ type: TIMER_EXPIRED })를 받아 expired 키 변수에 할당하고 나머지 이펙트들은 자동으로 취소시킵니다. 자연스럽게 다른 키 변수들의 값은 undefined가 됩니다.

이제 왜 이 함수의 이름이 race 인지 이해가 가시나요? 여러 이펙트들을 경주시키고 경주에서 진 태스크들은 취소시키는 기능을 하기 때문입니다. 함수 이름이 직관적으로 이해가 되어 마음에 드네요 🤠

Q. saga가 기다리고 있는 액션들은 어디서 dispatch 되는 건가요?

참고로 각 액션들은 컴포넌트, 커스텀 훅, 다른 saga 등에서 디스패치됩니다. 예를 들어 "TIMER_EXPIRED" 액션의 경우, 타이머 기능을 구현한 useTimer 라는 커스텀 훅에서 count가 0이 되는 경우에 해당 액션을 디스패치합니다. 아래처럼요.

export const useTimer = (target: TimerTarget): [number, (count?: number) => void] => {
  ...

  const expireTimer = () => {
    dispatch({ type: "TIMER_EXPIRED" });
  };

  ...

  // count state를 useEffect에서 hooking
  // count가 0이 될 경우, TIMER_EXPIRED 액션 dispatch 및 타이머 Interval 해제
  useEffect(() => {
    if (count === 0) {
      expireTimer();
      clearInterval(timerId.current);
    }
  }, [count]);

  ...
};

위 코드와 같이 타이머 기능은 재사용성을 위해서 커스텀 훅으로 구현되었습니다. 휴대폰 인증 로직과는 별개로, 타이머 기능은 독립적으로 사용이 가능합니다. 휴대폰 인증 saga는 타이머 훅이 액션을 디스패치하는 것을 기다리다가 끌어와 자신의 로직을 계속해서 진행할 뿐이에요. 휴대폰 인증의 어떤 로직도 타이머 기능에 영향을 주진 않습니다.

저는 이처럼 각 기능은 독립적으로 수행하게 하고, 특정 작업의 수행 여부를 액션으로 알리는 패턴을 자주 활용합니다. 특정 태스크가 선행되어야 하는 비동기 작업을 기능 간의 의존성을 줄이면서도 쉽게 구현이 가능합니다. saga 덕분이죠🙌

🦾 각 액션 핸들링하기

이제 우리는 휴대폰 인증 중에 발생하는 상황에 따른 액션들을 처리할 수 있게 되었습니다. 가장 먼저 발생하는 우승자 액션 만이 우리의 관심 대상입니다. 각 액션 핸들링을 어떻게 하는지 볼까요?

// 사전 작업(번호 중복 검사, 인증번호 발송, 타이머 시작 액션 처리)
...

// 네 가지 사건의 race 처리
const { expired, verified, resetPhoneNumber } = yield race({
  ...
});

// race를 통해 경주에서 이긴 태스크의 액션만이 값이 존재(나머지는 undefined)
if (!!expired) {
  // [타이머 만료] 액션 값이 존재하는 경우
  throw new Error(AUTH_ERROR.timerIsExpired);
} else if (!!verified) {
  // [인증 코드 확인 성공] 액션 값이 존재하는 경우
  break;
} else if (!!resetPhoneNumber) {
  // [새로운 번호 입력] 액션 값이 존재하는 경우
  throw new Error(AUTH_ERROR.phoneNumberIsChanged);
} else if (!!again) {
  // [중복 검사 및 요청 시작] 액션 값이 존재하는 경우
  const {
    payload: { remainingCount },
  } = again;

  isCountRemained = !!remainingCount;
}
...

상황 별로 살펴보겠습니다.

1. 에러 처리: expired 또는 resetPhoneNumber

expired (타이머가 만료되는 경우) 또는 resetPhoneNumber (새로운 휴대폰 번호를 입력하는 경우) 값이 존재하는 경우에는 에러를 throw 하여 catch 문으로 들어가게 됩니다. 아래는 에러 핸들링 코드입니다.

...
while (true) {
  try {
    // 앞에서 다룬 인증 로직
    ...
    
    if (!!expired) {
      throw new Error(AUTH_ERROR.timerIsExpired);
    }
    ...
    else if (!!resetPhoneNumber) {
      throw new Error(AUTH_ERROR.phoneNumberIsChanged);
    } ...

  } catch (error) {
    // [중복 검사 및 요청 실패] 액션을 에러 객체와 함께 dispatch
    yield put({ 
      type: "CHECK_PHONE_NUMBER_AND_REQUEST_CODE_FAILURE"(error)
      payload: error 
    });

    // 재전송 가능 여부 초기화
    isCountRemained = false;
    phoneNumber = '';
  }
}

먼저 해당 작업 실패를 알리는 액션을 디스패치합니다. 이후 리듀서에서 해당 작업의 성공 여부 상태 값을 false 로 업데이트합니다. 보시다시피 액션 디스패치 시에payload 로 error 객체도 함께 전달하는데요, 아까 보았듯 에러 throw 시에 해당 상황에 대한 에러 코드도 함께 전달하였습니다. 따라서 성공 여부 값과 에러 코드 값을 구독 중인 컴포넌트에서 아래와 같이 상황에 맞는 에러 메시지를 보여줄 수 있게 됩니다.

그 다음으로 재전송 가능 여부 초기화를 해주는데요, 개발 중인 서비스의 경우 어뷰징을 막기 위해 같은 번호의 경우 5분 내에는 추가로 1회까지만 재전송이 가능하게끔 처리해주고 있습니다. 타이머 만료나 새로운 번호 입력의 경우에는 다시 인증 번호 요청을 할 수 있는 상황이므로 관련된 변수들의 초기화만 시켜줍니다.

이후 catch 문 실행이 완료되면 다시 바깥쪽 while (true) 문에 의해 처음부터 인증 과정이 다시 시작됩니다.

2. 인증 완료: verified

else if (!!verified) {
  // [인증 코드 확인 성공] 액션 값이 존재하는 경우
  break;
}

verified 값이 존재하는 경우는 [인증 코드 확인 성공] 액션이 가장 먼저 디스패치된 경우겠죠. 해당 경우에는 break 로 반복문을 중단시켜줍니다. 자연스럽게 휴대폰 번호 인증 saga는 실행이 완료되고, verifyPhoneNumber saga를 사용하는 각 상위 태스크에서는 다음 작업을 처리할 거에요.

function* signUpWithEmail({ payload }) {
  ...
  yield call(verifyPhoneNumber, 'signUpWithEmail');
  
  // 다음 작업 실행
  ...
}

3. 재전송 요청: again

else if (!!again) {
  // [중복 검사 및 요청 시작] 액션 값이 존재하는 경우
  const {
    payload: { remainingCount },
  } = again;

  isCountRemained = !!remainingCount;
}

again 값이 존재하는 경우는 전송 버튼을 다시 클릭하여 [중복 검사 및 요청 시작] 액션이 디스패치되는 경우입니다. 액션의 payload로 전달된 remainingCount 값으로 재전송 가능 여부를 갱신합니다. 반복문을 중단시켜주거나 하지 않으므로 다시 처음으로 돌아가 인증 과정을 수행하게 됩니다.

...
while (true) {
  try {
    if (!isCountRemained) {
      const {
        payload: { phoneNumber: newPhoneNumber }
      } = yield take("CHECK_PHONE_NUMBER_AND_REQUEST_START");

      phoneNumber = newPhoneNumber;
      yield call(authService.checkPhoneNumberIsValid, phoneNumber);
    }
    
    ...
  }

여기서 주목할 점은 반복문 제일 앞부분인데요. isCountRemained 값이 false 이면 액션을 기다리는 작업과 번호 중복 검사를 생략하게 됩니다. 이미 같은 번호로 재전송 요청을 받은 후이니, 전송 요청 액션을 또 다시 기다릴 필요가 없겠죠. 또한 처음 요청 시 이미 번호 중복 검사를 한 번 진행했으니 굳이 다시할 필요가 없는 것입니다.

이후 과정은 똑같습니다. 인증 번호 요청을 하고, 타이머를 시작하고, 가장 먼저 디스패치되는 액션들을 기다리며 각 상황별 핸들링을 해주는 작업이 진행될 겁니다 :)

마치며,

이전에 작성한 포스트(휴대폰 인증 1편) 에서 보여드린 전체 코드를 다시 한 번 살펴보세요. 이제 전체 흐름이 이해가 되시나요? 최대한 세분화하여 설명드리긴 했으나, 이해가 어려우신 분들은 댓글을 달아주시면 더 설명드리겠습니다🙂

이번 휴대폰 인증 기능에서는 take, race 를 주로 이용하였습니다. 특정 액션을 기다리고, 액션들을 경쟁시켜 상황에 따라 반복적으로 수행될 수 있도록 구현해보았습니다.

다음 포스트에서는 firebase realtime DB를 이용하여 구현한 채팅 기능에서 redux-saga를 활용한 경험을 공유합니다.

👉 발생하는 데이터의 추가/변경 이벤트(외부 이벤트)를 어떻게 saga에서 끌어와서 처리하는지
👉 첨부파일 전송을 취소하고 싶을 때 실행 중인 saga를 어떻게 취소하는지

와 같은 내용들을 다룰 수 있을 것 같습니다. 그럼 다음 포스트에서 찾아 뵙겠습니다 :)

profile
개발자와 디제이 두 개의 자아를 실현 중인 프론트엔드 개발자입니다.

0개의 댓글