현재 우리 서비스 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 함수를 실행하면 되는데 이걸 퍼널스크린으로 줄 방법이 생각이 안난다 ㅠㅠ
만약 팀원들 반응이 괜찮다면 같이 고민해봐야겠다