Friday, October 18, 2019

Flutter 네이티브 스플래시, 로딩 중 서버 점검까지 완벽하게 구현하기 (흰 화면 깜빡임 해결)

모든 모바일 앱의 첫인상은 '스플래시 화면(Splash Screen)'에서 결정됩니다. 사용자가 아이콘을 탭하고 처음 마주하는 이 짧은 순간은 앱의 전문성과 완성도를 가늠하는 중요한 척도입니다. 하지만 많은 Flutter 개발자들이 앱 시작 시 발생하는 '흰 화면 깜빡임(White Screen Flash)' 현상으로 인해 골머리를 앓고 있습니다. 이는 사용자에게 미완성된 듯한 인상을 주며, 앱의 전반적인 품질을 떨어뜨리는 주범입니다.

이 문제의 원인은 Flutter 엔진의 초기화 시간 때문입니다. 다트(Dart) 코드로만 스플래시 화면을 구현할 경우, Flutter 엔진이 준비되기 전까지 네이티브 단에서 잠시 빈 화면을 보여주게 되고, 이것이 바로 깜빡임으로 나타나는 것입니다.

이 글에서는 이러한 문제를 근본적으로 해결하고, 한 걸음 더 나아가 스플래시 화면이 표시되는 동안 서버 상태를 점검하거나, 원격 설정을 가져오는 등 필수적인 초기화 작업을 수행하는 고급 기법까지 완벽하게 다룹니다. 네이티브 스플래시 화면을 구현하는 가장 현대적이고 효율적인 방법부터, `main()` 함수를 활용하여 앱 로직을 제어하는 실전 노하우까지, 당신의 Flutter 앱을 한 단계 업그레이드할 모든 것을 자세히 알려드립니다.

왜 '네이티브' 스플래시 화면이어야 하는가?

Flutter에서 스플래시 화면을 만드는 방법은 크게 두 가지로 나뉩니다. 다트 코드로 첫 페이지를 만드는 방식과, 안드로이드/iOS 네이티브 기능을 활용하는 방식입니다. 왜 후자가 압도적으로 권장되는 방법인지 명확히 이해하고 넘어가야 합니다.

1. 다트 기반 스플래시 화면 (권장하지 않는 방법)

가장 직관적으로 떠올릴 수 있는 방법입니다. 앱의 첫 번째 라우트를 스플래시 전용 `StatefulWidget`으로 지정하고, `initState`에서 몇 초간 대기한 후 다음 화면으로 넘어가는 방식입니다.


class SplashScreen extends StatefulWidget {
  const SplashScreen({Key? key}) : super(key: key);

  @override
  _SplashScreenState createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {
  @override
  void initState() {
    super.initState();
    Future.delayed(const Duration(seconds: 3), () {
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(builder: (_) => const HomeScreen()),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Image.asset('assets/logo.png'), // 스플래시 이미지
      ),
    );
  }
}

이 코드의 치명적인 문제점은 `SplashScreen` 위젯이 화면에 그려지기 전에 이미 Flutter 엔진이 로드되는 시간이 필요하다는 것입니다. 그 시간 동안 사용자는 플랫폼의 기본 배경색인 흰색 또는 검은색 화면을 보게 됩니다. 이로 인해 '흰 화면 → 스플래시 화면 → 홈 화면'으로 이어지는 부자연스러운 전환이 발생하여 사용자 경험을 크게 해칩니다.

2. 네이티브 스플래시 화면 (공식 권장 방법)

네이티브 스플래시 화면은 앱의 네이티브 부분(Android의 XML, iOS의 Storyboard)에서 직접 이미지를 설정하는 방식입니다. 사용자가 앱 아이콘을 탭하는 즉시, Flutter 엔진이 준비되었는지 여부와 상관없이 운영체제가 바로 이 화면을 띄워줍니다.

장점:

