Recode Log

  • tech-blog
  • device
  • features
Copyright © [WebKBS]. All rights reserved.
  • tech-blog
  • device
  • features
  1. Home
  2. blog
  3. Expo에서 TanStack Query(React Query)로 데이터 로딩 시 스플래시 화면 제어하는 방법

Expo에서 TanStack Query(React Query)로 데이터 로딩 시 스플래시 화면 제어하는 방법

Expo 앱에서 폰트 로딩과 TanStack Query 데이터 로딩을 동시에 고려하여 Splash Screen을 깔끔하게 유지/해제하는 방법 정리

  • expo ~54.0.30
  • react-native 0.81.5
2025년 12월 26일

개요

Expo 앱을 개발하다 보면 초기 진입 시점에 다음과 같은 작업이 동시에 일어납니다.

  • 커스텀 폰트 로딩 (expo-font)
  • 초기 설정 / 원격 설정 / manifest 로딩 (API 호출)
  • 테마 초기화, 인증 상태 확인 등

이때 자주 발생하는 문제가 바로 Splash Screen이 너무 빨리 사라지거나, 반대로 여러 번 hide 되어 경고가 발생하는 현상입니다.

이번 글에서는 Expo + TanStack Query(React Query) 환경에서

  • 폰트 로딩
  • 서버 데이터 로딩

이 모두 완료될 때까지 Splash Screen을 안정적으로 유지하는 패턴을 정리합니다.

Splash Screen을 단순히 "폰트 로딩 완료" 기준으로만 내리면,

  • 데이터는 아직인데 화면이 먼저 렌더링되어 빈 화면/깜빡임이 생기거나
  • 폰트가 늦게 적용되며 FOUT(폰트 깜빡임) 이 발생할 수 있습니다.

반대로, 상태 변화마다 hideAsync()가 여러 번 호출되면 디버깅 시 경고가 뜨거나(환경에 따라) 타이밍 문제가 생길 수 있습니다.

그래서 아래 3가지를 동시에 만족하는 방식으로 구성합니다.

  1. 폰트 + 데이터 로딩이 끝날 때까지 Splash 유지
  2. SplashScreen.hideAsync()는 1회만 호출
  3. 에러가 나도 무한 스플래시(진입 불가) 상태가 되지 않게 처리

구성 방법

Splash 자동 해제를 막는다

가장 먼저 해야 할 일은 Expo가 자동으로 Splash를 내려버리는 동작을 막는 것입니다.

아래 코드는 앱이 실행되자마자 한 번 호출되면 됩니다. (일반적으로 app/_layout.tsx 최상단)

app/_layout.tsx
import * as SplashScreen from "expo-splash-screen";
 
SplashScreen.preventAutoHideAsync().catch(console.error);
  • preventAutoHideAsync()를 호출하지 않으면, Expo가 특정 타이밍에 Splash를 내려버릴 수 있어 제어가 어려워집니다.

문제점

  • 데이터 로딩이 먼저 끝나면 → 폰트 로딩 전에도 Splash 해제
  • 상태 변경마다 hideAsync()가 여러 번 호출될 수 있음

안정적인 Splash 제어 패턴

  • 각 로딩 상태를 명확히 분리
  • "준비 완료" 상태를 boolean으로 명시
  • Splash는 한 번만 숨긴다

예제 코드

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>
  );
}

이 패턴의 장점

  • 폰트 / 데이터 로딩 순서와 무관
  • Splash가 너무 빨리 사라지는 문제 방지
  • hideAsync() 중복 호출 방지
  • 에러 상황에서도 앱 진입 가능

정리

1. Splash는 "로딩 UI"가 아니다

Splash Screen은 초기 진입 안정화용입니다.

  • 실제 로딩 UI는 Skeleton / Spinner로 처리
  • Splash에 비즈니스 로직을 과도하게 넣지 않기

2. 폰트 로딩 상태를 "준비 완료"로 만든다

useFonts()는 fontsLoaded(성공) 또는 fontError(실패)로 상태가 결정됩니다.

여기서 중요한 포인트는 성공이든 실패든 로딩이 끝난 상태를 fontsReady로 통일하는 것입니다.

const [fontsLoaded, fontError] = useFonts({
  "Pretendard-Regular": require("../assets/fonts/Pretendard-Regular.otf"),
});
 
const fontsReady = fontsLoaded || !!fontError;
  • 폰트 로딩에 실패했을 때도 앱은 동작해야 합니다.
  • 그래서 fontsReady는 "결과가 났다"는 의미로 정의합니다.

3. React Query 데이터 로딩도 "준비 완료"로 만든다

