포스트

Flutter를 은퇴시켰습니다 — 앱 3개를 웹으로 다시 쓰며 배운 것

미니앱 환경에서 Flutter 웹이 왜 계속 발목을 잡았는지, 그리고 주스 스피너·병 돌리기·사주로또를 순수 웹으로 재작성하면서 지킨 것과 버린 것을 정리했습니다

Flutter를 은퇴시켰습니다 — 앱 3개를 웹으로 다시 쓰며 배운 것

오늘 flutter_project 폴더를 아카이브로 옮겼습니다. 2년 가까이 앱들의 출발점이었던 폴더인데, 이제 라이브 서비스 중 Flutter로 돌아가는 앱은 하나도 없습니다. 주스 스피너, 병 돌리기, 사주로또 — 셋 다 순수 웹(TypeScript + Vite)으로 다시 썼고, Play 스토어와 앱인토스 양쪽에 업데이트로 제출을 마쳤습니다.

이 글은 “Flutter가 나쁘다”는 이야기가 아닙니다. 네이티브 앱을 만들 때의 Flutter는 여전히 훌륭합니다. 다만 미니앱(웹뷰) 플랫폼에 Flutter 웹 빌드를 올리는 조합은 제 규모의 1인 개발에서는 유지비가 이득을 넘어섰다는 기록입니다.

왜 은퇴인가 — 실제로 맞은 문제들

돌아보면 경고는 계속 있었습니다. 전부 이 블로그에 하나씩 기록했던 실전 사고들입니다.

로딩 20초 반려. Flutter 웹은 CanvasKit 렌더러(수 MB)를 내려받아야 첫 화면이 뜹니다. 기본 설정은 이걸 구글 CDN(gstatic)에서 받는데, 미니앱 심사 환경에서 이 CDN이 느리면 흰 화면이 20초를 넘겨 자동 반려됐습니다. 빌드 플래그로 CanvasKit을 번들에 포함해 넘겼지만, 앱 하나 크기가 수십 MB가 되는 건 그대로였습니다.

전면 광고가 화면을 죽이는 문제. 네이티브 전면 광고에서 돌아오면 Flutter 웹의 렌더 표면(WebGL 컨텍스트)이 죽은 채로 돌아오는 경우가 있었습니다. 터치도 소리도 사는데 그림만 안 그려집니다. 결국 “광고 보기 전에 하려던 일을 저장해 두고 앱을 통째로 리로드”하는 우회로를 짜야 했습니다.

한글 폰트 404 폭주. 한글 폰트를 번들에 넣어놨는데도 Noto Sans KR 서브셋을 수백 번 내려받으려다 404가 쏟아지는 문제. 원인은 두 가지가 겹친 것이었는데, 테마 레벨 TextStyle의 빈 폰트 패밀리가 엔진에 전달되는 구조를 이해하는 데 하루를 썼습니다.

dart2js 정수 함정. 웹 빌드에서 2^53을 넘는 정수 시드가 소리 없이 잘려, 매주 바뀌어야 할 번호가 고정되는 버그. 네이티브에서는 절대 안 나는, 웹 타깃에서만 나는 종류의 버그입니다.

하나하나는 해결했지만, 패턴이 보였습니다. 문제의 대부분이 “게임 로직”이 아니라 “Flutter를 웹에 얹는 접착부”에서 나온다는 것. 앱 자체는 셀렉트 박스 몇 개와 캔버스 하나면 되는 물건들인데요.

다시 쓰기 — 모노레포와 세 개의 게임

이미 반응속도·기억력 카드 같은 신작들은 pnpm 모노레포에서 순수 웹으로 만들고 있었습니다. 공통 패키지(저장소, 광고 추상화, 크로스 프로모션)가 갖춰져 있으니, 남은 Flutter 앱 3개를 같은 틀로 옮기는 게 자연스러운 수순이었습니다.

