크로스플랫폼 To-Do 앱 만들기 Part 4: UI 다듬기와 빌드/배포
이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
시리즈 목차#
- Part 1: Flutter 소개와 개발 환경 설정
- Part 2: 기본 UI 구성과 상태 관리
- Part 3: 로컬 저장소 연동과 CRUD 완성
- 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_background와 adaptive_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
color와 color_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 배포#
- Google Play Console 에 개발자 계정을 등록합니다 (등록비 $25, 1회성).
- 새 앱을 생성하고 앱 정보(이름, 설명, 스크린샷 등) 를 입력합니다.
- AAB 파일을 업로드합니다.
- 콘텐츠 등급, 개인정보처리방침 등 필수 항목을 작성합니다.
- 내부 테스트 또는 비공개 테스트 트랙에 먼저 배포하여 검증합니다.
- 검증이 완료되면 프로덕션 트랙으로 출시합니다.
6.2 Apple App Store 배포#
- Apple Developer Program 에 등록합니다 (연회비 $99).
- App Store Connect에서 새 앱을 등록합니다.
- IPA 파일을 Xcode의 Organizer 또는
xcrun altool을 통해 업로드합니다. - TestFlight를 통해 베타 테스터에게 먼저 배포하여 피드백을 받습니다.
- 앱 심사를 제출하고 승인을 기다립니다 (보통 1~3일 소요).
- 심사 승인 후 App Store에 출시합니다.
6.3 배포 시 주의사항#
- 서명 키 관리: Android의 keystore 파일과 iOS의 인증서/프로비저닝 프로파일은 분실하면 앱 업데이트가 불가능해질 수 있습니다. 안전한 곳에 백업해 두세요.
- 버전 관리:
pubspec.yaml의version필드(예: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 개발을 시작하는 데 도움이 되었기를 바랍니다. 읽어 주셔서 감사합니다!