일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- Binding
- 마우이
- 플러터
- 애니메이션
- page
- 리엑트
- JavaScript
- typescript
- MS-SQL
- AnimationController
- 오류
- Maui
- React JS
- db
- 함수
- Animation
- Firebase
- 자바스크립트
- HTML
- .NET
- 파이어베이스
- MVVM
- 바인딩
- listview
- MSSQL
- spring boot
- 닷넷
- Flutter
- 깃허브
- GitHub
- Today
- Total
개발노트
51. [Flutter] FireStore 연동하기(NoSQL DB) 본문
Firestore
클라우드 기반 NoSQL 데이터베이스
Firebase Firestore는 Google Cloud Platform(GCP)의 일부로 제공되는 NoSQL 데이터베이스 서비스로, 클라우드에서 데이터를 저장하고 동기화하는 강력하면서도 사용하기 쉬운 도구입니다.
1. NoSQL 데이터베이스의 유연성
Firestore는 NoSQL 데이터베이스로서 JSON 형식의 문서(document)를 사용합니다. 이것은 개발자들이 구조적인 제약에서 벗어나고 유연한 데이터 모델을 채택할 수 있게 해줍니다. 문서는 컬렉션(collection)에 저장되며, 필요에 따라 서브컬렉션을 구성할 수 있습니다.
2. 실시간 업데이트
Firestore는 실시간 데이터베이스로서, 데이터의 변경이 발생하면 연결된 모든 클라이언트에 즉시 알림을 보냅니다. 이로써 사용자는 앱을 사용하는 동안 항상 최신 정보를 유지할 수 있습니다.
3. 스케일 가능성과 성능
Firestore는 확장 가능한 아키텍처를 가지고 있어, 앱이 성장함에 따라 자동으로 처리량을 확장할 수 있습니다. 강력한 쿼리 기능과 효율적인 색인 구조를 통해 데이터베이스에서 빠르고 효율적인 검색이 가능합니다.
4. 강력한 쿼리 기능
Firestore는 강력한 쿼리 기능을 제공하여 데이터를 유연하게 검색할 수 있습니다. 범위 쿼리, 정렬, 필터링 등 다양한 기능을 활용하여 개발자는 복잡한 데이터 검색을 쉽게 수행할 수 있습니다.
5. 오프라인 동기화
Firestore는 오프라인에서도 동작하며, 네트워크 연결이 끊겨 있을 때에도 앱 데이터를 로컬에서 캐시하고 변경 사항을 추적하여 다시 온라인 상태가 되었을 때 서버와 동기화할 수 있습니다.
6. 보안과 인증
Firebase Authentication과 통합된 Firestore는 사용자 인증을 효과적으로 관리합니다. 데이터베이스 규모의 권한을 사용하여 데이터의 안전성을 보장하며, Firebase Security Rules를 통해 세밀한 접근 제어가 가능합니다.
7. 서버리스 및 클라우드 호스팅
Firestore는 서버리스 서비스로서 별도의 인프라 구성이나 관리가 필요 없습니다. Google Cloud의 안정적인 인프라에서 호스팅되므로 안전하고 확장 가능합니다.
Firebase Firestore는 클라이언트 측 앱을 개발할 때 강력한 도구로, Firebase SDK를 사용하면 간편하게 연동할 수 있습니다. Firebase Console을 통해 데이터베이스를 모니터링하고 관리할 수 있으며, 다양한 Firebase 기능들과 통합되어 개발자에게 편리한 개발 및 배포 경험을 제공합니다. 이를 통해 앱의 데이터 관리와 실시간 동기화를 효과적으로 수행할 수 있습니다.
FireStore Database 구조
구현 목표.
Firebase 의 DB 기능인 Firestore를 Flutter에 연동하고, UserInfo Collection을 생성하고 [uid, name, email, link] Field를 가진 Document를 생성하여 값을 넣고 수정하는 기능을 만들어 보겠습니다.
*uid, email은 User Authenication에서 가져옴
*MVVM과 Repository를 이용하여 연동
구현 할 3가지 화면.
1번 화면(test_mvvmtest.dart)
- Email과 Password로 회원가입하는 화면(이전 포스팅 참고)
- 소스: https://mroh1226.tistory.com/156
2번 화면(testwidget.dart)
- 로그인 인증된 uid로 userInfo(name,email,link)를 보여주는 화면
- 수정 아이콘을 클릭 시, 3번 정보수정하기 화면으로 넘어감
3번 화면(testwidget2.dart)
- 로그인 인증된 uid의 name과 link를 수정 업데이트하는 화면
- 수정하기 버튼 클릭 시, 2번 화면으로 돌아가며, 업데이트된 userInfo를 볼 수 있음
시작 전, FireStore 기능 설정
1. FireStore 기능 추가
- 아래 포스팅과 같이 Firebase 프로젝트에 FireStore 기능을 시작합니다.
FireStore 기능 설정하기: https://mroh1226.tistory.com/159
2. Flutter 프로젝트에 Firestore 패키지를 설치합니다.
설치링크: https://pub.dev/packages/cloud_firestore/install
3. main.dart에 아래와 같이 firebase 연동 소스를 작성합니다.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
...
}
구현 소스.
UserInfo (Model)
/////////////////////////////Model
class UserInfo {
final String uid;
final String name;
final String email;
final String link;
UserInfo({
required this.uid,
required this.name,
required this.email,
required this.link,
});
UserInfo.empty()
: uid = "",
name = "",
email = "",
link = "";
UserInfo.fromJson(Map<String, dynamic> json)
: uid = json["uid"],
name = json["name"],
email = json["email"],
link = json["link"];
Map<String, dynamic> toJson() => {
"uid": uid,
"name": name,
"email": email,
"link": link,
};
UserInfo copyWith({String? name, String? email, String? link, String? uid}) {
//copyWith: 원본 객체를 유지하면서, 특정 속성에 지정된 값을 갖는 객체를 생성할 때 사용함(Flutter에서 자주 사용되는 개념)
//예시1) UserInfo 객체의 틀을 그대로 사용하면서 name이 "Gyu"인 객체를 생성할 때
//예시2) 지정된 ThemeData 에서 색이나 크기만 변경해서 쓰고싶을때
//이미있는 객체를 복사한뒤, 사본을 만들고 바꾸고싶은 값만 재정의해서 사용하는 개념,
//따라서 기존 객체의 데이터에 영향을 주지않음
return UserInfo(
uid: uid ?? this.uid,
name: name ?? this.name,
email: email ?? this.email,
link: link ?? this.link);
}
}
UserInfo 클래스는 사용자 정보를 나타내는 Dart 클래스입니다. 이 클래스는 사용자의 고유 식별자(uid), 이름(name), 이메일(email), 그리고 링크(link) 정보를 저장합니다.
생성자
UserInfo({
required this.uid,
required this.name,
required this.email,
required this.link,
});
UserInfo 클래스는 위와 같은 생성자를 가지고 있습니다. 이 생성자를 통해 객체를 생성할 때 필요한 정보를 모두 전달해야 합니다.
빈 객체 생성
UserInfo.empty()
: uid = "",
name = "",
email = "",
link = "";
**empty**라는 명명된 생성자는 비어있는 UserInfo 객체를 생성합니다. 이것은 초기화되지 않은 상태의 객체를 만들어 특정 값이 없는 경우 사용할 수 있습니다.
JSON에서 객체 생성
UserInfo.fromJson(Map<String, dynamic> json)
: uid = json["uid"],
name = json["name"],
email = json["email"],
link = json["link"];
**fromJson**이라는 명명된 생성자는 JSON 데이터에서 UserInfo 객체를 생성합니다. JSON 데이터는 맵 형태로 전달되며, 각 필드는 매핑되어 초기화됩니다.
객체를 JSON으로 변환
Map<String, dynamic> toJson() => {
"uid": uid,
"name": name,
"email": email,
"link": link,
};
toJson 메서드는 UserInfo 객체를 JSON 형태로 변환합니다. 이 메서드를 사용하면 객체를 외부에서 사용하기 적합한 형태로 직렬화할 수 있습니다.
객체 복사 및 수정
UserInfo copyWith({String? name, String? email, String? link, String? uid}) {
return UserInfo(
uid: uid ?? this.uid,
name: name ?? this.name,
email: email ?? this.email,
link: link ?? this.link,
);
}
copyWith 메서드는 원본 객체를 복사하면서 특정 속성에 새로운 값을 갖는 객체를 생성합니다. 이는 Flutter에서 자주 사용되며, 객체의 일부 속성만 변경하고자 할 때 유용합니다. 새로운 값을 지정하지 않으면 기존의 값을 유지합니다.
UserInfoRepository (UserInfo Repository)
///////////////////////////UserInfo Repository
class UserInfoRepository {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseStorage _storage = FirebaseStorage.instance;
//사용될 firestore => db와 storage instance를 생성함
//firestore컬랙션 생성 메소드
Future<void> createUserInfo(UserInfo userInfo) async {
await _firestore
.collection("userInfo")
.doc(userInfo.uid)
.set(userInfo.toJson());
}
//uid로 firestore에 있는 userInfo 컬랙션을 조회하여 데이터를 가져오는 메서드
Future<Map<String, dynamic>?> findUserInfo(String uid) async {
final doc = await _firestore.collection("userInfo").doc(uid).get();
return doc.data();
}
//uid로 firestore에 있는 userInfo 컬랙션에 data로 update하는 메서드
Future<void> updateUserInfo(String uid, Map<String, dynamic> data) async {
await _firestore.collection("userInfo").doc(uid).update(data);
}
}
final userInfoRepoProvider = Provider((ref) => UserInfoRepository());
UserInfoRepository 클래스는 사용자 정보를 Firestore 데이터베이스에 저장하고 관리하기 위한 Dart 클래스입니다. 이 클래스는 Firestore 및 Firebase Storage 인스턴스를 사용하여 사용자 정보를 생성, 조회, 및 업데이트하는 메서드를 제공합니다.
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseStorage _storage = FirebaseStorage.instance;
위의 코드는 Firestore 및 Firebase Storage의 인스턴스를 생성합니다. 이 인스턴스는 이후에 클래스 내에서 데이터베이스 및 스토리지 작업에 사용됩니다.
사용자 정보 생성
Future<void> createUserInfo(UserInfo userInfo) async {
await _firestore
.collection("userInfo")
.doc(userInfo.uid)
.set(userInfo.toJson());
}
createUserInfo 메서드는 주어진 UserInfo 객체를 Firestore 데이터베이스의 "userInfo" 컬렉션에 새로운 문서로 저장합니다. 사용자의 **uid**를 문서의 고유 식별자로 사용하고, **userInfo.toJson()**을 사용하여 객체를 JSON으로 직렬화하여 저장합니다.
사용자 정보 조회
Future<Map<String, dynamic>?> findUserInfo(String uid) async {
final doc = await _firestore.collection("userInfo").doc(uid).get();
return doc.data();
}
findUserInfo 메서드는 주어진 **uid**에 해당하는 사용자 정보를 Firestore 데이터베이스에서 조회합니다. 조회된 문서의 데이터를 반환하며, 데이터가 없는 경우 **null**을 반환합니다.
사용자 정보 업데이트
Future<void> updateUserInfo(String uid, Map<String, dynamic> data) async {
await _firestore.collection("userInfo").doc(uid).update(data);
}
updateUserInfo 메서드는 주어진 **uid**에 해당하는 사용자 정보를 Firestore 데이터베이스에서 업데이트합니다. data 매개변수는 업데이트할 데이터를 포함하는 맵입니다.
Provider 설정
final userInfoRepoProvider = Provider((ref) => UserInfoRepository());
**userInfoRepoProvider**는 UserInfoRepository 클래스의 인스턴스를 생성하기 위한 Provider입니다. 이 Provider를 사용하면 애플리케이션에서 **UserInfoRepository**에 쉽게 액세스할 수 있습니다.
이렇게 구성된 UserInfoRepository 클래스는 Firestore를 사용하여 사용자 정보를 효과적으로 관리할 수 있도록 도와줍니다.
UserInfoViewModel (ViewModel)
//////////////////////UserInfo ViewModel
class UserInfoViewModel extends AsyncNotifier<UserInfo> {
late final UserInfoRepository _userInfoRepository;
late final AuthRepository _authRepository;
//ViewModel에서 사용될 Repository 선언
@override
//ViewModel 초기화 메서드 build()
FutureOr<UserInfo> build() async {
_authRepository = ref.read(authRepoProvider);
_userInfoRepository = ref.read(userInfoRepoProvider);
//Repositiory 초기화
if (_authRepository.isLoggedIn) {
final userInfo =
await _userInfoRepository.findUserInfo(_authRepository.user!.uid);
//이미 로그인되어있다면, .findUserInfo에 uid로 조회하여 userInfo 데이터를 받아옴
if (userInfo != null) {
return UserInfo.fromJson(userInfo);
//받아온 userInfo에 데이터가 있다면 UserInfo를 return함
}
}
return UserInfo.empty();
//로그인되어있지 않다면 비어있는 UserInfo를 return함
}
//userInfo를 생성하는 메서드
Future<void> createAccount(UserCredential userCredential) async {
//firbaseAuth의 createUserWithEmailAndPassword()로
//return된 값을 이용하기 위해 UserCredential를 para값으로 받음
if (userCredential.user == null) throw Exception("Account not created");
state = const AsyncValue.loading();
final userInfo = UserInfo(
uid: userCredential.user!.uid,
name: userCredential.user!.displayName ?? "NoName",
email: userCredential.user!.email ?? "NoEmail",
link: ref.read(userInfoProvider).value?.link ?? "NoLink");
_userInfoRepository.createUserInfo(userInfo);
//firebase UserInfo 컬랙션생성
state = AsyncValue.data(userInfo);
//생성된 userInfo를 state에 넣음
}
Future<void> setUserName(String name) async {
state = const AsyncValue.loading();
final user = state.value!.copyWith(name: name);
await _userInfoRepository.updateUserInfo(user.uid, {"name": name});
//UserInfo 컬랙션, 다큐먼트 uid에 입력받은 name 업데이트
final updatedUserInfo = await _userInfoRepository.findUserInfo(user.uid);
//업데이트된 정보를 다시 가져오기 위해 findUserInfo 호출
state = AsyncValue.data(UserInfo.fromJson(updatedUserInfo!));
}
Future<void> setUserLink(String link) async {
state = const AsyncValue.loading();
final user = state.value!.copyWith(link: link);
await _userInfoRepository.updateUserInfo(user.uid, {"link": link});
//UserInfo 컬랙션, 다큐먼트 uid에 입력받은 link 업데이트
final updatedUserInfo = await _userInfoRepository.findUserInfo(user.uid);
//업데이트된 정보를 다시 가져오기 위해 findUserInfo 호출
state = AsyncValue.data(UserInfo.fromJson(updatedUserInfo!));
}
}
final userInfoProvider = AsyncNotifierProvider<UserInfoViewModel, UserInfo>(
() => UserInfoViewModel());
//UserInfoViewModel과 UserInfo를 View에 expose하기 위한 Provider 생성
UserInfoViewModel 클래스는 사용자 정보를 관리하고 표현하기 위한 클래스로, Flutter 프레임워크에서 상태 관리를 위해 사용됩니다. 이 클래스는 AsyncNotifier 클래스를 상속하며, 비동기적인 작업을 수행할 때 사용되는 **AsyncValue**를 통해 상태를 관리합니다.
class UserInfoViewModel extends AsyncNotifier<UserInfo> {
late final UserInfoRepository _userInfoRepository;
late final AuthRepository _authRepository;
위의 코드는 **_userInfoRepository**와 **_authRepository**라는 두 가지 리포지토리를 선언하고 있습니다. 이들은 각각 사용자 정보와 사용자 인증과 관련된 작업을 수행하기 위한 리포지토리입니다.
@override
FutureOr<UserInfo> build() async {
_authRepository = ref.read(authRepoProvider);
_userInfoRepository = ref.read(userInfoRepoProvider);
build 메서드는 AsyncNotifier 클래스의 메서드로, 초기화 시에 호출되며 사용자 정보를 초기화합니다. **_authRepository**와 **_userInfoRepository**를 Provider로부터 읽어와 초기화합니다.
if (_authRepository.isLoggedIn) {
final userInfo = await _userInfoRepository.findUserInfo(_authRepository.user!.uid);
if (userInfo != null) {
return UserInfo.fromJson(userInfo);
}
}
return UserInfo.empty();
build 메서드는 사용자가 이미 로그인한 경우에는 해당 사용자의 정보를 조회하고, 정보가 있으면 **UserInfo.fromJson(userInfo)**로 사용자 정보를 반환합니다. 로그인하지 않은 경우에는 빈 **UserInfo**를 반환합니다.
Future<void> createAccount(UserCredential userCredential) async {
// ...
}
Future<void> setUserName(String name) async {
// ...
}
Future<void> setUserLink(String link) async {
// ...
}
createAccount, setUserName, setUserLink 메서드들은 각각 사용자 계정 생성, 사용자 이름 설정, 사용자 링크 설정을 담당합니다. 이들은 비동기 작업을 수행하며, 상태 변화를 **state**를 통해 알립니다.
final userInfoProvider = AsyncNotifierProvider<UserInfoViewModel, UserInfo>(
() => UserInfoViewModel());
**userInfoProvider**는 UserInfoViewModel 및 **UserInfo**를 뷰(View)에 노출하기 위한 Provider입니다. 이를 통해 사용자 정보와 뷰를 연결할 수 있습니다.
이렇게 구성된 UserInfoViewModel 클래스는 사용자 정보를 관리하고 뷰에 상태를 전달하는 데 사용됩니다.
TestScreen (2번 화면 View - UserInfo 정보 조회화면)
//////////////////////////View
class TestScreen extends ConsumerStatefulWidget {
static String routeName = "testwidget";
static String routeURL = "/testwidget";
const TestScreen({super.key});
@override
ConsumerState<TestScreen> createState() => _TestScreenState();
}
class _TestScreenState extends ConsumerState<TestScreen> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return ref.watch(userProvider).when(
data: (data) {
return Scaffold(
body: Stack(
children: [
Positioned(
top: 30,
right: 30,
child: GestureDetector(
onTap: () =>
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const TestScreen2(),
)),
child: const Icon(
Icons.mode_edit_outline_outlined,
size: 40,
),
)),
Center(
child: Column(
children: [
const Padding(
padding: EdgeInsets.only(top: 70, bottom: 20),
child: CircleAvatar(
radius: 150,
backgroundColor: Colors.amber,
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ref.watch(userInfoProvider).value!.name,
style: const TextStyle(fontSize: 40),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ref.watch(userInfoProvider).value!.email,
style: const TextStyle(fontSize: 40),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Transform.rotate(
angle: 2.9,
child: Icon(
Icons.link,
size: 32,
color: Colors.blue.shade400,
),
),
Text(
ref.watch(userInfoProvider).value!.link,
style: const TextStyle(fontSize: 20),
),
],
)
],
),
),
],
),
);
},
error: (error, stackTrace) {
return Text(error.toString());
},
loading: () => const CircularProgressIndicator(),
);
}
}
사용자 이름, 이메일, 링크 Text로 보여주는 소스
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ref.watch(userInfoProvider).value!.name,
style: const TextStyle(fontSize: 40),
),
],
),
// ...
- Row: 가로로 위젯을 배열할 때 사용하는 위젯입니다.
- Text: 텍스트를 표시하는 위젯입니다. 여기서는 사용자 이름, 이메일, 링크를 표시합니다.
when 메서드
ref.watch(userProvider).when 메서드는 **userProvider**의 상태를 감시하고, 상태에 따라 적절한 UI를 반환합니다.
- data: 데이터가 있는 경우 해당 데이터를 사용하여 UI를 반환합니다.
- error: 에러가 발생한 경우 에러 메시지를 표시합니다.
- loading: 로딩 중인 경우 로딩 인디케이터를 표시합니다.
TestScreen2 (3번 화면 View - UserInfo name, link 수정화면)
class TestScreen2 extends ConsumerStatefulWidget {
static String routeName = "testwidget2";
static String routeURL = "testwidget2";
const TestScreen2({super.key});
@override
ConsumerState<TestScreen2> createState() => _TestScreen2State();
}
class _TestScreen2State extends ConsumerState<TestScreen2> {
final TextEditingController _nameEditingController = TextEditingController();
final TextEditingController _linkEditingController = TextEditingController();
@override
void initState() {
_nameEditingController.addListener(() {});
_linkEditingController.addListener(() {});
super.initState();
}
@override
void dispose() {
_nameEditingController.dispose();
_linkEditingController.dispose();
super.dispose();
}
void _onPressed() {
if (_nameEditingController.text == "" ||
_linkEditingController.text == "") {
return;
}
ref
.read(userInfoProvider.notifier)
.setUserName(_nameEditingController.text);
ref
.read(userInfoProvider.notifier)
.setUserLink(_linkEditingController.text);
Navigator.of(context).pop();
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const TestScreen(),
));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('정보 수정하기'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 50),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _nameEditingController,
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: "Name 입력",
prefixIcon: Icon(
Icons.person_sharp,
size: 32,
)),
),
const SizedBox(
height: 20,
),
TextField(
controller: _linkEditingController,
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: "Link 입력",
prefixIcon: Icon(
Icons.link_sharp,
size: 32,
)),
),
const SizedBox(
height: 40,
),
CupertinoButton(
color: Colors.red.shade400,
onPressed: _onPressed,
child: const Text("수정하기"),
)
],
),
),
));
}
}
- TextField에 name, link를 입력 받고 입력받은 Text값을 Controller로 가져옵니다.
- 가져온 Text 값(String)을 userInfoProvider를 통해 setUserName(), setUserLink() 메소드를 호출하여 넘겨줍니다.
- 넘어간 값으로 firestore Document값과 state의 값을 수정 Update하고 최신화 시켜줍니다.
1번 화면(test_mvvmtest.dart)
- Email과 Password로 회원가입하는 화면(이전 포스팅 참고)
- 소스: https://mroh1226.tistory.com/156
LoginViewModel (1번 화면 ViewModel)에 아래와 같이 userCredential을 추가 하고 createAccount를 호출하여 컬랙션을 만들어줍니다.
Future<void> createUsers(String email, String password) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final userCredential =
await ref.read(authRepoProvider).createUser(email, password);
await ref.read(userInfoProvider.notifier).createAccount(userCredential);
});
}
완성된 모습.
'앱 개발 > Flutter' 카테고리의 다른 글
53. [Flutter] Firebase Function 기능 활용하기 (1) | 2024.01.08 |
---|---|
52. [Flutter] Firebase Storage 이용하기 (파일 업로드) (1) | 2024.01.05 |
50. [Flutter] Firebase Authentication (깃허브) 인증 구현하기 (1) | 2023.12.07 |
49. [Flutter] Firebase Authentication (이메일/비밀번호) 인증 구현하기 (2) | 2023.12.01 |
48. [Flutter] Riverpod Provider 종류와 사용법(with MVVM 패턴) (1) | 2023.11.24 |