ios에서 react native firebase getToken 핸들링하기

고병찬·2024년 10월 19일

TIL

목록 보기
42/54

들어가며

현재 우리 서비스 OneStep에선 여러 기기간 동기화 기능을 FCM을 통해 구현하고 있다. 따라서 멀티 디바이스 유저의 각각 기기를 식별하고 FCM을 통해 기기간 동기화를 제공하기 위해서 FCM에서 FCM 토큰을 사용한다. 그래서 이 FCM 토큰을 안드로이드에선 앱이 실행되고 로그인 화면이 로드될 때 바로 받아왔는데 그런데 ios에서는 FCM 토큰을 받아오는 getToken 함수를 실행하기 전에 무조건 사용자에게 알림 허용 동의를 받아야 한다는 것...


대략 이런 흐름을 타고 가서 로그인 화면이 로드되서 컴포넌트 내부 코드들이 로드될 때 바로 문제가 생기는 건데
고민 포인트
1. 이걸 원래 흐름을 살리면서 해결하려고 하면 사용자는 앱에 들어오자마자 알림을 허용할 건지 응답해야 하는게 좀 마음에 걸린다.
2. getToken을 하고 이때 사용자가 알림 허용을 거부할 수 있는데 이때는 알림 허용을 거부할 시 동기화 기능을 이용할 수 없다고 경고하는 로직으로 넘어가려고 한다. 이걸 어느 지점에서 처리할 것인가
3. 알림 허용을 물어보는 다이아로그를 띄우는 코드와 getToken 함수가 같은 스코프에 있어야 하나? 그건 상관없나

앱에 들어오자마자 알림을 허용할 건지 물어봐도 될까

방금까지 쓰던 내용을 gpt한테 주고 gpt가 더 잘 응답할 수 있는 프롬프트로 개선해달라고 했다.
중간에 역할 지정같은 특정 프롬프팅 방식을 적용해달라고도 하니까 나름 잘 만들어주어서 o1-preview에게 해당 프롬프트를 전달하니까 나름 만족스런 답을 얻었다. 링크

일부만 가져와보면

초기 앱 실행 시 요청 피하기

문제점

앱을 처음 실행하자마자 알림 권한을 요청하면, 사용자는 앱의 가치나 필요성을 느끼기 전에 거부할 가능성이 높습니다.

해결책

  • 초기 권한 요청 지양: 초기에는 권한 요청을 피하고, 사용자가 앱에 익숙해질 시간을 줍니다.
  • 로그인 후 또는 주요 기능 사용 시점에 요청:
    • 로그인 직후: 사용자가 로그인 과정을 완료하면, 앱의 주요 기능을 사용하기 전에 권한을 요청합니다.
    • 동기화 기능 사용 시점: 사용자가 동기화 기능을 처음 시도하거나 설정하는 시점에 권한을 요청하면 자연스럽습니다.

사용자 행동에 따른 요청

  • 특정 행동에 따른 트리거: 사용자의 특정 행동(예: 설정 메뉴 접근, 동기화 버튼 클릭)에 따라 권한 요청을 트리거합니다.

사전 안내 메시지 제공

  • 커스텀 프롬프트: 시스템 권한 요청 전에 사용자에게 권한이 필요한 이유를 설명하는 화면을 보여줍니다.
    • 예: "원활한 기기 간 동기화를 위해 알림 권한이 필요합니다."
  • 혜택 강조: 알림 권한을 허용하면 어떤 이점이 있는지 구체적으로 알려줍니다.

권한 요청 타이밍 최적화

  • 사용자 참여 유도: 사용자가 앱의 가치를 충분히 느낄 때 권한을 요청하면 허용률이 높아집니다.
  • 중요하지 않은 순간 피하기: 앱 로딩 중이나 사용자에게 중요하지 않은 순간에는 요청을 피합니다.

정리하자면

확실히 들어오자마자 알림 허용을 물어보면 바로 무시할 것 같다. 그래서 정리하면
1. FCM Token을 받는 로직을 최대한 미뤄야 할 것. -> 구글 웹뷰에서 리디렉션된 후 메인 투두 화면으로 넘어 가기 전 퍼널 구간을 만들면 되지 않을까
2. 퍼널 구간에서 알림 허용을 하면 동기화 기능을 이용할 수 있다는 혜택 강조하기
3. 동시에 우리 서비스의 핵심 기능 어필해서 이탈율 줄이기 일석이조

동기화 기능을 이용할 수 없다고 경고하는 로직은 어디에 넣을까

위와 같은 생각을 하고보니까 퍼널에서 알림 허용을 물으면서 사용자가 거부를 눌렀을 때 경고를 하면 될 것.
그렇다면 퍼널 로직을 현재 로그인 과정 중간에 끼워 넣어야 한다.