주스 스피너 / 병 돌리기 — 반나절짜리 이식입니다. 휠을 그리는 캔버스 코드와 “4.5바퀴 + 랜덤” 스핀 수식, 당첨 판정 각도 계산을 Flutter 코드에서 그대로 옮기고, CSS 트랜지션으로 회전을 맡겼습니다. APK 크기가 40MB대에서 5.8MB로 줄었습니다. 기능은 같습니다.

사주로또 — 이번 은퇴의 최종 관문이자 제일 조심스러웠던 앱입니다. 이 앱의 존재 이유가 “같은 사주는 같은 기간에 항상 같은 번호”라는 결정론이라서, 재작성판이 기존 판과 비트 단위로 같은 번호를 내야 했습니다. 옮겨야 했던 것들:

  • 19자리 정수 시드(생년월일·성별·기간이 자릿수로 인코딩) — JavaScript도 정수가 53비트라 BigInt가 필수입니다. dart2js에서 맞은 함정을 여기서 또 맞을 뻔했죠.
  • SHA-256 기반 난수 생성기 — Web Crypto는 비동기라 수만 번 호출하는 시뮬레이션에 안 맞아서, 표준 구현을 동기 함수로 직접 옮겼습니다.
  • 양력→음력 변환기 — 원본 Dart 패키지의 225개짜리 룩업 테이블 두 개를 그대로 이식했습니다.

검증은 골든 벡터로 했습니다. 원본 Dart 코드로 대표 케이스(양력/음력, 연금복권형, 시간별 기준 등)의 출력을 뽑고, TS판이 같은 입력에서 같은 출력을 내는지 diff — 전부 일치할 때까지는 UI를 한 줄도 안 짰습니다.

기존 사용자 데이터도 승계했습니다. Flutter 웹의 shared_preferences는 같은 도메인 localStorage에 flutter. 접두사로 남습니다. 미니앱은 업데이트돼도 오리진이 유지되니, 새 앱이 첫 실행 때 이 키들을 읽어 저장된 프로필·프리셋·번호를 그대로 가져옵니다. 사용자 입장에서는 앱이 가벼워졌을 뿐 아무것도 사라지지 않습니다.

덤으로 좋아진 것들

웹으로 오니 Flutter에서 우회로가 필요했던 것들이 그냥 사라졌습니다. 전면 광고 복구용 리로드 해크는 삭제했고(DOM은 광고에서 돌아와도 멀쩡합니다), 전 회차 당첨 시뮬레이션은 순수 JS SHA-256으로 1,200여 회차를 0.3초에 돕니다. 신기능도 그날 바로 넣었습니다 — “지난주에 이 사주로 5게임 샀다면 몇 등이었을까?”를 실제 당첨 번호와 대조해 보여주는 화면인데, 결정론 생성기라 지난주 번호를 “다시” 만들 수 있으니 가능한 기능입니다.

당첨 번호 데이터는 앱에 CSV로 내장하되, 블로그(GitHub Pages)에 같은 CSV를 올려두고 GitHub Actions가 매주 일요일 아침 신규 회차를 append하게 했습니다. 앱은 시작할 때 이 원격 CSV를 백그라운드로 받아 캐시에 병합합니다. 재배포 없이도 데이터가 늘 최신입니다.

마무리 — 아카이브

마지막 날 한 일들은 수수합니다. 공유 서명 키를 안전한 위치로 옮기고 빌드 설정을 갱신한 뒤 서명 검증 태스크로 확인, flutter_project 전체를 _archive/로 이동, 낡은 빌드 스크립트 삭제, 그리고 “왜 여기 있는지”를 설명하는 README를 아카이브에 남겼습니다. 삭제는 하지 않았습니다 — 결정론 검증용 원본 코드는 두고두고 오라클로 쓸 수 있으니까요.

교훈을 한 줄로 줄이면 이렇습니다. 프레임워크는 그 프레임워크가 1급 시민인 곳에서 쓰라는 것. Flutter는 네이티브 앱에서 1급 시민이고, 웹뷰 미니앱에서 1급 시민은 그냥 웹입니다. 접착부에서 새는 시간은 기능 개발이 아니라 순수 유지비인데, 1인 개발에서 그 유지비는 곧 신작 하나의 기회비용입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.