  • 완벽한 깜빡임 제거: 앱 실행과 동시에 스플래시 이미지가 표시되므로 중간에 빈 화면이 전혀 나타나지 않습니다.
  • 체감 로딩 속도 향상: 사용자는 앱이 즉각적으로 반응한다고 느끼게 되어, 실제 로딩 시간이 같더라도 훨씬 빠르다고 인지합니다.
  • 프로페셔널한 첫인상: 매끄러운 시작은 잘 만들어진 앱이라는 신뢰감을 줍니다.
  • Flutter 공식 가이드라인: Flutter 팀에서도 이 방식을 표준으로 권장하고 있습니다.

가장 쉬운 네이티브 스플래시 구현: `flutter_native_splash` 패키지 활용

과거에는 안드로이드와 iOS 네이티브 코드를 직접 수정해야 해서 번거로웠지만, 이제는 `flutter_native_splash`라는 훌륭한 패키지 덕분에 몇 가지 설정만으로 손쉽게 구현할 수 있습니다.

1단계: 패키지 추가

먼저 `pubspec.yaml` 파일에 패키지를 추가합니다.


dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_native_splash: ^2.3.1 # 최신 버전으로 추가

주의: 이 패키지는 앱의 빌드 설정을 변경하므로 `dependencies`가 아닌 `dev_dependencies`에 추가하는 것이 일반적입니다.

2단계: 스플래시 설정 구성

`pubspec.yaml` 파일 하단에 `flutter_native_splash` 설정을 추가합니다. 이 곳에서 스플래시 화면의 배경색, 이미지, 다크 모드 지원 여부 등을 모두 정의할 수 있습니다.


flutter_native_splash:
  # 스플래시 화면의 배경색
  color: "#FFFFFF"
  # 스플래시 화면에 표시될 이미지 (필수)
  image: assets/splash_logo.png
  # 다크 모드일 때의 배경색
  color_dark: "#121212"
  # 다크 모드일 때의 이미지
  image_dark: assets/splash_logo_dark.png

  # Android 12 이상을 위한 브랜딩 설정
  # 이 기능을 사용하면 스플래시 아이콘 하단에 작은 브랜딩 이미지를 추가할 수 있습니다.
  # branding: assets/branding.png
  # branding_dark: assets/branding_dark.png

  # Android/iOS 플랫폼별 세부 설정
  android_12:
    # Android 12 이상에서 사용할 아이콘 이미지
    image: assets/splash_icon_android12.png
    # 아이콘 주변의 배경색 (아이콘 배경과 다를 경우 유용)
    icon_background_color: "#FFFFFF"
    # Android 12 브랜딩 이미지
    # branding: assets/branding_android12.png

  # 전체 화면으로 표시할지 여부
  fullscreen: true

이미지 경로는 프로젝트의 `assets` 폴더를 기준으로 작성합니다. `assets/` 폴더를 만들고 사용할 이미지를 미리 넣어두세요.

3단계: 설정 적용

터미널에서 아래 명령어를 실행하여 `pubspec.yaml`에 작성한 설정을 실제 네이티브 파일(Android의 `launch_background.xml`, iOS의 `LaunchScreen.storyboard` 등)에 적용합니다.


dart run flutter_native_splash:create

이 명령어를 실행하면 패키지가 알아서 각 플랫폼에 맞는 설정 파일들을 생성하거나 수정해줍니다. 이제 앱을 완전히 종료했다가 다시 실행해보세요. 깜빡임 없이 아름다운 네이티브 스플래시 화면이 당신을 맞이할 것입니다.

핵심: 스플래시 화면 표시 중 백그라운드 작업 실행하기

네이티브 스플래시 화면을 성공적으로 띄웠습니다. 하지만 우리의 목표는 여기서 그치지 않습니다. 스플래시 화면이 보이는 그 짧은 시간 동안, 앱에 필요한 필수 데이터들을 미리 로드해야 합니다. 예를 들어 다음과 같은 작업들이 있습니다.

