[Riverpod] #2 Provider의 종류

2023. 4. 4. 20:18Flutter

반응형

Riverpod이 제공하는 provider에는 다양한 종류가 있다.

하나씩 알아보도록 하자

 

Provider

가장 기본적인 프로바이터로서, 상태를 읽기만 가능하다

일반적으로 다음과 같은 용도로 사용된다

  • 계산 결과를 캐싱
  • 다른 공급자(Repository 또는 HttpClient)에 값 노출
  • 테스트 또는 위젯이 값을 재정의 할 수 있는 방법 제공
  • select를 사용하지 않고 provider 및 위젯의 리빌드를 최소화

목록을 필터링하는 것은 비용이 들 수 있다. 예를 들어 Todo목록에 필터를 적용하는 경우 화면을 다시 렌더링 할 때마다 todo목록은 다시 필터링하지 않는 것이 이상적이다. 리렌더링 시 목록을 필터링하고 싶지 않은 상황에서 provider를 사용할 수 있다.

 

아래의 할 일목록 관리를 위한 Todo 코드를 통해 provider의 사용법을 알아보자

class Todo {
  Todo(this.description, this.isCompleted);
  final bool isCompleted;
  final String description;
}

class TodosNotifier extends StateNotifier<List<Todo>> {
  TodosNotifier() : super([]);

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  // TODO: "removeTodo"와 같은 다른 메소드들을 추가하기
}

final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

여기서 provider를 사용해 Todo 목록을 필터링하고 완료된 할 일만 표시할 수 있다.

final completedTodosProvider = Provider<List<Todo>>((ref) {
  // todosProvider로부터 모든 todos을 가져온다.
  final todos = ref.watch(todosProvider);

  // 완료된 todos만 반환한다.
  return todos.where((todo) => todo.isCompleted).toList();
});

completedTodosProvider는 todosProvider의 변경이 없으면 다시 계산하지 않는다. completedTodosProvider를 통해 완료된 할 일 목록을 위젯에 표시한다.

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
  // TODO: a ListView/GridView/...등을 사용하여 todos를 표시하기
  // ...
});

여기서 흥미로운 부분은 할 일 목록 필터링이 캐시 된다는 것이다.

즉, 완료된 할 일 목록을 여러 번 읽어도 할 일이 추가, 제거, 업데이터 될 때까지는 완료된 할 일 목록이 다시 계산되지 않는다.

 

Provider가 다른 프로바이더와 다른 점은 프로바이더가 다시 계산되더라도 값이 변경되지 않는 경우, 이 프로바이더 값을 감시하는 위젯, 프로바이더는 업데이트하지 않는다는 점이다.

이전/다음 버튼을 활성화/비활성화하는 예제를 통해 알아보자.

현재 페이지 인덱스가 0이면 이전 버튼이 비활성화 돼야 한다.

final pageIndexProvider = StateProvider<int>((ref) => 0);

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 만약 첫페이지가 아니라면 이전 버튼이 활성화 된다.
    final canGoToPreviousPage = ref.watch(pageIndexProvider) == 0;

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

이 코드의 문제점은 현재 페이지를 변경할 때마다 "이전" 버튼이 재빌드 된다는 것이다.

활성화/비활성화 상태가 변경될 때만 버튼이 다시 빌드되는 것이 가장 이상적이다.

이 문제의 원인은 이전 페이지로 이동할 수 있는지 여부를 PreviousButton 위젯 내부에서 계산하고 있다는 것이다.

해결 방법은 페이지 인덱스를 체크하는 로직을 위젯 외부 Provider로 제공하는 것이다.

final pageIndexProvider = StateProvider<int>((ref) => 0);

// 이전 페이지로 돌아갈 수 있는지 계산하기 위한 Provider
final canGoToPreviousPageProvider = Provider<bool>((ref) {
  return ref.watch(pageIndexProvider) == 0;
});

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 위젯 내부에서는 이전 페이지로 이동할 수 있는지 여부를 더 이상 계산하지 않는다.
    final canGoToPreviousPage = ref.watch(canGoToPreviousPageProvider);

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

이렇게 리팩토링 하면 페이지 인덱스가 변경될 때마다 PreviousButton 위젯은 더 이상 재빌드 되지 않는다.

