이 글에서 답하는 질문: BootSplash가 네이티브에 어떻게 개입하는가, 우리는 어떻게 쓰고 있는가, 그리고 SplashScreen을 이미 같은 디자인으로 만들었는데도 왜 필요한가.
세 레이어의 역할 분담
Cold boot 동안 사용자에게 보이는 그림은 한 개의 연속된 화면으로 느껴지지만, 실제로는 세 종류의 레이어가 순차적으로 그려지는 결과다.
세 layer는 내용은 같지만 사는 영역이 다르다.
| 화면 | 어디서 도는 코드 | 언제부터 그릴 수 있나 |
|---|---|---|
| LaunchScreen | OS / 네이티브 storyboard | 앱 프로세스 시작 즉시 (JS 0% 필요) |
| BootSplash 복제 view | iOS Objective-C / Android Kotlin | AppDelegate / MainActivity가 호출하는 즉시 (JS 0% 필요) |
| RN SplashScreen | React JavaScript 컴포넌트 | RN bridge 부팅 + JS bundle 평가 + Root 첫 paint 완료 후 |
시간 축으로 본 cold boot
BootSplash 없을 때
"흰 화면"의 정체 = OS가 LaunchScreen을 dismiss한 뒤에 보이는 RCTRootView의 background color. 진짜 빈 화면이 아니라, RCTRootView가 마운트되긴 했는데 RN이 아직 그릴 게 없어서 view의 background만 보이는 상태다.
BootSplash 있을 때
BootSplash는 흰 화면 구간을 LaunchScreen의 복제 view로 덮어둔다. RCTRootView 자체의 backgroundColor는 여전히 흰색이지만, 그 위에 storyboard view를 한 번 더 올려놓았기 때문에 사용자 눈에는 LaunchScreen이 계속 보인다.
SplashScreen을 같은 디자인으로 만들었는데도 왜 BootSplash가 필요한가
가장 자주 나오는 의문이다. 답은 "RN SplashScreen은 RN이 부팅된 뒤에야 그릴 수 있다" 다.
같은 디자인이라는 사실은 전환 시점에 jitter 없게 한 것이고, 언제 보일 수 있느냐는 별개 문제다.
RN SplashScreen은 JS 컴포넌트
RN SplashScreen은 코드 자체가 App.tsx 안에 있는 JavaScript다. 그걸 그리려면:
- RN bridge가 떠야 함 (네이티브 ↔ JS thread 연결)
- JS bundle이 다운·로드·평가돼야 함
- Hermes가 bytecode 인터프리트를 시작해야 함
- React가 mount cycle을 시작해야 함
- Root → RootGate → SplashScreen 컴포넌트 트리가 그려져야 함
- 그 결과가 GPU로 가서 첫 frame paint까지 돼야 함
이 모든 과정이 1~3초 걸린다. 그 시간 동안 RN SplashScreen은 존재 자체가 불가능하다.
그래서 갭을 메우려면
갭을 메우려면 그 시간 동안 그려질 수 있는 코드가 필요하다. 후보가 두 개다.
- OS의 LaunchScreen을 더 오래 유지시키기
- 네이티브 코드로 LaunchScreen 복제 view를 한 번 더 그리기
1번은 불가능에 가깝다.
- iOS LaunchScreen은 OS가 자동 dismiss 시점을 결정한다. 개발자가 "더 보여줘"라고 못한다.
- Android 12+의 SplashScreen API는 timing을 OS가 정한다.
2번이 BootSplash다. 네이티브에서 LaunchScreen의 복제 view를 만들어 RN root view 위에 올려두는 방식이고, JS 0% 시점에도 동작한다.
한 줄로
RN SplashScreen을 LaunchScreen과 똑같이 만든 건 "RN 첫 paint 시점에 logo 위치 jitter가 없도록" 한 거고, BootSplash를 쓰는 건 "RN 첫 paint가 일어나기 전의 1~3초를 메우려고" 한 거다. 두 결정은 서로 다른 문제를 푸는 것 — 같이 있어야 효과가 난다.
BootSplash 라이브러리 동작 원리
표면적으로는 "네이티브 splash를 hide()한다"인데, 실제 코드를 보면 LaunchScreen을 유지하는 게 아니라 같은 디자인을 한 번 더 덮어씌우는 트릭이다.
iOS: LaunchScreen을 다시 한 번 인스턴스화해서 RN root view에 얹기
등록 시점은 AppDelegate.swift다.
override func customize(_ rootView: RCTRootView) {
super.customize(rootView)
RNBootSplash.initWithStoryboard("LaunchScreen", rootView: rootView)
}customize(_ rootView:)는 RN factory가 root view를 만든 직후에 호출하는 hook이다. 여기서 BootSplash에 어떤 storyboard를 복제해 얹을지 알려준다.
라이브러리가 하는 일(RNBootSplash.mm):
+ (void)initWithStoryboard:(NSString *)storyboardName rootView:(UIView *)rootView {
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:storyboardName bundle:nil];
// 1. LaunchScreen storyboard를 "다시 한 번" 인스턴스화해서 UIView로 만듦
_loadingView = [[storyboard instantiateInitialViewController] view];
_loadingView.frame = _rootView.bounds;
// 2. RN root view의 subview로 추가
[_rootView addSubview:_loadingView];
// 3. JS 로드 완료/실패 시점을 알기 위해 옵저버 등록
// RCTJavaScriptDidLoadNotification / RCTJavaScriptDidFailToLoadNotification
// 4. iOS LaunchScreen 자체 fade-out이 끝날 시점을 추측 (350ms 타이머)
}iOS LaunchScreen은 OS가 보여주는 정적 이미지라 직접 유지할 수 없다. BootSplash는 그 사라지는 LaunchScreen과 완전히 동일한 view를 미리 RN root view 위에 얹어둬서, 사용자 눈에는 LaunchScreen이 계속 보이는 것처럼 느껴지게 만든다.
hide()가 하는 일은 단순하다.
+ (void)hideAndClearPromiseQueue {
if (_fade) {
[UIView transitionWithView:_rootView duration:0.250 ... animations:^{
_loadingView.hidden = YES;
} completion:^(...) {
[_loadingView removeFromSuperview]; // ← 실제 dismiss = superview에서 빼기
_loadingView = nil;
}];
} else {
_loadingView.hidden = YES;
[_loadingView removeFromSuperview];
_loadingView = nil;
}
}BootSplash.hide()의 실체는 _loadingView(덮어씌운 storyboard view)를 removeFromSuperview하는 것이다.
Android: 테마 swap + ViewTreeObserver + SplashScreen API 세 가지 트릭
등록 시점은 MainActivity.kt다.
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
RNBootSplash.init(this, R.style.BootTheme)
super.onCreate(savedInstanceState)
}
}super.onCreate() 이전에 init 호출이 중요하다. OnPreDrawListener 등록이 액티비티 content view가 만들어지기 전에 끝나야 첫 paint를 잡을 수 있기 때문이다.
라이브러리가 하는 세 가지 트릭이 있다.
트릭 1 — Theme 즉시 swap
val typedValue = TypedValue()
currentTheme.resolveAttribute(R.attr.postBootSplashTheme, typedValue, true)
mainActivity.setTheme(typedValue.resourceId)Android는 액티비티의 windowBackground가 LaunchScreen 역할을 한다. 그대로 두면 RN 화면 뒤로 LaunchScreen이 계속 비친다. BootSplash는 즉시 일반 테마로 갈아끼워서 그 windowBackground를 제거한다.
트릭 2 — ViewTreeObserver로 첫 paint 자체를 막음
val contentView = mainActivity.findViewById<View>(android.R.id.content)
mStatus = Status.INITIALIZING
contentView.viewTreeObserver.addOnPreDrawListener(object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (mStatus == Status.INITIALIZING) {
return false // ← false 반환 = "지금 그리지 마"
}
contentView.viewTreeObserver.removeOnPreDrawListener(this)
return true
}
})이게 핵심 트릭이다. Android의 OnPreDrawListener.onPreDraw()가 false를 반환하면 그 frame의 렌더링 자체가 중단된다. 상태가 INITIALIZING인 동안엔 RN의 어떤 화면도 그려지지 않는다.
트릭 3 — Android 12+의 SplashScreen API 가로채기
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val splashScreen = mainActivity.splashScreen
splashScreen.setOnExitAnimationListener { view ->
view.remove() // ← OS의 splash 종료 애니메이션을 무력화
splashScreen.clearOnExitAnimationListener()
}
}Android 12부터는 OS가 모든 앱에 공식 SplashScreen API를 강제한다. 거긴 fade-out 종료 애니메이션이 자동으로 들어있다. BootSplash는 이 listener를 가로채서 종료 애니메이션 없이 즉시 제거한다(제어권을 BootSplash가 가지기 위해서).
전체 흐름
우리 셋팅 4곳
iOS: AppDelegate.swift
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func customize(_ rootView: RCTRootView) {
super.customize(rootView)
RNBootSplash.initWithStoryboard("LaunchScreen", rootView: rootView)
}
}Android: MainActivity.kt
import com.zoontek.rnbootsplash.RNBootSplash
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
RNBootSplash.init(this, R.style.BootTheme)
super.onCreate(savedInstanceState)
}
}Android 테마: styles.xml
<style name="BootTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>AndroidManifest.xml의 <activity android:theme="@style/AppTheme"> — BootTheme이 manifest theme이 아니다. RNBootSplash.init(this, R.style.BootTheme)의 두 번째 인자로 전달해서 라이브러리 init 안에서 액티비티 테마를 transient하게 swap하는 용도다.
JS: App.tsx
function Root(): React.JSX.Element {
useEffect((): void => {
void BootSplash.hide({fade: false});
}, []);
...
}Root 컴포넌트 마운트 후 첫 useEffect — 이 시점이 React 첫 paint 완료 직후. 이때 덮어씌운 view를 removeFromSuperview시켜서 RN 화면을 드러낸다.
우리 셋팅의 특이 포인트
특이점 1: 별도 BootSplash storyboard/asset 안 만들고 LaunchScreen.storyboard 그대로 재사용
표준 설치 가이드는 다음 명령으로 별도 자산을 생성하라고 권한다.
npx react-native-bootsplash generate --logo ./logo.png --background "#FFF" ...
# → BootSplash.storyboard, bootsplash_logo.png drawable, bootsplash.json 등 자동 생성이러면 LaunchScreen.storyboard와 BootSplash.storyboard 두 개를 따로 관리해야 한다. 디자인이 일관되게 유지되려면 두 곳을 동기화해야 하는 부담이 생긴다.
우리는 그 단계를 생략하고 initWithStoryboard("LaunchScreen", ...)로 LaunchScreen 자체를 재사용한다. AppDelegate.swift의 주석에도 명시돼 있다.
동일 storyboard("LaunchScreen")를 재사용 — 별도 BootSplash.storyboard 추가 불필요.
장점은 storyboard 한 군데만 관리하면 된다는 것. 단점은 BootSplash가 LaunchScreen의 모든 view hierarchy를 재인스턴스화하므로, LaunchScreen이 복잡하면 그만큼 늘어난다는 것이다(우리 LaunchScreen은 로고 하나뿐이라 영향 없음).
특이점 2: LaunchScreen 좌표를 SafeArea가 아니라 view 절대 중앙으로
LaunchScreen.storyboard의 constraint가 SafeArea 기준이 아니라 view 절대 중앙으로 설정돼 있다.
<constraint firstItem="LGO-iD-001" firstAttribute="centerY"
secondItem="Ze5-6b-2t3" secondAttribute="centerY"/>
<!-- secondItem이 safeArea (SAF-eA-rea)가 아니라 view 자체 (Ze5-6b-2t3) -->storyboard 안에 직접 적어둔 주석이 그 이유다.
SafeArea (safeAreaLayoutGuide)를 기준으로 하면 iOS native 측정과 react-native-safe-area-context의 inset 측정이 ~11pt 차이가 나서 LaunchScreen → RN SplashScreen 전환 시 logo 위치 점프가 발생한다. 두 splash 모두 view 절대 중앙으로 통일.
pages/splash/SplashScreen.tsx의 RN logoLayer도 같은 기준으로 잡혀 있다.
const screenHeightPt: number = Dimensions.get('screen').height;
const logoTopPt: number = screenHeightPt / 2 - LOGO_HEIGHT / 2;
// SafeArea에 의존하지 않고 화면 절대 중앙이 둘이 같은 좌표 기준이라야 iOS LaunchScreen → BootSplash 복제 view → RN SplashScreen 세 단계 전환 동안 로고가 한 픽셀도 안 튄다.
왜 쓰는가: 세 가지 이유
LaunchScreen 자동 dismiss 후 RN 첫 paint 전 사이의 갭 메우기 (최우선)
OS가 LaunchScreen을 dismiss하는 시점이 우리 통제 밖이라는 게 핵심이다.
iOS: LaunchScreen은 OS가 UIWindow가 key window가 되는 순간 자동 dismiss된다. 그 시점은 보통 factory.startReactNative(...) 호출 직후 = RN bridge 부팅이 아직 시작도 안 된 시점. 즉, LaunchScreen이 사라지고 RN 첫 paint까지 RCTRootView의 빈 배경만 보인다.
Android: 비슷하게 windowBackground(BootTheme의 흰 배경)가 액티비티 시작 시 보이다가, RN 첫 frame paint 직전에 사라지는 사이에 갭이 발생한다.
저사양 디바이스의 체감 시간은 다음과 같다.
| 디바이스 | RN bridge 부팅 ~ 첫 paint | 갭 |
|---|---|---|
| iPhone 15 Pro 시뮬레이터 (debug) | ~500ms | 짧음 |
| 실기기 iPhone 8 (release) | ~1.5s | 체감됨 |
| 저사양 Android (release) | ~2.5s | 명확히 보임 |
JS bundle 로드 실패 시 자동 복구
BootSplash가 의외로 큰 역할을 하나 더 한다. 치명적 에러 시점에 화면을 풀어주는 것이다.
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onJavaScriptDidFailToLoad)
name:RCTJavaScriptDidFailToLoadNotification
object:nil];
+ (void)onJavaScriptDidFailToLoad {
[self hideAndClearPromiseQueue]; // ← 강제 dismiss
}만약 hot-updater로 받은 번들이 parse error나 Hermes bytecode incompatibility 등으로 로드 실패하면:
- BootSplash 없을 때: LaunchScreen이 영원히 안 사라지거나, 흰 화면에 RN의 red box error가 떠도 그게 안 보인다
- BootSplash 있을 때: 자동 dismiss → RN의 fallback 화면(또는 hot-updater의 자동 크래시 롤백)이 표시된다
특히 hot-updater 셋팅에서 자동 크래시 롤백 기능과 궁합이 중요하다. HotUpdaterRecoveryManager가 RCTJavaScriptDidFailToLoadNotification을 들으면서 crash marker를 쓰는데, BootSplash도 같은 noti를 들어서 화면을 풀어준다. 두 시스템이 협력하는 셈이다.
진행률 UX의 연속성
RN bridge 부팅(1.5초) + RootGate(1.5초) + 다운로드(n초) + 인증 복원(1초)의 시작 부분이 사용자에게 어떻게 보이느냐의 차이다.
BootSplash 없으면:
흰 화면이 먼저 보이면 사용자는 "앱이 안 떴음" + "이제 떴음" 두 단계로 인식한다.
BootSplash 있으면:
연속된 한 화면처럼 인식된다. 신뢰감이 다르다.
OTA 흐름과 같이 쓸 때 효과가 커진다
cold boot의 흰 화면이 부정적 신호로 작용하는 앱일수록 비용이 크다. 특히 hot-updater + OTA 흐름은 cold boot가 raw RN보다 길다 (check-update + RootGate의 MIN_CHECK_UPDATE_DISPLAY_MS 등이 추가됨). 라이브러리를 안 쓸수록 그 갭이 더 두드러지고, BootSplash 없이 hot-updater만 쓰면 "흰 화면 → 로고+진행바"의 어색한 전환이 생긴다.
한 줄로: OTA 라이브러리를 쓰는 RN 앱은 cold boot가 raw보다 길어지기 때문에, BootSplash가 옵션이 아닌 세트로 들어가는 의존성에 가깝다.
안 쓰면 어떤 일이 일어나나
| 영향 | |
|---|---|
| 안 쓸 때 | cold boot에 흰 화면 1~2초, JS 로드 실패 시 LaunchScreen 영구 잔류 위험, RN 진입 시점이 "끊겨" 보임 |
| 쓸 때 | LaunchScreen → 복제 view → RN SplashScreen 한 흐름, JS 실패 시 자동 dismiss, 1~2초 더 길어 보여도 진행 중으로 인식 |
비용은 react-native-bootsplash 라이브러리 의존성 1개 + AppDelegate/MainActivity 각 한 줄 + storyboard 좌표를 view 절대 중앙으로 맞추기 정도. 이득 대비 비용이 작아서 채택했다.