  • 서버가 정상 운영 중인지 상태 확인
  • Firebase Remote Config 같은 원격 설정 값 가져오기
  • 저장된 사용자 인증 토큰 확인 및 자동 로그인 처리
  • 필수적인 서비스(Dependency Injection, 분석 도구 등) 초기화

만약 이런 로직을 스플래시 화면 이후의 첫 페이지(`HomeScreen` 등)의 `initState`에서 처리한다면 어떻게 될까요? 네이티브 스플래시가 사라진 후, 다시 로딩 인디케이터(`CircularProgressIndicator`)를 보여줘야 합니다. 이는 '스플래시 → 로딩 화면 → 홈 화면'이라는 또 다른 부자연스러운 흐름을 만듭니다.

우리의 목표는 네이티브 스플래시 화면이 계속 표시되는 동안 이 모든 작업을 끝내고, 준비가 완료되면 바로 앱의 메인 화면을 보여주는 것입니다.

해결책은 `main.dart`의 `main()` 함수에 있습니다.

Flutter 앱의 생명주기를 이해하는 것이 중요합니다. 앱이 실행되면 다음 순서로 코드가 동작합니다.

  1. 사용자가 앱 아이콘을 탭합니다.
  2. 네이티브 플랫폼(Android/iOS)이 즉시 네이티브 스플래시 화면을 표시합니다.
  3. 백그라운드에서 Flutter 엔진을 구동시킵니다.
  4. `lib/main.dart` 파일의 `main()` 함수가 실행됩니다.
  5. `main()` 함수 내의 `runApp()` 함수가 호출됩니다.
  6. `runApp()`이 호출되는 순간, Flutter가 첫 번째 프레임을 그리면서 네이티브 스플래시 화면을 제거하고 Flutter 위젯을 화면에 표시합니다.

여기서 핵심은 4번과 5번 사이의 시간입니다. 즉, `main()` 함수가 시작되고 `runApp()`이 호출되기 전까지의 모든 코드는 네이티브 스플래시 화면이 보이는 상태에서 실행됩니다. 바로 이 지점이 우리가 초기화 로직을 넣어야 할 최적의 장소입니다.

기본적인 Flutter main 함수 구조
일반적인 main() 함수와 MyApp 클래스. `runApp()`이 바로 호출됩니다.

`runApp()` 함수를 호출하기 전에, 우리가 원하는 모든 초기화 로직을 수행하는 코드를 삽입하는 것입니다.

실전 코드: `main()` 함수에서 초기화 로직 구현하기

이제 실제로 코드를 작성해 보겠습니다. 서버 상태를 체크하고, 간단한 설정을 불러오는 가상 시나리오를 구현합니다.

1단계: `main` 함수를 `async`로 변경하고 바인딩 초기화

`runApp()` 이전에 Flutter 엔진의 기능을 사용하려면(예: `http` 통신, `shared_preferences` 접근 등), 반드시 Flutter의 위젯 바인딩이 초기화되었는지 확인해야 합니다. `WidgetsFlutterBinding.ensureInitialized();` 코드가 이 역할을 합니다. 이 코드는 항상 `main` 함수의 최상단에 위치해야 합니다.


import 'package:flutter/material.dart';

void main() async { // 1. main 함수를 async로 변경
  // 2. runApp 전에 바인딩을 초기화해야 함
  WidgetsFlutterBinding.ensureInitialized();

  // 여기에 초기화 코드를 추가할 예정

  runApp(const MyApp());
}

2단계: 초기화 로직을 별도 함수로 분리

`main()` 함수를 깔끔하게 유지하기 위해, 모든 초기화 로직을 별도의 `async` 함수로 만드는 것이 좋습니다. 예를 들어 `initializeApp()` 이라는 함수를 만들어 보겠습니다.


Future<void> initializeApp() async {
  // 실제 로직을 시뮬레이션하기 위해 2초 대기
  await Future.delayed(const Duration(seconds: 2));

  // 예시 1: 서버 상태 체크 (Dio 또는 http 패키지 사용)
  print('서버 상태 체크 완료...');

  // 예시 2: 원격 설정(Remote Config) 로드
  print('원격 설정 로드 완료...');

  // 예시 3: 사용자 인증 정보 로드
  print('사용자 정보 로드 완료...');
}

3단계: `main()` 함수에서 초기화 함수 호출

이제 `main()` 함수에서 `runApp()`을 호출하기 전에 방금 만든 `initializeApp()` 함수를 `await` 키워드로 호출해주기만 하면 됩니다.


import 'package:flutter/material.dart';

void main() async {
  // 1. Flutter 엔진 바인딩 초기화
  WidgetsFlutterBinding.ensureInitialized();

  // 2. 스플래시 화면이 표시되는 동안 초기화 작업 수행
  // 이 작업이 끝날 때까지 네이티브 스플래시는 계속 보입니다.
  await initializeApp();

  // 3. 모든 초기화가 끝나면 앱 실행
  runApp(const MyApp());
}

// 초기화 로직을 담당하는 함수
Future<void> initializeApp() async {
  print('초기화 시작...');
  // 실제 로직을 시뮬레이션하기 위해 2초 대기
  await Future.delayed(const Duration(seconds: 2));
  print('초기화 완료!');
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Splash Screen Demo',
      home: Scaffold(
        appBar: AppBar(title: const Text('Home Screen')),
        body: const Center(child: Text('앱 시작 완료!')),
      ),
    );
  }
}

