<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>삽질 on monkshark.dev</title><link>https://monkshark.github.io/tags/%EC%82%BD%EC%A7%88/</link><description>Recent content in 삽질 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/tags/%EC%82%BD%EC%A7%88/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>#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></channel></rss>