이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


시리즈 목차#

  1. Part 1: Flutter 소개와 개발 환경 설정
  2. Part 2: 기본 UI 구성과 상태 관리
  3. Part 3: 로컬 저장소 연동과 CRUD 완성
  4. Part 4: UI 다듬기와 빌드/배포 (이번 글)

Part 3 회고#

Part 3까지 진행하면서 할 일의 추가, 수정, 삭제, 완료 토글 기능과 로컬 저장소 연동까지 모두 구현했습니다. 기능적으로는 완성된 상태이지만, 실제로 사용자에게 배포하려면 몇 가지 더 다듬어야 할 부분이 있습니다.

이번 마지막 파트에서는 테마 적용, UX 개선, 앱 아이콘/스플래시 스크린 설정, 그리고 빌드와 배포 까지 다루겠습니다.


1. 테마와 다크 모드 지원#

라이트/다크 테마 정의#

앱의 전체적인 룩앤필을 통일하기 위해 ThemeData를 커스터마이징합니다. MaterialApp 위젯에서 라이트 테마와 다크 테마를 각각 정의할 수 있습니다.

// lib/app.dart
import 'package:flutter/material.dart';
import 'screens/todo_list_screen.dart';

class TodoApp extends StatelessWidget {
  const TodoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My Todo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.indigo,
          brightness: Brightness.light,
        ),
        useMaterial3: true,
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
        floatingActionButtonTheme: const FloatingActionButtonThemeData(
          elevation: 2,
        ),
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.indigo,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      ),
      themeMode: ThemeMode.system,
      home: const TodoListScreen(),
    );
  }
}

핵심은 themeMode: ThemeMode.system 설정입니다. 이렇게 하면 사용자의 기기 설정(다크 모드 ON/OFF) 에 따라 자동으로 테마가 전환됩니다. ThemeMode.light 또는 ThemeMode.dark로 고정할 수도 있습니다.

ColorScheme.fromSeed()를 사용하면 하나의 시드 컬러를 기반으로 Material 3 스펙에 맞는 전체 색상 팔레트가 자동 생성되어 편리합니다.


2. UX 개선#

2.1 Dismissible 위젯으로 스와이프 삭제#

리스트 항목을 왼쪽으로 스와이프하면 삭제되는 패턴은 모바일 앱에서 매우 흔합니다. Flutter에서는 Dismissible 위젯으로 간단히 구현할 수 있습니다.

// lib/widgets/todo_item.dart
Widget build(BuildContext context) {
  final theme = Theme.of(context);

  return Dismissible(
    key: Key(todo.id),
    direction: DismissDirection.endToStart,
    background: Container(
      alignment: Alignment.centerRight,
      padding: const EdgeInsets.only(right: 20),
      color: theme.colorScheme.error,
      child: Icon(
        Icons.delete,
        color: theme.colorScheme.onError,
      ),
    ),
    onDismissed: (direction) {
      onDelete(todo);
    },
    child: ListTile(
      leading: Checkbox(
        value: todo.isDone,
        onChanged: (value) => onToggle(todo),
      ),
      title: Text(
        todo.title,
        style: TextStyle(
          decoration: todo.isDone
              ? TextDecoration.lineThrough
              : TextDecoration.none,
          color: todo.isDone
              ? theme.colorScheme.outline
              : theme.colorScheme.onSurface,
        ),
      ),
      trailing: IconButton(
        icon: const Icon(Icons.edit),
        onPressed: () => onEdit(todo),
      ),
    ),
  );
}

direction: DismissDirection.endToStart로 설정하면 오른쪽에서 왼쪽으로만 스와이프할 수 있습니다. background 속성에 빨간 배경과 삭제 아이콘을 넣어 시각적 피드백을 제공합니다.

2.2 SnackBar로 삭제 취소(Undo) 기능#

실수로 삭제했을 때 되돌릴 수 있도록 SnackBar에 Undo 버튼을 추가합니다.

// lib/screens/todo_list_screen.dart
void _deleteTodo(Todo todo) {
  final index = _todos.indexOf(todo);
  setState(() {
    _todos.removeAt(index);
  });
  _saveTodos();

  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text('"${todo.title}" 이(가) 삭제되었습니다.'),
      action: SnackBarAction(
        label: '되돌리기',
        onPressed: () {
          setState(() {
            _todos.insert(index, todo);
          });
          _saveTodos();
        },
      ),
      duration: const Duration(seconds: 3),
    ),
  );
}

삭제 직후 원래 인덱스와 객체를 기억해 두었다가, 사용자가 “되돌리기"를 누르면 같은 위치에 다시 삽입합니다. duration을 3초로 설정하여 충분한 시간을 제공합니다.

2.3 할 일 정렬 (미완료 우선, 최신순)#

할 일 목록을 보여줄 때 미완료 항목이 위쪽에, 완료된 항목이 아래쪽에 오도록 정렬하면 사용성이 좋아집니다.

