Skip to content

iOS 앱 재시작마다 홈 즐겨찾기 조회가 로그인 토큰 없이 나가 거부되던 것에서 "계정이 기억된 동안 익명 요청 금지" 가드까지 (2026-09-07)

  • 기간: 2026-09-07 00:05 ~ 02:20 KST (세션 1개 d3e6f5f2 — 분석·Phase 0~3. 발견은 세션 dd9f7df4 릴리스 v0.17.0 반영 확인 프로브. 오너 go 원문: "Phase 2 포함해서 go" · "이거 최종 완료되면 0.17.1 스테이징으로 잡아줘")
  • 랜딩: PR #1308(Phase 0~3, 6e5127c2 2026-09-07 KST, CI 1회 초록(run 34048037777: verify·landing-queue 통과, full 레인은 CI 범위 판정으로 생략 — 로컬 ci:local --full 로 같은 묶음 통과)) — 마이그레이션·엣지 없음, Vercel 배포 = 릴리스 v0.17.1 PR #1303 58e9b047(2026-09-07 21:36 KST 머지, Production Deploy run 34122817046 성공, 태그 v0.17.1) — 프로브 2026-09-08 00:31 KST 이상 없음(§5)
  • 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1277 댓글(2026-09-07)
  • 정본: 규칙 "기기가 계정을 기억하는 동안 데이터 요청(/rest·/storage·/functions)은 절대 익명(공개) 키로 나가지 않는다" = src/react/services/authSessionGuard.ts 머리 주석 · 순서 규칙 = src/react/services/supabaseAuth.ts getClient()·awaitNativeAuthSessionReady
  • 도구: Production 프로브 스크립트(세션 scratchpad probe1~5.mjs, 레포 밖) — client_error_events·게이트웨이 로그(logs.all edge_logs)·Postgres 로그·인증 로그 4종 대조
  • 게이트: tests/react/authSessionReadyGate.native.test.mjs(iOS 재시작 재현: 계정 id 기억 + 메모리 세션 없음 + Keychain 복원 진행 중 → 조회 3건이 복원 뒤 사용자 토큰으로, 복원 1회, 교착 없음) · tests/react/authSessionGuard.test.mjs(가드 순수 4 + 웹 실제 SDK 흐름 1) · 로컬 통과 기록: npm run ci:local -- --full 2026-09-07 full 통과 — verify 5단계 · db reset · 스냅샷 --check · pgTAP 109파일/1899 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 37/37·viewport 14/14 · 11분 35초 (02:30 KST, HEAD c0b1670d; 첫 실행의 viewport 1건 실패는 main에 이미 고쳐진 #1250 e2e 달력 경계 결함)
  • 버그리포트: bug-report/bug-080-20260907.md
  • 계약: 없음(문서 계약 변경 없음)

Phase 현황

Phase내용상태
Phase 0재현 테스트 — iOS 저장소 흉내, 현재 코드에서 익명 헤더가 나감을 빨강으로 고정ed92fb28
Phase 1순서 고정 — getClient()가 Keychain 복원을 기다린 뒤 클라이언트를 내준다d8556a0e
Phase 2전수 가드 — SDK global.fetch에서 익명 강등 금지 + 오류 분류 4곳122b2a71
Phase 3ci:local --full → PR 1개 → CI 1회 → [v0.17.1 스테이징]✅ PR #1308 (6e5127c2)

1. 배경

오프라인 사용 지원(#1173, v0.15.0, 9/3)은 iOS 앱이 세션을 메모리에만 두고 Keychain에는 갱신 토큰만 두는 구조에서 "오프라인에도 내 계정으로 첫 화면을 그리기" 위해 기기에 계정 id(barbelic:auth-owner:v1)를 남기고, 그 id만으로 로그인 상태로 판정해 저장본을 먼저 그리게 했다(#1016 부팅 즉시 그리기와 결합). 그 뒤로 홈 화면에는 로그인 상태가 되자마자 서버를 조회하는 로더가 셋 있었다: 즐겨찾기 보드, 계정 정지 여부, 관리자 여부.

릴리스 v0.17.0 반영 확인 프로브(2026-09-07, 세션 dd9f7df4)가 client_error_events에서 오너 계정에만 하루 1~5회 쌓이는 get_user_exercise_favorites 42501을 발견해 이슈 #1277로 남겼다. 원문 가설은 두 갈래("익명으로 나감" / "함수 안 auth.uid() 빔")였다.

2. 문제 제기

iOS 앱을 다시 열 때마다 조회 3건이 로그인 토큰 없이 나갔다

게이트웨이 로그 2026-09-05 01:11:35.012Z: get_my_moderation_state_v1 401 · is_lift_guild_admin 401 · POST /auth/v1/token 200 · get_user_exercise_favorites 401이 같은 밀리초에, UA는 iPhone WKWebView. 0.7초 뒤 같은 함수들이 200. Postgres 로그는 permission denied for function … 3건(익명 역할). 앱 오류 이벤트 13건(9/3 15:08Z ~ 9/6 13:30Z)은 전부 42501·소요 0.15~1.5초. 다른 사용자 0건. 가설 ①(익명으로 나감)이 맞고 ②는 아니었다 — PostgREST가 함수 실행 전에 거부했다.

"로그인됨"의 기준이 앱과 SDK에서 달랐고, SDK는 토큰이 없으면 익명으로 강등했다

앱은 기기가 기억한 계정 id를 로그인으로 보고(authBootstrapPolicy.ts presence.status === "present" → 세션 확인 전 adopt), 기기 스냅샷이 프로필을 채우면 즐겨찾기 로더가, ownerId가 생기면 정지 여부가, 오버레이가 마운트되면 관리자 여부가 곧바로 조회했다. SDK(supabase-js 2.107)는 토큰 보유를 로그인으로 보며, 없으면 _getAccessToken: session?.access_token ?? supabaseKey로 공개 키를 붙여 보낸다. 둘 사이에 "토큰이 준비될 때까지 기다린다"는 다리가 없었다. 이 구조에서는 계정 id만 보고 먼저 조회하는 화면이 하나 늘 때마다 같은 사고가 하나 늘어난다(당시 3곳).

화면에 안 보여서 4일간 쌓였다

즐겨찾기는 실패하면 기기 캐시로 그리고, 복원 뒤 프로필 재조회가 즐겨찾기를 다시 불러 성공한다. 정지 여부는 실패 시 "정지 아님"으로 조용히 통과(fail-open), 관리자 여부는 그 부팅에서 false로 남았다. 정지·관리자 조회는 화면 관문(callScreenRpc)을 지나지 않아 오류 이벤트도 없었다.

3. 해결 방안

원칙 (오너 결정, 2026-09-07)

  • D1 "Phase 2 포함해서 go" — 순서 고정(Phase 1)만으로 관측된 13건은 사라지지만, 관문을 거치지 않는 경로와 웹의 "갱신 실패 → 익명 강등"까지 막는 전수 가드(Phase 2)를 함께 채택. 전역 지침 §22(땜질 대신 구조)와 #1195 전수 가드 원칙에 맞춘 것.
  • 릴리스: "최종 완료되면 0.17.1 스테이징으로" — 수정만이므로 patch.

접근

내용판단
A 즐겨찾기 로더만 부팅 뒤로 미루기 / 42501이면 재시도증상 자리 조건문✗ 정지·관리자·다음 화면에서 재발. 재시도는 익명 요청 한 번 더
B 순서 고정모든 서버 호출이 지나는 getClient()가 "계정 기억 + 세션 없음"이면 Keychain 복원(restoreNativeAuthSession, 중복 합치기 기존)을 기다림. 복원 실패는 재시도 가능 오류로 던져 사본 대체✓ Phase 1
C 전수 가드createClientglobal.fetch: 계정 기억 + 데이터 요청 + 익명 Authorization → 세션 준비 뒤 토큰 교체 / 없으면 LG_AUTH_SESSION_NOT_READY. /auth/v1 제외(교착 없음)✓ Phase 2
D supabase-js accessToken 옵션공식 토큰 공급 콜백✗ auth 클라이언트(로그인·갱신·이벤트)가 비활성화됨

4. 적용한 내용

Phase 0 — 재현 테스트 (ed92fb28)

tests/react/authSessionReadyGate.native.test.mjs: iOS 껍데기 흉내(webkit 메시지 핸들러 → barbelic:native-session-restore/persist/pkce-storage 이벤트 응답), 계정 id 키, 메모리 세션 없음, /auth/v1/token은 40ms 왕복. 부팅 절차(getAuthSession)와 로더 3개(client.rpc ×3)를 같은 순간 출발 → 즐겨찾기 요청의 Authorization이 공개 키로 나가는 것을 단언해 현재 코드에서 빨강. 두 번째 테스트(복원 1회·관문 재진입 없음)는 Phase 1의 안전망.

Phase 1 — 순서 고정 (d8556a0e)

supabaseAuth.ts: awaitNativeAuthSessionReady(네이티브 && 메모리 세션 없음 && 계정 id 기억 → restoreNativeAuthSession 대기)를 getClient()에 추가. Keychain에 갱신 토큰이 없으면(missing/corrupt) 계정 id도 지워 "준비 중"으로 계속 기다리지 않게. 인증 이벤트 구독은 서버 요청이 아니므로 ensureRawClient로(복원이 내는 TOKEN_REFRESHED를 놓치지 않게). 복원 경로는 인자로 받은 raw 클라이언트만 쓰므로 교착 없음(테스트로 고정).

Phase 2 — 전수 가드 (122b2a71)

  • authSessionGuard.ts(신규): createAuthSessionGuardedFetch({ fetch, supabaseKey, ownerRemembered, resolveAccessToken }) · AuthSessionNotReadyError(code LG_AUTH_SESSION_NOT_READY, status 0) · isAuthSessionNotReadyError(postgrest가 {message:"<이름>: …", code:""}로 감싼 모양도 인식) · isSupabaseDataRequestUrl.
  • supabaseAuth.ts: createClient(..., { global: { fetch: guardedFetch } })(window.fetch는 호출 시점에 읽어 오류 관측 래퍼를 그대로 지남) · ownerRemembered = readPersistedAuthSessionPresence().status === "present" · resolveGuardAccessToken = 네이티브 복원 대기 → 저장된 세션을 갱신 없이 읽기(만료면 즉시 null) → 저장본이 없을 때만 SDK getSession · isRetryableAuthError에 가드 오류 포함.
  • 분류 4곳: ownDataReplica.isOwnDataReplicaFallbackError(사본 대체) · connectivityAppAdapter.isConnectivityOutageError/shouldSuppressHandledConnectivityAppError(연결 장애) · directWorkoutWrite.classifyDirectWorkoutWriteError(대기 저장 retryable) · barbelicRepository.callScreenRpc(오류 이벤트에 코드 보존).
  • tests/react/authSessionGuard.test.mjs: 순수 가드 4건(토큰 교체·준비 안 됨 오류+분류 4곳·인증/타 서버/이미 토큰/미기억 불변·Request 객체/평범 헤더) + 웹 실제 SDK 흐름 1건(localStorage 세션 만료 + 갱신 네트워크 실패 → 익명 데이터 요청 0건, isRetryableAuthError true).

Phase 3 — 검증·랜딩

npm run ci:local -- --full full 통과 — verify 5단계 · db reset · 스냅샷 --check · pgTAP 109파일/1899 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 37/37·viewport 14/14 · 11분 35초 (02:30 KST, HEAD c0b1670d; 첫 실행의 viewport 1건 실패는 main에 이미 고쳐진 #1250 e2e 달력 경계 결함) → PR #1308 → CI 1회 초록(run 34048037777: verify·landing-queue 통과, full 레인은 CI 범위 판정으로 생략 — 로컬 ci:local --full 로 같은 묶음 통과) → 이슈·세션 [v0.17.1 스테이징].

주요 결정과 그 근거

  • 관문 대기 자리 = getClient(): 서버 호출 114곳 + 저장소·엣지 함수 호출이 전부 이 함수로 클라이언트를 받는다. 로더 3개를 각각 고치면 다음 로더에서 재발한다.
  • 가드가 세션을 SDK에 다시 묻지 않는다: 웹에서 갱신이 네트워크로 실패하면 SDK는 30초 동안 지수 대기로 재시도한 뒤 세션 null을 돌려준다. 가드가 getSession을 또 부르면 30초를 더 기다린다(테스트 76초). 저장된 세션을 직접 읽어 만료면 즉시 오류로 돌리는 것으로 조정(0.2초).
  • "익명/토큰 표시" 계측 대신 오류 코드 1개: 가드가 익명 데이터 요청 자체를 없애므로, 남는 실패는 LG_AUTH_SESSION_NOT_READY로 찍혀 42501(권한)과 구분된다.
  • 계정 id 정리 조건 확장: Keychain 비어 있음(재설치 등)이면 복원할 계정이 없으므로 기억한 id를 지운다. 없으면 가드가 "준비 중"으로 영원히 막는다.

작업 중 드러난 것

  • main 빨간불(무관): workoutDraftArchive.test.mjs 픽스처 저장 시각이 2026-08-29 고정이라 임시 보관 TTL 7일이 달력으로 지난 9/5부터 3건 실패, 9/6 main Repository checks(verify) 빨강. PR #1291(테스트 전용, CI 대기 없이)로 현재 시각 기준 오프셋으로 수정. 같은 시각 스테이징 database 잡 실패는 풀러 인증 조회 타임아웃(auth_query secret check timed out, 일시 오류).
  • 감사 명부 밖 파일은 신고하면 오히려 실패: check:test-manifest는 명부에 없는 파일의 pending-changes 신고를 거부한다.
  • Node 테스트 하니스 함정 2건: ① SDK가 세션을 저장할 때 Keychain PKCE 검증값 삭제까지 요청하므로 흉내 껍데기가 그 응답도 돌려줘야 한다(안 주면 2.5초 타임아웃 뒤 실패). ② 웹 흉내에 document를 두면 auth-js가 탭 간 세션 공유용 BroadcastChannel을 열고, Node에서는 열린 채널이 프로세스 종료를 막아 테스트가 통과하고도 끝나지 않는다(globalThis.BroadcastChannel = undefined로 차단). npm run check 한 바퀴가 이것 때문에 36분 멈췄다.
  • Git Bash의 cmd //c mklink는 경로 인자를 깨뜨린다 — junction은 PowerShell cmd /c mklink /J로.
  • 워크트리 파일은 CRLF — node 스크립트로 고칠 때 EOL을 읽어 보존.

5. 적용 결과

항목결과
iOS 재시작 시 익명 키로 나가는 조회재현 테스트: 3건 익명 → 0건(전부 복원 뒤 사용자 토큰), 복원 요청 1회
웹 갱신 실패 시 익명 강등실제 SDK 흐름 테스트: 익명 데이터 요청 0건, 오류 LG_AUTH_SESSION_NOT_READY(재시도 가능·사본 대체)
가드 준비 대기 지연가드가 SDK에 재질의하던 설계 76초 → 저장본 직접 읽기 0.2초(테스트 기준)
로컬 게이트npm run check 2,593/2,594(실패 1 = 하니스 미종료, 수정 후 단독 5/5) · ci:local full 통과 — verify 5단계 · db reset · 스냅샷 --check · pgTAP 109파일/1899 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 37/37·viewport 14/14 · 11분 35초 (02:30 KST, HEAD c0b1670d; 첫 실행의 viewport 1건 실패는 main에 이미 고쳐진 #1250 e2e 달력 경계 결함)
CI초록(run 34048037777: verify·landing-queue 통과, full 레인은 CI 범위 판정으로 생략 — 로컬 ci:local --full 로 같은 묶음 통과)
Production 실측릴리스 v0.17.1(2026-09-07 21:36 KST) 뒤 2시간 55분(00:31 KST 프로브): 앱 오류 42501 0건(릴리스 전 같은 날 2건, 9/3~9/7 누적 17건) · 게이트웨이 /rest/v1/rpc/* 401 0건(릴리스 전 9/5 9건·9/6 18건·9/7 6건) · Postgres permission denied for function 0건 · iOS 즐겨찾기 조회 7건 전부 200(릴리스 전 하루는 200 14건 + 401 5건) · LG_AUTH_SESSION_NOT_READY 0건. 재시작 1회당 조회 횟수는 로그로 구분되지 않아 401 0건으로 대신 확인. 창이 3시간이라 하루 단위 수치는 아니며, 이후 재발은 이슈 재오픈으로 받는다
iOS 체감자동 검증 대상 아님 — 첫 데이터 요청이 복원(실측 0.7초)을 기다리는 것은 종전의 실패→재조회와 같은 시간

6. 이번 개선으로 향상된 것

앱 재시작 직후에도 로그인된 사람의 요청만 나간다

iOS 재시작마다 나가던 헛된 실패 3건과 즐겨찾기 재조회 1건이 없어진다. 정지된 계정이 재시작 시 정지 화면 없이 통과하던 구멍(fail-open)과 관리자 메뉴가 첫 부팅에서 안 보이던 것도 같은 수리로 닫힌다.

구조적으로 남는 것

  • "계정 기억 ≠ 토큰 보유"를 잇는 대기 지점 1곳(getClient()).
  • "계정이 기억된 동안 데이터 요청은 절대 익명으로 나가지 않는다" 가드 1곳(SDK fetch) — 새 화면이 계정 id만 보고 먼저 조회해도, 웹에서 갱신이 실패해도 자동으로 보호된다.
  • 실패 원인 갈래가 오류 코드로 구분된다(42501 권한 / LG_AUTH_SESSION_NOT_READY 로그인 준비 전).
  • iOS 껍데기 흉내 하니스(Keychain 복원·저장·PKCE 응답)가 테스트에 남아 다음 부팅 순서 변경 때 재사용.

남은 것

  • (완료 2026-09-08) 릴리스 v0.17.1 반영·프로브 확인 뒤 이슈 [v0.17.1 반영완료]로 닫음.
  • 정지 여부 조회의 fail-open(실패 시 통과) 자체는 그대로 — 부팅 시 실패 원인은 없앴지만, 서버 장애 중에는 여전히 통과한다(범위 밖).
  • SDK의 갱신 재시도 30초 동안 화면이 기다리는 것은 SDK 기본 동작(범위 밖).