다짐) React Native에서 로그인 가딩으로 사용성 극대화하기 (with Promise)

2ast·2023년 8월 17일
2
post-custom-banner

로그인 가딩이 필요한 이유

다짐은 기본적으로 로그인이 없어도 앱의 대다수 컨텐츠에 접근 가능한 형태의 서비스다. 따라서 아무런 제약 없이 앱을 이용하다가, 결제, 상담, 찜 등 로그인이 필요한 기능을 사용하려고 할 때 로그인을 요청하게 된다. 즉, 로그인 여부와 관계 없이 모든 사용자에게 '결제하기' 버튼은 노출되고 있으며, 버튼을 눌렀을 때 로그인 상태라면 결제 화면으로, 로그아웃 상태라면 로그인 화면으로 이동시키는 작업이 필요하다. 바로 이 절차를 나는 '로그인 가딩'이라고 부른다. 물론 이 작업을 매번 필요할 때마다 직접 구현해도 무방하지만, 코드 재사용성을 고려해서 useLoginGuard라는 커스텀 훅을 만들어 사용하고 있다.

useLoginGuard의 기본적인 형태

import {useUserInfo} from '~/store/user-info.store';
import useDgNavigation from '~/hooks/services/useDgNavigation';
import {useCallback} from 'react';
import {SCREEN_NAMES} from '~/constants/screen-names';

const useLoginGuard = () => {
  const isLoggedIn = useUserInfo(state => state.isLoggedIn); //zustand global state
  const {navigation} = useDgNavigation();


  const goToSign = useCallback(() => {
    navigation.navigate(SCREEN_NAMES.SIGN_STACK);
  }, [navigation]);

  return useCallback(
    (callback: (...args: any) => void) => (isLoggedIn ? callback : goToSign),
    [isLoggedIn, goToSign],
  );
};
export default useLoginGuard;

useLoginGuard은 하나의 함수를 return하고 있다. 이 함수는 callback을 arg로 받고, 만약 로그인이 된 상태라면 callback을 그대로 return하지만, 로그아웃 상태라면 로그인 화면으로 이동하는 함수를 반환해준다.


const CartScreen = () =>{
	
	const loginGuard = useLoginGuard()

	const goToPayment = ()=>{
    	//결제하기 화면으로 이동
    }
    
    return <Container>
      <PaymentButton onPress={loginGuard(goToPayment)}/>
    </Container>
    
}

실제 코드에서는 위와 같이 사용할 수 있다. 만약 현재 로그인이 된 상태라면 버튼을 눌렀을 때 goToPayment 함수가 실행될 것이고, 로그인이 되어있지 않다면 useLogInGuard 훅 안쪽에서 정의한 goToSign 함수가 실행되어 로그인 화면으로 넘어가게 될 것이다.

로그인 성공 후 본래 플로우 복원하기

여기까지만 해도 기능상 큰 문제 없이 로그인 가딩을 구현할 수 있지만, 사용성 측면에서 조금 아쉬운 부분이 있다. loginGuard함수는 1회성으로 해당 시점에 로그인 여부만을 판단해서 함수를 리턴해준 뒤 더는 이 플로우에 관여하고 있지 않다. 즉, 로그인하지 않은 사용자가 결제하기 버튼을 눌러 로그인 화면으로 이동한 뒤 로그인에 성공한다고 하더라도 다시 원래 있던 페이지로 돌아올 뿐, 본래 하려고 했던 플로우(결제하기)가 이어서 진행되지는 않기 때문에 사용자가 결제하기 버튼을 다시 눌러야만 한다. 만약 본래 하려고했던 행동(callback)을 기억하고 있다가 로그인이 완료됐을 때 이를 감지하고 자동적으로 callback을 실행시켜주면 사용성과 전환률에 긍정적인 영향을 줄 수 있지 않을까? 이번에는 이처럼 정상플로우에서 벗어나 로그인 화면으로 넘어갔을 때 로그인 완료 이후 다시 정상 플로우로 복귀시키는 작업을 진행해볼 것이다.

Promise와 deferred