페이지 인덱스가 변경되면 canGoToPreviousPageProvider는 다시 계산된다. canGoToPreviousPageProvider 가 노출한 값이 변경되지 않는다면 PreviousButton 위젯은 다시 빌드되지 않는다.

 

이 작은 리팩토링으로 버튼의 성능을 개선하고 위젯과 로직을 분리하여 관리할 수 있다.

 


NotifierProvider

NotifierProvider는 Notifier를 수신하고 노출하는 데 사용되는 프로바이더이다.

NotifierProvider는 Notifier와 함께 사용자 상호 작용에 반응하여 변경될 수 있는 상태를 관리하기 위한 riverpod의 권장 솔루션이다.

 

일반적으로 다음 용도로 사용된다.

  • 커스텀 이벤트에 반응하여 시간이 지남에 따라 변경될 수 있는 상태를 노출한다.
  • 일부 상태를 수정하기 위한 비즈니스 로직을 한 곳에서 중앙 집중 관리하여, 시간이 지남에 따라 유지 관리성을 향상한다.

사용 예로, NotifierProvider를 사용하여할 일 목록을 구현할 수 있다.

이렇게 하면 UI가 사용자 상호작용에서 할 일을 추가하는 addTodo와 같은 메서드를 노출할 수 있다.

@freezed
class Todo with _$Todo {
  factory Todo({
    required String id,
    required String description,
    required bool completed,
  }) = _Todo;
}

// 이것은 Notifier와 NotifierProvider를 생성할 것이다.
// NotifierProvider에 전달할 Notifier 클래스이다.
// 이 클래스는 "state" properties 외부에 상태를 노출해서는 안된다. 
// 즉, public getter/properties가 없다!
// 이 클래스의 public 메서드는 UI가 상태를 수정할 수 있도록 한다.
@riverpod
class Todos extends _$Todos {
  @override
  List<Todo> build() {
    return [];
  }
  
  // todo를 추가할 수 있다
  void addTodo(Todo todo) {
    // 상태가 immutable 라서 `state.add(todo)`를 수행할 수 없다.
    // 대신 이전 항목과 새 항목이 포함된 할 일 목록을 새로 만들어야 한다.
    // 이때는 Dart의 스프레드 연산자가 도움이 된다!
    state = [...state, todo];
    // "notifyListeners" 또는 이와 유사한 것을 호출할 필요가 없다. 
    // "state ="를 호출하면 필요할 때 UI가 자동으로 리빌드된다.
    
  }

  // todo를 제거할 수 있다
  void removeTodo(String todoId) {
    // 다시 말하지만, 상태는 immutable이다. 
    // 그래서 기존 목록을 변경하는 대신 목록을 새로 만든다.
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  // todo를 완료로 표시할 수 있다
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        // 일치하는 할 일만 완료된 것으로 표시하고 있다.
        if (todo.id == todoId)
          // 다시 한 번, 상태는 변경할 수 없으므로 할 일을 복사해야 한다. 
          // 이전에 구현한 `copyWith` 메소드를 사용한다.
          todo.copyWith(completed: !todo.completed)
        else
          // 다른 todos는 수정하지 않는다.
          todo,
    ];
  }
}

// 마지막으로 UI가 TodosNotifier 클래스와 상호 작용할 수 있도록 NotifierProvider를 사용한다.
final todosProvider = NotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

이제 NotifierProvider를 정의했으므로, 이를 사용하여 UI의 할 일 목록과 상호 작용할 수 있다.

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 할 일 목록이 변경되면 위젯이 다시 빌드된다.
    List<Todo> todos = ref.watch(todosProvider);

    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // 할 일을 탭하면 상태를 완료로 변경한다.
            onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

 


StateNotifierProvider

StateNotifierProvider는 StateNotifier를 수신하고 노출하는 데 사용되는 프로바이더이다.

 

다음 용도로 사용된다.

  • 커스텀 이벤트에 반응하여 시간이 지남에 따라 변경될 수 있는 불변 상태를 노출한다.
  • 일부 상태를 수정하기 위한 비즈니스 로직을 한 곳에서 중앙 집중 관리하여, 시간이 지남에 따라 유지 관리 가능성이 개선된다.

