앱 켜자마자 데이터가 필요할 때 (비동기 초기화 패턴)
1. "로그인 안 했는데 메인 화면이 잠깐 보여요."
앱을 켜면 저장된 토큰을 확인해서 '로그인 화면'이나 '홈 화면' 중 하나로 보내야 합니다. 그런데 아주 짧은 순간(0.1초), 홈 화면의 껍데기가 보였다가 로그인 화면으로 튕기는 "Flash of Unstyled Content (FOUC)" 현상이 발생합니다.
"데이터를 다 불러오고 나서 화면을 띄울 순 없나요?"
비동기(Async) 세계인 Flutter에서 "준비될 때까지 기다려(Blocking)"는 가장 까다로운 주제, 그리고 가장 중요한 첫인상(First Impression) 문제입니다.
2. 전략 1: main()에서 기다리기 (가장 단순함)
가장 쉬운 방법은 앱이 실행(runApp)되기 전에 모든 것을 끝내는 것입니다.
void main() async {
// 1. Flutter 엔진 초기화 필수
WidgetsFlutterBinding.ensureInitialized();
// 2. 비동기 작업 대기 (예: SharedPreferences 로드)
await UserPreferences.init();
final isLoggedIn = await AuthService.checkLogin();
// 3. 준비 끝난 후 앱 실행
runApp(MyApp(startPage: isLoggedIn ? Home() : Login()));
}
장점:
- 확실성: 앱이 켜지는 순간 이미 데이터가 다 있습니다. 화면 깜빡임이 원천천단됩니다.
- 단순함: 복잡한 상태 관리가 필요 없습니다.
단점:
- 느린 부팅: 초기화가 3초 걸리면, 사용자는 3초 동안 흰 화면(White Screen)만 보고 있어야 합니다. OS(Android/iOS)는 앱이 응답하지 않는다고 판단하고 프로세스 순위를 낮출 수도 있습니다.
3. 전략 2: 스플래시 스크린 위젯 (가장 추천)
네이티브 로딩 화면은 빨리 넘기고, Flutter가 그린 로딩 화면을 보여주는 방식입니다. 사용자에게 "앱이 켜졌고, 데이터를 불러오는 중이다"라는 피드백을 줄 수 있습니다.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: InitService.initialize(), // 여기서 병렬 초기화 추천
builder: (context, snapshot) {
// 1. 로딩 중이면 스플래시 화면 표시
if (snapshot.connectionState == ConnectionState.waiting) {
return SplashScreen();
}
// 2. 에러 처리 (인터넷 연결 끊김 등)
if (snapshot.hasError) {
return ErrorScreen(onRetry: () {
// setState로 Future 다시 트리거
});
}
// 3. 완료되면 메인 앱 시작
return MaterialApp(home: Home());
},
);
}
}
장점:
- 즉각 반응: 앱이 주저 없이 켜집니다.
- 브랜딩: 로딩 중에 귀여운 애니메이션이나 로고를 보여주면 체감 대기 시간이 줄어듭니다.
- 복구 가능: 초기화 실패 시 "재시도 버튼"을 띄울 수 있습니다.
main()에서 실패하면 앱이 크래시(Crash) 납니다.
4. 전략 3: Riverpod AppStartupWidget (고급 패턴)
상태 관리 라이브러리를 쓴다면, 초기화 로직도 상태로 관리하는 게 좋습니다. Riverpod 창시자 Remi Rousselet가 추천하는 Eager Initialization 패턴입니다.
// 1. 초기화 로직을 담은 Provider
// FutureProvider로 만들면 캐싱과 에러 핸들링이 공짜입니다.
final appStartupProvider = FutureProvider<void>((ref) async {
// 병렬 실행으로 속도 최적화
await Future.wait([
ref.watch(sharedPrefsProvider.future),
ref.watch(authProvider.future),
ref.watch(remoteConfigProvider.future),
]);
});
// 2. 초기화를 감시하는 위젯
class AppStartupWidget extends ConsumerWidget {
final WidgetBuilder onLoaded;
const AppStartupWidget({required this.onLoaded});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 3. 초기화 상태 구독
final startupStatus = ref.watch(appStartupProvider);
return startupStatus.when(
data: (_) => onLoaded(context), // 성공 시 자식 위젯 렌더링
loading: () => const SplashScreen(),
error: (e, st) => ErrorScreen(e, onRetry: () => ref.invalidate(appStartupProvider)),
);
}
}
이 패턴의 강력한 점은 의존성 주입(DI)이 완벽하게 해결된다는 점입니다.
onLoaded가 호출된 시점에는 sharedPrefsProvider가 이미 준비된 상태임이 보장됩니다.
5. 심화: Future.wait으로 병렬 처리하기
초기화할 게 많을 때 순차적으로 (await A; await B; await C;) 하면 시간이 줄줄 샙니다.
독립적인 작업이라면 반드시 병렬로 처리하세요.
// ❌ 1초 + 1초 + 1초 = 3초
await initAds();
await initAnalytics();
await initUser();
// ✅ 동시에 시작 = 1초 (가장 느린 작업 기준)
await Future.wait([
initAds(),
initAnalytics(),
initUser(),
]);
단, 순서가 중요한 작업(예: Firebase 초기화 -> Analytics 초기화)은 순차적으로 해야 합니다.
6. 심화: Flutter Native Splash
전략 2, 3을 쓰더라도, Flutter 엔진이 예열되는 아주 짧은 시간(약 0.5초) 동안은 여전히 흰색이나 검은색 화면이 나올 수 있습니다.
이것까지 완벽하게 잡으려면 flutter_native_splash 패키지를 써야 합니다.
pubspec.yaml에 설정을 적어두면, 네이티브(Android/iOS) 프로젝트의 Launch Screen을 자동으로 만들어줍니다.
Flutter가 첫 프레임(First Frame)을 그릴 때까지 네이티브 이미지가 버텨주므로, 사용자는 앱이 "켜지자마자 로딩 화면이 떴다"고 느낍니다.
7. 심화: Completer Pattern (준비될 때까지 대기)
가끔 위젯이 아니라, 일반 클래스나 함수에서 초기화를 기다려야 할 때가 있습니다.
"API 클라이언트(RestClient)가 토큰이 준비될 때까지 요청을 보류하고 싶다" 같은 경우죠.
이때 Completer를 씁니다.
class TokenService {
final Completer<String> _completer = Completer();
// 초기화 함수 (어딘가에서 호출됨)
void setToken(String token) {
if (!_completer.isCompleted) _completer.complete(token);
}
// 토큰이 필요하면 이걸 호출
Future<String> getToken() {
return _completer.future; // 토큰 들어올 때까지 무한 대기
}
}
이렇게 하면 await getToken() 하는 녀석들은 setToken이 불릴 때까지 얌전히 줄 서서 기다립니다. 동기화(Synchronization)의 기본기입니다.
8. Case Study: 타임아웃 안전장치 (Safety Fuse)
초기화 로직에 Future.wait을 쓸 때 가장 무서운 건 "하나라도 영원히 안 끝나면 앱이 영원히 안 켜진다"는 겁니다.
특히 서드파티 SDK(광고, 분석 툴)가 네트워크 문제로 응답이 없으면 대재앙이 일어납니다.
반드시 타임아웃을 거세요.
try {
await Future.wait([
initCritical(),
initOptional().timeout(Duration(seconds: 2)), // 👈 2초 지나면 버림
]);
} catch (e) {
// 타임아웃 나도 앱은 켜야 함
print("일부 초기화 실패: $e");
}
runApp(MyApp());
"광고 초기화 실패했으니 쇼핑몰 앱을 안 켜주겠다"는 건 주객전도입니다. 핵심 코어와 부가 기능을 분리하세요.
9. Case Study: 화이트 스크린의 공포
제가 만든 첫 앱은 main() 함수에서 무거운 API를 호출했습니다.
와이파이가 느린 환경에서 테스트해보니, 앱 아이콘을 누르고 5초 동안 아무 반응이 없었습니다.
QA 팀에서는 "앱이 터치 인식을 안 해요"라고 리포트했습니다.
사실은 await apiCall()에 갇혀서 runApp()까지 도달을 못한 것이었죠.
해결:
초기화 로직을 FutureBuilder + SplashScreen으로 옮겼습니다.
앱은 0.1초 만에 켜졌고, "데이터 로딩 중..."이라는 스피너가 돌았습니다.
기능은 똑같지만, 사용자는 "앱이 빠릿하다"라고 느꼈습니다.
UX의 핵심은 '기다리지 않게 하는 것'이 아니라 '기다리고 있다는 걸 알려주는 것'입니다.
8. FAQ: 자주 묻는 질문
Q: 초기화 실패하면 앱 꺼야 하나요?
A: 절대 안 됩니다. "네트워크 오류가 발생했습니다. 재시도하시겠습니까?" 버튼을 보여줘야 합니다. main()에서 에러가 나면 복구할 방법이 없지만, 위젯 레벨에서는 setState나 ref.refresh로 재시도가 가능합니다.
Q: 권한 요청(위치, 알림)은 언제 해요? A: 초기화 때 하지 마세요. 앱 켜자마자 "알림 허용하시겠습니까?" 물어보면 99% 거절합니다. 해당 기능이 필요할 때(예: 지도 탭 누를 때) 요청하세요.
9. Glossary
- Blocking: Code execution stops and waits for an operation to finish. In UI, this usually freezes the screen.
- Splash Screen (Launch Screen): The very first image users see while the app loads into memory.
- Race Condition: When the outcome depends on the timing of uncontrollable events (e.g., trying to read UserID before Login completes).
- Lazy Initialization: creating an object only when it is actually needed, not at startup.
12. Summary
- 초기화가 0.1초 컷이다:
main()에서await해도 됨. (SharedPrefs 정도) - API 호출이 있다 (1초 이상): 반드시 스플래시 스크린 위젯을 써라.
- Future.wait: 독립적인 작업은 병렬로 돌려라.
- Native Splash: 엔진 로딩 시간까지 숨겨라.