본격적으로 구현하기에 앞서 Promise에 대한 기본적인 이해가 선행되어야한다. 만약 Promise를 처음 들어 본다면 개념을 대충이라도 익히고 오는 걸 추천한다. Promise는 js에서 비동기를 다룰 때 사용되는데, 이 Promise를 이용해 goToSign 함수를 비동기함수로 바꾸고, 로그인 성공 여부를 return하도록 해줄 것이다.

아래는 deferred라는 util 함수의 구현 코드를 나타낸 것이다. deferred를 사용하면 Promise를 사용하기 쉬운 형태로 변환할 수 있기 때문에 deferred를 이용해 loginGuard를 구현해보려고 한다.

export interface Deferred<T> {
  get state(): 'pending' | 'fulfilled' | 'rejected';
  promise: Promise<T>;
  resolve: (value?: T | PromiseLike<T>) => void;
  reject: (reason?: unknown) => void;
}

export const deferred = <T>(): Deferred<T> => {
  let state = 'pending';
  let resolve!: (value: T | PromiseLike<T>) => void;
  let reject!: (reason?: unknown) => void;

  const promise = new Promise<T>((res, rej) => {
    resolve = (value: T | PromiseLike<T>) => {
      state = 'fulfilled';
      res(value);
    };
    reject = (reason?: unknown) => {
      state = 'rejected';
      rej(reason);
    };
  });

  return {
    get state() {
      return state;
    },
    promise,
    resolve,
    reject,
  } as Deferred<T>;
};

구현 컨셉

전체적인 작업 내용을 정리하자면 우선 deferred 함수가 return하는 Deferred 객체를 전역상태로 만들어 어디서든 접근할 수 있도록 만들어 준다. 그 후 goToSign을 promise를 리턴하는 비동기 함수로 만들어주고 로그인이 완료된 시점에 Deferred를 resolve해주면 goToSign 함수를 통해 로그인 여부를 판단할 수 있게 된다. 이후 조건문으로 callback을 실행해주면 된다.

여담으로 여기서 굳이 Deferred 객체를 navigate params로 넘기지 않고 전역상태로 만드는 이유는, 로그인 완료 이벤트가 발생하는 스크린과 훅, 함수가 여러개 존재하기 때문에, 각각의 위치까지 props drilling이 발생할 수밖에 없는 상황을 우회하기 위함이다. 만약 스크린의 구조가 단순하다면 개인의 판단하에 전역상태를 사용하지 않아도 무방하다.

Deferred를 전역상태로 관리하기

다짐은 전역 상태 관리 라이브러리로 zustand를 채택하고 있기 때문에 zustand store를 만들어 주었다. zustand외에 redux, recoil, context API 등등 어떤 것을 써도 무방하다.

import {create} from 'zustand';
import {Deferred} from '~/utils/deferred';

interface SignDeferredState {
  signDeferred: Deferred<'complete' | 'cancel'> | null;
  setSignDeferred: (
    signDeferred: Deferred<'complete' | 'cancel'> | null,
  ) => void;
  resolveSignDeferred: (result: 'complete' | 'cancel') => void;
}

export const useSignDeferred = create<SignDeferredState>(set => ({
  signDeferred: null,
  setSignDeferred: signDeferred =>
    set(() => {
      return {signDeferred};
    }),
  resolveSignDeferred: result =>
    set(state => {
      if (state.signDeferred) {
        state.signDeferred.resolve(result);
      }
      return {signDeferred: null};
    }),
}));

signDeferred와 setSignDeferred만으로 기능 구현은 가능하지만 편의를 위해 resolveSignDeferred를 구현해서 추가해주었다. resolveSignDeferred는 signDeferred를 reolve한뒤 null로 초기화해주는 역할을 수행한다.

로그인 성공 시 complete로 resolve하기

 const onEmailSignInPress = () => {
    loginMutation({id, password})
      .then(data => {
		...
        resolveSignDeferred('complete');
      })
    
  };