다음 예제와 같이 할 일 목록을 구현하는데 사용할 수 있다.

// StateNotifier의 상태는 변경할 수 없다.
// 구현을 돕기 위해 Freezed와 같은 패키지를 사용할 수 있다.
@immutable
class Todo {
  const Todo({required this.id, required this.description, required this.completed});

  // 클래스의 모든 속성은 반드시 'final' 이어야 한다.
  final String id;
  final String description;
  final bool completed;

  // Todo는 변경할 수 없는 immutable 이므로
  // 아래와 같이 Todo를 복제할 수 있는 함수를 구현한다.
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

// StateNotifierProvider에 전달할 StateNotifier 클래스이다.
// 이 클래스는 "state" properties 외부에 상태를 노출해서는 안된다. 
// 즉, public getter/properties가 없다!
// 이 클래스의 public 메서드는 UI가 상태를 수정할 수 있도록 한다.
class TodosNotifier extends StateNotifier<List<Todo>> {
  // 할 일 목록을 빈 목록으로 초기화한다.
  TodosNotifier(): super([]);

  // UI가 할 일을 추가할 수 있도록 한다.
  void addTodo(Todo todo) {
    // 상태가 immutable 라서 `state.add(todo)`를 수행할 수 없다.
    // 대신 이전 항목과 새 항목이 포함된 할 일 목록을 새로 만들어야 한다.
    // 이때는 Dart의 스프레드 연산자가 도움이 된다!
    state = [...state, todo];
    // "notifyListeners" 또는 이와 유사한 것을 호출할 필요가 없다. 
    // "state ="를 호출하면 필요할 때 UI가 자동으로 리빌드된다.
    
  }

  // 할 일 삭제하기
  void removeTodo(String todoId) {
    // 다시 말하지만, 상태는 immutable이다. 
    // 그래서 기존 목록을 변경하는 대신 목록을 새로 만든다.
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  // 할 일 완료 처리하기
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        // 일치하는 할 일만 완료된 것으로 표시하고 있다.
        if (todo.id == todoId)
          // 다시 한 번, 상태는 변경할 수 없으므로 할 일을 복사해야 한다. 
          // 이전에 구현한 `copyWith` 메소드를 사용한다.
          todo.copyWith(completed: !todo.completed)
        else
          // 다른 todos는 수정하지 않는다.
          todo,
    ];
  }
}

// 마지막으로 UI가 TodosNotifier 클래스와 상호 작용할 수 있도록 StateNotifierProvider를 사용한다.
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

이제 StateNotifierProvider를 정의했으므로, 이를 사용하여 UI의 할 일 목록과 상호 작용할 수 있다.

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 할 일 목록이 변경되면 위젯이 다시 빌드된다.
    List<Todo> todos = ref.watch(todosProvider);

    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // 할 일을 탭하면 상태를 완료로 변경한다.
            onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

 


FutureProvider

FutureProvider  Provider와 동일하지만 비동기로 동작한다.

 

다음 용도로 사용된다.

  • 비동기 작업 수행 및 캐싱(예: 네트워크 요청)
  • 비동기 작업의 오류/로드 상태를 처리.
  • 여러 비동기 값을 다른 값들과 결합

FutureProvider를 사용하면 일부 변수가 변경될 때 데이터를 자동으로 다시 가져올 수 있다. 즉, 항상 최신값을 유지할 수 있다.

하지만 FutureProvider는 상호 작용 후 계산을 직접 수정하는 방법은 제공하지 않는다. 단순한 사용을 위해서만 설계되었다. 복잡한 로직을 구현하기 위해서는 AsyncNotifierProvider를 사용하는 것이 좋다.

 

FutureProvider는 JSON 파일을 읽어 생성한 Configuration 객체를 노출하는 방법으로 사용될 수 있다.

config 생성은 async/await 구문을 사용하여 Provider 내부에서 수행하면 된다. Flutter asset 시스템에 사용 방법은 다음과 같다.

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

그런 다음 UI에서는 다음과 같이 사용할 수 있다.

Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

