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

sangho.moon·2021년 11월 17일
4

redux-saga

목록 보기
2/3

실제 서비스 개발에서 로그인 및 회원가입, 개인 정보 수정 기능은 빠질 수 없겠죠. 오늘은 그 중 휴대폰 인증 기능을 어떻게 redux-saga를 이용해서 개발했는지 다뤄보려고 합니다.

👉🏻 세부 로직을 Araboza

현재 개발 중인 서비스에서는 휴대폰 인증 기능을 세 군데에서 사용합니다.

이메일 회원가입 / 소셜 로그인(구글) / 휴대폰 번호 수정

이 세 가지 기능에서 필요한 기본적인 휴대폰 인증 로직은 다음과 같습니다.

쉽게 이해되는 수준의 순서도입니다. 번호 검사, 인증 번호 요청, 인증 번호 검사를 거치면 휴대폰 인증이 보통 완료됩니다.

그런데

과연 저 간단한 순서도대로 구현만 하면 될까요? 아마 그렇게 호락호락하지는 않을 겁니다. 유저의 행동 또는 환경에 따라 상황은 달라집니다.

  • 아차, 번호를 잘못 입력했네. 다시 해야지.
  • 한 눈 파느라 제 시간 내 번호 인증을 못했네. 다시 해야지.
  • 인증번호가 안 왔는데? 다시 눌러야지.

유저가 번호도 올바르게 한 번에 입력하고, 제 때 인증 번호도 잘 입력하고, 에러도 전혀 발생하지 않는 상황만 존재한다면 얼마나 행복할까요? 하지만 아래의 짤처럼, 우리는 유저가 커피를 마시는 일반적인 방식 뿐 아니라 다른 여러 방식도 고려하여 개발해야 합니다🤣

이러한 상황들을 모두 포함하려면 휴대폰 인증의 구현은 난이도가 다소 올라갑니다. 저희 서비스의 경우, 위의 상황들을 해결하기 위해

  • 과정 중에 번호를 다시 입력했을 시 처음 단계로 돌아간다.
  • 과정 중에 타이머가 만료되면 처음 단계로 돌아간다.
  • 추가로 1번 더 인증번호를 발송할 수 있도록 한다. (어뷰징 케이스를 막기 위해 연속으로는 2번까지 발송되도록 제한한다)

위 상황들을 고려하여 설계하였습니다. 세부 순서도를 다시 볼까요? (기존 순서도의 판단/처리 기호는 간소화하였습니다)

타이머가 만료(5분)되거나, 다른 번호로 재입력하는 경우, 다음 작업으로 예상했던 인증 번호 검사 대신 인증의 처음 단계로 돌아갑니다.
같은 번호로 재인증을 요청(최대 2회)하는 경우, 휴대폰 번호 중복 검사 과정을 제외하고 인증 번호 요청 단계부터 재돌입합니다.

자, 이제 휴대폰 인증 과정에서 발생하는 케이스들을 모두 파악했습니다. 코드를 뜯어봅시다.

🧑🏼‍💻 드디어 코드냐?

위에서 말씀드린 대로, 3군데에서 휴대폰 인증 기능을 사용합니다.

// 이메일 가입 saga
function* signUpWithEmail({ payload }) {
  ...
  yield call(verifyPhoneNumber, 'signUpWithEmail');
  ...
}
  
// 소셜 로그인(구글) 회원가입 saga
function* signUpWithGoogle({ payload }) {
  ...
  yield call(verifyPhoneNumber, 'signUpWithGoogle');
  ...
}

// 휴대폰 번호 수정 saga
function* updatePhoneNumber() {
  ...
  yield call(verifyPhoneNumber, 'updatePhoneNumber');
  ...
}