현재 로그인 로직은


대략 이런 흐름이다.

여기서 에러가 발생하는 getToken 함수 호출을 최대한 미뤄야 한다. FCM Token이 필요한 시점은(코드에서 변수명은 deviceToken이 FCM Token이다) 우리 api 서버로 로그인 요청을 할 때인 Api.googleLogin인데 이 요청이 성공하면 메인 투두 화면으로 이동한다.
그렇다면 대략 도표 상 handleGoogleLoginToken 이전에 퍼널이 들어가고 그 과정에서 알림 허용을 받고 우리 앱 기능을 홍보한 뒤 handleGoogleLoginToken 로직을 이어가면 될 것

간단하게 구현해보기

근데 이건 오로지 내 생각이니까 간단한 PoC 정도만 만들어보고 팀원들과 얘기해볼 것.
이를 위해 현재 브랜치에서 로그인 로직 수정하고 퍼널 간단하게 넣어보기

gpt랑 얘기하면서 짜보았다.

// useFunnel.js

import React, { useState, isValidElement } from 'react';

export function useFunnel(initialStep) {
  const [stepHistory, setStepHistory] = useState([initialStep]);

  const currentStep = stepHistory[stepHistory.length - 1];

  const setStep = nextStep => {
    setStepHistory([...stepHistory, nextStep]);
  };

  const goBack = () => {
    if (stepHistory.length > 1) {
      setStepHistory(stepHistory.slice(0, -1));
    }
  };

  const Step = ({ name, children }) => {
    return <>{children}</>;
  };

  const Funnel = ({ children }) => {
    const currentChild = React.Children.toArray(children).find(
      child => isValidElement(child) && child.props.name === currentStep,
    );
    return currentChild || null;
  };

  Funnel.Step = Step;

  return { Funnel, setStep, goBack, currentStep };
}
// FunnelScreen.js

import React from 'react';
import { StyleSheet, Alert, Platform } from 'react-native';
import { useFunnel } from '../hooks/funnel/useFunnel';
import {
  Layout,
  Text,
  Select,
  SelectItem,
  Button,
  TopNavigation,
  TopNavigationAction,
  Icon,
} from '@ui-kitten/components';
import * as Animatable from 'react-native-animatable';
import messaging from '@react-native-firebase/messaging';
import { useLocalSearchParams, useRouter } from 'expo-router';
import useGoogleAuth from '@/hooks/auth/useGoogleAuth';