이메일 로그인을 눌러 로그인을 성공했을 때 signDeferred를 'complete'로 resolve하는 코드를 간략하게 작성해보았다. 실제 앱을 구현하다보면 로그인 성공 케이스가 이메일 로그인뿐만 아니라 소셜 로그인, 회원가입 후 로그인 등 여러가지 케이스로 나뉘어진다는 것을 알 수 있다. 이처럼 다양한 케이스를 찾아 모두 resolveSignDeferred('complete')를 실행해주면 된다.

로그인 실패 시 'cancel'로 resolve하기

로그인 성공 케이스와는 다르게 로그인 실패 케이스는 매우 다양하기 때문에 각각의 시점을 모두 찾아내기란 불가능하다고 판단했다. 때문에 SignStackNavigator의 useEffect를 활용하기로 했다. 다짐은 로그인, 회원가입, 아이디/비밀번호 찾기 등 sign관련 기능을 모두 하나의 stack navigator로 관리하고 있다. 즉, SignStackNavigator가 unmount 되는 시점에 signDeferred를 'cancel'로 resolve 해주기만 하면 모든 문제를 해결할 수 있다. 만약 로그인을 성공했다면, 이미 signDeferred는 complete로 resolve된 뒤 null로 초기화 됐으므로 무시될 것이며, complete로 resolve 되기 전에 sign stack이 unmount됐다는 건 로그인에 실패했거나 취소했다는 의미이기 때문이다.

const SignStackNavigator = () => {
  ...

  const resolveSignDeferred = useSignDeferred(
    state => state.resolveSignDeferred,
  );
  
  useEffect(() => {
    return () => resolveSignDeferred('cancel');
  }, []);
  
  ...
}

useLoginGuard에 deferred 반영하기

import {useUserInfo} from '~/store/user-info.store';
import useDgNavigation from '~/hooks/services/useDgNavigation';
import {useCallback} from 'react';
import {SCREEN_NAMES} from '~/constants/screen-names';
import {deferred} from '~/utils/deferred';
import {useSignDeferred} from '~/store/sign-deferred.store';

const useLoginGuard = () => {
  const isLoggedIn = useUserInfo(state => state.isLoggedIn);
  const {navigation} = useDgNavigation();
  const setSignDeferred = useSignDeferred(state => state.setSignDeferred);

  const goToSign = useCallback(async () => {
    const d = deferred<'complete' | 'cancel'>();
    setSignDeferred(d);
    navigation.navigate(SCREEN_NAMES.SIGN_STACK);
    return d.promise;
  }, [navigation, setSignDeferred]);

  return useCallback(
    (callback: (...args: any) => void) => {
      if (isLoggedIn) {
        return callback;
      } else {
        return async (...args:any) => {
          const result = await goToSign();
          if (result === 'complete') {
            setTimeout(() => callback(...args), 200);
          } else {
            console.log('사용자가 로그인을 취소했습니다.');
          }
        };
      }
    },
    [isLoggedIn, goToSign],
  );
};
export default useLoginGuard;

deferred를 반영하여 새롭게 구현한 useLoginGuard의 코드다. goToSign함수를 보면 반환받은 Deferred 객체를 전역상태로 셋팅하고, d.promise를 리턴하여 Deferred의 state를 추적할 수 있도록 했다. 그 후 return 부분을 보면 로그인 결과를 result라는 변수에 할당하고 있고, 만약 result가 complete라면 callback을, 그렇지 않다면 console.log를 실행하고 있다. 여기서 callback을 setTimeout으로 감싼 이유는 로그인 성공 이후 유저 정보 셋팅이나 네비게이팅 중 혹시 있을지 모르는 충돌을 방지하기 위한 노파심 때문이다.

결과 확인해보기

이제 실제로 잘 동작하는지 테스트해볼 차례다.

다짐 트레이너 '바로상담' 기능은 로그아웃 상태에서는 접근할 수 없기 때문에 로그인 화면으로 리다이렉트 되는 모습이다. 그 후 로그인 성공 시 원래 하려고 했던 동작인 '바로상담' 기능이 자동으로 실행되면서 본래 플로우로 잘 복귀하고 있는 것을 확인할 수 있다.

profile
React-Native 개발블로그
post-custom-banner

0개의 댓글