Skip to content

웹 이식 Phase A — 웹 기동·데스크톱 크롬·공유 링크 진입#196

Merged
seizeh merged 20 commits into
mainfrom
feat/web-port-phase-a
Jul 27, 2026
Merged

웹 이식 Phase A — 웹 기동·데스크톱 크롬·공유 링크 진입#196
seizeh merged 20 commits into
mainfrom
feat/web-port-phase-a

Conversation

@seizeh

@seizeh seizeh commented Jul 26, 2026

Copy link
Copy Markdown
Owner

웹을 앱 유입 퍼널로 만드는 첫 단계. 커뮤니티·후기 열람과 회원가입만 웹에서 하고, 지도·동네 인증·글쓰기·채팅은 앱으로 유도한다. 설계 전체는 docs/web-port.md.

1. 웹 기동 — 빌드가 아니라 런타임이 문제였다

계획 단계의 전제("flutter build web 이 컴파일에서 깨진다")는 틀렸다. 빌드는 손대기 전에도 통과했다. 실제 증상은 브라우저 흰 화면이었고 원인은 한 줄이다:

DartError: MissingPluginException(No implementation found for
method initializeNcp on channel flutter_naver_map_sdk)

main() 의 네이버 지도 초기화가 runApp 이전에 던져 앱이 아예 뜨지 않았다. 빌드 게이트로는 못 잡는 종류다.

  • dart:io 제거Platform.isIOS/isAndroidlib/utils/platform_info.dart. 웹에서 컴파일은 되고 런타임에만 UnsupportedError 라 빌드로 안 잡힌다. 임시파일 삭제는 utils/temp_file.dart 조건부 import 파사드로.
  • 네이티브 전용 플러그인 — 파사드 없이 kIsWeb 가드로 호출만 막는다(지도 초기화·FCM 셋업·영상 포스터 생성). Dart 코드는 멀쩡히 컴파일되므로 stub 이 필요 없었다.
  • MainScreen.visibleTabs — 탭 상수는 위치가 아니라 정체성으로 고정하고 표시 순서만 플랫폼별로. 딥링크(tabRequest)는 없는 탭이면 무시된다. 웹은 검색·커뮤니티·마이 3탭.
  • CI 게이트 2개flutter build web --release + dart:io 직접 import 금지 grep. 후자가 런타임 회귀를 막는 실질적인 게이트다.

2. 데스크톱은 "크롬만" 분리

본문(카드·타이포·모션)은 폭과 무관하게 앱과 동일하고, 바뀌는 건 내비게이션 위치뿐이다.

< 900px ≥ 900px
내비게이션 하단 플로팅 바 좌측 플로팅 레일
본문 컬럼 화면 폭 (460 초과 시 460) 460 중앙 정렬
  • SpringyNavBaraxis 추가 — 세로 레일은 별도 위젯이 아니라 같은 위젯이다. 스프링·스쿼시·근접 보간이 같은 코드를 타므로 두 형태가 영원히 같은 모션 언어를 유지한다.
  • widgets/app_shell.dartMaterialApp.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 을 반환하고, fetchFeedcodes == null 이면 지역 필터를 아예 걸지 않는다. 게스트·웹 신규 가입자 모두 이미 전국 피드가 나온다 — 브라우저에서 확인했다.

검증

  • flutter analyze 무결점 / flutter test 162개 통과 / 포맷·dart:io 게이트 통과
  • 릴리스 웹 번들을 실제 Chrome 에서 기동: 웰컴 → 게스트 진입 → 커뮤니티 피드(실데이터) → 게시글 상세
  • 1357px 레일 + 460 컬럼 ↔ 500px 하단 탭바 전환 확인
  • /p/<uuid> 공유 진입 정상, /p/not-a-uuid 는 조회 없이 웰컴 폴백

여담으로 flutter analyze 는 통과했는데 dart2js 가 널 프로모션 에러를 잡았다(클로저에 캡처된 지역변수는 승격이 풀림). 위 웹 빌드 게이트가 바로 값을 한 사례라 해당 위치에 주석으로 남겼다.

남은 것

  • 레일 하단 "앱으로 보기" CTA — 스토어 등록 전이라 링크할 URL 이 없어 Phase C 로
  • Phase B: 토큰 저장(웹 localStorage 문제)·juso CORS·URL 라우팅 확장

🤖 Generated with Claude Code

seizeh and others added 19 commits July 26, 2026 14:49
웹은 앱 유입 퍼널로 만든다(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>
숨기면 앱에서 뭘 더 할 수 있는지 알 길이 없어 유입으로 이어지지 않는다.
보여주고 누르면 권하는 쪽으로 뒤집는다(웹 3탭 → 5탭 노출).

  · visibleTabs = 아이콘으로 노출할 탭(5개, 플랫폼 무관)
  · appOnlyTabs = 웹에서 {지도, 채팅} — 누르면 AppInviteDialog, 화면 전환 없음
    (알약도 안 움직인다). 내용 위젯도 만들지 않아 웹 구현 없는 지도 플랫폼뷰가
    붙는 것을 막는다.
  · contentTabs = 실제 화면이 있는 탭. IndexedStack 은 이쪽 인덱스를 쓴다.
  · 딥링크(tabRequest)가 앱 전용 탭을 가리키면 조용히 무시 — 사용자가 누른 것이
    아니므로 안내를 띄우지 않는다.

AppInviteDialog 는 AuthWallDialog 와 같은 시각 언어. 스토어 주소
(Env.storeUrlIos/storeUrlAndroid, --dart-define)가 비어 있으면 — 출시 전
기본값 — 설치 버튼 대신 '출시 준비 중' 안내만 보여준다(죽은 링크 방지).
share-view Edge Function 의 STORE_URL_* 와 같은 값을 넣으면 된다.

덤으로 withTopicParticle(labels.dart) — '지도은(는)' 같은 괄호 표기 대신
받침을 보고 은/는을 고른다. 테스트 4건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: 웹에서 게시글 상세의 영상이 잘린다.

원인은 점진 블러다. 하단 패널 블러는 카드 문법을 따라 **같은 소스를 한 겹 더**
그려 ImageFiltered+ShaderMask 를 먹이는데(#184), 그 사본이 영상일 때 웹에서
깨진다. video_player_web 은 영상을 HtmlElementView(DOM <video>)로 그리고,
플랫폼 뷰는 캔버스 밖에서 합성되므로

  · ImageFiltered·ShaderMask 가 먹지 않고
  · ClipRect 도 따라오지 않는다

→ 블러 사본으로 만든 두 번째 <video> 가 진짜 영상 위로 삐져나와 잘려 보였다.

웹에서는 포스터(정지 프레임)를 블러 원본으로 쓴다 — 이미 있던 '초기화 전'
폴백 경로로 흘려보내면 된다. σ8 로 뭉개지는 하단 패널이라 정지 프레임과
구분이 거의 안 된다. 네이티브는 기존 동작 그대로(사본 방식 유지).

post_media_hero._blurSource 와 review_detail_screen 둘 다 같은 패턴이라 함께 고침.
post_card 는 원래 이미지를 블러해서 영향 없음.

검증: 배포본에서 영상 게시글 상세 진입 — 영상이 꽉 차고 잘림 없음, 하단 패널
블러도 경계 없이 페이드. analyze 무결점, 테스트 166개 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
앞 커밋(블러 원본을 포스터로)으로 사본 <video> 는 사라졌지만 잘림이 남아 있었다.
DOM 실측으로 진짜 원인을 잡았다.

  <video> 실제 렌더 박스 : 460×818, y=-50  (뷰포트 위아래로 50px 씩 초과)
  Flutter 가 잡은 플랫폼뷰 호스트 : 460×7

영상은 원본 크기(1080×1920) SizedBox 를 FittedBox(cover, clipBehavior:hardEdge)로
확대해 채우는데, video_player_web 의 영상은 HtmlElementView(DOM <video>)이고
플랫폼 뷰는 캔버스 밖에서 합성되므로 **클립이 먹지 않는다**. 넘치는 부분이
잘리지 않고 그대로 박스 밖으로 흘러 위아래 UI 를 덮었다.

웹은 박스를 영상 비율에 맞춰(AspectRatio) 애초에 넘칠 것이 없게 한다. cover 가
contain 이 되어 레터박스가 생기지만 뒤에 검정 배경이 이미 깔려 있어 자연스럽고,
잘림보다 낫다. 네이티브는 FittedBox 그대로.

post_media_hero 는 _videoSurface 로 분기를 모으고, review_detail_screen 도 같은
패턴이라 함께 고침. media_widgets 의 전체화면 플레이어는 원래 AspectRatio 라 무관.

검증(DOM 실측, 폭 1200): 404×718 · y=0 · 위아래 초과 없음. 404/718 이 정확히
1080/1920 이라 비율도 보존된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: /p/<postId> 로 들어왔는데 상세가 안 열리고 피드에 머문다(간헐).

_openInitialWebLink 는 첫 프레임 직후 한 번만 시도하고 실패하면 조용히 포기했다.
콜드 로드에서는 canvaskit(2MB)·main.dart.js(1.3MB) 다운로드와 Supabase 첫 요청이
겹쳐 일시 실패하기 쉽다. openFromPush 는 같은 이유로 이미 재시도·대기를 갖고
있는데(주석: "백그라운드 복귀 직후엔 첫 요청이 일시 실패하곤 한다") 이쪽만 없었다.

  · 네비게이터 준비를 최대 2초 대기(첫 프레임 직후라 보통 즉시).
  · fetchPost 는 **예외일 때만** 최대 3회 재시도(400ms 간격). 결과가 null 이면
    (삭제·비공개) 재시도해봐야 소용없으므로 바로 빠진다.
  · 그래도 못 열면 말없이 피드로 떨구지 않고 토스트로 이유를 알린다.

검증: 배포본에서 유효한 공유 링크 진입 정상(영상 게시글 상세까지). 폭 430·620·
1200 에서 모두 확인. 다만 '못 찾음' 경로의 토스트는 화면에 뜨는 것을 확인하지
못했다 — 재시도·대기 자체는 동작하나 토스트 표시 여부는 미검증(원인 미상).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
전면 점검(1440폭)에서 나온 것 중 기능·디자인에 직접 걸리는 3건.

(1) 상세 위에서 레일이 먹통 — 레일은 본문 컬럼 바깥(Navigator 위)에 있어 게시글
    상세가 열려도 계속 보인다. 그 상태로 누르면 아래 탭만 바뀌고 화면은 상세
    그대로라 "눌러도 아무 일도 안 일어남"으로 보였다. 데스크톱 전용 증상이다
    (하단 바는 상세에 가려 이 상황 자체가 없다).
    → 레일 탭은 _selectFromRail 로 분리해 MainScreen 위 라우트를 먼저 닫는다.

(2) 카테고리 칩 도달 불가 — 기본 dragDevices 는 터치·스타일러스뿐이라 마우스로
    가로 목록을 끌 수 없다. 칩이 컬럼 밖으로 잘리는데 넘길 방법이 없어 뒤쪽
    '입양'·'자유' 카테고리를 아예 못 썼다.

(3) 데스크톱 스크롤바 — 앱에 없는 요소가 컬럼 바깥에 뜬다.

(2)(3)은 웹 전용 ScrollBehavior 하나로 함께 잡는다(app_scroll_behavior.dart).
네이티브는 지정하지 않아 무변경.

검증(배포본): 상세 열고 레일 탭 → 상세가 닫히며 탭 전환. 칩을 마우스로 끌어
'입양'·'자유'까지 도달. 스크롤 후 스크롤바 없음.

점검에서 나온 나머지(레일-본문 간격, 정보 밀도)는 데스크톱 디자인 방향 결정에
종속되므로 보류.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
실제 로그인 상태로 점검하다 발견. 로그인 후 데스크톱에서 좌측 레일도 하단 바도
없어 **이동 수단이 아예 없다**(게스트일 때는 정상).

MainScreen 은 initState 에서 navRail 에 자기 설정을 올리고 dispose 에서 null 로
지운다. 그런데 로그인은 pushAndRemoveUntil 이라 순서가

  새 MainScreen.initState → navRail = 새 설정
  게스트 MainScreen.dispose → navRail = null      ← 방금 올린 것까지 지움

이 된다. 웰컴에서 게스트로 둘러보다 로그인하는 흐름(가장 흔한 경로)에서 항상
재현된다. 하단 바는 MainScreen 안에 있어 멀쩡하지만, 넓은 화면에서는 하단 바를
안 그리므로 결과적으로 내비게이션이 0 이 된다.

dispose 는 **자기가 올린 설정일 때만** 지운다(identical 비교).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
지역인증을 웹에서 하면 법적 문제 소지가 있다는 지적을 반영. 지금까지는 웹에서
위치 화면에 '도달할 수 없다'에만 기대고 있었는데, 그 전제에 기대지 않는다.

  · LocationService.getCurrentPosition 이 웹에서 즉시 deniedForever 를 반환한다.
    호출부(글쓰기·지역인증·지도·촬영인증)는 모두 웹에서 도달 불가지만, 경로가
    하나라도 열리면 브라우저 위치 권한 프롬프트가 뜬다 — 그것부터 막는다.
    (신뢰성 이유도 같다: 브라우저 Geolocation 에는 isMocked 가 없어 조작을 못
     거른다 — 동네 인증 0017 의 전제가 무너진다.)

  · 프로필 편집의 '활동 지역' 행이 웹에서 **눌리는데 아무 일도 안 나는** 상태였다
    (Phase A 에서 onTap 만 null 로 막고 표시는 그대로 뒀다). 라벨을 '앱에서'로
    바꾸고 누르면 AppInviteDialog 로 안내한다. 하단 설명도 웹 문구로.

  · 커뮤니티 글쓰기의 동네 인증 게이트도 웹이면 앱 안내로. 현재 웹에는 글쓰기
    FAB 자체가 없어 도달하지 않지만 경로가 열려도 안전하게.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
게시글 공유는 이제 go.pawmate.kr/s 에서 곧장 웹앱이 뜬다(서버 쪽은 pmdb
feat/share-view-app-shell). 주소에는 토큰만 있고 토큰→게시글 해석은 service_role
전용이라 웹앱이 스스로 못 하므로, 서버가 <meta name="pm-post"> 로 실어 보낸다.

  · utils/web_link_meta.dart(조건부 export) — 그 메타를 읽는다. 네이티브는 null.
  · initialSharedPostId 가 주소 /p/<uuid> → 주입 메타 순으로 본다. 주입값도
    uuid 형태를 검사한다.
  · web/_headers — 셸은 go.pawmate.kr 오리진에서 뜨고 <base href> 로 에셋만
    app.pawmate.kr 에서 받으므로 canvaskit.wasm 등이 교차 출처 fetch 가 된다.
    전부 공개 정적 자산이라 * 로 연다.

케어리포트는 이 경로를 쓰지 않는다 — 수신자가 '내 아이 기록을 받을 보호자'
한 명이라 웹 탐색이 아니라 앱 설치·계정 연결이 목적이고, 그 연결은 앱에만 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
로그인 상태 점검에서 발견. 내비게이션에서 채팅 탭을 눌렀을 때는 앱 설치 안내가
뜨는데, **유저 프로필의 '채팅' 버튼**을 누르면 채팅방이 그대로 열렸다(실제로
방이 열리는 것까지 확인). 게시글 상세의 '채팅'과 지원자 목록도 같은 경로다.

탭만 막고 진입로를 안 막은 탓이다. 진입로가 셋(프로필·게시글 상세·지원자 목록)
이라 각각 고치는 대신 공통 함수 openDirectChat 한곳에서 막는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
go.pawmate.kr/s 에서 웹앱 셸을 <base href> 만 바꿔 내려주는 방법을 배포해봤더니
브라우저에서 앱이 시작하자마자 죽었다.

  SecurityError: Failed to execute 'replaceState' on 'History':
  URL 'https://app.pawmate.kr/s?…' cannot be created in a document
  with origin 'https://go.pawmate.kr'

Flutter 라우터가 히스토리를 조작할 때 교차 출처 base 를 브라우저가 막는다(서비스
워커 등록도 동일). flutter.js 에는 에셋 기준을 base href 와 분리하는 설정이 없어
(모두 document.baseURI 기준) 우회할 수 없다. 프로덕션은 시크릿을 즉시 해제해
서버 렌더링으로 되돌렸고, 지금은 302 방식으로 다시 켰다.

  · 셸 방식 전용이던 web_link_meta*(주입 메타 수신)와 web/_headers(교차 출처
    에셋용 CORS)를 제거 — 302 는 동일 출처라 둘 다 불필요하다.
  · initialSharedPostId 는 주소 /p/<uuid> 만 본다.

서버 쪽 UA 분기는 pmdb feat/share-view-redirect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
매장에 나눠줄 QR 을 찍으면 미리보기를 거치지 않고 **그 매장의 후기 작성 화면**이
바로 열린다. 손님이 QR 을 찍는 목적이 후기이기 때문이다(서버 쪽 302 는 pmdb).

대상을 업체 계정이 아니라 **시설(facility)** 로 잡은 게 핵심이다. QR 을 뿌릴 매장
대부분은 아직 업체 인증 전이라 프로필이 없지만, 시설 행은 공공데이터로 이미
존재하고(전국 24,552곳) 후기도 시설에 달린다. 나중에 그 매장이 인증하면
business_profiles.matched_facility_id 로 같은 시설에 붙어 수정 권한을 갖는다 —
QR 을 먼저 뿌려도 주인이 나중에 나타나는 순서가 성립한다.

  · web_link — `/p/`(게시글)·`/u/`(업체 프로필)·`/r/`(후기 작성) 세 딥링크를
    _idAfter 로 통일. 서로 섞이지 않는 것까지 테스트로 잠갔다.
  · FacilityRepository.fetchById — 시설 1건 조회. RPC 가 아니라 테이블 직접
    읽기다(RLS `using (true)` + anon SELECT, 반경 RPC 와 달리 좌표가 불필요).

검색이 비로그인에서 통째로 죽어 있던 것도 함께 고친다. SocialRepository._uid 가
게스트에서 StateError 를 던지는데 searchUsers/listBusinesses 가 '나 제외' 필터에
그걸 쓰고 있었다 — 검색 탭 기본 화면(업체 목록)부터 예외로 죽었다. uid 가 null
이면 제외 조건과 팔로우 표시를 건너뛴다.

검색 결과에 **매장** 섹션도 추가했다. 시설이 곧 '주인 없는 프로필' 이라 상호로
찾아 후기까지 갈 수 있어야 하는데, 지금까지 검색 탭은 사용자·펫만 찾았다.

web_link.dart 의 web_link_meta import 제거는 3b09cf5 의 누락 보완이다 — 그 커밋이
web_link_meta.dart 를 지우면서 import 를 남겨 HEAD 가 컴파일되지 않는 상태였다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
회원가입 없이 후기를 남길 수 있게 한다. 쓰기 전에 가입을 요구하면 대부분
이탈하므로, 다 쓰고 **'게시'를 누르는 순간에만** 번호 인증을 청한다. 인증을
취소해도 별점·본문·사진이 그대로 남는다(초안 보존).

간이 회원은 비회원 취급이다. 서버가 users.status='lite' 로 두어 app.uid() 에서
제외하므로 채팅·게시글·펫 등 다른 기능은 전부 자동으로 막히고, 후기 작성 하나만
app.uid_lite() 로 뚫려 있다(pmdb 20260727100000). 세션도 저장하지 않는다 —
토큰은 메모리에만 있고 게시 직후 버리며, 다음 후기를 쓰려면 인증을 다시 받는다.

  · SessionManager — _liteToken/_liteUserId(메모리 전용) + beginLiteSession/
    endLiteSession. isLoggedIn 은 여전히 false 라 앱은 계속 게스트로 동작한다.
  · storage_service — 업로드 경로의 uid 를 storageUserId 로. 버킷 RLS 가 첫 폴더를
    검사하므로 토큰 주인과 일치해야 한다.
  · facility_review_repository — 로그인 판정을 SessionManager.user 가 아니라
    canWriteReview 로. user 가 없는 게 간이 회원의 정상 상태다.
  · facility_review_screen — 비로그인은 사진을 로컬에 들고 있다가 인증 후 올린다
    (Storage RLS 가 `<uid>/...` 라 인증 전에는 쓸 수 없다). 동영상은 포스터 생성이
    얽혀 있어 간이 후기에서 제외하고 안내한다.
  · user_profile_screen — 업체 프로필의 후기 작성 로그인 게이트 제거.

동의는 체크박스로 명시적으로 받는다. 전화번호는 개인정보이고, 나중에 같은 번호로
정식 가입할 때 이 후기들을 그 계정에 잇는 것까지가 수집 목적이라 그 목적이 동의
항목에 적혀 있어야 한다(서버도 동의 없으면 거부). 전문은 pmlegal 07 을 에셋으로
번들해 TermsScreen.liteReview 로 띄운다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seizeh

seizeh commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

추가: 매장 QR 후기 동선 + 간이 회원 (커밋 2개)

857e76e · b739bfd

서버 쪽은 pmdb#132, 약관 정본은 pmlegal#14 입니다. 이미 프로덕션에 배포된 상태입니다(DB·엣지 함수·Cloudflare Pages).

매장 QR 이 후기 작성으로 직행

QR 을 찍으면 미리보기를 거치지 않고 그 매장의 후기 작성 화면이 바로 열립니다. 대상이 업체 계정이 아니라 시설(facility) 인 게 핵심인데, QR 을 뿌릴 매장 대부분이 아직 업체 인증 전이라 프로필이 없기 때문입니다. 시설 행은 공공데이터로 이미 있고(전국 24,552곳, 동탄 190곳) 나중에 인증하면 같은 시설에 붙습니다.

/p/(게시글) · /u/(업체 프로필) · /r/(후기 작성) 세 딥링크를 _idAfter 로 통일하고, 서로 섞이지 않는 것까지 테스트로 잠갔습니다.

간이 회원 — 게시 시점에만 인증

다 쓰고 '게시'를 누르는 순간에만 번호 인증을 청합니다. 인증을 취소해도 별점·본문·사진이 남습니다. 비로그인은 사진을 로컬에 들고 있다가 인증 후 올립니다(Storage RLS 가 <uid>/... 라 인증 전에는 쓸 수 없습니다). 동영상은 포스터 생성이 얽혀 있어 간이 후기에서 제외하고 안내합니다.

세션은 저장하지 않습니다 — 토큰은 메모리에만 있고 게시 직후 버립니다. isLoggedIn 은 여전히 false라 앱은 계속 게스트로 동작하고, 서버가 status='lite' 로 후기 외 전부를 막습니다.

검색이 비로그인에서 통째로 죽어 있던 것

SocialRepository._uid 가 게스트에서 StateError 를 던지는데 searchUsers/listBusinesses 가 '나 제외' 필터에 그걸 쓰고 있었습니다 — 검색 탭 기본 화면부터 예외로 죽었습니다. 결과에 매장 섹션도 추가했습니다.

참고 — HEAD 컴파일 불가 상태였습니다

3b09cf5web_link_meta.dart 를 지우면서 web_link.dart 의 import 를 남겨뒀습니다. 857e76e 에 보완이 함께 들어갔습니다.

검증

flutter analyze 무결점, 테스트 176개 통과(딥링크 테스트 10개 추가). 배포 후 실물 확인 — 사람 UA → 302 /r/<facilityId>, 크롤러 → OG 태그 정상.

CI 가 두 가지로 막혀 있었다.

  · 포맷 — 간이 회원 작업 파일 5개가 dart format 미적용이었다(기계적 수정).
  · analyze — 직전 revert 커밋에서 web_link.dart 수정본이 빠져 삭제된
    web_link_meta.dart 를 계속 import 하고 있었다. `git add -A` 에 이미 삭제된
    경로를 함께 넘겨 명령이 통째로 중단됐는데 stderr 를 버려 놓쳤다.
    (이후 커밋에 딸려 들어가 지금은 해소된 상태다.)

docs/web-port.md 는 결정 6 을 302 방식으로 확정하고, 폐기한 셸 서빙 방법과
그 이유(교차 출처 base href 에서 history.replaceState 가 SecurityError)를
기록으로 남긴다 — 같은 길을 다시 시도하지 않도록.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seizeh
seizeh merged commit f506fb2 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant