Skip to content

앱 부팅이 쿠키 동기화에 묶여 빈 웹뷰로 고착되는 문제 방어 - #530

Open
m-a-king wants to merge 14 commits into
devfrom
fix/webview-cookie-sync-timeout
Open

앱 부팅이 쿠키 동기화에 묶여 빈 웹뷰로 고착되는 문제 방어#530
m-a-king wants to merge 14 commits into
devfrom
fix/webview-cookie-sync-timeout

Conversation

@m-a-king

Copy link
Copy Markdown
Contributor

Situation

  • prod 에서 앱을 켰는데 화면이 멈춘 채 넘어가지 않는 상태가 발생했다. 같은 시각 다른 팀원들은 정상이었다.
  • 서버를 먼저 확인했으나 문제 로그는 전혀 없었고, 같은 시간대 다른 사용자 요청은 정상 처리되고 있었다.
  • 결정적 단서는 굳어 있는 동안 서버에 요청이 한 건도 오지 않는다는 것이었다. nginx 로그 기준 3분 35초 동안 해당 사용자 요청이 0건이었고, 배경에서 도는 알림 구독(SSE) 재연결만 30초 주기로 들어왔다. prod 와 dev 양쪽 모두 무음이었다.

Task

  • 앱이 요청조차 만들지 않는다면 멈춘 지점은 API 호출 이전, 즉 앱 셸의 부팅 단계다. 그 지점을 코드에서 특정하는 것이 첫 과제였다.
  • 두 번째 과제는 제약 아래에서의 선택이었다. 실기기와 시뮬레이터 접근이 모두 없어(맥에 Xcode 미설치) 고착 자체를 재현할 수 없었다. 재현 없이도 안전하게 넣을 수 있는 방어가 무엇인지가 판단 대상이었다.

Action

부팅 체인에서 멈추는 지점 특정

apps/app/app/index.tsx 의 웹뷰 소스는 동기화 완료 여부에 묶여 있다.

source={isSynced ? { uri: webviewUri } : { html: '' }}

부팅은 한 줄로 엮여 있다. 빈 웹뷰 로드 완료 → 쿠키 동기화 실행 → 완료 시 isSynced 를 세워 실제 URL 로 교체. 중간 한 곳이라도 완료 신호를 내지 않으면 웹뷰는 빈 화면인 채로 남고, 그 상태에서는 어떤 API 도 호출되지 않는다. 관측된 "요청 0건"과 정확히 일치한다.

기존 방어가 닿지 않는 자리

useWebviewCookieSync 는 이미 무한 스플래시를 막으려 했지만, 그 방어는 promise 가 reject 될 때만 동작한다.

상황 기존 동작 결과
동기화가 예외로 실패 .catch.finally 실행 부팅 계속
동기화가 pending 으로 매달림 .finally 가 불리지 않음 빈 웹뷰로 고착

sync() 안에서 기다리는 네이티브 호출(CookieManager, TokenStorage)에는 타임아웃이 없다. 같은 파일 주석이 이미 iOS 쿠키 저장소의 취약성을 언급하고 있고, 특히 갱신 실패 분기에서만 CookieManager.clearAll 을 연속 호출한다.

최소 방어 추가

postTokenRefresh 에는 이미 5초 타임아웃이 있고 주석도 "부팅이 이 요청에 묶여있어 네트워크가 멈추면 무한 대기하므로 제한" 이다. 같은 문제의식이 한 곳에만 적용돼 있었으므로, 그 방어를 sync() 전체로 넓혔다. 어느 호출이 매달리든 8초 뒤에는 부팅이 진행된다.

검토 후 채택하지 않은 안

판단
매달릴 수 있는 개별 호출마다 타임아웃 변경 범위가 넓고, 새 await 이 추가되면 같은 함정이 다시 열린다
갱신 실패 분기의 clearAll 만 방어 가장 유력한 후보지만 추정이다. 다른 호출이 매달리면 그대로 재발한다
sync() 전체를 타임아웃으로 감쌈 (채택) 두 줄 변경으로 어느 지점이든 덮는다. 매달림 지점을 특정하지 못한 현 상황에 맞다

