Flutter로 하루 한 줄 노트앱 만들기: Hive와 TableCalendar
하루를 한 줄로 남기는 앱을 Flutter로 직접 만들었습니다. 노트를 펴자니 부담스럽고 SNS에 올리기는 싫어서, 딱 한 줄만 쓰고 닫는 앱이 필요했습니다. 이름은 '하루 한줄'입니다. 이 글은 기능을 어디까지 잘라낼지 정한 과정과, 캘린더 커스터마이징과 날짜 비교에서 막혔던 두 지점을 코드와 함께 정리한 기록입니다.
기능을 먼저 잘라냈다: 하루 한 줄이라는 제약
첫 결정은 기능을 더하는 쪽이 아니라 빼는 쪽이었습니다. 하루에 쓸 수 있는 글은 한 줄, 그것도 하루에 하나. 이 제약이 앱의 전부라서 나머지 화면은 제약을 거들기만 하면 됐습니다. 기록을 다시 보는 통로는 캘린더 하나로 정했고, 빈 화면 앞에서 막막하지 않도록 짧은 글귀를 함께 띄웠습니다. 저장은 일단 기기 안에서 끝내고, 서버는 나중에 생각하기로 했습니다.
핵심 기능은 네 가지로 정리했습니다.
- 하루에 딱 한 줄만 작성
- 캘린더 기반 기록 조회
- 간단한 글귀/명언 노출
- 로컬 저장, 이후 Firebase 연동 검토
화면 설계에서 지킨 원칙도 비슷합니다. 구조는 최대한 단순하게 가져가고, 타이핑 공간에는 무엇을 써야 할지 망설이지 않도록 감성을 유도하는 문구를 넣었습니다. 배경색으로 그날의 기분을 반영하는 기능은 필수가 아니라 선택 사항으로 뒀습니다. 기능 범위를 이렇게 좁히고 나니 남는 고민이 전부 기술 쪽으로 몰렸습니다. 저장은 어디에 할지, 캘린더는 무엇을 쓸지, 상태는 누가 들고 있을지. Flutter를 고른 이유는 단순합니다. UI를 빠르게 짜 볼 수 있고 iOS와 Android를 함께 낼 수 있어서, 혼자 하는 사이드 프로젝트에 잘 맞았습니다.
Flutter 3.19 위에 Provider, Hive, GetIt을 얹다
개발은 Flutter 3.19에서 시작했습니다. 이 버전에 무엇이 들어갔는지는 공식 3.19.0 릴리스 노트에 항목별로 정리돼 있습니다. 그 위에 상태관리는 Provider, 로컬 DB는 Hive, 캘린더는 TableCalendar, 의존성 주입은 GetIt을 올렸습니다.
Provider 사용 방식은 Flutter 공식 문서의 단순 앱 상태 관리 가이드가 설명하는 그대로입니다. 상태를 담는 클래스가 ChangeNotifier를 상속하고, 값이 바뀔 때 notifyListeners()를 부르면 이를 듣고 있던 위젯이 다시 그려집니다. 그 인스턴스를 하위 트리에 내려주는 위젯이 ChangeNotifierProvider이고, 실제로 값을 읽어 화면을 만드는 쪽이 Consumer입니다. 같은 문서는 Consumer를 트리 깊숙한 곳에 두어 다시 그리는 범위를 좁히라고 권합니다. 값은 필요 없고 메서드만 부르면 될 때는 Provider.of<T>(context, listen: false) 형태로 rebuild를 끊습니다.
저장은 Hive를 썼습니다. pub.dev의 Hive 패키지 페이지는 Hive를 순수 Dart로 작성된 가볍고 빠른 키-값 데이터베이스로 소개합니다. 데이터는 Hive 저장소가 말하는 박스(box)라는 컨테이너 단위로 담깁니다. 날짜 키 하나에 한 줄짜리 텍스트만 붙는 앱이라 관계형 스키마를 얹을 이유가 없었습니다.
의존성 주입은 GetIt입니다. get_it 저장소는 이 패키지를 Dart와 Flutter용 서비스 로케이터로 소개하고, BuildContext 없이 어디서든 등록한 객체를 꺼낼 수 있다는 점을 앞세웁니다. 등록 방식은 세 갈래입니다.
- Singleton: 한 번 만들어 계속 공유
- LazySingleton: 첫 접근 시점에 생성
- Factory: 요청할 때마다 새 인스턴스
디렉토리는 역할별로 나눴습니다. 규모가 작으니 계층을 더 파지 않았습니다.
lib/
┣ models/
┣ providers/
┣ screens/
┣ widgets/
┗ main.dart
TableCalendar를 그대로 쓰지 못한 이유
TableCalendar에는 캘린더에 필요한 기능이 대부분 들어 있습니다. 저장소 README가 적어 둔 목록만 봐도 월과 2주와 주 단위 포맷 전환, 로케일 지원, 범위 선택과 다중 선택, 동적 이벤트와 휴일 처리가 포함됩니다. 문제는 기능이 아니라 기본 디자인이었습니다. 그대로 붙이면 앱 분위기와 따로 놀아서, 위젯을 직접 조합해 스타일링을 바꾸는 데 시간을 썼습니다.
가장 손이 많이 간 부분은 기록이 있는 날에 붙는 점 표시입니다. README는 UI를 직접 짜려면 CalendarBuilders를 쓰라고 안내하면서, 각 builder로 필요한 부분만 골라 덮어쓸 수 있다고 설명합니다. 점 표시에 해당하는 것은 markerBuilder입니다. CalendarBuilders API 문서는 이 builder를 이벤트 마커용 커스텀 builder로 소개하면서, markerBuilder를 지정하면 singleMarkerBuilder와 기본 이벤트 마커를 모두 덮어쓴다고 못 박습니다. 손을 대는 순간 마커 UI 전체가 내 책임이 된다는 뜻입니다.
어떤 날에 마커를 띄울지는 eventLoader가 정합니다. README 예제는 eventLoader에 DateTime 하나를 받아 그날의 이벤트 목록을 돌려주는 함수를 넘깁니다. '하루 한줄'에서는 이 자리에 그날 기록이 있는지 조회하는 코드가 들어갑니다. 결국 캘린더 커스터마이징은 마커를 어떻게 그릴지와 언제 그릴지를 나눠서 푸는 작업이었습니다. 참고로 이 패키지의 라이선스는 Apache-2.0입니다.
하루 한 줄 제한은 결국 날짜 비교 문제였다
하루에 하나만 쓰게 하려면 오늘 쓴 기록이 이미 있는지를 판정해야 합니다. 처음에는 DateTime.now()를 그대로 저장하고 비교했는데 여기서 시간차 문제가 났습니다. 원인은 Dart 공식 API 문서의 DateTime 클래스 설명에 그대로 나와 있습니다. now()는 로컬 타임존의 현재 날짜와 시간을 담아 돌려줍니다. 날짜만이 아니라 시, 분, 초, 밀리초, 마이크로초까지 값이 들어갑니다.
== 연산자 설명은 더 분명합니다. 문서는 "Whether other is a DateTime at the same moment and in the same time zone (UTC or local)"이라고 적어 뒀습니다. 같은 순간이면서 같은 타임존일 때만 같다고 본다는 뜻입니다. 그래서 같은 날 오전과 오후에 찍은 두 값은 날짜가 같아도 == 비교에서 거짓이 됩니다.
해결은 비교 기준을 날짜로 통일하는 것이었습니다. 같은 문서의 생성자 시그니처를 보면 DateTime(int year, [int month = 1, int day = 1, int hour = 0, ...]) 형태라, DateTime(year, month, day)로 만들면 시각이 자정으로 고정됩니다. 비교 함수는 이렇게 썼습니다.
bool isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
같은 판정을 Flutter가 이미 제공하기도 합니다. material 라이브러리의 DateUtils.isSameDay는 static bool isSameDay(DateTime? dateA, DateTime? dateB) 시그니처로, 연과 월과 일이 같거나 두 값이 모두 null이면 true를 돌려줍니다. 값을 저장하기 전에 시각을 미리 잘라 두고 싶다면 DateUtils.dateOnly가 있습니다. 문서 설명은 "Returns a DateTime with the date of the original, but time set to midnight"입니다. 직접 만든 함수를 계속 쓸지 material 쪽 유틸리티로 갈아탈지는 취향에 가깝지만, 날짜 키를 만드는 지점이 여러 군데로 흩어질 것 같으면 한쪽으로 몰아 두는 편이 낫습니다.
상태관리를 다시 고른다면, 그리고 다음 계획
만들고 나서 남은 생각은 두 가지입니다. 하나는 감성 앱에서 느낌이 전부라는 것. 기능보다 분위기가 앞서고, UI/UX 결정이 코드보다 먼저 왔습니다. 다른 하나는 상태관리 구조를 처음에 잡아 두는 편이 편하다는 것입니다. Provider로 시작했지만 규모가 커질수록 GetX나 Riverpod도 후보에 올려 두고 있습니다. 프레임워크는 달라도 고민은 같아서, React에서 Recoil로 상태를 정리했던 기록과 비교해 보면 선택 기준이 비슷하게 갈립니다.
다만 이 선택을 Flutter 공식 문서가 대신 해 주지는 않습니다. 상태 관리 접근법 페이지는 setState, ValueNotifier와 InheritedNotifier, InheritedWidget과 InheritedModel 같은 내장 방식을 먼저 소개한 뒤, 패키지에 대해서는 보일러플레이트를 줄이고 디버깅 도구를 제공하며 일관된 구조를 잡는 데 도움이 된다고만 적습니다. 그리고 "The best choice for your app often depends on the app's complexity, your team's preferences, and the specific problems you need to solve"라고 덧붙인 뒤, pub.dev의 state-management 토픽에서 직접 골라 보라고 안내합니다. 앱 복잡도와 팀 사정이 기준이라는 이야기라, 지금 규모에서는 Provider로 충분하다는 판단을 굳이 뒤집지 않았습니다.
Flutter 상태 관리 접근법내장 방식과 패키지 선택 기준을 공식 문서가 어떻게 정리했는지 get_it README싱글턴과 지연 싱글턴과 팩토리 등록의 차이를 확인할 때
남은 계획은 두 갈래입니다. Firebase 연동은 유저 인증과 백업을 위해 적용할 예정입니다. Flutter용 Firebase Authentication 시작 문서는 Firebase Auth가 새 앱이든 기존 앱이든 안전한 인증을 붙일 수 있도록 여러 메서드와 유틸리티를 제공한다고 설명합니다. 로컬 저장으로 시작한 데이터를 계정에 묶는 작업이 여기서부터 시작됩니다. 다른 하나는 명언 API를 붙여 하루 글귀를 자동으로 띄우는 기능이고, 아직 실험 단계입니다. API 쪽은 FastAPI로 만든 Todo 앱에서 다뤘던 구조를 그대로 가져올 생각입니다.
사이드 프로젝트는 기능 구현만 남기지 않습니다. 코드도 쌓이고 기록도 쌓입니다. '하루 한줄'을 만들면서 그 안에 하루도 함께 쌓인다는 걸 알았습니다.
정리
- 기능을 하루 한 줄로 좁히고 나면 남는 결정은 저장과 캘린더와 상태관리
- 캘린더 마커는
CalendarBuilders의markerBuilder로 교체, 표시 여부는eventLoader가 결정 markerBuilder지정 시singleMarkerBuilder와 기본 마커를 함께 덮어쓰는 점 주의DateTime.now()는 시각까지 담기고==는 같은 순간과 같은 타임존을 요구하므로 날짜 기준 통일 필요- 다음 작업은 Firebase 인증과 백업 연동, 그리고 글귀 자동 노출
출처: Dart DateTime 클래스 / DateUtils.isSameDay / DateUtils.dateOnly / Flutter 상태 관리 접근법 / Flutter 단순 앱 상태 관리 / table_calendar 저장소 / CalendarBuilders API / Hive 패키지 / Hive 저장소 / get_it 저장소 / Firebase Authentication for Flutter / Flutter 3.19.0 릴리스 노트