이렇게 하면 Future가 완료되면 UI가 자동으로 재빌드된다. 게다가 캐시 기능이 동작하기 때문에 여러 위젯이 동시에 config를 원하는 경우에도 에셋은 한 번만 로드된다.
위젯 내부의 FutureProvider를 감시하면 오류/로딩 상태를 처리할 수 있는 AsyncValue가 리턴된다. AsyncValue 덕분에 오류/로딩과 같은 상태를 적절하게 위젯으로 변환할 수 있다.

 


StreamProvider

StreamProvider는 FutureProvider와 유사하지만 Future 대신 Stream에 사용된다.

다음 용도로 사용된다.

  • Firebase 또는 web-sockets 수신하기
  • 매 초마다 다른 provider 재빌드하기

StreamBuilder보다 StreamProvider를 사용하면 다음과 같은 이점이 있다.

  • 다른 providers가 ref.watch를 사용하여 스트림을 수신할 수 있다.
  • AsyncValue 덕분에 로딩과 에러 케이스가 확실하게 처리된다.
  • 브로드캐스트 스트림과 일반 스트림을 구별할 필요가 없다.
  • 스트림에서 내보낸 최신 값을 캐시 하여 이벤트가 발생한 후 리스너가 추가되는 경우, 리스너는 여전히 가장 최신 이벤트에 즉시 액세스 할 수 있다.
  • StreamProvider를 재정의 overriding 하여 테스트 중에 스트림을 쉽게 목킹 mocking 할 수 있다.

StreamProvider는 비디오 스트리밍, 일기 예보 API 또는 라이브 채팅과 같은 비동기 데이터 스트림을 처리할 때 사용된다.

Stream<List<String>> chat(ChatRef ref) async* {
  // 소켓을 사용하여 API에 연결하고 출력을 디코딩한다
  final socket = await Socket.connect('my-api', 4242);
  ref.onDispose(socket.close);

  var allMessages = const <String>[];
  await for (final message in socket.map(utf8.decode)) {
    // 새로운 메시지가 수신되면, 전체 메시지 목록에 추가한다
    allMessages = [...allMessages, message];
    yield allMessages;
  }
}

그러면 UI에서 다음과 같이 라이브 스트리밍 채팅을 모니터링할 수 있다.

Widget build(BuildContext context, WidgetRef ref) {
  final liveChats = ref.watch(chatProvider);
  // FutureProvider와 마찬가지로, loading/error 상태를 처리하기 위해 AsyncValue.when을 사용할 수 있다
  return liveChats.when(
    loading: () => const CircularProgressIndicator(),
    error: (error, stackTrace) => Text(error.toString()),
    data: (messages) {
      return ListView.builder(
        reverse: true,
        itemCount: messages.length,
        itemBuilder: (context, index) {
          final message = messages[index];
          return Text(message);
        },
      );
    },
  );
}

 


StateProvider

StateProvider는 외부에서 변경이 가능한 상태를 노출하는 Provider이다. StateNotifier 클래스를 작성할 필요가 없도록 설계된 StateNotifierProvider의 단순화 버전이다.

따라서 StateProvider는 UI에서 사용되는 간단한 상태를 관리하는데 적합하다.

  • 필터 타입과 같은 enum
  • String, 일반적으로 텍스트 필드의 raw 콘텐츠
  • 체크박스용 boolean
  • 페이징 또는 연령 폼 필드용 number

반대로, 다음 경우에는 사용해서는 안된다.

  • 상태 검증 로직이 필요한 경우
  • 상태가 복잡한 경우(예: 커스텀 클래스, 리스트/맵,...)
  • 상태를 수정하는 로직이 단순한 count++보다 더 복잡한 경우.

복잡한 로직의 경우에는 StateNotifier 클래스를 작성한 다음 StateNotifierProvider를 사용하는 것이 좋다.

이 경우 약간의 보일러 플레이트 코드 설정이 필요하다. 하지만 비즈니스 로직을 한 곳에서 집중 관리 할 수 있기 때문에 프로젝트의 장기적인 유지 보수 관리에 최선이라고 할 수 있다.

 

StateProvider는 드롭다운/텍스트 필드/체크박스와 같은 간단한 폼 컴포넌트의 상태 관리에 사용할 수 있다.

StateProvider를 사용하여 제품 목록이 정렬되는 방식을 변경할 수 있는 드롭다운을 구현하는 방법을 살펴보자.

 

