웹 이식 Phase A — 웹 기동·데스크톱 크롬·공유 링크 진입#196
Open
seizeh wants to merge 7 commits into
Open
Conversation
웹은 앱 유입 퍼널로 만든다(docs/web-port.md). 커뮤니티·후기 열람과 회원가입만 웹에서 하고, 지도·동네 인증·글쓰기·채팅은 앱으로 유도한다. 웹 기동 — 빌드가 아니라 런타임이 문제였다. `flutter build web` 은 손대기 전에도 통과했지만, main() 의 네이버 지도 초기화가 runApp 이전에 MissingPluginException(initializeNcp) 을 던져 흰 화면이었다. · dart:io 제거 — Platform.isIOS/isAndroid → utils/platform_info.dart. 웹에서 컴파일은 되고 런타임에만 UnsupportedError 라 빌드로는 안 잡힌다. 임시파일 삭제는 utils/temp_file.dart 조건부 import 파사드로. · 네이티브 전용 플러그인은 파사드 없이 kIsWeb 가드로 호출만 막는다 (지도 초기화·FCM 셋업·영상 포스터 생성). · MainScreen.visibleTabs — 탭 상수는 위치가 아니라 정체성으로 고정하고 표시 순서만 플랫폼별로. 딥링크(tabRequest)는 없는 탭이면 무시된다. 웹은 검색·커뮤니티·마이 3탭. · CI 에 웹 빌드 + dart:io 직접 import 금지 게이트 추가. 데스크톱은 크롬만 분리 — 본문(카드·타이포·모션)은 폭과 무관하게 앱과 동일하고 바뀌는 건 내비게이션 위치뿐이다. · SpringyNavBar 에 axis 추가. 세로 레일은 별도 위젯이 아니라 같은 위젯이라 스프링·스쿼시·근접 보간이 같은 코드를 탄다. · widgets/app_shell.dart — MaterialApp.builder(Navigator 바깥)에 꽂아 본문을 폰 폭 컬럼으로 묶고 레일을 그 바깥에 둔다. 상세 라우트가 얹혀도 유지된다. · 폭 판정은 전부 kIsWeb 게이트 — 네이티브 레이아웃 무변경. 공유 링크 → 웹앱: go.pawmate.kr/s 는 그대로 두고(OG 미리보기·즉시 페인트·퍼널 계측 유지) app.pawmate.kr/p/<postId> 로 잇는 진입만 받는다. 비로그인이면 웰컴 대신 게스트 메인을 깔고 그 위에 상세를 얹어, 닫으면 커뮤니티 피드로 이어진다. 서버측(CTA·post id 노출)은 pmdb 에 별도 PR. 커뮤니티 피드는 폴백이 이미 동작해 서버 작업이 없다 — feed_region_codes 가 미인증 사용자에게 NULL 을 반환하고 fetchFeed 는 그때 지역 필터를 걸지 않는다. 검증: analyze 무결점, 테스트 162개 통과, 릴리스 웹 번들을 Chrome 에서 기동해 웰컴 → 게스트 진입 → 피드·상세, 데스크톱 레일과 모바일 하단바 전환, /p/<id> 공유 진입까지 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
web/ 이 Flutter 기본 템플릿 그대로였다. 실제 도메인에 올리면 전부 사용자에게 보이는 것들이라 배포 전에 정리한다. · 아이콘 5종이 Flutter 기본 로고(파란 F)였다 — assets/icon/app_icon.png 에서 192·512·maskable·favicon 재생성. 파비콘·PWA 아이콘·홈화면 아이콘에 그대로 쓰인다. · index.html — 제목 'pawmate'/설명 'A new Flutter project' 교체, 한국어 lang, OG 태그(웹앱 주소를 직접 공유했을 때의 폴백. 공유 링크 자체는 go.pawmate.kr 서버 렌더링 뷰어가 담당한다), theme-color 라이트/다크. · 스플래시 — CanvasKit 은 수 MB라 첫 페인트까지 몇 초 걸린다. 그동안 흰 화면 대신 앱 배경색(cream #F5EFE3 / 다크 #121212)과 로고를 깔고, flutter-first-frame 이벤트에 걷어낸다. prefers-reduced-motion 존중. · manifest.json — 이름·설명·색을 앱 기준으로(기존값은 Flutter 블루 #0175C2). Cloudflare Pages(pawmate-web)에 배포해 pages.dev 에서 확인: SPA 폴백(_redirects) 동작, /p/<postId> 딥링크로 게시글 상세 진입, 데스크톱 레일·실데이터 정상. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
배포 결과와 배포 중 걸린 함정, 그리고 실제 도메인에서만 드러난 증상을 남긴다. · Cloudflare Pages(pawmate-web) → https://app.pawmate.kr · 커스텀 도메인 함정: wrangler CLI 에 Pages 커스텀 도메인 명령이 없고, wrangler OAuth 토큰은 zone(read) 뿐이라 DNS 를 못 만든다. 도메인 등록은 API 로 되지만 CNAME 은 대시보드에서 사람이 넣어야 한다. API 로 등록하면 대시보드 마법사의 DNS 자동 생성 단계가 건너뛰어져 그 행에 버튼이 안 생긴다. 전파 중 몇 분간 522 는 정상. · B6(초기 로딩) 부분 완료로 갱신 — index.html·manifest·아이콘 정비 반영. · B7 신설: 첫 로드에서 몇 초간 한글이 전부 두부(□)로 보인다. 스플래시는 첫 프레임에 걷히는데 CanvasKit 이 CJK 글리프를 번들하지 않고 폴백 폰트를 첫 프레임 이후에 받기 때문. localhost 에서는 빨라서 안 보였고 실제 도메인에서 드러났다. 한글 폰트 번들로 닫는다 — 외부에 URL 알리기 전 필수. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
첫 로드에서 몇 초간 한글이 전부 □ 로 보이던 문제. 처음엔 한글 폰트 번들로
잡으려 했으나 실측 결과 처방이 틀렸다.
· 폴백으로 받는 Noto Sans KR = 32KB (unicode-range 조각 4개)
· 번들 시 = 1.15MB × 굵기 수 (앱은 w400~w800 사용)
· canvaskit.wasm(gstatic) = 5.3MB
32KB 를 없애자고 2~4MB 를 첫 프레임 앞에 세우는 꼴이라 손해다. 진짜 원인은
용량이 아니라 순서와 연결 비용이었다 — 첫 프레임을 그린 뒤에야 빠진 글자를
발견하고, 그 시점에 fonts.gstatic.com 연결을 DNS 부터 새로 맺는다.
· index.html 에 fonts.gstatic.com preconnect — 핸드셰이크를 미리 끝낸다.
google_fonts 의 Baloo2(웰컴 로고)도 같은 오리진이라 함께 덮인다.
· 빌드에 --no-web-resources-cdn — CanvasKit 을 같은 오리진에서 서빙한다.
Cloudflare brotli 로 2.0MB 만 전송(gstatic 원본 5.4MB). 서드파티 의존도
사라진다. 배포 때마다 필요한 플래그라 문서에 명시.
콜드 로드 실측(적용 후): main.dart.js 0.74→2.6s, canvaskit.wasm 0.74→2.4s,
폰트 요청은 3.7s 에 시작해 7ms 에 완료. 두부 구간 수 초 → 100ms 대.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
웹에서 flutter_secure_storage 를 쓰지 않는다. 그 웹 구현은 localStorage + AES 인데
복호화 키도 같은 localStorage 에 있어 사실상 난독화다.
먼저 계획 문서의 전제가 틀렸음을 바로잡는다. "무상태 JWT exp 30일"은 옛말이고
refresh-token phase 2 이후 실제로는 access 8시간 + 회전 refresh(슬라이딩 30일·
절대 90일)이며, rt_rotate 가 재사용을 감지하면 family 를 통째로 회수한다.
httpOnly 쿠키 이관(원래 계획)은 하지 않는다 — 로그인 경로를 바꾸는 작업이라
검증 부담이 큰 데 비해, XSS 가 페이지 안에서 그대로 요청하는 건 어차피 못 막는다.
웹은 앱 유입 퍼널이므로 세션을 짧게 끊는 쪽이 맞는 교환이다.
· access·user → sessionStorage(탭 닫으면 소멸)
· refresh → 영속화하지 않고 갱신에도 쓰지 않는다. 메모리에만 들고 있다가
로그아웃 시 서버 family 회수에만 쓴다.
· 결과: XSS 로 새어나갈 수 있는 최대치가 access 하나(8시간)이고 갱신 불가.
· 앱(네이티브)은 무변경 — 30일 회전 세션 그대로.
⚠️ 로그인의 x-client-refresh:1 헤더는 그대로 유지해야 한다. 빼면 서버가 레거시
클라로 보고 30일짜리 access 를 발급해 오히려 나빠진다(login/index.ts).
구현은 session_store.dart 조건부 export(_io/_web) + session.dart 가 _store 로만
접근. 서버 변경 없음. 부수 효과로 wasm 빌드를 막던 flutter_secure_storage_web
(dart:html·package:js)이 웹 경로에서 빠졌다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: 웹에서 로그인 버튼은 눌리는데 오류 토스트만 뜨고 로그인이 안 된다. (실제로는 세션이 저장된 뒤라 새로고침하면 로그인돼 있는 상태였다.) 원인은 Phase A 에서 내가 만든 구멍이다. main() 의 _setupPush() 만 건너뛰면 된다고 봤지만, PushService 는 final FirebaseMessaging _fm = FirebaseMessaging.instance; 를 **인스턴스 필드**로 갖고 있어서 PushService.instance 를 처음 참조하는 순간 Firebase 가 없다며 [core/no-app] 로 던진다. 그 참조가 하필 로그인 성공 직후 (auth_service registerToken)와 로그아웃(clearToken)에 있었다. login() 의 바깥 catch 가 이를 삼켜 network_error 로 바꾸는 바람에 원인도 안 보였다. · _fm 을 필드 → 게터로(참조 시점 지연, 웹에서는 평가되지 않는다) · init/registerToken/clearToken 에 kIsWeb 조기 반환 교훈은 문서에도 남긴다 — kIsWeb 가드는 초기화 지점이 아니라 공개 진입점마다 걸어야 하고, 게스트 둘러보기만 확인해서는 이 부류를 못 잡는다. 검증: 배포본에서 일부러 틀린 자격증명으로 시도해 CORS 프리플라이트 200 → '아이디 또는 비밀번호가 올바르지 않아요' 정상 응답 확인(이전엔 일반 오류). 성공 경로의 나머지 호출(RealtimeService.start)은 순수 Supabase 라 웹 안전. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: PC 에서 창을 가로로 넓힐수록 게시글 상세가 엉뚱한 자리(거의 화면 밖)에서
펼쳐진다. 원인은 AppShell 이 Navigator **바깥**에서 본문을 가운데 컬럼으로
옮기고 좁히는데, 라우트 안은 그걸 모른다는 것.
(1) 좌표 — originRect 는 localToGlobal(창 좌표)로 잡는데 라우트는 컬럼 좌표계다.
캡처 지점이 18곳이라 규약을 "originRect 는 언제나 창 좌표"로 못박고 소비
지점 3곳(CollapsibleView 2 · ExpandRoute 1)에서 toRouteRect() 로 변환한다.
riseOriginRect 는 MediaQuery 기준이라 반대로 shellOrigin() 을 더해 맞춘다.
(2) 크기 — ConstrainedBox 는 MediaQuery 를 바꾸지 않아 컬럼 안에서도
MediaQuery.size 가 창 크기로 보고된다(모프 도착 사각형이 창 폭으로 잡힘).
컬럼 안에서 MediaQuery 를 컬럼 크기로 덮어쓴다.
이때 크롬 구성 판단까지 오염되는 함정이 있다 — useSideNav 가 컬럼 폭(460)을
보고 '좁은 화면'으로 오판해 좌측 레일과 하단 바가 동시에 나온다. 그래서
창 크기는 ShellMetrics(InheritedWidget)로 따로 내려보내고
useSideNav/useContentColumn 이 그 값을 우선 보게 한다.
셸이 없으면(네이티브·좁은 화면) 오프셋 0 · ShellMetrics 부재라 기존 동작 그대로다.
검증: 배포본에서 게스트 진입 → 카드 탭 → 모프가 카드 자리에서 펼쳐지고 컬럼에
안착, 레일은 단일 유지. analyze 무결점, 테스트 162개 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
웹을 앱 유입 퍼널로 만드는 첫 단계. 커뮤니티·후기 열람과 회원가입만 웹에서 하고, 지도·동네 인증·글쓰기·채팅은 앱으로 유도한다. 설계 전체는
docs/web-port.md.1. 웹 기동 — 빌드가 아니라 런타임이 문제였다
계획 단계의 전제("
flutter build web이 컴파일에서 깨진다")는 틀렸다. 빌드는 손대기 전에도 통과했다. 실제 증상은 브라우저 흰 화면이었고 원인은 한 줄이다:main()의 네이버 지도 초기화가runApp이전에 던져 앱이 아예 뜨지 않았다. 빌드 게이트로는 못 잡는 종류다.dart:io제거 —Platform.isIOS/isAndroid→lib/utils/platform_info.dart. 웹에서 컴파일은 되고 런타임에만UnsupportedError라 빌드로 안 잡힌다. 임시파일 삭제는utils/temp_file.dart조건부 import 파사드로.kIsWeb가드로 호출만 막는다(지도 초기화·FCM 셋업·영상 포스터 생성). Dart 코드는 멀쩡히 컴파일되므로 stub 이 필요 없었다.MainScreen.visibleTabs— 탭 상수는 위치가 아니라 정체성으로 고정하고 표시 순서만 플랫폼별로. 딥링크(tabRequest)는 없는 탭이면 무시된다. 웹은 검색·커뮤니티·마이 3탭.flutter build web --release+dart:io직접 import 금지 grep. 후자가 런타임 회귀를 막는 실질적인 게이트다.2. 데스크톱은 "크롬만" 분리
본문(카드·타이포·모션)은 폭과 무관하게 앱과 동일하고, 바뀌는 건 내비게이션 위치뿐이다.
SpringyNavBar에axis추가 — 세로 레일은 별도 위젯이 아니라 같은 위젯이다. 스프링·스쿼시·근접 보간이 같은 코드를 타므로 두 형태가 영원히 같은 모션 언어를 유지한다.widgets/app_shell.dart—MaterialApp.builder(Navigator 바깥)에 꽂는다. 레일이 본문 컬럼 바깥에 있어야 하고 상세 라우트가 얹혀도 유지돼야 해서 거기밖에 둘 곳이 없다.MainScreen은 탭 상태만navRail로 공개한다.kIsWeb게이트 — 네이티브 레이아웃 무변경(아이패드 포함).3. 공유 링크 → 웹앱 진입
go.pawmate.kr/s는 그대로 둔다(OG 미리보기·즉시 페인트·퍼널 계측 유지).app.pawmate.kr/p/<postId>로 들어오는 진입만 받는다. 비로그인이면 웰컴 대신 게스트 메인을 깔고 그 위에 상세를 얹어, 닫으면 커뮤니티 피드로 이어진다.서버측(CTA·post id 노출)은 pmdb
feat/share-view-web-cta에 별도 PR.계획에서 뺀 것
커뮤니티 피드 전국 폴백은 서버 작업이 필요 없었다.
feed_region_codes()가 미인증 사용자(is_location_verified=false또는latitude is null)에게 NULL 을 반환하고,fetchFeed는codes == null이면 지역 필터를 아예 걸지 않는다. 게스트·웹 신규 가입자 모두 이미 전국 피드가 나온다 — 브라우저에서 확인했다.검증
flutter analyze무결점 /flutter test162개 통과 / 포맷·dart:io게이트 통과/p/<uuid>공유 진입 정상,/p/not-a-uuid는 조회 없이 웰컴 폴백여담으로
flutter analyze는 통과했는데 dart2js 가 널 프로모션 에러를 잡았다(클로저에 캡처된 지역변수는 승격이 풀림). 위 웹 빌드 게이트가 바로 값을 한 사례라 해당 위치에 주석으로 남겼다.남은 것
🤖 Generated with Claude Code