const FunnelScreen = () => {
  const { Funnel, setStep, goBack, currentStep } = useFunnel('Step1');
  const params = useLocalSearchParams();
  const { token } = params;

  const { handleLogin } = useGoogleAuth();
  const route = useRouter();

  // 직업과 나이 옵션 데이터
  const jobOptions = ['개발자', '디자이너', '기획자', '마케터', '기타'];
  const ageOptions = ['10대', '20대', '30대', '40대', '50대 이상'];

  // 상태 관리
  const [selectedJobIndex, setSelectedJobIndex] = React.useState(null);
  const [selectedAgeIndex, setSelectedAgeIndex] = React.useState(null);

  // 알림 권한 요청 함수
  const requestNotificationPermission = async () => {
    try {
      const authStatus = await messaging().hasPermission();

      const enabled =
        authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
        authStatus === messaging.AuthorizationStatus.PROVISIONAL;

      if (enabled) {
        // 권한이 이미 허용됨
        return true;
      } else {
        if (Platform.OS === 'ios') {
          // iOS에서 권한 요청
          const authStatus = await messaging().requestPermission();
          const enabled =
            authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
            authStatus === messaging.AuthorizationStatus.PROVISIONAL;
          return enabled;
        } else if (Platform.OS === 'android') {
          // Android에서 버전 확인
          const androidVersion = Platform.Version;
          if (androidVersion >= 33) {
            // Android 13 이상에서 권한 요청
            const result = await messaging().requestPermission();
            const enabled = result === messaging.AuthorizationStatus.AUTHORIZED;
            return enabled;
          } else {
            // Android 13 미만은 권한 요청 불필요
            return true;
          }
        } else {
          // 기타 플랫폼 (web 등)
          return false;
        }
      }
    } catch (error) {
      console.error('Notification permission error:', error);
      return false;
    }
  };

  // 애니메이션 Ref
  const ageSelectRef = React.useRef(null);
  const nextButtonRef = React.useRef(null);

  // 직업이 선택되었을 때 나이 드롭다운 애니메이션 실행
  React.useEffect(() => {
    if (selectedJobIndex !== null && ageSelectRef.current) {
      ageSelectRef.current.fadeInUp(500);
    }
  }, [selectedJobIndex]);

  // 나이가 선택되었을 때 다음 버튼 애니메이션 실행
  React.useEffect(() => {
    if (selectedAgeIndex !== null && nextButtonRef.current) {
      nextButtonRef.current.fadeInUp(500);
    }
  }, [selectedAgeIndex]);

  // Back Icon Component
  const BackIcon = props => <Icon {...props} name="arrow-back" />;

  // Back Action Component
  const BackAction = () => (
    <TopNavigationAction icon={BackIcon} onPress={handleBackAction} />
  );

  // Handle Back Button Press
  const handleBackAction = () => {
    if (currentStep === 'Step1') {
      route.back();
    } else {
      goBack();
    }
  };

  return (
    <Layout style={{ flex: 1 }}>
      {/* 커스텀 헤더 */}
      <TopNavigation
        accessoryLeft={BackAction}
        alignment="center"
        title=""
        style={{ marginTop: 30 }}
      />

      {/* 펀넬 내용 */}
      <Funnel>
        {/* Step 1 */}
        <Funnel.Step name="Step1">
          <Layout style={styles.container}>
            <Text category="h5" style={styles.title}>
              직업과 나이를 선택해주세요
            </Text>

            {/* 직업 드롭다운 */}
            <Select
              placeholder="직업을 선택하세요"
              value={
                selectedJobIndex !== null
                  ? jobOptions[selectedJobIndex.row]
                  : ''
              }
              selectedIndex={selectedJobIndex}
              onSelect={index => {
                setSelectedJobIndex(index);
                setSelectedAgeIndex(null); // 직업 변경 시 나이 초기화
              }}
              style={styles.select}
            >
              {jobOptions.map((title, index) => (
                <SelectItem title={title} key={index} />
              ))}
            </Select>

            {/* 직업이 선택되면 나이 드롭다운 표시 */}
            {selectedJobIndex !== null && (
              <Animatable.View ref={ageSelectRef} style={styles.animatableView}>
                <Select
                  placeholder="나이를 선택하세요"
                  value={
                    selectedAgeIndex !== null
                      ? ageOptions[selectedAgeIndex.row]
                      : ''
                  }
                  selectedIndex={selectedAgeIndex}
                  onSelect={index => setSelectedAgeIndex(index)}
                  style={styles.select}
                >
                  {ageOptions.map((title, index) => (
                    <SelectItem title={title} key={index} />
                  ))}
                </Select>
              </Animatable.View>
            )}

            {/* 나이가 선택되면 다음 버튼 표시 */}
            {selectedAgeIndex !== null && (
              <Animatable.View
                ref={nextButtonRef}
                style={styles.animatableView}
              >
                <Button
                  style={styles.button}
                  onPress={() => {
                    setStep('Step2');
                  }}
                >
                  다음
                </Button>
              </Animatable.View>
            )}
          </Layout>
        </Funnel.Step>

        {/* Step 2 */}
        <Funnel.Step name="Step2">
          <Layout style={styles.container}>
            <Text category="h5" style={styles.title}>
              알림을 허용하시겠습니까?
            </Text>
            <Button
              style={styles.button}
              onPress={async () => {
                const granted = await requestNotificationPermission();
                if (granted) {
                  setStep('Step3');
                } else {
                  Alert.alert('알림 권한이 필요합니다.');
                }
              }}
            >
              알림 허용
            </Button>
          </Layout>
        </Funnel.Step>

        {/* Step 3 */}
        <Funnel.Step name="Step3">
          <Layout style={styles.container}>
            <Text category="h5" style={styles.title}>
              설정이 완료되었습니다!
            </Text>
            <Button
              style={styles.button}
              onPress={async () => await handleLogin(token)}
            >
              투두 만들러 가기
            </Button>
          </Layout>
        </Funnel.Step>
      </Funnel>
    </Layout>
  );
};

export default FunnelScreen;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    padding: 20,
    backgroundColor: '#FFFFFF',
  },
  title: {
    textAlign: 'center',
    marginBottom: 30,
  },
  select: {
    marginVertical: 10,
  },
  button: {
    marginTop: 30,
  },
  animatableView: {
    width: '100%',
  },
});

대략 이런 느낌... 당장 쓸 코드인지도 몰라서 js로 하고 컴포넌트 분리도 안했다!
참고로 퍼널 코드 작성에 대해 gpt와 얘기할 때 참고한 블로그...

그런데 문제가 퍼널이 다 끝나고 이제 메인 투두로 넘어가야하는데 이때 useGoogleAuth의 handleLogin 함수를 실행하면 되는데 이걸 퍼널스크린으로 줄 방법이 생각이 안난다 ㅠㅠ
만약 팀원들 반응이 괜찮다면 같이 고민해봐야겠다

profile
안녕하세요, 반갑습니다.

0개의 댓글