이제 앱을 실행해보세요. 네이티브 스플래시 화면이 약 2초간 표시된 후(콘솔에 '초기화 시작...', '초기화 완료!'가 출력되는 동안), 바로 'Home Screen'이 나타나는 것을 확인할 수 있습니다. 중간에 흰 화면이나 별도의 로딩 화면 없이 아주 매끄럽게 전환됩니다.

runApp 전에 초기화 로직을 추가한 최종 코드 예시
runApp() 호출 전에 비동기 초기화 로직을 추가한 최종 코드 구조. 이 방식이 핵심입니다.

고급 기법 및 고려사항

1. 초기화 실패 시 처리 (서버 점검 등)

만약 `initializeApp()` 함수 내에서 서버 점검에 실패하거나 필수 데이터를 가져오지 못하면 어떻게 해야 할까요? `try-catch` 구문을 사용하여 에러를 처리하고, 그 결과에 따라 다른 화면을 보여줄 수 있습니다.


// main.dart

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 초기화 성공 여부를 저장할 변수
  bool isInitializedSuccessfully = false;
  try {
    await initializeApp();
    isInitializedSuccessfully = true;
  } catch (e) {
    print('초기화 중 에러 발생: $e');
    isInitializedSuccessfully = false;
  }

  // 결과에 따라 다른 위젯을 실행
  runApp(MyApp(isInitializedSuccessfully: isInitializedSuccessfully));
}

class MyApp extends StatelessWidget {
  final bool isInitializedSuccessfully;

  const MyApp({Key? key, required this.isInitializedSuccessfully}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Splash Screen Demo',
      // 초기화 성공 여부에 따라 홈 화면 또는 에러 화면을 보여줌
      home: isInitializedSuccessfully ? const HomeScreen() : const MaintenanceScreen(),
    );
  }
}

// 서버 점검 중 화면
class MaintenanceScreen extends StatelessWidget {
  const MaintenanceScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('서버 점검 중입니다. 잠시 후 다시 시도해주세요.'),
      ),
    );
  }
}

이처럼 초기화 결과 값을 `runApp`에 전달하여 앱의 시작점을 동적으로 결정할 수 있습니다. 이는 서버 점검 공지나 필수 업데이트 강제 등 다양한 시나리오에 매우 유용하게 사용됩니다.

2. `flutter_native_splash` 패키지와의 연동 심화

사실 `flutter_native_splash` 패키지는 위에서 설명한 로직을 더욱 안정적으로 구현할 수 있도록 도와주는 기능을 제공합니다. 기본적으로 이 패키지는 `runApp`이 호출되면 자동으로 스플래시를 제거하지만, 수동으로 제어할 수도 있습니다.

