<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Ximya</title>
        <link>https://velog.io/</link>
        <description>https://medium.com/@ximya</description>
        <lastBuildDate>Sun, 16 Feb 2025 16:32:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Ximya</title>
            <url>https://velog.velcdn.com/images/ximya_hf/profile/4b1c8ad8-0226-4d3b-a2fa-d8e93ec1f008/image.webp</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Ximya. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ximya_hf" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[레미도 안 알려주는 5가지 Flutter Riverpod 실전 팁]]></title>
            <link>https://velog.io/@ximya_hf/5-flutter-riverpod-practical-tips</link>
            <guid>https://velog.io/@ximya_hf/5-flutter-riverpod-practical-tips</guid>
            <pubDate>Sun, 16 Feb 2025 16:32:04 GMT</pubDate>
            <description><![CDATA[<p><code>Riverpod(리버팟)</code>을 사용한 지 약 1년이 조금 넘어가네요. 이 기간 동안 총 2개의 프로덕션 앱을 Riverpod으로 구현하거나 전환하였고 이 과정에서 터득한 크고 작은 몇 가지 저만의 실전 팁들이 있습니다.</p>
<p>그리고 이런 팁들은 아래와 같은 고민에서 비롯되었습니다.</p>
<ul>
<li><code>전역 Provider</code>가 편리하긴 한데... 어디서나 접근 가능하다는 점이 너무 많은 위험 요소를 불러오는 건 아닐까?</li>
<li>특정 화면에서 전달받은 <code>Argument</code>(인자)를 어떻게 뎁스가 깊은 위젯 구조에서도 효과적으로 전달하고 관리할 수 있을까?</li>
<li><code>WidgetRef</code>에 직접 접근하기 어려운 곳에 provider에 접근해야 한다면 어떻게 해야 할까?</li>
<li><code>Async Provider</code>로부터 값을 참조할 때 <code>requiredValue</code>, <code>value</code>와 같은 extension 메서드를 자주 쓰는데, StateError + null 오류가 자주 발생해...</li>
<li>각 페이지에서 사용되는 <code>ConsumerWidget</code>을 더 쉽고 편리하게 사용하기 위해 유틸리티 클래스 형태로 모듈화할 수는 없을까?</li>
</ul>
<p>Riverpod을 사용하면서 저와 비슷한 고민들을 해오신 분들께는 꽤 쏠쏠한 인사이트를 발견해 나가실 수 있을 거라 생각됩니다.</p>
<p>아, 참고로 제목에 언급한 레미(Remi Rousselet)는 Riverpod, Provider, Freezed 등등 여러 수만 명이 사용하는 패키지를 만든 유명 컨트리뷰터 입니다.</p>
<br/>


<h2 id="1-mixin-class로-전역-provider를-구조화하기">1. Mixin Class로 전역 Provider를 구조화하기</h2>
<p>전에 이 주제로 글을 작성했던 적이 있습니다.</p>
<p>👉 <a href="https://velog.io/@ximya_hf/organize-your-global-providersinflutterriverpod">Mixin Class를 활용하여 Riverpod 단점 극복하기</a></p>
<p>해당 글을 간단히 요약하면, Riverpod의 Provider는 <code>전역</code> 상태(top-level)로 선언되어 어디서든 간단히 import만 하면 접근할 수 있다는 메커니즘은 여러 장점이 있지만, 역설적이게도 <strong>특정 페이지에서 어떤 Provider가 사용되는지 파악하기 어렵다는 단점</strong>으로 이어짐으로 인해 여러 사이드 이펙트가 발생한다는 것이었죠.</p>
<p>이를 해결하기 위해 각 화면에서 사용되는 <code>상태(state)</code>와 <code>이벤트(event)</code>를 <code>Mixin Class</code>로 구조화하는 방안을 제안했었습니다. 아래 예제를 확인해 볼까요.</p>
<h4 id="homestate">HomeState</h4>
<pre><code class="language-dart">mixin class HomeState {    
  List&lt;Todo&gt; filteredTodos(WidgetRef ref) =&gt; ref.watch(filteredTodosProvider);  </code></pre>
<h4 id="homeevent">HomeEvent</h4>
<pre><code class="language-dart">mixin class HomeEvent {  
  void addTodo(  
    WidgetRef ref, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) {  
    ref.read(todoListProvider.notifier).add(value);  
    textEditingController.clear();  
  }  
}</code></pre>
<h4 id="homepage">HomePage</h4>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/a670c288-5ec8-4a45-98d7-fc0c2a71e7e4/image.png" alt=""></p>
<p>위에 코드에서 확인하실 수 있듯이, 특정 화면에서 Provider의 상태를 참조하는 로직은 State Mixin Class에, 그리고 상태를 변경하거나 참조하여 특정 이벤트를 실행하는 로직을 Event Mixin Class로 구조화하여 적용하는것 입니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/e6ca2a9f-f418-4929-a85b-c0a169dc334b/image.png" alt=""></p>
<p>이 구조의 장점 중 하나는 <strong>Provider 사용 범위를 명확히 구분하고 쉽게 추적</strong>할 수 있게 도와준다는 점입니다.</p>
<p><strong><em>특정 페이지에서 어떤 상태를 전달받고 어떤 이벤트가 발생하는지 오직 2개의 Class(Mixin)로만 유추할 수 있다는 점은 작업자와 주변 동료들에게 큰 안정감을 주죠. 이는 우리가 좋은 변수명을 짓는 이유와 비슷합니다.</em></strong></p>
<p>그리고 코드의 가독성을 높이면서 동시에 <strong>복잡도</strong>를 낮춰주는 부분에 대해서도 꽤 큰 이점이 있습니다. 예를 들어 ProductDetail 페이지에서 특정 이벤트가 실행될 때, Home 페이지에서 특정 버튼을 클릭했을 때 트리거 되는 이벤트를 동일하게 실행해야 되어야 하고 HomePage에서 버튼을 클릭할 때 실행되는 메서드는 아래와 같이 UI 코드 영역에 정의되어 있다고 가정 해볼게요.</p>
<pre><code class="language-dart">/// HomePage의 하위 위젯중 하나
Button(
  onPress : () {
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
     ref.read(fProvider).someIntent();

  }
)</code></pre>
<p>ProductDetailPage에서 HomePage에서 Button이 클릭되었을 때와 동일한 이벤트를 트리거하기 위해서는 해당 이벤트를 별도로 추출해서 별도의 공통 모듈을 만들거나, 그다지 좋은 방법은 아니지만 저 코드를 그대로 복사하여 ProductDetail 페이지에 위치시킬 수도 있겠습니다.</p>
<pre><code class="language-dart">mixin class HomeEvent {
  void onBtnTapped() {
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
     ref.read(fProvider).someIntent();
    }
}</code></pre>
<p>그런데 만약 HomeEvent라는 <code>Event Mixin Class</code>에 버튼을 클릭했을 실행되는 로직을 관리하고 있었다면</p>
<pre><code class="language-dart">mixin class ProductDetailEvent {
  void onBtnTapped() {
     final homeEvent = HomeEvent(); // &lt;-- Mixin Class를 인스턴스화
     homeEvent.onBtnTapped();
  }
}</code></pre>
<p>위와같이 해당 Mixin Class를 인스턴스화해서 필요한 메소드들을 간단하게 실행시킬 수 있겠죠. <strong>mixin이 아닌 <code>mixin class</code>를 쓰는 이유가 여기에 있습니다.</strong></p>
<br/>

<h2 id="2-providerscope를-사용하여-페이지-argument인자를-효과적으로-전달하고-관리하기">2. ProviderScope를 사용하여 페이지 argument(인자)를 효과적으로 전달하고 관리하기</h2>
<p>페이지에서 다른 페이지로 Argument(인자)를 전달하는 경우는 굉장히 흔합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/6b2295d8-ed4e-410e-bc25-8ecf54406ff4/image.png" alt=""></p>
<p>예를 들어 커머스 앱이라고 가정해보면, 상품 리스트 페이지에서 특정 상품 상세 페이지로 이동할 때, 상품의 고유 <code>id</code>값을 함께 넘겨 받아 상세 페이지에서는 상품의 상세한 정보를 불러오는 로직이 고려되어야 합니다.</p>
<pre><code class="language-dart">class ProductDetailPage extends ConsumerWidget {  
  const ProductDetailPage({super.key, required this.id});  

  final String id;  

  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    return _Scaffold(  
      header: _Header(id),  
      leadingInfoView: _LeadingInfoView(id),  
      imgListView: _ImgListView(id),  
      sizeInfoView: _SizeInfoView(id),  
      descriptionView: _DescriptionView(id),  
      reviewListView: _ReviewListView(id),  
      priceInfoView: _PriceInfoView(id),  
      bottomFixedButton: _BottomFixedButton(id),  
    );  
  }  
}</code></pre>
<p>코드로 확인해보면 ProductDetailPage라는 상세 페이지에서는 상품의 <code>id</code>값을 <code>Argument</code>로 전달받고 있고, 페이지에서 각 섹션별로 위젯의 별도의 클래스로 분리되어 UI가 구조화된 형태입니다. 그리고 각 분리된 위젯에서는 id 값을 기반으로 상품의 상세 정보를 호출하는 로직이 고려되어 있습니다.</p>
<blockquote>
<p>이전에 UI 코드의 구조화와 관련해서 자세히 작성한 글이 있으니 참고 바랍니다
👉 <a herf="https://velog.io/@ximya_hf/flutter-clean-ui-code">내일 바로 써먹는 Flutter Clean UI Code</a></p>
</blockquote>
<h4 id="뎁스가-깊은-ui-코드에서-route-argument를-참조해야-될-때-발생하는-이슈">뎁스가 깊은 UI 코드에서 Route Argument를 참조해야 될 때 발생하는 이슈</h4>
<p>가독성을 높이고 상태에 변화에 따른 불필요한 리빌드를 최소화하기 위해 UI를 구조화했지만, 페이지의 자식 위젯중 대부분이 상품의 상세 정보(리뷰, 상세 설명)를 호출하기 위해 부<strong>모로 위젯으로부터 상품의 <code>id</code> 값을 계속 전달받아야 하는 구조이기 때문에 일일이 <code>id</code>값을 넘겨받는 과정이 꽤 번거로워 지게 됩니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/46e23cd0-1bdf-4f8c-832f-00e261a2ca29/image.jpeg" alt=""></p>
<p>만약 화면에서 전달되는 Argument 타입이 바뀌면, 자식 위젯들에서도 모두 일일이 타입을 변경해줘야 하고, 더 깊은 자식 위젯에서 또 <code>id</code>가 필요하면 계속 내려줘야 하죠. 그리고 이걸 전문적인 용어로 <strong><code>State(Prop) Drilling</code></strong>이라고 부릅니다.</p>
<p><em>‘그렇다면 자식 위젯을 분리하지 않고, 모든 코드를 한 파일에 몰아넣으면 되지 않나?’</em> 할 수도 있지만, 그렇게 하면 긴 스파게티 코드로 인해 유지 보수가 매우 어려워집니다.</p>
<p>이 문제를 해결하기 위해, 저는 GetX처럼 정적(static)으로 Argument를 관리하거나, InheritedWidget을 직접 구현하여 Argument를 관리하는 등 여러 시도를 해봤는데요. 각각의 방식마다 장점이 있긴 해도, 명확한 한계점도 존재했습니다. 그래서 도출해낸 방법이 바로 <strong><code>ProviderScope</code></strong>를 이용하는 것입니다.</p>
<h4 id="새로운-providerscope에서-argument-provider-초기화하기">새로운 ProviderScope에서 Argument Provider 초기화하기</h4>
<p>방법은 간단한데요. 먼저 Argument를 저장하고 관리할 <code>Provider</code>를 선언해줍니다. (본 글에서는 해당 Provider를 <code>Argument Provider</code>로 지칭합니다)</p>
<pre><code class="language-dart">/// 일반 provider 선언문
class ProductDetailArgumentNotifier extends StateNotifier&lt;String&gt; {  
  ProductDetailArgumentNotifier(super.state);  
}  

final productDetailArgumentProvider = Provider.autoDispose&lt;String&gt;(  
  (ref) =&gt; throw Exception(&#39;argument를 초기화 시켜 주어야 합니다&#39;),  
);


/// Anotation 적용 provider 선언문
part &#39;product_detail_page_argument_provider.g.dart&#39;;

@riverpod  
String productDetailArgument(Ref ref) {  
  throw Exception(&#39;argument를 초기화 시켜 주어야 합니다&#39;);  
}</code></pre>
<p>해당 Provider는 Route Argument 값을 동적으로 받아 초기화 되어야 하므로, state값을 지정하지 않고, <code>Exception</code>을 리턴하도록 합니다. 또한 화면 위젯이 해제될 때 Argument Provider 또한 위젯 트리에서 제거되어야 하기 때문에 꼭 <code>AutoDispose Provider</code>로 선언해주는게 좋습니다.</p>
<pre><code class="language-dart">class ProductDetailPage extends ConsumerWidget {  
  const ProductDetailPage({super.key, required this.id});  

  final String id;  

  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    return ProviderScope(  
      overrides: [  
        /// &#39;overrideWithValue&#39; 메소드를 사용하여 argument를 초기화
        productDetailArgumentProvider.overrideWithValue(id), 
      ],  
      child: Consumer(  
        builder: (context, ref, _) {  
          return _Scaffold(...);  
        },  
      ),  
    );  
  }  
}</code></pre>
<p>그리고 페이지 위젯의 <code>build</code> 메서드 최상단 위치에 <code>ProviderScope</code>와 <code>Consumer</code>로 감싸준 뒤 <code>ProviderScope</code>의 <code>overrides</code> 속성에 이전에 선언한 Argument Provider(productDetailArgumentProvider)를 <code>overrideWithValue(id)</code>로 초기화하면 준비는 끝납니다.</p>
<blockquote>
<p>*<em>⚠️ NOTE  *</em>
 <code>ProviderScope</code>에서 Argument Provider를 override한 뒤에야 해당 값이 초기화되므로, 반드시 <code>ProviderScope</code> 바로 하위에 있는 <code>Consumer</code> 위젯(혹은 <code>HookConsumer</code>)에서 <code>ref</code>를 통해 안전하게 접근해야 합니다.</p>
</blockquote>
<p>이제 하위 위젯에서는 아래처럼 WidgetRef만 있으면 바로 Argument Provider를 참조하여 <code>id</code>에 접근할 수 있는 구조가 되었습니다. 이제 상품의 id값을 참조하여 상품의 리뷰 리스트를 불러오는 코드를 확인해볼까요.</p>
<pre><code class="language-dart">/// 상품 리뷰 뷰
class _ReviewListView extends ConsumerWidget {  
  const _ReviewListView({super.key});  

  // ⬇️ 이제 부모 위젯에서 직접 id를 전달 받을 필요가 없어요!  
  // const _ReviewListView({super.key, required this.id}); 
  // final String id;  

  Widget build(BuildContext context, WidgetRef ref) {  
    final id = ref.read(productDetailArgumentProvider);  
    final reviewListAsync = ref.watch(reivewListProvider(id));  
    return ...  
  }  
}</code></pre>
<p>이렇게 부모 위젯으로 직접 id를 전달받을 필요 없이 이전에 선언한 <code>Argument Provider</code>를 <code>WidgetRef</code>를 통해 위젯의 build 메서드에서 참조해주면 되고,</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/c9151deb-f2b3-43c8-8768-65f90681c16b/image.png" alt=""></p>
<p>별도의 파라미터를 계속 넘겨주지 않아도 되므로, 한 페이지에서 다루는 상태가 많거나 자식이 위젯의 뎁스가 깊을수록 굉장히 편리해집니다.</p>
<h4 id="argument-provider와-state-event-mixin-활용-방법">Argument Provider와 State, Event Mixin 활용 방법</h4>
<p>앞서 소개한 <code>Mixin Class</code> 방식을 함께 적용해보면, <code>Family Provider</code>처럼 Route Argument를 기반으로 초기화되는 Provider와 관련된 로직이 훨씬 간단해집니다.</p>
<pre><code class="language-dart">mixin class ProductDetailState {  
  /// ✅ GOOD
  AsyncValue&lt;List&lt;Review&gt;&gt; reviewsAsync(WidgetRef ref) {  
    final id = ref.read(productDetailArgumentProvider);  
    return ref.watch(reivewListProvider(id));  
  }  

  /// ❌ BAD
 AsyncValue&lt;List&lt;Review&gt;&gt; reviewsAsync(WidgetRef ref, {required String id}) {  
    return ref.watch(reivewListProvider(id));  
  }  
}  </code></pre>
<p><code>Mixin Class</code> 내부 메서드에서는 <code>Route Argument Provider</code>를 통해 id를 직접 참조할 수 있기 때문에 Argument를 전달받는 파라미터를 설정해주지 않고 WidgetRef만 넘겨줍니다. 그리고 넘겨받은 <code>WidgetRef</code>를 통해 <code>Route Argument</code>, 즉 id 값을 참조하고 해당 id값을 상품의 리뷰 목록을 불러오는 reivewListProvider라는 Family Provider를 watch하여 반환해 주고요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/d4fd88e1-aaca-4e14-bafb-97bfa7a5064b/image.png" alt=""></p>
<p>이제 위젯단에서 <code>Mixin Class</code> 메서드에 접근하도록 구성해보면 이전과 가독성적인 측면에서 차이를 확인해볼 수 있습니다. 훨씬 더 코드가 간결해지지 않았나요? 이뿐만 아니라 만약 상품 리스트 값을 필요로 하는 위젯이 많아질수록 <code>Mixin Class</code>를 동일하게 참조하면 되기 때문에 <strong>중복 코드를 줄이고 유지 보수에도 유리</strong>해진다는 이점을 가집니다.</p>
<h4 id="provider에서-직접-argument를-직접-참조하는-방식">Provider에서 직접 Argument를 직접 참조하는 방식</h4>
<p>조금 더 간결한 방법도 있습니다. 만약 특정 Provider가 페이지의 Argument값에 <strong>직접 의존</strong>하여 초기화되는 Family Provider 형태라면, <code>Argument</code>를  파라미터로 전달받지 않고<code>Argument Provider</code>를 직접 참조하는 방식으로 구성하는 것이죠.</p>
<pre><code class="language-dart">/// 일반 provider 선언문
class Reviews extends AsyncNotifier&lt;List&lt;String&gt;&gt; {  
  @override  
  Future&lt;List&lt;Review&gt;&gt; build() async {  
  final id = ref.read(productDetailArgumentProvider); // &lt;- 여기서 Route Argument를 참조 
return await productRepository.getReviews(id);
  }  
}  

final reviewsProvider = AsyncNotifierProvider&lt;ReviewList, List&lt;String&gt;&gt;(  
  dependencies: [productDetailArgumentProvider],  
  () =&gt; ReviewList(),  
);


/// Anotation 적용 provider 선언문
part &#39;review_list_provider.g.dart&#39;;  

@Riverpod(dependencies: [productDetailArgument])  
class Reviews extends _$ReviewList {  
  @override  
  FutureOr&lt;List&lt;Review&gt;&gt; build() async {  
final id = ref.read(productDetailArgumentProvider);  // &lt;- 여기서 Route Argument를 참조  
return await productRepository.getReviews(id);
  }  
}</code></pre>
<p>그러기 위해서는 해당 Provider의 <code>dependencies</code>에 Argument Provider를 설정해주어야 합니다. <strong>이유는 서로 다른 ProviderScope에 Provider가 초기화되었기 때문입니다</strong>. <code>dependencies</code>가 설정되었다면 Notifier의 build 메서드 내부에서 Argument Provider를 문제 없이 참조하여 필요한 동작을 수행할 수 있습니다.</p>
<blockquote>
<p><strong>⚠️ NOTE</strong>
  dependencies가 설정되면 Argument Provider가 초기화되지 않는 다른 화면에서는 해당 Provider를 정상적으로 접근할 수 없으니, 해당 Provider가 특정 화면에서만 명확히 사용되는 형태일 경우에만 이런 방식을 사용하는 것이 권장됩니다.</p>
</blockquote>
<br/>

<h2 id="3-uncontrolledproviderscope-providercontainer를-통해-widgetref가-없는-곳에서도-provider에-접근하기">3. UncontrolledProviderScope, ProviderContainer를 통해 WidgetRef가 없는 곳에서도 provider에 접근하기</h2>
<p>앱을 개발하다 보면 <code>WidgetRef</code>에 접근할 수 없는 곳에서 특정 Provider에 접근해야 하는 경우가 있습니다.</p>
<pre><code class="language-dart">FirebaseMessaging.onMessage.listen((RemoteMessage message) {  
  ...  
  if(message.category == &#39;home&#39;) {  
  /// 현재 WidgetRef가 없어서 ref.read(...) 불가능
  ref.read(bottomNavigationIndex.notifier).changeIndex(0);  
  }  

});</code></pre>
<p>예를 들어 FCM 푸시 알림을 수신했을 때, Provider의 상태를 변경하여 바텀 네비게이션 인덱스를 변경해야 할 수 있지만, 일반적으로 <code>presentation</code> 레이어가 아닌, 앱 진입 단계에서 등록되는 fcm <code>listen</code> 메서드에서는 <code>WidgetRef</code>에 접근하기 어렵죠. 어떻게 이 문제를 해결할 수 있을까요?</p>
<p><code>ProviderContainer</code>에 실마리가 있습니다.</p>
<pre><code class="language-dart">final globalContainer = ProviderContainer();</code></pre>
<p>먼저 전역에 <code>ProviderContainer</code>를 하나 선언해둡니다.</p>
<pre><code class="language-dart">  runApp(
    UncontrolledProviderScope(
      container: globalContainer, // &lt;- 여기!
      child: ProviderScope(
        child: MyApp(),
      ),
    ),
  );</code></pre>
<p>그다음 앱 실행의 시작점에 있는 <code>ProviderScope</code>의 상단에 속성에 <code>UncontrolledProviderScope</code> 위젯을 감사주고 container 속성에 선언한 ProviderContainer를 넘겨주어 <strong>앱 전체에서 동일한 전역 상태</strong>를 참조하고 관리할 수 있도록 해주면 됩니다.</p>
<p><code>UncontrolledProviderScope</code>는 이미 외부에서 생성된 ProviderContainer를 위젯 트리 하위로 전달할 때 사용됩니다. 보통의 ProviderScope는 내부에서 새로운 ProviderContainer를 생성하고, 위젯 트리와 함께 해당 컨테이너의 생명주기를 관리(예: dispose)하지만, UncontrolledProviderScope는 외부에서 미리 생성한 컨테이너를 그대로 주입하며, 생명주기를 관리하지 않는다는 점이 다릅니다. *<em>결과적으로 앞서 선언한 <code>ProviderContainer</code>는 어떠한 위젯트리의 라이프 사이클에도 영향을 받지 않기 때문에 ProviderScope 대신 UncontrolledProviderScope를 사용하는 것이죠. *</em></p>
<p>비슷하게 <code>ProviderContainer</code>는 <code>위젯</code>이 아니기 때문에 Flutter <code>위젯 트리</code> 밖에서도 사용 가능합니다. 이런 특징으로 보통은 테스트를 할 때 주로 사용되지만, 이번에는 <code>WidgetRef</code>가 없는 레이어 또는 모듈에서 특정 Provider를 참조할 수 있도록 설정해줄 수 있습니다.</p>
<pre><code class="language-dart">FirebaseMessaging.onMessage.listen((RemoteMessage message) {  
  ...  
  if(message.category == &#39;home&#39;) {  
  providerContainer.read(bottomNavigationIndex.notifier).changeIndex(0);    
  }  
});</code></pre>
<p>이제 이렇게 <code>WidgetRef</code>를 접근하지 못하는 레이어 또는 모듈에서도 <code>globalContainer</code>로 Provider에 접근하여 상태를 변경할 수 있습니다.</p>
<pre><code class="language-dart">ProviderScope.containerOf(context).read(bottomNavigationIndex.notifier).changeIndex;</code></pre>
<p>만약 WidgetRef는 없지만 <code>BuildContext</code>에 접근할 수 있는 상태라면 <code>ProviderScope.containerOf</code> 메서드를 사용하는 것도 괜찮습니다. 현재 위젯 트리에서 가장 가까운 ProviderScope가 관리하는 <strong><code>ProviderContainer</code></strong>를 가져와 Provider를 참조할 수 있게 됩니다.</p>
<br/>

<h2 id="4-async-provider의-상태를-가장-안전하게-참조하는-방법">4. Async Provider의 상태를 가장 안전하게 참조하는 방법</h2>
<p> Async Provider의 비동기 상태를 참조해야 될 때 유의해야 하는 부분이 있습니다.</p>
<pre><code class="language-dart">class CartItems extends _$CartItems {  
  @override  
  Future&lt;List&lt;ProductEntity&gt;&gt; build() async {  
    ...  
  }  
}</code></pre>
<p>위는 유저의 &#39;장바구니&#39; 상품 목록 리스트를 서버로부터 호출하여 관리하는 <code>Async Provider</code>입니다. 유저의 취향이 가장 잘 드러난 데이터이기 때문에 여러 섹션에서 해당 Provider를 참조하는 경우가 빈번하다고 가정해 봅시다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3114076c-2e01-4b59-8f74-b228cba882da/image.png" alt=""></p>
<p>뭐 예를 들어 특정 상품을 구매한 이후 유저의 장바구니에 담겨 있는 상품을 함께 노출하여 구매 전환율을 높이는 기능이 있을 수도 있겠죠.</p>
<pre><code class="language-dart">Future&lt;void&gt; promptUserToPurchase(WidgetRef ref){
final cardItemsA = ref.read(cartItemsProvider).value;  
final cardItemsB = ref.read(cartItemsProvider).valueOrNull;  
final cardItemsC = ref.read(cartItemsProvider).requireValue;
}</code></pre>
<p>그러려면 먼저 장바구니의 있는 아이템 리스트를 불러와야 하기 때문에 cartItemsProvider를 참조해야 됩니다. 그리고 cartItemsProvider는 비동기적으로 초기화되는 <code>Async Provider</code>이기 때문에 비동기적으로 호출된 값에 접근하기 위해서는 <code>value</code>, <code>requiredValue</code> 같은 extension 메서드를 사용해볼 수 있겠습니다.</p>
<p><strong>그런데 잠깐, promptUserToPurchase라는 메서드를 실행할 때 cartItemsProvider가 비동기적으로 초기화된 것을 보장할 수 있을까요?</strong> 만약 Provider가 비동기적으로 초기화되지 않았다면 현재 상태가 <code>AsyncValue</code>(loading 또는 error)일 것이고, 로직이 정상 작동하지 않거나 <code>requireValue</code>를 사용했다면 아래와 같은 오류가 발생할 겁니다.</p>
<pre><code>&#39;Tried to call `requireValue` on an `AsyncValue` that has no value: $this&#39;</code></pre><p>그래도 해당 메서드를 실행하는 시점에 비동기 Provider가 초기화된다는 것이 확실히 보장된다고 판단되면 <code>value</code> 또는 <code>requireValue</code>를 사용해도 문제는 없습니다.</p>
<p>하지만 우리의 프로젝트는 수없이 기획 정책이 변경되고, 작업자의 기억이 희미해지기 때문에 해당 메서드가 트리거 되는 시점에** 특정 <code>Async Provider</code>가 비동기적으로 초기화되어 있는지 판단하기 힘든 상황이 분명 오게 됩니다.** 그 당시에는 해당 이벤트가 트리거 되는 시점에 Async Provider가 이전에 초기화된다는 것이 당연했지만, 시간이 지나면 당연한 것이 그렇지 않게 되는 순간이 분명히 오게 된다는 것이죠.</p>
<p>그렇기 때문에 Async Provider를 참조할 경우에는 비동기적으로 호출이 완료되어 생성된 Provider라는 것이 분명하다고 해도 조금 더 안전하게 상태를 참조해야 합니다.</p>
<p>방법은 간단해요.</p>
<pre><code class="language-dart">Future&lt;void&gt; promptUserToPurchase() async {  
  // cartItemsProvider가 로딩 중이라면 완료될 때까지 기다림  
  // 이미 로딩이 끝났다면 기존 데이터를 바로 리턴  
  final cartItems = await ref.read(cartItemsProvider.future);  
}</code></pre>
<p><code>.future</code> 프로퍼티를 사용하면, Provider가 아직 로딩 상태일 경우에도 비동기 호출 및 초기화가 완료될 때까지 <code>await</code>하고 이후 상태를 반환합니다. Provider가 이미 초기화되어 있다면 기존 상태를 즉시 반환하니, 어떤 경우에도 안전하게 값을 얻을 수 있죠.</p>
<p>결과적으로 Async Provider 초기화 여부와 상관없이 항상 안전하게 상태를 참조할 수 있게 됩니다.</p>
<p>이렇게 여러 섹션에서 사용되는 Async Provider 상태를 참조해야 될 때에는 습관적으로 <code>.future</code>를 사용하는 것이 우리 개발자들의 정신 건강에 이롭습니다 🥲</p>
<br/>


<h2 id="5-궁극의-riverpod-페이지-유틸리티-클래스">5. 궁극의 Riverpod 페이지 유틸리티 클래스</h2>
<p>마지막으로, 프로젝트를 진행하면서 저는 공통적으로 사용하는 상태관리 패키지(이 글에서는 Riverpod)에 맞게 &#39;페이지 유틸리티 클래스&#39;를 만들어두곤 합니다.</p>
<p>&#39;궁극의 Riverpod 화면 유틸리티 클래스&#39;라고 조금 거창하게 제목을 지었지만 사실 별거 없습니다. 한 페이지를 구성하는 위젯에서 자주 사용하는 로직을 <strong>BasePage</strong>(또는 BaseScreen) 형태로 뽑아 모듈화해둔 정도입니다. 그리고 각 페이지가 이 BasePage를 상속받아 필요한 부분만 오버라이드하는 식이죠.</p>
<blockquote>
<p>BasePage 코드 자체를 일일이 설명하지는 않겠습니다. 대신 글 하단에 ‘BasePage’ 원본 코드를 첨부했으니 참고 바랍니다.</p>
</blockquote>
<h4 id="라이플-사이클-메소드">라이플 사이클 메소드</h4>
<pre><code class="language-dart">class ProductDetailPage extends BasePage {  
  const ProductDetailPage({super.key});  

  /// 페이지 위젯이 위젯트리에 생성되었을 때
  @override  
  void onInit(WidgetRef ref) {  
    ...  
  } 

  /// 페이지 위젯이 위젯트리에서 해제될 때
  @override  
  void onDispose(WidgetRef ref) {  
    ...  
  }

  @override  
  Widget buildPage(BuildContext context, WidgetRef ref) {  
    return Container();  
  }  
}</code></pre>
<p>보통 각 페이지가 생성되거나 해제될 때, 특정 이벤트를 트리거해야 하는 경우가 있습니다. 그럴 때 BasePage에 정의된 라이프사이클 메서드를 오버라이드하면 됩니다.</p>
<h4 id="argument--provider-초기화">Argument  Provider 초기화</h4>
<p>앞서 ProviderScope를 활용하여 페이지에서 관리하는 라우트 Argument를 효과적으로 관리하는 방법에 대해 다루었습니다. 그러기 위해서는 각 페이지 위젯에서 최상단 부분에서 <code>ConsumerWidget</code>과 <code>ProviderScope</code>를 차례대로 감싸주고 Argument Provider를 초기화해줘야 했었는데요.</p>
<pre><code class="language-dart">class ProductDetailPage extends BasePage {  
  const ProductDetailPage({super.key});  

  @override  
  Override? get argProviderOverrides =&gt; productDetailRouteArgProvider;  

  @override  
  Widget buildPage(BuildContext context, WidgetRef ref) {  
    return Placeholder();  
  }  
}</code></pre>
<p>해당 로직을 BasePage로 편입시켜 위 코드와 같이 Argument Provider를 넘겨주어 Scope를 지정하고 Provider가 해당 Scope에서 초기화되도록 설계하였습니다. 조금 더 간결해 보이죠?</p>
<h4 id="레이아웃-속성-설정">레이아웃 속성 설정</h4>
<p>일반적으로 저희는 Scaffold 위젯을 사용하여 각 페이지의 레이아웃을 구성합니다. 그리고 이 Scaffold 위젯에서는 appBar, body 속성에 화면에 보일 위젯을 설정해주거나, backgroundColor, extendBodyBehindAppBar 같은 속성에는 필요한 여러 레이아웃 값을 설정해주곤 합니다. 그리고 때로는 Scaffold 위에 <code>PopScope</code> 같은 위젯을 감싸 필요한 제스처 설정들을 해주기도 하고요.</p>
<pre><code class="language-dart">@override  
Widget build(BuildContext context, WidgetRef ref) {  
  return PopScope(  
    onPopInvokedWithResult: (didPop, result) async {...},  
    child: Scaffold(  
      backgroundColor: Colors.white,  
      resizeToAvoidBottomInset: true,  
      extendBodyBehindAppBar: true,  
      appBar: AppBar(),  
      floatingActionButton: FloatingActionButton(),  
      drawer: Drawer(),  
      body: SafeArea(...),  
    ),  
  );  
}</code></pre>
<p>이런 자잘한 레이아웃 속성을 매번 페이지마다 만들다 보면, 정작 중요한 <strong>본연의 UI</strong>(body)와 섞여서 가독성이 떨어질 수 있죠.</p>
<p>그래서 BasePage를 상속하는 페이지에서는 ‘body가 되는 위젯만’ <code>buildPage</code>에서 작성하고, 나머지 속성(예: AppBar, BackgroundColor 등)은 오버라이드 메서드로 설정하게 만들어줍니다.</p>
<pre><code class="language-dart">class ProductDetailPage extends BasePage {  
  const ProductDetailPage({super.key});  

  @override  
  Widget buildPage(BuildContext context, WidgetRef ref) {  
    ...  
  }  

  @override  
  PreferredSizeWidget? buildAppBar(BuildContext context, WidgetRef ref) {...}  

  @override  
  Widget? buildFloatingActionButton(WidgetRef ref) {...}  

  @override  
  Color? get screenBackgroundColor =&gt; Colors.white;  

  @override  
  bool get wrapWithSafeArea =&gt; false;  

  @override  
  bool get extendBodyBehindAppBar =&gt; false;  

  @override  
  void onWillPop(WidgetRef ref) {...}  
}</code></pre>
<p>기능적으로는 별 차이가 없지만, 페이지마다 <strong>어떤 레이아웃 옵션을 쓰고 있는지</strong>를 한눈에 파악하기 쉬워지고, 자주 쓰는 인터랙션(라이프사이클, WillPop 등)도 쉽게 커스터마이징할 수 있는 장점이 있습니다.</p>
<p>이외에도 여러 다양한 속성 및 기능들이 정의되어 있으니 아래는 원본 <code>BasePage</code> 코드를 참고 부탁드립니다.</p>
<pre><code class="language-dart">abstract class BasePage extends HookConsumerWidget {  
  const BasePage({Key? key}) : super(key: key);  

  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    /// 페이지의 초기화 및 해제를 처리  
    useEffect(  
      () {  
        onInit(ref);  
        return () =&gt; onDispose(ref);  
      },  
      [],  
    );  

    /// 앱의 라이플 사이클 변화를 처리  
    useOnAppLifecycleStateChange((previousState, state) {  
      switch (state) {  
        case AppLifecycleState.resumed:  
          onResumed(ref);  
          break;  
        case AppLifecycleState.paused:  
          onPaused(ref);  
          break;  
        case AppLifecycleState.inactive:  
          onInactive(ref);  
          break;  
        case AppLifecycleState.detached:  
          onDetached(ref);  
          break;  
        case AppLifecycleState.hidden:  
          onHidden(ref);  
      }  
    });  

    return PopScope(  
      canPop: canPop,  
      onPopInvokedWithResult: (didPop, result) async {  
        if (didPop) return;  
        onWillPop(ref);  
      },  
      child: ProviderScope(  
        overrides: argProviderOverrides != null ? [argProviderOverrides!] : [],  
        child: AnnotatedRegion&lt;SystemUiOverlayStyle&gt;(  
          value: SystemUiOverlayStyle(  
            systemNavigationBarColor: Colors.white,  
            systemNavigationBarIconBrightness: Brightness.dark,  
            statusBarColor: Colors.transparent,  
            statusBarBrightness: statusBarBrightness,  
            statusBarIconBrightness: statusBarBrightness,  
          ),  
          child: HookConsumer(  
            builder: (context, ref, child) {  
              return GestureDetector(  
                onTap: !preventAutoUnfocus  
                    ? () =&gt; FocusManager.instance.primaryFocus?.unfocus()  
                    : null,  
                child: Container(  
                  color: unSafeAreaColor,  
                  child: wrapWithSafeArea  
                      ? SafeArea(  
                          top: setTopSafeArea,  
                          bottom: setBottomSafeArea,  
                          child: _buildScaffold(context, ref),  
                        )  
                      : _buildScaffold(context, ref),  
                ),  
              );  
            },  
          ),  
        ),  
      ),  
    );  
  }  

  Widget _buildScaffold(BuildContext context, WidgetRef ref) {  
    return Scaffold(  
      extendBody: extendBodyBehindAppBar,  
      resizeToAvoidBottomInset: resizeToAvoidBottomInset,  
      appBar: buildAppBar(context, ref),  
      body: buildPage(context, ref),  
      backgroundColor: screenBackgroundColor,  
      bottomNavigationBar: buildBottomNavigationBar(context),  
      bottomSheet: buildBottomSheet(ref),  
      floatingActionButtonLocation: floatingActionButtonLocation,  
      floatingActionButton: buildFloatingActionButton(ref),  
    );  
  }  

  /// 하단 네비게이션 바를 구성하는 위젯을 반환  
  @protected  
  Widget? buildBottomNavigationBar(BuildContext context) =&gt; null;  

  @protected  
  Widget? buildBottomSheet(WidgetRef ref) =&gt; null;  

  /// 상단 status bar(노치바 영역) 텍스트 overlay style  /// 값을 설정하여 상단 텍스트 색상을 조정할 수 있음  
  Brightness get statusBarBrightness =&gt;  
      Platform.isIOS ? Brightness.light : Brightness.dark;  

  /// 화면 페이지의 본문을 구성하는 위젯을 반환  
  @protected  
  Widget buildPage(BuildContext context, WidgetRef ref);  

  /// 화면 상단에 표시될 앱 바를 구성하는 위젯을 반환  
  @protected  
  PreferredSizeWidget? buildAppBar(BuildContext context, WidgetRef ref) =&gt; null;  

  /// 화면에 표시될 플로팅 액션 버튼을 구성하는 위젯을 반환  
  @protected  
  Widget? buildFloatingActionButton(WidgetRef ref) =&gt; null;  

  /// 뷰의 안전 영역 밖의 배경색을 설정  
  @protected  
  Color? get unSafeAreaColor =&gt; AppColor.of.white;  

  /// 키보드가 화면 하단에 올라왔을 때 페이지의 크기를 조정하는 여부를 설정  
  @protected  
  bool get resizeToAvoidBottomInset =&gt; true;  

  /// 플로팅 액션 버튼의 위치를 설정  
  @protected  
  FloatingActionButtonLocation? get floatingActionButtonLocation =&gt; null;  

  /// 앱 바 아래의 콘텐츠가 앱 바 뒤로 표시되는지 여부를 설정  
  @protected  
  bool get extendBodyBehindAppBar =&gt; false;  

  /// Swipe Back 제스처 동작을 막는지 여부를 설정  
  @protected  
  bool get canPop =&gt; true;  

  /// 화면의 배경색을 설정  
  @protected  
  Color? get screenBackgroundColor =&gt; AppColor.of.white;  

  /// SafeArea로 감싸는 여부를 설정  
  @protected  
  bool get wrapWithSafeArea =&gt; true;  

  /// 뷰의 안전 영역 아래에 SafeArea를 적용할지 여부를 설정  
  @protected  
  bool get setBottomSafeArea =&gt; true;  

  /// 뷰의 안전 영역 위에 SafeArea를 적용할지 여부를 설정  
  @protected  
  bool get setTopSafeArea =&gt; true;  

  /// 화면 클릭 시 자동으로 포커스를 해제할지 여부를 설정  
  @protected  
  bool get preventAutoUnfocus =&gt; false;  

  /// 앱이 활성화된 상태로 돌아올 때 호출  
  @protected  
  void onResumed(WidgetRef ref) {}  

  /// 앱이 일시 정지될 때 호출  
  @protected  
  void onPaused(WidgetRef ref) {}  

  /// 앱이 비활성 상태로 전환될 때 호출  
  @protected  
  void onInactive(WidgetRef ref) {}  

  /// 앱이 분리되었을 때 호출  
  @protected  
  void onDetached(WidgetRef ref) {}  

  /// 앱이 hidden 되었을 때 호출  
  @protected  
  void onHidden(WidgetRef ref) {}  

  /// 페이지 초기화 시 호출  
  @protected  
  void onInit(WidgetRef ref) {}  

  /// 페이지 해제 시 호출  
  @protected  
  void onDispose(WidgetRef ref) {}  

  /// will pop시  
  @protected  
  void onWillPop(WidgetRef ref) {}  
}</code></pre>
<p>결국 이 BasePage의 목표는 <strong>한 페이지에서 자주 필요한 기능들을 모아 간편하게 쓸 수 있도록 하고</strong>, <strong>레이아웃 속성들은 명시적으로 오버라이드</strong>하여 가독성과 일관성을 높이려는 데 있다고 볼 수 있습니다.</p>
<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 글에서는 Riverpod을 사용한다면 프로덕션 수준에서 적용해볼 만한 여러 방법에 대해 다루어 보았습니다. 어디까지나 제 경험을 바탕으로 한 팁들이니, 프로젝트 성격과 팀 스타일에 맞춰 적절히 변형해 쓰시면 좋을 것 같네요 :)</p>
<p>긴 글 읽어주셔서 항상 감사합니다 🙇</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[삼쩜삼 Flutter앱에 토스의 터치 인터렉션 끼얹기]]></title>
            <link>https://velog.io/@ximya_hf/three-point-three-enhance-touch-interaction</link>
            <guid>https://velog.io/@ximya_hf/three-point-three-enhance-touch-interaction</guid>
            <pubDate>Sun, 17 Nov 2024 18:08:50 GMT</pubDate>
            <description><![CDATA[<p>삼쩜삼에서 세금 환급 서비스를 이용해 보신 적이 있으신가요? </p>
<p>삼쩜삼 서비스 초창기 때 세금 삼쩜삼에서 꽤 쏠쏠한 금액을 환급 받아서 기분이 좋았던 기억이 납니다. 당시에는 웹 기반으로만 서비스를 제공했던 것으로 기억합니다. 근데 최근에, <strong>Flutter</strong>로 개발된 쌈점삼 앱이 있다는 사실을 알게 되어 직접 다운로드 받아 이것저것 살펴보았습니다.</p>
<h2 id="토스랑-유사한데">토스랑 유사한데...?</h2>
<p>삼쩜삼 앱에 대한 제 첫인상은 토스앱과 UI나 UX적으로 굉장히 유사하다는 점이었는데요. </p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/bb72e4e9-ad87-412f-a41b-2c8e42e899ec/image.png" alt=""></p>
<p>두 앱 모두 <strong>카드 UI</strong>와 <strong>3D 형태의 GUI</strong>를 적극적으로 활용한다는 점이 닮아 있었고, 더더욱 금융이라는 카테고리에 있는 두 앱이 두 앱 모두 파란색 계열의 브랜드 컬러를 채택하하고 있다는 점 때문에 더욱 비슷한 느낌을 주는 것 같았습니다.</p>
<h2 id="토스의-터치-인터렉션-애니메이션">토스의 터치 인터렉션 (애니메이션)</h2>
<p>UI는 닮아 있지만, UX 측면에서는 삼쩜삼과 토스 앱이 명확히 구분되는 부분이 하나 있었습니다. 바로 <strong>터치 인터랙션</strong>입니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/e36215ba-5425-4422-9cc4-89e0fed6cf38/image.gif" alt=""></p>
<p>토스 앱에서는 터치 가능한 대부분의 UI 요소(카드, 버튼 등)에 <strong>터치 애니메이션</strong>이 적용되어 있습니다. 사용자가 UI를 누르면 요소가 살짝 움츠러들었다가, 터치가 해제되는 시점에 다시 원래 크기로 돌아오는 인터렉션이 고려되어 있습니다.</p>
<p>이런 인터랙션은 사소해 보일 수 있지만, 모바일 환경에서는 마우스 커서가 없는 대신 터치 영역이 한정적이라는 점을 보완하며 앱을 <strong>더 생동감 있고 매력적으로</strong> 만들어 준다고 생각합니다. 이러한 이유로 여러 앱스토어, 슬랙, 카카오톡 등등 여러 빅테크 앱에서도 이런 터치 인터렉션들을 적극 사용하고 있는 것 같기도 하고요.</p>
<p>그래서 이번 포스팅에서는 삼쩜삼앱에 토스의 터치 인터렉션 로직을 적용할 수 있는 모듈을 차근차근 단계별로 만들어보려고 합니다. </p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/24c5719e-985e-4298-a707-863462dc4408/image.png" alt=""></p>
<blockquote>
<p>토스와 유사한 터치 인터렉션을 간편하게 적용할 수 있도록 도와주는 <a href="https://pub.dev/packages/bounce_tapper">bounce_tapper</a> 패키지를 개발하였습니다. 여러분의 프로젝트에 풍분한 터치 인터렉션을 적용하고 싶으시다면 해당 패키지를 사용하시길 적극 권장드립니다 😀</p>
</blockquote>
<br/>

<h2 id="1-listener-위젯을-활용해-터치-제스쳐-활성화-및-해제-시점-구분하기">1. Listener 위젯을 활용해 터치 제스쳐 활성화 및 해제 시점 구분하기</h2>
<p>삼쩜삼앱 곳곳에서 사용되고 있는 &#39;환급 받기&#39; 버튼(FilledButton)을 예시로 터치 애니메이션을 적용해 보려고 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/1254d8df-e9f7-4c95-a690-004b7d5d5127/image.png" alt=""></p>
<p>먼저, 터치 시 위젯이 움츠러들고 손가락이 화면에서 떼어질 때 본래 크기로 돌아오는 애니메이션을 구현하려면 <strong>터치 시작</strong>과 <strong>터치 해제</strong> 시점을 정확히 파악해야 합니다. 그 이후에 각 시점에 필요한 애니메이션을 적용하면 될 것 같습니다.</p>
<pre><code class="language-dart">GestureDetector( 
 onTapDown: (_) {  
   log(&#39;onTapDown&#39;);  
    },  
  onTapUp: (_) {  
    log(&#39;onTapUp&#39;);  
  }, 
  child: YourWidget(),
)</code></pre>
<p>일반적으로 여러 제스쳐 처리하는데 많이 주로 사용하는 GestureDetectors 위젯의 <code>onTapDown</code>, <code>onTapUp</code>과 같은 콜백 메서드를 통해 터치가 시작되고 해제되는 시점을 처리할 수 있겠습니다.</p>
<blockquote>
<ul>
<li><strong>onTapDown</strong>: 위젯이 처음 터치될 때 호출됨</li>
<li><strong>onTapUp</strong>: 화면에서 터치가 해제될 때 호출됨</li>
</ul>
</blockquote>
<pre><code class="language-dart">GestureDetector( 
 onTapDown: (_) {  
    log(&#39;onTapDown&#39;);  
    },  
  onTapUp: (_) {  
    log(&#39;onTapuUp&#39;);  
  }, 
  child: FilledButton(  
    onPressed: () {  
      log(&#39;FilledButton &gt; onPressed&#39;);  
    },
   child : Text(&#39;환급받기&#39;)
  ),
)</code></pre>
<p>하지만, 터치 제스처를 지원하는 위젯(FilledButton, Material, Inkwell 등)을 <code>GestureDetector</code>로 감쌌을 때는 제스처 콜백 이벤트가 정상 작동하지 않는 문제가 발생합니다. 반면, 터치 이벤트가 없는 위젯(예: <code>Container</code>)으로 감쌌을 경우에는 정상적으로 작동하는데 말이죠. 이 문제를 로그를 통해 조금 더 명확히 확인해 볼 수 있습니다.</p>
<pre><code class="language-dart">//// Gesture Detector로 FilledButton로 감싸져 있을 때
[log] FilledButton &gt; onPressed

/// 터치 제스쳐를 실행시키지 않는 Container로 감싸져 있을 때
[log] onTapDown  
[log] onTapuUp  
[log] FilledButton &gt; onPressed</code></pre>
<p>로그를 보면, <code>Container</code>를 터치했을 때는 모든 제스처 이벤트가 작동하지만, <code>FilledButton</code>을 터치했을 때는 <code>onPressed</code> 로그만 출력되게 됩니다.</p>
<p>왜 이렇게 작동할까요?</p>
<p>그 이유는 플러터의 제스처 처리 방식에 있습니다. 플러터에서는 하나의 터치 이벤트에 대해 응답하는 위젯이 단 하나만 존재합니다. 즉, 터치 위치에 중복된 위젯들이 있더라도 최종적으로 한 위젯만 이벤트를 처리하는 것이죠.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/7e0961b2-7772-4525-aec0-94ba9f847c47/image.png" alt=""></p>
<p>예를 들어 3가지의 A, B, C 컨테이너가 순서대로 감싸져 있고 각각 GestureDetector에 onTap 제스쳐에 특정 이벤트가 특정된다고 가정해 보겠습니다. 여기서 가장 하위 위젯인 C 컨테이너를 사용자가 터치했을 때 때 A,B의 onTap은 트리거 되지 않을 겁니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/5eca52eb-128e-4534-85a8-c75e578db337/image.png" alt=""></p>
<p>어떻게 보면 당연해 보이지만, GestureDetector 위젯은 내부적으로 여러 복잡한 알고리즘을 거쳐 이러한 터치 동작을 지원합니다. 조금 간단하게 말씀드리자면 터치 영역이 중복된 여러 위젯중 이벤트에 응답하는 위젯을 결정하기 위해 Flutter는 Gesture Arena(경기장)에서 여러 규칙을 통해 승자를 결정하는데요. 그리고 이 Arena에서 가장 첫 번째 승리 원칙은 상위 위젯은 자식 위젯의 Tap Gesture를 이길 수 없다는 것입니다. 그러므로 여기서는 가장 하위 위젯에 속해 있는 &#39;C&#39; 컨테이너가 승자가 되는 것이죠.</p>
<p>그래도 여전히 이 문제를 해결할 방법은 있습니다.
Gesture Arena라는 경기장의 규칙에서 벗어나 필요한 터치 제스쳐 콜백을 실행하기 위해 Listener 위젯을 사용하는 것입니다.</p>
<pre><code class="language-dart">Listener(  
  onPointerDown: (_) {  
    ...  
  },  
  onPointerUp: (_) {  
    ...  
  },  
  child: FilledButton(  
    onPressed: () {  
      ...  
    },  
    child: Text(  
      &#39;환급 받기&#39;,  
    ),  
  ),  
),</code></pre>
<p><code>Listener</code> 위젯은 <code>GestureDetector</code>보다 Raw한 레벨에서 터치 이벤트를 감지하기 때문에 하위 위젯의 제스처와 충돌 없이 터치 시작(<code>onPointerDown</code>)과 해제(<code>onPointerUp</code>) 이벤트를 실행할 수 있게 됩니다.</p>
<blockquote>
<p>RawGestureDetector을 이용하여 GestureArena의 알고리즘을 재설정하는 것도 하나의 방법이 입니다. 다만 개인적으로 Listener를 사용하는 것이 훨씬 더 직관적이고 코드가 간결했습니다.</p>
</blockquote>
<br/>

<h2 id="2-부드러운-축소확대-애니메이션-적용">2. 부드러운 축소/확대 애니메이션 적용</h2>
<p>이제 위젯이 터치되거나 해제되는 시점을 파악했으니, 각 시점에 적합한 축소/확대 애니메이션을 적용해 보겠습니다. <code>AnimationScale</code> 위젯을 사용하면 간단하게 Scale 애니메이션을 적용할 수 있지만, 이후 추가할 애니메이션(예: 터치 시 하이라이트 효과)을 고려해 <code>AnimationController</code>, <code>AnimatedBuilder</code>, <code>Transform.scale</code> 조합하는 방식을 적용하려고 합니다.</p>
<pre><code class="language-dart">late final AnimationController animationController;  
late final Animation&lt;double&gt; _scaleValue;  

@override  
void initState() {  
  super.initState();  
  animationController = AnimationController(  
    vsync: this,  
    duration: const Duration(milliseconds: 160),  
    reverseDuration: const Duration(milliseconds: 120),  
  );  
  _scaleValue = Tween(begin: 1.0, end: 0.965).animate(  
    CurvedAnimation(  
      parent: _controller,  
      curve: Curves.easeInSine,  
      reverseCurve: Curves.easeOutSine,  
    ),  
  );  
}
  ...</code></pre>
<p>우선 AnimationController 초기화 시켜주어야  합니다.  <code>TickerProvider(this)</code> 객체와 애니메이션 재생 시간(<code>duration</code>), 역재생 시간(<code>reverseDuration</code>)을 설정하여 <code>AnimationController</code>를 초기화 해주었습니다.</p>
<blockquote>
<p><code>TickerProvider</code> 객체를 전달하려면 <code>SingleTickerProviderStateMixin</code>을 믹스인해야 합니다. 플러터 Hooks를 사용 중이라면 <code>useAnimationController</code>를 활용해 컨트롤러를 선언해도 됩니다.  </p>
</blockquote>
<p>그 다음 애니메이션의 값 변화를 정의하기 위해 <code>Tween</code> 객체를 사용하여, 시작 값(<code>begin</code>)과 끝 값(<code>end</code>)을 설정해주었는데요. 애니메이션을 적용하려고 하는 카드뷰의 기본 크기에서 0.965배로 축소되도록 하기 위해 시작 값을 <code>1.0</code>, 끝 값을 <code>0.965</code>로 설정한 뒤  추가적으로 Curved 애니메이션을 속성을 통해 애니메이션이 실행될 때 부드럽게 크기가 줄어드는 부분을 고려했습니다.</p>
<pre><code class="language-dart">return AnimatedBuilder(  
  animation: _controller,  
  builder: (context, child) {  
    return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          animationController.forward();   
 .       } 
        onPointerUp: (_) {   
           animationController.reverse();   
 .       } 
        child: FilledButton(  
          onPressed: () {},  
          child: const Text(&#39;환급 받기&#39;),  
        ),  
      ),  
    );  
  },  
);</code></pre>
<p>이후 Listener의 제스쳐 콜백 이벤트에 animationController forward, reverse 메소드를 통해 animation value를 조작하는 메소드를 적절히 설정해 주고,Transform.scale위젯에 해당 value를 전달해 주면 됩니다. 추가적으로 <code>AnimatedBuilder</code>를 사용하여 Animiation이 실행될 때마다 위젯의 렌더링이 될 수 있도록 설정해 주는 부분도 꼭 고려해 주어야 합니다.

그리고 위젯을 한번 터치해 보면 위젯이 터치되는 시점에는 위젯의 크기가 줄어들고 해제되는 순간에 다시 원래 크기로 돌아어오는 애니메이션이 적상적으로 작동되는 것을 확인하실 수 있습니다.</p>
<p>하지만, 일반적인 짧은 순간 탭을 하는 경우 <code>onPointerDown</code>과 <code>onPointerUp</code>이 연달아 호출되어 축소 애니메이션이 끝나기도 전에 다시 확대됩니다. 위젯이 축소되었다가 찰나의 순간 다시 원래 크기로 돌아오기 때문에 터치 애니메이션 실행된다는 것조차 알아차리기 힘들 수 있죠.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/ce626f70-6b0f-4362-ac4b-55f4b4e8f223/image.png" alt=""></p>
<p>계속 예시로 들고 있는 토스앱의 터치 인터렉션을 유심히 살펴보시면 위젯이 완전히 축소된 이후에 확대 애니메이션이 실행되는 것을 알 수 있습니다.</p>
<p>이처럼 유사한 애니메이션을 적용하기 위해 축소 애니메이션이 끝난 후에 확대 애니메이션을 실행하도록 조정할 필요가 있어 보입니다.</p>
<pre><code class="language-dart"> return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          _controller.forward();   
 .       } 
        onPointerUp: (_) async{   
           await _controller.forwrad();
           _controller.reverse();   
 .       } 
        child: FilledButton(  
          onPressed: () {},  
          child: const Text(&#39;환급 받기&#39;),  
        ),  
      ),  
    );  </code></pre>
<p>그리고 이걸 꽤 간단하게 해결할 수 있습니다. onPointerUp안 revers() 메소드가 실행되기 전에, 축소되는 애니메이션이 종료될 때까지 기다리는 &#39;await _controller.forward()&#39; 메소드만 추가해 주면 됩니다. </p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/bf68cf5d-7e08-493a-a429-ac3d3095fe49/image.png" alt=""></p>
<p>그럼이 이제 축소 애니메이션이 마무리 된 이후 자연스럽게 확대되는 애니메이션이 적용되게 됩니다.</p>
<br/>

<h2 id="3-터치-이벤트-중복호출-방지">3. 터치 이벤트 중복호출 방지</h2>
<p>자체적으로 터치 제스쳐 이벤트를 실행시키는 <code>FilledButton</code>과 같은 위젯에 애니메이션을 적용할 때도 있지만, 터치 제스처가 없는 위젯도 고려해야하므로 <code>onTap</code> 메소드를 별도로 실행시켜 주는 로직을 추가해야 합니다.</p>
<pre><code class="language-dart"> return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          _controller.forward();   
 .       } 
        onPointerUp: (_) async{   
           await _controller.forwrad();
           onTap(); // &lt;-- onTap 이벤트 실행!
           _controller.reverse();   
 .       } 
        child: TaxRefoundCard();
      ),  
    );  
</code></pre>
<p>일반적으로 터치가 해제되는 시점에 onTap를 실행시키기 터치가 해제될 때 트리거되는 onPointerUp 메소드 onTap이벤트를 할당해 주었습니다.</p>
<p>하지만 onTap를 실행하는 과정에서 또 다른 문제점이 발생합니다 😢</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3decbbc1-181d-406f-97fa-d3dbb9d444c7/image.png" alt=""></p>
<p>만약 터치 애니메이션이 적용된 위젯을 짧은 시간 동안 연속적으로 탭하거나, 거의 동시에 여러 위젯을 탭하면 <code>Listener</code>의 <code>onPointerUp</code> , <code>onPointerDown</code> 제스처가 여러 번 트리거되며 <code>onTap</code> 이벤트가 중복 호출되는 문제가 발생하게 됩니다.</p>
<p>어떻게 이 문제를 해결할 수 있을까요?</p>
<h3 id="animationcontroller-상태를-활용한-중복-호출-방지">AnimationController 상태를 활용한 중복 호출 방지</h3>
<p>다행히 애니메이션 상태를 나타내는 <code>AnimationController</code>의 상태값을 활용하면, 중복 호출 여부를 판단할 수 있습니다. 애니메이션이 실행 중인 상태에서 <code>onPointerDown</code> 이벤트가 발생하거나, 애니메이션이 종료된 상태에서 <code>onPointerUp</code> 이벤트가 발생하면 중복 호출로 판단하면 될 것 같아요.</p>
<pre><code class="language-dart"> return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          // 애니메이션 실행 중이라면 중복 호출로 판단하여 리턴
           if (controller.isAnimating) return;

          _controller.forward();   
 .       } 
        onPointerUp: (_) async{   
         // 애니메이션이 종료된 상태라면 중복 호출로 판단하여 리턴
          if(_controller.isDismissed) return;

           await _controller.forwrad();
           onTap();
           _controller.reverse();   
 .       } 
        child: TaxRefoundCard();
      ),  
    );  </code></pre>
<p>위 코드에서는 <code>AnimationController</code>의 상태값(<code>isAnimating</code>, <code>isDismissed</code>)을 확인하는 가드문을 추가해 중복 호출 여부를 판단하고, 중복 호출 시 애니메이션 실행이나 <code>onTap</code> 메소드 호출을 방지했습니다.</p>
<h3 id="pointerevent를-활용한-고유-터치-식별">PointerEvent를 활용한 고유 터치 식별</h3>
<p>다만, 이렇게 연속적으로 위젯이 터치되어 실행되는 중복 이벤트 호출은 예외처리할 수 있겠지만, 거의 동시에 여러 위젯이 클릭 되었을 때 발생하는 중복 호출은 여전히 막지 못합니다. </p>
<p>한 화면에서 여러 위젯이 거의 동시에 탭되었는지 판단하는 것은 까다로워 보이지만, <code>Listener</code> 위젯의 터치 제스처 콜백 메소드가 제공하는 <code>PointerEvent</code> 객체를 활용하면 해결의 실마리를 찾을 수 있습니다.</p>
<pre><code class="language-dart"> return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerUp: (PointerUpEvent event)  { 
          /// 애니메이션 실행되고 있는 상태에서 onTapDown이 되었다면 중복호출된 경우 이므로 리턴
           if (controller.isAnimating) return;

          _controller.forward();   
 .       } 
 .       onPointerDown: (PointerUpEvent event)  {   
         /// 애니메이션 실행되지 않은 상태라면 onTapUp이 되었다면 중복호출된 경우 이므로 리턴
          if(_controller.isDismissed) return;

           await _controller.forwrad();
           onTap();
           _controller.reverse();   
 .       } 
        child: TaxRefoundCard();
      ),  
    );  </code></pre>
<p><code>Listener</code>의 터치 제스처 콜백 메소드는 <code>PointerEvent</code>라는 객체를 인자로 제공합니다. 이 객체는 터치와 관련된 다양한 정보를 포함하며, 터치 강도(<code>pressure</code>)와 같은 세부 정보까지 확인할 수 있습니다. 여러 정보를 제공하지만 PointerEvent에서 제공하는 pointer값에 해결책이 있습니다.</p>
<pre><code class="language-dart">  onPointerDown: (PointerUpEvent event)  {   
       final int potiner = event.pointer;
       print(&#39;onPointerUp pointer : $pointer&#39;);
  }
  onPointerUp: (PointerUpEvent event)  {   
       final int potiner = event.pointer;
       print(&#39;onPointerDown : $pointer&#39;);
  }</code></pre>
<p>터치 이벤트가 발생할 때마다 새로운 정수값의 <code>pointer</code>가 생성되며, 이를 통해 각 이벤트를 고유하게 식별하는데 사용됩니다.</p>
<pre><code class="language-log">[log] onPointerUp pointer : 0
[log] onPointerDown pointer : 0
[log] onPointerUp pointer : 1
[log] onPointerDown pointer : 1
[log] onPointerUp pointer : 2
[log] onPointerDown pointer : 2</code></pre>
<p>실제로 여러번 터치를하여 porinter값을 출력해 보면 onPointerUp, onPointerDown을 한 쌍으로 매번 새로운 정수형 형태의 값이 생성되는 걸 확인할 수 있죠. </p>
<p>그러면 이제 이 pointer라는 고유한 값을기 반으로 아래와 같은 예외 처리 작업을 해볼 수 있겠습니다.</p>
<pre><code class="language-dart">int? currentPointer; // &lt;--전역 (top-level) 수준에서 nullable한 변수를 선언


Listener(  
  onPointerDown: (_) {  
    if(currentPointer != null) return; // &lt;-- pointer값이 null이 아니라면 종료  
    _controller.forward();  
    currentPointer = event;   
  },  
  onPointerUp: (_) {  
    if(currentPointer != event.pointer) return; // &lt;-- pointer값이 다르다면 종료  
    await _controller.forwrad();  
        . onTap();  
    currentPointer = null; // &lt;-- pointer 값을 null로 초기화  
    _controller.reverse();  
  },  
  child: FilledButton(  
    onPressed: () {  
      ...  
    },  
    child: Text(  
      &#39;환급 받기&#39;,  
    ),  
  ),  
),
</code></pre>
<p>nullable한 <code>currentPointer</code>라는 전역 변수를 생성한 뒤, <code>onTapDown</code>이 호출되면 <code>currentPointer</code> 값을 현재 고유한 <code>pointer</code> 값으로 갱신하고, <code>onTapUp</code>이 호출되면 다시 <code>null</code>로 초기화합니다.</p>
<p>이 로직을 통해, <code>onTapDown</code> 이벤트 발생 시 <code>currentPointer</code> 값이 <code>null</code>이 아니라면, 현재 화면에서 다른 영역에서 터치 이벤트가 실행 중인 것으로 간주하고 메소드를 종료 해줍니다. 또한, <code>onTapUp</code>에서 전달된 <code>pointer</code> 값이 전역 변수 <code>currentPointer</code>와 일치하지 않으면, 다른 터치 이벤트가 실행 중인 것으로 판단해, <code>onTap</code> 이벤트를 실행하기 전에 메소드를 종료하도록 설계하였습니다.</p>
<p>이제 연속적인 탭을 하거나 여러 영역이 탭되는 경우 onTap 이벤트가 여러 번 실행되는 것을 currentPointer라는 nullable 정수형 flag값을 기반으로 중복 호출을 방지할 수 있습니다.</p>
<p>그리고 지금까지 작성한 터치 인터렉션 로직들을 BounceTapper라는 커스텀 위젯으로 모듈화해 준다면,</p>
<pre><code class="language-dart">BounceTapper(
  onTap: () {
    ...
  },
  child: RefundCard(),
);

BounceTapper(
  child: FilledButton(
    onPressed: () {
      ...
    },
    child: Text(&#39;환급 받기&#39;),
  ),
);
</code></pre>
<p>이렇게 어떤 위젯에든 간단하고 유연하게 터치 인터렉션을 적용할 수 있습니다.</p>
<br/>

<h2 id="4-터치-영역을-벗어났을-때-인터렉션-해제하기">4. 터치 영역을 벗어났을 때 인터렉션 해제하기</h2>
<p>GestureDetector, FilledButton, Inkwell 같이 터치 제스쳐을 지원하는 위젯들은 터치가 된 상태에서 터치 영역 밖으로 이동하면 터치가 해제되면 별도의 onTap 이벤트를 실행시키지 않습니다. 이는 내부적으로 터치 영역을 벗어났는지 확인하고, 터치를 해제하는 로직이 구현되어 있기 때문인데요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/e84aa795-cd04-49da-b510-4c328e510554/image.png" alt=""></p>
<p>아쉽게도 현재 사용 중인 <code>Listener</code> 위젯은 Raw 수준의 터치 제스처만 인식하기 때문에 터치 영역을 벗어났을 때 이를 해제하는 로직이 기본적으로 제공되지 않지만, 직접 만들 수 있는 방법은 있습니다.</p>
<p><code>Listener</code> 위젯은 onPointerDown, onPointerUp 제스쳐 뿐만 아니라 화면에서 손가락이 터치된 상태로 움직일 때마다 <code>onPointerMove</code> 콜백 메서드를 실행합니다.</p>
<p>이 <code>onPointerMove</code> 메서드는 앞서 언급한 제스처 이벤트들처럼 <code>PointerEvent</code> 객체를 제공하며, 해당 객체의 <code>localPosition</code> 속성을 통해 현재 터치가 발생한 좌표를 확인할 수 있습니다.</p>
<p>이 좌표를 활용하여 터치가 지정된 영역을 벗어났는지 판별하려면, 위젯의 크기(너비와 높이)를 기준으로 터치 좌표가 경계를 넘어섰는지 확인하면 될 것 같습니다.</p>
<pre><code class="language-dart">/// 특정 위치가 터치 영역 내에 있는지 확인하는 메서드
bool isWithinBounds({required Offset position, required Size touchAreaSize}) {  
  return !(position.dx &lt;= 0 ||  
      position.dx &gt;= touchAreaSize.width ||  
      position.dy &lt;= 0 ||  
      position.dy &gt;= touchAreaSize.height);  
}

// 터치 영역을 식별하기 위한 GlobalKey
final GlobalKey _touchAreaKey = GlobalKey();

...

return Listener(
  // 현재 위젯에 GlobalKey를 연결
  key: _touchAreaKey,  
  // 터치가 움직일 때마다 호출되는 콜백
  onPointerMove: (PointerEvent event) {  
    // 현재 터치 위치가 지정된 영역을 벗어났는지 확인
    if (!isWithinBounds(  
      position: event.localPosition, 
      touchAreaSize: _touchAreaKey.currentContext?.size ?? Size.zero, 
    )) {  
      // 터치가 영역을 벗어난 경우
      if (_controller.isCompleted) {  // 애니메이션이 완료된 상태라면
        await _controller.reverse();  
        currentPointer = null;  
      }  
    }
  }
);
</code></pre>
<p><code>Listener</code> 위젯에 <code>GlobalKey</code>를 할당해 현재 위젯의 크기(높이와 너비)를 가져온 뒤, 이를 바탕으로 터치 좌표가 영역을 벗어났는지 확인하는 <code>isWithinBounds</code> 메서드를 작성했습니다.</p>
<p>이 메서드는 터치 좌표가 영역 내부에 있다면 <code>true</code>, 그렇지 않으면 <code>false</code>를 반환하며, 이를 통해 터치 인터렉션을 해제할지 결정할 수 있습니다.</p>
<br/>

<h2 id="5-스크롤이-되었을-때--인터렉션을-해제">5. 스크롤이 되었을 때  인터렉션을 해제</h2>
<p>이제 꽤 쓸만한 터치 인터렉션을 모듈을 구현했지만, 조금 더 욕심내어 섬세한 인터렉션을 로직들을 추가해 보려고 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/f4d784f9-a808-4633-894e-1a1b8ad2095a/image.gif" alt=""></p>
<p>토스앱을 유심히 살펴보면 특정 영역이 터치되어 축소 애니메이션이 일어난 상태에서 스크롤이 되면 현재 축소된scale 애니메이션을 즉시 해제되면서 동시에 별도의 터치 이벤트(onTap)도 실행시키지 않는다는 것을 확인하실 수 있습니다..이 동작을 기존 모듈에 적용해 보려고 합니다.</p>
<pre><code class="language-dart">
final scrollController = ScrollController();  

scrollController.addListener(() async {  
   if (currentPoint == null) return;  

    /// 스크롤 제스쳐가 감지되었고 현재 터치 애니메이션이 실행 중이라면
    /// 애니메이션을 해제하고 터치이벤트가 발생하지 않도록 값을 설정
      await _controller.reverse();  
      currentPoint = null;
});  


return Listener(..)</code></pre>
<p>간단히 BounceTapper 모듈에 scrollController를 전달해 주고 모듈 내부에서 전달받은 ScrollController로부터 터치 제스쳐를 감지할 수 있는 addListener 등록해준 뒤, 스크롤이 되면 addListener 콜백 메소드가 실행되기 때문에 내부에 애니메이션이 진행 중이라면 애니메이션을 해제(reverse)하고 onTap 이벤트또한 실행되지 않도록  currentPoint를 null로 초기화 해주면 되겠죠.</p>
<p>잘 작동은 하겠지만, 이렇게 ScrollController를 전달하여 addListener 로직을 BouncTapper 모듈 내부 실행해 주는 방식으로 설계한다면 한 가지 번거로운 점이 발생합니다.</p>
<pre><code class="language-dart">SingleChildScrollView(  
    controller: scrollController,  
    child: Column(  
      mainAxisAlignment: MainAxisAlignment.center,  
      children: [  
        BounceTapper(  
          scrollController: scrollController,  
          child: WidgetA(),  
        ),  
        BounceTapper(  
          scrollController: scrollController,  
          child: WidgetB(),  
        ),  
        BounceTapper(  
          scrollController: scrollController,  
        ),  
        BounceTapper(  
          scrollController: scrollController,  
        ),  
      ],  
    ),  
  ),  </code></pre>
<p>Scroll을 지원하는 위젯 내부에 <code>BounceTapper</code> 위젯이 많아질수록 <code>scrollController</code>를 각 위젯마다 매번 전달해야 한다는 것이죠. 위젯 트리의 깊이가 깊어질수록 관리가 더 까다로워지고 코드도 점점 복잡해질 것입니다.</p>
<p><code>ScrollController</code>를 일일이 전달하지 않고 접근할 방법이 없을까요? Flutter에서 제공하는 <strong><code>Scroll.maybeOf</code></strong> 메소드를 사용하면 이를 손쉽게 해결할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/582a5178-1bf7-41b3-b5a8-0d4ca47b6e00/image.png" alt=""></p>
<p><code>Scroll.maybeOf</code>는 전달받은 <code>BuildContext</code>에서 가장 가까운 조상(ancestor) <strong><code>ScrollController</code></strong>를 반환해 줍니다. 이를 통해 <code>ScrollController</code>를 <code>BounceTapper</code> 모듈에 명시적으로 전달할 필요가 없어집니다.</p>
<pre><code class="language-dart">final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller</code></pre>
<p>참고로, <code>SingleChildScrollView</code>나 <code>ListView</code>처럼 스크롤이 가능한 위젯은 기본적으로 <strong><code>ScrollController</code>를 생성</strong>하므로, 개발자가 따로 할당하지 않아도 됩니다.</p>
<pre><code class="language-dart">@override  
void initState() {  
  ....

  WidgetsBinding.instance.addPostFrameCallback((_) {  
    final ScrollController? scrollController =  
        Scrollable.maybeOf(context)?.widget.controller;  
    scrollController?.addListener(() {  
      if (!animationController.isForwardOrCompleted ||  
          currentPoint == null) return;  

    /// 스크롤 제스쳐가 감지되었고 현재 터치 애니메이션이 실행중이라면
    /// 애니메이션을 해제하고 터치이벤트가 발생하지 않도록 값을 설정
      await _controller.reverse();  
      currentPoint = null;
    });  
  });  
}</code></pre>
<p>이제 <code>BounceTapper</code> 모듈의 <code>initState</code> 메소드에서 위 코드와 같이 <code>ScrollController</code>를 리슨하는 로직을 추가합니다.</p>
<p><code>Scrollable.maybeOf(context)</code>를 통해 <code>ScrollController</code>에 접근하려면 <strong><code>context</code>가 완전히 초기화된 이후</strong>여야 하므로, <strong><code>WidgetsBinding.instance.addPostFrameCallback</code></strong>을 사용해 위젯 트리가 생성된 다음에 해당 로직이 실행되도록 합니다.</p>
<blockquote>
<p><strong>참고</strong>: 상위 위젯에 스크롤 위젯이 없는 경우 <code>null</code>을 반환하므로, 이 경우에는 <code>addListener</code>가 등록되지 않습니다.</p>
</blockquote>
<br/>

<h2 id="6-터치되었을-때-하이라이트-되는-효과">6. 터치되었을 때 하이라이트 되는 효과</h2>
<p>자, 거의 다 왔습니다. 마지막으로 토스의 터치 인터렉션을 하나 더 참고하여 적용해 보죠. 
토스 앱에서는 터치 영역이 활성화되면 해당 영역 위에 오버레이되어 하이라이트 효과가 존재합니다. 이러한 효과는 인터페이스의 시각적 피드백을 제공하여 사용자의 경험을 향상시킬 수 있는 하나의 요소가 될 수 있기에 꽤 중요한 부분이라고 생각됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/8bd45850-3a4c-4c7c-88fe-533d3da1d9b5/image.png" alt=""></p>
<p><code>BounceTapper</code>에 동일한 하이라이트 효과를 적용하기 위해 <code>Stack</code> 레이아웃을 사용해 구현해보겠습니다.</p>
<pre><code class="language-dart">AnimatedBuilder(  
  animation: _animation,  
  child: widget.child,  
  builder: (context, child) {  
    return Transform.scale(  
      alignment: Alignment.center,  
      scale: _animation.value : 1.0,  
      child: Stack(  
        clipBehavior: Clip.none,  
        children: [  
          // 터치 애니메이션을 적용하려고 하는데 위젯
          child!,  
          /// 하아라이트 박스 
          Positioned.fill(  
            child: ClipRRect(  
              borderRadius: targetRadius,
              child: IgnorePointer(  
                child: Builder(  
                  builder: (context) {  
                    const shrinkScaleFactor = 0.965;  
                    final opacity = _animation.value &lt;= shrinkScaleFactor  
                        ? 1.0  
                        : (1.0 - _animation.value) /  
                            (1.0 - shrinkScaleFactor);  

                    return Opacity(  
                      opacity: opacity,  
                      child: ColoredBox(  
                        color: widget.highlightColor,  
                      ),  
                    );  
                  },  
                ),  
              ),  
            ),  
          ),  

        ],  
      ),  
    );  
  },  
),</code></pre>
<p><code>Stack</code> 내부에 하이라이트 박스를 <code>Positioned</code>로 감싸 위젯(<code>child</code>) 위에 오버레이 형태로 배치했습니다.<br>이 하이라이트 박스는 <strong><code>opacity</code></strong> 값을 조정하여 노출 여부를 제어합니다.</p>
<p>해당 코드에서는 <code>AnimationController</code>의 값(<code>value</code>)에 따라 <code>opacity</code>를 설정합니다.<br>애니메이션이 진행될수록 축소(<code>value</code>가 <code>0.965</code>로 수렴)될 때는 <code>opacity</code>가 <code>1.0</code>에 가까워지고,<br>확장(<code>value</code>가 <code>1.0</code>으로 수렴)될 때는 <code>opacity</code>가 <code>0.0</code>으로 감소하여 하이라이트 효과가 사라지게 설정했습니다.</p>
<p>또한, 하이라이트 박스를 <code>IgnorePointer</code>로 감싸 터치 이벤트를 방해하지 않도록 하고,  <code>ClipRRect</code>로 감싸 <strong><code>borderRadius</code></strong> 를 지정할 수 있도록 설계했습니다.</p>
<h3 id="자동으로-borderradius-설정하기">자동으로 <code>borderRadius</code> 설정하기</h3>
<p>하이라이트 효과가 잘 작동하지만, 한 가지 추가로 고려해야 할 점이 있습니다.</p>
<pre><code class="language-dart">BounceTapper(
    tagetBorderRadius : BorderRadius.radius(8)
    child : FilledButton(
      child : Text(&#39;환급받기&#39;)
       )
),</code></pre>
<p>터치 인터랙션이 적용된 위젯에 <strong><code>borderRadius</code></strong> 가 설정되어 있다면, 위 코드처럼 해당 값을 <code>BounceTapper</code> 모듈에도 반드시 전달해야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/51377447-87f8-412a-b5f8-40aa06ef2fd3/image.png" alt=""></p>
<p><code>borderRadius</code> 값을 누락하면 위 그림처럼 하이라이트 영역이 터치 위젯의 경계를 벗어나 어색해 보일 수 있습니다.<br>하지만 매번 값을 수동으로 전달하는 것은 번거롭습니다. 위젯의 <code>borderRadius</code>를 자동으로 추출해 적용할 수 있다면 훨씬 간편해지겠죠?</p>
<p>앞서 <code>Scrollable.maybeOf(context)</code> 메소드를 통해 위젯 트리의 상위에서 <code>ScrollController</code>를 자동으로 추출했던 것처럼, 이번에도 비슷한 접근 방식을 활용해 보면 될 것 같습니다. <code>BuildContext</code>를 기반으로 하위 위젯 트리를 순회하면서 <code>borderRadius</code> 값을 추출하는 것이죠. 아래와 같이 코드를 작성해 보았습니다.</p>
<pre><code class="language-dart">/// 주어진 context에서 가장 가까운 borderRadius 값을 추출하는 메소드
BorderRadiusGeometry? getChildBorderCloseBorderRadius(BuildContext context) {  
  try {  
    BorderRadiusGeometry? closestBorderRadius;  

    void inspectElement(Element element) {  
      final renderObject = element.renderObject;  
      if (renderObject is RenderBox) {  
        final renderInfo = _getRenderInfoFromRenderObject(renderObject);  
        if (context.size == renderInfo.size &amp;&amp;  
            renderInfo.borderRadius != null &amp;&amp;  
            renderInfo.borderRadius != BorderRadius.zero) {  
          closestBorderRadius = renderInfo.borderRadius;  
          return;  
        }  
      }  
      // 자식 요소를 순회하여 위의 조건을 충족하는 borderRadius가 있는지 확인
      element.visitChildren((childElement) {  
        inspectElement(childElement);  
        if (closestBorderRadius != null) return;  
      });  
    }  

    final rootElement = context as Element;  
    inspectElement(rootElement);  

    return closestBorderRadius;  
  } catch (e) {  
     log(&#39;대상 위젯의 borderRadius를 추출하는 동안 오류가 발생했습니다. 예상치 못한 오류나 Flutter 버전 호환성 문제일 수 있습니다: $e&#39;);
    return null;  
  }  
}  

/// 다양한 유형의 RenderBox에서 borderRadius를 추출하는 메소드
({Size size, BorderRadiusGeometry? borderRadius})  
    _getRenderInfoFromRenderObject(RenderBox renderObject) {  
  if (renderObject is RenderClipRRect) {  
    return (size: renderObject.size, borderRadius: renderObject.borderRadius);  
  }  
  if (renderObject is RenderPhysicalModel) {  
    return (size: renderObject.size, borderRadius: renderObject.borderRadius);  
  }  
  if (renderObject is RenderDecoratedBox) {  
    final decoration = renderObject.decoration;  
    if (decoration is BoxDecoration) {  
      return (size: renderObject.size, borderRadius: decoration.borderRadius);  
    } else if (decoration is ShapeDecoration) {  
      final shape = decoration.shape;  
      if (shape is RoundedRectangleBorder) {  
        return (size: renderObject.size, borderRadius: shape.borderRadius);  
      }  
    }  
  }  
  if (renderObject is RenderPhysicalShape) {  
    final CustomClipper&lt;Path&gt;? clipper = renderObject.clipper;  
    if (clipper is ShapeBorderClipper) {  
      final shape = clipper.shape;  
      if (shape is RoundedRectangleBorder) {  
        return (size: renderObject.size, borderRadius: shape.borderRadius);  
      }  
    }  
  }  
  // borderRadius가 없는 경우 null 반환
  return (size: renderObject.size, borderRadius: null);  
}</code></pre>
<p>코드가 꽤 복잡해 보이지만 원리는 간단합니다. <code>BuildContext</code>를 순회하며 특정 <code>RenderBox</code>에서 <code>borderRadius</code>를 추출하는 것이죠. 예외적인 상황에서도 오류가 발생하거나 잘못된 borderRadius 값을 반환하지 않도록 여러 안정장치를 두었으며, <code>borderRadius</code> 값을 반환하거나, 없을 경우 <code>null</code>을 반환합니다.</p>
<pre><code class="language-dart">BorderRadiusGeometry? targetRadius;

@oveeride
initState(){
WidgetsBinding.instance.addPostFrameCallback((_) {  
  targetRadius = getChildBorderCloseBorderRadius(context);  
}
</code></pre>
<p><code>Scrollable.maybeOf(context)</code>와 동일하게, getChildBorderCloseBorderRadius 메소드 context을 전달받아 필요한 작업을 처리하기 때문에 위젯 트리가 완전히 초기화된 후에 <strong><code>context</code></strong> 에 접근할 수 있도록 보장해 주어야 합니다.  따라서<code>WidgetsBinding.instance.addPostFrameCallback</code>을 사용해 <code>borderRadius</code>를 추출하는 메소드를 실행하도록 설정하였습니다.</p>
<p>마지막으로 getChildBorderCloseBorderRadius메소드를 통해 추출된 값을 <code>ClipRRect</code> 위젯에 전달하면, 매번 수동으로 값을 설정할 필요 없이 자동으로 적절한 <strong><code>borderRadius</code></strong> 가 적용되게 됩니다.</p>
<br/>

<h2 id="삼쩜삼앱에-적용하기">삼쩜삼앱에 적용하기</h2>
<p>이제 지금까지 구현된 BounceTapper을 모듈을 삼쩜삼앱에 적용해볼 차례입니다.</p>
<pre><code class="language-dart">BounceTapper(  
  onTap: (){  
    ...  
  },  
  child: const _TaxRefundStatusCard(),  
),</code></pre>
<p>어떠한 위젯이든 터치 인터렉션을 적용하고 싶은 위젯에 BounceTapper 위젯을 감싸주기만 하면 됩니다.</p>
<pre><code class="language-dart">BounceTapper(  
  child: FilledButton(  
    onPressed: () async {  
      Navigator.of(context).push(  
        MaterialPageRoute(  
          builder: (context) =&gt; const DetailPage(),  
        ),  
      );  
    },  
    child: const Text(&#39;환급 받기&#39;),  
  ),  
),</code></pre>
<p>특히, 앞서 Listener위젯을 사용하여 터치 이벤트를 리슨하기 때문에 FilledButton과 같은 터치 제스쳐와 충돌하지 않기에 기존 위젯의 onTap 또는 onPress 이벤트를 수정하지 않아도 되어 훨씬 간편하기도 합니다.</p>
<p>삼쩜삼앱의 홈 영역을 클론하여 터치 인터렉션 로직을 적용하였는데요. 구현 영상을 아래 유튜브 링크를 참고해주세요.</p>
<p>!youtube[1zAuoLTHxEA]</p>
<blockquote>
<p>예제 코드가 궁금하다면 아래 깃허브 레포지토리를 참고해 주세요.
👉 <a href="https://github.com/Xim-ya/three_point_three">https://github.com/Xim-ya/three_point_three</a></p>
</blockquote>
<p> 서론에 잠깐 언급 드렸지만, 혹시 여러분의 프로젝트에 터치인터렉션을 적용하고 싶으면 최근에 제가 배포한 패키지를 사용해 보시길 권장드립니다. 글에서 다루지 못한 더 세밀한 인터렉션 로직들이 여럿 적용되어 있습니다.</p>
<blockquote>
<p>👉 bounce_tapper : <a href="https://pub.dev/packages/bounce_tapper">https://pub.dev/packages/bounce_tapper</a></p>
</blockquote>
<br/>

<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 글에서는 여러 빅테크앱에 적용되어 있는 축소 / 확대 터치 인터렉션을 적용하는 방법에 대해 알아보았습니다. 사실 이번에 bounce_tapper 패키지를 개발하면서 초반에는 정말 간단한 작업이라고 생각했지만, 세밀한 인터렉션을 하나하나 고려해야 하고 쉽게 사용할 수 있는 형태로 모듈화하는 방법을 구상하다보니 생각보다 공수를 많이 들이게 되었네요.</p>
<p>긴 글 읽어주셔서 감사하며, 다음에는 조금 더 유익한 글로 돌아오겠습니다.
감사합니다 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[iOS 앱을 개발하고 있다면, 당장 핫픽스를 해야 할지도...]]></title>
            <link>https://velog.io/@ximya_hf/multilingual-typing-ios-issue</link>
            <guid>https://velog.io/@ximya_hf/multilingual-typing-ios-issue</guid>
            <pubDate>Tue, 15 Oct 2024 15:30:35 GMT</pubDate>
            <description><![CDATA[<p>새롭게 출시된 iOS 18 버전에 &#39;이중 언어 키보드&#39; 기능이 추가되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/fdaee847-99dd-40b5-80a3-d164690dbe01/image.png" alt=""></p>
<p>덕분에 한국어와 다른 언어(영어)를 더욱 편리하게 입력할 수 있게 되었지만, 숫자 패드 키보드에서 숫자를 입력하고 이를 일정한 형식으로 포맷팅하는 과정에서 오류가 발생하는 이슈가 있었습니다.</p>
<p>해당 이슈로 급하게 핫픽스를 했고, 관련 사례를 간단히 소개드리려고 합니다.</p>
<p>Flutter(플러터) 환경에서 발생한 버그를 기반으로 공유드리지만, iOS(네이티브), React Native 등 다른 환경에서 iOS 앱을 개발하고 계신다면, 여러분의 프로젝트에서도 동일한 문제가 발생할 가능성이 높습니다.</p>
<h2 id="핸드폰번호-하이픈--삽입-로직">핸드폰번호 하이픈(-) 삽입 로직</h2>
<p>저희 앱의 로그인 화면에서 휴대전화 번호를 입력받는 <code>TextField</code>가 있으며, 번호가 입력될 때 형식에 맞게 하이픈을 자동으로 삽입해주는 <code>InputFormatter</code> 로직이 존재합니다. </p>
<pre><code class="language-dart">class SampleTextField extends StatelessWidget {
  const SampleTextField({super.key});

  @override
  Widget build(BuildContext context) {
    return TextField(
      inputFormatters: [
        PhoneNumForamtter(), 
      ],
      keyboardType: TextInputType.number, // OR TextInputType.phone
    );
  }
}</code></pre>
<p>많은 앱들이 그러하듯, 일반적으로 11자리 번호를 입력할 때 아래와 같이 포맷이 되어야 하죠.</p>
<pre><code>010
010-1
010-12
...
010-1234-5678</code></pre><p>그런데 문제는 iOS 18 환경에서 이중 언어(한국어+영어) 키보드로 설정하고 가상 <code>keyboardType</code>을 숫자 패드(number, phone)로 설정했을 때, 아래와 같이 비정상적인 포맷팅 값이 떨어지게 됩니다.</p>
<pre><code>010
010-1
001-012
000-101-23
000-0010-1234</code></pre><p>포맥 로직을 관리하는 <code>TextInputFormatter</code>의 <code>formatEditUpdate</code> 메소드에 로그를 찍어보면 문제가 조금 더 확연히 보이는데요. </p>
<pre><code class="language-dart">class SampleFormatter extends TextInputFormatter {
  SampleFormatter();

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    log(&#39;oldValue: $oldValue&#39;);
    log(&#39;newValue: $newValue&#39;);
    return newValue;
  }
}</code></pre>
<h4 id="expected-result">Expected Result</h4>
<pre><code>[log] oldValue : TextEditingValue(text: ┤123├, selection: TextSelection.collapsed(offset: 3, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤1234├, selection: TextSelection.collapsed(offset: 4, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))</code></pre><p>이렇게 포맷팅 로직에서 현재 텍스트가 입력되기 이전 값인 <code>oldValue</code>와 새롭게 입력된 값인 <code>newValue</code>를 로그로 출력해보면, 위와 같은 기대값이 나와야 하지만,</p>
<h4 id="actual-result">Actual Result</h4>
<pre><code>[log] oldValue : TextEditingValue(text: ┤123├, selection: TextSelection(baseOffset: 1, extentOffset: 3, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤1├, selection: TextSelection.collapsed(offset: 1, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] oldValue : TextEditingValue(text: ┤1├, selection: TextSelection.collapsed(offset: 1, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤12├, selection: TextSelection.collapsed(offset: 2, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] oldValue : TextEditingValue(text: ┤12├, selection: TextSelection(baseOffset: 0, extentOffset: 2, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤├, selection: TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] oldValue : TextEditingValue(text: ┤├, selection: TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤1├, selection: TextSelection.collapsed(offset: 1, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] oldValue : TextEditingValue(text: ┤1├, selection: TextSelection(baseOffset: 0, extentOffset: 1, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤├, selection: TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] oldValue : TextEditingValue(text: ┤├, selection: TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
[log] newValue : TextEditingValue(text: ┤1234├, selection: TextSelection.collapsed(offset: 4, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
</code></pre><p>문제가 발생하는 환경에서는 위와 같이 <code>TextInputFormatter</code>의 <code>formatEditUpdate</code> 이벤트가 여러 번 트리거되는 것을 확인할 수 있었습니다. </p>
<p>이 과정에서 하이픈을 삽입하는 로직이 예상치 못한 방식으로 작동하는것이였고, 입력된 숫자를 포맷팅하는 로직이 없더라도 입력 이벤트가 여러 번 실행되기 때문에 숫자를 입력하는 순간에 <code>TextField</code>의 숫자가 깜빡거리는 이슈가 발생하게 됩니다.</p>
<p>이 이슈를 해결하기 위해 텍스트 입력 이벤트가 여러번 트리거되는 특정 패턴을 찾아보려 했으나, 패턴이 일정하지 않아 리스크가 크다고 판단되어 채택하지 않았습니다.</p>
<p>그래서 이중 언어 키보드를 사용하는 환경에서만 하이픈 삽입 로직을 배제하려 했으나, iOS에서 사용자의 키보드 유형을 알 수 있는 방법이 없어서 결국 iOS 18 환경에서는 하이픈 삽입 로직을 제거하는 방향으로 코드를 수정했습니다.</p>
<h2 id="이거-또또또-😮💨-flutter-이슈인가">이거 또또또.. 😮‍💨 Flutter 이슈인가?</h2>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/2ca4a769-7a2a-40ba-adc4-ab42f52124e4/image.jpeg" alt=""></p>
<p>또 수많은 플러터 이슈중에 하나라고 생각되어, Flutter 깃헙에 이슈를 등록했지만, </p>
<p>👉 <a href="https://github.com/flutter/flutter/issues/156691">깃헙 이슈</a></p>
<p>조금 더 확인해보니, 당근마켓, 뱅크샐러드 등 네이티브로 구현된 메이저 앱에서도 현재 동일한 문제가 발생하고 있더라고요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/89d26f6e-c760-4d39-84f4-3035bd5706ca/image.gif" alt=""></p>
<blockquote>
<p>당근 마켓 로그인 화면</p>
</blockquote>
<p>번호 자동완성이나 복사 붙여넣기 기능이 있다면 어느 정도 문제가 커버되지만, 그렇지 않다면 몇몇 서비스에서는 로그인 자체가 불가능한 상황이 발생할 수 있어 꽤 크리티컬한 이슈라고 판단됩니다.</p>
<p>아쉽게 완벽하게 문제를 해결하지는 못했지만, 최근 iOS 18 버전 출시 이후 여러 이슈들을 팔로우업 하시면 조금이라도 도움이 되실 수 있을 것 같아서 공유드립니다:)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[힙하게 가변적인 위젯의 크기 구하기]]></title>
            <link>https://velog.io/@ximya_hf/measure-dynamic-size-of-widget</link>
            <guid>https://velog.io/@ximya_hf/measure-dynamic-size-of-widget</guid>
            <pubDate>Sun, 05 May 2024 04:59:31 GMT</pubDate>
            <description><![CDATA[<p>Flutter로 UI 작업을 하다 보면 자식 위젯에 따라 <code>가변적인 크기</code>를 가지는 위젯의 크기를 측정해야 하는 경우가 종종 있습니다. 직장인들이 많이 이용하는 &#39;블라인드&#39; 앱을 예로 들어보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/44ae0a76-c8be-4770-ab73-e10c71c3c3f2/image.png" alt=""></p>
<p>블라인드 앱의 회사 리뷰 페이지를 보면, 리뷰 항목이 일정 높이를 넘어가면 <code>더보기</code> 버튼이 나타나고, 일부 UI가 가려지는 형태를 볼 수 있습니다. 더보기 버튼을 누르면 가려진 내용이 나타나는데요. 만약 특정 높이를 초과하지 않으면 <code>더보기</code> 버튼은 보이지 않습니다.</p>
<p>이러한 리뷰 목록 항목은 <strong>크기가 고정되어 있지 않고 텍스트 데이터의 양과 디바이스의 너비에 따라 유동적으로 높이가 변합니다</strong>. 그렇기 때문에 이런 구성을 가지고 있는 UI를 구현하기 위해서는 위젯이 렌더링된 높이를 측정하고 특정 높이를 초과하는지에 따라 조건별로 더보기 버튼과 가려지는 형태의 UI를 구성할 필요가 있습니다.</p>
<p>그럼 어떻게 가변적인 <code>위젯의 렌더링 크기</code>를 측정할 수 있을까요? 이번 포스팅에서는 간단한 예제를 통해 위젯의 가변적인 크기를 측정하는 방법은 단계별로 알아보려고 합니다. 또한 다음 개념들을 다루면서 Flutter의 <code>렌더링 원리</code>에 대해 다루고 있습니다.</p>
<ul>
<li>WidgetTree, ElementTree, RenderTree</li>
<li>BuildContext</li>
<li>RenderObject</li>
<li>addPostFrameCallback</li>
<li>NotificationListener</li>
</ul>
<br/>

<h2 id="구현-목표">구현 목표</h2>
<p>포스팅에서 다룰 예제를 간단히 살펴보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/0be52fc3-c612-4284-bd55-2b2320a64a3a/image.png" alt=""></p>
<p>위 스크린샷은 영화 출연진들의 정보를 보여주는 간단한 페이지입니다. 제목 섹션의 Text 위젯과 출연진들의 정보를 보여주는 ExpansionTile로 구성된 ListView 위젯이 Column 안에 감싸져 있으며, 제목 Text 위젯에는 현재 Column 위젯의 높이를 보여줍니다.</p>
<pre><code class="language-dart">class CastInfoPage extends StatelessWidget {
  const CastInfoPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF000000),
      appBar: AppBar(
        leading: const Icon(
          Icons.arrow_back_ios,
          color: Colors.white,
        ),
        titleSpacing: 0,
        backgroundColor: Colors.black,
        centerTitle: false,
        title: Text(
          &#39;Dune: Part Two&#39;,
          style: AppTextStyle.headline1,
        ),
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.symmetric(horizontal: 16) +
              const EdgeInsets.only(top: 20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                &#39;Height : ${0}&#39;,
                style: PretendardTextStyle.bold(
                  size: 24,
                  height: 37,
                  letterSpacing: -0.2,
                ),
              ),
              const SizedBox(height: 10),
              ListView.separated(
                physics: const NeverScrollableScrollPhysics(),
                padding: EdgeInsets.zero,
                shrinkWrap: true,
                itemCount: CastModel.castList.length,
                separatorBuilder: (_, __) =&gt; const SizedBox(height: 8),
                itemBuilder: (context, index) {
                  final item = CastModel.castList[index];
                  return ExpansionTile(
                    tilePadding: EdgeInsets.zero,
                    title: Row(
                      children: [
                        ClipRRect(
                          borderRadius: BorderRadius.circular(56 / 2),
                          child: CachedNetworkImage(
                            height: 56,
                            width: 56,
                            imageUrl: item.imgUrl,
                            fit: BoxFit.cover,
                          ),
                        ),
                        const SizedBox(width: 10),
                        Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              item.name,
                              style: AppTextStyle.title1,
                            ),
                            Text(
                              item.role,
                              style: AppTextStyle.body3.copyWith(
                                color: AppColor.gray02,
                              ),
                            )
                          ],
                        ),
                      ],
                    ),
                    children: &lt;Widget&gt;[
                      Text(
                        item.description,
                        style: AppTextStyle.body3,
                      ),
                    ],
                  );
                },
              )
            ],
          ),
        ),
      ),
    );
  }
}
</code></pre>
<p>ExpansionTile을 클릭하게 되면 위젯이 확장되며 출연진들에 대한 상세 정보를 보여주는데요. 이에 따라 위젯의 크기가 변화하므로, 이에 맞게 높이 값도 조절되어야한다는 조건이 붙습니다. 다음과 같은 요구사항을 정리해 볼 수 있겠습니다.</p>
<pre><code>- 현재 렌더링된 위젯의 정확한 크기를 얻을 수 있어야 함.
- 크기가 측정되는 위젯에서 해당 값에 접근하여 조건별로 UI를 처리할 수 있어야 .
- 위젯의 크기가 동적으로 변할 때 이를 감지하고 변화된 크기를 얻을 수 있어야 함 (확장 가능한 크기 X)
- 사용이 편리하고 간단해야 함.</code></pre><blockquote>
<p> 아래 링크를 통해 구현된 예제를 확인하실 수 있습니다.
 👉 <a href ="https://measure-size-builder-example.netlify.app/"> Measure Size Implementation </a></p>
</blockquote>
<br/>


<h2 id="위젯이-어떻게-그려질까">위젯이 어떻게 그려질까?</h2>
<p>먼저, Flutter에서 위젯이 화면에 그려지는 원리를 살펴보죠.
<img src="https://velog.velcdn.com/images/ximya_hf/post/ea89b5c3-b605-4f26-92bf-16dd3432b035/image.png" alt=""></p>
<p>Flutter는 <code>Widget</code>, <code>Element</code>, <code>Render</code> 이렇게 총 3가지의 <code>트리 구조</code>를 기반으로 위젯을 생성한다는 개념을 많이 들어보셨을 겁니다. Flutter로 개발 하는 과정에서 엘레먼트 또는 렌더트리의 객체들을 접할 경우가 비교적 많이 없기 때문에 익숙하지 않으실 수 있어요. 하지만 이번 예제처럼 엘러먼트 또는 렌더 객체의 직접적인 조작 및 접근이 필요한 경우 Flutter의 위젯 트리 구조에 대한 기본적인 개념을 숙지하시면 큰 도움이 됩니다. 그래서 조금 더 이해하기 쉽게 설명을 드려 볼까 합니다.</p>
<h3 id="widget-tree---자동차-설계도면">Widget Tree - 자동차 설계도면</h3>
<pre><code class="language-dart">class Lamborghini extends StatelessWidget {
  const Lamborigini({super.key});

  @override
  Widget build(BuildContext context) {
    return Car(
        paint: RedPaint(),
        engine: 4LV8Engine(),
        wheel: RimsAltaneroShinyBlack(),
        carbon : UpperExteriorCarbon(),
        ...
    );
  }
}
</code></pre>
<p>위젯의 세 가지 트리 구조에 대한 이해를 돕기 위해 <code>람보르기니</code> 차량을 만드는 과정에 비유해 보겠습니다. 차량을 만들기 위해서는 색상, 엔진 등 여러 구성 요소를 결정해야 합니다. 위 코드에서 <code>build</code> 메소드 내부에서 <code>Car</code> 클래스에 필요한 옵션들을 전달하고 있습니다.
<img src="https://velog.velcdn.com/images/ximya_hf/post/6e485ab0-dbe7-4cb2-8dce-a59f37df1c02/image.png" alt=""></p>
<p>이러한 과정은 차량의 설계 도면을 제작하는 과정과 유사합니다. 자동차의 부품들이 어떤 구조와 형태로 구성되는지 정의하는 것이죠. StatelessWidget 또는 StatefulWidget은 항상 <code>build</code> 메소드를 오버라이드하고, 내부에서는 Widget을 반환합니다. 이러한 코드들은 <code>위젯 트리</code>로 반환되며, 내부적으로 <code>createElement()</code>를 통해 필요한 <code>Element</code>를 생성합니다.</p>
<blockquote>
<p><strong>핵심 포인트</strong> 
<code>build</code> 메소드 내부의 코드는 &#39;위젯 트리&#39;로 반환되어 &#39;엘리먼트 트리&#39;를 생성합니다.</p>
</blockquote>
<br/>


<h3 id="element-tree---자동차-부품과-엔지니어">Element Tree - 자동차 부품과 엔지니어</h3>
<p>이렇게 <code>위젯 트리</code>로부터 생성된 <code>엘러먼트 트리</code>는, 위젯의 일부로 구성되어 있으며 위젯의 라이프사이클 관리 및 상태 변경을 담당합니다. <strong><code>위젯 트리</code>에는 개발자가 작성한 코드에 대한 구조 정보만 있었다면, <code>엘리먼트 트리</code>에는 위젯트리를 기반으로 생성된 위젯의 일부 조각이 존재하게 됩니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/6bcb0e31-bafd-4337-91da-2b2ddfe7032d/image.png" alt=""></p>
<p>위젯 트리가 자동차 <code>설계도면</code>에 비유되었다면, <code>엘리먼트 트리</code>의 Element는 <code>자동차 부품</code>과 해당 부품을 관리하는 <code>엔지니어</code>라고 할 수 있습니다. <strong>자동차 엔지니어는 설계 도면에 따라 적재적소에 필요한 부품을 배치 및 관리하는 것처럼, 엘러먼트 트리는 위젯이 최종적으로 그려지기 위한 <code>UI의 일부 조각</code>인 Element를 생성하고 필요에 따라 렌더 트리에 <code>변경 사항</code>을 전달합니다.</strong> 그럼, Element의 특성을 몇 가지 좀 살펴볼까요.</p>
<h4 id="위젯은-immutable-element는-mutable">위젯은 immutable element는 mutable</h4>
<p>모든 Flutter 위젯은 <code>immutable</code>하기 때문에 런타임 중에 내용을 수정할 수 없습니다. <strong>이는 자동차가 갑자기 오토바이로 변할 수 없는 것과 비슷합니다</strong>. 그러나 엘리먼트는 <code>mutable</code>하기 때문에 필요에 따라 위젯을 변경할 수 있습니다. 즉, 엘리먼트는 삭제되고 새로운 엘리먼트로 대체될 수 있습니다. </p>
<h4 id="buildcontext의-역할">BuildContext의 역할</h4>
<p>우리가 StatlessWidget 또는 StateFullWidget에서 <code>build</code> 메소드를 실행할 때마다 항상 인자로 넘겨주었던 BuildContext는 Element <strong>객체의 직접적인 조작을 제어 및 접근을 필요할 때 사용</strong>됩니다. 또한 위젯트리로부터 생성된 Element가 어떤 노드에 위치하는지 알려주는 역할을 담당합니다. *<em>마치 엔지니어(BuildContext)가 설계 도면(Widget Tree)을 보고 필요한 부품(Element)이 어디에 있는지 파악하고 적절하게 배치하는것 처럼요. *</em></p>
<pre><code class="language-dart">showDialog&lt;void&gt;(  
  context: context,  
  builder: (BuildContext context) {  
    return AlertDialog(...);  
  },  
);</code></pre>
<p>마찬가지로, showDialog 같은 메소드를 이용하여 팝업을 노출할 때도 항상 <code>BuildContext</code>를 넘겨주어야 했던 이유는 트리에 구성된 여러 위젯중에 어떤 위젯(화면)에 Dialog를 띄어줄지 알아야 하기 때문입니다.  </p>
<blockquote>
<p><strong>핵심 포인트</strong></p>
<ul>
<li>엘리먼트 트리는 위젯의 라이프 사이클을 관리하고 필요에 따라 변경 사항을 렌더 트리에 전달함.</li>
<li>BuildContext는 현재 화면에 보여지는 위젯의 위치를 파악하는 데 사용되며, Element를 조작하거나 접근할 때 중요한 역할을 함. </li>
<li>BuildContext도 하나의 Element로 간주됨.</li>
</ul>
</blockquote>
<br/>



<h3 id="rendering-tree---자동차-조립-조립된-자동차">Rendering Tree - 자동차 조립, 조립된 자동차</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/f1dca91f-bf6a-4b27-ba64-08469977c075/image.png" alt=""></p>
<p>필요한 Element가 생성되었다면, 마지막으로 위젯은 <code>Render Tree</code>를 만듭니다. 위젯의 <code>createRenderObject</code> 메소드를 통해 실제 렌더링을 처리하는 데 사용되며, 이 과정에서는 위젯의 크기와 레이아웃 정보를 관리하는 객체인 RenderObject가 생성됩니다. 이때 Element Tree에서 생성된 <code>RenderObjectElement</code>가 직접적으로 관여하게 됩니다.</p>
<p>렌더링 트리는 자동차 부품을 사용하여 <code>자동차를 조립하는 단계</code>라고 비유할 수 있습니다. Element Tree에서 구성한 자동차 부품을 이용하여 자동차를 완성하는 것이죠.</p>
<p>렌더링 트리에서는 <code>layout</code> , <code>paint</code> 이렇게 크게 2가지 메소드를 통해 실질적으로 우리의 눈앞에 보이는 위젯을 그립니다. <strong><code>layout</code> 단계에서는 부모 노드가 자식 노드로부터 제약 조건을 전달하고, 최하위 노드에서는 최종적으로 결정된 크기 정보를 다시 위로 전달하여 위젯이 어떤 위치에 어떤 크기로 그려질지를 결정합니다</strong>. 그 후에는 <code>paint</code> 작업이 이루어져서 GPU 스레드에 작업을 전달하여 최종적으로 위젯이 완성됩니다.</p>
<blockquote>
<p><strong>핵심 포인트<img src="https://velog.velcdn.com/images/ximya_hf/post/44b70210-e9ca-426f-9e0a-fc99bd9efbe8/image.png" alt=""></strong></p>
</blockquote>
<br/>




<h3 id="만약-3가지의-위젯트리로-구성되어있지-않았다면">만약 3가지의 위젯트리로 구성되어있지 않았다면?</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/2d401727-7c90-41c6-ab6b-0abdfd0aa540/image.png" alt=""></p>
<p>이제 위젯 트리의 구조에 대해 어느 정도 이해하셨다면, <strong>왜 위젯이 3가지의 위젯 트리로 구성되는지에 대한 이유</strong>를 명확하게 이해하실 수 있을 겁니다. 만약 여러분이 자동차에 새로운 바퀴를 교체한다면, 자동차를 처음부터 다시 만들 필요가 없을 것입니다. 기존의 바퀴를 새로운 바퀴로 교체하기만 하면 되겠죠. Flutter에서도 위젯이 비슷한 원리를 가집니다. 화면이 그려진 위젯의 일부가 상태에 따라 변화해야 할 때, 해당 위젯에 해당하는 엘리먼트가 이를 감지하고 렌더 트리로 변경 사항을 전달하여 필요한 부분만 다시 렌더링할 수 있게 합니다.</p>
<p>근데 만약 Flutter의 위젯트리가 하나로만 구성되어 있었다면, 상태에 따라 변경되지 않아도 되는 위젯도 다시 그려지는 비효율이 발생했을 겁니다. <strong>자동차를 바퀴를 바꾼다고 새로운 자동차를 만드는 꼴이죠.</strong></p>
<p>요약하자면, Flutter의 위젯이 3가지의 트리 구조로 구성된 가장 핵심적인 이유는 상태에 따라 화면이 변경되어야 할 때** 전체 화면을 다시 렌더링하는 것이 아니라 변경이 필요한 부분만 <code>효율적</code>으로 다시 <code>렌더링</code>하기 위함**입니다.</p>
<br/>


<h2 id="1-위젯트리를-분리하고-buildcontext로-렌더-객체에-접근하기">1. 위젯트리를 분리하고 BuildContext로 렌더 객체에 접근하기</h2>
<p>자, 이제 자식 위젯에 따라 가변적인 크기를 가지는 위젯의 크기를 도출하는 방법을 단계별로 알아보겠습니다.</p>
<p>앞서 언급한 대로, 렌더링된 위젯의 크기는 <code>렌더 트리</code>에 위치한 <code>RenderObject</code>에 존재하며, 해당 객체에 접근하기 위해서는 <code>BuildContext</code>라는 Element가 필요합니다. 결과적으로 <strong>BuildContext만 있으면 위젯의 렌더링된 크기에 접근</strong>할 수 있는 공식이 성립합니다.</p>
<pre><code class="language-dart">Size size = context.size!;</code></pre>
<p>그리하여 위 코드처럼 BuildContext로 접근 가능한 위젯의 렌더링 사이즈 확인할 수 있습니다.</p>
<p>다만, 현재 예제 코드의 위젯 트리에서는 한 가지 문제점이 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/5a62109b-4866-4826-ad60-a08bd82386b0/image.png" alt=""></p>
<p>Column 위젯에 해당하는 RenderObject를 통해 위젯의 렌더링된 크기를 얻고 싶지만, RenderObject에 접근 가능한 BuildContext는 그보다 상위에 위치한 CaseInfoScreen에 위치하고 있습니다. 이렇게 된다면 Column의 크기뿐만 아니라 Scaffold의 하위 위젯으로 구성된 AppBar 크기도 함께 측정할 수밖에 없습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/35c4d754-4d06-4e49-a8f3-159b481fd25c/image.png" alt=""></p>
<p>이 문제를 해결하기 위해서는 여러 방법이 있겠지만, 가장 단순한 방법은 크기를 측정하고자 하는 위젯을 StatelessWidget 또는 StatefulWidget처럼 별도의 위젯으로 분리하는 것입니다. 이렇게 되면 분리된 위젯의 <code>build(BuildContext context)</code> 메소드를 통해 <code>BuildContext</code>가 직접 접근 가능한 새로운 <code>하위 위젯 트리</code>가 생성되고 Column의 렌더링된 크기에 접근할 수 있습니다. 이를 흔히 <strong>&quot;BuildContext를 분리한다&quot;</strong> 라고 표현하기도 합니다.</p>
<pre><code class="language-dart">class ContentView extends StatefulWidget {
  const ContentView({super.key});

  @override
  State&lt;ContentView&gt; createState() =&gt; _ContentViewState();
}

class _ContentViewState extends State&lt;ContentView&gt; {
  double renderedHeight; // &lt;-- [ContentView] 위젯의 렌더링된 높이

  @override
  void initState() {
      super.initState();
      /// [BuildContext]를 통해 위젯의 렌더링 크기에 접근하여
      /// renderedHeight 변수에 할당
      renderedHeight = context.size?.height ?? 0; 
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
          Text(
           &#39;Height : ${renderedHeight}&#39;, // &lt;- 렌더링 높이를 Text 위젯으로 표시
          ),
        ),
        ...
      ],
    );
  }
}
</code></pre>
<p>이제 위 코드처럼 크기를 측정하고자 하는 위젯을 별도의 StatefulWidget으로 분리하여, 새로운 build 메소드 내부의 BuildContext를 통해 size라는 값을 얻어 우리가 측정하고자 하는 위젯의 렌더링된 높이를 도출할 수 있습니다.</p>
<br/>



<h2 id="2-위젯의-렌더링이-완료된-시점을-파악하여-크기-구하기">2. 위젯의 렌더링이 완료된 시점을 파악하여 크기 구하기</h2>
<p>하지만 위 코드를 실행시키면 아래와 같은 런타임 오류가 발생합니다. </p>
<pre><code class="language-bash">======== Exception caught by widgets library =======================================================
The following assertion was thrown building Builder(dirty):
Cannot get size during build.</code></pre>
<p>왜 이런 오류가 발생할까요? </p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/730556be-c36c-4a2c-92a3-ed8ef2f3f570/image.png" alt=""></p>
<p>앞서 설명한 대로, <strong>렌더 트리에서는 <code>layout</code> 메소드를 통해 부모 노드는 자식 노드로부터 제약조건을 전달하고, 최하위 노드에서는 최종적으로 결정된 크기 정보를 다시 위로 올려보내 위젯이 어떤 위치에 어떤 크기로 그려질지를 정하</strong>는데, 아직 <code>layout</code> 메소드가 실행되지 않은 시점에서 사이즈 값에 접근하려고 해서 발생하는 문제입니다.</p>
<p>이를 해결하기 위해 Flutter에서는 <code>addPostFrameCallback</code> 메소드를 제공합니다. 이 메소드는 위젯이 화면에 그려진 후에 호출되는 콜백을 등록하는 데 사용됩니다. 즉, <strong>렌더 트리의 작업이 완료된 후 실행되는 콜백 메서드입니다</strong>.</p>
<pre><code class="language-dart">WidgetsBinding.instance.addPostFrameCallback((_) { 
    setState(() {
         renderedHeight = context.size!.height; 
      });
});</code></pre>
<p>위 코드처럼 <code>addPostFrameCallback</code> 콜백 메소드 내부에 이전과 같이 <code>renderedHeight</code> 변수에 <code>BuildContext</code>를 통해 접근할 수 있는 위젯의 렌더링 높이를 할당해 주면, 위젯이 렌더링된 후에만 context를 통해 렌더링 크기값에 접근하므로 오류 없이 정상적으로 <code>renderedHeight</code> 변수에 값을 할당할 수 있게 됩니다.</p>
<br/>


<h2 id="3-위젯의-크기가-동적으로-변할-때-이를-감지하고-변화된-크기-구하기">3. 위젯의 크기가 동적으로 변할 때 이를 감지하고 변화된 크기 구하기</h2>
<p>필요한 요구사항을 대부분 충족했지만, 위젯의 크기가 유저의 인터렉션으로 인해 변할 때 이를 감지하고 변화된 크기를 화면에 보여주는 기능이 하나 남았습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/96c2fc1a-b796-407d-b385-a5e2e6a5b99b/image.png" alt=""></p>
<p>화면의 ExpansionListTile 위젯을 클릭하면 위젯이 확장되어 크기가 변하지만, 현재 코드에서는 위젯의 크기 변화를 감지하고 크기가 값을 업데이트하고 있지 않죠.</p>
<p>이때 위젯의 크기 변화를 감지하고 특정 이벤트를 실행하는데 유용한 <code>NotificationListener</code>라는 위젯을 사용해 볼 수 있습니다. 일반적으로 <code>NotificationListener</code>은 위젯 트리에서 발생하는 알림(크기 변화, 스크롤, 제스쳐 등)을 수신하고 처리하는데 사용됩니다.</p>
<pre><code class="language-dart">NotificationListener(  
  onNotification: (_) { 
    if(renderedHeight != context.size!.height) {
      setState(() {  
        renderedHeight = context.size!.height;  
        log(&#39;height : $renderedHeight&#39;);  
       });  
    }
    return true;  
  },  
  child: Column(...)
  )</code></pre>
<p>기존의 Column 위젯을 NotificationListener 위젯으로 감싼 뒤, onNotification 콜백 내부에서 위젯의 크기 변화를 감지하고 해당 크기를 업데이트하는 로직을 추가합니다. 위 코드에서는 <code>setState</code> 메소드를 사용하여 변경된 크기를 업데이트하고 있습니다.</p>
<blockquote>
<p>참고로 <code>NotificationListener</code>는 스크롤 또는 터치 제스쳐등의 이벤트를 감지하고 onNotification 콜백이 실행되어 위젯의 크기가 변하지 않았지만 동일한 크기 값을 불필요하게 업데이트할 수 있기 때문에  <code>if(renderedHeight != context.size!.height)</code> 이라는 조건 안에서만 업데이트 로직을 실행하도록 조건문을 붙였습니다.</p>
</blockquote>
<p>여기서 onNotification 콜백 메소드 안에서 위젯의 높이 크기를 출력하는 로그를 통해 한 가지 문제점을 확인할 수 있습니다.</p>
<pre><code class="language-shell">[log] height : 367.0
[log] height : 367.0
[log] height : 369.3854225873947
[log] height : 375.8518112897873
[log] height : 385.81551444530487
[log] height : 435.25
[log] height : 413.13516367971897
[log] height : 430.28125
[log] height : 448.9247215986252
[log] height : 469.3435592651367
[log] height : 491.38857555389404
[log] height : 551.9744523763657
[log] height : 540.0271100997925
[log] height : 567.0</code></pre>
<p>위젯의 크기가 변할 때마다 onNotification 콜백이 연속적으로 실행되어 불필요하게 setState 메소드가 여러 번 호출되고 있네요. 이러한 부분은 성능 저하로 이어질 수 있기 때문에 수정이 필요합니다.</p>
<p>이 문제를 해결하기 위해 <code>디바운서(debouncer)</code> 로직을 추가할 수 있습니다. 디바운서는 연속적인 호출을 일정 시간 동안 지연시키고, 마지막 호출 이후에만 작업을 실행할 수 있게 도와줍니다.</p>
<pre><code class="language-dart">/// Deboucner 모듈
class Debouncer {  
  final Duration delay;  
  Timer? _timer;  

  Debouncer(this.delay);  

  void run(VoidCallback action) {  
    _timer?.cancel();  
    _timer = Timer(delay, action);  
  }  
}

/// ContentView 위젯
double? renderedHeight;  
final Debouncer debouncer = Debouncer(const Duration(milliseconds: 50));

Widget build(BuildContext context) {  
    return NotificationListener(  
    onNotification: (_) {  
        debouncer.run(() {  // &lt;-- 디바운서 콜백 적용 
            if(renderedHeight != context.size!.height) {
              setState(() {  
                renderedHeight = context.size!.height;  
                log(&#39;height : $renderedHeight&#39;);  
               });  
           }
          });  
          return true;  
        },  
        child: Column(...)
    ... 
   }</code></pre>
<p>위 코드에서는 디바운서 클래스를 선언하고, onNotification 콜백 내부에서 디바운서를 사용하여 setState 메소드를 호출하여 BuildContext로부터 접근된 size값을 업데이트하고 있습니다. 이를 통해 최종적으로 ExpansionTile 위젯의 크기가 확장된 후에만 업데이트 메소드가 호출되어 성능을 최적화할 수 있습니다.</p>
<br/>


<h2 id="4-사용하기-편하게-모듈화-하기">4. 사용하기 편하게 모듈화 하기</h2>
<p>가변적인 위젯의  렌더링 크기를 구하는 기능은 다른 화면 또는 여러 프로젝트에서 적용될 수 있기 때문에 사용하기 쉽게 <code>모듈화</code> 해 보는 것도 좋은 방법입니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/07b335d6-89cd-4e49-a43d-dcba4e056766/image.png" alt=""></p>
<p>그래서 저는 <code>MeasureSizeBuilder</code>라는 커스텀 위젯을 만들었습니다. 이 위젯은 앞서 다루었던 모든 로직을 포함하며, <code>builder</code> 속성에 크기를 측정하고자 하는 위젯을 반환하여 해당 위젯의 렌더링 크기를 builder 내부의 <code>size</code> 속성으로 접근할 수 있도록 설계되었습니다.</p>
<p>이제 완성된 예제의 코드를 확인해볼까요?</p>
<pre><code class="language-dart">import &#39;package:cached_network_image/cached_network_image.dart&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:measure_size_builder/measure_size_builder.dart&#39;;
import &#39;package:measure_size_implementation/src/cast_model.dart&#39;;
import &#39;package:measure_size_implementation/src/style/app_color.dart&#39;;
import &#39;package:measure_size_implementation/src/style/app_text_style.dart&#39;;

class CastInfoPage extends StatelessWidget {
  const CastInfoPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF000000),
      appBar: AppBar(
        leading: const Icon(
          Icons.arrow_back_ios,
          color: Colors.white,
        ),
        titleSpacing: 0,
        backgroundColor: Colors.black,
        centerTitle: false,
        title: Text(
          &#39;Dune: Part Two&#39;,
          style: AppTextStyle.headline1,
        ),
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.symmetric(horizontal: 16) +
              const EdgeInsets.only(top: 20),
          child: MeasureSizeBuilder( &lt;-- // MeasureSizeBuilder 적용!
            builder: (context, size) {
              return Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    &#39;Height : ${size.height}&#39;,
                    style: PretendardTextStyle.bold(
                      size: 24,
                      height: 37,
                      letterSpacing: -0.2,
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListView.separated(
                    physics: const NeverScrollableScrollPhysics(),
                    padding: EdgeInsets.zero,
                    shrinkWrap: true,
                    itemCount: CastModel.castList.length,
                    separatorBuilder: (_, __) =&gt; const SizedBox(height: 8),
                    itemBuilder: (context, index) {
                      final item = CastModel.castList[index];
                      return ExpansionTile(
                        tilePadding: EdgeInsets.zero,
                        title: Row(
                          children: [
                            ClipRRect(
                              borderRadius: BorderRadius.circular(56 / 2),
                              child: CachedNetworkImage(
                                height: 56,
                                width: 56,
                                imageUrl: item.imgUrl,
                                fit: BoxFit.cover,
                              ),
                            ),
                            const SizedBox(width: 10),
                            Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                  item.name,
                                  style: AppTextStyle.title1,
                                ),
                                Text(
                                  item.role,
                                  style: AppTextStyle.body3.copyWith(
                                    color: AppColor.gray02,
                                  ),
                                )
                              ],
                            ),
                          ],
                        ),
                        children: &lt;Widget&gt;[
                          Text(
                            item.description,
                            style: AppTextStyle.body3,
                          ),
                        ],
                      );
                    },
                  )
                ],
              );
            },
          ),
        ),
      ),
    );
  }
}
</code></pre>
<p>그리고 마침내 드디어 앞서 우리가 목표로 했던 요구사항을 모두 충족했습니다🎉</p>
<p>예제에 적용한 <code>measure_size_buidler</code>라는 패키지를 배포하였으니 해당 기능이 필요하거나 모듈화된 코드가 궁금하신 분들은 아래 링크를 참고해 주세요!
👉 <a href="https://pub.dev/packages/measure_size_builder">measure_size_builder 1.0.2</a></p>
<br/>


<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 포스팅에서는 가변적인 크기를 가지는 위젯의 렌더링 크기를 구하는 방법과 함께 <code>위젯의 렌더링 원리</code>에 대해 살펴보았습니다. 사실 Flutter의 렌더링 원리에 대해 깊게 들어갈수록 굉장히 내용이 어려워지고 복잡해지기 때문에 본 포스팅에서는 간단한 개념만 다루었는데요. 혹시 자세한 Flutter 렌더링 원리에 대해 관심이 있으시면 아래 포스팅을 참고해 보시면 좋을 것 같습니다. 정말 자세히 잘 정리되어 있습니다.
👉 <a href="https://www.flutteris.com/blog/en/flutter-internals?fbclid=IwAR1s-dcp-SffTC_LxDDg4oXos3N_Y8-ZFI4sLKJ40n7RuzU4CIJW1Xp58Bc">flutteris</a></p>
<p>혹시 영어를 읽는게 부담스러우시면, Broccolism님이 Velog에 작성한 <code>공식 문서 읽기 프로젝트</code> 시리즈를 읽어보시길 강력히 추천드립니다. 21년도에 작성된 글이지만, Flutter 렌더링을 주제로 이보다 잘 작성된 한국어 포스팅은 없는 것 같네요.
👉 <a href="https://velog.io/@broccolism/series/%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EC%9D%BD%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8">공식 문서 읽기 프로젝트(flutter)</a></p>
<p>또한 포스팅에서 다룬 예제의 전체 코드가 궁금하시면 제 깃허브 레포를 참고해주세요.
👉 <a href="https://github.com/Xim-ya/measure_dynamic_widget_size_implementation">깃허브 레포</a></p>
<p>다음에는 조금 더 유익한 주제로 돌아오겠습니다. 
읽어주셔서 감사합니다:)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[16번의 Flutter 개발자 면접에서 나온 질문 모음]]></title>
            <link>https://velog.io/@ximya_hf/flutter-interview-questions</link>
            <guid>https://velog.io/@ximya_hf/flutter-interview-questions</guid>
            <pubDate>Mon, 11 Mar 2024 18:44:37 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/20daa148-2949-4fbf-b34b-f1001dcfa52e/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅에서는 개발자 기술 면접을 효과적으로 도와주는 서비스 <code>테크톡</code>과 관련된 내용을 포함하고 있습니다.
다운로드 링크 : <a href="https://apps.apple.com/kr/app/id6478161786">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.techtalk.ai">플레이스토어</a></p>
</blockquote>
<p>정말 운이 좋게 길면 길었던 취준 생활을 마무리하게 되었습니다. 졸업 후 여러 회사에 지원서를 넣으면서 Flutter 개발자로 면접을 볼 기회가 많았는데요. <strong>초기 스타트업부터 중견, 대기업까지 총 16곳의 회사</strong>에서 기술 면접을 보았고, 실제 면접에서 나온 <code>질문</code>들과 <code>경험</code>을 공유드리고자 합니다. </p>
<p>너무 뻔하고 수준 낮은 인사이트를 전달해 드리는 게 아닐지 하는 걱정이 들지만, 저도 면접을 앞두고 불안한 마음에 괜히 면접 관련 포스팅을 뒤적거리며 괜한 위안을 얻곤 했기에, 이 글이 조금이라도 여러분의 불안함을 해소할 수 있기를 바라는 마음에 적습니다:)</p>
<p>제가 경험한 Flutter 개발자 면접의 질문 유형은 아래와 같습니다.</p>
<ul>
<li>컬쳐핏(인성) 질문</li>
<li>이력서/포트폴리오 또는 블로그 프로젝트에 기재된 기술과 관련된 심층 질문</li>
<li>구현 과제와 관련된 기술 질문</li>
<li>기초 Flutter, CS 개념 질문</li>
</ul>
<br/>

<h1 id="컬쳐핏-질문">컬쳐핏 질문</h1>
<p>비용을 획기적으로 절감할 수 있다는 면에서 Flutter를 채택하고 있는 초기 <code>스타트업</code>이 많아지고 있습니다. 그래서 제가 면접을 경험한 여러 스타트업의 경우, 기술질문 보다는 지원자가 회사와 핏이 많고 <code>스타트업에 적합한 인재</code>인지 검증하는 질문들의 비중이 꽤 컸습니다. 심지어 기술 질문을 전혀 하지 않은 회사도 몇몇 있었고요.</p>
<p>컬쳐핏 질문은 다양하고 예측하기 어려워 완벽하게 대비하기 어렵겠지만, 아래와 같은 <code>단골 질문</code>들은 한 번쯤 되새기면 도움이 될 수 있습니다.</p>
<ul>
<li>지금까지 진행한 프로젝트 중 가장 어려웠던 기술적 문제는 무엇이었나요? 이 문제를 해결하기 위해 어떤 노력을 기울였나요?<ul>
<li>어떤 도메인(산업)에 대해 특별한 관심을 갖고 계신가요?</li>
<li>최근 읽은 책이나 포스팅글이 있다면 소개해 주세요.</li>
<li>주변의 주니어 개발자들과 비교했을 때 본인의 장점은 무엇이라고 생각하시나요?</li>
<li>컴퓨터 공학에서 다양한 분야를 배우셨을 텐데, 왜 모바일(Flutter) 앱 개발을 선택하셨나요?</li>
<li>앞으로 10년 동안 달성하고 싶은 커리어적 목표가 있나요?</li>
<li>함께 일하고 싶은 우수한 동료의 특징이 있나요?</li>
<li>신규 프로젝트와 기존 운영 중인 프로젝트의 유지보수 및 개선 업무 중 어느 쪽을 선호하시나요?</li>
</ul>
</li>
</ul>
<p>이 질문들에는 명확한 정답이 없으며 면접관은 답변을 통해 지원자의 <code>태도</code>를 파악하는데 초점을 둡니다. 지원자의 태도를 가장 잘 보여줄 수 있는 포인트는 <code>일관성</code>이라고 생각하는데요. 너무 당연한 말이겠지만, 본인이 이력서나 포트폴리오에 써놓은 경험을 바탕으로 일관성 있는 답변을 한다면 훨씬 더 설득력 있는 지원자의 태도를 보여줄 수 있겠습니다. </p>
<h3 id="마지막-질문도-중요합니다">마지막 질문도 중요합니다!</h3>
<blockquote>
<p>마지막으로 저희 회사에 궁금하신 점이 있나요?</p>
</blockquote>
<p>자, 앞서 예시로 들었던 컬쳐핏 질문들은 면접에 나올지는 미지수지만, 면접관이 지원자에게 마지막으로 하고 싶은 말이나 회사에 궁금한 부분이 있는지 물어보는 질문은 무조건 그리고 반드시 나옵니다. 이 질문에 대한 대답도 면접의 일부로서 신중하게 준비해야 합니다.</p>
<p>이런 질문이 단순히 면접관의 <code>마무리 멘트</code>라고만 생각하시는 분들이 있을 수 있는데요. 개인적으로 이런 마무리 질문은 또한 지원자의 <code>자세</code>를 어필할 수 있는 부분이기 때문에 면접 서두에 진행하는 1분 자기소개만큼 중요하다고 생각합니다.</p>
<h3 id="그럼-어떤-질문을-해야-하나요">그럼 어떤 질문을 해야 하나요?</h3>
<p>실제로 궁금한 것을 물어보는 것이 가장 좋습니다. 회사의 <code>서비스</code>나 <code>직무</code>에 대해 자세히 알아본 상태에서 관련 질문하신다면 좋은 인상을 남길 수 있겠죠. 다만 너무 부정적이거나 공격적인 질문을 피하는 게 좋아요. 예를 들어 &quot;작년 매출이 급격히 감소한 이유는 무엇인가요?&quot; 또는 &quot;퇴사자가 많은 이유가 무엇인가요?&quot;와 같은 질문은 면접관을 곤란하게 할 수 있기 때문에 적절하지 않습니다.</p>
<h3 id="물어보고-싶은-질문이-정말-없으면-어떻게-하나요">물어보고 싶은 질문이 정말 없으면 어떻게 하나요?</h3>
<p>질문을 준비해 가는 게 중요하다고 말씀드렸지만, 정말 물어보고 싶은 질문이 없을 수도 있습니다. 저도 한 회사에 1차(기술), 2차(컬쳐핏), 3차(임원) 면접을 하루 만에 본적 이 있었는데요. 마지막 3차 면접에서는 체력도 바닥나고 1,2차에서 궁금한 부분들은 모두 물어봐서 더 이상 물어볼 질문이 없었거든요.</p>
<p>이럴 때 저는 아래와 같이간단한 감사 멘트를 드리곤 합니다.</p>
<blockquote>
<p>추가로 궁금한 건 없습니다. 바쁘신 와중에 시간을 내어주시고 면접 기회를 주셔서 감사드립니다.</p>
</blockquote>
<p>너무 정직하게 &quot;특별히 궁금한 건 없습니다!&quot;라고 답변해 주시는 것만 피해주셔도 충분할 것 같네요. 또는 이전 면접 질문에서 본인의 답변에 아쉬움 부분이 있다면 면접관분들에게 양해를 구하고 해당 질문에 대해서 다시 답변해도 될지 여쭤보는 것도 괜찮습니다. </p>
<h3 id="인트로-문구의-함정">인트로 문구의 함정</h3>
<blockquote>
<p>저는 유저 친화적인 개발자 홍길동입니다.
단순 구현보다는 서비스가 해결하고자 하는 문제에 집중하는 개발자 홍길동입니다.</p>
</blockquote>
<p>일반적으로 개발자의 이력서를 살펴보면 본인의 <code>개발 철학</code>이나 <code>방향성</code>을 강조하는 문구가 포함되어 있습니다. 근데 이런 문구들은 보통 <code>추상적</code>인 경우가 많습니다. 추상적인 문구가 문제가 된다고 말씀드리는 건 아닙니다만, 문구들이 추상적일수록 답변 또한 모호해질 수 있다는 게 문제가 될 수 있습니다.</p>
<p>예를 들어 면접관이 <strong>&quot;유저 친화적인 개발자라고 적어주셨는데, 실제로 개발 업무를 하시면서 유저 친화적인 사고가 어떻게 도움이 되었는지 설명해 주실 수 있나요?</strong>&quot;라고 실제 인트로문구와 관련된 사례를 물어보는 질문을 할 수도 있는데, 명확한 근거가 부족하다면 오히려 난항을 겪을 수 있습니다.</p>
<p><strong>&quot;개발자가 유저 친화적이고, 서비스가 해결하고자 하는 문제의 본질에 집중하는 것이 바람직하다&quot;</strong> 라는 명제에 동의하지 않은 사람은 많이 없겠지만, 막상 이런 태도가 실제 업무에 어떻게 영향을 주는지 설명해 보라고 하면 쉽지는 않을 수 있습니다. 본인이 철학과 방향성을 인트로 문구에 적어두셨다면, 실제 관련된 사례를 한번 쯤 고민해 보세요.</p>
<p>개인적으로 저는 이런 컬쳐핏 질문들에 자신이 있다고 자만하다가 낭패를 본 적이 정말 많습니다. 저처럼 자만하시고 조금이라도 본인이 개발자로서 어떤 방향성을 가지고 계신는지 깊게 고민하시고 면접장에 가시면 큰 도움이 될 겁니다.</p>
<br/>

<h1 id="블로그-또는-이력서포트폴리오에-기재된-기술과-관련된-심층-질문">블로그 또는 이력서/포트폴리오에 기재된 기술과 관련된 심층 질문</h1>
<p>블로그 또는 이력서/포트폴리오에 써 놓은 기술 항목들은 면접관한테 좋은 질문거리가 됩니다. 다만 단순 개념을 물어보는 질문보다는 <code>심화 질문</code>이 많이 나오는 게 특징이라고 할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/0583c456-dc3b-4185-9513-3e5f8f51066b/image.png" alt=""></p>
<p>예를 들어, 제 블로그에는 네트워크 이미지 렌더링 최적화와 관련된 <a href="https://velog.io/@ximya_hf/optimizing-network-image-rendering-in-flutter">포스팅</a>이 있습니다. 한 번 면접관이 해당 포스팅에서 &#39;cached_network_image&#39; 패키지를 활용하여 이미지 캐싱할 수 있다는 내용을 보고 <strong>네트워크 이미지가 어떤 기준으로 캐싱 되는지에 대해 질문</strong>한 적이 있습니다. 그전까지 저는 한 번도 네트워크 이미지가 어떤 기준으로 캐싱 되는지 고민해 본 적도 없고 단순히 이미지가 캐싱 된다는 개념만 알고 있었기에 제대로 된 답변을 하지 못했던 기억이 납니다. 이처럼 본인이 기재한 기술적인 항목에 대해 조금 더 깊게 알아보고 여러 심층 질문에 대해 준비하시는 게 좋습니다.</p>
<br/>

<h1 id="구현-과제와-관련된-기술-질문">구현 과제와 관련된 기술 질문</h1>
<blockquote>
<p>&quot;Flutter 개발자로 취업을 희망하고 있습니다. 알고리즘을 테스트 준비해야될까요?&quot;</p>
</blockquote>
<p>누군가 이런 질문을 한다면 &quot;준비하면 좋죠&quot; 라고 답변할 것 같습니다. 다만 질문을 조금 다르게 해서, &quot;알고리즘을 준비하는 데 시간을 많이 할애해야 할까요?&quot; 라고 물어본다면 저는 알고리즘 공부보다는 구현 과제를 준비하는 데 우선순위를 두시는 걸 추천한다고 말씀 릴 것 같습니다. 이유는 간단합니다.** Flutter 개발자 채용 프로세스에서는 알고리즘 테스트보다 구현 과제가 압도적으로 많기 때문입니다.**</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3bd9473b-7de0-4838-9af2-5fabaa4cb994/image.png" alt=""></p>
<p>단적인 예로, 저는 지금까지 총 5곳의 회사에서 코딩테스트를 봤었는데, 이 중에서 알고리즘 코딩 테스트를 진행한 곳은 한 곳밖에 없었습니다. 그리고 현재 원티트 flutter 채용공고 기준(2024.03.11) <strong>총 44개의 회사 중 9곳에서 면접 프로세스에 구현 과제 전형이 포함</strong>되어 있기도 하고요.</p>
<p>구현 과제는 주로 간단한 토이 프로젝트를 구현하는 형태로 진행됩니다. 보통 2<del>5 페이지 내의 UI 디자인과 구현 요구사항을 전달받고 5</del>7일 기한 내로 완료해야 합니다. 대부분의 과제에서 상태관리 라이브러리를 사용하는 것을 권장하는 편인 것 같은데요. 특별한 상태 관리 패키지를 사용하도록 강제하는 곳을 드물지만, 제가 과제 전형을 보았던 한 회사는 getx는 사용할 수 없다는 조건이 붙기도 했으니 참고해 주세요.</p>
<h3 id="과제-전형-관련-팁">과제 전형 관련 팁</h3>
<p>과제 전형을 진행하는 데 있어 저만의 사소한 팁을 공유드려볼까 합니다.</p>
<p><strong>1. 미리 boilerplate을 만들어두자</strong>
과제를 진행하는 동안 시간이 촉박해지는 경우가 많습니다. 특히 재직 중인 분들이라면 과제를 진행하기 위해 평일에 시간을 내기가 어려울 수 있습니다. 따라서 프로젝트 구조를 미리 설계하고 자주 사용하는 패키지와 커스텀 모듈이 포함된 <code>boilerplate</code>를 만들어두면 개발 시간을 단축할 수 있습니다.</p>
<p><strong>2. 코드 퀄리티보다는 구현 요구사항 충족이 우선</strong>
시간이 부족하다 보면 <code>코드 퀄리티</code>와 <code>요구사항 충족</code>, 둘 중에 양자택일을 해야 할 경우가 생길 수도 있는데요. 저는 이때 코드 퀄리티가 낮더라도 요구사항을 충족시키는 것이 더 중요하다고 생각합니다. 함께 일하고 싶은 동료를 한 명 고른다면 코드 퀄리티가 조금 부족하더라도 요구사항을 알맞게 구현하시는 분과 함께 일을 하고 싶기 때문입니다. 
!youtube[cyoUrxDVGXE]</p>
<p>근데 이건 저의 개인적인 생각이고, 실제 개발자를 채용하는 각 회사의 면접관분들은 어떤 선택을 할까요? 관련 내용 반려생활의 CTO 호돌맨님과, 인프랩의 CTO 향로님이 운영하시는 <code>개발바닥</code> 채널에서 확인해 보실 수 있으니 참고 부탁드립니다.</p>
<p><strong>3. 비장의 무기를 하나쯤 준비</strong>
면접을 진행하는 회사 입장에서 보면 작게는 몇십 개에서, 많게는 수백개의 과제를 검토하게 됩니다. 즉, 모든 지원자가 동일한 기능을 구현하기 때문에 단순히 요구사항을 구현했다는 것만으로는 과제 전형에서 <code>경쟁력</code>을 가지길 힘들 수 있습니다. 이렇게 수많은 지원자 중에서 본인을 부각하려면 조금 <code>특별한 전략</code>이 필요할 수 있습니다. 예를 들어, 직접 상태 관리 모듈을 개발하거나 성능 최적화 작업을 진행하여 전후 차이를 보여주는 것 등 독특한 전략을 고려해 보세요. 혹은 과제 요구사항에 테스트 코드를 작성하라는 내용은 없었지만, 여러 테스트 시나리오를 설계하고 테스트 코드를 작성한다면 가산점을 받을 수도 있을 것 같다는 생각이 듭니다.</p>
<p><strong>4. 과제 제출 전 불필요한 log, print문 및 불필요한 코드 블록 주석은 다 지우자</strong>
정신없이 과제를 진행하다 보면 테스트용 log, print문 또는 주석으로 처리된 코드 블록들같이 치열한 개발 흔적이 남게 될 수 있는데요. 과제를 제출하기 전에는 불필요한 log, print문 및 코드 블록 주석을 지워주세요. 이런 흔적들이 지원자의 <code>꼼꼼하지 못함</code>을 반영할 수 있습니다.</p>
<p><strong>5. 아쉽고 부족한 부분은 먼저 언급해서 선수치기</strong>
마감 당일에 과제를 제출하려고 보면 항상 부족하고 <code>아쉬운 부분</code>이 있기 마련입니다. 이런 부분들을 개선할 시간이 부족하다면, 리드미(README)에서 본인의 생각하기에 부족한 부분과 이에 대한 <code>개선안</code>을 써주면 좋은 평가 요소가 될 수 있습니다. 미리 언급하지 않았다면 감점 요소가 될 수도 있지만, <code>지원자가 스스로 문제점을 인식</code>하고 있다는 것만으로도 플러스 요인이 되는 것이죠.</p>
<p><strong>6. 읽는 사람을 배려한 리드미 작성</strong>
리드미를 깔끔하게 잘 작성하는 것도 좋은 평가 요소가 된다고 생각합니다. 면접관도 사람이기 때문에 지원자가 작성한 모든 코드를 살펴보는 게 어려울 수 있습니다. 본인이 과제를 진행하면서 강조하고 싶은 부분을 리드마 잘 작성한다면, 면접관이 자칫 지나칠 수 있는 부분도 꼼꼼하게 살펴볼 기회를 만들 수도 있습니다. 특히 리드미 마크다운 파일에 코드의 특징에 대한 설명을 적고 파일에 링크 문법 <code>[text](#link)</code> 을 이용하여 특정 소스파일로 이동할 수 있도록 리드미를 구성해 주신다면 더 파악하고 읽기 좋은 프로젝트가 될 수 있습니다. </p>
<p>Best Practice는 아니지만 제가 작성한 리드미 양식을 가볍게 참고하실 수 있도록 링크를 남겨두겠습니다.
(<a href="https://github.com/Xim-ya/github_search_app">https://github.com/Xim-ya/github_search_app</a>)</p>
<p>미자믹으로, 사전 과제는 기술 면접 전에 진행되는 경우가 많습니다. 면접관은 지원자가 과제를 완료한 후 해당 과제를 기반으로 여러 기술 질문을 할 가능성이 높은데, 이를 위해 아래와 같은 제너럴한 질문들에 대한 답변을 준비해 두는 것이 좋습니다.</p>
<ul>
<li>과제 난이도는 어떠했나요?</li>
<li>프로젝트를 진행하시면 새롭게 얻은 지식이 있었나요?</li>
<li>과제를 진행하면서 기술적으로 어려웠던 부분이 있었나요? 또 어떻게 해결하셨나요?</li>
</ul>
<br/>

<h1 id="기초-flutter-cs-개념-질문">기초 Flutter, CS 개념 질문</h1>
<p>당연하게도 면접장에서는 여러 기초 Flutter 및 CS(컴퓨터공학) 개념을 물어봅니다. 개인적으로 단순한 개념을 물어보는 질문들의 난이도는 생각보다 평이했습니다. 좀 더 자세히 말씀드리자면 구글에 <code>Flutter 개발자 질문 면접</code> 또는 <code>신입 개발자 CS 면접 질문</code>라는 키워드로 검색하면 나오는 면접 질문들에서 크게 벗어나는 질문은 많이 없었습니다.</p>
<p>면접에 자주 나온 기초 Flutter 개념 면접 TOP 10 질문은 아래와 같습니다.</p>
<ul>
<li>StatelessWidget과 StatefulWidget의 차이에 대해 설명해 주세요.</li>
<li>BuildContext에 대해 설명해주세요.</li>
<li>ListView와 ListView.builder의 차이점에 대해 설명 해주세요.</li>
<li>Dart에서 제공하는 컬렉션의 종류와 각각의 개념을 설명해주실 수 있나요?</li>
<li>위젯이 빌드되는 과정을 주요 3가지의 위젯 트리를 통해 설명해 주세요.</li>
<li>Inherited Widget에 대해 설명해주세요.</li>
<li>Flutter에서 사용되는 Sliver에 대한 개념과 언제 사용하는지 설명해 주세요.</li>
<li>Scaffold.of(context) 코드가 어떻게 작동하는지 BuildContext의 개념을 사용하여 설명해 주세요.</li>
<li>mixin에 대해서 설명해주세요</li>
<li>해당 코드에서 출력되는 알파벳 대문자를 순서대로 나열해 주세요.<pre><code class="language-dart">void main() {  
print(&#39;A&#39;);  
Future(() {  
  print(&#39;B&#39;);  
  Future(() =&gt; print(&#39;C&#39;));  
  Future.microtask(() =&gt; print(&#39;D&#39;));  
  Future(() =&gt; print(&#39;E&#39;));  
  print(&#39;F&#39;);  
});  
Future.microtask(() =&gt; print(&#39;G&#39;));  
print(&#39;H&#39;);  
}</code></pre>
</li>
</ul>
<p>기초적인 개념이라고 해도 실제 면접에서 생각이 잘 안나거나 버벅이는 경우가 있으므로 충분한 연습이 필요합니다. 그렇기 위해서는 <strong>단순히 눈으로만 면접 질문과 답변을 읽는 것이 아니라 실제 말하고 적는 연습이 필요</strong>하다고 생각하는데요. 가장 좋은 방법은 면접 스터디에 들어가서 스터디원끼리 서로 면접 질문을 주고받는 것이겠지만, 그러한 여건이 되지 못한다면 제가 개발한 <code>테크톡</code> 서비스를 이용해 보시는 걸 추천드립니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/ce410776-274c-4f77-8f4d-d4d2820e94ba/image.png" alt=""></p>
<p>테크톡 서비스는 이직 또는 첫 취업을 준비하고 있는 개발자분들 대상으로 <code>기술 면접</code>을 도와주는 서비스입니다. *<em>AI 면접관과 진행하는데 모의 면접을 통해 기술 개념을 스스로 완벽하게 이해하고 있는지 점검하실 수 있어요. *</em></p>
<p>해당 앱에서는 제가 지금까지 면접을 보면서 받았던 <code>모든 Flutter 면접 질문과 모범 답변</code>들이 있으니 Flutter 개발자 면접을 앞두고 계신 분들에게 조금이나 도움이 되었으면 좋겠습니다. 이외에도 iOS, Android, React , Spring, 자료구조, 데이터이스 등등 여러 기술 면접 주제들이 준비되어 있으니 많은 관심 부탁드립니다:)</p>
<blockquote>
<p>📱 플레이스토어 : <a href="https://play.google.com/store/apps/details?id=com.techtalk.ai">https://play.google.com/store/apps/details?id=com.techtalk.ai</a>
🍎 앱스토어 : <a href="https://apps.apple.com/kr/app/id6478161786">https://apps.apple.com/kr/app/id6478161786</a>
🛠 깃헙 레포 주소 : <a href="https://github.com/MakeFrog/TechTalk">https://github.com/MakeFrog/TechTalk</a></p>
</blockquote>
<br/>

<h1 id="마무리하면서">마무리하면서</h1>
<p>면접은 항상 떨리고 긴장되죠. 그렇다고 너무 주눅들 필요는 없을 것 같아요. 이미 여러분들은 1차 서류에서 회사의 검증을 받으셨기 때문에 충분한 자심감을 가지고 면접장에 들어가셔도 됩니다. </p>
<p>혹시 서류에서 떨어지셨다고 해도 너무 낙담할 필요는 없습니다. 평소에 패기어린 마음에 운칠기삼(運七技三)이라는 말을 별로 좋아하지는 않았는데요. 취업 시장에 막상 뛰어들고 보니 여러 부분에서 운이 정말 큰 요소로 작용하거나라고 생각했었습니다. 제 경험을 잠깐 말씀드리자면, 하향으로 지원했던 회사는 서류 단계에서부터 떨어졌지만, 오히려 상향 지원이라고 생각했던 회사가 붙는 경우도 비일비재했었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/fd281583-31d8-40e8-8999-4fe9e17a5e16/image.avif" alt=""></p>
<p>그리고 요즘 채용 시장이 완전히 얼어붙었습니다. 특히 신입으로서 취업의 장벽이 더 높아진 것 같다는 생각이 듭니다. 올해 올라온 네이버랩스의 체험형 인턴 공고가 현재 개발자 취업 시장의 단면을 잘 보여준다고 생각하는데요. 정규직 전환율이 높지 않은 <code>체험형 인턴</code>을 뽑는 공고의 자격 요견에서 <code>1년 이상의 실무 경험</code>이 이례적으로 기재되어 있다는 점이 놀라우면서도 씁쓸한 마음이 드네요. 참고로 아는 지인으로부터 해당 공고에 총 300명이 넘게 지원했다는 소식을 들었습니다.</p>
<p>저는 너무 운이 좋게 Flutter 개발자로 취업을 할 수 있었습니다. 특히 주변 선배 개발자분들의 도움이 컸어요. 이 자리를 빌려 함께 사이드프로젝트를 진행하면서 많이 가르쳐주신 순억님과, 직접 이력서를 피드백 해주신 지혜님 그리고 커피챗에서 제 고민을 항상 잘 들어주신 토르, 맥스 그리고 제이에게 감사 인사를 드립니다.</p>
<p>이렇게 오랜 기간동안 취준을 하고, 주변 선배 개발자분들한테 도움을 많이 받으면서 저도 그들처럼 좋은 멘토가 되고 싶다는 생각을 하게 되었습니다. 체계적으로 멘토링 커리큘럼을 구축하고 올해 하반기에는 소규모 무료 Flutter 개발 멘토링을 진행할 계획입니다. </p>
<p>지금 글을 읽고 계신 취준생분들이 면접에서 좋은 결과를 얻으실 수 있기를 진심으로 기원하겠습니다 🔥
부족한 제 글을 읽어주셔서 감사드립니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mixin Class를 활용하여 Riverpod 단점 극복하기]]></title>
            <link>https://velog.io/@ximya_hf/organize-your-global-providersinflutterriverpod</link>
            <guid>https://velog.io/@ximya_hf/organize-your-global-providersinflutterriverpod</guid>
            <pubDate>Mon, 01 Jan 2024 13:37:14 GMT</pubDate>
            <description><![CDATA[<p>혹시 Rivperod 좋아하시나요?</p>
<p>저는 최근에 새롭게 시작한 Flutter 프로젝트에서 Riverpod 패키지를 사용하여 상태관리를 하고 있습니다. 제가 주로 사용해 온 패키지는 <code>Provider</code>나 <code>GetX</code>였기 때문에 Riverpod과는 친숙하지 않았습니다. 다만, 근래 Flutter 유저들이 Riverpod에 열광하는 이유가 궁금했죠. 그래서 과감하게 Riverpod을 선택하게 되었습니다.</p>
<p>그리고 얼마 지나지 않아 저 또한 Riverpod의 매력에 푹 빠져들게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/47c766bb-5a4f-4f4d-a253-632f22928b35/image.png" alt=""></p>
<p>Riverpod을 사용하여 프로젝트를 구현하면서 패키지가 제시하는 리액티브한 메커니즘과 각종 기능에 대부분 만족했지만, 단 한 가지 부분에서는 불만족스러운 경험이 있었습니다.</p>
<h4 id="바로-provider가-전역top-level으로-선언된다는-점입니다">바로 provider가 전역(top-level)으로 선언된다는 점입니다.</h4>
<p><code>전역 변수</code>로 선언된 provider는 무조건 나쁘다는 것을 말하고 싶은 것은 아닙니다. (또한, provider의 상태는 <code>ProviderContainer</code> 안에서 관리되고 있어 완전한 전역 변수로 보기도 어렵습니다.)</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/f0c03952-7551-4982-af44-27ffdf309619/image.png" alt=""></p>
<p>Riverpod 공식문서에도 기재되었듯이 전역으로 선언된 provider는 <code>immutable</code>한 성격을 띄고 있기 때문에 앱의 라이프사이클을 방해하거나 테스트 코드를 작성하는데 문제가 발생하지는 않겠죠. 
<strong>그러나 어디에서든 import만으로 provider에 접근할 수 있다는 점은, 특정 페이지에서 어떤 provider가 사용되는지 파악하기 어렵다는 단점으로 이어집니다.</strong></p>
<br/>

<h2 id="riverpod가-전역으로-선언되었을-때-발생할-수-있는-문제점">Riverpod가 전역으로 선언되었을 때 발생할 수 있는 문제점</h2>
<p>이런 단점은 여러 문제를 동반합니다.</p>
<p>예를 들어, 당신이 Riverpod을 사용한 Flutter 프로젝트에 참여하게 되고, 상사가 다음과 같은 Task를 던져줬다고 가정해 봅시다.</p>
<blockquote>
<p>&quot;홈 페이지에 사용되는 Provider들을 기반으로 Unit Test 코드를 작성해주세요.&quot;</p>
</blockquote>
<p>이제 막 프로젝트에 투입되었기 때문에 홈 페이지에서 어떤 provider의 <code>상태값</code>과 <code>이벤트 메소드</code>가 사용되는지 파악하는 것이 어려울 것이고, 이로인해 테스트 코드의 범위를 파악하고 작성하는 데 많은 시간 소요하게 됩니다.</p>
<p>또한, 여러 명이 협업하는 프로젝트에서는 특정 페이지에서 사용되는 provider들을 파악하는 것이 중요한데요. provider의 사용 범위를 명확히 이해하지 못하면 이미 만들어진 provider를 재사용할 기회를 놓칠 수 있고, 불필요한 추가적인 provider를 만들어 <code>사이드 이펙트</code>가 발생할 수 있겠죠.</p>
<p>이러한 문제 외에도 <strong>앱이 규모가 크고 복잡할수록 provider의 사용 범위를 파악하기 어려워진다는 점은 프로젝트의 <code>유지보수</code>를 어렵게하는 원인이 됩니다.</strong></p>
<br/>

<h2 id="riverpod은-전역-변수만을-사용하는가">Riverpod은 전역 변수만을 사용하는가?</h2>
<p>위와 같은 문제를 방지하기 위해서는 <code>provider의 사용 범위를 구조화</code>하는 것이 중요합니다. <strong>즉, 특정 섹션에서 어떤 provider가 사용되는지 쉽게 파악할 수 있어야 합니다</strong>. provider 사용 범위를 어떻게 구조화할 수 있을지 고민을 하던 중 Randal L. Schwartz의 &#39;The Riverpod &quot;Global&quot; Myth&#39;라는 유튜브 영상을 보게 되었습니다.</p>
<p>!youtube[gHbkIgDnDyg]</p>
<p>해당 동영상에서는 Riverpod가 오해되는 <code>전역 변수만</code>을 사용한다는 내용과 함께, <code>provider 사용 범위를 구조화</code>하는 다양한 방법에 대해 자세히 설명하고 있습니다.</p>
<p>본 포스팅에서는 이 영상에서 다루고 있는 두 가지 방법을 간단하게 소개합니다.</p>
<ul>
<li>provider를 로컬화 (private 접근 제한자)</li>
<li>Class 정적 변수로 provider를 관리</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/82a4f5f9-cf94-45fa-8414-37651d6a53eb/image.png" alt=""></p>
<p>더불어, provider의 사용 범위를 구조화한다는 면에서 앞서 Randal이 소개한 방법과 비슷한 매커니즘을 가지고 있지만, Dart 3.0에서 추가된 <strong><code>mixin class</code></strong>를 이용하여 조금 더 <code>명시적이</code>고 <code>테스트가 용이한 형태</code>로 provider의 사용 범위를 구조화하는 방법에 대해서도 설명 드리니, 참고하시면 좋겠습니다.</p>
<blockquote>
<p>본 포스팅에서는 다루고 있는 예제는  <a href="https://riverpod.dev/docs/introduction/getting_started">Riverpod 공식 문서</a>에 있는 &#39;Todo app&#39;를 기반으로 합니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/2738362c-7004-49fc-ab1f-396fb65e42bd/image.png" alt=""></p>
<br/>

<h2 id="provider를-로컬화-private-접근-제한자">provider를 로컬화 (private 접근 제한자)</h2>
<p>먼저, 특정 페이지 섹션에서 사용되는 provider를 <code>private</code>으로 선언하여 provider 자체를 <code>로컬화</code>시키는 방법을 살펴보겠습니다.</p>
<pre><code class="language-dart">final _uncompletedTodosCount = Provider&lt;int&gt;((ref) {  
  return ref.watch(todoListProvider).where((todo) =&gt; !todo.completed).length;  
});

class Toolbar extends HookConsumerWidget {  
  const Toolbar({  
    Key? key,  
  }) : super(key: key);  

  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    return Material(  
      child: Row(  
        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
        children: [  
          Text(  
            &#39;${ref.watch(_uncompletedTodosCount)}&#39;, // &lt;- 로컬화된 provider에 접근
          ),
         ...

      }
  }    </code></pre>
<p>위에 코드 예제처럼 특정 소스 파일에서만 사용될 수 있도록 provider의 접근 범위를 private으로 제한하면, <strong>해당 소스 파일에서만 해당 provider에 접근이 가능하게 됩니다</strong>. 이를 통해 provider의 사용 범위를 명시적으로 관리할 수 있게 되죠.</p>
<p>다만 class로 분리된 여러 자식 위젯에서 해당 provider에 접근해야 하는 경우 코드가 조금 복잡해질 수 있습니다.</p>
<pre><code class="language-dart">final _uncompletedTodosCount = Provider&lt;int&gt;((ref) {  
  return ref.watch(todoListProvider).where((todo) =&gt; !todo.completed).length;  
});

part &#39;tool_bar3.dart&#39;; // &lt;- part 파일로분리

class HomePage extends HookConsumerWidget with HomeEvent, HomeState {  
  const HomePage({Key? key}) : super(key: key);  

  @override  
  Widget build(BuildContext context, WidgetRef ref) {  

    return Scaffold(  
      body: ListView(  
        children: [  
          Toolbar1(ref.watch(_uncompletedTodosCount)),
          Toolbar2(ref.watch(_uncompletedTodosCount)),
          const _Toolbar3(), 
          ...

      } 
  }</code></pre>
<p>예를 들어, HomePage에 <code>Toolbar1</code>, <code>Toolbar2</code>, <code>Toolbar3</code>라는 자식 위젯이 있고 모두 <code>_uncompletedTodosCount</code> provider에 접근해야 하는 구조라면, 매번 로컬화된 provider의 상태 값을 인자로 넘겨주거나 자식 위젯을 <code>part 파일</code>로 분리해야 하는 번거로움이 발생할 수 있습니다.</p>
<br/>

<h2 id="class-정적-변수로-provider를-관리">Class 정적 변수로 provider를 관리</h2>
<p>앞서 언급한 문제를 해결할 수 있는 방법은 <code>클래스의 정적 변수</code>에 provider를 할당하는 것 입니다.</p>
<pre><code class="language-dart">abstract class HomeProviders {  
  HomeProviders._();  

  static final todoListFilter = StateProvider((_) =&gt; TodoListFilter.all);  

  static final uncompletedTodosCount = Provider&lt;int&gt;((ref) {  
    return ref.watch(todoListProvider).where((todo) =&gt; !todo.completed).length;  
  });

  ...
}</code></pre>
<p>위에 코드처럼 Home 섹션에서 사용되는 모든 provider들을 class 내부에 정적 변수로 할당합니다. </p>
<pre><code class="language-dart">class Toolbar extends ConsumwerWidget {  
  const Toolbar({  
    Key? key,  
  }) : super(key: key);  

  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    return Material(  
      child: Row(  
        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
        children: [  
          Text(  
            &#39;${ref.watch(HomeProviders.uncompletedTodosCount)}&#39;, 
            // 정적 변수를 통해 provider에 접근
          ),
         ...

      }
  }    </code></pre>
<p>그다음 위젯에서 필요한 provider를 해당 class를 통해 참조하게 하면 됩니다. provider가 class의 정적 변수로 provider가 할당되었기 때문에** 불필요한 인스턴스가 생성되거나 provider의 라이프 사이클을 방해하지 않으면서 동시에 provider의 사용 범위를 구조화할 수 있게 됩니다.**</p>
<br/>


<h2 id="mixin-class를-활용하여-provider의-사용-범위를-구조화">Mixin Class를 활용하여 Provider의 사용 범위를 구조화</h2>
<p>이미 소개한 두 가지 방법으로도 충분히 provider의 사용 범위를 명시할 수 있지만, Mixin Class를 활용하여 조금 더 명시적이고 테스트가 용이한 방법을 제안드려 볼까 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/61a29bc2-0346-4ded-aa65-b46a52f39b3d/image.png" alt=""></p>
<h3 id="state-mixin-class">State Mixin Class</h3>
<p>먼저 <code>State Mixin Class</code>입니다. 해당 클래스에는 특정 페이지에서 사용되는 모든 provider의 <code>상태 값</code>을 리턴하는 <code>메소드들</code>이 구성되어 있습니다.</p>
<pre><code class="language-dart">mixin class HomeState {  
  int uncompletedTodosCount(WidgetRef ref) =&gt; ref.watch(uncompletedTodosCountProvider);  

  List&lt;Todo&gt; filteredTodos(WidgetRef ref) =&gt; ref.watch(filteredTodosProvider);  

  ...
}</code></pre>
<p>위의 <code>HomeState</code> Mixin Class는 <code>HomePage</code> 섹션에서 사용되는 provider들의 상태값을 관리하고 있습니다. 각 메소드는 <code>WidgetRef</code>을 인자로 받으며, WidgetRef의 확장 메소드인 <code>watch</code>를 사용하여 상태를 전달합니다.</p>
<pre><code class="language-dart">AsyncValue&lt;Todo&gt; todoAsync(WidgetRef ref) =&gt; ref.watch(todoProvider);</code></pre>
<p>만약 비동기 데이터 <code>Future</code> 타입을 반환해야한다면, 해당 값을 <code>AsyncValue</code> 타입으로 감싸주면 됩니다.</p>
<blockquote>
<p>앞서 소개한 방식과는 달리, provider 자체를 변수로 관리하는 것이 아닌, WidgetRef를 통해 provider의 state 값을 메소드로 리턴하고 있다는 차이가 있습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/4a299b38-d20d-424f-b60e-866751a708d6/image.png" alt=""></p>
<p>위와 같이 구성된 State Mixin Class는 위젯 클래스에 <code>mixin</code>되어 위젯에서 provider의 <code>상태 값</code>에 접근할 수 있도록 합니다.</p>
<h3 id="event-mixin-clsas">Event Mixin Clsas</h3>
<p>이어서 <code>Event Mixin Class</code>에 대해 살펴보겠습니다. Event Mixin Class에서는 특정 섹션에서 사용되는 모든 <code>이벤트 로직</code>을 효율적으로 관리합니다. State Mixin Class와 마찬가지로 <code>WidgetRef</code>를 인자로 받아서 provider의 메소드에 손쉽게 접근할 수 있습니다.</p>
<pre><code class="language-dart">mixin class HomeEvent {  
  void addTodo(  
    WidgetRef ref, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) {  
    ref.read(todoListProvider.notifier).add(value);  
    textEditingController.clear();  
  }  


  void requestTextFieldsFocus(  
    {required FocusNode textFieldFocusNode,  
    required FocusNode itemFocusNode}) {  
  itemFocusNode.requestFocus();  
  textFieldFocusNode.requestFocus();  
  }
...

}</code></pre>
<p>예를 들어, 위의 <code>addTodo</code> 메소드는 <code>WidgetRef</code> 객체를 통해 <code>todoListProvider</code>라는 Notifier Provider에 접근하여 새로운 아이템을 현재 리스트에 추가하는 메소드를 실행시킵니다.</p>
<blockquote>
<p>NOTE 
provider와 관련된 로직뿐만 아니라, 기타 사용자 인터랙션으로 인한 이벤트 값들도 해당 Mixin Class에서 관리하는 것이 권장됩니다. 특정 섹션에서 사용되는 event 값들을 한 곳에서 관리하면 UI 코드와 event 로직을 완벽하게 분리하여 가독성을 향상시킬 수 있으며, 코드 추적이 용이해집니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/768d5405-6e38-4ffd-b5db-6845a3c895b5/image.png" alt=""></p>
<p>마찬가지로, 위와 같이 Event Mixin Class는 Event 메소드가 필요한 위젯에 <code>mixin</code>되어, 간편하게 event 메소드를 전달할 수 있습니다.</p>
<h3 id="핵심-개념">핵심 개념</h3>
<p>조금 복잡해 보일 수 있지만 개념은 아주 간단합니다. </p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/e6ca2a9f-f418-4929-a85b-c0a169dc334b/image.png" alt=""></p>
<p><strong>핵심은 widget에서 provider에 바로 접근하게 하지 않고 State 및 Event Mixin Class라는 새로운 <code>통로</code>를 통해 <code>provider에 접근</code>할 수 있도록 하는 것입니다.</strong></p>
<blockquote>
<p>BLoC을 사용하신 경험이 있으신분들이랑면 state과 event를 분리한다는 면에서 일부 유사하다는 점을 눈치채셨을 겁니다. 이렇게 provider의 state과 event 로직을 분리함으로써 생기는 장점과 관련해서는 뒤에 내용에서 확인하실 수 있습니다.</p>
</blockquote>
<br/>


<h2 id="이점">이점</h2>
<p>그럼 이렇게 Rvierpod provider의 state 값과 event메소드들은  Mixin Class에서 관리하면 어떤 <code>이점</code>이 있을까요? 크게 5가지 이점을 살펴보겠습니다.</p>
<h3 id="1-유지보수에-용이">1. 유지보수에 용이</h3>
<p>특정 페이지 섹션에서 사용되는 provider들의 로직은 하나의 Mixin Class에서 <code>중앙 집중적</code>으로 관리되어 유지보수가 용이한 구조가 됩니다.</p>
<pre><code class="language-dart">mixin class HomeState {  
  List&lt;Todo&gt; todos(WidgetRef ref) =&gt; ref.watch(todoListFromRemoteProvider).value; 
}</code></pre>
<p>예를 들어, 위 코드에서는 <code>todoListFromRemoteProvider</code> provider를 통해 원격 데이터를 받아오고 있고, 특정 페이지 내 여러 위젯에서 이 값을 참조하고 있다고 가정해 봅시다.</p>
<pre><code class="language-dart">mixin class HomeState {  
  List&lt;Todo&gt; todos(WidgetRef ref) =&gt; ref.watch(todoListFromLocal); 
}</code></pre>
<p>만약 원격 데이터를 호출하는 기존 provider에서 로컬 데이터를 불러오는 <code>todoListFromLocal</code> provider로 변경해야 한다면, 간단히 HomeState class에서 provider만 교체해 주면 됩니다.</p>
<p>그러나 Mixin State Class를 사용하지 않고 각 위젯에서 직접 provider를 사용하는 구조라면, <strong>각 위젯에서 사용 중인 기존 provider를 새로운 provider로 일일이 변경해주 어야 하는 번거로움이 발생할 수 있습니다.</strong></p>
<br/>

<h3 id="2-가독성-향상">2. 가독성 향상</h3>
<p>Mixin Class에서 provider 리소스를 관리하면 <strong>특정 페이지에서 어떤 provider 상태 값과 이벤트 로직이 사용되는지 한눈에 파악할 수 있습니다.</strong> Mixin Class가 부모 페이지 위젯 또는 자식 위젯에 mixin되어 명확한 <code>종속성</code>이 설정되기 때문입니다.</p>
<p>또한, Event Mixin Class에서 event 로직을 관리함으로써 <strong>UI 코드와 event 메소드를 완벽하게 분리</strong>하여 가독성을 높일 수 있습니다.</p>
<br/>

<h3 id="3-유닛-테스트-코드를-작성할-때의-이점">3. 유닛 테스트 코드를 작성할 때의 이점</h3>
<p>State 및 Event Mixin Class를 활용하면 <code>유닛 테스트</code> 코드 작성이 더욱 편리해집니다.</p>
<h4 id="테스트-범위-파악">테스트 범위 파악</h4>
<p>유닛 테스트 코드를 작성하기 전에 앱의 규모가 클수록 <code>테스트 범위</code>를 설정하기가 까다롭습니다. 대체 어디까지 테스트를 해야되는지 항상 고민이 되죠.</p>
<pre><code class="language-dart">mixin class HomeEvent {  
  void addTodo(  
    WidgetRef ref, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) { ... }  

  void removeTodo(WidgetRef ref, {required Todo selectedTodo}) { ... }  


  void changeFilterCategory(WidgetRef ref, {required TodoListFilter filter}) {     ...
  }  

  void toggleTodoState(WidgetRef ref, {required String todoId}) { ... }  

  void editTodoDesc(WidgetRef ref,  
      {required bool isFocused,  
      required TextEditingController textEditingController,  
      required Todo selectedTodo}) { ... }  
}</code></pre>
<p>하지만 Event Mixin Class에 특정 페이지에서 사용되는 이벤트 로직들을 한 눈에 파악할 수 있다면 테스트 범위를 설정하고 테스트 시나리오를 구성하는데 꽤 도움이 될 수 있습니다. </p>
<h4 id="간결한-유닛-테스트-코드">간결한 유닛 테스트 코드</h4>
<p>기존에 작성된 State 및 Event Mixin 모듈을 활용하면 유닛 테스트 코드가 훨씬 간결해집니다.</p>
<pre><code class="language-dart">mixin class HomeEventTest {  
  void addTodo(  
    ProviderContainer container, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) {  
    container.read(todoListProvider.notifier).add(value);  
    textEditingController.clear();  
  }  

  void removeTodo(ProviderContainer container, {required Todo selectedTodo}) {  
    container.read(todoListProvider.notifier).remove(selectedTodo);  
  }
  ...

}

mixin class HomeStateTest {  
  List&lt;Todo&gt; filteredTodos(ProviderContainer container) =&gt;  
      container.read(filteredTodosProvider);  

  int uncompletedTodosCount(ProviderContainer container) =&gt;  
      container.read(uncompletedTodosCountProvider);
  ...

}  </code></pre>
<p>먼저 기존 State 및 Event Mixin 모듈의 코드를 <code>복사</code>해서 새로운 <code>테스트용 Mixin Class</code>를 만들어 줍니다. 이때 기존 메소드에서는 <code>WidgetRef</code>를 인자로 받았지만, 테스트 코드를 실행시키기 위해 <code>ProviderContainer</code> 타입으로 변경하고, 기존의 <code>.watch</code> 메소드를 <code>.read</code>로 변경해 주어야 합니다.</p>
<pre><code class="language-dart">void main() {  
  final homeEvent = HomeEventTest();  
  final homeState = HomeStateTest();  

  test(&#39;Add todo&#39;, () {  
    final container = createContainer();  
    const String todoDescription = &#39;Write Riverpod Test Code&#39;;  
    homeEvent.onTodoSubmitted(container,  
        textEditingController: TextEditingController(), value: todoDescription);  
    expect(  
        homeState.filteredTodos(container).last.description, todoDescription);  
  });
}</code></pre>
<p>그다음에는 테스트 main 메소드 안에서 각각 State 및 Event Mixin Class <code>인스턴스</code>를 생성한 뒤, 해당 인스턴스들을 활용하여 테스트 코드를 작성해 줍니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/4bb52494-11b5-423c-b206-3615175deeda/image.png" alt=""></p>
<p>단계별로 설명드리면 아래와 같습니다.</p>
<ol>
<li><strong>초기화:</strong> 필요한 테스트 State 및 Event Mixin 인스턴스를 초기화합니다.</li>
<li><strong>조작:</strong> 테스트 Event Mixin을 사용하여 이벤트 로직을 실행하고 상태를 조작 및 변경합니다.</li>
<li><strong>검증:</strong> 테스트 State Mixin을 활용하여 기대 값을 검증합니다.</li>
</ol>
<p>Event Mixin Class 내부에는 테스트하려는 <code>이벤트 메소드</code>가 정의되어 있으며, State Mixin Class에는 예상되는 테스트 <code>결과 값</code>이 구성되어 있기 때문에, 이를 활용하여 복잡한 시나리오를 포함한 유닛 테스트 코드를 간편하게 작성할 수 있게 됩니다.</p>
<br/>


<h3 id="4-작업-프로세스의-효율-증가">4. 작업 프로세스의 효율 증가</h3>
<p>현업에서 프로젝트를 진행할 때 기능 명세와 API 스펙은 모두 개발자에게 전달되었지만, 디자인이 완성되지 않은 상황이 종종 발생합니다. 이때, 디자인이 완성되기를 기다리지 않고 <strong>미리 State 및 Event Mixin Class 모듈을 작성</strong>하는 작업을 수행할 수 있습니다. 이렇게 미리 만들어진 Mixin Class를 활용하면 완성된 디자인을 받은 후 UI 위젯을 구현하고, <strong>만들어둔 Mixin Class를 연동하여 공백없이 프로젝트를 진행할 수 있게 됩니다</strong>.</p>
<br/>


<h3 id="5-협업-과정에서-발생할-수-있는-실수-최소화">5. 협업 과정에서 발생할 수 있는 실수 최소화</h3>
<p>앞서 살짝 언급했지만, 여려명이 협업하는 프로젝트일수록 특정 페이지에서 어떤 provider들이 사용되고 있는지 파악하는건  중요합니다. 이런 부분을 간과할 경우 기능은 동일하지만 이름만 다른 provider가 하나 더 생겨 사이드 이펙트가 발생하는 불상사가 발생할 수도 있습니다. (제 경험담입니다)</p>
<br/>

<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 포스팅에서는 Mixin Class를 활용하여 Riverpod에서 provider의 사용범위를 구조화하는 방법에 대해 알아보았습니다. 앱의 규모가 작다면 이런 접근 방법은 오버지니어링으로 비춰질 수 있지만, 앱의 규모가 커지고 다루는 provider가 많아질수록 더 많은 이점이 있습니다. 무엇보다도 <code>유닛 테스트 코드</code>를 아주 쉽게 작성할 수 있다는 점이 개인적으로 마음에 듭니다.</p>
<p>또한 Mixin Class를 활용하여 Provider의 사용 범위를 구조화하는 방법은 제가 이전에 작성한 <a href="https://velog.io/@ximya_hf/flutter-clean-ui-code">&#39;내일 바로 써먹는 Flutter Clean UI Code&#39;</a>라는 포스팅에서 제시한 접근방법과 함께 사용했을 때 더 빛을 발휘합니다. 이 포스팅에서는 Flutter의 UI코드를 섹션별로 Class Widget으로 구조화하는 방법에 대해 다루고 있는데, <strong>Mixin Class는 구조화된 UI 위젯에 유연하게 mixin되어 적용할 수 있기 때문에 함께 적용했을 때 시너지가 발생합니다.</strong></p>
<p>참고로, 본 포스팅에서 다룬 Todo App 예제 프로젝트가 궁금하신다면 제 <a href="https://github.com/Xim-ya?tab=repositories">깃허브 레포</a>를 참고해주세요. 기존 Riverpod 공식문서에 있는 예제 코드에서 State 및 Event Mixin Class 적용 로직과 간다한 테스트 코드만 추가해 두었습니다.</p>
<p>최근에 Riverpod을 너무 만족하며 사용하고 있어서 Riverpod과 관련된 다양한 포스팅을 추가로 업로드할 계획이 있습니다. 혹시 관련된 내용이 궁금하시다면 팔로우를 해주세요:)</p>
<p>읽어주셔서 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[제네릭(Generic) 클래스를 활용하여 Flutter에서 효과적으로 데이터 상태 관리하기]]></title>
            <link>https://velog.io/@ximya_hf/handle-data-stata-by-generic-class-in-flutter</link>
            <guid>https://velog.io/@ximya_hf/handle-data-stata-by-generic-class-in-flutter</guid>
            <pubDate>Sun, 26 Nov 2023 23:28:06 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/0c44cb3d-1ae1-4652-b227-b4a261530b0c/image.png" alt=""></p>
<p>서버로부터 <code>비동기 데이터</code>를 호출하여 화면에 보여줄 때 신경 써야 할 부분이 여러 가지 있습니다. 데이터의 <code>상태(state)</code>에 따라 화면에 적절한 <code>UI</code>를 보여주는 것도 그중 하나죠. 일반적으로 데이터 호출의 성공, 실패 그리고 로딩 상태를 고려해야 됩니다.</p>
<p>데이터 상태를 정의하고 관리하기 위한 여러 가지 접근 방법들이 있는데, 관련해서 레딧(reddit)에 <a href="https://www.reddit.com/r/FlutterDev/comments/145d87c/flutter_bloc_which_approach_would_you_suggest/">흥미로운 글</a>을 하나 발견했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/8f680037-a1e1-417d-ac20-f7ebaabbab7b/image.png" alt=""></p>
<p>BLoC 환경에서 <code>데이터의 상태를 정의</code>할 때 어떤 접근 방식이 더 효과적인지에 물어보는 글이었고, 작성자는 두 가지 접근 방식을 소개했습니다.</p>
<ul>
<li>각 상태에 대한 개별 클래스를 정의</li>
<li>Enum(열거형)을 사용하여 여러 상태를 정의</li>
</ul>
<p>해당 포스팅에 여러 의견이 달렸고, 정답이 정해져 있는 문제가 아니기 때문에 결론을 내릴 수 없지만 각 방식에 대한 <code>장단점</code>은 명확해 보였습니다.</p>
<p>장점을 유지하고 단점을 보완하는 방법은 없을지 고민했고, <strong>이 2가지 방식의 개념을 <code>결합</code>하는 것에서 힌트를 얻었습니다.</strong></p>
<p>그래서 이번 포스팅에서는 레딧에서 언급된 데이터의 상태를 정의하는 2가지 방식에 대한 장단점들을 분석하고, 기존 방식의 장점을 살리고 단점을 보완할 수 있는 <code>새로운 접근 방식</code>을 소개 드려볼까 합니다.</p>
<blockquote>
<p>레딧 포스팅에는 BLoC 상태 관리 라이브러리를 전제로 하고 있지만, 본 포스팅에서 다루는 방법은 다른 기타 상태 관리 라이브러리 (Provider, Getx)에서도 적용 가능하니 참고 해주세요.</p>
</blockquote>
<br/>

<h2 id="1-각-상태에-대한-개별-클래스를-정의">1. 각 상태에 대한 개별 클래스를 정의</h2>
<pre><code class="language-dart">sealed class ProfileState {}  

class ProfileFetchedState extends ProfileState {  
  ProfileFetchedState({required this.info});  

  final User info;  
}  

class ProfileLoadingState extends ProfileState {}  

class ProfileFailedState extends ProfileState {}</code></pre>
<p>첫 번째로, <strong>각 상태에 대한 개별 클래스를 정의하는 방법</strong>을 분석해 봅시다.
&#39;ProfileState&#39;라는 <code>공통 추상 클래스</code>가 존재하고 각 상태에 대한 <code>개별 클래스</code>가 해당 추상 클래스를 상속하여 데이터 상태를 정의하는 형태입니다. 이 방식은 각 상태의 특징을 명확하게 <code>식별</code>할 수 있는 장점이 있습니다.</p>
<pre><code class="language-dart">return switch(state) {
  ProfileSuccessState() =&gt; ProfileCard(),
  ProfileLoadingState() =&gt; CircularProgressIndicator(),
  ProfileFailedState() =&gt; ErrorIndicator(),
};</code></pre>
<p>또한, 공통 state 추상 클래스를 <code>sealed 클래스</code>로 선언하면 <code>패턴 매칭 구문</code>을 활용하여 클래스 타입 유형을 검사하여 직관적으로 state 상태에 따라 UI를 분기하여 반환할 수 있습니다</p>
<p>그러나 이 방식은 상태를 정의하기 위해 <code>boilerplate 코드</code>가 많아지는 단점이 있습니다. <strong>상태에 대한 각 클래스를 별도로 정의하고 필요 이상으로 많은 코드를 작성해야 하며, 상태의 구조가 다른 클래스와 유사하거나 간단한 경우에도 각 클래스를 일일이 작성해야 하는 불편함이 동반됩니다.</strong></p>
<br/>


<h2 id="2-enum열거형을-사용하여-데이터-상태를-정의">2. enum(열거형)을 사용하여 데이터 상태를 정의</h2>
<pre><code class="language-dart">enum ProfileState { fetched, loading, failed }

class Profile {  
  final User? info;  
  final ProfileState state;  

  ProfileInfo({required this.userInfo, required this.state});  
}</code></pre>
<p>두 번째 방법은 하나의 클래스 내에서 <code>Enum(열거형)</code>을 활용하여 각 상태를 표현하는 것입니다. 이 방식은 별도의 클래스를 생성하지 않고도 각 상태에 대한 정보를 클래스 내에서 직접 표현할 수 있어 코드의 <code>간결성</code>과 <code>유연성</code>을 높일 수 있습니다.</p>
<p>boilerplate 코드 없이 간결하게 상태를 정의할 수 있다는 장점이 있지만, 이런 구조는 자칫 <code>null 지옥</code>에 빠질 수 있다는 단점이 있습니다.</p>
<p>예를 들어, 예제로 제공한 Profile 클래스에는 &#39;user&#39; 필드가 nullable로 선언이 되었습니다. <strong>그 이유는 해당 필드의 값이 특정 상태에서는 존재하지 않을 수 있기 때문입니다</strong>. 데이터를 로드하거나 가져오는 데 실패한 경우 user 필드에 값이 초기화되지 않겠죠.</p>
<pre><code class="language-dart">Profile data;  

switch (data.state) {  
  case ProfileState.fetched:  
    return ProfileCard(data.info!); // ❗null 체크가 필요합니다
  case ProfileState.loading:  
    return CircularProgressIndicator();  
  case ProfileState.eror:  
    return ErrorIndicator();  
}</code></pre>
<p>그러므로 데이터를 정상적으로 불러온 상태에서도 항상 <code>null 체크</code>를 수행해야 하는 번거로움이 발생할 수 있습니다.</p>
<br/>


<h2 id="3-제네릭-클래스를-활용한-데이터-상태-정의">3. 제네릭 클래스를 활용한 데이터 상태 정의</h2>
<p><strong>앞서 소개한 두 가지 방식은 각각의 <code>장단점</code>을 지니고 있습니다.</strong> 그렇다면 이러한 단점을 보완하고 동시에 기존의 장점을 유지하며 데이터 상태를 정의할 수 있는 방법은 없을까요?</p>
<p>이에 대한 해결책으로 공통 <code>제네릭 클래스</code>를 활용하는 방법을 제안합니다. 이 방법은 두 가지 방법의 개념을 짬뽕(?)한 것으로 볼 수 있습니다.</p>
<p>우선, 몇 가지 기본 모듈을 만들어야 하므로 단계별로 설명하겠습니다.</p>
<h3 id="공통-제네릭-클래스">공통 제네릭 클래스</h3>
<pre><code class="language-dart">enum DataState {  
  fetched,  
  loading,  
  failed;  
}

sealed class Ds&lt;T&gt; {  
  Ds({required this.state, this.error});  

  T? valueOrNull;  
  Object? error;  
  DataState state;  


  T get value =&gt; valueOrNull!;  
}</code></pre>
<p>먼저 데이터 상태를 나타내는 <code>Enum</code>과 <code>Ds(Data State)</code>라는 <code>제네릭 추상 클래스</code>를 만들어 줍니다. 이 추상 클래스는 데이터 <code>상태(enum)</code>와 실제 데이터 값을 <code>제네릭한 형태</code>로 받아들이며, 에러 정보도 포함합니다. 자세한 내용은 다음과 같습니다.</p>
<ul>
<li><p><code>T? valueOrNull</code>: 제네릭 타입 <code>T</code>의 데이터 또는 <code>null</code>을 나타내는 변수입니다. 데이터가 <code>null</code>이 아닌 경우 해당 값을 반환하는 <code>value</code> 메서드가 정의되어 있습니다.</p>
</li>
<li><p><code>Object? error</code>: 에러 정보를 나타내는 변수입니다. 이 변수에는 발생한 에러에 대한 정보가 들어갑니다. <code>null</code>일 수 있습니다.</p>
</li>
<li><p><code>DataState state</code>: 데이터의 상태를 나타내는 변수로, <code>DataState</code> enum의 값 중 하나가 들어갑니다. 데이터가 성공적으로 가져와진 경우 <code>DataState.fetched</code>, 로딩 중일 경우 <code>DataState.loading</code>, 실패한 경우 <code>DataState.failed</code>가 해당됩니다.</p>
</li>
<li><p><code>T get value =&gt; valueOrNull!</code>: 데이터가 <code>null</code>이 아닌 경우 해당 값을 반환하는 <code>value</code> 메서드입니다.</p>
</li>
</ul>
<br/>

<h3 id="제네릭-클래스를-상속-받는-상태-클래스">제네릭 클래스를 상속 받는 상태 클래스</h3>
<p>다음으로 앞서 구현한 <code>Ds&lt;T&gt;</code> 추상 클래스를 상속받아 각각의 데이터 상태를 나타내는 <code>하위 클래스들</code>을 만들어 줍시다.</p>
<pre><code class="language-dart">// 성공적으로 데이터를 가져왔을 때의 데이터 상태 클래스
class Fetched&lt;T&gt; extends Ds&lt;T&gt; {  
  final T data;  

  Fetched(this.data) : super(state: DataState.fetched, valueOrNull: data);  
}

// 로딩 중일 때의 데이터 상태 클래스
class Loading&lt;T&gt; extends Ds&lt;T&gt; {  
  Loading() : super(state: DataState.loading);  
}


// 데이터 가져오기 실패했을 때의 데이터 상태 클래스
class Failed&lt;T&gt; extends Ds&lt;T&gt; {  
  final Object error;  

  Failed(this.error) : super(state: DataState.failed, error: error);  
}</code></pre>
<p>각 클래스의 생성자에서는 <code>super</code> 키워드를 사용하여 데이터의 상태를 나타내는 <code>state</code> 필드 적합한 enum 값을 전달하여 초기화 시켜주고 있고,  <code>valueOrNull</code> 및 <code>error</code> 상위 클래스의 멤버 변수를 필요에 따라 조건별로 초기화 시켜주고 있습니다.</p>
<p>예를 들면 <code>Fetched&lt;T&gt;</code> 클래스는 데이터를 성공적으로 가져왔을 때의 경우이므로 <code>T</code> 타입의 데이터를 생성자로 전달받아 상위 클래스의 <code>valueOrNull</code> 필드 값을 초기화합니다. 반대로 <code>Loading&lt;T&gt;</code> 및 <code>Failed&lt;T&gt;</code> 클래스의 경우 데이터를 전달받지 않기 때문에 별도의 <code>valueOrNull</code> 필드 초기화 구문이 필요하지 않습니다. 또한 <code>Failed&lt;T&gt;</code> 클래스에서는 <code>Object</code> 타입의 에러 객체를 전달받고 초기화할 수 있도록 설계했습니다.</p>
<p>그런데 코드가 조금 익숙해 보이지 않으신가요?</p>
<pre><code class="language-dart">sealed class ProfileState {}  

class ProfileFetchedState extends ProfileState {  
  ProfileFetchedState({required this.info});  

  final User info;  
}  

class ProfileLoadingState extends ProfileState {}  

class ProfileFailedState extends ProfileState {}
</code></pre>
<p>눈치가 빠르시다면 앞서 소개한 <code>첫 번째 방식</code>과 완전히 유사한 구조로 구성되어 있음을 캐치하셨을 겁니다. 다른 점이 있다면 기존과 달리 공통 state 클래스가 <code>제네릭 타입</code>으로 선언되어 있고 각 state 클래스에는 상태에 맞는 <code>enum 값</code>이 매핑되어 있다는 것이겠죠.</p>
<br/>

<h3 id="적용하기">적용하기</h3>
<p>자, 이제 모든 준비가 끝났습니다. 앞서 구현한 모듈을 기반으로 데이터 상태를 정의하고 화면에 데이터를 불러오는 방법을 예제로 확인해 보겠습니다.</p>
<ol>
<li>초기 데이터 선언<pre><code class="language-dart">Ds&lt;Profile&gt; profileInfo = Loading(); // or Loading&lt;Profile&gt;();</code></pre>
</li>
</ol>
<p>프로필 정보를 담을 <code>profileInfo</code> 변수를 <code>Ds&lt;Profile&gt;</code> 타입으로 선언하고 <code>Loading</code> 값을 할당합니다. 이는 초기에 어떤 데이터도 로드되지 않은 상태를 나타냅니다.</p>
<blockquote>
<p>참고로 <code>Loading</code>은 <code>Ds</code>의 하위 클래스이므로 <code>profileInfo</code> 변수에 할당할 수 있습니다. </p>
</blockquote>
<ol start="2">
<li>데이터 호출</li>
</ol>
<pre><code class="language-dart">Future&lt;void&gt; fetchData() async {  
  try {  
    profileInfo = Fetched(  
      User(  
        imgUrl: &#39;https://avatars.githubusercontent.com/u/75591730?v=4&#39;,  
        name: &#39;Ximya&#39;,  
        description:  
            &#39;Lorem ipsum dolor sit amet, consectetur adipiscing ...&#39;,  
      ),  
    ); 
    log(&#39;데이터를 성공적으로 호출하였습니다&#39;);  
  } catch (e) {  
    profileInfo = Failed(e);  
    log(&#39;데이터 호출에 실패했습니다. ${e}&#39;);  
  }  
}</code></pre>
<p>데이터를 호출하는 부분입니다. 성공적으로 데이터를 가져오면, 해당 프로필 정보를 가진 <code>User</code> 객체를 생성하여 <code>Fetched</code> 클래스로 감싸 <code>profileInfo</code>에 할당합니다. 반대로 데이터 호출에 실패하면 <code>Failed</code>  상태로 에러 정보를 저장합니다.</p>
<ol start="3">
<li>상태별로 위젯 반환하기</li>
</ol>
<pre><code class="language-dart">final profile = controller.profileInfo;  

return switch (profile) {  
  Fetched() =&gt; ProfileCard(profile.value),  
  Failed() =&gt; ErrorIndicator(profile.error),  
  Loading() =&gt; CircularProgressIndicator(),  
};</code></pre>
<p>패턴 매칭 구문을 활용하여 <code>profile</code>의 실제 타입을 확인하며, 각 상태에 맞게 적절한 위젯을 반환합니다. 데이터를 성공적으로 불러왔을 경우 <code>.value</code> 구문을 사용하여 데이터에 접근하고, 이 값을 ProfileCard 위젯에 전달하여 반환합니다. 실패한 경우에는 <code>.error</code> 구문을 활용하여 에러 데이터에 접근하고, 이 값을 ErrorIndicator 위젯에 전달하여 반환합니다. 그리고 로딩 중일 때는 CircularProgressIndicator를 반환하여 로딩 상태를 보여주도록 설계했습니다.</p>
<p><strong>기존 방식의 장점</strong></p>
<ul>
<li>패턴 매칭 구문을 사용하여 직관적으로 state 상태에 따라 UI를 분기하여 반환</li>
<li>각 상태의 특징을 쉽게 식별 가능</li>
<li>코드의 간결성과 유연성을 높임</li>
</ul>
<p><strong>기존 방식의 단점</strong></p>
<ul>
<li>지나친 boilerplate 코드</li>
<li>번거로운 null 체크</li>
</ul>
<p>이렇게 구현된 코드는 <strong>기존 2가지 방식의 장점은 유지하고 단점을 보완하면서 데이터 상태에 따라 간편하게 동작을 처리</strong>할 수 있게 됩니다.</p>
<br/>

<h2 id="함수형-프로그래밍-스타일로-위젯-분기하기">함수형 프로그래밍 스타일로 위젯 분기하기</h2>
<p>현재 코드도 패턴 매칭 구문으로 데이터의 상태에 따라 위젯을 직관적으로 반환하고 있지만, 조금 더 <code>함수형 프로그래밍</code>스럽게 코드를 반환하는 방법이 있습니다. 기존 코드에서 두 가지 부분을 추가해 주시면 됩니다.</p>
<h3 id="1-getter-메소드-추가">1. getter 메소드 추가</h3>
<pre><code class="language-dart">enum DataState {  
  fetched,  
  loading,  
  failed;  

  // 추가된 코드
  bool get isFetched =&gt; this == DataState.fetched;  
  bool get isLoading =&gt; this == DataState.loading;  
  bool get isFailed =&gt; this == DataState.failed;  
}</code></pre>
<p>DataState Emum에 새로운 getter 메소드를 추가해 줍니다. 이 getter 메소드는 상태가 <code>fetched</code>, <code>loading</code>, <code>failed</code>인지 여부를 확인할 수 있게 됩니다.</p>
<h3 id="2--onstate-메소드-추가">2.  onState 메소드 추가</h3>
<pre><code class="language-dart">sealed class Ds&lt;T&gt; {  
  Ds({required this.state, this.error, this.valueOrNull});  

  T? valueOrNull; 
  DataState state;
  T get value =&gt; valueOrNull!;  

  // 추가된 코드
  R onState&lt;R&gt;({  
    required R Function(T data) fetched,  
    required R Function(Object error) failed,  
    required R Function() loading,  
  }) {  
    if (state.isFailed) {  
      return failed(error!);  
    } else if (state.isLoading) {  
      return loading();  
    } else {  
      return fetched(valueOrNull as T);  
    }  
  }  
}</code></pre>
<p>그다음 <code>Ds&lt;T&gt;</code> 클래스에 <code>onState</code>라는 새로운 메소드를 추가합니다. <strong>이 고차함수는 제네릭 타입 <code>T</code>에 대한 데이터 상태에 따라 세 가지 함수를 받아와서, 현재 데이터 상태에 따라 적절한 함수를 호출하고 그 결과를 반환합니다.</strong></p>
<h3 id="onstate-메소드-적용">onState 메소드 적용</h3>
<pre><code class="language-dart">final profile = controller.profileInfo; 

return profile.onState(  
  fetched: (value) =&gt; ProfileCard(value),  
  failed: (e) =&gt; ErrorIndicator(e),  
  loading: () =&gt; CircularProgressIndicator(),  
);</code></pre>
<p>이제 <code>onsState</code> 메소드를 사용하여 상태에 따라 위젯을 분기하는 로직을 수행할 수 있습니다. 개인적으로 패턴 매칭 구문보다 조금 더 간결한 것 같네요.</p>
<br/>

<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 포스팅에서는 제네릭 클래스를 사용하여 데이터 상태를 정의하고 관리하는 방법에 대해 알아보았습니다. <strong>글 초반부에 언급했듯이 관점에 따라 정답이 달라질 수 있는 문제이기 때문에 제가 소개해 드린 방식이 완벽한 <code>Best Practice</code>가 아닌 점을 말씀드립니다.</strong> 그래도 코드의 중복을 줄이고 간결하게 데이터 상태를 정의할 수 있는 부분이 마음에 드네요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/2bfbe6c3-bff8-43b1-b086-992d0123ce8e/image.png" alt=""></p>
<p>본 포스팅에서 다룬 예제 코드가 궁금하시다면 제 <a href="https://github.com/Xim-ya/efficient_data_state_handle_implementation">깃허브 레포지터리</a>에서 확인하실 수 있습니다.</p>
<p>읽어주셔서 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[내일 바로 써먹는 Flutter Clean UI Code]]></title>
            <link>https://velog.io/@ximya_hf/flutter-clean-ui-code</link>
            <guid>https://velog.io/@ximya_hf/flutter-clean-ui-code</guid>
            <pubDate>Wed, 22 Nov 2023 21:12:29 GMT</pubDate>
            <description><![CDATA[<p>다들 한 번쯤 방에서 소중한 물건을 잃어버린 경험을 한 적이 있을 겁니다. 특히 여러분의 방이 어지럽혀져 있을 수 록 또 방의 크기가 클수록 찾는데 더 많은 시간이 걸립니다. 이는 UI 코드와 비슷한 맥락을 가집니다. 코드가 정돈되지 않고 한 페이지에 여러 위젯이 섞여 있을수록 한눈에 알아보기 힘든 코드가 됩니다.</p>
<p>방에서 물건을 찾는 수고 정도는 본인이 감내하면 되겠지만, 함께 협업하여 진행하는 프로젝트에서는 정돈되지 않은 UI 코드는 동료 개발자들을 고생시킬 수 있습니다. 어지럽혀진 본인의 방에서 동료들이 잃어버린 물건을 찾는 경우는 없어야겠죠.</p>
<p>이번 글에서는 <strong>UI 코드를 <code>구조화</code>하여 가독성을 높이고 <code>유지보수</code>와 <code>협업</code>에 용이한 형태로 구성하는 방법</strong>을 소개해 드리고 있으니 관련 팁을 얻어가세요!</p>
<br/>

<h2 id="스파게티-코드의-문제점">스파게티 코드의 문제점</h2>
<p>먼저, 정돈되지 않은 UI 코드에는 어떤 문제점이 있을까요?</p>
<pre><code class="language-dart">class ProductDetailPage extends StatelessWidget {  
  const ProductDetailPage({Key? key}) : super(key: key);  

  @override  
  Widget build(BuildContext context) {  
    return Scaffold(  
      body: Stack(  
        children: [  
          SingleChildScrollView(  
            child: Column(  
              children: &lt;Widget&gt;[  
                /// HEADER  
                Container(  
                  height: 386,  
                  decoration: BoxDecoration(  
                    image: DecorationImage(  
                        image: Image.asset(Assets.productImg0).image,  
                        fit: BoxFit.cover),  
                  ),  
                ),  
                const SizedBox(height: 15),  

                Padding(  
                  padding: const EdgeInsets.symmetric(horizontal: 20),  
                  child: Column(  
                    crossAxisAlignment: CrossAxisAlignment.start,  
                    children: &lt;Widget&gt;[  
                      /// PRODUCT INFO  
                      SizedBox(  
                        width: double.infinity,  
                        child: Wrap(  
                          crossAxisAlignment: WrapCrossAlignment.end,  
                          alignment: WrapAlignment.spaceBetween,  
                          children: &lt;Widget&gt;[  
                            Wrap(  
                              direction: Axis.vertical,  
                              children: [  
                                Text(  
                                  &#39;Men\&#39;s Printed Pullover Hoodie &#39;,  
                                  style: AppTextStyle.body3.copyWith(  
                                    color: AppColor.grey,  
                                  ),  
                                ),  
                                const SizedBox(height: 8),  
                                // NAME  
                                Text(  
                                  &#39;Nike Club Fleece&#39;,  
                                  style: AppTextStyle.headline3,  
                                ),  
                              ],  
                            ),  

                            Wrap(  
                              direction: Axis.vertical,  
                              children: &lt;Widget&gt;[  
                                Text(  
                                  &#39;Price&#39;,  
                                  style: AppTextStyle.body3.copyWith(  
                                    color: AppColor.grey,  
                                  ),  
                                ),  
                                const SizedBox(height: 8),  
                                Text(  
                                  &#39;\$120&#39;,  
                                  style: AppTextStyle.headline3,  
                                ),  
                              ],  
                            ),  
                            // CATEGORY  
                          ],  
                        ),  
                      ),  
                      const SizedBox(height: 20),  

                      /// PRODUCT PICTURE LIST  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: &lt;Widget&gt;[  
                          ...List.generate(productImgList.length, (index) {  
                            final img = productImgList[index];  
                            final imgSize =  
                                (MediaQuery.of(context).size.width - 67) / 4;  
                            return Container(  
                              height: imgSize,  
                              width: imgSize,  
                              decoration: BoxDecoration(  
                                borderRadius: BorderRadius.circular(20),  
                                image: DecorationImage(  
                                  image: Image.asset(img).image,  
                                ),  
                              ),  
                            );  
                          })  
                        ],  
                      ),  
                      const SizedBox(height: 15),  

                      /// SIZE  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: &lt;Text&gt;[  
                          Text(  
                            &#39;Size&#39;,  
                            style: AppTextStyle.body1,  
                          ),  
                          Text(  
                            &#39;Size Guide&#39;,  
                            style: AppTextStyle.body2,  
                          )  
                        ],  
                      ),  
                      const SizedBox(height: 10),  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: &lt;Widget&gt;[  
                          ...List.generate(sizeOptions.length, (index) {  
                            final option = sizeOptions[index];  
                            final buttonSize =  
                                (MediaQuery.of(context).size.width - 76) / 5;  
                            return ElevatedButton(  
                              style: ElevatedButton.styleFrom(  
                                padding: EdgeInsets.zero,  
                                minimumSize: Size(buttonSize, buttonSize),  
                                elevation: 0,  
                                shape: RoundedRectangleBorder(  
                                  borderRadius: BorderRadius.circular(10),  
                                ),  

                                backgroundColor: AppColor.lightGrey,  
                                // background (button) color  
                                foregroundColor:  
                                    AppColor.black, // foreground (text) color  
                              ),  
                              onPressed: () {},  
                              child: Text(option),  
                            );  
                          })  
                        ],  
                      ),  
                      const SizedBox(height: 20),  

                      /// DESCRIPTION  
                      Text(  
                        &#39;Description&#39;,  
                        style: AppTextStyle.body1,  
                      ),  
                      const SizedBox(height: 10),  
                      const ExpandableTextView(  
                        text: productDescription,  
                        maxLines: 3,  
                      ),  

                      /// REVIEWS  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: [  
                          Text(  
                            &#39;Reviews&#39;,  
                            style: AppTextStyle.body1,  
                          ),  
                          TextButton(  
                              onPressed: () {},  
                              child: Text(  
                                &#39;View All&#39;,  
                                style: AppTextStyle.body3.copyWith(  
                                  color: AppColor.grey,  
                                ),  
                              ))  
                        ],  
                      ),  

                      const SizedBox(height: 16),  
                      ListView.builder(  
                        padding: EdgeInsets.zero,  
                        physics: const NeverScrollableScrollPhysics(),  
                        shrinkWrap: true,  
                        itemCount: 1,  
                        itemBuilder: (context, index) {  
                          return Column(  
                            children: &lt;Widget&gt;[  
                              Row(  
                                children: &lt;Widget&gt;[  
                                  // PROFILE IMAGE  
                                  Container(  
                                    height: 40,  
                                    width: 40,  
                                    decoration: BoxDecoration(  
                                      shape: BoxShape.circle,  
                                      image: DecorationImage(  
                                        image: Image.asset(  
                                          &#39;assets/images/avatar.png&#39;,  
                                        ).image,  
                                      ),  
                                    ),  
                                  ),  
                                  const SizedBox(width: 10),  

                                  Column(  
                                    crossAxisAlignment:  
                                        CrossAxisAlignment.start,  
                                    children: &lt;Widget&gt;[  
                                      // REVIEWER NAME  
                                      Text(  
                                        &#39;Ronald Richards&#39;,  
                                        style: AppTextStyle.body2,  
                                      ),  
                                      const SizedBox(height: 5),  
                                      Row(  
                                        mainAxisAlignment:  
                                            MainAxisAlignment.center,  
                                        children: &lt;Widget&gt;[  
                                          SvgPicture.asset(  
                                            Assets.clock,  
                                          ),  
                                          const SizedBox(width: 5),  
                                          Text(  
                                            &#39;13 Sep, 2020&#39;,  
                                            style: AppTextStyle.body4.copyWith(  
                                              color: AppColor.grey,  
                                            ),  
                                          )  
                                        ],  
                                      ),  
                                      // REVIEWED DATE  
                                    ],  
                                  ),  
                                  const Spacer(),  
                                  Column(  
                                    children: &lt;Widget&gt;[  
                                      Text.rich(  
                                        TextSpan(  
                                          children: &lt;TextSpan&gt;[  
                                            TextSpan(  
                                              text: &#39;4.8&#39;,  
                                              style: AppTextStyle.body2,  
                                            ),  
                                            TextSpan(  
                                              text: &#39;  rating&#39;,  
                                              style:  
                                                  AppTextStyle.body4.copyWith(  
                                                color: AppColor.grey,  
                                              ),  
                                            ),  
                                          ],  
                                        ),  
                                      ),// my boss is fool  
                                      const SizedBox(height: 5),  
                                      SvgPicture.asset(  
                                          &#39;assets/icons/group_star.svg&#39;)  
                                    ],  
                                  ),  
                                ],  
                              ),  
                              const SizedBox(height: 10),  
                              Text(  
                                &#39;Lorem ipsum dolor sit amet, consectetur...&#39;,  
                                style: AppTextStyle.body2.copyWith(  
                                  color: AppColor.grey,  
                                ),  
                              )  
                            ],  
                          );  
                        },  
                      ),  

                      const SizedBox(height: 20),  

                      /// TOTAL Price  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: &lt;Widget&gt;[  
                          Column(  
                            crossAxisAlignment: CrossAxisAlignment.start,  
                            children: &lt;Widget&gt;[  
                              Text(  
                                &#39;Total Price&#39;,  
                                style: AppTextStyle.body1,  
                              ),  
                              const SizedBox(height: 5),  
                              Text(  
                                &#39;with VAT, SD&#39;,  
                                style: AppTextStyle.body4.copyWith(  
                                  color: AppColor.grey,  
                                ),  
                              )  
                            ],  
                          ),  
                          Text(  
                            &#39;\$125&#39;,  
                            style: AppTextStyle.body1,  
                          )  
                        ],  
                      ),  

                      SizedBox(  
                        height: MediaQuery.of(context).padding.bottom + 96,  
                      )  
                    ],  
                  ),  
                ),  
              ],  
            ),  
          ),  

          /// BOTTOM FIXED BUTTON  
          Positioned(  
            bottom: 0,  
            child: MaterialButton(  
              elevation: 0,  
              onPressed: () {},  
              padding: EdgeInsets.only(  
                  bottom: MediaQuery.of(context).padding.bottom),  
              height: 56 + MediaQuery.of(context).padding.bottom,  
              minWidth: MediaQuery.of(context).size.width,  
              color: AppColor.purple,  
              child: Text(  
                &#39;Add to Cart&#39;,  
                style: AppTextStyle.body1.copyWith(  
                  color: AppColor.white,  
                ),  
              ),  
            ),  
          )  
        ],  
      ),  
    );  
  }  
}</code></pre>
<p>문제점은 너무 명확합니다. 위의 코드 예시처럼 <strong>길게 나열된 선언형 UI 코드</strong>는 가독성을 떨어트리고, 가독성이 떨어지니 기능이 추가되거나 오류를 수정할 때도 기존의 <code>긴 코드를 분석</code>하느라 시간이 더 걸리게 됩니다. 또한 화면에서 다루고 있는 위젯이 많아질수록 더 유지보수하기 힘든 형태가 됩니다. 아마 저런 코드에는 상사 욕을 주석으로 달아두어도 눈치를 못 챌 수 있습니다.</p>
<p>반대로 클린한 UI 코드는 보다 더 <code>단순하</code>고 <code>직접적</code>이어야 합니다. UI의 <code>레이아웃</code>을 파악하기 쉬워야 하며, 코드를 통해 화면의 전체적인 <code>구조</code>를 빠르게 파악할 수 있어야 합니다. <strong>당장 눈앞에 디자인 시안이 없어도 코드만 보고 대략적인 UI 구조를 유추할 수 있을 정도로요.</strong></p>
<br/>

<h2 id="1-섹션-정의">1. 섹션 정의</h2>
<p>이제 단계별로 긴 스파게티 코드를 UI 코드를 리팩토링하는 방법을 간단한 제품 상세 페이지를 예시로 설명드리겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/2d147f14-fbc5-4057-a461-516ecdaddb19/image.png" alt=""></p>
<p>먼저 페이지의 각 <code>섹션</code>을 명확하게 정의해야 합니다. <strong>섹션은 일반적으로 레이아웃이나 데이터의 내용으로 구분됩니다</strong>. 아래는 제가 정의한 섹션 목록입니다.</p>
<ul>
<li>header</li>
<li>leading info </li>
<li>image list </li>
<li>size info </li>
<li>description</li>
<li>review</li>
<li>price info</li>
<li>bottom fixed button (add to cart button)</li>
</ul>
<p>이러한 각 섹션을 정의함으로써 코드 리팩토링 시에 구조를 명확히 파악할 수 있으며, 각 섹션을 독립적으로 다룰 수 있게 됩니다.</p>
<br/>

<h2 id="2-scaffold-모듈-생성">2. Scaffold 모듈 생성</h2>
<p>섹션을 구분해 주셨으면 이제 커스텀 <code>Scaffold 모듈</code>을 만들어 줄 차례입니다. 이 모듈은 제품 상세 페이지의 <code>레이아웃 구조</code>를 정의하는 Stateless 클래스입니다. 앞서 정의한 <code>섹션</code>들을 위젯 프로퍼티로 받아 배치하는 역할을 합니다.</p>
<pre><code class="language-dart">class ProductDetailScaffold extends StatelessWidget {  
  const ProductDetailScaffold({  
    Key? key,  
    required this.header,  
    required this.leadingInfoView,  
    required this.imgListView,  
    required this.sizeInfoView,  
    required this.descriptionView,  
    required this.reviewListView,  
    required this.priceInfoView,  
    required this.bottomFixedButton,  
  }) : super(key: key);  

  final Widget header;  
  final Widget leadingInfoView;  
  final Widget imgListView;  
  final Widget sizeInfoView;  
  final Widget descriptionView;  
  final Widget reviewListView;  
  final Widget priceInfoView;  
  final Widget bottomFixedButton;  

  @override  
  Widget build(BuildContext context) {  
    return Scaffold(  
      backgroundColor: Colors.white,  
      body: Stack(  
        children: [  
          SingleChildScrollView(  
            child: Column(  
              children: &lt;Widget&gt;[  
                header,  
                const SizedBox(height: 15),  
                Padding(  
                  padding: const EdgeInsets.symmetric(horizontal: 20),  
                  child: Column(  
                    crossAxisAlignment: CrossAxisAlignment.start,  
                    children: &lt;Widget&gt;[  
                      leadingInfoView,  
                      const SizedBox(height: 20),  
                      imgListView,  
                      const SizedBox(height: 15),  
                      sizeInfoView,  
                      const SizedBox(height: 20),  
                      descriptionView,  
                      const SizedBox(height: 15),  
                      reviewListView,  
                      const SizedBox(height: 20),  
                      priceInfoView,  
                      SizedBox(  
                        height: MediaQuery.of(context).padding.bottom + 96,  
                      )  
                    ],  
                  ),  
                ),  
              ],  
            ),  
          ),  

          /// BOTTOM FIXED BUTTON  
          Positioned(  
            bottom: 0,  
            child: bottomFixedButton,  
          )  
        ],  
      ),  
    );  
  }  
}</code></pre>
<p>여기서 주의해야 할 점은, Scaffold 모듈은 단순히 <code>레이아웃</code>과 프로퍼티로 받는 위젯들의 <code>배치</code>에만 집중해야 한다는 것입니다. <strong>즉, 상태를 가지거나 외부 데이터에 의존하는 위젯이 존재해서는 안 됩니다.</strong></p>
<p>Scaffold 모듈을 만들 때 조그마한 팁을 드리자면, 처음부터 Scaffold 모듈로 만들 필요는 없습니다. 모든 페이지의 UI를 구현한 다음에 별도의 Scaffold 소스 파일을 만들어서 기존 페이지의 코드를 복사하고 수정하면 됩니다.</p>
<br/>

<h2 id="3-위젯-모듈화">3. 위젯 모듈화</h2>
<p>Scaffold를 적용하기 전에 섹션별로 구분한 위젯들을 각각 별도의 <code>Stateless 위젯</code>으로 추출해주어야 합니다.</p>
<pre><code class="language-dart">/// Statless 위젯으로 추출 (O)
class Header extends StatelessWidget {  
  const Header({Key? key}) : super(key: key);  

  @override  
  Widget build(BuildContext context) {  
    return Container(  
      height: 386,  
      decoration: BoxDecoration(  
        image: DecorationImage(  
            image: Image.asset(Assets.imagesProductImg0).image,  
            fit: BoxFit.fitHeight),  
      ),  
    );  
  }  
}

/// 메소드로 추출 (X)
_buildHeader() {
    return Container(  
      height: 386,  
      decoration: BoxDecoration(  
        image: DecorationImage(  
            image: Image.asset(Assets.imagesProductImg0).image,  
            fit: BoxFit.fitHeight),  
      ),  
    ); 
}</code></pre>
<p>이때 섹션을 추출할 때 <code>메소드</code>가 아닌 <code>StatelessWidget</code>으로 추출하는 이유는 <code>BuildContext를 분리</code>해 줄 수 있기 때문입니다. BuildContext를 분리해 주지 않으면 위젯 트리 상에서 다른 위젯과 공유되는 context를 사용하게 되고, 이는 다른 위젯에서 상태 변화로 인해 새롭게 빌드 될 때 상태와 관련이 없는 위젯들도 <code>함께 빌드되는 문제</code>가 발생할 수 있습니다. 위젯의 <code>리빌드를 최소화</code>하기 위해 StatelessWidget을 사용하는 것이 좋습니다.</p>
<p>그리고 <strong>분리한 위젯의 클래스명을 짓는 데 있어서 다른 페이지의 위젯과 중복된 이름을 사용하지 않도록 유의해야 합니다</strong>. 예를 들어 Header와 같은 영역은 다른 페이지에서도 충분히 섹션으로 정의될 수 있기 때문에 클래스 위젯명을 &#39;Header&#39;라고 짓는 것은 위험합니다. 고유한 이름을 짓기 위해 위젯 이름 앞에 페이지 이름을 붙여줄 수 있습니다.</p>
<ul>
<li>ProductDetailHeader</li>
<li>ProductDescriptionView</li>
<li>ProductPriceInfoView</li>
</ul>
<p>하지만 이런 방식으로 네이밍을 해버리면 클래스 이름이 너무 길어진다는 단점이 있습니다. 좀 더 괜찮은 방법은 <code>private 접근 제한자</code>를 이용하는 것입니다.</p>
<pre><code class="language-dart">part of &#39;../product_detail_screen.dart&#39;;

class _Header extends StatelessWidget {  
  const _Header({Key? key}) : super(key: key);  

  @override  
  Widget build(BuildContext context) {  
    return Container(  
      height: 386,  
      decoration: BoxDecoration(  
        color: const Color(0xFFF2F2F2),  
        image: DecorationImage(  
            image: Image.asset(Assets.imagesProductImg0).image,  
            fit: BoxFit.fitHeight),  
      ),  
    );  
  }  
}</code></pre>
<p>접근 제한자를 사용하여 클래스 이름을 지정해 주면 다른 페이지에서 해당 헤더 모듈을 불러오지 못하기 때문에 더 안정적으로 위젯을 모듈화할 수 있게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/cf291dfa-f5a6-4c78-b4e4-181a7a5f2663/image.png" alt=""></p>
<p>만약 접근제한로 설정한 위젯을 별도의 소스 파일에서 관리하고 싶다면 <code>part</code>, <code>part of</code> 디렉티브를 사용하여 page위젯과 분리한 섹션 위젯들을 연결해 주면 됩니다. </p>
<br/>

<h2 id="4-scaffold-모듈-적용">4. Scaffold 모듈 적용</h2>
<p>이제 모든 준비가 끝났습니다. 사전에 작성한 Scaffold 모듈과 분리한 위젯들을 기존 페이지에 선언해 주면 됩니다. </p>
<pre><code class="language-dart">part &#39;local_widgets/bottom_fixed_button.dart&#39;;  
part &#39;local_widgets/description_view.dart&#39;;  
part &#39;local_widgets/img_list_view.dart&#39;;  
part &#39;local_widgets/leading_info_view.dart&#39;;  
part &#39;local_widgets/price_info_view.dart&#39;;  
part &#39;local_widgets/product_detail_header.dart&#39;;  
part &#39;local_widgets/product_detail_layout.dart&#39;;  
part &#39;local_widgets/review_list_view.dart&#39;;  
part &#39;local_widgets/size_info_view.dart&#39;;  

class ProductDetailPage extends StatelessWidget {  
  const ProductDetailPage({Key? key}) : super(key: key);  

  @override  
  Widget build(BuildContext context) {  
    return const _Scaffold(  
      header: _Header(),  
      leadingInfoView: _LeadingInfoView(),  
      imgListView: _ImgListView(),  
      sizeInfoView: _SizeInfoView(),  
      descriptionView: _DescriptionInfoView(),  
      reviewListView: _ReviewListView(),  
      priceInfoView: _PriceInfoView(),  
      bottomFixedButton: _BottomFixedButton(),  
    );  
  }  
}</code></pre>
<p>코드가 이전보다 훨씬 더 깔끔해졌네요.</p>
<br/>

<h2 id="클린-ui-코드의-이점">클린 UI 코드의 이점</h2>
<p>이렇게 리팩토링 된 UI 코드는 크게 두 가지 이점을 가집니다.</p>
<h3 id="유지보수에-용이">유지보수에 용이</h3>
<p>일단 <strong>변경에 쉽게 대응할 수 있는 코드가 됩니다</strong>. 예를 들어 &#39;상품 설명 텍스트의 폰트 사이즈를 변경해 주세요&#39;라는 요청이 들어왔다고 가정해 봅시다. 길게 나열된 스파게티 코드라면 상품 설명 텍스트를 가지고 있는 위젯을 찾는 데 시간이 좀 필요하겠죠. 만약 코드를 작성하지 않은 작업자가 해당 위젯을 찾아야 한다면 시간을 분명 배로 걸립니다. 하지만 앞서 소개한 방식으로 UI를 구조화하여 리팩토링했다면 수정이 필요한 위젯을 간단하게 찾을 수 있게 됩니다.</p>
<h3 id="협업에-유리">협업에 유리</h3>
<p>이렇게 구조화된 코드는 여러 개발자와 협업을 진행할 때 빛을 발휘합니다. 두 명의 개발자들이 한 페이지의 UI를 함께 구현한다고 가정해 봅시다. 코드를 구조화하지 않고 하나의 소스 파일에서 작업을 하다 보면 나중에 코드를 병합하는 과정에서 conflict가 발생할 수 있습니다.</p>
<pre><code class="language-dart">@override  
Widget build(BuildContext context) {  
  return const _Scaffold(  
    appBar: _AppBar(),
    contentTabView: _ContentTabView(), // &lt;-- 팀원1 작업공간
    reviewTabView: _ReviewTabView(), // &lt;-- 팀원2 작업공간
  );  
}</code></pre>
<p>하지만 위와 같이 사전에 UI 코드를 구조화하여 작업 공간을 분리한다면, 불필요한 <code>conflict</code>를 사전에 방지할 수 있게 됩니다.  </p>
<br/>


<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 글에서는 UI 코드를 <code>구조화</code>하여 <code>가독성</code>을 높이고 <code>유지보수</code>와 <code>협업</code>이 용이한 형태로 구성하는 방법에 대해 알아보았습니다. 소개해 드린 방법이 조금은 귀찮은 작업일 수 있지만 앱의 규모가 크고 다루는 페이지 수가 많을수록 큰 이점을 발휘하니 고려해 보시면 좋을 것 같네요.</p>
<p>글에서 다룬 예제 코드가 궁금하시다면 제 <a href="https://github.com/Xim-ya/clean_ui_code_implementation">깃허브 레포지토리</a>에서 확인하실 수 있습니다.</p>
<p>읽어주셔서 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[네트워크 이미지 렌더링을 최적화하여 메모리 사용량 절감하기]]></title>
            <link>https://velog.io/@ximya_hf/optimizing-network-image-rendering-in-flutter</link>
            <guid>https://velog.io/@ximya_hf/optimizing-network-image-rendering-in-flutter</guid>
            <pubDate>Sat, 11 Nov 2023 18:11:35 GMT</pubDate>
            <description><![CDATA[<p>Flutter에서 네트워크 이미지를 호출하여 화면에 보여줄 때 어떤 부분을 신경 써야 할까요?</p>
<p>더 나은 사용자 경험(UX)을 제공하기 위해 이미지 위젯을 사용할 때, Fade-In 애니메이션을 적용하거나 네트워크에서 이미지를 로딩하기 전 로딩 인디케이터를 표시하는 것을 고려할 수 있습니다.</p>
<p>UX를 고려한 이러한 로직은 중요하지만, 네트워크 이미지를 <code>렌더링</code>할 때 <code>메모리 사용량을 절감</code>하는 부분도 매우 중요합니다. <strong>이미지의 사이즈가 클수록 렌더링 과정에서 많은 메모리를 필요하기 때문입니다.</strong></p>
<p>제 개인 프로젝트 사례를 소개하자면, 앱을 사용하면 화면이 버벅대거나 비정상적으로 종료되는 문제가 발생했습니다. 앞서 언급한 대로, 원인은 고해상도 네트워크 이미지를 화면에 표시할 때 메모리가 과도하게 사용되었기 때문이었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/992ace17-07d8-423a-b974-a4caa5da4ec6/image.jpg" alt=""></p>
<p>저와 같은 실수를 하지 않기 위해서는 이미지를 화면에 로드할 때 렌더링 작업을 최적화하는 방법 알고 계셔야 합니다. 이번 글에서는 네트워크 이미지를 효과적으로 렌더링하여 메모리를 절감하는 방법을 소개드리고 있으니 관련 팁을 얻어가세요!</p>
<blockquote>
<p>글 후반부에서 관련 예제코드도 제공하니 참고하시길 바랍니다.</p>
</blockquote>
<br/>


<h2 id="oversized-이미지-진단하기">Oversized 이미지 진단하기</h2>
<p>먼저, 네트워크 이미지를 렌더링할 때 메모리 사용량이 과도한지 <code>진단</code>하는 것이 중요합니다. 간단한 예제를 통해 이를 확인해 보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9043acf9-464d-40a0-9131-b39f7526ed50/image.png" alt=""></p>
<pre><code class="language-dart"> Image.network(  
    imageUrl,
    width: 250, 
 ),  </code></pre>
<p>위에 이미지 위젯은 효율적으로 렌더링 되었을까요? 간단하게 알아볼 수 있는 방법이 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/052360f3-d6dd-4471-8349-3348ade94528/image.png" alt=""></p>
<p>Flutter Inspector에서 <code>Highlight oversized images</code> 버튼을 활성화해 주시면 됩니다.</p>
<blockquote>
<p><strong>NOTE</strong>
Flutter에서 제공하는 플래그를 사용할 수도 있습니다.
<code>debugInvertOversizedImages = true;</code>
이 코드를 앱의 시작점이나 이미지 위젯을 포함한 클래스에 추가하세요.</p>
</blockquote>
<p align="center"><img
width = "375"                       src="https://velog.velcdn.com/images/ximya_hf/post/d8270a12-0192-47b7-84b4-601cd46944dc/image.png"/></p>



<p>그러면 화면에서 이미지의 <code>색상이 반전</code>되고 <code>수직으로 뒤집힌</code> 것을 확인할 수 있을 겁니다. 이는 이미지를 <code>디코딩</code>하는 과정에서 필요한 것보다 더 많은 메모리를 사용했다는 것을 나타냅니다.</p>
<h3 id="display-size--decode-size">Display Size &amp; Decode Size</h3>
<p>에러 로그를 확인하면 더 구체적인 정보를 얻을 수 있습니다.</p>
<pre><code>Image [...] has a display size of 750×421 but a decode size of 3840×2160, which uses an additional 41552KB (assuming a device pixel ratio of 3.0).</code></pre><p>화면의 이미지는 750x421 사이즈를 가지고 있지만 실제로 디코드한 사이즈는 3840×2160이기 때문에 추가로 <strong>41552KB 메모리</strong>를 사용했다고 합니다.</p>
<p>디스플레이 사이즈(Display Size)는 이미지가 디코딩된 크기를 나타냅니다. 즉 실제 화면에 보여질 때 실제로 필요한  디스플레이 크기는 750×421 이기 때문에 이미지의 원래 크기인 3840×2160(Decod Size)를 모두 디코딩하는 것이 <strong>불필요</strong>하다는 것이죠.</p>
<p>조금 더 쉽게 이해하기 위해서 비유를 하나 해보죠.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/6de794f1-676a-42d7-9093-a6fe81d7a327/image.png" alt=""></p>
<p>여러분이 화가에게 친구와 함께 찍은 사진을 주고 그림을 그려달라는 상황을 가정해 보겠습니다. 화가에게 사진을 제공할 때, 그림을 그리는 데 필요한 크기보다 훨씬 큰 <strong>전광판 사이즈의 사진</strong>을 전달할 필요가 없습니다. 실제로 저런 큰 사진은 오히려 화가의 작업을 방해합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/330069f3-39f3-4f78-a0c2-61ea979b63d0/image.png" alt=""></p>
<p>화가가 정확하고 빠르게 그림을 그리려면 적절한 크기의 사진만 있으면 충분합니다. 이 개념은 네트워크 이미지를 Flutter에서 불러올 때도 적용됩니다. <code>Flutter 엔진</code>은 이미지를 디코딩할 때, 이미지의 크기가 화면에 표시할 크기(디스플레이 크기)보다 훨씬 크면 디코딩 과정에서 메모리를 낭비하게 되는 것처럼요.</p>
<blockquote>
<p>화가 : Flutter 엔진
화가에게 전달한 사진 : 디스플레이 사이즈
그림을 그리는 화가의 행위 : 디코딩 
화가가 그린 그림 :  이미지 위젯 </p>
</blockquote>
<br/>

<h2 id="이미지-resize하기">이미지 Resize하기</h2>
<p>그럼, 이미지의 디코할 이미지의 사이즈를 어떻게 조절해야 할까요? 이어지는 에러 로그에서 이미지 크기를 조절하는 방법에 대한 안내가 있습니다.</p>
<pre><code>Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 750, a cacheHeight parameter of 421, or using a ResizeImage</code></pre><p>이미지를 미리 <code>리사이징(Resizing)</code>하는 방법은 Image.network의 <code>cacheWidth</code> 및 <code>cacheHeight</code> 속성을 사용하는 것입니다. 이러한 속성을 사용하면 이미지를 디코딩하기 전에 원하는 크기로 조절할 수 있습니다. <strong>이미지의 실제 디스플레이 크기와 상관없이 이러한 속성에 지정된 크기로 이미지가 디코딩됩니다</strong>. <code>Image.network</code>에서는 HTTP 헤더와 상관없이 모든 네트워크 이미지가 <code>캐싱</code> 되기 때문에 이러한 속성을 설정하는 것이 중요합니다. </p>
<blockquote>
<p><strong>NOTE</strong>
이미지를 화면에 표시할 때 실제로 표시되는 크기 &#39;width&#39; 및 &#39;height&#39; 속성에 의해 결정되지만, 렌더링 된 이미지의 크기는 &#39;cacheWidth&#39; 및 &#39;cacheHeight&#39; 에 의해 결정됩니다.</p>
</blockquote>
<p>이제 로그를 참고하여 코드를 수정해 보겠습니다.</p>
<pre><code class="language-dart">Image.network(  
  imageUrl,  
  width: 250,  
  cacheWidth: 750,  
),  
const Divider(),  
Image.network(  
  imageUrl,  
  width: 250,  
),</code></pre>
<p><strong>비교를 위해 cacheWidth 속성을 설정 안 한 위젯도 추가하였습니다. (한 쪽 cache 속성만 설정해 주면, 다른 쪽도 이미지의 비율을 유지한 채로 리사이즈 됩니다)</strong></p>
<p align="center"><img
width = "375"                       src="https://velog.velcdn.com/images/ximya_hf/post/7af9a26a-e7b3-4190-8a3f-6f9604b8df64/image.png"/></p>



<p><code>cacheWidth</code>를 설정한 이미지는 overSize 에러 없이 정상적으로 표시되지만, 반대의 경우 이미지의 색상과 방향이 반전되고 수직으로 뒤집혔습니다. <code>cacheWidth</code>를 올바르게 설정함으로써 이미지를 <code>리사이징</code>하여 디코딩 프로세스에서 필요한 메모리 사용을 최적화했습니다.</p>
<br/>

<h2 id="디바이스별-픽셀-비율">디바이스별 픽셀 비율</h2>
<p>그럼에도 여전히 문제가 발생할 수 있습니다.</p>
<p align="center"><img
src="https://velog.velcdn.com/images/ximya_hf/post/10e92806-1aed-44c6-a8fa-e5f57e7f4993/image.png"/></p>



<p>cacheWidth을 설정한 동일한 코드에서 iPhone 12mini는 정상적으로 이미지가 출력되지만, 디바이스 크기가 작은 iPhone se에서는 여전히 이미지가  <code>oversize</code> 되었다고 표시됩니다.</p>
<p>왜 이런 문제가 발생할까요? 이번에도 에러 로그를 확인해 봅시다.</p>
<h4 id="iphone-12mini">iPhone 12mini</h4>
<pre><code class="language-bash">Image [...] has a display size of 750×421 but a decode size of 3840×2160, which uses an additional 41552KB (assuming a device pixel ratio of 3.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 750, a cacheHeight parameter of 421, or using a ResizeImage.</code></pre>
<p>iPhone 12mini의 경우 이미지의 display 넓이가 750이고 <code>디바이스 픽셀 비율</code>은 3.0라고 합니다.</p>
<h4 id="iphone-se">iPhone se</h4>
<pre><code>Image [...] has a display size of 500×281 but a decode size of 3840×2160, which uses an additional 42467KB (assuming a device pixel ratio of 2.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 500, a cacheHeight parameter of 281, or using a ResizeImage.</code></pre><p>반면에 iPhone SE의 경우 이미지의 디스플레이 크기는 500이고 <code>디바이스 픽셀 비율</code>은 2.0인 것으로 확인됩니다.</p>
<p>이 차이는 각 디바이스의 <code>픽셀 비율(Device Pixel Ratio)</code> 다르기 때문에 때문에 발생합니다.</p>
<p><code>디바이스 픽셀 비율</code>은 각 디바이스의 화면에서 표시되는 <code>픽셀의 밀도(Density)</code>를 나타내는데, 특정 디바이스에서 화면 크기당 표시되는 픽셀의 수를 화면 크기로 나눈 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/effd2696-22a3-4338-9e3f-ed46f455e2b8/image.png" alt=""></p>
<p>픽셀 밀도는 일반적으로  <code>ppi (pixcels per inch)</code>로 측정되며, 디바이스의 화면 크기에 따라 다양한 값을 가질 수 있습니다. 예를 들어, 고해상도 디바이스는 같은 크기의 화면에 더 많은 픽셀을 포함하므로 높은 픽셀 밀도를 가지게 됩니다.</p>
<p>요약하면, iPhone SE의 경우 2.0의 픽셀 비율을 가지고 있어서 <code>1인치당 2개의 픽셀</code>이 표시되지만, iPhone 12 mini의 경우 <code>1인치당 3개의 픽셀</code>이 표시됩니다. <strong>따라서 iPhone 12 mini을 기준으로 <code>cacheWidth</code>를 설정하면 iPhone SE에서 여전히 불필요하게 디코딩할 크기가 남아 있게 되는 것입니다.</strong></p>
<br/>

<h2 id="이미지의-캐시-사이즈를-동적으로-구하기">이미지의 캐시 사이즈를 동적으로 구하기</h2>
<p>이제 모든 힌트를 얻었으니 디바이스 픽셀 비율에 따라 cacheWidth 값을 구할 수 있겠죠?</p>
<pre><code>250(위젯 사이즈) X 2(ipone se 디바이스 픽셀 비율) = 500(캐시 사이즈)</code></pre><p>타겟하고 있는 위젯 사이즈가 250이고 iPhone se는 1인치에 2개의 픽셀이 표시되기 때문에 위젯 사이즈에 디바이스 픽셀 비율을 곱해주면 알맞는 디스플레이 사이즈(500)를 도출할 수 있습니다.</p>
<p>코드로 표시하면 아래와 같습니다.</p>
<pre><code class="language-dart">Image.network(  
  imageUrl,  
  width: 250,  
  cacheWidth: (250 * MediaQuery.of(context).devicePixelRatio).round(),  
),</code></pre>
<p><code>MediaQuery</code>를 사용하여 디바이스의 픽셀 비율을 구하고, 이미지 위젯의 너비와 곱하여 <code>cacheWidth</code> 값을 설정합니다. 그리고 <code>cacheWidth</code> 속성은 정수형 값을 요구하므로 <code>round</code> 메소드를 사용하여 가장 가까운 정수로 반올림했습니다. 이렇게 코드 적용하면 이미지를 디바이스 픽셀 비율에 맞게 캐시 값을 설정하여 이미지를 리사이즈 할 수 있게 됩니다.  </p>
<p>또한, 코드를 더 간결하게 구성하기 위해 이미지 크기를 계산하는 작업을 <code>extension</code>으로 구현할 수 있습니다. 아래는 extension을 구현한 코드입니다.</p>
<pre><code class="language-dart">extension ImageExtension on num {  
  int cacheSize(BuildContext context) {  
    return (this * MediaQuery.of(context).devicePixelRatio).round();  
  }  
}</code></pre>
<p>그런 다음 이미지 위젯에서 extension을 활용하여 필요한 cache값을 <code>간결</code>하게 설정할 수 있습니다.</p>
<pre><code class="language-dart">Image.network(  
  imageUrl,  
  width: 250,  
  cacheWidth: 250.cacheSize(context),  
)</code></pre>
<br/>


<h2 id="캐시-사이를-지정할-때-고려해야되는-부분">캐시 사이를 지정할 때 고려해야되는 부분</h2>
<p>만약 이미지의 원본 비율과 타겟하고 있는 위젯의 <code>비율</code>이 다르고 이미지 위젯이 <code>fit: BoxFit.cover</code> 형태라면 고려해야 하는 사항이 있습니다.</p>
<p>일반적으로 <code>fit: BoxFit.cover</code>를 사용하면 이미지가 위젯에 맞게 이미지가 잘리게 되는데, 이때 이미지의 디스플레이 사이즈를 결정할 때 비율을 고려해야 합니다. <strong>원본 이미지와 위젯의 비율이 다른 경우, 캐시 크기를 설정할 때 더 <code>작은 측면</code> (넓이 또는 높이)을 기준으로 설정해야 원본 이미지의 비율을 유지하면서 이미지를 최적화할 수 있습니다.</strong></p>
<p>만약 반대로 설정을 하게 된다면 <code>낮은 해상도</code>의 이미지가 출력될 수 있습니다.</p>
<p>예를 들어보겠습니다.</p>
<pre><code class="language-dart">Image.network(  
  imageUrl,  
  width: 250,  
  height: 250,  
  cacheWidth: 250.cacheSize(context),  
  fit: BoxFit.cover,
)</code></pre>
 <p align="center"><img
src="https://velog.velcdn.com/images/ximya_hf/post/f2669148-d373-465b-87b2-da63b5d695ce/image.png"/></p>


<ul>
<li>이미지 크기 : 3000 x 1688 </li>
<li>이미지 비율 : 1.7</li>
<li>디코딩된 이미지 디스플레이 사이즈 : 500 x 282</li>
<li>이미지 위젯의 크기 : 250 x 250</li>
<li>이미지 위젯의 비율 : 1</li>
</ul>
<p>각 넓이와 높이가 250인 이미지 위젯에 <code>cacheWidth</code>을 500(위젯 넓이 X 디바이스 픽셀 비율)으로 설정하면 자동으로 비율을 유치하면서 이미지의 <code>디스플레이 높이</code>가 결정됩니다.  근데 원본 이미지는 화면 그려져야하는 위젯의 비율과 달리, 높이보다 넓이가 큰 비율을 가지고 있기 때문에 디코딩된 이미지의 <code>디스플레이 높이</code>(281)는 타겟해야되는 <code>디스플레이 높이</code>(500)보다 낮게 설정되게 됩니다. 그래서 위에 예시 사진처럼 이미지가 흐릇하게 보일겁니다.</p>
<pre><code class="language-dart">Image.network(  
  imageUrl,  
  width: 250,  
  height: 250,  
  cacheHeight: 250.cacheSize(context),  
  fit: BoxFit.cover,
)</code></pre>
 <p align="center"><img
src="https://velog.velcdn.com/images/ximya_hf/post/4819233d-570c-41a5-8635-bf457ab9d97c/image.png"/></p>

<p>반대로 <code>cacheHeight</code>를 설정하면 이미지의 비율을 유지한 채 최소 디스플레이 사이즈로 리사이즈 되며 선명한 해상도를 유지할 수 있습니다.</p>
<pre><code>Image [...] - Resized(null×500) has a display size of 500×500 but a decode size of 889×500, which uses an additional 1013KB (assuming a device pixel ratio of 2.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 500, a cacheHeight parameter of 500, or using a ResizeImage.</code></pre><p>여전히 overSize 에러 로그는 발생합니다. 이미지 위젯이 원하는 디스플레이 사이즈의 넓이는 500이지만  디코딩된 이미지의 디스플레이 넓이는 886이기 때문입니다.  </p>
<p>그럼에도  디코딩 되어야 하는 이미지 사이즈를 기존보다 2배 이상 줄이고, 이미지 비율을 유지하면서 선명한 이미지를 보여주었기 때문에 이미지를 최적화하였다고 볼 수 있습니다.</p>
<h3 id="이미지의-비율을-고려한-동적-캐시-사이즈-지정">이미지의 비율을 고려한 동적 캐시 사이즈 지정</h3>
<p>근데 대부분 네트워크 이미지의 <code>비율</code>을 클라이언트 쪽에서 미리 알고 있는 경우는 많이 없죠.</p>
<pre><code class="language-dart">Builder(  
  builder: (context) {  
    int? cacheWidth, cacheHeight;  
    Size targetSize = const Size(250, 250);  
    const double originImgAspectRatio = 1.7;  

    // 원본 이미지 비율이 0보다 크면 높이보다 넓이 더 큰 이미지임을 의미합니다.
    if (originImgAspectRatio &gt; 0) {  
      cacheHeight = targetSize.height.cacheSize(context);  
    } else {  
      cacheWidth = targetSize.width.cacheSize(context);  
    }  

    return Image.network(  
      imageUrl,  
      width: targetSize.width,  
      height: targetSize.height,  
      cacheWidth: cacheWidth,  
      cacheHeight: cacheHeight,  
      fit: BoxFit.cover,  
    );  
  },  
),</code></pre>
<p><strong>그래서 이때는 위 코드와 같이 원본 이미지의 비율을 조건으로 넓이와 높이 중 어떤 곳을 기준으로 cache값을 설정할지 결정 해주면 됩니다.</strong> 앞서 계속 언급했듯이, 한쪽 cache 사이즈 속성만 설정 해주면 이미지가 비율대로 라사이즈 되기 때문에 다른 쪽은 null값을 전달 해줘도 설정해 줘도 무방합니다.</p>
<blockquote>
<p>원본 이미지의 비율이나 사이즈나 모르면 어떻게 해야하나요? 🤔</p>
<p>Flutter에는 원본 이미지의 사이즈나 비율을 구하는 방법이 있기 때문에 이미지의 비율 값을 도출할 수 있습니다. 하지만 이 방법을 권장하지는 않습니다. 원본 이미지의 비율이나 사이즈를 비동기적으로 리턴 받을 때 딜레이 시간이 꽤 길기 때문입니다.</p>
<p>서버로부터 데이터를 받아올 때 네트워크 이미지의 주소뿐만 아니라 이미지의 사이즈 또는 비율 정보를 같이 전달받는 것이 가장 이상적인 형태입니다. Youtube, Tmdb 등등, 여러 Open API에서 네트워크 이미지 리소스를 전달할 때 이미지 주소와 사이즈(비율) 값을 함께 제공하는 것을 확인하실 수 있습니다.</p>
</blockquote>
<br/>

<h2 id="cachenetworkimage-패키지">CacheNetworkImage 패키지</h2>
<p>Flutter에서는 이미지 캐싱을 위해 <code>Image.network</code> 위젯을 사용할 수 있지만, <code>cached_network_image</code> 패키지의 사용을 권장합니다. 이 패키지는 캐싱과 관련된 세밀한 기능을 제공하여 성능을 향상시킬 수 있습니다. 다음은 <code>cached_network_image</code> 패키지를 활용한 코드 예제입니다</p>
<pre><code class="language-dart">CachedNetworkImage(  
  imageUrl: imageUrl,  
  memCacheHeight: 320.cacheSize(context),  
  memCacheWidth: 250.cacheSize(context),  
),</code></pre>
<p><code>CachedNetworkImage</code> 위젯을 사용하면 <code>Image.network</code> 위젯과 동일하게 캐시 사이즈를 지정할 수 있는 <code>memCacheHeight</code>와 <code>memCacheWidth</code> 속성을 활용할 수 있습니다.</p>
<br/>

<h2 id="마무리하면서">마무리하면서</h2>
<p>이번 글에서는 네트워크 이미지를 효율적으로 불러오고, 메모리 사용량을 최적화하기 위한 방법을 살펴보았습니다.</p>
<p>평상시 작업하면서 쉽게 놓칠 수 있는 부분이지만 꼭 필요한 작업이라고 생각합니다.
<strong>특히 고화질의 이미지가 화면에 여러 개 그려지는 어플리케이션일수록 네트워크 이미지 렌더링 최적화 작업이 꼭 필요합니다.</strong></p>
<p>이미지 렌더링을 최적화하는 작업뿐만 아니라 이미지의 UX를 고려하기 위한 팁들을 얻어가시려면 해당 포스팅 (<a href="https://medium.com/itnext/12-image-tips-and-best-practices-for-the-best-ux-performance-in-flutter-e7a1b2b1da2a">12 Image Tips and Best Practices for the Best UX Performance in Flutter</a>)을 참고 해보세요. 잘 정리되어 있습니다.</p>
<p>또한 본글에서 다룬 예제 코드가 궁금하시다면 제 <a href="https://github.com/Xim-ya/optimizing_network_image_rendering_implementation">깃허브 레포지토리</a>에서 확인하실 수 있습니다.</p>
<p>읽어주셔서 감사합니다!</p>
<h3 id="레퍼펀스">레퍼펀스</h3>
<ul>
<li><a href="https://www.themoviedb.org/tv/1396-breaking-bad/images/backdrops?language=ko">https://www.themoviedb.org/tv/1396-breaking-bad/images/backdrops?language=ko</a></li>
<li><a href="https://api.flutter.dev/flutter/painting/debugInvertOversizedImages.html">https://api.flutter.dev/flutter/painting/debugInvertOversizedImages.html</a></li>
<li><a href="https://github.com/flutter/flutter/issues/56239">https://github.com/flutter/flutter/issues/56239</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter 정규 표현식을 이용한 욕설·비속어를 필터링 로직]]></title>
            <link>https://velog.io/@ximya_hf/korean-profanity-filter-based-on-regex</link>
            <guid>https://velog.io/@ximya_hf/korean-profanity-filter-based-on-regex</guid>
            <pubDate>Sat, 28 Oct 2023 06:14:51 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3190dca4-44b4-4f02-9dc3-9493cab09daf/image.png" alt=""></p>
<p><em>&quot;아니 X발 무슨 다 경력직만 뽑으면 나 같은 신입은 어디서 경력은 쌓나?&quot;</em></p>
<p>오래 전부터 유행했던 밈 중 하나죠.
코미디언 유병재씨는 &#39;SNL 코리아&#39;에서 경력직을 선호하는 기업이 증가함에 따라 신입들의 채용 기회가 줄어드는 현상을 비꼬며 코믹한 방식으로 이야기했습니다. 
예전에는 이 밈이 마냥 웃기기만 했는데, 신입으로 구직 시장에 뛰어든 요즘에는 웃기면서도 슬프네요,,</p>
<p>암튼 각설하고, 실제 대면 상황에서는 그런 직설적인 욕설을 하는 사람들은 드물겠지만, 익명의 온라인 공간에서는 욕설을 쓰는 경우가 상당히 흔합니다. 그런 상황을 대비하기 위해 앱에서 욕설을 필터링하는 로직이 필요한 경우가 있겠죠.</p>
<p>그래서 이번 포스팅에서는 <code>정규 표현식</code>을 이용하여 욕설·비속어를 필터링하는 로직에 관하여 다루어 보려고 합니다. </p>
<blockquote>
<p>글 후반부에 <code>정규 표현식</code>을 이용한 <code>닉네임 유효성 검사</code> 예제와 관련 코드 그리고 <code>욕설·비속어</code>를 효과적으로 <code>식별</code>할 수 있는  <a href="https://pub.dev/packages/korean_profanity_filter">Flutter 패키지</a>를 소개해 드리고 있으니 참고하실 수 있기를 바랍니다.</p>
</blockquote>
<p><strong>본 글에서는 욕설·비속어 필터링 로직을 예제들 다루기 위해 일부 비속어가 사용된다는 점 양해 부탁드립니다.</strong></p>
<br/>


<h3 id="왜-정규-표현식이-필요한가">왜 정규 표현식이 필요한가</h3>
<blockquote>
<p><em>&quot;아니 시발 무슨 다 경력직만 뽑으면 나 같은 신입은 어디서 경력은 쌓나?&quot;</em></p>
</blockquote>
<p>위 비속어가 들어간 문장을 필터링해 본다고 가정해 봅시다. 어떻게 &#39;시발&#39;이라는 비속어가 문자열에 포함되어 있는지 확인할 수 있을까요?</p>
<pre><code class="language-dart">const String comment = &#39;아니 시발 무슨 다 경력직만 뽑으면 나 같은 신입은 어디서 경력은 쌓나?&#39;;

bool containsFWord = comment.contains(&#39;시발&#39;);

print(containsFWord); // true
</code></pre>
<p>1차원적으로 접근해 보면 dart에서 제공하는 <code>continas</code>라는 String extension 메소드를 이용하여 &#39;시발&#39;라는 단어가 포함되어 있는지 확인해 볼 수 있습니다.</p>
<p>하지만 이런 경우는 어떻게 해야 할까요?</p>
<ul>
<li>아니 <code>씌빨</code> 무슨 다 경력직만 뽑으면 ...</li>
<li>아니 <code>시2발</code> 무슨 다 경럭직만 뽑으면 ...</li>
</ul>
<p>비속어가 변형된다면 고려해야 하는 <code>경우의 수</code>가 너무 많아지기 때문에 <code>contains</code> 메소드만을 이용해서는 비속어를 감지하기 힘들 겁니다.</p>
<p>이럴 때 <code>정규 표현식</code>을 사용하면 여러 경우의 비속어들을 필터링할 수 있습니다.</p>
<br/>


<h3 id="정규-표현식이란">정규 표현식이란?</h3>
<p>먼저 정규 표현식에 대한 개념을 가볍게 짚고 넘어갑시다. 사전적 정의는 아래와 같습니다.</p>
<blockquote>
<p>특정한 패턴을 가진 문자열의 집합을 표현하기 위해 쓰이는 형식 언어</p>
</blockquote>
<p>정규 표현식은 문자열에서 패턴을 검색하고 치환하는데 사용되는 하나의 <code>도구</code>라고 이해할 수 있습니다.</p>
<p><strong>즉, 정규 표현식을 이용하기 위해서는 문자열의 특정 <code>패턴</code>을 잘 정의하면 됩니다.</strong></p>
<br/>

<h3 id="regexexp를-사용하여-욕설-필터링-로직-설정하기">RegexExp를 사용하여 욕설 필터링 로직 설정하기</h3>
<pre><code>시발, 씌발, 시2발</code></pre><p>그럼, 이전에 예시로든 변형이 된 비속어들은 어떤 <code>패턴</code>이 있는지 확인해 볼까요. 아래와 같이 정의 해볼 수 있을 것 같습니다.</p>
<ul>
<li>2개의 음절로 구성됨</li>
<li>각 음절이 여러 음절로 대체되는 경우가 있음 (시-&gt; 씌, 발 -&gt; 빨)</li>
<li>음절과 음절 사이에 숫자가 들어갈 수 있음.</li>
</ul>
<p>이제 파악한 패턴들을 기반으로 Flutter에서 정규 표현식을 사용하는 데 도움을 주는 <code>RegExp</code> 클래스 사용하여 해당 규칙들을 정의해 주면 됩니다.</p>
<pre><code class="language-dart"> const String comment0 = &#39;아니 시발 무슨 다 경력직만 뽑으면 ...&#39;;
 const String comment1 = &#39;아니 시2발 무슨 다 경력직만 뽑으면 ...&#39;;
 const String comment2 = &#39;아니 씌벌 무슨 다 경력직만 뽑으면 ...&#39;;

 for (var comment in [comment0, comment1, comment2]) {
   bool containsFWord = RegExp(
       r&#39;[시씨씌슈쓔쉬쉽쒸쓉]([0-9]*)[바발벌빠빡빨뻘파팔펄]&#39;).hasMatch(comment);
         print(containsFWord); // true
 }
</code></pre>
<p>이렇게 정규 표현식을 사용하면 여러 변형된 비속어들이 들어간 문자열을 간결하게 필터링할 수 있습니다.</p>
<h3 id="정규표현식-분석하기">정규표현식 분석하기</h3>
<pre><code class="language-dart">const String comment0 = &#39;아니 시발 무슨 다 경력직만 뽑으면 ...&#39;;

RegExp(r&#39;[시씨씌슈쓔쉬쉽쒸쓉]([0-9]*)[바발벌빠빡빨뻘파팔펄]&#39;).hasMatch(comment);</code></pre>
<p>정규 표현식에서 사용되는 기호를<code>Meta문자</code>라고 말하는데요. 표현식에서 내부적으로 특정 의미를 가지는 문자를 말하는데 위에 정규식 어떤 의미를 가지고 있는 확인 해보시죠.</p>
<p><strong>1. r</strong> 
원시 문자열(raw string)의 <code>시작</code>을 나타내는 표시입니다.</p>
<p>  <strong>2. [시씨씌슈쓔쉬쉽쒸쓉] &amp; [바발벌빠빡빨뻘파팔펄]</strong>
이 부분은 문자 집합(character set)을 나타냅니다. <code>[...]</code>는 문자 <code>집합</code>을 정의하며, 내부에 있는 문자 중 하나와 일치하는 것을 찾습니다. 즉 &#39;시&#39;, &#39;씨&#39;, &#39;씌&#39;, &#39;슈&#39;, &#39;쓔&#39;, &#39;쉬&#39;, &#39;쉽&#39;, &#39;쒸&#39;, &#39;쓉&#39; 중 어떤 문자와 일치하도록 패턴을 정의한다고 볼 수 있습니다.</p>
<p><strong>3. ([0-9]*)</strong>
이 부분은 괄호로 둘러싸인 그룹(group)을 나타냅니다. ([0-9]<em>)는 0에서 9 사이의 숫자로 이루어진 부분 문자열을 찾으며, 이 부분은 그룹화됩니다. 
&#39; \</em> &#39;는 <code>0회 이상의 반복을 의미하므로</code>, 숫자가 없을 수도 있고 여러 개일 수도 있음을 의미합니다.. </p>
<pre><code>시발
시2발 
시12345발 </code></pre><p>즉 음절과 음절 사이에 숫자가 여러개 있거나 없어도 정규식으로 잡아낼 수 있게 됩니다. </p>
<p><strong>4. hasMatch</strong>
주어진 입력 문자열에서 정규 표현식 <code>패턴과 일치</code>하는 부분이 있는지 확인하는 메서드입니다. 이 메서드는 true 또는 false를 반환합니다.</p>
<br/>

<h3 id="어떤-기능에-정규-표현식이-접목될-수-있는가">어떤 기능에 정규 표현식이 접목될 수 있는가</h3>
<p>정규 표현식은 여러 기능에서 사용될 수 있는데요. <code>닉네임이 유효성 검사 로직</code>을 예로 들어보겠습니다</p>
<p>일반적으로 닉네임을 설정할 때 권장되는 규칙이 있습니다.</p>
<ul>
<li>닉네임 공백 금지</li>
<li>한글, 알파벳, 숫자 언더스코어(_), 하이픈(-)만 사용 가능</li>
<li>비속어, 욕설 단어 사용 금지</li>
</ul>
<p>그리고 이런 규칙들이 준수되었는지 닉네임 문자열을 식별하려고 할 때 <code>정규식 표현</code>이 적극 사용됩니다.</p>
<pre><code class="language-dart">import &#39;package:korean_profanity_filter/korean_profanity_filter.dart&#39;;

abstract class Regex {
     Regex._();

  // 공백 존재 여부
  static bool hasSpaceOnString(String value) {
    return RegExp(r&#39;\s&#39;).hasMatch(value);
  }

  /// 적합한 문자 사용 여부
  /// 한글, 알파벳, 숫자, 언더스코어(_), 하이픈(-)만 사용할 수 있음
  static bool hasProperCharacter(String value) {
    return !RegExp(r&#39;^[a-zA-Z0-9ㄱ-ㅎ가-힣_-]+$&#39;).hasMatch(value.trim());
  }

  /// 비속어, 욕설 단어 포함 여부
  static bool hasFWords(String value) {
    return value.containsBadWords;
  }
}</code></pre>
<p>관련 유효성을 검증할 수 있는 코드입니다. 정규식 없이도 관련 기능을 만들 수 있습니다. 다만 정규식을 사용하지 않았다면 위에 예제 코드보다 몇 배는 많은 유효성 검사 코드가 필요했을 겁니다.</p>
<p align="center"><img  src="https://velog.velcdn.com/images/ximya_hf/post/4fedbb3c-595f-4bd5-a363-05a4ba18d84c/image.png"/></p>

<p>참고로 위 닉네임 유효성 검사 로직들은 <a href ="https://korean-profanity-filter-example.netlify.app/"> 닉네임 유효성 검사 예제 사이트 </a>에 접속하셔서 테스트 해보실 수 있고, 관련 코드는 해당 <a href="https://github.com/Xim-ya/simple_nickname_validation_implementation">깃허브 레포지토리</a> 확인하실 수 있습니다.</p>
<p><img src="https://github.com/MakeFrog/TechTalk/assets/75591730/f53a1441-717e-40b9-9fae-8fda1629a780"/></p></p>
<p>또한 정규식을 기반으로 욕설·비속어를 식별하고 필터링할 수 있도록 도와주는 <a href="https://pub.dev/packages/korean_profanity_filter">Korean Profanity Filter</a> 패키지를 배포했습니다. 욕설·비속어 필터링 기능이 필요하신 분들에게 도움이 되었으면 좋겠네요 :)</p>
<br/>

<h3 id="마무리하면서">마무리하면서</h3>
<p>요약하자면, 정규 표현식은 <code>특정 형태를 보이는 문자열</code>을 효과적으로 <code>식별</code>하기 위해서 사용된다고 볼 수 있습니다. 앞서 언급드린 것처럼 정규 표현식을 사용하지 않고도 패턴을 가지고 있는 문자열을 식별할 수 있지만, 정규식이 제공하는 간편한 <code>편리성</code> 때문에 많은 기능들에서 정규식이 사용되고 있는 것이죠.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/8d07231e-8723-4fbc-892f-2aa6aaaabaa7/image.jpg" alt=""></p>
<p>그리고...
요즘 정말 개발자 채용 <code>혹한기</code> 시즌이 온 것 같습니다. 특히 신입으로서 취업의 장벽이 더 높아진 것 같다는 생각이 듭니다.</p>
<p>상황이 쉽지 않지만, 그래도 잘 헤쳐 나가야겠죠.
모든 취준생분들 화이팅입니다🔥 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ViewModel 리소스 다이어트 시키기]]></title>
            <link>https://velog.io/@ximya_hf/split-view-model-with-part-and-extensino</link>
            <guid>https://velog.io/@ximya_hf/split-view-model-with-part-and-extensino</guid>
            <pubDate>Tue, 15 Aug 2023 15:12:41 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.<br>다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p>Plotz 앱은 기본적으로 <code>OOP(객체지향)</code>를 기반한 <code>MVVM(Model-View-ViewModel)</code> 아키텍쳐를 준수하고 있습니다. MVVM은 많은 장점이 있지만, 몇 가지 단점도 동반합니다. 그중에서도 프로젝트의 규모가 커지고 한 화면에서 다루어야 할 비즈니스 로직들이나 데이터들이 많을수록 <code>ViewModel 무거워지고 복잡해지는 것</code>이 가장 큰 <code>단점</code>이라고 할 수 있습니다.</p>
<p><code>객체치향 프로그래밍</code>이란 처음에 이루고자 하는 목표에서부터, 덩어리들을 차근차근 분리
하고 <code>깍아내는 과정</code>이라고 합니다. 이러한 관점으로 복잡해진 ViewModel을 <strong><code>인간이 쉽게 이해할 수 있을 정도로 쪼개는 작업</code></strong>이 필요합니다. <strong>케이크</strong>를 먹을 때도 먹기 좋게 슬라이스하는 것처럼 말이죠.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/b2b2d334-4c8a-4e42-9b6b-e3be8ff4c03c/image.png" alt=""></p>
<p>ViewModel를 쪼개는(구조화) 방법은 여러 가지가 있습니다.</p>
<ul>
<li>여러 개의 ViewModel로 분리하여 관리</li>
<li>Use Case 및 Repository 도입 (비즈니스 데이터 액세스 분리)</li>
<li><strong><code>extension, part 키워드를 활용하여 ViewModel 리소스를 분리</code></strong></li>
<li>기타 등등</li>
</ul>
<p>상황과 목적에 따라 위에 방법들을 적절히 사용하는 게 중요하겠지만, 본 포스팅에서는 <code>extension, part 키워드를 이용하여 ViewModel 리소스를 분리하여 관리</code> 하는 방법과 그 이점에 대해 다루어보려고 합니다. </p>
<blockquote>
<p>Plotz 앱의 &#39;콘텐츠 상세 스크린&#39;을 예시로 합니다.</p>
</blockquote>
<br/>


<h2 id="1-섹션정의">1. 섹션정의</h2>
<p>먼저 섹션을 구분해야됩니다. </p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/cc19c1ed-9938-415e-9790-8b4a37e07542/image.png" alt=""></p>
<p>콘텐츠의 여러 정보를 보여주는 &#39;콘텐츠 상세 스크린&#39;을 크게 3가지의 섹션으로 정의했습니다.</p>
<ul>
<li>Header</li>
<li>Tab1</li>
<li>Tab2</li>
</ul>
<blockquote>
<p>NOTE 
각 섹션은 보통 하나의 주제나 레이아웃으로 구분됩니다.</p>
</blockquote>
<br/>

<h2 id="2-getter로-데이터-접근-구조화하기">2. Getter로 데이터 접근 구조화하기</h2>
<p>섹션을 정의했으면 이제 섹션별로 ViewModel의 <code>데이터 접근을 구조화</code>해 볼 수 있습니다.</p>
<pre><code class="language-dart">class ContentDetailViewModel extends BaseViewModel {
    //    콘텐츠 정보(제목, 장르, 개봉년도, 출연진, 등등)
    final Content contentInfo; 

    // 유튜브 채널 정보 (채녈명, 구독자 수, 등등)
    final Video videoInfo; 

    //유튜브 영상 정보 (조회수, 좋아요 수, 업로드일)
    final Channel channleInfo;

   ...
}</code></pre>
<p>콘텐츠 상세 페이지에는 <code>콘텐츠</code>, <code>채널</code>, <code>영상</code> 이렇게 3가지 유형의 데이터가 존재하고 ViewModel 클래스에서 각 객체를 관리하고 있습니다. </p>
<pre><code class="language-dart">final vm = ContentDetailViewModel()

// 헤더 섹션
Column(
    children: [
        Text(vm.contentInfo.title.korean), // 제목
        Text(vm.contentInfo.genres.name), // 장르
        Text(vm.videoInfo.title) // 비디오 제목
    ]
)    </code></pre>
<p>ViewModel에서 관리하고 있는 데이터의 모델이 위와 같이 <code>중첩된 형태</code>이기 때문에 UI 위젯에서 데이터를 접근할 때 <code>dot notation</code>, 즉 .(온점)을 이용하여 필요한 객체에 접근하고 있습니다. 이 방식도 문제가 있는건 아니지만, ViewModel 클래스에서 <code>getter</code> 메소드를 이용하여 데이터 접근을 구조화하면 조금 간편하고 쉽게 UI 위젯에서 데이터를 받을 수 있게 됩니다. </p>
<blockquote>
<p><strong>getter란?</strong>
<code>getter</code>는 클래스 내부의 속성을 외부에서 읽을 수 있게 해주는 메소드입니다. Getter는 일반적으로 클래스의 내부 속성을 직접 접근하는 대신, 속성 값을 반환하는 데 사용됩니다.</p>
</blockquote>
<pre><code class="language-dart">class ContentDetailViewModel extends BaseViewModel {
    /* Variables */
    final Content _contentInfo;
    final Video _videoInfo;     
    final Channel _channleInfo;

   /* Getters */
   String title =&gt; _contentInfo.title.korean;
   String genres =&gt; _contentInfo.genres.name;
   String videoTitle =&gt; _contentInfo.videoInfo.title;

   ...
}</code></pre>
<p>위 코드와 같이 ViewModel 클래스 안에서 UI 위젯에서 필요한 데이터들을 getter메소드를 통해  접근할 수 있게 명확히 구분 지어 주면 여러 가지 <code>이점</code>이 있습니다.</p>
<h4 id="1-추상화와-단순성">1. 추상화와 단순성</h4>
<pre><code class="language-dart">final vm = ContentDetailViewModel()

// getter 적용버전
Column(
    children: [
        Text(vm.title), 
        Text(vm.genres), 
        Text(vm.videoTitle)
    ]
)    

// 이전 버전
Column(
    children: [
        Text(vm.contentInfo.title.korean), // 제목
        Text(vm.contentInfo.genres.name), // 장르
        Text(vm.videoInfo.title) // 비디오 제목
    ]
)    
</code></pre>
<p>첫 번째로, getter를 이용해서 데이터 구조를 ViewModel에 <code>캡슐화함</code>으로써 UI 코드가 더 추상화되고 단순해집니다. UI 코드에서는 데이터의 구체적인 구조를 알 필요가 없으며, <code>간결하게 접근</code>할 수 있게 됩니다.</p>
<h4 id="2확장성">2.확장성</h4>
<pre><code class="language-dart">/// ex) 데이터 모델이 변경되었을 때
/// 기존 값 -&gt; _contentInfo.title.korean
String title =&gt; _contentInfo.title;

Column(
    children: [
        Text(vm.title), 
    ]
)    </code></pre>
<p>ViewModel에서 관리하는 객체의 내부 구현을 변경하거나 확장할 때, getter를 통해 접근하는 UI 코드는 그대로 유지될 가능성이 높습니다. 새로운 데이터 속성을 추가하거나 기존 속성을 변경해도 getter의 시그니처는 유지되므로, ViewModel과 View의 <code>의존성이 분리</code>되어 UI 코드 변경이 최소화되는 효과를 얻습니다.</p>
<h4 id="3-연산-추가">3. 연산 추가</h4>
<pre><code class="language-dart">// 날짜 포맷 변경 로직, 2008-01-20 --&gt; 2008
String releaseYear =&gt; Formatter.dateToYear(_contentInfo.releaseDate)</code></pre>
<p>getter는 단순히 속성값을 반환하는 것 이상의 역할을 할 수 있습니다. 계산된 값, 변환된 데이터, 다른 속성들의 조합 등을 반환하는 로직을 getter 내부에 넣을 수 있습니다. 이로써 코드의 가독성을 높이고 재사용성을 증가시킬 수 있습니다. </p>
</br>



<h2 id="3-extension-키워드를-이용하여-viewmodel-분할하기">3. extension 키워드를 이용하여 ViewModel 분할하기</h2>
<p>마지막 단계입니다. 이제 ViewModel 리소스들을 분리해 주면 됩니다. 이 단계에서 <code>part</code>와 그리고 <code>extension</code> 키워드가 사용됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/59b65499-8e11-422b-b6f4-929c34829320/image.png" alt=""></p>
<p>먼저 위에서 정의한 섹션별로 <code>part 파일</code>을 각각 만들어 줍니다.</p>
<blockquote>
<p><strong>NOTE</strong>
part파일의 경로나 파일명은 유동적으로 변경하셔도 무방합니다.</p>
</blockquote>
<p><em>content_detail_view_model.dart</em></p>
<pre><code class="language-dart">part &#39;resources/header.p.dart&#39;; // 헤더 영역
part &#39;resources/tab1.p.dart&#39;; // tab1 영역
part &#39;resources/tab2.p.dart&#39;; // tab2 영역

class ContentDetailViewModel extends BaseViewModel {
    ...
}</code></pre>
<p>그다음 ViewModel 소스파일에서 <code>part 파일</code>을 호출해 주고,</p>
<p><em>resources/header.dart</em></p>
<pre><code class="language-dart">part of &#39;../content_detail_view_model.dart&#39;;

extension HeaderResources on ContentDetailViewModel {
    ....
}</code></pre>
<p>생성한 part 파일에는 <code>part of</code> 디렉티브를 사용하여 ViewModel 소스파일에서 part 파일의 리소스들에 접근할 수 있도록 연결해 줍니다. 그리고 가장 중요한 부분인데, 해당 part 파일에 ViewModel을 확장하는 <code>extension</code>을 정의 해주면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/8b75df91-86e3-49dc-9ed1-e0386a26b193/image.png" alt=""></p>
<p>이렇게 part파일 안에 ViewModel을 확장하는 <code>extension</code>을 만들어 주는 이유는 part파일에서 <code>ViewModel 자원에 접근하기 위함</code>입니다. *<em><code>part of</code> 디렉티브를 사용하여 ViewModel에서 part파일 자원에 접근할 수 있게하고 <code>extension을</code> 만들어줘 part파일이 ViewModel 자원에 접근할 수 있는 형태인 거죠.
*</em></p>
<p><em>resources/header.dart</em></p>
<pre><code class="language-dart">part of &#39;../content_detail_view_model.dart&#39;;

extension HeaderResources on ContentDetailViewModel {
   /* Getters */
   String title =&gt; _contentInfo.title.korean;
   String genres =&gt; _contentInfo.genres.name;
   String videoTitle =&gt; _contentInfo.videoInfo.title;

   /* Intent */
   void heaerMethod1() {...}
   void heaerMethod2() {...}
   void heaerMethod3() {...}
}</code></pre>
<p>마지막으로 각 part파일에 각 섹션에 해당하는 <code>이벤트 메소드</code>와 <code>getter 구문</code>을 기존 ViewModel 클래스로부터 옮겨주어 리소스를 분리합니다.</p>
<blockquote>
<p><strong>NOTE</strong>
extension 내부에서 <code>변수</code>를 선언하는 것은 허용되지 않은 점에 유의해주세요. extension은 기존 클래스에 새로운 기능을 추가하는 메커니즘으로, 클래스의 인스턴스 변수나 속성을 직접 추가할 수 없습니다. Extension은 클래스의 인스턴스 메서드나 getter, setter, 일부 특정 메타데이터만을 추가할 수 있습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/065afbc4-475d-47ec-bc42-16f221cd9739/image.png" alt=""></p>
<p>모든 섹션에서 공통으로 사용되는 리소스는 기존 ViewModel 클래스에서 관리하고, 각 섹션에서 독립적으로 사용되는 리소스들을 위와 같이 part 파일로 분리하여 관리하는 게 바람직합니다.</p>
<br/>

<h2 id="이점">이점</h2>
<p>이렇게 ViewModel 리소스를 분리하여 관리하면 어떤 <code>이점</code>이 있을까요?</p>
<h4 id="1-가독성-향상">1. 가독성 향상</h4>
<p>ViewModel 크고 복잡해질수록 가독성이 중요해집니다. ViewModel 리소스를 분리하여 관리하면 정의한 섹션의 코드가 하나의 파일에 모여 있어서 <code>코드의 목적</code>을 파악하기 쉬워집니다. </p>
<h4 id="2-유지-보수-용이성">2. 유지 보수 용이성</h4>
<p>ViewModel이나 다른 로직이 part 파일로 분리되면, 해당 부분을 수정하거나 확장할 때 다른 부분에 영향을 덜 줍니다. 변경이 <code>필요한 부분만 수정</code>하여 기능을 개선하거나 버그를 수정할 수 있습니다.</p>
<h4 id="3-팀-협업-용이성">3. 팀 협업 용이성</h4>
<p>여러 명의 개발자가 <code>협업</code>하는 경우, 코드의 구조를 일관성 있게 유지하고 변경 사항을 더 쉽게 추적하는 데 유리할 수 있습니다. 각각의 part 파일을 담당하는 팀원들이 독립적으로 작업할 수 있게 되는 거죠. 
예를 들어 한 화면에서 각 영역별로 한명씩 구현 작업을 분담한다고 했을 때 <code>part 파일</code>을 분리하면 각자의 <code>작업영역</code>을 명확하게 할 수 있기 때문에 불필요하게 <code>git conflict</code>가 나는 상황을 방지할 수 있습니다.
<br/></p>
<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 MVVM 패턴에서 ViewModel 리소스를 구조화하여 관리하는 방법과 그 이점에 대해 알아보았습니다. ViewModel 리소스를 분리하면 여러 분명 이점이 있지만 상황과 목적에 맞게 적절히 사용해야 합니다. 필요 이상으로 많은 part 파일을 생성하여 ViewModel을 분리하다 보면, 오히려 <code>코드의 문맥</code>을 이해하기 어려워질 수 있기 때문이죠. 상황과 목적을 고려하여 ViewModel을 깔끔하게 관리해 보시죠! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Provider를 Getx처럼 사용하기 ]]></title>
            <link>https://velog.io/@ximya_hf/use-provider-like-getx</link>
            <guid>https://velog.io/@ximya_hf/use-provider-like-getx</guid>
            <pubDate>Sun, 23 Jul 2023 16:02:01 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : <a     href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p>최근에 <code>GetX</code>로 관리되고 있던 Plotz 프로젝트를 100% <code>Provider</code>로 전환했습니다. 마이그레션을 진행하고 Provider에서 제공하는 기능과 컨셉들이 결과적으로 마음에 들었지만, Provider가 Getx의 최대 장점인 코드의 <code>단순함</code>과 <code>가독성</code>을 따라오지 못한다는 점이 아쉬웠습니다. 그래서 <strong>Provider 환경에서도 GetX처럼 단순하고 가독성이 높은 코드를 작성할 수 있는 방법</strong>에 대해 고민했었습니다. </p>
<p>본 포스팅에서는 Getx의 컨셉들에 착안해서 Provider 패키지를 더 단순한 코드로 상태를 관리할 수 있는 방법과 관련 예제 코드를 제공합니다. 글을 다 읽으시고 나서는 <strong>Provider 상태관리 패키지를 더 쉽고 심플하게 사용할 수 있는 팁</strong>을 얻으실 수 있을 겁니다.</p>
<blockquote>
<p>참고로 MVVM 아키텍처에 입각해서 필요한 개념들을 다루고 있지만, 다른 아키텍처에서도 유연하게 적용할 수 있으니 참고해 주세요.</p>
</blockquote>
<h2 id="getx-vs-provider-코드-가독성과-단순성-비교">GetX vs Provider: 코드 가독성과 단순성 비교</h2>
<p>먼저 GetX와 Provider의 코드를 가독성과 단순성의 관점으로 비교해 보겠습니다.</p>
<h3 id="1-간단한-상태-접근">1. 간단한 상태 접근</h3>
<p>아래는 GetX와 Provider에서 간단한 상태에 접근하는 예제 코드입니다.</p>
<p>GetX 예제</p>
<pre><code class="language-dart">class MyGetXScreen extends GetView&lt;MyGetXController&gt; {
  @override
  Widget build(BuildContext context) {
    return Text(controller.title);
  }
}</code></pre>
<p>Provider 예제</p>
<pre><code class="language-dart">class MyProviderScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider&lt;MyProviderController&gt;(
      create: (context) =&gt; MyProviderController(),
      builder: (BuildContext context, Widget? child) =&gt;
          Text(context.read&lt;MyProviderController&gt;().title),
    );
  }
}</code></pre>
<p>GetX 예제에서는 <code>GetView</code>를 사용하여 <code>controller</code> 객체에 접근합니다. GetView의 <code>제네릭 타입</code>에 컨트롤러(MyGetXController)를 명시해 주면, <code>controller</code>에 접근할 때 별도의 타입을 명시할 필요가 없어서 상태 접근이 직관적입니다. 하지만 Provider 예제에서는 Provider의 <code>context extension</code> 메소드 <code>context.read&lt;T&gt;()</code>를 사용할 때 <code>제네릭 타입</code>을 필수적으로 명시해 주어야 합니다. 이로 인해 코드가 더 길어지고 번거로워질 수밖에 없습니다.</p>
<h3 id="2-viewmodel과-ui-간의-낮은-의존성">2. ViewModel과 UI 간의 낮은 의존성</h3>
<pre><code class="language-dart">  // Getx버전 라우팅: buildContext가 필요 없음
  getxRoute() {
    Get.to(SomeScreen());
  }


  // Provider버전 라우팅: buildContext가 필요함
  providerRoute(BuildContext context) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =&gt; SomeScreen(),
      ),
    );
  }
</code></pre>
<p>GetX는 <code>context</code>를 전역으로 관리하기 때문에 일반적으로 context가 필요한 라우팅 및 다이얼로그 노출 등의 로직에 context를 사용하지 않습니다. 따라서 코드가 비교적 단순하고 context를 UI 위젯으로부터 전달받을 필요가 없기 때문에 ViewModel과 UI의 <code>의존성</code>이 낮아집니다. 반면 Provider에서는 UI에 종속적인 작업이 필요한 경우 UI 위젯으로부터 <code>context</code>를 인자로 받아와야 하는 번거로움이 발생하며, 이에 따라 UI와 ViewModel 간의 <code>의존성</code>이 높아지게 됩니다.</p>
<h3 id="3-life-cycle-메소드">3. life cycle 메소드</h3>
<p>Getx 패키지의 <code>GetxController</code>에서는 <code>onInit</code> , <code>onDispose</code>와 같은 라이프 사이클 메서드를 제공합니다. 이러한 메소드를 사용하여 GetxController의 라이프 사이클 동안 특정 로직을 실행할 수 있습니다. </p>
<p>Provide패키지에서는 GetxController처럼 라이프 사이클 메소드를 제공하지는 않지만 아래 코드와 같이 ViewModel이 생성되고 해제되는 시점을 구분질 수 있습니다.  </p>
<pre><code class="language-dart">class CustomScreen extends StatelessWidget {
  const CustomScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider&lt;CustomViewModel&gt;(
      create: (context) =&gt; CustomViewModel();
      builder: (context, child) {
        return Container();
      },
    );
  }
}

class CustomViewModel extends ChangeNotifier {

  CustomViewModel() {
    onInit();
  }

  void onInit() {
    // ViewModel이 생성되는 시점에 필요한 메소드
  }

  @override
  void dispose() {
    super.dispose();
    // ViewModel이 해제되는 시점에 필요한 메소드
  } 
}</code></pre>
<p>CustomViewModel 생성자 구문에 <code>onInit</code> 메소드를 적어놓았기 때문에 CustomViewModel이 생성되는 시점에 onInit 메소드를 호출하여 필요한 초기화 메소드들을 실행할 수 있게 됩니다. 또한 <code>ChangeNotifier</code>에 정의된 <code>dispose</code> 메소드를 오버라이드하여 ViewModel 해제되는 시점에서 필요한 메소드들을 처리할 수 있도록 합니다. </p>
<p>이렇게 접근해도 기능상 문제가 없지만, onInit을 메소드를 ViewModel이 생성되는 시점에 실행시키기 위해 해당 메소드를 생성자 구문에 적는 방법보다는 GetxController처럼 간단하게 <code>오버라이드</code>하여 정의할 수 있게 하는 게 조금 더 직관적이라고 생각합니다.</p>
<h2 id="solution-provider를-getx처럼-간단하게">Solution: Provider를 Getx처럼 간단하게</h2>
<p>위에서 언급한 GetX의 이점들을 Provider 환경에서도 적용할 수 있도록 도와주는 <code>Base Module(BaseScreen, BaseViewModel, BaseView)</code>들을 소개해 드리겠습니다.</p>
<h4 id="basescreen">BaseScreen</h4>
<pre><code class="language-dart">abstract class BaseScreen&lt;T extends BaseViewModel&gt; extends StatelessWidget {
  const BaseScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider&lt;T&gt;(
      create: (context) {
        final vm = createViewModel(context);
        vm.initContext(context);
        return vm;
      },
      lazy: setLazyInit,
      builder: (BuildContext context, Widget? child) =&gt; buildScreen(context),
    );
  }

  @protected
  Widget buildScreen(BuildContext context);

  @protected
  bool get setLazyInit =&gt; false;

  @protected
  T vm(BuildContext context) =&gt; Provider.of&lt;T&gt;(context, listen: false);

  @protected
  T vmR(BuildContext context) =&gt; context.read&lt;T&gt;();

  @protected
  T vmW(BuildContext context) =&gt; context.watch&lt;T&gt;();

  @protected
  S vmS&lt;S&gt;(BuildContext context, S Function(T) selector) {
    return context.select((T value) =&gt; selector(value));
  }

  @protected
  T createViewModel(BuildContext context);
}</code></pre>
<h4 id="baseview">BaseView</h4>
<pre><code class="language-dart">abstract class BaseView&lt;T extends BaseViewModel&gt; extends StatelessWidget {
  const BaseView({Key? key}) : super(key: key);

  @protected
  T vm(BuildContext context) =&gt; Provider.of&lt;T&gt;(context, listen: false);

  @protected
  T vmR(BuildContext context) =&gt; context.read&lt;T&gt;();

  @protected
  T vmW(BuildContext context) =&gt; context.watch&lt;T&gt;();

  @protected
  S vmS&lt;S&gt;(BuildContext context, S Function(T) selector) {
    return context.select((T value) =&gt; selector(value));
  }
}</code></pre>
<h4 id="baseviewmodel">BaseViewModel</h4>
<pre><code class="language-dart">abstract class BaseViewModel extends ChangeNotifier {
  BaseViewModel() {
    onInit();
  }

  late BuildContext context;

  @override
  void dispose() {
    onDispose();
    super.dispose();
  }

  @protected
  void onInit() {}

  @protected
  void onDispose() {}

  void initContext(BuildContext contextArg) {
    context = contextArg;
  }
}  </code></pre>
<p>위의 Base Module은 Provider를 GetX처럼 사용할 수 있도록 도와주는 모듈들입니다. <code>BaseScreen</code>은 화면에 해당하는 추상 클래스로 기본적인 화면의 구조와 뷰 모델과의 연결을 정의합니다. <code>BaseView</code>는 뷰에 해당하는 추상 클래스로 화면 위젯들을 별도의 클래스로 쪼개어 관리할 때 사용됩니다. <code>BaseViewModel</code>은 뷰 모델에 해당하는 추상 클래스로 뷰 모델의 구조와 초기화, 해제 로직을 정의합니다. 이러한 모듈들을 사용하면 Provider 환경에서도 GetX의 가독성과 단순성을 얻을 수 있게 됩니다.</p>
<h4 id="예제-적용">예제 적용</h4>
<pre><code class="language-dart">class CustomViewModel extends BaseViewModel {
  final String title = &quot;It looks like GetX, but it&#39;s actually Provider.&quot;;
  final String nestedViewTitle = &quot;Some Text&quot;;
}

class CustomScreen extends BaseScreen&lt;CustomViewModel&gt; {
  const CustomScreen({Key? key}) : super(key: key);

  @override
  Widget buildScreen(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              vm(context).title,
            ),
            const NestedView(),
          ],
        ),
      ),
    );
  }

  @override
  CustomViewModel createViewModel(BuildContext context) =&gt; CustomViewModel();
}

class NestedView extends BaseView&lt;CustomViewModel&gt; {
  const NestedView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text(
      vm(context).nestedViewTitle,
    );
  }
}</code></pre>
<p>간단한 예제를 적용해 보았습니다. 여기서 주의할 몇 가지 있으니, 아래를 참고해 주세요.</p>
<ul>
<li><code>BaseScreen</code>과 <code>BaseViewModel</code>은 바늘과 실과 같은 관계로 서로 항상 정의가 되어 있어야 합니다. </li>
<li>BaseScreen을 extends하고 있는 위젯에서는 build메소드가 아닌 <code>buildScreen</code> 메소드를 오버라이드 해주어야 합니다.</li>
<li><code>BaseView</code>를 extends하고 있는 위젯은 항상 BaseScreen을 extends하고 있는 위젯에 감싸져 있어야 합니다. </li>
<li>BaseScreen에 정의된 <code>createViewModel</code> 오버라이드 메소드에 항상 ViewModel의 <code>인스턴스</code>를 필수적으로 넘겨주어야 합니다.<pre><code class="language-dart">@override
CustomViewModel createViewModel(BuildContext context) =&gt; GetIt.I&lt;CustomViewModel&gt;();</code></pre>
<a href="https://pub.dev/packages/get_it"><code>get_it</code></a>과 같은 의존성 주입 패키지를 이용하고 있다면 위에 코드처럼 인스턴스를 넘겨줄 수 있습니다.</li>
</ul>
<h2 id="분석">분석</h2>
<p>그럼, 이 Base모듈 코드들을 분석해 보면서 모듈의 원리와 이점에 대해 자세히 알아보겠습니다.</p>
<h3 id="1-간단해진-상태-접근">1. 간단해진 상태 접근</h3>
<p>Base 모듈을 사용하면 상태 접근이 훨씬 간편해집니다.</p>
<pre><code class="language-dart">  @protected
  T vm(BuildContext context) =&gt; Provider.of&lt;T&gt;(context, listen: false);

  @protected
  T vmR(BuildContext context) =&gt; context.read&lt;T&gt;();

  @protected
  T vmW(BuildContext context) =&gt; context.watch&lt;T&gt;();

  @protected
  S vmS&lt;S&gt;(BuildContext context, S Function(T) selector) {
    return context.select((T value) =&gt; selector(value));
  }
</code></pre>
<p>BaseScreen 코드를 보면 <code>vm</code>, <code>vmR</code>, <code>vmW</code> 그리고 <code>vmS</code> 등의 메소드들이 정의되어 있습니다. 이는 Provider  read, write, select context <code>extension 메소드</code>를 리턴하고 있으며, 해당 모듈에서 <code>&#39;T&#39;</code> 타입의 <code>ViewModel</code>을 전달받았기 때문에 실제로 해당 메소드를 사용할 때 ViewModel 타입을 명시해줄 필요가 없게 됩니다. (여기서 <code>vm</code> 키워드는 ViewModel의 약어입니다)</p>
<p>예를 들어, 기존에는 다음과 같이 데이터에 접근하였습니다.</p>
<pre><code class="language-dart">// 기존방식
Text(context.read&lt;CounterViewModel&gt;().userName),
Text(context.watch&lt;CounterViewModel&gt;().userName),
Text(context.select&lt;CounterViewModel, String&gt;((value) =&gt; value.userName)),  </code></pre>
<p>하지만 Base 모듈을 사용하면 아래와 같이 간결하게 접근할 수 있습니다.</p>
<pre><code class="language-dart">// Base 모듈 사용
Text(vm(context).userName),
Text(vmW(context).userName),
Text(vmS(context, (value) =&gt; value.userName)),</code></pre>
<p>데이터에 접근할 때마다 타입을 명시할 필요가 없어져서 코드가 훨씬 간결해졌네요. 또한 BaseScreen 모듈에 <code>ChangeNotifierProvider</code> 위젯이 추상화되어 있기 때문에 BaseScreen을 상속하는 스크린 위젯의 코드들이 전체적으로 더 깔끔하게 정리됩니다. 이러한 점에서 Getx 패키지의 <code>GetView</code>와 유사한 접근 방식을 제공한다고 볼 수 있습니다.</p>
<br/>

<h3 id="2-viewmodel과-ui-간의-의존성-낮추기">2. ViewModel과 UI 간의 의존성 낮추기</h3>
<pre><code class="language-dart">abstract class BaseViewModel extends ChangeNotifier {

  // ViewModel에서 사용할 context 변수
  late BuildContext context; 

  // context 초기화 메소드 
  void initContext(BuildContext contextArg) {
    context = contextArg;
  }

  ...
}  </code></pre>
<p>BaseViewModel의 코드를 보면 <code>BuildContext</code>타입의 변수가 late 키워드로 선언되어 있고, 아래에 해당 변수를 초기화하는 <code>initContext</code>가 존재합니다.</p>
<pre><code class="language-dart">abstract class BaseScreen&lt;T extends BaseViewModel&gt; extends StatelessWidget {
  const BaseScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider&lt;T&gt;(
      create: (context) {
        final vm = createViewModel(context);
          // 여기서 viewModel에서 사용할 context를 초기화함
        vm.initContext(context); 
        return vm;
      },

 ... 
}</code></pre>
<p>그리고 <code>BaseScreen</code> 안에서는 <code>initContext</code> 메소드를 실행하여 ViewModel이 생성되는 시점에 <code>BaseViewModel</code>의 <code>context</code> 변수도 초기화 시키게 됩니다.</p>
<pre><code class="language-dart"> /// BaseViewModel 자체에 초기화된 buildContext가 있기 때문에
 /// 라우팅 메소드에서 인자로 buildContext를 전달 받을 필요가 없음. 
  providerRoute() {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =&gt; SomeScreen(),
      ),
    );
  }


 /// BaseViewModel이 적용이 안되었을 때는 당연히
 /// UI위젯으로부터 buildContext를 받아와야함
  providerRouteByContextArg(BuildContext context) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =&gt; SomeScreen(),
      ),
    );
  }  </code></pre>
<p>그럼, 이제 ViewModel 자체에서 <code>context</code> 인스턴에 접근할 수 있게 되므로, 
BuildContext를 활용해야 하는 로직들을 ViewModel에서 관리할 때 매번 UI 위젯에서 BuildContext를 넘겨받을 필요가 전혀 없게 되죠. 훨씬 더 <code>직관적</code>이고 ViewModel과 UI의 <code>의존성</code>을 낮출 수 있게 되었습니다.</p>
<h3 id="3-life-cycle-메소드-지원">3. life cycle 메소드 지원</h3>
<pre><code class="language-dart">abstract class BaseViewModel extends ChangeNotifier {
  BaseViewModel() {
      // viewModel이 생성되는 시점에 onInit 메소드 발동
    onInit();
  }

  @override
  void dispose() {
    // viewModel이 해제되는 시점에 onDispose 메소드 발동
    onDispose();
    super.dispose();
  }


  // 간편하게 오버라이드가 가능한 onInit, onDispose 메소드 정의 
  @protected
  void onInit() {}

  @protected
  void onDispose() {}

}</code></pre>
<p>BaseViewModel 모듈안에 <code>onInit()</code>, <code>onDispose()</code>메소드가 정의되어 있습니다. onInit 메소드는 BaseViewModel을 extends하고 있는 클래스가 생성될 때 발동될 수 있도록 생성자 구문안에 메소드를 실행시키고 있고, <code>ChangeNotifier</code>에서 제공하는 dispose 메소드가 발동될 때 onDispose() 메소드가 실행될 수 있도록 설계했습니다.</p>
<pre><code class="language-dart">class CustomViewModel extends BaseViewModel {

  @override
  void onInit() {
    super.onInit();
    // 필요한 초기화 작업을 수행
  }

  @override
  void onDispose() {
    super.onDispose();
    // 필요한 dispose 작업을 수행
  }
}
</code></pre>
<p>BaseViewModel extends하고 있는 ViewModel은 Getx의 GetxController처럼 라이프 사이클 메소드를 <code>오버라이드</code> 하여 사용할 수 있게 되고 엄청 편리하게 라이프 사이클을 관리할 수 있도록 도와줍니다.  </p>
<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 <code>Provider</code> 패키지를 <code>GetX</code>처럼 사용할 수 있도록 도와주는 Base Module에 관해 설명하였습니다. Base Module을 통해 Provider를 더 효과적으로 사용하고, 코드의 가독성을 높일 수 있었죠.</p>
<p>최근에 제가 배포한 <a href="https://pub.dev/packages/provider_screen">provider_screen</a> 패키지에는 Provider를 효과적으로 사용할 수 있는 기능과 더불어 생산성을 높여줄 수 있는 추가 기능들이 적용되어 있으니 참고해보셔도 좋을 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[더 빠르고 깔끔하게 Import 하기 : Single Import]]></title>
            <link>https://velog.io/@ximya_hf/manangesseveralimportinflutterbysinlgeimport</link>
            <guid>https://velog.io/@ximya_hf/manangesseveralimportinflutterbysinlgeimport</guid>
            <pubDate>Sat, 08 Jul 2023 15:09:40 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.<br>다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p>여러분이 개발하고 있는 Flutter 프로젝트의 규모가 커질수록 Import 구문 라인들이 많아질 수밖에 없습니다. 
예를 들어 한 화면을 구현한다고 했을 때 기본적으로 Material을Material를 패키지를 import 해야 하고 상태관리 라이브러리를 이용한다면 Provider나 GetX 같은 패키지를 import 하겠죠. 그것뿐만 아니라 앱에서 정의된 컬러나 폰트 등을 관리하는 유틸리티 클래스도 필요하고, 커스텀 위젯이 있다면 구현한 커스텀 위젯도 따로 불러와야 합니다.</p>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:flutter_svg/svg.dart&#39;;
import &#39;package:provider/provider.dart&#39;;
import &#39;package:cached_network_image/cached_network_image.dart&#39;;
import &#39;package:carousel_slider/carousel_slider.dart&#39;;
import &#39;package:soon_sak/app/config/app_insets.dart&#39;;
import &#39;package:soon_sak/app/config/app_space_config.dart&#39;;
import &#39;package:soon_sak/app/config/color_config.dart&#39;;
import &#39;package:soon_sak/app/config/font_config.dart&#39;;
import &#39;package:soon_sak/app/config/size_config.dart&#39;;
import &#39;package:soon_sak/app/di/locator/locator.dart&#39;;
import &#39;package:soon_sak/presentation/base/base_screen.dart&#39;;
import &#39;package:soon_sak/presentation/base/base_view.dart&#39;;
import &#39;package:soon_sak/presentation/common/image/content_poster_item_view.dart&#39;;
import &#39;package:soon_sak/presentation/common/image/round_profile_img.dart&#39;;
import &#39;package:soon_sak/presentation/common/slider/content_post_slider.dart&#39;;
import &#39;package:soon_sak/presentation/common/skeleton_box.dart&#39;;
import &#39;package:soon_sak/presentation/screens/home/home_view_model.dart&#39;;
import &#39;package:soon_sak/presentation/screens/home/localWidget/category_content_section_view.dart&#39;;
import &#39;package:soon_sak/presentation/screens/home/localWidget/home_scaffold.dart&#39;;
import &#39;package:soon_sak/presentation/screens/home/localWidget/paged_category_list_view.dart&#39;;
import &#39;package:soon_sak/domain/model/channel/channel_model.dart&#39;;
import &#39;package:soon_sak/domain/model/content/content_argument_format.dart&#39;;
import &#39;package:soon_sak/domain/model/content/home/banner_model.dart&#39;;
import &#39;package:soon_sak/domain/model/content/home/content_poster_shell.dart&#39;;
import &#39;package:soon_sak/utilities/extensions/tmdb_img_path_extension.dart&#39;;


class HomeScreen extends BaseScreen&lt;HomeViewModel&gt; {
  const HomeScreen({Key? key}) : super(key: key);
  ...
</code></pre>
<p>그러다 보면 위에 제 홈스크린 코드처럼 어느 순간 이처럼 수많은<code>import 구문</code>이 쌓이게 됩니다.</p>
<h2 id="불편한-점">불편한 점</h2>
<p>사실 저렇게 여러 import 구문들이 쌓인다는 건 어떻게 보면 당연한 거고 기능적으로는 문제가 있는 건 아니지만 몇 가지 <code>불편한 점</code>이 생깁니다.</p>
<h4 id="1-반복적으로-import-문을-적어야-하는-번거로움">1. 반복적으로 import 문을 적어야 하는 번거로움</h4>
<p>새로운 위젯을 만들 때 기본적으로 사용되는 base 모듈(config, extestion 등등)들에 대한 import 구문을 일일이 적는다는 건, 반복적인 작업을 싫어하는 개발자들에게는 꽤 귀찮은 작업일 수 있습니다.</p>
<h4 id="2불필요한-merge-conflict-발생">2.불필요한 Merge Conflict 발생</h4>
<p>프로덕션 수준의 제품을 개발할 때 여러 개발자가 <code>깃</code>을 이용해 서로의 작업물을 공유하고 합치는 작업을 합니다. 이때 여러 파일에서 import 구문들이 변경되었다면 <code>merge</code> 하는 과정에서 <code>conflict</code>가 발생할 수 있고 일일이 conflict를 해결해야 하는 번거로움이 생깁니다.</p>
<h4 id="3-가독성이-떨어짐">3. 가독성이 떨어짐</h4>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/f97877fc-e4d0-46fa-b627-bca71dca2904/image.png" alt="">
또한 불러오는 import구문 많을수록 작업 중에 더 이상 <code>필요 없거나</code> <code>중복</code>된 import 구문이 쉽게 쌓이게 될 수 있습니다. 이런 코드들을 일일이 삭제하는 것도 또 번거로운 일이겠죠.</p>
<blockquote>
<p><strong>[Tip]</strong>
Flutter 팀에서 제공하는 <code>dart fix --apply</code> 명령어를 입력하면 불필요한 import 구문을 자동으로 제거해 줍니다.</p>
</blockquote>
<br/>

<h2 id="single-import-방식으로-import를-관리">Single Import 방식으로 Import를 관리</h2>
<p>이런 불편한 점들을 해결할 방법은 기존의 <code>Multi Import</code> 대신 <code>Single Import</code> 방법으로 파일을 만들어 호출하는 것입니다.</p>
<p><code>Single Import</code>방식의 원리는 간단한데요.</p>
<pre><code class="language-dart">export &#39;package:cupertino_will_pop_scope/cupertino_will_pop_scope.dart&#39;;
export &#39;package:connectivity_plus/connectivity_plus.dart&#39;;
export &#39;package:cached_network_image/cached_network_image.dart&#39;;
export &#39;package:plotz/presentation/screens/search/localWidget/search_scaffold.dart&#39;;
export &#39;package:plotz/presentation/screens/search/localWidget/searched_list_item.dart&#39;;
export &#39;package:plotz/presentation/screens/search/search_view_model.dart&#39;;
</code></pre>
<p><code>index.dart</code>라는 임의의 파일을 하나 만들고 해당 파일에 자주 사용되는 모듈에 대한 export 구문을 적는 것입니다. </p>
<pre><code class="language-dart">import &#39;package:soon_sak/utilities//index.dart&#39;;

class HomeScreen extends BaseScreen&lt;HomeViewModel&gt; {
  const HomeScreen({Key? key}) : super(key: key);</code></pre>
<p>그리고 이 index. dart파일을 호출하면 기존처럼 매번 여러 라인의 import 구문들을 적는 번거로운 작업을 피할 수 있게 됩니다.</p>
<h2 id="구조화된-single-import-파일-barrel-export">구조화된 Single Import 파일 (barrel export)</h2>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3a4dbd75-ff76-498d-b1e5-fd56cfa16f3f/image.png" alt="">
그리고 여러분의 프로젝트가 여러 <code>레이어</code>로 구분되어 있다면 레이어마다 <code>index.dart</code>파일을 구성하는 것도 좋은 방법입니다. 어떤 심볼의 Single Import 파일을 가져왔는지 식별할 수 있기 때문이죠. 이는 보통 <code>barrel export</code>라고 불리기도 합니다. </p>
<blockquote>
<p>참고로 여러 파일에 대한 export들을 한 파일에서 관리하는 <code>barrel export</code> 방식은
React, Angular와 같은 웹 프론트엔드 환경에서 자주 사용되곤 합니다.</p>
</blockquote>
<p>근데 섹션마다각 섹션마다 index.dart 파일을 만드는 것 자체가 너무 귀찮은 작업이죠. 그래서 Single Import 파일을 쉽게 만들어 주는<strong><a href="https://pub.dev/packages/single_import_generator">single_import_generator</a></strong> 패키지를 만들었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/13c21afc-d537-4a92-a6e3-dba6686d896d/image.png" alt=""></p>
<p><strong><a href="https://pub.dev/packages/single_import_generator">single_import_generator</a></strong> 패키지는 간단한 명령어로 Single Import 파일을 생성하여 프로젝트의 여러 파일의 export 구문들을 조직화하여 <code>생산성</code>과 <code>가독성</code>을 향상해 주는 데 도움을 줍니다. 예시를 통해 자세히 설명해 드려 보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/51d6b18d-e959-4242-8a6e-34d5357a76d0/image.png" alt=""></p>
<p>lib &gt; presentation 경로에 있는 모든 하위의 dart 파일에 대한 export 구문을 담은 index.dart을 만든다고 해봅시다.</p>
<pre><code class="language-bash">$ dart run single_import_generator -target=lib/presentation all</code></pre>
<p>그럼, 위 명령어를 통해 간단하게 index.dart 파일을 만들 수 있습니다.</p>
<br/>

<p><img src="https://velog.velcdn.com/images/ximya_hf/post/4b63c0ab-95c9-4f5a-aede-761c971a9365/image.png" alt="">
혹시 하위 경로에 있는 모든 파일을 대상으로 Single Import 파일을 만들기보다는 <code>직속</code> 경로에 있는 파일들만 타겟하고 싶다면, 아래 명령어를 사용하시면 됩니다.</p>
<pre><code class="language-bash">$ dart run single_import_generator -target=lib/presentation/common dir</code></pre>
<p> 그럼, 직속 경로에 있는 파일들에 대한 export 구문들을 관리하는 index.dart 파일이 생성됩니다.</p>
<br/>

<p>또는 반대로, 레이어마다 Single Import 파일을 만들기보다는 자주 사용되는 특정 모듈만 타겟하여 export 구문들을 관리하고 싶다면 패키지의 <code>@SingleImport</code> 어노테이션 기능을 이용해 보실 수 있습니다.</p>
<pre><code class="language-dart">@SingleImport()
class FrequentlyUsedClass {
   ...
}</code></pre>
<pre><code class="language-dart">@SingleImport()
extension SomeStringExtension on String {
  ...
}</code></pre>
<p>위에 코드처럼 자주 사용되는 모듈에 <code>@SingleImport</code> 어노테이션을 표시해 주시면 준비는 끝났습니다.</p>
<pre><code class="language-dart">dart run single_import_generator -path=lib/utilities</code></pre>
<p>그다음 위 명령어를 실행시키면 <code>path argument</code>에 넘겨진 경로에 <code>@SingleImport()</code> 어노테이션으로 마크된 파일들에 대한 export 구문이 담긴 index.dart 파일이 생성되게 됩니다.</p>
<pre><code class="language-dart">export &#39;package:projectName/domain/frequently_used_class.dart
export &#39;package:projectName/utilities/some_strig_extension.dart

// Package imports:
export &#39;package:intl/intl.dart&#39;;
export &#39;package:provider/provider.dart&#39;;
</code></pre>
<p>또한 어노테이션을 생성되는 index.dart 파일에 자주 사용하는 <code>외부 패키지</code>에 대한 export 구문을 추가하여 관리하는 것도 좋은 방법입니다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 Flutter 프로젝트에서 <code>Single Import</code> 방식을 사용하여 import 구문을 관리하는 방법에 대해 알아보았습니다. 프로젝트의 규모가 커질수록 import 구문이 증가하여 발생하는 불편한 문제들을 Single Import를 활용하여 해결할 수 있습니다. 이때 앞서 말씀드린 <strong><a href="https://pub.dev/packages/single_import_generator">single_import_generator</a></strong> 패키지를 이용하면 더 쉽게 Single Import 파일을 손쉽게 만들 수 있으니, 사용을 적극 권장드립니다! 
감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Factory 생성자로 유연하게 위젯을 모듈화하기]]></title>
            <link>https://velog.io/@ximya_hf/factory-constructor-base-ui-module</link>
            <guid>https://velog.io/@ximya_hf/factory-constructor-base-ui-module</guid>
            <pubDate>Sun, 18 Jun 2023 05:45:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.<br>다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9122d89e-f258-414f-9b28-d1c7062e9d98/image.png" alt=""></p>
<p>위에는 Plotz앱 전반에 걸쳐 자주 사용되는 위에는 2가지 형태의 <code>다이어로그</code> 입니다. 둘 다 비슷한 디자인으로 구성되어 있지만 하단 버튼의 구성과 개수가 각각 다릅니다.</p>
<p>여러분이라면 다이어로그를 어떻게 <code>모듈화</code> 하실건가요?</p>
<p>여러 방법이 있겠지만 해당 포스팅에서는, <code>factory 생성자</code>를 이용하여 <code>유지보수성</code>, <code>명확성</code> 그리고 <code>가독성</code>에 초점을 맞추어 자주사용되는 비슷한 성질의 UI를 <code>모듈화</code> 하는 방법에 대해 다루어 보려고 합니다.</p>
<h2 id="1-서로-다른-두개의-위젯-클래스로-모듈화">1. 서로 다른 두개의 위젯 클래스로 모듈화</h2>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/8b35c728-61e3-4c10-a9fa-01f1d80dac49/image.png" alt=""></p>
<p>아마 제일 간단한 방법은 각각 위젯 클래스를 만드는 거겠죠. 다만 이런 형태는 <code>유지보수</code>에 용이한 형태라고 볼 수 없습니다. 
예를 들어 모달의 상단 Padding 간격을 수정해야 될 때 다이어로그가 하나의 코드로 구성되어 있지 않기 때문에 각각 2번을 수정해야 하는 번거로운 일이 생깁니다.
뭐, 2번은 그럴 수 있다고 칩시다. 만약 이런 다이어로그가 10개가 있다면? 그때부턴 굉장히 지루한 작업을 여러 번 해야겠죠. 그리고 사실 이렇게 각각 서로 다른 클래스를 모듈화한 것은 <code>DRY</code>(Don&#39;t Repeat Yourself) 원칙을 준수했다고 보기도 어렵습니다.</p>
<h2 id="2-기본-생성자-프로퍼티-값을-조건으로-모듈-내에서-분기처리">2. 기본 생성자 프로퍼티 값을 조건으로 모듈 내에서 분기처리</h2>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3466296b-bbec-40fd-85c2-a1999787fdb6/image.png" alt=""></p>
<p>그럼, 하나의 클래스에서 공통되는 부분과 그렇지 않은 하단 버튼영역을 구분해서 적절히 분기처리하는 방법은 어떠할까요? 위 코드에서는 <code>isDivideBtnForamt</code> 라는 Boolean 필드값이 조건 값이 되어 구성이 다른 두 버튼 영역을 모듈 안에서 분기처리하고 있습니다. 이렇게 구성된다면 이전보다 훨씬 유지보수가 편해지겠지만 <code>required 프로퍼티</code>를 <code>optional 프러퍼티</code>를 조건별로 구현하는 데 불편함이 생깁니다.</p>
<p> <img src="https://velog.velcdn.com/images/ximya_hf/post/e3da692a-55f2-445f-a4e5-2d7dc472c462/image.png" alt=""></p>
<p>예를들어 보겠습니다. <code>버튼이 하나</code>인 다이어로그를 구현한다고 했을 때 아래 코드와 같이 <code>required 프로퍼티만</code> 초기화 시켜주면 됩니다.</p>
<pre><code class="language-dart">AppDialog(title: &#39;제목&#39;, btnText: &#39;버튼 텍스트&#39;, onBtnClicked: () {...},
isDividedBtnFormat: false)</code></pre>
<br/>

<p>반면, <code>버튼이 두 개</code>인 다이어로그는 optional 프로퍼티인 <code>onLeftBtnClicked</code> 과 <code>leftBtnText</code> 필드도 필수적으로 초기화 시켜주어야 합니다. </p>
<pre><code class="language-dart">AppDialog(leftBtnText: &#39;닫기&#39;, onLeftBtnClicked: () {...}
,title: &#39;제목&#39;, btnText: &#39;버튼 텍스트&#39;, onBtnClicked: () {...},
isDividedBtnFormat: true)</code></pre>
<br/>

<p><strong>여기서 작업자의 실수가 발생할 수 있는데요.</strong>
버튼이 두 개일 때 필수적으로 초기화 해줘야하는 필드 값이 <code>optional 프러퍼티</code>로 선언되어 있기 때문에 실수로 <code>optional 프러퍼티</code>를 초기화하지 않는 경우가 생길 수 있습니다.</p>
<pre><code class="language-dart">// 버튼이 두 개인 다이어로그를 구현할 때 실수로 &#39;leftBtnClicked&#39; 프로퍼티를 초기화 시키지 않은 경우
AppDialog(leftBtnText: &#39;닫기&#39;, title: &#39;제목&#39;,
 btnText: &#39;버튼 텍스트&#39;, onBtnClicked: () {...},
isDividedBtnFormat: true)</code></pre>
<p>위 코드에서는 버튼이 2개인 다이어로그를 구현하려고 했지만 <code>leftBtnClicked</code> optional 프러퍼티를 초기화 시켜주고 있지 않죠. optional 프로퍼티로 선언되어 있기 때문에 <code>컴파일 단계</code>에서도 오류를 확인할 수 없습니다.</p>
<h4 id="required-프러퍼티로-변경한다면">required 프러퍼티로 변경한다면?</h4>
<p>그럼 모든 프러퍼티를 <code>required 프러퍼티</code>면 변경하면 문제를 해결할 수 있을까요? 실수로 필요한 프로퍼티를 초기화하지 않는 실수는 방지할 수 있겠지만 <code>가독성</code>이  떨어질겁니다.</p>
<pre><code class="language-dart">AppDialog(title: &#39;제목&#39;, btnText: &#39;버튼 텍스트&#39;,
onBtnClicked: () {...}, isDividedBtnFormat: false, 
leftBtnText: null, onLeftBtnClicked: null)</code></pre>
<p>위 코드처럼 버튼이 하나인 다이어로그를 만들 때도 사용하지 않은 <code>leftBtnText</code> &amp; <code>onLeftBtnClicked</code> 프로퍼티를 null로 초기화 시켜주어야 하고, 불필요한 코드를 적기 때문에 <code>가독성</code>이 안 좋다고 볼 수 있습니다.</p>
<h2 id="3-factory-생성자-기반-모듈화">3. Factory 생성자 기반 모듈화</h2>
<p>앞서 소개한 모듈화 방법은 크게 3가지 문제점이 있었습니다.</p>
<ul>
<li>유지보수에 용이하지 않음</li>
<li>가독성이 떨어짐</li>
<li>명시성이 떨어지기 때문에 오류가 발생할 수 있음. </li>
</ul>
<p>이 3가지 문제점들을 Factory 패턴, 즉 <code>Factory 생성자</code>를 통해 해결할 수 있습니다. </p>
<h3 id="팩토리-패턴이란">팩토리 패턴이란?</h3>
<blockquote>
<p>[정의]
팩토리 메서드 패턴(Factory method pattern)은 객체지향 디자인 패턴이다. Factory method는 부모(상위) 클래스에 알려지지 않은 구체 클래스를 생성하는 패턴이며. 자식(하위) 클래스가 어떤 객체를 생성할지를 결정하도록 하는 패턴이기도 하다.
위키백과中</p>
</blockquote>
<p>설명이 조금 복잡하지만, factory는 말 그대로 클래스 인스턴스를 <code>공장</code>이 물건을 생성하듯이 인스턴스를 생성하는 것이라고 이해할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/ddb98bba-23ca-44f2-b9a7-1bff2343f592/image.png" alt="">
보다 쉽게 factory 패턴을 이해하기 위해 스타크래프트의 테란 배럭스 건물에서 보병 유닛을 생산하는 작동 원리에 비유해 보겠습니다. 배럭스에서는 테란의 보병 유닛을 생산할 수 있는 시설입니다. 게임을 플레이하는 유저는 필요한 자원을 지불하여 원하는 보병을 생산할 수 있습니다. 
<img src="https://velog.velcdn.com/images/ximya_hf/post/cb9e3bca-9e9f-4b99-ad3d-8f200fb4c130/image.png" alt=""></p>
<p>위 그림은 생산할 수 있는 보병의 <code>종류</code>와 필요한 <code>자원</code> 그리고 생성 <code>단축키</code>에 대한 정보입니다. 그럼 이제 <code>클래스</code>를 <code>배럭스</code>, 그리고 <code>인스턴스</code>를 <code>보병 유닛</code>이라고 가정한다면 아래와 같이 코드를 작성해 볼 수 있겠습니다.</p>
<pre><code class="language-dart">class Barracks {
  final int mineral; // 미네랄
  final int supply; // 인구수
  final int? gauss; // 가스

  Barracks({
    required this.mineral,
    required this.supply,
    this.gauss,
  });

  // 마린
  factory Barracks.M({required int mineral, required int supply}) =&gt; Barracks(mineral: mineral, supply: supply);

  // 파이버뱃
  factory Barracks.F({required int mineral, required int gauss, required int supply}) =&gt; Barracks(mineral: mineral, gauss: gauss, supply: supply);

  // 고스트
  factory Barracks.G({required int mineral, required int gauss, required int supply}) =&gt; Barracks(mineral: mineral, gauss: gauss, supply: supply);

  // 매딕
  factory Barracks.C({required int mineral, required int gauss, required int supply}) =&gt; Barracks(mineral: mineral, gauss: gauss, supply: supply);
}</code></pre>
<p>보병을 생성하는 데 필수적인 마네랄과 인구수 자원은 <code>required 프로퍼티</code>로 그리고 선택적으로 필요한 가스는 <code>optional 프로퍼티</code>로 설정했습니다. 그리고 <code>factory 생성자</code>를 유닛의 종류별로 선언하여 유저가 생성할 인스턴스를 선택할 수 있도록 했습니다(<code>factory 생성자의 이름</code>은 유닛의 생성 단축키로 설정).</p>
<pre><code class="language-dart">final marine = Barracks.M(mineral: 50, supply: 1);
final ghost = Barracks.G(mineral: 25,  gauss: 75, supply: 1);
final firebat = Barracks.F(mineral: 50,  gauss: 25, supply: 1);
final medic = Barracks.C(mineral: 50,  gauss: 25, supply: 1);</code></pre>
<p>이처럼 factory 패턴은 하나의 클래스에서 여러 객체가 생성될 수 있는 상황일 때 <code>factory 생성자</code>를 이용해 서로 다른 객체의 유형을 <code>동적</code>으로 결정할 수 있는 <code>유연성</code>을 제공합니다.</p>
<h3 id="factory-패턴이-적용된-ui-모듈">Factory 패턴이 적용된 UI 모듈</h3>
<p>자, 이제 factory 패턴이 적용된 다이어로그 UI 모듈을 만들어 보겠습니다.</p>
<pre><code class="language-dart">class AppDialog extends Dialog {
  const AppDialog({
    Key? key,
    this.isDividedBtnFormat = false,
    this.description,
    this.subTitle,
    this.onLeftBtnClicked,
    this.leftBtnText,
    required this.btnText,
    required this.onBtnClicked,
    required this.title,
  }) : super(key: key);

  factory AppDialog.singleBtn({
    required String title,
    required VoidCallback onBtnClicked,
    String? subTitle,
    String? description,
    String? btnText,
  }) =&gt;
      AppDialog(
        title: title,
        subTitle: subTitle,
        onBtnClicked: onBtnClicked,
        description: description,
        btnText: btnText,
      );

  factory AppDialog.dividedBtn({
    required String title,
    String? description,
    String? subTitle,
    required String leftBtnText,
    required String leftBtnText,
    required VoidCallback onRightBtnClicked,
    required VoidCallback onLeftBtnClicked,
  }) =&gt;
      AppDialog(
        isDividedBtnFormat: true,
        title: title,
        subTitle: subTitle,
        onBtnClicked: onRightBtnClicked,
        onLeftBtnClicked: onLeftBtnClicked,
        description: description,
        leftBtnText: leftBtnText,
        btnText: rightBtnText,
      );


  final bool isDividedBtnFormat;
  final String title;
  final String? description;
  final VoidCallback onBtnClicked;
  final VoidCallback? onLeftBtnClicked;
  final String? btnText;
  final String? leftBtnText;
  final String? subTitle;

  @override
  Widget build(BuildContext context) {
    return Dialog(
      insetPadding: EdgeInsets.zero,
      elevation: 0,
      backgroundColor: Colors.transparent,
      child: Container(
        margin: AppInset.horizontal16,
        constraints: const BoxConstraints(minHeight: 120, maxWidth: 256),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(12),
          color: AppColor.strongGrey,
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            // 본분
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 34) +
                  const EdgeInsets.only(top: 18, bottom: 19),
              child: Column(
                children: [
                  Center(
                    child: Text(
                      title,
                      style: AppTextStyle.title3.copyWith(color: AppColor.main),
                      textAlign: TextAlign.center,
                    ),
                  ),
                  AppSpace.size12,
                  if (subTitle.hasData) ...[
                    Text(
                      subTitle!,
                      style: AppTextStyle.alert1,
                      textAlign: TextAlign.center,
                    ),
                    AppSpace.size2,
                  ],
                  if (description.hasData) ...[
                    Center(
                      child: Text(
                        description!,
                        textAlign: TextAlign.center,
                        style: AppTextStyle.desc
                            .copyWith(color: AppColor.lightGrey, height: 1.3),
                      ),
                    )
                  ]
                ],
              ),
            ),
            // 하단 버튼

            // 두개의 버튼으로 나누어진 형식이라면 아래 위젯을 러틴
            if (isDividedBtnFormat)
              Container(
                height: 44,
                decoration: const BoxDecoration(
                  border: Border(
                    top: BorderSide(
                      color: AppColor.gray06,
                      width: 0.5,
                    ),
                  ),
                ),
                child: Row(
                  children: &lt;Widget&gt;[
                    Expanded(
                      child: MaterialButton(
                        padding: EdgeInsets.zero,
                        shape: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.only(
                            bottomLeft: Radius.circular(10),
                          ),
                        ),
                        onPressed: onLeftBtnClicked,
                        child: Center(
                          child: Text(
                            leftBtnText!,
                            style: AppTextStyle.title3
                                .copyWith(color: AppColor.white),
                          ),
                        ),
                      ),
                    ),
                    Container(
                      width: 0.5,
                      color: AppColor.gray06,
                    ),
                    Expanded(
                      child: MaterialButton(
                        padding: EdgeInsets.zero,
                        shape: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.only(
                            bottomRight: Radius.circular(10),
                          ),
                        ),
                        onPressed: onBtnClicked,
                        child: Center(
                          child: Text(
                            btnText ?? &#39;확인&#39;,
                            style: AppTextStyle.title3
                                .copyWith(color: AppColor.white),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),

            // 하나의 버튼으로 구성되어 있는 다이어로그 라면 아래 위젯을 리턴
            if (!isDividedBtnFormat)
              MaterialButton(
                padding: EdgeInsets.zero,
                shape: const RoundedRectangleBorder(
                  borderRadius: BorderRadius.only(
                    bottomLeft: Radius.circular(10),
                    bottomRight: Radius.circular(10),
                  ),
                ),
                onPressed: onBtnClicked,
                child: Container(
                  decoration: const BoxDecoration(
                    border: Border(
                      top: BorderSide(
                        color: AppColor.gray06,
                        width: 0.5,
                      ),
                    ),
                  ),
                  height: 50,
                  child: Center(
                    child: Text(
                      btnText ?? &#39;확인&#39;,
                      style:
                          AppTextStyle.title3.copyWith(color: AppColor.white),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}</code></pre>
<p>factory 생성자를 이용해서 버튼의 구성이 다른 2가지 형태의 다이어로그를 반환하도록 했습니다. 기존에는 dividedBtn 위젯을 생성할 때 필수적으로 구현해 줘야 하는 <code>onLeftBtnClicked</code> &amp; <code>leftBtnText</code>들이 <code>optional</code> 이었기 때문에 오류가 발생할 수 있었지만, 변경된 코드에서는 <code>factory 생성자</code>에서 <code>required</code> 파라미터로 필요한 값들을 받고 객체에 전달하기 때문에 컴파일 단계에서 작업자의 실수를 줄일 수 있습니다.</p>
<pre><code class="language-dart">   showDialog(
      context: context,
      builder: (_) =&gt; AppDialog.singleBtn(
        onBtnClicked: () {},
        title: &#39;제목&#39;,
        description: &#39;본문 내용&#39;,
      ),
    );

   showDialog(
     context: context,
     builder: (_) =&gt; AppDialog.dividedBtn(
         title: &#39;제목&#39;,
        subTitle: &#39;부제목&#39;,
             description: &#39;본문&#39;,
           leftBtnText: &#39;왼쪽 버튼 텍스트&#39;,
         rightBtnText: &#39;오른쪽 버튼 텍스트&#39;,
         onRightBtnClicked: () {},
         onLeftBtnClicked: () {}
     ),
   );</code></pre>
<p>또한 factory <code>생성자의 이름</code>으로 객체를 생성하고 생성자의 <code>파라미터 이름</code>을 유동적으로 변경할 수 있기 때문에 훨씬 <code>명시적</code>이고 <code>가독성</code>도 훨씬 좋아졌습니다. 
<strong>ex) btnText -&gt; rightBtnText</strong>
그리고 공통된 특성들을 모듈화 했기 때문에 <code>유지보수하기</code>도 매우 편하고요. </p>
<br/>


<h2 id="factory-생성자를-활용한-로딩-뷰-처리-로직">factory 생성자를 활용한 로딩 뷰 처리 로직</h2>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/d217c7ba-a903-4a1e-addb-a6ce90bc761e/image.png" alt=""></p>
<p>추가로 factory 생성자를 이용하면 UI 위젯에 사용되는 데이터를 불러오기 전에 보여지는 스켈레톤과 같은 <code>로딩뷰</code>를 적절하게 처리할 수 있습니다.</p>
<pre><code class="language-dart">// [모듈의 전체 코드]
class RoundProfileImg extends StatelessWidget {
  const RoundProfileImg({Key? key, required this.size, required this.imgUrl})
      : super(key: key);

  final double size;
  final String? imgUrl;

  /// 팩토링 생성자 
  /// 로딩처리 뷰를 리턴하기 위해 imgUrl값에 &#39;skeletone&#39; 문자열 값 전달
  factory RoundProfileImg.createSkeleton({required double size}) =&gt;
      RoundProfileImg(size: size, imgUrl: &#39;skeleton&#39;);

  @override
  Widget build(BuildContext context) {
    /// imgUrl값을 기반으로 위젯 분기
    /// imgUrl값이 &#39;skeleton&#39;이면 스켈레톤 뷰를 리턴함
    if (imgUrl == &#39;skeleton&#39;) {
      return SkeletonBox(
        height: size,
        width: size,
        borderRadius: size / 2,
      );
    } else {
      return ClipRRect(
        borderRadius: BorderRadius.circular(size / 2),
        child: imgUrl.hasData
            ? CachedNetworkImage(
                height: size,
                width: size,
                memCacheHeight: (size * 3).toInt(),
                imageUrl: imgUrl!,
                fit: BoxFit.cover,
                placeholder: (context, url) =&gt; const SkeletonBox(),
                errorWidget: (context, url, error) =&gt; Container(
                  color: Colors.grey.withOpacity(0.1),
                  child: const Center(
                    child: Icon(Icons.error),
                  ),
                ),
              )
            : Container(
                color: Colors.red,
                child: Image.asset(
                  &#39;assets/images/blank_profile.png&#39;,
                  height: size,
                  width: size,
                ),
              ),
      );
    }
  }
}
</code></pre>
<pre><code class="language-dart">    // [적용 예시]
    if (imgUrl != null) {
      return RoundProfileImg(size: 62, imgUrl: imgUrl);
    } else {
      return RoundProfileImg.createSkeleton(size: 62);
    }</code></pre>
<p>물론 이런 방법 말고도 여러 가지 접근 방법이 있을 수 있지만 하나의 클래스에서 뷰의 로딩처리 로직까지 모듈화하고 로딩 여부를 명시적으로 선언 및 초기화할 수 있기 때문에 더 좋은 구조라고 생각합니다.</p>
<br/>

<h2 id="마무리">마무리</h2>
<p>이번 포스티에서는 <code>factory 생성자</code>를 이용해 여러 가지 형태의 위젯들을 모듈화하는 방법에 대해 알아보았습니다. 사실 factory 이렇게 서로 다른 UI 위젯을 상태별로 유동적으로 생성할 때 사용하는 것뿐만 아니라 여러 방면에서 사용이 되는데요. 대표적으로 <code>캐싱된 객체</code> 를 반환하여 메모리를 절감할 때도 사용이 되기도 합니다. 기회가 된다면 다음에는 factory 패턴의 다양한 사용 예시를 다루어 보려고 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[진짜 🐶쉽게 Isolate 적용하기 ]]></title>
            <link>https://velog.io/@ximya_hf/flutter-isolate</link>
            <guid>https://velog.io/@ximya_hf/flutter-isolate</guid>
            <pubDate>Mon, 29 May 2023 07:12:27 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/7b8c929d-924b-4490-ad0d-7c047f8e3ea1/image.png" alt=""></p>
<p>이번 포스팅에서는 아주 쉽게 Flutter 프로젝트에 <code>Isolate</code>을 적용하는 방법에 대해 다루어 보려고 합니다. 복잡한 설명은 최대 배제하고 <code>핵심</code> 내용만 설명하려고 합니다. 목차는 아래와 같습니다.</p>
<ol>
<li>Isloate 이란?</li>
<li>언제 그리고 왜 Isolate을 사용할까?</li>
<li>쉽고 유연하게 Isolate을 적용하는 방법</li>
</ol>
<p><em>Isolate에 구체적인 작동방식에 대해 이해하지 못하고 있더라도 바로 <code>여러분의 프로젝트에 적용</code>할 수 있도록 모듈화된 <code>코드</code>를 제공합니다.</em></p>
<p>상세 개념이 궁금하시면 <a href="https://dart.dev/language/concurrency#how-isolates-work">Flutter 공식 문서</a>를 참고하세요.</p>
<p>++
<a href="https://pub.dev/packages/easy_isolate_mixin">https://pub.dev/packages/easy_isolate_mixin</a>
최근에 Isolate을 쉽게 도와주는 패키지를 배포했습니다!</p>
<br/>

<h1 id="isloate-이란">Isloate 이란?</h1>
<p>먼저 Isolate 개념을 가볍게 짚고 넘어갑시다.</p>
<blockquote>
<p>Isolate는 독립적인 작업 단위로, 자체적인 메모리 공간을 가지고 병렬 처리 및 비동기 작업을 수행하는 기능. 각 isolate는 자체적으로 실행되는 코드를 가지며, 메모리를 공유하지 않고 통신을 위해 메시지 전달을 사용함 이를 통해 플러터 애플리케이션에서 병렬 작업을 처리하고, 응답성을 향상시키며, 긴 작업을 분리하여 앱의 성능을 개선할 수 있.</p>
</blockquote>
<p>공식문서에 설명되어 있는 글 입니다. 조금 복잡하죠?</p>
<p>좀 더 쉽게 이야기해보겠습니다.
Dart는 <code>싱글 스레드</code>로 구성되어 있습니다. 그러므로 한 번에 하나의 작업만 처리할 수 있습니다. 예를 들어, 화면 <code>UI를 렌더링</code>하는 동안에는 <code>네트워크를 호출</code>하는 것이 원칙적으로 불가능합니다. 하지만 <code>비동기 처리</code>와 <code>Isolate(이벤트 루프)</code>를 통해 <code>동시성 프로그래밍</code>을 지원하고, 결과적으로 UI 렌더링 작업과 네트워크 호출 작업을 동시에 실행시킬 수 있게 됩니다.</p>
<h3 id="비동기-처리와-isolate의-차이">비동기 처리와 Isolate의 차이</h3>
<p>이때 비동기 처리(async-await)와 Isolate 모두 <code>동시성 프로그래밍</code>을 지원한다는 공통점이 있지만 명확한 차이점도 있습니다. 이 부분이 Isolate를 이해하는데, 있어서 굉장히 중요한 개념인데요. <code>스타크래프트 게임</code>으로 비유해 보겠습니다.</p>
<h3 id="1-비동기-처리와-테란">1. 비동기 처리와 테란</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/6ca60e5a-0979-4430-9ed3-c95df34556e7/image.png" alt=""></p>
<p>비동기 처리를 통해 동시적으로 작업을 처리한다는 것은 테란의 <code>SCV</code>가 2개의 <code>건물</code>을 짓는 상황일 때, 건물을 조금씩 번갈아 가면서 짓는 상황이라고 이해할 수 있습니다(SCV는 건물을 짓는 과정을 중단 및 재개할 수 있다는 점이 고증되었습니다) 
여기서 건물은 하나의 <code>작업 단위</code>이고 건물을 짓는 SCV는 <code>메모리</code>를 가지고 있는 <code>스레드</code>인 셈이죠.</p>
<h3 id="2-isolate과-프로토스">2. isolate과 프로토스</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/e3fa6e91-b62d-4698-8a52-e236e9e4ef88/image.png" alt="">
반대로 프로토스의 유닛은 건물을 짓는 과정 자체에 관여하지 않습니다. 유닛이 구 형태의 <code>플라즈마 필드</code>를 생성하면 <code>플라즈마 필드 자체가 유닛의 관여 없이 스스로 건물이 완성되는 형태</code>입니다. 여러 개의 플라즈마 필드를 생성할 수 있고, 각 플라즈마 필드는 독립적으로 건물을 구성한다는 점에서 <code>Isolate</code>과 유사한 특징을 가집니다. 여러 개의 Isolate이 생성될 수 있고, 각각 <code>서로 다른 메모리 공간</code>을 가지고 작업을 진행하기 때문입니다.</p>
<p>즉, 비동기 처리 방식과 다르게 Isolate은 <code>독립된 메모리 공간</code>을 가지고 병렬 및 비동기 작업을 수행하는 특징이 있습니다. 또한, <code>여러 개의 Isolate이 실행</code>될 수 있는 구조이기도 합니다.</p>
<blockquote>
</blockquote>
<p>건물을 짓는 행위 : 메모리 할당
건물 : 프로세스
유닛 : 스레드 
프로토스의 플라즈마 필드 : isolate(event loop)</p>
<br/>

<h1 id="언제-그리고-왜-isolate을-사용할까">언제 그리고 왜 Isolate을 사용할까?</h1>
<p>제 경험상 대부분은 Isolate을 사용할 필요가 없습니다. <code>메인 스레드에서도</code> 충분히 역할을 해낼 수 있기 때문입니다. 하지만 아래와 같은 경우는 예외 Ioslate 도입을 고려해 볼 수 있습니다.</p>
<ol>
<li><strong>대용량 이미지 처리</strong>
어플리케이션에서 이미지를 처리하는데 생각보다 리소스를 많이 사용하게 됩니다. 여러 개의 고화질 이미지를 동시에 처리해야 하는 경우 Isolate을 사용하는게 좋습니다</li>
</ol>
<ol start="2">
<li><strong>대규모 데이터 처리</strong> 
<img src="https://velog.velcdn.com/images/ximya_hf/post/aa3caf5e-8a11-45f3-8a61-41a2f72a6bc9/image.png" alt="">
대규모 데이터 처리 작업을 수행해야 할 때는 Isolate을 사용하는 게 정말 중요합니다. 보통 주식 거래 앱에서 일반적으로 보여지는 차트를 그릴 때 자주 사용됩니다. 1년치 이상의 차트 기록을 보여준다고 했을 때 대용량 데이터를 호출하기 때문이죠. 실제로 저의 경우 isolate을 적용하지 않고 1년 치 차트 데이터를 불렀더니 앱 1.5초 정도 버벅거리는 증상이 발생했습니다. (이미지 출처 : 서울거래 비상장, 아워튜브)</li>
</ol>
<ol start="3">
<li><p><strong>백그라운드 동기화</strong>
애플리케이션에서 주기적으로 서버와 동기화해야 하는 경우, Isolate 사용이 권장됩니다. 예를 들어 주식거래 앱에서 실시간으로 차트 데이터를 동기화해야 할 때 사용할 수 있습니다. </p>
</li>
<li><p><strong>복잡한 계산</strong> 
어플리케이션에서 복잡한 계산 작업이 필요한 경우에도 Isolate을 사용할 수 있습니다. 특히 무거운 작업을 수행하는 loop문 여러 번 반복된다면 Isolate 도입을 고려해 보세요.</p>
</li>
</ol>
<p>위에서 언급한 경우 이외에도 <code>Frame Drop</code>, 즉 <code>버벅거림 현상</code>이 보인다고 원인을 파악하고 Isolate을 적용해도 무방합니다. 요약하면 UI 스레드의 부하를 줄이고 앱의 전체적인 성능을 향상하기 위해 Isolate을 사용한다고 볼 수 있습니다.</p>
<br/>

<h1 id="쉽고-유연하게-isolate을-적용하는-방법">쉽고 유연하게 Isolate을 적용하는 방법</h1>
<p>이제 실제로 Isolate을 적용하는 방법을 설명드리겠습니다. 본 포스팅에서는 구체적인 Isolate 코드의 작동 원리에 대해서는 설명드리지 않지만 <code>주석</code>을 일일히 다 달아놓았으니 참고해주세요. 핵심 <code>컨셉</code>만 소개해 드리겠습니다.</p>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;dart:collection&#39;;
import &#39;dart:isolate&#39;;
import &#39;package:flutter/services.dart&#39;;

// Isolate를 다루는 mixin 클래스
mixin IsolateHelperMixin {
  // 동시에 실행할 수 있는 Isolate의 최대 개수 설정
  static const int _maxIsolates = 5;

  // 현재 실행 중인 Isolate의 개수를 추적
  int _currentIsolates = 0;

  // 보류 중인 작업을 저장하는 큐
  final Queue&lt;Function&gt; _taskQueue = Queue();

  // Isolate를 생성하여 함수를 실행하거나, 만약 현재 실행 중인 Isolate의 개수가 최대치에 도달한 경우 큐에 작업을 추가
  Future&lt;T&gt; loadWithIsolate&lt;T&gt;(Future&lt;T&gt; Function() function) async {
    if (_currentIsolates &lt; _maxIsolates) {
      _currentIsolates++;
      return _executeIsolate(function);
    } else {
      final completer = Completer&lt;T&gt;();
      _taskQueue.add(() async {
        final result = await _executeIsolate(function);
        completer.complete(result);
      });
      return completer.future;
    }
  }

  // 새로운 Isolate를 생성하여 주어진 함수를 실행
  Future&lt;T&gt; _executeIsolate&lt;T&gt;(Future&lt;T&gt; Function() function) async {
    final ReceivePort receivePort = ReceivePort();
    final RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;

    final isolate = await Isolate.spawn(
      _isolateEntry,
      _IsolateEntryPayload(
        function: function,
        sendPort: receivePort.sendPort,
        rootIsolateToken: rootIsolateToken,
      ),
    );

    // Isolate의 결과를 받고, 이 Isolate를 종료한 후, 큐에서 다음 작업을 실행
    return receivePort.first.then(
          (dynamic data) {
        _currentIsolates--;
        _runNextTask();
        if (data is T) {
          isolate.kill(priority: Isolate.immediate);
          return data;
        } else {
          isolate.kill(priority: Isolate.immediate);
          throw data;
        }
      },
    );
  }

  // 큐에서 다음 작업을 꺼내어 실행
  void _runNextTask() {
    if (_taskQueue.isNotEmpty) {
      final nextTask = _taskQueue.removeFirst();
      nextTask();
    }
  }
}

// Isolate에서 실행되는 함수
Future&lt;void&gt; _isolateEntry(_IsolateEntryPayload payload) async {
  final Function function = payload.function;

  try {
    BackgroundIsolateBinaryMessenger.ensureInitialized(
      payload.rootIsolateToken,
    );
  } on MissingPluginException catch (e) {
    print(e.toString());
    return Future.error(e.toString());
  }

  // payload로 전달받은 함수 실행 후 결과를 sendPort를 통해 메인 Isolate로 보냄
  final result = await function();
  payload.sendPort.send(result);
}

// Isolate 생성 시 필요한 데이터를 담는 클래스
class _IsolateEntryPayload {
  const _IsolateEntryPayload({
    required this.function,
    required this.sendPort,
    required this.rootIsolateToken,
  });

  final Future&lt;dynamic&gt; Function() function; // Isolate에서 실행할 함수
  final SendPort sendPort; // 메인 Isolate로 데이터를 보내기 위한 SendPort
  final RootIsolateToken rootIsolateToken; // Isolate간 통신을 위한 토큰
}</code></pre>
<p>적용 예시: </p>
<pre><code class="language-dart">// Repository 레이어에서 IsolateHelperMixin을 적용

// 유저 레포지토리 클래스
class UserRepository with IsolateHelperMixin {
  UserRepository(this._api);

  // Isolate 적용 버전
  Future&lt;ProfileImgRes&gt; loadUserProfileImg() async =&gt;
      loadWithIsolate(() =&gt; _api.loadUserProfileImg());

  // Isolate 적용이 안된 버전
  Future&lt;UserInfo&gt; loadUserDetailInfo() async =&gt; _api.loadUserDetailInfo();
}

// 콘텐츠 레포지토리 클래스
class ContentRepository with IsolateHelperMixin {
  ContentRepository(this._api);

  // Isolate 적용 버전
  Future&lt;ContentImg&gt; loadContentImg() async =&gt;
      loadWithIsolate(() =&gt; _api.loadContentImg());

  // Isolate 적용이 안된 버전
  Future&lt;ContentInfo&gt; loadContentInfo() async =&gt; () =&gt; _api.loadContentInfo();
}

// 사용자 정보 가져오기
final userRepository = UserRepository(UserApi());
final user = await userRepository.getUserDetails();
print(user);</code></pre>
<pre><code class="language-dart">// 메인 함수에서 필요한 값들을 초기화 해줘야 함

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // NOTE : Isolate 토큰 생성 및 초기화
  final RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
  BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

  runApp(const MyApp());
}
</code></pre>
<br/>

<h2 id="특징">특징</h2>
<h3 id="1-futuret-function-타입을-인자로-받는-메소드">1. <code>Future&lt;T&gt; Function()</code> 타입을 인자로 받는 메소드</h3>
<p>Isolate으로 처리하고 싶은 <code>Future&lt;T&gt;</code> 메소드를 인자로 받으면 간편하게 Isolate을 기능을 실행시킬 수 있기 때문에 <code>코드의 유연성</code>을 크게 향상시킵니다. </p>
<pre><code class="language-dart">// _api.loadContentImg() &lt;-- 기존 메소드

Future&lt;ContentImg&gt; loadContentImg() async =&gt;
     loadWithIsolate(() =&gt; _api.loadContentImg());</code></pre>
<p>더불어, <code>이미 존재하는 클래스나 함수에 대해 별도의 수정 없이 Isolate를 적용할 수 있게 됩니다.</code> 이에 따라 기존 코드를 크게 수정하지 않고도 병렬 처리를 쉽게 적용할 수 있으며, 이는 <code>개발 시간</code>을 줄이고 <code>코드의 가독성</code>을 향상시키는 효과를 가져옵니다.</p>
<p>요약하자면, Future<T> Function() 타입을 인자로 받음으로써 다양한 비동기 작업에 대해 간결하고 유연한 코드 작성이 가능하게 됩니다. 이는 코드의 <code>재사용성</code>을 향상시키며, <code>유지 관리</code>를 용이하게 합니다.</p>
<h3 id="2--mixin-클래스-기반의-isolate">2.  mixin 클래스 기반의 Isolate</h3>
<p>Isolate을 <code>mixin</code> 클래스로 관리하는 이유는 크게 3가지 장점이 있기 때문입니다.</p>
<ol>
<li><p>재사용성 
Isolate 관리 코드를 여러 클래스에서 사용할 수 있습니다. 이는 <code>DRY</code>(Don&#39;t Repeat Yourself)원칙을 따르는 데 도움이 됩니다. 위 코드에서 UserRepository와 ContentRepository 클래스에 공통적으로 <code>IsolateHelperMixin</code> 클래스가 믹스인되어 isolate기능 동일하게 수행하고 있습니다</p>
</li>
<li><p>설계의 유연성
mixin을 이용하면 클래스 내에서 관리되는 메소드에 <code>선택적으로 isolate을 적용</code>할 수 있게 됩니다. 예를들어 UserRpeository의 loadUserProfileImg 메소드는 Isolate이 적용되었지만 loadUserDetailInfo 메소드는 선택적으로 Ioslate을 적용하지 않았습니다. mixin 덕분에 이렇게 <code>유연한 구조</code>를 구성할 수 있게 됩니다.</p>
</li>
<li><p>모듈화
mixin을 사용하면 코드를 더 잘 <code>모듈화</code>할 수 있습니다. 이는 코드의 가독성을 향상시키며 이해하기 쉽게 만들어줍니다. <strong><code>단편적으로 Isolate을 동작 원리를 자세히 이해하지 못하는 작업자라도 간편하게 Isolate 기능을 수행할 수 있도록 합니다.</code></strong></p>
</li>
</ol>
<h3 id="3-isolate-최대-개수-제한">3. Isolate 최대 개수 제한</h3>
<p><code>loadWithIsolate</code> 메소드를 호출할 때 최대 <code>실행 가능한 Isolate의 개수를 제한</code>하는 로직이 들어가 있습니다. 이를 통해 시스템 리소스를 효율적으로 활용하고, 너무 많은 Isolate이 동시에 실행되어 발생할 수 있는 성능 저하나 문제를 방지합니다.</p>
<br/>


<h1 id="마무리">마무리</h1>
<p>이번 글에서는 Isolate을 쉽고 유연하게 적용하는 방법에 대해 알아보았습니다. 위에서 잠깐 언급했지만, 대부분은 Isolate을 사용할 필요는 없습니다. 오히려 Isolate을 남용하면 전체적으로 앱 성능에 안좋은 영향을 주기 때문이죠. </p>
<p>그래서 적절하게 사용하는 게 중요합니다. Ioslate이 필요한 경우 포스팅에서 제공된 코드를 참고하여 쉽고 유연하게 Ioslate을 적용해 보세요 😀</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter, 완성도 높은 채팅 기능을 만들기 위한 인터렉션 로직들]]></title>
            <link>https://velog.io/@ximya_hf/Flutter-%EC%99%84%EC%84%B1%EB%8F%84-%EB%86%92%EC%9D%80-%EC%B1%84%ED%8C%85-%EA%B8%B0%EB%8A%A5%EC%9D%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%9C%84%ED%95%9C-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A1%9C%EC%A7%81%EB%93%A4</link>
            <guid>https://velog.io/@ximya_hf/Flutter-%EC%99%84%EC%84%B1%EB%8F%84-%EB%86%92%EC%9D%80-%EC%B1%84%ED%8C%85-%EA%B8%B0%EB%8A%A5%EC%9D%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%9C%84%ED%95%9C-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A1%9C%EC%A7%81%EB%93%A4</guid>
            <pubDate>Fri, 12 May 2023 20:33:56 GMT</pubDate>
            <description><![CDATA[<p>채팅 UI를 구현할 때 고려해야 할 세세한 부분들이 많습니다. 우리는 하루에도 몇십 번씩 사용하는 채팅 앱을 만들기 위해서는 당연하다고 느껴질 수 있는 세부 사항들을 고려하고, 사용자 경험(UX)을 고려하여 고품질의 채팅 기능을 구현해야 합니다.</p>
<p>이 포스팅에서는 WhatsApp, 카카오톡, 그리고 라인과 같은 대표적인 채팅 앱에서 적용되는 UI <code>인터랙션</code> 로직을 적용한 채팅 앱을 개발하는 방법에 대해 설명합니다.</p>
<h3 id="기본적인-구조-뼈대">기본적인 구조 (뼈대)</h3>
<p>먼저, 채팅 스크린의 기본 구조를 살펴봅시다.</p>
<pre><code class="language-dart">    Scaffold(
      appBar: AppBar(
        title: const Text(&quot;Chat&quot;),
        backgroundColor: const Color(0xFF007AFF),
      ), // &lt;-- 앱바
      body: Column(
        children: [
          Expanded(
            child: ListView.separated(...), // &lt;- 채팅 리스트 뷰
          ), 
           _BottomInputField(), // &lt;- 하단 고정 TextField 위젯
        ],
      ),
    );
</code></pre>
<p>일반적으로 채팅 스크린은 간단한 구조를 가지고 있습니다.
<code>AppBar</code>, <code>Chat ListView</code>, 그리고 하단에 고정된 <code>TextField</code>으로 구성되어 있습니다.</p>
<p>여기서 중요한 점은 채팅 리스트 뷰와 텍스트 필드를 <code>Column</code> 위젯으로 감싸야 하며, 채팅 리스트 뷰 섹션은 <code>Expanded</code> 위젯으로 감싸야 한다는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/90722d02-af0b-4e34-acab-6381c71c2a89/image.png" alt=""></p>
<p>Column 위젯으로 감싸진 <code>채팅 리스트 뷰</code>와 <code>입력창</code>은 위아래로 구성이 된 상태에서,<code>채팅 리스트 뷰 섹션</code>이 Expand로 감싸졌기 때문에 자연스럽게 <code>입력창</code> 뷰가 하단에 고정이 됩니다. 굳이 Stack &amp; Positioned 위젯을 이용해서 <code>입력창</code> 위젯을 하단에 고정시킬 필요가 없는 이점이 있습니다.</p>
<p>앞으로 계속 보여드릴 예제도 해당 구조로 레이아웃이 구성되어 있다는 점 유의해 주시면 좋겠습니다.</p>
<br/>

<h3 id="1-가상-키보드의-영역을-감지하여-입력창과-채팅-리스트뷰-섹션이-변화에-대응하는-인터랙션">1. 가상 키보드의 영역을 감지하여 입력창과 채팅 리스트뷰 섹션이 변화에 대응하는 인터랙션</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/f5202f23-c817-45ed-aad8-afd0791b28ed/image.png" alt=""></p>
<p>가장 먼저 고려해야 할 채팅 인터랙션은 <code>가상 키보드</code>가 나타났을 때 <code>입력창</code>과 <code>채팅 리스트뷰 섹션</code>의 변화에 대응하는 것입니다. 사용자 경험을 위해 가상 키보드가 나타났을 때 입력창과 채팅 리스트뷰가 자연스럽게 따라 움직이는 것이 중요합니다.</p>
<p>이를 위해 다음 두 가지 <code>속성</code>을 설정해야 합니다.</p>
<h4 id="resizetoavoidbottominset-속성">resizeToAvoidBottomInset 속성</h4>
<pre><code class="language-dart">    return Scaffold(
      resizeToAvoidBottomInset: true, // true값 할당
      appBar: AppBar(
        title: const Text(&quot;Ximya&quot;),
        backgroundColor: const Color(0xFF007AFF),
      ),
</code></pre>
<p>먼저 Scaffold 위젯에 <code>resizeToAvoidBottomInset</code> 속성을 <code>true</code>로 설정해야 합니다. 이 속성이 true로 설정되면, 가상 키보드가 나타날 때 Scaffold 위젯이 자동으로 크기를 조정하여 <code>가상 키보드</code>와 겹치지 않도록 합니다.</p>
<h4 id="reversed-속성">reversed 속성</h4>
<pre><code class="language-dart">ListView.separated(
    reverse: true,
    itemCount: chatList.length,
    ...
 )     </code></pre>
<p>두 번째로 <code>ListView</code> 위젯의 <code>reversed</code> 속성을 <code>true</code>로 설정해야 합니다. 이 속성은 리스트 아이템을 역순으로 배치하는지를 지정합니다. reversed를 true로 설정하면 아이템이 아래에서 위로 배치되고 가상 키보드의 크기 변화를 감지할 수 있게 됩니다.</p>
<blockquote>
<p>NOTE : 인덱스와 위치
reversed: true로 설정하면 ListView의 아이템이 아래에서 위로 배치됩니다. 이에 따라 아이템의 인덱스와 화면상의 위치가 반대로 됩니다. 이를 고려하여 ListView에 전달되는 데이터를 조작해야되는 경우가 발생할 수 있습니다. 데이터의 조작이 필요한 경우 ListView의 데이터를 전달하기 전에 한번 더 값을 reversed 시키는게 정답이 될 수 있습니다.
ex) controller.chatList.reversed.toList()</p>
</blockquote>
<br/>


<h3 id="2-채팅이-추가될-때-아래로-스크롤-되는-인터렉션">2. 채팅이 추가될 때 아래로 스크롤 되는 인터렉션</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/c2493d6b-8b7d-4f7f-8e2c-02e4869fbc0a/image.png" alt=""></p>
<p>채팅 리스트에 메시지가 추가될 때 해당 메시지가 가장 아래에 배치되고 자연스럽게 스크롤되어야 합니다. 이를 위해 ListView의 <code>reversed</code> 속성을 <code>true</code>로 설정해야 합니다. reversed를 true로 설정하면 아이템이 아래에서 위로 배치되므로 메시지가 추가될 때 ListView의 영역이 커지면서 스크롤 위치가 변경됩니다.</p>
<h3 id="3채팅-리스트-뷰-섹션에서-메세지가-위로-정렬이-되는-레이아웃">3.채팅 리스트 뷰 섹션에서 메세지가 위로 정렬이 되는 레이아웃</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/6c65d2db-aa50-4ca0-9bef-f95483bd84eb/image.png" alt=""></p>
<p>지금까지 listView 위젯의 <code>reversed</code> 속성을 <code>true</code>로 설정해야 한다고 말씀드렸습니다. 다만 이렇게 설정을 하면 채팅 리스트 섹션이 화면 가장 아래에 배치 된다는 문제가 발생합니다. </p>
<pre><code class="language-dart"> Align(
    alignment: Alignment.topCenter,
    child: ListView.separated(
    shrinkWrap: true,
    reverse: true,
    itemCount: chatList.length,
    itemBuilder: (context, index) {
    return Bubble(chat: chatList[index]);
       },
    );
   ),</code></pre>
<p>reversed 속성을 true로 설정하면 채팅 리스트 섹션이 화면 가장 아래에 배치되므로, 화면 상단에 채팅 메시지가 보이도록 하려면 몇 가지 수정이 필요합니다. ListView 위젯을 <code>Align</code>으로 감싸고 alignment 속성에 Alignment.topCenter 값을 전달하여 상단에 배치하도록 설정합니다. 또한 ListView에 <code>shrinkWrap: true</code> 속성을 설정해야 합니다. 이렇게 하면 ListView가 내부 콘텐츠에 맞게 크기를 조정하여 Alignment 위젯의 영향을 받아 상단에 배치됩니다.</p>
<h3 id="4-채팅-메세지-전송-시-스크롤-위치-최적화-인터렉션">4. 채팅 메세지 전송 시 스크롤 위치 최적화 인터렉션</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/a6e79b6f-afb0-4665-82f5-61776cbf2e0e/image.png" alt=""></p>
<p>채팅 메시지를 전송하는 순간에 현재 스크롤 위치가 어디에 있든 간에 가장 아래로 변경되어야 합니다. 이를 위해 <code>ScrollController를</code> 사용하여 ListView의 스크롤 동작을 제어할 수 있습니다.</p>
<pre><code class="language-dart">final scrollController = ScrollController()

...

ListView.separated(
    shrinkWrap: true,
    reverse: true,
    controller: scrollController                                  
    itemCount: chatList.length,
    itemBuilder: (context, index) {
        return Bubble(chat: chatList[index]);
     },
 );            </code></pre>
<p>먼저 변수에 ScrollController를 초기화시켜 줍니다. 그리고 ListView의 controller 속성에 해당 변수를 전달합니다. 이제 ListView의 스크롤 동작을 컨트롤러 설정할 수 있게 됩니다.</p>
<pre><code class="language-dart">Future&lt;void&gt; onFieldSubmitted() async {
  addMessage();

  // 스크롤 위치를 맨 아래로 이동 시킴 
  scrollController.animateTo(
    0,
    duration: const Duration(milliseconds: 300),
    curve: Curves.easeInOut,
  );

  textEditingController.text = &#39;&#39;;
}</code></pre>
<p>그리고 채팅이 추가될 때 발생하는 메소드에 <code>scrollController.animatedTo</code> 이벤트를 적용하여 가장 아래로 스크롤 되는 애니메이션 동작을 추가합니다. animatedTo 메소드의 offset값을 <code>0</code>으로 전달한 이유는 listview.buidler가 <code>reversed:true</code>로 설정되어 있으므로, <code>0</code>이라는 위치는 사실상 리스트의 맨 아래를 의미하기 때문입니다. </p>
<h3 id="5-채팅-영역을-클릭하면-가상-키보드를-사라지게-하는-인터렉션">5. 채팅 영역을 클릭하면 가상 키보드를 사라지게 하는 인터렉션</h3>
<p>자 이제 마지막입니다. 일반적인 채팅 앱에서는 가상 키보드가 올라온 상태에서 일반 채팅 리스트 영역을 탭 하면 가상 <code>키보드 아래로 숨겨지는 인터렉션</code>이 존재하는데요. 이 부분을 구현하기 위해서 간단하게 코드를 하나 추가하시면 됩니다.</p>
<pre><code class="language-dart">          Expanded(
            child: GestureDetector(
              onTap: () {
                FocusScope.of(context).unfocus(); // &lt;-- 가상 키보드 숨기기
              },
              child: Align(
                alignment: Alignment.topCenter,
                child: Selector&lt;ChatController, List&lt;Chat&gt;&gt;(
                  selector: (context, controller) =&gt;
                      controller.chatList.reversed.toList(),
                  builder: (context, chatList, child) {
                    return ListView.separated(
                      shrinkWrap: true,
                      reverse: true,
                      padding: const EdgeInsets.only(top: 12, bottom: 20) +
                          const EdgeInsets.symmetric(horizontal: 12),
                      separatorBuilder: (_, __) =&gt; const SizedBox(
                        height: 12,
                      ),
                      controller:
                          context.read&lt;ChatController&gt;().scrollController,
                      itemCount: chatList.length,
                      itemBuilder: (context, index) {
                        return Bubble(chat: chatList[index]);
                      },
                    );
                  },
                ),
              ),
            ),
          ),</code></pre>
<p>채팅 리스트 섹션을 <code>GestureDetector</code> 위젯으로 감싸고 onTap 함수로<code>FocusScope.of(context).unfocus()</code> 이벤트를 넘겨주면 됩니다.</p>
<pre><code class="language-dart">// 1. 초기화
final focusNode = FocusNode();


// 2. focusNode 객체 전달
TextField(
    focusNode :  focusNode,
...
),


// 3. 채팅 섹션이 탭 되었을 때 
onChatListSectinoTapped() {
    focusNode.unfocus()

}</code></pre>
<p>또 다른 방법으로는 FocusNode 객체를 사용하여 가상 키보드를 숨길 수도 있습니다. FocusNode 객체를 초기화하고 텍스트 필드에 focusNode 속성을 설정합니다. 그리고 채팅 리스트 섹션이 탭되었을 때 focusNode.unfocus()를 호출하여 가상 키보드를 숨깁니다.</p>
<br/>

<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 채팅 앱을 구성할 때 고려해야된 인터렉션들에 대해 알아보았습니다. 어떻게 보면 사소한 부분일 수 있지만 이런 인터렉션들을 고려했을 때 채팅 기능의 완성도가 월등히 높아진다고 생각합니다.
앞서 설명한 인터렉션뿐만 아니라 전체적인 구성이 궁금하시다면 예제 코드가 있는 <a href="https://github.com/Xim-ya/Basic_Chat_UI_Implementation">깃헙 레포</a>를 클론 받아보시길 바랍니다😀</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter, 개발 생산성을 2배 증가 시켜줄 BaseScreen 모듈  : Getx버전 ]]></title>
            <link>https://velog.io/@ximya_hf/basescreenonmvvm</link>
            <guid>https://velog.io/@ximya_hf/basescreenonmvvm</guid>
            <pubDate>Fri, 28 Apr 2023 08:26:14 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p> ** 📝 셀프진단 체크리스 **
✔️ <code>GetX</code> 상태관리 라이브러리를 이용하고 있다.
✔️ <code>MVVM 아키텍쳐</code>를 프로젝트에 적용하고 있다.
✔️ 프로젝트에서 관리하는 <code>스크린 위젯</code>의 개수가 5개 이상이다.</p>
<p>위 체크리스트에 모두 해당하시나요?
그렇다면 이 포스팅에서는 <strong>MVVM 아키텍처에서 구조화된 <code>base screen 템플릿 모듈</code>을 사용하여 화면을 직관적으로 구성하고 개발 생산성을 크게 향상하는 팁을 얻고,</strong> 여러분들의 프로젝트에 바로 적용하실 수 있을 겁니다. </p>
<p>본 포스팅에서는 다음 개념들을 다룹니다.</p>
<ul>
<li>캡슐화</li>
<li>Generic Type </li>
<li>abstract class</li>
<li>@protected keyword</li>
<li>@immutable keyword</li>
</ul>
<h2 id="mvvm-아키텍쳐에서-view의-역할">MVVM 아키텍쳐에서 View의 역할</h2>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/70d9eea2-6852-43a0-ac8c-254e230d4689/image.png" alt=""></p>
<p>먼저 <code>MVVM</code> 대해 간단히 짚고 갑시다.
MVVM 아키텍처에서는 각 구성 요소가 명확한 역할과 책임을 갖도록 하여 <code>사용자 인터페이스와 비즈니스 로직</code>을 분리하는 것을 강조합니다. 이를 통해 Presentation 레이어의 <code>역할</code>과 <code>책임</code>을 명확히 할 수 있거든요.</p>
<p>Presentation 레이어의 <code>역할</code>과 <code>책임</code>은 크게 두 가지로 나눌 수 있습니다.</p>
<ol>
<li><p><strong>사용자 인터페이스 구성 및 제공</strong>: Presentation 레이어는 UI를 구성하고 사용자에게 제공하는 역할을 수행. 이는 화면의 레이아웃과 디자인을 담당하며, 사용자가 상호작용할 수 있는 인터페이스를 제공.</p>
</li>
<li><p><strong>ViewModel과 상호작용 및 데이터 전달</strong>: Presentation 레이어는 ViewModel과 상호작용하며 데이터를 전달하거나 전송하는 역할을 수행. 이를 통해 필요한 데이터를 ViewModel로부터 받아오거나 변경된 데이터를 ViewModel에 전달할 수 있음. 또한, 필요에 따라 UI를 업데이트하여 사용자에게 시각적인 피드백을 제공.</p>
</li>
</ol>
<p>이제 소개할 <code>BaseScreen</code> 모듈은 <code>MVVM</code>아키텍쳐 관점에 기반하여 개발자가 <code>UI를 편리하고 직관적으로 구성</code>할 수 있도록 도와주며, <code>View와 ViewModel을 완벽하게 분리</code>하는 것에 초점을 두고 있습니다. </p>
<br/>

<h1 id="base-screen-module">Base Screen Module</h1>
<p>코드를 먼져 살펴보겠습니다.</p>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:get/get.dart&#39;;

@immutable
abstract class BaseScreen&lt;T extends GetxController&gt; extends GetView&lt;T&gt; {
  const BaseScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (!vm.initialized) {
      initViewModel();
    }
    return Container(
      color: unSafeAreaColor,
      child: wrapWithSafeArea
          ? SafeArea(
              top: setTopSafeArea,
              bottom: setBottomSafeArea,
              child: _buildScaffold(context),
            )
          : _buildScaffold(context),
    );
  }

  Widget _buildScaffold(BuildContext context) {
    return Scaffold(
      extendBody: extendBodyBehindAppBar,
      resizeToAvoidBottomInset: resizeToAvoidBottomInset,
      appBar: buildAppBar(context),
      body: buildScreen(context),
      backgroundColor: screenBackgroundColor,
      bottomNavigationBar: buildBottomNavigationBar(context),
      floatingActionButtonLocation: floatingActionButtonLocation,
      floatingActionButton: buildFloatingActionButton,
    );
  }

  @protected
  Color? get unSafeAreaColor =&gt; Colors.black;

  @protected
  bool get resizeToAvoidBottomInset =&gt; true;

  @protected
  Widget? get buildFloatingActionButton =&gt; null;

  @protected
  FloatingActionButtonLocation? get floatingActionButtonLocation =&gt; null;

  @protected
  bool get extendBodyBehindAppBar =&gt; false;

  @protected
  Color? get screenBackgroundColor =&gt; Colors.white;

  @protected
  Widget? buildBottomNavigationBar(BuildContext context) =&gt; null;

  @protected
  Widget buildScreen(BuildContext context);

  @protected
  PreferredSizeWidget? buildAppBar(BuildContext context) =&gt; null;

  @protected
  bool get wrapWithSafeArea =&gt; true;

  @protected
  bool get setBottomSafeArea =&gt; true;

  @protected
  bool get setTopSafeArea =&gt; true;

  @protected
  void initViewModel() {
    vm.initialized;
  }

  @protected
  T get vm =&gt; controller;
}
</code></pre>
<p><code>BaseScreen</code> 클래스는 일반적인 앱 화면의 <code>공통 요소</code>를 <code>추상화</code>하여, 다양한 화면에서 공통으로 사용할 수 있는 템플릿입니다. 이 클래스를 사용하면 화면을 구성하는 요소들을 활용하여 일관된 <code>스켈레톤 구조</code>를 제공함으로써 <code>개발 생산성</code>을 대폭 향상시킬 수 있습니다.</p>
<p>또한, BaseScreen 클래스는 <code>GetView</code>를 상속하며, <code>GetxController</code>를 확장한 타입 <code>T</code>를 사용합니다. 이를 통해 MVVM 구조에 맞게 ViewModel로 사용되는 GetxController와 <code>1대1 대응</code>되는 형태로 작동합니다. 이러한 구조를 통해 View에서 손쉽게 데이터를 주고받을 수 있도록 지원합니다.</p>
<p><strong>위와 같이 BaseScreen 클래스는 앱 개발에서 공통적으로 필요한 기능들을 추상화하여 템플릿으로 제공하며, MVVM 아키텍처에 맞는 데이터 흐름을 간편하게 구현할 수 있도록 도와줍니다.</strong></p>
<h1 id="구성">구성</h1>
<p>이제 BaseScreen 모듈의 핵심 구성 요소를 하나하나 살펴보도록 하겠습니다.</p>
<h3 id="1-build-메소드-safearea">1. build 메소드 (SafeArea)</h3>
<pre><code class="language-dart">  @override
  Widget build(BuildContext context) {
    if (!vm.initialized) {
      initViewModel();
    }
    return Container(
      color: unSafeAreaColor,
      child: wrapWithSafeArea
          ? SafeArea(
              top: setTopSafeArea,
              bottom: setBottomSafeArea,
              child: _buildScaffold(context),
            )
          : _buildScaffold(context),
    );
  }
</code></pre>
<p>이 메서드는 화면의 구성 요소를 생성하며, <code>SafeArea</code>를 조건부로 사용하여 화면의 내용을 안전 영역으로 감쌀지를 결정할 수 있습니다. 또한, <code>setBottomSafeArea</code>와 <code>setTopSafeArea</code> 속성은 SafeArea 위젯에 적용되어 상단과 하단의 안전 영역을 설정하는 데 사용됩니다. 하위 클래스에서 이러한 속성을 재정의하여 개별 화면에 맞게 상단과 하단의 안전 영역을 설정할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/a311fdcb-3a0c-43db-8d8e-6fb15c49131f/image.png" alt=""></p>
<p>또한, <code>Scaffold</code>를 <code>Container</code>위젯으로 감싸고 있고 해당 Container의 color 속성에 <code>unSafeAreaColor</code> 이 적용되어 있어, 위의 코드처럼 unSafeAreaColor 값으로 safeArea 밖의 color 값을 지정할 수 있습니다.</p>
<h3 id="2-_buildscaffold-메소드">2. _buildScaffold 메소드</h3>
<pre><code class="language-dart">  Widget _buildScaffold(BuildContext context) {
    return Scaffold(
      extendBody: extendBodyBehindAppBar,
      resizeToAvoidBottomInset: resizeToAvoidBottomInset,
      appBar: buildAppBar(context),
      body: buildScreen(context),
      backgroundColor: screenBackgroundColor,
      bottomNavigationBar: buildBottomNavigationBar(context),
      floatingActionButtonLocation: floatingActionButtonLocation,
      floatingActionButton: buildFloatingActionButton,
    );
  }</code></pre>
<p>이 메서드는 기본적인 스켈레톤 구조를 제공하는 <code>Scaffold</code> 위젯을 구축합니다. AppBar, Screen Body, BackgroundColor, BottomNavigationBar 및 FloatingActionButton과 같은 여러 구성 요소를 관리합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/66bc048f-185d-4dd6-bf6e-1d00ed5656f5/image.png" alt=""></p>
<p>또한,<code>buildScreen</code> 메서드는 하위 클래스에서 <code>필수적</code>으로 재정의되어야 하는 메서드입니다.</p>
<pre><code class="language-dart">  @protected
  Widget buildScreen(BuildContext context);

  @protected
  Widget? buildBottomNavigationBar(BuildContext context) =&gt; null;

  @protected
  PreferredSizeWidget? buildAppBar(BuildContext context) =&gt; null;</code></pre>
<p>위 3가지 메서드는 모두 추상 메서드로 선언되어 있습니다. 그중에서도 buildScreen 메서드는 <code>기본값</code>이 설정되어 있지 않고, <code>nullable한 위젯</code>이 아니기 때문에 반드시 오버라이드되어야 하는 필수 메서드입니다.</p>
<h3 id="3-여러-protected-속성-및-메서드">3. 여러 @protected 속성 및 메서드</h3>
<pre><code class="language-dart">  @protected
  Color? get unSafeAreaColor =&gt; Colors.black;

  @protected
  bool get resizeToAvoidBottomInset =&gt; true;

  @protected
  Widget? get buildFloatingActionButton =&gt; null;

  @protected
  FloatingActionButtonLocation? get floatingActionButtonLocation =&gt; null;

  @protected
  bool get extendBodyBehindAppBar =&gt; false;

  @protected
  Color? get screenBackgroundColor =&gt; Colors.white;

  @protected
  Widget? buildBottomNavigationBar(BuildContext context) =&gt; null;

  @protected
  Widget buildScreen(BuildContext context);

  @protected
  PreferredSizeWidget? buildAppBar(BuildContext context) =&gt; null;

  @protected
  bool get wrapWithSafeArea =&gt; true;

  @protected
  bool get setBottomSafeArea =&gt; true;

  @protected
  bool get setTopSafeArea =&gt; true;
</code></pre>
<p>위 코드에서 사용되는 getter 메서드는 하위 클래스에서 재정의할 수 있으며, 각 화면에 대한 맞춤화된 동작을 제공합니다.</p>
<p>여기서 반복적으로 사용되는 <code>@protected</code> 키워드는 Dart 언어의 메타데이터 어너테이션으로, 해당 속성이나 메소드를 <code>클래스 외부에서 직접 사용하지 못하도록 제한</code>하는 역할을 합니다. 대신, 해당 속성이나 메소드는 클래스를 상속받은 <code>하위 클래스</code>에서만 재정의하거나 호출할 수 있습니다.</p>
<p>이렇게 함으로써, 다음과 같은 <code>이점</code>들을 얻을 수 있습니다.</p>
<ul>
<li><p><strong>캡슐화</strong> :  클래스의 내부 구현 세부 사항을 숨기고 외부로 노출되는 인터페이스를 제한할 수 있음. 이를 통해 클래스의 구현을 변경하더라도 외부에서 사용하는 인터페이스는 그대로 유지되어 코드의 유지보수가 쉬워짐.</p>
</li>
<li><p><strong>상속 계층의 명확성</strong> : <code>@protected</code>로 표시된 속성과 메서드는 하위 클래스에서만 사용 가능하므로, 상속 계층을 따라 어떤 속성과 메서드가 재정의되고 사용되어야 하는지 명확하게 파악할 수 있음. 이를 통해 코드의 가독성과 이해도가 향상됨.</p>
</li>
<li><p><strong>확장 가능성</strong> : <code>@protected</code> 키워드가 적용된 속성과 메서드는 하위 클래스에서 재정의할 수 있으므로, 상속받은 클래스에서 특정 동작을 변경하거나 확장할 수 있음 이로 인해 코드의 유연성과 확장 가능성이 향상됨.</p>
</li>
<li><p><strong>예기치 않은 오류 방지</strong> : 클래스를 잘못 사용하는 것을 방지하고, 클래스 내부의 속성이나 메서드를 외부에서 직접 변경하거나 호출하지 못하도록 제한함으로써, 예기치 않은 오류나 버그를 줄일 수 있음.</p>
</li>
</ul>
<p>또한, 이 구조는 클래스의 멤버 변수가 아니라 <code>getter</code>를 사용함으로써 필요한 경우에만 해당 값을 계산하고 반환하므로, 전체적인 메모리 사용량을 고려했다고 볼 수 있겠죠.</p>
<h3 id="4-vm-속성">4. vm 속성</h3>
<pre><code class="language-dart">abstract class BaseScreen&lt;T extends GetxController&gt; extends GetView&lt;T&gt; {
  const BaseScreen({Key? key}) : super(key: key);

  .....(some code)

  @protected
  T get vm =&gt; controller;

</code></pre>
<p>위 코드는 BaseScreen 모듈에서 가장 핵심적인 부분입니다. BaseScreen 클래스는 <code>GetView&lt;T&gt;</code>를 상속하여 ViewModel(GetxController)과 상호작용할 수 있게 됩니다. 여기서 제네릭 타입 T는 <code>GetxController</code>를 상속받는 타입을 나타냅니다.</p>
<blockquote>
<p><strong>NOTE: Generic Type</strong>
<code>&lt;T extends GetxController&gt;</code>는 제네릭 타입을 정의한 부분입니다. T는 제네릭 타입 변수로, 실제 타입이 지정되지 않은 상태에서 일종의 <code>타입 플레이스홀더</code> 역할을 합니다. 
즉, <code>extends GetxController</code> 부분은 T가 <code>GetxController 클래스를 상속한 타입</code>이어야 함을 나타냅니다. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/08877e56-8b26-473c-afed-aee671935e22/image.png" alt=""></p>
<p><code>@protected</code> 어노테이션이 적용된 vm getter 메서드는 BaseScreen 클래스 내부에서 GetxController의 인스턴스에 접근할 수 있는 속성인 controller를 사용합니다. 이를 통해 코드를 명확하게 작성하고 가독성을 높일 수 있습니다. <code>vm</code>이라는 이름을 사용하여 컨트럴러 인스턴스에 접근할 수 있게 됩니다.</p>
<p><strong>따라서, BaseScreen 클래스를 상속받은 Screen 위젯에서는 ViewModel 인스턴스에 편리하게 접근할 수 있으며, 코드를 논리적이고 가독성 있게 작성할 수 있습니다.</strong></p>
<h3 id="5-immutable-키워드">5. @immutable 키워드</h3>
<pre><code class="language-dart">@immutable
abstract class BaseScreen&lt;T extends GetxController&gt; extends GetView&lt;T&gt; {
  const BaseScreen({Key? key}) : super(key: key);

   .....
  }</code></pre>
<p><code>@immutable</code> 키워드는 Dart 언어에서 불변(immutable) 객체를 표시하는 어노테이션입니다. 불변 객체란 한 번 생성되면, 그 상태가 변경되지 않는 객체를 말합니다. 그러므로<code>@immutable</code> 키워드를 선언함으로써 클래스 멤버 변수들이<code>final</code>로 선언되어야 함을 강제하는 역할을 하기도 합니다.</p>
<p>근데 한 가지 의문이 드실겁니다🤔
BaseScreen를 상속받아서 사용할 때 getter 속성을 재정의할 수 있기 때문에 완전히 불변한 객체라고 보기 힘들고, 해당 클래스에서 관리하고 있는 <code>멤버 변수</code>가 하나도 없기 때문에 <code>final</code>을 강제할 필요할 필요도 없지 않냐고 제게 반문하실 수 있겠죠.</p>
<p>여러분이 맞습니다.</p>
<p>하지만, 저는 코드를 명시적으로 만들기 위해 <code>@immutable</code>를 사용하는 것을 선호합니다. 이를 통해 클래스의 멤버 변수들이 <code>final</code>로 선언되어야 함을 강조하고, 클래스의 성격을 불변성에 가깝게 만들기 위한 목적으로 사용합니다.</p>
<p>사실,<code>@immutable</code> 어노테이션이 없어도 코드의 동작에는 문제가 없습니다. 하지만, 해당 어노테이션을 사용함으로써 코드를 더 명확하게 작성할 수 있습니다.</p>
<h3 id="6-initviewmodel-메소드">6. initViewModel 메소드</h3>
<pre><code class="language-dart">  @protected
  void initViewModel() {
    vm.initialized;
  }</code></pre>
<p>거의 다 왔습니다. 마지막으로 <code>initViewModel</code> 메소드에 대해 알아봅시다.</p>
<p>이렇게 BaseScreen 클래스에서 사용하는 <code>initViewModel</code> 메소드는 GetxController가 <code>lazy하게 inject</code>되는 경우, GetX의 라이프사이클에 유의해야 할 때 중요한 역할을 합니다.</p>
<p>만약 <code>Get.lazyPut</code>을 사용하여 컨트롤러를 inject하고 있다면, 특정 위젯에서 GetxController의 인스턴스에 접근하기 전까지는 컨트롤러가 inject되지 않도록 설정되어 있기 때문에 initViewModel 메소드를 사용하여 컨트롤러를 <code>강제로 초기화</code>해야 합니다.</p>
<pre><code class="language-dart">class SplashViewModel extends GetxController {

  void someInitialMethod() {
    // some events
  }

  @override
  void onInit() {
    super.onInit();
    someInitialMethod(); // &lt;-- 해당 메소드가 발동이 되어야 함.
  }
}

class SplashScreen extends GetView&lt;SplashViewModel&gt; {
  const SplashScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text(&#39;Splash Screen&#39;),
    );
  }
}

</code></pre>
<p>예를 들어, Splash View와 SplashViewModel이 있다고 가정해 봅시다. Get.lazyPut을 사용하여 컨트롤러를 inject하였고, SplashScreen에서는 SplashViewModel 인스턴스에 접근하지 않습니다. 단지 ViewModel에서 관리되고 있는 someInitialMethod() 메소드가 Splash 화면이 나타날 때 실행되어야 한다는 것이 목표입니다.</p>
<p>하지만, SplashScreen에서 GetxController(SplashViewModel)의 인스턴스에 접근하지 않았기 때문에 GetXController 자체가 초기화되지 않아 someInitialMethod 이벤트도 발생하지 않을 것입니다.</p>
<pre><code class="language-dart">class SplashScreen extends GetView&lt;SplashViewModel&gt; {
  const SplashScreen({Key? key}) : super(key: key);


  @override
  Widget build(BuildContext context) {
   /// GetxController 기본 인스턴스에 접근
   /// 어떤 인스턴턴스에 접근하던지 상관 없음
   controller.initialized;

   //controller.isClosed;
   //controller.isBlank;
   //controller.runtimeType;


    return const Center(
      child: Text(&#39;Splash Screen&#39;),
    );
  }
}</code></pre>
<p>이럴 경우, 위의 코드에서와 같이 <code>initizlied</code> 라는 GetxController 기본 인스턴스에 접근하여 lazy하게 inject되는 컨트롤러를 강제로 초기화해야 합니다.</p>
<p>결과적으로 화면이 보일 때 무조건 GetxController를 초기화의 보장한다고 볼 수 있습니다.</p>
<pre><code class="language-dart">abstract class BaseScreen&lt;T extends GetxController&gt; extends GetView&lt;T&gt; {
  const BaseScreen({Key? key}) : super(key: key);


  @override
  Widget build(BuildContext context) {
    if (!vm.initialized) {
      initViewModel();
    }

    ....

    @protected
      void initViewModel() {
    vm.initialized;
  }</code></pre>
<p>이제 왜 BaseScreen 클래스에서 initViewModel 메소드를 사용하는지 이해가 될 것 입니다. </p>
<blockquote>
<p> <strong>NOTE</strong>
마지막으로 요약하자면, lazy inject되는 GetxController도 화면이 보여짐과 동시에 inject 되는 것을 보장하기 위해 initViewModel()매소드를 사용합니다.</p>
</blockquote>
<br/>

<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 <code>BaseScreen</code> 클래스를 기반으로 개발자가 화면 구성을 보다 효율적으로 구현할 수 있도록 돕고, MVVM 구조에서 View와 ViewModel의 역할과 책임을 명확하게 해주는 방법에 대해 알아보았습니다.</p>
<p>설명이 조금 복잡했다면, BaseScreen 예제 코드가 있는 <a href="https://github.com/Xim-ya/Base_MVVM_Helper_Moudle">깃헙 레포</a>를 클론 받아 이것저것 만져 보시길 바랍니다. 전혀 어렵진 않습니다.</p>
<p>한번 익숙해진다면 굉장히 편리하고 개발 생산성이 엄청 높아질겁니다😀</p>
<p>다음 포스팅에서는 <code>Provider</code> 상태 관리 라이브러리를 기반으로 한 <code>BaseScreen</code> 클래스를 구성하는 방법에 대해 다루겠습니다.</p>
<h4 id="아-마지막으로">아 마지막으로</h4>
<p>만약 <code>Scaffold</code>를 기반으로 한 완전한 앱 화면을 구성하지 않고 단순한 위젯을 빌드하고 컨트롤러와 화면 레이아웃을 분리하고 싶으시면 아래 코드를 사용하시면 됩니다.</p>
<pre><code class="language-dart">@immutable
abstract class BaseView&lt;T extends BaseViewModel&gt; extends GetView&lt;T&gt; {
  const BaseView({Key? key}) : super(key: key);

  T get vm =&gt; controller;

  @override
  Widget build(BuildContext context) {
    return buildView(context);
  }

  Widget buildView(BuildContext context);
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter, 프로답게 유틸리티 class 구현하기 (feat : 메모리 최적화)]]></title>
            <link>https://velog.io/@ximya_hf/howtowirtutilclasslikepro</link>
            <guid>https://velog.io/@ximya_hf/howtowirtutilclasslikepro</guid>
            <pubDate>Fri, 21 Apr 2023 19:01:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a>    </p>
</blockquote>
<p>일반적으로 앱 내에서 자주 사용되는 리소스(컬러, 폰트, 등등)를 관리하기 위해 <code>유틸리티 클래스</code>를 정의하여 사용합니다. 그런데 자주 사용되는 만큼 중요하지만 놓치기 쉬운 부분들이 있습니다. 이번 글에서는 유틸리티 클래스를 좋은 구조로 작성하는 방법에 대해 다루어 보려고 합니다.</p>
<p><strong>본 포스팅에서는 개선이 필요한 코드를 단계적으로 고쳐 나가며 <code>메모리 관점</code>에서 어떤 부분을 개선해야 할지 집중적으로 설명합니다.</strong></p>
<p>또한, 아래 개념에 대해 다룹니다.</p>
<ul>
<li>class instance</li>
<li>abstract class</li>
<li>private constructor</li>
<li>static properties</li>
</ul>
<br/>

<h2 id="code1-메모리를-낭비하는-코드">Code1: 메모리를 낭비하는 코드</h2>
<pre><code class="language-dart">class AppColor {
  Color darkGrey = const Color(0xFFBBBAC1);
  Color lightGrey = const Color(0xFF1F1F20);
  Color red = const Color(0xFFFF484E);
}</code></pre>
<p>AppColor라는 클래스는 프로젝트에서 자주 사용되는 color 값들을 정의하고 있습니다. 
이렇게 구성된 코드는 앱 전반에 걸쳐 AppColor 클래스를 기반의 color 값들을 불러올 수 있습니다.</p>
<p><strong>다만, 의도대로 작동은 하겠지만 효율적인 코드라고 보기는 힘듭니다.</strong>
이유는 color값이 필요할 때마다 <code>매번 인스턴스를 생성</code>해야하기 때문입니다. 그리고 매번 인스턴스를 생성하는건 메모리를 효율적으로 관리한다고 볼 수 없죠.</p>
<h3 id="클래스-인스턴스란">클래스 인스턴스란?</h3>
<p>먼저 클래스 인스턴스에 대해 간단히 짚고 갑시다. 클래스 인스턴스는 클래스에서 정의한 속성과 메서드를 가진 객체를 말합니다. 즉, 클래스를 기반으로 생성된 실제 데이터가 클래스 인스턴스입니다. 
스타크래프트로 비유하자면 <code>클래스</code> 자체는 <code>배럭</code>, 그리고 배럭에서 생성된 <code>마린</code>을 <code>클래스 인스턴스</code> 라고, 설명드릴 수 있겠네요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/d43dc6e5-1c5f-4a23-bb45-c41a8622ddf9/image.png" alt=""></p>
<p>*<em>하지만 클래스 코드가 적혀 있다고 모두 인스턴스를 생성하는 것은 아닙니다. *</em></p>
<pre><code class="language-dart">// 인트턴스 생성 코드
final darkGrey= Palette().darkGrey,
final ximya = Student(name: &quot;ximya&quot;, major: &quot;cs&quot;)

// 인스턴스 생성 코드 X
final red = Colors.red;
final textAlign = TextAlignVertical.top;
</code></pre>
<p><code>darkGrey</code> 변수에는 Palette클래스의 인스턴스가 담겨 있을 겁니다.(엄밀히 말하면 인스턴스의 주소가 담겨있음)
반면, <code>Colors.red</code>나 <code>TextAlignVertical.top</code>과 같은 값은 클래스의 인스턴스가 아니라, 클래스에서 정의한 정적 멤버(static member)입니다. 이 값들은 클래스가 로드될 때 메모리에 할당되며, 클래스의 인스턴스를 생성하지 않아도 참조할 수 있습니다.
즉 <code>final red = Colors.red</code> 코드는 인스턴스를 생성하고 저장하는 코드가 아닙니다.</p>
<p><strong>만약 헷갈리시면 하나만 기억하시면 됩니다.</strong>
인스턴스를 생성하는 코드는 필히 <code>생성자 구문</code>을 필요합니다. 즉 <code>()</code> 소괄호를 적어야 하는 거죠.
코드에 소괄호가 감싸져 있다면 <code>클래스 인스턴스를 생성하는 코드</code>, 반대의 경우라면 <code>이미 메모리상에 올라와 있는 값을 가져온 코드</code> 라고 이해하시면 됩니다.</p>
<br/>

<h3 id="코드의-메모리-구조">코드의 메모리 구조</h3>
<p>그럼, 실제 AppColor 클래스가 사용된 코드가 메모리 위에서 어떻게 작동하는지 알아보겠습니다.</p>
<pre><code class="language-dart">      body: ListView(
        children: [
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: AppColor().darkGrey, // -&gt; 클래스 인스턴스 생성
            height: 100,
            width: 100,
          ),
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: AppColor().lightGrey,  // -&gt; 클래스 인스턴스 생성
            height: 100,
            width: 100,
          ),
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: AppColor().red,  // -&gt; 클래스 인스턴스 생성
            height: 100,
            width: 100,
          ),
        ],
      ),</code></pre>
<p>위 코드에서 ListView를 사용하여 세 개의 Container 위젯을 생성하고, 각각 다른 색상으로 배경을 설정하고 높이와 너비를 100으로 지정하였습니다. 그리고 <code>AppColor</code> 클래스를 사용하여 각 Container 위젯의 배경색을 지정합니다. 앞서 언급했듯이 <code>클래스 인스턴스</code>를 생성하는 방식으로 color 속성에 값을 전달 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/16f4cde4-8dfd-4ed3-9d82-1aa800059f54/image.png" alt=""></p>
<p>메모리 구조를 대략적으로 표현한다면 위 그림과 같습니다. AppColor클래스 인스턴스들이 힙 영역에 저장되어 있는 구조입니다.</p>
<blockquote>
<p>본 포스팅에서메모리 할당 방식에 대해서 깊게 설명하지는 않지만 2가지 개념만 숙지해주세요.</p>
</blockquote>
<ul>
<li>클래스 인스턴스는 <code>힙(Heap)</code> 메모리 영역에 저장됨.</li>
<li>정적 변수 &amp; 전역 변수들을 <code>데이터(Data)</code>영역에 저장됨.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/ec9171f3-b24c-4324-88fe-567b7a2a60ae/image.png" alt=""></p>
<p>만약 여러 Widget에서 AppColor에 정의된 color 값들을 많이 사용해야 한다고 하면 그만큼 더 인스턴스를 매번 생성해야 하는 구조이기 때문에 메모리를 많이 차지하겠죠. 힙 영역에는 수 많은 인스턴스가 쌓이게 될 것 같습니다.</p>
<br/>

<h2 id="code2-전역변수로-관리">Code2: 전역변수로 관리</h2>
<p>매번 color 인스턴스들을 생성하는 게 좋은 구조는 아닌 것은 명백해 보입니다. 이제 효율적인 구조로 변경해 봅시다. 
<strong>어차피 프로젝트 전반에 걸쳐 자주 사용되는 Color 값들이라면 <code>한 번에 다 생성해 놓고 필요할 때마다 쓰는 방식</code>은 어떨까요?</strong></p>
<p>그럼 전역변수로 Color값들을 관리하는 방식을 고려해 볼 수 있겠네요.</p>
<pre><code class="language-dart">// lib/utilities/colors.dart 

const Color darkGrey = const Color(0xFFBBBAC1);
const Color lightGrey = const Color(0xFF1F1F20);
const Color red = const Color(0xFFFF484E);
</code></pre>
<p>이렇게 한 소스파일에 Color 값들을 전역으로 정의해 둔다면 프로젝트가 로드 될 때 모든 Color값들이 메모리상에서 올라갈 것이고, 이전 코드처럼 매번 인스턴스를 생성할 일도 없어질 것입니다.</p>
<h3 id="전역변수-메모리-구조">전역변수 메모리 구조</h3>
<p>이제는 메모리상에서 Color 값들이 어떻게 할당될지 확인해 봅시다.</p>
<pre><code class="language-dart">      body: ListView(
        children: [
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: darkGrey, 
            height: 100,
            width: 100,
          ),
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: lightGrey,
            height: 100,
            width: 100,
          ),
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: red,
            height: 100,
            width: 100,
          ),
        ],
      ),</code></pre>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/58c07282-615e-4fdc-a8b3-812e80017231/image.png" alt=""></p>
<p>클래스 인스턴스 값을 생성하지 않기 때문에 <code>힙</code> 영역에 color 값들 위치하지 않고 <code>데이터</code> 영역에 저장되어 있습니다. 결과적으로 데이터 영역에 할당된 값들을 쉽게 참조할 수 있는 상태이기 때문에 리소스를 효율적으로 사용할 수 있게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/07e0839c-af3f-4587-a038-20851788a27e/image.png" alt="">
좀 더 자세히 확인해 볼까요 🤔
<code>전역으로 관리하는 방법</code>과 <code>클래스 인스턴스 값으로 관리하는 방법</code>을 비교하여, 메모리 구조를 통해 더 효율적인 방법이 어떤 것인지 쉽게 알 수 있습니다.</p>
<p>여러 위젯에서 Color 값들이 필요하다고 가정한다고 했을 때, 전자의 경우 전역으로 관리되고 있는 Color 값들을 참조하면 되기 때문에 큰 리소스가 발생되지 않습니다.
반면 클래스 인스턴스로 값을 할당해야 하는 경우 매번 클래스 객체를 생성하기 때문에 <code>오버헤드</code>가 발생할 수밖에 없죠.</p>
<br/>

<h2 id="code3-더욱-구조화된-코드로-관리">Code3: 더욱 구조화된 코드로 관리</h2>
<p>color 값들을 전역으로 선언하여  관리하는 방법이 리소스를 효율적으로 사용할 수 있는 부분이 장점이지만, <strong>적절한 <code>그룹화</code>나 관리 없이 <code>전역 범위에</code>서 정의되어 있어 코드의 구조와 가독성을 떨어트린다고 볼 수 있습니다.</strong></p>
<p>그럼 어떻게 코드를 구조화 할 수 있을까요?</p>
<p>이때 <code>클래스</code>를 적절하게 이용하면됩니다.</p>
<pre><code class="language-dart">class AppColor {
  static const darkGrey = const Color(0xFFBBBAC1);
  static const lightGrey = const Color(0xFF1F1F20);
  static const red = const Color(0xFFFF484E);
}</code></pre>
<p>위 코드처럼 color 값들을 <code>정적 상수(static const)</code>로 선언하여 <code>클래스 인스턴스화</code> 없이 사용할 수 있습니다. 
<strong>정리하자면 앞서 소개해 드린 두 번째 방식(전역으로 관리)과 기능적으로 <code>동일하게 작동</code>된다고 볼 수 있습니다.</strong></p>
<h3 id="static-properties-메모리-구조">static properties 메모리 구조</h3>
<p>이번에도 메모리 구조를 살펴 봅시다. 예시는 이전과 동일합니다.</p>
<pre><code class="language-dart">      body: ListView(
        children: [
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: AppColor.darkGrey,  // -&gt; 인스턴스 생성 X
            height: 100,
            width: 100,
          ),
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: AppColor.lightGrey, // -&gt; 인스턴스 생성 X
            height: 100,
            width: 100,
          ),
          Container(
            margin: const EdgeInsets.only(bottom: 20),
            color: AppColor.red,  // -&gt; 인스턴스 생성 X
            height: 100,
            width: 100,
          ),
        ],
      ),</code></pre>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/3019ee29-cc21-4acd-ac96-7190934a2997/image.png" alt=""></p>
<p>여기서 재미있는건 분명 클래스를 이용하고 있지만 <code>힙</code>영역에 인스턴스를 생성하지 않고 <code>데이터</code> 영역에 color 값들이 저장되어 있다는 것입니다.</p>
<p>그 이유는 AppColor 클래스가 처음 로드될 때 <code>static properties</code>(정작 상수 값)들이 데이터 영역에 할당되기 때문입니다.</p>
<p><code>static properties</code>는 프로그램의 수명 동안 메모리에 남아 있으며, 다른 모든 인스턴스와 공유됩니다. 이러한 이유로, 메모리 사용을 최적화하고 전체 애플리케이션에서 공통으로 사용되는 값을 저장하는 데 유용합니다.</p>
<br/>


<h2 id="code4-좀-더-명시적인-코드">Code4: 좀 더 명시적인 코드</h2>
<p>자, 이제 마지막 단계 입니다.
이전에 소개드린 접근방식도 충분히 괜찮은 코드라고 볼 수 있지만 좀 더 <code>프로</code> 답게 코드를 작성할 수 있는 방법이 있습니다.</p>
<pre><code class="language-dart">abstract class AppColor {
  AppColor._();

  static const darkGrey = const Color(0xFFBBBAC1);
  static const lightGrey = const Color(0xFF1F1F20);
  static const red = const Color(0xFFFF484E);
}</code></pre>
<h3 id="abstract-class">abstract class</h3>
<p>위 AppColor 클래스처럼 클래스의 목적이 <code>static properties</code>을 제공하기 위함이라면 <code>추상</code>(abstract) 클래스를 이용하면 됩니다. 추상 클래스를 사용하면 AppColor 클래스의 인스턴스를 만들 수 없으므로 <code>클래스를 실수로 인스턴스화하는 것을 방지</code>합니다.</p>
<h3 id="private-constructor">private constructor</h3>
<p><code>AppColor._();</code> , 이렇게 prviate 생성자를 적는 이유도 마찬가지입니다. 개발자가 실수로 인스턴스화 하는 코드를 작성하는 실수를 방지할 수 있습니다.</p>
<p>또한, <strong>실수를 방지할 수 있는 것 뿐만 아니라 <code>정적 상수를 사용하는 데 초점</code>을 맞추기 때문에 더 가독성을 고려하고 명시적인 코드라고 볼 수 있습니다.</strong>

결론적으로, <code>abstract class</code>, <code>private constructor</code> 을 적용하지 않아도 기능에 전혀 문제가 없지만 코드의 목적을 명확하게 나타내고, 실수로 인스턴스화되는 것을 방지하기 위해서 사용이 권장됩니다. </p>
<br/>

<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 어떻게 효율적인 구조로 유틸리티 클래스를 작성하는 방법에 대해 알아보았습니다.
그럼 순삭 앱에서 실제 적용된 코드 사례를 몇 가지 소개드리며 글을 마무리 하겠습니다 😀</p>
<h4 id="app_pagesdart">app_pages.dart</h4>
<pre><code class="language-dart">abstract class AppPages {
  AppPages._();

  static final routes = [
    // 스플래쉬
    GetPage(
      name: AppRoutes.splash,
      page: SplashScreen.new,
      binding: SplashBinding(),
    ),

    // 로그인
    GetPage(
      name: AppRoutes.login,
      page: LoginScreen.new,
      binding: LoginBinding(),
    ),

    // 컨텐츠 상세
    GetPage(
      name: AppRoutes.contentDetail,
      page: ContentDetailScreen.new,
      binding: ContentDetailBinding(),
    ),

    // 검색
    GetPage(
      name: AppRoutes.search,
      page: SearchScreen.new,
      binding: SearchBinding(),
    ),
    .....
   }
</code></pre>
<br/>

<h4 id="app_pagesdart-1">app_pages.dart</h4>
<pre><code class="language-dart">abstract class AppRoutes {
  AppRoutes._();

  // 스플래시
  static const splash = &#39;/&#39;;

  // 로그인
  static const login = &#39;/login&#39;;

  // 탭
  static const tabs = &#39;/tabs&#39;;

  .....
 }</code></pre>
<br/>

<h4 id="app_spacedart">app_space.dart</h4>
<pre><code class="language-dart">abstract class AppSpace {
  AppSpace._();

  static const size2 = SizedBox(width: 2, height: 2);
  static const size4 = SizedBox(width: 4, height: 4);
  static const size6 = SizedBox(width: 6, height: 6);
  static const size7 = SizedBox(width: 7, height: 7);

  .....
 }</code></pre>
<br/>

<h4 id="app_fire_storedart">app_fire_store.dart</h4>
<pre><code class="language-dart">abstract class AppFireStore {
  AppFireStore._(); 

  static final FirebaseFirestore _db = FirebaseFirestore.instance;

  static FirebaseFirestore get getInstance =&gt; _db; 
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter,  불필요한 import 구문들을 명령어 한 줄로 제거하기 (dart fix)]]></title>
            <link>https://velog.io/@ximya_hf/dartfixapplyridofuselessimports</link>
            <guid>https://velog.io/@ximya_hf/dartfixapplyridofuselessimports</guid>
            <pubDate>Wed, 12 Apr 2023 05:22:43 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p>정신없이 개발에 몰입하다 보면, <code>불필요한 import 구문</code>을 적기 마련입니다. 아래 코드처럼 말이죠.
<img src="https://velog.velcdn.com/images/ximya_hf/post/54ef2f4d-e6b1-4960-be4f-042700f9b6b2/image.png" alt=""></p>
<p>중복되고 사용하지 않는 구문들이 적혀 있네요. 굉장히 지저분해 보입니다.
그리고 이런 코드들을 하나하나 확인하고 지우는 것도 굉장히 귀찮은 작업니다 😂</p>
<p>이럴 때 명령어 하나만으로 쉽게 해결할 수 있습니다.</p>
<h2 id="dart-fix-명령어">Dart Fix 명령어</h2>
<pre><code class="language-dart">dart fix --apply</code></pre>
<p>Flutter 팀에서 제공하는 <code>dart fix --apply</code> 명령어를 입력하면 불필요한 import 구문을 자동으로 제거해줍니다.</p>
<blockquote>
<p>NOTE: 명령어를 실행할 위치는 Flutter 프로젝트의 최상위 경로여야 한다는 점을 참고하세요. (모든 소스파일들을 검사하기 위해서)</p>
</blockquote>
<p><strong>또한 프로젝트에서 설정한 <code>lint 규칙</code>에 맞지 않는 코드들도 알맞게 수정해줍니다.</strong>
프로젝트 도중 새로운 lint규칙을 설정한다고 했을 때 굉장히 유용하겠네요.</p>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/df86be62-e276-4f42-904f-d04273e8e73d/image.png" alt=""></p>
<p><code>dart fix --apply</code> 명령어로 불필요한 import구문을 제거했더니 이렇게 코드가 깔끔해졌습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter, ListView.builder 렌덩링 최적화]]></title>
            <link>https://velog.io/@ximya_hf/listviewbuilder</link>
            <guid>https://velog.io/@ximya_hf/listviewbuilder</guid>
            <pubDate>Thu, 06 Apr 2023 10:28:41 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9677c14f-1096-4030-94a4-6d2784b4c745/image.png" alt=""></p>
<blockquote>
<p>해당 포스팅은 유튜브 영화&amp;드라마 리뷰 영상 큐레이션 플랫폼 <code>Plotz</code>를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : <a href="https://apps.apple.com/kr/app/%EC%88%9C%EC%82%AD/id1671820197">앱스토어</a> / <a href="https://play.google.com/store/apps/details?id=com.soon_sak">플레이스토어</a></p>
</blockquote>
<p>Flutter에서 대용량 데이터를 처리하고 화면에 보여주기 위해 <code>ListView.builder</code>를 주로 사용합니다.
ListView.builder는 데이터를 미리 모두 로드하지 않고, <code>필요한 부분만 동적으로 생성</code>하므로 데이터를 효율적으로 처리할 수 있는 거죠.</p>
<p>예를 들어, 100개의 리스트 아이템이 있을 때, 화면에 보이는 리스트 아이템은 10개일 수 있습니다. 이 경우, ListView.builder는 보이는 10개의 리스트 아이템만 렌더링하고, 스크롤 되는 방향으로 보이지 않게 되는 위젯은 제거합니다. 이를 통해 화면에 보이지 않는 리스트 아이템을 생성하지 않으므로, <code>메모리</code>사용량과 <code>렌더링 시간</code>을 줄일 수 있습니다.</p>
<h2 id="불필요한-리렌더링">불필요한 리렌더링</h2>
<p>이렇게 ListView.builder를 사용함으로 메모리를 효율적으로 사용하고 렌더링 퍼포먼스를 개선할 수 있지만 1가지 문제점이 있습니다
ListView.builder는 <code>스크롤이 되는 방향으로 보이지 않게 되는 위젯을 제거</code>하기 때문에 이미 한번 렌더링이 된 위젯들을 다시 렌더링 해버립니다.</p>
<p>아래 스크린 녹화 영상을 보면서 자세히 이야기해보죠.
<img src="https://velog.velcdn.com/images/ximya_hf/post/4ef8ef29-6a28-4922-928d-f50079b01168/image.gif" alt="">
순삭 콘텐츠 상세 스크린의 정보 탭에는 콘텐츠의 이미지 데이터들을 ListView.builder 형태로 보여주고 있습니다. 이때 오른쪽으로 스크롤 하여 더 많은 이미지를 확인하고 다시 첫 번째 이미지 위젯을 돌아간다고 했을 때, 이미 렌더링인 된 이미지 위젯이 한 번 더 <code>렌더링</code> 된다는 걸 <code>Shimmer(스켈레톤)효과</code>로 확인할 수 있습니다. </p>
<p><code>리스트의 아이템들이 뷰포트에서 사라졌다가 다시 나타날 때 상태를 유지</code>하고 싶으면 어떻게 해야 할까요?</p>
<h2 id="automatickeepaliveclientmixin-적용">AutomaticKeepAliveClientMixin 적용</h2>
<p>Flutter에서 제공하는 <code>AutomaticKeepAliveClientMixin</code>을 통해 ListView.builder의 아이템 위젯들이 리렌더링이 되는 것을 방지할 수 있습니다.
이 mixin은 위젯 트리에서 해당 위젯이 일시적으로 사라져도 여전히 관리하고 싶은 상태를 유지할 수 있도록 도와줍니다.</p>
<p>AutomaticKeepAliveClientMixin을 적용하는 방법은 간단합니다. 먼저, StatefulWidget 클래스를 상속하고, AutomaticKeepAliveClientMixin을 믹스인합니다. 그리고, <code>wantKeepAlive</code> 속성을 true로 설정하여 해당 위젯을 보존하도록 지정합니다. 이렇게 하면, 해당 위젯이 화면에서 사라지더라도 자동으로 보존되고, 이전 상태를 유지할 수 있습니다.</p>
<p>예를 들어, 다음과 같이 AutomaticKeepAliveClientMixin을 적용할 수 있습니다.</p>
<pre><code class="language-dart">class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() =&gt; _MyWidgetState();
}

class _MyWidgetState extends State&lt;MyWidget&gt; with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive =&gt; true;

  @override
  Widget build(BuildContext context) {
    super.build(context); 
    return Container(
      // ...
    );
  }
}</code></pre>
<br/>

<h2 id="모듈화-keepaliveview">모듈화 (KeepAliveView)</h2>
<p>이때 이 AutomaticKeepAliveClientMixin mixin이 적용된 StatefulWidget을 ListView.builder<code>부모위젯</code>으로 감싸는게 아니라 <code>itembuilder</code> 의 <code>아이템 위젯</code>에 감싸야 하는 것에 유의해야 합니다.</p>
<p>일단 코드를 간결하기 위해서 해당 StatefulWidget을 모듈화하겠습니다.</p>
<pre><code class="language-dart">class KeepAliveView extends StatefulWidget {
  final Widget child;

  const KeepAliveView({Key? key, required this.child}) : super(key: key);

  @override
  State&lt;KeepAliveView&gt; createState() =&gt; _KeepAliveViewState();
}

class _KeepAliveViewState extends State&lt;KeepAliveView&gt;
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive =&gt; true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }
}
</code></pre>
<p>KeepAlieView라는 AutomaticKeepAliveClientMixin이 적용된 StatefulWidget을 만들었고, ListView.builder의 아이템을 Argument로 받기 위해 <code>child</code>라는 위젯 타입의 프로퍼티를 설정하였습니다.</p>
<pre><code class="language-dart">ListView.builder(
              padding: const EdgeInsets.only(left: 16),
              scrollDirection: Axis.horizontal,
              shrinkWrap: true,
              itemCount: vm.contentImgList?.length ?? 0,
              itemBuilder: (context, index) {
                final imgItem = vm.contentImgList![index];
                return KeepAliveView(   //&lt;-- KeepAliveView
                  child: CachedNetworkImage(
                    fit: BoxFit.contain,
                    imageUrl: imgItem.prefixTmdbImgPath,
                    height: 100,
                    width: SizeConfig.to.screenWidth - 32,
                    imageBuilder: (context, imageProvider) =&gt; Container(
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(6),
                        image: DecorationImage(
                          image: imageProvider,
                          fit: BoxFit.fitWidth,
                        ),
                      ),
                    ),
                    placeholder: (context, url) =&gt; Shimmer(
                      child: Container(
                        color: AppColor.black,
                      ),
                    ),
                    errorWidget: (context, url, error) =&gt;
                        const Center(child: Icon(Icons.error)),
                  ),
                );
              },
            )</code></pre>
<p>이제 좀 더 편하게  ListView.builder의 <code>아이템</code> 부분에 <code>AutomaticKeepAliveClientMixin</code>이 믹스인된 StatefulWidget을 감쌀 수 있습니다.</p>
</br>

<h2 id="렌더링-측정-결과">렌더링 측정 결과</h2>
<p>그럼 이제 리스트의 아이템들이 뷰포트에서 사라졌다가 다시 나타날 때 리렌더링을 하지 않는지, <code>Flutter Dev Tools</code>를 통해 확인해 봅시다.</p>
<blockquote>
<p><strong>측정 방법</strong>
ListView.builder 스크롤 방향을 10개의 아이템 위젯을 확인하고, 다시 첫 번째 위젯으로 돌아간다고 했을 때의 경우로 렌더링 횟수를 측정.
이때 KeepAliveView의 자식 위젯인 <code>CachedNetworkImage</code> 위젯을 기준으로 함.</p>
</blockquote>
<h3 id="keepaliveview를-적용하지-않았을-때">KeepAliveView를 적용하지 않았을 때</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/85a50c99-995a-4523-808a-6fde3db6011e/image.png" alt=""></p>
<p>KeeapAliveView를 적용하지 않으면 총 ListView.builder의 아이템 위젯이 총 21번 정도 렌더링이 됩니다. 다시 첫 번째 위젯으로 돌아가기 위해 반대로 스크롤을 했을 때 이전에 렌더링이된 위젯도 한번 더 렌더링 된 것이죠. </p>
<h3 id="keepaliveview-적용">KeepAliveView 적용</h3>
<p><img src="https://velog.velcdn.com/images/ximya_hf/post/9bb65be0-15c3-4775-a405-294e576fa090/image.png" alt=""></p>
<p>반대로 KeepAliveView를 ListView.builder 아이템을 감싸면 총 13번 정도 렌더링이 됩니다. 기존에 렌더링이 된 위젯의 상태를 AutomaticKeepAliveClientMixin를 통해 유지하기 문에 다시 리렌더링이 일어나지 않습니다.</p>
</br>


<h2 id="keepaliveview을-적용하는게-무조건적으로-좋을까">KeepAliveView을 적용하는게 무조건적으로 좋을까?</h2>
<p>앞서 소개해 드린 방식으로 불필요한 위젯 렌더링을 방지하여 앱의 성능을 향상하는 데 도움을 줍니다.
<code>하지만 항상 앱의 성능을 향상시킨다고 말하긴 힘듭니다.</code></p>
<p>왜냐하면 ListView.builder에서 이미 렌더링이 된 위젯의 상태를 보존하기 위해 <code>메모리를 항상 점유</code>하고 있기 때문입니다. 만약 리스트 아이템이 한번 뷰포트에 노출되고 다시 돌아갈 일이 없는 ListView.builder라고하면, 오히려 <code>불필요하게 렌더링이된 위젯의 상태를 유지</code>하는 거겠죠.</p>
<p>따라서, AutomaticKeepAliveClientMixin을 적용할 때는, 언제 어떻게 사용해야 하는지를 잘 판단하여야 합니다. 불필요한 위젯 렌더링을 방지하여 성능을 향상하기 위해 사용하는 것이 가장 이상적입니다. 그러나,메모리 사용량과 성능 사이의 균형을 잘 조절하여야 하며, 사용하는 상황에 따라 적절하게 사용해야 합니다.</p>
]]></description>
        </item>
    </channel>
</rss>