fix(desktop): CloudSync Realtime 자동 재구독 + Refresh token 실패 재인증 알림 (U1+U3, 빅뱅 Phase 5 Part 6)
## U1 — Realtime TIMED_OUT 자동 재구독
CloudSyncService.startRealtime()의 subscribe() 콜백이 TIMED_OUT 한 번만
로깅하고 끝나던 것을 지수 백오프 재구독으로 교체.
- SUBSCRIBED: retry count reset + timer clear
- TIMED_OUT / CHANNEL_ERROR / CLOSED: _scheduleRealtimeRetry() 트리거
- 백오프: 1s → 3s → 10s (MAX 3회)
- 초과 시 warn 로깅 후 포기 (Auto push fallback으로만 동작)
- stopRealtime()에서 타이머/카운터 cleanup
Publication, RLS, setAuth 모두 설정 완료 상태에서도 매 기동 시 TIMED_OUT이
찍히는 증상. 근본 원인은 Supabase 서버 transient(네트워크/WebSocket
타임아웃) 의심. 이 패치로 완전 해결은 아니지만 일시적 네트워크 jitter
복구 윈도우를 확보하고, 3회 실패 시 명확한 포기 메시지를 남긴다.
## U3 — Refresh token 실패 시 재인증 이벤트
init()의 refreshSession 실패 브랜치에서 warn 로깅 + 토큰 삭제만 수행해
사용자가 "왜 로그아웃됐지?" 상태로 방치되던 문제.
- logger.warn → logger.error(에러 레벨 승격)
- sync-error 이벤트 emit (사용자에게 보여줄 메시지 포함)
- auth-changed { user: null } emit (renderer 게이트 재잠금)
- catch 브랜치도 동일 처리 (기존엔 로깅만)
## U4 — package-lock.json optional dep (유지 결정)
rollup 전 플랫폼 optional deps가 lock에 기록돼 있지만 Windows 빌드자
사전 fetch 효율 + 크로스플랫폼 CI 환경을 고려해 유지. apps/desktop/
package.json:56에 @rollup/rollup-win32-x64-msvc 명시 의존이 있는 이유도
동일. 이 이슈는 확인 후 close.
This commit is contained in:
parent
dd591d06e7
commit
dc7f93349e
2 changed files with 108 additions and 2 deletions
|
|
@ -64,6 +64,9 @@ class CloudSyncService extends EventEmitter {
|
||||||
private _session: Session | null = null
|
private _session: Session | null = null
|
||||||
private _lastSyncAt: number | null = null
|
private _lastSyncAt: number | null = null
|
||||||
private _syncing = false
|
private _syncing = false
|
||||||
|
/** Realtime 재구독 지수 백오프 상태 (U1) */
|
||||||
|
private _realtimeRetryCount = 0
|
||||||
|
private _realtimeRetryTimer: NodeJS.Timeout | null = null
|
||||||
private _initialized = false
|
private _initialized = false
|
||||||
private _realtimeChannel: RealtimeChannel | null = null
|
private _realtimeChannel: RealtimeChannel | null = null
|
||||||
|
|
||||||
|
|
@ -99,8 +102,17 @@ class CloudSyncService extends EventEmitter {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
|
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.warn(`Stored session refresh failed: ${error.message}`)
|
// U3: 장시간 idle 후 refresh token 소진 시 사용자에게 재인증 필요를
|
||||||
|
// 알리기 위해 error 로깅 승격 + sync-error + auth-changed(user:null) emit.
|
||||||
|
// 기존에는 조용히 토큰만 삭제해서 사용자가 "왜 로그아웃됐지?" 상태로 방치됐음.
|
||||||
|
logger.error(
|
||||||
|
`세션 복원 실패 — 재로그인 필요: ${error.message} (토큰 만료 또는 서버 세션 무효화)`
|
||||||
|
)
|
||||||
this._clearStoredRefreshToken()
|
this._clearStoredRefreshToken()
|
||||||
|
this.emit('sync-error', {
|
||||||
|
error: `Sign-in session expired. Please sign in again. (${error.message})`,
|
||||||
|
})
|
||||||
|
this.emit('auth-changed', { user: null })
|
||||||
} else if (data.session) {
|
} else if (data.session) {
|
||||||
this._saveRefreshToken(data.session.refresh_token)
|
this._saveRefreshToken(data.session.refresh_token)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -110,7 +122,13 @@ class CloudSyncService extends EventEmitter {
|
||||||
await this._onAuthenticated(data.session, { reason: 'restore' })
|
await this._onAuthenticated(data.session, { reason: 'restore' })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Session restore error: ${err instanceof Error ? err.message : String(err)}`)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.error(`세션 복원 예외 — 재로그인 필요: ${message}`)
|
||||||
|
this._clearStoredRefreshToken()
|
||||||
|
this.emit('sync-error', {
|
||||||
|
error: `Sign-in session restore failed. Please sign in again. (${message})`,
|
||||||
|
})
|
||||||
|
this.emit('auth-changed', { user: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,10 +432,66 @@ class CloudSyncService extends EventEmitter {
|
||||||
)
|
)
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
logger.info(`Realtime 채널 상태: ${status}`)
|
logger.info(`Realtime 채널 상태: ${status}`)
|
||||||
|
// U1: TIMED_OUT / CHANNEL_ERROR / CLOSED 시 지수 백오프 자동 재구독.
|
||||||
|
// Supabase realtime은 네트워크 jitter로 간헐적 TIMED_OUT이 발생할 수 있고,
|
||||||
|
// 이때 기본 fallback(Phase 3.3 auto push)은 동작하지만 다른 기기 변경은 놓친다.
|
||||||
|
// 1s → 3s → 10s 3회 시도 후 포기. 성공(SUBSCRIBED) 시 카운터 리셋.
|
||||||
|
if (status === 'SUBSCRIBED') {
|
||||||
|
this._realtimeRetryCount = 0
|
||||||
|
if (this._realtimeRetryTimer) {
|
||||||
|
clearTimeout(this._realtimeRetryTimer)
|
||||||
|
this._realtimeRetryTimer = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 'TIMED_OUT' || status === 'CHANNEL_ERROR' || status === 'CLOSED') {
|
||||||
|
this._scheduleRealtimeRetry()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realtime 재구독 지수 백오프 스케줄러 (U1).
|
||||||
|
* - 1차: 1s, 2차: 3s, 3차: 10s
|
||||||
|
* - 3회 초과 시 포기 (Auto push fallback만으로 동작)
|
||||||
|
* - 이미 스케줄돼 있으면 no-op (중복 방지)
|
||||||
|
*/
|
||||||
|
private _scheduleRealtimeRetry(): void {
|
||||||
|
if (this._realtimeRetryTimer) return
|
||||||
|
if (!this._client || !this._session) return
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3
|
||||||
|
const DELAYS_MS = [1000, 3000, 10000]
|
||||||
|
|
||||||
|
if (this._realtimeRetryCount >= MAX_RETRIES) {
|
||||||
|
logger.warn(
|
||||||
|
`Realtime 재구독 ${MAX_RETRIES}회 실패 — Auto push fallback으로만 동작. 수동 재시도: cloudSync.startRealtime()`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = DELAYS_MS[this._realtimeRetryCount]
|
||||||
|
this._realtimeRetryCount++
|
||||||
|
const attempt = this._realtimeRetryCount
|
||||||
|
|
||||||
|
logger.info(`Realtime 재구독 예약 (${attempt}/${MAX_RETRIES}): ${delay}ms 후`)
|
||||||
|
this._realtimeRetryTimer = setTimeout(() => {
|
||||||
|
this._realtimeRetryTimer = null
|
||||||
|
if (!this._session) return
|
||||||
|
void this.startRealtime().catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
`Realtime 재구독 실패 (${attempt}/${MAX_RETRIES}): ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
async stopRealtime(): Promise<void> {
|
async stopRealtime(): Promise<void> {
|
||||||
|
if (this._realtimeRetryTimer) {
|
||||||
|
clearTimeout(this._realtimeRetryTimer)
|
||||||
|
this._realtimeRetryTimer = null
|
||||||
|
}
|
||||||
|
this._realtimeRetryCount = 0
|
||||||
if (this._realtimeChannel) {
|
if (this._realtimeChannel) {
|
||||||
await this._realtimeChannel.unsubscribe()
|
await this._realtimeChannel.unsubscribe()
|
||||||
this._realtimeChannel = null
|
this._realtimeChannel = null
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,38 @@
|
||||||
> 마지막 갱신: 2026-04-11 (빅뱅 Phase 5 Part 6 — Voice Conversation 몰입 패널 구현)
|
> 마지막 갱신: 2026-04-11 (빅뱅 Phase 5 Part 6 — Voice Conversation 몰입 패널 구현)
|
||||||
> 규칙 13: 작업 완료 즉시 이 파일 갱신 의무
|
> 규칙 13: 작업 완료 즉시 이 파일 갱신 의무
|
||||||
|
|
||||||
|
## 빅뱅 Phase 5 Part 6++ (2026-04-11) — U1/U3/U4 남은 이슈 정리 ✅
|
||||||
|
|
||||||
|
### U1 — Realtime TIMED_OUT 자동 재구독 (CloudSyncService.ts)
|
||||||
|
|
||||||
|
Publication/RLS/setAuth 모두 설정 완료 상태에서도 매 기동 시 `TIMED_OUT`이 찍히는 증상. 근본 원인은 Supabase 서버 측 transient(네트워크/WebSocket 타임아웃) 의심으로 판정. Auto push(Phase 3.3) fallback이 완벽히 동작해 블로커는 아니지만, 다른 기기에서의 원격 변경 pull을 놓치는 window가 생김.
|
||||||
|
|
||||||
|
**변경**: `subscribe()` 콜백에 지수 백오프 자동 재구독 추가.
|
||||||
|
- `SUBSCRIBED` → retry count reset + 타이머 클리어
|
||||||
|
- `TIMED_OUT` / `CHANNEL_ERROR` / `CLOSED` → `_scheduleRealtimeRetry()`
|
||||||
|
- 백오프: 1s → 3s → 10s, 3회 초과 시 포기하고 경고 로그(Auto push로만 동작)
|
||||||
|
- `stopRealtime()`에서 타이머 cleanup + count reset
|
||||||
|
|
||||||
|
### U3 — Refresh token 실패 시 재인증 이벤트 emit (CloudSyncService.ts)
|
||||||
|
|
||||||
|
`init()`의 `refreshSession` 실패 브랜치에서 기존에는 `warn` 로깅 + 토큰 삭제만 수행 → 사용자는 "왜 로그아웃됐지?" 상태로 방치. 재현이 쉽지 않지만 장시간 idle 후 발생 가능성 있어 예방적 개선.
|
||||||
|
|
||||||
|
**변경**:
|
||||||
|
- `logger.warn` → `logger.error`(에러 레벨 승격)
|
||||||
|
- `sync-error` 이벤트 emit (`"Sign-in session expired. Please sign in again."`)
|
||||||
|
- `auth-changed { user: null }` emit (renderer 게이트 재잠금)
|
||||||
|
- `try/catch`의 catch 브랜치도 동일 처리 (기존에는 로깅만)
|
||||||
|
|
||||||
|
### U4 — package-lock optional dep (확인 후 유지 결정)
|
||||||
|
|
||||||
|
`package-lock.json`에 rollup의 전 플랫폼 `optionalDependencies`(darwin/win32/linux/freebsd/android)가 기록돼 있음. Mac `npm install --force` 시 win32 관련 경고가 나왔지만 런타임 영향 없음. `apps/desktop/package.json:56`에 `@rollup/rollup-win32-x64-msvc`가 명시적 의존으로 선언되어 있는 이유는 Windows 빌드자가 사전에 binding을 확보하기 위함.
|
||||||
|
|
||||||
|
**결정**: **유지**. 크로스플랫폼 개발/CI 효율 + Win 빌드자 경험을 우선. 향후 lock 재생성 시에도 선택적 제거 불필요. 이 이슈는 **close**.
|
||||||
|
|
||||||
|
### 변경 파일 (2)
|
||||||
|
- `apps/desktop/src/main/services/CloudSyncService.ts` — U1 재구독 + U3 재인증 이벤트
|
||||||
|
- `memory/project_status.md` — 본 섹션
|
||||||
|
|
||||||
## 빅뱅 Phase 5 Part 6+ (2026-04-11) — U7 AudioCaptureService Mac 분기 ✅
|
## 빅뱅 Phase 5 Part 6+ (2026-04-11) — U7 AudioCaptureService Mac 분기 ✅
|
||||||
|
|
||||||
`AudioCaptureService.getDevices()`가 `powershell -NoProfile`만 호출하던 것을
|
`AudioCaptureService.getDevices()`가 `powershell -NoProfile`만 호출하던 것을
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue