<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Hand-over.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 13 Oct 2025 00:55:24 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. Hand-over.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/loopback_log" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[TECH]왜 HEIC를 JPG로 변환하면 용량이 커질까?]]></title>
            <link>https://velog.io/@loopback_log/TECH%EC%99%9C-HEIC%EB%A5%BC-JPG%EB%A1%9C-%EB%B3%80%ED%99%98%ED%95%98%EB%A9%B4-%EC%9A%A9%EB%9F%89%EC%9D%B4-%EC%BB%A4%EC%A7%88%EA%B9%8C</link>
            <guid>https://velog.io/@loopback_log/TECH%EC%99%9C-HEIC%EB%A5%BC-JPG%EB%A1%9C-%EB%B3%80%ED%99%98%ED%95%98%EB%A9%B4-%EC%9A%A9%EB%9F%89%EC%9D%B4-%EC%BB%A4%EC%A7%88%EA%B9%8C</guid>
            <pubDate>Mon, 13 Oct 2025 00:55:24 GMT</pubDate>
            <description><![CDATA[<h2 id="이슈">이슈</h2>
<p>회사에서 이미지 업로드 시에 10MB제한을 걸었는데 실제 용량이 최대 5MB에 불과함에도 방어코드에 걸리는 이슈가 있었다.</p>
<h2 id="heic-→-jpg-변환-시-용량이-커지는-이유">HEIC → JPG 변환 시 용량이 커지는 이유</h2>
<p>아이폰으로 찍은 사진을 보면 확장자가 <strong>.heic</strong>인 경우가 많다.<br>그런데 이 파일을 <strong>.jpg</strong>로 변환하면 이상하게 용량이 커진다.<br>“둘 다 사진인데 왜 커질까?”<br>이건 단순한 형식 차이가 아니라 <strong>압축 기술 세대의 차이</strong> 때문이다.</p>
<hr>
<h2 id="1-heic는-hevc-기반-jpg는-90년대-기술">1. HEIC는 HEVC 기반, JPG는 90년대 기술</h2>
<p>HEIC는 <strong>High Efficiency Image Container</strong>의 약자다.<br>내부적으로 <strong>HEVC(H.265)</strong> 영상 압축 기술을 사용한다.<br>반면 JPG(JPEG)는 1990년대 초반에 만들어진 <strong>DCT(Discrete Cosine Transform)</strong> 기반 압축 방식을 쓴다.</p>
<p>즉,  </p>
<ul>
<li>HEIC는 <strong>H.265 동영상 기술을 정지 이미지에 적용한 최신 포맷</strong>,  </li>
<li>JPG는 <strong>1990년대의 고전적인 포맷</strong>이다.</li>
</ul>
<table>
<thead>
<tr>
<th>항목</th>
<th>HEIC</th>
<th>JPG</th>
</tr>
</thead>
<tbody><tr>
<td>등장 시기</td>
<td>2017년 (iOS 11)</td>
<td>1992년</td>
</tr>
<tr>
<td>압축 방식</td>
<td>HEVC(H.265)</td>
<td>DCT(JPEG)</td>
</tr>
<tr>
<td>압축 효율</td>
<td>높음 (약 50% 작음)</td>
<td>낮음</td>
</tr>
<tr>
<td>색심도</td>
<td>최대 10bit</td>
<td>8bit</td>
</tr>
<tr>
<td>평균 용량</td>
<td>작음</td>
<td>큼</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-변환은-단순한-형식-변경이-아니다">2. 변환은 단순한 ‘형식 변경’이 아니다</h2>
<p>많은 사람이 HEIC → JPG 변환을<br>그냥 확장자만 바꾸는 일로 생각하지만 실제로는 그렇지 않다.</p>
<p>이 과정에서 HEIC의 고효율 압축 데이터를 풀었다가,<br>효율이 낮은 JPG 알고리즘으로 다시 압축한다.<br>그래서 <strong>같은 화질을 유지하려면 용량이 커질 수밖에 없다.</strong></p>
<hr>
<h2 id="3-heic와-jpg의-압축-방식-차이">3. HEIC와 JPG의 압축 방식 차이</h2>
<p><strong>HEIC (HEVC 기반)</strong>  </p>
<ul>
<li>인접한 픽셀의 색과 밝기를 예측하고<br>실제 값과의 차이(오차값)만 저장한다.  </li>
<li>동영상 압축처럼 <strong>공간적 상관관계</strong>를 적극 활용한다.</li>
</ul>
<p><strong>JPG (DCT 기반)</strong>  </p>
<ul>
<li>이미지를 <strong>8×8 블록</strong>으로 나누어 각각을 독립적으로 압축한다.  </li>
<li>블록 간 관계를 고려하지 않아 압축 효율이 떨어진다.</li>
</ul>
<p>예를 들어 같은 12MP 사진이라면:</p>
<table>
<thead>
<tr>
<th>포맷</th>
<th>평균 용량</th>
<th>화질 수준</th>
</tr>
</thead>
<tbody><tr>
<td>HEIC</td>
<td>약 2MB</td>
<td>기준</td>
</tr>
<tr>
<td>JPG (quality 95)</td>
<td>약 6~8MB</td>
<td>비슷한 화질</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-색심도비트-깊이-차이">4. 색심도(비트 깊이) 차이</h2>
<p>HEIC는 <strong>최대 10bit 색심도</strong>를 지원한다.<br>한 픽셀의 색을 더 정밀하게 표현할 수 있다.</p>
<p>JPG는 <strong>8bit</strong>까지만 가능하다.<br>HEIC를 JPG로 바꿀 때는 <strong>고비트 데이터를 낮은 비트로 줄이는 과정</strong>이 필요하다.<br>이때 <strong>디더링(dithering)</strong> 을 적용해 색 계단 현상을 줄이지만,<br>픽셀 데이터가 복잡해지면서 <strong>압축 효율이 떨어지고 용량이 커진다.</strong></p>
<hr>
<h2 id="5-변환-시-품질quality-인자-영향">5. 변환 시 ‘품질(quality)’ 인자 영향</h2>
<p>JPG로 저장할 때는 품질(quality) 값을 지정할 수 있다.<br>값이 높을수록 화질은 좋지만 용량도 커진다.</p>
<table>
<thead>
<tr>
<th>원본</th>
<th>변환 품질</th>
<th>변환 후 용량</th>
</tr>
</thead>
<tbody><tr>
<td>HEIC 2MB</td>
<td>JPG quality=95</td>
<td>약 6~8MB</td>
</tr>
<tr>
<td>HEIC 2MB</td>
<td>JPG quality=80</td>
<td>약 3~4MB</td>
</tr>
</tbody></table>
<p>결국 <strong>같은 화질을 유지하면 용량이 커지고</strong>,  
용량을 줄이려면 화질을 낮춰야 한다.</p>
<hr>
<h2 id="6-메타데이터-구조-차이">6. 메타데이터 구조 차이</h2>
<p>HEIC는 <strong>하나의 컨테이너 안에 여러 이미지와 EXIF 데이터를 효율적으로 압축</strong>한다.<br>“라이브 포토”처럼 여러 장이 들어가도 하나의 파일로 관리된다.</p>
<p>JPG는 <strong>헤더 세그먼트마다 중복 메타데이터</strong>를 포함할 수 있다.<br>이 구조 차이 때문에 JPG가 더 많은 공간을 차지한다.</p>
<hr>
<h2 id="7-flutter에서의-변환-과정">7. Flutter에서의 변환 과정</h2>
<p>Flutter에서 HEIC → JPG 변환을 수행하면<br>기본적으로 “디코드 후 재인코드”가 일어난다.</p>
<p>자주 쓰는 라이브러리는 다음과 같다.</p>
<ul>
<li><a href="https://pub.dev/packages/heic_to_jpg"><code>heic_to_jpg</code></a>: 네이티브에서 HEIC를 디코딩 후 JPG로 저장  </li>
<li><a href="https://pub.dev/packages/flutter_image_compress"><code>flutter_image_compress</code></a>: <code>quality</code> 값으로 용량 제어 가능</li>
</ul>
<p>예시 코드:</p>
<pre><code class="language-dart">final result = await FlutterImageCompress.compressWithFile(
  inputPath,
  format: CompressFormat.jpeg,
  quality: 80, // 낮을수록 용량 줄어듦
  outputPath: outputPath,
);</code></pre>
<h2 id="정리">정리</h2>
<p>HEIC는 HEVC(H.265) 기반의 고효율 압축을 쓴다.</p>
<p>JPG는 오래된 DCT 기반 압축이라 효율이 낮다.</p>
<p>변환은 단순 복사가 아니라 디코드 + 재인코드 과정이다.</p>
<p>같은 화질을 유지하면 JPG 용량이 커진다.</p>
<p>Flutter에서는 flutter_image_compress로 품질 제어가 가능하다.</p>
<p>한 줄 요약
HEIC → JPG 변환은 ‘효율적인 압축을 풀고, 덜 효율적인 방식으로 다시 압축하는 과정’이다.
따라서 용량이 커지는 건 자연스러운 결과다.</p>
<h2 id="참고-자료">참고 자료</h2>
<p>Adobe: <a href="https://www.adobe.com/creativecloud/file-types/image/comparison/heic-vs-jpeg.html?utm_source=chatgpt.com">HEIC vs JPEG</a></p>
<p>Wikipedia: <a href="https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding?utm_source=chatgpt.com">Wikipedia:  (HEVC)
</a></p>
<p>Wikipedia: <a href="https://en.wikipedia.org/wiki/High_Efficiency_Image_File_Format?utm_source=chatgpt.com">High Efficiency Image File Format (HEIF)</a></p>
<p>Petapixel: <a href="https://petapixel.com/what-is-an-heic-file/?utm_source=chatgpt.com">What is an HEIC File?</a></p>
<p>Cloudinary: <a href="https://cloudinary.com/guides/image-formats/jpeg-vs-heic?utm_source=chatgpt.com">JPEG vs HEIC</a></p>
<p>Wikipedia: <a href="https://en.wikipedia.org/wiki/JPEG?utm_source=chatgpt.com">JPEG</a></p>
<p>pub.dev: <a href="https://pub.dev/packages/flutter_image_compress?utm_source=chatgpt.com">flutter_image_compress</a></p>
<p>pub.dev: <a href="https://pub.dev/documentation/heic_to_jpg/latest/?utm_source=chatgpt.com">heic_to_jpg</a></p>
<p>StackOverflow: <a href="https://stackoverflow.com/questions/62190594/flutter-picking-images-as-jpg-png-instead-of-heic-on-ios?utm_source=chatgpt.com">Picking images as jpeg/png instead of heic on iOS</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]PaintingBinding.instance.imageCache]]></title>
            <link>https://velog.io/@loopback_log/FlutterPaintingBinding.instance.imageCache</link>
            <guid>https://velog.io/@loopback_log/FlutterPaintingBinding.instance.imageCache</guid>
            <pubDate>Wed, 01 Oct 2025 00:38:37 GMT</pubDate>
            <description><![CDATA[<p>우리 회사는 인스타그램, 핀터레스트 못지 않게 많은 이미지를 사용한다.
이전에 FastCachedNetworkImage패키지를 썼으나, main.dart에서 await init을 하는 시점에서 계획되지 않은 캐싱 전략으로 앱 용량은 1GB가 넘어갔고 결국 OS스플래시 단에서 10초 가까이 걸리는 이슈가 발생했다.
그걸 대체하기 위해 좀 더 이미지를 공부했다.</p>
<h3 id="요약">요약</h3>
<p>PaintingBinding.instance.imageCache는 전역 메모리 이미지 캐시다. 
디코딩된 비트맵을 LRU(<a href="https://velog.io/@loopback_log/LRULease-Recently-Used-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98">LRU 참고</a>)로 보관한다. 
기본 한도는 개수 1000장, 용량 100MB다. 필요하면 maximumSize, maximumSizeBytes로 조정한다.
<a href="https://api.flutter.dev/flutter/painting/ImageCache-class.html?utm_source=chatgpt.com">ImageCache Class</a></p>
<p>ImageProvider가 이미지를 불러오면 ImageCache.putIfAbsent로 캐시에 등록된다. 항목 상태는 pending / live / keepAlive로 추적한다. 조회는 containsKey나 statusForKey로 한다. 
<a href="https://api.flutter.dev/flutter/painting/ImageCache-class.html?utm_source=chatgpt.com">ImageCache class
</a>
2025 변경점: 캐시가 큰 이미지에 맞춰 자동 확대되지 않는다. 큰 이미지를 자주 다루면 maximumSizeBytes를 올리거나 디코드 크기를 줄여야 한다. 
<a href="https://docs.flutter.dev/release/breaking-changes/imagecache-large-images?utm_source=chatgpt.com">ImageCache large images</a></p>
<h4 id="1-무엇이고-어디에-있나">1) 무엇이고 어디에 있나</h4>
<p>imageCache는 PaintingBinding이 보관하는 싱글턴이다. 프레임워크 전역에서 공유된다. 일반적으로 직접 교체하지 않고 설정값만 조정한다. 
<a href="https://api.flutter.dev/flutter/painting/PaintingBinding/imageCache.html?utm_source=chatgpt.com">imageCache property
</a></p>
<h4 id="기본-정책">기본 정책</h4>
<p>LRU 기반. 초과 시 가장 덜 최근 사용 항목부터 퇴출한다.</p>
<p>기본 상한: 최대 1000개, 최대 100MB. 값은 런타임에 조정 가능. </p>
<pre><code class="language-dart">// 전역 캐시 한도 조정 예
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  final cache = PaintingBinding.instance.imageCache;
  cache.maximumSize = 150;              // 개수 상한
  cache.maximumSizeBytes = 100 &lt;&lt; 20;   // 100MB
  runApp(const MyApp());
}</code></pre>
<h3 id="2-이미지가-캐시에-들어오는-흐름">2) 이미지가 캐시에 들어오는 흐름</h3>
<p>ImageProvider가 키를 만들고 ImageCache.putIfAbsent(key, loader)를 호출한다.</p>
<p>캐시에 없으면 loader가 디코딩을 시작하고 상태가 pending이 된다.</p>
<p>성공적으로 디코딩되고 크기 제한에 맞으면 keepAlive로 보관된다. 리스너가 붙어 있으면 live로도 표시된다.</p>
<p>한도 초과 시 LRU로 퇴출한다. 
<a href="https://api.flutter.dev/flutter/painting/ImageCache/putIfAbsent.html?utm_source=chatgpt.com">putIfAbsent method</a></p>
<p>상태 조회</p>
<pre><code class="language-dart">final key = /* ImageProvider가 내부적으로 만드는 키 */;
final inCache = PaintingBinding.instance.imageCache.containsKey(key);
final status = PaintingBinding.instance.imageCache.statusForKey(key);
// status.pending / status.keepAlive / status.live</code></pre>
<p><a href="https://api.flutter.dev/flutter/painting/ImageCache/containsKey.html?utm_source=chatgpt.com">containsKey method</a></p>
<h3 id="3-한도와-퇴출">3) 한도와 퇴출</h3>
<p>maximumSize : 보관 개수 상한. 초과 시 LRU로 제거.</p>
<p>maximumSizeBytes : 총 용량 상한. 초과 시 LRU로 제거.</p>
<p>값을 0으로 만들면 즉시 비운다. 다시 원래 값으로 돌리면 빈 상태에서 재시작한다. 
<a href="https://api.flutter.dev/flutter/painting/ImageCache/maximumSize.html?utm_source=chatgpt.com">maximumSize property</a></p>
<h4 id="현재-사용량-관찰">현재 사용량 관찰</h4>
<pre><code class="language-dart">final cache = PaintingBinding.instance.imageCache;
debugPrint(&#39;count=${cache.currentSize}, bytes=${cache.currentSizeBytes}&#39;);
</code></pre>
<h4 id="개별전체-비우기">개별/전체 비우기</h4>
<pre><code class="language-dart">// 개별 제거(키 필요). live도 제거하려면 includeLive: true
PaintingBinding.instance.imageCache.evict(myKey, includeLive: true);

// 전체 제거
PaintingBinding.instance.imageCache.clear();</code></pre>
<p>주의: 빌드 중에 clear()를 반복 호출하면 스크롤 시 재디코딩 난사로 jank가 커진다. (설계상 clear는 즉시 전부 퇴출한다.) 
<a href="https://api.flutter.dev/flutter/painting/ImageCache/clear.html?utm_source=chatgpt.com">clear method</a></p>
<h3 id="4-2025-변경점-큰-이미지와-캐시">4) 2025 변경점: 큰 이미지와 캐시</h3>
<p>이전에는 큰 이미지 하나가 들어오면 캐시 용량을 그 크기에 맞게 늘렸다. 이제는 자동 확대 금지다. 캐시보다 큰 이미지는 캐시에 들어가지 않고 화면 전환 때마다 다시 디코딩될 수 있다. 대처:</p>
<p>maximumSizeBytes를 합리적으로 상향한다.</p>
<p>디코드 크기를 줄여 캐시에 들어가게 만든다. </p>
<p>5) “디코딩 크기”를 줄여 메모리와 시간 절약</p>
<p>이미지는 디코딩 후 압축 해제된 비트맵으로 캐시에 저장된다. 4K(3840×2160) 한 장이 <strong>30MB+</strong>를 차지한다. cacheWidth / cacheHeight로 디코드 크기를 작게 지정하면 메모리 사용을 크게 줄인다. </p>
<pre><code class="language-dart">// 네트워크 이미지: 디코드 타깃 크기 지정
Image.network(
  url,
  width: 120, height: 90,
  cacheWidth: (120 * MediaQuery.devicePixelRatioOf(context)).round(),
  cacheHeight: (90  * MediaQuery.devicePixelRatioOf(context)).round(),
);</code></pre>
<p>CachedNetworkImage 사용 시에는 memCacheWidth/Height가 같은 역할을 한다. 둘 다 주지 말고 한 축만 주면 비율 유지로 충분하다. 
<a href="https://medium.com/make-android/save-your-memory-usage-by-optimizing-network-image-in-flutter-cbc9f8af47cd">Save Your Memory Usage By Optimizing Network Images in Flutter
</a></p>
<h3 id="6-디스크-캐시와-메모리-캐시의-차이">6) 디스크 캐시와 메모리 캐시의 차이</h3>
<p>ImageCache는 디코딩된 비트맵의 메모리 캐시다.</p>
<p>cached_network_image + flutter_cache_manager는 파일(압축본)의 디스크 캐시다. 디스크에서 읽어와도 디코딩과 ImageCache 보관은 별개다. 
<a href="https://pub.dev/packages/cached_network_image?utm_source=chatgpt.com">cached_network_image</a></p>
<h4 id="실전-팁">실전 팁</h4>
<p>리스트 썸네일: cacheWidth 또는 memCacheWidth만 지정해 디코드 작게.</p>
<p>상세 보기: 필요할 때만 큰 사이즈로 요청.</p>
<p>캐시 thrash가 보이면 maximumSizeBytes를 올리거나 디코드 크기를 더 줄인다. 
Flutter Docs</p>
<h3 id="7-교체와-커스터마이즈">7) 교체와 커스터마이즈</h3>
<p>전역 캐시 구현을 바꾸려면 바인딩을 확장해 createImageCache()를 재정의한다. 앱 시작 전에 해당 바인딩을 초기화한다. (고급 용도) 
<a href="https://stackoverflow.com/questions/47836539/how-do-i-change-or-replace-the-imagecache-in-flutter?utm_source=chatgpt.com">How do I change or replace the ImageCache in Flutter?</a></p>
<pre><code class="language-dart">class MyBinding extends WidgetsFlutterBinding with PaintingBinding {
  @override
  ImageCache createImageCache() {
    final c = super.createImageCache();
    c.maximumSize = 500;
    c.maximumSizeBytes = 200 &lt;&lt; 20;
    return c;
  }
}

void main() {
  MyBinding(); // runApp 전에 바인딩 고정
  runApp(const MyApp());
}</code></pre>
<h3 id="8-운영-체크리스트">8) 운영 체크리스트</h3>
<p>한도 설정: 메모리 여유가 크지 않다면 maximumSizeBytes는 50–200MB 범위에서 실기기 프로파일링으로 결정한다.
디코드 힌트: 리스트·그리드는 cacheWidth/memCacheWidth만. DPR 상한(예: 2.0)을 둔다. 
관찰: currentSizeBytes, currentSize, liveImageCount로 히트율과 thrash 여부를 본다. </p>
<p>클리어 남용 금지: 화면 전환마다 clear() 호출은 금물. 필요 시 <strong>키 기반 evict()</strong>를 사용한다. 
<a href="https://api.flutter.dev/flutter/painting/ImageCache/evict.html?utm_source=chatgpt.com">evict method</a></p>
<p>참고</p>
<p>Flutter API: ImageCache class. 기본 LRU, 기본 한도(1000개/100MB), 전역 인스턴스 설명. </p>
<p>Flutter API: PaintingBinding.imageCache. 전역 캐시 게터. </p>
<p>Flutter API: maximumSize / maximumSizeBytes. 한도 조정과 즉시 퇴출 동작. </p>
<p>Flutter API: putIfAbsent / containsKey / statusForKey / ImageCacheStatus. 상태 추적과 조회. </p>
<p>Flutter API: clear / evict. 캐시 비우기와 개별 퇴출. </p>
<p>Flutter Breaking change: ImageCache large images. 큰 이미지 자동 확대 제거, 마이그레이션 가이드. </p>
<p>Flutter API: Image widget. 메모리 사용량, cacheWidth/cacheHeight로 디코드 크기 지정. </p>
<p>패키지 문서: cached_network_image. 디스크 캐시와 cacheManager 전달. 
StackOverflow: 이미지 캐시 교체 방법(Ian Hickson 답변). 바인딩 확장과 createImageCache 재정의. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LRU(Lease Recently Used) 알고리즘]]></title>
            <link>https://velog.io/@loopback_log/LRULease-Recently-Used-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</link>
            <guid>https://velog.io/@loopback_log/LRULease-Recently-Used-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</guid>
            <pubDate>Wed, 01 Oct 2025 00:31:05 GMT</pubDate>
            <description><![CDATA[<h3 id="1-정의">1) 정의</h3>
<p>LRU(Least Recently Used)는 캐시 교체 알고리즘의 하나다.
말 그대로 가장 오랫동안 사용되지 않은 항목을 먼저 제거한다.</p>
<p>즉, 최근에 쓰인 데이터는 앞으로도 쓸 가능성이 높고, 오래 안 쓰인 데이터는 다시 쓸 확률이 낮다고 가정하는 방식이다.
이 원리를 기반으로 제한된 저장소(메모리 캐시, 디스크 캐시 등)의 크기를 관리한다.</p>
<h3 id="2-동작-원리">2) 동작 원리</h3>
<p>캐시에 접근할 때마다 해당 항목을 &quot;가장 최근에 사용됨&quot;으로 표시한다.</p>
<p>캐시가 가득 찼을 때 새로운 항목을 넣으려 하면,</p>
<p>가장 오래 사용되지 않은 항목(least recently used)을 찾아서 제거한다.</p>
<p>새로운 항목을 삽입하고, 이를 최근 사용된 것으로 표시한다.</p>
<p>이 과정을 반복하면서 캐시는 항상 최근 사용된 항목들을 유지한다.</p>
<h3 id="3-자료구조-구현">3) 자료구조 구현</h3>
<p>일반적으로 <strong>이중 연결 리스트(Doubly Linked List)</strong>와 <strong>해시맵(HashMap)</strong>을 조합하여 구현한다.</p>
<p>해시맵: O(1) 시간에 키를 찾아서 노드를 얻는다.</p>
<p>연결 리스트: 노드를 앞으로 옮기거나 뒤에서 제거할 때 O(1)로 처리한다.</p>
<p>이 조합으로 조회, 삽입, 삭제 모두 평균 O(1) 시간 복잡도를 달성한다.</p>
<h3 id="4-캐시에서의-lru">4) 캐시에서의 LRU</h3>
<p>Flutter의 PaintingBinding.instance.imageCache 역시 LRU 정책을 따른다.</p>
<p>캐시에 저장된 이미지가 많아져서 maximumSize(개수)나 maximumSizeBytes(용량) 상한을 넘으면,</p>
<p>가장 오래 안 쓰인 이미지부터 차례로 제거한다.
이렇게 해서 자주 쓰이는 이미지(최근 접근된 이미지)는 계속 캐시에 남게 된다.</p>
<h3 id="5-장단점">5) 장단점</h3>
<h4 id="장점">장점</h4>
<p>구현이 비교적 단순하다.</p>
<p>“지역성(Locality)” 원리를 활용해 실제 워크로드에서 효율이 좋다.</p>
<h4 id="단점">단점</h4>
<p>데이터 접근 패턴이 특정 방식(예: 순환 접근)일 경우 효율이 떨어질 수 있다.</p>
<p>모든 접근 시점을 기록해야 하므로 구현에 추가 자료구조가 필요하다.</p>
<h3 id="6-예시">6) 예시</h3>
<p>캐시 크기가 3개라고 가정한다.</p>
<p>순서대로 항목 A, B, C를 넣으면 캐시는 [A, B, C]다.</p>
<p>D를 넣으려 하면 캐시가 가득 찼으므로, 가장 오래 안 쓰인 A가 제거되고 [B, C, D]가 된다.</p>
<p>이후 B를 다시 접근하면 B가 &quot;최근 사용&quot;으로 올라가서 [C, D, B] 순서가 된다.</p>
<p>다음에 E를 넣으면 C가 제거되고 [D, B, E]가 된다.</p>
<h3 id="7-실제-사용-사례">7) 실제 사용 사례</h3>
<p>운영체제의 페이지 교체 알고리즘
브라우저의 탭/리소스 캐시
DB 쿼리 캐시
Flutter ImageCache (PaintingBinding.instance.imageCache)</p>
<h2 id="참고">참고</h2>
<p><a href="https://api.flutter.dev/flutter/painting/ImageCache-class.html?utm_source=chatgpt.com">Flutter API</a>
<a href="https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)">Wikipedia</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]iOS에서만 구글 지도 배경을 두 번 눌러야 복원되는 문제]]></title>
            <link>https://velog.io/@loopback_log/FlutteriOS%EC%97%90%EC%84%9C%EB%A7%8C-%EA%B5%AC%EA%B8%80-%EC%A7%80%EB%8F%84-%EB%B0%B0%EA%B2%BD%EC%9D%84-%EB%91%90-%EB%B2%88-%EB%88%8C%EB%9F%AC%EC%95%BC-%EB%B3%B5%EC%9B%90%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@loopback_log/FlutteriOS%EC%97%90%EC%84%9C%EB%A7%8C-%EA%B5%AC%EA%B8%80-%EC%A7%80%EB%8F%84-%EB%B0%B0%EA%B2%BD%EC%9D%84-%EB%91%90-%EB%B2%88-%EB%88%8C%EB%9F%AC%EC%95%BC-%EB%B3%B5%EC%9B%90%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Thu, 11 Sep 2025 00:47:38 GMT</pubDate>
            <description><![CDATA[<p>iOS의 PlatformView(google_maps_flutter의 UiKitView)가 라우트 복귀(pop) 직후 재부착될 때 첫 탭을 내부 초기화에 소모해 GoogleMap.onTap이 호출되지 않는 순간이 있었다. 복귀 직후 1회용 투명 오버레이로 첫 탭을 가로채 복원 로직을 직접 실행하고, no-op 카메라 이동으로 제스처 연결을 안정화해 문제를 해결했다. Android에는 영향이 없었다.</p>
<h2 id="왜-iphone에서만-재현되나">왜 iPhone에서만 재현되나</h2>
<p>iOS는 UiKitView가 자체 제스처 파이프라인을 가진다. 라우트 pop 직후 첫 입력이 내부 재정렬/가드(카메라 이동 중 무시 등)에 잡혀 사라질 수 있다. 안드로이드는 하이브리드 컴포지션 경로가 달라 영향이 덜하다.
또한, iOS의 PlatformView(google_maps_flutter의 UiKitView)는 라우트 복귀(pop) 직후 재부착될 때 첫 터치가 제스처 초기화에 소모되어 무시되는 사례가 보고돼 있다. 원인은 iOS 제스처 우선순위와 플랫폼뷰 재부착 시점의 충돌이다. Flutter가 iOS 네이티브 뷰를 임베드할 때 이런 제약이 존재함은 공식 문서와 관련 이슈들로 확인된다.</p>
<h3 id="배경">배경</h3>
<p>플로우: 마커 탭 → 바텀시트 아이템 탭 → 상세 진입 → 뒤로 → 지도 배경 탭으로 이전 상태 복원</p>
<p>실제 현상: Android는 한 번 탭으로 즉시 복원됐다. iOS는 두 번 눌러야 복원됐다.</p>
<h3 id="재현-조건">재현 조건</h3>
<p>google_maps_flutter 같이 네이티브 UIView 기반 지도 + 스크롤 가능한 바텀시트가 같은 화면에 존재했다.</p>
<p>상세에서 뒤로(pop) 직후 지도 배경을 탭하면 첫 탭에서 onTap이 불리지 않았다. 두 번째부터 정상 동작했다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>iOS는 Flutter 위에 임베드된 UiKitView가 자체 제스처 파이프라인을 가진다. 라우트 복귀 시 뷰가 재부착/재정렬되며 첫 입력이 내부 초기화에 소비될 수 있었다.</p>
<p>지도 내부 가드(줌 중 차단, 카메라 이동 중 차단 등)를 함께 쓰고 있었다면, 복귀 직후 가드 플래그가 남아 첫 탭을 무시하는 경우도 겹칠 수 있었다.</p>
<h3 id="디버깅">디버깅</h3>
<p>GoogleMap.onTap에 로그를 넣어 첫 탭 호출 유무를 확인했다.</p>
<p>복원 엔트리(restoreToBaseline)에 로그를 넣어 가드 통과 여부를 확인했다.</p>
<p>onCameraMoveStarted, onCameraIdle에 로그를 넣어 pop 직후 내부 상태 변화를 확인했다.</p>
<h3 id="로그-요약">로그 요약</h3>
<p>첫 탭: onTap 로그가 없었다 → 플랫폼뷰가 첫 탭을 삼켰다</p>
<p>두 번째 탭: onTap + 복원 로그가 정상 출력됐다</p>
<h3 id="해결-전략">해결 전략</h3>
<p><strong>복귀 직후 1회용 투명 오버레이로 첫 탭을 가로채 복원 실행</strong>
iOS에서 상세 → 뒤로 복귀 시 오버레이를 한 프레임 동안만 켠다. 사용자가 지도 배경을 탭하면 오버레이가 탭을 받아 바로 복원 함수를 호출하고 자신을 끈다. 지도 제스처 파이프라인을 거치지 않으므로 첫 탭 유실을 회피했다.</p>
<p>*<em>no-op 카메라 이동으로 제스처 연결 안정화
*</em>복귀 직후 마지막 카메라 상태를 동일 값으로 moveCamera 했다. 시각적 변화 없이 iOS 플랫폼뷰 제스처 연결이 안정화됐다.</p>
<p>*<em>내부 가드 초기화
*</em>복귀 시점에 blockTapRestoreDueToZoom, isUserInteracting 같은 가드 플래그를 초기화했다.</p>
<h3 id="해결-예시">해결 예시</h3>
<pre><code class="language-dart">// imports
import &#39;dart:io&#39; show Platform;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:google_maps_flutter/google_maps_flutter.dart&#39;;

class MapHomePage extends StatefulWidget {
  const MapHomePage({super.key});
  @override
  State&lt;MapHomePage&gt; createState() =&gt; _MapHomePageState();
}

class _MapHomePageState extends State&lt;MapHomePage&gt; with RouteAware {
  GoogleMapController? _map;
  CameraPosition? _lastCamera;
  bool _oneShotTapOverlay = false;     // iOS 복귀 직후 1회만 켠다
  bool _sheetOpen = false;             // 바텀시트 열림 여부(앱 상태에 맞게 설정)
  bool _blockTapRestoreDueToZoom = false;
  bool _isUserInteracting = false;

  Future&lt;void&gt; _restoreToBaseline() async {
    // 선택 해제, 바텀시트 닫기 등 복원 로직
  }
</code></pre>
<h3 id="지도-위젯">지도 위젯</h3>
<pre><code class="language-dart">  @override
  Widget build(BuildContext context) {
    final bottomInset = _sheetOpen ? _sheetHeight : 0.0; // 측정값 주입(아래 참고)

    return Stack(
      children: [
        GoogleMap(
          onMapCreated: (c) =&gt; _map = c,
          initialCameraPosition: const CameraPosition(
            target: LatLng(37.5665, 126.9780),
            zoom: 14,
          ),
          onTap: (pos) {
            // 정상 경로: 오버레이가 꺼진 이후엔 onTap으로도 복원
            if (_blockTapRestoreDueToZoom || _isUserInteracting) return;
            _restoreToBaseline();
          },
          onCameraMove: (cp) =&gt; _lastCamera = cp,
          onCameraMoveStarted: () =&gt; _isUserInteracting = true,
          onCameraIdle: () =&gt; _isUserInteracting = false,
        ),

        // iOS 복귀 직후 1회용 오버레이: 바텀시트 윗부분까지만 덮는다
        if (_oneShotTapOverlay &amp;&amp; !_sheetOpen)
          Positioned.fromRect(
            rect: Rect.fromLTWH(0, 0, MediaQuery.of(context).size.width,
                MediaQuery.of(context).size.height - bottomInset),
            child: GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: () {
                setState(() =&gt; _oneShotTapOverlay = false);
                _restoreToBaseline();
              },
            ),
          ),

        // 바텀시트 예시
        Align(
          alignment: Alignment.bottomCenter,
          child: _BottomSheet(
            onHeight: (h) =&gt; _sheetHeight = h, // 높이 측정
            onOpenChanged: (open) =&gt; setState(() =&gt; _sheetOpen = open),
          ),
        ),
      ],
    );
  }

  double _sheetHeight = 0;
</code></pre>
<h3 id="상세페이지---뒤로-복귀-훅">상세페이지 -&gt; 뒤로 복귀 훅</h3>
<pre><code class="language-dart">  Future&lt;void&gt; pushDetail() async {
    // 상세 진입 전에 필요한 정리
    final result = await Navigator.of(context).pushNamed(&#39;/detail&#39;);
    // result 사용 여부는 선택 사항
    await _onReturnFromDetail();
  }

  Future&lt;void&gt; _onReturnFromDetail() async {
    if (!mounted) return;
    if (Platform.isIOS) {
      // 1) no-op 카메라 이동: 동일 카메라로 moveCamera
      if (_map != null &amp;&amp; _lastCamera != null) {
        await _map!.moveCamera(
          CameraUpdate.newCameraPosition(_lastCamera!),
        );
      }
      // 2) 가드 초기화
      _blockTapRestoreDueToZoom = false;
      _isUserInteracting = false;

      // 3) 한 프레임 뒤 오버레이 on
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (!mounted) return;
        setState(() =&gt; _oneShotTapOverlay = true);
      });
    }
  }
}
</code></pre>
<h3 id="바텀시트-높이-측정-예시">바텀시트 높이 측정 예시</h3>
<pre><code class="language-dart">class _BottomSheet extends StatelessWidget {
  const _BottomSheet({
    required this.onHeight,
    required this.onOpenChanged,
  });

  final ValueChanged&lt;double&gt; onHeight;
  final ValueChanged&lt;bool&gt; onOpenChanged;

  @override
  Widget build(BuildContext context) {
    return _MeasureSize(
      onChange: (size) =&gt; onHeight(size.height),
      child: DraggableScrollableSheet(
        initialChildSize: 0.15,
        minChildSize: 0.12,
        maxChildSize: 0.6,
        builder: (ctx, controller) {
          onOpenChanged(controller.positions.isNotEmpty &amp;&amp;
              controller.positions.first.pixels &gt; 1);
          return Material(
            elevation: 8,
            borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
            child: ListView(
              controller: controller,
              children: const [
                SizedBox(height: 400, child: Center(child: Text(&#39;Sheet&#39;))),
              ],
            ),
          );
        },
      ),
    );
  }
}

class _MeasureSize extends SingleChildRenderObjectWidget {
  const _MeasureSize({required this.onChange, super.child});
  final ValueChanged&lt;Size&gt; onChange;
  @override
  RenderObject createRenderObject(BuildContext context) =&gt;
      _MeasureSizeRender(onChange);
}

class _MeasureSizeRender extends RenderProxyBox {
  _MeasureSizeRender(this.onChange);
  final ValueChanged&lt;Size&gt; onChange;
  Size? _oldSize;
  @override
  void performLayout() {
    super.performLayout();
    if (size != _oldSize) {
      _oldSize = size;
      WidgetsBinding.instance.addPostFrameCallback((_) =&gt; onChange(size));
    }
  }
}
</code></pre>
<h3 id="대안과-왜-배제했는가">대안과 왜 배제했는가</h3>
<p>Navigator 결과로 즉시 복원: 상세에서 Navigator.pop(context, ResetIntent)로 신호를 보내 복귀 즉시 복원하는 방식도 썼다. 플로우가 간단하고 강력하다. 다만 “사용자 탭으로 복원한다”는 기존 UX를 유지하려고 본 이슈에서는 오버레이 방식을 채택했다.</p>
<p>첫 탭 무시 플래그: RouteObserver.didPopNext에서 ignoreNextTap = true로 두고 onTap 첫 호출을 버리는 방법도 있다. 플랫폼뷰가 첫 탭을 아예 삼키면 콜백이 오지 않아서 복원 트리거가 없다는 한계가 있었다.</p>
<p>gestureRecognizers 커스터마이즈: EagerGestureRecognizer로 탭 선점도 가능하다. 바텀시트 스크롤과 충돌 가능성이 높아 본 구성에서는 배제했다.</p>
<h3 id="결과">결과</h3>
<p>iOS에서 복귀 후 첫 탭만으로 즉시 복원됐다.</p>
<p>Android 동작은 변경하지 않았다.</p>
<p>지도/바텀시트 제스처 충돌 없이 안정적으로 동작했다.</p>
<h3 id="체크리스트">체크리스트</h3>
<p> iOS 복귀 직후 onTap 미호출 현상이 오버레이 경로로 대체됐는가</p>
<p> 오버레이가 바텀시트 상단까지만 덮는가</p>
<p> no-op moveCamera가 화면 흔들림 없이 적용됐는가</p>
<p> 가드 플래그가 복귀 시 초기화됐는가</p>
<p> Android에서 회귀가 없는가</p>
<h3 id="마무리">마무리</h3>
<p>문제의 본질은 플랫폼뷰 재부착 타이밍과 첫 입력 소모였다. 가장 확실한 회피는 복귀 프레임에 한정된 오버레이로 첫 탭을 가로채는 것이었다. 여기에 no-op 카메라 이동과 가드 초기화를 결합해 재현율을 0으로 만들었다. 같은 구성(네이티브 지도 + 스크롤 시트)을 쓰는 iOS 화면이라면 동일한 접근이 그대로 통한다.</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://docs.flutter.dev/platform-integration/ios/platform-views">https://docs.flutter.dev/platform-integration/ios/platform-views</a></li>
<li><a href="https://docs.flutter.dev/platform-integration/ios/platform-views">https://docs.flutter.dev/platform-integration/ios/platform-views</a></li>
<li><a href="https://docs.flutter.dev/cookbook/navigation/navigation">https://docs.flutter.dev/cookbook/navigation/navigation</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[GridView, 단말기마다 아이템이 잘리는 이유와 해결법]]></title>
            <link>https://velog.io/@loopback_log/GridView-%EB%8B%A8%EB%A7%90%EA%B8%B0%EB%A7%88%EB%8B%A4-%EC%95%84%EC%9D%B4%ED%85%9C%EC%9D%B4-%EC%9E%98%EB%A6%AC%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95</link>
            <guid>https://velog.io/@loopback_log/GridView-%EB%8B%A8%EB%A7%90%EA%B8%B0%EB%A7%88%EB%8B%A4-%EC%95%84%EC%9D%B4%ED%85%9C%EC%9D%B4-%EC%9E%98%EB%A6%AC%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95</guid>
            <pubDate>Tue, 09 Sep 2025 04:23:36 GMT</pubDate>
            <description><![CDATA[<p>Flutter에서 카드 UI를 그리드로 배치할 때 GridView를 많이 사용한다. 보통은 SliverGridDelegateWithFixedCrossAxisCount를 쓰고 childAspectRatio로 가로세로 비율을 맞추곤 한다.</p>
<p>하지만 이렇게 구현하면 단말기마다 UI가 잘리는 문제가 자주 발생한다. 어떤 기기에서는 텍스트가 두 줄로 잘 나오는데, 다른 기기에서는 같은 아이템이 하단에서 UI가 잘려 보인다.</p>
<h2 id="왜-이런-현상이-생기는-걸까">왜 이런 현상이 생기는 걸까?</h2>
<h3 id="왜-단말기마다-잘릴까">왜 단말기마다 잘릴까?</h3>
<h4 id="childaspectratio는-비율-기반이다">childAspectRatio는 비율 기반이다</h4>
<p>childAspectRatio는 width / height 비율로 셀 크기를 계산한다.</p>
<p>화면 너비가 달라지면 셀의 가로폭이 달라지고, 그에 따라 높이도 비율로 계산된다.</p>
<h4 id="폰트-렌더링-차이">폰트 렌더링 차이</h4>
<p>단말기별로 폰트 메트릭이 달라 텍스트가 占占 2~4px 정도 더 커지거나 줄어든다.</p>
<p>textScaleFactor 설정이 다르면 한 줄에 필요한 세로 공간도 달라진다.</p>
<h4 id="dprdevice-pixel-ratio-반올림">DPR(Device Pixel Ratio) 반올림</h4>
<p>3열 분할 시 셀 폭이 소수점으로 나뉘면 반올림 오차가 발생한다.</p>
<p>이 오차가 누적되어 세로 길이가 약간씩 달라지고, 하단 뱃지가 잘려 보인다.</p>
<h4 id="부모-높이-제한">부모 높이 제한</h4>
<p>흔히 SizedBox(height: …)로 GridView를 감싸는데, 텍스트가 2줄일 때 필요한 높이가 그 박스보다 크면 하단이 잘린다.</p>
<p>결국 비율로 정한 높이 &lt; 실제 필요한 높이가 되면서 잘리는 것이다.</p>
<h2 id="해결-방법-mainaxisextent-사용하기">해결 방법: mainAxisExtent 사용하기</h2>
<p>이 문제를 해결하는 가장 간단한 방법은 childAspectRatio 대신 mainAxisExtent를 쓰는 것이다.</p>
<pre><code class="language-dart">gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
  crossAxisCount: 3,
  mainAxisExtent: 240, // &lt;- 높이를 픽셀 단위로 고정
  crossAxisSpacing: 8,
  mainAxisSpacing: 16,
),</code></pre>
<p>mainAxisExtent는 아이템 하나의 <strong>세로 길이를 고정값(px)</strong>으로 지정한다.</p>
<p>따라서 화면 너비나 비율에 영향받지 않고, 항상 동일한 높이를 가진다.</p>
<p>텍스트가 한 줄이든 두 줄이든, 카드 전체 높이가 변하지 않으므로 배지 같은 하단 요소가 잘리지 않는다.</p>
<h2 id="정리">정리</h2>
<p>childAspectRatio는 비율 기반이라 기기마다 텍스트·폰트·픽셀 오차에 의해 잘림 문제가 생긴다.</p>
<p>mainAxisExtent를 쓰면 카드 높이를 고정할 수 있어서 단말기에 상관없이 일관된 레이아웃을 보장한다.</p>
<p>UI가 잘린다면 우선 childAspectRatio를 의심하고, 가능하면 mainAxisExtent로 바꾸는 게 안전하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GetX에서 Riverpod으로 마이그레이션한 이유와 과정]]></title>
            <link>https://velog.io/@loopback_log/GetX%EC%97%90%EC%84%9C-Riverpod%EC%9C%BC%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%9C-%EC%9D%B4%EC%9C%A0%EC%99%80-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@loopback_log/GetX%EC%97%90%EC%84%9C-Riverpod%EC%9C%BC%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%9C-%EC%9D%B4%EC%9C%A0%EC%99%80-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Tue, 19 Aug 2025 01:46:09 GMT</pubDate>
            <description><![CDATA[<p>Flutter로 개발을 하다 보면 가장 먼저 부딪히는 선택지가 있다. 바로 상태 관리(State Management) 를 어떻게 할 것인가이다. 상태 관리는 앱의 데이터 흐름과 UI 업데이트를 결정하는 중요한 요소인데, 어떤 라이브러리를 선택하느냐에 따라 프로젝트 구조와 생산성이 크게 달라진다.</p>
<p>처음 Flutter를 접했을 때는 대부분 단순한 앱을 만들기 때문에 setState 만으로도 충분하다. 하지만 앱이 커지고 비즈니스 로직이 복잡해지면 상태 관리 도구의 필요성이 커진다. 나 역시 프로젝트 초반에는 GetX를 선택했다. GetX는 문서가 잘 되어 있고, 코드도 짧으며, 빠르게 결과물을 만들어낼 수 있어서 입문자들에게 매우 매력적으로 보인다. 특히 Obx, Get.put, Get.find 같은 간단한 문법은 러닝 커브를 거의 느끼지 못하게 만든다.</p>
<p>그러나 프로젝트가 성장하고 팀 단위 협업이 본격화되면서 GetX의 한계가 드러났다. 결국 우리 팀은 Riverpod으로 전환하게 되었고, 지금은 왜 더 일찍 마이그레이션하지 않았을까 하는 생각도 든다. 이 글에서는 그 과정을 단계별로 풀어보겠다.</p>
<h2 id="문제-정의">문제 정의</h2>
<p>GetX를 쓰면서 마주친 문제는 단순히 “버그가 생겼다” 수준이 아니었다. 구조적이고 반복적인 문제였다.</p>
<h3 id="의존성의-불투명함">의존성의 불투명함</h3>
<p>Get.find()로 불러온 인스턴스가 어디서 만들어졌고 언제 해제되는지 바로 알기 어렵다. 작은 프로젝트에서는 문제가 없지만, 규모가 커질수록 의존성이 꼬여 디버깅이 힘들어진다.</p>
<h3 id="테스트의-부재">테스트의 부재</h3>
<p>전역 싱글톤 구조가 많다 보니 Mock을 넣기가 힘들었다. 테스트 코드를 못 쓰니 안정성이 점점 떨어졌고, 결국 배포 전에 QA에만 의존하게 되었다.</p>
<h3 id="사이드-이펙트">사이드 이펙트</h3>
<p>한 군데 상태를 수정했는데 다른 화면이 예기치 않게 갱신되는 경우가 많았다. 디버깅할 때 “여기 고쳤는데 왜 저기까지 바뀌지?” 하는 순간이 잦았다.</p>
<h3 id="타입-안정성-부족">타입 안정성 부족</h3>
<p>Observable로 감싸는 방식은 단순했지만, 타입 추론이나 null-safety를 적극적으로 활용하기 어려웠다. IDE가 잡아주지 못하는 오류가 런타임에 터져 나오면서 유지보수 비용이 늘어났다.</p>
<h3 id="배경-설명">배경 설명</h3>
<p>이런 문제를 해결할 방법을 찾다가 여러 대안을 검토했다. Bloc, Redux, MobX, Provider 등등. 그중에서 Riverpod이 눈에 띈 이유는 다음과 같다.</p>
<h3 id="타입-안정성과-컴파일-타임-안전성">타입 안정성과 컴파일 타임 안전성</h3>
<p>IDE 단계에서 잘못된 접근을 잡아준다. 실수로 null을 접근하거나 없는 Provider를 호출하면 런타임까지 가지 않고 개발 단계에서 알 수 있다.</p>
<h3 id="명시적인-의존성-관리">명시적인 의존성 관리</h3>
<p>Provider로 어떤 상태가 어디서 어떻게 관리되는지 선언적으로 표현한다. 코드만 봐도 구조가 보이고, 추적이 쉬워졌다.</p>
<h3 id="테스트-친화적">테스트 친화적</h3>
<p>ProviderScope를 통해 특정 Provider만 교체하거나 Mock으로 대체할 수 있다. 테스트 코드 작성이 자연스러워졌고, 버그를 사전에 잡을 수 있었다.</p>
<h3 id="협업-친화성">협업 친화성</h3>
<p>팀 단위에서 중요한 건 예측 가능성과 가독성이다. Riverpod은 boilerplate가 조금 있지만, 그 대신 구조가 명확하다. 새로운 팀원이 들어와도 상태가 어디서 생성되고 주입되는지 바로 이해할 수 있었다.</p>
<h2 id="고민의-과정">고민의 과정</h2>
<p>Riverpod으로 전환하기로 하기 전까지, 팀 내부에서도 의견이 갈렸다.</p>
<ul>
<li>“GetX가 충분히 빠르고 간단한데 굳이 옮길 필요가 있나?”</li>
<li>“새로운 라이브러리를 도입하면 학습 비용이 크지 않을까?”</li>
<li>“지금까지 짜놓은 코드를 다 뜯어고쳐야 한다면 리스크가 크지 않을까?”</li>
</ul>
<p>이런 우려는 당연했다. 그래서 전면적인 교체 대신 점진적 도입을 선택했다. 새로운 기능을 작성할 때는 Riverpod을 쓰고, 기존 기능은 GetX를 유지하는 방식이다. 실제로 이렇게 병행하다 보니 자연스럽게 팀원들이 Riverpod의 장점을 체감하게 되었고, 나중에는 “왜 이제야 바꿨지?”라는 분위기로 변했다.</p>
<p>Riverpod을 쓰면서 가장 크게 와닿았던 건 테스트 가능성과 예측 가능성이었다. API 연동 로직을 Provider로 분리했더니, 같은 API를 여러 화면에서 공유해도 로직이 흩어지지 않고 한곳에서 관리됐다. 또 Provider를 Mock으로 교체해 단위 테스트를 돌릴 수 있게 되니 QA 단계에서 발견하던 버그 상당수를 개발 단계에서 걸러낼 수 있었다.</p>
<h2 id="한-번에-다-옮기지-말라">한 번에 다 옮기지 말라</h2>
<p>신규 기능부터 Riverpod으로 작성하고, 기존 로직은 그대로 두는 방식이 현실적이다.</p>
<h3 id="팀-규칙-만들기">팀 규칙 만들기</h3>
<p>어떤 경우에 Provider를 만들고, 어떤 경우에 Notifier를 쓸지 컨벤션을 정해두면 협업 효율이 훨씬 올라간다.</p>
<h2 id="결론">결론</h2>
<p>GetX는 빠르고 간단하다. 개인 프로젝트나 프로토타입에는 여전히 좋은 선택이다. 하지만 프로젝트가 커지고 팀 단위로 협업을 한다면, 그 단순함이 오히려 발목을 잡는다. 상태 추적이 어렵고, 테스트가 불가능에 가까우며, 사이드 이펙트가 잦다.</p>
<p>Riverpod은 진입 장벽이 있지만, 타입 안전성, 테스트 가능성, 협업 친화성 덕분에 장기적으로는 더 안정적인 선택이다. 우리 팀도 결국 Riverpod으로 넘어오면서 코드 품질이 좋아지고, 디버깅과 테스트 비용이 줄어들었으며, 새로운 기능 개발 속도가 안정적으로 유지됐다.</p>
<p>즉, 작은 프로젝트라면 GetX, 협업을 전제로 한 장기 프로젝트라면 Riverpod이 답이라는 결론에 도달했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GetX 기반 바텀내비게이션에서 IndexedStack + SWR 전략을 도입한 이유]]></title>
            <link>https://velog.io/@loopback_log/GetX-%EA%B8%B0%EB%B0%98-%EB%B0%94%ED%85%80%EB%82%B4%EB%B9%84%EA%B2%8C%EC%9D%B4%EC%85%98%EC%97%90%EC%84%9C-IndexedStack-SWR-%EC%A0%84%EB%9E%B5%EC%9D%84-%EB%8F%84%EC%9E%85%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@loopback_log/GetX-%EA%B8%B0%EB%B0%98-%EB%B0%94%ED%85%80%EB%82%B4%EB%B9%84%EA%B2%8C%EC%9D%B4%EC%85%98%EC%97%90%EC%84%9C-IndexedStack-SWR-%EC%A0%84%EB%9E%B5%EC%9D%84-%EB%8F%84%EC%9E%85%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 18 Aug 2025 03:22:16 GMT</pubDate>
            <description><![CDATA[<h2 id="기존-구조-바텀내비게이션--getx">기존 구조: 바텀내비게이션 + GetX</h2>
<p>처음 앱을 설계했을 때는 흔히 쓰는 방식대로 BottomNavigationBar + GetX 컨트롤러 조합을 사용했다. 각 탭을 누를 때마다 onTap에서 해당 화면의 컨트롤러를 초기화하고, API 요청을 보내 데이터를 가져오는 구조였다. 우리는 신규사업을 위해 적은 공수로 빠르게 스위치가 필요했고, 이전 경매 서비스에서 최신 데이터 갱신을 위한 이전 구조를 그대로 가져와야하는 실정이였다. 그리고 실제 서비스 운영 과정에서 다음과 같은 한계가 드러났다.</p>
<ul>
<li>탭 이동 시마다 로딩 스피너가 반복적으로 노출된다.</li>
<li>네트워크 낭비: 같은 데이터를 불필요하게 계속 요청한다.</li>
<li>스크롤 위치 초기화: 다른 탭 갔다가 돌아오면 항상 맨 위부터 다시 로드된다.</li>
<li>사용자 경험(UX)이 버벅이고 느리다는 인상을 준다.</li>
</ul>
<h3 id="전환-indexedstack--lazy-loading-도입">전환: IndexedStack + Lazy Loading 도입</h3>
<p>이 문제를 해결하기 위해 먼저 IndexedStack을 도입했다. IndexedStack은 각 탭의 위젯 트리를 유지하면서 화면만 전환하기 때문에, 상태 보존이 가능하다.</p>
<p>이전처럼 탭을 이동해도 스크롤 위치, 입력값, 컨트롤러 상태가 그대로 유지된다. 덕분에 UX는 눈에 띄게 개선되었다.</p>
<p>하지만 새로운 문제가 나타났다.</p>
<h3 id="indexedstack-도입-후-문제">IndexedStack 도입 후 문제</h3>
<p>데이터 최신화 타이밍 불분명</p>
<p>상태가 그대로 유지되다 보니, 화면이 다시 보일 때 데이터가 낡아 있을 수 있다.</p>
<p>예를 들어 다른 유저가 상품을 올렸는데, 내가 탭을 이동했다가 돌아와도 목록이 갱신되지 않는다.</p>
<h4 id="불필요한-무한-캐싱">불필요한 무한 캐싱</h4>
<p>컨트롤러가 계속 살아 있으니 메모리에 오래 쌓일 수 있다.</p>
<p>특히 API 응답 데이터가 많아질수록 앱이 무거워진다.</p>
<p>즉, IndexedStack은 상태 보존 문제는 해결했지만, 데이터 최신화라는 또 다른 숙제를 남겼다.</p>
<h3 id="도입-swr-전략">도입: SWR 전략</h3>
<p>여기서 도입한 것이 SWR(Stale-While-Revalidate) 전략이다.</p>
<ul>
<li>Stale(낡았지만 즉시 보여줄 값)</li>
</ul>
<p>IndexedStack 덕분에 탭 상태와 데이터가 남아 있으니, 먼저 이 값을 즉시 보여준다.</p>
<ul>
<li>While Revalidate(동시에 재검증)</li>
</ul>
<p>백그라운드에서 API를 호출해 최신 데이터를 가져오고, 완료되면 UI를 갱신한다.</p>
<p>즉, 즉시성 + 최신성을 동시에 확보할 수 있다.</p>
<h4 id="왜-swr인가">왜 SWR인가</h4>
<p>즉시성과 최신성의 공존</p>
<p>캐시된 데이터는 즉시 보여주고,</p>
<p>최신 데이터는 나중에 자연스럽게 반영한다.</p>
<p>IndexedStack과 찰떡궁합</p>
<p>IndexedStack이 상태를 보존해주고,</p>
<p>SWR이 데이터 신선도를 관리한다.</p>
<h4 id="네트워크-효율">네트워크 효율</h4>
<p>탭 전환할 때마다 같은 API를 반복 호출하지 않는다.</p>
<p>대신 일정 시간(staleTime) 기준으로만 갱신한다.</p>
<h4 id="오프라인-내성">오프라인 내성</h4>
<p>네트워크가 끊겨도 마지막 캐시가 있기 때문에 빈 화면을 보여주지 않는다.</p>
<h2 id="shell-페이지-설계의-핵심-아이디어">Shell 페이지 설계의 핵심 아이디어</h2>
<ol>
<li><p>중앙 집중식 상태 관리
기존에는 각 탭마다 독립적인 컨트롤러를 가지고 있었지만, Shell 페이지에서는 전역 상태를 중앙에서 관리한다.</p>
<pre><code class="language-dart">class GlobalShellPageCtl extends GetxController with WidgetsBindingObserver {
final RxInt currentIndex = 0.obs;
final Rx&lt;NavTab&gt; currentTab = NavTab.home.obs;

/* 각 탭의 페이지들을 저장할 리스트 */
final List&lt;Widget?&gt; pages = List.filled(5, null);

/* 페이지가 초기화되었는지 확인하는 리스트 */
final List&lt;bool&gt; pageInitialized = List.filled(5, false);
}</code></pre>
</li>
<li><p>Lazy Loading으로 메모리 최적화
모든 탭을 한 번에 로드하지 않고, 사용자가 실제로 방문한 탭만 로드한다.</p>
<pre><code class="language-dart">Widget _buildPage(int index) {
if (pages[index] == null &amp;&amp; !pageInitialized[index]) {
 pages[index] = _createPage(index);
 pageInitialized[index] = true;
 tabActive[index] = true;
}
return pages[index] ?? const SizedBox.shrink();
}</code></pre>
</li>
<li><p>메모리 압박 대응
앱이 메모리 부족 상황에 처했을 때 비활성 탭의 리소스를 정리한다.</p>
</li>
</ol>
<pre><code class="language-dart">@override
void didHaveMemoryPressure() {
  try {
    for (int i = 0; i &lt; tabActive.length; i++) {
      if (!tabActive[i]) _cleanupTabResources(i);
    }
    PaintingBinding.instance.imageCache.maximumSizeBytes = 200 &lt;&lt; 20;
    Logger.debug(&#39;메모리 프레셔 대응 정리 완료&#39;);
  } catch (e) {
    Logger.debug(&#39;메모리 프레셔 정리 중 오류: $e&#39;);
  }
}</code></pre>
<h3 id="swr-전략의-구체적-구현">SWR 전략의 구체적 구현</h3>
<ol>
<li>탭별 데이터 Freshness 설정
각 탭의 특성에 맞게 다른 갱신 주기를 설정했다.<pre><code class="language-dart">static const Map&lt;String, Duration&gt; _tabFreshnessDurations = {
&#39;home&#39;: Duration(minutes: 5),      // 홈 탭: 5분 후 자동 갱신
&#39;map&#39;: Duration(hours: 24),        // 맵 탭: 자동 갱신 비활성화 (24시간)
&#39;community&#39;: Duration(minutes: 3), // 커뮤니티 탭: 3분 후 자동 갱신
&#39;store&#39;: Duration(minutes: 5),     // 스토어 탭: 5분 후 자동 갱신
&#39;profile&#39;: Duration(minutes: 15),  // 마이페이지 탭: 15분 후 자동 갱신
};</code></pre>
</li>
<li>앱 생명주기 기반 스마트 갱신
앱이 백그라운드에서 포그라운드로 돌아올 때 현재 활성 탭의 데이터만 갱신한다.<pre><code class="language-dart"></code></pre>
</li>
</ol>
<p>void onAppResumed() {
  _isAppInBackground = false;
  _lastAppResumeTime = DateTime.now();
  Logger.debug(&#39;�� 앱 포그라운드 복귀 - SWR 전략 활성화&#39;);
}</p>
<p>Future<void> refreshCurrentTabData() async {
  final tabName = _getTabName(currentTab.value);
  Logger.debug(&#39;�� 앱 복귀 시 현재 탭 데이터 갱신: $tabName&#39;);</p>
<p>  switch (currentTab.value) {
    case NavTab.home:
      await _refreshHomeData();
      break;
    case NavTab.map:
      await _refreshMapData();
      break;
    // ... 다른 탭들
  }
}</p>
<pre><code>3. 성능 모니터링 및 최적화
캐시 히트율, 갱신 횟수, 평균 응답 시간 등을 실시간으로 모니터링한다.
```dart
/* 성능 모니터링 데이터 */
final Map&lt;String, int&gt; _cacheHits = {};
final Map&lt;String, int&gt; _cacheMisses = {};
final Map&lt;String, int&gt; _refreshCounts = {};
final Map&lt;String, Duration&gt; _averageFetchTimes = {};
final Map&lt;String, List&lt;Duration&gt;&gt; _fetchTimes = {};</code></pre><h4 id="getx에서의-swr-구현-예시">GetX에서의 SWR 구현 예시</h4>
<p>Riverpod처럼 전용 SWR 패키지는 없지만, GetX도 캐시와 타임스탬프만 관리하면 충분히 구현 가능하다.</p>
<pre><code class="language-dart">class GoodsController extends GetxController {
  final _repo = GoodsRepository();
  final goods = &lt;Goods&gt;[].obs;
  DateTime? _lastFetched;
  final Duration staleTime = Duration(seconds: 60);

  @override
  void onInit() {
    super.onInit();
    fetchGoods(force: true);
  }

  Future&lt;void&gt; fetchGoods({bool force = false}) async {
    final now = DateTime.now();
    if (!force &amp;&amp; _lastFetched != null &amp;&amp; now.difference(_lastFetched!) &lt; staleTime) {
      // 캐시가 아직 신선하다면 그대로 사용
      return;
    }

    try {
      final fresh = await _repo.getGoods();
      goods.assignAll(fresh);
      _lastFetched = now;
    } catch (e) {
      // 네트워크 실패 시 캐시 fallback
    }
  }

  Future&lt;void&gt; refreshGoods() async {
    await fetchGoods(force: true);
  }
}</code></pre>
<p>IndexedStack은 컨트롤러를 계속 살려두고,</p>
<p>fetchGoods()가 staleTime을 기준으로 재검증을 수행한다.</p>
<p>사용자가 당겨서 새로고침하면 refreshGoods()로 강제 최신화할 수 있다.</p>
<h4 id="우리-회사가-swr을-도입한-이유">우리 회사가 SWR을 도입한 이유</h4>
<p>우리 팀이 SWR을 도입한 건 단순한 기술적 시도가 아니라 실제 서비스 문제 해결이 목적이었다.</p>
<h3 id="ux-개선">UX 개선</h3>
<p>탭 전환 시 매번 로딩 스피너를 보는 건 큰 불편이었다.</p>
<p>캐시를 먼저 보여주고 자연스럽게 최신화하니 UX가 매끄러워졌다.</p>
<h3 id="네트워크-절약">네트워크 절약</h3>
<p>같은 API를 불필요하게 계속 호출하지 않아 서버 부하와 트래픽 비용이 줄었다.</p>
<h3 id="유지보수-단순화">유지보수 단순화</h3>
<p>GetX 컨트롤러를 계속 재생성/해제하지 않아 코드가 단순해졌다.</p>
<h3 id="확장성-확보">확장성 확보</h3>
<p>앞으로 국제화(i18n), SEO, 마케팅용 딥링크 등 다양한 요구가 생길 때도 SWR 구조가 훨씬 유연하다.</p>
<h2 id="결론">결론</h2>
<p>기존 바텀내비게이션 구조는 단순했지만, UX와 성능에 문제가 있었다. IndexedStack으로 상태 보존 문제를 해결했으나, 데이터 최신화 타이밍이 불분명해지는 새로운 문제가 생겼다.
여기에 SWR 전략을 결합함으로써 빠른 반응성 + 최신 데이터 보장 + 네트워크 최적화라는 세 가지 목표를 동시에 달성할 수 있었다.
결국, 우리 회사가 IndexedStack과 SWR 전략을 도입한 이유는 명확하다:
사용자 경험 개선과 성능 최적화, 그리고 비즈니스 가치 창출.
이 구조는 단순한 기술적 선택이 아니라, 사용자와 비즈니스 모두에게 가치를 제공하는 아키텍처적 결정이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]VSCODE iOS 디버깅 안정화 세팅]]></title>
            <link>https://velog.io/@loopback_log/FlutterVSCODE-iOS-%EB%94%94%EB%B2%84%EA%B9%85-%EC%95%88%EC%A0%95%ED%99%94-%EC%84%B8%ED%8C%85</link>
            <guid>https://velog.io/@loopback_log/FlutterVSCODE-iOS-%EB%94%94%EB%B2%84%EA%B9%85-%EC%95%88%EC%A0%95%ED%99%94-%EC%84%B8%ED%8C%85</guid>
            <pubDate>Wed, 25 Jun 2025 00:56:22 GMT</pubDate>
            <description><![CDATA[<h3 id="1단계--settingsjson-vscode-전역-설정">1단계 — settings.json (VSCode 전역 설정)</h3>
<p>이건 Flutter Extension의 전체 동작을 제어한다.
개인적으로 아래 옵션은 나에게 맞는 iOS 실기기 기준으로 안정성, 편의성, 속도 모두 고려한 최적 조합이다.</p>
<pre><code class="language-json">{
  &quot;dart.flutterRunOnAttach&quot;: true,
  &quot;dart.flutterHotReloadOnSave&quot;: true,
  &quot;dart.previewFlutterUiGuides&quot;: true,
  &quot;dart.openDevTools&quot;: &quot;flutter&quot;,
  &quot;dart.debugExternalPackageLibraries&quot;: false,
  &quot;dart.debugSdkLibraries&quot;: false,
  &quot;dart.showTodos&quot;: true,
  &quot;dart.closeTerminalOnTestsEnd&quot;: true,
  &quot;dart.flutterShowStructuredErrors&quot;: true,
  &quot;dart.flutterTrackWidgetCreation&quot;: true
}
</code></pre>
<h3 id="설정-상세-설명">설정 상세 설명</h3>
<table>
<thead>
<tr>
<th>설정</th>
<th>설명</th>
<th>추천 이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>&quot;dart.flutterRunOnAttach&quot;: true</code></td>
<td>▶ 버튼 누르면 <strong>항상 flutter run 기반으로 실행</strong></td>
<td>VM Service, iproxy 연결 안정</td>
</tr>
<tr>
<td><code>&quot;dart.flutterHotReloadOnSave&quot;: true</code></td>
<td>저장 시 자동 Hot Reload</td>
<td>개발 생산성 ↑</td>
</tr>
<tr>
<td><code>&quot;dart.previewFlutterUiGuides&quot;: true</code></td>
<td>UI 가이드선 표시 (padding/margin 시각화)</td>
<td>위젯 레이아웃 보면서 잡기 좋음</td>
</tr>
<tr>
<td><code>&quot;dart.openDevTools&quot;: &quot;flutter&quot;</code></td>
<td>Flutter DevTools 열릴 때 위치</td>
<td><code>flutter</code> → 기본 DevTools</td>
</tr>
<tr>
<td><code>&quot;dart.debugExternalPackageLibraries&quot;: false</code></td>
<td>외부 패키지 디버깅 off</td>
<td>SDK 안 파고들 때 디버깅 속도 ↑</td>
</tr>
<tr>
<td><code>&quot;dart.debugSdkLibraries&quot;: false</code></td>
<td>Dart SDK 디버깅 off</td>
<td>동일</td>
</tr>
<tr>
<td><code>&quot;dart.showTodos&quot;: true</code></td>
<td>TODO 주석 강조</td>
<td>코드 관리 ↑</td>
</tr>
<tr>
<td><code>&quot;dart.closeTerminalOnTestsEnd&quot;: true</code></td>
<td>테스트 종료시 터미널 자동 정리</td>
<td>터미널 깔끔 유지</td>
</tr>
<tr>
<td><code>&quot;dart.flutterShowStructuredErrors&quot;: true</code></td>
<td>Flutter 에러 메시지 포맷 보기 좋게</td>
<td>디버깅 가독성 ↑</td>
</tr>
<tr>
<td><code>&quot;dart.flutterTrackWidgetCreation&quot;: true</code></td>
<td>위젯 생성 위치 추적</td>
<td>DevTools에서 위젯 트리 추적 기능 향상</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]댓글 입력창에서 한글 입력 시 닉네임 태그가 삭제되는 문제 해결기]]></title>
            <link>https://velog.io/@loopback_log/Flutter%EB%8C%93%EA%B8%80-%EC%9E%85%EB%A0%A5%EC%B0%BD%EC%97%90%EC%84%9C-%ED%95%9C%EA%B8%80-%EC%9E%85%EB%A0%A5-%EC%8B%9C-%EB%8B%89%EB%84%A4%EC%9E%84-%ED%83%9C%EA%B7%B8%EA%B0%80-%EC%82%AD%EC%A0%9C%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@loopback_log/Flutter%EB%8C%93%EA%B8%80-%EC%9E%85%EB%A0%A5%EC%B0%BD%EC%97%90%EC%84%9C-%ED%95%9C%EA%B8%80-%EC%9E%85%EB%A0%A5-%EC%8B%9C-%EB%8B%89%EB%84%A4%EC%9E%84-%ED%83%9C%EA%B7%B8%EA%B0%80-%EC%82%AD%EC%A0%9C%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Wed, 28 May 2025 05:21:25 GMT</pubDate>
            <description><![CDATA[<p>Flutter로 커뮤니티 앱을 개발하던 중, 대댓글 입력창에 상대방 닉네임 태그(@홍길동)를 표시하고 한글을 입력할 때, 닉네임 태그가 갑자기 사라지는 문제가 발생했다. 비슷한 문제를 겪는 개발자들에게 도움이 되길 바라며 문제 해결 과정을 기록한다.</p>
<h3 id="문제-상황">문제 상황</h3>
<p>대댓글 입력창에서 TextField에 TextEditingController를 연결해 사용자 입력을 감지하고 있었다. 대댓글 입력 모드에서는 상대방 닉네임을 @홍길동처럼 텍스트 앞에 표시하고, 유저가 이어서 댓글을 입력할 수 있도록 했다.</p>
<p>그런데 한글 입력 도중, 특히 초성(ㅎ)이나 중성(ㅏ)을 입력하는 단계에서 닉네임 태그가 통째로 사라졌다. 입력을 멈추고 커서를 이동하거나 영문을 입력하면 이런 현상은 발생하지 않았다. 문제는 한글 조합 중에 텍스트 필드가 임시적으로 비어있는 상태로 인식되면서, 입력창의 listener가 닉네임을 지워버리는 것이었다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>처음에는 TextEditingController의 addListener를 통해 입력 텍스트가 비어있는지 확인하고 닉네임 태그를 유지할지 결정하는 로직을 작성했다. 한글 입력 중 텍스트가 사라지는 현상이 발생한 이유는 다음과 같았다.</p>
<p>1️⃣ 한글 입력은 IME(입력기)를 통해 조합 상태(Composing)를 거쳐 완성된다.
2️⃣ 이때 controller.text 값은 조합 중간에 임시로 비어있는 것으로 감지될 수 있다.
3️⃣ 나는 text.isEmpty 조건으로 닉네임 태그 삭제 로직을 동작하도록 했기 때문에, 입력 중 text가 비어있다고 판단하고 clearReplyMode()를 호출해버렸다.
4️⃣ 그 결과 한글 입력의 초성-중성 조합 중에 닉네임 태그가 사라지는 문제가 발생했다.</p>
<h3 id="시행착오">시행착오</h3>
<p>처음에는 text.isEmpty로만 판단했다가 한글 입력 도중 태그가 삭제됐다.</p>
<p>controller.value.composing.isCollapsed를 이용해 조합 중 상태를 감지하려고 했으나, 한글 입력 시점과 로직 실행 타이밍의 차이로 여전히 문제가 남았다.</p>
<p>한글 조합이 끝나기 전에 listener가 너무 빨리 동작해서 text가 비어있는 것으로 처리되기 때문이었다.</p>
<h3 id="해결-방법-딜레이를-주고-재확인">해결 방법: 딜레이를 주고 재확인</h3>
<p>IME의 입력기 조합이 완료된 후 텍스트를 확인하기 위해 약간의 딜레이를 주고 재확인하는 로직을 작성했다. 이렇게 하면 한글 입력 중 닉네임 태그가 실수로 삭제되지 않는다.</p>
<pre><code class="language-dart">void _updateSendButtonState() {
  final controllerValue = widget.controller.commentController.value;
  final currentText = controllerValue.text;
  final isComposing = !controllerValue.composing.isCollapsed;

  final validatedText = currentText.replaceAll(&#39;\u200B&#39;, &#39;&#39;).trim();
  _isTextFieldValid.value = validatedText.isNotEmpty;

  if (widget.controller.repliesToNickname.isNotEmpty) {
    if (!isComposing &amp;&amp; validatedText.isEmpty &amp;&amp; currentText != &#39;\u200B&#39;) {
      // 딜레이 후 최종 확인
      Future.delayed(const Duration(milliseconds: 100), () {
        final delayedText = widget.controller.commentController.text
            .replaceAll(&#39;\u200B&#39;, &#39;&#39;)
            .trim();
        final delayedComposing = widget.controller.commentController.value.composing;

        if (delayedText.isEmpty &amp;&amp; delayedComposing.isCollapsed) {
          widget.controller.clearReplyMode(); // 닉네임 태그 삭제
        }
      });
    }
  }
}</code></pre>
<p>Future.delayed를 통해 100ms 정도의 짧은 시간을 두고, 조합 완료 후 텍스트와 composing 상태를 다시 체크했다. 이렇게 하면 한글 입력 도중 임시로 비워진 상태에서도 태그가 지워지지 않고, 진짜로 입력이 끝나고 텍스트가 비어있을 때만 태그를 삭제하도록 처리할 수 있었다.</p>
<h3 id="핵심-포인트">핵심 포인트</h3>
<p>한글 입력은 조합 상태를 거쳐 완성되므로, IME 조합 중에는 TextField의 text 값이 비어있을 수 있다.</p>
<p>즉각적인 판단 대신 약간의 딜레이 후 재확인으로 안정적으로 처리해야 한다.</p>
<p>controller.value.composing.isCollapsed를 통해 조합 상태를 감지하고, 텍스트 완성 후에 판단하도록 했다.</p>
<h3 id="결론">결론</h3>
<p>이번 문제는 Flutter의 TextField와 한글 입력 방식(IMEs)의 특성에서 비롯된 입력 처리 문제였다.
한글 입력 시 발생할 수 있는 IME 조합 처리 문제를 이해하고, 딜레이 후 재확인 로직으로 안정적으로 닉네임 태그 유지 기능을 구현했다.</p>
<p>같은 문제를 겪는 개발자들에게 도움이 되길 바라며, Flutter 개발 시 한글 입력 처리의 특성을 꼭 고려하길 추천한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] iOS 스와이프 뒤로가기 문제와 해결 과정]]></title>
            <link>https://velog.io/@loopback_log/Flutter-Flutter-iOS-%EC%8A%A4%EC%99%80%EC%9D%B4%ED%94%84-%EB%92%A4%EB%A1%9C%EA%B0%80%EA%B8%B0-%EB%AC%B8%EC%A0%9C%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@loopback_log/Flutter-Flutter-iOS-%EC%8A%A4%EC%99%80%EC%9D%B4%ED%94%84-%EB%92%A4%EB%A1%9C%EA%B0%80%EA%B8%B0-%EB%AC%B8%EC%A0%9C%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Tue, 27 May 2025 06:12:43 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>Flutter로 커뮤니티 앱을 개발하던 중, iOS에서 Cupertino 스타일의 스와이프 제스처(뒤로가기) 가 앱의 특정 화면에서 의도치 않게 동작하는 문제가 발생했다.
특히게시글 상세 페이지 등에서 사용자가 데이터를 입력 중(예: 좋아요, 댓글 작성) 혹은 좋아요 상태를 변경한 상태에서 스와이프하면, 데이터 반영 없이 화면이 닫혀버린다.
뒤로가기를 해서 다시 데이터를 호출하여 갱신하는 경우는 좋은 방식이 아니였기에 객체 내에서만 업데이트가 필요했다.</p>
<p>반면 Android에서는 이런 문제가 없었고, 뒤로가기 버튼과 WillPopScope로 제어가 가능했다.</p>
<h3 id="첫-시도-willpopscope">첫 시도: WillPopScope</h3>
<p>Flutter 기본 WillPopScope를 사용하여 뒤로가기 이벤트를 감지하고, 사용자 확인 팝업을 띄우는 방식으로 시도했다.</p>
<pre><code class="language-dart">WillPopScope(
  onWillPop: () async {
    // 사용자 확인 및 데이터 처리
    return false; // 혹은 true
  },
  child: Scaffold(...),
);
</code></pre>
<p>하지만 iOS의 스와이프 제스처는 WillPopScope를 우회하여 작동했다. 즉, 사용자가 왼쪽 엣지를 스와이프하면 바로 화면이 닫혀버렸고, 뒤로가기 시 감지가 불가능했다.</p>
<h3 id="두-번째-시도-popscope-flutter-37">두 번째 시도: PopScope (Flutter 3.7+)</h3>
<p>Flutter 3.7부터 도입된 PopScope는 iOS 스와이프까지 감지할 수 있는 강력한 기능을 제공한다. 이를 적용해 iOS에서도 뒤로가기 제어를 시도했다.</p>
<pre><code class="language-dart">PopScope(
  canPop: false,
  onPopInvoked: (didPop) {
    if (didPop) {
      // 뒤로가기 또는 스와이프 감지됨
    }
  },
  child: Scaffold(...),
);</code></pre>
<p>하지만 canPop: false만으로는 스와이프를 완전히 차단하지 못했다. 또한 사용자가 제스처를 완전히 완료하기 전에 차단할 방법은 없었다.</p>
<h3 id="시행착오-ios만-스와이프-차단-android는-기존-유지">시행착오: iOS만 스와이프 차단, Android는 기존 유지</h3>
<p>PopScope + canPop + onPopInvoked는 유용했지만, 사용자가 의도적으로 스와이프하려는 행위를 감지하고 직접 처리하는 방식을 고려했다.
또한 Android에서는 기존 WillPopScope를 유지하고자 했다.</p>
<h3 id="최종-해결-platform-분기--gesturedetector">최종 해결: Platform 분기 + GestureDetector</h3>
<p>최종적으로 아래와 같이 정리했다:</p>
<p>iOS: PopScope(canPop: false) + GestureDetector(onHorizontalDragUpdate)를 사용하여 스와이프 감지 및 직접 처리.</p>
<p>Android: 기존 WillPopScope 유지.</p>
<pre><code class="language-dart">return Platform.isIOS
    ? PopScope(
        canPop: false,
        onPopInvoked: (didPop) {
          print(&#39;iOS 스와이프 막힘&#39;);
        },
        child: GestureDetector(
          onHorizontalDragUpdate: (details) {
            if (details.delta.dx &gt; 10) {
              ctl._onWillPop().then((canPop) {
                if (canPop) Get.back();
              });
            }
          },
          child: scaffold,
        ),
      )
    : WillPopScope(
        onWillPop: ctl._onWillPop,
        child: scaffold,
      );
</code></pre>
<h3 id="왜-이-방법을-선택했나">왜 이 방법을 선택했나?</h3>
<p>PopScope는 iOS의 스와이프를 감지할 수 있지만, 완벽한 제어는 어렵다.</p>
<p>GestureDetector를 추가하여 스와이프 방향과 거리를 감지하면, 스와이프 시작 시도 자체를 제어 가능하다.</p>
<p>Android는 스와이프 대신 하드웨어 뒤로가기 버튼을 사용하므로, 기존 WillPopScope로 충분하다.</p>
<p>플랫폼별 분기 처리로 일관성을 유지했다.</p>
<h3 id="결론">결론</h3>
<p>Flutter의 기본 네비게이션과 제스처는 플랫폼별 동작 차이가 존재한다.
iOS의 Cupertino 네비게이션 스와이프는 기본적으로 우회 가능하므로, 직접 제어 로직을 고려해야 한다.
PopScope와 GestureDetector를 병행해 사용자 경험과 데이터 안정성을 모두 확보할 수 있었다.
Flutter에서는 기능별, 플랫폼별 분기 처리를 잘 설계하는 것이 유지보수성과 안정성에 매우 중요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]PageView와 InteratcionViewer를 동시에 사용할때 Zoom에 따른 제스쳐 우선권 이슈]]></title>
            <link>https://velog.io/@loopback_log/FlutterPageView%EC%99%80-InteratcionViewer%EB%A5%BC-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%A0%EB%95%8C-Zoom%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%A0%9C%EC%8A%A4%EC%B3%90-%EC%9A%B0%EC%84%A0%EA%B6%8C-%EC%9D%B4%EC%8A%88</link>
            <guid>https://velog.io/@loopback_log/FlutterPageView%EC%99%80-InteratcionViewer%EB%A5%BC-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%A0%EB%95%8C-Zoom%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%A0%9C%EC%8A%A4%EC%B3%90-%EC%9A%B0%EC%84%A0%EA%B6%8C-%EC%9D%B4%EC%8A%88</guid>
            <pubDate>Tue, 13 May 2025 04:48:28 GMT</pubDate>
            <description><![CDATA[<h2 id="기존-구현-개요">기존 구현 개요</h2>
<h3 id="사용-구성요소">사용 구성요소</h3>
<p>PageView.builder를 사용해 이미지/비디오 미디어 리스트를 스와이프</p>
<p>이미지 뷰어: InteractiveViewer로 핀치 줌, 팬 기능 구현
비디오 뷰어: VideoPlayerController + GestureDetector로 재생/탭 제어
확대 여부를 감지하기 위해 onInteractionUpdate를 통해 isZoomed 상태 추적
isZoomed일 경우 NeverScrollableScrollPhysics로 PageView 스크롤을 제한하는 방식 사용</p>
<h3 id="예상-동작">예상 동작</h3>
<p>핀치 줌 시 스와이프가 차단되어야 함
줌 상태가 아닌 경우에는 자유롭게 좌우 넘김 가능해야 함</p>
<h3 id="문제--줌-중-스와이프가-동작해버림">문제 : 줌 중 스와이프가 동작해버림</h3>
<p>이미지가 아직 줌되고 있는 도중에 좌우 드래그를 시도하면, PageView가 먼저 인식하고 페이지가 넘어가버림</p>
<p>isZoomed 상태는 줌이 확실히 끝난 뒤에만 반응하므로 초기 핀치 시도에 대한 제어가 불가능</p>
<h3 id="원인-flutter-gesturearena-구조">원인: Flutter GestureArena 구조</h3>
<p>Flutter는 모든 제스처 인식기(GestureRecognizer) 가 하나의 Arena에 모여 &quot;경쟁&quot;하며, 더 일찍, 더 강력한 제스처가 이벤트를 가져간다:</p>
<h3 id="제스처-종류">제스처 종류</h3>
<p>좌우 스와이프    HorizontalDragGestureRecognizer (PageView)
핀치 줌    ScaleGestureRecognizer (InteractiveViewer)</p>
<ul>
<li>기본적으로 PageView의 드래그 인식기가 강력하고 빠르게 동작함</li>
<li>InteractiveViewer는 핀치가 일정 정도 이상 진행되어야 인식</li>
<li>따라서 핀치 시도 중에도 PageView가 제스처를 먼저 잡아버리는 현상 발생</li>
</ul>
<h3 id="기존-코드에서-시도된-해결-방법">기존 코드에서 시도된 해결 방법</h3>
<pre><code class="language-dart">복사
편집
physics: ctl.isZoomed.value
    ? const NeverScrollableScrollPhysics()
    : const BouncingScrollPhysics(),</code></pre>
<h3 id="한계">한계</h3>
<p>isZoomed는 ScaleUpdateDetails에서 scale &gt; 1.0일 때만 true로 변경됨</p>
<p>핀치를 시작하기 전에 이미 PageView가 제스처를 가져가 버리면 스크롤 차단이 무의미</p>
<h3 id="해결-전략-extended_image-도입">해결 전략: extended_image 도입</h3>
<p>선택한 도구: extended_image
extended_image는 이미지 뷰잉에 특화된 고성능 위젯이며, 내부적으로 제스처 간 우선순위, 충돌, 동시 제스처 등 GestureArena의 한계를 우회하는 로직이 포함되어 있음</p>
<h3 id="핵심-해결-포인트">핵심 해결 포인트</h3>
<ol>
<li>ExtendedImageGesturePageView
PageView + 이미지 줌 + 팬 + 더블탭 확대/축소 통합</li>
</ol>
<p>이미지 확대 중에는 자동으로 스크롤 차단됨</p>
<ol start="2">
<li>GestureConfig.inPageView = true
이 옵션 하나로 핀치 상태일 때 PageView의 drag 인식을 막음</li>
</ol>
<p>내부적으로 GestureDetector를 제어하여 PageView가 arena에서 이기지 못하게 처리</p>
<ol start="3">
<li><p>ExtendedImageMode.gesture
확대/축소, 팬, 더블탭 줌을 통합적으로 제공</p>
</li>
<li><p>비디오는 기존 방식 유지
FutureBuilder + VideoPlayer 조합을 유지하며, 전체 구조에 통합</p>
</li>
</ol>
<h3 id="리팩토링-예제-코드">리팩토링 예제 코드</h3>
<p>변경 전 (PageView + InteractiveViewer)</p>
<pre><code class="language-dart">PageView.builder(
  controller: ctl.pageController,
  physics: ctl.isZoomed.value
      ? const NeverScrollableScrollPhysics()
      : const BouncingScrollPhysics(),
  ...
)</code></pre>
<p>변경 후 (ExtendedImageGesturePageView)</p>
<pre><code class="language-dart">ExtendedImageGesturePageView.builder(
  controller: ctl.pageController,
  itemCount: ctl.mediaItems.length,
  onPageChanged: ctl.updateIndex,
  scrollDirection: Axis.horizontal,
  physics: const BouncingScrollPhysics(),
  itemBuilder: (ctx, idx) {
    final item = ctl.mediaItems[idx];

    if (item.type == MediaType.IMAGE) {
      return ExtendedImage.network(
        item.url,
        fit: BoxFit.contain,
        mode: ExtendedImageMode.gesture,
        enableLoadState: true,
        initGestureConfigHandler: (_) =&gt; GestureConfig(
          minScale: 1.0,
          maxScale: 4.0,
          initialScale: 1.0,
          animationMinScale: 0.7,
          animationMaxScale: 4.0,
          inPageView: true, // 핵심
          cacheGesture: false,
        ),
      );
    }

    return FutureBuilder&lt;VideoPlayerController&gt;(
      future: ctl.futureAt(idx),
      builder: (_, snap) {
        if (!snap.hasData) return const OringLoadingIndicator().center();
        final vc = snap.data!;
        final screenWidth = MediaQuery.of(ctx).size.width;
        final aspectRatio = vc.value.size.width / vc.value.size.height;
        final height = screenWidth / aspectRatio;

        return Stack(
          children: [
            Center(
              child: VideoPlayer(vc)
                  .constrained(width: screenWidth, height: height),
            ),
            Positioned.fill(
              child: GestureDetector(
                onTap: ctl.toggleControls,
                behavior: HitTestBehavior.translucent,
                child: Container(color: Colors.transparent),
              ),
            ),
          ],
        );
      },
    );
  },
)</code></pre>
<h3 id="왜-이-방식이-근본적인-해결인가">왜 이 방식이 근본적인 해결인가?</h3>
<p>기준    기존 방식 (InteractiveViewer)    extended_image 방식
제스처 충돌    GestureArena에서 스와이프가 승리    GestureArena를 우회 또는 내부 제어
줌 중 페이지 넘김 방지    수동으로 physics 조절 → 지연됨    자동 제어 (즉시 적용)
더블탭 줌    직접 구현해야 함    기본 포함
팬 제스처    제한적    고급 팬 + 스프링 복구 포함
유지보수    제스처 복잡성 ↑    명확한 역할 분리로 ↓</p>
<h2 id="결론">결론</h2>
<p>기존 Flutter 구조에서는 PageView와 InteractiveViewer의 제스처 충돌이 근본적으로 해결되지 않음
extended_image의 ExtendedImageGesturePageView는 이러한 제스처 충돌 문제를 설계 차원에서 해결
특히 GestureConfig.inPageView = true 옵션은 줌 상태일 때 스크롤 차단이라는 핵심 기능을 제공
전체 구조는 유지하면서도, 이미지 줌 시 UX를 훨씬 자연스럽고 직관적으로 개선</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]iOS에서 FCM 푸시 알림이 두 번 뜨는 이유와 해결 방법]]></title>
            <link>https://velog.io/@loopback_log/FlutteriOS%EC%97%90%EC%84%9C-FCM-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC%EC%9D%B4-%EB%91%90-%EB%B2%88-%EB%9C%A8%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@loopback_log/FlutteriOS%EC%97%90%EC%84%9C-FCM-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC%EC%9D%B4-%EB%91%90-%EB%B2%88-%EB%9C%A8%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 24 Apr 2025 07:26:30 GMT</pubDate>
            <description><![CDATA[<p>앱에 푸시 알림 붙였을 때, 안드로이드는 잘 동작하는데 iOS에서는 알림이 두 번씩 오는 문제가 생길 때가 있다. 특히 Firebase Cloud Messaging(FCM) 쓰고 있다면 이거 한 번쯤 겪었을 이슈다. 
왜 이런 일이 생기는지, 그리고 어떻게 해결하는지 정리해봤다.</p>
<p>이중 표시의 원인은 다음 두 가지 알림 처리 방식이 동시에 작동하기 때문이다:</p>
<h3 id="fcm-시스템이-표시하는-알림">FCM 시스템이 표시하는 알림</h3>
<p>Firebase iOS SDK에서 제공하는 기능으로, setForegroundNotificationPresentationOptions를 통해 포그라운드 상태에서도 시스템이 알림을 띄울 수 있다.</p>
<pre><code class="language-dart">await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
  alert: true,
  badge: true,
  sound: true,
);</code></pre>
<h3 id="개발자가-직접-표시하는-로컬-알림">개발자가 직접 표시하는 로컬 알림</h3>
<p>FirebaseMessaging.onMessage.listen에서 flutter_local_notifications 등을 사용해 알림을 수동으로 띄우는 방식.</p>
<pre><code class="language-dart">FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  flutterLocalNotificationsPlugin.show(
    ...
  );
});</code></pre>
<p> 이 두 방식이 동시에 작동하면 동일한 알림이 2번 노출되는 것이다.</p>
<h2 id="방법-1-시스템-표시-끄고-수동으로만-표시하기-권장">방법 1: 시스템 표시 끄고 수동으로만 표시하기 (권장)</h2>
<pre><code class="language-dart">await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
  alert: false, // ← 이 부분이 핵심
  badge: true,
  sound: true,
);
</code></pre>
<p>이렇게 하면 iOS 시스템에서 알림을 자동으로 띄우지 않으므로, 개발자가 onMessage.listen 안에서 수동으로만 표시하면 된다.</p>
<h2 id="방법-2-수동-표시-조건을-넣어서-중복-방지하기">방법 2: 수동 표시 조건을 넣어서 중복 방지하기</h2>
<p>onMessage.listen 내부에서 iOS인지 확인 후 처리하지 않도록 조건을 둘 수 있다.</p>
<pre><code class="language-dart">if (Platform.isAndroid) {
  flutterLocalNotificationsPlugin.show(
    ...
  );
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]Flutter에서 이미지 비율을 유지하면서 자동으로 너비 조정하기]]></title>
            <link>https://velog.io/@loopback_log/Flutter</link>
            <guid>https://velog.io/@loopback_log/Flutter</guid>
            <pubDate>Thu, 06 Mar 2025 09:14:56 GMT</pubDate>
            <description><![CDATA[<p>Flutter에서 이미지를 표시할 때 원본 이미지의 비율(aspect ratio)을 유지하면서 자동으로 너비를 조정해야 하는 경우가 있다. 특히 네트워크 이미지를 로드할 때는 이미지의 원본 크기를 미리 알 수 없기 때문에 더 어려움이 있을 수 있다. 이 글에서는 고정된 높이를 유지하면서 이미지의 비율에 따라 너비를 자동으로 조정하는 방법을 살펴본다.</p>
<h2 id="문제-상황">문제 상황</h2>
<p>다음과 같은 요구사항이 있다고 가정해보자:</p>
<ol>
<li>이미지의 높이는 150픽셀로 고정되어 있다.</li>
<li>이미지의 너비는 원본 이미지의 비율에 맞게 자동으로 조정되어야 한다.</li>
<li>이미지 로딩 중에도 적절한 크기의 컨테이너를 표시해야 한다.</li>
</ol>
<p>일반적으로 <code>AspectRatio</code> 위젯이나 <code>FittedBox</code>를 사용할 수 있지만, 네트워크 이미지의 원본 비율을 알아내는 것이 관건이다.</p>
<h2 id="해결-방법">해결 방법</h2>
<p>Flutter에서는 <code>ImageStreamListener</code>를 사용하여 이미지가 로드되기 전에 이미지의 메타데이터(너비와 높이)를 가져올 수 있다. 이 정보를 바탕으로 이미지의 비율을 계산하고, 그에 맞는 너비를 지정할 수 있다.</p>
<h3 id="전체-구현-예제">전체 구현 예제</h3>
<p>아래는 네트워크 이미지의 비율에 따라 자동으로 너비를 조정하는 위젯의 전체 구현 예제다:</p>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;package:flutter/material.dart&#39;;

class AspectRatioNetworkImage extends StatelessWidget {
  final String imageUrl;
  final double fixedHeight;
  final BoxFit fit;
  final Widget? placeholder;
  final double defaultWidth;

  const AspectRatioNetworkImage({
    Key? key,
    required this.imageUrl,
    this.fixedHeight = 150.0,
    this.fit = BoxFit.cover,
    this.placeholder,
    this.defaultWidth = 250.0,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder&lt;ImageInfo&gt;(
      future: _getImageInfo(imageUrl),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done &amp;&amp; snapshot.hasData) {
          // 이미지 정보를 바탕으로 비율 계산
          final double aspectRatio = snapshot.data!.image.width / snapshot.data!.image.height;

          // 고정된 높이를 기준으로 너비 계산
          final calculatedWidth = fixedHeight * aspectRatio;

          return Container(
            height: fixedHeight,
            width: calculatedWidth,
            child: Image.network(
              imageUrl,
              height: fixedHeight,
              width: calculatedWidth,
              fit: fit,
            ),
          );
        } else {
          // 로딩 중 상태
          return LayoutBuilder(
            builder: (context, constraints) {
              // 부모 위젯의 제약조건 확인하여 로딩 중 너비 결정
              final parentWidth = constraints.maxWidth;
              final loadingWidth = parentWidth &gt; 0 ? parentWidth * 0.8 : defaultWidth;

              return Container(
                height: fixedHeight,
                width: loadingWidth,
                child: placeholder ?? Center(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      CircularProgressIndicator(),
                      SizedBox(height: 8),
                      Text(&quot;이미지 로딩 중...&quot;),
                    ],
                  ),
                ),
              );
            }
          );
        }
      },
    );
  }

  // 이미지 정보를 가져오는 함수
  Future&lt;ImageInfo&gt; _getImageInfo(String url) async {
    final Completer&lt;ImageInfo&gt; completer = Completer();
    final ImageStream stream = NetworkImage(url).resolve(const ImageConfiguration());

    final ImageStreamListener listener = ImageStreamListener(
      (ImageInfo info, bool _) =&gt; completer.complete(info),
      onError: (dynamic exception, StackTrace? stackTrace) {
        completer.completeError(exception);
      },
    );

    stream.addListener(listener);
    return completer.future;
  }
}</code></pre>
<h3 id="사용-예제">사용 예제</h3>
<p>이 위젯을 사용하는 방법은 다음과 같다:</p>
<pre><code class="language-dart">// 기본 사용법
AspectRatioNetworkImage(
  imageUrl: &#39;https://picsum.photos/1200/800&#39;,
  fixedHeight: 150.0,
)

// 커스텀 로딩 플레이스홀더 사용
AspectRatioNetworkImage(
  imageUrl: &#39;https://picsum.photos/1200/800&#39;,
  fixedHeight: 200.0,
  fit: BoxFit.contain,
  placeholder: Center(child: Text(&#39;Loading...&#39;)),
  defaultWidth: 300.0,
)</code></pre>
<h2 id="동작-원리-설명">동작 원리 설명</h2>
<p>이 솔루션의 핵심 동작 원리는 다음과 같다:</p>
<h3 id="1-이미지-메타데이터-가져오기">1. 이미지 메타데이터 가져오기</h3>
<p><code>_getImageInfo()</code> 메서드는 <code>NetworkImage</code>와 <code>ImageStreamListener</code>를 사용하여 이미지가 실제로 로드되기 전에 이미지의 원본 너비와 높이 정보를 가져온다. 이 과정은 비동기적으로 이루어진다.</p>
<pre><code class="language-dart">Future&lt;ImageInfo&gt; _getImageInfo(String url) async {
  final Completer&lt;ImageInfo&gt; completer = Completer();
  final ImageStream stream = NetworkImage(url).resolve(const ImageConfiguration());

  final ImageStreamListener listener = ImageStreamListener(
    (ImageInfo info, bool _) =&gt; completer.complete(info),
    onError: (dynamic exception, StackTrace? stackTrace) {
      completer.completeError(exception);
    },
  );

  stream.addListener(listener);
  return completer.future;
}</code></pre>
<h3 id="2-비율-계산-및-적용">2. 비율 계산 및 적용</h3>
<p>이미지 정보를 성공적으로 가져오면, 원본 이미지의 너비와 높이를 사용하여 종횡비(aspect ratio)를 계산한다:</p>
<pre><code class="language-dart">final double aspectRatio = snapshot.data!.image.width / snapshot.data!.image.height;</code></pre>
<p>이 비율을 사용하여 고정된 높이에 맞는 너비를 계산한다:</p>
<pre><code class="language-dart">final calculatedWidth = fixedHeight * aspectRatio;</code></pre>
<p>예를 들어, 원본 이미지가 1200×800 픽셀이라면 종횡비는 1.5가 된다. 고정 높이가 150픽셀이라면 계산된 너비는 225픽셀(150 * 1.5)이 된다.</p>
<h3 id="3-로딩-중-상태-처리">3. 로딩 중 상태 처리</h3>
<p>이미지 메타데이터를 가져오는 동안에는 로딩 상태를 표시한다. <code>LayoutBuilder</code>를 사용하여 부모 위젯의 제약조건을 확인하고, 적절한 너비를 결정한다:</p>
<pre><code class="language-dart">LayoutBuilder(
  builder: (context, constraints) {
    final parentWidth = constraints.maxWidth;
    final loadingWidth = parentWidth &gt; 0 ? parentWidth * 0.8 : defaultWidth;

    return Container(
      height: fixedHeight,
      width: loadingWidth,
      child: placeholder ?? Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            CircularProgressIndicator(),
            SizedBox(height: 8),
            Text(&quot;이미지 로딩 중...&quot;),
          ],
        ),
      ),
    );
  }
)</code></pre>
<h2 id="실제-적용-사례">실제 적용 사례</h2>
<p>이 패턴은 다양한 시나리오에서 유용하게 사용할 수 있다:</p>
<ol>
<li>이미지 갤러리 - 고정된 높이에서 이미지들이 자연스러운 비율로 표시된다.</li>
<li>이미지 업로드 미리보기 - 사용자가 업로드한 이미지의 비율을 유지하면서 표시한다.</li>
<li>배너 이미지 - 이미지의 중요한 부분이 잘리지 않도록 비율을 유지한다.</li>
</ol>
<h2 id="고려사항-및-최적화">고려사항 및 최적화</h2>
<h3 id="1-메모리-캐싱">1. 메모리 캐싱</h3>
<p>동일한 이미지에 대해 반복적으로 메타데이터를 가져오는 것을 방지하기 위해 이미지 정보를 캐싱할 수 있다:</p>
<pre><code class="language-dart">// 정적 캐시 맵 추가
static final Map&lt;String, ImageInfo&gt; _imageInfoCache = {};

// _getImageInfo 메서드 수정
Future&lt;ImageInfo&gt; _getImageInfo(String url) async {
  // 캐시된 정보가 있으면 반환
  if (_imageInfoCache.containsKey(url)) {
    return _imageInfoCache[url]!;
  }

  final Completer&lt;ImageInfo&gt; completer = Completer();
  final ImageStream stream = NetworkImage(url).resolve(const ImageConfiguration());

  final ImageStreamListener listener = ImageStreamListener(
    (ImageInfo info, bool _) {
      _imageInfoCache[url] = info; // 캐시에 저장
      completer.complete(info);
    },
    onError: (dynamic exception, StackTrace? stackTrace) {
      completer.completeError(exception);
    },
  );

  stream.addListener(listener);
  return completer.future;
}</code></pre>
<h3 id="2-에러-처리">2. 에러 처리</h3>
<p>네트워크 이미지 로드 실패에 대한 적절한 에러 처리를 추가할 수 있다:</p>
<pre><code class="language-dart">FutureBuilder&lt;ImageInfo&gt;(
  // ...
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return Container(
        height: fixedHeight,
        width: defaultWidth,
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(Icons.error, color: Colors.red),
              SizedBox(height: 8),
              Text(&quot;이미지를 불러올 수 없습니다&quot;),
            ],
          ),
        ),
      );
    }
    // ...
  }
)</code></pre>
<h2 id="결론">결론</h2>
<p>Flutter에서 네트워크 이미지의 원본 비율을 유지하면서 높이를 고정하고 너비를 자동으로 조정하는 방법을 알아보았다. 이 접근 방식은 <code>ImageStreamListener</code>를 사용하여 이미지의 메타데이터를 먼저 가져온 후, 계산된 비율에 따라 컨테이너의 크기를 조정하는 방식으로 작동한다.</p>
<p>이 기술을 사용하면 UI 디자인에서 이미지의 원본 비율을 존중하면서도 레이아웃의 일관성을 유지할 수 있다. 특히 사용자가 업로드한 이미지나 다양한 비율의 이미지를 표시해야 하는 애플리케이션에서 유용하다.<img src="https://velog.velcdn.com/images/loopback_log/post/a2583f17-c264-4c14-a30a-35b52df835ae/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]List, Set, Map]]></title>
            <link>https://velog.io/@loopback_log/FlutterList-Set-Map</link>
            <guid>https://velog.io/@loopback_log/FlutterList-Set-Map</guid>
            <pubDate>Mon, 03 Feb 2025 04:37:03 GMT</pubDate>
            <description><![CDATA[<p>Dart의 기본적인 컬렉션(List, Set, Map)과 Class 개념을 하나씩 살펴보자.</p>
<h3 id="1-list-리스트">1. List (리스트)</h3>
<p>List는 Dart에서 배열과 유사한 개념으로, 여러 개의 값을 순차적으로 저장하는 컬렉션이다.</p>
<h4 id="list의-특징">List의 특징</h4>
<ol>
<li>인덱스(index)를 사용하여 요소에 접근</li>
<li>중복된 값을 허용</li>
<li>가변 길이(Growable List)와 고정 길이(Fixed-Length List)가 있음<h4 id="list-생성-방법">List 생성 방법</h4>
<pre><code class="language-dart"></code></pre>
</li>
</ol>
<p>void main() {
  // 1. 기본적인 List 선언 (가변 길이)
  List<int> numbers = [1, 2, 3, 4, 5];
  print(numbers); // [1, 2, 3, 4, 5]</p>
<p>  // 2. 빈 리스트 선언 후 값 추가
  List<String> names = [];
  names.add(&quot;Alice&quot;);
  names.add(&quot;Bob&quot;);
  print(names); // [&quot;Alice&quot;, &quot;Bob&quot;]</p>
<p>  // 3. 고정 길이 리스트
  List<int> fixedList = List.filled(3, 0);
  print(fixedList); // [0, 0, 0]</p>
<p>  // 4. 다양한 메서드 사용
  numbers.add(6);
  numbers.removeAt(0);
  print(numbers.contains(3)); // true
  print(numbers.length); // 5
}</p>
<pre><code>### List 주요 메서드

1. add(value)    리스트에 값 추가
2. remove(value)    특정 값 삭제
3. removeAt(index)    특정 인덱스의 값 삭제
4. length    리스트의 길이 반환
5. contains(value)    특정 값 포함 여부 확인
6. map()    리스트 내 모든 요소에 함수 적용
7. where()    특정 조건을 만족하는 요소 필터링


### 2. Set (셋)
Set은 중복을 허용하지 않는 컬렉션이다.

#### Set의 특징
1. 중복된 값을 허용하지 않음
2. 순서가 보장되지 않음
3. List와 달리 인덱스로 접근 불가능
#### Set 생성 방법
```dart
void main() {
  // 1. 기본적인 Set 선언
  Set&lt;int&gt; numberSet = {1, 2, 3, 4, 5};
  print(numberSet); // {1, 2, 3, 4, 5}

  // 2. 중복된 값 추가 (자동으로 제거됨)
  numberSet.add(3);
  numberSet.add(6);
  print(numberSet); // {1, 2, 3, 4, 5, 6}

  // 3. Set 메서드 활용
  numberSet.remove(2);
  print(numberSet.contains(3)); // true
  print(numberSet.length); // 5
}</code></pre><h4 id="set-주요-메서드">Set 주요 메서드</h4>
<ol>
<li>add(value)    값 추가</li>
<li>remove(value)    특정 값 삭제</li>
<li>contains(value)    특정 값 포함 여부 확인</li>
<li>length    Set의 크기 반환</li>
<li>union(otherSet)    두 Set을 합친 새로운 Set 반환</li>
<li>intersection(otherSet)    두 Set의 공통 요소 반환</li>
</ol>
<h3 id="3-map-맵">3. Map (맵)</h3>
<p>Map은 Key-Value(키-값) 쌍으로 데이터를 저장하는 컬렉션이다.</p>
<h4 id="map의-특징">Map의 특징</h4>
<ol>
<li><p>고유한 Key 값을 기반으로 데이터 저장</p>
</li>
<li><p>Key를 통해 Value에 접근 가능</p>
</li>
<li><p>Key는 중복될 수 없지만, Value는 중복 가능</p>
<h4 id="map-생성-방법">Map 생성 방법</h4>
<pre><code class="language-dart">void main() {
// 1. 기본적인 Map 선언
Map&lt;String, int&gt; ageMap = {
 &quot;Alice&quot;: 25,
 &quot;Bob&quot;: 30,
 &quot;Charlie&quot;: 28
};
print(ageMap); // {Alice: 25, Bob: 30, Charlie: 28}

// 2. 값 추가 및 변경
ageMap[&quot;David&quot;] = 22;
ageMap[&quot;Alice&quot;] = 26;
print(ageMap[&quot;Alice&quot;]); // 26

// 3. Map 메서드 활용
print(ageMap.keys); // (Alice, Bob, Charlie, David)
print(ageMap.values); // (26, 30, 28, 22)
print(ageMap.containsKey(&quot;Charlie&quot;)); // true
ageMap.remove(&quot;Bob&quot;);
print(ageMap); // {Alice: 26, Charlie: 28, David: 22}
}</code></pre>
<h3 id="map-주요-메서드">Map 주요 메서드</h3>
</li>
<li><p>putIfAbsent(key, () =&gt; value)    키가 없을 경우 값 추가</p>
</li>
<li><p>remove(key)    특정 키 삭제</p>
</li>
<li><p>containsKey(key)    특정 키 포함 여부 확인</p>
</li>
<li><p>containsValue(value)    특정 값 포함 여부 확인</p>
</li>
<li><p>keys    모든 키 반환</p>
</li>
<li><p>values    모든 값 반환</p>
</li>
</ol>
<pre><code class="language-dart">개념    특징
List : 순서가 있으며, 중복 가능
Set    : 순서가 없으며, 중복 불가능
Map    : Key-Value 쌍으로 저장</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]Styled Widget 패키지]]></title>
            <link>https://velog.io/@loopback_log/FlutterStyled-Widget-%ED%8C%A8%ED%82%A4%EC%A7%80</link>
            <guid>https://velog.io/@loopback_log/FlutterStyled-Widget-%ED%8C%A8%ED%82%A4%EC%A7%80</guid>
            <pubDate>Fri, 09 Aug 2024 00:28:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번 프로젝트에서 styled widget 패키지를 사용하고 있어, 간략하게 패키지에 대해 정리한다.</p>
</blockquote>
<h1 id="styled-widget-개념-및-사용-가이드">Styled Widget 개념 및 사용 가이드</h1>
<h2 id="1-styled-widget이란">1. Styled Widget이란?</h2>
<p>Styled Widget은 Flutter 애플리케이션에서 위젯의 스타일을 더 쉽고 체계적으로 관리할 수 있게 해주는 패키지다. 
이 패키지를 사용하면 위젯의 스타일을 선언적이고 재사용 가능한 방식으로 정의할 수 있다.</p>
<h2 id="2-왜-styled-widget을-사용하는가">2. 왜 Styled Widget을 사용하는가?</h2>
<p><strong>코드 간소화:</strong> 복잡한 스타일 코드를 간단하고 읽기 쉬운 형태로 작성할 수 있다.
재사용성: 한 번 정의한 스타일을 여러 위젯에서 재사용할 수 있다.
<strong>유지보수 용이성:</strong> 스타일 변경이 필요할 때 한 곳에서 수정하면 모든 관련 위젯에 적용된다.
<strong>일관성:</strong> 앱 전체에 걸쳐 일관된 디자인을 유지하기 쉬워진다.
*<em>가독성: *</em>위젯 트리와 스타일 정의를 분리하여 코드의 가독성이 향상된다.</p>
<h2 id="3-styled-widget-사용-예제">3. Styled Widget 사용 예제</h2>
<h3 id="설치">설치</h3>
<pre><code class="language-dart">dependencies:
  flutter:
    sdk: flutter
  styled_widget: ^1.0.0  # 최신 버전으로 대체</code></pre>
<h3 id="기존-플러터에서-작성법">기존 플러터에서 작성법</h3>
<pre><code class="language-dart">Container(
  padding: EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(8),
  ),
  child: Text(
    &#39;Click me&#39;,
    style: TextStyle(
      color: Colors.white,
      fontSize: 18,
      fontWeight: FontWeight.bold,
    ),
  ),
)</code></pre>
<h3 id="styled-widget을-사용한-작성법">Styled Widget을 사용한 작성법</h3>
<pre><code class="language-dart">Text(&#39;Click me&#39;)
  .textColor(Colors.white)
  .fontSize(18)
  .fontWeight(FontWeight.bold)
  .padding(all: 16)
  .decorated(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(8),
  )</code></pre>
<p>이 예제에서는 텍스트 위젯에 여러 스타일을 체인 형식으로 적용하고 있다.</p>
<h3 id="스타일-재사용">스타일 재사용</h3>
<pre><code class="language-dart">final buttonStyle = Styled.widget(([Widget? child]) =&gt; child)
  .padding(all: 16)
  .decorated(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(8),
  )
  .gestures(
    onTap: () =&gt; print(&#39;Button tapped!&#39;),
  );

Widget myStyledButton() {
  return Text(&#39;Click me&#39;)
    .textColor(Colors.white)
    .fontSize(18)
    .fontWeight(FontWeight.bold)
    .parent(buttonStyle);
}</code></pre>
<p>이 예제에서는 buttonStyle을 정의하고 여러 버튼에서 재사용할 수 있다.</p>
<h3 id="4-주의사항">4. 주의사항</h3>
<p>Styled Widget은 기존 Flutter 위젯을 대체하는 것이 아니라 보완하는 도구다.
모든 상황에 적합하지 않을 수 있으므로, 프로젝트의 요구사항에 따라 사용 여부를 결정해야 한다.
팀 내에서 일관된 사용 방식을 정의하고 따르는 것이 중요하다.
실제로 필자는 처음 접했을 때, <strong>스플리팅이 되지 않은</strong> 방대한 코드에서 보기에는 오히려 더 가독성이 떨어지고 복잡해보였다. </p>
<h3 id="5-결론">5. 결론</h3>
<p>Styled Widget은 Flutter 애플리케이션의 UI 코드를 더 깔끔하고 유지보수하기 쉽게 만들어주는 강력한 도구다. 
특히 큰 규모의 프로젝트나 복잡한 UI를 가진 앱에서 그 장점이 두드러진다.
그러나 프로젝트의 특성에 따라 다른 스타일링 접근 방식(예: 테마 사용, 커스텀 위젯 생성)이 더 적합할 수 있다. 
Styled Widget의 도입 여부는 프로젝트의 요구사항과 팀의 선호도를 고려하여 결정해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Presigned URL 방식]]></title>
            <link>https://velog.io/@loopback_log/Presigned-URL-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@loopback_log/Presigned-URL-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Fri, 09 Aug 2024 00:17:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Presigned URL 방식은 클라우드 스토리지 서비스에서 파일을 업로드하거나 다운로드할 때 자주 사용되는 접근 방법이다.
이 방식은 서버가 특정 시간 동안 유효한 URL을 생성하여 클라이언트에게 제공한다. 
이 URL을 사용하면 해당 시간 동안 인증 없이도 파일에 접근할 수 있다. 
주로 AWS S3와 같은 서비스에서 많이 사용된다.</p>
</blockquote>
<h2 id="어떤-이점이-있는건가">어떤 이점이 있는건가?</h2>
<h3 id="1-보안-강화">1. 보안 강화</h3>
<p><strong>제한된 접근:</strong> Presigned URL은 특정 기간 동안만 유효하다. 
이 기간이 지나면 URL이 만료되어 더 이상 접근할 수 없다고 한다. 
이를 통해 파일에 대한 접근을 제한할 수 있다.
<strong>노출 최소화:</strong> 클라이언트는 직접적인 인증 정보를 갖지 않고도 파일에 접근할 수 있다. 
서버에서 생성된 presigned URL을 사용하기 때문에 인증 토큰이나 비밀번호를 노출할 필요가 없다.</p>
<h3 id="2-클라이언트와-서버-간의-부담-분산">2. 클라이언트와 서버 간의 부담 분산</h3>
<p><strong>서버 부담 감소:</strong> 파일 업로드나 다운로드 요청을 서버가 직접 처리하지 않고, 클라이언트가 클라우드 스토리지와 직접 통신한다. 이를 통해 서버의 부담을 줄일 수 있다.
<strong>대역폭 효율성:</strong> 클라이언트가 직접 파일을 업로드하거나 다운로드하기 때문에 서버와 클라이언트 간의 데이터 전송 대역폭을 효율적으로 사용할 수 있다.</p>
<h3 id="3-간편한-사용">3. 간편한 사용</h3>
<p><strong>단순한 구현: *<em>서버 측에서 presigned URL을 생성하여 클라이언트에 전달하기만 하면 되므로, 클라이언트 측에서는 복잡한 인증 절차를 거치지 않고 URL을 사용해 파일을 전송할 수 있다.
*</em>다양한 사용 사례 지원:</strong> 파일 업로드, 다운로드뿐만 아니라 특정 파일에 대한 일시적인 읽기 권한을 제공하는 등 다양한 시나리오에서 활용할 수 있다.</p>
<h3 id="4-일시적-권한-부여">4. 일시적 권한 부여</h3>
<p><strong>임시 접근 권한:</strong> 특정 파일에 대해 일시적인 접근 권한을 부여할 수 있어, 예를 들어 제한된 시간 동안만 파일을 공유하고 싶은 경우에 유용하다.</p>
<h3 id="5-적용-사례">5. 적용 사례</h3>
<p><strong>파일 업로드:</strong> 사용자가 파일을 업로드할 때 presigned URL을 사용하여 직접 클라우드 스토리지에 업로드하도록 할 수 있다.
<strong>파일 다운로드:</strong> 사용자가 특정 파일을 다운로드할 때 presigned URL을 사용하여 다운로드 링크를 제공할 수 있다.
<strong>파일 공유:</strong> 특정 기간 동안만 파일을 공유하고자 할 때 presigned URL을 사용할 수 있다.</p>
<h3 id="flutter에서-사용-예시">Flutter에서 사용 예시:</h3>
<p>AWS S3를 예로 들면, Flutter 애플리케이션에서 서버로부터 presigned URL을 받아와 해당 URL을 사용하여 파일을 업로드하거나 다운로드할 수 있다. 
http 패키지를 사용하여 HTTP 요청을 보내 presigned URL을 받을 수 있으며, 받은 URL을 통해 파일 전송 작업을 수행한다.</p>
<pre><code class="language-dart">import &#39;package:http/http.dart&#39; as http;

Future&lt;void&gt; uploadFile(String filePath, String presignedUrl) async {
  final file = File(filePath);
  final response = await http.put(
    Uri.parse(presignedUrl),
    headers: {
      &#39;Content-Type&#39;: &#39;application/octet-stream&#39;,
    },
    body: await file.readAsBytes(),
  );

  if (response.statusCode == 200) {
    print(&#39;File uploaded successfully&#39;);
  } else {
    print(&#39;File upload failed&#39;);
  }
}
</code></pre>
<p>위 예제는 presigned URL을 사용하여 파일을 업로드하는 간단한 예제다. 
비슷한 방식으로 파일 다운로드도 가능하다.</p>
<p>깊이가 없어 본 글에 알맹이가 없지만, 결론적으로 Presigned URL 방식은 Flutter와 같은 클라이언트 애플리케이션에서 파일 전송 작업을 안전하고 효율적으로 처리할 수 있도록 도와준다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]직렬화와 불변객체 + Freezed 도입]]></title>
            <link>https://velog.io/@loopback_log/Flutter%EC%A7%81%EB%A0%AC%ED%99%94%EC%99%80-%EB%B6%88%EB%B3%80%EA%B0%9D%EC%B2%B4-Freezed-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@loopback_log/Flutter%EC%A7%81%EB%A0%AC%ED%99%94%EC%99%80-%EB%B6%88%EB%B3%80%EA%B0%9D%EC%B2%B4-Freezed-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Wed, 10 Jul 2024 02:44:39 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Freezed 도입 이전에 직렬화와 불변 객체 생성에 대한 이해 개념이 필요하다.
본 글은 코딩 공부를 시작하는 조카를 위해 최대한 쉽게 설명해보겠다.</p>
</blockquote>
<h2 id="불변-객체란-무엇일까">불변 객체란 무엇일까?</h2>
<p>불변 객체는 한 번 만들어지면 그 값을 바꿀 수 없는 객체다. 
예를 들어, 생성한 게임 캐릭터가 있다고 생각해보면 이 캐릭터의 이름이나 능력치를 한 번 정하면 바꿀 수 없는 것과 같다.</p>
<h3 id="왜-필요할까">왜 필요할까?</h3>
<p><strong>안정성:</strong> 불변 객체는 값을 바꿀 수 없어서 실수로 값을 바꾸는 일이 없다. 
게임 중에 갑자기 캐릭터의 능력치가 바뀌면 곤란하기 때문이다.
<strong>예측 가능성:</strong> 언제나 같은 값을 유지하니까 코드가 어떻게 동작할지 예측하기 쉽다.
<strong>안전한 공유:</strong> 여러 사람이 동시에 객체를 사용할 때도 안전하다. 
예를 들어, 친구와 함께 게임을 하는데 친구가 캐릭터의 능력치를 바꾸면 혼란스러울 것이다. 
불변 객체는 이런 일을 방지해준다.
<strong>간단한 디버깅:</strong> 값이 변하지 않아서 디버깅(오류를 찾는 작업)이 더 쉬워진다.</p>
<h2 id="직렬화serialization란-무엇일까">직렬화(serialization)란 무엇일까?</h2>
<h4 id="의미">의미:</h4>
<p>직렬화는 객체를 JSON 같은 형식으로 바꾸는 작업이다. 
이렇게 하면 데이터를 네트워크로 주고받거나 파일에 저장할 수 있다.</p>
<h3 id="왜-필요할까-1">왜 필요할까?</h3>
<p><strong>데이터 전송:</strong> 서버와 통신할 때 데이터를 JSON 형식으로 주고 받는다. 
예를 들어, 게임 캐릭터 정보를 서버에 보내려면 JSON 형식으로 바꿔서 보내야 한다.
<strong>영구 저장:</strong> 데이터를 파일에 저장할 때도 JSON 형식으로 저장하면 나중에 쉽게 읽을 수 있다.
<strong>디버깅과 로깅:</strong> JSON 형식의 데이터를 쉽게 읽고 분석할 수 있어서 디버깅과 로깅에 유용하다.</p>
<h3 id="freezed-라이브러리를-사용하는-이유">freezed 라이브러리를 사용하는 이유</h3>
<p>freezed는 불변 객체를 쉽게 만들고, JSON 직렬화도 간편하게 해주는 라이브러리다. 
다음과 같은 장점이 있다:</p>
<p><strong>자동으로 불변 객체 생성:</strong> freezed는 불변 객체를 자동으로 만들어줘서 쉽게 사용할 수 있다.
<strong>직렬화 및 역직렬화 자동화:</strong> JSON 형식으로 데이터를 쉽게 변환하고 다시 객체로 만들 수 있다.
<strong>보일러플레이트 코드 제거:</strong> 반복적인 코드를 자동으로 생성해줘서 코드가 간결해진다.</p>
<h2 id="다른-라이브러리와-비교-왜-freezed를-추천하는지">다른 라이브러리와 비교, 왜 freezed를 추천하는지?</h2>
<h3 id="1-freezed-vs-built_value">1. freezed vs built_value</h3>
<p><strong>built_value</strong>는 Dart에서 불변 객체와 빌더 패턴을 제공하는 라이브러리다. 
그러나 freezed와 비교했을 때 몇 가지 차이점이 있다.</p>
<h4 id="주요-차이점">주요 차이점:</h4>
<p><strong>코드 간결성:</strong>
freezed는 코드 제너레이션을 통해 더 간단한 문법을 제공한다.
built_value는 빌더 패턴을 사용하여 좀 더 복잡한 설정이 필요하다고 느꼈다.</p>
<pre><code class="language-dart">// `freezed` 예제
@freezed
class User with _$User {
  factory User({
    required String name,
    required int age,
  }) = _User;
}

// `built_value` 예제
abstract class User implements Built&lt;User, UserBuilder&gt; {
  String get name;
  int get age;

  User._();
  factory User([void Function(UserBuilder) updates]) = _$User;
}</code></pre>
<p><strong>패턴 매칭:</strong>
freezed는 패턴 매칭을 통해 다양한 상태를 쉽게 처리할 수 있다.
built_value는 패턴 매칭을 제공하지 않는다.</p>
<pre><code class="language-dart">// `freezed` 패턴 매칭 예제
@freezed
class Result with _$Result {
  const factory Result.success(String data) = Success;
  const factory Result.error(String message) = Error;
}

void main() {
  final result = Result.success(&#39;Data loaded&#39;);

  result.when(
    success: (data) =&gt; print(&#39;Success: $data&#39;),
    error: (message) =&gt; print(&#39;Error: $message&#39;),
  );
}</code></pre>
<h3 id="2-freezed-vs-json_serializable">2. freezed vs json_serializable</h3>
<p><strong>json_serializable</strong>은 JSON 직렬화 및 역직렬화에 특화된 라이브러리다. 
freezed와 함께 사용될 수 있지만, freezed만으로도 JSON 관련 기능을 처리할 수 있디.</p>
<h4 id="주요-차이점-1">주요 차이점:</h4>
<p><strong>기능의 포괄성:</strong>
freezed는 JSON 직렬화 외에도 불변 객체, 패턴 매칭 등 다양한 기능을 제공한다.
json_serializable은 JSON 직렬화에 특화되어 있으며, 데이터 클래스를 직접 생성하지는 않는다.</p>
<pre><code class="language-dart">// `freezed` 예제
@freezed
class User with _$User {
  factory User({
    required String name,
    required int age,
  }) = _User;

  factory User.fromJson(Map&lt;String, dynamic&gt; json) =&gt; _$UserFromJson(json);
}</code></pre>
<h3 id="3-freezed-vs-equatable">3. freezed vs equatable</h3>
<p><strong>equatable</strong>은 객체의 동등성 비교를 쉽게 할 수 있도록 도와주는 라이브러리다. 
freezed는 이를 내장하고 있어 별도로 사용하지 않아도 된다.</p>
<h4 id="주요-차이점-2">주요 차이점:</h4>
<p><strong>동등성 비교:</strong>
freezed는 equatable을 내장하여 객체의 동등성 비교를 자동으로 처리한다.
equatable은 동등성 비교만을 위한 라이브러리로, 다른 기능은 제공하지 않는다.</p>
<pre><code class="language-dart">// `freezed` 예제
@freezed
class User with _$User {
  factory User({
    required String name,
    required int age,
  }) = _User;
}

// `equatable` 예제
class User extends Equatable {
  final String name;
  final int age;

  User({required this.name, required this.age});

  @override
  List&lt;Object&gt; get props =&gt; [name, age];
}</code></pre>
<h3 id="결론">결론</h3>
<p>freezed는 다양한 기능을 하나의 라이브러리로 제공하여, 별도의 라이브러리를 사용하지 않고도 불변 객체 생성, JSON 직렬화, 패턴 매칭, 동등성 비교 등을 쉽게 할 수 있게 해준다. 
built_value, json_serializable, equatable 등 다른 라이브러리와 비교했을 때, 더 많은 기능을 제공하며 코드의 간결성과 
생산성을 높여준다.</p>
<h2 id="freezed-설치-방법과-사용-예제">Freezed 설치 방법과 사용 예제</h2>
<h3 id="1-설치-방법">1. 설치 방법</h3>
<p>Step 1: pubspec.yaml 파일에 의존성 추가</p>
<pre><code class="language-yaml">dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^2.0.0

dev_dependencies:
  build_runner: ^2.0.0
  freezed: ^2.0.0
  json_serializable: ^6.0.0 # JSON serialization을 사용하는 경우</code></pre>
<p>Step 2: 의존성 설치</p>
<pre><code class="language-sh">flutter pub get</code></pre>
<h3 id="2-사용-예제">2. 사용 예제</h3>
<p>freezed를 사용하여 간단한 예제를 만들어 보겠다. 
예제에서는 사용자(User) 모델을 생성하고, 이를 JSON으로 직렬화하는 방법을 포함한다.</p>
<p><strong>Step 1: 모델 클래스 생성</strong></p>
<p>user.dart 파일을 생성한다.
필요한 패키지를 임포트한다.</p>
<pre><code class="language-dart">import &#39;package:freezed_annotation/freezed_annotation.dart&#39;;
part &#39;user.freezed.dart&#39;;
part &#39;user.g.dart&#39;;

@freezed
class User with _$User {
  const factory User({
    required String name,
    required int age,
  }) = _User;

  factory User.fromJson(Map&lt;String, dynamic&gt; json) =&gt; _$UserFromJson(json);
}</code></pre>
<p><strong>Step 2: 코드 생성</strong></p>
<pre><code class="language-sh">flutter pub run build_runner build</code></pre>
<p>이 명령어는 user.freezed.dart와 user.g.dart 파일을 생성한다. 
이 파일들은 freezed가 필요한 모든 boilerplate 코드를 생성한다.</p>
<p><strong>Step 3: 사용 예제</strong></p>
<pre><code class="language-dart">void main() {
  // User 인스턴스 생성
  final user = User(name: &#39;John Doe&#39;, age: 30);

  // User 인스턴스를 JSON으로 변환
  final userJson = user.toJson();
  print(userJson); // { &quot;name&quot;: &quot;John Doe&quot;, &quot;age&quot;: 30 }

  // JSON을 User 인스턴스로 변환
  final newUser = User.fromJson(userJson);
  print(newUser); // User(name: John Doe, age: 30)
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter]특정 프로젝트만 다른 플러터 버전으로 사용하는 방법]]></title>
            <link>https://velog.io/@loopback_log/Flutter%ED%8A%B9%EC%A0%95-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A7%8C-%EB%8B%A4%EB%A5%B8-%ED%94%8C%EB%9F%AC%ED%84%B0-%EB%B2%84%EC%A0%84%EC%9C%BC%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@loopback_log/Flutter%ED%8A%B9%EC%A0%95-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A7%8C-%EB%8B%A4%EB%A5%B8-%ED%94%8C%EB%9F%AC%ED%84%B0-%EB%B2%84%EC%A0%84%EC%9C%BC%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 10 Jul 2024 01:27:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>사이드 프로젝트 시, 최신 버전의 플러터를 사용하고 싶어 본 글을 작성한다.</p>
</blockquote>
<p>기본적으로 Flutter SDK는 전체 시스템에 설치되며, 여러 프로젝트 중 특정 프로젝트만 플러터의 다른 버전으로 
사용하는 것은 기본적으로 지원되지 않는다고 한다. 
이는 Flutter SDK가 시스템 전체에 영향을 미치는 설치 방식 때문이다. 
그러나 특정 프로젝트에서 다른 버전을 사용하고자 할 때는 몇 가지 방법을 사용할 수 있다:</p>
<h3 id="방법-1-flutter-sdk를-로컬로-설치">방법 1: Flutter SDK를 로컬로 설치</h3>
<p>Flutter SDK의 여러 버전을 각각의 폴더에 설치한다.
프로젝트별로 사용하는 Flutter 버전을 설정한다.
예시:</p>
<pre><code class="language-bash">/path/to/flutter_v1.22
/path/to/flutter_v2.0</code></pre>
<p>프로젝트의 루트 디렉터리에서 사용하고자 하는 Flutter 버전의 경로를 설정한다.</p>
<pre><code class="language-sh">export PATH=/path/to/flutter_v2.0/bin:$PATH</code></pre>
<h3 id="방법-2-fvm-flutter-version-manager-사용">방법 2: fvm (Flutter Version Manager) 사용</h3>
<p>Flutter Version Manager(fvm)를 사용하면 프로젝트별로 Flutter SDK의 버전을 관리할 수 있다고 한다.</p>
<h4 id="fvm-설치">fvm 설치:</h4>
<pre><code class="language-sh">dart pub global activate fvm</code></pre>
<h4 id="프로젝트별로-flutter-버전-설치">프로젝트별로 Flutter 버전 설치:</h4>
<pre><code class="language-sh">cd my_project
fvm install 2.0.6</code></pre>
<h4 id="프로젝트에-flutter-버전-설정">프로젝트에 Flutter 버전 설정:</h4>
<pre><code class="language-sh">fvm use 2.0.6</code></pre>
<h4 id="fvm을-통해-flutter-명령어-실행">fvm을 통해 Flutter 명령어 실행:</h4>
<pre><code class="language-sh">fvm flutter run</code></pre>
<h2 id="방법-3-flutter-version-파일-사용">방법 3: .flutter-version 파일 사용</h2>
<p>프로젝트 루트 디렉토리에 .flutter-version 파일을 생성하여 사용할 Flutter 버전을 명시할 수 있다.
이 방법은 fvm과 함께 사용된다.</p>
<pre><code class="language-sh">echo &quot;2.0.6&quot; &gt; .flutter-version</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 특정 영역에서 유저 인풋을 차단하기]]></title>
            <link>https://velog.io/@loopback_log/Flutter-%ED%8A%B9%EC%A0%95-%EC%98%81%EC%97%AD%EC%97%90%EC%84%9C-%EC%9C%A0%EC%A0%80-%EC%9D%B8%ED%92%8B%EC%9D%84-%EC%B0%A8%EB%8B%A8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@loopback_log/Flutter-%ED%8A%B9%EC%A0%95-%EC%98%81%EC%97%AD%EC%97%90%EC%84%9C-%EC%9C%A0%EC%A0%80-%EC%9D%B8%ED%92%8B%EC%9D%84-%EC%B0%A8%EB%8B%A8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 18 Jun 2024 08:00:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>프로젝트 진행 시에 Sacffold의 body 속성과 Indexed Stack을 함께 사용하는데 있어 레이아웃 복잡도가 생겨 자식 위젯들이 겹치는 이슈가 발생했다.
거기에 더해 나는 FAB까지 사용을 하는데 endflot위치가 바텀 내비게이션 바와 ui적으로 가까워 터치 이벤트가 겹치는 이슈가 발생했다.</p>
</blockquote>
<h2 id="absorbpointer란">AbsorbPointer란?</h2>
<p>AbsorbPointer는 Flutter에서 사용자 입력(터치 이벤트)을 차단하는 데 사용되는 위젯이다. 
이 위젯을 사용하면 특정 영역의 모든 사용자 입력을 무시할 수 있다. 
이를 통해 해당 영역에 배치된 자식 위젯들이 사용자 입력을 받지 않도록 할 수 있다.</p>
<h3 id="주요-속성">주요 속성</h3>
<p><strong>absorbing</strong>: 이 속성이 true로 설정되면 AbsorbPointer가 모든 입력 이벤트를 차단한다. 
false로 설정하면 자식 위젯이 정상적으로 입력 이벤트를 받을 수 있다.
<strong>ignoringSemantics</strong>: 이 속성이 true로 설정되면 AbsorbPointer는 자식 위젯의 
시맨틱 이벤트(접근성 이벤트)도 무시한다. 
기본값은 true다.</p>
<h3 id="사용-예시">사용 예시</h3>
<p>다음은 AbsorbPointer의 간단한 사용 예시다. 
버튼을 포함한 컨테이너에 AbsorbPointer를 적용하여 버튼이 터치 이벤트를 받지 않도록 한다.</p>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text(&#39;AbsorbPointer Example&#39;),
        ),
        body: Center(
          child: AbsorbPointer(
            absorbing: true,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: &lt;Widget&gt;[
                ElevatedButton(
                  onPressed: () {
                    print(&#39;Button Pressed&#39;);
                  },
                  child: Text(&#39;Press Me&#39;),
                ),
                Text(&#39;This button is not clickable&#39;),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
</code></pre>
<p>위의 예시에서 AbsorbPointer의 absorbing 속성을 true로 설정하여 버튼을 포함한 
모든 자식 위젯들이 터치 이벤트를 무시하도록 했다. 
따라서 &quot;Press Me&quot; 버튼을 눌러도 아무런 반응이 없다.</p>
<h3 id="활용-사례">활용 사례</h3>
<p><strong>비활성 상태</strong> <strong>UI</strong>: 로딩 중이거나 특정 조건에서 비활성 상태로 표시해야 하는
UI 요소들을 비활성화하는 데 유용하다.
<strong>모달 다이얼로그</strong>: 모달 다이얼로그를 표시할 때 배경의 모든 사용자 입력을 차단하기 위해 사용될 수 있다.
<strong>조건부 입력 차단</strong>: 특정 조건에 따라 입력을 차단하거나 허용하는 동작을 구현할 때 사용될 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 'image' 패키지를 사용한 이미지 압축]]></title>
            <link>https://velog.io/@loopback_log/Flutter-image-%ED%8C%A8%ED%82%A4%EC%A7%80%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%95%95%EC%B6%95</link>
            <guid>https://velog.io/@loopback_log/Flutter-image-%ED%8C%A8%ED%82%A4%EC%A7%80%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%95%95%EC%B6%95</guid>
            <pubDate>Tue, 11 Jun 2024 01:49:40 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이미지를 서버로 업로드할 때 이미지 크기가 너무 크면 전송 시간이 오래 걸리고, 네트워크 대역폭을 많이 차지할 수 있다. 
이를 해결하기 위해 Flutter에서는 이미지 파일을 압축할 수 있다. 
이 글에서는 image 패키지를 사용하여 이미지를 압축하는 방법을 알아보겠다.</p>
</blockquote>
<h2 id="다른-이미지-처리-패키지들과의-비교">다른 이미지 처리 패키지들과의 비교</h2>
<p>이미지 처리를 위해 Flutter에서 사용할 수 있는 여러 패키지가 있다. 
그 중 대표적인 패키지로는 image, flutter_image_compress, image_picker, compressor 등이 있다. 
각 패키지의 장단점을 비교해 보겠다.</p>
<h3 id="image-패키지">image 패키지</h3>
<p><strong>장점:</strong>
완전한 Dart로 작성되어 있어 플랫폼에 의존하지 않는다.
다양한 이미지 처리 기능을 제공한다다 (크기 조정, 자르기, 포맷 변경 등).
설치 및 사용이 간편하다.
<strong>단점:</strong>
매우 큰 이미지 파일을 처리할 때 성능이 떨어질 수 있다.</p>
<h3 id="flutter_image_compress-패키지">flutter_image_compress 패키지</h3>
<p><strong>장점:</strong>
iOS와 Android 네이티브 코드를 사용하여 이미지를 압축한다.
성능이 우수하며, 이미지 품질을 세밀하게 조정할 수 있다.
<strong>단점:</strong>
플랫폼에 종속적이므로 플랫폼 별 설정이 필요하다.
네이티브 코드에 의존하므로, Dart 환경에서는 직접 사용하기 어렵다.</p>
<h3 id="image_picker-패키지">image_picker 패키지</h3>
<p><strong>장점:</strong>
이미지를 갤러리에서 선택하거나 카메라로 촬영할 수 있다.
Flutter에서 매우 널리 사용되며, 간편하게 이미지를 선택할 수 있다.
<strong>단점:</strong>
이미지 처리 기능이 제한적이다 (크기 조정, 압축 기능이 없음).</p>
<h3 id="compressor-패키지">compressor 패키지</h3>
<p><strong>장점:</strong>
간단한 API로 이미지를 압축할 수 있다.
기본적인 압축 기능을 제공한다.
<strong>단점:</strong>
다른 패키지들에 비해 기능이 제한적이다.
이미지 크기 조정, 자르기 등의 고급 기능이 부족하다.</p>
<h2 id="왜-image-패키지를-선택했는가">왜 image 패키지를 선택했는가?</h2>
<p>image 패키지는 완전한 Dart로 작성되어 있어 플랫폼에 독립적이며, 다양한 이미지 처리 기능을 제공한다는 점에서 매우 유용하다. 
특히, 이미지의 크기를 조정하고, 포맷을 변경하며, 이미지 품질을 조정하는 등의 작업을 하나의 패키지로 수행할 수 있다. 
또한, 플랫폼 별 설정 없이 간편하게 설치하고 사용할 수 있어, 
Flutter 프로젝트에서 이미지 처리를 위해 가장 적합한 선택이라고 판단했다.</p>
<h2 id="image-패키지를-통한-예제">image 패키지를 통한 예제</h2>
<p>pubspec.yaml 파일에 image 패키지를 추가한다.</p>
<pre><code class="language-dart">dependencies:
  flutter:
    sdk: flutter
  image: ^4.2.0</code></pre>
<h2 id="이-압축-기능-구현">이 압축 기능 구현</h2>
<p>다음은 Flutter에서 image 패키지를 사용하여 이미지를 압축하는 예제다. 
이 예제는 이미지의 크기를 조정하고, JPEG 포맷으로 압축하는 방법을 보여준다.</p>
<pre><code class="language-dart">import &#39;dart:io&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:image/image.dart&#39; as img;
import &#39;package:image_picker/image_picker.dart&#39;;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ImageCompressScreen(),
    );
  }
}

class ImageCompressScreen extends StatefulWidget {
  @override
  _ImageCompressScreenState createState() =&gt; _ImageCompressScreenState();
}

class _ImageCompressScreenState extends State&lt;ImageCompressScreen&gt; {
  File? _image;
  final ImagePicker _picker = ImagePicker();

  /* 이미지 선택 및 압축 메서드 */
  Future&lt;void&gt; _pickAndCompressImage() async {
    final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
    if (image != null) {
      final File compressedImage = await _compressImage(File(image.path));
      setState(() {
        _image = compressedImage;
      });
    }
  }

  /* 이미지 압축 메서드 */
  Future&lt;File&gt; _compressImage(File imageFile) async {
    final bytes = await imageFile.readAsBytes();
    img.Image image = img.decodeImage(bytes)!;

    // 이미지 크기를 조정 (예: 너비를 1024로 설정)
    img.Image resizedImage = img.copyResize(image, width: 1024);

    // 압축된 이미지 파일 생성 (JPEG 품질을 85로 설정)
    final compressedBytes = img.encodeJpg(resizedImage, quality: 85);
    final compressedImageFile = File(&#39;${imageFile.path}_compressed.jpg&#39;)
      ..writeAsBytesSync(compressedBytes);

    return compressedImageFile;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(&#39;Image Compression Example&#39;)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            _image == null
                ? Text(&#39;No image selected.&#39;)
                : Image.file(_image!),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _pickAndCompressImage,
              child: Text(&#39;Pick and Compress Image&#39;),
            ),
          ],
        ),
      ),
    );
  }
}</code></pre>
<h2 id="코드-설명">코드 설명</h2>
<h3 id="패키지-임포트">패키지 임포트</h3>
<p>image 패키지와 image_picker 패키지를 임포트한다. 
image_picker 패키지는 갤러리에서 이미지를 선택하는 데 사용된다.</p>
<h2 id="이미지-선택-및-압축">이미지 선택 및 압축</h2>
<p>_pickAndCompressImage 메서드는 이미지를 선택하고 압축한다. 
사용자가 이미지를 선택하면, _compressImage 메서드가 호출되어 이미지를 압축한다.</p>
<p>이미지 압축:
_compressImage 메서드는 다음 단계를 수행한다:</p>
<p>이미지 파일을 바이트 배열로 읽는다.
image 패키지를 사용하여 이미지를 디코딩한다.
copyResize 메서드를 사용하여 이미지 크기를 조정한다.
encodeJpg 메서드를 사용하여 이미지 품질을 설정하고 JPEG 포맷으로 압축한다.
압축된 바이트 배열을 새 파일로 저장한다.
UI 구성:
사용자가 이미지를 선택하고 압축된 이미지를 화면에 표시할 수 있도록 UI를 구성한다.</p>
]]></description>
        </item>
    </channel>
</rss>