Expo 앱에서 폰트 로딩과 TanStack Query 데이터 로딩을 동시에 고려하여 Splash Screen을 깔끔하게 유지/해제하는 방법 정리
Expo 앱을 개발하다 보면 초기 진입 시점에 다음과 같은 작업이 동시에 일어납니다.
expo-font)이때 자주 발생하는 문제가 바로 Splash Screen이 너무 빨리 사라지거나, 반대로 여러 번 hide 되어 경고가 발생하는 현상입니다.
이번 글에서는 Expo + TanStack Query(React Query) 환경에서
이 모두 완료될 때까지 Splash Screen을 안정적으로 유지하는 패턴을 정리합니다.
Splash Screen을 단순히 "폰트 로딩 완료" 기준으로만 내리면,
반대로, 상태 변화마다 hideAsync()가 여러 번 호출되면 디버깅 시 경고가 뜨거나(환경에 따라)
타이밍 문제가 생길 수 있습니다.
그래서 아래 3가지를 동시에 만족하는 방식으로 구성합니다.
SplashScreen.hideAsync()는 1회만 호출가장 먼저 해야 할 일은 Expo가 자동으로 Splash를 내려버리는 동작을 막는 것입니다.
아래 코드는 앱이 실행되자마자 한 번 호출되면 됩니다. (일반적으로 app/_layout.tsx 최상단)
import * as SplashScreen from "expo-splash-screen";
SplashScreen.preventAutoHideAsync().catch(console.error);preventAutoHideAsync()를 호출하지 않으면, Expo가 특정 타이밍에 Splash를 내려버릴 수 있어 제어가 어려워집니다.hideAsync()가 여러 번 호출될 수 있음import { useEffect, useRef } from "react";
import { Platform, StatusBar } from "react-native";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useFonts } from "expo-font";
import { useRootManifest } from "@/hooks/services/useRootManifest";
import { useTheme } from "@/providers/ThemeProvider";
SplashScreen.preventAutoHideAsync().catch(console.error);
export function RootLayoutComp() {
const { mode, theme } = useTheme();
const splashHiddenRef = useRef(false);
/** 1. 폰트 로딩 */
const [fontsLoaded, fontError] = useFonts({
"Pretendard-Regular": require("../assets/fonts/Pretendard-Regular.otf"),
// 다른 폰트들도 여기에 추가 가능
});
/** 2. 서버 데이터 로딩 (TanStack Query) */
const { isPending, error: manifestError } = useRootManifest();
const fontsReady = fontsLoaded || !!fontError;
const manifestReady = !isPending || !!manifestError;
/** 3. Splash 해제 (1회만) */
useEffect(() => {
if (!splashHiddenRef.current && fontsReady && manifestReady) {
splashHiddenRef.current = true;
SplashScreen.hideAsync().catch(console.error);
}
}, [fontsReady, manifestReady]);
/** 4. 준비 전에는 렌더 자체를 막음 */
if (!fontsReady || !manifestReady) return null;
return (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: theme.colors.background },
}}
/>
<StatusBar
barStyle={mode === "dark" ? "light-content" : "dark-content"}
backgroundColor={theme.colors.background}
translucent={Platform.OS === "ios"}
/>
</>
);
}
export default function RootLayout() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<RootLayoutComp />
</QueryClientProvider>
</ThemeProvider>
);
}hideAsync() 중복 호출 방지Splash Screen은 초기 진입 안정화용입니다.
useFonts()는 fontsLoaded(성공) 또는 fontError(실패)로 상태가 결정됩니다.
여기서 중요한 포인트는 성공이든 실패든 로딩이 끝난 상태를 fontsReady로 통일하는 것입니다.
const [fontsLoaded, fontError] = useFonts({
"Pretendard-Regular": require("../assets/fonts/Pretendard-Regular.otf"),
});
const fontsReady = fontsLoaded || !!fontError;fontsReady는 "결과가 났다"는 의미로 정의합니다.TanStack Query의 로딩 상태는 isPending을 사용합니다.
여기서도 동일하게 성공이든 실패든 결과가 나면 준비 완료로 처리합니다.
const { isPending, error: manifestError } = useRootManifest();
const manifestReady = !isPending || !!manifestError;isPending은 false가 됩니다.manifestError도 준비 완료 조건에 포함합니다.상태 변화로 useEffect는 여러 번 실행될 수 있으므로, useRef로 1회 실행 가드를 둡니다.
const splashHiddenRef = useRef(false);
useEffect(() => {
if (!splashHiddenRef.current && fontsReady && manifestReady) {
splashHiddenRef.current = true;
SplashScreen.hideAsync().catch(console.error);
}
}, [fontsReady, manifestReady]);fontsReady && manifestReady가 true가 되는 순간에만 숨깁니다.splashHiddenRef 덕분에 이후 상태가 바뀌어도 hideAsync()는 다시 호출되지 않습니다.Splash는 유지되고 있는데 React 트리가 렌더되면, 상황에 따라 화면이 잠깐 보이거나 레이아웃이 튈 수 있습니다.
그래서 준비 전에는 null을 반환해 렌더 자체를 지연합니다.
if (!fontsReady || !manifestReady) return null;데이터는 커스텀 훅으로 사용하여 RootLayout 컴포넌트에서 스플래시 지연용으로 사용하고, 실제 홈 화면에서는 캐시된 데이터를 사용합니다.
import { useQuery } from "@tanstack/react-query";
import { fetchRootManifest } from "@/services/api";
export function useRootManifest() {
return useQuery({
queryKey: ["root-manifest"],
queryFn: fetchRootManifest,
// .. 옵션들
});
}queryKey가 동일해야 합니다.export function HomeScreen() {
const { data: manifest } = useRootManifest();
return (
<View>
<Text>{manifest?.title}</Text>
</View>
);
}Splash Screen은 초기 진입 안정화를 위한 도구이지, 모든 데이터 로딩을 대신하는 수단은 아닙니다.
Splash에 과도하게 의존해 데이터를 가져오도록 설계하면, 네트워크 상황에 따라 앱 첫 실행이 불필요하게 느려질 수 있고, 사용자는 “앱이 느리다”는 인상을 받게 됩니다.
Splash가 유지되는 동안 로딩해야 할 데이터는 앱 진입 자체에 반드시 필요한 것으로 한정하는 것이 좋습니다.
권장되는 예시는 다음과 같습니다.
반대로, 아래와 같은 데이터는 화면이 렌더된 이후 비동기로 가져오는 것이 바람직합니다.
Splash Screen의 역할은 사용자가 아무것도 보지 못한 채 기다리게 하는 것이 아니라,
처럼 화면이 깨지지 않도록 시간을 벌어주는 것에 가깝습니다.
이후의 데이터 로딩은 Skeleton, Placeholder, Spinner 등의 UI로 자연스럽게 이어가는 것이 사용자 경험 측면에서 훨씬 좋습니다.
다음과 같은 상황에서는 Splash를 명시적으로 제어하는 패턴이 효과적입니다.
이 원칙을 지키면, 초기 진입 속도와 안정성 사이에서 균형 잡힌 앱 구조를 만들 수 있습니다.