안녕하세요. Flutter 에 입문하여 앱을 출시해 보고자 간단한 노트 앱을 만들고 있습니다.
이번 글에서는 노트를 쓰고, 노트를 조회하는 간단한 기능을 Provider 패키지를 이용하여 구현해보겠습니다.
먼저 구현 결과물부터 보여드리겠습니다.
처음에는 노트를 작성할 수 있는 노트 쓰기 화면이 나타나고, 저장을 누르면 노트 목록 화면으로 이동합니다. 이때, 휴지통 아이콘을 누르면 노트를 지울 수 있습니다.
그럼 이제 프로젝트를 시작해 봅시다!
플러터 프로젝트 생성
먼저 프로젝트를 생성해야겠죠. 처음에 프로젝트를 생성할 때 사용하는 명령어는 다음과 같습니다.
flutter create {프로젝트 이름}
그런데 이 경우에는 프로젝트의 도메인 설정이 `com.example.{프로젝트 이름}` 으로 표시되더라고요. 저는 이걸 따로 수정하기가 귀찮아서 다음 명령어로 프로젝트를 생성했습니다.
flutter create --org com.{도메인 이름} {프로젝트 이름}
이 명령어를 사용하면 `com.{도메인 이름}.{프로젝트 이름}` 으로 설정되기 때문에 번거롭게 파일을 찾아가면 수정할 필요가 없더라고요.
프로젝트 폴더 구조
플러터 프로젝트를 생성했다면 구조를 잡아보겠습니다. 플러터는 mvvm 패턴(모델, 뷰, 뷰모델)이 가장 유명한 것 같은데요. 저는 입문자라 그런지 어렵게 느껴지기도 하고 mvvm 이라는 명칭도 와닿지 않더라고요.
그래서 저에게 익숙한 view - service - repository 레이어로 폴더 구조를 나누었습니다. 큰 맥락에서 보면 mvvm 패턴과 별반 다르지 않다고 생각했고, 무엇보다 다양한 시도를 해보면서 직접 문제를 만나보고 싶은 욕구가 있었습니다.
그렇게 해서 만든 폴더구조는 다음과 같습니다.
lib/
│
├── models/
│ └── note_model.dart # 노트 데이터 모델
├── repositories/
│ └── note_repository.dart # 노트 저장소 (데이터 처리)
├── services/
│ └── note_service.dart # 비즈니스 로직 처리 (노트 상태 관리)
├── utils/
│ └── color_palette.dart # 색상 팔레트 (UI 디자인 요소)
├── views/
│ ├── note_detail_view.dart # 노트 쓰기 화면
│ ├── note_list_view.dart # 노트 목록 화면
│ └── settings_view.dart # 설정 화면 (이번 글에서는 빈 화면)
└── main.dart # 앱의 엔트리 포인트
자, 그럼 본격적으로 구현을 해볼까요~?
구현 1. 노트 데이터를 정의하자 (Models)
모델은 노트의 데이터 구조를 정의하는 곳입니다. 이 앱에서는 NoteModel
클래스를 사용해 노트의 id
, content
, createdAt
속성을 관리합니다. 또한, 이 데이터를 JSON으로 변환하거나, 반대로 JSON 데이터를 객체로 변환할 수 있는 메서드도 포함했습니다.
// lib/models/note_model.dart
class NoteModel {
final String id;
final String content;
final DateTime createdAt;
NoteModel({
required this.id,
required this.content,
required this.createdAt,
});
// JSON 변환 메소드
factory NoteModel.fromJson(Map<String, dynamic> json) {
return NoteModel(
id: json['id'],
content: json['content'],
createdAt: DateTime.parse(json['createdAt']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'content': content,
'createdAt': createdAt.toIso8601String(),
};
}
}
구현 2. 저장소 레이어를 만들자 (Repositories)
NoteRepository는 노트 데이터를 로컬에 저장하거나 불러오는 역할을 합니다. shared_preferences
라는 패키지 사용해 노트를 간단히 로컬에 저장하고, 목록을 불러오는 기능을 구현했습니다. 이곳은 실제 데이터 소스와 상호작용하는 레이어 계층입니다.
데이터를 저장하고 불러오기 위해, 먼저 shared_preferences 패키지를 설치합니다.
flutter pub add shared_preferences
설치가 끝난 다음에는 아래처럼 코드를 작성해 주세요. 기본적인 CRUD 를 구현했습니다.
// lib/repositories/note_repository.dart
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import '../models/note_model.dart';
class NoteRepository {
static const String _key = 'notes';
// 저장된 모든 노트 가져오기
Future<List<NoteModel>> fetchNotes() async {
final prefs = await SharedPreferences.getInstance();
final notesJson = prefs.getString(_key);
if (notesJson != null) {
List<dynamic> jsonList = jsonDecode(notesJson);
return jsonList.map((json) => NoteModel.fromJson(json)).toList();
}
return [];
}
// 노트 저장하기
Future<void> saveNote(NoteModel note) async {
final prefs = await SharedPreferences.getInstance();
List<NoteModel> notes = await fetchNotes();
notes.add(note);
final notesJson = jsonEncode(notes.map((n) => n.toJson()).toList());
await prefs.setString(_key, notesJson);
}
// 노트 삭제하기
Future<void> deleteNoteById(String id) async {
final prefs = await SharedPreferences.getInstance();
List<NoteModel> notes = await fetchNotes();
notes.removeWhere((note) => note.id == id);
final notesJson = jsonEncode(notes.map((n) => n.toJson()).toList());
await prefs.setString(_key, notesJson);
}
}
구현 3. 비즈니스 로직을 만들자 (Services)
NoteService는 ChangeNotifier
를 상속받아 상태 관리를 담당합니다. 이곳에서 노트를 추가, 삭제하거나 노트 목록을 가져오는 로직을 처리하고, 상태가 변경되면 UI에 자동으로 반영될 수 있도록 notifyListeners()
를 호출합니다.
ChangeNotifier 는 flutter/foundation.dart 에서 가져와 사용합니다.
import 'package:flutter/foundation.dart';
extends 를 이용하면 ChangeNotifier 를 상속받을 수 있습니다.
class NoteService extends ChangeNotifier { ...
Service 레이어에서는 Repository 등을 이용해 비즈니스 로직을 처리합니다. 아래 전체 코드를 보시면 Repository 를 통해 노트를 조회, 추가, 삭제합니다. 이때 상태가 변경되므로 notifyListeners() 를 호출하여 UI에 반영할 수 있도록 알립니다.
// lib/services/note_service.dart
import 'package:flutter/foundation.dart';
import '../models/note_model.dart';
import '../repositories/note_repository.dart';
class NoteService extends ChangeNotifier {
final NoteRepository _noteRepository = NoteRepository();
List<NoteModel> _notes = [];
List<NoteModel> get notes => _notes;
// 모든 노트 불러오기
Future<void> getNotes() async {
_notes = await _noteRepository.fetchNotes();
notifyListeners();
}
// 노트 추가하기
Future<void> addNote(String content) async {
if (content.length > 300) {
throw Exception('노트는 300자를 넘을 수 없습니다.');
}
final note = NoteModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
content: content,
createdAt: DateTime.now(),
);
await _noteRepository.saveNote(note);
_notes.add(note);
notifyListeners();
}
// 노트 삭제하기
Future<void> deleteNoteById(String id) async {
await _noteRepository.deleteNoteById(id);
_notes.removeWhere((note) => note.id == id);
notifyListeners();
}
}
구현 4. 색상 팔레트를 정의하자 (Utils)
색상 팔레트 파일은 앱 전반에 걸친 디자인 일관성을 유지하기 위한 설정입니다. 일단은 간단하게 색상 팔레트를 두었지만 더 많은 설정이 추가되면 리팩터링을 할 예정입니다.
// lib/utils/color_palette.dart
import 'package:flutter/material.dart';
class ColorPalette {
static const Color background = Color(0xFF1C1C1E); // 배경 검정색
static const Color noteYellow = Color(0xFFFFE599); // 노란색
static const Color noteBlue = Color(0xFFB6E5F8); // 파란색
static const Color textWhite = Color(0xFFFFFFFF); // 텍스트 하얀색
}
구현 5. 노트 쓰기 화면을 그리자 (Views)
사용자가 노트를 입력할 수 있는 화면입니다. 작성 후 저장 버튼을 누르면 NoteService
를 통해 데이터를 저장하고, 저장이 완료되면 노트 목록 화면으로 이동합니다.
View 파일에서는 Provider 를 함께 사용합니다. 먼저 명령어로 Provider 를 설치하겠습니다.
flutter pub add provider
그다음 Provider 를 import 합니다.
import 'package:provider/provider.dart';
Provider 는 상위에서 제공된 객체를 가져와서 사용할 수 있게 도와줍니다. 예를 들어 main.dart 를 아래처럼 작성하면 NoteService 객체를 MyApp 에 주입하는 것이죠.
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => NoteService()),
],
child: MyApp(),
),
);
}
여기서 ChangeNotifierProvider는 NoteService라는 객체를 앱 전반에 제공하고, 하위 위젯에서 Provider.of 또는 Consumer를 통해 이 객체에 접근할 수 있습니다.
노트 쓰기 화면의 전체 코드는 아래와 같습니다.
// lib/views/note_detail_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:myapp/views/note_list_view.dart';
import '../services/note_service.dart';
import '../utils/color_palette.dart';
class NoteDetailView extends StatefulWidget {
const NoteDetailView({super.key});
@override
_NoteDetailViewState createState() => _NoteDetailViewState();
}
class _NoteDetailViewState extends State<NoteDetailView> {
final TextEditingController _controller = TextEditingController();
int _charCount = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorPalette.background,
appBar: AppBar(
title: Text('노트 쓰기', style: TextStyle(color: ColorPalette.textWhite)),
backgroundColor: ColorPalette.background,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _controller,
maxLength: 300,
onChanged: (text) {
setState(() {
_charCount = text.length;
});
},
style: TextStyle(color: ColorPalette.textWhite),
decoration: InputDecoration(
hintText: '노트를 작성하세요...',
hintStyle: TextStyle(color: Colors.grey),
counterText: '',
border: InputBorder.none,
),
),
SizedBox(height: 10),
Text(
'$_charCount / 300',
style: TextStyle(color: Colors.grey),
),
Spacer(),
ElevatedButton(
onPressed: () async {
if (_controller.text.isNotEmpty) {
await Provider.of<NoteService>(context, listen: false)
.addNote(_controller.text);
// 노트를 저장한 후 NoteListView로 이동
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => NoteListView()),
);
}
},
child: Text('저장', style: TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
),
),
],
),
),
);
}
}
노트를 저장하면 Provider 로 주입받은 NoteService 를 통해 노트를 저장합니다.
이때, 옵션으로 listen: false 파라미터를 넣는데요. 이것은 상태 변화 알림을 받지 않겠다는 뜻입니다. 노트를 저장한 다음에는 Navigator 를 통해 곧바로 NoteListView(노트 목록 화면)로 이동할 것이므로 현재 화면이 새로 빌드되지 않도록 listen: false 으로 막은 것입니다.
구현 6. 노트 목록 화면을 그리자 (Views)
저장된 노트 목록을 조회할 수 있는 화면입니다. Provider를 사용해 상태를 구독하고, 상태 변화에 따라 UI가 자동으로 업데이트되도록 구현해 보겠습니다.
우선 initState() 를 통해 위젯이 처음 생성되면 노트를 불러옵니다.
@override
void initState() {
super.initState();
Provider.of<NoteService>(context, listen: false).getNotes();
}
이때에도 Provider 를 사용하여 NoteService 을 호출하여 노트를 불러오는데요. 노트 쓰기 화면과 마찬가지로 listen: false 옵션을 넣습니다. 그 이유는 initState() 이 처음 생성될 때만 호출되므로 굳이 상태 변화를 구독하고 있을 필요가 없기 때문입니다.
대신, 상태 변화에 따른 UI 업데이트가 필요한 경우에는 이렇게 Consumer 를 사용하여 상태 알림을 받습니다.
body: Consumer<NoteService>(
builder: (context, noteService, child) { ...
Consumer 를 이용하면 상태가 변할 때 전체 위젯이 아닌, 정확히 필요한 위젯만 다시 빌드할 수 있습니다.
노트 목록 화면의 전체 코드는 아래와 같습니다. NoteService 로부터 노트를 가져와 ListView 를 이용해 목록을 그립니다.
// lib/views/note_list_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/note_service.dart';
import '../utils/color_palette.dart';
class NoteListView extends StatefulWidget {
const NoteListView({super.key});
@override
_NoteListViewState createState() => _NoteListViewState();
}
class _NoteListViewState extends State<NoteListView> {
@override
void initState() {
super.initState();
Provider.of<NoteService>(context, listen: false).getNotes();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorPalette.background,
appBar: AppBar(
title: Text('노트 목록', style: TextStyle(color: ColorPalette.textWhite)),
backgroundColor: ColorPalette.background,
),
body: Consumer<NoteService>(
builder: (context, noteService, child) {
if (noteService.notes.isEmpty) {
return Center(
child: Text(
'저장된 노트가 없습니다.',
style: TextStyle(color: ColorPalette.textWhite),
),
);
}
return ListView.builder(
itemCount: noteService.notes.length,
itemBuilder: (context, index) {
final note = noteService.notes[index];
return ListTile(
title: Text(
note.content,
style: TextStyle(color: ColorPalette.textWhite),
),
subtitle: Text(
note.createdAt.toString(),
style: TextStyle(color: Colors.grey),
),
trailing: IconButton(
icon: Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
await noteService.deleteNoteById(note.id);
},
),
);
},
);
},
),
);
}
}
구현 7. 앱을 실행하자 (main)
main.dart 파일은 앱을 실행하는 엔트리 포인트입니다. 여기서 MultiProvider
를 사용해 NoteService
를 앱의 모든 화면에서 사용할 수 있도록 설정하고, 초기 화면은 노트 쓰기 화면으로 이동하도록 해보겠습니다.
먼저 ChangeNotifierProvider 에 대해 설명하자면, ChangeNotifier 를 상속받은 클래스의 상태 변화가 있을 때 이를 구독하고 있는 위젯들이 자동으로 UI를 업데이트할 수 있도록 돕는 역할을 합니다.
앞서 우리는 ChangeNotifier 를 NoteService 에 상속했는데요. NoteService 의 상태 변화를 구독하는 위젯인 Views 에서 알아차리고 UI를 업데이트하는 거죠.
적용하는 방법은 아래 코드처럼 MyApp 클래스 안에 providers 를 명시합니다.
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<NoteService>(create: (_) => NoteService()),
],
...
main.dart 의 전체 코드는 아래와 같습니다.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/note_service.dart';
import 'views/note_detail_view.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<NoteService>(create: (_) => NoteService()),
],
child: MaterialApp(
home: NoteDetailView(),
theme: ThemeData.dark(),
),
);
}
}
Provider 를 사용한 이유
Provider 는 가장 많이 사용하는 상태 관리 패키지 중에 하나로, 플러터에서 쉽게 상태를 관리하고 UI에 반영할 수 있게 해줍니다. 이번 노트 앱 프로젝트에서 Provider를 사용한 이유는 크게 다음 세 가지입니다.
1. 상태의 전역 공유: ChangeNotifierProvider로 앱 전체에서 NoteService 의 상태를 공유할 수 있습니다.
2. 자동 UI 업데이트: 사용자가 노트를 추가하거나 삭제할 때, notifyListeners()를 호출하여 상태 변화를 알려주고, 이를 구독하는 노트 목록 화면이 자동으로 업데이트됩니다.
3. 상태와 UI의 분리: NoteService는 상태 관리와 비즈니스 로직만 담당하고, UI는 이 상태를 구독하여 데이터를 표시하는 역할만 합니다.
한 문장으로 요약하자면 '상태를 효율적으로 관리하고 UI에 쉽게 반영하기 위함'이라고 할 수 있습니다.
마무리
이번 글에서는 Flutter 로 노트를 쓰고, 저장하고, 목록을 조회하는 간단한 노트 앱을 만들어보았습니다. 아래 내용을 다시 한번 확인해 주세요.
- Provider를 사용해 상태 관리하기
- ChangeNotifier를 통해 상태 변경을 UI에 알리기
- shared_preferences를 사용한 로컬 데이터 저장 및 불러오기
다음 글에서는 노트 앱을 더 고도화하여 하단 메뉴바를 추가해 보겠습니다.
'나는 이렇게 논다 > Flutter 로 간단한 노트 앱을 만들어보자' 카테고리의 다른 글
Flutter로 간단한 노트 앱을 만들어보자 ⑥ - 노트에 중요 표시 라벨 붙이기 (0) | 2024.11.08 |
---|---|
Flutter로 간단한 노트 앱을 만들어보자 ⑤ - 노트 검색 기능 구현 (유사도 알고리즘을 곁들인) (4) | 2024.11.07 |
Flutter로 간단한 노트 앱을 만들어보자 ④ - 노트 수정 기능 구현 (0) | 2024.11.06 |
Flutter로 간단한 노트 앱을 만들어보자 ③ - GoRouter로 네비게이션 바 리팩터링 (0) | 2024.10.31 |
Flutter로 간단한 노트 앱을 만들어보자 ② - 하단 네비게이션 바 구현 (0) | 2024.10.30 |