<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>PJOS</title>
        <link>https://velog.io/</link>
        <description>Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)</description>
        <lastBuildDate>Sun, 11 Oct 2026 15:06:10 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>PJOS</title>
            <url>https://images.velog.io/images/peeeeeter_j/profile/ee9e1790-4d3d-4dba-a678-b8788cfef636/20200820_003148.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. PJOS. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/peeeeeter_j" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[DAY 38-1 | 개인 프로젝트 — 유지보수 (1)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-38-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-38-1</guid>
            <pubDate>Sun, 11 Oct 2026 15:06:10 GMT</pubDate>
            <description><![CDATA[<h1 id="day-38-1--개인-프로젝트--유지보수-1">DAY 38-1 | 개인 프로젝트 — 유지보수 (1)</h1>
<p>일단 버그가 자꾸 발견된다. 최조의 버전 <code>v1.0.0+1</code> 을 빌드하여 올린 후 환경변수가 누락된 버그와 route가 제대로 설정되지 않은 부분이 있던 버그를 고치기 위해 <code>v1.0.0+2</code> 로 올렸는데, 그 사이에 색상 틴트가 잘못 적용되는 치명적인 버그가 또 발견되어 하루만에 <code>v1.0.1+3</code> 으로 올라갔다. 왜 빌드번호 1에서 2로 갈 땐 semantic version이 안 올랐는데, 2에서 3으로 갈 땐 올랐는가. <code>v1.0.0+2</code>와 <code>v1.0.1+3</code> 사이에 Apple App Store 심사가 통과되었기 때문이다. 첫 배포가 이루어지기 전에는 <code>v1.0.0</code> 을 유지하고 있다가 첫 배포 이후에는 patch version을 하나 올린 거다.</p>
<p>이 정도로 치명적인 버그가 아니라면 이렇게 급하게 올리진 않을 것 같긴 한데. 일단 발견되는 녀석들을 <a href="https://github.com/neont21/move-sketch/issues">issue</a>로 등록해 놓고 하나씩 꺼내서 처리하기로 했다. 쌓여 있는 이슈들 중 중요도에 따라 우선순위 큐처럼 꺼내서 작업해야지. 버전이 올라가는 거에 따라 <a href="https://github.com/neont21/move-sketch/tags">tag</a> 및 <a href="https://github.com/neont21/move-sketch/releases">release</a>도 남기기로 했다.</p>
<h2 id="빌드-가이드">빌드 가이드</h2>
<p>업데이트 절차 중 누락되는 사항이 생기지 않도록 미래의 나를 위한 빌드 가이드도 노션에 작성해 놓았다. 버전 숫자 변경하는 것부터 각각의 스토어에 빌드해서 올린 후 버전 태그 달아서 commit하는 것까지. commit 먼저 하고 빌드/업로드 해도 되긴 하는데, 왠지 commit 하면 작업 끝! 이라는 느낌 때문에 순서를 이렇게 했다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2c5a12a2-d169-4638-99a6-dfdf1fdc47e6/image.png" alt=""></p>
<p>commit 먼저하고 빌드/업로드를 할 경우, commit에 태그 달고 GitHub에 Release Note 작성해서 Android의 [출시 노트]나 iOS의 [이 빌드에서 업그레이드된 사항]에 업데이트 요약 아래 Release Note 링크를 첨부하는 것도 괜찮을 것 같다고 생각했는데... 아무래도 그 업데이트 요약 텍스트에는 외부 링크를 청부하지 않는 게 일반적이니까.</p>
<h2 id="업데이트공지사항-알림">업데이트/공지사항 알림</h2>
<p>새로운 버전이 나왔을 때 사용자에게 알림을 보내주는 게 낫지 않을까 하고 찾아보았더니 앱 진입 시점에 버전 체크를 하여 알림을 띄워주는 방식이 가장 정석적이라고 하더라. 최소 지원 버전, 설치된 버전, 최신 버전을 비교하여 적절히 알림을 띄워 주는 거다.</p>
<p>설치된 버전이 최신 버전보다 낮으면 선택적으로 업데이트하라는 알림 화면을 보여주고, 설치된 버전이 최소 지원 버전보다도 낮으면 업데이트를 강제하는 알림 화면을 보여준다. 이렇게 하면 최소 지원 버전 아래의 앱에 대한 지원은 고려하지 않아도 되기 때문에 운영에 이점이 있다. 단, 최소 지원 버전을 너무 높게 책정하면 사용자가 피곤해질 수 있으니 치명적인 버그 수정이 아니고서는 웬만하면 버전 호환성을 고려하는 게 좋겠다.</p>
<p>그리고 그거랑 별개로 앱 공지사항이 있을 경우 알림 화면에 띄워주면 좋을 것 같더라. 공지사항 모아두는 곳을 따로 만드는 것보다는 공지가 자주 있지는 않을 테니 알림 화면에 두는 걸로 충분할 것 같다.</p>
<p>버전 알림에 업데이트 버튼을 넣든 앱 공지사항 알림에 관련 링크를 넣든 외부 링크 및 스토어 연결에는 <a href="https://pub.dev/packages/url_launcher">url_launcher</a> package가 필요하다고 하더라.</p>
<h2 id="패치-업데이트">패치 업데이트</h2>
<p>뭐가 많다. 버그가 자연발생하는 것 같다. 출판업계에서는 오탈자가 자연발생한다고 주장하더니 여긴 버그가 자연발생하는 모양이다. 프로젝트 기간이 촉박해서 테스트를 충분히 해보지 못한 채 심사를 넣은 탓도 있다. 거의 하루나 이틀에 하나씩은 새 업데이트가 올라가고 있다. 몰아치는 느낌이다. 첫 출시 때는 일단 빼놓고 추후에 업데이트해야지 하고 잠시 미뤄놓은 기능들은 아직 건드리지도 못한 채 v1.0.4+6에 도달했다. 갈 길이 멀구만.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 38 | 개인 프로젝트 — 배포하기]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-38</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-38</guid>
            <pubDate>Wed, 07 Oct 2026 08:45:09 GMT</pubDate>
            <description><![CDATA[<h1 id="day-38--개인-프로젝트--배포하기">DAY 38 | 개인 프로젝트 — 배포하기</h1>
<p><code>1.0.0+1</code> 형태의 version에서 <code>+</code> 우측에 있는 숫자는 빌드 번호로, 늘 1씩 증가한다. 버전이 바뀌든 바뀌지 않든 스토어에 빌드를 올릴 때마다 이전보다 높은 값으로 설정되어 있어야 한다. <code>+</code> 좌측의 버전은 semantic versioning을 사용하는 게 가장 일반적이지만 반드시 이를 따라야 하는 건 아니다.</p>
<p>앱 서명에 대한 설명도 하셨지만 그건 이미 해놨기 때문에 가볍게 들었다. 나는 CLI로 했는데 수업에서는 GUI로 하더라. 개인적으로 이런 작업은 CLI가 훨씬 간결해서 좋은 것 같다. 물론 이건 어디까지나 선호도 차이일 뿐이다.</p>
<h2 id="안드로이드-배포">안드로이드 배포</h2>
<p>다음 명령어를 통해 생성된 <code>app-release.aab</code> 파일이 Google Play Console에 업로드할 녀석이다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutter build appbundle --release</code></pre>
<p>환경변수를 사용하는 경우에는 다음과 같이 환경변수 설정도 넣어주어야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutter build appbundle --release --dart-define-from-file=env.json</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/ca423a0b-f586-471a-8175-babe404ed8cc/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/5ae50727-d8d1-478c-aafe-bc618f9322c5/image.png" alt=""></p>
<p>대시보드에서 요구하는 항목들을 다 채워넣어야 한다. 다 채워 넣었으면 [스토어 등록정보]에 미리 준비한 앱 이름과 설명, 아이콘, 그래픽 이미지, 스크린샷을 첨부해서 저장한다.</p>
<p>안드로이드 앱 배포를 위해서는 12명 이상의 테스터가 비공개 테스트 참여를 해야 한다. 따라서 비공개 테스트를 생성한다. 테스터의 구글 이메일을 등록하고 비공개 테스트 버전을 생성하는데, 여기서 아까 <code>flutter build appbundle --release</code> 로 생성한 <code>app-release.aab</code> 파일을 업로드해야 한다.</p>
<p>내 앱의 경우 위치 정보를 사용하기 때문에 이에 대한 항목을 추가로 제출해야 했다. 위치 정보가 어떻게 쓰이는지 동영상을 제출하라는 부분도 있더라.</p>
<h3 id="심사가-완료되기-전에-새-버전을-만들었다면">심사가 완료되기 전에 새 버전을 만들었다면</h3>
<p><code>pubspec.yaml</code> 의 빌드 번호를 1 올린다. 배포된 앱이라면 semantic version도 같이 올리는 게 좋지만 심사 전 테스트 상태에서는 빌드 번호만 올리는 게 일반적이라고 한다.</p>
<p>수정된 소스 코드를 다시 빌드한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutter build appbundle --release --dart-define-from-file=env.json</code></pre>
<p>[비공개 테스트] 트랙 -&gt; [새 버전 만들기]에서 새로 생성된 <code>app-release.aab</code> 파일을 업로드한다.</p>
<h2 id="아이폰-배포">아이폰 배포</h2>
<p>CSR 파일을 생성해야 한다. [키체인 접근]을 실행하여 [인증서 지원]에서 [인증기관에서 인증서 요청]을 선택한다. 이메일과 이름을 적은 뒤 디스크에 저장한다. Apple Developer 계정과 같은 이메일/이름을 사용하는 게 좋다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/4f2ee2cd-1920-44eb-9738-32d7aa9da190/image.png" alt=""></p>
<p>Apple Developer에서 Certificate를 발급한다. [iOS Distribution (App Store Connect and Ad Hoc)] 를 선택하고 조금 전에 만든 CSR 파일을 업로드한다. 다음으로 넘어가면 Certificate를 다운로드받을 수 있으며 이를 더블클릭하면 Mac의 키체인에 설치할 수 있다. <code>keychain: login</code> 으로 선택하여 설치한다.</p>
<p>Identifier는 소셜 로그인 구현 때 사용했던 걸 그대로 쓰면 된다. Profile은 [App Store Connect]를 선택하고 앞서 만든 App ID와 Certificate를 연결한다. 이름은 아무거나 써도 되지만 앱 이름과 통일하는 게 관리하기 수월하다.</p>
<p>준비가 되었으면 <a href="https://appstoreconnect.apple.com/apps">App Store Connect</a>에서 앱을 등록할 수 있다. 배포 정보를 입력하고 스크린샷을 첨부하고 앱 심사 정보를 넣는다. 그리고 [앱 정보] 메뉴를 채우고 [가격 및 사용 가능 여부]에 앱 가격을 설정한다. [앱이 수집하는 개인 정보]에 개인정보 처리방침 URL을 넣고 설문을 작성한다.</p>
<p>Xcode의 [Product &gt; Archive] 를 누르고 기다렸다가 [Distribute App]을 눌러 [App Store Connect]를 선택하고 업로드되길 기다린다. 이 때, 환경변수를 사용했다면 [Product &gt; Archive] 를 누르기 전에 [Product &gt; Scheme &gt; Edit Scheme...]를 눌러 [Archive]의 [Pre-actions]으로 이동한다. [New Run Script Action]을 누르고 <code>Provide build settings from Runner</code> 로 설정한 뒤 다음과 같이 스크립트를 작성한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">cd &quot;$SRCROOT/..&quot;
flutter build ios --config-only --release --dart-define-from-file=env.json</code></pre>
<p>Xcode의 [Product &gt; Archive]를 누르는 대신 터미널에서 다음 명령어를 사용하여 빌드 후 바로 Distribute App 할 수도 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutter build ipa --release --dart-define-from-file=env.json
&gt;
open build/ios/archive/Runner.xcarchive</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/db877768-1218-4b3e-9828-1ea80d59a8e1/image.png" alt=""></p>
<p>Symbol에 대한 경고는 무시해도 되고 완료되었으면 5-20분 후에 성공/실패 이메일이 온다. 실패 메일이 올 경우 실패 사유에 따라 수정한 뒤 다시 빌드하여 시도한다. 성공 메일이 올 경우에는 <a href="https://appstoreconnect.apple.com/apps">App Store Connect</a>로 돌아가 빌드를 업로드하고 [심사에 추가] 후 [심사를 위해 제출]을 누른다. 심사를 기다리라는 메일이 오면 보통 1-2일 후에 심사가 완료된다는 모양이다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/24ec37b6-fa1a-426e-afb1-4128ac289911/image.png" alt=""></p>
<p>보니까 지원 URL도 필수라고 하길래 이메일로 문의하기와 FAQ가 포함되어 있는 고객 지원 페이지를 AI로 생성해서 firebase hosting으로 올려 놓았다.</p>
<h3 id="심사가-완료되기-전에-새-버전을-만들었다면-1">심사가 완료되기 전에 새 버전을 만들었다면</h3>
<p>빌드 버전은 안드로이드 재배포 전에 올렸으니 아이폰용으로 빌드하여 올리면 된다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutter build ipa --release --dart-define-from-file=env.json
&gt;
open build/ios/archive/Runner.xcarchive</code></pre>
<p>아직 심사 대기 중이라면 <a href="https://appstoreconnect.apple.com/apps">App Store Connect</a>에 들어가 기존 빌드를 내리고 새 빌드를 선택하여 심사에 추가한다. 이미 심사 중이라면 임의로 빌드를 변경할 수 없는데, 심사에서 이 버전 삭제를 누른 뒤, 새 빌드를 선택하여 다시 제출하시는 것을 권장한다고 하더라. 결함이 있는 상태로 리젝을 받는 것보다, 자발적으로 빌드를 교체하여 재제출하는 것이 심사 히스토리 관리상 훨씬 안전하다나.</p>
<hr>
<p>이제 심사를 기다리며 발견된 버그들을 처리하고, 1차 배포 이후로 미뤄 놓았던 개발을 해야 한다. 그리고 12인의 구글 테스터도 모아야 하는데... 번거로운 비공개 테스트를 도와줄 사람이 몇이나 있으려나. 매일 접속해서 뭐라도 건드려 주는 게 좋다는데 아무래도 귀찮은 일이겠지. 테스트 해 줄 사람을 영 못 구하면 돈 주고 맡기기도 한다더라.</p>
<p>라고 했는데 Apple App Store는 심사 요청 당일에 바로 승인 메일이 왔다...! 버그는 계속 발견되고 있지만 열심히 고쳐 나가야지.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/d21d89dc-0af8-493c-9aa9-94b5fb2c5f9a/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 37-1 | 개인 프로젝트 — crash log & metadata]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-37-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-37-1</guid>
            <pubDate>Wed, 07 Oct 2026 00:02:06 GMT</pubDate>
            <description><![CDATA[<h1 id="day-37-1--개인-프로젝트--crash-log--metadata">DAY 37-1 | 개인 프로젝트 — crash log &amp; metadata</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#39 [config] crashlytics log</li>
<li>#47 [config] 앱 배포를 위한 메타데이터 설정</li>
</ul>
<h2 id="39-config-crashlytics-log">#39 [config] crashlytics log</h2>
<p>사용자가 앱을 실행하다 오류가 발생했을 경우 그 오류 데이터를 수집해 문제를 해결할 수 있도록 하는 게 좋다. 그럴 때 사용하는 게 Firebase Crashlytics다. Firebase 무료 제공 제품군에 포함되어 있어 요금제와 상관 없이 사용할 수 있다. 어디까지나 오류 보고용이기 때문에 저장 공간과 네트워크를 너무 많이 사용하지 않도록 크기 제한이 존재할 뿐이다.</p>
<p><code>FirebaseCrashlytics.instance.log()</code> 로 남긴 로그는 앱 흐름을 추적하기 위한 것으로, 이것만으로는 그저 데이터가 쌓이다 FIFO 방식으로 덮어씌워질 뿐 서버에 알리지 않는다. 그러다 크래시 발생 시 당시 상황 로그로 함께 첨부된다. <code>FirebaseCrashlytics.instance.recordError()</code> 로 남긴 로그는 비치명적 예외를 추적하기 위한 것으로, Crashlytics 대시보드의 [Non-fatal] 탭에 집계된다. 어디서 얼마나 자주 발생했는지 통계를 확인할 수 있다. 앱이 뻗은 경우에는 프로세스 종료 직전에 minidump로 작성되어 앱이 다시 켜질 때 백엔드로 전송된다.</p>
<p>개발자가 예외 처리를 한 부분에 대해서는 예외 처리를 통해 대처했지만 이슈가 발생하긴 했다는 의미에서 <code>FirebaseCrashlytics.instance.recordError()</code> 로 기록을 남긴다. 때로는 개발자의 예외 처리로 다 잡히지 않는 이슈도 존재할 수 있는데 그 경우 <code>PlatformDispatcher.onError()</code> 를 통해 백엔드로 전송한다. 런타임 버그, 라이브러리 결함 등은 개발자가 전부 예측하여 방어할 수 없기 때문에 최후의 방어선으로 넣어주는 게 좋다고 한다.</p>
<p><a href="https://pub.dev/packages/firebase_crashlytics">firebase_crashlytics</a> package를 사용한다. 사용자가 가입 및 로그인했을 때 누구에게서 발생한 오류인지 식별하기 위한 사용자 식별 바인딩을 하고, 로그아웃하거나 탈퇴했을 때 그것을 해제한다. 그리고 백그라운드에서 발생할 수 있는 이슈와 사용자의 행동에 따라 발생할 수 있는 이슈를 각각 추적하여 모니터링하는 코드를 작성한다. 흐름을 파악하는 데 도움이 될 만한 곳에 <code>FirebaseCrashlytics.instance.log()</code> 를, 문제가 발생한 부분에 <code>FirebaseCrashlytics.instance.recordError()</code> 를 적절히 사용한다.</p>
<p>Crashlytics는 설정한다고 바로 대시보드가 뜨지는 않고 한 번 비정상 종료 테스트를 해 주어야 뜬다. 그리고 아이폰의 경우 dSYM이라는 녀석을 별도로 구성해 주어야 한다. <code>ios/Runner.xcworkspace</code> 을 열어 루트 <code>Runner</code> 에서 [Targets] 에 있는 <code>Runner</code> 를 선택한 후, [Build Phases] 탭에서 [New Run Script Phase] 드롭다운 메뉴를 눌러 쉘 스크립트를 작성한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">if [ -f &quot;${PODS_ROOT}/FirebaseCrashlytics/run&quot; ]; then
  &quot;${PODS_ROOT}/FirebaseCrashlytics/run&quot;
elif [ -f &quot;${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run&quot; ]; then
  &quot;${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run&quot;
fi</code></pre>
<p>기본적으로 디버그 빌드에서는 dSYM을 사용하지 않도록 되어 있기 때문에 별도로 설정하지 않으면 릴리즈 빌드를 해야 dSYM 번들이 생성된다. 디버그 빌드에서도 dSYM을 사용하고 싶다면 [Build Settings] 탭에서 [Basic]이 아닌 [All] 을 선택하고 [Debug Information Format]의 [Debug] 항목을 [DWARF with dSYM File]로 변경하면 된다.</p>
<p>[Input Files] 섹션에 적절한 파일 경로를 등록해 두면 Xcode 빌드 시스템이 빌드 산출물의 타임스탬프를 추적하여, dSYM 파일이 실제로 새로 생성되었을 때만 업로더를 실행함으로써 불필요한 네트워크 중복 전송 방지 및 증분 빌드 최적화를 보장한다고 한다.</p>
<h2 id="47-config-앱-배포를-위한-메타데이터-설정">#47 [config] 앱 배포를 위한 메타데이터 설정</h2>
<p>앱 이름을 설정하고 아이콘을 설정하고 스플래시 화면을 설정한다. 아이콘 설정에는 <a href="https://pub.dev/packages/flutter_launcher_icons">flutter_launcher_icons</a> package를 사용하고 스플래시 화면 설정에는 <a href="https://pub.dev/packages/flutter_native_splash">flutter_native_splash</a> package를 사용한다. 이들은 dependencies가 아닌 dev_dependencies로 추가하여 개발 시에만 사용하고 배포 시에는 포함하지 않는다.</p>
<p>flutter_launcher_icons package를 사용할 땐 <code>pubspec.yaml</code> 에 <code>flutter_launcher_icons:</code> 로 시작하는 설정값을 작성한 뒤 명령어를 실행하여 아이콘을 적용한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">dart run flutter_launcher_icons</code></pre>
<p>flutter_native_splash package도 마찬가지로 <code>pubspec.yaml</code> 에 <code>flutter_native_splash:</code> 로 시작하는 설정값을 작성한 뒤 명령어를 실행하여 아이콘을 적용한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">dart run flutter_native_splash:create</code></pre>
<p>현재는 안드로이드 설정이 디버그 키로 되어 있는데 배포를 하기 위해서는 릴리즈 키를 연결해야 한다. 릴리즈 키는 명령어를 통해 생성할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">keytool -genkey -v -keystore android/app/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload</code></pre>
<p>릴리즈 키를 생성한 후에는 <code>android/key.properties</code> 파일에 키 정보를 집어넣고 이를 통해 <code>android/app/build.gradle.kts</code> 파일에 키를 등록한다. 설정을 마친 후 프로덕션 배포 규격으로 빌드를 실행하여 정상 서명되는지 검증한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutter build appbundle --release</code></pre>
<p><code>✓ Built build/app/outputs/bundle/release/app-release.aab (101.4MB)</code> 와 같은 내용으로 마무리되면 잘 된 거다.</p>
<p>개인정보 처리방침 및 이용약관은 직접 작성하면 놓치는 부분이 꽤 있을 것 같아서 AI에게 다음 사이트들을 참고하여 작성해 달라고 하였다.</p>
<blockquote>
</blockquote>
<ul>
<li><a href="https://www.privacy.go.kr/front/bbs/bbsView.do?bbsNo=BBSMSTR_000000000049&amp;bbscttNo=20710">개인정보 포털 - 개인정보 처리방침 작성지침 (2024.4월, 개정)</a></li>
<li><a href="https://www.ftc.go.kr/www/selectBbsNttList.do?bordCd=201&amp;key=202">공정거래위원회 - 표준약관</a></li>
<li><a href="https://www.law.go.kr/admRulSc.do?menuId=5&amp;subMenuId=41&amp;tabMenuId=183&amp;eventGubun=060115#liBgcolor36">법제처 국가법령정보센터 - 행정규칙</a></li>
</ul>
<p>앱 내 위젯으로 작성하는 것과 별도로 제출용 HTML 파일을 작성하여 Firebase Hosting으로 띄운다. <code>firebase.json</code> 에 특정 디렉토리만 호스팅하도록 작성해 놓으면 그 안에 있는 파일만 띄울 수 있다.</p>
<blockquote>
<p><code>firebase.json</code></p>
</blockquote>
<pre><code class="language-js">// 앞 부분 생략
  &quot;hosting&quot;: {
    &quot;public&quot;: &quot;public&quot;,
    &quot;ignore&quot;: [
      &quot;firebase.json&quot;,
      &quot;**/.*&quot;,
      &quot;**/node_modules/**&quot;
    ]
// 뒷 부분 생략</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">firebase deploy --only hosting</code></pre>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/a9bb213d0535d2f6ace7a7858afa1d8c845908dc">&gt;&gt;&gt; GitHub Repository at this point (a9bb213)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 37 | 개인 프로젝트 — social login & notification]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-37</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-37</guid>
            <pubDate>Mon, 05 Oct 2026 14:08:15 GMT</pubDate>
            <description><![CDATA[<h1 id="day-37--개인-프로젝트--social-login--notification">DAY 37 | 개인 프로젝트 — social login &amp; notification</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#40 [refactor] 화면 구성 리팩토링</li>
<li>#26 [view] 소셜 가입 후 초기 설정</li>
<li>#46 [notification] action에 대한 알림 보내기</li>
</ul>
<h2 id="40-refactor-화면-구성-리팩토링">#40 [refactor] 화면 구성 리팩토링</h2>
<p>다음으로 넘어가기 전에 기술 부채를 털고 가는 게 나을 것 같다고 판단했다. 로딩이 제대로 되지 않았을 때 다시 시도하는 버튼을 중간에 넣기 시작하면서 초반에는 작성한 코드에는 그게 빠져 있고 이후에 작성한 코드에는 일관성 없게 되어 있어 일관성 있는 코드로 정리하고 가는 게 나을 것 같았다.</p>
<p>그리고 렌더링 관점에서 비효율적으로 작성되어 있는 helper function이 몇 가지 발견되어 별도의 widget으로 빼기로 했다. 가볍게 넘어갈 수 있을 것 같아서 그렇게 했는데 생각보다 코드가 길어진 곳들이 좀 있더라.</p>
<h2 id="26-view-소셜-가입-후-초기-설정">#26 [view] 소셜 가입 후 초기 설정</h2>
<p>소셜 로그인 기능 구현은 설정 화면 할 때 해놓을 거라 생각해서 <code>[view]</code> 태그를 붙였지만 소셜 로그인 기능부터 여기서 한다. 몇 가지 package를 설치해야 한다.</p>
<blockquote>
</blockquote>
<ul>
<li><a href="https://pub.dev/packages/google_sign_in"><code>google_sign_in</code></a> : Google OAuth 2.0 인증 토큰(idToken, accessToken) 획득</li>
<li><a href="https://pub.dev/packages/sign_in_with_apple"><code>sign_in_with_apple</code></a> : Apple ID 토큰(identityToken) 및 권한 부여 코드 획득</li>
<li><a href="https://pub.dev/packages/crypto"><code>crypto</code></a> : Apple 로그인 시 리플레이 공격(Replay Attack) 방지를 위한 SHA-256 Nonce 해싱에 필요</li>
</ul>
<p>google_sign_in packgage는 버전 7로 넘어가면서 상당 부분 개편되었기 때문에 오래된 자료 참고 시 유의해야 한다는 모양이다.</p>
<p>소셜 로그인은 코드 구현뿐만 아니라 Firebase Console에서도 작업을 해주어야 한다.</p>
<blockquote>
</blockquote>
<ul>
<li><strong>iOS용 Google 로그인</strong> | <strong><code>GoogleService-Info.plist</code> 를 다운로드</strong> 받아 <code>ios/Runner/GoogleService-Info.plist</code> 를 덮어씌운다.</li>
<li><strong>Android용 Google 로그인</strong> | 터미널에서 프로젝트의 <code>android/</code> 디렉토리에 들어가 <code>./gradlew signingReport</code> 명령어를 통해 SHA-1 지문을 얻어내고, [프로젝트 설정 &gt; 내 앱]의 [디지털 지문 추가]에 붙여 넣은 뒤 <strong><code>google-services.json</code> 을 다운로드</strong> 받아 <code>android/app/google-services.json</code> 를 덮어씌운다.<ul>
<li><span style="color:#787878">터미널에 뜨는 여러 값들 중 <code>Variant: debug</code> 라고 뜨는 영역에 있는 <code>SHA1</code> 을 사용하면 된다. 이건 디버그 키인데, 배포 시점에 릴리즈 키를 새로 발급받게 되겠지만 여러 개의 키를 등록할 수 있으므로 일단은 디버그 키를 넣어 두도록 한다. 애뮬레이터 실행 시 이 키를 사용하기 때문에 릴리즈 키만으로는 테스트를 할 수 없다나.</span></li>
<li><span style="color:#787878">Google 로그인에는 SHA-1이 필수인데 그것과 별개로 SHA-1의 암호학적 충돌 취약점 우려로 인해 구글의 최신 보안 인프라는 더 강력한 SHA-256을 표준으로 요구하고 있기도 하니 SHA-256도 추가로 넣어주는 건 선택 사항.</span></li>
</ul>
</li>
<li><strong>iOS용 Apple 로그인</strong> | <code>ios/Runner.xcworkspace</code> 을 열어 루트 <code>Runner</code> 에서 [Targets] 에 있는 <code>Runner</code> 를 선택한 후, [Signing &amp; Capabilities] 탭에서 [+ Capability] 버튼을 눌러 <strong>[Sign in with Apple] 더블클릭</strong>으로 추가한다.</li>
<li><strong>Android용 Apple 로그인</strong> | <a href="https://developer.apple.com/account/resources/identifiers/list">Apple Developer Console</a>에서 Services ID를 등록하고 [Sign In with Apple] 를 활성화하여 [Configure]에서 Firebase 도메인을 등록한다. 저장 후 좌측 메뉴의 [Keys]에서 추가 버튼을 눌러 [Sign In with Apple] 를 위한 key를 생성한다. 10자리 Key ID를 잘 복사해 놓고 <code>.p8</code> 파일을 다운로드한다. (다시 받을 수 없음 주의!!) 다시 Firebase Console로 돌아가 앞서 저장해 놓은 <strong>Service ID, Team ID(우상단 이름 부근에 뜨는 10자리), Key ID를 붙여 넣고, 다운로드된 <code>.p8</code> 파일의 본문도 비공개 키 부분에 붙여 넣는다.</strong></li>
</ul>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/ba2bdce6-9091-455d-874d-b38ca6c33a35/image.png" alt=""></p>
<p>소셜 로그인 구현 과정에서 Firestore Rules를 살짝 변경했다. 원래는 사용자의 username은 불변이라는 설정 때문에 <code>/account_ids/{username}</code> 문서에 대해 <code>allow update: if false;</code> 를 걸어 놨었는데, 이메일 연결 없이 소셜 로그인만 사용할 경우에는 email을 null로 두고 이메일을 연결하면 email을 업데이트하는 식으로 수정이 들어가게 되어 본인 계정에 한해 수정 권한을 주기로 했다.</p>
<p>소셜 가입 시 username과 nickname을 작성하는 화면을 추가하다 보니 계정 삭제 또한 아이디/비밀번호 로그인 기준으로 맞춰져 있다는 것을 인지했다. 연결되어 있는 로그인 제공업체가 하나일 경우에는 그 방식으로 제시하고 둘 이상일 경우에는 사용자가 계정 재인증에 사용할 수단을 선택하여 그 방식으로 인증하면 탈퇴를 할 수 있게 선택지를 주는 게 좋을 것 같다고 판단했다. 소셜 로그인도 최근 인증 세션이 있어야 탈퇴가 가능하므로 계정 삭제를 누르면 재인증 수단을 고르라고 한 뒤, 소셜 재인증이든 비밀번호 재인증이든 선택하여 수행하도록 작성하였다.</p>
<p>하면 할수록 edge-case가 발견되어 계속 수정하게 된다. 기본적으로 인증 수단이 하나밖에 없으면 그 유일한 인증 수단을 해제하지 못하는데, 이메일 인증을 아직 받지 않은 상태에서 소셜 로그인 해제가 가능하다거나. 이메일 인증 받기도 전에 그 이메일에 연결된 비밀번호로 계정 삭제를 시도할 수 있다거나. 탈퇴한 사용자의 댓글 처리 등 부가적인 요소들이 따라오더라. 그래도 이런 걸 외면하면 서비스의 질이 떨어지니까, 파악되는 것들은 최대한 잡고 가는 게 좋다.</p>
<h2 id="46-notification-action에-대한-알림-보내기">#46 [notification] action에 대한 알림 보내기</h2>
<p>알림 기능이 개념적으로는 존재하지만 친구 요청 및 수락, 응원, 댓글, 답글 등의 작업을 수행했을 때 실제로는 알림이 가지 않는 상태였다. Firebase를 통해 실제로 알림이 전달되도록 한다. 친구와 관련된 건 UseCase로 작성되어 있는데 피드와 관련된 건 ViewModel에 작성되어 있어 &#39;알림을 어디에서 보내야 하는가&#39;에 대한 일관성 이슈가 발생했다. 이를 해소하기 위해 피드와 관련된 것도 UseCase로 작성하기로 했다.</p>
<p>switch 문 안에 또 switch 문이 들어가는 경우가 발생하는데 이 때 <code>case Ok(:final value):</code> 의 <code>value</code> 가 겹치는 걸 어떻게 처리하나 했더니 <code>case Ok(value: final customVariableName):</code> 와 같이 할 수 있는 모양이다. 정확히는 property 이름을 동일하게 사용할 경우에만 앞 부분을 생략했던 거라고 하는 게 맞겠다.</p>
<p>알림을 보내는 것은 Firestore에 문서를 쌓는 것뿐이고 알림을 받는 건 그것과 별개의 구현이다. 인 앱 알림은 그냥 구현할 수 있지만 알림창에 뜨는 진짜 알림은 private key가 필요하기 때문에 모바일 기기 간에 직접 푸시 알림을 쏘지 못하므로 적절한 firebase package를 사용해야 한다. <a href="https://pub.dev/packages/firebase_messaging">firebase_messaging</a> package와 Firebase Cloud Functions을 통해 작업을 수행할 수 있다.</p>
<p>앱에 알림 권한을 부여한 사용자 기기 고유의 FCM(Firebase Cloude Messaging) 토큰을 발급받아 firestore에 저장해 놓고, 그 토큰을 통해 알림을 전송한다. 이 토큰은 로그아웃 시 지우거나 무효화하고 로그인 시 다시 저장하거나 활성화해야 잘못된 사용자에게 알림을 보내지 않을 수 있다. 이걸 빼먹을 경우 한 기기에서 여러 사용자 계정을 사용할 때 알림이 잘못 전송될 수 있다. 알림은 네이티브의 영역이므로 <code>android/app/src/main/AndroidManifest.xml</code> 파일과 <code>ios/Runner/Info.plist</code> 파일에 적절한 권한을 명시해 주어야 한다.</p>
<blockquote>
<p><code>android/app/src/main/AndroidManifest.xml</code> 에 추가</p>
</blockquote>
<pre><code class="language-xml">&lt;uses-permission android:name=&quot;android.permission.POST_NOTIFICATIONS&quot; /&gt;</code></pre>
<blockquote>
<p><code>ios/Runner/Info.plist</code> 에 추가</p>
</blockquote>
<pre><code class="language-plist">&lt;key&gt;UIBackgroundModes&lt;/key&gt;
&lt;array&gt;
    &lt;string&gt;location&lt;/string&gt;
    &lt;string&gt;fetch&lt;/string&gt;
    &lt;string&gt;remote-notification&lt;/string&gt;
&lt;/array&gt;</code></pre>
<p><code>User</code> 와 관련된 model, service, repository 전체적으로 FCM 토큰과 관련된 코드를 추가하고 알림 기능을 전담할 녀석을 새로 생성한다. 기존의 <code>Notification</code> 도메인이 알림을 생성하고 인앱 알림으로 보내주는 녀석이었다면 이번에 생성하는 <code>PushNotification</code> 도메인은 그렇게 생성된 알림을 Firebase Cloud Function으로부터 전달 받아 푸시 알림으로 내보내는 녀석이다.</p>
<p>앱이 종료되었거나 백그라운드 상태일 때 원격 푸시 메시지를 수신하는 최상위 엔트리포인트 핸들러는 백그라운드에서 알림에 대한 처리가 필요할 때 사용하는데, 그런 걸 구현하지 않을 경우에도 빈 핸들러로 등록해 두어야 네이티브 엔진 내부에서 알림을 제대로 처리한다고 한다. FlutterFire 라이브러리의 규격이니 안 쓸 거여도 작성을 해야 한다나. 알림에 대한 데이터를 로컬 DB에 쌓아두거나 [답장하기], [읽음 처리] 등이 포함된 커스텀 알림을 띄우고자 할 때 여기에 코드를 작성할 수 있다는 모양이다.</p>
<h3 id="firebase-cloude-functions-푸시-발송-트리거-파이프라인">Firebase Cloude Functions 푸시 발송 트리거 파이프라인</h3>
<p>프로젝트 루트에서 Firebase Cloud Functions 환경을 셋업한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">firebase init functions</code></pre>
<p>JavaScript, TypeScript, Python 중에 선택할 수 있는데 TypeScript를 선택했다. 아무래도 타입 안정성이 확보되어 있는 편이 좋을 것 같다. 데이터 분석 관련된 게 필요하다면 Python이 충분히 매리트가 있지만 여기서는 굳이.</p>
<p>코드를 작성한 후에는 검증 및 빌드 후 Firebase 인프라에 배포해야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">cd functions
npm run lint
npm run build
cd ..
&gt;
firebase deploy --only functions</code></pre>
<p>Functions 2세대는 Firestore의 이벤트를 수신하기 위해 내부적으로 Google Cloud의 Eventarc 및 Cloud Run을 사용하는데, Google Cloud의 보안 및 IAM 권한 시스템은 새로 생성된 서비스 계정의 권한이 분산 인프라 전역에 반영되기까지 약 1~3분의 전파 시간이 소요되기 때문에 최초 배포 시에는 권한 전파 지연으로 배포 실패가 뜰 수 있다고 한다. 그럴 땐 잠시 기다린 후 다시 시도하면 반영된다나.</p>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/deaeff97204b2b636bd1360a2d3cb0e2caa95321">&gt;&gt;&gt; GitHub Repository at this point (deaeff9)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 36 | 개인 프로젝트 — viewmodel & riverpod (4)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-36</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-36</guid>
            <pubDate>Thu, 01 Oct 2026 14:51:07 GMT</pubDate>
            <description><![CDATA[<h1 id="day-36--개인-프로젝트--viewmodel--riverpod-4">DAY 36 | 개인 프로젝트 — viewmodel &amp; riverpod (4)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#35 [state] ProfileNotifier 및 프로필/스케치 아카이브 구현</li>
<li>#36 [state] SettingsNotifier 및 계정/앱 설정 관리 구현</li>
</ul>
<h2 id="35-state-profilenotifier-및-프로필스케치-아카이브-구현">#35 [state] ProfileNotifier 및 프로필/스케치 아카이브 구현</h2>
<p>프로필 화면까지 하다 보니 친구 목록 화면이랑 사용자 검색 화면이랑 프로필 화면에서 코드 중복이 발생하는 것을 발견했다. 하는 일이 완전히 동일하지는 않지만 거의 비슷한 녀석들. Gemini를 통해 비교해 보자면 다음과 같다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">ViewModel</th>
<th align="center">들고 있는 상태 (State)</th>
<th align="center">친구 수락/취소 시 일어나는 일</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>FriendsViewModel</code></td>
<td align="center"><code>List&lt;UserSummary&gt; friends</code><br><code>List&lt;UserSummary&gt; receivedRequests</code></td>
<td align="center">받은 요청 리스트에서 제거하고, 친구 리스트에 추가</td>
</tr>
<tr>
<td align="center"><code>UserSearchViewModel</code></td>
<td align="center"><code>Set&lt;String&gt; friendIds</code><br><code>Set&lt;String&gt; sentRequestIds</code><br><code>Set&lt;String&gt; receivedRequestIds</code></td>
<td align="center">ID Set에서 add / remove</td>
</tr>
<tr>
<td align="center"><code>UserProfileViewModel</code></td>
<td align="center"><code>Friendship? friendship</code><br><code>List&lt;SketchPost&gt; sketches</code></td>
<td align="center">단일 관계 객체 변경 및 상대방 스케치 목록 즉시 조회</td>
</tr>
</tbody></table>
<blockquote>
</blockquote>
<p><code>FriendsViewModel</code>, <code>UserSearchViewModel</code>, <code>UserProfileViewModel</code> 세 곳을 비교해 보면 겉모습은 똑같이 <code>friendshipRepository.acceptFriendRequest(...)</code> 등을 호출합니다. 하지만 <strong>호출 결과를 자기 State에 반영하는 방식(State Mutation)</strong> 이 화면마다 완전히 다릅니다. 즉, &quot;화면마다 보여줘야 하는 데이터 형태가 다르기 때문에, 각 ViewModel이 자기 화면의 상태를 즉시 갱신(낙관적 UI 업데이트)하느라&quot; Repository를 부르는 코드가 복붙처럼 중복된 것입니다.</p>
<hr>
<h4 id="현재-방식의-잠재적-문제점-상태-불일치-state-inconsistency">현재 방식의 잠재적 문제점: &quot;상태 불일치 (State Inconsistency)&quot;</h4>
<p>이 방식은 코드가 중복될 뿐만 아니라 더 큰 문제가 있습니다:</p>
<blockquote>
</blockquote>
<ul>
<li>프로필 화면(<code>UserProfileScreen</code>)에서 친구를 맺거나 차단했는데,</li>
<li>뒤로가기를 눌러 검색 화면이나 친구 목록 화면으로 돌아갔을 때,</li>
<li>만약 깜빡하고 <code>ref.invalidate(...)</code> 를 호출하지 않으면 화면마다 친구 상태가 제각각 다르게 보이는 &#39;상태 불일치&#39; 버그가 생깁니다.<blockquote>
</blockquote>
Flutter 및 Riverpod 실무에서 이 중복을 말끔히 해결하는 대표적인 방법은 <strong>&quot;조회(Query)와 변경(Command)의 분리 (CQS 패턴)&quot;</strong> 입니다.</li>
</ul>
<hr>
<h4 id="관련-개념">관련 개념</h4>
<ul>
<li>단일 진실 공급원 (Single Source of Truth, SSOT) 원칙</li>
<li>관심사의 분리 (Separation of Concerns)와 계층화 (Layering)</li>
<li>단방향 데이터 흐름 (Unidirectional Data Flow, UDF) 및 CQS 패턴</li>
<li>코드 유지보수성 및 실무 규모 확장성 (Scalability)</li>
</ul>
<p>따라서 친구 요청을 보내고 취소하고 수락하고 거절하는 등의 기능은 ViewModel에서 개별적으로 작성하는 게 아니라 UseCase로 따로 빼기로 했다. &quot;1 UseCase = 1 Action&quot; 원칙에 따라 친구 요청, 친구 요청 취소, 친구 요청 수락, 친구 요청 거절, 친구 삭제, 차단, 차단 해제에 대한 UseCase를 작성한다.</p>
<p>상대와의 관계에 따라 화면에 보이는 게 달라져 조건문 분기를 사용했다. 한 줄 소개 및 스케치 게시물은 친구 공개이므로 친구가 아닌 자에게는 보여주지 않는다. 한 줄 소개 위치에 친구가 아닌 경우에는 친구 요청 버튼이 뜬다. 요청 상태에 따라 친구 요청, 수락 및 거절, 요청 취소 버튼이 뜬다. 친구일 경우에는 실수로 친구 삭제를 누르지 않도록 친구 삭제 버튼은 메뉴 안에 숨겨져 있다.</p>
<p>route가 꼬인 부분이 있어서 살펴 보니 검색해서 얻은 자료에서 사용하던 방식이 내 프로젝트와 차이가 있는데 제대로 검토하지 않고 사용한 여파였다. 어디에는 UID 기반으로 되어 있고 어디에는 username 기반으로 되어 있는 것을 정리해 주니 문제가 해결되었다. 하는 김에 나중에 리팩토링 하려고 했던 route 작업도 같이 처리했다.</p>
<h2 id="36-state-settingsnotifier-및-계정앱-설정-관리-구현">#36 [state] SettingsNotifier 및 계정/앱 설정 관리 구현</h2>
<p>Mock model이 남아 있는 [차단한 사용자 괸리]부터 작업하였다. 여기서도 다른 곳처럼 ViewModel에서 사용할 State class를 만들고 작업을 하다가 엎엇다. 차단 해제에 대한 것도 <code>FriendshipActionHandler</code> 에 묶어 버리는 게 나을 것 같다. 당장은 차단된 사용자는 검색에서 빠져 있기에 [차단한 사용자 관리]에서밖에 차단 해제를 안 하겠지만, 딥링크로 사용자 프로필에 접속하는 경우가 발생할 경우 차단된 사용자 프로필 메뉴에서도 차단 해제를 할 수 있도록 하는 게 UX적으로 더 나으니까.</p>
<p>ViewModel에서 차단 해제를 처리했다면 차단한 사용자 목록 <code>blockedUsers</code> 와 작업 진행 중 여부 <code>isProcessing</code> 을 담는 State class가 필요했겠지만 작업 수행을 <code>FriendshipActionHandler</code> 에서 하면 <code>blockedUsers</code> 만 있으면 되므로 따로 State class를 생성하지 않고 <code>List&lt;UserSummary&gt;</code> 를 사용하기로 했다.</p>
<p>[차단된 사용자 관리] 페이지를 작업하다 보니 검색에서는 차단된 사용자를 제외했지만 차단된 사용자가 친구의 스케치에 댓글을 달았을 때에 대한 처리가 되어 있지 않다는 것을 인지했다. 삭제된 댓글과 마찬가지로 답글이 없다면 화면에서 완전히 가리고 답글이 있다면 차단된 사용자의 댓글이라고 표시되도록 수정하였다.</p>
<p>기본적으로 모든 View가 ViewModel과 연결되고 ViewModel이 Repository와 연결되며 Repository가 Service로 연결되는 구조가 MVVM 아키텍처의 흐름이다. 경우에 따라서는 UseCase가 끼기도 하지만. 하여간 기본적으로는 이를 따르되, 설정 화면에서는 예외를 두기로 했다. 독자적인 비즈니스 상태를 갖는 게 아니라 이미 메모리에 존재하는 전역 로그인 세션의 단일 속성을 변경하는 역할을 수행하므로, 설정 화면마다 ViewModel을 두면 사용자 세션에 대한 상태의 이중화 및 동기화 오버헤드가 발생할 수 있다는 이슈가 있다.</p>
<p>교과서적인 아키텍처 100%보다는 상황에 따라 적절한 trade-off를 고려하여 조정하는 것이 낫다는 판단으로, MVVM 구조의 완성보다는 단일 진실 공급원 원칙을 우선시하기로 했다.</p>
<p>일단은 소셜 로그인을 배제한 상태로 설정 기능을 마무리하고, 소셜 로그인 기능을 추가할 때 설정 화면의 기능 구현을 마무리하도록 하겠다. 최초로 소셜 로그인을 하였을 때 필요한 페이지도 새로 만들어야 하고 처리할 게 좀 있으니 설정 화면 기능 구현과 별개로 따로 빼는 게 나을 것 같다. 이용약관 및 개인정보 처리방침도 그 이후로 넘긴다.</p>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/111b9df1e4249af91deeec7ef632f5ff91598280">&gt;&gt;&gt; GitHub Repository at this point (111b9df)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 35 | 개인 프로젝트 — viewmodel & riverpod (3)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-35</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-35</guid>
            <pubDate>Tue, 29 Sep 2026 09:59:25 GMT</pubDate>
            <description><![CDATA[<h1 id="day-35--개인-프로젝트--viewmodel--riverpod-3">DAY 35 | 개인 프로젝트 — viewmodel &amp; riverpod (3)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#33 [state] FeedNotifier &amp; NotificationNotifier (스케치 피드 및 알림) 구현</li>
<li>#34 [state] FriendsNotifier 및 친구/검색 관리 구현</li>
</ul>
<h2 id="33-state-feednotifier--notificationnotifier-스케치-피드-및-알림-구현">#33 [state] FeedNotifier &amp; NotificationNotifier (스케치 피드 및 알림) 구현</h2>
<p>분명 처음 설계할 땐 Use Case가 <code>CompleteSessionUseCase</code> 하나였는데 구현하다 보니 이건 Use Case로 빼는 게 낫겠다는 의사결정을 하게 되어 Use Case가 늘어난다. 설계 시점에서 놓친 부분이 꽤 있단 말이지.</p>
<p>게시물을 수정하거나 삭제, 댓글을 다는 등의 상태 변화를 주었을 때 새로고침하기 전까지 반영되지 않는 이슈가 있었는데, <code>ref.invalidate();</code> 을 통해 해결했다. 변화를 반영해야 하는 녀석들의 ViewModel Provider들을 각각 전달하면 되더라.</p>
<p>댓글 작성/삭제 시 서버에 데이터를 보내는 한편 변동사항을 새로 받아오지 않고 로컬에서만 슬쩍 바꿔서 보여주는데, 그 과정에서도 이슈가 있었다. 나갔다 들어오면 제대로 뜨는데 댓글을 단 시점에는 답글이 원댓글 아래가 아니라 가장 밑에 달린다거나, 답글이 있는 댓글을 삭제했을 때 &#39;삭제된 메시지입니다&#39;라고 뜨는 게 아니라 그냥 완전히 사라져 버린다거나. 로컬에서도 댓글 작성/삭제 시점에 필터링을 한 번 해주는 걸로 처리했다. 그리고 삭제된 댓글에는 답글 버튼이나 메뉴 버튼이 뜨지 않게 하였다.</p>
<p>아직 친구 관련 기능이 구현되지 않은 상태라 알림 화면을 테스트할 수 없었는데, 이를 처리하기 위해 Gemini에게 샘플 notifications 문서를 작성해 달라고 하여 Firestore에 넣어놓고 테스트 해보았다.</p>
<h2 id="34-state-friendsnotifier-및-친구검색-관리-구현">#34 [state] FriendsNotifier 및 친구/검색 관리 구현</h2>
<p>초반에는 Riverpod을 처음 다뤄보다 보니 AI한테 물어보고 따라치며 &#39;이건 왜 이렇게 쓰는 거지?&#39; 하는 의문이 들 때 물어보면서 작업하는 경우가 많았는데 이쯤 오니까 AI 없이도 작성할 수 있는 코드가 많아졌다. 아직은 조금씩 놓치는 부분도 있지만 차차 개선되겠지.</p>
<p>하다 보니 일관성이 결여된 부분도 발견되고 뒤늦게 덧붙이게 되는 것들도 있다. 미리 규칙을 정해놓지 않으면 그런 일이 발생하곤 한다. 회원가입에서 엔터를 쳐서 다음 입력칸으로 넘어간다거나 댓글을 작성하고 엔터를 치면 댓글이 입력된다거나 하는 기능도 뒤늦게 추가된 것 중 하나다. 검색 기능 넣다 보니 예제 코드에 그런 게 적용되어 있길래 전체적으로 따라해 보았다. TextField에서 <code>textInputAction</code> 와 <code>onFieldSubmitted</code> 를 활용할 수 있게 되었다!</p>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/10451f2e8a57728bcd4a5ade0243e0629ddea09e">&gt;&gt;&gt; GitHub Repository at this point (10451f2)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 34-1 | 개인 프로젝트 — viewmodel & riverpod (2)]]></title>
            <link>https://velog.io/@peeeeeter_j/flutter-2026-sesac-34-1</link>
            <guid>https://velog.io/@peeeeeter_j/flutter-2026-sesac-34-1</guid>
            <pubDate>Sat, 26 Sep 2026 13:35:06 GMT</pubDate>
            <description><![CDATA[<h1 id="day-34-1--개인-프로젝트--viewmodel--riverpod-2">DAY 34-1 | 개인 프로젝트 — viewmodel &amp; riverpod (2)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#18 [state] SessionNotifier 및 운동 세션 트래킹 구현</li>
<li>#32 [state] HistoryNotifier 및 월별 캘린더 기록 관리 구현</li>
</ul>
<h2 id="18-state-sessionnotifier-및-운동-세션-트래킹-구현">#18 [state] SessionNotifier 및 운동 세션 트래킹 구현</h2>
<p>이 프로젝트의 핵심 영역이다. 그래서 처리할 게 많다. 단일 파일로 생성하기보다는 세션 시작 화면, 진행 화면, 종료 화면의 ViewModel을 각각 작성하기로 했다.</p>
<p>하다 보니 스케줄 짤 때 놓쳤던 부분 중에 지금 붙잡고 있긴 힘들 것 같은 부분도 발견되었다. 그것들은 과감히 첫 번째 배포 버전에서 제외하고 다음 마일스톤 이슈로 넣어두기로 했다. 지금 구현하기엔 번거롭지만 결과적으로는 있는 게 좋고, 당장은 빼더라도 앱이 굴러가는 데는 지장이 없는 것들. 나중에 확장하는 걸로 충분할 것 같은 녀석들이다. 처음부터 완성도 높게 만들면 좋긴 하겠지만 때로는 일단 작동하는 앱을 만든 후 iterate하게 개발하는 것도 필요하다.</p>
<p>View에 구현해 놓은 걸 ViewModel로 옮기기도 하고, View 작성할 땐 나중으로 미뤄둔 걸 새로 구현하기도 하고. 그닥 일관성 있는 작업은 아니었구나 싶다. 전체적인 흐름을 머릿속에 담아둔 채 체계적으로 기획하지 않으면 엄청나게 꼬인 채로 진행될 수도 있겠구나 싶기도 하고.</p>
<p>ViewModel 연결하다 View에서 중복코드 나오는 걸 보고 뒤늦게 Model에 데이터 포맷 변환 추가하기도 하고 그런다. 이것도 해야 하는데 저것도 해야 하는데 하며 정신이 없다.</p>
<p>각 화면의 로직을 구현하고 나서 잘 작동하는지 테스트해 보는데 <code>[활성 세션 조회 중 오류가 발생했습니다]</code> 오류가 떴다. 테스트를 하다 [세션 시작] 후 [일시 정지]를 눌러 멈춰보고 [기록을 남기지 않고 종료하기]를 눌렀는데, 그 다음 세션을 시작하려고 하니 오류가 발생한 것이다. 원인을 찾아 보니 dialog를 닫으면서 세션 종료 시에도 세션 재개 처리를 해버려서 진행 중인 세션이 있는 것으로 취급되어 발생한 일이더라. dialog를 닫을 때 세션 재개 여부를 return하여 처리하도록 수정하였고 버그로 활성 세션을 처리하지 못한 채 진입할 경우 기존 세션은 폐기하는 걸로 코드를 수정하였다. 보통은 활성 세션이 있다면 앱 진입 시 중단된 세션에 대한 dialog가 뜨기 때문에 활성 세션을 가진 채 [세션 시작]을 누를 일은 없지만 말이다.</p>
<p>이 부분이 핵심 로직이긴 하지만 이것만 이틀이 걸렸다.</p>
<h2 id="32-state-historynotifier-및-월별-캘린더-기록-관리-구현">#32 [state] HistoryNotifier 및 월별 캘린더 기록 관리 구현</h2>
<p>원래는 세션 결과의 스케치 이미지와 이동 경로 이미지를 서버에 저장해 놓고 기록에서 불러오려고 했는데, 이동 경로 이미지는 쓰지 않기로 했다. Flutter 오프스크린 캔버스에서는 실제 지도 타일을 함께 캡처해 합성하는 것이 기술적으로 까다로워, 결국 단색 배경에 선만 덜렁 그려진 이미지로 업로드되는데, 그런 이미지를 쓸 바에는 훨씬 용량이 작은 폴리라인 문자열만 저장해 놓고 디코딩하여 사용하는 게 낫다고 판단되었다. 설계 시점에 패키지에 대한 검증이 되어 있었다면 처음부터 이 방향으로 갈 수도 있었을까. 역시 설계 미숙에 대한 아쉬움이 크다.</p>
<p>화면에 떠야 할 게 자꾸 잘 안 떠서 많이 지체되었다. 세션 진행 화면과 세션 결과 화면에서 잘 떴던 지도 타일이 왜 여기선 잘 안 뜨는가. Firebase 통신도 되고 세션 진행 화면에서는 지도가 잘 뜨는 걸 봐서는 네트워크 이슈는 아닌데. 안 되겠다 싶어서 AI와 함께 원인을 뜯어보았다.</p>
<blockquote>
</blockquote>
<h4 id="원인-1-줌-레벨과-타일-요청-수의-16배-폭증-4δz">원인 1: 줌 레벨과 타일 요청 수의 16배 폭증 ($$4^{Δz}$$)</h4>
<p>OpenStreetMap을 비롯한 타일 맵 시스템(Slippy Map)은 줌 레벨이 1단계 오를 때마다 타일 수가 가로 2배, 세로 2배, 즉 <strong>4배($$2^2$$)</strong>씩 늘어납니다.</p>
<ul>
<li><strong>실시간 트래킹 화면</strong>: 줌 15 고정 → 화면에 필요한 타일은 단 4~6장.</li>
<li><strong>기록 상세 화면</strong>: 짧은 코스를 300x250 작은 뷰포트에 꽉 차게 보여주려고 <code>maxZoom: 17.0</code> 으로 자동 확대 → 줌 15 대비 타일 개수가 <strong>16배($$4^2$$)</strong> 로 폭증 (한 번에 15~25장 이상의 타일 필요).<blockquote>
</blockquote>
<h4 id="원인-2-initialcamerafit과-onmapready의-이중-피팅-함정">원인 2: initialCameraFit과 onMapReady의 &#39;이중 피팅&#39; 함정</h4>
코드 내부에서 카메라를 맞추는 로직이 2군데에서 겹쳐 있었습니다:</li>
</ul>
<ol>
<li><code>MapOptions(initialCameraFit: CameraFit.bounds(...))</code> 가 레이아웃 크기 측정 즉시 카메라 이동.</li>
<li>거의 같은 밀리초에 <code>onMapReady</code> 콜백이 실행되며 <code>_mapController.fitCamera(...)</code> 를 또 한 번 실행.<blockquote>
</blockquote>
결과적으로 <strong>1밀리초 사이에 수십 개의 고해상도 타일 다운로드 TCP 소켓이 동시에 생성</strong>되었습니다.<blockquote>
</blockquote>
<h4 id="원인-3-안드로이드-에뮬레이터의-가상-네트워크nat-한계">원인 3: 안드로이드 에뮬레이터의 가상 네트워크(NAT) 한계</h4>
안드로이드 에뮬레이터는 호스트 PC와 가상 라우터(qemu SLIRP NAT, <code>10.0.2.2</code>)로 통신합니다.
Dart의 <code>IOClient</code> 가 짧은 순간에 수십 개의 병렬 소켓을 열어젖히자, 에뮬레이터 네트워크 스택의 임시 포트가 순간 고갈되거나 라우팅 테이블이 끊어지면서 리눅스 커널 에러인 <strong><code>errno 113: EHOSTUNREACH (No route to host)</code></strong> 를 뱉어낸 것입니다.<blockquote>
</blockquote>
<h4 id="원인-4-핵심-스모킹-건-flutter의-에러-이미지-캐싱">원인 4 (핵심 스모킹 건): Flutter의 &#39;에러 이미지 캐싱&#39;</h4>
가장 결정적인 미스터리였던 <strong>“왜 안 뜨는 녀석들은 화면을 나갔다 들어와도 계속 안 뜨고, 한 번 성공한 녀석만 계속 뜨는가?”</strong> 의 범인은 바로 Flutter의 캐싱 동작이었습니다.<blockquote>
</blockquote>
Flutter의 <code>PaintingBinding.instance.imageCache</code> 는 이미지를 다운로드하다가 예외(<code>SocketException</code>)가 발생하면, &quot;이 URL은 다운로드에 실패했다&quot;는 에러 상태 자체를 캐시에 저장합니다. 즉, 처음에 소켓 버스트로 한 번 실패하고 나면, 이후 사용자가 화면을 백날 나갔다 들어와도 Flutter는 <strong>&quot;아, 이 타일 URL? 지난번에 실패했던 애네. 네트워크 재요청 안 하고 바로 에러 던질게&quot;</strong>라며 즉시 에러를 반환해 버렸던 것입니다.</li>
</ol>
<blockquote>
</blockquote>
<h4 id="해결-방법">해결 방법</h4>
<blockquote>
</blockquote>
<h4 id="조치-1-중복-fitcamera-제거">조치 1: 중복 <code>fitCamera</code> 제거</h4>
<p><code>initialCameraFit</code> 이 이미 렌더링 시작 시점에 바운드를 정확히 잡아주므로, <code>onMapReady</code> 에서 불필요하게 카메라를 흔들던 중복 호출을 제거합니다.</p>
<h4 id="조치-2-maxzoom-160으로-완화-타일-요청량-75-감소">조치 2: <code>maxZoom</code> 16.0으로 완화 (타일 요청량 75% 감소)</h4>
<p><code>maxZoom</code> 을 17.0에서 16.0으로 1단계만 낮춰도 필요한 타일 개수가 1/4로 급감합니다. 사용자 눈에는 코스가 충분히 크고 선명하게 보이면서도 에뮬레이터 네트워크 부하는 대폭 낮아집니다.</p>
<h4 id="조치-3-에러-캐시-방출-전략-변경-evicterrortilestrategydispose">조치 3: 에러 캐시 방출 전략 변경 (<code>EvictErrorTileStrategy.dispose</code>)</h4>
<p>일시적으로 네트워크 패킷이 튀어 타일 다운로드에 실패하더라도, 위젯이 사라지거나 뷰포트에서 벗어날 때 Flutter ImageCache에서 실패 기록을 즉시 삭제하도록 설정합니다. 이렇게 하면 다음 방문 시 깨끗하게 재시도됩니다.</p>
<h4 id="조치-4-예외-무음화">조치 4: 예외 무음화</h4>
<p>NetworkTileProvider(silenceExceptions: true)를 추가해 콘솔 에러 폭탄을 방지합니다.</p>
<p>여러 가지가 복합적으로 작용하고 있었다. 확실히 AI가 있으니까 덜 헤매고 답을 찾아갈 수 있는 것 같다. AI에게 의존하지 않으면서 적절히 도움을 받는 균형점이 어디일까.</p>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/819732e9160a937f9a05404ad050c0a0d3d3f485">&gt;&gt;&gt; GitHub Repository at this point (819732e)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 34 | 개인 프로젝트 — viewmodel & riverpod (1)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-34</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-34</guid>
            <pubDate>Wed, 23 Sep 2026 16:29:04 GMT</pubDate>
            <description><![CDATA[<h1 id="day-34--개인-프로젝트--viewmodel--riverpod-1">DAY 34 | 개인 프로젝트 — viewmodel &amp; riverpod (1)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#16 [state] AuthNotifier 및 인증 상태 관리 구현</li>
<li>#17 [state] HomeNotifier 및 홈 대시보드 상태 관리 구현</li>
</ul>
<p>issue 번호가 아주 엉망이다. 처음 스케줄 잡은 게 아키텍처 이해도가 많이 떨어졌던 시점이라 중간에 좀 엎었기 때문이다. 아무렴 어때.</p>
<hr>
<h2 id="16-state-authnotifier-및-인증-상태-관리-구현">#16 [state] AuthNotifier 및 인증 상태 관리 구현</h2>
<p><code>class AuthViewModel extends AsyncNotifier&lt;User?&gt;</code> 를 구현한다. Riverpod의 <code>AsyncNotifier</code> 를 extend하여 비동기 상태를 관리하는 <code>AsyncValue&lt;User?&gt;</code> 를 사용한다. 이 녀석은 다음과 같은 상태를 갖는다.</p>
<blockquote>
</blockquote>
<ul>
<li><code>AsyncLoading</code>: 인증 확인 및 로그인/회원가입 진행 중</li>
<li><code>AsyncData(User)</code>: 로그인 완료 상태</li>
<li><code>AsyncData(null)</code>: 미로그인 (게스트) 상태</li>
<li><code>AsyncError</code>: 로그인/가입 실패 및 네트워크 오류 상태</li>
</ul>
<p>가령 로그인에 대한 method를 작성한다고 할 때, 로그인을 시도하면 <code>state = AsyncLoading</code> 로 둔 채 <code>signInWithUsername()</code> 를 하고, 그 결과에 따라 <code>state = AsyncData(value);</code> 하거나 <code>state = AsyncError(error, StackTrace.current);</code> 하여 consumer 쪽에서 이 값에 따라 판단하는 식이다.  <code>state = AsyncLoading</code> 라면 로그인 버튼을 아무리 눌러도 요청이 중복으로 들어가지 않고, <code>state = AsyncData(value);</code> 이면 무사히 로그인 되도록 하며, <code>state = AsyncError(error, StackTrace.current);</code> 이면 로그인 실패 Snackbar를 띄워주는 식으로 구현할 수 있다.</p>
<p>이 부분은 확실히 Rust의 pattern-matching과 닮아 있다. 요즘 스타일인가.</p>
<p>그것과 별개로 여기선 AI로부터 코드 지적을 많이 받았다. 놓치는 부분이 너무 많다. 아무래도 처음 써 보는 package는 쉽지 않다. 주요 이슈는 다음과 같다. 이것도 몇 개 작성하다 보면 어떤 것들을 고려해야 하는지 요령이 생기려나.</p>
<blockquote>
</blockquote>
<ol>
<li><strong>레이스 컨디션 및 광클 방지 (<code>if (state.isLoading)</code>)</strong></li>
</ol>
<ul>
<li>이슈:
네트워크 지연 상황에서 사용자가 로그인/가입/탈퇴 버튼을 빠르게 연타할 때, 동일한 요청이 백엔드/Firebase로 중복 전송되어 <code>email-already-in-use</code> 에러나 불필요한 트랜잭션이 폭증하는 현상.</li>
<li>설계 방식:
모든 상태 변경 액션 진입부에 <code>if (state.isLoading) return const Result.error(ValidationException(&#39;이미 요청이 진행 중입니다.&#39;));</code> 가드를 배치하여 첫 번째 요청이 끝날 때까지 후속 입력을 즉시 차단합니다.</li>
</ul>
<ol start="2">
<li><strong>Provider 해제 후 메모리 누수 및 크래시 방지 (<code>ref.mounted</code>)</strong></li>
</ol>
<ul>
<li>이슈:
사용자가 로그인/가입/프로필 조회 비동기 처리를 기다리는 도중 화면을 이탈하거나 앱이 백그라운드로 전환될 때, 비동기 처리가 끝난 뒤 이미 해제된 Notifier의 상태를 바꾸려다 발생하는 <code>Bad state: Cannot use ref/state after dispose</code> 치명적 크래시.</li>
<li>설계 방식: Riverpod 3.x 프레임워크 내장 표준인 <code>ref.mounted</code> 를 모든 await 직후에 적용하여 Provider가 안전하게 살아있을 때만 상태를 변경하도록 보장합니다.</li>
</ul>
<ol start="3">
<li><strong>앱 기동 시 불필요한 Firestore 중복 쿼리 방지 (<code>_listenAuthState</code>)</strong></li>
</ol>
<ul>
<li>이슈:
Firebase Auth의 <code>authStateChanges</code> 는 구독 직후 즉시 1회 초기 이벤트를 방출합니다. 이때 <code>build()</code> 에서 프로필을 조회하고 있는데 리스너도 동시에 조회를 시작하여 앱 켤 때마다 Firestore 읽기 비용이 2배로 청구되는 낭비.</li>
<li>설계 방식:
<code>if (state.isLoading) return;</code>으로 <code>build()</code> 초기화 중 발생하는 초기 스트림 이벤트를 무시.
<code>state.value?.uid != uid</code> 조건으로 계정이 실제로 바뀌었을 때만 프로필 조회를 트리거하여 불필요한 네트워크 호출을 원천 차단합니다.</li>
</ul>
<ol start="4">
<li><strong>후속 레이어와의 결합도 분리 (<code>currentUserProvider</code>)</strong></li>
</ol>
<ul>
<li>이슈: Auth 외의 feature들은 로그인 진행 상태(<code>AsyncLoading</code>)에는 관심이 없고 오직 &quot;현재 사용자(<code>User?</code>)&quot; 데이터만 필요합니다.</li>
<li>설계 방식:
다음과 같은 편의 Provider를 파일 하단에 선언함으로써, 후속 ViewModel들이 복잡한 AsyncValue 매핑 없이 <code>ref.watch(currentUserProvider)</code> 만으로 현재 로그인 사용자를 반응형으로 구독할 수 있게 설계합니다.<pre><code class="language-dart">final currentUserProvider = Provider&lt;User?&gt;((ref) {
  return ref.watch(authNotifierProvider).value;
});</code></pre>
</li>
</ul>
<p>1번의 경우 Riverpod이 아닌 기본 Provider를 사용할 경우 <code>lib/utils/command.dart</code> 를 통해 한다던 처리인데, 분명 들은 게 있었음에도 구현하다 보니 생각을 못 했다. 나머지는 그냥 고려하지 못했던 부분들이다.</p>
<h2 id="17-state-homenotifier-및-홈-대시보드-상태-관리-구현">#17 [state] HomeNotifier 및 홈 대시보드 상태 관리 구현</h2>
<p>여기선 여러 개의 State가 존재한다. 이런 걸 구현하는 방식은 여러 가지가 있다.</p>
<blockquote>
</blockquote>
<ul>
<li>방법 1: 각각의 View가 하나의 State를 관리하도록 화면을 여러 개의 View로 쪼갠다.</li>
<li>방법 2: 모든 State를 <code>ChangeNotifier</code> 를 extends 하는 class에 넣고 변경될 때마다 <code>notifyListeners()</code> 한다.</li>
<li>방법 3: 모든 State를 <code>AsyncNotifier&lt;T&gt;</code> 를 extends 하는 class에 넣고 변경될 때마다 <code>state = AsyncData(state.value.copyWith(...))</code> 한다.</li>
</ul>
<p>두 번째 방법은 Provider를 사용할 때 흔히 하는 방식이고 세 번째 방법은 Riverpod을 사용할 때 흔히 하는 방식이다. 세 번째 방식을 사용하면 <code>AsyncValue</code> 가 로딩/에러/성공을 자동 래핑해주며 코드 안정성이 높아진다. 따라서 이 방식으로 구현하도록 하겠다.</p>
<p>하다 보니 놓친 부분, 누락된 내용이 꽤 있다는 걸 인지했다. 발견되는 대로 issue로 등록하고 반영해 가며 적절한 commit을 남기면 좋을 텐데 마음이 급하니 그게 잘 안 된다.</p>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/78e3fcd2fd920d2c9de7dd69984a7978991b4c01">&gt;&gt;&gt; GitHub Repository at this point (78e3fcd)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 33-1 | Riverpod, 알아야 쓸 수 있다]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-33-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-33-1</guid>
            <pubDate>Tue, 22 Sep 2026 08:04:59 GMT</pubDate>
            <description><![CDATA[<h1 id="day-33-1--riverpod-알아야-쓸-수-있다">DAY 33-1 | Riverpod, 알아야 쓸 수 있다</h1>
<p>프로젝트에서 Riverpod을 본격적으로 다루기 전에 어떻게 쓰는 녀석인지 살펴보자. 완전히 익히고 시작하기보다는 일단 건드려 보는 게 효율적이긴 하지만 그래도 최소한의 흐름은 알아야 제대로 쓸 수 있겠지.</p>
<blockquote>
<h4 id="riverpod이-무엇인가">[Riverpod이 무엇인가]</h4>
<p>Riverpod은 Flutter에서 가장 널리 쓰이는 <strong>상태 관리(State Management)이자 의존성 주입(DI) 도구</strong> 입니다. 기존의 <code>Provider</code> 패키지를 만들었던 개발자(Remi Rousselet)가 <code>Provider</code>의 한계(<code>BuildContext</code>에 묶여 발생하는 런타임 에러 <code>ProviderNotFoundException</code> 등)를 극복하기 위해 아예 새롭게 만든 2세대 라이브러리입니다. (이름도 <code>Provider</code>의 철자를 재배열한 아나그램입니다!)</p>
</blockquote>
<h4 id="핵심-철학-위젯-트리에-갇히지-않는-컴파일-타임-안전성">핵심 철학: &quot;위젯 트리에 갇히지 않는 컴파일 타임 안전성&quot;</h4>
<blockquote>
</blockquote>
<p>Riverpod은 Provider들을 <strong>전역 상수(Global Final)</strong> 로 선언합니다. 덕분에 컴파일 시점에 문법과 타입이 100% 검증되므로 런타임에 &quot;찾을 수 없다&quot;는 에러가 원천 차단됩니다.</p>
<h2 id="riverpod의-구성-요소">Riverpod의 구성 요소</h2>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/f9770e89-3ca7-4e85-aadc-546629ad5d33/image.svg" alt=""></p>
<h3 id="providerscope"><code>ProviderScope</code></h3>
<ul>
<li>앱 전체의 모든 상태 데이터가 실제로 저장되는 단 하나의 전역 컨테이너</li>
<li><code>main.dart</code> 의 <code>runApp(ProviderScope(child: ...))</code> 처럼 앱 최상단에 딱 한 번 wrap</li>
</ul>
<h3 id="provider"><code>Provider</code></h3>
<ul>
<li>&quot;어떤 객체(<code>Repository</code>, <code>ViewModel</code>)를 어떻게 만들고 관리할 것인가?&quot;를 선언하는 설명서</li>
</ul>
<h4 id="단순-객체-제공용-providert">단순 객체 제공용: <code>Provider&lt;T&gt;</code></h4>
<ul>
<li>Repository, Database, HTTP Client 등 인스턴스 자체를 제공할 때 사용</li>
<li>순수 Provider를 사용하는 것처럼 한 파일에 모아놓는 편</li>
<li>Flutter 공식 Compass 예제에서는 <a href="https://github.com/flutter/samples/blob/main/compass_app/app/lib/config/dependencies.dart"><code>lib/config/dependencies.dart</code></a></li>
</ul>
<h4 id="비동기-상태-관리용-asyncnotifierprovider">비동기 상태 관리용: <code>AsyncNotifierProvider</code></h4>
<ul>
<li>서버 통신, DB 조회처럼 로딩/성공/에러가 발생하는 화면의 상태(ViewModel)를 만들 때 사용</li>
<li>UI 레이어의 각 기능별 디렉토리 내에서 <code>AsyncNotifier&lt;T&gt;</code> 를 extends 하는 Notifier class 생성</li>
<li>따로 관리용 파일을 만들지 않고, 인터페이스가 정의된 파일 내부에 주입용 Provider를 선언하는 경향</li>
</ul>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">이름</th>
<th align="center">담당 계층</th>
<th align="center">역할 비유</th>
<th align="center">주로 사용하는 Provider 종류</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>&lt;Feature&gt;ServiceProvider</code></td>
<td align="center">외부 통신 / I/O (Data Source)</td>
<td align="center">외주 일꾼 (외부 API 통신, DB, 기기 센서 직접 조작)</td>
<td align="center"><code>Provider&lt;Service&gt;</code> (단순 객체 주입)</td>
</tr>
<tr>
<td align="center"><code>&lt;Feature&gt;RepositoryProvider</code></td>
<td align="center">데이터 도메인 (Data Layer)</td>
<td align="center">창고 관리자 (여러 Service를 조합해 가공된 비즈니스 데이터 제공)</td>
<td align="center"><code>Provider&lt;Repository&gt;</code> (단순 객체 주입)</td>
</tr>
<tr>
<td align="center"><code>&lt;Feature&gt;ViewModelProvider</code></td>
<td align="center">화면 상태 (Presentation / ViewModel)</td>
<td align="center">매장 매니저 (UI가 바라볼 화면 상태를 관리하고 화면과 소통)</td>
<td align="center"><code>AsyncNotifierProvider</code> / <code>NotifierProvider</code></td>
</tr>
</tbody></table>
<blockquote>
</blockquote>
<ul>
<li>API 주소가 바뀌거나 HTTP 라이브러리를 Dio에서 http로 바꾸면? ➔ <strong>Service</strong> 만 수정</li>
<li>토큰 저장 방식이 SharedPreferences에서 SecureStorage로 바뀌면? ➔ <strong>Repository</strong> 만 수정</li>
<li>화면 디자인이 바뀌거나 로딩 스피너 디자인이 바뀌면? ➔ <strong>ViewModel / UI</strong> 만 수정</li>
</ul>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/23317c1c-c95b-45fe-b22a-36afd56bea4d/image.svg" alt=""></p>
<h3 id="ref-또는-widgetref"><code>Ref</code> 또는 <code>WidgetRef</code></h3>
<ul>
<li>Provider의 값에 접근하거나, 메서드를 실행하거나, 상태를 구독하기 위해 사용하는 매개체</li>
<li>다른 Provider 안에서는 <code>ref</code>, 위젯 안에서는 <code>WidgetRef ref</code> 를 사용</li>
</ul>
<h2 id="widget에서-riverpod-사용하기">widget에서 Riverpod 사용하기</h2>
<ul>
<li><code>StatelessWidget</code> ➔ <strong><code>ConsumerWidget</code></strong></li>
<li><code>StatefulWidget</code> ➔ <strong><code>ConsumerStatefulWidget</code></strong></li>
</ul>
<p><strong><code>ConsumerWidget</code></strong> 에서는 <code>Widget build(BuildContext context, WidgetRef ref)</code> 와 같이 <code>build()</code> 의 두 번째 parameter로 <code>WidgetRef ref</code> 가 제공된다.</p>
<p><strong><code>ConsumerStatefulWidget</code></strong> 에서는 <code>ConsumerState</code> 의 기본 property로 들어 있어 연결된 State class에서 자유롭게 사용할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">// ConsumerWidget (Stateless 대안)
class MyScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) { // 👈 파라미터로 받음
    final auth = ref.watch(authProvider);
    return ...;
  }
}</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-dart">// ConsumerStatefulWidget (Stateful 대안)
class MyScreen extends ConsumerStatefulWidget {
  @override
  ConsumerState&lt;MyScreen&gt; createState() =&gt; _MyScreenState();
}
&gt;
class _MyScreenState extends ConsumerState&lt;MyScreen&gt; {
  @override
  void initState() {
    super.initState();
    // 👈 build() 뿐만 아니라 initState(), dispose() 등에서도 ref를 바로 쓸 수 있습니다!
    ref.read(authProvider.notifier);
  }
&gt;
  @override
  Widget build(BuildContext context) { // 👈 파라미터에는 context만 존재
    final auth = ref.watch(authProvider); // 👈 State의 멤버 변수처럼 ref 사용
    return ...;
  }
}</code></pre>
<h3 id="ref-의-method"><code>ref</code> 의 method</h3>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="left">method</th>
<th align="left">언제 쓰는가?</th>
<th align="left">동작 방식</th>
<th align="left">예시 위치</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong><code>ref.watch(...)</code></strong></td>
<td align="left">상태가 바뀌면 <strong>화면을 다시 그려야 할 때</strong></td>
<td align="left">상태를 실시간 구독. 데이터가 바뀌면 <code>build()</code>가 자동 재실행됨</td>
<td align="left"><code>build()</code> 메서드 본문 내부</td>
</tr>
<tr>
<td align="left"><strong><code>ref.read(...)</code></strong></td>
<td align="left">상태를 구독하지 않고 <strong>함수(액션)만 딱 1번 실행할 때</strong></td>
<td align="left">화면 재빌드 없이 인스턴스의 메서드만 호출</td>
<td align="left">버튼의 <code>onPressed: () =&gt; ...</code></td>
</tr>
<tr>
<td align="left"><strong><code>ref.listen(...)</code></strong></td>
<td align="left">상태가 바뀌었을 때 <strong>스낵바, 다이얼로그, 화면 이동</strong>을 띄울 때</td>
<td align="left">화면을 다시 그리지 않고 부수 효과(Side-effect)만 실행</td>
<td align="left"><code>build()</code> 메서드 시작 부분</td>
</tr>
</tbody></table>
<p>동기 Provider는 있는 그대로의 값을 return하지만, 비동기 Provider에서 <code>ref.watch()</code> 하면 <strong><code>AsyncValue&lt;T&gt;</code></strong> instance가 return된다. 이것은 로딩(Loading), 성공(Data), 실패(Error)를 하나로 담는 상태 상자로, 이를 통해 로딩/성공/에러 상태에 따라 적절한 작업을 작성한다. </p>
<p><code>AsyncValue&lt;T&gt;</code> 에 <code>when()</code> method를 사용하여 상태별 분기를 구현할 수 있다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/9a66065c-631e-48b6-9a5a-fa044db10bc4/image.svg" alt=""></p>
<blockquote>
</blockquote>
<pre><code class="language-dart">@override
Widget build(BuildContext context, WidgetRef ref) {
  final authState = ref.watch(authNotifierProvider);
&gt;
  return authState.when(
    // 1. 로딩 중일 때 보여줄 위젯
    loading: () =&gt; const Center(child: CircularProgressIndicator()),
&gt;
    // 2. 에러가 났을 때 보여줄 위젯
    error: (err, stack) =&gt; Center(child: Text(&#39;로그인 실패: $err&#39;)),
&gt;
    // 3. 성공해서 데이터(User)가 있을 때 보여줄 위젯
    data: (user) {
      if (user == null) return const LoginForm();
      return HomeScreen(user: user);
    },
  );
}</code></pre>
<blockquote>
<h4 id="사용자가-로그인-버튼을-눌렀을-때의-흐름도-예시">[사용자가 로그인 버튼을 눌렀을 때의 흐름도 예시]</h4>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/52b76f45-f647-4ddc-bd67-3558d2b6790c/image.svg" alt=""></p>
</blockquote>
<h3 id="notifier의-함수를-호출하기">Notifier의 함수를 호출하기</h3>
<ul>
<li>상태 값 자체를 읽는 것이 아니라 상태를 바꾸는 클래스(Notifier)의 메서드를 호출</li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-dart">ElevatedButton(
  onPressed: () {
    // ❌ ref.read(authNotifierProvider).login(...) -&gt; 에러! (상태값엔 login 메서드가 없음)
    // ⭕ ref.read(authNotifierProvider.notifier).login(...);
  },
  child: const Text(&#39;로그인&#39;),
)</code></pre>
<h2 id="한-화면이-여러-개의-상태를-가질-때">한 화면이 여러 개의 상태를 가질 때</h2>
<p>전통적인 방식에서는 View 하나당 State 하나가 연결된다. 여러 개의 State를 다루는 화면은 여러 개의 View로 쪼개야 한다. 하나의 View에 담고자 한다면 모든 State를 하나의 class에 때려넣고 <code>ChangeNotifier</code> 를 extends 하여 사용하기도 한다. 이 경우, 어디서나 해당 class의 property를 mutable하게 조작할 수 있고, 값이 바뀔 때마다 <code>notifyListeners();</code> 를 호출해야 하며, 상태 관리가 어려워진다.</p>
<p>Riverpod은 immutable한 <code>AsyncNotifier&lt;T&gt;</code> 를 extends 하도록 하여 관리 편의성을 높인다. Riverpod의 <code>AsyncNotifier&lt;T&gt;</code> 는 오직 단 하나의 상태 <code>T</code> 만 관리하도록 강제되는 generic class다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center"><code>ChangeNotifier</code> (Compass 앱 스타일)</th>
<th align="center"><code>AsyncNotifier&lt;T&gt;</code> (Riverpod 표준 정석)</th>
</tr>
</thead>
<tbody><tr>
<td align="center">상태 관리 철학</td>
<td align="center">가변(Mutable)</td>
<td align="center">불변(Immutable)</td>
</tr>
<tr>
<td align="center">State class 유무</td>
<td align="center">❌ 필요 없음 (ViewModel 자체가 상태 바구니)</td>
<td align="center">⭕ 필수 (HomeState)</td>
</tr>
<tr>
<td align="center">상태 갱신 방식</td>
<td align="center">내부 변수 직접 수정 후 <code>notifyListeners()</code></td>
<td align="center"><code>state = AsyncData(state.value.copyWith(...))</code></td>
</tr>
<tr>
<td align="center">로딩 / 에러 처리</td>
<td align="center"><code>bool isLoading</code>, <code>String? error</code> 직접 선언</td>
<td align="center"><code>AsyncValue</code> 가 로딩/에러/성공을 자동 래핑</td>
</tr>
<tr>
<td align="center">코드 안정성</td>
<td align="center">빠르고 직관적이나 규모가 커지면 상태 추적 난이도 상승</td>
<td align="center">상태가 어디서 어떻게 바뀌었는지 100% 예측 가능</td>
</tr>
</tbody></table>
<p>물론 관리해야 할 상태가 하나뿐인 경우에는 State class를 따로 만들지 않고 그 상태 자체를 <code>AsyncNotifier&lt;T&gt;</code> 의 <code>T</code> 로 사용하면 된다.</p>
<h2 id="riverpod-annotation"><code>@riverpod</code> annotation</h2>
<ul>
<li>현재 Riverpod 공식 문서에서는 <code>AsyncNotifierProvider</code> 등을 손으로 직접 타이핑하기보다, <code>riverpod_generator</code> 패키지를 통한 <code>@riverpod</code> annotation 방식을 가장 권장</li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-dart">import &#39;package:riverpod_annotation/riverpod_annotation.dart&#39;;
&gt;
part &#39;auth_notifier.g.dart&#39;; // 👈 파일명과 동일하게 .g.dart 선언
&gt;
// 수동 작성 대신 이렇게 쓰면 Riverpod이 알아서 Provider를 생성해 줍니다.
@riverpod
class AuthNotifier extends _$AuthNotifier {
  @override
  FutureOr&lt;User?&gt; build() async {
    return null;
  }
&gt;
  Future&lt;void&gt; login(String id, String pw) async { ... }
}</code></pre>
<p><code>dart run build_runner build</code> 로 빌드하면 class 이름에 따라 <code>authNotifierProvider</code> 와 같은 형태로 전역 상수가 자동으로 생성되어 widget에서 바로 쓸 수 있게 된다. 항상 이 방식이 정답은 아니긴 하다. 직접 다 작성하는 것과 이 방식을 사용하는 것은 다음과 같은 장단점이 있으니 프로젝트 성격에 따라 결정하면 된다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">비교 항목</th>
<th align="center">수동 작성 방식</th>
<th align="center"><code>@riverpod</code> 코드 생성 방식</th>
</tr>
</thead>
<tbody><tr>
<td align="center">작성 방식</td>
<td align="center"><code>AsyncNotifierProvider&lt;VM, State&gt;(...)</code> 직접 타이핑</td>
<td align="center"><code>@riverpod</code> annotation + <code>_$class명</code> extend</td>
</tr>
<tr>
<td align="center">개발 워크플로우</td>
<td align="center">코드 작성 후 즉시 저장 ➔ 핫 리로드 바로 반영</td>
<td align="center">저장 후 <code>build_runner</code>가 <code>.g.dart</code> 생성할 때까지 대기</td>
</tr>
<tr>
<td align="center">파일 관리</td>
<td align="center"><code>home_viewmodel.dart</code> 단 1개 파일로 깔끔</td>
<td align="center">원본 파일 외에 <code>*.g.dart</code> 생성 파일이 파일트리에 계속 추가됨</td>
</tr>
<tr>
<td align="center">코드 투명성</td>
<td align="center">모든 동작(타입, 인스턴스 생성 등)이 코드에 직접 보여 추적과 디버깅이 쉬움</td>
<td align="center">내부 로직이 생성된 파일 뒤로 숨겨져 있어 초기 진입 장벽 존재</td>
</tr>
<tr>
<td align="center">의존성 &amp; 도구</td>
<td align="center"><code>flutter_riverpod</code> 기본 패키지만 있으면 됨</td>
<td align="center"><code>riverpod_annotation</code>, <code>riverpod_generator</code>, <code>build_runner</code> 필요</td>
</tr>
<tr>
<td align="center">Family (동적 인자)</td>
<td align="center"><code>family</code> 문법을 직접 작성해야 함 (약간의 번거로움)</td>
<td align="center">메서드 파라미터만 넘기면 알아서 Family 자동 생성</td>
</tr>
<tr>
<td align="center">빌드 충돌 리스크</td>
<td align="center">없음 (순수 Dart 코드)</td>
<td align="center">가끔 빌드 캐시 꼬임 발생 시 <code>--delete-conflicting-outputs</code> 재실행 필요</td>
</tr>
</tbody></table>
<hr>
<blockquote>
</blockquote>
<p>Gemini 응답을 기반으로 학습하였음을 밝힙니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 33 | 개인 프로젝트 — Firebase & Riverpod config (+개발자 계정)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-33</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-33</guid>
            <pubDate>Tue, 22 Sep 2026 07:15:46 GMT</pubDate>
            <description><![CDATA[<h1 id="day-33--개인-프로젝트--firebase--riverpod-config-개발자-계정">DAY 33 | 개인 프로젝트 — Firebase &amp; Riverpod config (+개발자 계정)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#29 [config] Firebase 설정</li>
<li>#30 [use-case] CompleteSessionUseCase</li>
<li>#15 [state] Riverpod 의존성 설정 및 Repository Provider 구성</li>
<li>+) 개발자 계정 설정</li>
</ul>
<hr>
<h2 id="29-config-firebase-설정">#29 [config] Firebase 설정</h2>
<p>이제 Model도 확정되었고, Service에 의해 어떤 복합 색인을 추가해야 하는지도 확정되었으니 Firebase 설정을 하도록 하겠다. 프로젝트 이름은 중복이 없으니 <code>move-sketch</code> 를 그대로 사용한다.</p>
<h3 id="firebase-authentification">Firebase Authentification</h3>
<p>좌측 메뉴의 [보안 &gt; Authentification] 에서 [시작하기]를 눌러 로그인 제공업체로 [이메일/비밀번호]를 선택하여 추가한다. [Google] 및 [Apple]도 추가해야 하지만 그것은 기능 구현이 완성된 후에 확장하기로 하였으므로 그때 가서 추가하도록 하겠다.</p>
<h3 id="cloud-firestore">Cloud Firestore</h3>
<p>좌측 메뉴의 [데이터베이스 및 스토리지 &gt; Firestore]에서 [데이터베이스 만들기]를 눌러 [Standard 버전]을 선택하고 region을 [asia-northeast3 (서울)]으로 선택한다. 보안 규칙은 기본값인 [프로덕션 모드에서 시작]으로 한다. 잠시 후에 보안 규칙을 설정할 것이다.</p>
<h3 id="firebase-storage">Firebase Storage</h3>
<p>좌측 메뉴의 [데이터베이스 및 스토리지 &gt; Storage]에서 [시작하기]를 눌러 [모든 위치]의 [ASIA-NORTHEAST3 / 표준]을 선택한다. 여기서도 보안 규칙은 [프로덕션 모드에서 시작]으로 한다.</p>
<p>무료 범위 내에서 사용할 거라도 Blaze 요금제로 프로젝트를 업그레이드 해야 한다는 것을 유의하자.</p>
<h3 id="flutter-앱에-firebase-추가">Flutter 앱에 Firebase 추가</h3>
<p>터미널에서 다음과 같이 입력하여 FlutterFire CLI를 설치 및 활성화한다. 처음이라면 환경변수와 관련된 경고가 뜨겠지만 우리는 <a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-19">실습</a> 때 환경변수 설정을 했으므로 문제 없다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">dart pub global activate flutterfire_cli</code></pre>
<p>그리고 프로젝트 루트에서 다음 명령어를 통해 프로젝트를 연동한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">flutterfire configure --project=move-sketch</code></pre>
<p>Flutter 프로젝트를 생성할 때 선택했던 플랫폼들이 자동으로 체크되어 있으니 그대로 엔터를 치면 된다. 프로젝트 연동이 완료되면 <code>lib/firebase_options.dart</code> 파일이 생성되는데, 여기에 적힌 apiKey, appId는 Secret Key가 아니라, 클라이언트 앱이 Firebase 프로젝트를 식별하기 위한 공개 식별자이므로 <code>.gitignore</code> 하지 않고 Git에 커밋하는 것이 Flutter의 표준 관례라고 한다. 설정에 따라 <code>.gitignore</code> 에 관성적으로 들어가 있기도 한데 CI/CD 측면에서 이 녀석도 같이 commit 해주는 게 좋기 때문에 <code>.gitignore</code> 에 포함되어 있다면 제외하도록 하자.</p>
<p><code>lib/main.dart</code> 에 Firebase 초기화 코드를 추가하면 설정이 완료된다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:firebase_core/firebase_core.dart&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:intl/date_symbol_data_local.dart&#39;;
import &#39;firebase_options.dart&#39;;
import &#39;routing/app_router.dart&#39;;
import &#39;ui/core/theme/theme.dart&#39;;
&gt;
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
&gt;
  // Firebase 초기화
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
&gt;
  await initializeDateFormatting(&#39;ko&#39;, null);
  runApp(const MoveSketchApp());
}
&gt;
class MoveSketchApp extends StatelessWidget {
  const MoveSketchApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
      title: &#39;무브스케치&#39;,
      theme: MoveSketchTheme.lightTheme,
    );
  }
}</code></pre>
<h3 id="복합-색인-등록">복합 색인 등록</h3>
<p>다중 조건 검색이나 정렬이 결합된 쿼리는 복합 색인을 필요로 한다. 좌측 메뉴의 [데이터베이스 및 스토리지 &gt; Firestore]에서 [색인] 탭을 눌러 [색인 만들기]를 한다. [구조화된 색인 만들기]를 눌러 적절한 색인을 생성한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/27f2ad0c-0408-4cae-acca-2dab5531e2d6/image.png" alt=""></p>
<p>혹은 애뮬레이터/시뮬레이터로 앱을 실행하는 동안 콘솔에 뜨는 URL을 통해서도 등록할 수 있지만, 나는 이렇게 미리 등록해 놓는 것을 선호한다.</p>
<h3 id="보안-규칙-적용">보안 규칙 적용</h3>
<p>Firestore 및 Storage에 각각의 보안 규칙을 적용해야 한다.</p>
<p>Firestore 보안 규칙은 collection별로 지정한다. 아이디와 UID, 이메일을 매핑하는 <code>/account_ids/{username}</code> 문서는 특정 아이디 1건의 존재 여부 및 이메일 조회를 위한 GET은 모두에게 열어놓지만 전체 내용을 읽어오는 LIST는 허용하지 않고, 본인에 해당하는 문서만 생성하거나 삭제할 수 있도록 한다.</p>
<p>사용자 프로필을 담은 <code>/users/{userId}</code> 는 로그인한 모든 사용자에게 읽기 권한이 있고 쓰기 및 삭제 권한은 본인에게만 있다. 개인 세션 결과를 담은 <code>/session_results/{resultId}</code> 는 오직 본인만 읽고 쓰고 삭제할 수 있으며, 이를 바탕으로 스케치를 공유해야 <code>/sketch_posts/{sketchId}</code> 에 작성되어 로그인한 사용자가 읽을 수 있게 된다. 응원이나 댓글 같은 것만 다른 사용자에게 수정 권한이 부여된다.</p>
<p>이런 식으로 모든 collection에 대해 권한을 지정한다.</p>
<p>Storage 보안 규칙에는 그곳에 저장한 이미지 파일들에 대한 규칙이 들어간다. 각 사용자는 자신의 디렉토리 내의 파일만 수정 및 삭제 가능하다. 공유되지 않고 개인 세션 결과 기록에만 남는 이미지는 읽기 권한도 자신에게만 부여하며, 공유 가능한 이미지는 로그인한 사용자 전체에게 읽기 권한을 부여한다.</p>
<p>권한이 잘못 지정될 경우 필요 이상의 권한이 부여되어 보안 상의 허점이 되거나 반대로 필요한 권한도 충분히 부여되지 않아 앱 사용 중 Permission Denied가 뜰 수 있으므로 유의하도록 하자. 이를 방지하기 위해 AI를 통해 코드베이스 전수 대조와 모의 보안 감사를 요청하여 최대한 빈틈을 매울 수 있도록 하였다.</p>
<h2 id="30-use-case-completesessionusecase">#30 [use-case] CompleteSessionUseCase</h2>
<p><a href="https://docs.flutter.dev/app-architecture">Flutter App Architecture 공식 문서</a>를 처음 읽었을 때 그래서 이게 뭐지 했던 부분 중 하나다. 사실 Service가 뭐고 Repository가 뭔지 명확히 파악하지 못한 채 프로젝트를 시작했다. 직접 작성하다 보니 어느 정도 감이 잡히긴 하더라. 처음부터 알고 설계했으면 좀 더 체계적으로 개발할 수 있었을 것 같기도 하고.</p>
<p>UseCase는 간단히 말해, 하나의 구체적인 비즈니스 행위다. 둘 이상의 Repository를 조합해서 사용하는 비즈니스 로직이 많아질수록 UseCase를 사용하지 않는다면 ViewModel이 너무 많은 책임을 지게 만들어 코드가 기하급수적으로 비대해진다.</p>
<p>ViewModel의 책임을 줄이겠다고 Repository끼리 서로 참조하게 될 경우 순환 참조가 발생하거나 코드가 꼬일 수 있다. 피해야 할 anti-pattern 중 하나다.</p>
<p>Repository와 1대1로 깔끔하게 소통 가능한 화면에 대해서는 UseCase를 만드는 게 boilerplate로 작용한다. ViewModel에서 UseCase만 사용하기로 의사결정을 했다면 그렇게 작성할 수도 있긴 하지만 단순 CRUD는 ViewModel이 Repository를 직접 주입받아 호출하는 게 깔끔하다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">UseCase를 사용해야 하는 경우</th>
<th align="center">UseCase를 쓰지 말아야 하는 경우</th>
</tr>
</thead>
<tbody><tr>
<td align="center">작업 형태</td>
<td align="center">복합 오케스트레이션 / 복잡한 규칙</td>
<td align="center">단순 CRUD</td>
</tr>
<tr>
<td align="center">참조 리포지토리</td>
<td align="center">2개 이상의 리포지토리가 협력할 때</td>
<td align="center">1개의 리포지토리만 호출할 때</td>
</tr>
<tr>
<td align="center">코드 예시</td>
<td align="center">세션 종료 시 로컬 DB 완료 + 서버 업로드 + 로컬 정리</td>
<td align="center">단순히 <code>userRepository.getUser(id)</code> 만 호출</td>
</tr>
<tr>
<td align="center">이유</td>
<td align="center">비즈니스 조율 로직이 명확히 존재하므로 분리 가치가 높음</td>
<td align="center">단순 전달용 껍데기 클래스만 늘어나 복잡도 증가</td>
</tr>
<tr>
<td align="center">아키텍처 흐름</td>
<td align="center">ViewModel ➔ UseCase ➔ Repositories</td>
<td align="center">ViewModel ➔ Repository (직접 호출)</td>
</tr>
</tbody></table>
<p>내 프로젝트의 경우 대부분의 것들은 UseCase를 굳이 필요로 하지 않지만 세션 종료에 한해서 <code>SessionRepository</code> 와 <code>SessionResultRepository</code> 가 함께 사용된다. 로컬 Drift DB와 원격 Firebase DB가 만나는 지점이라고 할 수 있겠다.</p>
<p>UseCase를 만들지 않는 걸로 의사결정을 했다면 ViewModel에서 장황한 코드를 작성하는 것도 방법이지만, UseCase에 대한 이해가 떨어져서 초기 기획에서 UseCase로 빼지 못했던 것뿐이므로 뒤늦게라도 issue를 추가하여 작업에 포함시켰다.</p>
<p>Dart 3에서 패턴 매칭이 추가되었다는데 문법은 조금 다르지만 대략적인 느낌은 Rust에서 복구 가능한 에러를 처리하던 방식이랑 비슷해서 사람들이 구현해 놓은 예제를 몇 개 보니 금방 이해할 수 있었다.</p>
<p>당장은 세션 업로드 시도 직후에만 업로드 재시도를 할 수 있게 하기로 했다. 일시적인 네트워크 오류에 대한 대처일 뿐, 완전한 오프라인 상태에 대한 대처는 아니다. 오프라인 상태에서 완료 세션이 몇 개 쌓였을 때 앱 실행 시 일괄 업로드 재시도 하는 코드는 초기 배포 후 업데이트 하는 편이 나을 듯하다. 그것도 UseCase로 작성하게 될 듯.</p>
<h2 id="15-state-riverpod-의존성-설정-및-repository-provider-구성">#15 [state] Riverpod 의존성 설정 및 Repository Provider 구성</h2>
<p><a href="https://pub.dev/packages/flutter_riverpod">flutter_riverpod</a> package로 상태 관리를 할 것이다. 그리고 <a href="https://github.com/flutter/samples/tree/main/compass_app">공식 Compass 예제 앱</a>에 따라 관성적으로 추가한 <code>lib/utils/command.dart</code> 는 Riverpod을 사용할 경우 필요 없다는 것을 알게 되었다. 역시 뭐든 알고 해야 제대로 설계할 수 있다. 경험의 중요성을 다시 한 번 느낀다. <a href="https://github.com/flutter/samples/tree/main/compass_app">공식 Compass 예제 앱</a>에서는 <a href="https://github.com/flutter/samples/blob/main/compass_app/app/lib/config/dependencies.dart"><code>lib/config/dependencies.dart</code></a> 에 Provider 코드를 넣었는데, 마찬가지로 나도 이 파일을 생성하여 Riverpod 사용을 준비하기로 했다.</p>
<p><code>lib/di/repository_providers.dart</code> 로도 하고 <code>lib/data/repositories/repository_providers.dart</code> 로도 하고 <code>lib/data/repositories/&lt;feature_name&gt;/&lt;feature_name&gt;_repository.dart</code> 내에 구현하기도 하고 정답은 없는 모양이지만. (마지막 방식이 Riverpod 공식 문서의 방식이라나.)</p>
<blockquote>
<h4 id="ai에-의하면">[AI에 의하면...]</h4>
</blockquote>
<ul>
<li><strong>기존 Provider</strong>: 위젯 트리 결합을 위해 한 파일(<code>dependencies.dart</code>)에 몰아서 리스트 형태로 묶어 선언하는 구조가 필수적이었습니다.</li>
<li><strong>Riverpod</strong>: 결합이 전역 공간에서 유연하게 일어나므로 해당 클래스가 있는 파일에 Provider를 1:1로 쪼개어 선언하고, 만약 환경별(Remote/Local) 교체가 필요하다면 <code>main.dart</code>에서 <code>overrides</code> 옵션을 통해 제어하는 것이 Riverpod 고유의 표준 설계 방식입니다.</li>
</ul>
<p>단순 객체 제공용 <code>Provider&lt;T&gt;</code> 는 한 곳에 모아두고 비동기 상태 관리용 <code>AsyncNotifierProvider</code> 는 각 화면별로 분산시켜 응집도를 높이는 하이브리드 방식도 많이 쓰인다고 한다. 지금 보니까 <a href="https://github.com/flutter/samples/tree/main/compass_app">공식 Compass 예제 앱</a>도 하이브리드 방식이구나. 이걸 따르는 게 좋겠다.</p>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/5d036d6af59a30c7ac077cc65bc58554a04a7eb6">&gt;&gt;&gt; GitHub Repository at this point (5d036d6)</a></p>
</blockquote>
<hr>
<h2 id="개발자-계정-설정">개발자 계정 설정</h2>
<p>아직 앱 개발이 마무리되지는 않았으나 개발자 계정에 대한 신원 인증에 시간이 꽤나 소요되니 미리 준비해 두는 게 좋다고 하더라.</p>
<h3 id="google-play-console">Google Play Console</h3>
<p>절차는 비교적 간단하다. <a href="https://play.google.com/console">Google Play Console</a>에 접속하여 2단계 인증 후 개인 개발자로 계정을 생성한다. (나는 이미 2단계 인증이 되어 있어서 바로 계정 생성으로 넘어갔다.)</p>
<p>PlayStore에서 앱 이름 하단에 표기될 개발자 이름을 정해야 하는데, 이건 나중에 수정 가능하다고 하더라.</p>
<p>그러고 나면 결제 프로필을 만들라고 하는데, 이때 본인의 서류상의 이름 및 주소를 띄어쓰기까지 정확하게 입력해주는 게 심사에 유리하다고 한다. 개발자 계정 생성에는 25달러가 필요한데, 그래도 여긴 구독제가 아니라 한 번 결제하면 추가적인 비용이 들지 않는다.</p>
<p>계정이 생성되면 대시보드에서 본인 인증을 해주어야 한다. 본인 명의로 발행된 전기, 수도 또는 공공요금 청구서나 신용카드 명세서, 은행 명세서, 임대 계약서를 요구하는데, 이건 미국의 행정 서식을 기반으로 한 것이고 국내에서는 주로 정부24에서 발급받는 공적 서류로 승인받는다고 한다. 구글 결제 프로필의 이름과 주소가 한글인지 영문인지에 따라 주민등록등본의 언어도 맞춰서 제출하는 게 좋다고 하더라.</p>
<p>만약 반려된다면 본인 이름 및 주소가 포함되어 있는 체크카드 이용대금명세서나 은행 예금잔액증명서로도 시도해 볼 수 있다고 한다.</p>
<p>Gemini에 의하면 다음과 같은 주의사항을 지켜야 한다더라.</p>
<blockquote>
</blockquote>
<ol>
<li><strong>구글 결제 프로필 주소와 100% 일치 여부</strong>
구글 플레이 콘솔(또는 Google 결제 센터)에 등록된 이름, 도로명 주소, 동·호수, 우편번호가 제출할 서류(등본)의 내용과 글자 하나, 띄어쓰기 하나까지 일치해야 합니다.
만약 프로필 주소가 약칭으로 적혀 있다면, 서류를 제출하기 전에 구글 결제 프로필 주소를 서류상의 공식 도로명 주소와 동일하게 먼저 수정하세요.</li>
<li><strong>화면 캡처(스크린샷) 금지</strong>
컴퓨터 모니터나 스마트폰 화면을 캡처한 이미지는 위변조 방지를 위해 자동으로 거부됩니다. 반드시 발급된 공식 PDF 원본이거나 출력물을 실물 촬영한 사진이어야 합니다.</li>
<li><strong>90일 이내 발급 일자</strong>
서류 하단에 표시된 발급 일자가 최근 90일 이내여야 합니다 (오늘 바로 발급받으시는 것을 권장합니다).</li>
</ol>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/599d2e3e-9ecc-4ce6-893f-2cb0f54d34f6/image.png" alt=""></p>
<p>앱 서명이니 뭐니 하는 것들은 추후에 배포 준비를 하며 하도록 하고 일단 이대로 작업을 마저 하며 신원 확인이 완료되길 기다려 보자. 반려되지 않고 무사히 승인되길 바라며...ㅎ</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/e9bea80f-9831-41dc-a14f-ec447334c324/image.png" alt=""></p>
<p>라고 했는데 최대 48시간이라는 말이 무색하게 12시간도 안 되어 처리되어 있었다. 이메일 온 걸 보니 9시간만에 처리되었군.</p>
<h3 id="apple-developer">Apple Developer</h3>
<p><a href="https://developer.apple.com">Apple Developer</a>에 접속하여 Apple ID로 로그인하여 개발자 등록을 할 수 있다. 이 녀석은 매년 99달러를 가져간다. 개발자 계정마저 구독제라니. 그러니까 양아치 소리를 듣는 거지, 라는 생각이 스쳐 지나간다. 1회 비용도 Google보다 비싼데 심지어 연간 결제야? 하여간 난 <a href="https://sesac.seoul.kr/sesac/board/B000000016/postDetailFormLoad.do?pstSn=5137">장학금</a> 받는 게 있으니 너무 아끼려고 하지 말고 투자해 보기로 했다. SeSAC 안 다녔으면 못 받았을 돈이니 아까워 하지 말자. 한 달치 장학금보다 1년치 Apple 개발자 멤버십이 더 싸다!</p>
<p>여긴 따로 서류를 제출하라는 거 없이 빈 칸만 잘 채워 내면 결제하라고 하면서 잘 흘러간다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/77d49a56-fdbd-457a-bc85-6574916db9f1/image.png" alt=""></p>
<p>구입을 처리하는 데 48시간까지 걸릴 수 있다고 하고 아무튼 <code>(대기 중)</code> 이라고 뜨니 역시 개발 작업이나 이어서 하고 있도록 하자.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/ba7551be-b8cb-4fd8-b79e-70707da70701/image.png" alt=""></p>
<p>는 마찬가지로 몇 시간 뒤에 이미 처리되어 있었다. 이메일 온 걸 보니 70분만에 처리되었군. 확실히 돈을 많이 받아먹어서 그렇지 개발자 계정 만드는 거 자체는 이쪽이 빠르긴 하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 32 | 개인 프로젝트 — 아키텍처 리팩토링 & service/repository (2)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-32</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-32</guid>
            <pubDate>Mon, 21 Sep 2026 10:06:29 GMT</pubDate>
            <description><![CDATA[<h1 id="day-32--개인-프로젝트--아키텍처-리팩토링--servicerepository-2">DAY 32 | 개인 프로젝트 — 아키텍처 리팩토링 &amp; service/repository (2)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#28 [architecture] 아키텍처를 명확히 리팩토링하고 넘어가자</li>
<li>#13 [repository] 세션 및 로컬 DB 관련</li>
<li>#14 [repository] 계정 및 소셜 관련</li>
<li>#27 [model/service/repository] 날씨 API</li>
</ul>
<h2 id="28-architecture-아키텍처를-명확히-리팩토링하고-넘어가자">#28 [architecture] 아키텍처를 명확히 리팩토링하고 넘어가자</h2>
<p>처음에 Flutter 공식 문서의 <a href="https://docs.flutter.dev/app-architecture/case-study">App Archiecture - Case study</a>를 기반으로 아키텍처를 구성해 보려다가, 처음 접해보는 것들에 복잡함을 느껴 일단 손 가는 대로 작업을 시작하였다. 그런데 파일이 많아지며 아키텍처 리팩토링의 필요성을 느껴 공식 문서를 참고하여 아키텍처와 표현들을 정리하고 넘어가기로 했다.</p>
<p>구현을 좀 하고 나서 보니까 공식 문서가 조금 더 읽히기 시작한다. 구현을 마무리짓고 한 번 더 정독해 보면 좋을 것 같다.</p>
<h2 id="13-repository-세션-및-로컬-db-관련">#13 [repository] 세션 및 로컬 DB 관련</h2>
<p>Repository를 본격적으로 구현하기 전에 <code>lib/utils/result.dart</code> 을 작성하기로 했다. Result&lt;T&gt;는 비동기 작업의 성공(Ok)과 실패(Error)를 명시적으로 나타내는 밀폐형 클래스라고 한다. 예외를 ViewModel까지 전파하지 않고 Repository에서 예외 처리를 하며 ViewModel로는 성공과 실패의 두 가지 경우의 수만 전달하는 것이다. Rust에서 예외처리를 하는 방법과 닮아 있어 왠지 친숙하다.</p>
<p>공식 문서에서 인용하는 Compass 앱에서도 <a href="https://github.com/flutter/samples/blob/main/compass_app/app/lib/utils/result.dart"><code>lib/utils/result.dart</code></a>를 사용하고 있다. 이 파일은 BSD 라이선스로 원작자 표기만 하면 상업적 이용을 포함하여 자유롭게 이용할 수 있으므로, 새로 작성하기 보다는 라이선스를 유지한 채 이 파일을 가져다 쓰도록 하겠다.</p>
<p>비동기 액션의 상태와 중복 실행 방지를 하나로 캡슐화한 <code>lib/utils/command.dart</code> 또한 공식 문서에서 인용하는 Compass 앱의 <a href="https://github.com/flutter/samples/blob/main/compass_app/app/lib/utils/command.dart"><code>lib/utils/command.dart</code></a> 를 그대로 사용할 수 있다.</p>
<p>modern Dart 3 convention에서는 Repository 구현체에 <code>final</code> 을 붙여 불필요한 inherit을 방지하고 최종 말단 class임을 명시하는 편이라고 한다. 공식 문서에서 인용하는 Compass 앱은 그렇게 되어 있지 않지만 <code>final</code> 을 붙여서 구현하기로 했다.</p>
<h2 id="14-repository-계정-및-소셜-관련">#14 [repository] 계정 및 소셜 관련</h2>
<p>Flutter에서는 로컬에 연결된 Repository의 구현체는 <code>*_repository_local.dart</code> 라는 이름을 사용하고 원격 DB나 외부 API에 연결된 Repository의 구현체는 <code>*_repository_remote.dart</code> 라는 이름을 사용한다고 한다. 내 프레젝트의 경우 <code>SessionRepositoryLocal</code> 을 제외하고는 전부 <code>Remote</code> 다.</p>
<p>Repository 구현은 Service에서 작성한 코드를 조합하여 적절한 예외처리를 포함하여 작성해 본 후, 보완해야 할 사항이 있는지 AI를 통해 검토해 가며 진행했다. 초반에는 놓치는 부분이 많았는데 몇 개 하다 보니 지적받는 부분이 많이 줄어들었다.</p>
<blockquote>
<h4 id="ai-검토-사례-중-일부">[AI 검토 사례 중 일부]</h4>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/acbf0460-0b93-46e8-879f-7a58e894c0a2/image.png" alt=""></p>
<blockquote>
<h4 id="때로는-나의-의사결정을-피력하며-ai를-설득하고-넘어간다">[때로는 나의 의사결정을 피력하며 AI를 설득하고 넘어간다]</h4>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2b1fb411-146f-43d0-ab0d-b99d5b01372f/image.png" alt=""></p>
<h2 id="27-modelservicerepository-날씨-api">#27 [model/service/repository] 날씨 API</h2>
<p>스케줄을 작성할 때 누락되어 있는 부분이 있었다. ViewModel로 넘어가기 전에 날씨 API와 관련된 작업을 처리하고 가기로 했다.</p>
<p><a href="https://pub.dev/packages/http">http</a> package를 가져오고 Model을 작성하는 것부터 시작한다. 날씨는 세션 기록뿐만 아니라 홈 화면에서도 사용되므로 <code>LocationPoint</code> 와 달리 <code>lib/domain/models/session/</code> 에 넣지 않고 <code>lib/domain/models/weather/</code> 를 새로 생성하겠다.</p>
<p>날씨 API는 <a href="https://openweathermap.org/api">Open Weather Map</a>을 이용하기로 했다. 웹사이트에서 회원가입을 하면 기본적으로 default API key가 발급된다. 우리가 사용할 현재 날씨 데이터에 대한 사용법은 <a href="https://openweathermap.org/api/current">Current Weather data</a>에서 확인할 수 있다. 월 1,000,000회까지 조회 가능한 무료 API다.</p>
<p>API key는 <code>.env</code> 에 담을 수도 있고 <code>env.json</code> 과 같은 임의의 이름을 가진 JSON 파일에 담을 수도 있는데 여기선 <code>env.json</code> 을 사용하도록 하겠다. <code>.gitignore</code> 에 포함되어 있지 않다면 추가하는 걸 잊지 말아야 한다. [Additional run args]에 등록해 주어야 Android Studio에서 이 환경변수를 포함하여 빌드한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/bc4ac8c7-d4c3-4888-84cd-3b905163c64e/image.png" alt=""></p>
<p>Firebase는 <code>FirebaseException</code> 및 <code>FirebaseAuthException</code> 에 예외가 잘 정리되어 있었지만 외부 API를 사용할 경우 예외처리 코드가 길어진다. 하지만 전반적인 흐름은 크게 다르지 않으므로 마찬가지로 Service는 순수하게 외부 통신/SDK 호출만 수행하며 날것의 예외를 그대로 던지고 Repository에서 예외처리하여 <code>Result</code> 로 감싸는 방식을 사용한다.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/3ef401f62e503e697e8bf08816a1b20ac763c84f">&gt;&gt;&gt; GitHub Repository at this point (3ef401f)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 31 |  개인 프로젝트 — model (2) & service/repository (1)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-31</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-31</guid>
            <pubDate>Sat, 19 Sep 2026 23:54:29 GMT</pubDate>
            <description><![CDATA[<h1 id="day-31--개인-프로젝트--model-2">DAY 31 | 개인 프로젝트 — model (2)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#9 [model] 데이터 모델링</li>
<li>#10 [model] 세션 기반 로컬 DB 설계 및 구현</li>
<li>#11 [service] 세션 및 로컬 DB 관련</li>
<li>#12 [service] 원격 DB 관련</li>
</ul>
<h2 id="9-model-데이터-모델링">#9 [model] 데이터 모델링</h2>
<p>해 본 적 없는 작업이다 보니 AI의 도움을 좀 받았다. AI 초안을 보며 내 프로젝트에 맞게 변환하고 그걸 또 AI가 보고 개선할 부분을 제시하고, 내 의도와 다른 부분은 내 의도를 전달하고 차용할 부분은 차용하고. 수정된 코드를 AI가 다시 검토하고 하면서 몇 차례의 상호작용이 있었다.</p>
<p>AI로 딸깍 하는 거면 정말 금방 끝나지만 의사과정이 들어가면 최소 몇 시간에서 하루 정도는 잡아야 하는 것 같다. 경험이 있는 사람이었다면 처음부터 설계 요구사항을 명확히 전달해서 좀 더 수월했겠지만.</p>
<h2 id="10-model-세션-기반-로컬-db-설계-및-구현">#10 [model] 세션 기반 로컬 DB 설계 및 구현</h2>
<blockquote>
<h4 id="ai-응답-중-일부-발췌--로컬-db-설계-시-고려할-점">AI 응답 중 일부 발췌 | 로컬 DB 설계 시 고려할 점</h4>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/f2807f06-8ca7-4403-903a-e0ccddcc6f5a/image.png" alt=""></p>
<p><a href="https://drift.simonbinder.eu/setup/">Drift 공식 문서</a>에 의하면 다음과 같은 dependecy들이 필요하다.</p>
<blockquote>
</blockquote>
<pre><code class="language-yaml">dependencies:
  drift: ^2.35.0
  drift_flutter: ^0.3.1
  path_provider: ^2.1.6
&gt;
dev_dependencies:
  drift_dev: ^2.35.0
  build_runner: ^2.16.1</code></pre>
<p>하지만 다음과 같은 오류가 나므로 <code>build_runner: ^2.15.1</code> 으로 변경하여 진행하도록 하겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">Resolving dependencies...
Note: meta is pinned to version 1.18.0 by flutter_test from the flutter SDK.
See https://dart.dev/go/sdk-version-pinning for details.
&gt;
Because build_runner &gt;=2.15.2 depends on analyzer &gt;=13.3.0 &lt;15.0.0 which depends on meta ^1.18.3, build_runner &gt;=2.15.2 requires meta ^1.18.3.
And because every version of flutter_test from sdk depends on meta 1.18.0, build_runner &gt;=2.15.2 is incompatible with flutter_test from sdk.
So, because move_sketch depends on both flutter_test from sdk and build_runner ^2.16.1, version solving failed.
&gt;
You can try the following suggestion to make the pubspec resolve:
* Consider downgrading your constraint on build_runner: flutter pub add dev:build_runner:^2.15.1
Failed to update packages.
Process finished with exit code 1</code></pre>
<blockquote>
<h4 id="여담-공식문서-내용과-ai의-가이드라인이-다르길래-물어보니">[여담: 공식문서 내용과 AI의 가이드라인이 다르길래 물어보니...]</h4>
<p>공식 문서를 아주 잘 확인하셨습니다! <strong>공식 문서에 적힌 구성이 최신 Drift에서 권장하는 &quot;가장 최신이자 정석 방식&quot;이 맞습니다.</strong></p>
</blockquote>
<p>제가 처음에 안내해 드렸던 방식과 공식 문서의 방식 사이에 <strong>무엇이 달라졌는지</strong>, 그리고 <strong>왜 공식 문서의 방식이 훨씬 좋은지</strong> 핵심을 짚어드리겠습니다.</p>
<blockquote>
</blockquote>
<hr>
<blockquote>
</blockquote>
<h3 id="무엇이-다른가요-drift_flutter의-등장">무엇이 다른가요? (<code>drift_flutter</code>의 등장)</h3>
<blockquote>
</blockquote>
<p>과거의 Drift는 Flutter 전용 패키지가 따로 없어서, SQLite를 연결하려면 개발자가 직접 여러 패키지를 조합해야 했습니다:</p>
<ul>
<li><strong>과거 방식</strong>: <code>sqlite3_flutter_libs</code> + <code>path_provider</code> + <code>path</code> 패키지를 설치한 뒤, <code>getApplicationDocumentsDirectory()</code>로 파일 경로를 찾고, <code>NativeDatabase.createInBackground(file)</code>을 호출하는 등 <strong>10줄이 넘는 보일러플레이트 연결 코드</strong>를 직접 작성해야 했습니다.</li>
<li><strong>최신 공식 방식 (<code>drift_flutter</code>)</strong>: Drift 팀에서 Flutter 개발자를 위해 이 번거로운 연결 과정을 자동화한 전용 헬퍼 패키지인 <strong><code>drift_flutter</code></strong>를 새롭게 출시했습니다.<blockquote>
</blockquote>
</li>
</ul>
<hr>
<blockquote>
</blockquote>
<h3 id="공식-문서-방식drift_flutter을-쓰면-코드가-어떻게-바뀌나요">공식 문서 방식(<code>drift_flutter</code>)을 쓰면 코드가 어떻게 바뀌나요?</h3>
<blockquote>
</blockquote>
<p>이전의 복잡했던 <code>_openConnection()</code> 함수가 <strong>단 한 줄</strong>로 줄어듭니다!</p>
<blockquote>
</blockquote>
<h4 id="기존-과거-방식">기존 (과거 방식):</h4>
<pre><code class="language-dart">// 직접 파일 경로 찾고 백그라운드 스레드 열고... 번거로움
LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, &#39;move_sketch.sqlite&#39;));
    return NativeDatabase.createInBackground(file);
  });
}</code></pre>
<blockquote>
</blockquote>
<h4 id="최신-공식-방식-drift_flutter-사용">최신 공식 방식 (<code>drift_flutter</code> 사용):</h4>
<pre><code class="language-dart">import &#39;package:drift/drift.dart&#39;;
import &#39;package:drift_flutter/drift_flutter.dart&#39;; // 헬퍼 import
&gt;
@DriftDatabase(tables: [...], daos: [...])
class AppDatabase extends _$AppDatabase {
  // driftDatabase(name: &#39;...&#39;) 한 줄로 연결 끝!
  AppDatabase() : super(driftDatabase(name: &#39;move_sketch&#39;));
&gt;
  @override
  int get schemaVersion =&gt; 1;
}</code></pre>
<blockquote>
</blockquote>
<hr>
<blockquote>
</blockquote>
<h3 id="drift_flutter가-자동으로-해주는-일"><code>drift_flutter</code>가 자동으로 해주는 일</h3>
<blockquote>
</blockquote>
<ol>
<li><strong>저장 위치 자동 선정</strong>: Android, iOS, macOS 등 각 OS의 표준 문서 디렉터리를 알아서 찾아 안전하게 DB 파일을 생성합니다.</li>
<li><strong>자동 백그라운드 실행(Isolate)</strong>: DB 쿼리가 무거워져도 앱 UI(화면)가 버벅이지 않도록, 자동으로 백그라운드 스레드(Isolate)에서 DB 작업을 처리합니다.</li>
<li><strong>의존성 간소화</strong>: <code>sqlite3_flutter_libs</code> 같은 플랫폼별 네이티브 바이너리 패키지를 <code>drift_flutter</code>가 내부적으로 알아서 관리해 줍니다.<blockquote>
</blockquote>
따라서 사용자분께서 찾아보신 <strong>공식 문서의 의존성(<code>drift</code>, <code>drift_flutter</code>, <code>path_provider</code> 등)을 사용하시는 것이 훨씬 현대적이고 깔끔한 정답</strong>입니다!</li>
</ol>
<p>무슨 차이인지 궁금해서 확인하며... Flutter 관련 자료를 찾아보면 버전 2 내용이 많이 섞여 있다거나 과거의 유산이 많이 보이며 특히 AI 답변은 과거인지 현재인지 구분하지 않고 섞어서 설명하는 경향이 있으니 주의하라는 말을 언젠가 강사님이 하신 적 있는데 새삼 실감했다.</p>
<h2 id="11-service-세션-및-로컬-db-관련">#11 [service] 세션 및 로컬 DB 관련</h2>
<p>스케줄을 짤 땐 설계가 마무리되기 전이라 대략 적어놓았는데 위치 정보도 결국엔 로컬 DB 관련에 포함되니 같이 가는 게 낫겠다. 사실 앞의 로컬 DB 설계도 service/repository로 넘어오기 전 model 단계에서는 따로 진행할 거 없이 issue#9에 통합되어도 무방한 영역이었다.</p>
<p>역시 프로젝트도 해봐야 전체적인 게 그려지는구나 싶다.</p>
<h3 id="libserviceslocal"><code>lib/services/local</code></h3>
<p>다음과 같은 구조로 되어 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">lib/services/local/
├── app_database.dart
├── tables/
│   ├── tracking_sessions_table.dart
│   ├── location_points_table.dart
│   └── mission_instances_table.dart 
├── daos/
│   └── session_dao.dart
└── mappers/
    └── session_mapper.dart</code></pre>
<p><code>lib/services/local/tables/</code> 에 데이터베이스 스키마를 작성하고 이를 <code>lib/services/local/app_database.dart</code> 를 통해 등록한다. 데이터베이스에 대한 접근은 <code>lib/services/local/dao/session_dao.dart</code> 에서 관리한다. <code>lib/services/local/matters/session_mapper.dart</code> 에서는 이들을 하나의 domain으로 묶어준다.</p>
<p>데이터베이스 스키마를 작성하는 건 SQL을 다뤄본 적 있다면 누구나 쉽게 할 수 있을 것 같다. 문법만 Dart 문법일 뿐 형태는 익숙하다.</p>
<p>하라는 대로 했는데 테이블까지는 문제 없었지만 나머지 파일들은 작성 중 빨간 줄이 많이 보여서 뭐지 싶었다. 확인해 보니 Drift가 자동으로 만들어주는 것들을 아직 생성하지 않았기 때문에 그런 거라고 하더라. <code>part</code> 로 시작하는 줄은 반드시 파일명과 일치시켜야 한다고 하고.</p>
<p>터미널에서 다음 명령어를 사용하면 필요한 파일들이 자동 생성되어 빨간 줄이 사라진다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">dart run build_runner build</code></pre>
<p>생성된 <code>app_database.g.dart</code> 에 파일 import와 관련된 에러가 뜬다면 <code>app_database.g.dart</code> 에 직접 넣어주지 말고(!) <code>app_database.dart</code> 에 추가해야 한다.</p>
<h3 id="librepositorysession"><code>lib/repository/session</code></h3>
<p>우선 abstract class를 <code>lib/repositories/session/session_repository.dart</code> 에 작성해 놓고, 실제 구현 작업은 Service를 전체적으로 마무리한 후에 하도록 하겠다.</p>
<h3 id="libservicehardware"><code>lib/service/hardware</code></h3>
<p>기기의 물리적 GPS 센서를 사용하기 위해 <a href="https://pub.dev/packages/geolocator">geolocator</a> package가 필요하다. 버전은 <code>^14.0.3</code> 로 넣어주겠다.</p>
<p>화면이 꺼져도 GPS가 끊기지 않아야 하므로 관련 권한을 주어야 한다. 안드로이드는 <code>android/app/src/main/AndroidManifest.xml</code> 에서 설정할 수 있다.</p>
<blockquote>
<p><code>android/app/src/main/AndroidManifest.xml</code></p>
</blockquote>
<pre><code class="language-xml">&lt;manifest xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;&gt;
    &lt;uses-permission android:name=&quot;android.permission.ACCESS_FINE_LOCATION&quot; /&gt;
    &lt;uses-permission android:name=&quot;android.permission.ACCESS_COARSE_LOCATION&quot; /&gt;
    &lt;uses-permission android:name=&quot;android.permission.FOREGROUND_SERVICE&quot; /&gt;
    &lt;uses-permission android:name=&quot;android.permission.FOREGROUND_SERVICE_LOCATION&quot; /&gt;
    &lt;uses-permission android:name=&quot;android.permission.WAKE_LOCK&quot; /&gt;
&lt;!-- 이하 생략 --&gt;</code></pre>
<p>iOS의 경우 Xcode를 열어 GUI로 설정할 수도 있고 <code>ios/Runner/Info.plist</code> 에 직접 작성해 주어도 된다.</p>
<blockquote>
<p><code>ios/Runner/Info.plist</code></p>
</blockquote>
<pre><code class="language-plist">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot; &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;NSLocationWhenInUseUsageDescription&lt;/key&gt;
    &lt;string&gt;조깅과 라이딩 경로 및 운동 거리를 측정하기 위해 위치 권한이 필요합니다.&lt;/string&gt;
    &lt;key&gt;NSLocationAlwaysAndWhenInUseUsageDescription&lt;/key&gt;
    &lt;string&gt;화면이 꺼진 상태에서도 운동 경로를 정확하게 기록하기 위해 백그라운드 위치 권한이 필요합니다.&lt;/string&gt;
    &lt;key&gt;UIBackgroundModes&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;location&lt;/string&gt;
    &lt;/array&gt;
&lt;!-- 이하 생략 --&gt;</code></pre>
<p><code>lib/services/hardware/location_service.dart</code> 에서는 위치 좌표를 적절히 변형하여 <code>LocationPoint</code> model의 형태로만 내보낸다. 정확도 오차가 너무 큰 경우 GPS 오차로 취급하여 필터링을 하기로 했다.</p>
<h2 id="12-service-원격-db-관련">#12 [service] 원격 DB 관련</h2>
<p>로컬 DB는 Drift를 사용했고 원격 DB는 Firebase를 사용할 것이다. 우리는 Firebase Auth, Firestore, Storage를 사용할 것이므로 이에 대한 pakcage를 각각 준비한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-yaml">firebase_core: ^4.15.0
firebase_auth: ^6.7.0
cloud_firestore: ^6.10.0
firebase_storage: ^13.6.0</code></pre>
<h3 id="firebase-auth">Firebase Auth</h3>
<p>현재 로그인한 사용자가 누구인지를 다루는 영역이다. 로그인된 사용자를 식별하고, 회원가입과 로그인, 로그아웃을 담당한다.</p>
<p>소셜 로그인 버튼은 있지만 일단은 이메일로 로그인만 구현하고 소셜 로그인은 보류해 두겠다.</p>
<p>Firebase는 소셜 로그인이 아닌 자체 로그인을 이메일로 로그인만 지원하지만 나는 아이디로 로그인을 넣고 싶어서 이와 관련된 추가 작업을 했다. 아이디를 key로 하고 이메일과 메타데이터를 value로 갖는 <code>account_ids/{id}</code> 문서를 따로 저장해 놓고 가입 또는 로그인 시도하는 아이디로 <code>GET</code> 하여 (전체 목록이 아니라 해당 아이디에 대한 문서만 읽어온다) 문서의 존재 여부로 가입 여부를 판단하는 것이다.</p>
<p>가입할 때는 이 문서가 존재하지 않은 경우에만 허용해주며, 로그인 할 때도 이 문서를 확인해서 문서가 존재하는 경우에만 이메일 정보를 뽑아와서 내부적으로 이메일로 로그인을 시도한다.</p>
<p>아이디 뒤에 임의의 도메인을 붙여 가상의 이메일로 가입시키는 방법도 있지만 이메일 인증이 복잡해져서 아이디 문서를 따로 만드는 편이 더 낫다고 판단했다.</p>
<p>이로서 사용자 프로필에는 사용자가 선택해서 지은 가변적인 이름과 아이디만 노출될 뿐, 이메일은 시스템상으로만 존재하며 계정 관리에만 사용되도록 할 수 있다.</p>
<h3 id="cloud-firestore">Cloud Firestore</h3>
<p>스케치 이미지를 서버에 올리고 접근할 수 있는 URL을 return하는 녀석이다. 서버에 올려야 할 이미지는 사용자 프로필 이미지, 세션 종료 후 생성되는 스케치 이미지, 그리고 기록 탭에서 확인할 수 있는 해당 세션의 이동 경로 이미지 정도가 있겠다.</p>
<p>스케치 이미지나 이동 경로 이미지는 Session ID가 붙어서 상관 없지만, 프로필 이미지를 올릴 때 그냥 올릴 경우 파일 이름이 같을 경우 캐시 갱신과 관련된 버그가 있을 수 있다. 따라서 타임스탬프를 붙여 주도록 하겠다.</p>
<p>이미지 삭제는 <code>deleteFileByUrl()</code> 하나로 수행한다. 삭제 권한에 대한 검증은 Repository에서 1차로 수행하고, 서버에서 파일경로상의 userId와 삭제 요청자의 userId를 비교해서 2차로 수행할 것이다.</p>
<h3 id="firebase-storage">Firebase Storage</h3>
<p>Firebase Storage와 관련된 Service는 <code>lib/serivices/remote/firestore</code> 디렉토리 내에 collection 별로 작성하기로 했다.</p>
<p>처음에는 특정 아이디가 사용 중인지 여부를 <code>AuthService</code> 에서 확인하여 가입 가능 여부를 판단하도록 작성했는데, 그렇게 하면 <code>AuthService</code> 가 두 가지 Firebase SDK에 종속되기도 하고, Repository에서 예외처리를 하기로 했던 만큼 의사결정 처리를 Repository에서 하는 게 적절한 것 같은데 Service단에 필요 이상의 의사결정 과정이 포함되더라. 그래서 사용 중인 아이디인지 여부는 <code>UserService</code> 에서 판단하고 <code>AuthService</code> 에서는 이메일로 로그인만 제공하며 이들을 조합하여 아이디로 로그인을 구현하는 건 <code>AuthRepository</code> 에서 하기로 했다.</p>
<p>피드 게시물 읽어오는 게 생각보다 복잡했다. 내 프로젝트에서는 모든 게시물이 친구 공개인데, 친구 목록에 있는 사용자가 작성한 글만 읽어오려 했더니 Firestore의 <code>whereIn</code> 연산자는 한 번에 30개의 ID까지만 지원한다.</p>
<p>AI에게 조언을 구하니 친구가 30명 이하일 경우와 30명을 초과할 경우를 나누어 로직을 구성하라고 하더라. 개인 운동 기록 앱이기 때무에 친구 수가 엄청나게 많은 경우게 edge-case일 것이니 항상 30명을 초과할 것을 상정할 필요가 없다고.</p>
<p>30명을 넘어설 경우에는 <code>Future.wait()</code> 을 통해 청크 분할 병렬 쿼리를 수행할 것을 추천하더라. 페이지네이션에 따라 limit만큼씩 불러와서 처리하고 결과 목록을 다시 limit만큼 자르라고 하길래 그러면 자른 만큼 읽기 토큰이 낭비되는 거 아니냐고 물어 보았다. 필요 이상으로 많은 걸 한 번에 읽으려고 하면 로딩 속도도 느리고 토큰 낭비도 될 수 있기 때문에 limit을 두고 페이지네이션을 하는 건데 이왕 읽어온 걸 칼 같이 자르면 좀 주객전도 같다고 느껴졌다. 그런 경우에는 캐싱을 해도 되고 그냥 다 return 해줘도 괜찮다고 응답 받았다. 나는 캐싱을 하는 건 살짝 오버엔지니어링인 것 같고, 다 return하기로 했다.</p>
<p>댓글을 적절한 순서로 불러오는 것도 좀 난해했는데, 이것도 결국 AI의 도움을 받았다. 시간 순서대로 하면 답글이 제대로 된 위치에 달리지 않을 수 있는 이슈였다. 내 프로젝트에서는 댓글의 답글의 답글 같은 건 없고 부모 아니면 자식의 두 계층만 있는데, 이를 활용하여 댓글(부모)과 답글(자식)을 나눴다.</p>
<p>시간 순으로 가져온 댓글 목록에서 부모를 따로 List에 담고 자식도 &lt;부모ID,자식리스트&gt;로 담아서 마지막에 부모를 최종 댓글 목록에 넣으며 사이사이에 자식을 끼워넣는 방식이다.</p>
<blockquote>
<p><code>lib/services/remote/firestore/sketch_post_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
 final parentComments = &lt;Comment&gt;[];
    final repliesByParentId = &lt;String, List&lt;Comment&gt;&gt;{};
&gt;
    for (final comment in comments) {
      if (comment.parentCommentId == null || comment.parentCommentId!.isEmpty) {
        parentComments.add(comment);
      } else {
        repliesByParentId
            .putIfAbsent(comment.parentCommentId!, () =&gt; [])
            .add(comment);
      }
    }
&gt;
    final organized = &lt;Comment&gt;[];
    for (final parent in parentComments) {
      organized.add(parent);
      if (repliesByParentId.containsKey(parent.id)) {
        organized.addAll(repliesByParentId[parent.id]!);
      }
    }
// 뒷 부분 생략</code></pre>
<p>Service를 구현하다 보니 Model의 결함도 종종 발견되어 함께 수정해 주었다. 그리고 아키텍처를 한 번 리팩토링 해줘야 할 것 같은데. Service 구현 마무리하고 Repository 넘어가기 전에 처리해 주기로 했다.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/59eb4d54e09edb2e00618542402266c5efae89f1">&gt;&gt;&gt; GitHub Repository at this point (59eb4d5)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 30 |  개인 프로젝트 — view (4) & model (1)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-30</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-30</guid>
            <pubDate>Wed, 16 Sep 2026 03:21:46 GMT</pubDate>
            <description><![CDATA[<h1 id="day-30--개인-프로젝트--view-4--model-1">DAY 30 | 개인 프로젝트 — view (4) &amp; model (1)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#20 [fixme] 게시물 편집 도중 탭 전환 이슈</li>
<li>#21 [fixme] 테마 일관성 리팩토링</li>
<li>#22 [view] 누락된 기능 추가</li>
<li>#9 [model] 데이터 모델링</li>
</ul>
<p>일단 적어만 놓고 미뤄 두었던 issue들을 처리해 보자.</p>
<h2 id="20-fixme-게시물-편집-도중-탭-전환-이슈">#20 [fixme] 게시물 편집 도중 탭 전환 이슈</h2>
<p>게시물 편집 화면의 route를 <code>/feed/post/:sketch_id/edit</code> 과 같이 두었더니 게시물 편집 도중 탭 전환이 가능한 이슈가 있었다. 이 경우 메모리 이슈로 편집하던 내용이 날아갈 수도 있고, 피드 탭에서 편집하다가 나 탭에서 편집하려고 할 수도 있고 문제의 여지가 있다.</p>
<p>따라서 편집 화면을 <code>/edit/:sketch_id</code> route로 따로 빼고 편집하기 버튼 클릭 시 <code>context.go()</code> 가 아닌 <code>context.push()</code> 로 화면을 쌓았다가 편집 취소 또는 저장 시 <code>context.pop()</code> 하여 원래 페이지로 돌아가는 방식으로 수정하였다.</p>
<h2 id="21-fixme-테마-일관성-리팩토링">#21 [fixme] 테마 일관성 리팩토링</h2>
<p>작업 도중 테마 파일이 일부 수정되면서 불필요한 코드가 좀 생겼다. 그 외에도 놓친 일관성 부분을 정리하고 넘어가기로 했다. 정리하다 보니 모듈화를 하는 게 좋을 것 같은 중복 코드도 발견되어서 새로운 widget도 몇 개 만들었다.</p>
<p>정적 분석에서 걸리는 것들도 좀 있더라. Rust 하던 습관 때문에 일단 private으로 하고 보던 것들이 Flutter 표준하고는 거리가 멀다는 경고에 대해 받아들이기로 했다. Flutter 표준에 익숙해지는 게 좋겠지.</p>
<p>그 외에도 &quot;Flutter에서는 주로 이렇게 한다&quot; 하는 것들을 좀 더 반영해 보았다.</p>
<h2 id="22-view-누락된-기능-추가">#22 [view] 누락된 기능 추가</h2>
<p>wireframe을 그리고 디자인을 뽑는 과정에서 생각하지 못했던, 누락된 것들이 일부 발견되었다. 가령 피드 게시물은 삭제할 수 있지만 기록 탭에 있는 세션 기록은 삭제하지 못한다거나, 잘못 눌러서 시작한 경우 중단하고 저장하지 않고 종료하는 기능이 없다거나, 의도치 않게 앱이 종료되었을 때 세션 복구를 하는 기능이 없다거나. 신고하기에 신고 사유를 작성해서 제출하는 폼도 있으면 좋을 것 같고. 신고를 개발자가 어떻게 받도록 하는 게 좋으려나.</p>
<p>댓글의 답글 버튼을 눌렀으면 답글을 다는 중이라는 걸 표시해 주는 것도 아직 없다. 알림 일괄 읽음처리 및 지우기도 있어야 할 것 같고. 지금보니까 캘린더에 요일 글자가 조금 잘린다. 이것도 고쳐야지.</p>
<p>세션 종료 버튼을 누르면 바로 종료되는 게 아니라 3초 정도 누르고 있어야 종료되게 하고 싶었는데 그것도 구현이 안 되어 있어서 이번에 처리했다. ElevatedButton으로는 안 되고 GestureDetector를 사용해야 하는데, ElevatedButton의 스타일을 유지하고 싶으면 GestureDetector 안에 ElevatedButton을 넣고 Stack을 쌓는다. 버튼은 작동하지 않고 단지 스타일을 일치시키 위함이다. 애니메이션 적용을 위해 SingleTickerProviderStateMixin을 사용하고 말이다.</p>
<blockquote>
<p><code>lib/widgets/hold_button.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
 return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTapDown: (_) =&gt; _animationController.forward(),
      onTapUp: (_) =&gt; _animationController.reverse(),
      onTapCancel: () =&gt; _animationController.reverse(),
      child: AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          return Stack(
            fit: StackFit.expand,
            children: [
              IgnorePointer(
                child: ElevatedButton(
                  onPressed: () {},
                  clipBehavior: Clip.antiAlias,
                  style: ElevatedButton.styleFrom(
                    splashFactory: NoSplash.splashFactory,
                    padding: EdgeInsets.zero,
                  ),
                  child: const SizedBox.shrink(),
                ),
              ),
              ClipRRect(
                borderRadius: BorderRadius.circular(36),
                child: LinearProgressIndicator(
                  value: _animationController.value,
                  backgroundColor: Colors.transparent,
                  valueColor: AlwaysStoppedAnimation&lt;Color&gt;(
                    colorScheme.primaryContainer,
                  ),
                ),
              ),
              Center(
                child: Text(
                  widget.title,
                  style: textTheme.headlineSmall?.copyWith(
                    color: colorScheme.onPrimary,
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
// 뒷 부분 생략</code></pre>
<h2 id="9-model-데이터-모델링">#9 [model] 데이터 모델링</h2>
<p>model을 작성하는 과정에서는 AI의 도움을 많이 받았다. 설계 경험이 없기 때문이다. &quot;아무튼 만들어 줘&quot;는 아니고 나의 요구사항을 제시하며 샘플 코드를 보여달라고 한 뒤, 그것을 기반으로 내 프로젝트에 맞는 코드를 작성했다.</p>
<p>단순한 영역은 거의 그대로 사용할 수 있었지만 대체로 내 프로젝트에서 사용할 수 있는 형태로 property도 조절하고 변형해야 할 부분이 꽤 있었다. 가령 알림 설정에 대해서 AI는 <code>bool pushNotificationEnabled;</code> 를 사용했지만 우리 프로젝트에서는 전체 알림 설정 하위에 친구 알림, 반응 알림, 리마인더가 있기 때문에 알림 설정을 다루는 model을 추가하여 각각의 설정에 대한 옵션을 담을 수 있게 하였다.</p>
<hr>
<h2 id="여담">여담</h2>
<p>도중에 갑자기 아이폰 시뮬레이터가 사라졌다. 터미널에 다음과 같은 내용이 떠서</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">You have not agreed to the Xcode and Apple SDKs license. You must agree to the license below in order to use Xcode.
Press enter to display the license:</code></pre>
<p>엔터를 누르니 다음 문장으로 끝나는 장문의 텍스트가 출력되었는데</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">Agreeing to the Xcode and Apple SDKs license requires admin privileges, please accept the Xcode license as the root user (e.g. &#39;sudo xcodebuild -license&#39;).</code></pre>
<p><code>sudo xcodebuild -license</code> 를 하니 시뮬레이터가 먹통이 되고 그 어디에서도 시뮬레이터를 찾을 수 없게 되었다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">❯ open -a Simulator                                                            
&gt;
Unable to find application named &#39;Simulator&#39;</code></pre>
<p>완전히 사라진 건 아니고 Xcode에 들어가서 상단 메뉴에서 [Xcode &gt; Open Developer Tool &gt; Device Hub]로 들어가면 기존에 사용하던 시뮬레이터들이 들어 있는 걸 확인할 수 있다.</p>
<p>이걸 찾느라 몇 분 걸렸지만 무사히 찾아냈다.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/2d6fbe640ed277bb980ef6072f67f21a53a143f6">&gt;&gt;&gt; GitHub Repository at this point (2d6fbe6)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 29 | 개인 프로젝트 — view (3)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-29</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-29</guid>
            <pubDate>Sun, 13 Sep 2026 06:29:16 GMT</pubDate>
            <description><![CDATA[<h1 id="day-29--개인-프로젝트--view-3">DAY 29 | 개인 프로젝트 — view (3)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#7 [view] 설정 화면 뼈대 잡기</li>
<li>#8 [view] 로그인 및 회원가입 화면 뼈대 잡기</li>
</ul>
<h2 id="7-view-설정-화면-뼈대-잡기">#7 [view] 설정 화면 뼈대 잡기</h2>
<h3 id="설정-화면-mesettings">설정 화면 (<code>/me/settings</code>)</h3>
<p>설정 화면은 불변 목록이라 쉽게 구현할 수 있다. 하단에 앱 버전을 표기하기로 하였는데 이는 <a href="https://pub.dev/packages/package_info_plus">package_info_plus</a> package를 사용하여 작성할 수 있다. 패키지 정보를 비동기로 읽어오기 때문에 FutureBuilder로 감싸 주어야 한다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
  intl: ^0.20.2
  animated_toggle_switch: ^0.8.7
  uuid: ^4.6.0
  flutter_map: ^8.3.2
  latlong2: ^0.10.1
  table_calendar: ^3.2.1
  month_picker_dialog: ^6.7.2
  image_picker: ^1.2.3
  package_info_plus: ^10.2.1
# 뒷 부분 생략</code></pre>
<blockquote>
<p><code>lib/pages/settings_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
FutureBuilder&lt;PackageInfo&gt;(
  future: _getPackageInfo(),
  builder: (context, snapshot) {
    final PackageInfo info;
    if (snapshot.hasError || !snapshot.hasData) {
      return Container();
    }
    info = snapshot.data!;
    return Text(&#39;v${info.version}&#39;, style: textTheme.labelSmall,);
  },
),
// 뒷 부분 생략</code></pre>
<h3 id="계정-설정-화면-mesettingsaccount">계정 설정 화면 (<code>/me/settings/account</code>)</h3>
<p>계정 설정 화면도 당장은 할 게 많지 않다. 로그아웃 경고 모달 및 계정 삭제 경고 모달을 만들다 보니 modal dialog도 너무 많아지는 것 같아 별도 디렉토리로 빼기로 했다.</p>
<h3 id="비밀번호-변경-화면-mesettingsaccountpassword">비밀번호 변경 화면 (<code>/me/settings/account/password</code>)</h3>
<p>비밀번호 변경 화면은 비밀번호 보이기/가리기까지만 작성해 놓고 검증은 로직 구현 단계에서 진행하기로 했다.</p>
<h3 id="캐릭터-변경-화면-mesettingscharacter">캐릭터 변경 화면 (<code>/me/settings/character</code>)</h3>
<p>캐릭터 변경 화면은 프로필 화면에 기록을 띄우는 것처럼 GridView를 사용하였다. 처음에는 내용이 잘려서 나와서 잠깐 헤맸는데, GridView는 기본적으로 항목을 정방형으로 출력하기 때문에 발생한 이슈였다. <code>gridDelegate</code> property를 통해 항목의 비율을 변경하니 문제 없이 출력되었다.</p>
<p>캐릭터 변경이 완료되면 하단에 SnackBar를 띄워주기로 했다. 이전에 구현한 부분에도 SnackBar가 있으면 좋을 만한 곳이 좀 있긴 한데 그건 로직을 구현하면서 적당히 추가하도록 하자.</p>
<h3 id="알림-설정-화면-mesettingsnotification">알림 설정 화면 (<code>/me/settings/notification</code>)</h3>
<p>친구, 반응 알림은 켜져 있는 것을 기본값으로 하고 리마인더 알림은 꺼져 있는 것을 기본값으로 한다. 그리고 전체 알림을 끄거나 켤 수 있는 버튼도 존재한다.</p>
<p>요일 선택은 실습 때 했던 대로 할까 하다가 찾아보니 Material 3에서는 ToggleButtons보다 SegmentedButton이 더 선호된다고 한다. 적은 코드로 버튼 제스처에 대한 기능을 구현할 수 있고, 다중 선택이 보다 간결하며, 타입 안정성과 웹/앱 접근성 표준 측면에서도 이득이라나.</p>
<p>ToggleButtons는 Material 2 스타일이었다는 모양이다.</p>
<h3 id="오픈소스-라이선스-고지-화면-license">오픈소스 라이선스 고지 화면 (<code>/license</code>)</h3>
<p>오픈소스 라이선스 고지 화면은 built-in function<code>showLicensePage(context: context);</code> 를 통해 쉽게 만들 수 있지만 레이아웃에 제약이 있다. 이를 보완한 외부 package도 존재하지만 연습삼아 직접 작성해 보도록 하겠다.</p>
<p>각 패키지 항목은 Card로 작성하고 클릭하면 ModalBottomSheet을 통해 라이선스 정보가 출력되도록 하였다.</p>
<p>설정 화면 구성할 때 <code>context.go()</code> 로 연결해 놓았는데 오픈소스 라인선스 고지화면과 개인정보 처리방침 화면, 이용약관 화면은 어차피 거기서 이동할 곳도 없으니 <code>context.push()</code> 로 하는 편이 나을 듯하여 수정했다. 오픈소스 라이선스 고지는 그렇다 쳐도 나머지 두 녀석은 회원가입 화면에서도 띄워야 하는데 <code>context.go()</code> 로 하면 코드가 더 복잡해질 테니 말이다.</p>
<blockquote>
<p><code>/lib/pages/oss_licenses_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
 void _showBottomSheet(BuildContext context, String package, List licenses) {
    TextTheme textTheme = Theme.of(context).textTheme;
&gt;
    showModalBottomSheet(
      context: context,
      useSafeArea: true,
      isScrollControlled: true,
      builder: (context) {
        return Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
          ),
          child: Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(package, style: textTheme.bodyLarge),
                Text(&#39;라이선스 ${licenses.length}개&#39;, style: textTheme.labelMedium),
                Divider(),
                Expanded(
                  child: ListView.separated(
                    itemCount: licenses.length,
                    itemBuilder: (context, index) {
                      return Padding(
                        padding: EdgeInsets.symmetric(vertical: 20),
                        child: Text(
                          licenses[index],
                          style: textTheme.labelSmall,
                        ),
                      );
                    },
                    separatorBuilder: (BuildContext context, int index) {
                      return Divider();
                    },
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
&gt;  
  @override
  void initState() {
    super.initState();
    _loadLicenses();
  }
&gt;
  @override
  Widget build(BuildContext context) {
    TextTheme textTheme = Theme.of(context).textTheme;
    ColorScheme colorScheme = Theme.of(context).colorScheme;
&gt;
    return Scaffold(
      appBar: AppBar(
        leading: IconButton(
          onPressed: () {
            context.pop();
          },
          icon: Icon(Icons.chevron_left, color: colorScheme.tertiary),
        ),
        title: Text(&#39;오픈소스 라이선스&#39;),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20),
        child: _isLoading
            ? Center(child: CircularProgressIndicator())
            : ListView.builder(
                itemCount: _ossLicenses.length,
                itemBuilder: (context, index) {
                  final item = _ossLicenses[index];
                  final String package = item[&#39;name&#39;];
                  final List licenses = item[&#39;licenses&#39;];
&gt;
                  return Card(
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadiusGeometry.circular(20),
                    ),
                    child: ListTile(
                      title: Text(package, style: textTheme.bodyMedium),
                      subtitle: Text(
                        &#39;라이선스 ${licenses.length}개&#39;,
                        style: textTheme.labelMedium,
                      ),
                      trailing: Icon(
                        Icons.chevron_right,
                        color: colorScheme.tertiaryContainer,
                      ),
                      onTap: () =&gt; _showBottomSheet(context, package, licenses),
                    ),
                  );
                },
              ),
      ),
    );
// 뒷 부분 생략</code></pre>
<h3 id="개인정보-처리방침-화면-privacy-및-이용약관-화면-tos">개인정보 처리방침 화면 (<code>/privacy</code>) 및 이용약관 화면 (<code>/tos</code>)</h3>
<p>개인정보 처리방침과 이용약관 페이지는 일단 AppBar만 만들어 놓고 내용은 추후에 채우도록 하겠다.</p>
<h2 id="8-view-로그인-및-회원가입-화면-뼈대-잡기">#8 [view] 로그인 및 회원가입 화면 뼈대 잡기</h2>
<h3 id="로그인-화면-authlogin">로그인 화면 (<code>/auth/login</code>)</h3>
<p>로그인 화면 상단에 들어가는 로고 이미지는 Claude Design이 SVG로 뽑아줬으므로 <a href="https://pub.dev/packages/flutter_svg">flutter_svg</a> package를 사용하도록 하겠다. PNG로 다시 뽑아달라고 해도 되겠지만 언젠가 실무에서는 내 마음대로 요청하지 못하고 주는 대로 써야 하는 상황도 있을 수 있으니 알아둬서 나쁠 게 없지.</p>
<h3 id="회원가입-화면-authsignup">회원가입 화면 (<code>/auth/signup</code>)</h3>
<p>회원가입 화면을 만들다 보니 이것저것 옵션이 들어가 있는 TextFormField를 widget으로 따로 빼는 게 나을 것 같더라. 이거 말고도 하다 보니 조정해야 할 부분이 보이는데 전체적인 레이아웃 마치고 나서 다시 검토해 봐야지.</p>
<h3 id="회원가입-완료-화면-authsignupcomplete">회원가입 완료 화면 (<code>/auth/signup/complete</code>)</h3>
<p>회원가입 완료 화면에서는 가입한 이메일을 띄워주며 로그인하러 가기 버튼이 출력되고 인증 메일 다시 보내기 버튼도 함께 존재한다. UI상으로 크게 신경쓸 건 없다.</p>
<h3 id="비밀번호-재설정-화면-authpassword">비밀번호 재설정 화면 (<code>/auth/password</code>)</h3>
<p>비밀번호 재설정 화면도 크게 신경쓸 건 없다. 로직 넣을 때 검증이나 잘 해야지.</p>
<h3 id="랜딩-페이지-">랜딩 페이지 (<code>/</code>)</h3>
<p>랜딩페이지에는 샘플 이미지를 넣으려고 했는데 아직 image assets 준비가 되지 않았다. 일단 틀만 잡아놓고 image assets 작업이 완료되면 집어넣도록 하자.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/520a372ba11521655de4b05d44715b2ece7f4b6f">&gt;&gt;&gt; GitHub Repository at this point (520a372)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 28 | 개인 프로젝트 — view (2)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-28</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-28</guid>
            <pubDate>Fri, 11 Sep 2026 10:10:37 GMT</pubDate>
            <description><![CDATA[<h1 id="day-28--개인-프로젝트--view-2">DAY 28 | 개인 프로젝트 — view (2)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#4 [view] 피드 탭과 게시물 화면 뼈대 잡기</li>
<li>#5 [view] 기록 탭과 기록 상세 화면 뼈대 잡기</li>
<li>#6 [view] 나 탭과 프로필, 친구 관련 화면 뼈대 잡기</li>
</ul>
<h2 id="4-view-피드-탭과-게시물-화면-뼈대-잡기">#4 [view] 피드 탭과 게시물 화면 뼈대 잡기</h2>
<h3 id="피드-화면-feed">피드 화면 (<code>/feed</code>)</h3>
<p>피드 화면에는 나와 친구의 공유된 기록이 최신순으로 출력된다. 페이지네이션과 관련된 기능은 Provider를 붙인 후에 수정하기로 하고 일단 뼈대만 잡아 놓았다.</p>
<p>기본적으로 <code>FeedPost</code> widget의 List다. 사용자 및 세션 기록에 따라 데이터가 출력된다. 더보기 메뉴를 클릭할 경우 내 기록이라면 편집하기 버튼과 삭제하기 버튼이 뜨며 타인의 기록이라면 신고하기 버튼이 뜬다. 웹으로 확장하게 된다면 URL 복사 같은 버튼도 추가될 수도 있겠지.</p>
<h3 id="피드-게시물-화면-feedpostsketch_id">피드 게시물 화면 (<code>/feed/post/:sketch_id</code>)</h3>
<p>세션 기록과 스케치는 1 대 0..1 관계이므로 스케치 식별자는 세션 식별자를 그대로 사용해도 괜찮다고 판단했다. 동일한 UUID 값을 공유한다. 내 게시물이냐 타인의 게시물이냐에 따라 응원 버튼의 작동이 다른 점은 브런치스토리의 라이킷을 벤치마킹했다. 타인의 게시물에 대해서는 응원을 하거나 안 하거나의 토글 버튼으로 작용하며 누가 응원했는지는 알 수 없고, 자신의 게시물에 대해서는 누가 응원했는지 목록을 확인할 수 있는 구조다.</p>
<h3 id="피드-알림-화면-feednotifications">피드 알림 화면 (<code>/feed/notifications</code>)</h3>
<p>피드 알림은 일단 누구에 의해 어떤 알림이 트리거되었는지와 읽음 여부만 담았는데, 모두 읽음으로 표시하는 버튼이나 알림 기록 지우기 버튼 같은 걸 넣어도 괜찮을 것 같다. 일단은 기획에 있던 부분까지만 구현하고 이 부분에 대해서는 추후 다시 판단해 보도록 하겠다.</p>
<h2 id="5-view-기록-탭과-기록-상세-화면-뼈대-잡기">#5 [view] 기록 탭과 기록 상세 화면 뼈대 잡기</h2>
<h3 id="기록-화면-history">기록 화면 (<code>/history</code>)</h3>
<p>기록 화면은 캘린더와 리스트로 구성된다. 캘린더의 달을 이동하면 그 달에 수행한 운동 기록 목록이 출력되는 형태다. 캘린더 부분은 <a href="https://pub.dev/packages/table_calendar">table_calendar</a> widget을 사용하여 구현하기로 했다. table_calendar가 제공하는 날짜 선택기는 연/월/일을 선택하는데 우리는 연/월까지만 보기 때문에 불필요한 일 선택이 포함되어 있다. 따라서 <a href="https://pub.dev/packages/month_picker_dialog">month_picker_dialog</a>라는 별도의 package를 title header에 연결하겠다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
  intl: ^0.20.2
  animated_toggle_switch: ^0.8.7
  uuid: ^4.6.0
  flutter_map: ^8.3.2
  latlong2: ^0.10.1
  table_calendar: ^3.2.1
  month_picker_dialog: ^6.7.2
# 뒷 부분 생략</code></pre>
<p>캘린더 뷰가 생각보다 난해했다. 목록 뷰는 해당 연/월의 모든 기록을 가져와 (필요에 따라 페이지네이션을 포함할 수도 있고) 순차적으로 뿌려주면 되는데, 캘린더 뷰는 해당 날짜에 기록이 있는지, 있다면 서로 다른 유형의 기록이 함께 있지는 않는지 확인해야 했다.</p>
<p>이를 처리하기 위해 월간 기록을 담고 있는 mock model에 조깅 기록이 있는 날과 라이딩 기록이 있는 날의 날짜를 담은 Set을 추가했다. Set으로 구현하여 중복에 대한 처리를 따로 할 필요 없이 중복된 날짜를 배제했다. 그리고 어떤 날짜에 대해 그 날짜가 <code>dayRidding</code> 에 들어 있는지 <code>dayJogging</code> 에 들어 있는지 둘 다 들어있거나 혹은 둘 다 없는지에 따라 적절한 색상으로 표기하도록 하였다.</p>
<p>이러한 캘린더 뷰 작업이 생각보다 오래 걸렸다.</p>
<blockquote>
<p><code>lib/widgets/session_calendar.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:table_calendar/table_calendar.dart&#39;;
import &#39;package:month_picker_dialog/month_picker_dialog.dart&#39;;
import &#39;../theme.dart&#39;;
import &#39;../utils/diagonal_painter.dart&#39;;
import &#39;../models/mock_monthly_history.dart&#39;;
&gt;
class SessionCalendar extends StatelessWidget {
  final DateTime _focusedDay;
  final MockMonthlyHistory _monthlyHistory;
  final Function(DateTime) _onMonthChanged;
&gt;
  const SessionCalendar({
    super.key,
    required this._focusedDay,
    required this._monthlyHistory,
    required this._onMonthChanged,
  });
&gt;
  /// 원하는 연/월의 기록을 확인하기 위한 helper function
  Future&lt;void&gt; _pickYearMonth(BuildContext context) async {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
&gt;
    final selected = await showMonthPicker(
      context: context,
      initialDate: _focusedDay,
      firstDate: DateTime(2026),
      lastDate: DateTime(DateTime.now().year + 1),
      monthPickerDialogSettings: MonthPickerDialogSettings(
        dialogSettings: PickerDialogSettings(locale: Locale(&#39;ko&#39;)),
        dateButtonsSettings: PickerDateButtonsSettings(
          selectedMonthBackgroundColor: colorScheme.primary,
          selectedMonthTextColor: colorScheme.onPrimary,
          currentMonthTextColor: colorScheme.secondary,
          buttonBorder: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(20),
          ),
        ),
        actionBarSettings: PickerActionBarSettings(),
      ),
    );
    if (selected != null) {
      _onMonthChanged(selected);
    }
  }
&gt;
  /// 기록에 따라 날짜에 표시하기 위한 helper function
  Widget _buildCellBackground(BuildContext context, int day) {
    ActivityColors activityColors = context.activityColors;
&gt;
    if (_monthlyHistory.dayJogging.contains(day) &amp;&amp;
        _monthlyHistory.dayRiding.contains(day)) {
      return ClipRRect(
        borderRadius: BorderRadius.circular(20),
        child: CustomPaint(
          painter: DiagonalPainter(
            topLeftColor: activityColors.joggingFill,
            bottomRightColor: activityColors.ridingFill,
          ),
        ),
      );
    } else if (_monthlyHistory.dayJogging.contains(day)) {
      return Container(
        decoration: BoxDecoration(
          color: activityColors.joggingFill,
          shape: BoxShape.circle,
        ),
      );
    } else if (_monthlyHistory.dayRiding.contains(day)) {
      return Container(
        decoration: BoxDecoration(
          color: activityColors.ridingFill,
          shape: BoxShape.circle,
        ),
      );
    }
    return Container();
  }
&gt;
  /// 날짜를 꾸며주는 helper function
  Widget? decorateDayCell(
    BuildContext context,
    DateTime day,
    DateTime currentFocus,
  ) {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
    if (day.month != currentFocus.month) {
      return Center(
        child: Text(&#39;${day.day}&#39;, style: const TextStyle(color: Colors.grey)),
      );
    }
&gt;
    final isToday = isSameDay(day, DateTime.now());
&gt;
    return Center(
      child: Container(
        width: 38,
        height: 38,
        decoration: isToday
            ? BoxDecoration(
                shape: BoxShape.circle,
                border: Border.all(color: colorScheme.outlineVariant, width: 2),
              )
            : null,
        alignment: Alignment.center,
        child: Stack(
          alignment: Alignment.center,
          children: [
            SizedBox(
              width: 32,
              height: 32,
              child: _buildCellBackground(context, day.day),
            ),
            Text(
              &#39;${day.day}&#39;,
              style: TextStyle(
                fontWeight:
                    isToday ||
                        _monthlyHistory.dayJogging.contains(day.day) ||
                        _monthlyHistory.dayRiding.contains(day.day)
                    ? FontWeight.bold
                    : FontWeight.normal,
              ),
            ),
          ],
        ),
      ),
    );
  }
&gt;
  @override
  Widget build(BuildContext context) {
    TextTheme textTheme = Theme.of(context).textTheme;
&gt;
    return SizedBox(
      height: 400,
      child: TableCalendar(
        locale: &#39;ko_KR&#39;,
        firstDay: DateTime.utc(2026),
        lastDay: DateTime.utc(DateTime.now().year + 1),
        focusedDay: _focusedDay,
        selectedDayPredicate: (day) =&gt; false,
        onDaySelected: null,
        onPageChanged: (date) =&gt; _onMonthChanged(date),
        headerStyle: const HeaderStyle(
          formatButtonVisible: false,
          titleCentered: true,
        ),
        calendarBuilders: CalendarBuilders(
          headerTitleBuilder: (context, date) =&gt; InkWell(
            onTap: () =&gt; _pickYearMonth(context),
            child: Text(
              &#39;${date.year}년 ${date.month}월&#39;,
              style: textTheme.bodyLarge,
              textAlign: TextAlign.center,
            ),
          ),
          prioritizedBuilder: decorateDayCell,
        ),
      ),
    );
  }
}</code></pre>
<h3 id="기록-상세-화면-historydetailssession_id">기록 상세 화면 (<code>/history/details/:session_id</code>)</h3>
<p>기록 상세 화면은 대체로 세션 진행 화면 및 세션 결과 화면의 코드 재사용이었다. 기록을 피드에 공유했는지 여부에 따라, 그리고 공유하지 않았다면 가장 최신 기록인지 여부에 따라 하단 버튼이 달라지는 것과 비공개 메모 정도가 추가되었다. 공유된 기록은 피드 게시물로 연결되는 버튼이 있고, 공유되지 않은 기록은 가장 최신 기록에 한해 피드에 기록하는 버튼이 있다. 공유되지 않았으면서도 최신도 아닌 기록은 버튼이 보이지 않는다.</p>
<p>비공개 메모는 피드 게시물보다 넉넉한 분량으로 작성할 수 있고, 따로 게시 버튼을 누르지 않아도 자동으로 저장되는 형태다.</p>
<h2 id="6-view-나-탭과-프로필-친구-관련-화면-뼈대-잡기">#6 [view] 나 탭과 프로필, 친구 관련 화면 뼈대 잡기</h2>
<h3 id="내-프로필-화면-me">내 프로필 화면 (<code>/me</code>)</h3>
<p>프로필과 공유된 기록이 뜨는 화면이다. 공유된 기록을 클릭하면 뜨는 상세 화면은 피드에서와 같은 화면을 공유하되 route를 분리하였다. 그런데 한 가지 이슈가 발견되었다.</p>
<blockquote>
<h4 id="발견된-이슈">발견된 이슈</h4>
<p>피드에서 내 게시물 편집을 하다가 탭을 이동하여 내 프로필 화면에서도 게수믈 편집을 할 수 있다? 충돌이 발생할 수 있을 것 같은데. 처음 업로드하는 건 아예 별도 route로 되어 있어서 하단 네비게이션이 없기 때문에 문제 없는데 편집 화면끼리가 문제다. 편집 화면도 별도 route로 빼는 게 나으려나.
편집 화면만 go가 아닌 push로 올리고 치우면 그럭저럭 괜찮을 것 같긴 한데. 일단 전체적인 UI를 마무리하고 layout 검토 작업을 하며 좀 더 생각해 보자.</p>
</blockquote>
<p>프로필 사진 변경에는 <a href="https://pub.dev/packages/image_picker">image_picker</a> package를 사용한다. 여기선 프로필 변경 모달에서 이미지를 불러 오는 것까지만 하고 실제 반영하는 건 이후에 하도록 하겠다. 이 부분은 실습 때 해봤던 거니까 어렵지 않을 것이다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
  intl: ^0.20.2
  animated_toggle_switch: ^0.8.7
  uuid: ^4.6.0
  flutter_map: ^8.3.2
  latlong2: ^0.10.1
  table_calendar: ^3.2.1
  month_picker_dialog: ^6.7.2
  image_picker: ^1.2.3
# 뒷 부분 생략</code></pre>
<h3 id="사용자-프로필-화면-profileuser_id">사용자 프로필 화면 (<code>/profile/:user_id</code>)</h3>
<p>사용자 프로필 화면은 대체로 내 프로필과 비슷하다. 그래서 같은 화면을 공유할까 싶기도 했지만 별도로 구성하기로 했다. 대상이 친구인지, 친구 요청을 보낸 상태인지, 친구 요청을 받은 상태인지, 아무 관계 없는 상태인지에 따라 화면 구성이 조금씩 다른데 여기에 나 자신인 경우까지 한 파일에 넣으면 코드가 너무 복잡해지기 때문이다.</p>
<p>어차피 공유된 기록 그리드는 별도의 widget으로 작성해 놓았으니 중복되는 부분은 많지 않다.</p>
<p>친구 목록에 있는 사용자일 경우 한 줄 소개가 뜨고 친구 요청을 보낸 상태라면 요청 취소 버튼이, 친구 요청을 받은 상태라면 요청 수락 버튼이 뜬다. 아무 관계 없는 사람의 경우 친구 요청 버튼이 뜬다. 친구 요청 상태가 어떻든 친구가 아닌 사용자라면 함께 아는 친구 목록이 뜬다. 친구가 아닐 경우에도 함께 아는 친구가 없다면 함께 아는 친구 목록이 뜨지 않는다.</p>
<p>함께 아는 친구 목록을 클릭하면 함께 아는 친구를 모두 확인할 수 있는 modal dialog를 띄우도록 하고 보니 go_router 상으로 같은 route를 가리키고 있어 이동되지 않는 이슈를 발견했다. 함께 아는 친구 프로필 클릭 시 누군지 확인하고 다시 돌아올 것이라 생각되어 이 부분은 <code>context.go()</code> 대신 <code>context.push()</code> 를 사용하는 것으로 문제를 해결했다.</p>
<h3 id="친구-목록-화면-mefriends-및-사용자-검색-화면-mefriendssearch">친구 목록 화면 (<code>/me/friends</code>) 및 사용자 검색 화면 (<code>/me/friends/search</code>)</h3>
<p>이 두 녀석은 같이 구현했다. 사용자를 나타내는 ListTile이 나열되어 있는 형태가 겹치기 때문이다. 받은 요청 뷰, 내 친구 뷰, 알 수도 있는 사람 뷰, 검색 결과 뷰를 작성한 후, 친구 목록 화면에 받은 요청 뷰와 내 친구 뷰를 보여주고, 사용자 검색 화면에 검색 여부에 따라 알 수도 있는 사람 뷰와 검색 결과 뷰를 보여준다.</p>
<p>사용자 프로필 화면과 마찬가지로 ListTile의 레이아웃에 조금씩 변주가 있다. 다음과 같은 옵션을 두어 적절한 레이아웃으로 출력되게 구현하였다.</p>
<blockquote>
<p><code>lib/widgets/user_card.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
class UserCard extends StatelessWidget {
  final MockUser _user;
  final bool _isFriend;
  final bool _isRequested;
  final bool _isSent;
  final bool _onSearch;
  final bool _onRecommend;
&gt;
  const UserCard({
    super.key,
    required this._user,
    this._isFriend = false,
    this._isRequested = false,
    this._isSent = false,
    this._onSearch = false,
    this._onRecommend = false,
  });
// 뒷 부분 생략</code></pre>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/7af6073fb77b55953eb65aff9228b4f937e812f2">&gt;&gt;&gt; GitHub Repository at this point (7af6073)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 27 | 개인 프로젝트 — view (1)]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-27</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-27</guid>
            <pubDate>Wed, 09 Sep 2026 03:33:22 GMT</pubDate>
            <description><![CDATA[<h1 id="day-27--개인-프로젝트--view-1">DAY 27 | 개인 프로젝트 — view (1)</h1>
<blockquote>
<h4 id="작업-일정">작업 일정</h4>
</blockquote>
<ul>
<li>#1 프로젝트 구조 설계</li>
<li>#2 [view] StatefulShellRoute 와 go_route</li>
<li>#3 [view] 홈 탭과 운동 세션 화면 뼈대 잡기</li>
</ul>
<h2 id="1-프로젝트-구조-설계">#1 프로젝트 구조 설계</h2>
<p>Andorid Studio에서 새 프로젝트를 생성하고 README.md에 프로젝트 설명을 간단히 적는다. 우선 핵심적인 부분만 적어놓고 initial commit을 남긴 뒤, 추후 프로젝트 진행에 따라 보완하기로 했다.</p>
<p>그리고 기획했던 디자인 토큰을 프로젝트에 반영하기 위해 <code>lib/theme.dart</code> 에 주요 테마 설정을 적어놓았다. 일단 생각나는 것만 적어놓긴 했는데 나중에 필요에 따라 추가하거나 수정하도록 하겠다. 버튼 같은 건 일단 Claude Deisgn으로 작업했던 Design Foundation을 기반으로 느낌만 잡아 놓아서 UI를 앉혀 본 후에 조정을 하게 될 듯하다.</p>
<h2 id="2-view-statefulshellroute-와-go_route">#2 [view] StatefulShellRoute 와 go_route</h2>
<p>간단히 말해 하단 네비게이션 바를 만들고 route를 잡는 거다. 화면을 설계할 때 애초에 route URL을 화면 식별자로 사용했기에 그걸 거의 그대로 코드로 옮기며 검토하는 정도다.</p>
<p><a href="https://pub.dev/packages/go_router">go_router</a> package는 그새 18.0.1으로 버전이 올라 있다. 18번대의 Dart 최소 버전은 3.12이고 Flutter 최소 버전은 3.44인데 우리 작업 환경의 Dart SDK는 3.12.2이고 Flutter SDK는 3.44.4이므로 문제 없이 사용할 수 있다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
# 뒷 부분 생략</code></pre>
<p>실습했던 환경과의 변경점은 다음과 같은데 당장 크게 신경쓰지는 않아도 될 것 같다. route metadata 관련해서는 실습했던 것과 다르게 구현할 수 있는 부분도 있을 것 같은데 로그인 관련 코드를 작성하면서 좀 더 자세히 살펴봐야겠다.</p>
<blockquote>
</blockquote>
<ul>
<li>Migrates to material_ui and cupertino_ui.</li>
<li>Updates minimum supported SDK version to Flutter 3.44/Dart 3.12.</li>
<li>Adds route metadata support, including inheritance and override behavior with exposure on GoRouterState.</li>
<li>Documents support for regular expression constraints in GoRoute path parameters.</li>
</ul>
<p>URL parameter에 정규표현식을 사용하는 게 공식 기능으로 안정화되었다는 게 흥미롭다. <code>/endpoint/:id</code> 라는 route에서 <code>id</code> 에 숫자만 허용하거나 정규표현식 패턴 매칭에 따라 다른 화면으로 연결하는 게 가능한 모양이다.</p>
<h3 id="하단-네비게이션을-사용하지-않는-페이지">하단 네비게이션을 사용하지 않는 페이지</h3>
<p>실습 땐 없던 유형의 페이지다. 하지만 세션 진행 도중 다른 탭으로 이동하지 않도록 해야 하므로 하단 네비게이션이 없는 화면이 필요하다.</p>
<p>이런 경우에는 해당 route는 <code>StatefulShellRoute.indexedStack</code> 밖으로 빼야 한다고 한다. 그러니까 홈 탭에서만 세션에 진입하니 언뜻 보기엔 <code>/home</code> route 아래에 <code>session</code> route가 있을 것 같지만 완전히 별개의 route로 존재하는 것이다.</p>
<p>세션 페이지뿐만 아니라 타인의 프로필 페이지, 이용약관 및 개인정보처리방침도 이런 식으로 shell 밖에 작성된다. 그리고 실습 때와 달리 로그인 및 회원가입 관련 페이지도 shell 밖으로 빼도록 하겠다.</p>
<h3 id="전반적인-route-구성">전반적인 route 구성</h3>
<p>일단은 앱을 켜면 랜딩 페이지가 나오고 버튼을 누르면 홈 화면으로 이동하며 홈을 포함한 네 개 탭이 존재하는 상태로 만들었다. 각 화면은 Placeholder로 둔 채 순차적으로 구현해 나갈 것이다. 총 27개의 route가 존재한다.</p>
<blockquote>
<h4 id="route-구조">route 구조</h4>
</blockquote>
<ul>
<li>랜딩 페이지 (<code>/</code>)</li>
<li>Stateful Shell Route<ul>
<li>홈 탭 (<code>/home</code>)</li>
<li>피드 탭 (<code>/feed</code>)<ul>
<li>피드 내 게시물 (<code>/feed/post/:sketch-id</code>)</li>
<li>피드 알림 (<code>/feed/notifications</code>)</li>
</ul>
</li>
<li>기록 탭 (<code>/history</code>)<ul>
<li>세션 상세 기록 (<code>/history/details/:session-id</code>)</li>
</ul>
</li>
<li>나 탭 (<code>/me</code>)<ul>
<li>친구 목록 (<code>/me/friends</code>)<ul>
<li>사용자 검색 (<code>/me/friends/search</code>)</li>
</ul>
</li>
<li>설정 (<code>/me/settings</code>)<ul>
<li>계정 설정 (<code>/me/settings/account</code>)<ul>
<li>비밀번호 변경 (<code>/me/settings/account/password</code>)</li>
</ul>
</li>
<li>캐릭터 설정 (<code>/me/settings/character</code>)</li>
<li>알림 설정 (<code>/me/settings/notifications</code>)</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li>세션 및 운동 선택 (<code>/session-start</code>)</li>
<li>세션 진행 (<code>/session-tracking</code>)</li>
<li>세션 결과 (<code>/session-result/:session-id</code>)<ul>
<li>세션 결과 공유 (<code>/session-result/:session-id/share</code>)</li>
</ul>
</li>
<li>다른 사용자 프로필 (<code>/profile/:user-id</code>)</li>
<li>오픈소스 라이선스 (<code>/license</code>)</li>
<li>개인정보 처리방침 (<code>/privacy</code>)</li>
<li>이용약관 (<code>/tos</code>)</li>
<li>로그인 (<code>/auth/login</code> - <code>/auth</code> 로 접근해도 redirect)</li>
<li>회원가입 (<code>/auth/signup</code>)<ul>
<li>회원가입 성공 및 이메일 인증 안내 (<code>/auth/signup/complete</code>)</li>
</ul>
</li>
<li>비밀번호 찾기 (<code>auth/password</code>)</li>
</ul>
<h2 id="3-view-홈-탭과-운동-세션-화면-뼈대-잡기">#3 [view] 홈 탭과 운동 세션 화면 뼈대 잡기</h2>
<h3 id="홈-화면-home">홈 화면 (<code>/home</code>)</h3>
<p>홈 화면에서는 오늘 날짜와 날씨를 띄워주며 가장 최근 운동 기록을 보여주고 (기록이 없다면 시작해 보라고 한다) 세션 시작 CTA 버튼을 띄우며 지난 일주일 동안의 기록 여부를 보여준다.</p>
<p>일단 날씨, 운동 기록, 사용자 프로필은 Mock 데이터를 사용하여 화면 구성을 한다. 날짜는 피드와 기록에서도 형식에 맞는 날짜를 종종 쓰게 될 테니 편하게 쓰기 위해 <a href="https://pub.dev/packages/intl">intl</a> package를 사용하도록 하겠다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
  intl: ^0.20.2
# 뒷 부분 생략</code></pre>
<p>운동 기록은 피드에서도 계속 쓸 것이므로 custom widget으로 작성한다. 그리고 주간 기록 인디케이터는 홈 화면에서만 사용하지만 코드 복잡도를 낮추기 위해 이 또한 custom widget으로 작성한다.</p>
<h3 id="세션-및-운동-선택-화면-session-start">세션 및 운동 선택 화면 (<code>/session-start</code>)</h3>
<p>어떤 운동을 선택할지 토글 스위치는 <a href="https://pub.dev/packages/animated_toggle_switch">animated_toggle_switch</a>를 사용하기로 했다. <a href="https://pub.dev/packages/flutter_switch">flutter_switch</a>는 몇 년씩이나 유지보수가 안 되고 있고 <a href="https://pub.dev/packages/flutter_toggle_switch">flutter_toggle_swtich</a>는 4개월 전에 첫 버전이 나와 아직 충분히 검증되지 않은 느낌이다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
  intl: ^0.20.2
  animated_toggle_switch: ^0.8.7
# 뒷 부분 생략</code></pre>
<p>운동은 양자택일이므로 토글 스위치 방향에 따라 <code>isJogging</code> 의 값을 뒤집고, 이에 따라 적절한 미션 목록을 출력한다. 미션 목록은 다섯 가지 중 최대 두 개까지 선택할 수 있다. 이미 두 개가 선택되어 있는 상태에서 하나 더 선택하면 가장 처음 선택한 것이 취소되는 방식으로 하였다.</p>
<p>각 미션 카드는 <code>MissionCard</code> widget을 정의하여 사용한다. 카드가 선택되었는지 여부는 이 widget 안에 있는데 미션이 두 개 이상 선택되어 있는지 여부는 바깥에서 판단해야 하므로 <code>SessionStartPage</code> 에서 <code>onTap</code> 에 대한 callback function을 작성하여 <code>MissionCard</code> 로 넘겨준다.</p>
<blockquote>
<p><code>lib/pages/session_start_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  final List&lt;int&gt; _selectedIndices = [];
  void _handleCardTapped(int index) {
    setState(() {
      if (_selectedIndices.contains(index)) {
        _selectedIndices.remove(index);
      } else {
        if (_selectedIndices.length &gt;= 2) {
          _selectedIndices.removeAt(0);
        }
        _selectedIndices.add(index);
      }
    });
  }
// 뒷 부분 생략</code></pre>
<h3 id="세션-진행-화면-session-tracking">세션 진행 화면 (<code>/session-tracking</code>)</h3>
<p>세션 진행 화면은 화면 설계상으로는 단순해 보이지만 추후 기능 구현 난이도가 있는 부분이다. 실시간으로 처리되어야 하는 것들도 있고 백그라운드에서 돌아가는 것들도 있고 의도치 않게 종료되었을 때에 대한 처리도 필요하다. 기능 구현 시 가장 힘써야 할 부분이지만 일단은 layout만 잡고 넘어갈 것이니 의사결정의 많은 부분을 뒤로 미룬다.</p>
<p><code>sessionId</code> 는 [종료] 버튼 클릭 시 세션 결과 화면으로 이동할 때 필요하므로 어떻게 정해지는지 결정하고 넘어가는 게 좋을 것 같다. <a href="https://pub.dev/packages/uuid">uuid</a> package를 사용하여 UUID v4로 id 값을 생성하기로 했다.</p>
<p>이동 그래프도 일단 임의의 좌표를 넣어서 작성하는데, <a href="https://pub.dev/packages/flutter_map">flutter_map</a> package와 <a href="https://pub.dev/packages/latlong2">latlong2</a> package를 사용하여 좌표를 나타낸다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  go_router: ^18.0.1
  intl: ^0.20.2
  animated_toggle_switch: ^0.8.7
  uuid: ^4.6.0
  flutter_map: ^8.3.2
  latlong2: ^0.10.1
# 뒷 부분 생략</code></pre>
<p>이동 경로 뷰는 로직 붙이면서 여러 모로 손봐야 할 듯하다.</p>
<h3 id="세션-일시정지-모달-modalsessionpause">세션 일시정지 모달 (<code>modalSessionPause</code>)</h3>
<p>세션 일시정지 모달은 데이터를 보여주기만 하기 때문에 단순하다. 하드코딩 되어 있는 부분을 이후에 Provider로부터 직접 받게 수정하는 정도?</p>
<h3 id="세션-결과-화면-session-resultsession_id">세션 결과 화면 (<code>/session-result/:session_id</code>)</h3>
<p><code>&#39;/session-result/:session-id&#39;</code> 라는 route를 사용할 수 없다는 걸 발견했다! <code>:</code> 뒤에 오는 parameter 부분에는 &#39;-&#39;를 사용할 수 없다. <code>session_id</code> 로 하든 <code>sessionId</code> 로 하든 수정해야 한다. 전자가 더 나을 듯하여 <code>session_id</code> 로 수정하였다. 웹 URL에서는 &#39;-&#39;이 흔히 쓰여 route를 그렇게 작성했던 건데 <code>go_router</code> 의 path parameter에서는 허용하지 않는다더라.</p>
<p>화면이 길어서 <code>SingleChildScrollView</code> 로 감쌌는데 하단 버튼은 스크롤과 관련 없이 늘 맨 아래 있었으면 해서 <code>Scaffold</code> 의 <code>bottomNavigationBar</code> 에 버튼을 넣었다.</p>
<h3 id="세션-결과-공유-화면-session-resultsession_idshare">세션 결과 공유 화면 (<code>/session-result/:session_id/share</code>)</h3>
<p>세션 결과 공유 화면에는 재사용이 많다. 스케치 이미지 뷰도 기존에 만들어 놓은 widget이고, 위치 선택 태그도 <code>SessionStartPage</code> 에서 조깅/라이딩 선택할 때 썼던 package를 사용하면 된다.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/move-sketch/tree/e69564612757ef5317a60e06727223438809390f">&gt;&gt;&gt; GitHub Repository at this point (e695646)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 26 | 본격적인 프로젝트 준비]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-26</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-26</guid>
            <pubDate>Sat, 05 Sep 2026 14:37:31 GMT</pubDate>
            <description><![CDATA[<h1 id="day-26--본격적인-프로젝트-준비">DAY 26 | 본격적인 프로젝트 준비</h1>
<h2 id="디자인-보완하기">디자인 보완하기</h2>
<p>Figma로 작성한 wireframe을 기반으로 Claude Design으로 디자인 작업을 한다. 개발 영역에서는 생성형 AI를 사용하지 않을 것이므로 AI를 활용하는 마지막 지점이다.</p>
<p>Claude Design을 통해 디자인을 입히면서 보니 wireframe을 작성할 때 놓쳤던 부분들도 새로 발견된다. 내가 간과했던 부분을 Claude Design이 알아서 해주는 경우도 있고, 그런 부분을 엄한 방식으로 임의로 채워 넣어 내가 방향성을 다시 잡아준 경우도 있고. 때로는 내 의도대로 만들어지긴 했는데 디자인을 입혀 보니 뭔가 애매해서 함께 수정한 부분도 있다.</p>
<p>내가 대략 느낌만 잡아둔 부분에 대해서 Claude Design이 내가 생각하지 못했지만 꽤 괜찮은 방식으로 구체화한 부분도 있는데, 그런 걸 보면 사람들이 왜 AI를 사용하여 기획을 하는지 알 것 같기도 하고. 물론 기획자나 디자이너 관점에서는 AI 결과물이 만족스럽지 않을 수 있겠지만 그쪽 역량이 크지 않은 나로서는 AI와 협업하는 편이 훨씬 나은 것 같다.</p>
<p>지난 시간에는 삽질만으로 5시간치 토큰이 전부 만료되었지만 이번에는 홈/피드/기록 탭을 마무리하고 나 탭 진행 도중 토큰이 만료되었다. 그리고 어찌저찌 디자인 작업을 마무리했다.</p>
<h3 id="전체적인-플로우">전체적인 플로우</h3>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/0954b166-8e6d-4977-a522-f8ba907e404c/image.png" alt=""></p>
<h2 id="repository-준비하기">repository 준비하기</h2>
<p><a href="https://github.com/neont21/move-sketch">프로젝트 저장소</a>를 만들었다. 가칭 NF는 중단하고 가칭 CW의 기획을 이어나가 앱 이름은 MoveSketch, 무브스케치로 정했다. 가벼운 조깅과 라이딩의 기록을 이미지로 남기는 앱이다. 처음에는 생성형 AI로 이미지를 만들어주는 것도 생각해 보았지만 파츠 기반으로 쌓아 올리는 게 좀 더 이 앱의 감성에 맞을 것 같아서 레이어를 쌓아 이미지를 만들기로 했다.</p>
<p>프로젝트 관리는 GitHub Projects를 통해 하기로 했다. 학생 때는 Jira와 Slack을 연결해서 팀 프로젝트를 진행했던 것 같다. 이젠 다 어렴풋한 기억이지만. 개인 프로젝트에서는 GitHub Project로 충분할 것 같다. 개발 경험이 없다 보니 어느 정도 걸릴지 감은 잘 안 오지만 일단 대략 일정을 적어 보았다. 수업이 있는 날 구현 작업을 하고 그 외 화목토 정도에는 풀타임으로 작업하지는 못 해도 문서화 및 리팩토링 작업을 어느 정도 할 수 있지 않을까 하고 있다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/f223bd9e-e5ea-4542-b5d8-fd2eb922c3fa/image.png" alt=""></p>
<p>처음부터 기간을 널널하게 주는 것보다는 살짝 타이트하게 주면서 조금씩 딜레이되는 편이 나은 것 같더라. 기간이 널널하면 안심하고 있다가 분명 시간이 충분했는데도 촉박하게 끝내기도 하니까 말이다. 흐름이 잘 읽히는 것은 촉박하게 하기보다는 미리미리 해두는 걸 선호하지만 흐름이 잘 안 읽히는 건 끝까지 미루는 경향이 있더라. 앱 개발은 처음 하는 영역이기에 후자에 가깝게 흘러가고 있다. 그러니 이런 건 늘어질 걸 고려해서 좀 촉박하게 잡아야지.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 25 | AI를 활용과 API]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-25</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-25</guid>
            <pubDate>Wed, 02 Sep 2026 08:47:18 GMT</pubDate>
            <description><![CDATA[<h1 id="day-25--ai를-활용과-api">DAY 25 | AI를 활용과 API</h1>
<h2 id="ai-에이전트-활용하기">AI 에이전트 활용하기</h2>
<h3 id="mcp에-대하여">MCP에 대하여</h3>
<p>MCP(Model Context Protocol)은 AI와 서비스 사이의 통신규약이다. 과거에는 N개의 AI가 M개의 서비스를 다룰 때 N*M개의 연결을 구현해야 했는데, MCP가 사실상 표준으로 자리잡으면서 AI와 MCP를 연결하는 N개와 MCP와 서비스를 연결하는 M개로 총 N+M개의 연결만 구현하면 되게 되었다. 이에 따라 AI는 MCP 클라이언트 규격을 갖추고 서비스는 MCP 서버 규격만 갖추면 되므로 파편화된 연동 비용을 획기적으로 줄이고 플러그앤플레이 형태의 연결이 가능해졌다.</p>
<p>LLM을 사용하는 AI 에이전트에게 요청을 하면 AI 에이전트가 클라이언트로서 MCP 서버와 통신한다. MCP 서버는 적절한 Tool의 종류와 사용법을 AI 에이전트에게 알려준다. AI 에이전트는 사용자의 요청에 따라 적절한 MCP Tool을 사용하여 MCP 서버에 작업을 요청해 가며 역할을 수행한다.</p>
<p>우리는 Flutter MCP 서버를 사용해 볼 것이다.</p>
<h3 id="flutter-mcp-준비하기">Flutter MCP 준비하기</h3>
<p>서버라고 하면 외부 어딘가에 있는 서버에 원격 접속해야 할 것 같지만 우리가 사용할 MCP 서버는 외부 서버가 아니라 로컬 서버다. 많은 MCP 서버가 로컬에서 돌아간다.</p>
<p>MCP를 사용하지 않는 AI 에이전트도 Flutter 관련 기능을 수행할 수 있지만 Flutter MCP를 사용하는 편이 훨씬 정확도가 높아진다. 이때, Dart 3.9 이상의 버전이어야 MCP 서버를 사용할 수 있다는 것을 유의하자. (우리의 실습 환경은 Dart 3.12다.)</p>
<p>안드로이드 스튜디오 우측 메뉴에서 [Agent] 를 누르고 로그인한다. 예전에는 채팅모드를 기본값으로 하고 에이전트 모드를 켜야 에이전트 모드로 작동했다는 모양인데 이제는 그냥 에이전트 모드로 되는 듯하다.</p>
<p>설정에서 JSON으로 MCP를 추가한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-js">{
  &quot;mcpServers&quot;: {
    &quot;dart&quot;: {
      &quot;command&quot;: &quot;dart&quot;,
      &quot;args&quot;: [&quot;mcp-server&quot;]
    }
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/19da8c00-9ae7-470b-baf0-f60afeafeb6b/image.png" alt=""></p>
<p>잘 연결되었다면 안드로이드 스튜디오를 재실행한 후 채팅창에 <code>/mcp</code> 를 입력했을 때 사용 가능한 Tool 목록을 알려준다.</p>
<p>터미널에서 적절한 skills를 설치하여 생산성을 높이도록 하겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash"># Flutter skills 설치
npx skills add flutter/agent-plugins --skill &#39;*&#39; --agent universal --yes
&gt;
# Dart skills 설치
npx skills add dart-lang/skills --skill &#39;*&#39; --agent universal --yes</code></pre>
<p>이렇게 설치한 skills는 프로젝트 루트의 <code>.agents/skills</code> 에 마크다운 형태로 저장된다.</p>
<h2 id="api-활용하기">API 활용하기</h2>
<p>Flutter SDK가 제공되지 않은 API를 사용하는 실습을 진행한다. 직접 작성하지 않고 앞서 구축한 MCP를 통한 AI를 활용한 개발을 하도록 하겠다.</p>
<p>API 제공자는 API 문서를 함께 제공하며, 이 문서에는 해당 API를 어떻게 사용할 수 있고 각각의 endpoint에서 받을 수 있는 데이터가 어떻게 생겼는지 등의 정보가 작성되어 있다.</p>
<p>AI 에이전트와 상호작용하여 wordpress API로부터 article 탭을 작성한다. 이 때, 그냥 시키면 유지보수하기 어려워지므로 레이어 단위로 구현하는 흐름 자체는 코드를 직접 작성할 때와 같이 진행한다. 그렇게 하면 아키텍처를 유지할 수 있고, AI를 활용하면서도 코드를 원활히 이해할 수 있다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/acce160e-24d9-4950-a88c-319d27f0bd32/image.png" alt=""></p>
<h2 id="프로젝트-준비하기">프로젝트 준비하기</h2>
<p>그동안 작성했던 기획 wireframe을 기반으로 디자인을 개선한다. Figma로 만든 wireframe에 생성형 AI를 통해 디자인을 입힐 것이다. 우리는 기획과 디자인까지만 생성형 AI의 도움을 받고 개발 과정에서는 AI 에이전트를 붙이지 않고 진행하기로 했다.</p>
<p>쉽진 않다. Claude Design을 어떻게 사용하는 건지 대충 감을 잡고 나니 남은 토큰이 없다. Claude는 토큰 소모가 빠르다는 소문은 듣긴 했지만 정말 쉽지 않다.</p>
<p>무엇보다 배가 고프니 머리가 잘 안 돌아간다. 점심 식대 만 원으로 용산구에서 식사를 해결한다는 건 그렇다. 맛대가리 없는 과자 줄 간식비 있으면 차라리 식비에 보태 줬으면 좋겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 24 | Flutter 데모 앱 만들기: 데이터베이스 연결 下]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-24</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-24</guid>
            <pubDate>Mon, 31 Aug 2026 09:06:54 GMT</pubDate>
            <description><![CDATA[<h1 id="day-24--flutter-데모-앱-만들기-데이터베이스-연결-下">DAY 24 | Flutter 데모 앱 만들기: 데이터베이스 연결 下</h1>
<h2 id="데이터-읽어오기">데이터 읽어오기</h2>
<p>데이터를 읽어오는 코드를 작성하기에 앞서 지난 시간에 Firestore에 넣은 데이터 중 최종 데이터 형태와 맞지 않는 것들을 제거한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/521fa072-0375-49cd-a886-07edca8ec0ca/image.png" alt=""></p>
<p>DB에서 JSON 형태로 전달된 데이터로부터 object를 생성하는 factory constructor를 작성한다.</p>
<blockquote>
<p><code>lib/modesl/my_workout.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  factory MyWorkout.fromMap(Map&lt;String, dynamic&gt; map) {
    return MyWorkout(
      uid: map[&#39;uid&#39;],
      id: map[&#39;id&#39;],
      name: map[&#39;id&#39;],
      workoutDays: map[&#39;workoutDays&#39;],
      imageURL: map[&#39;imageUrl&#39;],
      minutes: map[&#39;minutes&#39;],
      createAt: (map[&#39;createdAt&#39;] as Timestamp).toDate(),
    );
  }
// 뒷 부분 생략</code></pre>
<p>이를 사용하여 DB에서 데이터를 가져와 object로 변환하는 코드를 작성한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략  Future&lt;MyWorkout?&gt; readMyWorkout(String workoutId) async {
    try {
      final myWorkoutCollection = _fs.collection(&#39;myWorkouts&#39;);
      final docRef = myWorkoutCollection.doc(workoutId);
      final docSnapshot = await docRef.get();
      if (!docSnapshot.exists) {
        throw Exception(&#39;no data&#39;);
      }
      final mapData = docSnapshot.data()!;
      return MyWorkout.fromMap(mapData);
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<p>Model에서 <code>Timestamp</code> 를 사용하는 건 SDK에 대한 종속성이 생겨 좋은 방법은 아니지만 우리는 작은 프로젝트니까 이대로 넘어가겠다.</p>
<h3 id="일괄-읽어오기">일괄 읽어오기</h3>
<p><code>MyWorkoutList</code> 에 접근하면 DB에 있는 현재 사용자의 모든 사용자 지정 운동 목록을 불러오는 코드를 작성해 보자.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;List&lt;MyWorkout&gt;&gt; fetchAllMyWorkout(String uid) async {
    try {
      final myWorkoutCollection = _fs.collection(&#39;myWorkouts&#39;);
      final query = myWorkoutCollection
          .where(&#39;uid&#39;, isEqualTo: uid)
          .orderBy(&#39;createdAt&#39;)
          .orderBy(&#39;id&#39;);
      final querySnapshot = await query.get();
      final docSnapshotList = querySnapshot.docs;
      List&lt;MyWorkout&gt; returnData = [];
      for (final doc in docSnapshotList) {
        final mapData = doc.data();
        returnData.add(MyWorkout.fromMap(mapData));
      }
      return returnData;
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<p>이때, View에서 Service에 직접 접근하지 않고 ViewModel을 사용하도록 한다.</p>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; fetchAllMyWorkout() async {
    if (_auth.user == null) {
      return;
    }
    final fetchedWorkouts = await _firebaseStore.fetchAllMyWorkout(_auth.user!.uid);
    _workouts.addAll(fetchedWorkouts);
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<p>그리고 이 ViewModel method를 View에서 페이지가 처음 열리는 순간 call해 주어야 한다. <code>initState()</code> 에서 해주면 된다. <code>MyWorkoutListPage</code> 를 StatefulWidget으로 변경한 후 <code>initState()</code> 를 작성한다.</p>
<blockquote>
<p><code>lib/pages/my_workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
class _MyWorkoutListPageState extends State&lt;MyWorkoutListPage&gt; {
  @override @override
  void initState() {
    super.initState();
    Provider.of&lt;MyWorkoutProvider&gt;(context, listen: false).fetchAllMyWorkout();
  }
// 뒷 부분 생략</code></pre>
<blockquote>
<h4 id="view는-state를-소유하지-않는다">View는 State를 소유하지 않는다</h4>
<p>MVVM 아키텍처에서는 View가 State를 직접 소유하지 않는다. ViewModel이 가지고 있는 State를 listening 하고 있을 뿐이다. State의 변동 사항은 Provider를 통해 전달된다.</p>
</blockquote>
<p>View에 리스트가 비어 있는 경우에 대한 처리를 해준다. 비어 있는 것보다는 로딩 인디케이터를 출력하거나 비어 있음을 명시해 주는 게 좋다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
      body: Consumer&lt;MyWorkoutProvider&gt;(
        builder: (context, myWorkoutProvider, child) {
          List&lt;MyWorkout&gt; workouts = myWorkoutProvider.workouts;
          if (workouts.isEmpty) {
            return Center(
              child: Text(&#39;운동 데이터가 로드되지 않았습니다.&#39;),
            );
          }
          return ListView.builder(
            // 생략
          );
        }
      ),
// 뒷 부분 생략</code></pre>
<p>그리고 이제 DB에 있는 데이터만을 보여줄 것이므로 샘플 데이터를 삭제한다.</p>
<p>이 상태로 실행을 하면... DB 에러가 발생하며 운동 데이터가 로드되지 않는다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/50a49757-3c74-4d53-abf0-162dac7c6d37/image.png" alt=""></p>
<p><code>where</code> 와 <code>orderBy</code> 가 사용된 query에 index가 생성되지 않았기 때문이다. 단일 인덱스는 자동 생성되기 때문에 문제 없이 실행되지만 복합 인덱스를 사용할 경우 개발자가 직접 생성해 주어야 한다. id를 통해 값을 가져오는 건 단일 인덱스라 괜찮지만 방금 것은 복합 인덱스라 오류가 난 것이다. Firebase Console에서 복합 인덱스를 생성해 주도록 한다.</p>
<p>에러 메시지의 링크를 누르면 자동으로 생성되지만 잘 되지 않는다면 수동으로 해주어야 한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/99a4a727-b124-4332-9d37-e0e69449bb70/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/166ba541-130a-405e-abc3-89af27b45d74/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/66e858b1-ea28-4cd2-a64d-a065121a9940/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/8fe77dc1-6d5a-4a8a-ab28-357a9218d87b/image.png" alt=""></p>
<p>화면이 그려지는 도중에 DB로부터 데이터가 asynchronous하게 fetch되면 화면을 그리다 말고 화면을 다시 그려야 하는 이슈가 있다. 이를 해결하기 위해 <code>initState()</code> 코드를 다음과 같이 수정할 수 있다.</p>
<blockquote>
<p><code>lib/pages/my_workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_){
      Provider.of&lt;MyWorkoutProvider&gt;(context,listen:false).fetchAllMyWorkouts();
    });
  }
// 뒷 부분 생략</code></pre>
<h2 id="페이지네이션">페이지네이션</h2>
<p>한 번에 모든 정보를 가져오기엔 부하가 커질 수 있다. 따라서 몇 개만 불러오고 필요 시 추가로 불러오도록 한다. 여기선 5개만 가져오고 스크롤 시 추가로 5개씩 불러오도록 구현하겠다.</p>
<p>이를 위해 query에 limit을 추가한다. argument로 받되 기본값을 5로 두겠다.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;List&lt;MyWorkout&gt;&gt; fetchAllMyWorkout({required String uid, int limit=5}) async {
    try {
      final myWorkoutCollection = _fs.collection(&#39;myWorkouts&#39;);
      final query = myWorkoutCollection
          .where(&#39;uid&#39;, isEqualTo: uid)
          .orderBy(&#39;createdAt&#39;)
          .orderBy(&#39;id&#39;)
          .limit(limit);
      final querySnapshot = await query.get();
      final docSnapshotList = querySnapshot.docs;
      List&lt;MyWorkout&gt; returnData = [];
      for (final doc in docSnapshotList) {
        final mapData = doc.data();
        returnData.add(MyWorkout.fromMap(mapData));
      }
      return returnData;
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<p>named parameter를 사용하는 형태로 변경하였으니 Provider도 수정해 주어야 한다.</p>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; fetchAllMyWorkout() async {
    if (_auth.user == null) {
      return;
    }
    final fetchedWorkouts = await _firebaseStore.fetchAllMyWorkout(
      uid: _auth.user!.uid,
      limit: 5,
    );
    _workouts.addAll(fetchedWorkouts);
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<p>스크롤 시 데이터를 추가로 fetch하도록 하기 위해 마지막으로 불러온 운동이 빈 값이 아니라면 추가로 fetch하도록 코드를 수정하겠다.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;List&lt;MyWorkout&gt;&gt; fetchAllMyWorkout({
    required String uid,
    int limit = 5,
    MyWorkout? lastWorkout,
  }) async {
    try {
      final myWorkoutCollection = _fs.collection(&#39;myWorkouts&#39;);
      Query&lt;Map&lt;String, dynamic&gt;&gt; query = myWorkoutCollection
          .where(&#39;uid&#39;, isEqualTo: uid)
          .orderBy(&#39;createdAt&#39;)
          .orderBy(&#39;id&#39;)
          .limit(limit);
      if (lastWorkout != null) {
        query = query.startAfter([
          Timestamp.fromDate(lastWorkout.createdAt),
          lastWorkout.id,
        ]);
      }
      final querySnapshot = await query.get();
      final docSnapshotList = querySnapshot.docs;
      List&lt;MyWorkout&gt; returnData = [];
      for (final doc in docSnapshotList) {
        final mapData = doc.data();
        returnData.add(MyWorkout.fromMap(mapData));
      }
      return returnData;
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; fetchAllMyWorkouts() async {
    if (_auth.user == null) {
      return;
    }
    final fetchedWorkouts = await _firebaseStore.fetchAllMyWorkout(
      uid: _auth.user!.uid,
      limit: 5,
      lastWorkout: _workouts.lastOrNull,
    );
    _workouts.addAll(fetchedWorkouts);
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<p>이제 스크롤 했을 때 fetch가 수행되도록 controller를 작성한다.</p>
<blockquote>
<p><code>lib/pages/my_workout_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
class _MyWorkoutListPageState extends State&lt;MyWorkoutListPage&gt; {
  final ScrollController _scrollController = ScrollController();
&gt;
  void _scrollListener() {
    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
      Provider.of&lt;MyWorkoutProvider&gt;(context,listen:false).fetchAllMyWorkouts();
    }
  }
&gt;
  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
    WidgetsBinding.instance.addPostFrameCallback((_){
      Provider.of&lt;MyWorkoutProvider&gt;(context,listen:false).fetchAllMyWorkouts();
    });
  }
&gt;  
  @override
  void dispose() {
    _scrollController.removeListener(_scrollListener);
    _scrollController.dispose();
    super.dispose();
  }
&gt;
  @override
  Widget build(BuildContext context) {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(title: Text(&#39;MyWorkoutList&#39;)),
      body: Consumer&lt;MyWorkoutProvider&gt;(
        builder: (context, myWorkoutProvider, child) {
          List&lt;MyWorkout&gt; workouts = myWorkoutProvider.workouts;
          if (workouts.isEmpty) {
            return Center(child: Text(&#39;운동 데이터가 로드되지 않았습니다.&#39;));
          }
          return ListView.builder(
            controller: _scrollController,
            physics: AlwaysScrollableScrollPhysics(),
// 뒷 부분 생략</code></pre>
<p>잘 작동하는지 확인하기 위해 샘플 데이터를 넣어보도록 하겠다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/916526cc-7626-4c68-9d42-299456333912/image.png" alt=""></p>
<h2 id="수정-및-삭제">수정 및 삭제</h2>
<p>수정 및 삭제 시 DB에 반영하는 코드를 추가한다.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
 Future&lt;void&gt; updateMyWorkout(MyWorkout myWorkout) async {
    try {
      final myWorkoutsCollection = _fs.collection(&#39;myWorkouts&#39;);
      final documentRef = myWorkoutsCollection.doc(myWorkout.id);
      documentRef.update(myWorkout.toMap());
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
&gt;
  Future&lt;void&gt; deleteMyWorkout(String workoutId) async {
    try {
      final myWorkoutsCollection = _fs.collection(&#39;myWorkouts&#39;);
      final documentRef = myWorkoutsCollection.doc(workoutId);
      await documentRef.delete();
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; deleteMyWorkout(int index) async {
    _firebaseStore.deleteMyWorkout(_workouts[index].id!);
    _workouts.removeAt(index);
    notifyListeners();
  }
&gt;
  Future&lt;void&gt; updateMyWorkoutDays({
    required List&lt;bool&gt; isSelected,
    required int workoutIndex,
  }) async {
    _workouts[workoutIndex].workoutDays = isSelected;
    _firebaseStore.updateMyWorkout(_workouts[workoutIndex]);
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/c9475e657d05abb72b40a5a23c08fb7bd89dc079">&gt;&gt;&gt; GitHub Repository at this point (c9475e6)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 23 | Flutter 데모 앱 만들기: 데이터베이스 연결 上]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-23</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-23</guid>
            <pubDate>Fri, 28 Aug 2026 07:04:49 GMT</pubDate>
            <description><![CDATA[<h1 id="day-23--flutter-데모-앱-만들기-데이터베이스-연결-上">DAY 23 | Flutter 데모 앱 만들기: 데이터베이스 연결 上</h1>
<h2 id="사용자-지정-목록-완성하기">사용자 지정 목록 완성하기</h2>
<p><code>MyWorkoutList</code> 를 마저 작성해 본다. 운동 삭제 기능을 구현하고 운동 요일을 state에 반영해야 한다.</p>
<h3 id="운동-삭제">운동 삭제</h3>
<p>삭제 로직은 어렵지 않다. 운동의 index를 받아 해당 index의 운동을 목록에서 제거하면 된다. 그리고 state가 변경되었음을 알리기 위해 <code>notifyListeners();</code> 를 해준다. 이를 수행하는 interface를 Provider logic 영역에 작성한다.</p>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; deleteMyWorkout(int index) async {
    _workouts.removeAt(index);
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<p>운동 타일의 삭제 버튼에서 Provider를 통해 앞서 작성한 코드를 사용한다.</p>
<blockquote>
<p><code>lib/widgets/workout_tile.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
            IconButton(
              onPressed: () {
                Provider.of&lt;MyWorkoutProvider&gt;(
                  context,
                  listen: false,
                ).deleteMyWorkout(index);
              },
              color: colorScheme.outlineVariant,
              icon: Icon(
                Icons.delete_outline,
                size: textTheme.titleLarge?.fontSize,
              ),
            ),
// 뒷 부분 생략</code></pre>
<h3 id="목록-길이-제한하여-저장하기">목록 길이 제한하여 저장하기</h3>
<p>운동 요일을 state에 기록하기 위해 model에 <code>workoutDays</code> property를 추가한다.</p>
<blockquote>
<p><code>lib/models/my_workout.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;dart:core&#39;;
&gt;
class MyWorkout {
  String name;
  String imageURL;
  int minutes;
  List&lt;bool&gt; workoutDays=List.filled(7, false, growable: false);
&gt;
  MyWorkout({
    required this.name,
    required this.imageURL,
    required this.minutes,
    List&lt;bool&gt;? workoutDays,
  }): workoutDays=_normalizeDays(workoutDays);
&gt;
  static List&lt;bool&gt; _normalizeDays(List&lt;bool&gt;? days) {
    assert(days == null || days.length == 7, &#39;운동 요일의 Length가 7이어야 합니다&#39;);
    if (days == null) {
      return List.filled(7, false, growable: false);
    }
    return List&lt;bool&gt;.of(days, growable: false);
  }
}</code></pre>
<p><code>_normalizeDays</code> method는 형식에 맞지 않는 List가 전달되었을 때에 대한 예외처리다. <code>workouDays</code> 는 null이거나 7개의 bool로 이루어져 있어야 한다. 그 외의 경우는 <code>assert</code> 를 통해 런타임 시점에 오류를 알린다. 사용자가 이 목록을 잘못 전달하는 상황은 발생하지 않고 오직 개발자 실수로 인해서만 발생할 수 있는 문제이기 때문에 이걸로 충분하다.</p>
<p><code>null</code> 이 전달되면 크기를 바꿀 수 없는 false로 채워진 목록을 return하고, 7개의 bool로 이루어진 목록이 전달되면 List의 크기를 불변으로 만들어 return한다.</p>
<p><code>WorkoutDaySelector</code> 에서 요일 toggle을 누름에 따라 call할 Provider mehod를 작성한다. 이 또한 <code>notifyListeners();</code> 를 해주어야 한다.</p>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; updateMyWorkoutDays({
    required List&lt;bool&gt; isSelected,
    required int workoutIndex,
  }) async {
    _workouts[workoutIndex].workoutDays = isSelected;
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<p>toggle을 누르면 UI상으로만 요일 선택이 되도록 작성되어 있었는데, state에도 반영되도록 코드를 수정한다. 이때, 이것이 어느 운동의 요일 선택기인지 알 수 있도록 <code>WorkoutDaySelector</code> 에 <code>workoutIndex</code> property를 추가하여 constructor로 전달 받는다.</p>
<blockquote>
<p><code>lib/widgets/workout_day_selector.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 잎 부분 생략
class WorkoutDaySelector extends StatefulWidget {
  final int workoutIndex;
  const WorkoutDaySelector({super.key, required this.workoutIndex});
&gt;
  @override
  State&lt;WorkoutDaySelector&gt; createState() =&gt; _WorkoutDaySelectorState();
}
&gt;
class _WorkoutDaySelectorState extends State&lt;WorkoutDaySelector&gt; {
  List&lt;bool&gt; isSelected = List.filled(7, false);
&gt;
  void updateIsSelected(int index) {
    isSelected[index] = !isSelected[index];
    Provider.of&lt;MyWorkoutProvider&gt;(context, listen: false).updateMyWorkoutDays(
      isSelected: List&lt;bool&gt;.from(isSelected),
      workoutIndex: widget.workoutIndex,
    );
  }
// 뒷 부분 생략</code></pre>
<p>기본값으로 모두 false인 List를 사용하도록 구현되어 있는데  state상의 요일 목록을 불러오도록 수정한다. 이 작업은 <code>initState()</code> 에서 해 주어야 한다.</p>
<blockquote>
<p><code>lib/widgets/workout_day_selector.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  @override
  void initState() {
    super.initState();
    isSelected = List&lt;bool&gt;.from(
      Provider.of&lt;MyWorkoutProvider&gt;(
        context,
        listen: false,
      ).workouts[widget.workoutIndex].workoutDays,
    );
  }
// 뒷 부분 생략</code></pre>
<p><code>WorkoutDaySelector</code> 는 widget tree에서 상당히 아래에 위치하고 있지만, widget tree를 타고 가지 않아도 Provider를 통해 state를 직접 접근할 수 있다.</p>
<h3 id="state-key-설정하기">state key 설정하기</h3>
<p>ListView로 동일 widget을 여러 개 뿌릴 경우 ListTile 삭제 시 state가 형제 widget에 잘못 붙는 이슈가 발생할 수 있다. 이를 방지하기 위해 state key를 사용하여 각각의 widget을 식별하도록 작성해야 한다.</p>
<p>value key를 사용하면 index 같은 단순한 데이터를 key로 사용할 수 있지만 index가 바뀌었을 때 state가 잘못된 widget에 붙는다. ListTile을 따라 붙는 게 아니라 index를 따라 붙어버린 것이다.</p>
<p>따라서 개별 ListTile의 불변 데이터를 key로 사용하는 편이 좋다. 여기서는 <code>workout</code> 을 object key로 사용하겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
return WorkoutTile(
  key: ObjectKey(workout),
  index: index - 1,
  name: workout.name,
  image: workout.imageURL,
  minutes: workout.minutes,
);
// 뒷 부분 생략</code></pre>
<p>이제 [0, 1, 2] 목록에서 1을 삭제한다고 해서 1의 state가 2(였던 것, 이제는 1이 된.)에 붙지 않고 적절한 항목을 찾아갈 수 있다.</p>
<h2 id="데이터베이스-활용하기">데이터베이스 활용하기</h2>
<p>데이터베이스를 사용하기 위해 Firebase의 Firestore를 설정하도록 하겠다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/4548a5cf-7609-439b-8a0b-a4eaee8c8d08/image.png" alt=""></p>
<p>우리 프로젝트의 DB 버전은 Standard로 충분하다. ID는 기본값으로 두고 지역은 서울로 선택한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/643513da-b2da-4c46-bc20-2ec1f1b2e8b2/image.png" alt=""></p>
<p>규칙은 Storage랑 동일하게 해주면 되는데 여기서는 다른 방식으로 해보겠다. (결국 같은 말인데 여기서는 함수를 사용해 보았다.)</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/a0ec64e1-3b9f-4da1-89b9-1dd551e43fe1/image.png" alt=""></p>
<p>데이터를 담을 collection을 생성한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/1294ba7a-45e1-4f83-9fa6-38a96e93ae41/image.png" alt=""></p>
<p>collection에 들어가는 데이터는 document라고 하며, 각 document 안에는 key-value 형태의 JSON 데이터가 저장된다. document 안에 또 collection을 넣어 sub-collection을 만들 수 있지만 매우 복잡해질 수 있다.</p>
<p><a href="https://pub.dev/packages/cloud_firestore">cloud_firestore</a> package를 가져다 사용할 것이다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml">dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
  intl: ^0.20.3
  go_router: ^17.4.0
  shared_preferences: ^2.5.5
  firebase_core: ^4.13.0
  firebase_auth: ^6.5.7
  image_picker: ^1.2.3
  firebase_storage: ^13.4.6
  provider: ^6.1.5+1
  cloud_firestore: ^6.8.0</code></pre>
<p>Firestore를 사용하는 Service의 초안을 작성한다.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:cloud_firestore/cloud_firestore.dart&#39;;
import &#39;../models/my_workout.dart&#39;;
&gt;
class FirestoreService {
  FirebaseFirestore _fs = FirebaseFirestore.instance;
&gt;  
  Future&lt;void&gt; createMyWorkout(MyWorkout workout) async {
    try {
&gt;      
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
&gt;  
  Future&lt;MyWorkout?&gt; readMyWorkout(String workoutId) async {
    try {
&gt;
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
    return null;
  }
&gt;  
  Future&lt;void&gt; updateMyWorkout(MyWorkout workout) async {
    try {
&gt;
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
&gt;
  Future&lt;void&gt; deleteMyWorkout(String workoutId) async {
    try {
&gt;
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
}</code></pre>
<p>먼저 Create를 구현해 본다.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; createMyWorkout(MyWorkout myWorkout) async {
    try {
      final myWorkoutCollection = _fs.collection(&#39;myworkouts&#39;);
      Map&lt;String, dynamic&gt; createData = {
        &#39;name&#39;: myWorkout.name,
        &#39;minutes&#39;: myWorkout.minutes,
        &#39;imageUrl&#39;: myWorkout.imageURL,
        &#39;workoutDays&#39;: myWorkout.workoutDays,
      };
      await myWorkoutCollection.add(createData);
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<p>그런데 Map으로 변환해주는 녀석은 Service보다는 Model에 구현되어 있는 편이 낫다.</p>
<blockquote>
<p><code>lib/models/my_workout.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Map&lt;String, dynamic&gt; toMap() {
    return {
      &#39;name&#39;: name,
      &#39;minutes&#39;: minutes,
      &#39;imageURL&#39;: imageURL,
      &#39;workoutDays&#39;: workoutDays,
    };
  }
// 뒷 부분 생략</code></pre>
<p>우리는 document 하나와 model object 하나를 1대1 매칭하여 설계했다. 이때, document의 JSON 데이터만으로는 ID가 나오지 않아 object를 식별하기 어렵다. 따라서 개발 편의성을 위해 document id를 document 내부에 중복하여 넣는 게 사용하기 편하다.</p>
<blockquote>
<p><code>lib/services/firestore_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; createMyWorkout(MyWorkout myWorkout) async {
    try {
      final myWorkoutCollection = _fs.collection(&#39;myWorkouts&#39;);
      final docRef = await myWorkoutCollection.add(myWorkout.toMap());
      docRef.update({&#39;id&#39;: docRef.id});
    } catch (e) {
      throw Exception(&#39;db error: $e&#39;);
    }
  }
// 뒷 부분 생략</code></pre>
<p>이에 따라 <code>MyWorkout</code> 에도 <code>id</code> property를 추가하고 <code>toMap()</code> method에도 반영해 주어야 한다.</p>
<p>현재 구현으로는 운동을 누가 추가했는지 알 수 없다. 그런데 실제로는 그 운동을 추가한 사용자에게만 해당 운동을 보여주어야 한다. 따라서 각 운동 데이터에 사용자 정보를 담는 uid 값을 추가해 주어야 한다. 하는 김에 생성 일시도 추가해 보자.</p>
<blockquote>
<p><code>lib/models/my_workout.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
class MyWorkout {
  String? id;
  String name;
  String imageURL;
  int minutes;
  List&lt;bool&gt; workoutDays = List.filled(7, false, growable: false);
  String? uid;
  DateTime createdAt;
&gt;
  MyWorkout({
    this.id,
    required this.name,
    required this.imageURL,
    required this.minutes,
    this.uid,
    List&lt;bool&gt;? workoutDays,
    DateTime? createAt,
    // }): workoutDays=workoutDays??List.filled(7, false, growable: false);
  }) : workoutDays = _normalizeDays(workoutDays),
       createdAt = createAt ?? DateTime.now();
// 뒷 부분 생략</code></pre>
<p>Provider에서 운동을 추가할 때 uid를 전달하게 작성하겠다.</p>
<blockquote>
<p><code>lib/logics/my_workout_provider.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  Future&lt;void&gt; addMyWorkout(MyWorkout workout) async {
    workout.uid = _auth.user?.uid;
    await _firebaseStore.createMyWorkout(workout);
    _workouts.add(workout);
    notifyListeners();
  }
// 뒷 부분 생략</code></pre>
<p>Firestore Service에서 Firebase Auth Service에 직접 접근하여 uid를 가져오는 것도 가능하다. 동일 레이어끼리의 종속성은 아키텍처 관점에서 충분히 가능한 일이다. 그러나 Service는 ViewModel에서 접근하기로 했으니 그런 접근 방식은 지양한다. 어차피 ViewModel에서 여러 Service를 들고 있으니 ViewModel이 데이터를 넘겨 주도록 한다.</p>
<p>운동을 추가하면 Firestore에 반영된다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/d7f93296-eb82-4314-b0c6-59cd51300b76/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/1afb93ae-4aee-42ca-a496-db69ad030983/image.png" alt=""></p>
<p>이번 시간에는 Create까지만 하고 나머지는 이후에 이어서 작성하도록 하겠다.</p>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/e0f0884fd09a7daad475abb2d88e67f4e9112a7c">&gt;&gt;&gt; GitHub Repository at this point (e0f0884)</a></p>
</blockquote>
<hr>
<h2 id="여담-여러-개의-state가-필요할-때">여담: 여러 개의 state가 필요할 때</h2>
<p>한 수강생이 점심 시간에 강사님께 질문을 하는데 나갈 준비를 하며 들어보니 대충 유추 가능한 영역이었다. 그래서 그냥 식사를 하고 오려 했는데 내가 유추할 수 있던 영역 외에 이슈가 하나 있다고 밑밥을 깔고 설명하기 시작해서 잠시 머물러 보았다. 그리고 결국 다시 자리에 앉아 이야기를 마저 듣다 식사를 하러 갔다.</p>
<p><code>lib/main.dart</code> 에서 <code>providers</code> property에는 목록을 전달했다. 그러니 여러 개의 Provider가 필요하면 여기에 그것들을 열거하면 되겠지 생각했고 실제로 그랬다. 그런데 사용하는 측에서 이슈가 있었다. <code>Consumer&lt;Generic&gt;</code> 에는 하나의 Provider만 전달할 수 있는 거 아닌가.</p>
<p>이런 경우에 사용할 수 있는 방법이 몇 가지 있다고 한다.</p>
<p>먼저, 한 페이지를 여러 View로 나누고 각각의 View가 state와 1대1로 연결되도록 구현하는 것이다. 이것이 Provider가 지향하는 정석적인 아키텍처이기도 하다. 그런데 이렇게 작성하면 boilerplate 코드로 인한 개발 생산성이 떨어질 수 있다.</p>
<p>다른 방법으로는 여러 state를 묶는 더 큰 state를 생성하는 것이 있다. 가령 <code>workout</code> 과 <code>user</code> 를 동시에 사용해야 한다면 그 둘을 property로 갖는 상위 state를 추가로 구현하는 것이다. 가능은 한데 내 눈에는 별로 좋아보이지 않는다.</p>
<p>그리고 하나의 View가 여러 개의 state와 연결되도록 1대多로 연결하는 방법이 있다. Riverpod 예제에 이런 식으로 작성되어 있는 경우가 많다고 하더라.</p>
<p>명확한 아키텍처와 개발 생산성은 어느 정도 trade-off 관계에 있다. 어느 쪽을 더 중요시할지는 개발자의 판단(혹은 팀의 약속)에 달려 있다. 나는 개인적으로 전자를 더 우선시한다. 개발 생산성은 AI를 통해 얼마든지 높일 수 있지만 아키텍처는 한 번 잡아놓으면 개선하는 게 더 어렵기 때문이다.</p>
<p>그래서 내 프로젝트에서는 Riverpod을 사용하되 기본적으로는 View와 State를 1대1로 구현하고, boilerplate로 인한 비합리성이 커지는 부분에 한해서 1대多로 선택적으로 구현하려고 계획하고 있다.</p>
]]></description>
        </item>
    </channel>
</rss>