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:
윤찬 2026-04-11 23:24:22 +09:00
parent dd591d06e7
commit dc7f93349e
2 changed files with 108 additions and 2 deletions

View file

@ -64,6 +64,9 @@ class CloudSyncService extends EventEmitter {
private _session: Session | null = null
private _lastSyncAt: number | null = null
private _syncing = false
/** Realtime 재구독 지수 백오프 상태 (U1) */
private _realtimeRetryCount = 0
private _realtimeRetryTimer: NodeJS.Timeout | null = null
private _initialized = false
private _realtimeChannel: RealtimeChannel | null = null
@ -99,8 +102,17 @@ class CloudSyncService extends EventEmitter {
try {
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
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.emit('sync-error', {
error: `Sign-in session expired. Please sign in again. (${error.message})`,
})
this.emit('auth-changed', { user: null })
} else if (data.session) {
this._saveRefreshToken(data.session.refresh_token)
logger.info(
@ -110,7 +122,13 @@ class CloudSyncService extends EventEmitter {
await this._onAuthenticated(data.session, { reason: 'restore' })
}
} 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) => {
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> {
if (this._realtimeRetryTimer) {
clearTimeout(this._realtimeRetryTimer)
this._realtimeRetryTimer = null
}
this._realtimeRetryCount = 0
if (this._realtimeChannel) {
await this._realtimeChannel.unsubscribe()
this._realtimeChannel = null

View file

@ -3,6 +3,38 @@
> 마지막 갱신: 2026-04-11 (빅뱅 Phase 5 Part 6 — Voice Conversation 몰입 패널 구현)
> 규칙 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 분기 ✅
`AudioCaptureService.getDevices()``powershell -NoProfile`만 호출하던 것을