TanStack Query의 로딩 상태는 isPending을 사용합니다.

여기서도 동일하게 성공이든 실패든 결과가 나면 준비 완료로 처리합니다.

const { isPending, error: manifestError } = useRootManifest();
 
const manifestReady = !isPending || !!manifestError;
  • 요청이 끝나면 isPending은 false가 됩니다.
  • 에러가 나도 무한 대기 상태가 되지 않도록 manifestError도 준비 완료 조건에 포함합니다.

4. Splash 해제는 딱 한 번만

상태 변화로 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()는 다시 호출되지 않습니다.

5. 준비되기 전에는 렌더 자체를 막는다

Splash는 유지되고 있는데 React 트리가 렌더되면, 상황에 따라 화면이 잠깐 보이거나 레이아웃이 튈 수 있습니다.

그래서 준비 전에는 null을 반환해 렌더 자체를 지연합니다.

if (!fontsReady || !manifestReady) return null;

데이터는 Hook으로 분리해서 사용하는게 좋다

데이터는 커스텀 훅으로 사용하여 RootLayout 컴포넌트에서 스플래시 지연용으로 사용하고, 실제 홈 화면에서는 캐시된 데이터를 사용합니다.

useRootManifest.ts
import { useQuery } from "@tanstack/react-query";
import { fetchRootManifest } from "@/services/api";
export function useRootManifest() {
  return useQuery({
    queryKey: ["root-manifest"],
    queryFn: fetchRootManifest,
    // .. 옵션들
  });
}
  • tanstack-query는 기본적으로 캐싱을 지원하므로, 같은 쿼리를 여러 번 호출해도 네트워크 요청이 중복되지 않습니다.
  • 단, queryKey가 동일해야 합니다.
home.tsx
export function HomeScreen() {
  const { data: manifest } = useRootManifest();
 
  return (
    <View>
      <Text>{manifest?.title}</Text>
    </View>
  );
}

마지막 주의사항

Splash Screen은 초기 진입 안정화를 위한 도구이지, 모든 데이터 로딩을 대신하는 수단은 아닙니다.

Splash에 과도하게 의존해 데이터를 가져오도록 설계하면, 네트워크 상황에 따라 앱 첫 실행이 불필요하게 느려질 수 있고, 사용자는 “앱이 느리다”는 인상을 받게 됩니다.

Splash에 의존하는 데이터는 최소화해야 한다

Splash가 유지되는 동안 로딩해야 할 데이터는 앱 진입 자체에 반드시 필요한 것으로 한정하는 것이 좋습니다.

권장되는 예시는 다음과 같습니다.

  • 인증 상태 확인 (로그인 여부, 토큰 유효성 등)
  • 테마 / 다크모드 / 언어 설정
  • 앱 전역에서 필요한 최소한의 설정 데이터

반대로, 아래와 같은 데이터는 화면이 렌더된 이후 비동기로 가져오는 것이 바람직합니다.

  • 화면별 목록 데이터
  • 사용자 콘텐츠, 히스토리, 통계성 데이터
  • 초기 진입과 직접적인 관련이 없는 API 호출

Splash는 "시간을 버는 용도"로 사용한다

Splash Screen의 역할은 사용자가 아무것도 보지 못한 채 기다리게 하는 것이 아니라,

  • 초기 레이아웃 준비
  • 폰트 적용
  • 필수 상태 동기화

처럼 화면이 깨지지 않도록 시간을 벌어주는 것에 가깝습니다.

이후의 데이터 로딩은 Skeleton, Placeholder, Spinner 등의 UI로 자연스럽게 이어가는 것이 사용자 경험 측면에서 훨씬 좋습니다.

이런 경우에 Splash 제어 패턴이 특히 유용하다

다음과 같은 상황에서는 Splash를 명시적으로 제어하는 패턴이 효과적입니다.

  • 폰트 적용이 늦어 UI가 깜빡이는 현상을 막고 싶을 때
  • 초기 데이터 유무에 따라 첫 화면이 크게 달라지는 경우
  • 인증 상태 확인 없이는 화면을 그릴 수 없는 구조일 때

정리

  • Splash Screen은 필수 초기 상태만 담당하게 한다
  • 모든 데이터를 Splash에 묶지 않는다
  • 초기 진입 이후의 로딩은 화면 단에서 처리한다

이 원칙을 지키면, 초기 진입 속도와 안정성 사이에서 균형 잡힌 앱 구조를 만들 수 있습니다.


다음글

Expo (React Native)에서 폰트 적용 및 설정 방법


관련 태그

  • expo
  • react-native
  • tanstack-query
  • react-query
  • splash-screen