List<Todo> get _sortedTodos {
  final sorted = List<Todo>.from(_todos);
  sorted.sort((a, b) {
    // 미완료 항목을 먼저 표시
    if (a.isDone != b.isDone) {
      return a.isDone ? 1 : -1;
    }
    // 같은 상태라면 최신 항목을 먼저 표시
    return b.createdAt.compareTo(a.createdAt);
  });
  return sorted;
}

ListView.builder에서 _todos 대신 _sortedTodos를 사용하면 됩니다. 완료 상태가 바뀌면 자동으로 재정렬되어 완료된 항목이 아래로 내려갑니다.

2.4 빈 상태 화면#

할 일이 하나도 없을 때 빈 화면을 보여주면 사용자가 당황할 수 있습니다. 안내 메시지를 표시해 봅시다.

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('My Todo'),
    ),
    body: _todos.isEmpty
        ? Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(
                  Icons.checklist,
                  size: 80,
                  color: Theme.of(context).colorScheme.outline,
                ),
                const SizedBox(height: 16),
                Text(
                  '할 일이 없습니다.\n아래 버튼을 눌러 추가해 보세요!',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontSize: 16,
                    color: Theme.of(context).colorScheme.outline,
                  ),
                ),
              ],
            ),
          )
        : ListView.builder(
            itemCount: _sortedTodos.length,
            itemBuilder: (context, index) {
              final todo = _sortedTodos[index];
              return TodoItem(
                todo: todo,
                onToggle: _toggleTodo,
                onEdit: _editTodo,
                onDelete: _deleteTodo,
              );
            },
          ),
    floatingActionButton: FloatingActionButton(
      onPressed: _addTodo,
      child: const Icon(Icons.add),
    ),
  );
}

_todos.isEmpty 조건으로 분기하여, 목록이 비어 있으면 아이콘과 안내 텍스트를 보여줍니다.


3. 앱 아이콘 설정#

기본 Flutter 아이콘 대신 나만의 앱 아이콘을 설정하려면 flutter_launcher_icons 패키지를 사용합니다.

먼저 1024x1024 크기의 아이콘 이미지를 assets/icon/ 디렉토리에 준비합니다. 그런 다음 pubspec.yaml에 설정을 추가합니다.

dev_dependencies:
  flutter_launcher_icons: ^0.14.3

flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/icon/app_icon.png"
  adaptive_icon_background: "#FFFFFF"
  adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"

설정을 추가한 뒤 아래 명령어를 실행합니다.

dart run flutter_launcher_icons

이 명령어가 Android와 iOS 각 플랫폼에 맞는 크기의 아이콘을 자동으로 생성해 줍니다. Android의 경우 adaptive_icon_backgroundadaptive_icon_foreground를 별도로 설정하면 Android 8.0 이상에서 사용되는 적응형 아이콘도 지원할 수 있습니다.


4. 스플래시 스크린 설정#

앱이 시작될 때 잠깐 보이는 스플래시 스크린도 flutter_native_splash 패키지로 간편하게 설정할 수 있습니다.

dev_dependencies:
  flutter_native_splash: ^2.4.4

flutter_native_splash:
  color: "#FFFFFF"
  color_dark: "#1A1A2E"
  image: "assets/icon/splash_logo.png"
  android_12:
    image: "assets/icon/splash_logo.png"
    color: "#FFFFFF"
    color_dark: "#1A1A2E"

설정 후 아래 명령어를 실행합니다.

dart run flutter_native_splash:create

colorcolor_dark를 각각 지정하면 라이트/다크 모드에 맞는 스플래시 스크린이 자동으로 적용됩니다. Android 12 이상에서는 별도의 스플래시 스크린 API를 사용하므로 android_12 섹션도 함께 설정하는 것이 좋습니다.


5. 빌드 설정#

5.1 Android 빌드#

서명 키 생성#

Google Play에 배포하려면 서명 키가 필요합니다.

keytool -genkey -v -keystore ~/my-todo-key.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias my-todo

key.properties 설정#

프로젝트의 android/ 디렉토리에 key.properties 파일을 생성합니다. 이 파일은 반드시 .gitignore에 추가해야 합니다.

storePassword=<비밀번호>
keyPassword=<비밀번호>
keyAlias=my-todo
storeFile=<키 파일 경로>

build.gradle 설정#

android/app/build.gradle에 서명 설정을 추가합니다.

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

android {
    // ...

    signingConfigs {
        release {
            keyAlias keystoreProperties['keyAlias']
            keyPassword keystoreProperties['keyPassword']
            storeFile keystoreProperties['storeFile']
                ? file(keystoreProperties['storeFile']) : null
            storePassword keystoreProperties['storePassword']
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled true
            proguardFiles getDefaultProguardFile(
                'proguard-android-optimize.txt'),
                'proguard-rules.pro'
        }
    }
}

APK / AAB 빌드#

# APK 빌드 (직접 설치용)
flutter build apk --release

# AAB 빌드 (Google Play 업로드용)
flutter build appbundle --release

Google Play Console에 업로드할 때는 AAB(Android App Bundle) 형식을 사용합니다. 빌드 결과물은 build/app/outputs/ 디렉토리에 생성됩니다.

5.2 iOS 빌드#

iOS 빌드를 위해서는 macOS 환경과 Xcode가 필요합니다.

