DAY 34-1 | 개인 프로젝트 — viewmodel & riverpod (2)

Pt J·5일 전
post-thumbnail

DAY 34-1 | 개인 프로젝트 — viewmodel & riverpod (2)

작업 일정

  • #18 [state] SessionNotifier 및 운동 세션 트래킹 구현
  • #32 [state] HistoryNotifier 및 월별 캘린더 기록 관리 구현

#18 [state] SessionNotifier 및 운동 세션 트래킹 구현

이 프로젝트의 핵심 영역이다. 그래서 처리할 게 많다. 단일 파일로 생성하기보다는 세션 시작 화면, 진행 화면, 종료 화면의 ViewModel을 각각 작성하기로 했다.

하다 보니 스케줄 짤 때 놓쳤던 부분 중에 지금 붙잡고 있긴 힘들 것 같은 부분도 발견되었다. 그것들은 과감히 첫 번째 배포 버전에서 제외하고 다음 마일스톤 이슈로 넣어두기로 했다. 지금 구현하기엔 번거롭지만 결과적으로는 있는 게 좋고, 당장은 빼더라도 앱이 굴러가는 데는 지장이 없는 것들. 나중에 확장하는 걸로 충분할 것 같은 녀석들이다. 처음부터 완성도 높게 만들면 좋긴 하겠지만 때로는 일단 작동하는 앱을 만든 후 iterate하게 개발하는 것도 필요하다.

View에 구현해 놓은 걸 ViewModel로 옮기기도 하고, View 작성할 땐 나중으로 미뤄둔 걸 새로 구현하기도 하고. 그닥 일관성 있는 작업은 아니었구나 싶다. 전체적인 흐름을 머릿속에 담아둔 채 체계적으로 기획하지 않으면 엄청나게 꼬인 채로 진행될 수도 있겠구나 싶기도 하고.

ViewModel 연결하다 View에서 중복코드 나오는 걸 보고 뒤늦게 Model에 데이터 포맷 변환 추가하기도 하고 그런다. 이것도 해야 하는데 저것도 해야 하는데 하며 정신이 없다.

각 화면의 로직을 구현하고 나서 잘 작동하는지 테스트해 보는데 [활성 세션 조회 중 오류가 발생했습니다] 오류가 떴다. 테스트를 하다 [세션 시작] 후 [일시 정지]를 눌러 멈춰보고 [기록을 남기지 않고 종료하기]를 눌렀는데, 그 다음 세션을 시작하려고 하니 오류가 발생한 것이다. 원인을 찾아 보니 dialog를 닫으면서 세션 종료 시에도 세션 재개 처리를 해버려서 진행 중인 세션이 있는 것으로 취급되어 발생한 일이더라. dialog를 닫을 때 세션 재개 여부를 return하여 처리하도록 수정하였고 버그로 활성 세션을 처리하지 못한 채 진입할 경우 기존 세션은 폐기하는 걸로 코드를 수정하였다. 보통은 활성 세션이 있다면 앱 진입 시 중단된 세션에 대한 dialog가 뜨기 때문에 활성 세션을 가진 채 [세션 시작]을 누를 일은 없지만 말이다.

이 부분이 핵심 로직이긴 하지만 이것만 이틀이 걸렸다.

#32 [state] HistoryNotifier 및 월별 캘린더 기록 관리 구현

원래는 세션 결과의 스케치 이미지와 이동 경로 이미지를 서버에 저장해 놓고 기록에서 불러오려고 했는데, 이동 경로 이미지는 쓰지 않기로 했다. Flutter 오프스크린 캔버스에서는 실제 지도 타일을 함께 캡처해 합성하는 것이 기술적으로 까다로워, 결국 단색 배경에 선만 덜렁 그려진 이미지로 업로드되는데, 그런 이미지를 쓸 바에는 훨씬 용량이 작은 폴리라인 문자열만 저장해 놓고 디코딩하여 사용하는 게 낫다고 판단되었다. 설계 시점에 패키지에 대한 검증이 되어 있었다면 처음부터 이 방향으로 갈 수도 있었을까. 역시 설계 미숙에 대한 아쉬움이 크다.

화면에 떠야 할 게 자꾸 잘 안 떠서 많이 지체되었다. 세션 진행 화면과 세션 결과 화면에서 잘 떴던 지도 타일이 왜 여기선 잘 안 뜨는가. Firebase 통신도 되고 세션 진행 화면에서는 지도가 잘 뜨는 걸 봐서는 네트워크 이슈는 아닌데. 안 되겠다 싶어서 AI와 함께 원인을 뜯어보았다.

