Bloc Pattern (with flutter_bloc Plugin)
Updated:
1.왜? Bloc Pattern 써야 하는가?
-
모든 flutter app 은 Stateful Widget 을 사용해서 동적인 화면을 변경을
setState()
를 사용해서 쉽게 구현 할 수 있습니다. -
하지만, 이런한 구조는 단순한 위젯의 구조 일 경우에 해당되는 부분이고, 보통 App 을 만들고 사용자들에게 배포를 할 경우 서버로 부터 data 를 받아오거나, local 에 있는 data 를 business logic 를 통해 만들경우 복잡한 Widget tree 가 되게 됩니다. 이러한 경우에
setState()
로만 사용해서 data 를 변경 할 경우 더 복잡하게 됩니다 -
이러한 불편하고, 복잡한 부분을 해결하기 위해 Bloc (Business Logic Component) pattern 이라고 합니다. 말 그대로 Business logic 을 따로 때내어 분리한 일종의 component 입니다.
-
UI 부분에서는 business 부분을 관여할 필요가 없게 되며, data 의 값을 가공할 필요가 없게 되고 bloc pattern 로 정리된 data 를 그대로 받아서 보여주기만 하면 되고, 모든 business logic 은 bloc pattern 에서 다루게 됩니다.
-
위의 그림에서는 UI widget 에서는 events 만 bloc 에 넘기고 bloc 안에 transition 이라던지 모든 state 는 bloc 안에서 Streams 로 UI widget 에 넘기게 됩니다
-
bloc pattern 의 가장 큰 장점은 state 를 widget tree 상에서 관리하지 않기 때문에 예전에는 만약
scaffold
상의setState()
를 변경할때는 전체 widget tree 에서 build context 에서 reload 가 되지 않기 때문에 tree 구조가 복잡할 경우, app 구동 속도가 빨라 집니다
Bloc pattern 의 단점
-
관리되는 파일이 많이 집니다. (state 를 다루는게 하나의 Bloc 만 있는것이 아니라, 다수의 Bloc 이 있을 것인데, 관리하기가 복잡하고,
setState()
와 비교해도 복잡한 구조를 가지고 있게 됩니다) -
그것을 보안하기 위해서 나온것이
Provider
가 있습니다
Bloc pattern code 예시로
- flutter 설치시 demo 로 보이는 숫자 count 되는 화면을 bloc pattern 으로 만든 예시 입니다
구조는 ui 부분 ui 부분의 body 부분은 component 로 분리해서 count_view.dart
파일로 이루어 져있고 모든 state 값은 bloc 폴더에 count_bloc.dart
에 위치하게 됩니다
// in main.dart
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: ElevatedButton(
child: Text("bloc 패턴"),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) {
return BlocDisplayWidget();
}));
},
),
),
],
),
),
);
}
}
// in bloc_display_widget.dart
// Global 변수생성
CountBloc countBloc;
class BlocDisplayWidget extends StatefulWidget {
BlocDisplayWidget({Key key}) : super(key: key);
@override
_BlocDisplayWidgetState createState() => _BlocDisplayWidgetState();
}
class _BlocDisplayWidgetState extends State<BlocDisplayWidget> {
// initState() : Bloc 생성
@override
void initState() {
super.initState();
countBloc = CountBloc();
}
// dispose() : 페이지가 닫히면 app 이 dispose 되게 함
@override
void dispose() {
super.dispose();
countBloc.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("bloc 패턴"),
),
body: CountView(countBloc: countBloc),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
countBloc.add();
},
),
);
}
}
// in count_view.dart
class CountView extends StatelessWidget {
CountBloc countBloc;
CountView({Key key, this.countBloc}) : super(key: key);
@override
Widget build(BuildContext context) {
print("CountView Build!!");
return Center(
child: StreamBuilder(
stream: countBloc.count,
initialData: 0,
// AsyncSnapshot 으로 값이 들어오게 됨
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString(),
style: TextStyle(fontSize: 70.0),
);
}
return CircularProgressIndicator();
},
),
);
}
}
// in count_bloc.dart
class CountBloc {
int _count = 0;
final StreamController<int> _countSubject = StreamController<int>();
// 변경된 widget 값을 전달 하게 됨
Stream<int> get count => _countSubject.stream;
add() {
_count++;
_countSubject.sink.add(_count);
}
dispose() {
_countSubject.close();
}
}
2.flutter_bloc package
- bloc pattern 을 flutter 에 적용할경우, package 없이 그냥 custom 하게 될 경우 작성해야될 경우가 너무 많게 되고 관리도 복잡하기 때문에 flutter 에서 bloc 패턴 사용시 주로 flutter_bloc_package 를 설치해서 사용하게 됩니다
flutter_bloc package 사용 예제(todo List) 환경 설정
설치 dependency
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.0
flutter_bloc: ^7.3.1
freezed_annotation:
json_annotation: ^4.3.0
equatable:
dev_dependencies:
flutter_test:
sdk: flutter
build_runner:
freezed:
json_serializable:
model 생성
- freezed 를 사용해서 Todo class model 생성
// in model/todo.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'todo.freezed.dart';
part 'todo.g.dart';
@freezed
class Todo with _$Todo {
factory Todo({
required int id,
required String title,
required String createdAt,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
repository 생성
// in repository/todo_repository.dart
// todo list app 은 실제로 만들면 server 와 연동을 하게 되는데 RestAPI 연동하는것을 시뮬레이션 하기 위한 repository 생성
// 3개의 method 생성 (이벤트 처리를 위한)
// GET = listTodo
// POST = createTodo
// DELETE = deleteTodo
import 'package:flutter_bloc_sample/model/todo.dart';
class TodoRepository {
Future<List<Map<String, dynamic>>> listTodo() async {
await Future.delayed(Duration(seconds: 1));
return [
{
'id': 1,
'title': 'Flutter Study',
'createdAt': DateTime.now().toString(),
},
{
'id': 2,
'title': 'Dart Study',
'createdAt': DateTime.now().toString(),
},
];
}
Future<Map<String, dynamic>> createTodo(Todo todo) async {
// 원래는 이런 방식으로 작성해야 하는데 body - request - response - return 과정을 생략한것임
await Future.delayed(Duration(seconds: 1));
return todo.toJson();
}
Future<Map<String, dynamic>> deleteTodo(Todo todo) async {
await Future.delayed(Duration(seconds: 1));
return todo.toJson();
}
}
bloc state
-
bloc state 은 가장 핵심 적인 부분입니다. 어떠한 상태를 가지고 있는가를 결정하게 됩니다. 나중에 bloc 을 생성할때, 어떠한 type 이 상태가 될지를 입력을 해줘야 되기 때문에 하나의 base class 를 만들어서 다 extend 한 다음에 상태들을 생성해야 합니다
-
Equatable 플러그인을 사용해서 TodoState 에 적용해줍니다
// in bloc/todo_state.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc_sample/model/todo.dart';
// immutable TodoState extends Equatable
@immutable
abstract class TodoState extends Equatable {}
// Empty TodoState : 맨처음에 아무것도 state 가 없을때 사용
class Empty extends TodoState {
@override
// TODO: implement props
List<Object?> get props => [];
}
// Loading TodoState :RestAPI 에 요청을 했을 때 사용(repository 실행 했을때)
class Loading extends TodoState {
@override
// TODO: implement props
List<Object?> get props => [];
}
// Error TodoState : server 에서 error message 가 나왔을때
class Error extends TodoState {
final String message;
Error({
required this.message,
});
@override
// TODO: implement props
List<Object?> get props => [this.message];
}
// Loaded TodoState : Loaded 가 완료 되는 시점에 todos list 에 값을 넘겨 주는 state
class Loaded extends TodoState {
final List<Todo> todos;
Loaded({
required this.todos
})
@override
// TODO: implement props
List<Object?> get props => [this.todos];
}
bloc event
-
bloc_flutter 를 쓸때 2가지 개념이 있는데 cubic 을 사용할때는 event 가 필요 없지만, bloc 을 사용할때는 event 가 필요해서 만든 파일 임
-
event 는 거의 restAPI 의 endpoint 와 갯수가 같아야 한다 (Get, Create, Delete 해오는거와 같이. )
// in bloc/todo_state.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc_sample/model/todo.dart';
// 기본 class 생성
@immutable
abstract class TodoEvent extends Equatable {}
class ListTodosEvent extends TodoEvent {
@override
// TODO: implement props
List<Object?> get props => [];
}
// todo 를 입력 받게 되어 있는데, 그렇게 하려면 todo 안에서도 todo object 를 가지고 있어야 함
class CreateTodoEvent extends TodoEvent {
final Todo todo;
CreateTodoEvent({
required this.todo,
});
@override
// TODO: implement props
List<Object?> get props => [this.todo];
}
// DeleteTodo 에서도 Todo object 가 들어가 잇으니까 같이 받아 줘야 함
class DeleteTodoEvent extends TodoEvent {
final Todo todo;
DeleteTodoEvent({
required this.todo,
});
@override
// TODO: implement props
List<Object?> get props => [this.todo];
}
bloc logic 생성
- flutter_bloc library 에서 제공을 해주는 bloc base class 로는 2가지가 있습니다. cubic 과 bloc 이 있는데 먼저 bloc 에 대해서 로직을 구성해 봅니다
// in bloc/todo_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_bloc_sample/bloc/todo_event.dart';
import 'package:flutter_bloc_sample/bloc/todo_state.dart';
import 'package:flutter_bloc_sample/model/todo.dart';
import 'package:flutter_bloc_sample/repository/todo_repository.dart';
// Bloc logic 생성
// <> generic 으로 첫번째는 event 를 받고, 그 다음에는 state를 받습니다
class TodoBloc extends Bloc<TodoEvent, TodoState> {
// dependancy injection 하기 위한 변수 선언
final TodoRepository repository;
// constructor 생성 : super 에는 가장 기본이 되는 state 인 Empty() 를 넣어 줌(처음 실행할때 아무것도 없는 상태이기 때문임) / TodoBloc 안에서 repository 의 로직도 안에서 실행하기 위해서 dependency injection 을 해줌
TodoBloc({
required this.repository,
}) : super(Empty());
// 모든 event 들이 이 함수를 통해서 실행이 됨
// Stream 은 async* 해줘야함 Future 는 그냥 async
@override
Stream<TodoState> mapEventToState(TodoEvent event) async* {
// 먼저 어떠한 event 인지 check 하는 것 (ListTodosEvent, CreateTodoEvent, DeleteTodoEvent)
if (event is ListTodosEvent) {
yield* _mapListTodoEvent(event);
} else if (event is CreateTodoEvent) {
yield* _mapCreateTodoEvent(event);
} else if (event is DeleteTodoEvent) {
yield* _mapDeleteTodoEvent(event);
}
}
// 아래의 로직이 가장 처음으로 UI 와 연결되는 부분이기 때문에 Stream builder 형태로 Stream 으로 들어가게 되는데 UI 에서 error 가 나는것을 최소화 시켜 줘야 함. 그래서 모든 error 를 이 단계에서 설정을 함(try , catch 로)
// _mapListTodoEvent Stream logic 생성
Stream<TodoState> _mapListTodoEvent(ListTodosEvent event) async* {
try {
// circular indicator 를 보여주기 위해 Loading을 호출
yield Loading();
// repository 에서 가져온 정보 변수 선언
final resp = await this.repository.listTodo();
// listTodo 는 Map<String, dynamic> 을 return 해주기 때문에 따로 class 화를 해줘야 함
final todos = resp.map<Todo>((e) => Todo.fromJson(e)).toList();
// 값을 가져왔으니 loading 이 끝난것을 호출하고 todos 를 넘긴다
yield Loaded(todos: todos);
} catch (e) {
yield Error(message: e.toString());
}
}
// _mapCreateTodoEvent Stream logic 생성
Stream<TodoState> _mapCreateTodoEvent(CreateTodoEvent event) async* {
try {
// 아래의 state 가 loaded state 인지 확인 (아직 load 가 안됬는데 data 를 가져 오면 안되기때문에)
if (state is Loaded) {
// todo 를 만들기 전에 기존 데이터를 가져와야 함
// 모두 yield 된것들은 state 안에서 가져올수 있음 state 인데 사실 이건 Loaded state 라는것
final parseState = (state as Loaded);
// todo 생성
final newTodo = Todo(
// ID: todos 의 길이 -1 의 index 의 id 값이 + 1 해서 추가 ID 번호 생성
id: parseState.todos[parseState.todos.length - 1].id + 1,
// Title : event 에서 title 을 불러옴
title: event.title,
// CreatedAt : 지금 시간 호출해서 String 으로 반환
createdAt: DateTime.now().toString(),
);
// repository 전송전에 UI 화면에 변경된 내용을 표시해주는부분 todos 호출
// prevTodos 에 기존에 있는것을 복사
final prevTodos = [
...parseState.todos,
];
// newTodos 에 기존거 + 생성한거 새로 생성
final newTodos = [
...prevTodos,
newTodo,
];
// 요청을 하기전에 가상으로 yield 하기 (load 가 다 됬다고 임의로 선언 UI 에 표시하기 위해서)
yield Loaded(todos: newTodos);
// repository 로 데이터 전송
final resp = await this.repository.createTodo(newTodo);
// id 값과 createdAt 의 값을 서버에 있는 쪽과 UI 쪽의 id 와 createdAt 의 값을 맞춰야 되기때문에 repository 전송후에 다시 Loaded 호출해서 업데이트 하는것
yield Loaded(todos: [
...prevTodos,
Todo.fromJson(resp),
]);
}
} catch (e) {
yield Error(message: e.toString());
}
}
// _mapDeleteTodoEvent Stream logic 생성
Stream<TodoState> _mapDeleteTodoEvent(DeleteTodoEvent event) async* {
try {
if (state is Loaded) {
final newTodos = (state as Loaded)
.todos
.where((todo) => todo.id != event.todo.id)
.toList();
yield Loaded(todos: newTodos);
await repository.deleteTodo(event.todo);
}
} catch (e) {
yield Error(message: e.toString());
}
}
}
bloc logic 을 UI 에 나타내기
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_bloc_sample/bloc/todo_bloc.dart';
import 'package:flutter_bloc_sample/bloc/todo_event.dart';
import 'package:flutter_bloc_sample/bloc/todo_state.dart';
import 'package:flutter_bloc_sample/repository/todo_repository.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
// BlocProvider 호출 사용 : Provider 를 초기화 하는 작업
return BlocProvider(
// 함수를 만들어서 bloc 을 return 해줌 : BlocProvider 가 생성해준 bloc 을 child 에 있는 HomeWidget() 에 TodoBloc 이 사용가능하도록 해줌
create: (_) => TodoBloc(repository: TodoRepository()),
child: HomeWidget(),
);
}
}
class HomeWidget extends StatefulWidget {
const HomeWidget({Key? key}) : super(key: key);
@override
_HomeWidgetState createState() => _HomeWidgetState();
}
class _HomeWidgetState extends State<HomeWidget> {
String title = '';
// initState 생성
@override
void initState() {
// TODO: implement initState
super.initState();
// ListTodosEvent 를 BlocProvider 에서 TodoBloc 을 부를 수 있는것
BlocProvider.of<TodoBloc>(context).add(ListTodosEvent());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter Bloc'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// bloc 을 부르는 2번째 방법 BlocProvider.of.. 위의 것과 동일한것
context.read<TodoBloc>().add(
CreateTodoEvent(
title: this.title,
),
);
},
child: Icon(Icons.edit),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Column(
children: [
TextField(
onChanged: (val) {
this.title = val;
},
),
SizedBox(height: 16.0),
// 만들어진 bloc 을 불러들이기 위해선 BlocBuilder 를 사용해야 함
// 2개의 generic 을 불러와야 되는데 처음에는 실제 가져올 bloc 을 다음에는 그것의 상태를 넣어주면 됨
Expanded(
child: BlocBuilder<TodoBloc, TodoState>(builder: (_, state) {
// state 가 Empty 이면 그냥 Container() return
if (state is Empty) {
return Container();
// state 가 Error 일 경우
} else if (state is Error) {
return Container(
child: Text(state.message),
);
// state 가 Loading 중일때는 circularProgressindecator()
} else if (state is Loading) {
return Center(
child: CircularProgressIndicator(),
);
// state 가 Loaded 중일때 기존의 todos 를 불러 온다음에 item 별로 화면에 나타 내기
} else if (state is Loaded) {
final items = state.todos;
return ListView.separated(
itemBuilder: (_, index) {
final item = items[index];
return Row(
children: [
Expanded(
child: Text(
item.title,
),
),
GestureDetector(
onTap: () {
BlocProvider.of<TodoBloc>(context).add(
DeleteTodoEvent(todo: item),
);
},
child: Icon(Icons.delete),
)
],
);
},
separatorBuilder: (_, index) => Divider(),
itemCount: items.length,
);
}
return Container();
}),
),
],
),
),
);
}
}
cubit 으로 logic 만들기
-
flutter_bloc 의 2번째 방법으로 cubit 을 사용하는 방법인데, Provider 나 GetX 와 비슷한 방법입니다.
-
위의 todo_bloc.dart 에서
_mapListTodoEvent
,_mapCreateTodoEvent
,_mapDeleteTodoEvent
과 마찬가지로 똑같은 logic 인데 다른점은.-
cubit 은 generic 으로 하나만 state 로 가짐
-
yield 를 emit() 으로 바꿔 주면 됨
-
// in bloc/todo_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_bloc_sample/bloc/todo_event.dart';
import 'package:flutter_bloc_sample/bloc/todo_state.dart';
import 'package:flutter_bloc_sample/model/todo.dart';
import 'package:flutter_bloc_sample/repository/todo_repository.dart';
// Bloc logic 생성
// <> generic 으로 첫번째는 event 를 받고, 그 다음에는 state를 받습니다
class TodoBloc extends Bloc<TodoEvent, TodoState> {
// dependency injection 하기 위한 변수 선언
final TodoRepository repository;
// constructor 생성 : super 에는 가장 기본이 되는 state 인 Empty() 를 넣어 줌(처음 실행할때 아무것도 없는 상태이기 때문임) / TodoBloc 안에서 repository 의 로직도 안에서 실행하기 위해서 dependency injection 을 해줌
TodoBloc({
required this.repository,
}) : super(Empty());
// 모든 event 들이 이 함수를 통해서 실행이 됨
// Stream 은 async* 해줘야함 Future 는 그냥 async
@override
Stream<TodoState> mapEventToState(TodoEvent event) async* {
// 먼저 어떠한 event 인지 check 하는 것 (ListTodosEvent, CreateTodoEvent, DeleteTodoEvent)
if (event is ListTodosEvent) {
yield* _mapListTodoEvent(event);
} else if (event is CreateTodoEvent) {
yield* _mapCreateTodoEvent(event);
} else if (event is DeleteTodoEvent) {
yield* _mapDeleteTodoEvent(event);
}
}
// 아래의 로직이 가장 처음으로 UI 와 연결되는 부분이기 때문에 Stream builder 형태로 Stream 으로 들어가게 되는데 UI 에서 error 가 나는것을 최소화 시켜 줘야 함. 그래서 모든 error 를 이 단계에서 설정을 함(try , catch 로)
// _mapListTodoEvent Stream logic 생성
Stream<TodoState> _mapListTodoEvent(ListTodosEvent event) async* {
try {
// circular indicator 를 보여주기 위해 Loading을 호출
yield Loading();
// repository 에서 가져온 정보 변수 선언
final resp = await this.repository.listTodo();
// listTodo 는 Map<String, dynamic> 을 return 해주기 때문에 따로 class 화를 해줘야 함
final todos = resp.map<Todo>((e) => Todo.fromJson(e)).toList();
// 값을 가져왔으니 loading 이 끝난것을 호출하고 todos 를 넘긴다
yield Loaded(todos: todos);
} catch (e) {
yield Error(message: e.toString());
}
}
// _mapCreateTodoEvent Stream logic 생성
Stream<TodoState> _mapCreateTodoEvent(CreateTodoEvent event) async* {
try {
// 아래의 state 가 loaded state 인지 확인 (아직 load 가 안됬는데 data 를 가져 오면 안되기때문에)
if (state is Loaded) {
// todo 를 만들기 전에 기존 데이터를 가져와야 함
// 모두 yield 된것들은 state 안에서 가져올수 있음 state 인데 사실 이건 Loaded state 라는것
final parseState = (state as Loaded);
// todo 생성
final newTodo = Todo(
// ID: todos 의 길이 -1 의 index 의 id 값이 + 1 해서 추가 ID 번호 생성
id: parseState.todos[parseState.todos.length - 1].id + 1,
// Title : event 에서 title 을 불러옴
title: event.title,
// CreatedAt : 지금 시간 호출해서 String 으로 반환
createdAt: DateTime.now().toString(),
);
// repository 전송전에 UI 화면에 변경된 내용을 표시해주는부분 todos 호출
// prevTodos 에 기존에 있는것을 복사
final prevTodos = [
...parseState.todos,
];
// newTodos 에 기존거 + 생성한거 새로 생성
final newTodos = [
...prevTodos,
newTodo,
];
// 요청을 하기전에 가상으로 yield 하기 (load 가 다 됬다고 임의로 선언 UI 에 표시하기 위해서)
yield Loaded(todos: newTodos);
// repository 로 데이터 전송
final resp = await this.repository.createTodo(newTodo);
// id 값과 createdAt 의 값을 서버에 있는 쪽과 UI 쪽의 id 와 createdAt 의 값을 맞춰야 되기때문에 repository 전송후에 다시 Loaded 호출해서 업데이트 하는것
yield Loaded(todos: [
...prevTodos,
Todo.fromJson(resp),
]);
}
} catch (e) {
yield Error(message: e.toString());
}
}
// _mapDeleteTodoEvent Stream logic 생성
Stream<TodoState> _mapDeleteTodoEvent(DeleteTodoEvent event) async* {
try {
if (state is Loaded) {
final newTodos = (state as Loaded)
.todos
.where((todo) => todo.id != event.todo.id)
.toList();
yield Loaded(todos: newTodos);
await repository.deleteTodo(event.todo);
}
} catch (e) {
yield Error(message: e.toString());
}
}
}
cubit 적용하기
기존 UI page 에서 변경사항은..
- BlocProvider 의
TodoBloc
대신TodoCubit
으로 바꿔 줌
// ListTodosEvent 를 BlocProvider 에서 TodoBloc 을 부를 수 있는것
BlocProvider.of<TodoBloc>(context).add(ListTodosEvent());
// 위에것을 TodoCubit 으로 만드는것
BlocProvider.of<TodoCubit>(context).listTodo();
Source Code
🔶 🔷 📌 🔑
Reference
Getting Started with the BLoC Pattern - https://www.raywenderlich.com/4074597-getting-started-with-the-bloc-pattern
BLoC in Flutter: Implement Clean, Flux-like Architecture - https://everyday.codes/mobile/bloc-in-flutter-implement-clean-flux-like-architecture/
개발하는남자 - https://youtu.be/AY6i0a4BM7o
코드팩토리 - https://youtu.be/xlmkMF5kVvA
Leave a comment