Xcode 프로젝트 설정#

ios/Runner.xcworkspace를 Xcode에서 열고 다음 항목을 확인합니다.

  • Bundle Identifier: 고유한 앱 ID (예: com.example.mytodo)
  • Team: Apple Developer 계정 선택
  • Deployment Target: 지원할 최소 iOS 버전
  • Signing & Capabilities: Automatically manage signing 체크

iOS 빌드 명령어#

# iOS 릴리스 빌드
flutter build ios --release

# IPA 파일 생성 (App Store 업로드용)
flutter build ipa --release

flutter build ipa 명령이 성공하면 build/ios/ipa/ 디렉토리에 .ipa 파일이 생성됩니다.


6. 배포 개요#

6.1 Google Play Store 배포#

  1. Google Play Console 에 개발자 계정을 등록합니다 (등록비 $25, 1회성).
  2. 새 앱을 생성하고 앱 정보(이름, 설명, 스크린샷 등) 를 입력합니다.
  3. AAB 파일을 업로드합니다.
  4. 콘텐츠 등급, 개인정보처리방침 등 필수 항목을 작성합니다.
  5. 내부 테스트 또는 비공개 테스트 트랙에 먼저 배포하여 검증합니다.
  6. 검증이 완료되면 프로덕션 트랙으로 출시합니다.

6.2 Apple App Store 배포#

  1. Apple Developer Program 에 등록합니다 (연회비 $99).
  2. App Store Connect에서 새 앱을 등록합니다.
  3. IPA 파일을 Xcode의 Organizer 또는 xcrun altool을 통해 업로드합니다.
  4. TestFlight를 통해 베타 테스터에게 먼저 배포하여 피드백을 받습니다.
  5. 앱 심사를 제출하고 승인을 기다립니다 (보통 1~3일 소요).
  6. 심사 승인 후 App Store에 출시합니다.

6.3 배포 시 주의사항#

  • 서명 키 관리: Android의 keystore 파일과 iOS의 인증서/프로비저닝 프로파일은 분실하면 앱 업데이트가 불가능해질 수 있습니다. 안전한 곳에 백업해 두세요.
  • 버전 관리: pubspec.yamlversion 필드(예: 1.0.0+1) 에서 빌드 번호를 업데이트해야 스토어에 새 버전을 업로드할 수 있습니다.
  • 난독화: flutter build apk --obfuscate --split-debug-info=build/debug-info 옵션으로 코드 난독화를 적용하면 리버스 엔지니어링을 어렵게 만들 수 있습니다.
  • 개인정보처리방침: 두 스토어 모두 개인정보처리방침 URL을 요구합니다. 간단한 페이지라도 준비해야 합니다.

7. 시리즈 마무리#

4개의 파트에 걸쳐 Flutter로 크로스플랫폼 To-Do 앱을 처음부터 끝까지 만들어 보았습니다. 전체 시리즈에서 다룬 내용을 정리하면 다음과 같습니다.

Part주요 내용
Part 1Flutter 소개, 개발 환경 설정, 프로젝트 생성
Part 2위젯 구성, 상태 관리 (setState), 기본 UI 완성
Part 3shared_preferences 로컬 저장소 연동, CRUD 기능 완성
Part 4다크 모드, UX 개선, 앱 아이콘/스플래시, 빌드 및 배포

간단한 To-Do 앱이지만, 이 과정에서 Flutter 개발의 핵심 흐름을 대부분 경험했습니다. 위젯 조합으로 UI를 구성하고, 상태 관리로 데이터를 다루고, 로컬 저장소에 데이터를 영속화하고, 최종적으로 각 플랫폼에 맞게 빌드/배포하는 전체 사이클을 한 번 돌아본 셈입니다.

더 도전해볼 만한 것들#

이 앱을 기반으로 확장해 볼 수 있는 주제들을 소개합니다.

  • Firebase 연동: Firestore를 사용하면 클라우드 동기화가 가능합니다. 여러 기기에서 같은 할 일 목록을 공유할 수 있습니다.
  • 알림 기능: flutter_local_notifications 패키지를 사용하면 마감일에 맞춰 알림을 보낼 수 있습니다.
  • 상태 관리 고도화: 앱이 커지면 setState 만으로는 한계가 있습니다. Riverpod, Bloc, Provider 등의 상태 관리 라이브러리를 도입해 보세요.
  • 카테고리/태그 기능: 할 일을 그룹으로 묶어 관리하면 더 실용적인 앱이 됩니다.
  • 위젯 테스트: flutter_test 패키지를 활용하여 UI 단위 테스트를 작성하면 리팩터링 시 안정성을 확보할 수 있습니다.
  • CI/CD 파이프라인: GitHub Actions나 Codemagic을 활용하면 커밋할 때마다 자동으로 빌드와 배포가 이루어지도록 설정할 수 있습니다.

Flutter는 하나의 코드베이스로 모바일뿐 아니라 웹, 데스크톱 앱까지 만들 수 있는 프레임워크입니다. 이 시리즈가 Flutter 개발을 시작하는 데 도움이 되었기를 바랍니다. 읽어 주셔서 감사합니다!