원인 1: 줌 레벨과 타일 요청 수의 16배 폭증 (4Δz4^{Δz})

OpenStreetMap을 비롯한 타일 맵 시스템(Slippy Map)은 줌 레벨이 1단계 오를 때마다 타일 수가 가로 2배, 세로 2배, 즉 4배(222^2)씩 늘어납니다.

  • 실시간 트래킹 화면: 줌 15 고정 → 화면에 필요한 타일은 단 4~6장.
  • 기록 상세 화면: 짧은 코스를 300x250 작은 뷰포트에 꽉 차게 보여주려고 maxZoom: 17.0 으로 자동 확대 → 줌 15 대비 타일 개수가 16배(424^2) 로 폭증 (한 번에 15~25장 이상의 타일 필요).

원인 2: initialCameraFit과 onMapReady의 '이중 피팅' 함정

코드 내부에서 카메라를 맞추는 로직이 2군데에서 겹쳐 있었습니다:
1. MapOptions(initialCameraFit: CameraFit.bounds(...)) 가 레이아웃 크기 측정 즉시 카메라 이동.
2. 거의 같은 밀리초에 onMapReady 콜백이 실행되며 _mapController.fitCamera(...) 를 또 한 번 실행.

결과적으로 1밀리초 사이에 수십 개의 고해상도 타일 다운로드 TCP 소켓이 동시에 생성되었습니다.

원인 3: 안드로이드 에뮬레이터의 가상 네트워크(NAT) 한계

안드로이드 에뮬레이터는 호스트 PC와 가상 라우터(qemu SLIRP NAT, 10.0.2.2)로 통신합니다.
Dart의 IOClient 가 짧은 순간에 수십 개의 병렬 소켓을 열어젖히자, 에뮬레이터 네트워크 스택의 임시 포트가 순간 고갈되거나 라우팅 테이블이 끊어지면서 리눅스 커널 에러인 errno 113: EHOSTUNREACH (No route to host) 를 뱉어낸 것입니다.

원인 4 (핵심 스모킹 건): Flutter의 '에러 이미지 캐싱'

가장 결정적인 미스터리였던 “왜 안 뜨는 녀석들은 화면을 나갔다 들어와도 계속 안 뜨고, 한 번 성공한 녀석만 계속 뜨는가?” 의 범인은 바로 Flutter의 캐싱 동작이었습니다.

Flutter의 PaintingBinding.instance.imageCache 는 이미지를 다운로드하다가 예외(SocketException)가 발생하면, "이 URL은 다운로드에 실패했다"는 에러 상태 자체를 캐시에 저장합니다. 즉, 처음에 소켓 버스트로 한 번 실패하고 나면, 이후 사용자가 화면을 백날 나갔다 들어와도 Flutter는 "아, 이 타일 URL? 지난번에 실패했던 애네. 네트워크 재요청 안 하고 바로 에러 던질게"라며 즉시 에러를 반환해 버렸던 것입니다.

해결 방법

조치 1: 중복 fitCamera 제거

initialCameraFit 이 이미 렌더링 시작 시점에 바운드를 정확히 잡아주므로, onMapReady 에서 불필요하게 카메라를 흔들던 중복 호출을 제거합니다.

조치 2: maxZoom 16.0으로 완화 (타일 요청량 75% 감소)

maxZoom 을 17.0에서 16.0으로 1단계만 낮춰도 필요한 타일 개수가 1/4로 급감합니다. 사용자 눈에는 코스가 충분히 크고 선명하게 보이면서도 에뮬레이터 네트워크 부하는 대폭 낮아집니다.

조치 3: 에러 캐시 방출 전략 변경 (EvictErrorTileStrategy.dispose)

일시적으로 네트워크 패킷이 튀어 타일 다운로드에 실패하더라도, 위젯이 사라지거나 뷰포트에서 벗어날 때 Flutter ImageCache에서 실패 기록을 즉시 삭제하도록 설정합니다. 이렇게 하면 다음 방문 시 깨끗하게 재시도됩니다.

조치 4: 예외 무음화

NetworkTileProvider(silenceExceptions: true)를 추가해 콘솔 에러 폭탄을 방지합니다.

여러 가지가 복합적으로 작용하고 있었다. 확실히 AI가 있으니까 덜 헤매고 답을 찾아갈 수 있는 것 같다. AI에게 의존하지 않으면서 적절히 도움을 받는 균형점이 어디일까.

>>> GitHub Repository at this point (819732e)

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글