Result

  • 타임아웃이 걸리면 쿠키가 심기지 않은 채 웹뷰가 뜨므로 로그인 화면이 보일 수 있다. 사용자가 다시 로그인하면 되는 상태이고, 아무것도 못 하는 빈 화면보다 낫다는 판단이다.
  • 이 변경은 고착을 막을 뿐 원인을 없애지 않는다. 어느 await 이 매달렸는지는 여전히 미확정이다.

앱 환경에서의 확인 요청

작성자 환경에 Xcode 와 실기기가 없어 앱에서의 동작은 검증하지 못했다. 개발 빌드가 가능한 분이 아래를 봐주시면 좋겠다.

  • 정상 부팅에 회귀가 없는지 (타임아웃이 걸리지 않는 일반 경로)
  • 고착 상황을 만들 수 있다면, 8초 뒤 실제로 웹뷰가 뜨는지
  • 8000ms 라는 값이 적절한지. 안에 포함된 토큰 갱신이 5초 타임아웃이라 그보다 크게 잡았다

검증한 것과 못 한 것

  • dev 에서 고착의 선행 조건은 재현했다. 세션 식별자가 없는 옛 형식의 갱신 토큰을 만들어 넣으면 서버가 401 을 주는 것까지 확인했고, prod 에서 관측된 것과 같은 거부 사유였다.
  • 같은 401 을 웹은 정상 처리한다. 크롬에서 그 토큰으로 진입하면 로그인 화면으로 리다이렉트된다. 이 대조로 문제 범위가 apps/web 이 아니라 apps/app 으로 좁혀졌다.
  • 다만 앱 셸의 고착 자체는 크롬에 해당 코드가 없어 재현 불가였다. 그래서 위 확인 요청이 필요하다.

후속

  • 매달림 지점이 특정되면 그 호출에 맞는 방어로 좁힐 수 있다.
  • 고착을 부르는 401 조건 자체는 갱신 토큰 유효기간이 지나면 사라진다. 이 방어는 그 이후의 다른 401 에도 적용된다.

연관 이슈

kanghaeun and others added 14 commits August 13, 2026 16:12
* feat: 위시 아이템 INCOMPLETE 상태 대응

