<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>NEIS API on monkshark.dev</title><link>https://monkshark.github.io/tags/neis-api/</link><description>Recent content in NEIS API on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Fri, 10 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://monkshark.github.io/tags/neis-api/index.xml" rel="self" type="application/rss+xml"/><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>#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></channel></rss>