From 7db825cd41260ea3caa42108d840c039bcc769c2 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 13 Apr 2026 13:39:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(live-room):=20=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=ED=9B=84=20=EC=98=A4=EB=9E=98=EB=90=9C=20?= =?UTF-8?q?=EB=A3=B8=20=EC=A0=95=EB=B3=B4=20=EC=98=A4=EB=A5=98=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Live/Room/LiveRoomViewModel.swift | 50 ++++++++++++++++--- ...20260413_라이브룸방장부재시갱신호출차단.md | 41 +++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 docs/20260413_라이브룸방장부재시갱신호출차단.md diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index 79b885f..c0dc00f 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -287,6 +287,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { private var blockedMemberIdList = Set() private var hasInvokedJoinChannel = false + private var hasDetectedHostOffline = false private var v2vMessageAssembler = V2vMessageAssembler() private var v2vAgentId: String? private var v2vSourceLanguage: String? @@ -322,6 +323,35 @@ final class LiveRoomViewModel: NSObject, ObservableObject { liveRoomInfo?.creatorId == UserDefaults.int(forKey: .userId) } + private func shouldSuppressMissingRoomInfoError(_ message: String?) -> Bool { + let ignorableLiveRoomNotFoundMessages: Set = [ + "해당하는 라이브의 정보가 없습니다.", + "Live session information not found.", + "該当するライブの情報がありません。" + ] + + guard let message, ignorableLiveRoomNotFoundMessages.contains(message) else { + return false + } + + return liveRoomInfo != nil + || hasInvokedJoinChannel + || hasDetectedHostOffline + || AppState.shared.roomId == 0 + } + + private func shouldRefreshRoomInfoOnMemberLeave(memberId: Int) -> Bool { + guard let liveRoomInfo = liveRoomInfo else { + return false + } + + guard !hasDetectedHostOffline else { + return false + } + + return liveRoomInfo.creatorId != memberId + } + func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) { guard isV2VJoined else { return } stopV2VTranslation(clearCaptionText: clearCaptionText) @@ -616,6 +646,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { let previousIsChatFrozen = self.isChatFrozen let syncedIsChatFrozen = data.isChatFrozen ?? false + self.hasDetectedHostOffline = false self.liveRoomInfo = data self.updateV2VAvailability(roomInfo: data) @@ -662,13 +693,17 @@ final class LiveRoomViewModel: NSObject, ObservableObject { onSuccess(nickname) } } else { - if let message = decoded.message { - self.errorMessage = message + if self.shouldSuppressMissingRoomInfoError(decoded.message) { + DEBUG_LOG("Suppress stale getRoomInfo error during live-room teardown: \(decoded.message ?? "")") } else { - self.errorMessage = I18n.Common.commonError + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } + + self.isShowErrorPopup = true } - - self.isShowErrorPopup = true } self.isLoading = false @@ -2973,12 +3008,13 @@ extension LiveRoomViewModel: AgoraRtcEngineDelegate { if uid == UInt(creatorId) { // 라이브 종료 + self.hasDetectedHostOffline = true self.deInitAgoraEngine() self.liveRoomInfo = nil AppState.shared.errorMessage = I18n.LiveRoom.liveEndedMessage AppState.shared.isShowErrorPopup = true AppState.shared.roomId = 0 - } else { + } else if self.shouldRefreshRoomInfoOnMemberLeave(memberId: Int(uid)) { // get room info self.getRoomInfo() } @@ -3265,7 +3301,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate { } } } else if eventType == .remoteLeaveChannel { - if let liveRoomInfo = liveRoomInfo, liveRoomInfo.creatorId != Int(memberId)! { + if shouldRefreshRoomInfoOnMemberLeave(memberId: Int(memberId)!) { getRoomInfo() } } diff --git a/docs/20260413_라이브룸방장부재시갱신호출차단.md b/docs/20260413_라이브룸방장부재시갱신호출차단.md new file mode 100644 index 0000000..0ad2cbf --- /dev/null +++ b/docs/20260413_라이브룸방장부재시갱신호출차단.md @@ -0,0 +1,41 @@ +# 20260413 라이브룸 방장 부재 시 갱신 호출 차단 + +## 구현 체크리스트 +- [x] 라이브룸 오프라인/퇴장 이벤트와 `getRoomInfo()` 호출 경로 확인 +- [x] 방장 부재 상태에서 추가 `getRoomInfo()` 호출이 발생하지 않도록 최소 수정 +- [x] 종료 경쟁으로 내려오는 `라이브 정보가 없습니다.` 토스트 억제 범위 확인 +- [x] 변경 파일 정적 진단 및 빌드 검증 +- [x] 수동 검증 시나리오와 결과 기록 + +## 완료 기준 (Acceptance Criteria) +- [ ] QA: 방장 퇴장 감지 시 참여자에게 `라이브가 종료되었습니다`가 표시되고 종료 흐름이 유지된다. +- [ ] QA: 방장 퇴장 감지 이후 다른 참여자 퇴장 이벤트가 이어져도 `getRoomInfo()`가 추가 호출되지 않는다. +- [ ] QA: 이미 입장한 라이브가 종료된 뒤 후속 `getRoomInfo()`가 실패해도 `라이브 정보가 없습니다.` 토스트는 표시되지 않는다. +- [ ] QA: 일반 참여자 단독 퇴장 시 기존처럼 `getRoomInfo()` 갱신이 유지된다. + +## 검증 기록 +- [2026-04-13] 무엇: `didOfflineOfUid` 및 `remoteLeaveChannel` 기반 `getRoomInfo()` 재호출 경로 조사 + - 왜: 방장 종료와 다른 참여자 종료가 겹칠 때 방장 부재 상태에서도 추가 갱신 호출이 발생할 수 있음 + - 어떻게: `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`의 RTC/RTM 오프라인 이벤트 분기와 `LiveRoomViewV2.swift` 초기 진입 흐름을 확인 +- [2026-04-13] 무엇: 방장 부재 감지 이후 추가 `getRoomInfo()` 호출 차단 구현 + - 왜: 방장 퇴장 직후 다른 참여자 퇴장 이벤트가 이어질 때 stale `creatorId` 기준으로 불필요한 갱신이 발생할 수 있음 + - 어떻게: `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`에 `hasDetectedHostOffline` 상태와 `shouldRefreshRoomInfoOnMemberLeave(memberId:)` 헬퍼를 추가하고, RTC `didOfflineOfUid` 및 RTM `remoteLeaveChannel`이 동일한 조건으로 `getRoomInfo()` 호출 여부를 판단하도록 수정 +- [2026-04-13] 실행 명령 및 결과 + - `lsp_diagnostics(SodaLive/Sources/Live/Room/LiveRoomViewModel.swift)` → `No diagnostics found` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` → 두 스킴 모두 `BUILD SUCCEEDED` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` → 두 스킴 모두 `Scheme ... is not currently configured for the test action.` +- [2026-04-13] 수동 검증 시나리오 + - 시나리오: 방장 1명과 참여자 여러 명이 접속한 상태에서 방장 종료와 다른 참여자 퇴장이 연속 또는 동시 발생 + - 기대 결과: 방장 퇴장 감지 후에는 `라이브가 종료되었습니다` 종료 흐름만 유지되고, 후속 참여자 퇴장 이벤트로 `getRoomInfo()`가 재호출되지 않음 + - 현재 결과: CLI 환경에서는 실시간 다중 클라이언트/Agora 세션을 직접 구성할 수 없어 실제 앱 런타임 수동 검증은 별도 iOS 실행 환경에서 추가 확인 필요 +- [2026-04-13] 무엇: 종료 경쟁으로 반환된 `라이브 정보가 없습니다.` 토스트 억제 처리 추가 + - 왜: 일반 참여자 leave 신호가 먼저 처리되면 이미 종료된 라이브에 대한 `getRoomInfo()` 재조회가 실패하면서 서버 문구가 사용자 토스트로 그대로 노출될 수 있음 + - 어떻게: `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`에 `shouldSuppressMissingRoomInfoError(_:)` 헬퍼를 추가하고, `getRoomInfo()` 실패 분기에서 이미 입장했던 세션/종료 진행 상태의 `라이브 정보가 없습니다.`는 `DEBUG_LOG`만 남기고 토스트는 띄우지 않도록 최소 수정 +- [2026-04-13] 실행 명령 및 결과 + - `lsp_diagnostics(SodaLive/Sources/Live/Room/LiveRoomViewModel.swift)` → SourceKit 환경에서 `No such module 'Moya'` 반환 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` → 두 스킴 모두 `BUILD SUCCEEDED` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` → 두 스킴 모두 `Scheme ... is not currently configured for the test action.` +- [2026-04-13] 수동 검증 시나리오 + - 시나리오: 이미 라이브에 입장한 참여자 상태에서 방 종료 직전 일반 참여자 leave 이벤트로 `getRoomInfo()`가 먼저 호출되고, 서버는 라이브 종료 상태를 반환 + - 기대 결과: `라이브 정보가 없습니다.` 토스트는 표시되지 않고, 기존 종료 흐름 또는 후속 종료 신호 처리만 유지됨 + - 현재 결과: CLI 환경에서는 실시간 Agora/다중 클라이언트 수동 재현이 불가능해 실제 앱 런타임 검증은 별도 iOS 환경에서 추가 확인 필요