- 서버가 추출 결과를 일부만 채웠을 때 INCOMPLETE 를 내려준다 (TeamPiKi/core#945). 기존 코드는 FAILED·PENDING·PROCESSING 이 아니면 전부 정상 카드로 그려, 이름·가격이 빈 칸인 카드가 보이고 채우라는 유도가 없었다
- 위시 그리드에서 INCOMPLETE 를 FAILED 와 같은 편집 유도 카드로 보내되 문구만 "일부만 가져왔어요" 로 가른다
- name·price 를 nullable 로 바꾸면서 카드 컴포넌트도 nullable 을 받게 했다. INCOMPLETE 를 앞에서 걸러 실제로 빈 값이 정상 카드에 들어가지는 않는다
- 토너먼트: 담기 후보에서 INCOMPLETE 를 제외하고(서버가 출전을 막는다), 바스켓에서는 클릭 가능하게 둔다(값을 채워야 하므로)

* feat: 토너먼트 아이템 상세에 incomplete 상태 처리 추가

* refactor: 리터럴/상수로 섞어 쓰던 곳을 ITEM_STATUS 로 통일

* feat: 토너먼트 시작 차단에 INCOMPLETE 포함

* feat: INCOMPLETE 를 수정 화면으로 링크

* chore: 불필요한 파싱 상태 주석 정리

---------

Co-authored-by: kanghaeun <xgkg0330@jnu.ac.kr>
* feat: INCOMPLETE 파싱 알림 타입·SSE 상태 대응

- 서버가 파싱이 일부만 끝난 경우 ITEM_PARSING_INCOMPLETE 알림과 status=INCOMPLETE SSE 를 보낸다 (TeamPiKi/core#945). 모르는 타입이라 switch default 로 빠져 딥링크가 동작하지 않았다
- 알림함·푸시·SSE 세 라우팅에 케이스를 더한다. 목적지는 기존 파싱 알림과 같다(위시 또는 토너먼트 담기 화면)
- SSE 토스트는 실패와 갱신 대상이 같아 케이스를 합치되 문구만 info 로 가른다 — 실패가 아니라 "채워 주세요" 안내라서다

* chore: 불필요한 파싱 상태 주석 정리

---------

Co-authored-by: kanghaeun <xgkg0330@jnu.ac.kr>
Co-authored-by: kanghaeun <145974230+kanghaeun@users.noreply.github.com>
* feat: 소셜 로그인 사용자 취소 메시지 타입 추가 (APP_RES_SOCIAL_LOGIN_CANCEL)

* fix: 소셜 로그인 취소 시 에러 토스트 노출되는 문제 수정 (앱)

* fix: 소셜 로그인 취소 시 에러 토스트 노출되는 문제 수정 (웹)

* fix: 구글 로그인 취소를 반환값으로 처리 (isCancelledResponse)

---------

Co-authored-by: soyeong <mb535622@sookmyung.ac.kr>
* refactor: 아이템 status 비교를 ITEM_STATUS 상수로 통일

리터럴로 남아 있던 아이템 status 비교를 ITEM_STATUS 상수로 교체.
notification.ts 의 status 유니온도 다른 타입 파일과 동일하게
ITEM_STATUS 파생 형태로 맞춤.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: 토너먼트 status 비교를 TOURNAMENT_STATUS 상수로 통일

리터럴로 남아 있던 토너먼트 status 비교·할당을 TOURNAMENT_STATUS
상수로 교체. 탭별 상태 매핑 상수도 함께 정리.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: 정보 수집 실패 위시에 삭제 버튼 노출

* style: 위시 정보 수정 화면 경고 배너-이미지 간격 정합 (8px → 16px)
* chore: 인스타 랜딩 유입 소스를 UA 로 판정해 utm 파라미터 제거

인스타 바이오에 `https://piki.day/open` 만 걸 수 있도록, `?utm_source=instagram` 없이도
인앱 브라우저 UA(`isInstagramBrowser`)로 유입 소스를 판정한다.

- 바이오 탭은 인스타 인앱 브라우저로 열리므로 랜딩 도달 시점에 UA 토큰이 남아 있다
- 쿼리로 들어온 `utm_source` 가 있으면 그쪽이 우선 — 다른 채널 확장 시 그대로 사용
- GA4 `landing_view` 의 `source`, Play 스토어 `referrer` 는 동일하게 유지된다

* chore: 미사용 open.piki.day 랜딩 서브도메인 제거

인스타 링크를 `piki.day/open` 으로 직접 걸기로 해서 서브도메인 경로를 걷어낸다.

- `proxy.ts` 의 `open.*` 루트 rewrite 제거
- `isLandingHost` 삭제 — 앱의 associatedDomains 에 등록된 적 없는 호스트였다
- `landingHost.ts` → `serviceHost.ts` 로 이름 변경. 남은 `toServiceHost` 는
  Vercel 프리뷰 등 미등록 호스트를 프로덕션으로 정규화하는 역할만 한다

* fix: Host 헤더를 엄격히 파싱해 userinfo 오리진 조작 차단
* fix: 탈퇴 후 죽은 토큰이 남아 로그인 페이지가 홈으로 되돌리던 문제 수정

- 탈퇴 성공 시 웹 브라우저에서도 서버 액션으로 httpOnly 인증 쿠키 폐기
- 쿠키 폐기 서버 액션을 clearAuthCookies 로 추출해 로그아웃과 공유
- proxy 의 쿠키 폐기 발동 조건에 withdrawn-account action 추가

closes #467

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 탈퇴 후 로그인 페이지로 직행하도록 수정

루트는 스플래시·온보딩 판정 경로라 탈퇴 직후 앱 첫 실행과 같은 화면을 거친다.
탈퇴는 세션 종료이므로 로그아웃과 동일하게 로그인으로 보낸다.

* fix: 앱에서 온보딩이 노출되지 않던 문제 수정

앱은 웹뷰를 /login 으로 바로 열어 스플래시를 거치지 않는데, 온보딩 진입 판정이
SplashClient 안에 있어 앱 사용자는 온보딩을 볼 수 없었다.

- 판정을 로그인 진입 시점(OnboardingGate)으로 옮겨 웹·앱 두 경로 모두 게이트를 통과
- 스플래시는 브랜드 연출만 담당하도록 목적지를 로그인으로 단일화
- 쿼리가 붙은 진입(세션 만료 안내, 복귀 경로)은 안내 유실·redirect 소실을 막기 위해 제외

* fix: 인증 경로에서 죽은 세션이 정리되지 않던 문제 수정

handleSessionExpired 가 /login·/ 에서 조기 반환해 죽은 쿠키가 남았다. 웹뷰의 FCM
동기화는 모든 페이지에서 돌아 이 경로로 401 을 받는데, 쿠키가 남으면 다음 진입에서
로그인 페이지가 멤버로 오인해 홈으로 되돌려보낸다.

- 인증 경로에서는 리다이렉트 대신 세션 폐기만 수행 (자기 자신 루프는 그대로 회피)
- 소셜 로그인 코드 교환 중인 /auth/callback 은 새 토큰을 덮어쓰지 않도록 제외
- 폐기 로직을 clearAuthSession 으로 모아 탈퇴 훅과 공유
- 서버 액션을 앱 전역에서 쓰게 되어 App Router 밖(actions/)으로 이동

* chore: 온보딩 노출 플래그 변수명 오타 수정

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor: 하단 바를 공통 CTA 컴포넌트로 통일

- open/open-app 스플래시의 직접 fixed 구현을 BottomCta 로 교체
- 담기 화면 in-flow 버튼 블록을 BottomCta 로 교체
- 98px 을 --spacing-bottom-cta 토큰으로 뽑고 pb-[98px] 을 pb-bottom-cta 로 교체

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: 토스트가 하단 CTA와 겹치는 문제

* test: 전역 토스트 offset 규칙 e2e 회귀 테스트 추가

* fix: e2e 타입 import 방식 수정

* fix: 담은 개수 배지를 인디케이터와 CTA 사이 중앙 정렬

* test: 토스트 offset e2e 를 실제 CTA 페이지 기준으로 보강

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jung Sun A <amber0809@naver.com>
* fix: 주최자·본인 아이템만 토너먼트 바스켓에서 클릭 가능하도록 제한

* feat: 상품 정보 화면에 조회 전용(readOnly) 모드 추가

* fix: 주최자·본인이 아닌 토너먼트 아이템은 조회 전용으로 노출

* feat: 토너먼트 아이템 INCOMPLETE 상태 지원 및 수정 진입 허용
시작 조건을 못 채운 토너먼트의 /match 로 주소를 직접 바꿔 들어가면
검증 없이 start 를 호출해 서버 400(TOURNAMENT-007) 이 나고
전역 에러 화면으로 떨어졌다.

- (예방) 이미 조회한 토너먼트 정보로 대기실 버튼과 같은 기준을 먼저 검사
- (방어) start 가 007/013/014 를 주면 throw 대신 대기실로 이동
- 두 경로 모두 ?action=tournament-not-startable 로 사유 안내
앱을 켜면 화면이 멈춘 채, 서버에는 요청이 한 건도 오지 않는 상태가 prod 에서 관측됐다.

부팅은 sync() 가 끝나야 isSynced 가 true 가 되고 그때 웹뷰 source 가
{ html: '' } 에서 실제 URL 로 교체되는 구조다. 기존 .finally 방어는 promise 가
reject 될 때만 동작해, 네이티브 호출이 pending 으로 남으면 걸리지 않는다.
sync() 안의 CookieManager · TokenStorage 호출에는 타임아웃이 없다.

postTokenRefresh 는 이미 같은 이유(부팅이 요청에 묶임)로 5초 타임아웃을 두고 있어,
그 방어를 sync() 전체로 넓힌다. 타임아웃이 걸리면 쿠키가 심기지 않은 채 웹뷰가 떠
로그인 화면이 보일 수 있지만, 빈 화면으로 고착되는 것보다 낫다.
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
piki Ready Ready Preview Aug 19, 2026 12:18am

@m-a-king m-a-king added the fix Something isn't working label Aug 19, 2026
@m-a-king m-a-king self-assigned this Aug 19, 2026
@github-actions
github-actions Bot requested a review from soyeong0115 August 19, 2026 00:17
@github-actions github-actions Bot added the APP Good for newcomers label Aug 19, 2026
@github-actions

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@iOdiO89, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 06cba23b-c9b5-46f3-9ada-1d1ec81ed67a

📥 Commits

Reviewing files that changed from the base of the PR and between 53c4890 and b3a9952.

📒 Files selected for processing (73)
  • apps/app/hooks/useSocialLogin.ts
  • apps/app/hooks/useWebviewCookieSync.ts
  • apps/web/e2e/specs/common/toastOffset.spec.ts
  • apps/web/e2e/specs/tournament/tournamentItemAdd.spec.ts
  • apps/web/src/actions/clearAuthCookies.ts
  • apps/web/src/app/_components/SplashClient.tsx
  • apps/web/src/app/archive/tournament/_consts/tournamentTab.ts
  • apps/web/src/app/archive/wish/[id]/_types/wish.ts
  • apps/web/src/app/archive/wish/[id]/layout.tsx
  • apps/web/src/app/archive/wish/_components/WishContent.tsx
  • apps/web/src/app/archive/wish/_components/WishlistBottomBar.tsx
  • apps/web/src/app/archive/wish/_components/wish-grid/WishFailedCard.tsx
  • apps/web/src/app/archive/wish/_components/wish-grid/index.tsx
  • apps/web/src/app/login/_components/LoginButtons.tsx
  • apps/web/src/app/login/_components/OnboardingGate.tsx
  • apps/web/src/app/login/page.tsx
  • apps/web/src/app/mypage/_actions/logout.ts
  • apps/web/src/app/mypage/edit/_components/EditForm.tsx
  • apps/web/src/app/mypage/withdraw/_hooks/useDeleteMe.ts
  • apps/web/src/app/mypage/withdraw/page.tsx
  • apps/web/src/app/notification/_utils/getNotificationRoute.ts
  • apps/web/src/app/onboarding/_components/OnboardingClient.tsx
  • apps/web/src/app/open-app/_components/AppStoreRedirect.tsx
  • apps/web/src/app/open/_components/OpenLanding.tsx
  • apps/web/src/app/open/page.tsx
  • apps/web/src/app/play/[id]/_components/PlayClient.tsx
  • apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx
  • apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx
  • apps/web/src/app/tournament/[id]/create/_components/product-image/index.tsx
  • apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket-status/TournamentItemBasketStatus.tsx
  • apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentBasketItem.tsx
  • apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasket.tsx
  • apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasketCarousel.tsx
  • apps/web/src/app/tournament/[id]/create/by-wish/_components/ByWishContent.tsx
  • apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx
  • apps/web/src/app/tournament/[id]/item/[itemId]/_components/TournamentItemInfoScreen.tsx
  • apps/web/src/app/tournament/[id]/item/[itemId]/_types/tournamentItem.ts
  • apps/web/src/app/tournament/[id]/item/[itemId]/_utils/canEditTournamentItem.ts
  • apps/web/src/app/tournament/[id]/item/[itemId]/layout.tsx
  • apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts
  • apps/web/src/app/tournament/[id]/match/page.tsx
  • apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx
  • apps/web/src/app/tournament/[id]/result/page.tsx
  • apps/web/src/components/bottom-cta/index.tsx
  • apps/web/src/components/common/item-info-screen/ItemDetailView.tsx
  • apps/web/src/components/common/item-info-screen/ItemEditForm.tsx
  • apps/web/src/components/common/item-info-screen/ItemInfoCard.tsx
  • apps/web/src/components/common/item-info-screen/index.tsx
  • apps/web/src/components/common/wish-card/index.tsx
  • apps/web/src/components/toast/index.tsx
  • apps/web/src/consts/appLink.ts
  • apps/web/src/consts/item.ts
  • apps/web/src/consts/queryAction.ts
  • apps/web/src/consts/queryActionToast.ts
  • apps/web/src/consts/route.ts
  • apps/web/src/consts/tournament.ts
  • apps/web/src/hooks/useNativeLoginResult.ts
  • apps/web/src/hooks/useNotificationSSE.ts
  • apps/web/src/proxy.ts
  • apps/web/src/styles/globals.css
  • apps/web/src/types/item.ts
  • apps/web/src/types/notification.ts
  • apps/web/src/utils/clearAuthSession.ts
  • apps/web/src/utils/handleSessionExpired.ts
  • apps/web/src/utils/item.ts
  • apps/web/src/utils/landingHost.ts
  • apps/web/src/utils/pushNotificationRoute.ts
  • apps/web/src/utils/serviceHost.ts
  • packages/core/src/consts/appVersion.ts
  • packages/core/src/consts/webBridge.ts
  • packages/core/src/types/login.ts
  • packages/core/src/types/pushNotification.ts
  • packages/core/src/types/webBridge.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@iOdiO89

iOdiO89 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 51 minutes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

APP Good for newcomers fix Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants