<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>monkshark.dev</title><link>https://monkshark.github.io/</link><description>Recent content on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Wed, 15 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://monkshark.github.io/index.xml" rel="self" type="application/rss+xml"/><item><title>#11 - 작업이 전부 날아갔다</title><link>https://monkshark.github.io/p/lost-changes/</link><pubDate>Wed, 15 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/lost-changes/</guid><description>&lt;h2 id="날아갔다"&gt;&lt;a href="#%eb%82%a0%ec%95%84%ea%b0%94%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;날아갔다
&lt;/h2&gt;&lt;p&gt;2026년 4월 3일. 전날부터 UI 전면 개편 작업을 하고 있었다. 앱의 거의 모든 화면을 건드리는 큰 작업이었다. 게시판 레이아웃을 바꾸고, 채팅에 읽음 표시를 넣고, 개인정보 동의 화면을 만들고, 교사용 시간표를 추가하고, Cloud Functions로 푸시 알림을 연결하고 — 한 마디로 앱 전체를 뜯어고치는 중이었다.&lt;/p&gt;
&lt;p&gt;그리고 한순간에 전부 사라졌다.&lt;/p&gt;
&lt;h2 id="배경-onedrive-폴더"&gt;&lt;a href="#%eb%b0%b0%ea%b2%bd-onedrive-%ed%8f%b4%eb%8d%94" class="header-anchor"&gt;&lt;/a&gt;배경: OneDrive 폴더
&lt;/h2&gt;&lt;p&gt;프로젝트 폴더가 OneDrive 동기화 경로 안에 있었다. 처음부터 의도한 건 아니고, 바탕화면이 OneDrive에 연결되어 있었는데 거기서 프로젝트를 만든 거다.&lt;/p&gt;
&lt;p&gt;평소에는 별 문제가 없었다. 파일을 수정하면 OneDrive가 알아서 클라우드에 올리고, 혹시 모를 상황에 백업도 되니까 오히려 편하다고 생각했다. 실제로 한동안 아무 탈 없이 잘 돌아갔다.&lt;/p&gt;
&lt;p&gt;문제는 이 구조가 Flutter 프로젝트와 근본적으로 맞지 않는다는 점이다. &lt;code&gt;build/&lt;/code&gt;, &lt;code&gt;.dart_tool/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt; 같은 폴더는 빌드할 때마다 수천 개의 파일을 생성하고 삭제한다. OneDrive는 이 파일들을 전부 동기화하려고 한다. 파일 잠금이 걸리고, 동기화 충돌이 나고, 결국 빌드 자체가 안 되는 상황이 온다.&lt;/p&gt;
&lt;h2 id="터진-순간"&gt;&lt;a href="#%ed%84%b0%ec%a7%84-%ec%88%9c%ea%b0%84" class="header-anchor"&gt;&lt;/a&gt;터진 순간
&lt;/h2&gt;&lt;p&gt;빌드가 안 됐다. 정확한 에러는 기억 안 나지만, 캐시나 빌드 파일이 꼬인 전형적인 증상이었다. &lt;code&gt;flutter clean&lt;/code&gt;을 해야 하는 상황.&lt;/p&gt;
&lt;p&gt;그런데 OneDrive 동기화가 걸려 있으면 clean이 제대로 안 된다. 파일을 지워도 OneDrive가 클라우드에서 다시 복원하거나, 동기화 중인 파일이라 삭제가 안 되거나, 잠금이 걸려서 빌드 디렉토리를 못 지운다.&lt;/p&gt;
&lt;p&gt;그래서 OneDrive 백업 설정을 껐다. &amp;ldquo;이 폴더 동기화 중지.&amp;rdquo; 이러면 깔끔하게 clean build를 할 수 있을 거라고 생각했다.&lt;/p&gt;
&lt;p&gt;OneDrive는 동기화를 중지하면서 로컬 파일을 클라우드에 마지막으로 저장된 상태로 되돌렸다. 마지막 커밋 시점. 커밋하지 않은 모든 변경사항 — UI 전면 개편의 모든 작업이 증발했다.&lt;/p&gt;
&lt;p&gt;터미널에서 &lt;code&gt;git status&lt;/code&gt;를 쳤을 때 &lt;code&gt;nothing to commit, working tree clean&lt;/code&gt;이 뜬 그 순간의 기분은 설명하기 어렵다. 분명 수십 개 파일을 수정했는데, clean이라니.&lt;/p&gt;
&lt;h2 id="날아간-것들"&gt;&lt;a href="#%eb%82%a0%ec%95%84%ea%b0%84-%ea%b2%83%eb%93%a4" class="header-anchor"&gt;&lt;/a&gt;날아간 것들
&lt;/h2&gt;&lt;p&gt;커밋 메시지에 남긴 복구 목록이다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Restored (lost from git reset --hard):
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Category chips Wrap, post action sheet, bookmark, chat icon
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Chat: leave, message delete, read receipts, system messages, limit(30)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Privacy consent checkbox, privacy policy in-app screen
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Home refresh (WidgetsBindingObserver, RefreshIndicator), board→recent order
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Crashlytics + crash log to Firestore
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Onboarding→login flow, HomeScreen tab refresh
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- My activity 3 tabs (posts/comments/bookmarks)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Teacher timetable, today highlight, font 12px
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Cloud Functions: chat push, reply notifications
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Firestore rules: crash_logs, field-level post update
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;실제로는 &lt;code&gt;git reset --hard&lt;/code&gt;를 한 게 아니라 OneDrive가 파일을 되돌린 건데, 효과는 같았기에 커밋 메시지에 그렇게 적었다. 핵심은 커밋하지 않은 작업이 전부 사라졌다는 것이다.&lt;/p&gt;
&lt;p&gt;하나씩 보면:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;채팅 시스템 대규모 개선.&lt;/strong&gt; 읽음 표시, 메시지 삭제, 나가기 기능, 시스템 메시지, 메시지 30개 제한. 채팅 화면만 344줄이 바뀌었다. 이건 단순 UI가 아니라 Firestore 쿼리, Cloud Functions, 클라이언트 로직이 엮인 기능이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;개인정보 처리방침.&lt;/strong&gt; 회원가입 시 동의 체크박스, 인앱 개인정보처리방침 화면. 법적으로 필요한 기능이라 빠뜨릴 수 없었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;내 활동 3탭.&lt;/strong&gt; 내 글, 내 댓글, 북마크를 탭으로 나눠 보여주는 화면. 각 탭이 별도 Firestore 쿼리를 가지고 있었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;교사 시간표.&lt;/strong&gt; 일반 학생 시간표와 구조가 다르다. 교사는 여러 학급에서 수업하니까 학년/반 선택이 필요하고, 멀티 클래스 선택을 지원해야 했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloud Functions.&lt;/strong&gt; 채팅 푸시 알림, 댓글 답글 알림. 클라이언트가 아니라 서버 사이드 코드라 별도로 테스트하고 배포한 거였다.&lt;/p&gt;
&lt;p&gt;전부 커밋 전이었다. Git에 흔적이 없다.&lt;/p&gt;
&lt;h2 id="복구-6시간"&gt;&lt;a href="#%eb%b3%b5%ea%b5%ac-6%ec%8b%9c%ea%b0%84" class="header-anchor"&gt;&lt;/a&gt;복구: 6시간
&lt;/h2&gt;&lt;p&gt;같은 날 오전 6시 반에 복구 작업을 시작해서, 12:26에 복구 커밋을 찍었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;18 files changed, 1,432 insertions(+), 314 deletions(-)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;6시간 만에 18개 파일, 1,432줄을 다시 쳤다. 거기에 원래 계획에 없던 피드백 시스템(버그 신고 + 학생회 건의함)까지 새로 추가했다.&lt;/p&gt;
&lt;p&gt;한 번 만들어본 코드를 다시 치는 건 확실히 빠르다. &amp;ldquo;이 화면에 이 위젯이 필요하고, 이 Firestore 쿼리를 써야 하고, 이 Cloud Function이 이 트리거로 동작한다&amp;rdquo; — 설계를 처음부터 고민할 필요가 없으니까. 머릿속에 완성된 그림이 있고, 타이핑만 하면 된다.&lt;/p&gt;
&lt;p&gt;하지만 원본과 같은 코드는 아니다. 처음 만들 때는 시행착오를 거친다. 이 변수명이 맞나, 이 조건 분기가 맞나, 이 에러 핸들링은 충분한가 — 그 과정에서 다듬어진 디테일들이 있다. 복구할 때는 &amp;ldquo;대충 이랬다&amp;quot;로 넘어간다. 복구한 코드는 기능은 같지만, 처음 코드가 가졌던 미세한 개선들은 빠져 있다.&lt;/p&gt;
&lt;p&gt;그래도 전부 잃는 것보단 훨씬 낫다.&lt;/p&gt;
&lt;h2 id="이후-바꾼-것들"&gt;&lt;a href="#%ec%9d%b4%ed%9b%84-%eb%b0%94%ea%be%bc-%ea%b2%83%eb%93%a4" class="header-anchor"&gt;&lt;/a&gt;이후 바꾼 것들
&lt;/h2&gt;&lt;h3 id="프로젝트-폴더-이동"&gt;&lt;a href="#%ed%94%84%eb%a1%9c%ec%a0%9d%ed%8a%b8-%ed%8f%b4%eb%8d%94-%ec%9d%b4%eb%8f%99" class="header-anchor"&gt;&lt;/a&gt;프로젝트 폴더 이동
&lt;/h3&gt;&lt;p&gt;OneDrive 동기화 경로 밖으로 프로젝트를 옮겼다. &lt;code&gt;C:\Users\Desktop\&lt;/code&gt; 아래에 두되, OneDrive 동기화 대상에서 제외했다. 백업은 Git이 하면 된다. 클라우드 동기화 서비스는 코드 저장소가 아니다.&lt;/p&gt;
&lt;h3 id="커밋-습관"&gt;&lt;a href="#%ec%bb%a4%eb%b0%8b-%ec%8a%b5%ea%b4%80" class="header-anchor"&gt;&lt;/a&gt;커밋 습관
&lt;/h3&gt;&lt;p&gt;&amp;ldquo;큰 작업 끝나면 한 번에 커밋하자&amp;rdquo; → &amp;ldquo;작은 단위로 자주 커밋하자&amp;quot;로 바꿨다.&lt;/p&gt;
&lt;p&gt;이전에는 여러 기능을 한꺼번에 만들고 한 커밋에 몰아넣었다. 커밋 메시지에 &amp;ldquo;Major update: board, notifications, admin web, UI overhaul&amp;rdquo; 같은 게 나오는 이유다. 깔끔한 커밋 히스토리보다 작업 흐름을 끊지 않는 게 더 중요하다고 생각했다.&lt;/p&gt;
&lt;p&gt;사건 이후에는 기능 하나가 완성되면 바로 커밋한다. 완벽하지 않아도. 커밋 메시지가 좀 지저분해져도. 커밋하지 않은 코드는 존재하지 않는 코드다.&lt;/p&gt;
&lt;h3 id="gitignore"&gt;&lt;a href="#gitignore" class="header-anchor"&gt;&lt;/a&gt;.gitignore
&lt;/h3&gt;&lt;p&gt;OneDrive와 무관하게, &lt;code&gt;.gitignore&lt;/code&gt;를 꼼꼼하게 관리하기 시작했다. &lt;code&gt;build/&lt;/code&gt;, &lt;code&gt;.dart_tool/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt; 같은 폴더가 동기화되거나 추적되지 않도록. 이건 OneDrive 사건과 직접 관련은 없지만, 빌드 아티팩트가 소스 코드와 섞이면 안 된다는 걸 체감한 뒤로 더 신경 쓰게 됐다.&lt;/p&gt;
&lt;h2 id="돌이켜보면"&gt;&lt;a href="#%eb%8f%8c%ec%9d%b4%ec%bc%9c%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌이켜보면
&lt;/h2&gt;&lt;p&gt;클라우드 동기화 폴더에서 개발하는 건 시한폭탄이다. OneDrive, Dropbox, iCloud Drive — 전부 마찬가지다. 이 서비스들은 문서, 사진, 일반 파일을 동기화하도록 설계되었지, 수천 개의 임시 파일을 초 단위로 생성하고 삭제하는 개발 프로젝트를 위한 게 아니다.&lt;/p&gt;
&lt;p&gt;Git은 이미 완벽한 분산 백업 시스템이다. &lt;code&gt;git push&lt;/code&gt;만 해도 코드는 원격 저장소에 안전하게 보관된다. 그 위에 OneDrive까지 겹치면 동기화 충돌, 파일 잠금, 빌드 실패가 생기고, 최악의 경우 이 글처럼 작업이 통째로 날아간다.&lt;/p&gt;
&lt;p&gt;개발 폴더는 동기화 범위 밖에 둬라. 백업은 Git에 맡겨라. 그리고 커밋은 자주 해라.&lt;/p&gt;</description></item><item><title>#10 - 앱 시작 속도 줄이기</title><link>https://monkshark.github.io/p/app-speed/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/app-speed/</guid><description>&lt;h2 id="느린-시작"&gt;&lt;a href="#%eb%8a%90%eb%a6%b0-%ec%8b%9c%ec%9e%91" class="header-anchor"&gt;&lt;/a&gt;느린 시작
&lt;/h2&gt;&lt;p&gt;앱을 켜면 흰 화면이 2~3초. 그 동안 사용자는 앱이 멈춘 건지, 로딩 중인 건지 모른다. 실제로는 &lt;code&gt;main()&lt;/code&gt; 함수에서 Firebase, 타임존, 알림, FCM, AppCheck, Analytics, 시간표 프리로드 등을 전부 초기화하느라 시간이 걸리는 것이었다.&lt;/p&gt;
&lt;h2 id="원래-구조"&gt;&lt;a href="#%ec%9b%90%eb%9e%98-%ea%b5%ac%ec%a1%b0" class="header-anchor"&gt;&lt;/a&gt;원래 구조
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Firebase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseAppCheck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebasePerformance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setPerformanceCollectionEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseAnalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setAnalyticsCollectionEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SettingData&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;setupServiceLocator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;initializeDateFormatting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DailyMealNotification&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializeNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheduleDailyNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FcmService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;DeepLinkService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MyApp&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;await&lt;/code&gt;가 줄줄이 이어진다. 각각은 빠르지만, 직렬로 실행하면 합산된다. Firebase 초기화 200ms, AppCheck 300ms, 알림 설정 200ms, FCM 200ms&amp;hellip; 합치면 1~2초. 여기에 네트워크가 느린 날이면 더 길어진다.&lt;/p&gt;
&lt;p&gt;문제는 이 중 &lt;strong&gt;화면을 띄우는 데 정말 필요한 것&lt;/strong&gt;은 일부뿐이라는 거다.&lt;/p&gt;
&lt;h2 id="핵심-질문-runapp-전에-뭐가-꼭-필요한가"&gt;&lt;a href="#%ed%95%b5%ec%8b%ac-%ec%a7%88%eb%ac%b8-runapp-%ec%a0%84%ec%97%90-%eb%ad%90%ea%b0%80-%ea%bc%ad-%ed%95%84%ec%9a%94%ed%95%9c%ea%b0%80" class="header-anchor"&gt;&lt;/a&gt;핵심 질문: runApp 전에 뭐가 꼭 필요한가
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;runApp()&lt;/code&gt; 이전에 완료되어야 하는 것:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Firebase 초기화&lt;/strong&gt; — 거의 모든 기능이 의존&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SettingData&lt;/strong&gt; — 테마, 언어 설정을 읽어야 첫 화면을 그릴 수 있음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ServiceLocator&lt;/strong&gt; — DI 컨테이너 설정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;날짜 포맷&lt;/strong&gt; — 화면에 날짜를 표시하려면 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;runApp()&lt;/code&gt; 이후에 해도 되는 것:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AppCheck&lt;/strong&gt; — 보안 검증이지만 첫 화면에 바로 필요하지 않음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance/Analytics&lt;/strong&gt; — 수집 시작이 몇 초 늦어도 상관없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;알림 스케줄링&lt;/strong&gt; — 앱이 뜬 후에 설정해도 됨&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FCM&lt;/strong&gt; — 푸시 토큰 등록이 약간 늦어도 사용자가 모름&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;딥링크&lt;/strong&gt; — 앱이 뜬 후 처리해도 UX에 영향 없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시간표 프리로드&lt;/strong&gt; — 화면을 열 때 로드해도 되는 데이터&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;홈 위젯&lt;/strong&gt; — 백그라운드에서 갱신하면 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="바뀐-구조"&gt;&lt;a href="#%eb%b0%94%eb%80%90-%ea%b5%ac%ec%a1%b0" class="header-anchor"&gt;&lt;/a&gt;바뀐 구조
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;SystemChrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setPreferredOrientations&lt;/span&gt;&lt;span class="p"&gt;([...]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Firebase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;KakaoSdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializeTimeZones&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setLocalLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Asia/Seoul&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;providerContainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProviderContainer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 필수: SettingData + ServiceLocator만 await
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;SettingData&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;setupServiceLocator&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;initializeDateFormatting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt; &lt;span class="c1"&gt;// ← 여기서 화면이 뜬다
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// UI가 뜬 후 나머지를 백그라운드로
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_deferredInit&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;runApp()&lt;/code&gt; 전에는 진짜 필수적인 것만 남기고, 나머지는 &lt;code&gt;_deferredInit()&lt;/code&gt;으로 뺐다. &lt;code&gt;Future.wait()&lt;/code&gt;으로 독립적인 초기화 2개를 병렬 실행하는 것도 포인트다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unawaited()&lt;/code&gt;는 &amp;ldquo;이 Future의 완료를 기다리지 않겠다&amp;quot;는 명시적 선언이다. &lt;code&gt;_deferredInit()&lt;/code&gt;을 그냥 호출해도 되지만, &lt;code&gt;unawaited()&lt;/code&gt;로 감싸면 의도가 분명하고, lint 경고도 안 뜬다.&lt;/p&gt;
&lt;h2 id="_deferredinit-안전한-백그라운드-초기화"&gt;&lt;a href="#_deferredinit-%ec%95%88%ec%a0%84%ed%95%9c-%eb%b0%b1%ea%b7%b8%eb%9d%bc%ec%9a%b4%eb%93%9c-%ec%b4%88%ea%b8%b0%ed%99%94" class="header-anchor"&gt;&lt;/a&gt;_deferredInit: 안전한 백그라운드 초기화
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;span class="lnt"&gt;25
&lt;/span&gt;&lt;span class="lnt"&gt;26
&lt;/span&gt;&lt;span class="lnt"&gt;27
&lt;/span&gt;&lt;span class="lnt"&gt;28
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_deferredInit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_safeInit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;AppCheck&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FirebaseAppCheck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;androidProvider:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fromEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;dart.vm.product&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AndroidProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;playIntegrity&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AndroidProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_safeInit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Performance&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FirebasePerformance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setPerformanceCollectionEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_safeInit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Analytics&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FirebaseAnalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setAnalyticsCollectionEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fromEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;dart.vm.product&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_preloadSubjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_preloadSubjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DailyMealNotification&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializeNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheduleDailyNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FirebaseMessaging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onBackgroundMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firebaseMessagingBackgroundHandler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FcmService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeepLinkService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WidgetService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;WidgetService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updateAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;HomeWidget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;registerInteractivityCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;widgetBackgroundCallback&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;_deferredInit()&lt;/code&gt; 안에서도 독립적인 것들은 &lt;code&gt;unawaited()&lt;/code&gt;로 병렬 실행한다. AppCheck, Performance, Analytics는 서로 의존하지 않으니 동시에 시작한다. 시간표 프리로드도 2학년, 3학년을 병렬로.&lt;/p&gt;
&lt;p&gt;알림 초기화(&lt;code&gt;meal.initializeNotifications()&lt;/code&gt;)만 &lt;code&gt;await&lt;/code&gt;로 순서를 보장하는데, 알림 플러그인이 초기화되어야 스케줄링이 가능하기 때문이다.&lt;/p&gt;
&lt;h3 id="_safeinit-하나가-실패해도-나머지는-계속"&gt;&lt;a href="#_safeinit-%ed%95%98%eb%82%98%ea%b0%80-%ec%8b%a4%ed%8c%a8%ed%95%b4%eb%8f%84-%eb%82%98%eb%a8%b8%ec%a7%80%eb%8a%94-%ea%b3%84%ec%86%8d" class="header-anchor"&gt;&lt;/a&gt;_safeInit: 하나가 실패해도 나머지는 계속
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_safeInit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="s1"&gt; init failed: &lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;main&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;백그라운드 초기화에서 하나가 실패하면? AppCheck가 터져도 앱은 돌아가야 한다. &lt;code&gt;_safeInit()&lt;/code&gt;으로 각 초기화를 try-catch로 감싸서, 실패하면 로그만 남기고 넘어간다.&lt;/p&gt;
&lt;h2 id="결과"&gt;&lt;a href="#%ea%b2%b0%ea%b3%bc" class="header-anchor"&gt;&lt;/a&gt;결과
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;runApp()&lt;/code&gt;까지 걸리는 시간이 체감상 절반 이하로 줄었다. Firebase 초기화 + SettingData + ServiceLocator + 날짜 포맷 — 이것만 기다리면 화면이 뜬다. 나머지는 사용자가 첫 화면을 보는 동안 백그라운드에서 완료된다.&lt;/p&gt;
&lt;h2 id="핵심-원칙"&gt;&lt;a href="#%ed%95%b5%ec%8b%ac-%ec%9b%90%ec%b9%99" class="header-anchor"&gt;&lt;/a&gt;핵심 원칙
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;runApp()&lt;/code&gt; 전에는 화면에 필요한 것만&lt;/strong&gt; — 나머지는 전부 후순위&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;독립적인 초기화는 병렬로&lt;/strong&gt; — &lt;code&gt;Future.wait()&lt;/code&gt;과 &lt;code&gt;unawaited()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;하나의 실패가 전체를 막지 않게&lt;/strong&gt; — &lt;code&gt;_safeInit()&lt;/code&gt;으로 격리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;의도를 명시&lt;/strong&gt; — &lt;code&gt;unawaited()&lt;/code&gt;는 &amp;ldquo;기다리지 않는 게 의도&amp;quot;라는 선언&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;앱 시작 속도는 사소해 보이지만, 매일 여는 앱에서 2초와 0.5초의 차이는 크다.&lt;/p&gt;</description></item><item><title>#9 - 1:1 채팅, 실시간의 무게</title><link>https://monkshark.github.io/p/chat/</link><pubDate>Mon, 13 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/chat/</guid><description>&lt;h2 id="채팅이-필요한-이유"&gt;&lt;a href="#%ec%b1%84%ed%8c%85%ec%9d%b4-%ed%95%84%ec%9a%94%ed%95%9c-%ec%9d%b4%ec%9c%a0" class="header-anchor"&gt;&lt;/a&gt;채팅이 필요한 이유
&lt;/h2&gt;&lt;p&gt;게시판만으로는 부족한 순간이 있다. 분실물 게시판에서 &amp;ldquo;제 카드키 찾으신 분 연락주세요&amp;quot;라고 올리면, 찾은 사람이 연락할 방법이 없다. 댓글로 개인 정보를 주고받을 수도 없고. 1:1 채팅이 필요해진 순간이다.&lt;/p&gt;
&lt;h2 id="채팅방-id-정렬의-힘"&gt;&lt;a href="#%ec%b1%84%ed%8c%85%eb%b0%a9-id-%ec%a0%95%eb%a0%ac%ec%9d%98-%ed%9e%98" class="header-anchor"&gt;&lt;/a&gt;채팅방 ID: 정렬의 힘
&lt;/h2&gt;&lt;p&gt;A가 B에게 채팅을 걸든, B가 A에게 걸든 같은 채팅방이어야 한다. 중복 채팅방이 생기면 대화가 갈린다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;_getChatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;uid1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;uid2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uid1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid2&lt;/span&gt;&lt;span class="p"&gt;]..&lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;두 uid를 알파벳 순서로 정렬하고 &lt;code&gt;_&lt;/code&gt;로 연결한다. &lt;code&gt;abc123&lt;/code&gt;과 &lt;code&gt;xyz789&lt;/code&gt;의 조합은 항상 &lt;code&gt;abc123_xyz789&lt;/code&gt;가 된다. 누가 먼저 시작했는지와 관계없이 같은 ID.&lt;/p&gt;
&lt;p&gt;채팅방을 생성할 때는 이 ID로 문서가 이미 있는지 확인하고, 없으면 만든다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;startChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;otherUid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;otherName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;chatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_getChatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otherUid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;participants&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otherUid&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;participantNames&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;myUid:&lt;/span&gt; &lt;span class="n"&gt;myName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;otherUid:&lt;/span&gt; &lt;span class="n"&gt;otherName&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;lastMessage&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;lastMessageAt&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;unreadCount&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;myUid:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;otherUid:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 채팅방 화면으로 이동
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="메시지-전송"&gt;&lt;a href="#%eb%a9%94%ec%8b%9c%ec%a7%80-%ec%a0%84%ec%86%a1" class="header-anchor"&gt;&lt;/a&gt;메시지 전송
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_sendMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;messages&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;content&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;senderUid&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;senderName&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;myName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;createdAt&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;deletedFor&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;lastMessage&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;lastMessageAt&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;unreadCount.&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otherUid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;메시지를 보내면 두 가지 업데이트가 일어난다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;messages&lt;/code&gt; 서브컬렉션에 메시지 문서 추가&lt;/li&gt;
&lt;li&gt;부모 &lt;code&gt;chats&lt;/code&gt; 문서의 &lt;code&gt;lastMessage&lt;/code&gt;, &lt;code&gt;lastMessageAt&lt;/code&gt;, 상대방의 &lt;code&gt;unreadCount&lt;/code&gt; 업데이트&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;unreadCount.${widget.otherUid}&lt;/code&gt; — dot notation으로 상대방의 읽지 않은 메시지 수만 증가시킨다. 내 카운트는 건드리지 않는다.&lt;/p&gt;
&lt;p&gt;입력 필드를 먼저 비우고(&lt;code&gt;_controller.clear()&lt;/code&gt;) 나서 네트워크 요청을 보낸다. 전송이 완료될 때까지 입력 필드가 남아있으면 사용자가 답답해하니까.&lt;/p&gt;
&lt;h2 id="이미지-전송"&gt;&lt;a href="#%ec%9d%b4%eb%af%b8%ec%a7%80-%ec%a0%84%ec%86%a1" class="header-anchor"&gt;&lt;/a&gt;이미지 전송
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_sendImage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ImagePicker&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;pickImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ImageSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gallery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;imageQuality:&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 압축
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FlutterImageCompress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compressWithFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;quality:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;minWidth:&lt;/span&gt; &lt;span class="m"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;minHeight:&lt;/span&gt; &lt;span class="m"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Firebase Storage 업로드
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;chats/&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;millisecondsSinceEpoch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="s1"&gt;.jpg&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FirebaseStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;putData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getDownloadURL&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 이미지 URL을 메시지로 전송
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_addMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;imageUrl:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이미지는 선택 → 압축 → Storage 업로드 → URL을 메시지에 저장하는 순서다. 원본 대신 1280px, 80% 품질로 압축하여 용량을 줄인다. 채팅방마다 Storage 경로를 분리해서 정리도 쉽다.&lt;/p&gt;
&lt;h2 id="나만-삭제와-모두에게서-삭제"&gt;&lt;a href="#%eb%82%98%eb%a7%8c-%ec%82%ad%ec%a0%9c%ec%99%80-%eb%aa%a8%eb%91%90%ec%97%90%ea%b2%8c%ec%84%9c-%ec%82%ad%ec%a0%9c" class="header-anchor"&gt;&lt;/a&gt;&amp;ldquo;나만 삭제&amp;quot;와 &amp;ldquo;모두에게서 삭제&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;카카오톡처럼 두 가지 삭제 옵션이 있다.&lt;/p&gt;
&lt;h3 id="나만-삭제"&gt;&lt;a href="#%eb%82%98%eb%a7%8c-%ec%82%ad%ec%a0%9c" class="header-anchor"&gt;&lt;/a&gt;나만 삭제
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_deleteForMe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;messages&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;deletedFor&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrayUnion&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;메시지를 실제로 삭제하지 않고, &lt;code&gt;deletedFor&lt;/code&gt; 배열에 내 uid를 추가한다. 메시지를 표시할 때 이 배열을 확인:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;deletedFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;deletedFor&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deletedFor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SizedBox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shrink&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;내 uid가 &lt;code&gt;deletedFor&lt;/code&gt;에 있으면 렌더링하지 않는다. 상대방에게는 여전히 보인다.&lt;/p&gt;
&lt;h3 id="모두에게서-삭제"&gt;&lt;a href="#%eb%aa%a8%eb%91%90%ec%97%90%ea%b2%8c%ec%84%9c-%ec%82%ad%ec%a0%9c" class="header-anchor"&gt;&lt;/a&gt;모두에게서 삭제
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_deleteForAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;messages&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;deleted&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;content&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AppLocalizations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat_deletedMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;deleted: true&lt;/code&gt;로 표시하고 내용을 &amp;ldquo;삭제된 메시지&amp;quot;로 바꾼다. 양쪽 모두에게 &amp;ldquo;삭제된 메시지&amp;quot;가 보인다.&lt;/p&gt;
&lt;p&gt;단, 조건이 있다: &lt;strong&gt;보낸 지 1시간 이내&lt;/strong&gt;이고 &lt;strong&gt;상대방이 아직 읽지 않았을 때&lt;/strong&gt;만 가능하다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;canDeleteForAll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isWithinOneHour&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otherUnread&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이미 읽은 메시지를 삭제하는 건 의미가 없으니까.&lt;/p&gt;
&lt;h2 id="읽음-표시"&gt;&lt;a href="#%ec%9d%bd%ec%9d%8c-%ed%91%9c%ec%8b%9c" class="header-anchor"&gt;&lt;/a&gt;읽음 표시
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;unreadCount&lt;/code&gt;를 활용하여 읽음 표시를 구현한다.&lt;/p&gt;
&lt;p&gt;채팅방에 들어가면 내 &lt;code&gt;unreadCount&lt;/code&gt;를 0으로 초기화:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;override&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;unreadCount.&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;메시지 옆에 &amp;ldquo;읽음&amp;rdquo; 표시를 보여주는 로직은 상대방의 unreadCount를 역산하는 방식이다. 상대방이 3개를 안 읽었으면, 내가 보낸 최근 3개 메시지에는 &amp;ldquo;읽음&amp;quot;이 표시되지 않고 나머지에는 표시된다.&lt;/p&gt;
&lt;h2 id="채팅-목록"&gt;&lt;a href="#%ec%b1%84%ed%8c%85-%eb%aa%a9%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;채팅 목록
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;chats&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;participants&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;arrayContains:&lt;/span&gt; &lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;lastMessageAt&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;descending:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;내가 참여한 채팅방을 최근 메시지 순으로 실시간 스트리밍한다. 새 메시지가 오면 목록이 자동으로 재정렬된다.&lt;/p&gt;
&lt;p&gt;각 채팅방 아이템에는 상대 이름, 마지막 메시지, 시간, 읽지 않은 메시지 수가 표시된다. 읽지 않은 메시지가 있으면 빨간 뱃지가 뜬다.&lt;/p&gt;
&lt;h2 id="채팅방-나가기"&gt;&lt;a href="#%ec%b1%84%ed%8c%85%eb%b0%a9-%eb%82%98%ea%b0%80%ea%b8%b0" class="header-anchor"&gt;&lt;/a&gt;채팅방 나가기
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_leaveChat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 시스템 메시지 추가
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;messagesRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;type&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;system&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;content&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;myName님이&lt;/span&gt;&lt;span class="s1"&gt; 나갔습니다&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;createdAt&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 참가자 목록에서 제거
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chatRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;participants&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrayRemove&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;myUid&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;lastMessage&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;myName님이&lt;/span&gt;&lt;span class="s1"&gt; 나갔습니다&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;채팅방을 나가면 시스템 메시지를 남기고 참가자 목록에서 제거된다. 상대방에게는 &amp;ldquo;○○님이 나갔습니다&amp;quot;가 표시된다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;채팅의 핵심은 &amp;ldquo;실시간&amp;quot;이라기보다 &amp;ldquo;상태 동기화&amp;quot;였다. 읽음/안읽음, 삭제됨/안삭제됨, 참가 중/나감 — 양쪽의 상태가 항상 일치해야 한다. Firestore의 실시간 스트리밍이 이 동기화를 거의 공짜로 해주지만, 구조를 잘 잡아야 그 혜택을 받을 수 있다.&lt;/p&gt;
&lt;p&gt;uid 정렬로 채팅방 ID를 만드는 건 작은 결정이지만, 이 한 줄이 &amp;ldquo;중복 채팅방&amp;rdquo; 문제를 원천 차단했다. 작은 결정이 큰 버그를 예방하는 경험이었다.&lt;/p&gt;</description></item><item><title>#8 - 익명 게시판, 생각보다 복잡한 '익명'</title><link>https://monkshark.github.io/p/anonymous-board/</link><pubDate>Sun, 12 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/anonymous-board/</guid><description>&lt;h2 id="익명이면-그냥-이름-숨기면-되는-거-아닌가"&gt;&lt;a href="#%ec%9d%b5%eb%aa%85%ec%9d%b4%eb%a9%b4-%ea%b7%b8%eb%83%a5-%ec%9d%b4%eb%a6%84-%ec%88%a8%ea%b8%b0%eb%a9%b4-%eb%90%98%eb%8a%94-%ea%b1%b0-%ec%95%84%eb%8b%8c%ea%b0%80" class="header-anchor"&gt;&lt;/a&gt;&amp;ldquo;익명&amp;quot;이면 그냥 이름 숨기면 되는 거 아닌가
&lt;/h2&gt;&lt;p&gt;처음에는 그렇게 생각했다. &lt;code&gt;isAnonymous: true&lt;/code&gt;면 이름 대신 &amp;ldquo;익명&amp;quot;을 표시하면 끝. 하지만 실제로 만들어보니 익명 게시판에는 생각보다 많은 설계 결정이 필요했다.&lt;/p&gt;
&lt;h2 id="익명-번호-익명1과-익명2는-같은-사람인가"&gt;&lt;a href="#%ec%9d%b5%eb%aa%85-%eb%b2%88%ed%98%b8-%ec%9d%b5%eb%aa%851%ea%b3%bc-%ec%9d%b5%eb%aa%852%eb%8a%94-%ea%b0%99%ec%9d%80-%ec%82%ac%eb%9e%8c%ec%9d%b8%ea%b0%80" class="header-anchor"&gt;&lt;/a&gt;익명 번호: &amp;ldquo;익명1&amp;quot;과 &amp;ldquo;익명2&amp;quot;는 같은 사람인가
&lt;/h2&gt;&lt;p&gt;익명 게시판에서 댓글이 달리면 이런 상황이 생긴다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;익명 — 오늘 급식 맛있었나요?
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └ 익명 — 네 괜찮았어요
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └ 익명 — 별로였는데
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └ 익명 — 저도 괜찮았어요
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&amp;ldquo;괜찮았어요&amp;quot;를 쓴 첫 번째 사람과 세 번째 사람이 같은 사람인지 알 수 없다. 대화 맥락이 끊긴다. 에브리타임 같은 서비스에서는 이걸 익명 번호로 해결한다: &amp;ldquo;익명1&amp;rdquo;, &amp;ldquo;익명2&amp;quot;처럼.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;익명 — 오늘 급식 맛있었나요?
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └ 익명1 — 네 괜찮았어요
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └ 익명2 — 별로였는데
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └ 익명1 — 저도 괜찮았어요
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이제 &amp;ldquo;익명1&amp;quot;이 같은 사람이라는 걸 알 수 있다.&lt;/p&gt;
&lt;h3 id="anonymousmapping"&gt;&lt;a href="#anonymousmapping" class="header-anchor"&gt;&lt;/a&gt;anonymousMapping
&lt;/h3&gt;&lt;p&gt;이 기능을 구현하려면 &amp;ldquo;이 게시글에서 이 사용자가 몇 번 익명인지&amp;quot;를 추적해야 한다. Firestore 문서에 &lt;code&gt;anonymousMapping&lt;/code&gt;과 &lt;code&gt;anonymousCount&lt;/code&gt; 필드를 뒀다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;span class="lnt"&gt;25
&lt;/span&gt;&lt;span class="lnt"&gt;26
&lt;/span&gt;&lt;span class="lnt"&gt;27
&lt;/span&gt;&lt;span class="lnt"&gt;28
&lt;/span&gt;&lt;span class="lnt"&gt;29
&lt;/span&gt;&lt;span class="lnt"&gt;30
&lt;/span&gt;&lt;span class="lnt"&gt;31
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resolveAnonymousName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;authorLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;anonymousNumLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;postSnap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;postAuthorUid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postSnap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;authorUid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 글 작성자는 &amp;#34;작성자&amp;#34;로 표시
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;postAuthorUid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;authorLabel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runTransaction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;postDoc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;dynamic&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;anonymousMapping&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;anonymousCount&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;anonymousNumLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// 기존 번호 반환
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;newNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newNum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;anonymousMapping&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;anonymousCount&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newNum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;anonymousNumLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newNum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 새 번호 부여
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Firestore 트랜잭션을 쓰는 이유는 동시성 문제 때문이다. 두 사람이 동시에 댓글을 달면 같은 번호를 받을 수 있다. 트랜잭션으로 읽기-확인-쓰기를 원자적으로 처리해야 번호가 중복되지 않는다.&lt;/p&gt;
&lt;p&gt;글 작성자는 &amp;ldquo;익명1&amp;quot;이 아니라 &amp;ldquo;작성자&amp;quot;로 표시된다. 자기 글의 댓글에서 글쓴이를 구분할 수 있어야 하니까.&lt;/p&gt;
&lt;h3 id="댓글-렌더링"&gt;&lt;a href="#%eb%8c%93%ea%b8%80-%eb%a0%8c%eb%8d%94%eb%a7%81" class="header-anchor"&gt;&lt;/a&gt;댓글 렌더링
&lt;/h3&gt;&lt;p&gt;댓글을 화면에 표시할 때는 &lt;code&gt;anonymousMapping&lt;/code&gt;을 미리 로드해두고, 각 댓글의 &lt;code&gt;authorUid&lt;/code&gt;로 번호를 조회한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;isAnonymous&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;authorUid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;authorUid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;_currentPostAuthorUid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;authorName&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppLocalizations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_anonymousAuthor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_anonymousMapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;authorName&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppLocalizations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_anonymousNum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_anonymousMapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;i18n도 적용되어 있다. 한국어에서는 &amp;ldquo;익명1&amp;rdquo;, 영어에서는 &amp;ldquo;Anonymous 1&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="좋아요-배열에서-map으로"&gt;&lt;a href="#%ec%a2%8b%ec%95%84%ec%9a%94-%eb%b0%b0%ec%97%b4%ec%97%90%ec%84%9c-map%ec%9c%bc%eb%a1%9c" class="header-anchor"&gt;&lt;/a&gt;좋아요: 배열에서 Map으로
&lt;/h2&gt;&lt;h3 id="처음-배열"&gt;&lt;a href="#%ec%b2%98%ec%9d%8c-%eb%b0%b0%ec%97%b4" class="header-anchor"&gt;&lt;/a&gt;처음: 배열
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;likes: [&amp;#34;uid1&amp;#34;, &amp;#34;uid2&amp;#34;, &amp;#34;uid3&amp;#34;]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;단순하다. &lt;code&gt;arrayContains&lt;/code&gt;로 내가 좋아요를 눌렀는지 확인하고, &lt;code&gt;arrayUnion&lt;/code&gt;/&lt;code&gt;arrayRemove&lt;/code&gt;로 추가/삭제. 하지만 문제가 있었다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;두 명이 동시에 좋아요를 누르면 한쪽이 씹힐 수 있다&lt;/li&gt;
&lt;li&gt;&amp;ldquo;인기글&amp;rdquo; 정렬을 하려면 배열 크기로 정렬해야 하는데, Firestore에서는 배열 크기 기준 정렬이 불가능하다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="지금-map--비정규화-카운터"&gt;&lt;a href="#%ec%a7%80%ea%b8%88-map--%eb%b9%84%ec%a0%95%ea%b7%9c%ed%99%94-%ec%b9%b4%ec%9a%b4%ed%84%b0" class="header-anchor"&gt;&lt;/a&gt;지금: Map + 비정규화 카운터
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;likes: {&amp;#34;uid1&amp;#34;: true, &amp;#34;uid2&amp;#34;: true}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;likeCount: 2
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Map으로 바꾸면서 동시 업데이트 문제가 해결되었다. 각 uid가 독립적인 필드이기 때문에, 두 사람이 동시에 눌러도 충돌하지 않는다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;toggleLike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;hasLiked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;hasDisliked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasLiked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;postRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;likes.&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;likeCount&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;dynamic&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;likes.&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;likeCount&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasDisliked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;dislikes.&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;dislikeCount&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;postRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;likes.$uid&lt;/code&gt; — Firestore의 dot notation으로 Map의 특정 키만 업데이트한다. &lt;code&gt;FieldValue.increment(-1)&lt;/code&gt;로 카운터를 원자적으로 감소시킨다. 좋아요를 누르면서 동시에 싫어요를 취소하는 것도 한 번의 업데이트로 처리한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;likeCount&lt;/code&gt;는 비정규화된 필드다. &lt;code&gt;likes&lt;/code&gt; Map의 크기와 항상 같아야 한다. 이걸 별도로 유지하는 이유는 오직 &lt;strong&gt;정렬&lt;/strong&gt; 때문이다. &amp;ldquo;인기글&amp;rdquo; 탭에서 &lt;code&gt;likeCount&lt;/code&gt; 내림차순으로 정렬하려면 이 필드가 필요하다.&lt;/p&gt;
&lt;h2 id="검색-firestore에서-급식을-찾으려면"&gt;&lt;a href="#%ea%b2%80%ec%83%89-firestore%ec%97%90%ec%84%9c-%ea%b8%89%ec%8b%9d%ec%9d%84-%ec%b0%be%ec%9c%bc%eb%a0%a4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;검색: Firestore에서 &amp;ldquo;급식&amp;quot;을 찾으려면
&lt;/h2&gt;&lt;p&gt;Firestore에는 &lt;code&gt;LIKE '%급식%'&lt;/code&gt; 같은 전문 검색이 없다. 공식적으로는 Algolia나 Typesense 같은 외부 검색 엔진을 붙이라고 권장한다. 하지만 학생 프로젝트에서 외부 서비스 비용과 관리 부담은 크다.&lt;/p&gt;
&lt;h3 id="n-gram-토큰"&gt;&lt;a href="#n-gram-%ed%86%a0%ed%81%b0" class="header-anchor"&gt;&lt;/a&gt;n-gram 토큰
&lt;/h3&gt;&lt;p&gt;대안으로 2-gram 토큰 방식을 썼다. 게시글을 저장할 때 제목과 내용에서 2글자 단위로 토큰을 추출한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;forDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_ngrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_ngrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&amp;ldquo;오늘 급식 맛있었다&amp;rdquo; → &lt;code&gt;[&amp;quot;오늘&amp;quot;, &amp;quot;늘급&amp;quot;, &amp;quot;급식&amp;quot;, &amp;quot;식맛&amp;quot;, &amp;quot;맛있&amp;quot;, &amp;quot;있었&amp;quot;, &amp;quot;었다&amp;quot;]&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;정규화 과정에서 특수문자와 공백을 제거하고, 영어는 소문자로 통일한다. 한글, 영어, 숫자만 남긴다.&lt;/p&gt;
&lt;p&gt;검색 시에는 쿼리도 같은 방식으로 토큰화한 후 &lt;code&gt;arrayContainsAny&lt;/code&gt;로 Firestore에 쿼리한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;forQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_ngrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;완벽한 검색은 아니다. &amp;ldquo;급&amp;quot;만 검색하면 2-gram이 안 만들어지므로 1글자 검색은 정확도가 떨어진다. 문서당 토큰은 200개로 제한하여 Firestore 문서 크기가 과도하게 커지는 걸 방지한다. 하지만 학교 게시판에서 &amp;ldquo;급식&amp;rdquo;, &amp;ldquo;시간표&amp;rdquo;, &amp;ldquo;동아리&amp;rdquo; 같은 2글자 이상 키워드 검색에는 잘 동작한다.&lt;/p&gt;
&lt;h2 id="카테고리-시스템"&gt;&lt;a href="#%ec%b9%b4%ed%85%8c%ea%b3%a0%eb%a6%ac-%ec%8b%9c%ec%8a%a4%ed%85%9c" class="header-anchor"&gt;&lt;/a&gt;카테고리 시스템
&lt;/h2&gt;&lt;p&gt;게시판은 6개 카테고리로 나뉜다:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;카테고리&lt;/th&gt;
 &lt;th&gt;FCM 토픽&lt;/th&gt;
 &lt;th&gt;색상&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;자유&lt;/td&gt;
 &lt;td&gt;free&lt;/td&gt;
 &lt;td&gt;기본&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;질문&lt;/td&gt;
 &lt;td&gt;question&lt;/td&gt;
 &lt;td&gt;보조&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;정보공유&lt;/td&gt;
 &lt;td&gt;info&lt;/td&gt;
 &lt;td&gt;3차&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;분실물&lt;/td&gt;
 &lt;td&gt;lost&lt;/td&gt;
 &lt;td&gt;주황&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;학생회&lt;/td&gt;
 &lt;td&gt;council&lt;/td&gt;
 &lt;td&gt;초록&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;동아리&lt;/td&gt;
 &lt;td&gt;club&lt;/td&gt;
 &lt;td&gt;보라&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;여기에 &amp;ldquo;전체&amp;quot;와 &amp;ldquo;인기글&amp;rdquo; 탭이 추가된다. &amp;ldquo;전체&amp;quot;는 모든 카테고리를 보여주고, &amp;ldquo;인기글&amp;quot;은 &lt;code&gt;likeCount&lt;/code&gt; 기준으로 정렬한다.&lt;/p&gt;
&lt;p&gt;FCM 토픽은 카테고리별 알림 구독을 위해 영어 키로 매핑한다. 사용자가 &amp;ldquo;자유&amp;rdquo; 카테고리만 구독하면 해당 토픽의 알림만 받는다.&lt;/p&gt;
&lt;h2 id="추가-기능들"&gt;&lt;a href="#%ec%b6%94%ea%b0%80-%ea%b8%b0%eb%8a%a5%eb%93%a4" class="header-anchor"&gt;&lt;/a&gt;추가 기능들
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;북마크&lt;/strong&gt;: &lt;code&gt;bookmarkedBy&lt;/code&gt; 배열에 uid를 넣어서 내가 북마크한 글을 모아볼 수 있다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;고정글&lt;/strong&gt;: &lt;code&gt;isPinned&lt;/code&gt;과 &lt;code&gt;pinnedAt&lt;/code&gt;으로 관리자가 글을 상단 고정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;투표&lt;/strong&gt;: &lt;code&gt;pollOptions&lt;/code&gt;와 &lt;code&gt;pollVoters&lt;/code&gt;로 글 안에서 투표 가능. &lt;code&gt;pollVoters&lt;/code&gt;는 &lt;code&gt;{uid: optionIndex}&lt;/code&gt; Map&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;이미지&lt;/strong&gt;: &lt;code&gt;imageUrls&lt;/code&gt; 배열로 다중 이미지 첨부. Firebase Storage에 업로드 후 URL 저장&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;해결됨&lt;/strong&gt;: 질문 카테고리에서 &lt;code&gt;isResolved&lt;/code&gt;로 해결된 질문 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;익명 게시판은 &amp;ldquo;이름을 숨긴다&amp;quot;가 아니라 &amp;ldquo;이름을 숨기면서도 대화 맥락을 유지한다&amp;quot;가 핵심이었다. &lt;code&gt;anonymousMapping&lt;/code&gt; 하나를 추가하는 것이 사소해 보이지만, 트랜잭션으로 동시성을 처리하고, 글 작성자를 별도로 표시하고, i18n을 적용하는 과정이 필요했다.&lt;/p&gt;
&lt;p&gt;좋아요도 검색도 마찬가지다. Firestore의 제약 안에서 &amp;ldquo;그럴듯하게 동작하는&amp;rdquo; 것을 만드는 게 NoSQL 설계의 핵심인 것 같다.&lt;/p&gt;</description></item><item><title>#7 - Firestore 스키마, 처음부터 다시 설계한다면</title><link>https://monkshark.github.io/p/firestore-schema/</link><pubDate>Sat, 11 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/firestore-schema/</guid><description>&lt;h2 id="학교-앱에-firestore가-필요한-이유"&gt;&lt;a href="#%ed%95%99%ea%b5%90-%ec%95%b1%ec%97%90-firestore%ea%b0%80-%ed%95%84%ec%9a%94%ed%95%9c-%ec%9d%b4%ec%9c%a0" class="header-anchor"&gt;&lt;/a&gt;학교 앱에 Firestore가 필요한 이유
&lt;/h2&gt;&lt;p&gt;급식과 시간표는 NEIS API로 충분하다. 하지만 학교 앱이 단순 정보 조회를 넘어서려면 — 게시판, 채팅, 사용자 인증 — 자체 데이터베이스가 필요하다.&lt;/p&gt;
&lt;p&gt;Firebase의 Firestore를 선택한 이유는 단순했다. 서버를 직접 운영할 필요가 없고, 실시간 동기화가 기본이고, Flutter와의 통합이 잘 되어 있다. 무엇보다 학생 혼자 운영하는 앱에서 서버 관리까지 할 여유는 없었다.&lt;/p&gt;
&lt;p&gt;현재 앱에는 8개의 Firestore 컬렉션이 있다. 각각의 구조와 설계 과정에서 배운 것들을 정리한다.&lt;/p&gt;
&lt;h2 id="users-사용자-프로필"&gt;&lt;a href="#users-%ec%82%ac%ec%9a%a9%ec%9e%90-%ed%94%84%eb%a1%9c%ed%95%84" class="header-anchor"&gt;&lt;/a&gt;users: 사용자 프로필
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;users/{uid}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── uid, name, email
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── studentId, grade, classNum
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── role: &amp;#34;user&amp;#34; | &amp;#34;manager&amp;#34; | &amp;#34;admin&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── userType: &amp;#34;student&amp;#34; | &amp;#34;graduate&amp;#34; | &amp;#34;teacher&amp;#34; | &amp;#34;parent&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── approved: true/false
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── blockedUsers: [uid, uid, ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── fcmToken: &amp;#34;...&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── profilePhotoUrl, graduationYear, teacherSubject
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── lastProfileUpdate, updatedAt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── /sync/schedules → 개인 시간표
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── /sync/ddays → D-day 목록
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── /notifications → 알림 내역
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;role&lt;/code&gt; 필드가 3단계(&lt;code&gt;user&lt;/code&gt;, &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;)인 건 나중에 추가한 것이다. 처음에는 &lt;code&gt;isAdmin: true/false&lt;/code&gt;로 시작했다가, 학생회 임원에게 일부 권한만 주고 싶어서 &lt;code&gt;manager&lt;/code&gt; 역할을 중간에 넣었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;approved&lt;/code&gt; 필드는 가입 승인 시스템이다. 아무나 학교 앱에 글을 쓸 수 없도록, 가입 후 관리자가 승인해야 게시판 접근이 가능하다. 학교 앱이라는 특성상 필수적인 기능이었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;blockedUsers&lt;/code&gt; 배열은 사용자 차단 기능이다. 차단한 사용자의 게시글과 댓글이 보이지 않는다. 이걸 서버 쿼리로 처리하면 Firestore &lt;code&gt;not-in&lt;/code&gt; 쿼리 제한(10개)에 걸리기 때문에, 클라이언트에서 필터링한다.&lt;/p&gt;
&lt;h3 id="서브컬렉션-sync"&gt;&lt;a href="#%ec%84%9c%eb%b8%8c%ec%bb%ac%eb%a0%89%ec%85%98-sync" class="header-anchor"&gt;&lt;/a&gt;서브컬렉션: sync
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;users/{uid}/sync/schedules&lt;/code&gt;와 &lt;code&gt;users/{uid}/sync/ddays&lt;/code&gt;는 개인 데이터의 기기 간 동기화를 위한 구조다. 로컬 SQLite에 저장하되, Firestore에도 백업하여 기기를 바꿔도 데이터가 유지된다.&lt;/p&gt;
&lt;p&gt;처음에는 Firestore만 사용했다. 하지만 시간표를 볼 때마다 네트워크 요청이 발생하는 게 급식 API와 같은 문제였다. 결국 로컬 SQLite + Firestore 동기화 구조로 바꿨다.&lt;/p&gt;
&lt;h2 id="posts-게시판"&gt;&lt;a href="#posts-%ea%b2%8c%ec%8b%9c%ed%8c%90" class="header-anchor"&gt;&lt;/a&gt;posts: 게시판
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;posts/{postId}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── title, content, authorUid, authorName
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── category: &amp;#34;자유&amp;#34; | &amp;#34;질문&amp;#34; | &amp;#34;정보공유&amp;#34; | &amp;#34;분실물&amp;#34; | &amp;#34;학생회&amp;#34; | &amp;#34;동아리&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── isAnonymous, isPinned, isResolved
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── likes: {uid: true, uid: true, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── dislikes: {uid: true, uid: true, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── likeCount, dislikeCount ← 비정규화된 카운터
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── commentCount
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── bookmarkedBy: [uid, uid, ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── searchTokens: [&amp;#34;급식&amp;#34;, &amp;#34;식메&amp;#34;, &amp;#34;메뉴&amp;#34;, ...] ← n-gram
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── pollOptions, pollVoters
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── imageUrls: [...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── createdAt, pinnedAt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── /comments/{commentId}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── content, authorUid, authorName
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── isAnonymous, likes, dislikes
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── imageUrl, mentions
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── createdAt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이 컬렉션에서 가장 많이 고민한 것이 &lt;strong&gt;좋아요 구조&lt;/strong&gt;다.&lt;/p&gt;
&lt;h3 id="좋아요-배열-vs-map"&gt;&lt;a href="#%ec%a2%8b%ec%95%84%ec%9a%94-%eb%b0%b0%ec%97%b4-vs-map" class="header-anchor"&gt;&lt;/a&gt;좋아요: 배열 vs Map
&lt;/h3&gt;&lt;p&gt;처음에는 &lt;code&gt;likes: [uid1, uid2, uid3]&lt;/code&gt; 배열이었다. 누가 좋아요를 눌렀는지 확인하려면 &lt;code&gt;arrayContains&lt;/code&gt;로 쿼리하면 된다. 하지만 문제가 있었다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;좋아요/취소가 동시에 발생하면 배열이 꼬일 수 있다&lt;/li&gt;
&lt;li&gt;Firestore의 &lt;code&gt;arrayUnion&lt;/code&gt;/&lt;code&gt;arrayRemove&lt;/code&gt;가 있지만, 트랜잭션 없이는 race condition에 취약하다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Map 구조(&lt;code&gt;likes: {uid: true}&lt;/code&gt;)로 바꾸면서 해결했다. 특정 uid의 좋아요 여부를 확인하는 것도, 추가/삭제하는 것도 간단하다.&lt;/p&gt;
&lt;h3 id="likecount-비정규화의-필요성"&gt;&lt;a href="#likecount-%eb%b9%84%ec%a0%95%ea%b7%9c%ed%99%94%ec%9d%98-%ed%95%84%ec%9a%94%ec%84%b1" class="header-anchor"&gt;&lt;/a&gt;likeCount: 비정규화의 필요성
&lt;/h3&gt;&lt;p&gt;&amp;ldquo;인기글&amp;rdquo; 정렬이 필요했다. Firestore에서 Map의 크기로 정렬하는 건 불가능하다. &lt;code&gt;likes&lt;/code&gt; Map의 키 개수를 실시간으로 세는 것도 비효율적이다.&lt;/p&gt;
&lt;p&gt;결국 &lt;code&gt;likeCount&lt;/code&gt;, &lt;code&gt;dislikeCount&lt;/code&gt; 필드를 별도로 두고, 좋아요를 누를 때마다 트랜잭션으로 함께 업데이트한다. NoSQL에서는 이런 비정규화가 일상이다. RDB의 &lt;code&gt;COUNT(*)&lt;/code&gt; 대신 미리 계산해두는 것.&lt;/p&gt;
&lt;h3 id="검색-n-gram-토큰"&gt;&lt;a href="#%ea%b2%80%ec%83%89-n-gram-%ed%86%a0%ed%81%b0" class="header-anchor"&gt;&lt;/a&gt;검색: n-gram 토큰
&lt;/h3&gt;&lt;p&gt;Firestore는 전문 검색(full-text search)을 지원하지 않는다. &amp;ldquo;급식 메뉴&amp;quot;를 검색하려면 별도 검색 엔진(Algolia, Typesense 등)이 필요한데, 학생 프로젝트에서 외부 서비스를 붙이기는 부담스러웠다.&lt;/p&gt;
&lt;p&gt;대안으로 n-gram 토큰을 사용했다. 게시글을 저장할 때 제목과 내용에서 2글자 단위로 토큰을 추출하여 &lt;code&gt;searchTokens&lt;/code&gt; 배열에 저장한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#34;급식 메뉴 변경&amp;#34; → [&amp;#34;급식&amp;#34;, &amp;#34;식 &amp;#34;, &amp;#34; 메&amp;#34;, &amp;#34;메뉴&amp;#34;, &amp;#34;뉴 &amp;#34;, &amp;#34; 변&amp;#34;, &amp;#34;변경&amp;#34;]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;검색 시 &lt;code&gt;arrayContainsAny&lt;/code&gt;로 쿼리한다. 완벽한 전문 검색은 아니지만, 학교 게시판 규모에서는 충분히 동작한다.&lt;/p&gt;
&lt;h3 id="익명-게시판"&gt;&lt;a href="#%ec%9d%b5%eb%aa%85-%ea%b2%8c%ec%8b%9c%ed%8c%90" class="header-anchor"&gt;&lt;/a&gt;익명 게시판
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;isAnonymous&lt;/code&gt; 필드와 함께 &lt;code&gt;anonymousMapping&lt;/code&gt;, &lt;code&gt;anonymousCount&lt;/code&gt;가 있다. 같은 게시글에 같은 익명 사용자가 여러 댓글을 달면 &amp;ldquo;익명1&amp;rdquo;, &amp;ldquo;익명1&amp;quot;로 일관되게 표시해야 한다. &lt;code&gt;anonymousMapping&lt;/code&gt;은 &lt;code&gt;{uid: 1, uid: 2}&lt;/code&gt; 형태로 익명 번호를 추적한다.&lt;/p&gt;
&lt;h2 id="chats-11-채팅"&gt;&lt;a href="#chats-11-%ec%b1%84%ed%8c%85" class="header-anchor"&gt;&lt;/a&gt;chats: 1:1 채팅
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chats/{chatId} ← chatId = 정렬된 두 uid 조합
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── participants: [uid, uid]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── participantNames: {uid: &amp;#34;이름&amp;#34;, uid: &amp;#34;이름&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── lastMessage, lastMessageAt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── unreadCount: {uid: 3, uid: 0}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── /messages/{messageId}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── type: &amp;#34;text&amp;#34; | &amp;#34;system&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── content, imageUrl
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── senderUid, senderName
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── deletedFor: [uid, ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── createdAt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;chatId&lt;/code&gt;를 두 사용자의 uid를 정렬하여 합친 값으로 쓴다. A와 B의 채팅방은 항상 같은 ID를 가지므로, 중복 채팅방이 생기지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unreadCount&lt;/code&gt;를 Map으로 관리하는 것은 각 사용자가 읽지 않은 메시지 수를 독립적으로 추적하기 위해서다. A가 메시지를 보내면 B의 카운트가 올라가고, B가 채팅방을 열면 B의 카운트가 0으로 초기화된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deletedFor&lt;/code&gt; 배열은 &amp;ldquo;나만 삭제&amp;rdquo; 기능이다. 메시지를 실제로 삭제하지 않고, 삭제를 요청한 사용자의 uid를 배열에 추가한다. 클라이언트에서 자신의 uid가 &lt;code&gt;deletedFor&lt;/code&gt;에 있으면 해당 메시지를 표시하지 않는다.&lt;/p&gt;
&lt;h2 id="나머지-컬렉션들"&gt;&lt;a href="#%eb%82%98%eb%a8%b8%ec%a7%80-%ec%bb%ac%eb%a0%89%ec%85%98%eb%93%a4" class="header-anchor"&gt;&lt;/a&gt;나머지 컬렉션들
&lt;/h2&gt;&lt;h3 id="reports--신고"&gt;&lt;a href="#reports--%ec%8b%a0%ea%b3%a0" class="header-anchor"&gt;&lt;/a&gt;reports — 신고
&lt;/h3&gt;&lt;p&gt;게시글 신고 시 &lt;code&gt;postId&lt;/code&gt;, &lt;code&gt;reporterUid&lt;/code&gt;, &lt;code&gt;reason&lt;/code&gt;, &lt;code&gt;detail&lt;/code&gt;을 저장한다. 관리자 화면에서 목록을 보고 조치한다.&lt;/p&gt;
&lt;h3 id="admin_logs--관리-기록"&gt;&lt;a href="#admin_logs--%ea%b4%80%eb%a6%ac-%ea%b8%b0%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;admin_logs — 관리 기록
&lt;/h3&gt;&lt;p&gt;사용자 정지, 게시글 삭제 등 관리자 행동을 기록한다. &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;targetUid&lt;/code&gt;, &lt;code&gt;details&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt;. 누가 무엇을 했는지 추적할 수 있어야 관리자가 여러 명이어도 문제를 파악할 수 있다.&lt;/p&gt;
&lt;h3 id="app_feedbacks--council_feedbacks--피드백"&gt;&lt;a href="#app_feedbacks--council_feedbacks--%ed%94%bc%eb%93%9c%eb%b0%b1" class="header-anchor"&gt;&lt;/a&gt;app_feedbacks / council_feedbacks — 피드백
&lt;/h3&gt;&lt;p&gt;사용자가 앱이나 학생회에 보내는 피드백. &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;imageUrls&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;(pending/addressed). 이미지 첨부가 가능하고, 처리 상태를 관리자가 업데이트한다.&lt;/p&gt;
&lt;h3 id="crash_logs--오류-기록"&gt;&lt;a href="#crash_logs--%ec%98%a4%eb%a5%98-%ea%b8%b0%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;crash_logs — 오류 기록
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// main.dart
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;FlutterError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;crash_logs&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;error&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptionAsString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;stack&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;uid&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;createdAt&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Crashlytics 대신 간단하게 만든 오류 수집기. &lt;code&gt;error&lt;/code&gt;와 &lt;code&gt;stack&lt;/code&gt;을 각각 500자, 1000자로 잘라서 저장한다. 문서 크기 폭발을 방지하기 위한 장치다.&lt;/p&gt;
&lt;h3 id="app_config--앱-설정"&gt;&lt;a href="#app_config--%ec%95%b1-%ec%84%a4%ec%a0%95" class="header-anchor"&gt;&lt;/a&gt;app_config — 앱 설정
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;app_config/popup&lt;/code&gt; 문서 하나로 팝업 공지를 관리한다. 앱을 열 때 이 문서를 확인하고, 활성화된 팝업이 있으면 표시한다. 관리자 화면에서 실시간으로 팝업을 켜고 끌 수 있다.&lt;/p&gt;
&lt;h2 id="설계하면서-배운-것"&gt;&lt;a href="#%ec%84%a4%ea%b3%84%ed%95%98%eb%a9%b4%ec%84%9c-%eb%b0%b0%ec%9a%b4-%ea%b2%83" class="header-anchor"&gt;&lt;/a&gt;설계하면서 배운 것
&lt;/h2&gt;&lt;h3 id="1-firestore는-쿼리부터-설계한다"&gt;&lt;a href="#1-firestore%eb%8a%94-%ec%bf%bc%eb%a6%ac%eb%b6%80%ed%84%b0-%ec%84%a4%ea%b3%84%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;1. Firestore는 쿼리부터 설계한다
&lt;/h3&gt;&lt;p&gt;RDB에서는 데이터를 정규화하고, 필요할 때 JOIN한다. Firestore에서는 &lt;strong&gt;어떤 쿼리를 할 것인지&lt;/strong&gt; 먼저 정하고, 그 쿼리에 맞게 데이터를 배치한다. &lt;code&gt;likeCount&lt;/code&gt; 같은 비정규화가 그 예다.&lt;/p&gt;
&lt;h3 id="2-배열의-한계를-알아야-한다"&gt;&lt;a href="#2-%eb%b0%b0%ec%97%b4%ec%9d%98-%ed%95%9c%ea%b3%84%eb%a5%bc-%ec%95%8c%ec%95%84%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;2. 배열의 한계를 알아야 한다
&lt;/h3&gt;&lt;p&gt;Firestore에서 배열은 편리하지만 제약이 많다. &lt;code&gt;arrayContainsAny&lt;/code&gt;는 최대 30개 값만 비교할 수 있고, &lt;code&gt;not-in&lt;/code&gt;은 10개까지다. &lt;code&gt;blockedUsers&lt;/code&gt;를 서버 쿼리로 필터링하지 못하고 클라이언트에서 처리하는 것도 이 제약 때문이다.&lt;/p&gt;
&lt;h3 id="3-문서-크기를-의식해야-한다"&gt;&lt;a href="#3-%eb%ac%b8%ec%84%9c-%ed%81%ac%ea%b8%b0%eb%a5%bc-%ec%9d%98%ec%8b%9d%ed%95%b4%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;3. 문서 크기를 의식해야 한다
&lt;/h3&gt;&lt;p&gt;Firestore 문서 최대 크기는 1MB다. &lt;code&gt;likes&lt;/code&gt; Map에 사용자가 수천 명 좋아요를 누르면 문서가 커진다. 학교 앱 규모에서는 문제가 안 되지만, 설계 단계에서 &amp;ldquo;이 필드가 무한히 커질 수 있는가&amp;quot;를 항상 생각해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;crash_logs&lt;/code&gt;에서 &lt;code&gt;error&lt;/code&gt;와 &lt;code&gt;stack&lt;/code&gt;을 잘라서 저장하는 것도 같은 이유다. 스택 트레이스 전체를 저장하면 문서 하나가 수십 KB가 될 수 있다.&lt;/p&gt;
&lt;h3 id="4-보안-규칙은-스키마의-일부다"&gt;&lt;a href="#4-%eb%b3%b4%ec%95%88-%ea%b7%9c%ec%b9%99%ec%9d%80-%ec%8a%a4%ed%82%a4%eb%a7%88%ec%9d%98-%ec%9d%bc%eb%b6%80%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;4. 보안 규칙은 스키마의 일부다
&lt;/h3&gt;&lt;p&gt;Firestore Security Rules로 &amp;ldquo;자기 게시글만 수정 가능&amp;rdquo;, &amp;ldquo;승인된 사용자만 글 작성 가능&amp;rdquo;, &amp;ldquo;관리자만 사용자 정지 가능&amp;rdquo; 같은 규칙을 강제한다. 스키마를 설계할 때 보안 규칙에서 검증 가능한 구조인지도 함께 고려해야 한다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;8개 컬렉션은 한 번에 설계한 게 아니다. &lt;code&gt;users&lt;/code&gt;와 &lt;code&gt;posts&lt;/code&gt;로 시작해서, 채팅이 필요해지면 &lt;code&gt;chats&lt;/code&gt;를, 신고가 필요해지면 &lt;code&gt;reports&lt;/code&gt;를 추가했다. 기능이 늘어날 때마다 컬렉션이 하나씩 생겼다.&lt;/p&gt;
&lt;p&gt;처음부터 다시 설계한다면 크게 바꿀 것은 없다. 다만 &lt;code&gt;searchTokens&lt;/code&gt;의 n-gram 방식은 게시글이 많아지면 한계가 있으니, Algolia 같은 외부 검색 서비스를 처음부터 고려했을 것이다. 그리고 &lt;code&gt;crash_logs&lt;/code&gt;는 Crashlytics로 대체하는 게 더 나았을 것이다.&lt;/p&gt;
&lt;p&gt;하지만 학생이 혼자 만드는 앱에서 &amp;ldquo;완벽한 설계&amp;quot;를 추구하면 아무것도 못 만든다. 일단 동작하게 만들고, 문제가 생기면 고치는 것. NEIS API가 80줄에서 320줄로 진화한 것처럼, Firestore 스키마도 사용하면서 계속 진화하고 있다.&lt;/p&gt;
&lt;p&gt;아직 배포를 하진 않았지만, 배포 후 사용자가 많아져서 Firebase에 요금이 청구되기 시작하면 Firestore를 걷어내고 직접 백엔드를 구축할 생각도 있다. 내 첫 메인 프로젝트지만 돈이 아깝다.&lt;/p&gt;</description></item><item><title>#6 - 급식 API, 80줄에서 320줄까지</title><link>https://monkshark.github.io/p/meal-api/</link><pubDate>Fri, 10 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/meal-api/</guid><description>&lt;h2 id="java-시절-80줄짜리-api"&gt;&lt;a href="#java-%ec%8b%9c%ec%a0%88-80%ec%a4%84%ec%a7%9c%eb%a6%ac-api" class="header-anchor"&gt;&lt;/a&gt;Java 시절: 80줄짜리 API
&lt;/h2&gt;&lt;p&gt;Java 프로토타입의 &lt;code&gt;getMealData.java&lt;/code&gt;는 80줄이었다. 하는 일은 단순했다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;URL 조립&lt;/li&gt;
&lt;li&gt;HTTP 요청&lt;/li&gt;
&lt;li&gt;JSON 파싱&lt;/li&gt;
&lt;li&gt;문자열 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;getMeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mealScCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;requestURL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;https://open.neis.go.kr/hub/mealServiceDietInfo?&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;Type=json&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;MMEAL_SC_CODE=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mealScCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;ATPT_OFCDC_SC_CODE=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;niesAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ATPT_OFCDC_SC_CODE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;SD_SCHUL_CODE=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;niesAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SD_SCHUL_CODE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;MLSV_YMD=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CompletableFuture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;DDISH_NM&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;칼로리&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;CAL_INFO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;switch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;메뉴&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;칼로리&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;칼로리&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;lt;br/&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;호출할 때마다 네트워크 요청을 보냈다. 캐싱? 없다. 오프라인 대응? 없다. 에러 핸들링? &lt;code&gt;try-catch&lt;/code&gt;로 빈 문자열 반환이 전부. 그래도 동작했다. 학교 와이파이가 있으니까.&lt;/p&gt;
&lt;h2 id="flutter-초기-java를-그대로-옮기다"&gt;&lt;a href="#flutter-%ec%b4%88%ea%b8%b0-java%eb%a5%bc-%ea%b7%b8%eb%8c%80%eb%a1%9c-%ec%98%ae%ea%b8%b0%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;Flutter 초기: Java를 그대로 옮기다
&lt;/h2&gt;&lt;p&gt;Flutter 첫 커밋(2023-12-07)에서 &lt;code&gt;GetMealData.dart&lt;/code&gt;를 만들었을 때도 구조는 같았다. Java의 &lt;code&gt;CompletableFuture&lt;/code&gt;가 Dart의 &lt;code&gt;Future&lt;/code&gt;로 바뀌었을 뿐, URL을 조립하고, HTTP 요청을 보내고, JSON을 파싱해서 문자열을 돌려주는 것은 동일했다.&lt;/p&gt;
&lt;p&gt;하지만 Flutter 버전이 커지기 시작한 건 &lt;strong&gt;테스터가 늘면서&lt;/strong&gt;다.&lt;/p&gt;
&lt;h2 id="문제-1-매번-네트워크-요청"&gt;&lt;a href="#%eb%ac%b8%ec%a0%9c-1-%eb%a7%a4%eb%b2%88-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-%ec%9a%94%ec%b2%ad" class="header-anchor"&gt;&lt;/a&gt;문제 1: 매번 네트워크 요청
&lt;/h2&gt;&lt;p&gt;급식 화면을 열 때마다 NEIS API를 호출했다. 조식, 중식, 석식 — 화면 하나를 열면 API 호출 3번. 날짜를 넘기면 3번 더. 체감상 느렸고, NEIS API가 간헐적으로 느려지는 날에는 화면이 몇 초간 빈 채로 있었다.&lt;/p&gt;
&lt;h2 id="해결-sharedpreferences-캐시"&gt;&lt;a href="#%ed%95%b4%ea%b2%b0-sharedpreferences-%ec%ba%90%ec%8b%9c" class="header-anchor"&gt;&lt;/a&gt;해결: SharedPreferences 캐시
&lt;/h2&gt;&lt;p&gt;첫 번째 개선은 &lt;code&gt;SharedPreferences&lt;/code&gt;에 API 응답을 캐싱하는 것이었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;_cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;mealType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;meal_&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;DateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;yyyyMMdd&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;mealType&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;_saveToCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SharedPreferences&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Meal&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toJson&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="s1"&gt;-ts&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;millisecondsSinceEpoch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;캐시 키는 &lt;code&gt;meal_20240415_2&lt;/code&gt; 형태 — 날짜와 끼니(1=조식, 2=중식, 3=석식)의 조합이다. 한 번 불러온 급식 데이터는 로컬에 저장되어 다음에 같은 날짜를 볼 때 네트워크 요청 없이 바로 표시된다.&lt;/p&gt;
&lt;p&gt;이것만으로도 체감 속도가 크게 좋아졌다. 하지만 문제가 하나 더 있었다.&lt;/p&gt;
&lt;h2 id="문제-2-날짜를-넘길-때마다-로딩"&gt;&lt;a href="#%eb%ac%b8%ec%a0%9c-2-%eb%82%a0%ec%a7%9c%eb%a5%bc-%eb%84%98%ea%b8%b8-%eb%95%8c%eb%a7%88%eb%8b%a4-%eb%a1%9c%eb%94%a9" class="header-anchor"&gt;&lt;/a&gt;문제 2: 날짜를 넘길 때마다 로딩
&lt;/h2&gt;&lt;p&gt;급식 화면에서 스와이프로 날짜를 넘기면, 그날 데이터가 캐시에 없으니 다시 API를 호출한다. 월요일부터 금요일까지 쭉 넘기면 호출이 15번(5일 × 3끼). 사용자 입장에서는 날짜를 넘길 때마다 잠깐 로딩이 보인다.&lt;/p&gt;
&lt;h2 id="해결-월-단위-프리페치"&gt;&lt;a href="#%ed%95%b4%ea%b2%b0-%ec%9b%94-%eb%8b%a8%ec%9c%84-%ed%94%84%eb%a6%ac%ed%8e%98%ec%b9%98" class="header-anchor"&gt;&lt;/a&gt;해결: 월 단위 프리페치
&lt;/h2&gt;&lt;p&gt;NEIS API는 &lt;code&gt;MLSV_FROM_YMD&lt;/code&gt;와 &lt;code&gt;MLSV_TO_YMD&lt;/code&gt; 파라미터로 &lt;strong&gt;기간 조회&lt;/strong&gt;를 지원한다. 한 번의 요청으로 한 달 치 급식 데이터를 전부 가져올 수 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_prefetchMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;monthKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;yyyyMM&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 같은 달을 중복 요청하지 않도록 guard
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_prefetchingMonths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_prefetchingMonths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;firstDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;lastDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;requestURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;https://open.neis.go.kr/hub/mealServiceDietInfo?&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;Type=json&amp;amp;pIndex=1&amp;amp;pSize=100&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;MLSV_FROM_YMD=&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;fromDate&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;MLSV_TO_YMD=&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;toDate&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 응답의 모든 급식 데이터를 각각 캐시에 저장
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mealDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mealCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_saveToCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;한 번의 API 호출로 해당 월의 모든 급식(보통 60~90개 항목)을 가져와서 각각 캐시에 저장한다. 이후 같은 달의 어떤 날짜를 보더라도 캐시에서 즉시 표시된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_prefetchingMonths&lt;/code&gt; Map으로 같은 달의 중복 요청을 방지한다. 급식 화면을 열면서 프리페치를 시작하고, 그 사이에 사용자가 날짜를 넘겨도 같은 달이면 이미 진행 중인 프리페치를 기다린다.&lt;/p&gt;
&lt;p&gt;주간 프리페치도 있다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;prefetchWeek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;baseDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;monday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;days:&lt;/span&gt; &lt;span class="n"&gt;baseDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weekday&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;friday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;monday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;days:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;friday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_prefetchMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monday&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 월이 걸치면 두 달 모두 프리페치
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_prefetchMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monday&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_prefetchMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;friday&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;월~금이 월경계에 걸릴 수 있다. 예를 들어 3월 31일(월)~4월 4일(금)이면 3월과 4월 데이터를 모두 프리페치한다.&lt;/p&gt;
&lt;h2 id="문제-3-캐시가-오래되면"&gt;&lt;a href="#%eb%ac%b8%ec%a0%9c-3-%ec%ba%90%ec%8b%9c%ea%b0%80-%ec%98%a4%eb%9e%98%eb%90%98%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;문제 3: 캐시가 오래되면?
&lt;/h2&gt;&lt;p&gt;급식 데이터는 학교 사정으로 바뀔 수 있다. 어제 캐시한 데이터가 오늘도 맞다는 보장이 없다. 그렇다고 캐시를 매번 무시하면 캐싱의 의미가 없다.&lt;/p&gt;
&lt;h2 id="해결-swr-패턴"&gt;&lt;a href="#%ed%95%b4%ea%b2%b0-swr-%ed%8c%a8%ed%84%b4" class="header-anchor"&gt;&lt;/a&gt;해결: SWR 패턴
&lt;/h2&gt;&lt;p&gt;SWR(Stale-While-Revalidate)은 웹 개발에서 온 패턴이다. &lt;strong&gt;오래된 캐시를 일단 보여주고, 백그라운드에서 새 데이터를 가져온다.&lt;/strong&gt; 사용자는 즉시 데이터를 보고, 데이터가 바뀌었으면 자동으로 갱신된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Meal&lt;/span&gt;&lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getMeal&lt;/span&gt;&lt;span class="p"&gt;({...})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_getFromCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_isCacheStale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// SWR: 만료된 캐시를 즉시 반환하고 백그라운드에서 갱신
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_prefetchMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// await 하지 않음
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 캐시 없으면 네트워크 요청
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_prefetchMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_getFromCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;핵심은 &lt;code&gt;_prefetchMonth(date)&lt;/code&gt;를 &lt;code&gt;await&lt;/code&gt; &lt;strong&gt;하지 않는 것&lt;/strong&gt;이다. 캐시가 stale이면 일단 오래된 데이터를 반환하고, 프리페치는 백그라운드에서 돌린다. 다음에 화면을 열면 갱신된 데이터가 표시된다.&lt;/p&gt;
&lt;p&gt;캐시 만료 정책도 계층적이다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Meal&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_getFromCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SharedPreferences&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;millisecondsSinceEpoch&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ApiStrings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mealNoData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// &amp;#34;데이터 없음&amp;#34;은 5분만 캐시
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 3일 지나면 완전 만료
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&amp;ldquo;데이터 없음&amp;rdquo; 응답&lt;/strong&gt;: 5분만 캐시한다. 학교에서 아직 급식을 등록 안 했을 수 있으니 곧 다시 시도&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;정상 데이터&lt;/strong&gt;: 24시간까지 fresh, 24시간~3일은 stale(SWR 대상), 3일 이후는 완전 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="오프라인-대응"&gt;&lt;a href="#%ec%98%a4%ed%94%84%eb%9d%bc%ec%9d%b8-%eb%8c%80%ec%9d%91" class="header-anchor"&gt;&lt;/a&gt;오프라인 대응
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;NetworkStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isUnconnected&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Meal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;meal:&lt;/span&gt; &lt;span class="n"&gt;ApiStrings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mealNoInternet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;네트워크가 없으면 캐시가 아무리 오래되었어도 반환한다. 오래된 데이터라도 &amp;ldquo;인터넷 연결 없음&amp;quot;보다는 낫다. 캐시도 없으면 그때 안내 메시지를 보여준다.&lt;/p&gt;
&lt;h2 id="meal-모델의-등장"&gt;&lt;a href="#meal-%eb%aa%a8%eb%8d%b8%ec%9d%98-%eb%93%b1%ec%9e%a5" class="header-anchor"&gt;&lt;/a&gt;Meal 모델의 등장
&lt;/h2&gt;&lt;p&gt;Java에서는 급식 데이터가 &lt;code&gt;String&lt;/code&gt;이었다. &amp;ldquo;메뉴&amp;rdquo;, &amp;ldquo;칼로리&amp;rdquo;, &amp;ldquo;영양정보&amp;quot;를 별도 호출로 가져왔다.&lt;/p&gt;
&lt;p&gt;Flutter에서는 &lt;code&gt;Meal&lt;/code&gt; 모델 하나에 전부 담는다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 메뉴
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;kcal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 칼로리
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;ntrInfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 영양정보
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 날짜
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;mealType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1=조식, 2=중식, 3=석식
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;toJson()&lt;/code&gt;/&lt;code&gt;fromJson()&lt;/code&gt;이 있어서 캐시 직렬화도 한 줄이다. Java에서 &lt;code&gt;getMeal(date, &amp;quot;1&amp;quot;, &amp;quot;메뉴&amp;quot;)&lt;/code&gt;, &lt;code&gt;getMeal(date, &amp;quot;1&amp;quot;, &amp;quot;칼로리&amp;quot;)&lt;/code&gt;로 따로 호출하던 걸, &lt;code&gt;getMeal(date: date, mealType: 1)&lt;/code&gt;로 한 번에 전부 가져온다.&lt;/p&gt;
&lt;h2 id="80줄--320줄-뭐가-늘었나"&gt;&lt;a href="#80%ec%a4%84--320%ec%a4%84-%eb%ad%90%ea%b0%80-%eb%8a%98%ec%97%88%eb%82%98" class="header-anchor"&gt;&lt;/a&gt;80줄 → 320줄, 뭐가 늘었나
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;구분&lt;/th&gt;
 &lt;th&gt;Java (80줄)&lt;/th&gt;
 &lt;th&gt;Flutter (320줄)&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;URL 조립&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;HTTP 요청&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;JSON 파싱&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;데이터 모델&lt;/td&gt;
 &lt;td&gt;X (String)&lt;/td&gt;
 &lt;td&gt;O (Meal 클래스)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;캐시&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;SharedPreferences&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;월 단위 프리페치&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;중복 요청 방지&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;Completer&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;SWR 갱신&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;캐시 만료 정책&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;3단계 (5분/24시간/3일)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;오프라인 대응&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;O&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;테스트 지원&lt;/td&gt;
 &lt;td&gt;X&lt;/td&gt;
 &lt;td&gt;@visibleForTesting&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;코드가 4배 늘었지만, 네트워크 요청은 수십 분의 1로 줄었다. 사용자가 체감하는 로딩 시간은 거의 0이 되었다. 80줄에서 320줄로 가는 과정이 곧 &amp;ldquo;동작하는 코드&amp;quot;에서 &amp;ldquo;쓸 만한 앱&amp;quot;으로 가는 과정이었다.&lt;/p&gt;
&lt;h2 id="다음-글에서는"&gt;&lt;a href="#%eb%8b%a4%ec%9d%8c-%ea%b8%80%ec%97%90%ec%84%9c%eb%8a%94" class="header-anchor"&gt;&lt;/a&gt;다음 글에서는
&lt;/h2&gt;&lt;p&gt;앱의 커뮤니티 기능을 뒷받침하는 Firestore 스키마 설계 — 게시판, 채팅, 사용자 관리까지의 구조와 초기 실수들을 다룬다.&lt;/p&gt;</description></item><item><title>#5 - Flutter 첫 한 달</title><link>https://monkshark.github.io/p/first-month/</link><pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/first-month/</guid><description>&lt;h2 id="12월-7일-같은-날의-두-커밋"&gt;&lt;a href="#12%ec%9b%94-7%ec%9d%bc-%ea%b0%99%ec%9d%80-%eb%82%a0%ec%9d%98-%eb%91%90-%ec%bb%a4%eb%b0%8b" class="header-anchor"&gt;&lt;/a&gt;12월 7일, 같은 날의 두 커밋
&lt;/h2&gt;&lt;p&gt;2023년 12월 7일. &lt;a class="link" href="https://github.com/Monkshark/hansol_hs_java_app" target="_blank" rel="noopener"
 &gt;Java 레포&lt;/a&gt;에 v0.12.3 Beta 마지막 커밋을 남기고, 같은 날 Flutter 레포에 first commit을 찍었다. 하나를 끝내고 바로 다음을 시작한 것이다.&lt;/p&gt;
&lt;p&gt;첫 커밋은 &lt;code&gt;flutter create&lt;/code&gt; 그 자체였다. 137개 파일, 5,126줄. Flutter가 자동 생성하는 프로젝트 템플릿이다. android, ios, web, linux, macos, windows — 모든 플랫폼의 보일러플레이트가 포함되어 있었다.&lt;/p&gt;
&lt;p&gt;하지만 이 커밋에 이미 Java에서 가져온 파일 3개가 들어 있었다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/GetMealData.dart
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/GetNoticeData.dart
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/GetTimeTableData.dart
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Java의 &lt;code&gt;getMealData.java&lt;/code&gt;, &lt;code&gt;getNoticeData.java&lt;/code&gt;, &lt;code&gt;getTimetableData.java&lt;/code&gt;를 Dart로 포팅한 것이다. 프로젝트를 만들자마자 가장 먼저 한 일이 NEIS API 연동 코드를 옮기는 것이었다.&lt;/p&gt;
&lt;h2 id="첫째-주-뼈대-잡기-127--1213"&gt;&lt;a href="#%ec%b2%ab%ec%a7%b8-%ec%a3%bc-%eb%bc%88%eb%8c%80-%ec%9e%a1%ea%b8%b0-127--1213" class="header-anchor"&gt;&lt;/a&gt;첫째 주: 뼈대 잡기 (12/7 ~ 12/13)
&lt;/h2&gt;&lt;h3 id="화면-구조"&gt;&lt;a href="#%ed%99%94%eb%a9%b4-%ea%b5%ac%ec%a1%b0" class="header-anchor"&gt;&lt;/a&gt;화면 구조
&lt;/h3&gt;&lt;p&gt;둘째 커밋(12/8)에서 화면 4개의 껍데기를 만들었다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/Screens/homeScreen.dart +28줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/Screens/mainScreen.dart +11줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/Screens/mealScreen.dart +29줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/Screens/noticeScreen.dart +29줄
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Java 프로토타입과 동일한 구조다. 홈, 급식, 공지. BottomNavigation으로 전환하는 방식도 같았다. 이미 Java에서 검증한 화면 흐름을 그대로 가져왔다.&lt;/p&gt;
&lt;h3 id="파일-구조-리팩토링"&gt;&lt;a href="#%ed%8c%8c%ec%9d%bc-%ea%b5%ac%ec%a1%b0-%eb%a6%ac%ed%8c%a9%ed%86%a0%eb%a7%81" class="header-anchor"&gt;&lt;/a&gt;파일 구조 리팩토링
&lt;/h3&gt;&lt;p&gt;셋째 커밋(12/10)에서 바로 파일 구조를 정리했다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GetMealData.dart → Data/mealDataApi.dart
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GetNoticeData.dart → Data/noticeDataApi.dart
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GetTimeTableData.dart → Data/tiemtableDataApi.dart
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;루트에 흩어져 있던 API 파일들을 &lt;code&gt;Data/&lt;/code&gt; 폴더로 모았다. 파일명도 PascalCase에서 camelCase로 바꿨다. Java 습관을 Dart 컨벤션으로 전환하는 과정이었다. (참고로 &lt;code&gt;tiemtable&lt;/code&gt;은 오타다. 나중에 &lt;code&gt;timetable&lt;/code&gt;로 고쳤다.)&lt;/p&gt;
&lt;h3 id="알림-첫-시도-1212--1213"&gt;&lt;a href="#%ec%95%8c%eb%a6%bc-%ec%b2%ab-%ec%8b%9c%eb%8f%84-1212--1213" class="header-anchor"&gt;&lt;/a&gt;알림 첫 시도 (12/12 ~ 12/13)
&lt;/h3&gt;&lt;p&gt;Flutter 시작 5일 만에 알림 기능에 손을 댔다. &lt;a class="link" href="https://monkshark.github.io/p/meal-notification/" &gt;#3&lt;/a&gt;에서 자세히 다뤘지만, &lt;code&gt;NotificationManager.dart&lt;/code&gt; 165줄을 만들고, &lt;code&gt;FirebaseCloudMessaging.dart&lt;/code&gt;를 넣었다 4시간 만에 삭제하는 사건이 이때 일어났다.&lt;/p&gt;
&lt;p&gt;돌이켜보면 너무 일찍 손을 댄 것이었다. 화면 구조도 다 안 잡힌 상태에서 알림까지 만들려고 했으니.&lt;/p&gt;
&lt;h2 id="둘째-주-기능-구현-1215--1220"&gt;&lt;a href="#%eb%91%98%ec%a7%b8-%ec%a3%bc-%ea%b8%b0%eb%8a%a5-%ea%b5%ac%ed%98%84-1215--1220" class="header-anchor"&gt;&lt;/a&gt;둘째 주: 기능 구현 (12/15 ~ 12/20)
&lt;/h2&gt;&lt;p&gt;12월 17일에 커밋이 9개다. 하루에 9번. 이 시기가 가장 집중적으로 개발한 때였다.&lt;/p&gt;
&lt;p&gt;이 주에 만든 것들:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;급식 화면&lt;/strong&gt; — NEIS API 연동, 조식/중식/석식 표시, 날짜 이동&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시간표 화면&lt;/strong&gt; — 학년/반 선택, 요일별 시간표 표시&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;설정 화면&lt;/strong&gt; — 학년/반 저장, 알림 on/off&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;로그인/회원가입&lt;/strong&gt; — Firebase Auth 연동&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;달력&lt;/strong&gt; — 학사일정 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;12월 20일에는 커밋이 7개. 이 이틀(17일, 20일)에 전체 첫 달 커밋의 거의 절반이 몰려 있다. 기숙사에서 자습 시간과 주말을 전부 개발에 쏟은 날들이다.&lt;/p&gt;
&lt;h2 id="셋째-주-api-안정화-1220--1227"&gt;&lt;a href="#%ec%85%8b%ec%a7%b8-%ec%a3%bc-api-%ec%95%88%ec%a0%95%ed%99%94-1220--1227" class="header-anchor"&gt;&lt;/a&gt;셋째 주: API 안정화 (12/20 ~ 12/27)
&lt;/h2&gt;&lt;p&gt;12월 25일, 크리스마스에도 코딩했다. 이날 커밋은 API 3개를 전부 리팩토링한 것이다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/API/MealDataApi.dart +72줄, -65줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/API/NoticeDataApi.dart +54줄, -38줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/API/TimetableDataApi.dart +38줄, -27줄
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Java에서 포팅한 초기 코드가 Dart답지 않았다. &lt;code&gt;HttpURLConnection&lt;/code&gt; 스타일로 작성했던 걸 &lt;code&gt;http&lt;/code&gt; 패키지의 &lt;code&gt;get()&lt;/code&gt; 방식으로 바꾸고, 에러 핸들링을 추가하고, JSON 파싱을 정리했다.&lt;/p&gt;
&lt;p&gt;12월 27일에는 파일명을 Dart 컨벤션(snake_case)으로 전환했다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MealDataApi.dart → meal_data_api.dart
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;NoticeDataApi.dart → notice_data_api.dart
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TimetableDataApi.dart → timetable_data_api.dart
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Java의 PascalCase → Dart의 snake_case. 이런 사소한 컨벤션 전환이 프로젝트 초기에 계속 있었다.&lt;/p&gt;
&lt;h2 id="넷째-주-meal-모델-17"&gt;&lt;a href="#%eb%84%b7%ec%a7%b8-%ec%a3%bc-meal-%eb%aa%a8%eb%8d%b8-17" class="header-anchor"&gt;&lt;/a&gt;넷째 주: Meal 모델 (1/7)
&lt;/h2&gt;&lt;p&gt;1월 7일 커밋에서 &lt;code&gt;Meal&lt;/code&gt; 데이터 모델이 처음 등장한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lib/Data/meal.dart +13줄
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;그 전까지는 급식 데이터를 &lt;code&gt;String&lt;/code&gt;으로만 다뤘다. Java 시절과 똑같이 API 응답을 문자열로 받아서 화면에 바로 뿌렸다. 하지만 급식 정보에는 메뉴, 칼로리, 영양정보, 날짜, 끼니 구분 등 여러 필드가 있고, 이걸 하나의 모델 클래스로 묶어야 코드가 정리된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Meal&lt;/code&gt; 모델을 만들면서 동시에 &lt;code&gt;meal_card.dart&lt;/code&gt;가 95줄이나 추가되었다. 급식 카드 위젯이 별도 파일로 분리된 것이다. Java에서는 &lt;code&gt;MealFragment&lt;/code&gt; 하나에 271줄이 전부 들어 있었는데, Flutter에서는 위젯 단위로 분리하기 시작했다.&lt;/p&gt;
&lt;h2 id="첫-달의-숫자"&gt;&lt;a href="#%ec%b2%ab-%eb%8b%ac%ec%9d%98-%ec%88%ab%ec%9e%90" class="header-anchor"&gt;&lt;/a&gt;첫 달의 숫자
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;항목&lt;/th&gt;
 &lt;th&gt;수치&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;기간&lt;/td&gt;
 &lt;td&gt;2023-12-07 ~ 2024-01-07&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;커밋 수&lt;/td&gt;
 &lt;td&gt;36&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;가장 많은 날&lt;/td&gt;
 &lt;td&gt;12/17 (9커밋), 12/20 (7커밋)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;커밋 0개인 날&lt;/td&gt;
 &lt;td&gt;12/9, 12/11, 12/14, 12/16, 12/21~24, 12/26, 12/28~1/6&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;커밋이 없는 날이 꽤 많다. 매일 코딩한 게 아니라, &lt;strong&gt;할 수 있는 날에 몰아서&lt;/strong&gt; 했다. 기숙사 생활이라 평일 저녁 자습 시간과 주말이 개발 시간이었고, 시험이나 학교 일정이 있으면 며칠씩 손을 못 대기도 했다. (근데 사실 공부는 안했다.)&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;첫 한 달의 핵심은 &lt;strong&gt;Java에서 검증한 구조를 Dart로 옮기는 것&lt;/strong&gt;이었다. 화면 구조, API 파싱, 데이터 흐름 — 전부 Java에서 한 번 해봤던 것들이다. 덕분에 &amp;ldquo;무엇을 만들어야 하는지&amp;quot;는 고민하지 않았고, &amp;ldquo;Flutter에서는 이걸 어떻게 만드는지&amp;quot;에만 집중할 수 있었다.&lt;/p&gt;
&lt;p&gt;동시에 Java 습관을 하나씩 버리는 과정이기도 했다. 파일 이름, 폴더 구조, 코딩 컨벤션을 Dart 방식으로 바꿔가면서, 코드가 점점 &amp;ldquo;Flutter다워&amp;quot;졌다. 이 전환은 첫 달에 끝나지 않고 이후 몇 달간 계속되었다.&lt;/p&gt;
&lt;h2 id="다음-글에서는"&gt;&lt;a href="#%eb%8b%a4%ec%9d%8c-%ea%b8%80%ec%97%90%ec%84%9c%eb%8a%94" class="header-anchor"&gt;&lt;/a&gt;다음 글에서는
&lt;/h2&gt;&lt;p&gt;첫 달에 포팅한 NEIS API 급식 파싱이 이후 어떻게 진화했는지 — 캐싱, 월 단위 프리페치, SWR 패턴까지의 과정을 다룬다.&lt;/p&gt;</description></item><item><title>#4 - Java 코드에서 가져온 것, 버린 것</title><link>https://monkshark.github.io/p/java-to-dart/</link><pubDate>Wed, 08 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/java-to-dart/</guid><description>&lt;h2 id="159커밋의-유산"&gt;&lt;a href="#159%ec%bb%a4%eb%b0%8b%ec%9d%98-%ec%9c%a0%ec%82%b0" class="header-anchor"&gt;&lt;/a&gt;159커밋의 유산
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://monkshark.github.io/p/java-to-flutter/" &gt;#2&lt;/a&gt;에서 Java 프로토타입 159커밋을 버리고 Flutter로 전환한 이야기를 했다. 코드는 버렸지만, 모든 걸 버린 건 아니었다. Java에서 삽질하며 만든 설계와 경험은 그대로 가져갔고, 동시에 초보 시절의 나쁜 습관은 버렸다.&lt;/p&gt;
&lt;p&gt;Java 프로토타입의 실제 코드를 보면서, 뭘 가져가고 뭘 버렸는지 정리해본다.&lt;/p&gt;
&lt;h2 id="가져간-것-neis-api-파싱-구조"&gt;&lt;a href="#%ea%b0%80%ec%a0%b8%ea%b0%84-%ea%b2%83-neis-api-%ed%8c%8c%ec%8b%b1-%ea%b5%ac%ec%a1%b0" class="header-anchor"&gt;&lt;/a&gt;가져간 것: NEIS API 파싱 구조
&lt;/h2&gt;&lt;h3 id="java--getmealdatajava"&gt;&lt;a href="#java--getmealdatajava" class="header-anchor"&gt;&lt;/a&gt;Java — &lt;code&gt;getMealData.java&lt;/code&gt;
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;getMeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mealScCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;requestURL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;https://open.neis.go.kr/hub/mealServiceDietInfo?&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;Type=json&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;MMEAL_SC_CODE=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mealScCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;ATPT_OFCDC_SC_CODE=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;niesAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ATPT_OFCDC_SC_CODE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;SD_SCHUL_CODE=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;niesAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SD_SCHUL_CODE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;amp;MLSV_YMD=&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CompletableFuture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// HTTP 연결, JSON 파싱...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;DDISH_NM&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;칼로리&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;CAL_INFO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;영양정보&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;NTR_INFO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;switch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;메뉴&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;칼로리&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;칼로리&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;영양정보&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;영양정보&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;lt;br/&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="flutter--mealdataapi"&gt;&lt;a href="#flutter--mealdataapi" class="header-anchor"&gt;&lt;/a&gt;Flutter — &lt;code&gt;MealDataApi&lt;/code&gt;
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Meal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_fetchSingleMeal&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;requestURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;https://open.neis.go.kr/hub/mealServiceDietInfo?&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;key=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;niesApiKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NIES_API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;Type=json&amp;amp;MMEAL_SC_CODE=&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;mealType&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;ATPT_OFCDC_SC_CODE=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;niesApiKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ATPT_OFCDC_SC_CODE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;SD_SCHUL_CODE=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;niesApiKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SD_SCHUL_CODE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;amp;MLSV_YMD=&lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;formattedDate&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Meal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;meal:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;DDISH_NM&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;lt;br/&amp;gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;kcal:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;CAL_INFO&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;ntrInfo:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;NTR_INFO&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;lt;br/&amp;gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;URL 구조가 거의 동일하다. &lt;code&gt;ATPT_OFCDC_SC_CODE&lt;/code&gt;, &lt;code&gt;SD_SCHUL_CODE&lt;/code&gt;, &lt;code&gt;MMEAL_SC_CODE&lt;/code&gt; — NEIS API의 파라미터 이름은 바뀌지 않으니까. Java에서 이미 API 문서를 파고들어서 필요한 파라미터를 정리해뒀기 때문에, Flutter에서는 URL을 그대로 가져다 쓸 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;br/&amp;gt;&lt;/code&gt; → &lt;code&gt;\n&lt;/code&gt; 변환도 그대로다. NEIS API는 메뉴 항목을 &lt;code&gt;&amp;lt;br/&amp;gt;&lt;/code&gt; 태그로 구분해서 보내주는데, 이걸 줄바꿈으로 바꿔야 화면에 제대로 표시된다. Java에서 이미 알아낸 사실이라 Dart에서는 고민 없이 처리했다.&lt;/p&gt;
&lt;p&gt;JSON 응답 구조도 동일하다. &lt;code&gt;mealServiceDietInfo&lt;/code&gt; → &lt;code&gt;row&lt;/code&gt; 배열 → 각 항목에서 &lt;code&gt;DDISH_NM&lt;/code&gt;, &lt;code&gt;CAL_INFO&lt;/code&gt;, &lt;code&gt;NTR_INFO&lt;/code&gt; 추출. 이 구조를 파악하는 데 Java 시절에 꽤 시간을 썼는데, 한 번 알면 두 번 다시 삽질할 필요가 없다.&lt;/p&gt;
&lt;h2 id="가져간-것-알러지-괄호-제거"&gt;&lt;a href="#%ea%b0%80%ec%a0%b8%ea%b0%84-%ea%b2%83-%ec%95%8c%eb%9f%ac%ec%a7%80-%ea%b4%84%ed%98%b8-%ec%a0%9c%ea%b1%b0" class="header-anchor"&gt;&lt;/a&gt;가져간 것: 알러지 괄호 제거
&lt;/h2&gt;&lt;h3 id="java--homefragmentjava"&gt;&lt;a href="#java--homefragmentjava" class="header-anchor"&gt;&lt;/a&gt;Java — &lt;code&gt;HomeFragment.java&lt;/code&gt;
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;deleteBracket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[().1234567890]&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="flutter--dailymealnotification"&gt;&lt;a href="#flutter--dailymealnotification" class="header-anchor"&gt;&lt;/a&gt;Flutter — &lt;code&gt;DailyMealNotification&lt;/code&gt;
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;_cleanMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;menu&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;r&amp;#39;\([0-9.,\s]+\)&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39; · &amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;같은 목적, 다른 구현. NEIS API의 급식 메뉴에는 &lt;code&gt;비빔밥(5.6.13)&lt;/code&gt; 형태로 알러지 정보가 붙어 있다. 사용자에게 보여줄 때는 이 괄호를 제거해야 한다.&lt;/p&gt;
&lt;p&gt;Java 버전은 단순했다. 괄호, 점, 숫자를 전부 지워버리는 방식. 하지만 이러면 메뉴 이름에 포함된 숫자까지 날아갈 수 있다. Flutter 버전에서는 정규식을 &lt;code&gt;\([0-9.,\s]+\)&lt;/code&gt; — 괄호 안에 숫자/점/쉼표/공백만 있는 패턴으로 좁혀서, 알러지 정보만 정확히 제거하도록 개선했다.&lt;/p&gt;
&lt;p&gt;Java에서 &amp;ldquo;알러지 괄호를 제거해야 한다&amp;quot;는 &lt;strong&gt;문제 자체를 발견한 것&lt;/strong&gt;이 가장 큰 유산이었다. 해결 방법은 더 나은 걸로 바꿨지만, 문제를 아는 것과 모르는 것의 차이는 크다.&lt;/p&gt;
&lt;h2 id="가져간-것-급식-알림의-기본-구조"&gt;&lt;a href="#%ea%b0%80%ec%a0%b8%ea%b0%84-%ea%b2%83-%ea%b8%89%ec%8b%9d-%ec%95%8c%eb%a6%bc%ec%9d%98-%ea%b8%b0%eb%b3%b8-%ea%b5%ac%ec%a1%b0" class="header-anchor"&gt;&lt;/a&gt;가져간 것: 급식 알림의 기본 구조
&lt;/h2&gt;&lt;h3 id="java--firebasemessagingjava"&gt;&lt;a href="#java--firebasemessagingjava" class="header-anchor"&gt;&lt;/a&gt;Java — &lt;code&gt;FirebaseMessaging.java&lt;/code&gt;
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;setAlarms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@NonNull&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AlarmManager&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;alarmManager&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlarmManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ALARM_SERVICE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Calendar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;currentCalendar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dayOfWeek&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;currentCalendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DAY_OF_WEEK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dayOfWeek&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SATURDAY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dayOfWeek&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SUNDAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PendingIntent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pendingIntent1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;createPendingIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;조식&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PendingIntent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pendingIntent2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;createPendingIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;중식&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PendingIntent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pendingIntent3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;createPendingIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;석식&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;alarmManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExactAndAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlarmManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RTC_WAKEUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;calendar1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTimeInMillis&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pendingIntent1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="java--alarmreceiverjava"&gt;&lt;a href="#java--alarmreceiverjava" class="header-anchor"&gt;&lt;/a&gt;Java — &lt;code&gt;AlarmReceiver.java&lt;/code&gt;
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;onReceive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;분류&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStringExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;분류&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;getMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;분류&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;spDate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NotificationUtil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendMealNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;분류&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34; 정보&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;여기가 아이러니한 부분이다. &lt;a class="link" href="https://monkshark.github.io/p/meal-notification/" &gt;#3&lt;/a&gt;에서 Flutter로 급식 알림을 만드는 데 1년이 걸렸다고 했는데, &lt;strong&gt;Java 프로토타입에는 이미 동작하는 급식 알림이 있었다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AlarmManager&lt;/code&gt; + &lt;code&gt;setExactAndAllowWhileIdle&lt;/code&gt; + &lt;code&gt;BroadcastReceiver&lt;/code&gt; — Android 네이티브 API를 직접 쓰는 방식이었다. 알림이 울리면 &lt;code&gt;AlarmReceiver&lt;/code&gt;가 NEIS API를 호출해서 실제 메뉴를 가져오고, &lt;code&gt;NotificationUtil&lt;/code&gt;이 BigTextStyle로 보여준다. 주말은 건너뛴다.&lt;/p&gt;
&lt;p&gt;Flutter에서 이 기능을 다시 만들 때, 처음에는 &lt;code&gt;flutter_local_notifications&lt;/code&gt;로 시작했다가 안정성 문제로 Kotlin 네이티브(&lt;code&gt;MealNotificationReceiver.kt&lt;/code&gt; + &lt;code&gt;MealWorker.kt&lt;/code&gt;)로 갔다가, 결국 다시 Flutter로 돌아왔다. 1년간의 삽질 끝에 도달한 &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; — 이건 Java의 &lt;code&gt;setExactAndAllowWhileIdle&lt;/code&gt;과 &lt;strong&gt;같은 Android API&lt;/strong&gt;를 Flutter 래퍼로 호출하는 것이다.&lt;/p&gt;
&lt;p&gt;돌고 돌아 원점이었다. 다만 Java 시절에는 &amp;ldquo;이게 왜 동작하는지&amp;rdquo; 이해하지 못한 채 코드를 썼고, Flutter에서 삽질한 후에야 &lt;code&gt;AlarmManager&lt;/code&gt;의 exact alarm이 Doze 모드에서도 동작하는 이유를 이해하게 되었다.&lt;/p&gt;
&lt;h2 id="버린-것-한글-변수명"&gt;&lt;a href="#%eb%b2%84%eb%a6%b0-%ea%b2%83-%ed%95%9c%ea%b8%80-%eb%b3%80%ec%88%98%eb%aa%85" class="header-anchor"&gt;&lt;/a&gt;버린 것: 한글 변수명
&lt;/h2&gt;&lt;p&gt;Java 프로토타입에서 가장 눈에 띄는 특징은 &lt;strong&gt;한글 변수명&lt;/strong&gt;이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;DDISH_NM&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;칼로리&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;CAL_INFO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;영양정보&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;itemObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;NTR_INFO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// AlarmReceiver.java&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;분류&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStringExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;분류&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;final&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Java는 유니코드 식별자를 허용하기 때문에 기술적으로 문제는 없다. 그리고 솔직히 코드를 읽을 때 &lt;code&gt;String meal&lt;/code&gt;보다 &lt;code&gt;String 메뉴&lt;/code&gt;가 직관적이긴 하다.&lt;/p&gt;
&lt;p&gt;하지만 Flutter로 전환하면서 전부 영어로 바꿨다. 이유:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;라이브러리/프레임워크와의 일관성&lt;/strong&gt; — Flutter의 모든 API가 영어다. 내 코드만 한글이면 섞여서 읽기 어렵다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;자동완성&lt;/strong&gt; — IDE에서 &lt;code&gt;me&lt;/code&gt;까지 치면 &lt;code&gt;meal&lt;/code&gt;, &lt;code&gt;mealType&lt;/code&gt; 같은 후보가 뜨는데, 한글이면 &lt;code&gt;ㅁ&lt;/code&gt;을 치고 한영 전환을 해야 한다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;협업 가능성&lt;/strong&gt; — 혼자 만드는 앱이지만, 코드를 GitHub에 올리는 이상 영어가 맞다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="버린-것-커밋-메시지-update"&gt;&lt;a href="#%eb%b2%84%eb%a6%b0-%ea%b2%83-%ec%bb%a4%eb%b0%8b-%eb%a9%94%ec%8b%9c%ec%a7%80-update" class="header-anchor"&gt;&lt;/a&gt;버린 것: 커밋 메시지 &amp;ldquo;Update&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Java 레포의 159커밋 중 대부분의 메시지가 이렇다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Update
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Update
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Update
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Update
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Merge remote-tracking branch &amp;#39;origin/main&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Update
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;9월 12일 하루에 커밋이 20개가 넘는데, 전부 &amp;ldquo;Update&amp;rdquo;. 뭘 바꿨는지 메시지만 봐서는 전혀 알 수 없다. Git을 처음 쓰면서 &amp;ldquo;저장&amp;rdquo; 버튼처럼 사용했던 것 같다.&lt;/p&gt;
&lt;p&gt;Flutter 레포로 넘어오면서 커밋 메시지에 변경 내용을 적기 시작했다. 처음에는 &amp;ldquo;Migration&amp;quot;이 많았지만, 점차 구체적으로 바뀌어 갔다.&lt;/p&gt;
&lt;h2 id="버린-것-static-남용"&gt;&lt;a href="#%eb%b2%84%eb%a6%b0-%ea%b2%83-static-%eb%82%a8%ec%9a%a9" class="header-anchor"&gt;&lt;/a&gt;버린 것: &lt;code&gt;static&lt;/code&gt; 남용
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// getMealData.java&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;getMeal&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// result에 직접 대입&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;메뉴&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;lt;br/&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;static&lt;/code&gt; 필드에 결과를 직접 대입하는 방식. 여러 곳에서 동시에 &lt;code&gt;getMeal&lt;/code&gt;을 호출하면 &lt;code&gt;result&lt;/code&gt;가 덮어씌워질 수 있다. 실제로 HomeFragment에서 급식과 시간표를 동시에 비동기 호출하고 있었는데, 운 좋게 문제가 안 터졌을 뿐이다.&lt;/p&gt;
&lt;p&gt;Flutter 버전에서는 각 함수가 독립적인 반환값을 가지고, 상태를 공유하지 않는다.&lt;/p&gt;
&lt;h2 id="버린-것-배터리-최적화-해제-강제-요청"&gt;&lt;a href="#%eb%b2%84%eb%a6%b0-%ea%b2%83-%eb%b0%b0%ed%84%b0%eb%a6%ac-%ec%b5%9c%ec%a0%81%ed%99%94-%ed%95%b4%ec%a0%9c-%ea%b0%95%ec%a0%9c-%ec%9a%94%ec%b2%ad" class="header-anchor"&gt;&lt;/a&gt;버린 것: 배터리 최적화 해제 강제 요청
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// HomeFragment.java&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;checkBatteryOptimization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PowerManager&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PowerManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POWER_SERVICE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isIgnoringBatteryOptimizations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;package:&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;앱을 열 때마다 배터리 최적화 해제를 요청하는 코드. 알림이 안정적으로 오게 하려는 의도였지만, 사용자 경험이 최악이다. 앱을 열 때마다 시스템 팝업이 뜬다. Google Play 정책에서도 이런 방식은 권장하지 않는다.&lt;/p&gt;
&lt;p&gt;Flutter 버전에서는 이런 강제 요청 대신, &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; 모드로 시스템의 정상적인 알림 경로를 사용한다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;Java 프로토타입은 &amp;ldquo;이것도 되나? 저것도 되나?&amp;rdquo; 하면서 마구 시도한 코드였다. 정리되지 않았고, 위험한 패턴도 있었다. 하지만 그 덕분에:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NEIS API의 구조를 완전히 파악했다&lt;/li&gt;
&lt;li&gt;급식, 시간표, 알림의 &lt;strong&gt;핵심 로직&lt;/strong&gt;을 한 번 구현해봤다&lt;/li&gt;
&lt;li&gt;뭘 하면 안 되는지(한글 변수명, static 남용, 강제 권한 요청)를 경험으로 배웠다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;코드는 버렸지만 경험은 전부 가져갔다. Flutter 프로젝트의 첫 커밋이 Java 마지막 커밋과 같은 날(2023년 12월 7일)인 건, 하나를 끝내고 바로 다음을 시작할 수 있을 만큼 준비가 되어 있었다는 뜻이다.&lt;/p&gt;
&lt;h2 id="다음-글에서는"&gt;&lt;a href="#%eb%8b%a4%ec%9d%8c-%ea%b8%80%ec%97%90%ec%84%9c%eb%8a%94" class="header-anchor"&gt;&lt;/a&gt;다음 글에서는
&lt;/h2&gt;&lt;p&gt;Flutter 첫 커밋부터 한 달간 무엇을 만들었는지, 초기 개발의 속도와 순서를 다룬다.&lt;/p&gt;</description></item><item><title>#3 - 급식 알림의 삽질기</title><link>https://monkshark.github.io/p/meal-notification/</link><pubDate>Tue, 07 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/meal-notification/</guid><description>&lt;h2 id="단순해-보였던-기능"&gt;&lt;a href="#%eb%8b%a8%ec%88%9c%ed%95%b4-%eb%b3%b4%ec%98%80%eb%8d%98-%ea%b8%b0%eb%8a%a5" class="header-anchor"&gt;&lt;/a&gt;단순해 보였던 기능
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;매일 아침에 오늘 급식 메뉴를 알림으로 보내준다.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;한 줄로 설명 가능한 기능이다. 사용자 입장에서는 당연히 있어야 할 것 같고, 구현도 간단해 보인다. 알림 예약하고, 시간 되면 보내면 되는 거 아닌가?&lt;/p&gt;
&lt;p&gt;이 &amp;ldquo;간단한&amp;rdquo; 기능을 제대로 동작하게 만드는 데 1년이 걸렸다. 2023년 12월부터 2024년 12월까지, 접근 방식을 네 번 바꾸고, 파일을 만들었다 지우기를 반복하고, 결국 처음과 전혀 다른 구조로 끝났다.&lt;/p&gt;
&lt;h2 id="1차-시도-notificationmanager-2023년-12월"&gt;&lt;a href="#1%ec%b0%a8-%ec%8b%9c%eb%8f%84-notificationmanager-2023%eb%85%84-12%ec%9b%94" class="header-anchor"&gt;&lt;/a&gt;1차 시도: NotificationManager (2023년 12월)
&lt;/h2&gt;&lt;p&gt;Flutter로 전환한 직후, 가장 먼저 만들고 싶었던 기능이 급식 알림이었다. Java 프로토타입에서도 알림 기능이 있었으니까, Flutter에서도 금방 만들 수 있을 거라고 생각했다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;NotificationManager.dart&lt;/code&gt;를 만들었다. 165줄. &lt;code&gt;flutter_local_notifications&lt;/code&gt; 패키지를 사용해서 로컬 알림을 예약하는 구조였다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 2023-12-12, 첫 번째 시도
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// NotificationManager.dart — 165줄
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;같은 커밋에서 &lt;code&gt;FirebaseCloudMessaging.dart&lt;/code&gt;라는 빈 파일도 만들었다. 이름에서 알 수 있듯이, FCM(Firebase Cloud Messaging)으로 서버에서 푸시를 보내는 것도 고려하고 있었다.&lt;/p&gt;
&lt;h2 id="2차-시도-fcm을-넣었다-뺐다-같은-주"&gt;&lt;a href="#2%ec%b0%a8-%ec%8b%9c%eb%8f%84-fcm%ec%9d%84-%eb%84%a3%ec%97%88%eb%8b%a4-%eb%ba%90%eb%8b%a4-%ea%b0%99%ec%9d%80-%ec%a3%bc" class="header-anchor"&gt;&lt;/a&gt;2차 시도: FCM을 넣었다 뺐다 (같은 주)
&lt;/h2&gt;&lt;p&gt;다음 날, &lt;code&gt;FirebaseCloudMessaging.dart&lt;/code&gt;에 60줄의 코드를 채워 넣었다. FCM 토큰을 받고, 메시지를 수신하는 기본 구조를 작성했다. 동시에 &lt;code&gt;NotificationManager.dart&lt;/code&gt;도 대폭 수정했다. 90줄 분량의 변경.&lt;/p&gt;
&lt;p&gt;그리고 &lt;strong&gt;같은 날 밤&lt;/strong&gt;, FCM 코드 60줄을 통째로 삭제했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2023-12-13 20:05 FirebaseCloudMessaging.dart +60줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2023-12-13 23:53 FirebaseCloudMessaging.dart -60줄
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;4시간 만에 되돌린 것이다. 이유는 단순했다. 급식 알림은 &lt;strong&gt;서버가 보내는 게 아니라 기기가 스스로 보내야 하는 알림&lt;/strong&gt;이었다. FCM은 서버에서 클라이언트로 푸시를 보내는 도구인데, 매일 아침 급식 메뉴를 보내려면 서버 측에서 스케줄러를 돌려야 한다. Firebase Functions를 쓰면 가능하지만, 당시에는 무료 플랜(Spark)을 쓰고 있었고, Functions 배포가 불가능했다.&lt;/p&gt;
&lt;p&gt;그래서 방향을 틀었다. &lt;code&gt;ScheduledNotification.dart&lt;/code&gt;를 새로 만들고, 기기 로컬에서 알림을 예약하는 방식으로 갔다.&lt;/p&gt;
&lt;p&gt;이때까지만 해도 &amp;ldquo;방향만 잡으면 금방 끝나겠지&amp;quot;라고 생각했다.&lt;/p&gt;
&lt;h2 id="7개월의-공백-그리고-다시-시작-2024년-7월"&gt;&lt;a href="#7%ea%b0%9c%ec%9b%94%ec%9d%98-%ea%b3%b5%eb%b0%b1-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%8b%a4%ec%8b%9c-%ec%8b%9c%ec%9e%91-2024%eb%85%84-7%ec%9b%94" class="header-anchor"&gt;&lt;/a&gt;7개월의 공백, 그리고 다시 시작 (2024년 7월)
&lt;/h2&gt;&lt;p&gt;급식 알림은 한동안 손을 대지 못했다. 게시판, 채팅, 로그인 같은 핵심 기능들이 우선이었고, 알림은 &amp;ldquo;나중에 제대로 하자&amp;rdquo; 목록에 들어갔다.&lt;/p&gt;
&lt;p&gt;2024년 7월, 다시 &lt;code&gt;notification_manager.dart&lt;/code&gt;를 열었다. 150줄 이상을 수정하는 대규모 리팩토링이었다. 7개월 전에 작성한 코드를 다시 보니, 당시에는 이해가 부족해서 엉성하게 작성한 부분이 많았다. 알림 채널 설정, 권한 요청, 스케줄링 로직을 전부 다시 썼다.&lt;/p&gt;
&lt;p&gt;하지만 근본적인 문제가 남아 있었다. &lt;strong&gt;앱이 꺼져 있을 때 알림이 안 왔다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;flutter_local_notifications&lt;/code&gt;의 &lt;code&gt;zonedSchedule&lt;/code&gt;은 앱이 살아 있거나 백그라운드에 있을 때는 잘 동작한다. 하지만 사용자가 앱을 강제 종료하거나, 시스템이 메모리 부족으로 앱을 죽이면? 예약된 알림도 같이 사라진다. 특히 Android 제조사(삼성, 샤오미 등)의 배터리 최적화가 공격적으로 백그라운드 프로세스를 죽이는 환경에서, Flutter 앱의 로컬 알림은 불안정했다.&lt;/p&gt;
&lt;p&gt;매일 아침 급식 알림이 &lt;strong&gt;가끔&lt;/strong&gt; 오고 &lt;strong&gt;가끔&lt;/strong&gt; 안 온다. 이건 없느니만 못한 기능이었다.&lt;/p&gt;
&lt;h2 id="3차-시도-kotlin-네이티브로-2024년-8월"&gt;&lt;a href="#3%ec%b0%a8-%ec%8b%9c%eb%8f%84-kotlin-%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c%eb%a1%9c-2024%eb%85%84-8%ec%9b%94" class="header-anchor"&gt;&lt;/a&gt;3차 시도: Kotlin 네이티브로 (2024년 8월)
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;Flutter 레벨에서는 한계가 있다. 네이티브로 내려가자.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;2024년 8월 1일, 결정을 내렸다. Android의 네이티브 알림 시스템을 직접 사용하기로 했다. 하루 만에 세 개의 파일을 새로 만들었다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;MealNotificationReceiver.kt&lt;/code&gt;&lt;/strong&gt; (78줄) — &lt;code&gt;BroadcastReceiver&lt;/code&gt;를 상속. AlarmManager에서 트리거되면 실제 알림을 생성하는 역할&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;MealWorker.kt&lt;/code&gt;&lt;/strong&gt; (61줄) — &lt;code&gt;Worker&lt;/code&gt;를 상속. WorkManager에 등록되어, 시스템이 적절한 시점에 급식 데이터를 가져오고 알림을 예약&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;MainActivity.kt&lt;/code&gt;&lt;/strong&gt; — Flutter와 네이티브 코드를 연결하는 MethodChannel 설정 (+61줄)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;동시에 &lt;code&gt;notification_manager.dart&lt;/code&gt;에서 160줄을 삭제했다. Flutter 측의 알림 로직을 대부분 걷어내고, Kotlin 네이티브에 위임하는 구조로 바꾼 것이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2024-08-01 커밋 stat:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; MealNotificationReceiver.kt +78줄 (신규)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; MealWorker.kt +61줄 (신규)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; MainActivity.kt +61줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; notification_manager.dart -160줄
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이 접근의 핵심 아이디어는 이랬다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;WorkManager&lt;/strong&gt;가 시스템 수준에서 작업을 스케줄링한다. 앱이 죽어도 시스템이 살려서 실행한다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MealWorker&lt;/strong&gt;가 NEIS API를 호출해서 급식 데이터를 가져온다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MealNotificationReceiver&lt;/strong&gt;가 실제 알림을 사용자에게 보여준다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Android의 WorkManager는 앱이 종료되어도 시스템이 보장하는 백그라운드 작업이다. 이론적으로는 완벽한 해법이었다.&lt;/p&gt;
&lt;h3 id="그런데-문제가-또-터졌다"&gt;&lt;a href="#%ea%b7%b8%eb%9f%b0%eb%8d%b0-%eb%ac%b8%ec%a0%9c%ea%b0%80-%eb%98%90-%ed%84%b0%ec%a1%8c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;그런데 문제가 또 터졌다
&lt;/h3&gt;&lt;p&gt;이후 일주일간의 커밋 기록을 보면 상황이 얼마나 힘들었는지 알 수 있다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2024-08-04 Migration (MealWorker, Receiver 수정)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2024-08-06 Migration (notification_manager +105줄)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2024-08-07 Migration (Kotlin + notification_manager 동시 수정)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2024-08-07 commit (같은 날 또 수정)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;일주일 동안 거의 매일 커밋이 있었고, 같은 날 두 번 커밋한 날도 있었다. 네이티브 코드와 Flutter 코드를 동시에 수정하고 있다는 건, 둘 사이의 통신이 제대로 안 된다는 뜻이었다.&lt;/p&gt;
&lt;p&gt;문제들:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MethodChannel 통신 불안정&lt;/strong&gt; — Flutter에서 Kotlin으로, Kotlin에서 Flutter로 데이터를 주고받는 과정에서 타이밍 이슈가 발생. 앱이 cold start 상태일 때 채널이 준비되기 전에 호출이 가는 경우가 있었다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NEIS API를 Kotlin에서 직접 호출해야 하는 문제&lt;/strong&gt; — Flutter 쪽에 이미 잘 동작하는 MealDataApi가 있는데, 같은 로직을 Kotlin으로 다시 작성해야 했다. 코드 중복에 버그 가능성까지 두 배&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iOS는?&lt;/strong&gt; — Kotlin으로 네이티브를 작성하면 iOS에서는 별도로 Swift 코드를 작성해야 한다. AppDelegate.swift도 78줄이 추가되었지만, 플랫폼별로 다른 코드를 유지보수하는 건 크로스플랫폼의 이점을 스스로 버리는 것이었다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;8월 27일, &lt;code&gt;MealNotificationReceiver.kt&lt;/code&gt;에서 줄을 빼기 시작했다. 동시에 Flutter 쪽에 &lt;code&gt;meal_notification_worker.dart&lt;/code&gt;(33줄)를 새로 만들었다. 네이티브에서 다시 Flutter로 로직을 옮기기 시작한 것이다.&lt;/p&gt;
&lt;h2 id="kotlin-포기-2024년-9월"&gt;&lt;a href="#kotlin-%ed%8f%ac%ea%b8%b0-2024%eb%85%84-9%ec%9b%94" class="header-anchor"&gt;&lt;/a&gt;Kotlin 포기 (2024년 9월)
&lt;/h2&gt;&lt;p&gt;2024년 9월 25일의 커밋이 결정적이었다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MealNotificationReceiver.kt -65줄 (삭제)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MealWorker.kt -63줄 (삭제)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;notification_manager.dart +215줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;meal_notification_worker.dart 수정
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Kotlin 네이티브 파일 두 개를 완전히 삭제했다. 2개월 전에 &amp;ldquo;이게 정답이다&amp;quot;라고 확신하며 작성한 코드를 통째로 버렸다. 동시에 &lt;code&gt;notification_manager.dart&lt;/code&gt;에 215줄을 추가하며, 알림 로직을 전부 Flutter로 되돌렸다.&lt;/p&gt;
&lt;p&gt;Java 프로토타입 159커밋을 버릴 때도 그랬지만, 코드를 버리는 건 매번 아프다. 특히 이번에는 &amp;ldquo;Flutter로는 안 되니까 네이티브로 가야 한다&amp;quot;는 나름의 기술적 판단을 했던 것이라 더 그랬다. 그 판단이 틀렸다는 걸 인정하는 것이기도 했으니까.&lt;/p&gt;
&lt;p&gt;하지만 돌아보면 틀린 게 아니라 &lt;strong&gt;맞는 방향을 찾아가는 과정&lt;/strong&gt;이었다. 네이티브로 내려가봤기 때문에 네이티브의 한계와 복잡성을 직접 체감했고, &amp;ldquo;Flutter 안에서 해결하되, 더 똑똑하게 하자&amp;quot;라는 결론에 도달할 수 있었다.&lt;/p&gt;
&lt;h2 id="구조-전환-dailymealnotification-2024년-12월"&gt;&lt;a href="#%ea%b5%ac%ec%a1%b0-%ec%a0%84%ed%99%98-dailymealnotification-2024%eb%85%84-12%ec%9b%94" class="header-anchor"&gt;&lt;/a&gt;구조 전환: DailyMealNotification (2024년 12월)
&lt;/h2&gt;&lt;p&gt;2024년 12월 2일, 대규모 구조 전환을 했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;notification_manager.dart -253줄 (삭제)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;meal_notification_worker.dart -33줄 (삭제)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;daily_meal_notification.dart +259줄 (신규)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;daily_alarm_notification.dart +82줄 (신규)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MainActivity.kt -155줄
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;AppDelegate.swift -92줄
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;기존의 &lt;code&gt;notification_manager.dart&lt;/code&gt;(253줄)를 삭제하고, &lt;code&gt;daily_meal_notification.dart&lt;/code&gt;(259줄)를 새로 만들었다. 동시에 &lt;code&gt;MainActivity.kt&lt;/code&gt;에서 155줄, &lt;code&gt;AppDelegate.swift&lt;/code&gt;에서 92줄을 제거했다. 네이티브 쪽의 알림 관련 코드를 전부 걷어낸 것이다.&lt;/p&gt;
&lt;h3 id="matchdatetimecomponents의-발견"&gt;&lt;a href="#matchdatetimecomponents%ec%9d%98-%eb%b0%9c%ea%b2%ac" class="header-anchor"&gt;&lt;/a&gt;&lt;code&gt;matchDateTimeComponents&lt;/code&gt;의 발견
&lt;/h3&gt;&lt;p&gt;이 구조 전환에서 가장 결정적이었던 건 &lt;code&gt;flutter_local_notifications&lt;/code&gt;의 이 옵션이다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_localNotificationsPlugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;matchDateTimeComponents:&lt;/span&gt; &lt;span class="n"&gt;DateTimeComponents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dayOfWeekAndTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;dayOfWeekAndTime&lt;/code&gt;으로 설정하면, &lt;strong&gt;매주 같은 요일 같은 시간에 반복&lt;/strong&gt;되는 알림을 시스템이 직접 관리한다. 앱이 살아 있든 죽어 있든, 시스템 알람 스케줄러가 처리하기 때문에 안정적이다. &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; 모드와 결합하면 Doze 모드에서도 동작한다.&lt;/p&gt;
&lt;p&gt;1년 전에 이 옵션을 알았으면 Kotlin 네이티브로 내려갈 필요가 없었을지도 모른다. 하지만 이 옵션이 &amp;ldquo;정답&amp;quot;이라는 걸 확신하려면, 다른 방법들이 왜 안 되는지를 직접 경험해봐야 했다.&lt;/p&gt;
&lt;h3 id="그래도-남은-문제"&gt;&lt;a href="#%ea%b7%b8%eb%9e%98%eb%8f%84-%eb%82%a8%ec%9d%80-%eb%ac%b8%ec%a0%9c" class="header-anchor"&gt;&lt;/a&gt;그래도 남은 문제
&lt;/h3&gt;&lt;p&gt;구조는 잡혔지만, 이 버전에는 치명적인 문제가 있었다. &lt;strong&gt;앱을 일정 기간 열지 않으면 알림이 오지 않았다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;원인은 알림 스케줄링 시점에 있었다. 당시 코드는 스케줄링할 때 &lt;strong&gt;미래의 특정 날짜&lt;/strong&gt; 급식을 미리 가져와서 알림 내용에 박아넣는 구조였다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 2024-12 버전 — 스케줄링 시점에 특정 날짜의 급식을 가져옴
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;bigText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;MealDataApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getMeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;date:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scheduledDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduledDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduledDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;mealType:&lt;/span&gt; &lt;span class="n"&gt;mealType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;MealDataApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MENU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;급식 정보가 없습니다&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime&lt;/code&gt;은 매주 반복 알림을 시스템에 등록한다. 하지만 &lt;strong&gt;알림의 내용은 등록 시점에 고정&lt;/strong&gt;된다. 월요일에 스케줄링하면 그 주 월요일 급식 메뉴가 알림에 박히고, 다음 주 월요일에도 똑같은 내용이 표시된다.&lt;/p&gt;
&lt;p&gt;그래서 앱을 열 때마다 &lt;code&gt;scheduleDailyNotifications()&lt;/code&gt;를 호출해서 알림을 새로 등록하는 방식으로 우회했는데, 문제는 &lt;strong&gt;앱을 오래 안 열면 갱신이 안 된다&lt;/strong&gt;는 것이었다. 2주 동안 앱을 안 열면 2주 전 급식 메뉴가 계속 뜨거나, NEIS API에 해당 날짜 데이터가 없어서 &amp;ldquo;급식 정보가 없습니다&amp;quot;만 반복되었다.&lt;/p&gt;
&lt;p&gt;알림이 오긴 오는데 내용이 엉뚱하거나, 아예 의미 없는 메시지가 뜨니까 사용자 입장에서는 &amp;ldquo;알림이 안 온다&amp;quot;와 다름없었다.&lt;/p&gt;
&lt;h2 id="현재-버전으로의-진화-2026년-34월"&gt;&lt;a href="#%ed%98%84%ec%9e%ac-%eb%b2%84%ec%a0%84%ec%9c%bc%eb%a1%9c%ec%9d%98-%ec%a7%84%ed%99%94-2026%eb%85%84-34%ec%9b%94" class="header-anchor"&gt;&lt;/a&gt;현재 버전으로의 진화 (2026년 3~4월)
&lt;/h2&gt;&lt;p&gt;이 문제를 해결하면서 동시에 여러 개선을 적용한 게 현재 버전이다.&lt;/p&gt;
&lt;h3 id="알림-내용-갱신-방식-변경"&gt;&lt;a href="#%ec%95%8c%eb%a6%bc-%eb%82%b4%ec%9a%a9-%ea%b0%b1%ec%8b%a0-%eb%b0%a9%ec%8b%9d-%eb%b3%80%ea%b2%bd" class="header-anchor"&gt;&lt;/a&gt;알림 내용 갱신 방식 변경
&lt;/h3&gt;&lt;p&gt;가장 핵심적인 변경은 급식 데이터를 가져오는 방식이다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 현재 버전 — 항상 오늘 날짜 기준으로 가져옴
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;meal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;MealDataApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getMeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;date:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;mealType:&lt;/span&gt; &lt;span class="n"&gt;mealType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;MealDataApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MENU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;menuPreview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_cleanMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;scheduledDate&lt;/code&gt; 대신 &lt;code&gt;DateTime.now()&lt;/code&gt;를 사용한다. 어차피 앱이 열릴 때마다 &lt;code&gt;scheduleDailyNotifications()&lt;/code&gt;가 호출되면서 알림을 전부 취소하고 다시 등록하기 때문에, &lt;strong&gt;가장 최신 급식 데이터로 갱신&lt;/strong&gt;된다. 그리고 데이터를 못 가져왔을 때의 폴백도 달라졌다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 2024-12 버전
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;급식 정보가 없습니다&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 현재 버전
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;오늘의 &lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;mealLabel&lt;/span&gt;&lt;span class="s1"&gt; 메뉴를 확인하세요&amp;#39;&lt;/span&gt; &lt;span class="c1"&gt;// (i18n 적용 후: l.noti_mealConfirm(mealLabel))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;데이터가 없을 때 &amp;ldquo;정보가 없다&amp;quot;고 보여주는 대신, 앱을 열어보도록 유도하는 문구로 바꿨다. 월이 바뀔 때 데이터가 없거나, 예약 시점에 인터넷이 연결되지 않았을 수 있기 때문이다.&lt;/p&gt;
&lt;h3 id="알러지-정보-정리"&gt;&lt;a href="#%ec%95%8c%eb%9f%ac%ec%a7%80-%ec%a0%95%eb%b3%b4-%ec%a0%95%eb%a6%ac" class="header-anchor"&gt;&lt;/a&gt;알러지 정보 정리
&lt;/h3&gt;&lt;p&gt;NEIS API에서 내려오는 급식 메뉴에는 알러지 번호가 붙어 있다. &lt;code&gt;비빔밥(5.6.13)&lt;/code&gt; 이런 식으로. 알림에 이게 그대로 들어가면 지저분하다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;_cleanMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;menu&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;r&amp;#39;\([0-9.,\s]+\)&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39; · &amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;_cleanMenu()&lt;/code&gt;가 알러지 괄호를 제거하고, 줄바꿈을 &lt;code&gt;·&lt;/code&gt;로 연결해서 한 줄 미리보기를 만든다. &amp;ldquo;비빔밥 · 미역국 · 배추김치 · 요구르트&amp;rdquo; 이런 깔끔한 형태로.&lt;/p&gt;
&lt;h3 id="알림-탭--급식-화면-이동"&gt;&lt;a href="#%ec%95%8c%eb%a6%bc-%ed%83%ad--%ea%b8%89%ec%8b%9d-%ed%99%94%eb%a9%b4-%ec%9d%b4%eb%8f%99" class="header-anchor"&gt;&lt;/a&gt;알림 탭 → 급식 화면 이동
&lt;/h3&gt;&lt;p&gt;2024-12 버전에서는 알림을 탭해도 아무 일도 안 일어났다. 그냥 앱이 열리거나, 아예 반응이 없었다.&lt;/p&gt;
&lt;p&gt;현재 버전은 &lt;code&gt;payload&lt;/code&gt;를 활용한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_localNotificationsPlugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;payload:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;meal_screen&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;알림을 탭하면 &lt;code&gt;notificationStream&lt;/code&gt;에 &lt;code&gt;'meal_screen'&lt;/code&gt;이 전달되고, 앱이 이를 받아서 급식 화면으로 바로 이동한다. 앱이 꺼져 있었더라도 cold start 후 급식 화면까지 자동으로 네비게이션된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;_onNotificationTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NotificationResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Notification tapped: &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;notificationStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="권한-요청-분리"&gt;&lt;a href="#%ea%b6%8c%ed%95%9c-%ec%9a%94%ec%b2%ad-%eb%b6%84%eb%a6%ac" class="header-anchor"&gt;&lt;/a&gt;권한 요청 분리
&lt;/h3&gt;&lt;p&gt;2024-12 버전에서는 알림 초기화 과정에서 바로 권한을 요청했다. 앱을 처음 열자마자 &amp;ldquo;알림을 허용하시겠습니까?&amp;rdquo; 팝업이 뜨는 방식이었는데, 맥락 없이 갑자기 뜨는 권한 요청은 거부율이 높다.&lt;/p&gt;
&lt;p&gt;현재 버전에서는 &lt;code&gt;_requestPermissions()&lt;/code&gt;를 &lt;code&gt;DailyMealNotification&lt;/code&gt; 클래스에서 제거하고, &lt;strong&gt;설정 화면에서 알림을 켤 때 바텀시트로 권한을 요청&lt;/strong&gt;하는 방식으로 바꿨다. 사용자가 &amp;ldquo;급식 알림을 받겠다&amp;quot;는 의도를 먼저 표현한 상태에서 권한을 요청하니까 허용율이 훨씬 높다.&lt;/p&gt;
&lt;h3 id="다국어-지원"&gt;&lt;a href="#%eb%8b%a4%ea%b5%ad%ec%96%b4-%ec%a7%80%ec%9b%90" class="header-anchor"&gt;&lt;/a&gt;다국어 지원
&lt;/h3&gt;&lt;p&gt;하드코딩되어 있던 한국어 문자열을 전부 &lt;code&gt;AppLocalizations&lt;/code&gt;로 교체했다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 2024-12 버전
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;🍽️ &lt;/span&gt;&lt;span class="si"&gt;$&lt;/span&gt;&lt;span class="n"&gt;mealLabel&lt;/span&gt;&lt;span class="s1"&gt; 알림&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;급식 정보 알림을 제공합니다.&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;한솔고등학교&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 현재 버전
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noti_mealBreakfast&lt;/span&gt; &lt;span class="c1"&gt;// 알림 제목
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noti_mealChannelName&lt;/span&gt; &lt;span class="c1"&gt;// 채널 이름
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noti_mealChannelDesc&lt;/span&gt; &lt;span class="c1"&gt;// 채널 설명
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noti_schoolName&lt;/span&gt; &lt;span class="c1"&gt;// 학교 이름
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;영어를 사용하는 학생도 알림을 이해할 수 있게 되었다.&lt;/p&gt;
&lt;h3 id="조식중식석식-개별-설정"&gt;&lt;a href="#%ec%a1%b0%ec%8b%9d%ec%a4%91%ec%8b%9d%ec%84%9d%ec%8b%9d-%ea%b0%9c%eb%b3%84-%ec%84%a4%ec%a0%95" class="header-anchor"&gt;&lt;/a&gt;조식·중식·석식 개별 설정
&lt;/h3&gt;&lt;p&gt;사용자가 원하는 끼니만 골라서 알림을 받을 수 있다. 기숙사생은 조식·중식·석식 전부, 통학생은 중식만. 시간도 각각 다르게 설정 가능하다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isBreakfastNotificationOn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_scheduleWeeklyNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;mealLabel:&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meal_breakfast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;notiTitle:&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noti_mealBreakfast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;time:&lt;/span&gt; &lt;span class="n"&gt;_parseTimeOfDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;breakfastTime&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;weekdays:&lt;/span&gt; &lt;span class="n"&gt;weekdays&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;월~금 각 요일별로 별도의 알림 ID를 부여해서 (조식×5 + 중식×5 + 석식×5 = 최대 15개) 개별적으로 관리한다. 주말에는 급식이 없으니까 토·일은 제외.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;📎 알림 기능의 전체 구조는 &lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#notification/daily_meal_notification.md" target="_blank" rel="noopener"
 &gt;DailyMealNotification 문서&lt;/a&gt;에서 확인할 수 있다.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h3 id="2024-12-버전-vs-현재-버전"&gt;&lt;a href="#2024-12-%eb%b2%84%ec%a0%84-vs-%ed%98%84%ec%9e%ac-%eb%b2%84%ec%a0%84" class="header-anchor"&gt;&lt;/a&gt;2024-12 버전 vs 현재 버전
&lt;/h3&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;항목&lt;/th&gt;
 &lt;th&gt;2024-12 버전&lt;/th&gt;
 &lt;th&gt;현재 버전&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;급식 데이터&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;scheduledDate&lt;/code&gt; 기준 (고정)&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;DateTime.now()&lt;/code&gt; 기준 (갱신)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;데이터 없을 때&lt;/td&gt;
 &lt;td&gt;&amp;ldquo;급식 정보가 없습니다&amp;rdquo;&lt;/td&gt;
 &lt;td&gt;&amp;ldquo;메뉴를 확인하세요&amp;rdquo; (유도)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;알러지 정보&lt;/td&gt;
 &lt;td&gt;그대로 노출&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;_cleanMenu()&lt;/code&gt;로 제거&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;알림 탭&lt;/td&gt;
 &lt;td&gt;반응 없음&lt;/td&gt;
 &lt;td&gt;급식 화면으로 딥링크&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;권한 요청&lt;/td&gt;
 &lt;td&gt;앱 시작 시 즉시&lt;/td&gt;
 &lt;td&gt;설정 바텀시트에서 맥락적으로&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;언어&lt;/td&gt;
 &lt;td&gt;한국어 하드코딩&lt;/td&gt;
 &lt;td&gt;i18n (한국어 + 영어)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;디버깅&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;print()&lt;/code&gt; 남발&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;log()&lt;/code&gt; 정리&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;테스트&lt;/td&gt;
 &lt;td&gt;없음&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;sendTestNotification()&lt;/code&gt; 제공&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;코드 줄 수는 259줄에서 239줄로 오히려 줄었다. 기능은 훨씬 많아졌는데 코드가 줄어든 건, 권한 요청 로직을 분리하고 &lt;code&gt;print()&lt;/code&gt; 디버깅 코드를 정리한 덕분이다.&lt;/p&gt;
&lt;h2 id="1년-반의-기록"&gt;&lt;a href="#1%eb%85%84-%eb%b0%98%ec%9d%98-%ea%b8%b0%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;1년 반의 기록
&lt;/h2&gt;&lt;p&gt;급식 알림 하나를 만드는 데 거친 경로를 정리하면:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;시기&lt;/th&gt;
 &lt;th&gt;접근 방식&lt;/th&gt;
 &lt;th&gt;결과&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;2023-12&lt;/td&gt;
 &lt;td&gt;NotificationManager + FCM&lt;/td&gt;
 &lt;td&gt;FCM은 서버 필요 → 당일 삭제&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2023-12&lt;/td&gt;
 &lt;td&gt;ScheduledNotification (로컬)&lt;/td&gt;
 &lt;td&gt;기본 동작은 하지만 불안정&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2024-07&lt;/td&gt;
 &lt;td&gt;notification_manager 리팩토링&lt;/td&gt;
 &lt;td&gt;앱 종료 시 알림 누락&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2024-08&lt;/td&gt;
 &lt;td&gt;Kotlin 네이티브 (WorkManager)&lt;/td&gt;
 &lt;td&gt;플랫폼별 코드 중복, 통신 복잡&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2024-09&lt;/td&gt;
 &lt;td&gt;Kotlin 삭제, Flutter 복귀&lt;/td&gt;
 &lt;td&gt;구조 정리 시작&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2024-12&lt;/td&gt;
 &lt;td&gt;DailyMealNotification 구조 전환&lt;/td&gt;
 &lt;td&gt;동작하지만 장기 미접속 시 내용 갱신 불가&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-03~04&lt;/td&gt;
 &lt;td&gt;현재 버전&lt;/td&gt;
 &lt;td&gt;딥링크, i18n, 메뉴 프리뷰, 권한 UX 개선&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;파일 생성과 삭제 횟수를 세면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FirebaseCloudMessaging.dart&lt;/code&gt; → 생성 → 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ScheduledNotification.dart&lt;/code&gt; → 생성 → 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MealNotificationReceiver.kt&lt;/code&gt; → 생성 → 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MealWorker.kt&lt;/code&gt; → 생성 → 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;meal_notification_worker.dart&lt;/code&gt; → 생성 → 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;notification_manager.dart&lt;/code&gt; → 생성 → 수차례 대규모 수정 → 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;daily_meal_notification.dart&lt;/code&gt; → 최종 생존&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;6개의 파일이 만들어졌다 사라졌고, 1개만 살아남았다.&lt;/p&gt;
&lt;h2 id="배운-것"&gt;&lt;a href="#%eb%b0%b0%ec%9a%b4-%ea%b2%83" class="header-anchor"&gt;&lt;/a&gt;배운 것
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;&amp;ldquo;간단해 보이는 기능&amp;quot;은 없다.&lt;/strong&gt; 사용자에게 간단하게 보이는 기능일수록 뒤에서 처리해야 할 것이 많다. &amp;ldquo;매일 아침 알림 보내기&amp;quot;라는 한 문장 뒤에는 타임존, 배터리 최적화, 백그라운드 제약, 플랫폼별 차이, API 호출 타이밍 같은 문제들이 숨어 있었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;네이티브로 내려가는 건 최후의 수단이어야 한다.&lt;/strong&gt; 크로스플랫폼 프레임워크를 쓰면서 네이티브 코드를 작성하는 순간, 유지보수 비용이 플랫폼 수만큼 곱해진다. 특히 1인 개발에서는 치명적이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;동작한다&amp;quot;와 &amp;ldquo;제대로 동작한다&amp;quot;는 다르다.&lt;/strong&gt; 2024년 12월 버전은 동작했다. 알림이 왔다. 하지만 일정 기간 앱을 안 열면 내용이 갱신되지 않았고, 알림을 탭해도 아무 일도 안 일어났고, 권한 요청 타이밍이 나빴다. 기능이 &amp;ldquo;있는&amp;rdquo; 것과 &amp;ldquo;쓸 만한&amp;rdquo; 것 사이에는 이런 디테일들이 잔뜩 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;삽질은 낭비가 아니다.&lt;/strong&gt; FCM을 시도해봤기 때문에 서버 푸시와 로컬 알림의 차이를 이해했고, Kotlin 네이티브를 경험했기 때문에 Flutter의 한계와 가능성을 정확히 알게 되었다. 최종 코드 239줄에는 1년 반의 시행착오가 전부 녹아 있다.&lt;/p&gt;</description></item><item><title>#2 - Java에서 Flutter로</title><link>https://monkshark.github.io/p/java-to-flutter/</link><pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/java-to-flutter/</guid><description>&lt;h2 id="학교-사업-프로그램"&gt;&lt;a href="#%ed%95%99%ea%b5%90-%ec%82%ac%ec%97%85-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%a8" class="header-anchor"&gt;&lt;/a&gt;학교 사업 프로그램
&lt;/h2&gt;&lt;p&gt;학교에서 학생들의 창업이나 프로젝트를 지원하는 프로그램이 있었다. 아이디어를 제출하고, 팀을 꾸려서 발표하고, 선정되면 활동비와 멘토링을 지원받는 구조였다.&lt;/p&gt;
&lt;p&gt;팀원 두 명과 함께 &amp;ldquo;학교 통합 앱&amp;quot;이라는 주제로 지원했다. 급식, 시간표, 공지사항을 한곳에서 볼 수 있는 앱. 매번 학교 홈페이지에 들어가야 하는 불편함을 해결하자는 단순한 아이디어였고, 다행히 선정되었다.&lt;/p&gt;
&lt;h2 id="java--xml-프로토타입"&gt;&lt;a href="#java--xml-%ed%94%84%eb%a1%9c%ed%86%a0%ed%83%80%ec%9e%85" class="header-anchor"&gt;&lt;/a&gt;Java + XML 프로토타입
&lt;/h2&gt;&lt;p&gt;당시 나는 Java를 조금 공부한 상태였다. 모바일 개발 경험은 전혀 없었지만, Java를 알고 있으니 Android 네이티브가 가장 접근하기 쉬워 보였다. 그래서 첫 프로토타입은 Java + XML Layout으로 만들었다.&lt;/p&gt;
&lt;h3 id="159커밋의-기록"&gt;&lt;a href="#159%ec%bb%a4%eb%b0%8b%ec%9d%98-%ea%b8%b0%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;159커밋의 기록
&lt;/h3&gt;&lt;p&gt;이 프로토타입은 단순한 데모가 아니었다. &lt;a class="link" href="https://github.com/Monkshark/hansol_hs_java_app" target="_blank" rel="noopener"
 &gt;GitHub에 159개의 커밋&lt;/a&gt;이 남아 있을 정도로 꽤 오랜 기간 개발했다. v0.12.3 Beta까지 버전이 올라갔다.&lt;/p&gt;
&lt;p&gt;구현했던 기능들:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;급식 정보&lt;/strong&gt; — NEIS 공공데이터 API를 파싱해서 오늘/이번 주 급식을 보여줌. 영양정보까지 표시&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시간표 조회&lt;/strong&gt; — NEIS API로 시간표 데이터를 가져와서 요일별로 표시&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;공지사항&lt;/strong&gt; — 학교 공지를 앱에서 확인할 수 있는 Fragment 구현&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;알림&lt;/strong&gt; — 매일 급식 알림을 보내주는 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;개발하면서 겪었던 문제들도 많았다. &lt;code&gt;FutureTask&lt;/code&gt;에서 &lt;code&gt;CompletableFuture&lt;/code&gt;로 비동기 처리 방식을 전환하기도 했고, UI 디자인을 대규모로 리팩토링한 적도 있었다 (v0.11.5 → v0.12.3 구간). 투박한 첫 UI에서 점점 나아지는 과정이 커밋 히스토리에 그대로 남아 있다.&lt;/p&gt;
&lt;p&gt;하지만 이 프로토타입의 가장 큰 가치는 &lt;strong&gt;실제로 NEIS API를 호출해서 데이터를 가져오고 화면에 보여줄 수 있다&lt;/strong&gt;는 걸 증명한 것이었다. 학교 앱이라는 아이디어가 기술적으로 가능하다는 확신을 얻었다.&lt;/p&gt;
&lt;h2 id="발표와-부스"&gt;&lt;a href="#%eb%b0%9c%ed%91%9c%ec%99%80-%eb%b6%80%ec%8a%a4" class="header-anchor"&gt;&lt;/a&gt;발표와 부스
&lt;/h2&gt;&lt;p&gt;프로토타입이 어느 정도 완성된 후, 참여 팀들과 선생님 앞에서 발표하는 자리가 있었다. 발표는 팀원이 맡았다. 앱의 컨셉, 해결하려는 문제, 현재 구현된 기능을 설명하고 실제 동작하는 프로토타입을 시연했다.&lt;/p&gt;
&lt;p&gt;그리고 학교 메인 홀에서 부스를 열었다.&lt;/p&gt;
&lt;p&gt;부스를 운영하면서 직접 학생들을 만났다. 관심을 가지는 학생도 있었고, &amp;ldquo;이런 게 왜 필요해?&amp;rdquo; 하는 반응도 있었다. 하지만 가장 중요한 건, 부스에서 &lt;strong&gt;두 가지 결정적인 사실&lt;/strong&gt;을 알게 되었다는 것이다.&lt;/p&gt;
&lt;h3 id="1-android와-ios-거의-반반"&gt;&lt;a href="#1-android%ec%99%80-ios-%ea%b1%b0%ec%9d%98-%eb%b0%98%eb%b0%98" class="header-anchor"&gt;&lt;/a&gt;1. Android와 iOS, 거의 반반
&lt;/h3&gt;&lt;p&gt;부스에 태블릿을 놓고 직접 만든 사전등록 앱을 실행해뒀다. 학번, 이름, 전화번호, 사용 중인 OS를 입력하는 간단한 폼이었는데, OS 필드를 집계해보니 Android와 iOS가 거의 반반이었다.&lt;/p&gt;
&lt;p&gt;이건 심각한 문제였다. 지금까지 Java + XML로 Android 앱만 만들고 있었는데, 그러면 &lt;strong&gt;학교 학생 절반이 이 앱을 쓸 수 없다&lt;/strong&gt;는 뜻이다.&lt;/p&gt;
&lt;p&gt;그렇다고 Swift로 iOS 버전을 따로 만들 수 있는 상황이 아니었다. 나는 Java를 조금 아는 1학년이었고, 새로운 언어와 완전히 다른 플랫폼을 동시에 학습하면서 두 벌의 앱을 유지보수한다? 비현실적이었다.&lt;/p&gt;
&lt;p&gt;이때 처음으로 &lt;strong&gt;크로스플랫폼 프레임워크&lt;/strong&gt;를 진지하게 고민하기 시작했다. React Native와 Flutter가 후보였는데, 당시 Flutter의 성장세가 가파르기도 했고, Dart 언어가 Java에서 넘어오기에 비교적 친숙해 보였다.&lt;/p&gt;
&lt;h3 id="2-2-3학년은-시간표가-다른데요"&gt;&lt;a href="#2-2-3%ed%95%99%eb%85%84%ec%9d%80-%ec%8b%9c%ea%b0%84%ed%91%9c%ea%b0%80-%eb%8b%a4%eb%a5%b8%eb%8d%b0%ec%9a%94" class="header-anchor"&gt;&lt;/a&gt;2. &amp;ldquo;2, 3학년은 시간표가 다른데요?&amp;rdquo;
&lt;/h3&gt;&lt;p&gt;부스에서 받은 질문 중 하나가 프로젝트의 방향을 바꿨다.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&amp;ldquo;1학년은 반만 입력하면 되는데, 2학년부터는 선택과목 때문에 시간표가 다 달라요.&amp;rdquo;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;1학년인 나는 이걸 몰랐다. 1학년은 학년과 반을 입력하면 시간표가 그대로 확정된다. 같은 반 학생은 모두 같은 시간표를 따른다.&lt;/p&gt;
&lt;p&gt;하지만 2·3학년은 완전히 다른 세계였다. 선택과목 제도 때문에 &lt;strong&gt;같은 반이라도 학생마다 시간표가 다르다.&lt;/strong&gt; A는 3교시에 물리학을, B는 같은 시간에 생명과학을 듣는다. 그리고 그 수업은 각각 다른 반 교실에서 진행된다.&lt;/p&gt;
&lt;p&gt;NEIS API에서 제공하는 시간표 데이터는 반(CLASS_NM)별로 내려온다. 1학년이라면 자기 반 데이터만 가져오면 끝이지만, 2·3학년의 선택과목 시간에는 여러 반의 수업이 뒤섞여 있었다. 단순히 API를 호출해서 보여주는 것으로는 해결이 안 되는, &lt;strong&gt;로직이 필요한 문제&lt;/strong&gt;였다.&lt;/p&gt;
&lt;p&gt;이걸 들었을 때 솔직히 막막했다. 하지만 동시에 &amp;ldquo;이걸 해결하면 진짜 쓸 수 있는 앱이 되겠다&amp;quot;는 생각이 들었다.&lt;/p&gt;
&lt;h2 id="기숙사에서-2주"&gt;&lt;a href="#%ea%b8%b0%ec%88%99%ec%82%ac%ec%97%90%ec%84%9c-2%ec%a3%bc" class="header-anchor"&gt;&lt;/a&gt;기숙사에서 2주
&lt;/h2&gt;&lt;p&gt;당시 기숙사에 살고 있었다. 평일 저녁 자습 시간과 주말을 온전히 개발에 쏟을 수 있는 환경이었다. 부스에서 받은 시간표 피드백을 해결하겠다는 목표를 잡고, 2주를 잡았다. 1주는 기획, 1주는 구현.&lt;/p&gt;
&lt;p&gt;개발을 시작한 지 얼마 안 된 상태에서 이 로직을 만들어야 했기 때문에, 쉽지 않을 거라는 건 알고 있었다.&lt;/p&gt;
&lt;h3 id="1주차-neis-api-분석과-로직-기획"&gt;&lt;a href="#1%ec%a3%bc%ec%b0%a8-neis-api-%eb%b6%84%ec%84%9d%ea%b3%bc-%eb%a1%9c%ec%a7%81-%ea%b8%b0%ed%9a%8d" class="header-anchor"&gt;&lt;/a&gt;1주차: NEIS API 분석과 로직 기획
&lt;/h3&gt;&lt;p&gt;먼저 NEIS API의 시간표 데이터 구조를 철저히 분석했다. API를 호출하면 이런 형태의 데이터가 온다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;ALL_TI_YMD&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;20260414&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;GRADE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;CLASS_NM&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;PERIO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;4&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;ITRT_CNTNT&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;물리학Ⅰ&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;날짜, 학년, 반, 교시, 과목명. 이걸 조합해서 개인 시간표를 만들어야 했다.&lt;/p&gt;
&lt;p&gt;핵심 문제를 정리하면:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;학년&lt;/th&gt;
 &lt;th&gt;시간표 결정 방식&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;1학년&lt;/td&gt;
 &lt;td&gt;학년 + 반 입력 → 시간표 확정 (단순 조회)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2·3학년&lt;/td&gt;
 &lt;td&gt;학년 전체 시간표에서 반별 과목 추출 → 사용자가 선택과목 선택 → 해당 과목의 반·교시 매칭 → 커스텀 시간표 생성&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;2·3학년의 흐름을 더 구체적으로 설계했다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#api/timetable_data_api.md" target="_blank" rel="noopener"
 &gt;&lt;code&gt;getTimeTable()&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — NEIS API에서 해당 학년의 전체 시간표를 가져온다. 반 필터 없이 전체를 요청해서, 모든 반의 모든 과목 데이터를 확보한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;getAllSubjectCombinations()&lt;/code&gt;&lt;/strong&gt; — 가져온 전체 시간표에서 반별 과목 조합을 추출한다. &amp;ldquo;3반 4교시 = 물리학Ⅰ&amp;rdquo;, &amp;ldquo;5반 4교시 = 생명과학Ⅰ&amp;rdquo; 같은 매핑을 만든다. 이걸로 사용자에게 &amp;ldquo;어떤 과목을 듣는지&amp;rdquo; 선택지를 보여줄 수 있다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;사용자가 자기 선택과목을 고른다&lt;/strong&gt; — UI에서 과목 목록을 보여주고, 자기가 듣는 과목을 체크한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;getCustomTimeTable()&lt;/code&gt;&lt;/strong&gt; — 선택한 과목이 어느 반의 몇 교시에 있는지 역추적해서, 그 학생만의 개인 시간표를 생성한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

 &lt;blockquote&gt;
 &lt;p&gt;📎 시간표 API의 전체 구조는 &lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#api/timetable_data_api.md" target="_blank" rel="noopener"
 &gt;TimetableDataApi 문서&lt;/a&gt;에서 확인할 수 있다.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;일주일 동안 노트에 데이터 흐름을 그리고 또 그렸다. API 응답 구조부터 최종 시간표까지의 변환 과정을 머릿속에서 완전히 정리한 후에야 코드를 쓸 수 있겠다는 확신이 들었다.&lt;/p&gt;
&lt;h3 id="2주차-구현"&gt;&lt;a href="#2%ec%a3%bc%ec%b0%a8-%ea%b5%ac%ed%98%84" class="header-anchor"&gt;&lt;/a&gt;2주차: 구현
&lt;/h3&gt;&lt;p&gt;기획대로 코드를 작성했다. 처음이라 삽질도 많았지만, 기획을 확실히 해둔 덕분에 방향을 잃지는 않았다.&lt;/p&gt;
&lt;p&gt;까다로웠던 부분들:&lt;/p&gt;
&lt;h4 id="반별-과목-파싱"&gt;&lt;a href="#%eb%b0%98%eb%b3%84-%ea%b3%bc%eb%aa%a9-%ed%8c%8c%ec%8b%b1" class="header-anchor"&gt;&lt;/a&gt;반별 과목 파싱
&lt;/h4&gt;&lt;p&gt;NEIS API 응답에서 날짜별 → 반별 → 교시별 과목을 &lt;strong&gt;3중 중첩 Map&lt;/strong&gt;으로 정리해야 했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 날짜 → 반번호 → [1교시, 2교시, 3교시, ...]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;API에서 넘어오는 데이터는 flat한 배열이라, 이걸 날짜와 반으로 그룹핑하고, 교시 순서대로 정렬해서 넣어야 했다. 교시(PERIO) 사이에 빈 시간이 있을 수도 있어서, 리스트를 교시 수만큼 미리 할당하고 해당 인덱스에 과목명을 넣는 방식으로 처리했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;perio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;perio&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;선택과목 시간표를 처음 구상했을 때의 핵심 아이디어가 바로 이것이었다. &lt;strong&gt;빈 2차원 배열(요일 × 교시)을 먼저 만들어놓고, 사용자가 선택한 과목을 해당 위치에 채워 넣는 방식.&lt;/strong&gt; 빈 시간표라는 틀을 먼저 준비하고, 거기에 자기 과목을 하나씩 배치한다는 발상이 전체 로직의 출발점이었다.&lt;/p&gt;
&lt;h4 id="선택과목-매칭"&gt;&lt;a href="#%ec%84%a0%ed%83%9d%ea%b3%bc%eb%aa%a9-%eb%a7%a4%ec%b9%ad" class="header-anchor"&gt;&lt;/a&gt;선택과목 매칭
&lt;/h4&gt;&lt;p&gt;사용자가 고른 과목명이 어느 반의 몇 교시에 해당하는지 역추적하는 게 핵심이었다. 같은 과목이 여러 반에 걸쳐 있을 수 있다. 예를 들어 &amp;ldquo;물리학Ⅰ&amp;quot;이 3반에서도, 7반에서도 진행될 수 있다.&lt;/p&gt;
&lt;p&gt;이 문제를 해결하기 위해 &lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#data/subject.md" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Subject&lt;/code&gt; 모델&lt;/a&gt;에 &lt;code&gt;subjectClass&lt;/code&gt;(반 번호)를 포함시켰다. 단순히 과목명만으로는 부족하고, &lt;strong&gt;어느 반의 물리학인지&lt;/strong&gt;까지 알아야 정확한 시간표를 만들 수 있었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;subjectName:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;물리학Ⅰ&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;subjectClass:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 3반에서 진행되는 물리학
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;커스텀 시간표를 만들 때는, 사용자가 선택한 과목 목록을 반별로 그룹핑한 뒤, 해당 반의 시간표에서 매칭되는 교시를 찾아서 개인 시간표에 채워 넣었다.&lt;/p&gt;
&lt;h4 id="폴백-전략"&gt;&lt;a href="#%ed%8f%b4%eb%b0%b1-%ec%a0%84%eb%9e%b5" class="header-anchor"&gt;&lt;/a&gt;폴백 전략
&lt;/h4&gt;&lt;p&gt;시간표가 항상 있는 건 아니다. 방학 중에는 시간표 데이터가 없고, 시험 기간에는 특별 시간표가 올라오기도 한다. 이번 주 데이터가 없으면 앱이 빈 화면을 보여주는 건 좋지 않았다.&lt;/p&gt;
&lt;p&gt;그래서 3단계 폴백을 구현했다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;이번 주&lt;/strong&gt; 시간표를 가져온다&lt;/li&gt;
&lt;li&gt;없으면 &lt;strong&gt;다음 주&lt;/strong&gt; 시간표를 가져온다&lt;/li&gt;
&lt;li&gt;그래도 없으면 &lt;strong&gt;지난 주&lt;/strong&gt; 시간표를 가져온다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이렇게 하면 방학 직전이나 직후에도 가장 가까운 시간표를 보여줄 수 있었다.&lt;/p&gt;
&lt;h4 id="캐싱"&gt;&lt;a href="#%ec%ba%90%ec%8b%b1" class="header-anchor"&gt;&lt;/a&gt;캐싱
&lt;/h4&gt;&lt;p&gt;NEIS API를 매번 호출하면 느리고, 불필요한 네트워크 요청이 늘어난다. SharedPreferences에 캐싱을 구현했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;12시간 이내&lt;/strong&gt;: 캐시 데이터를 바로 반환 (네트워크 요청 없음)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;12시간 ~ 3일&lt;/strong&gt;: Stale-While-Revalidate — 일단 캐시를 반환하되, 다음 요청 때 갱신&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3일 초과&lt;/strong&gt;: 캐시 삭제 후 새로 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SWR 패턴을 적용한 건 나중의 일이지만, 캐싱의 기본 개념은 이때 처음 구현했다. 네트워크 상태가 좋지 않은 학교 와이파이 환경에서 캐싱이 얼마나 중요한지 실감했다.&lt;/p&gt;
&lt;h2 id="159커밋을-버리는-결정"&gt;&lt;a href="#159%ec%bb%a4%eb%b0%8b%ec%9d%84-%eb%b2%84%eb%a6%ac%eb%8a%94-%ea%b2%b0%ec%a0%95" class="header-anchor"&gt;&lt;/a&gt;159커밋을 버리는 결정
&lt;/h2&gt;&lt;p&gt;시간표 로직을 완성한 뒤, 프로젝트 전체를 Flutter로 전환하기로 결정했다.&lt;/p&gt;
&lt;p&gt;159개의 커밋이 쌓인 Java 프로젝트를 버리는 건 쉬운 결정이 아니었다. 급식 파싱, 시간표 조회, UI 리팩토링까지 수개월의 작업이 담겨 있었다. 하지만 부스에서 확인한 현실이 명확했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;학교 학생의 절반이 iOS를 쓴다 → &lt;strong&gt;Android만으로는 안 된다&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Swift로 iOS를 따로 만들 여력이 없다 → &lt;strong&gt;크로스플랫폼이 필수&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Flutter는 Dart 하나로 양쪽을 커버한다 → &lt;strong&gt;유일한 현실적 선택지&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 Java 프로토타입에서 이미 해결한 문제들 — NEIS API 파싱, 시간표 로직, 캐싱 전략 — 은 코드는 버려도 &lt;strong&gt;설계와 경험은 그대로 가져갈 수 있었다.&lt;/strong&gt; 실제로 Flutter로 다시 작성할 때 Java에서 삽질했던 부분들은 훨씬 빠르게 구현할 수 있었다.&lt;/p&gt;
&lt;p&gt;Java 프로토타입은 &lt;a class="link" href="https://github.com/Monkshark/hansol_hs_java_app" target="_blank" rel="noopener"
 &gt;GitHub&lt;/a&gt;에 그대로 남겨두었다. v0.12.3 Beta, 159커밋. 여기까지가 이 앱의 선사시대다.&lt;/p&gt;
&lt;h2 id="flutter로의-전환"&gt;&lt;a href="#flutter%eb%a1%9c%ec%9d%98-%ec%a0%84%ed%99%98" class="header-anchor"&gt;&lt;/a&gt;Flutter로의 전환
&lt;/h2&gt;&lt;p&gt;Dart는 Java와 문법이 비슷해서 언어 자체의 진입 장벽은 낮았다. 하지만 Flutter의 위젯 트리 개념은 XML Layout과 완전히 달랐다. XML에서는 화면을 선언적으로 정의하지만 로직과 분리되어 있었고, Flutter에서는 UI 자체가 코드다.&lt;/p&gt;
&lt;p&gt;처음에는 어색했지만, 익숙해지니 오히려 편했다. 특히 &lt;strong&gt;핫 리로드&lt;/strong&gt; — 코드를 수정하면 앱을 재시작하지 않아도 바로 화면에 반영된다 — 가 생산성을 극적으로 올려줬다. Java + XML 시절에는 빌드하고 에뮬레이터에 올리고 기다리는 시간이 길었는데, 그 시간이 거의 0으로 줄었다.&lt;/p&gt;
&lt;p&gt;시간표 로직도 Dart로 다시 작성했다. Java에서의 경험이 있으니 설계는 이미 머릿속에 있었고, Dart의 컬렉션 API가 Java보다 간결해서 코드량도 줄었다. &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt;, &lt;code&gt;Set&lt;/code&gt;의 함수형 메서드들이 데이터 파싱에 특히 유용했다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;부스에서 받은 피드백 두 개가 프로젝트의 방향을 결정했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;ldquo;iOS는 안 돼요?&amp;rdquo; → &lt;strong&gt;Flutter 전환&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;2학년 시간표는요?&amp;rdquo; → &lt;strong&gt;선택과목 커스텀 시간표 로직&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;사용자를 직접 만나는 게 얼마나 중요한지 체감한 순간이었다. 혼자 방에서 코딩만 했으면 절대 알 수 없었을 것들이다. 기획서를 아무리 잘 써도, 실제 사용자 앞에서 프로토타입을 보여주는 것만큼 확실한 검증은 없다.&lt;/p&gt;
&lt;p&gt;그리고 159커밋을 버린 건 아깝지만, 프로토타입의 목적은 원래 &amp;ldquo;버리기 위해 만드는 것&amp;quot;이다. 프로토타입에서 얻은 기술적 경험과 사용자 피드백이 Flutter 버전의 기초가 되었으니, 하나도 낭비된 게 아니었다.&lt;/p&gt;
&lt;h2 id="다음-글에서는"&gt;&lt;a href="#%eb%8b%a4%ec%9d%8c-%ea%b8%80%ec%97%90%ec%84%9c%eb%8a%94" class="header-anchor"&gt;&lt;/a&gt;다음 글에서는
&lt;/h2&gt;&lt;p&gt;Flutter 전환 직후 가장 먼저 만들고 싶었던 기능, 급식 알림. 단순해 보였던 이 기능을 제대로 만드는 데 1년이 걸린 이야기를 다룬다.&lt;/p&gt;</description></item><item><title>#1 - 프로젝트를 시작한 이유</title><link>https://monkshark.github.io/p/hansol-app-intro/</link><pubDate>Sun, 05 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/hansol-app-intro/</guid><description>&lt;h2 id="왜-만들었나"&gt;&lt;a href="#%ec%99%9c-%eb%a7%8c%eb%93%a4%ec%97%88%eb%82%98" class="header-anchor"&gt;&lt;/a&gt;왜 만들었나
&lt;/h2&gt;&lt;p&gt;고등학교 재학 중에 직접 느낀 불편함이 시작점이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;급식 확인이 번거롭다.&lt;/strong&gt; 오늘 뭐 나오는지 알려면 매번 학교 홈페이지에 들어가서 주간 급식표를 찾아야 했다. 모바일에서 학교 홈페이지는 반응형도 아니라서, 핀치 줌으로 표를 확대해서 오늘 날짜를 찾는 과정을 매일 반복했다. 아침마다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;시간표 관리가 불편하다.&lt;/strong&gt; 종이 시간표를 사진 찍어 갤러리에 넣어두거나, 기억에 의존했다. 시간표가 바뀌면 다시 사진 찍고, 예전 사진은 삭제하고. 특히 2·3학년은 선택과목 때문에 반 친구와 시간표가 다른 경우가 많았는데, 이건 나중에 직접 부딪혀서야 알게 된 문제였다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;학교 커뮤니티가 없다.&lt;/strong&gt; 학생들끼리 소통할 수 있는 공간이 마땅치 않았다. 단체 카톡방은 있었지만, 익명 게시판이나 학년을 넘어선 소통 채널은 없었다.&lt;/p&gt;
&lt;p&gt;이 세 가지 문제를 하나의 앱으로 해결하고 싶었다. 급식, 시간표, 학사일정을 한눈에 보고, 게시판과 채팅으로 학교 구성원들이 소통할 수 있는 통합 플랫폼.&lt;/p&gt;
&lt;h2 id="기술-스택-선택"&gt;&lt;a href="#%ea%b8%b0%ec%88%a0-%ec%8a%a4%ed%83%9d-%ec%84%a0%ed%83%9d" class="header-anchor"&gt;&lt;/a&gt;기술 스택 선택
&lt;/h2&gt;&lt;p&gt;프로그래밍 기초는 있었지만 모바일 앱 개발은 처음이었다. 어떤 기술을 써야 하는지부터 조사해야 했다.&lt;/p&gt;
&lt;h3 id="flutter를-선택한-이유"&gt;&lt;a href="#flutter%eb%a5%bc-%ec%84%a0%ed%83%9d%ed%95%9c-%ec%9d%b4%ec%9c%a0" class="header-anchor"&gt;&lt;/a&gt;Flutter를 선택한 이유
&lt;/h3&gt;&lt;p&gt;처음에는 Java로 Android 네이티브 앱을 만들었다. Java를 조금 알고 있었으니까 진입이 가장 쉬웠다. 하지만 이후 학교에서 부스를 운영하면서 iOS 사용자가 절반이라는 걸 알게 되었고, 크로스플랫폼이 필수라는 결론에 도달했다. (&lt;a class="link" href="https://monkshark.github.io/p/java-to-flutter/" &gt;#2 - Java에서 Flutter로&lt;/a&gt;에서 자세히 다룬다.)&lt;/p&gt;
&lt;p&gt;Flutter를 선택한 이유:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;크로스 플랫폼&lt;/strong&gt; — Android + iOS를 하나의 코드베이스로. 1인 개발에서 두 플랫폼을 따로 만드는 건 비현실적이었다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dart 언어&lt;/strong&gt; — Java에서 넘어오기에 문법이 친숙했다. 타입 안전성이 있으면서도 컬렉션 API가 간결해서 데이터 파싱에 유리했다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;핫 리로드&lt;/strong&gt; — 코드를 수정하면 앱을 재시작하지 않아도 바로 화면에 반영된다. Java + XML 시절에는 빌드 후 에뮬레이터에 올리는 데 수십 초가 걸렸는데, 그 시간이 거의 0으로 줄었다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="firebase를-선택한-이유"&gt;&lt;a href="#firebase%eb%a5%bc-%ec%84%a0%ed%83%9d%ed%95%9c-%ec%9d%b4%ec%9c%a0" class="header-anchor"&gt;&lt;/a&gt;Firebase를 선택한 이유
&lt;/h3&gt;&lt;p&gt;백엔드를 직접 만들 수 있는 능력이 없었다. 서버 구축, DB 관리, API 설계 — 전부 처음이었다. Firebase는 이 모든 걸 건너뛸 수 있게 해줬다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Firestore&lt;/strong&gt; — NoSQL 문서 데이터베이스. 스키마가 유연해서 프로토타이핑이 빠르다. 실시간 스트림으로 게시글이나 채팅이 작성 즉시 다른 사용자에게 반영된다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firebase Auth&lt;/strong&gt; — Google, Apple, Kakao, GitHub 4종 OAuth 로그인을 빠르게 붙일 수 있었다 (&lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#data/auth_service.md" target="_blank" rel="noopener"
 &gt;인증 서비스 문서&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud Storage&lt;/strong&gt; — 게시글 이미지, 프로필 사진 저장&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FCM&lt;/strong&gt; — 댓글, 채팅, 공지 등 13종 푸시 알림 (&lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#notification/fcm_service.md" target="_blank" rel="noopener"
 &gt;FCM 서비스 문서&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;무료 한도&lt;/strong&gt; — 학교 앱 규모(1,000명 이내)에서는 Spark 플랜으로 월 $0~3 운영이 가능했다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="상태-관리-riverpod"&gt;&lt;a href="#%ec%83%81%ed%83%9c-%ea%b4%80%eb%a6%ac-riverpod" class="header-anchor"&gt;&lt;/a&gt;상태 관리: Riverpod
&lt;/h3&gt;&lt;p&gt;처음에는 Provider를 썼다. 간단한 CRUD에서는 문제가 없었지만, 기능이 복잡해지면서 한계가 드러났다. Provider는 BuildContext에 의존하기 때문에 위젯 트리 바깥에서 상태에 접근하기가 번거롭고, 테스트도 까다로웠다.&lt;/p&gt;
&lt;p&gt;Riverpod 2.5로 전환했다. 결정적인 이유 세 가지:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AsyncNotifier&lt;/code&gt;&lt;/strong&gt; — 비동기 작업의 로딩/성공/에러 상태를 분기하는 코드가 깔끔해졌다. &lt;code&gt;.when(loading: ..., data: ..., error: ...)&lt;/code&gt;으로 한 줄에 처리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;family&lt;/code&gt; + &lt;code&gt;autoDispose&lt;/code&gt;&lt;/strong&gt; — 파라미터별로 별도 상태를 만들되, 사용하지 않으면 자동으로 메모리에서 해제. 게시글 목록처럼 화면을 벗어나면 필요 없는 상태를 관리하기에 적합&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BuildContext 독립&lt;/strong&gt; — 위젯 트리 바깥(서비스 레이어, 백그라운드 로직)에서도 상태에 접근 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;BLoC도 고려했지만, 단순 CRUD에도 Event 클래스와 State 클래스를 별도로 만들어야 하는 보일러플레이트가 과도하다고 판단했다. 학교 앱의 대부분의 기능은 &amp;ldquo;Firestore에서 데이터를 가져와서 보여주는&amp;rdquo; 패턴이라, Riverpod의 간결한 API가 더 맞았다.&lt;/p&gt;
&lt;h2 id="처음-설계한-핵심-기능"&gt;&lt;a href="#%ec%b2%98%ec%9d%8c-%ec%84%a4%ea%b3%84%ed%95%9c-%ed%95%b5%ec%8b%ac-%ea%b8%b0%eb%8a%a5" class="header-anchor"&gt;&lt;/a&gt;처음 설계한 핵심 기능
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;NEIS API 연동&lt;/strong&gt; — 교육부 공공데이터 API로 &lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#api/meal_data_api.md" target="_blank" rel="noopener"
 &gt;급식&lt;/a&gt;, &lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#api/timetable_data_api.md" target="_blank" rel="noopener"
 &gt;시간표&lt;/a&gt;, 학사일정 자동 수집&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4종 OAuth 로그인&lt;/strong&gt; — Google, Apple, Kakao, GitHub (&lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#data/auth_service.md" target="_blank" rel="noopener"
 &gt;인증 서비스 문서&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;게시판 + 1:1 채팅&lt;/strong&gt; — 학생·교사·졸업생·학부모 모두 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;역할 기반 권한&lt;/strong&gt; — 일반 사용자, 매니저, 관리자 3단계&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;푸시 알림&lt;/strong&gt; — 댓글, 채팅, 공지 등 13종 (&lt;a class="link" href="https://monkshark.github.io/hansol_hs_flutter_app/#notification/fcm_service.md" target="_blank" rel="noopener"
 &gt;FCM 서비스 문서&lt;/a&gt;)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;처음부터 이 모든 기능을 계획한 건 아니었다. 급식과 시간표로 시작해서, 부스에서 받은 피드백으로 선택과목 시간표를 만들고, 사용자가 늘면서 게시판과 채팅을 붙이고, 관리가 필요해져서 역할 시스템을 추가했다. 하나씩 필요에 의해 붙여나간 결과가 지금의 프로젝트다.&lt;/p&gt;
&lt;p&gt;2023년 12월 첫 커밋부터 지금까지, 312개의 커밋, 110개의 Dart 파일, 32,000줄 이상의 코드. 이 시리즈는 그 과정에서 겪은 기술적 결정들을 기록한다.&lt;/p&gt;
&lt;h2 id="다음-글에서는"&gt;&lt;a href="#%eb%8b%a4%ec%9d%8c-%ea%b8%80%ec%97%90%ec%84%9c%eb%8a%94" class="header-anchor"&gt;&lt;/a&gt;다음 글에서는
&lt;/h2&gt;&lt;p&gt;Java 프로토타입 159커밋을 만들고, 학교 부스에서 결정적인 피드백을 받고, Flutter로 전환하기까지의 과정을 다룬다.&lt;/p&gt;</description></item><item><title>Hugo + Stack 테마로 블로그 만들기</title><link>https://monkshark.github.io/p/hugo-stack-blog-setup/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/hugo-stack-blog-setup/</guid><description>&lt;h2 id="hugo-프로젝트-생성"&gt;&lt;a href="#hugo-%ed%94%84%eb%a1%9c%ec%a0%9d%ed%8a%b8-%ec%83%9d%ec%84%b1" class="header-anchor"&gt;&lt;/a&gt;Hugo 프로젝트 생성
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hugo new site monkshark.github.io --format yaml
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Hugo는 기본적으로 &lt;code&gt;config.toml&lt;/code&gt;을 생성하는데, &lt;code&gt;--format yaml&lt;/code&gt;을 붙이면 &lt;code&gt;hugo.yaml&lt;/code&gt;로 시작한다. YAML이 TOML보다 들여쓰기가 직관적이라 선호한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git init
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git submodule add https://github.com/CaiJimmy/hugo-theme-stack themes/hugo-theme-stack
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Stack 테마를 Git submodule로 추가했다. 테마를 직접 복사하는 대신 submodule로 관리하면, 테마 업데이트를 &lt;code&gt;git submodule update&lt;/code&gt;로 간단히 반영할 수 있다.&lt;/p&gt;
&lt;h2 id="hugoyaml-설정"&gt;&lt;a href="#hugoyaml-%ec%84%a4%ec%a0%95" class="header-anchor"&gt;&lt;/a&gt;hugo.yaml 설정
&lt;/h2&gt;&lt;p&gt;Stack 테마는 설정이 많은데, 핵심만 정리하면:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;https://monkshark.github.io/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;languageCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ko&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;monkshark.dev&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;hugo-theme-stack&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;sidebar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;풀스택 개발자가 되고싶은 사람&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;toc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# 목차 자동 생성&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;readingTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# 읽는 시간 표시&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# 다크/라이트 전환 버튼&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;auto &lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# 시스템 설정 따라감&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;widgets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;homepage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;search&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;archives&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;categories&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;tag-cloud&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;colorScheme.default: auto&lt;/code&gt;로 설정하면 사용자의 시스템 다크 모드 설정을 따라가되, 토글 버튼으로 수동 전환도 가능하다.&lt;/p&gt;
&lt;p&gt;permalink를 &lt;code&gt;/p/:slug/&lt;/code&gt;로 설정해서 URL이 깔끔하게 나오도록 했다. 날짜가 URL에 들어가면 길어지고, 나중에 글 날짜를 수정하면 URL이 바뀌어서 링크가 깨질 수 있다.&lt;/p&gt;
&lt;h2 id="커스텀-스크롤-애니메이션"&gt;&lt;a href="#%ec%bb%a4%ec%8a%a4%ed%85%80-%ec%8a%a4%ed%81%ac%eb%a1%a4-%ec%95%a0%eb%8b%88%eb%a9%94%ec%9d%b4%ec%85%98" class="header-anchor"&gt;&lt;/a&gt;커스텀 스크롤 애니메이션
&lt;/h2&gt;&lt;p&gt;Stack 테마는 깔끔하지만 정적이다. 스크롤할 때 요소들이 자연스럽게 등장하는 애니메이션을 추가하고 싶었다.&lt;/p&gt;
&lt;h3 id="css--assetsscsscustomscss"&gt;&lt;a href="#css--assetsscsscustomscss" class="header-anchor"&gt;&lt;/a&gt;CSS — &lt;code&gt;assets/scss/custom.scss&lt;/code&gt;
&lt;/h3&gt;&lt;p&gt;핵심 아이디어: &lt;code&gt;body.anim-ready&lt;/code&gt; 클래스가 있을 때만 요소를 숨기고, JS가 스크롤 위치에 따라 &lt;code&gt;.is-visible&lt;/code&gt;을 추가하면 등장한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-scss" data-lang="scss"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// JS가 anim-ready를 추가한 후에만 숨김 처리
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// → JS가 실패해도 콘텐츠는 보인다 (progressive enhancement)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nc"&gt;.anim-ready&lt;/span&gt; &lt;span class="nc"&gt;.article-list&lt;/span&gt; &lt;span class="nt"&gt;article&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="kt"&gt;px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.98&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;transition&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="ni"&gt;opacity&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.6&lt;/span&gt;&lt;span class="kt"&gt;s&lt;/span&gt; &lt;span class="nf"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.16&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ni"&gt;transform&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.6&lt;/span&gt;&lt;span class="kt"&gt;s&lt;/span&gt; &lt;span class="nf"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.16&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nc"&gt;.anim-ready&lt;/span&gt; &lt;span class="nc"&gt;.article-list&lt;/span&gt; &lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="nc"&gt;.is-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;body.anim-ready&lt;/code&gt;를 가드 조건으로 쓰는 이유가 있다. 처음에는 CSS에서 바로 &lt;code&gt;opacity: 0&lt;/code&gt;을 적용했는데, JS 로딩이 지연되면 &lt;strong&gt;페이지 전체가 빈 화면&lt;/strong&gt;으로 보이는 문제가 있었다. JS가 성공적으로 로드된 후에만 &lt;code&gt;body&lt;/code&gt;에 &lt;code&gt;anim-ready&lt;/code&gt; 클래스를 추가하고, 그때부터 애니메이션이 작동하게 했다. JS가 실패하면? 클래스가 안 붙으니까 모든 요소가 원래대로 보인다.&lt;/p&gt;
&lt;p&gt;카드 호버, 사이드바 위젯 등장, 헤딩 슬라이드, 코드 블록 fade-in, 네비게이션 밑줄 애니메이션도 같은 패턴으로 추가했다. 전부 순수 CSS + &lt;code&gt;cubic-bezier(0.16, 1, 0.3, 1)&lt;/code&gt; (ease-out-expo와 유사한 커브)로, 외부 라이브러리 없이 구현했다.&lt;/p&gt;
&lt;p&gt;그리고 &lt;code&gt;prefers-reduced-motion&lt;/code&gt; 미디어 쿼리로 모션 감소 설정을 존중한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-scss" data-lang="scss"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nc"&gt;.anim-ready&lt;/span&gt; &lt;span class="nc"&gt;.article-list&lt;/span&gt; &lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nc"&gt;.anim-ready&lt;/span&gt; &lt;span class="nc"&gt;.animate-on-scroll&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;/*&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="nt"&gt;기타&lt;/span&gt; &lt;span class="nt"&gt;요소들&lt;/span&gt; &lt;span class="o"&gt;*/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="ni"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="js--layoutspartialsfootercustomhtml"&gt;&lt;a href="#js--layoutspartialsfootercustomhtml" class="header-anchor"&gt;&lt;/a&gt;JS — &lt;code&gt;layouts/partials/footer/custom.html&lt;/code&gt;
&lt;/h3&gt;&lt;p&gt;Intersection Observer API로 요소가 뷰포트에 들어오는 시점을 감지한다:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;anim-ready&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;is-visible&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unobserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 한 번만 실행
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rootMargin&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;0px 0px -30px 0px&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;카드 목록에는 &lt;code&gt;transitionDelay&lt;/code&gt;를 인덱스 × 0.1초로 설정해서 순차적으로 등장하는 스태거(stagger) 효과를 줬다. 한꺼번에 나타나면 밋밋하고, 하나씩 올라오면 시선이 자연스럽게 따라간다.&lt;/p&gt;
&lt;h2 id="github-actions-배포"&gt;&lt;a href="#github-actions-%eb%b0%b0%ed%8f%ac" class="header-anchor"&gt;&lt;/a&gt;GitHub Actions 배포
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;span class="lnt"&gt;25
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# .github/workflows/hugo.yml&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ubuntu-latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;HUGO_VERSION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;0.160.1&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Install Hugo CLI&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="sd"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; wget -O ${{ runner.temp }}/hugo.deb \
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; &amp;amp;&amp;amp; sudo dpkg -i ${{ runner.temp }}/hugo.deb&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;submodules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;recursive&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Build with Hugo&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;hugo --gc --minify&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/upload-pages-artifact@v3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;./public&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;build&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/deploy-pages@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;master&lt;/code&gt; 브랜치에 push하면 Hugo가 빌드하고, GitHub Pages에 자동 배포된다. &lt;code&gt;submodules: recursive&lt;/code&gt;가 중요한데, Stack 테마가 submodule로 들어가 있어서 이걸 빠뜨리면 빌드 시 테마를 찾지 못한다.&lt;/p&gt;
&lt;p&gt;Hugo Extended 버전을 써야 SCSS 컴파일이 된다. 일반 Hugo로 빌드하면 &lt;code&gt;custom.scss&lt;/code&gt;가 무시되어 커스텀 스타일이 적용되지 않는다.&lt;/p&gt;
&lt;h2 id="카테고리-아카이브-커스터마이징"&gt;&lt;a href="#%ec%b9%b4%ed%85%8c%ea%b3%a0%eb%a6%ac-%ec%95%84%ec%b9%b4%ec%9d%b4%eb%b8%8c-%ec%bb%a4%ec%8a%a4%ed%84%b0%eb%a7%88%ec%9d%b4%ec%a7%95" class="header-anchor"&gt;&lt;/a&gt;카테고리 아카이브 커스터마이징
&lt;/h2&gt;&lt;p&gt;카테고리별 아카이브 페이지(&lt;code&gt;/archives/&lt;/code&gt;)에서 타일 카드에 카테고리 아이콘을 배경처럼 깔아줬다. 아이콘이 왼쪽과 하단으로 살짝 잘리면서 카드 뒤에 은은하게 보이는 효과.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-scss" data-lang="scss"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nc"&gt;.article-list--tile&lt;/span&gt; &lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="nc"&gt;.has-image&lt;/span&gt; &lt;span class="nc"&gt;.article-image&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="ni"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="kt"&gt;px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="kt"&gt;px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;z-index&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="kt"&gt;px&lt;/span&gt; &lt;span class="k"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="kt"&gt;px&lt;/span&gt; &lt;span class="k"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;border-radius&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="kt"&gt;px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="mf"&gt;.45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;overflow: hidden&lt;/code&gt;으로 카드 영역을 벗어나는 부분을 자르고, &lt;code&gt;z-index&lt;/code&gt;로 텍스트가 아이콘 위에 올라오게 했다. 다크 모드에서는 &lt;code&gt;opacity: 0.3&lt;/code&gt;으로 더 어둡게 처리한다.&lt;/p&gt;
&lt;h2 id="정리"&gt;&lt;a href="#%ec%a0%95%eb%a6%ac" class="header-anchor"&gt;&lt;/a&gt;정리
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;항목&lt;/th&gt;
 &lt;th&gt;선택&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;정적 사이트 생성기&lt;/td&gt;
 &lt;td&gt;Hugo v0.160.1 Extended&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;테마&lt;/td&gt;
 &lt;td&gt;Stack (git submodule)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;배포&lt;/td&gt;
 &lt;td&gt;GitHub Pages + GitHub Actions&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;커스텀 애니메이션&lt;/td&gt;
 &lt;td&gt;CSS + Intersection Observer (외부 라이브러리 없음)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;비용&lt;/td&gt;
 &lt;td&gt;$0&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>블로그 시작</title><link>https://monkshark.github.io/p/hello-world/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/hello-world/</guid><description>&lt;h2 id="왜-블로그를-시작했나"&gt;&lt;a href="#%ec%99%9c-%eb%b8%94%eb%a1%9c%ea%b7%b8%eb%a5%bc-%ec%8b%9c%ec%9e%91%ed%96%88%eb%82%98" class="header-anchor"&gt;&lt;/a&gt;왜 블로그를 시작했나
&lt;/h2&gt;&lt;p&gt;Flutter + Firebase로 학교 앱을 1인 개발하면서 수많은 문제를 만났고, 그때마다 구글링으로 해결했다. 그런데 같은 문제를 두 번째 만났을 때 &amp;ldquo;이거 전에 어떻게 해결했더라?&amp;rdquo; 하며 또 검색하고 있는 나를 발견했다.&lt;/p&gt;
&lt;p&gt;코드에 주석을 남기면 되지 않냐고 할 수 있지만, 주석은 &amp;ldquo;어떻게&amp;quot;만 남기지 &amp;ldquo;왜&amp;quot;는 남기지 않는다. 왜 이 구조를 선택했는지, 어떤 대안을 시도해봤고 왜 실패했는지, 그 과정을 기록하려면 코드 바깥에 공간이 필요했다.&lt;/p&gt;
&lt;p&gt;그리고 한 가지 더. 학교 앱을 만들면서 겪은 문제들 — NEIS API의 시간표 구조를 파싱하는 법, &lt;code&gt;flutter_local_notifications&lt;/code&gt;에서 &lt;code&gt;matchDateTimeComponents&lt;/code&gt;로 주간 반복 알림을 설정하는 법, Firestore에서 n-gram 검색을 구현하는 법 — 은 나만 겪는 문제가 아닐 거다. 누군가는 똑같은 상황에서 검색하고 있을 테니, 내가 삽질한 기록이 그 사람의 시간을 아껴줄 수 있다.&lt;/p&gt;
&lt;p&gt;기록하지 않으면 사라진다. 그래서 블로그를 만들기로 했다.&lt;/p&gt;
&lt;h2 id="블로그에-쓸-내용"&gt;&lt;a href="#%eb%b8%94%eb%a1%9c%ea%b7%b8%ec%97%90-%ec%93%b8-%eb%82%b4%ec%9a%a9" class="header-anchor"&gt;&lt;/a&gt;블로그에 쓸 내용
&lt;/h2&gt;&lt;p&gt;크게 세 가지 카테고리로 운영할 예정이다.&lt;/p&gt;
&lt;h3 id="서비스-개발기"&gt;&lt;a href="#%ec%84%9c%eb%b9%84%ec%8a%a4-%ea%b0%9c%eb%b0%9c%ea%b8%b0" class="header-anchor"&gt;&lt;/a&gt;서비스 개발기
&lt;/h3&gt;&lt;p&gt;직접 만든 서비스의 기획부터 개발, 운영까지의 과정을 기록한다. 첫 번째는 Flutter + Firebase로 만든 학교 앱. Java 프로토타입 159커밋을 버리고 Flutter로 전환한 이유, Riverpod을 선택한 과정, 급식 알림 하나를 제대로 만드는 데 1년이 걸린 이야기 같은 것들.&lt;/p&gt;
&lt;p&gt;단순히 &amp;ldquo;이렇게 했다&amp;quot;가 아니라 &amp;ldquo;왜 이렇게 했고, 다른 방법은 왜 안 됐는지&amp;quot;를 커밋 히스토리와 함께 복기하는 게 목표다. 앞으로 다른 서비스를 만들게 되면 그 과정도 여기에 쓸 예정이다.&lt;/p&gt;
&lt;h3 id="기술"&gt;&lt;a href="#%ea%b8%b0%ec%88%a0" class="header-anchor"&gt;&lt;/a&gt;기술
&lt;/h3&gt;&lt;p&gt;Flutter, Firebase에 국한하지 않고 개발하면서 배운 것들. 풀스택 개발자가 되고 싶은 사람으로서 새롭게 접하는 기술들도 다룰 예정이다.&lt;/p&gt;
&lt;p&gt;이 블로그 자체를 만든 과정 — Hugo 정적 사이트 생성기 선택, Stack 테마 커스터마이징, Intersection Observer 기반 스크롤 애니메이션 구현, GitHub Actions 배포 — 도 여기에 속한다.&lt;/p&gt;
&lt;h3 id="til-today-i-learned"&gt;&lt;a href="#til-today-i-learned" class="header-anchor"&gt;&lt;/a&gt;TIL (Today I Learned)
&lt;/h3&gt;&lt;p&gt;짧지만 기록할 가치가 있는 것들. 에러 해결법, 새로 알게 된 패턴, 유용한 도구 등. 블로그 글 하나로 쓰기엔 가볍지만 머릿속에서 사라지기엔 아까운 것들을 모아두는 공간.&lt;/p&gt;
&lt;p&gt;꾸준히 쓰는 게 목표다. 완벽한 글보다 기록하는 습관이 우선이니까.&lt;/p&gt;</description></item></channel></rss>