Expo 웹뷰에서 Rest API로 카카오 로그인 후 브라우저 대신 앱으로 리다이렉션하는 방법 알려주실 수 있을까요..? 🥹

문의 사항에 따라 필요한 정보를 먼저 입력하시면 더 빠르게 대응해 드릴 수 있습니다.

  • 개발 과정에서 문제가 있을 경우
    • 앱 아이디(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>
  );
}

안녕하세요.

redirect_uri 에 서비스측 앱 스킴을 입력하셨는데요.
서비스측 백앤드 주소를 입력 하셔야 합니다.

카카오톡 로그인 후, 서비스 앱으로 자동으로 돌아가며 웹뷰 내 JS SDK가 카카오톡 인증 여부를 확인하여 서비스측 redirect_uri로 이동합니다. 즉, 서비스측 앱 스킴 입력이 불필요 합니다.

2개의 좋아요

Hello, @이인수1562

I can suggest the following solutions for the integration of Expo WebView with Kakao Login:

  1. Kakao Redirect URI Settings:
// Use Native URL scheme
const REDIRECT_URI = Platform.select({
ios: 'com.yourapp.scheme://oauth/callback/kakao',
android: 'com.yourapp.scheme://oauth/callback/kakao'
});
  1. Contact Management in WebView:
// Recommended settings for WebView component
<WebView
ref={webViewRef}
source={{ uri: BASE_URL }}
onNavigationStateChange={(navState) => {
// Grab the Callback URL
if (navState.url.includes('/oauth/callback/kakao')) {
// Extract and process the token
const token = extractTokenFromUrl(navState.url);
handleKakaoLogin(token);
}
 }}
 onMessage={handleWebViewMessage}
/>
  1. Deep Linking Configuration:
  • Define scheme in app.json:
{
 "expo": {
 "scheme": "yourapp",
 "ios": {
 "bundleIdentifier": "com.yourapp"
 },
 "android":{
 "package": "com.yourapp"
 }
 }
}
  1. URL Processing:
const handleUrl = ({ url }) => {
 if (url.includes('oauth/callback/kakao')) {
 // return to WebView
 webViewRef.current?.injectJavaScript(`
 window.location.href = '${url}';
 `);
 }
};

Important Points:

  1. You should register your custom URL scheme in the Kakao Developer Console
  2. You should manage the communication between WebView and native application correctly
  3. You should use origin whitelist for security
  4. You should store tokens securely

With these configurations, after Kakao Login, the user will be redirected back to WebView and the session will be preserved.

1개의 좋아요