Product의 정의와 product 목록의 내용은 다음과 같다.

class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}

final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];

final productsProvider = Provider<List<Product>>((ref) {
  return _products;
});

위 예제에서는 더미 데이터를 사용했지만, 실제 프로그램에서는 FutureProvider를 사용하여 네트워크 요청을 통해 목록을 가져올 수 있다.

UI에서는 다음과 같이 제품 목록을 표시할 수 있다.

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);

  return Scaffold(
    body: ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text('${product.price} \$'),
        );
      },
    ),
  );
}

이제 드롭다운을 추가하여 가격이나 이름별로 제품을 필터링할 수 있다. 이를 위해 flutter의 DropDownButton을 사용한다.

// 필터의 종류를 나타내는 enum
enum ProductSortType {
  name,
  price,
}

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);

  return Scaffold(
    appBar: AppBar(
      title: const Text('Products'),
      actions: [
        DropdownButton<ProductSortType>(
          value: ProductSortType.price,
          onChanged: (value) {},
          items: const [
            DropdownMenuItem(
              value: ProductSortType.name,
              child: Icon(Icons.sort_by_alpha),
            ),
            DropdownMenuItem(
              value: ProductSortType.price,
              child: Icon(Icons.sort),
            ),
          ],
        ),
      ],
    ),
    body: ListView.builder(
      // ... 
    ),
  );
}

StateProvider를 생성하여 productSortTypeProvider를 생성하고, 드롭다운 필터링 상태를 반환하자.

먼저 StateProvider를 만든다

final productSortTypeProvider = StateProvider<ProductSortType>(
  // 기본 sort type인 name을 반환합니다.
  (ref) => ProductSortType.name,
);

그 후 다음과 같이 프로바이더와 드롭다운 메뉴를 연결한다.

DropdownButton<ProductSortType>(
  // sort type이 변경되면 dropdown이 재빌드되어 표시된 아이콘이 업데이트된다.
  value: ref.watch(productSortTypeProvider),
  // 사용자가 dropdown과 상호 작용할 때 provider state를 업데이트한다.
  onChanged: (value) {
      ref.read(productSortTypeProvider.notifier).state = value!;
  },
  items: [
    // ...
  ],
),

이렇게 하면 드롭다운 메뉴에서 정렬 타입을 변경할 수 있다. 다만, 이것만으로는 상품 목록이 정렬되지 않는다. 

상품 목록을 정렬하도록 productsProvider를 업데이트한다.

이 기능을 구현하는 핵심은 ref.watch이다. ref.watch를 사용해 productsProvider가 현재 정렬 타입을 모니터링하고, 정렬 타입이 변경될 때마다 상품 목록을 다시 계산하도록 하는 것이다.

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);

  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

이제 사용자가 정렬 타입을 변경할 때마다 제품 목록이 자동으로 재랜더링 될 것이다.

 

Provider를 두 번 읽지 않고 이전 값을 기반으로 상태를 업데이트하는 방법

이전 값을 기반으로 StateProvider의 상태를 업데이트하고 싶을 때가 있다. 다음과 같이 작성할 수 있다.

final counterProvider = StateProvider<int>((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 이전 값에서 상태를 업데이트하고, counterProvider를 두 번 읽었다.
          ref.read(counterProvider.notifier).state = ref.read(counterProvider.notifier).state + 1;
        },
      ),
    );
  }
}

코드를 조금 더 개선하기 위해  update 함수를 사용할 수 있다. 이 함수는 현재 상태를 수신하고 새로운 상태를 반환하는 콜백함수를 건네준다. 이 콜백함수는 최근 상태의 값을 파라미터로 사용할 수 있다.

리팩토링을 하면 다음과 같다.

final counterProvider = StateProvider<int>((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(counterProvider.notifier).update((state) => state + 1);
        },
      ),
    );
  }
}

 

 


https://docs-v2.riverpod.dev/

 

Riverpod

A boilerplate-free and safe way to share state

riverpod.dev

 

NotifierProvider와 StateNotifierProvider의 차이는 무엇인가...

왜 StateNotifierProvider보다 NotifierProvider를 사용하는 것이 좋은가...

반응형