보시다시피 verifyPhoneNumber 함수에 휴대폰 인증 과정을 모두 담았으므로, 이를 각 saga에서 그대로 가져다 사용하는 것을 볼 수 있습니다. 해당 휴대폰 인증 함수 역시 saga이며, call 함수를 사용하여 서술적 호출을 함으로써 인증 태스크가 완료될 때까지 generator는 진행을 멈춥니다. async-await 의 형태처럼 말이죠. 자, 그럼 verifyPhoneNumber 를 확인해볼까요.

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 }));

      const { 
        expired, 
        resetPhoneNumber, 
        verified, 
        again
      } = yield race({
        expired: take("TIMER_EXPIRED"),
        resetPhoneNumber: take("SET_PHONE_INFO"),
        verified: take("CHECK_VERIFICATION_CODE_SUCCESS"),
        again: take("CHECK_PHONE_NUMBER_AND_REQUEST_START"),
      });

      if (!!expired) throw new Error(ERROR_MESSAGE.timerIsExpired);
      else if (!!resetPhoneNumber) throw new Error(ERROR_MESSAGE.phoneNumberIsChanged);
      else if (!!verified) break;
      else if (!!again) {
        const { 
          payload: { remainingCount }
        } = again;

        isCountRemained = !!remainingCount;
      }
    } catch (error) {
      yield put(checkPhoneNumberAndRequestFailure(error));
      
      isCountRemained = false;
      phoneNumber = '';
    }
  }
}

🤦‍♂️ ...너무 길어

좀 길죠..? 한 번에 보여드리려고 하다보니 좀 길어졌어요. 그래서 각 코드를 쪼개서 설명드리려고 하니, 아직은 나가지 말아주세요😂

1. 함수 구조

