이 글은 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 1 Flutter 소개, 개발 환경 설정, 프로젝트 생성
Part 2 위젯 구성, 상태 관리 (setState), 기본 UI 완성
Part 3 shared_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 개발을 시작하는 데 도움이 되었기를 바랍니다. 읽어 주셔서 감사합니다!