[{"content":"날아갔다 2026년 4월 3일. 전날부터 UI 전면 개편 작업을 하고 있었다. 앱의 거의 모든 화면을 건드리는 큰 작업이었다. 게시판 레이아웃을 바꾸고, 채팅에 읽음 표시를 넣고, 개인정보 동의 화면을 만들고, 교사용 시간표를 추가하고, Cloud Functions로 푸시 알림을 연결하고 — 한 마디로 앱 전체를 뜯어고치는 중이었다.\n그리고 한순간에 전부 사라졌다.\n배경: OneDrive 폴더 프로젝트 폴더가 OneDrive 동기화 경로 안에 있었다. 처음부터 의도한 건 아니고, 바탕화면이 OneDrive에 연결되어 있었는데 거기서 프로젝트를 만든 거다.\n평소에는 별 문제가 없었다. 파일을 수정하면 OneDrive가 알아서 클라우드에 올리고, 혹시 모를 상황에 백업도 되니까 오히려 편하다고 생각했다. 실제로 한동안 아무 탈 없이 잘 돌아갔다.\n문제는 이 구조가 Flutter 프로젝트와 근본적으로 맞지 않는다는 점이다. build/, .dart_tool/, node_modules/ 같은 폴더는 빌드할 때마다 수천 개의 파일을 생성하고 삭제한다. OneDrive는 이 파일들을 전부 동기화하려고 한다. 파일 잠금이 걸리고, 동기화 충돌이 나고, 결국 빌드 자체가 안 되는 상황이 온다.\n터진 순간 빌드가 안 됐다. 정확한 에러는 기억 안 나지만, 캐시나 빌드 파일이 꼬인 전형적인 증상이었다. flutter clean을 해야 하는 상황.\n그런데 OneDrive 동기화가 걸려 있으면 clean이 제대로 안 된다. 파일을 지워도 OneDrive가 클라우드에서 다시 복원하거나, 동기화 중인 파일이라 삭제가 안 되거나, 잠금이 걸려서 빌드 디렉토리를 못 지운다.\n그래서 OneDrive 백업 설정을 껐다. \u0026ldquo;이 폴더 동기화 중지.\u0026rdquo; 이러면 깔끔하게 clean build를 할 수 있을 거라고 생각했다.\nOneDrive는 동기화를 중지하면서 로컬 파일을 클라우드에 마지막으로 저장된 상태로 되돌렸다. 마지막 커밋 시점. 커밋하지 않은 모든 변경사항 — UI 전면 개편의 모든 작업이 증발했다.\n터미널에서 git status를 쳤을 때 nothing to commit, working tree clean이 뜬 그 순간의 기분은 설명하기 어렵다. 분명 수십 개 파일을 수정했는데, clean이라니.\n날아간 것들 커밋 메시지에 남긴 복구 목록이다:\n1 2 3 4 5 6 7 8 9 10 11 Restored (lost from git reset --hard): - Category chips Wrap, post action sheet, bookmark, chat icon - Chat: leave, message delete, read receipts, system messages, limit(30) - Privacy consent checkbox, privacy policy in-app screen - Home refresh (WidgetsBindingObserver, RefreshIndicator), board→recent order - Crashlytics + crash log to Firestore - Onboarding→login flow, HomeScreen tab refresh - My activity 3 tabs (posts/comments/bookmarks) - Teacher timetable, today highlight, font 12px - Cloud Functions: chat push, reply notifications - Firestore rules: crash_logs, field-level post update 실제로는 git reset --hard를 한 게 아니라 OneDrive가 파일을 되돌린 건데, 효과는 같았기에 커밋 메시지에 그렇게 적었다. 핵심은 커밋하지 않은 작업이 전부 사라졌다는 것이다.\n하나씩 보면:\n채팅 시스템 대규모 개선. 읽음 표시, 메시지 삭제, 나가기 기능, 시스템 메시지, 메시지 30개 제한. 채팅 화면만 344줄이 바뀌었다. 이건 단순 UI가 아니라 Firestore 쿼리, Cloud Functions, 클라이언트 로직이 엮인 기능이다.\n개인정보 처리방침. 회원가입 시 동의 체크박스, 인앱 개인정보처리방침 화면. 법적으로 필요한 기능이라 빠뜨릴 수 없었다.\n내 활동 3탭. 내 글, 내 댓글, 북마크를 탭으로 나눠 보여주는 화면. 각 탭이 별도 Firestore 쿼리를 가지고 있었다.\n교사 시간표. 일반 학생 시간표와 구조가 다르다. 교사는 여러 학급에서 수업하니까 학년/반 선택이 필요하고, 멀티 클래스 선택을 지원해야 했다.\nCloud Functions. 채팅 푸시 알림, 댓글 답글 알림. 클라이언트가 아니라 서버 사이드 코드라 별도로 테스트하고 배포한 거였다.\n전부 커밋 전이었다. Git에 흔적이 없다.\n복구: 6시간 같은 날 오전 6시 반에 복구 작업을 시작해서, 12:26에 복구 커밋을 찍었다.\n1 18 files changed, 1,432 insertions(+), 314 deletions(-) 6시간 만에 18개 파일, 1,432줄을 다시 쳤다. 거기에 원래 계획에 없던 피드백 시스템(버그 신고 + 학생회 건의함)까지 새로 추가했다.\n한 번 만들어본 코드를 다시 치는 건 확실히 빠르다. \u0026ldquo;이 화면에 이 위젯이 필요하고, 이 Firestore 쿼리를 써야 하고, 이 Cloud Function이 이 트리거로 동작한다\u0026rdquo; — 설계를 처음부터 고민할 필요가 없으니까. 머릿속에 완성된 그림이 있고, 타이핑만 하면 된다.\n하지만 원본과 같은 코드는 아니다. 처음 만들 때는 시행착오를 거친다. 이 변수명이 맞나, 이 조건 분기가 맞나, 이 에러 핸들링은 충분한가 — 그 과정에서 다듬어진 디테일들이 있다. 복구할 때는 \u0026ldquo;대충 이랬다\u0026quot;로 넘어간다. 복구한 코드는 기능은 같지만, 처음 코드가 가졌던 미세한 개선들은 빠져 있다.\n그래도 전부 잃는 것보단 훨씬 낫다.\n이후 바꾼 것들 프로젝트 폴더 이동 OneDrive 동기화 경로 밖으로 프로젝트를 옮겼다. C:\\Users\\Desktop\\ 아래에 두되, OneDrive 동기화 대상에서 제외했다. 백업은 Git이 하면 된다. 클라우드 동기화 서비스는 코드 저장소가 아니다.\n커밋 습관 \u0026ldquo;큰 작업 끝나면 한 번에 커밋하자\u0026rdquo; → \u0026ldquo;작은 단위로 자주 커밋하자\u0026quot;로 바꿨다.\n이전에는 여러 기능을 한꺼번에 만들고 한 커밋에 몰아넣었다. 커밋 메시지에 \u0026ldquo;Major update: board, notifications, admin web, UI overhaul\u0026rdquo; 같은 게 나오는 이유다. 깔끔한 커밋 히스토리보다 작업 흐름을 끊지 않는 게 더 중요하다고 생각했다.\n사건 이후에는 기능 하나가 완성되면 바로 커밋한다. 완벽하지 않아도. 커밋 메시지가 좀 지저분해져도. 커밋하지 않은 코드는 존재하지 않는 코드다.\n.gitignore OneDrive와 무관하게, .gitignore를 꼼꼼하게 관리하기 시작했다. build/, .dart_tool/, node_modules/ 같은 폴더가 동기화되거나 추적되지 않도록. 이건 OneDrive 사건과 직접 관련은 없지만, 빌드 아티팩트가 소스 코드와 섞이면 안 된다는 걸 체감한 뒤로 더 신경 쓰게 됐다.\n돌이켜보면 클라우드 동기화 폴더에서 개발하는 건 시한폭탄이다. OneDrive, Dropbox, iCloud Drive — 전부 마찬가지다. 이 서비스들은 문서, 사진, 일반 파일을 동기화하도록 설계되었지, 수천 개의 임시 파일을 초 단위로 생성하고 삭제하는 개발 프로젝트를 위한 게 아니다.\nGit은 이미 완벽한 분산 백업 시스템이다. git push만 해도 코드는 원격 저장소에 안전하게 보관된다. 그 위에 OneDrive까지 겹치면 동기화 충돌, 파일 잠금, 빌드 실패가 생기고, 최악의 경우 이 글처럼 작업이 통째로 날아간다.\n개발 폴더는 동기화 범위 밖에 둬라. 백업은 Git에 맡겨라. 그리고 커밋은 자주 해라.\n","date":"2026-04-15T00:00:00Z","permalink":"/p/lost-changes/","title":"#11 - 작업이 전부 날아갔다"},{"content":"느린 시작 앱을 켜면 흰 화면이 2~3초. 그 동안 사용자는 앱이 멈춘 건지, 로딩 중인 건지 모른다. 실제로는 main() 함수에서 Firebase, 타임존, 알림, FCM, AppCheck, Analytics, 시간표 프리로드 등을 전부 초기화하느라 시간이 걸리는 것이었다.\n원래 구조 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Future\u0026lt;void\u0026gt; main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(...); await FirebaseAppCheck.instance.activate(...); await FirebasePerformance.instance.setPerformanceCollectionEnabled(true); await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true); await SettingData().init(); await setupServiceLocator(); await initializeDateFormatting(); final meal = DailyMealNotification(); await meal.initializeNotifications(); await meal.scheduleDailyNotifications(); await FcmService.initialize(); await DeepLinkService.initialize(); // ... runApp(const MyApp()); } await가 줄줄이 이어진다. 각각은 빠르지만, 직렬로 실행하면 합산된다. Firebase 초기화 200ms, AppCheck 300ms, 알림 설정 200ms, FCM 200ms\u0026hellip; 합치면 1~2초. 여기에 네트워크가 느린 날이면 더 길어진다.\n문제는 이 중 화면을 띄우는 데 정말 필요한 것은 일부뿐이라는 거다.\n핵심 질문: runApp 전에 뭐가 꼭 필요한가 runApp() 이전에 완료되어야 하는 것:\nFirebase 초기화 — 거의 모든 기능이 의존 SettingData — 테마, 언어 설정을 읽어야 첫 화면을 그릴 수 있음 ServiceLocator — DI 컨테이너 설정 날짜 포맷 — 화면에 날짜를 표시하려면 필요 runApp() 이후에 해도 되는 것:\nAppCheck — 보안 검증이지만 첫 화면에 바로 필요하지 않음 Performance/Analytics — 수집 시작이 몇 초 늦어도 상관없음 알림 스케줄링 — 앱이 뜬 후에 설정해도 됨 FCM — 푸시 토큰 등록이 약간 늦어도 사용자가 모름 딥링크 — 앱이 뜬 후 처리해도 UX에 영향 없음 시간표 프리로드 — 화면을 열 때 로드해도 되는 데이터 홈 위젯 — 백그라운드에서 갱신하면 됨 바뀐 구조 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Future\u0026lt;void\u0026gt; main() async { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setPreferredOrientations([...]); await Firebase.initializeApp(...); KakaoSdk.init(...); tz.initializeTimeZones(); tz.setLocalLocation(tz.getLocation(\u0026#39;Asia/Seoul\u0026#39;)); providerContainer = ProviderContainer(); // 필수: SettingData + ServiceLocator만 await await Future.wait([SettingData().init(), setupServiceLocator()]); await initializeDateFormatting(); runApp(...); // ← 여기서 화면이 뜬다 // UI가 뜬 후 나머지를 백그라운드로 unawaited(_deferredInit()); } runApp() 전에는 진짜 필수적인 것만 남기고, 나머지는 _deferredInit()으로 뺐다. Future.wait()으로 독립적인 초기화 2개를 병렬 실행하는 것도 포인트다.\nunawaited()는 \u0026ldquo;이 Future의 완료를 기다리지 않겠다\u0026quot;는 명시적 선언이다. _deferredInit()을 그냥 호출해도 되지만, unawaited()로 감싸면 의도가 분명하고, lint 경고도 안 뜬다.\n_deferredInit: 안전한 백그라운드 초기화 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 Future\u0026lt;void\u0026gt; _deferredInit() async { unawaited(_safeInit(\u0026#39;AppCheck\u0026#39;, () =\u0026gt; FirebaseAppCheck.instance.activate( androidProvider: const bool.fromEnvironment(\u0026#39;dart.vm.product\u0026#39;) ? AndroidProvider.playIntegrity : AndroidProvider.debug, ))); unawaited(_safeInit(\u0026#39;Performance\u0026#39;, () =\u0026gt; FirebasePerformance.instance.setPerformanceCollectionEnabled(true))); unawaited(_safeInit(\u0026#39;Analytics\u0026#39;, () =\u0026gt; FirebaseAnalytics.instance.setAnalyticsCollectionEnabled( const bool.fromEnvironment(\u0026#39;dart.vm.product\u0026#39;), ))); unawaited(_preloadSubjects(2)); unawaited(_preloadSubjects(3)); final meal = DailyMealNotification(); await meal.initializeNotifications(); await meal.scheduleDailyNotifications(); FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); unawaited(FcmService.initialize()); unawaited(DeepLinkService.initialize()); unawaited(WidgetService.initialize().then((_) { WidgetService.updateAll(); HomeWidget.registerInteractivityCallback(widgetBackgroundCallback); })); } _deferredInit() 안에서도 독립적인 것들은 unawaited()로 병렬 실행한다. AppCheck, Performance, Analytics는 서로 의존하지 않으니 동시에 시작한다. 시간표 프리로드도 2학년, 3학년을 병렬로.\n알림 초기화(meal.initializeNotifications())만 await로 순서를 보장하는데, 알림 플러그인이 초기화되어야 스케줄링이 가능하기 때문이다.\n_safeInit: 하나가 실패해도 나머지는 계속 1 2 3 4 5 6 7 Future\u0026lt;void\u0026gt; _safeInit(String name, Future\u0026lt;void\u0026gt; Function() fn) async { try { await fn(); } catch (e) { log(\u0026#39;$name init failed: $e\u0026#39;, name: \u0026#39;main\u0026#39;); } } 백그라운드 초기화에서 하나가 실패하면? AppCheck가 터져도 앱은 돌아가야 한다. _safeInit()으로 각 초기화를 try-catch로 감싸서, 실패하면 로그만 남기고 넘어간다.\n결과 runApp()까지 걸리는 시간이 체감상 절반 이하로 줄었다. Firebase 초기화 + SettingData + ServiceLocator + 날짜 포맷 — 이것만 기다리면 화면이 뜬다. 나머지는 사용자가 첫 화면을 보는 동안 백그라운드에서 완료된다.\n핵심 원칙 runApp() 전에는 화면에 필요한 것만 — 나머지는 전부 후순위 독립적인 초기화는 병렬로 — Future.wait()과 unawaited() 하나의 실패가 전체를 막지 않게 — _safeInit()으로 격리 의도를 명시 — unawaited()는 \u0026ldquo;기다리지 않는 게 의도\u0026quot;라는 선언 앱 시작 속도는 사소해 보이지만, 매일 여는 앱에서 2초와 0.5초의 차이는 크다.\n","date":"2026-04-14T00:00:00Z","permalink":"/p/app-speed/","title":"#10 - 앱 시작 속도 줄이기"},{"content":"채팅이 필요한 이유 게시판만으로는 부족한 순간이 있다. 분실물 게시판에서 \u0026ldquo;제 카드키 찾으신 분 연락주세요\u0026quot;라고 올리면, 찾은 사람이 연락할 방법이 없다. 댓글로 개인 정보를 주고받을 수도 없고. 1:1 채팅이 필요해진 순간이다.\n채팅방 ID: 정렬의 힘 A가 B에게 채팅을 걸든, B가 A에게 걸든 같은 채팅방이어야 한다. 중복 채팅방이 생기면 대화가 갈린다.\n1 2 3 4 String _getChatId(String uid1, String uid2) { final sorted = [uid1, uid2]..sort(); return \u0026#39;${sorted[0]}_${sorted[1]}\u0026#39;; } 두 uid를 알파벳 순서로 정렬하고 _로 연결한다. abc123과 xyz789의 조합은 항상 abc123_xyz789가 된다. 누가 먼저 시작했는지와 관계없이 같은 ID.\n채팅방을 생성할 때는 이 ID로 문서가 이미 있는지 확인하고, 없으면 만든다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Future\u0026lt;void\u0026gt; startChat(String otherUid, String otherName) async { final chatId = _getChatId(myUid, otherUid); final doc = await FirebaseFirestore.instance.collection(\u0026#39;chats\u0026#39;).doc(chatId).get(); if (!doc.exists) { await FirebaseFirestore.instance.collection(\u0026#39;chats\u0026#39;).doc(chatId).set({ \u0026#39;participants\u0026#39;: [myUid, otherUid], \u0026#39;participantNames\u0026#39;: {myUid: myName, otherUid: otherName}, \u0026#39;lastMessage\u0026#39;: \u0026#39;\u0026#39;, \u0026#39;lastMessageAt\u0026#39;: FieldValue.serverTimestamp(), \u0026#39;unreadCount\u0026#39;: {myUid: 0, otherUid: 0}, }); } // 채팅방 화면으로 이동 } 메시지 전송 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Future\u0026lt;void\u0026gt; _sendMessage() async { final text = _controller.text.trim(); if (text.isEmpty) return; _controller.clear(); await FirebaseFirestore.instance .collection(\u0026#39;chats\u0026#39;).doc(chatId) .collection(\u0026#39;messages\u0026#39;).add({ \u0026#39;content\u0026#39;: text, \u0026#39;senderUid\u0026#39;: myUid, \u0026#39;senderName\u0026#39;: myName, \u0026#39;createdAt\u0026#39;: FieldValue.serverTimestamp(), \u0026#39;deletedFor\u0026#39;: [], }); await FirebaseFirestore.instance.collection(\u0026#39;chats\u0026#39;).doc(chatId).update({ \u0026#39;lastMessage\u0026#39;: text, \u0026#39;lastMessageAt\u0026#39;: FieldValue.serverTimestamp(), \u0026#39;unreadCount.${widget.otherUid}\u0026#39;: FieldValue.increment(1), }); } 메시지를 보내면 두 가지 업데이트가 일어난다:\nmessages 서브컬렉션에 메시지 문서 추가 부모 chats 문서의 lastMessage, lastMessageAt, 상대방의 unreadCount 업데이트 unreadCount.${widget.otherUid} — dot notation으로 상대방의 읽지 않은 메시지 수만 증가시킨다. 내 카운트는 건드리지 않는다.\n입력 필드를 먼저 비우고(_controller.clear()) 나서 네트워크 요청을 보낸다. 전송이 완료될 때까지 입력 필드가 남아있으면 사용자가 답답해하니까.\n이미지 전송 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Future\u0026lt;void\u0026gt; _sendImage() async { final picked = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85); if (picked == null) return; // 압축 final compressed = await FlutterImageCompress.compressWithFile( picked.path, quality: 80, minWidth: 1280, minHeight: 1280, ); // Firebase Storage 업로드 final path = \u0026#39;chats/$chatId/${DateTime.now().millisecondsSinceEpoch}_$myUid.jpg\u0026#39;; final ref = FirebaseStorage.instance.ref(path); await ref.putData(compressed); final url = await ref.getDownloadURL(); // 이미지 URL을 메시지로 전송 await _addMessage(imageUrl: url); } 이미지는 선택 → 압축 → Storage 업로드 → URL을 메시지에 저장하는 순서다. 원본 대신 1280px, 80% 품질로 압축하여 용량을 줄인다. 채팅방마다 Storage 경로를 분리해서 정리도 쉽다.\n\u0026ldquo;나만 삭제\u0026quot;와 \u0026ldquo;모두에게서 삭제\u0026rdquo; 카카오톡처럼 두 가지 삭제 옵션이 있다.\n나만 삭제 1 2 3 4 5 6 7 8 Future\u0026lt;void\u0026gt; _deleteForMe(String messageId) async { await FirebaseFirestore.instance .collection(\u0026#39;chats\u0026#39;).doc(chatId) .collection(\u0026#39;messages\u0026#39;).doc(messageId) .update({ \u0026#39;deletedFor\u0026#39;: FieldValue.arrayUnion([myUid]), }); } 메시지를 실제로 삭제하지 않고, deletedFor 배열에 내 uid를 추가한다. 메시지를 표시할 때 이 배열을 확인:\n1 2 final deletedFor = List\u0026lt;String\u0026gt;.from(data[\u0026#39;deletedFor\u0026#39;] ?? []); if (deletedFor.contains(uid)) return const SizedBox.shrink(); 내 uid가 deletedFor에 있으면 렌더링하지 않는다. 상대방에게는 여전히 보인다.\n모두에게서 삭제 1 2 3 4 5 6 7 8 9 Future\u0026lt;void\u0026gt; _deleteForAll(String messageId) async { await FirebaseFirestore.instance .collection(\u0026#39;chats\u0026#39;).doc(chatId) .collection(\u0026#39;messages\u0026#39;).doc(messageId) .update({ \u0026#39;deleted\u0026#39;: true, \u0026#39;content\u0026#39;: AppLocalizations.of(context)!.chat_deletedMessage, }); } deleted: true로 표시하고 내용을 \u0026ldquo;삭제된 메시지\u0026quot;로 바꾼다. 양쪽 모두에게 \u0026ldquo;삭제된 메시지\u0026quot;가 보인다.\n단, 조건이 있다: 보낸 지 1시간 이내이고 상대방이 아직 읽지 않았을 때만 가능하다.\n1 canDeleteForAll = isWithinOneHour \u0026amp;\u0026amp; (otherUnread \u0026gt; 0); 이미 읽은 메시지를 삭제하는 건 의미가 없으니까.\n읽음 표시 unreadCount를 활용하여 읽음 표시를 구현한다.\n채팅방에 들어가면 내 unreadCount를 0으로 초기화:\n1 2 3 4 5 6 7 @override void initState() { super.initState(); FirebaseFirestore.instance.collection(\u0026#39;chats\u0026#39;).doc(chatId).update({ \u0026#39;unreadCount.$myUid\u0026#39;: 0, }); } 메시지 옆에 \u0026ldquo;읽음\u0026rdquo; 표시를 보여주는 로직은 상대방의 unreadCount를 역산하는 방식이다. 상대방이 3개를 안 읽었으면, 내가 보낸 최근 3개 메시지에는 \u0026ldquo;읽음\u0026quot;이 표시되지 않고 나머지에는 표시된다.\n채팅 목록 1 2 3 4 5 FirebaseFirestore.instance .collection(\u0026#39;chats\u0026#39;) .where(\u0026#39;participants\u0026#39;, arrayContains: myUid) .orderBy(\u0026#39;lastMessageAt\u0026#39;, descending: true) .snapshots() 내가 참여한 채팅방을 최근 메시지 순으로 실시간 스트리밍한다. 새 메시지가 오면 목록이 자동으로 재정렬된다.\n각 채팅방 아이템에는 상대 이름, 마지막 메시지, 시간, 읽지 않은 메시지 수가 표시된다. 읽지 않은 메시지가 있으면 빨간 뱃지가 뜬다.\n채팅방 나가기 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Future\u0026lt;void\u0026gt; _leaveChat() async { // 시스템 메시지 추가 await messagesRef.add({ \u0026#39;type\u0026#39;: \u0026#39;system\u0026#39;, \u0026#39;content\u0026#39;: \u0026#39;$myName님이 나갔습니다\u0026#39;, \u0026#39;createdAt\u0026#39;: FieldValue.serverTimestamp(), }); // 참가자 목록에서 제거 await chatRef.update({ \u0026#39;participants\u0026#39;: FieldValue.arrayRemove([myUid]), \u0026#39;lastMessage\u0026#39;: \u0026#39;$myName님이 나갔습니다\u0026#39;, }); } 채팅방을 나가면 시스템 메시지를 남기고 참가자 목록에서 제거된다. 상대방에게는 \u0026ldquo;○○님이 나갔습니다\u0026quot;가 표시된다.\n돌아보면 채팅의 핵심은 \u0026ldquo;실시간\u0026quot;이라기보다 \u0026ldquo;상태 동기화\u0026quot;였다. 읽음/안읽음, 삭제됨/안삭제됨, 참가 중/나감 — 양쪽의 상태가 항상 일치해야 한다. Firestore의 실시간 스트리밍이 이 동기화를 거의 공짜로 해주지만, 구조를 잘 잡아야 그 혜택을 받을 수 있다.\nuid 정렬로 채팅방 ID를 만드는 건 작은 결정이지만, 이 한 줄이 \u0026ldquo;중복 채팅방\u0026rdquo; 문제를 원천 차단했다. 작은 결정이 큰 버그를 예방하는 경험이었다.\n","date":"2026-04-13T00:00:00Z","permalink":"/p/chat/","title":"#9 - 1:1 채팅, 실시간의 무게"},{"content":"\u0026ldquo;익명\u0026quot;이면 그냥 이름 숨기면 되는 거 아닌가 처음에는 그렇게 생각했다. isAnonymous: true면 이름 대신 \u0026ldquo;익명\u0026quot;을 표시하면 끝. 하지만 실제로 만들어보니 익명 게시판에는 생각보다 많은 설계 결정이 필요했다.\n익명 번호: \u0026ldquo;익명1\u0026quot;과 \u0026ldquo;익명2\u0026quot;는 같은 사람인가 익명 게시판에서 댓글이 달리면 이런 상황이 생긴다:\n1 2 3 4 익명 — 오늘 급식 맛있었나요? └ 익명 — 네 괜찮았어요 └ 익명 — 별로였는데 └ 익명 — 저도 괜찮았어요 \u0026ldquo;괜찮았어요\u0026quot;를 쓴 첫 번째 사람과 세 번째 사람이 같은 사람인지 알 수 없다. 대화 맥락이 끊긴다. 에브리타임 같은 서비스에서는 이걸 익명 번호로 해결한다: \u0026ldquo;익명1\u0026rdquo;, \u0026ldquo;익명2\u0026quot;처럼.\n1 2 3 4 익명 — 오늘 급식 맛있었나요? └ 익명1 — 네 괜찮았어요 └ 익명2 — 별로였는데 └ 익명1 — 저도 괜찮았어요 이제 \u0026ldquo;익명1\u0026quot;이 같은 사람이라는 걸 알 수 있다.\nanonymousMapping 이 기능을 구현하려면 \u0026ldquo;이 게시글에서 이 사용자가 몇 번 익명인지\u0026quot;를 추적해야 한다. Firestore 문서에 anonymousMapping과 anonymousCount 필드를 뒀다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 Future\u0026lt;String\u0026gt; resolveAnonymousName( String postId, String uid, String authorLabel, String Function(int) anonymousNumLabel, ) async { final ref = postRef(postId); final postSnap = await ref.get(); final postAuthorUid = postSnap.data()?[\u0026#39;authorUid\u0026#39;]; // 글 작성자는 \u0026#34;작성자\u0026#34;로 표시 if (uid == postAuthorUid) return authorLabel; return _db.runTransaction\u0026lt;String\u0026gt;((transaction) async { final postDoc = await transaction.get(ref); final data = postDoc.data() ?? {}; final mapping = Map\u0026lt;String, dynamic\u0026gt;.from(data[\u0026#39;anonymousMapping\u0026#39;] ?? {}); final count = (data[\u0026#39;anonymousCount\u0026#39;] as int?) ?? 0; if (mapping.containsKey(uid)) { return anonymousNumLabel(mapping[uid]); // 기존 번호 반환 } else { final newNum = count + 1; mapping[uid] = newNum; transaction.update(ref, { \u0026#39;anonymousMapping\u0026#39;: mapping, \u0026#39;anonymousCount\u0026#39;: newNum, }); return anonymousNumLabel(newNum); // 새 번호 부여 } }); } Firestore 트랜잭션을 쓰는 이유는 동시성 문제 때문이다. 두 사람이 동시에 댓글을 달면 같은 번호를 받을 수 있다. 트랜잭션으로 읽기-확인-쓰기를 원자적으로 처리해야 번호가 중복되지 않는다.\n글 작성자는 \u0026ldquo;익명1\u0026quot;이 아니라 \u0026ldquo;작성자\u0026quot;로 표시된다. 자기 글의 댓글에서 글쓴이를 구분할 수 있어야 하니까.\n댓글 렌더링 댓글을 화면에 표시할 때는 anonymousMapping을 미리 로드해두고, 각 댓글의 authorUid로 번호를 조회한다:\n1 2 3 4 5 6 7 8 9 10 if (c[\u0026#39;isAnonymous\u0026#39;] == true \u0026amp;\u0026amp; c[\u0026#39;authorUid\u0026#39;] != null) { final uid = c[\u0026#39;authorUid\u0026#39;] as String; if (uid == _currentPostAuthorUid) { c[\u0026#39;authorName\u0026#39;] = AppLocalizations.of(context)!.post_anonymousAuthor; } else if (_anonymousMapping.containsKey(uid)) { c[\u0026#39;authorName\u0026#39;] = AppLocalizations.of(context)!.post_anonymousNum( _anonymousMapping[uid] ); } } i18n도 적용되어 있다. 한국어에서는 \u0026ldquo;익명1\u0026rdquo;, 영어에서는 \u0026ldquo;Anonymous 1\u0026rdquo;.\n좋아요: 배열에서 Map으로 처음: 배열 1 likes: [\u0026#34;uid1\u0026#34;, \u0026#34;uid2\u0026#34;, \u0026#34;uid3\u0026#34;] 단순하다. arrayContains로 내가 좋아요를 눌렀는지 확인하고, arrayUnion/arrayRemove로 추가/삭제. 하지만 문제가 있었다:\n두 명이 동시에 좋아요를 누르면 한쪽이 씹힐 수 있다 \u0026ldquo;인기글\u0026rdquo; 정렬을 하려면 배열 크기로 정렬해야 하는데, Firestore에서는 배열 크기 기준 정렬이 불가능하다 지금: Map + 비정규화 카운터 1 2 likes: {\u0026#34;uid1\u0026#34;: true, \u0026#34;uid2\u0026#34;: true} likeCount: 2 Map으로 바꾸면서 동시 업데이트 문제가 해결되었다. 각 uid가 독립적인 필드이기 때문에, 두 사람이 동시에 눌러도 충돌하지 않는다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Future\u0026lt;void\u0026gt; toggleLike(String postId, String uid, { required bool hasLiked, required bool hasDisliked, }) async { if (hasLiked) { await postRef(postId).update({ \u0026#39;likes.$uid\u0026#39;: FieldValue.delete(), \u0026#39;likeCount\u0026#39;: FieldValue.increment(-1), }); } else { final updates = \u0026lt;String, dynamic\u0026gt;{ \u0026#39;likes.$uid\u0026#39;: true, \u0026#39;likeCount\u0026#39;: FieldValue.increment(1), }; if (hasDisliked) { updates[\u0026#39;dislikes.$uid\u0026#39;] = FieldValue.delete(); updates[\u0026#39;dislikeCount\u0026#39;] = FieldValue.increment(-1); } await postRef(postId).update(updates); } } likes.$uid — Firestore의 dot notation으로 Map의 특정 키만 업데이트한다. FieldValue.increment(-1)로 카운터를 원자적으로 감소시킨다. 좋아요를 누르면서 동시에 싫어요를 취소하는 것도 한 번의 업데이트로 처리한다.\nlikeCount는 비정규화된 필드다. likes Map의 크기와 항상 같아야 한다. 이걸 별도로 유지하는 이유는 오직 정렬 때문이다. \u0026ldquo;인기글\u0026rdquo; 탭에서 likeCount 내림차순으로 정렬하려면 이 필드가 필요하다.\n검색: Firestore에서 \u0026ldquo;급식\u0026quot;을 찾으려면 Firestore에는 LIKE '%급식%' 같은 전문 검색이 없다. 공식적으로는 Algolia나 Typesense 같은 외부 검색 엔진을 붙이라고 권장한다. 하지만 학생 프로젝트에서 외부 서비스 비용과 관리 부담은 크다.\nn-gram 토큰 대안으로 2-gram 토큰 방식을 썼다. 게시글을 저장할 때 제목과 내용에서 2글자 단위로 토큰을 추출한다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static List\u0026lt;String\u0026gt; forDocument(String title, String content, {int maxTokens = 200}) { final combined = \u0026#39;$title $content\u0026#39;; final tokens = _ngrams(combined); if (tokens.length \u0026lt;= maxTokens) return tokens.toList(); return tokens.take(maxTokens).toList(); } static Set\u0026lt;String\u0026gt; _ngrams(String text) { final cleaned = _normalize(text); final out = \u0026lt;String\u0026gt;{}; for (int i = 0; i + 2 \u0026lt;= cleaned.length; i++) { out.add(cleaned.substring(i, i + 2)); } return out; } \u0026ldquo;오늘 급식 맛있었다\u0026rdquo; → [\u0026quot;오늘\u0026quot;, \u0026quot;늘급\u0026quot;, \u0026quot;급식\u0026quot;, \u0026quot;식맛\u0026quot;, \u0026quot;맛있\u0026quot;, \u0026quot;있었\u0026quot;, \u0026quot;었다\u0026quot;]\n정규화 과정에서 특수문자와 공백을 제거하고, 영어는 소문자로 통일한다. 한글, 영어, 숫자만 남긴다.\n검색 시에는 쿼리도 같은 방식으로 토큰화한 후 arrayContainsAny로 Firestore에 쿼리한다:\n1 2 3 4 5 6 static List\u0026lt;String\u0026gt; forQuery(String query, {int maxTokens = 10}) { final cleaned = _normalize(query); if (cleaned.length == 1) return [cleaned]; final tokens = _ngrams(query); return tokens.take(maxTokens).toList(); } 완벽한 검색은 아니다. \u0026ldquo;급\u0026quot;만 검색하면 2-gram이 안 만들어지므로 1글자 검색은 정확도가 떨어진다. 문서당 토큰은 200개로 제한하여 Firestore 문서 크기가 과도하게 커지는 걸 방지한다. 하지만 학교 게시판에서 \u0026ldquo;급식\u0026rdquo;, \u0026ldquo;시간표\u0026rdquo;, \u0026ldquo;동아리\u0026rdquo; 같은 2글자 이상 키워드 검색에는 잘 동작한다.\n카테고리 시스템 게시판은 6개 카테고리로 나뉜다:\n카테고리 FCM 토픽 색상 자유 free 기본 질문 question 보조 정보공유 info 3차 분실물 lost 주황 학생회 council 초록 동아리 club 보라 여기에 \u0026ldquo;전체\u0026quot;와 \u0026ldquo;인기글\u0026rdquo; 탭이 추가된다. \u0026ldquo;전체\u0026quot;는 모든 카테고리를 보여주고, \u0026ldquo;인기글\u0026quot;은 likeCount 기준으로 정렬한다.\nFCM 토픽은 카테고리별 알림 구독을 위해 영어 키로 매핑한다. 사용자가 \u0026ldquo;자유\u0026rdquo; 카테고리만 구독하면 해당 토픽의 알림만 받는다.\n추가 기능들 북마크: bookmarkedBy 배열에 uid를 넣어서 내가 북마크한 글을 모아볼 수 있다 고정글: isPinned과 pinnedAt으로 관리자가 글을 상단 고정 투표: pollOptions와 pollVoters로 글 안에서 투표 가능. pollVoters는 {uid: optionIndex} Map 이미지: imageUrls 배열로 다중 이미지 첨부. Firebase Storage에 업로드 후 URL 저장 해결됨: 질문 카테고리에서 isResolved로 해결된 질문 표시 돌아보면 익명 게시판은 \u0026ldquo;이름을 숨긴다\u0026quot;가 아니라 \u0026ldquo;이름을 숨기면서도 대화 맥락을 유지한다\u0026quot;가 핵심이었다. anonymousMapping 하나를 추가하는 것이 사소해 보이지만, 트랜잭션으로 동시성을 처리하고, 글 작성자를 별도로 표시하고, i18n을 적용하는 과정이 필요했다.\n좋아요도 검색도 마찬가지다. Firestore의 제약 안에서 \u0026ldquo;그럴듯하게 동작하는\u0026rdquo; 것을 만드는 게 NoSQL 설계의 핵심인 것 같다.\n","date":"2026-04-12T00:00:00Z","permalink":"/p/anonymous-board/","title":"#8 - 익명 게시판, 생각보다 복잡한 '익명'"},{"content":"학교 앱에 Firestore가 필요한 이유 급식과 시간표는 NEIS API로 충분하다. 하지만 학교 앱이 단순 정보 조회를 넘어서려면 — 게시판, 채팅, 사용자 인증 — 자체 데이터베이스가 필요하다.\nFirebase의 Firestore를 선택한 이유는 단순했다. 서버를 직접 운영할 필요가 없고, 실시간 동기화가 기본이고, Flutter와의 통합이 잘 되어 있다. 무엇보다 학생 혼자 운영하는 앱에서 서버 관리까지 할 여유는 없었다.\n현재 앱에는 8개의 Firestore 컬렉션이 있다. 각각의 구조와 설계 과정에서 배운 것들을 정리한다.\nusers: 사용자 프로필 1 2 3 4 5 6 7 8 9 10 11 12 13 14 users/{uid} ├── uid, name, email ├── studentId, grade, classNum ├── role: \u0026#34;user\u0026#34; | \u0026#34;manager\u0026#34; | \u0026#34;admin\u0026#34; ├── userType: \u0026#34;student\u0026#34; | \u0026#34;graduate\u0026#34; | \u0026#34;teacher\u0026#34; | \u0026#34;parent\u0026#34; ├── approved: true/false ├── blockedUsers: [uid, uid, ...] ├── fcmToken: \u0026#34;...\u0026#34; ├── profilePhotoUrl, graduationYear, teacherSubject ├── lastProfileUpdate, updatedAt │ ├── /sync/schedules → 개인 시간표 ├── /sync/ddays → D-day 목록 └── /notifications → 알림 내역 role 필드가 3단계(user, manager, admin)인 건 나중에 추가한 것이다. 처음에는 isAdmin: true/false로 시작했다가, 학생회 임원에게 일부 권한만 주고 싶어서 manager 역할을 중간에 넣었다.\napproved 필드는 가입 승인 시스템이다. 아무나 학교 앱에 글을 쓸 수 없도록, 가입 후 관리자가 승인해야 게시판 접근이 가능하다. 학교 앱이라는 특성상 필수적인 기능이었다.\nblockedUsers 배열은 사용자 차단 기능이다. 차단한 사용자의 게시글과 댓글이 보이지 않는다. 이걸 서버 쿼리로 처리하면 Firestore not-in 쿼리 제한(10개)에 걸리기 때문에, 클라이언트에서 필터링한다.\n서브컬렉션: sync users/{uid}/sync/schedules와 users/{uid}/sync/ddays는 개인 데이터의 기기 간 동기화를 위한 구조다. 로컬 SQLite에 저장하되, Firestore에도 백업하여 기기를 바꿔도 데이터가 유지된다.\n처음에는 Firestore만 사용했다. 하지만 시간표를 볼 때마다 네트워크 요청이 발생하는 게 급식 API와 같은 문제였다. 결국 로컬 SQLite + Firestore 동기화 구조로 바꿨다.\nposts: 게시판 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 posts/{postId} ├── title, content, authorUid, authorName ├── category: \u0026#34;자유\u0026#34; | \u0026#34;질문\u0026#34; | \u0026#34;정보공유\u0026#34; | \u0026#34;분실물\u0026#34; | \u0026#34;학생회\u0026#34; | \u0026#34;동아리\u0026#34; ├── isAnonymous, isPinned, isResolved ├── likes: {uid: true, uid: true, ...} ├── dislikes: {uid: true, uid: true, ...} ├── likeCount, dislikeCount ← 비정규화된 카운터 ├── commentCount ├── bookmarkedBy: [uid, uid, ...] ├── searchTokens: [\u0026#34;급식\u0026#34;, \u0026#34;식메\u0026#34;, \u0026#34;메뉴\u0026#34;, ...] ← n-gram ├── pollOptions, pollVoters ├── imageUrls: [...] ├── createdAt, pinnedAt │ └── /comments/{commentId} ├── content, authorUid, authorName ├── isAnonymous, likes, dislikes ├── imageUrl, mentions └── createdAt 이 컬렉션에서 가장 많이 고민한 것이 좋아요 구조다.\n좋아요: 배열 vs Map 처음에는 likes: [uid1, uid2, uid3] 배열이었다. 누가 좋아요를 눌렀는지 확인하려면 arrayContains로 쿼리하면 된다. 하지만 문제가 있었다:\n좋아요/취소가 동시에 발생하면 배열이 꼬일 수 있다 Firestore의 arrayUnion/arrayRemove가 있지만, 트랜잭션 없이는 race condition에 취약하다 Map 구조(likes: {uid: true})로 바꾸면서 해결했다. 특정 uid의 좋아요 여부를 확인하는 것도, 추가/삭제하는 것도 간단하다.\nlikeCount: 비정규화의 필요성 \u0026ldquo;인기글\u0026rdquo; 정렬이 필요했다. Firestore에서 Map의 크기로 정렬하는 건 불가능하다. likes Map의 키 개수를 실시간으로 세는 것도 비효율적이다.\n결국 likeCount, dislikeCount 필드를 별도로 두고, 좋아요를 누를 때마다 트랜잭션으로 함께 업데이트한다. NoSQL에서는 이런 비정규화가 일상이다. RDB의 COUNT(*) 대신 미리 계산해두는 것.\n검색: n-gram 토큰 Firestore는 전문 검색(full-text search)을 지원하지 않는다. \u0026ldquo;급식 메뉴\u0026quot;를 검색하려면 별도 검색 엔진(Algolia, Typesense 등)이 필요한데, 학생 프로젝트에서 외부 서비스를 붙이기는 부담스러웠다.\n대안으로 n-gram 토큰을 사용했다. 게시글을 저장할 때 제목과 내용에서 2글자 단위로 토큰을 추출하여 searchTokens 배열에 저장한다.\n1 \u0026#34;급식 메뉴 변경\u0026#34; → [\u0026#34;급식\u0026#34;, \u0026#34;식 \u0026#34;, \u0026#34; 메\u0026#34;, \u0026#34;메뉴\u0026#34;, \u0026#34;뉴 \u0026#34;, \u0026#34; 변\u0026#34;, \u0026#34;변경\u0026#34;] 검색 시 arrayContainsAny로 쿼리한다. 완벽한 전문 검색은 아니지만, 학교 게시판 규모에서는 충분히 동작한다.\n익명 게시판 isAnonymous 필드와 함께 anonymousMapping, anonymousCount가 있다. 같은 게시글에 같은 익명 사용자가 여러 댓글을 달면 \u0026ldquo;익명1\u0026rdquo;, \u0026ldquo;익명1\u0026quot;로 일관되게 표시해야 한다. anonymousMapping은 {uid: 1, uid: 2} 형태로 익명 번호를 추적한다.\nchats: 1:1 채팅 1 2 3 4 5 6 7 8 9 10 11 12 chats/{chatId} ← chatId = 정렬된 두 uid 조합 ├── participants: [uid, uid] ├── participantNames: {uid: \u0026#34;이름\u0026#34;, uid: \u0026#34;이름\u0026#34;} ├── lastMessage, lastMessageAt ├── unreadCount: {uid: 3, uid: 0} │ └── /messages/{messageId} ├── type: \u0026#34;text\u0026#34; | \u0026#34;system\u0026#34; ├── content, imageUrl ├── senderUid, senderName ├── deletedFor: [uid, ...] └── createdAt chatId를 두 사용자의 uid를 정렬하여 합친 값으로 쓴다. A와 B의 채팅방은 항상 같은 ID를 가지므로, 중복 채팅방이 생기지 않는다.\nunreadCount를 Map으로 관리하는 것은 각 사용자가 읽지 않은 메시지 수를 독립적으로 추적하기 위해서다. A가 메시지를 보내면 B의 카운트가 올라가고, B가 채팅방을 열면 B의 카운트가 0으로 초기화된다.\ndeletedFor 배열은 \u0026ldquo;나만 삭제\u0026rdquo; 기능이다. 메시지를 실제로 삭제하지 않고, 삭제를 요청한 사용자의 uid를 배열에 추가한다. 클라이언트에서 자신의 uid가 deletedFor에 있으면 해당 메시지를 표시하지 않는다.\n나머지 컬렉션들 reports — 신고 게시글 신고 시 postId, reporterUid, reason, detail을 저장한다. 관리자 화면에서 목록을 보고 조치한다.\nadmin_logs — 관리 기록 사용자 정지, 게시글 삭제 등 관리자 행동을 기록한다. action, targetUid, details, timestamp. 누가 무엇을 했는지 추적할 수 있어야 관리자가 여러 명이어도 문제를 파악할 수 있다.\napp_feedbacks / council_feedbacks — 피드백 사용자가 앱이나 학생회에 보내는 피드백. content, imageUrls, status(pending/addressed). 이미지 첨부가 가능하고, 처리 상태를 관리자가 업데이트한다.\ncrash_logs — 오류 기록 1 2 3 4 5 6 7 8 9 // main.dart FlutterError.onError = (details) { FirebaseFirestore.instance.collection(\u0026#39;crash_logs\u0026#39;).add({ \u0026#39;error\u0026#39;: details.exceptionAsString().substring(0, 500), \u0026#39;stack\u0026#39;: details.stack.toString().substring(0, 1000), \u0026#39;uid\u0026#39;: currentUser?.uid, \u0026#39;createdAt\u0026#39;: FieldValue.serverTimestamp(), }); }; Crashlytics 대신 간단하게 만든 오류 수집기. error와 stack을 각각 500자, 1000자로 잘라서 저장한다. 문서 크기 폭발을 방지하기 위한 장치다.\napp_config — 앱 설정 app_config/popup 문서 하나로 팝업 공지를 관리한다. 앱을 열 때 이 문서를 확인하고, 활성화된 팝업이 있으면 표시한다. 관리자 화면에서 실시간으로 팝업을 켜고 끌 수 있다.\n설계하면서 배운 것 1. Firestore는 쿼리부터 설계한다 RDB에서는 데이터를 정규화하고, 필요할 때 JOIN한다. Firestore에서는 어떤 쿼리를 할 것인지 먼저 정하고, 그 쿼리에 맞게 데이터를 배치한다. likeCount 같은 비정규화가 그 예다.\n2. 배열의 한계를 알아야 한다 Firestore에서 배열은 편리하지만 제약이 많다. arrayContainsAny는 최대 30개 값만 비교할 수 있고, not-in은 10개까지다. blockedUsers를 서버 쿼리로 필터링하지 못하고 클라이언트에서 처리하는 것도 이 제약 때문이다.\n3. 문서 크기를 의식해야 한다 Firestore 문서 최대 크기는 1MB다. likes Map에 사용자가 수천 명 좋아요를 누르면 문서가 커진다. 학교 앱 규모에서는 문제가 안 되지만, 설계 단계에서 \u0026ldquo;이 필드가 무한히 커질 수 있는가\u0026quot;를 항상 생각해야 한다.\ncrash_logs에서 error와 stack을 잘라서 저장하는 것도 같은 이유다. 스택 트레이스 전체를 저장하면 문서 하나가 수십 KB가 될 수 있다.\n4. 보안 규칙은 스키마의 일부다 Firestore Security Rules로 \u0026ldquo;자기 게시글만 수정 가능\u0026rdquo;, \u0026ldquo;승인된 사용자만 글 작성 가능\u0026rdquo;, \u0026ldquo;관리자만 사용자 정지 가능\u0026rdquo; 같은 규칙을 강제한다. 스키마를 설계할 때 보안 규칙에서 검증 가능한 구조인지도 함께 고려해야 한다.\n돌아보면 8개 컬렉션은 한 번에 설계한 게 아니다. users와 posts로 시작해서, 채팅이 필요해지면 chats를, 신고가 필요해지면 reports를 추가했다. 기능이 늘어날 때마다 컬렉션이 하나씩 생겼다.\n처음부터 다시 설계한다면 크게 바꿀 것은 없다. 다만 searchTokens의 n-gram 방식은 게시글이 많아지면 한계가 있으니, Algolia 같은 외부 검색 서비스를 처음부터 고려했을 것이다. 그리고 crash_logs는 Crashlytics로 대체하는 게 더 나았을 것이다.\n하지만 학생이 혼자 만드는 앱에서 \u0026ldquo;완벽한 설계\u0026quot;를 추구하면 아무것도 못 만든다. 일단 동작하게 만들고, 문제가 생기면 고치는 것. NEIS API가 80줄에서 320줄로 진화한 것처럼, Firestore 스키마도 사용하면서 계속 진화하고 있다.\n아직 배포를 하진 않았지만, 배포 후 사용자가 많아져서 Firebase에 요금이 청구되기 시작하면 Firestore를 걷어내고 직접 백엔드를 구축할 생각도 있다. 내 첫 메인 프로젝트지만 돈이 아깝다.\n","date":"2026-04-11T00:00:00Z","permalink":"/p/firestore-schema/","title":"#7 - Firestore 스키마, 처음부터 다시 설계한다면"},{"content":"Java 시절: 80줄짜리 API Java 프로토타입의 getMealData.java는 80줄이었다. 하는 일은 단순했다:\nURL 조립 HTTP 요청 JSON 파싱 문자열 반환 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public static CompletableFuture\u0026lt;String\u0026gt; getMeal(String date, String mealScCode, String type) { String requestURL = \u0026#34;https://open.neis.go.kr/hub/mealServiceDietInfo?\u0026#34; + \u0026#34;\u0026amp;Type=json\u0026#34; + \u0026#34;\u0026amp;MMEAL_SC_CODE=\u0026#34; + mealScCode + \u0026#34;\u0026amp;ATPT_OFCDC_SC_CODE=\u0026#34; + niesAPI.ATPT_OFCDC_SC_CODE + \u0026#34;\u0026amp;SD_SCHUL_CODE=\u0026#34; + niesAPI.SD_SCHUL_CODE + \u0026#34;\u0026amp;MLSV_YMD=\u0026#34; + date; return CompletableFuture.supplyAsync(() -\u0026gt; { String 메뉴 = itemObject.getString(\u0026#34;DDISH_NM\u0026#34;); String 칼로리 = itemObject.getString(\u0026#34;CAL_INFO\u0026#34;); switch (type) { case \u0026#34;메뉴\u0026#34; -\u0026gt; result = 메뉴; case \u0026#34;칼로리\u0026#34; -\u0026gt; result = 칼로리; } return result.replace(\u0026#34;\u0026lt;br/\u0026gt;\u0026#34;, \u0026#34;\\n\u0026#34;); }); } 호출할 때마다 네트워크 요청을 보냈다. 캐싱? 없다. 오프라인 대응? 없다. 에러 핸들링? try-catch로 빈 문자열 반환이 전부. 그래도 동작했다. 학교 와이파이가 있으니까.\nFlutter 초기: Java를 그대로 옮기다 Flutter 첫 커밋(2023-12-07)에서 GetMealData.dart를 만들었을 때도 구조는 같았다. Java의 CompletableFuture가 Dart의 Future로 바뀌었을 뿐, URL을 조립하고, HTTP 요청을 보내고, JSON을 파싱해서 문자열을 돌려주는 것은 동일했다.\n하지만 Flutter 버전이 커지기 시작한 건 테스터가 늘면서다.\n문제 1: 매번 네트워크 요청 급식 화면을 열 때마다 NEIS API를 호출했다. 조식, 중식, 석식 — 화면 하나를 열면 API 호출 3번. 날짜를 넘기면 3번 더. 체감상 느렸고, NEIS API가 간헐적으로 느려지는 날에는 화면이 몇 초간 빈 채로 있었다.\n해결: SharedPreferences 캐시 첫 번째 개선은 SharedPreferences에 API 응답을 캐싱하는 것이었다.\n1 2 3 4 5 6 7 8 static String _cacheKey(DateTime date, int mealType) { return \u0026#39;meal_${DateFormat(\u0026#39;yyyyMMdd\u0026#39;).format(date)}_$mealType\u0026#39;; } static void _saveToCache(SharedPreferences prefs, String key, Meal meal) { prefs.setString(key, jsonEncode(meal.toJson())); prefs.setInt(\u0026#39;$key-ts\u0026#39;, DateTime.now().millisecondsSinceEpoch); } 캐시 키는 meal_20240415_2 형태 — 날짜와 끼니(1=조식, 2=중식, 3=석식)의 조합이다. 한 번 불러온 급식 데이터는 로컬에 저장되어 다음에 같은 날짜를 볼 때 네트워크 요청 없이 바로 표시된다.\n이것만으로도 체감 속도가 크게 좋아졌다. 하지만 문제가 하나 더 있었다.\n문제 2: 날짜를 넘길 때마다 로딩 급식 화면에서 스와이프로 날짜를 넘기면, 그날 데이터가 캐시에 없으니 다시 API를 호출한다. 월요일부터 금요일까지 쭉 넘기면 호출이 15번(5일 × 3끼). 사용자 입장에서는 날짜를 넘길 때마다 잠깐 로딩이 보인다.\n해결: 월 단위 프리페치 NEIS API는 MLSV_FROM_YMD와 MLSV_TO_YMD 파라미터로 기간 조회를 지원한다. 한 번의 요청으로 한 달 치 급식 데이터를 전부 가져올 수 있다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static Future\u0026lt;void\u0026gt; _prefetchMonth(DateTime date) async { final monthKey = DateFormat(\u0026#39;yyyyMM\u0026#39;).format(date); // 같은 달을 중복 요청하지 않도록 guard if (_prefetchingMonths.containsKey(monthKey)) { await _prefetchingMonths[monthKey]; return; } final firstDay = DateTime(date.year, date.month, 1); final lastDay = DateTime(date.year, date.month + 1, 0); final requestURL = \u0026#39;https://open.neis.go.kr/hub/mealServiceDietInfo?\u0026#39; \u0026#39;\u0026amp;Type=json\u0026amp;pIndex=1\u0026amp;pSize=100\u0026#39; \u0026#39;\u0026amp;MLSV_FROM_YMD=$fromDate\u0026#39; \u0026#39;\u0026amp;MLSV_TO_YMD=$toDate\u0026#39;; // 응답의 모든 급식 데이터를 각각 캐시에 저장 for (var row in rows) { final key = _cacheKey(mealDate, mealCode); _saveToCache(prefs, key, meal); } } 한 번의 API 호출로 해당 월의 모든 급식(보통 60~90개 항목)을 가져와서 각각 캐시에 저장한다. 이후 같은 달의 어떤 날짜를 보더라도 캐시에서 즉시 표시된다.\n_prefetchingMonths Map으로 같은 달의 중복 요청을 방지한다. 급식 화면을 열면서 프리페치를 시작하고, 그 사이에 사용자가 날짜를 넘겨도 같은 달이면 이미 진행 중인 프리페치를 기다린다.\n주간 프리페치도 있다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 static Future\u0026lt;void\u0026gt; prefetchWeek(DateTime baseDate) async { final monday = baseDate.subtract(Duration(days: baseDate.weekday - 1)); final friday = monday.add(const Duration(days: 4)); if (monday.month == friday.month) { await _prefetchMonth(monday); } else { // 월이 걸치면 두 달 모두 프리페치 await Future.wait([ _prefetchMonth(monday), _prefetchMonth(friday), ]); } } 월~금이 월경계에 걸릴 수 있다. 예를 들어 3월 31일(월)~4월 4일(금)이면 3월과 4월 데이터를 모두 프리페치한다.\n문제 3: 캐시가 오래되면? 급식 데이터는 학교 사정으로 바뀔 수 있다. 어제 캐시한 데이터가 오늘도 맞다는 보장이 없다. 그렇다고 캐시를 매번 무시하면 캐싱의 의미가 없다.\n해결: SWR 패턴 SWR(Stale-While-Revalidate)은 웹 개발에서 온 패턴이다. 오래된 캐시를 일단 보여주고, 백그라운드에서 새 데이터를 가져온다. 사용자는 즉시 데이터를 보고, 데이터가 바뀌었으면 자동으로 갱신된다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static Future\u0026lt;Meal?\u0026gt; getMeal({...}) async { final cached = _getFromCache(prefs, cacheKey); if (cached != null \u0026amp;\u0026amp; cached.meal != null) { if (_isCacheStale(prefs, cacheKey)) { // SWR: 만료된 캐시를 즉시 반환하고 백그라운드에서 갱신 _prefetchMonth(date); // await 하지 않음 } return cached; } // 캐시 없으면 네트워크 요청 await _prefetchMonth(date); return _getFromCache(prefs, cacheKey); } 핵심은 _prefetchMonth(date)를 await 하지 않는 것이다. 캐시가 stale이면 일단 오래된 데이터를 반환하고, 프리페치는 백그라운드에서 돌린다. 다음에 화면을 열면 갱신된 데이터가 표시된다.\n캐시 만료 정책도 계층적이다:\n1 2 3 4 5 6 7 8 9 10 static Meal? _getFromCache(SharedPreferences prefs, String key) { final age = DateTime.now().millisecondsSinceEpoch - ts; if (meal.meal == ApiStrings.mealNoData) { if (age \u0026gt; 5 * 60 * 1000) return null; // \u0026#34;데이터 없음\u0026#34;은 5분만 캐시 } else if (age \u0026gt; 24 * 60 * 60 * 1000) { if (age \u0026gt; 3 * 24 * 60 * 60 * 1000) return null; // 3일 지나면 완전 만료 } return meal; } \u0026ldquo;데이터 없음\u0026rdquo; 응답: 5분만 캐시한다. 학교에서 아직 급식을 등록 안 했을 수 있으니 곧 다시 시도 정상 데이터: 24시간까지 fresh, 24시간~3일은 stale(SWR 대상), 3일 이후는 완전 삭제 오프라인 대응 1 2 3 4 if (await NetworkStatus.isUnconnected()) { if (cached != null) return cached; return Meal(meal: ApiStrings.mealNoInternet, ...); } 네트워크가 없으면 캐시가 아무리 오래되었어도 반환한다. 오래된 데이터라도 \u0026ldquo;인터넷 연결 없음\u0026quot;보다는 낫다. 캐시도 없으면 그때 안내 메시지를 보여준다.\nMeal 모델의 등장 Java에서는 급식 데이터가 String이었다. \u0026ldquo;메뉴\u0026rdquo;, \u0026ldquo;칼로리\u0026rdquo;, \u0026ldquo;영양정보\u0026quot;를 별도 호출로 가져왔다.\nFlutter에서는 Meal 모델 하나에 전부 담는다:\n1 2 3 4 5 6 7 class Meal { final String? meal; // 메뉴 final String kcal; // 칼로리 final String ntrInfo; // 영양정보 final DateTime date; // 날짜 final int mealType; // 1=조식, 2=중식, 3=석식 } toJson()/fromJson()이 있어서 캐시 직렬화도 한 줄이다. Java에서 getMeal(date, \u0026quot;1\u0026quot;, \u0026quot;메뉴\u0026quot;), getMeal(date, \u0026quot;1\u0026quot;, \u0026quot;칼로리\u0026quot;)로 따로 호출하던 걸, getMeal(date: date, mealType: 1)로 한 번에 전부 가져온다.\n80줄 → 320줄, 뭐가 늘었나 구분 Java (80줄) Flutter (320줄) URL 조립 O O HTTP 요청 O O JSON 파싱 O O 데이터 모델 X (String) O (Meal 클래스) 캐시 X SharedPreferences 월 단위 프리페치 X O 중복 요청 방지 X Completer SWR 갱신 X O 캐시 만료 정책 X 3단계 (5분/24시간/3일) 오프라인 대응 X O 테스트 지원 X @visibleForTesting 코드가 4배 늘었지만, 네트워크 요청은 수십 분의 1로 줄었다. 사용자가 체감하는 로딩 시간은 거의 0이 되었다. 80줄에서 320줄로 가는 과정이 곧 \u0026ldquo;동작하는 코드\u0026quot;에서 \u0026ldquo;쓸 만한 앱\u0026quot;으로 가는 과정이었다.\n다음 글에서는 앱의 커뮤니티 기능을 뒷받침하는 Firestore 스키마 설계 — 게시판, 채팅, 사용자 관리까지의 구조와 초기 실수들을 다룬다.\n","date":"2026-04-10T00:00:00Z","permalink":"/p/meal-api/","title":"#6 - 급식 API, 80줄에서 320줄까지"},{"content":"12월 7일, 같은 날의 두 커밋 2023년 12월 7일. Java 레포에 v0.12.3 Beta 마지막 커밋을 남기고, 같은 날 Flutter 레포에 first commit을 찍었다. 하나를 끝내고 바로 다음을 시작한 것이다.\n첫 커밋은 flutter create 그 자체였다. 137개 파일, 5,126줄. Flutter가 자동 생성하는 프로젝트 템플릿이다. android, ios, web, linux, macos, windows — 모든 플랫폼의 보일러플레이트가 포함되어 있었다.\n하지만 이 커밋에 이미 Java에서 가져온 파일 3개가 들어 있었다:\n1 2 3 lib/GetMealData.dart lib/GetNoticeData.dart lib/GetTimeTableData.dart Java의 getMealData.java, getNoticeData.java, getTimetableData.java를 Dart로 포팅한 것이다. 프로젝트를 만들자마자 가장 먼저 한 일이 NEIS API 연동 코드를 옮기는 것이었다.\n첫째 주: 뼈대 잡기 (12/7 ~ 12/13) 화면 구조 둘째 커밋(12/8)에서 화면 4개의 껍데기를 만들었다:\n1 2 3 4 lib/Screens/homeScreen.dart +28줄 lib/Screens/mainScreen.dart +11줄 lib/Screens/mealScreen.dart +29줄 lib/Screens/noticeScreen.dart +29줄 Java 프로토타입과 동일한 구조다. 홈, 급식, 공지. BottomNavigation으로 전환하는 방식도 같았다. 이미 Java에서 검증한 화면 흐름을 그대로 가져왔다.\n파일 구조 리팩토링 셋째 커밋(12/10)에서 바로 파일 구조를 정리했다:\n1 2 3 GetMealData.dart → Data/mealDataApi.dart GetNoticeData.dart → Data/noticeDataApi.dart GetTimeTableData.dart → Data/tiemtableDataApi.dart 루트에 흩어져 있던 API 파일들을 Data/ 폴더로 모았다. 파일명도 PascalCase에서 camelCase로 바꿨다. Java 습관을 Dart 컨벤션으로 전환하는 과정이었다. (참고로 tiemtable은 오타다. 나중에 timetable로 고쳤다.)\n알림 첫 시도 (12/12 ~ 12/13) Flutter 시작 5일 만에 알림 기능에 손을 댔다. #3에서 자세히 다뤘지만, NotificationManager.dart 165줄을 만들고, FirebaseCloudMessaging.dart를 넣었다 4시간 만에 삭제하는 사건이 이때 일어났다.\n돌이켜보면 너무 일찍 손을 댄 것이었다. 화면 구조도 다 안 잡힌 상태에서 알림까지 만들려고 했으니.\n둘째 주: 기능 구현 (12/15 ~ 12/20) 12월 17일에 커밋이 9개다. 하루에 9번. 이 시기가 가장 집중적으로 개발한 때였다.\n이 주에 만든 것들:\n급식 화면 — NEIS API 연동, 조식/중식/석식 표시, 날짜 이동 시간표 화면 — 학년/반 선택, 요일별 시간표 표시 설정 화면 — 학년/반 저장, 알림 on/off 로그인/회원가입 — Firebase Auth 연동 달력 — 학사일정 표시 12월 20일에는 커밋이 7개. 이 이틀(17일, 20일)에 전체 첫 달 커밋의 거의 절반이 몰려 있다. 기숙사에서 자습 시간과 주말을 전부 개발에 쏟은 날들이다.\n셋째 주: API 안정화 (12/20 ~ 12/27) 12월 25일, 크리스마스에도 코딩했다. 이날 커밋은 API 3개를 전부 리팩토링한 것이다:\n1 2 3 lib/API/MealDataApi.dart +72줄, -65줄 lib/API/NoticeDataApi.dart +54줄, -38줄 lib/API/TimetableDataApi.dart +38줄, -27줄 Java에서 포팅한 초기 코드가 Dart답지 않았다. HttpURLConnection 스타일로 작성했던 걸 http 패키지의 get() 방식으로 바꾸고, 에러 핸들링을 추가하고, JSON 파싱을 정리했다.\n12월 27일에는 파일명을 Dart 컨벤션(snake_case)으로 전환했다:\n1 2 3 MealDataApi.dart → meal_data_api.dart NoticeDataApi.dart → notice_data_api.dart TimetableDataApi.dart → timetable_data_api.dart Java의 PascalCase → Dart의 snake_case. 이런 사소한 컨벤션 전환이 프로젝트 초기에 계속 있었다.\n넷째 주: Meal 모델 (1/7) 1월 7일 커밋에서 Meal 데이터 모델이 처음 등장한다:\n1 lib/Data/meal.dart +13줄 그 전까지는 급식 데이터를 String으로만 다뤘다. Java 시절과 똑같이 API 응답을 문자열로 받아서 화면에 바로 뿌렸다. 하지만 급식 정보에는 메뉴, 칼로리, 영양정보, 날짜, 끼니 구분 등 여러 필드가 있고, 이걸 하나의 모델 클래스로 묶어야 코드가 정리된다.\nMeal 모델을 만들면서 동시에 meal_card.dart가 95줄이나 추가되었다. 급식 카드 위젯이 별도 파일로 분리된 것이다. Java에서는 MealFragment 하나에 271줄이 전부 들어 있었는데, Flutter에서는 위젯 단위로 분리하기 시작했다.\n첫 달의 숫자 항목 수치 기간 2023-12-07 ~ 2024-01-07 커밋 수 36 가장 많은 날 12/17 (9커밋), 12/20 (7커밋) 커밋 0개인 날 12/9, 12/11, 12/14, 12/16, 12/21~24, 12/26, 12/28~1/6 커밋이 없는 날이 꽤 많다. 매일 코딩한 게 아니라, 할 수 있는 날에 몰아서 했다. 기숙사 생활이라 평일 저녁 자습 시간과 주말이 개발 시간이었고, 시험이나 학교 일정이 있으면 며칠씩 손을 못 대기도 했다. (근데 사실 공부는 안했다.)\n돌아보면 첫 한 달의 핵심은 Java에서 검증한 구조를 Dart로 옮기는 것이었다. 화면 구조, API 파싱, 데이터 흐름 — 전부 Java에서 한 번 해봤던 것들이다. 덕분에 \u0026ldquo;무엇을 만들어야 하는지\u0026quot;는 고민하지 않았고, \u0026ldquo;Flutter에서는 이걸 어떻게 만드는지\u0026quot;에만 집중할 수 있었다.\n동시에 Java 습관을 하나씩 버리는 과정이기도 했다. 파일 이름, 폴더 구조, 코딩 컨벤션을 Dart 방식으로 바꿔가면서, 코드가 점점 \u0026ldquo;Flutter다워\u0026quot;졌다. 이 전환은 첫 달에 끝나지 않고 이후 몇 달간 계속되었다.\n다음 글에서는 첫 달에 포팅한 NEIS API 급식 파싱이 이후 어떻게 진화했는지 — 캐싱, 월 단위 프리페치, SWR 패턴까지의 과정을 다룬다.\n","date":"2026-04-09T00:00:00Z","permalink":"/p/first-month/","title":"#5 - Flutter 첫 한 달"},{"content":"159커밋의 유산 #2에서 Java 프로토타입 159커밋을 버리고 Flutter로 전환한 이야기를 했다. 코드는 버렸지만, 모든 걸 버린 건 아니었다. Java에서 삽질하며 만든 설계와 경험은 그대로 가져갔고, 동시에 초보 시절의 나쁜 습관은 버렸다.\nJava 프로토타입의 실제 코드를 보면서, 뭘 가져가고 뭘 버렸는지 정리해본다.\n가져간 것: NEIS API 파싱 구조 Java — getMealData.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public static CompletableFuture\u0026lt;String\u0026gt; getMeal(String date, String mealScCode, String type) { String requestURL = \u0026#34;https://open.neis.go.kr/hub/mealServiceDietInfo?\u0026#34; + \u0026#34;\u0026amp;Type=json\u0026#34; + \u0026#34;\u0026amp;MMEAL_SC_CODE=\u0026#34; + mealScCode + \u0026#34;\u0026amp;ATPT_OFCDC_SC_CODE=\u0026#34; + niesAPI.ATPT_OFCDC_SC_CODE + \u0026#34;\u0026amp;SD_SCHUL_CODE=\u0026#34; + niesAPI.SD_SCHUL_CODE + \u0026#34;\u0026amp;MLSV_YMD=\u0026#34; + date; return CompletableFuture.supplyAsync(() -\u0026gt; { // HTTP 연결, JSON 파싱... String 메뉴 = itemObject.getString(\u0026#34;DDISH_NM\u0026#34;); String 칼로리 = itemObject.getString(\u0026#34;CAL_INFO\u0026#34;); String 영양정보 = itemObject.getString(\u0026#34;NTR_INFO\u0026#34;); switch (type) { case \u0026#34;메뉴\u0026#34; -\u0026gt; result = 메뉴; case \u0026#34;칼로리\u0026#34; -\u0026gt; result = 칼로리; case \u0026#34;영양정보\u0026#34; -\u0026gt; result = 영양정보; } return result.replace(\u0026#34;\u0026lt;br/\u0026gt;\u0026#34;, \u0026#34;\\n\u0026#34;); }); } Flutter — MealDataApi 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static Future\u0026lt;Meal\u0026gt; _fetchSingleMeal(...) async { final requestURL = \u0026#39;https://open.neis.go.kr/hub/mealServiceDietInfo?\u0026#39; \u0026#39;key=${niesApiKeys.NIES_API_KEY}\u0026#39; \u0026#39;\u0026amp;Type=json\u0026amp;MMEAL_SC_CODE=$mealType\u0026#39; \u0026#39;\u0026amp;ATPT_OFCDC_SC_CODE=${niesApiKeys.ATPT_OFCDC_SC_CODE}\u0026#39; \u0026#39;\u0026amp;SD_SCHUL_CODE=${niesApiKeys.SD_SCHUL_CODE}\u0026#39; \u0026#39;\u0026amp;MLSV_YMD=$formattedDate\u0026#39;; // ... final meal = Meal( meal: (row[\u0026#39;DDISH_NM\u0026#39;] as String).replaceAll(\u0026#39;\u0026lt;br/\u0026gt;\u0026#39;, \u0026#39;\\n\u0026#39;), kcal: row[\u0026#39;CAL_INFO\u0026#39;] as String, ntrInfo: (row[\u0026#39;NTR_INFO\u0026#39;] as String?)?.replaceAll(\u0026#39;\u0026lt;br/\u0026gt;\u0026#39;, \u0026#39;\\n\u0026#39;) ?? \u0026#39;\u0026#39;, ); } URL 구조가 거의 동일하다. ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE, MMEAL_SC_CODE — NEIS API의 파라미터 이름은 바뀌지 않으니까. Java에서 이미 API 문서를 파고들어서 필요한 파라미터를 정리해뒀기 때문에, Flutter에서는 URL을 그대로 가져다 쓸 수 있었다.\n\u0026lt;br/\u0026gt; → \\n 변환도 그대로다. NEIS API는 메뉴 항목을 \u0026lt;br/\u0026gt; 태그로 구분해서 보내주는데, 이걸 줄바꿈으로 바꿔야 화면에 제대로 표시된다. Java에서 이미 알아낸 사실이라 Dart에서는 고민 없이 처리했다.\nJSON 응답 구조도 동일하다. mealServiceDietInfo → row 배열 → 각 항목에서 DDISH_NM, CAL_INFO, NTR_INFO 추출. 이 구조를 파악하는 데 Java 시절에 꽤 시간을 썼는데, 한 번 알면 두 번 다시 삽질할 필요가 없다.\n가져간 것: 알러지 괄호 제거 Java — HomeFragment.java 1 2 3 4 private String deleteBracket(String msg) { msg = msg.replaceAll(\u0026#34;[().1234567890]\u0026#34;, \u0026#34;\u0026#34;); return msg; } Flutter — DailyMealNotification 1 2 3 4 5 6 7 8 String _cleanMenu(String? menu) { if (menu == null || menu.isEmpty) return \u0026#39;\u0026#39;; return menu .split(\u0026#39;\\n\u0026#39;) .map((e) =\u0026gt; e.replaceAll(RegExp(r\u0026#39;\\([0-9.,\\s]+\\)\u0026#39;), \u0026#39;\u0026#39;).trim()) .where((e) =\u0026gt; e.isNotEmpty) .join(\u0026#39; · \u0026#39;); } 같은 목적, 다른 구현. NEIS API의 급식 메뉴에는 비빔밥(5.6.13) 형태로 알러지 정보가 붙어 있다. 사용자에게 보여줄 때는 이 괄호를 제거해야 한다.\nJava 버전은 단순했다. 괄호, 점, 숫자를 전부 지워버리는 방식. 하지만 이러면 메뉴 이름에 포함된 숫자까지 날아갈 수 있다. Flutter 버전에서는 정규식을 \\([0-9.,\\s]+\\) — 괄호 안에 숫자/점/쉼표/공백만 있는 패턴으로 좁혀서, 알러지 정보만 정확히 제거하도록 개선했다.\nJava에서 \u0026ldquo;알러지 괄호를 제거해야 한다\u0026quot;는 문제 자체를 발견한 것이 가장 큰 유산이었다. 해결 방법은 더 나은 걸로 바꿨지만, 문제를 아는 것과 모르는 것의 차이는 크다.\n가져간 것: 급식 알림의 기본 구조 Java — FirebaseMessaging.java 1 2 3 4 5 6 7 8 9 10 11 12 13 public void setAlarms(@NonNull Context context) { AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Calendar currentCalendar = Calendar.getInstance(); int dayOfWeek = currentCalendar.get(Calendar.DAY_OF_WEEK); if (dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY) return; PendingIntent pendingIntent1 = createPendingIntent(context, 1, \u0026#34;조식\u0026#34;, 6, 30); PendingIntent pendingIntent2 = createPendingIntent(context, 2, \u0026#34;중식\u0026#34;, 12, 0); PendingIntent pendingIntent3 = createPendingIntent(context, 3, \u0026#34;석식\u0026#34;, 17, 0); alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar1.getTimeInMillis(), pendingIntent1); } Java — AlarmReceiver.java 1 2 3 4 5 6 public void onReceive(Context context, Intent intent) { String 분류 = intent.getStringExtra(\u0026#34;분류\u0026#34;); final String[] 메뉴 = new String[1]; 메뉴[0] = getMenu(분류, spDate).join(); NotificationUtil.sendMealNotification(context, 분류 + \u0026#34; 정보\u0026#34;, 메뉴[0]); } 여기가 아이러니한 부분이다. #3에서 Flutter로 급식 알림을 만드는 데 1년이 걸렸다고 했는데, Java 프로토타입에는 이미 동작하는 급식 알림이 있었다.\nAlarmManager + setExactAndAllowWhileIdle + BroadcastReceiver — Android 네이티브 API를 직접 쓰는 방식이었다. 알림이 울리면 AlarmReceiver가 NEIS API를 호출해서 실제 메뉴를 가져오고, NotificationUtil이 BigTextStyle로 보여준다. 주말은 건너뛴다.\nFlutter에서 이 기능을 다시 만들 때, 처음에는 flutter_local_notifications로 시작했다가 안정성 문제로 Kotlin 네이티브(MealNotificationReceiver.kt + MealWorker.kt)로 갔다가, 결국 다시 Flutter로 돌아왔다. 1년간의 삽질 끝에 도달한 exactAllowWhileIdle — 이건 Java의 setExactAndAllowWhileIdle과 같은 Android API를 Flutter 래퍼로 호출하는 것이다.\n돌고 돌아 원점이었다. 다만 Java 시절에는 \u0026ldquo;이게 왜 동작하는지\u0026rdquo; 이해하지 못한 채 코드를 썼고, Flutter에서 삽질한 후에야 AlarmManager의 exact alarm이 Doze 모드에서도 동작하는 이유를 이해하게 되었다.\n버린 것: 한글 변수명 Java 프로토타입에서 가장 눈에 띄는 특징은 한글 변수명이다.\n1 2 3 4 5 6 7 String 메뉴 = itemObject.getString(\u0026#34;DDISH_NM\u0026#34;); String 칼로리 = itemObject.getString(\u0026#34;CAL_INFO\u0026#34;); String 영양정보 = itemObject.getString(\u0026#34;NTR_INFO\u0026#34;); // AlarmReceiver.java String 분류 = intent.getStringExtra(\u0026#34;분류\u0026#34;); final String[] 메뉴 = new String[1]; Java는 유니코드 식별자를 허용하기 때문에 기술적으로 문제는 없다. 그리고 솔직히 코드를 읽을 때 String meal보다 String 메뉴가 직관적이긴 하다.\n하지만 Flutter로 전환하면서 전부 영어로 바꿨다. 이유:\n라이브러리/프레임워크와의 일관성 — Flutter의 모든 API가 영어다. 내 코드만 한글이면 섞여서 읽기 어렵다 자동완성 — IDE에서 me까지 치면 meal, mealType 같은 후보가 뜨는데, 한글이면 ㅁ을 치고 한영 전환을 해야 한다 협업 가능성 — 혼자 만드는 앱이지만, 코드를 GitHub에 올리는 이상 영어가 맞다 버린 것: 커밋 메시지 \u0026ldquo;Update\u0026rdquo; Java 레포의 159커밋 중 대부분의 메시지가 이렇다:\n1 2 3 4 5 6 Update Update Update Update Merge remote-tracking branch \u0026#39;origin/main\u0026#39; Update 9월 12일 하루에 커밋이 20개가 넘는데, 전부 \u0026ldquo;Update\u0026rdquo;. 뭘 바꿨는지 메시지만 봐서는 전혀 알 수 없다. Git을 처음 쓰면서 \u0026ldquo;저장\u0026rdquo; 버튼처럼 사용했던 것 같다.\nFlutter 레포로 넘어오면서 커밋 메시지에 변경 내용을 적기 시작했다. 처음에는 \u0026ldquo;Migration\u0026quot;이 많았지만, 점차 구체적으로 바뀌어 갔다.\n버린 것: static 남용 1 2 3 4 5 6 7 8 // getMealData.java static String result = null; public static CompletableFuture\u0026lt;String\u0026gt; getMeal(...) { // result에 직접 대입 result = 메뉴; return result.replace(\u0026#34;\u0026lt;br/\u0026gt;\u0026#34;, \u0026#34;\\n\u0026#34;); } static 필드에 결과를 직접 대입하는 방식. 여러 곳에서 동시에 getMeal을 호출하면 result가 덮어씌워질 수 있다. 실제로 HomeFragment에서 급식과 시간표를 동시에 비동기 호출하고 있었는데, 운 좋게 문제가 안 터졌을 뿐이다.\nFlutter 버전에서는 각 함수가 독립적인 반환값을 가지고, 상태를 공유하지 않는다.\n버린 것: 배터리 최적화 해제 강제 요청 1 2 3 4 5 6 7 8 9 10 // HomeFragment.java private void checkBatteryOptimization(Context context) { PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); if (!pm.isIgnoringBatteryOptimizations(packageName)) { Intent intent = new Intent(); intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); intent.setData(Uri.parse(\u0026#34;package:\u0026#34; + packageName)); context.startActivity(intent); } } 앱을 열 때마다 배터리 최적화 해제를 요청하는 코드. 알림이 안정적으로 오게 하려는 의도였지만, 사용자 경험이 최악이다. 앱을 열 때마다 시스템 팝업이 뜬다. Google Play 정책에서도 이런 방식은 권장하지 않는다.\nFlutter 버전에서는 이런 강제 요청 대신, exactAllowWhileIdle 모드로 시스템의 정상적인 알림 경로를 사용한다.\n돌아보면 Java 프로토타입은 \u0026ldquo;이것도 되나? 저것도 되나?\u0026rdquo; 하면서 마구 시도한 코드였다. 정리되지 않았고, 위험한 패턴도 있었다. 하지만 그 덕분에:\nNEIS API의 구조를 완전히 파악했다 급식, 시간표, 알림의 핵심 로직을 한 번 구현해봤다 뭘 하면 안 되는지(한글 변수명, static 남용, 강제 권한 요청)를 경험으로 배웠다 코드는 버렸지만 경험은 전부 가져갔다. Flutter 프로젝트의 첫 커밋이 Java 마지막 커밋과 같은 날(2023년 12월 7일)인 건, 하나를 끝내고 바로 다음을 시작할 수 있을 만큼 준비가 되어 있었다는 뜻이다.\n다음 글에서는 Flutter 첫 커밋부터 한 달간 무엇을 만들었는지, 초기 개발의 속도와 순서를 다룬다.\n","date":"2026-04-08T00:00:00Z","permalink":"/p/java-to-dart/","title":"#4 - Java 코드에서 가져온 것, 버린 것"},{"content":"단순해 보였던 기능 \u0026ldquo;매일 아침에 오늘 급식 메뉴를 알림으로 보내준다.\u0026rdquo;\n한 줄로 설명 가능한 기능이다. 사용자 입장에서는 당연히 있어야 할 것 같고, 구현도 간단해 보인다. 알림 예약하고, 시간 되면 보내면 되는 거 아닌가?\n이 \u0026ldquo;간단한\u0026rdquo; 기능을 제대로 동작하게 만드는 데 1년이 걸렸다. 2023년 12월부터 2024년 12월까지, 접근 방식을 네 번 바꾸고, 파일을 만들었다 지우기를 반복하고, 결국 처음과 전혀 다른 구조로 끝났다.\n1차 시도: NotificationManager (2023년 12월) Flutter로 전환한 직후, 가장 먼저 만들고 싶었던 기능이 급식 알림이었다. Java 프로토타입에서도 알림 기능이 있었으니까, Flutter에서도 금방 만들 수 있을 거라고 생각했다.\nNotificationManager.dart를 만들었다. 165줄. flutter_local_notifications 패키지를 사용해서 로컬 알림을 예약하는 구조였다.\n1 2 // 2023-12-12, 첫 번째 시도 // NotificationManager.dart — 165줄 같은 커밋에서 FirebaseCloudMessaging.dart라는 빈 파일도 만들었다. 이름에서 알 수 있듯이, FCM(Firebase Cloud Messaging)으로 서버에서 푸시를 보내는 것도 고려하고 있었다.\n2차 시도: FCM을 넣었다 뺐다 (같은 주) 다음 날, FirebaseCloudMessaging.dart에 60줄의 코드를 채워 넣었다. FCM 토큰을 받고, 메시지를 수신하는 기본 구조를 작성했다. 동시에 NotificationManager.dart도 대폭 수정했다. 90줄 분량의 변경.\n그리고 같은 날 밤, FCM 코드 60줄을 통째로 삭제했다.\n1 2 2023-12-13 20:05 FirebaseCloudMessaging.dart +60줄 2023-12-13 23:53 FirebaseCloudMessaging.dart -60줄 4시간 만에 되돌린 것이다. 이유는 단순했다. 급식 알림은 서버가 보내는 게 아니라 기기가 스스로 보내야 하는 알림이었다. FCM은 서버에서 클라이언트로 푸시를 보내는 도구인데, 매일 아침 급식 메뉴를 보내려면 서버 측에서 스케줄러를 돌려야 한다. Firebase Functions를 쓰면 가능하지만, 당시에는 무료 플랜(Spark)을 쓰고 있었고, Functions 배포가 불가능했다.\n그래서 방향을 틀었다. ScheduledNotification.dart를 새로 만들고, 기기 로컬에서 알림을 예약하는 방식으로 갔다.\n이때까지만 해도 \u0026ldquo;방향만 잡으면 금방 끝나겠지\u0026quot;라고 생각했다.\n7개월의 공백, 그리고 다시 시작 (2024년 7월) 급식 알림은 한동안 손을 대지 못했다. 게시판, 채팅, 로그인 같은 핵심 기능들이 우선이었고, 알림은 \u0026ldquo;나중에 제대로 하자\u0026rdquo; 목록에 들어갔다.\n2024년 7월, 다시 notification_manager.dart를 열었다. 150줄 이상을 수정하는 대규모 리팩토링이었다. 7개월 전에 작성한 코드를 다시 보니, 당시에는 이해가 부족해서 엉성하게 작성한 부분이 많았다. 알림 채널 설정, 권한 요청, 스케줄링 로직을 전부 다시 썼다.\n하지만 근본적인 문제가 남아 있었다. 앱이 꺼져 있을 때 알림이 안 왔다.\nflutter_local_notifications의 zonedSchedule은 앱이 살아 있거나 백그라운드에 있을 때는 잘 동작한다. 하지만 사용자가 앱을 강제 종료하거나, 시스템이 메모리 부족으로 앱을 죽이면? 예약된 알림도 같이 사라진다. 특히 Android 제조사(삼성, 샤오미 등)의 배터리 최적화가 공격적으로 백그라운드 프로세스를 죽이는 환경에서, Flutter 앱의 로컬 알림은 불안정했다.\n매일 아침 급식 알림이 가끔 오고 가끔 안 온다. 이건 없느니만 못한 기능이었다.\n3차 시도: Kotlin 네이티브로 (2024년 8월) \u0026ldquo;Flutter 레벨에서는 한계가 있다. 네이티브로 내려가자.\u0026rdquo;\n2024년 8월 1일, 결정을 내렸다. Android의 네이티브 알림 시스템을 직접 사용하기로 했다. 하루 만에 세 개의 파일을 새로 만들었다:\nMealNotificationReceiver.kt (78줄) — BroadcastReceiver를 상속. AlarmManager에서 트리거되면 실제 알림을 생성하는 역할 MealWorker.kt (61줄) — Worker를 상속. WorkManager에 등록되어, 시스템이 적절한 시점에 급식 데이터를 가져오고 알림을 예약 MainActivity.kt — Flutter와 네이티브 코드를 연결하는 MethodChannel 설정 (+61줄) 동시에 notification_manager.dart에서 160줄을 삭제했다. Flutter 측의 알림 로직을 대부분 걷어내고, Kotlin 네이티브에 위임하는 구조로 바꾼 것이다.\n1 2 3 4 5 2024-08-01 커밋 stat: MealNotificationReceiver.kt +78줄 (신규) MealWorker.kt +61줄 (신규) MainActivity.kt +61줄 notification_manager.dart -160줄 이 접근의 핵심 아이디어는 이랬다:\nWorkManager가 시스템 수준에서 작업을 스케줄링한다. 앱이 죽어도 시스템이 살려서 실행한다 MealWorker가 NEIS API를 호출해서 급식 데이터를 가져온다 MealNotificationReceiver가 실제 알림을 사용자에게 보여준다 Android의 WorkManager는 앱이 종료되어도 시스템이 보장하는 백그라운드 작업이다. 이론적으로는 완벽한 해법이었다.\n그런데 문제가 또 터졌다 이후 일주일간의 커밋 기록을 보면 상황이 얼마나 힘들었는지 알 수 있다:\n1 2 3 4 2024-08-04 Migration (MealWorker, Receiver 수정) 2024-08-06 Migration (notification_manager +105줄) 2024-08-07 Migration (Kotlin + notification_manager 동시 수정) 2024-08-07 commit (같은 날 또 수정) 일주일 동안 거의 매일 커밋이 있었고, 같은 날 두 번 커밋한 날도 있었다. 네이티브 코드와 Flutter 코드를 동시에 수정하고 있다는 건, 둘 사이의 통신이 제대로 안 된다는 뜻이었다.\n문제들:\nMethodChannel 통신 불안정 — Flutter에서 Kotlin으로, Kotlin에서 Flutter로 데이터를 주고받는 과정에서 타이밍 이슈가 발생. 앱이 cold start 상태일 때 채널이 준비되기 전에 호출이 가는 경우가 있었다 NEIS API를 Kotlin에서 직접 호출해야 하는 문제 — Flutter 쪽에 이미 잘 동작하는 MealDataApi가 있는데, 같은 로직을 Kotlin으로 다시 작성해야 했다. 코드 중복에 버그 가능성까지 두 배 iOS는? — Kotlin으로 네이티브를 작성하면 iOS에서는 별도로 Swift 코드를 작성해야 한다. AppDelegate.swift도 78줄이 추가되었지만, 플랫폼별로 다른 코드를 유지보수하는 건 크로스플랫폼의 이점을 스스로 버리는 것이었다 8월 27일, MealNotificationReceiver.kt에서 줄을 빼기 시작했다. 동시에 Flutter 쪽에 meal_notification_worker.dart(33줄)를 새로 만들었다. 네이티브에서 다시 Flutter로 로직을 옮기기 시작한 것이다.\nKotlin 포기 (2024년 9월) 2024년 9월 25일의 커밋이 결정적이었다:\n1 2 3 4 MealNotificationReceiver.kt -65줄 (삭제) MealWorker.kt -63줄 (삭제) notification_manager.dart +215줄 meal_notification_worker.dart 수정 Kotlin 네이티브 파일 두 개를 완전히 삭제했다. 2개월 전에 \u0026ldquo;이게 정답이다\u0026quot;라고 확신하며 작성한 코드를 통째로 버렸다. 동시에 notification_manager.dart에 215줄을 추가하며, 알림 로직을 전부 Flutter로 되돌렸다.\nJava 프로토타입 159커밋을 버릴 때도 그랬지만, 코드를 버리는 건 매번 아프다. 특히 이번에는 \u0026ldquo;Flutter로는 안 되니까 네이티브로 가야 한다\u0026quot;는 나름의 기술적 판단을 했던 것이라 더 그랬다. 그 판단이 틀렸다는 걸 인정하는 것이기도 했으니까.\n하지만 돌아보면 틀린 게 아니라 맞는 방향을 찾아가는 과정이었다. 네이티브로 내려가봤기 때문에 네이티브의 한계와 복잡성을 직접 체감했고, \u0026ldquo;Flutter 안에서 해결하되, 더 똑똑하게 하자\u0026quot;라는 결론에 도달할 수 있었다.\n구조 전환: DailyMealNotification (2024년 12월) 2024년 12월 2일, 대규모 구조 전환을 했다.\n1 2 3 4 5 6 notification_manager.dart -253줄 (삭제) meal_notification_worker.dart -33줄 (삭제) daily_meal_notification.dart +259줄 (신규) daily_alarm_notification.dart +82줄 (신규) MainActivity.kt -155줄 AppDelegate.swift -92줄 기존의 notification_manager.dart(253줄)를 삭제하고, daily_meal_notification.dart(259줄)를 새로 만들었다. 동시에 MainActivity.kt에서 155줄, AppDelegate.swift에서 92줄을 제거했다. 네이티브 쪽의 알림 관련 코드를 전부 걷어낸 것이다.\nmatchDateTimeComponents의 발견 이 구조 전환에서 가장 결정적이었던 건 flutter_local_notifications의 이 옵션이다:\n1 2 3 4 await _localNotificationsPlugin.zonedSchedule( ... matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime, ); dayOfWeekAndTime으로 설정하면, 매주 같은 요일 같은 시간에 반복되는 알림을 시스템이 직접 관리한다. 앱이 살아 있든 죽어 있든, 시스템 알람 스케줄러가 처리하기 때문에 안정적이다. exactAllowWhileIdle 모드와 결합하면 Doze 모드에서도 동작한다.\n1년 전에 이 옵션을 알았으면 Kotlin 네이티브로 내려갈 필요가 없었을지도 모른다. 하지만 이 옵션이 \u0026ldquo;정답\u0026quot;이라는 걸 확신하려면, 다른 방법들이 왜 안 되는지를 직접 경험해봐야 했다.\n그래도 남은 문제 구조는 잡혔지만, 이 버전에는 치명적인 문제가 있었다. 앱을 일정 기간 열지 않으면 알림이 오지 않았다.\n원인은 알림 스케줄링 시점에 있었다. 당시 코드는 스케줄링할 때 미래의 특정 날짜 급식을 미리 가져와서 알림 내용에 박아넣는 구조였다:\n1 2 3 4 5 6 // 2024-12 버전 — 스케줄링 시점에 특정 날짜의 급식을 가져옴 String bigText = (await MealDataApi.getMeal( date: DateTime(scheduledDate.year, scheduledDate.month, scheduledDate.day), mealType: mealType, type: MealDataApi.MENU, ))?.meal ?? \u0026#39;급식 정보가 없습니다\u0026#39;; matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime은 매주 반복 알림을 시스템에 등록한다. 하지만 알림의 내용은 등록 시점에 고정된다. 월요일에 스케줄링하면 그 주 월요일 급식 메뉴가 알림에 박히고, 다음 주 월요일에도 똑같은 내용이 표시된다.\n그래서 앱을 열 때마다 scheduleDailyNotifications()를 호출해서 알림을 새로 등록하는 방식으로 우회했는데, 문제는 앱을 오래 안 열면 갱신이 안 된다는 것이었다. 2주 동안 앱을 안 열면 2주 전 급식 메뉴가 계속 뜨거나, NEIS API에 해당 날짜 데이터가 없어서 \u0026ldquo;급식 정보가 없습니다\u0026quot;만 반복되었다.\n알림이 오긴 오는데 내용이 엉뚱하거나, 아예 의미 없는 메시지가 뜨니까 사용자 입장에서는 \u0026ldquo;알림이 안 온다\u0026quot;와 다름없었다.\n현재 버전으로의 진화 (2026년 3~4월) 이 문제를 해결하면서 동시에 여러 개선을 적용한 게 현재 버전이다.\n알림 내용 갱신 방식 변경 가장 핵심적인 변경은 급식 데이터를 가져오는 방식이다:\n1 2 3 4 5 6 7 // 현재 버전 — 항상 오늘 날짜 기준으로 가져옴 final meal = await MealDataApi.getMeal( date: DateTime.now(), mealType: mealType, type: MealDataApi.MENU, ); menuPreview = _cleanMenu(meal?.meal); scheduledDate 대신 DateTime.now()를 사용한다. 어차피 앱이 열릴 때마다 scheduleDailyNotifications()가 호출되면서 알림을 전부 취소하고 다시 등록하기 때문에, 가장 최신 급식 데이터로 갱신된다. 그리고 데이터를 못 가져왔을 때의 폴백도 달라졌다:\n1 2 3 4 5 // 2024-12 버전 \u0026#39;급식 정보가 없습니다\u0026#39; // 현재 버전 \u0026#39;오늘의 $mealLabel 메뉴를 확인하세요\u0026#39; // (i18n 적용 후: l.noti_mealConfirm(mealLabel)) 데이터가 없을 때 \u0026ldquo;정보가 없다\u0026quot;고 보여주는 대신, 앱을 열어보도록 유도하는 문구로 바꿨다. 월이 바뀔 때 데이터가 없거나, 예약 시점에 인터넷이 연결되지 않았을 수 있기 때문이다.\n알러지 정보 정리 NEIS API에서 내려오는 급식 메뉴에는 알러지 번호가 붙어 있다. 비빔밥(5.6.13) 이런 식으로. 알림에 이게 그대로 들어가면 지저분하다.\n1 2 3 4 5 6 7 8 String _cleanMenu(String? menu) { if (menu == null || menu.isEmpty) return \u0026#39;\u0026#39;; return menu .split(\u0026#39;\\n\u0026#39;) .map((e) =\u0026gt; e.replaceAll(RegExp(r\u0026#39;\\([0-9.,\\s]+\\)\u0026#39;), \u0026#39;\u0026#39;).trim()) .where((e) =\u0026gt; e.isNotEmpty) .join(\u0026#39; · \u0026#39;); } _cleanMenu()가 알러지 괄호를 제거하고, 줄바꿈을 ·로 연결해서 한 줄 미리보기를 만든다. \u0026ldquo;비빔밥 · 미역국 · 배추김치 · 요구르트\u0026rdquo; 이런 깔끔한 형태로.\n알림 탭 → 급식 화면 이동 2024-12 버전에서는 알림을 탭해도 아무 일도 안 일어났다. 그냥 앱이 열리거나, 아예 반응이 없었다.\n현재 버전은 payload를 활용한다:\n1 2 3 4 await _localNotificationsPlugin.zonedSchedule( ... payload: \u0026#39;meal_screen\u0026#39;, ); 알림을 탭하면 notificationStream에 'meal_screen'이 전달되고, 앱이 이를 받아서 급식 화면으로 바로 이동한다. 앱이 꺼져 있었더라도 cold start 후 급식 화면까지 자동으로 네비게이션된다.\n1 2 3 4 void _onNotificationTap(NotificationResponse response) { log(\u0026#39;Notification tapped: ${response.payload}\u0026#39;); notificationStream.add(response.payload); } 권한 요청 분리 2024-12 버전에서는 알림 초기화 과정에서 바로 권한을 요청했다. 앱을 처음 열자마자 \u0026ldquo;알림을 허용하시겠습니까?\u0026rdquo; 팝업이 뜨는 방식이었는데, 맥락 없이 갑자기 뜨는 권한 요청은 거부율이 높다.\n현재 버전에서는 _requestPermissions()를 DailyMealNotification 클래스에서 제거하고, 설정 화면에서 알림을 켤 때 바텀시트로 권한을 요청하는 방식으로 바꿨다. 사용자가 \u0026ldquo;급식 알림을 받겠다\u0026quot;는 의도를 먼저 표현한 상태에서 권한을 요청하니까 허용율이 훨씬 높다.\n다국어 지원 하드코딩되어 있던 한국어 문자열을 전부 AppLocalizations로 교체했다:\n1 2 3 4 5 6 7 8 9 10 // 2024-12 버전 \u0026#39;🍽️ $mealLabel 알림\u0026#39; \u0026#39;급식 정보 알림을 제공합니다.\u0026#39; \u0026#39;한솔고등학교\u0026#39; // 현재 버전 l.noti_mealBreakfast // 알림 제목 l.noti_mealChannelName // 채널 이름 l.noti_mealChannelDesc // 채널 설명 l.noti_schoolName // 학교 이름 영어를 사용하는 학생도 알림을 이해할 수 있게 되었다.\n조식·중식·석식 개별 설정 사용자가 원하는 끼니만 골라서 알림을 받을 수 있다. 기숙사생은 조식·중식·석식 전부, 통학생은 중식만. 시간도 각각 다르게 설정 가능하다.\n1 2 3 4 5 6 7 8 9 if (settings.isBreakfastNotificationOn) { await _scheduleWeeklyNotification( id: 1, mealLabel: l.meal_breakfast, notiTitle: l.noti_mealBreakfast, time: _parseTimeOfDay(settings.breakfastTime), weekdays: weekdays, ); } 월~금 각 요일별로 별도의 알림 ID를 부여해서 (조식×5 + 중식×5 + 석식×5 = 최대 15개) 개별적으로 관리한다. 주말에는 급식이 없으니까 토·일은 제외.\n📎 알림 기능의 전체 구조는 DailyMealNotification 문서에서 확인할 수 있다.\n2024-12 버전 vs 현재 버전 항목 2024-12 버전 현재 버전 급식 데이터 scheduledDate 기준 (고정) DateTime.now() 기준 (갱신) 데이터 없을 때 \u0026ldquo;급식 정보가 없습니다\u0026rdquo; \u0026ldquo;메뉴를 확인하세요\u0026rdquo; (유도) 알러지 정보 그대로 노출 _cleanMenu()로 제거 알림 탭 반응 없음 급식 화면으로 딥링크 권한 요청 앱 시작 시 즉시 설정 바텀시트에서 맥락적으로 언어 한국어 하드코딩 i18n (한국어 + 영어) 디버깅 print() 남발 log() 정리 테스트 없음 sendTestNotification() 제공 코드 줄 수는 259줄에서 239줄로 오히려 줄었다. 기능은 훨씬 많아졌는데 코드가 줄어든 건, 권한 요청 로직을 분리하고 print() 디버깅 코드를 정리한 덕분이다.\n1년 반의 기록 급식 알림 하나를 만드는 데 거친 경로를 정리하면:\n시기 접근 방식 결과 2023-12 NotificationManager + FCM FCM은 서버 필요 → 당일 삭제 2023-12 ScheduledNotification (로컬) 기본 동작은 하지만 불안정 2024-07 notification_manager 리팩토링 앱 종료 시 알림 누락 2024-08 Kotlin 네이티브 (WorkManager) 플랫폼별 코드 중복, 통신 복잡 2024-09 Kotlin 삭제, Flutter 복귀 구조 정리 시작 2024-12 DailyMealNotification 구조 전환 동작하지만 장기 미접속 시 내용 갱신 불가 2026-03~04 현재 버전 딥링크, i18n, 메뉴 프리뷰, 권한 UX 개선 파일 생성과 삭제 횟수를 세면:\nFirebaseCloudMessaging.dart → 생성 → 삭제 ScheduledNotification.dart → 생성 → 삭제 MealNotificationReceiver.kt → 생성 → 삭제 MealWorker.kt → 생성 → 삭제 meal_notification_worker.dart → 생성 → 삭제 notification_manager.dart → 생성 → 수차례 대규모 수정 → 삭제 daily_meal_notification.dart → 최종 생존 6개의 파일이 만들어졌다 사라졌고, 1개만 살아남았다.\n배운 것 \u0026ldquo;간단해 보이는 기능\u0026quot;은 없다. 사용자에게 간단하게 보이는 기능일수록 뒤에서 처리해야 할 것이 많다. \u0026ldquo;매일 아침 알림 보내기\u0026quot;라는 한 문장 뒤에는 타임존, 배터리 최적화, 백그라운드 제약, 플랫폼별 차이, API 호출 타이밍 같은 문제들이 숨어 있었다.\n네이티브로 내려가는 건 최후의 수단이어야 한다. 크로스플랫폼 프레임워크를 쓰면서 네이티브 코드를 작성하는 순간, 유지보수 비용이 플랫폼 수만큼 곱해진다. 특히 1인 개발에서는 치명적이다.\n\u0026ldquo;동작한다\u0026quot;와 \u0026ldquo;제대로 동작한다\u0026quot;는 다르다. 2024년 12월 버전은 동작했다. 알림이 왔다. 하지만 일정 기간 앱을 안 열면 내용이 갱신되지 않았고, 알림을 탭해도 아무 일도 안 일어났고, 권한 요청 타이밍이 나빴다. 기능이 \u0026ldquo;있는\u0026rdquo; 것과 \u0026ldquo;쓸 만한\u0026rdquo; 것 사이에는 이런 디테일들이 잔뜩 있다.\n삽질은 낭비가 아니다. FCM을 시도해봤기 때문에 서버 푸시와 로컬 알림의 차이를 이해했고, Kotlin 네이티브를 경험했기 때문에 Flutter의 한계와 가능성을 정확히 알게 되었다. 최종 코드 239줄에는 1년 반의 시행착오가 전부 녹아 있다.\n","date":"2026-04-07T00:00:00Z","permalink":"/p/meal-notification/","title":"#3 - 급식 알림의 삽질기"},{"content":"학교 사업 프로그램 학교에서 학생들의 창업이나 프로젝트를 지원하는 프로그램이 있었다. 아이디어를 제출하고, 팀을 꾸려서 발표하고, 선정되면 활동비와 멘토링을 지원받는 구조였다.\n팀원 두 명과 함께 \u0026ldquo;학교 통합 앱\u0026quot;이라는 주제로 지원했다. 급식, 시간표, 공지사항을 한곳에서 볼 수 있는 앱. 매번 학교 홈페이지에 들어가야 하는 불편함을 해결하자는 단순한 아이디어였고, 다행히 선정되었다.\nJava + XML 프로토타입 당시 나는 Java를 조금 공부한 상태였다. 모바일 개발 경험은 전혀 없었지만, Java를 알고 있으니 Android 네이티브가 가장 접근하기 쉬워 보였다. 그래서 첫 프로토타입은 Java + XML Layout으로 만들었다.\n159커밋의 기록 이 프로토타입은 단순한 데모가 아니었다. GitHub에 159개의 커밋이 남아 있을 정도로 꽤 오랜 기간 개발했다. v0.12.3 Beta까지 버전이 올라갔다.\n구현했던 기능들:\n급식 정보 — NEIS 공공데이터 API를 파싱해서 오늘/이번 주 급식을 보여줌. 영양정보까지 표시 시간표 조회 — NEIS API로 시간표 데이터를 가져와서 요일별로 표시 공지사항 — 학교 공지를 앱에서 확인할 수 있는 Fragment 구현 알림 — 매일 급식 알림을 보내주는 기능 개발하면서 겪었던 문제들도 많았다. FutureTask에서 CompletableFuture로 비동기 처리 방식을 전환하기도 했고, UI 디자인을 대규모로 리팩토링한 적도 있었다 (v0.11.5 → v0.12.3 구간). 투박한 첫 UI에서 점점 나아지는 과정이 커밋 히스토리에 그대로 남아 있다.\n하지만 이 프로토타입의 가장 큰 가치는 실제로 NEIS API를 호출해서 데이터를 가져오고 화면에 보여줄 수 있다는 걸 증명한 것이었다. 학교 앱이라는 아이디어가 기술적으로 가능하다는 확신을 얻었다.\n발표와 부스 프로토타입이 어느 정도 완성된 후, 참여 팀들과 선생님 앞에서 발표하는 자리가 있었다. 발표는 팀원이 맡았다. 앱의 컨셉, 해결하려는 문제, 현재 구현된 기능을 설명하고 실제 동작하는 프로토타입을 시연했다.\n그리고 학교 메인 홀에서 부스를 열었다.\n부스를 운영하면서 직접 학생들을 만났다. 관심을 가지는 학생도 있었고, \u0026ldquo;이런 게 왜 필요해?\u0026rdquo; 하는 반응도 있었다. 하지만 가장 중요한 건, 부스에서 두 가지 결정적인 사실을 알게 되었다는 것이다.\n1. Android와 iOS, 거의 반반 부스에 태블릿을 놓고 직접 만든 사전등록 앱을 실행해뒀다. 학번, 이름, 전화번호, 사용 중인 OS를 입력하는 간단한 폼이었는데, OS 필드를 집계해보니 Android와 iOS가 거의 반반이었다.\n이건 심각한 문제였다. 지금까지 Java + XML로 Android 앱만 만들고 있었는데, 그러면 학교 학생 절반이 이 앱을 쓸 수 없다는 뜻이다.\n그렇다고 Swift로 iOS 버전을 따로 만들 수 있는 상황이 아니었다. 나는 Java를 조금 아는 1학년이었고, 새로운 언어와 완전히 다른 플랫폼을 동시에 학습하면서 두 벌의 앱을 유지보수한다? 비현실적이었다.\n이때 처음으로 크로스플랫폼 프레임워크를 진지하게 고민하기 시작했다. React Native와 Flutter가 후보였는데, 당시 Flutter의 성장세가 가파르기도 했고, Dart 언어가 Java에서 넘어오기에 비교적 친숙해 보였다.\n2. \u0026ldquo;2, 3학년은 시간표가 다른데요?\u0026rdquo; 부스에서 받은 질문 중 하나가 프로젝트의 방향을 바꿨다.\n\u0026ldquo;1학년은 반만 입력하면 되는데, 2학년부터는 선택과목 때문에 시간표가 다 달라요.\u0026rdquo;\n1학년인 나는 이걸 몰랐다. 1학년은 학년과 반을 입력하면 시간표가 그대로 확정된다. 같은 반 학생은 모두 같은 시간표를 따른다.\n하지만 2·3학년은 완전히 다른 세계였다. 선택과목 제도 때문에 같은 반이라도 학생마다 시간표가 다르다. A는 3교시에 물리학을, B는 같은 시간에 생명과학을 듣는다. 그리고 그 수업은 각각 다른 반 교실에서 진행된다.\nNEIS API에서 제공하는 시간표 데이터는 반(CLASS_NM)별로 내려온다. 1학년이라면 자기 반 데이터만 가져오면 끝이지만, 2·3학년의 선택과목 시간에는 여러 반의 수업이 뒤섞여 있었다. 단순히 API를 호출해서 보여주는 것으로는 해결이 안 되는, 로직이 필요한 문제였다.\n이걸 들었을 때 솔직히 막막했다. 하지만 동시에 \u0026ldquo;이걸 해결하면 진짜 쓸 수 있는 앱이 되겠다\u0026quot;는 생각이 들었다.\n기숙사에서 2주 당시 기숙사에 살고 있었다. 평일 저녁 자습 시간과 주말을 온전히 개발에 쏟을 수 있는 환경이었다. 부스에서 받은 시간표 피드백을 해결하겠다는 목표를 잡고, 2주를 잡았다. 1주는 기획, 1주는 구현.\n개발을 시작한 지 얼마 안 된 상태에서 이 로직을 만들어야 했기 때문에, 쉽지 않을 거라는 건 알고 있었다.\n1주차: NEIS API 분석과 로직 기획 먼저 NEIS API의 시간표 데이터 구조를 철저히 분석했다. API를 호출하면 이런 형태의 데이터가 온다:\n1 2 3 4 5 6 7 { \u0026#34;ALL_TI_YMD\u0026#34;: \u0026#34;20260414\u0026#34;, \u0026#34;GRADE\u0026#34;: \u0026#34;2\u0026#34;, \u0026#34;CLASS_NM\u0026#34;: \u0026#34;3\u0026#34;, \u0026#34;PERIO\u0026#34;: \u0026#34;4\u0026#34;, \u0026#34;ITRT_CNTNT\u0026#34;: \u0026#34;물리학Ⅰ\u0026#34; } 날짜, 학년, 반, 교시, 과목명. 이걸 조합해서 개인 시간표를 만들어야 했다.\n핵심 문제를 정리하면:\n학년 시간표 결정 방식 1학년 학년 + 반 입력 → 시간표 확정 (단순 조회) 2·3학년 학년 전체 시간표에서 반별 과목 추출 → 사용자가 선택과목 선택 → 해당 과목의 반·교시 매칭 → 커스텀 시간표 생성 2·3학년의 흐름을 더 구체적으로 설계했다:\ngetTimeTable() — NEIS API에서 해당 학년의 전체 시간표를 가져온다. 반 필터 없이 전체를 요청해서, 모든 반의 모든 과목 데이터를 확보한다.\ngetAllSubjectCombinations() — 가져온 전체 시간표에서 반별 과목 조합을 추출한다. \u0026ldquo;3반 4교시 = 물리학Ⅰ\u0026rdquo;, \u0026ldquo;5반 4교시 = 생명과학Ⅰ\u0026rdquo; 같은 매핑을 만든다. 이걸로 사용자에게 \u0026ldquo;어떤 과목을 듣는지\u0026rdquo; 선택지를 보여줄 수 있다.\n사용자가 자기 선택과목을 고른다 — UI에서 과목 목록을 보여주고, 자기가 듣는 과목을 체크한다.\ngetCustomTimeTable() — 선택한 과목이 어느 반의 몇 교시에 있는지 역추적해서, 그 학생만의 개인 시간표를 생성한다.\n📎 시간표 API의 전체 구조는 TimetableDataApi 문서에서 확인할 수 있다.\n일주일 동안 노트에 데이터 흐름을 그리고 또 그렸다. API 응답 구조부터 최종 시간표까지의 변환 과정을 머릿속에서 완전히 정리한 후에야 코드를 쓸 수 있겠다는 확신이 들었다.\n2주차: 구현 기획대로 코드를 작성했다. 처음이라 삽질도 많았지만, 기획을 확실히 해둔 덕분에 방향을 잃지는 않았다.\n까다로웠던 부분들:\n반별 과목 파싱 NEIS API 응답에서 날짜별 → 반별 → 교시별 과목을 3중 중첩 Map으로 정리해야 했다.\n1 2 // 날짜 → 반번호 → [1교시, 2교시, 3교시, ...] Map\u0026lt;String, Map\u0026lt;String, List\u0026lt;String\u0026gt;\u0026gt;\u0026gt; API에서 넘어오는 데이터는 flat한 배열이라, 이걸 날짜와 반으로 그룹핑하고, 교시 순서대로 정렬해서 넣어야 했다. 교시(PERIO) 사이에 빈 시간이 있을 수도 있어서, 리스트를 교시 수만큼 미리 할당하고 해당 인덱스에 과목명을 넣는 방식으로 처리했다.\n1 2 3 4 while (classList.length \u0026lt; perio) { classList.add(\u0026#39;\u0026#39;); } classList[perio - 1] = content; 선택과목 시간표를 처음 구상했을 때의 핵심 아이디어가 바로 이것이었다. 빈 2차원 배열(요일 × 교시)을 먼저 만들어놓고, 사용자가 선택한 과목을 해당 위치에 채워 넣는 방식. 빈 시간표라는 틀을 먼저 준비하고, 거기에 자기 과목을 하나씩 배치한다는 발상이 전체 로직의 출발점이었다.\n선택과목 매칭 사용자가 고른 과목명이 어느 반의 몇 교시에 해당하는지 역추적하는 게 핵심이었다. 같은 과목이 여러 반에 걸쳐 있을 수 있다. 예를 들어 \u0026ldquo;물리학Ⅰ\u0026quot;이 3반에서도, 7반에서도 진행될 수 있다.\n이 문제를 해결하기 위해 Subject 모델에 subjectClass(반 번호)를 포함시켰다. 단순히 과목명만으로는 부족하고, 어느 반의 물리학인지까지 알아야 정확한 시간표를 만들 수 있었다.\n1 2 3 4 Subject( subjectName: \u0026#34;물리학Ⅰ\u0026#34;, subjectClass: 3, // 3반에서 진행되는 물리학 ) 커스텀 시간표를 만들 때는, 사용자가 선택한 과목 목록을 반별로 그룹핑한 뒤, 해당 반의 시간표에서 매칭되는 교시를 찾아서 개인 시간표에 채워 넣었다.\n폴백 전략 시간표가 항상 있는 건 아니다. 방학 중에는 시간표 데이터가 없고, 시험 기간에는 특별 시간표가 올라오기도 한다. 이번 주 데이터가 없으면 앱이 빈 화면을 보여주는 건 좋지 않았다.\n그래서 3단계 폴백을 구현했다:\n이번 주 시간표를 가져온다 없으면 다음 주 시간표를 가져온다 그래도 없으면 지난 주 시간표를 가져온다 이렇게 하면 방학 직전이나 직후에도 가장 가까운 시간표를 보여줄 수 있었다.\n캐싱 NEIS API를 매번 호출하면 느리고, 불필요한 네트워크 요청이 늘어난다. SharedPreferences에 캐싱을 구현했다.\n12시간 이내: 캐시 데이터를 바로 반환 (네트워크 요청 없음) 12시간 ~ 3일: Stale-While-Revalidate — 일단 캐시를 반환하되, 다음 요청 때 갱신 3일 초과: 캐시 삭제 후 새로 요청 SWR 패턴을 적용한 건 나중의 일이지만, 캐싱의 기본 개념은 이때 처음 구현했다. 네트워크 상태가 좋지 않은 학교 와이파이 환경에서 캐싱이 얼마나 중요한지 실감했다.\n159커밋을 버리는 결정 시간표 로직을 완성한 뒤, 프로젝트 전체를 Flutter로 전환하기로 결정했다.\n159개의 커밋이 쌓인 Java 프로젝트를 버리는 건 쉬운 결정이 아니었다. 급식 파싱, 시간표 조회, UI 리팩토링까지 수개월의 작업이 담겨 있었다. 하지만 부스에서 확인한 현실이 명확했다.\n학교 학생의 절반이 iOS를 쓴다 → Android만으로는 안 된다 Swift로 iOS를 따로 만들 여력이 없다 → 크로스플랫폼이 필수 Flutter는 Dart 하나로 양쪽을 커버한다 → 유일한 현실적 선택지 그리고 Java 프로토타입에서 이미 해결한 문제들 — NEIS API 파싱, 시간표 로직, 캐싱 전략 — 은 코드는 버려도 설계와 경험은 그대로 가져갈 수 있었다. 실제로 Flutter로 다시 작성할 때 Java에서 삽질했던 부분들은 훨씬 빠르게 구현할 수 있었다.\nJava 프로토타입은 GitHub에 그대로 남겨두었다. v0.12.3 Beta, 159커밋. 여기까지가 이 앱의 선사시대다.\nFlutter로의 전환 Dart는 Java와 문법이 비슷해서 언어 자체의 진입 장벽은 낮았다. 하지만 Flutter의 위젯 트리 개념은 XML Layout과 완전히 달랐다. XML에서는 화면을 선언적으로 정의하지만 로직과 분리되어 있었고, Flutter에서는 UI 자체가 코드다.\n처음에는 어색했지만, 익숙해지니 오히려 편했다. 특히 핫 리로드 — 코드를 수정하면 앱을 재시작하지 않아도 바로 화면에 반영된다 — 가 생산성을 극적으로 올려줬다. Java + XML 시절에는 빌드하고 에뮬레이터에 올리고 기다리는 시간이 길었는데, 그 시간이 거의 0으로 줄었다.\n시간표 로직도 Dart로 다시 작성했다. Java에서의 경험이 있으니 설계는 이미 머릿속에 있었고, Dart의 컬렉션 API가 Java보다 간결해서 코드량도 줄었다. Map, List, Set의 함수형 메서드들이 데이터 파싱에 특히 유용했다.\n돌아보면 부스에서 받은 피드백 두 개가 프로젝트의 방향을 결정했다.\n\u0026ldquo;iOS는 안 돼요?\u0026rdquo; → Flutter 전환 \u0026ldquo;2학년 시간표는요?\u0026rdquo; → 선택과목 커스텀 시간표 로직 사용자를 직접 만나는 게 얼마나 중요한지 체감한 순간이었다. 혼자 방에서 코딩만 했으면 절대 알 수 없었을 것들이다. 기획서를 아무리 잘 써도, 실제 사용자 앞에서 프로토타입을 보여주는 것만큼 확실한 검증은 없다.\n그리고 159커밋을 버린 건 아깝지만, 프로토타입의 목적은 원래 \u0026ldquo;버리기 위해 만드는 것\u0026quot;이다. 프로토타입에서 얻은 기술적 경험과 사용자 피드백이 Flutter 버전의 기초가 되었으니, 하나도 낭비된 게 아니었다.\n다음 글에서는 Flutter 전환 직후 가장 먼저 만들고 싶었던 기능, 급식 알림. 단순해 보였던 이 기능을 제대로 만드는 데 1년이 걸린 이야기를 다룬다.\n","date":"2026-04-06T00:00:00Z","permalink":"/p/java-to-flutter/","title":"#2 - Java에서 Flutter로"},{"content":"왜 만들었나 고등학교 재학 중에 직접 느낀 불편함이 시작점이었다.\n급식 확인이 번거롭다. 오늘 뭐 나오는지 알려면 매번 학교 홈페이지에 들어가서 주간 급식표를 찾아야 했다. 모바일에서 학교 홈페이지는 반응형도 아니라서, 핀치 줌으로 표를 확대해서 오늘 날짜를 찾는 과정을 매일 반복했다. 아침마다.\n시간표 관리가 불편하다. 종이 시간표를 사진 찍어 갤러리에 넣어두거나, 기억에 의존했다. 시간표가 바뀌면 다시 사진 찍고, 예전 사진은 삭제하고. 특히 2·3학년은 선택과목 때문에 반 친구와 시간표가 다른 경우가 많았는데, 이건 나중에 직접 부딪혀서야 알게 된 문제였다.\n학교 커뮤니티가 없다. 학생들끼리 소통할 수 있는 공간이 마땅치 않았다. 단체 카톡방은 있었지만, 익명 게시판이나 학년을 넘어선 소통 채널은 없었다.\n이 세 가지 문제를 하나의 앱으로 해결하고 싶었다. 급식, 시간표, 학사일정을 한눈에 보고, 게시판과 채팅으로 학교 구성원들이 소통할 수 있는 통합 플랫폼.\n기술 스택 선택 프로그래밍 기초는 있었지만 모바일 앱 개발은 처음이었다. 어떤 기술을 써야 하는지부터 조사해야 했다.\nFlutter를 선택한 이유 처음에는 Java로 Android 네이티브 앱을 만들었다. Java를 조금 알고 있었으니까 진입이 가장 쉬웠다. 하지만 이후 학교에서 부스를 운영하면서 iOS 사용자가 절반이라는 걸 알게 되었고, 크로스플랫폼이 필수라는 결론에 도달했다. (#2 - Java에서 Flutter로에서 자세히 다룬다.)\nFlutter를 선택한 이유:\n크로스 플랫폼 — Android + iOS를 하나의 코드베이스로. 1인 개발에서 두 플랫폼을 따로 만드는 건 비현실적이었다 Dart 언어 — Java에서 넘어오기에 문법이 친숙했다. 타입 안전성이 있으면서도 컬렉션 API가 간결해서 데이터 파싱에 유리했다 핫 리로드 — 코드를 수정하면 앱을 재시작하지 않아도 바로 화면에 반영된다. Java + XML 시절에는 빌드 후 에뮬레이터에 올리는 데 수십 초가 걸렸는데, 그 시간이 거의 0으로 줄었다 Firebase를 선택한 이유 백엔드를 직접 만들 수 있는 능력이 없었다. 서버 구축, DB 관리, API 설계 — 전부 처음이었다. Firebase는 이 모든 걸 건너뛸 수 있게 해줬다.\nFirestore — NoSQL 문서 데이터베이스. 스키마가 유연해서 프로토타이핑이 빠르다. 실시간 스트림으로 게시글이나 채팅이 작성 즉시 다른 사용자에게 반영된다 Firebase Auth — Google, Apple, Kakao, GitHub 4종 OAuth 로그인을 빠르게 붙일 수 있었다 (인증 서비스 문서) Cloud Storage — 게시글 이미지, 프로필 사진 저장 FCM — 댓글, 채팅, 공지 등 13종 푸시 알림 (FCM 서비스 문서) 무료 한도 — 학교 앱 규모(1,000명 이내)에서는 Spark 플랜으로 월 $0~3 운영이 가능했다 상태 관리: Riverpod 처음에는 Provider를 썼다. 간단한 CRUD에서는 문제가 없었지만, 기능이 복잡해지면서 한계가 드러났다. Provider는 BuildContext에 의존하기 때문에 위젯 트리 바깥에서 상태에 접근하기가 번거롭고, 테스트도 까다로웠다.\nRiverpod 2.5로 전환했다. 결정적인 이유 세 가지:\nAsyncNotifier — 비동기 작업의 로딩/성공/에러 상태를 분기하는 코드가 깔끔해졌다. .when(loading: ..., data: ..., error: ...)으로 한 줄에 처리 family + autoDispose — 파라미터별로 별도 상태를 만들되, 사용하지 않으면 자동으로 메모리에서 해제. 게시글 목록처럼 화면을 벗어나면 필요 없는 상태를 관리하기에 적합 BuildContext 독립 — 위젯 트리 바깥(서비스 레이어, 백그라운드 로직)에서도 상태에 접근 가능 BLoC도 고려했지만, 단순 CRUD에도 Event 클래스와 State 클래스를 별도로 만들어야 하는 보일러플레이트가 과도하다고 판단했다. 학교 앱의 대부분의 기능은 \u0026ldquo;Firestore에서 데이터를 가져와서 보여주는\u0026rdquo; 패턴이라, Riverpod의 간결한 API가 더 맞았다.\n처음 설계한 핵심 기능 NEIS API 연동 — 교육부 공공데이터 API로 급식, 시간표, 학사일정 자동 수집 4종 OAuth 로그인 — Google, Apple, Kakao, GitHub (인증 서비스 문서) 게시판 + 1:1 채팅 — 학생·교사·졸업생·학부모 모두 사용 역할 기반 권한 — 일반 사용자, 매니저, 관리자 3단계 푸시 알림 — 댓글, 채팅, 공지 등 13종 (FCM 서비스 문서) 처음부터 이 모든 기능을 계획한 건 아니었다. 급식과 시간표로 시작해서, 부스에서 받은 피드백으로 선택과목 시간표를 만들고, 사용자가 늘면서 게시판과 채팅을 붙이고, 관리가 필요해져서 역할 시스템을 추가했다. 하나씩 필요에 의해 붙여나간 결과가 지금의 프로젝트다.\n2023년 12월 첫 커밋부터 지금까지, 312개의 커밋, 110개의 Dart 파일, 32,000줄 이상의 코드. 이 시리즈는 그 과정에서 겪은 기술적 결정들을 기록한다.\n다음 글에서는 Java 프로토타입 159커밋을 만들고, 학교 부스에서 결정적인 피드백을 받고, Flutter로 전환하기까지의 과정을 다룬다.\n","date":"2026-04-05T00:00:00Z","permalink":"/p/hansol-app-intro/","title":"#1 - 프로젝트를 시작한 이유"},{"content":"Hugo 프로젝트 생성 1 hugo new site monkshark.github.io --format yaml Hugo는 기본적으로 config.toml을 생성하는데, --format yaml을 붙이면 hugo.yaml로 시작한다. YAML이 TOML보다 들여쓰기가 직관적이라 선호한다.\n1 2 git init git submodule add https://github.com/CaiJimmy/hugo-theme-stack themes/hugo-theme-stack Stack 테마를 Git submodule로 추가했다. 테마를 직접 복사하는 대신 submodule로 관리하면, 테마 업데이트를 git submodule update로 간단히 반영할 수 있다.\nhugo.yaml 설정 Stack 테마는 설정이 많은데, 핵심만 정리하면:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 baseURL: https://monkshark.github.io/ languageCode: ko title: monkshark.dev theme: hugo-theme-stack params: sidebar: subtitle: \u0026#34;풀스택 개발자가 되고싶은 사람\u0026#34; article: toc: true # 목차 자동 생성 readingTime: true # 읽는 시간 표시 colorScheme: toggle: true # 다크/라이트 전환 버튼 default: auto # 시스템 설정 따라감 widgets: homepage: - type: search - type: archives - type: categories - type: tag-cloud colorScheme.default: auto로 설정하면 사용자의 시스템 다크 모드 설정을 따라가되, 토글 버튼으로 수동 전환도 가능하다.\npermalink를 /p/:slug/로 설정해서 URL이 깔끔하게 나오도록 했다. 날짜가 URL에 들어가면 길어지고, 나중에 글 날짜를 수정하면 URL이 바뀌어서 링크가 깨질 수 있다.\n커스텀 스크롤 애니메이션 Stack 테마는 깔끔하지만 정적이다. 스크롤할 때 요소들이 자연스럽게 등장하는 애니메이션을 추가하고 싶었다.\nCSS — assets/scss/custom.scss 핵심 아이디어: body.anim-ready 클래스가 있을 때만 요소를 숨기고, JS가 스크롤 위치에 따라 .is-visible을 추가하면 등장한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 // JS가 anim-ready를 추가한 후에만 숨김 처리 // → JS가 실패해도 콘텐츠는 보인다 (progressive enhancement) body.anim-ready .article-list article { opacity: 0; transform: translateY(30px) scale(0.98); transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1); } body.anim-ready .article-list article.is-visible { opacity: 1; transform: translateY(0) scale(1); } body.anim-ready를 가드 조건으로 쓰는 이유가 있다. 처음에는 CSS에서 바로 opacity: 0을 적용했는데, JS 로딩이 지연되면 페이지 전체가 빈 화면으로 보이는 문제가 있었다. JS가 성공적으로 로드된 후에만 body에 anim-ready 클래스를 추가하고, 그때부터 애니메이션이 작동하게 했다. JS가 실패하면? 클래스가 안 붙으니까 모든 요소가 원래대로 보인다.\n카드 호버, 사이드바 위젯 등장, 헤딩 슬라이드, 코드 블록 fade-in, 네비게이션 밑줄 애니메이션도 같은 패턴으로 추가했다. 전부 순수 CSS + cubic-bezier(0.16, 1, 0.3, 1) (ease-out-expo와 유사한 커브)로, 외부 라이브러리 없이 구현했다.\n그리고 prefers-reduced-motion 미디어 쿼리로 모션 감소 설정을 존중한다:\n1 2 3 4 5 6 7 8 @media (prefers-reduced-motion: reduce) { body.anim-ready .article-list article, body.anim-ready .animate-on-scroll, /* ... 기타 요소들 */ { opacity: 1; transform: none; } } JS — layouts/partials/footer/custom.html Intersection Observer API로 요소가 뷰포트에 들어오는 시점을 감지한다:\n1 2 3 4 5 6 7 8 9 10 document.body.classList.add(\u0026#39;anim-ready\u0026#39;); var observer = new IntersectionObserver(function(entries) { entries.forEach(function(entry) { if (entry.isIntersecting) { entry.target.classList.add(\u0026#39;is-visible\u0026#39;); observer.unobserve(entry.target); // 한 번만 실행 } }); }, { threshold: 0.05, rootMargin: \u0026#39;0px 0px -30px 0px\u0026#39; }); 카드 목록에는 transitionDelay를 인덱스 × 0.1초로 설정해서 순차적으로 등장하는 스태거(stagger) 효과를 줬다. 한꺼번에 나타나면 밋밋하고, 하나씩 올라오면 시선이 자연스럽게 따라간다.\nGitHub Actions 배포 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # .github/workflows/hugo.yml jobs: build: runs-on: ubuntu-latest env: HUGO_VERSION: \u0026#34;0.160.1\u0026#34; steps: - name: Install Hugo CLI run: | wget -O ${{ runner.temp }}/hugo.deb \\ https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \\ \u0026amp;\u0026amp; sudo dpkg -i ${{ runner.temp }}/hugo.deb - uses: actions/checkout@v4 with: submodules: recursive - name: Build with Hugo run: hugo --gc --minify - uses: actions/upload-pages-artifact@v3 with: path: ./public deploy: needs: build steps: - uses: actions/deploy-pages@v4 master 브랜치에 push하면 Hugo가 빌드하고, GitHub Pages에 자동 배포된다. submodules: recursive가 중요한데, Stack 테마가 submodule로 들어가 있어서 이걸 빠뜨리면 빌드 시 테마를 찾지 못한다.\nHugo Extended 버전을 써야 SCSS 컴파일이 된다. 일반 Hugo로 빌드하면 custom.scss가 무시되어 커스텀 스타일이 적용되지 않는다.\n카테고리 아카이브 커스터마이징 카테고리별 아카이브 페이지(/archives/)에서 타일 카드에 카테고리 아이콘을 배경처럼 깔아줬다. 아이콘이 왼쪽과 하단으로 살짝 잘리면서 카드 뒤에 은은하게 보이는 효과.\n1 2 3 4 5 6 7 8 9 10 11 12 13 .article-list--tile article.has-image .article-image { position: absolute; left: -40px; top: -15px; z-index: 0; img { width: 240px !important; height: 240px !important; border-radius: 24px; opacity: 0.45; } } overflow: hidden으로 카드 영역을 벗어나는 부분을 자르고, z-index로 텍스트가 아이콘 위에 올라오게 했다. 다크 모드에서는 opacity: 0.3으로 더 어둡게 처리한다.\n정리 항목 선택 정적 사이트 생성기 Hugo v0.160.1 Extended 테마 Stack (git submodule) 배포 GitHub Pages + GitHub Actions 커스텀 애니메이션 CSS + Intersection Observer (외부 라이브러리 없음) 비용 $0 ","date":"2026-04-04T00:00:00Z","permalink":"/p/hugo-stack-blog-setup/","title":"Hugo + Stack 테마로 블로그 만들기"},{"content":"왜 블로그를 시작했나 Flutter + Firebase로 학교 앱을 1인 개발하면서 수많은 문제를 만났고, 그때마다 구글링으로 해결했다. 그런데 같은 문제를 두 번째 만났을 때 \u0026ldquo;이거 전에 어떻게 해결했더라?\u0026rdquo; 하며 또 검색하고 있는 나를 발견했다.\n코드에 주석을 남기면 되지 않냐고 할 수 있지만, 주석은 \u0026ldquo;어떻게\u0026quot;만 남기지 \u0026ldquo;왜\u0026quot;는 남기지 않는다. 왜 이 구조를 선택했는지, 어떤 대안을 시도해봤고 왜 실패했는지, 그 과정을 기록하려면 코드 바깥에 공간이 필요했다.\n그리고 한 가지 더. 학교 앱을 만들면서 겪은 문제들 — NEIS API의 시간표 구조를 파싱하는 법, flutter_local_notifications에서 matchDateTimeComponents로 주간 반복 알림을 설정하는 법, Firestore에서 n-gram 검색을 구현하는 법 — 은 나만 겪는 문제가 아닐 거다. 누군가는 똑같은 상황에서 검색하고 있을 테니, 내가 삽질한 기록이 그 사람의 시간을 아껴줄 수 있다.\n기록하지 않으면 사라진다. 그래서 블로그를 만들기로 했다.\n블로그에 쓸 내용 크게 세 가지 카테고리로 운영할 예정이다.\n서비스 개발기 직접 만든 서비스의 기획부터 개발, 운영까지의 과정을 기록한다. 첫 번째는 Flutter + Firebase로 만든 학교 앱. Java 프로토타입 159커밋을 버리고 Flutter로 전환한 이유, Riverpod을 선택한 과정, 급식 알림 하나를 제대로 만드는 데 1년이 걸린 이야기 같은 것들.\n단순히 \u0026ldquo;이렇게 했다\u0026quot;가 아니라 \u0026ldquo;왜 이렇게 했고, 다른 방법은 왜 안 됐는지\u0026quot;를 커밋 히스토리와 함께 복기하는 게 목표다. 앞으로 다른 서비스를 만들게 되면 그 과정도 여기에 쓸 예정이다.\n기술 Flutter, Firebase에 국한하지 않고 개발하면서 배운 것들. 풀스택 개발자가 되고 싶은 사람으로서 새롭게 접하는 기술들도 다룰 예정이다.\n이 블로그 자체를 만든 과정 — Hugo 정적 사이트 생성기 선택, Stack 테마 커스터마이징, Intersection Observer 기반 스크롤 애니메이션 구현, GitHub Actions 배포 — 도 여기에 속한다.\nTIL (Today I Learned) 짧지만 기록할 가치가 있는 것들. 에러 해결법, 새로 알게 된 패턴, 유용한 도구 등. 블로그 글 하나로 쓰기엔 가볍지만 머릿속에서 사라지기엔 아까운 것들을 모아두는 공간.\n꾸준히 쓰는 게 목표다. 완벽한 글보다 기록하는 습관이 우선이니까.\n","date":"2026-04-04T00:00:00Z","permalink":"/p/hello-world/","title":"블로그 시작"}]