문의 사항에 따라 필요한 정보를 먼저 입력하시면 더 빠르게 대응해 드릴 수 있습니다.
- 개발 과정에서 문제가 있을 경우
- 앱 아이디(app ID): 1171292
- 호스팅 사: gcp
- 서비스 URL : https://mycashflow.kr/
- 오류 내용 : rest API로 카카오 로그인 후 브라우저 대신 앱으로 리다이렉션이 안됨.(정확히는 전달이 안 됨)
안녕하세요, 며칠 동안 이것만 잡고 해결하려고 하는데 해결이 어려워 도움을 구하고자 글을 올립니다. 비개발자라 이리저리 해봐도 너무 해결이 안됩니다…카카오에서 이걸 허용을 안 하는 건지, 아님 코드의 문제인지만 알아도 좀 풀릴 것 같은데 카카오 선생님 제발 도와주세요 ㅠㅠ
질문
Expo 웹뷰에서 카카오톡 인증 후 기본 브라우저가 아닌 웹뷰에 돌아와 로그인을 유지하려면 어떤 방식으로 설정해야 할까요? 도움 주시면 감사하겠습니다.
문제 상황
환경
- Next.js 15
- Firebase Auth OIDC (카카오 로그인 REST API 활용)
- Expo 앱에서 WebView로 카카오 로그인
현상
- 웹에서는 정상 작동하며, Expo Go에서 웹뷰로 실행했을 때도 로그인 성공
- 하지만 카카오 인증 완료 후 웹뷰로 돌아오지 않고 아이폰 기본 브라우저로 리다이렉션됨
문제 원인
- 현재 리다이렉트 URI가 웹 URL(
https://example.com
)로 설정되어 있어 인증 후 모바일 브라우저로 리다이렉트됩니다. - 카톡앱에서 인증 후 Expo앱 웹뷰로 리턴되지 않고 브라우저로 리다이렉트되어 웹브라우저에서 로그인이 됩니다.
시도한 방법
- 리다이렉트 URI에 커스텀 스킴 적용 시도
com.myteam.myservice://auth/kakao/callback
을 리다이렉트 URI로 설정하려 했으나, 카카오에서 “유효하지 않은 URI” 라고 오류 발생 - Deep Linking으로 시도했으나 여전히 리다이렉트 된 브라우저에서만 로그인 되고 웹뷰는 값이 넘겨지지 않은 것 같음
- 혹시나 싶어 HTTPS로 배포하고 시도해도 여전히 같은 문제 발생
참고 레퍼런스
- 와이즐리 웹뷰 앱처럼 "카카오 로그인 클릭 ➔ 카톡 앱 인증 ➔ 바로 앱웹뷰로 이동"하는 흐름을 구현하고자 시도
클라이언트 측 로그
GET /all 200 in 9837ms
○ Compiling /auth/kakao/callback ...
✓ Compiled /auth/kakao/callback in 580ms (924 modules)
GET /auth/kakao/callback?code=<인가 코드> 200 in 968ms
○ Compiling / ...
✓ Compiled / in 751ms (1032 modules)
GET / 200 in 873ms
⚠ The "images.domains" configuration is deprecated. Please use "images.remotePatterns" configuration instead.
✓ Compiled /api/auth/kakao/token in 256ms (1036 modules)
[Kakao Token Debug] 받은 인가 코드: <인가 코드>
[Kakao Token Debug] Client ID: <클라이언트 ID>
[Kakao Token Debug] Redirect URI: https://example.com/auth/kakao/callback
POST /api/auth/kakao/token 200 in 1121ms
GET / 200 in 63ms
Next.js 코드
// Kakao 로그인 URL 생성 함수
export function getKakaoAuthUrl() {
const clientId = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY!;
const redirectUri = process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!; // https://example.com
const scope = "openid account_email";
return `https://kauth.kakao.com/oauth/authorize?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
// src/app/(routes)/auth/kakao/callback/page.tsx
"use client";
import React, { useEffect, useState, useRef } from "react";
import { signInWithKakao } from "@/lib/auth";
import { useRouter, useSearchParams } from 'next/navigation';
import { getAuth } from "firebase/auth";
declare global {
interface Window {
ReactNativeWebView?: {
postMessage: (message: string) => void;
};
}
}
const KakaoCallback = () => {
const searchParams = useSearchParams();
const code = searchParams.get('code');
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const executedRef = useRef(false);
useEffect(() => {
const fetchTokenAndSignIn = async () => {
if (!code) {
console.log("[Callback] 인가 코드 없음");
return;
}
if (executedRef.current) {
console.log("[Callback] 이미 토큰 요청 수행함");
return;
}
executedRef.current = true;
try {
const res = await fetch('/api/auth/kakao/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const data = await res.json();
console.log("[Callback] Token response data:", data);
if (data.error) {
console.error("[Callback] Token error detail:", data.detail);
setError(data.error);
return;
}
const { id_token } = data;
if (!id_token) {
setError('No ID Token returned from Kakao');
console.log("[Callback] No id_token in response:", data);
return;
}
console.log("[Callback] Received id_token:", id_token);
const { user, error: signInError } = await signInWithKakao(id_token);
if (signInError) {
setError('Firebase sign-in error');
return;
}
console.log("[Callback] Firebase sign-in success.");
const auth = getAuth();
const token = await auth.currentUser?.getIdToken(true);
if (typeof window !== "undefined" && window.ReactNativeWebView && token) {
console.log("[Callback] Sending token to RN WebView via postMessage:", token);
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'LOGIN_SUCCESS', token }));
} else {
console.log("[Callback] Web environment detected. Redirecting home.");
router.push('/');
}
} catch (err: any) {
console.error("[Callback] 토큰 요청 중 오류:", err);
setError(err.message);
}
};
fetchTokenAndSignIn();
}, [code, router]);
if (error) {
return <div>에러 발생: {error}</div>;
}
return <div>로그인 처리 중...</div>;
};
export default KakaoCallback;
Expo WebView 코드
App.tsx (Expo)
import React, { useRef, useEffect, useState, useCallback } from "react";
import {
View,
Dimensions,
AppState,
AppStateStatus,
BackHandler,
StatusBar,
useColorScheme,
Platform,
Linking
} from "react-native";
import { WebView, WebViewMessageEvent } from "react-native-webview";
import { PanGestureHandler, State as GestureState, PanGestureHandlerStateChangeEvent } from "react-native-gesture-handler";
import { GestureHandlerRootView } from 'react-native-gesture-handler';
const SCREEN_WIDTH = Dimensions.get("window").width;
const SWIPE_THRESHOLD = SCREEN_WIDTH / 3;
// 웹뷰가 로딩하는 도메인을 redirect_uri와 동일하게 했는지 확인
const BASE_URL = "https://example.com";
const ALLOWED_URLS = [
"data:",
"https://example.com",
"https://kauth.kakao.com",
"https://accounts.kakao.com/login",
"kakaokompassauth://",
"kakaolink://",
];
export default function App() {
const webViewRef = useRef<WebView>(null);
const translationXRef = useRef(0);
const appState = useRef<string>(AppState.currentState);
const [currentUrl, setCurrentUrl] = useState("");
const colorScheme = useColorScheme();
const isDarkMode = colorScheme === 'dark';
const themeStyles = {
container: {
flex: 1,
backgroundColor: isDarkMode ? '#000000' : '#FFFFFF'
},
webView: {
flex: 1,
marginTop: (StatusBar.currentHeight || 25),
marginBottom: 25,
backgroundColor: isDarkMode ? '#000000' : '#F9FAFB',
}
};
const handleShouldStartLoadWithRequest = useCallback((event: { url: string }) => {
const url = event.url;
// 카카오 앱 스킴 처리
if ((Platform.OS === 'ios' && url.startsWith('kakaolink://')) ||
(Platform.OS === 'android' && url.startsWith('intent://'))) {
Linking.openURL(url).catch(err => console.log("Failed to open KakaoTalk:", err));
return false;
}
const shouldLoad = ALLOWED_URLS.some(allowed => url.startsWith(allowed));
if (!shouldLoad) {
Linking.openURL(url).catch(err => console.log("Failed to open URL:", err));
return false;
}
return true;
}, []);
const onNavigationStateChange = (navState: any) => {
if (navState.url) {
setCurrentUrl(navState.url);
console.log("현재 URL:", navState.url);
}
};
const onPanGestureEvent = (event: PanGestureHandlerStateChangeEvent) => {
const { state, translationX } = event.nativeEvent;
if (state === GestureState.END) {
const isRootPath = currentUrl.endsWith("/") || currentUrl.endsWith("/#");
if (translationXRef.current > SWIPE_THRESHOLD && !isRootPath) {
webViewRef.current?.goBack();
}
translationXRef.current = 0;
} else {
translationXRef.current = translationX;
}
};
const handleWebViewMessage = async (event: WebViewMessageEvent) => {
console.log("WebView 메시지 수신:", event.nativeEvent.data);
try {
const message = JSON.parse(event.nativeEvent.data);
if (message.type === 'LOGIN_SUCCESS') {
const token = message.token;
console.log("Expo App에서 토큰 수신:", token);
// 토큰을 앱 내 스토리지에 저장
} else if (message.type === 'KAKAO_LOGIN_REQUEST') {
const kakaoAuthUrl = message.url;
const kakaoAppScheme = kakaoAuthUrl.replace(
'https://kauth.kakao.com',
'kakaokompassauth://authorize'
);
console.log("카카오톡 앱 스킴:", kakaoAppScheme);
try {
await Linking.openURL(kakaoAppScheme);
} catch (error) {
console.error("카카오톡 앱 열기 실패:", error);
// 앱 열기 실패시 웹 URL로 폴백
await Linking.openURL(kakaoAuthUrl);
}
}
} catch (error) {
console.error("메시지 처리 중 오류:", error);
}
};
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextAppState === "active") {
webViewRef.current?.reload();
}
appState.current = nextAppState;
};
useEffect(() => {
const subscription = Linking.addEventListener("url", (event) => {
const url = event.url;
console.log("Deep Link URL:", url);
});
const appStateSubscription = AppState.addEventListener("change", handleAppStateChange);
return () => {
subscription.remove();
appStateSubscription.remove();
};
}, [currentUrl]);
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (webViewRef.current && currentUrl) {
const isRootPath = currentUrl.endsWith('/') || currentUrl.endsWith('/#');
if (!isRootPath) {
webViewRef.current.goBack();
return true;
}
}
return false;
});
return () => backHandler.remove();
}, [currentUrl]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar
backgroundColor={isDarkMode ? '#000000' : '#F9FAFB'}
barStyle={isDarkMode ? "light-content" : "dark-content"}
/>
<PanGestureHandler onHandlerStateChange={onPanGestureEvent}>
<View style={themeStyles.container}>
<WebView
ref={webViewRef}
source={{ uri: BASE_URL }}
style={themeStyles.webView}
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
onMessage={handleWebViewMessage}
allowsInlineMediaPlayback={true}
mediaPlaybackRequiresUserAction={false}
javaScriptEnabled={true}
domStorageEnabled={true}
allowFileAccess={true}
allowUniversalAccessFromFileURLs={true}
allowFileAccessFromFileURLs={true}
sharedCookiesEnabled={true}
thirdPartyCookiesEnabled={true}
incognito={false}
originWhitelist={['*']}
/>
</View>
</PanGestureHandler>
</GestureHandlerRootView>
);
}