function* verifyPhoneNumber(timerTarget) {
  // 요청 가능한 횟수가 남았는지 여부
  let isCountRemained = false;
  // 휴대폰 번호
  let phoneNumber;
  
  // try-catch 반복
  while (true) {
    try {
      ...
    } catch {
      ...
    }
  }

가장 바깥쪽 코드를 보면 이렇게 생겼습니다. 매개변수로 받는 timerTarget타이머를 사용할 대상입니다. 타이머 카운트를 redux로 관리하고 있어 당장 타이머를 사용해야 하는 컴포넌트를 빼고는 카운트 값 때문에 리렌더링되지 않도록 target 값도 함께 관리하고 있어요.
isRemainedCount재인증 요청을 처리하기 위해 둔 flag이고, phoneNumber 는 변수 이름 그대로 휴대폰 번호 값 변수입니다.

잠깐, while (true) 무한 반복문은 뭐야 미쳤어?

오해마세요! generator 를 이해한다면 전혀 이상한 코드가 아닙니다😎

제너레이터는 완료를 향해 달려가는(run-to-completion) 함수가 아닙니다. 만든 제너레이터는 한 번 반복될 때마다 액션이 일어나기를 기다릴 거에요. 그럼 이제 안쪽을 조금씩 뜯어 볼까요? 어떻게 무한 루프를 실행시키는지 보세요.

2. while 문 안쪽

 ...
 try {
   // 재인증 요청이 아닐 때만 분기문 내부 실행
   if (!isCountRemained) {
     // [중복 검사 및 요청 "시작"] 액션이 dispatch 될 때까지 기다림(Pulling)
     const {
       payload: { phoneNumber: newPhoneNumber }
     } = yield take("CHECK_PHONE_NUMBER_AND_REQUEST_START");

     phoneNumber = newPhoneNumber;
     
     // 휴대폰 번호 중복 검사(API call)
     yield call(authService.checkPhoneNumberIsValid, phoneNumber);
   }

   // 인증 번호 요청(API call)
   yield call(authService.requestVerificationCode, phoneNumber);

   // [중복 검사 및 요청 "성공"] 액션 dispatch
   yield put(checkPhoneNumberAndRequestSuccess());

   // [타이머 시작] 액션 dispatch
   yield put(timerStart({ count: DEFAULT_TIMER_COUNT, timerTarget }));
   ...
 } catch (error) {
   ...
 }
 ...

내부 로직의 앞 부분입니다. 순서대로 설명드릴게요.

  1. [같은 번호로 재인증을 요청하는 경우] 번호 중복 검사를 생략해도 되므로 분기문을 탈출합니다.
  2. [이외의 경우] 분기문 내부를 실행하여 해당 액션이 dispatch될 때까지 기다립니다(take 함수의 기능이죠). 이후 새로 받은 번호를 함수 영역의 phoneNumber 변수에 할당하고, 중복 검사를 실행합니다.
  3. [공동] 인증 번호를 요청하는 API를 call합니다.
  4. [공동] 요청이 성공했음을 알리는 액션을 dispatch합니다. (해당 액션을 통해 redux 내부의 success flag 값을 변경하여, 그 값을 구독 중인 인증 번호 입력 input의 UI를 변경해줄 겁니다.)
  5. [공동] 타이머 시작을 알리는 액션을 dispatch해줍니다.
  6. [공동] 에러가 발생했다면 catch 문 내부로 들어가 error 핸들링을 하고 다시 처음부터 작업이 수행됩니다.

각 코드에 대한 설명을 드렸는데, 어떤가요. 이해가 이제 좀 되시나요? 위쪽 순서도에도 그려놓은 부분이니 어렵지 않을 겁니다.

자, 여기서 주목할만한 부분은 take 함수입니다. 여러분이 saga를 공부해보셨다면 takeEvery 함수는 자주 사용하실텐데요. 해당 함수는 액션을 감시해서 해당 액션을 매개변수로 받는 핸들러 태스크를 실행시킵니다. 액션을 태스크로 밀어넣는 PUSH 방식이라고 할 수 있겠네요. 이 방식은 콜백과 비슷하니 다소 익숙합니다. 다만, 매칭되는 액션이 들어올 때마다 계속 수행되고 감시를 언제까지 해야하는지에 대한 방법도 정해진 게 없습니다.

그러나, take는 실행되고 있는 태스크에서 필요한 액션을 감시하고 있다가 잡아채는 방식이에요. 특정 동작이 수행됨을 기다리고 있다가 수행 완료를 알리는 액션을 잡아채고 이후 필요한 동작을 처리할 수 있는거죠. saga 스스로가 액션들을 PULLING 하는 방식이라고 할 수 있습니다.

이 take 함수 덕분에 우리는 비동기적인 동작에 대한 플로우를 굉장히 동기적으로 컨트롤할 수 있게 됩니다.

const {
  payload: { phoneNumber: newPhoneNumber }
} = yield take("CHECK_PHONE_NUMBER_AND_REQUEST_START");

CHECK_PHONE_NUMBER_AND_REQUEST_START 액션이 dispatch 될 때까지 오매불망 기다린다는 겁니다. 이렇게 while 문 안쪽에 해당 함수로 코드를 작성해놓으니 무한 반복이 계속 비동기적으로 멈춰 실행되는거죠. 실패의 경우에도 계속 해당 태스크가 실행되도록 무한 반복문을 사용한 겁니다. 성공했을 경우에는 단순히 반복문을 break 걸어만 주면 되니까요. 성능 상의 문제 또한 없습니다.

위에서 말씀드렸던 것처럼 generator는 결과를 향해 달려가는 함수가 아닙니다. 중간중간 물도 마시고, 잠깐 앉아서 쉬어가는 함수에요 :)

😰 다음편 예고

와, 쓰다보니 진짜 길어졌어요. 한 편에 나머지 내용도 다 쓰면 정말 너무 길어져서 읽기 싫어지실까봐 다음 포스트에 나머지 내용을 다루겠습니다. 다음 페이지에서는

  1. race 헬퍼 함수를 사용하여 먼저 수행되는 작업을 캐치하기
  2. 각각의 케이스(타이머 만료, 휴대폰 번호 재입력, 재인증 요청 등)의 경우에 대한 핸들링

을 마저 설명드리겠습니다!
궁금하신 점 있으시면 언제나 환영이니 댓글 달아주세요😃

참고
redux-saga GitBook

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

1개의 댓글

comment-user-thumbnail
2021년 11월 22일

모두가 겪는 사가의 그 고통..

답글 달기