바로 `FlutterNativeSplash.preserve()`와 `FlutterNativeSplash.remove()` 함수입니다.

  • `FlutterNativeSplash.preserve(widgetsBinding: binding)`: `runApp`이 호출되어도 스플래시 화면을 제거하지 말고 계속 유지하도록 명령합니다.
  • `FlutterNativeSplash.remove()`: 유지되고 있던 스플래시 화면을 즉시 제거합니다.

이 두 함수를 사용하면 초기화 로직을 더욱 명시적으로 제어할 수 있습니다.


import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';

void main() async {
  // 1. 바인딩 초기화는 필수
  WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

  // 2. 스플래시 화면을 계속 유지하도록 설정
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

  // 3. 초기화 작업 수행
  await initializeApp();

  // 4. 모든 작업이 끝났으므로 스플래시 화면 제거
  FlutterNativeSplash.remove();

  // 5. 앱 실행
  runApp(const MyApp());
}

Future<void> initializeApp() async {
  // ... (이전과 동일한 초기화 로직)
  await Future.delayed(const Duration(seconds: 2));
}

이 방식은 `runApp` 호출 시점과 스플래시 제거 시점을 완벽하게 분리해주기 때문에, 더 복잡한 초기화 로직을 다룰 때 발생할 수 있는 미세한 타이밍 이슈까지 방지해주는 가장 안정적인 방법입니다.

3. 초기화 시간 제한 (Timeout)

만약 네트워크 상태가 좋지 않아 서버 응답이 무한정 길어지면 사용자는 스플래시 화면에 영원히 갇히게 될 수 있습니다. 이를 방지하기 위해 `Future.timeout()`을 사용하여 최대 대기 시간을 설정하는 것이 좋습니다.


Future<void> initializeApp() async {
  try {
    // 5초 이상 걸리면 TimeoutException 발생
    await realInitializationLogic().timeout(const Duration(seconds: 5));
  } catch (e) {
    // 타임아웃 또는 다른 에러 발생 시 처리
    print('초기화 시간 초과 또는 에러: $e');
    // 여기서 에러를 다시 던져서 main 함수에서 잡도록 할 수 있음
    throw Exception('Initialization failed');
  }
}

결론: 완벽한 첫인상을 위한 투자

앱의 첫인상은 단 몇 초 만에 결정됩니다. 어설픈 흰 화면 깜빡임과 부자연스러운 로딩 화면은 사용자의 기대감을 꺾고 앱의 신뢰도를 떨어뜨립니다. 오늘 우리가 살펴본 네이티브 스플래시 화면 구현과 `main()` 함수를 활용한 선행 초기화 로직은 이러한 문제를 완벽하게 해결하는 가장 효과적이고 전문적인 방법입니다.

정리하자면, 성공적인 앱 런칭 플로우의 핵심은 다음과 같습니다.

  1. `flutter_native_splash` 패키지를 사용하여 플랫폼 네이티브 스플래시를 손쉽게 설정합니다.
  2. `main()` 함수를 `async`로 만들고 `runApp()` 호출 전에 필요한 모든 초기화 로직(서버 통신, 데이터 로드 등)을 배치합니다.
  3. `WidgetsFlutterBinding.ensureInitialized()`를 호출하여 `runApp()` 이전에 Flutter 엔진 기능을 안전하게 사용합니다.
  4. (선택 사항이지만 권장) `FlutterNativeSplash.preserve()`와 `remove()`를 사용하여 스플래시 화면의 생명주기를 수동으로 제어하여 안정성을 높입니다.
  5. `try-catch`와 `timeout`을 활용하여 초기화 과정에서 발생할 수 있는 예외 상황에 견고하게 대비합니다.

이 기법들은 단순히 보기 좋은 스플래시 화면을 만드는 것을 넘어, 앱의 전체적인 아키텍처를 견고하게 만들고 사용자에게는 흠잡을 데 없이 매끄러운 경험을 선사하는 중요한 첫걸음입니다. 지금 바로 당신의 Flutter 프로젝트에 적용하여 사용자를 사로잡는 완벽한 첫인상을 만들어보세요.


0 개의 댓글:

Post a Comment