From 80057677b37cc492ab00d84bff2c673dd30beea2 Mon Sep 17 00:00:00 2001 From: lioba00700 Date: Wed, 2 Sep 2026 19:54:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20RTC=20=EC=97=AD=ED=95=A0?= =?UTF-8?q?=EB=B3=84=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20lifecycle=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/rtc-session/model/index.ts | 1 - src/entities/rtc-session/model/models.ts | 4 - src/entities/rtc-session/model/rtc-store.ts | 40 +- .../rtc-session/model/rtc-viewer-session.ts | 11 - src/features/rtc/finalize-session/index.ts | 1 + .../ui/rtc-end-photo-selection.tsx} | 16 +- .../rtc/host-controls/api/rtc-host-query.ts | 9 - src/features/rtc/host-controls/index.ts | 4 + .../host-controls/model/rtc-host-control.ts | 2 + .../use-rtc-host-termination-controller.ts | 115 +++++ .../host-controls/ui/rtc-host-livekit.tsx} | 394 ++---------------- .../host-controls/ui/rtc-sharing-sheet.tsx} | 6 +- .../rtc/join-room/api/rtc-viewer-mutation.ts | 16 - src/features/rtc/join-room/index.ts | 6 + .../join-room/model/use-rtc-viewer-entry.ts | 28 +- .../model/use-rtc-viewer-exit-controller.ts | 85 ++++ .../rtc/join-room/ui/rtc-viewer-livekit.tsx | 250 +++++++++++ .../rtc/join-room/ui/rtc-viewer-waiting.tsx} | 6 +- src/pages/camera/ui/camera-page.tsx | 115 +++-- src/pages/camera/ui/rtc-viewer-page.tsx | 112 ++--- 20 files changed, 675 insertions(+), 546 deletions(-) delete mode 100644 src/entities/rtc-session/model/rtc-viewer-session.ts rename src/{pages/camera/ui/sharing-end-selection-page.tsx => features/rtc/finalize-session/ui/rtc-end-photo-selection.tsx} (94%) create mode 100644 src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts rename src/{pages/camera/ui/rtc-livekit-page.tsx => features/rtc/host-controls/ui/rtc-host-livekit.tsx} (55%) rename src/{pages/camera/ui/sharing-camera-page.tsx => features/rtc/host-controls/ui/rtc-sharing-sheet.tsx} (96%) create mode 100644 src/features/rtc/join-room/model/use-rtc-viewer-exit-controller.ts create mode 100644 src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx rename src/{pages/camera/ui/sharing-waiting-page.tsx => features/rtc/join-room/ui/rtc-viewer-waiting.tsx} (96%) diff --git a/src/entities/rtc-session/model/index.ts b/src/entities/rtc-session/model/index.ts index 97ca3e7..cefd3d4 100644 --- a/src/entities/rtc-session/model/index.ts +++ b/src/entities/rtc-session/model/index.ts @@ -1,3 +1,2 @@ export * from "./models"; export * from "./rtc-store"; -export * from "./rtc-viewer-session"; diff --git a/src/entities/rtc-session/model/models.ts b/src/entities/rtc-session/model/models.ts index e11b133..4672355 100644 --- a/src/entities/rtc-session/model/models.ts +++ b/src/entities/rtc-session/model/models.ts @@ -30,11 +30,7 @@ export interface RtcViewerSession { participantId: string; } -export type RtcLiveKitRole = "HOST" | "VIEWER"; - export interface RtcLiveKitConnection { - role: RtcLiveKitRole; url: string; token: string; - expiresAt?: string; } diff --git a/src/entities/rtc-session/model/rtc-store.ts b/src/entities/rtc-session/model/rtc-store.ts index f270c03..e89e007 100644 --- a/src/entities/rtc-session/model/rtc-store.ts +++ b/src/entities/rtc-session/model/rtc-store.ts @@ -1,14 +1,9 @@ import { create } from "zustand"; -import { - RtcHostSession, - RtcLiveKitConnection, - RtcViewerSession, -} from "./models"; +import { RtcHostSession, RtcViewerSession } from "./models"; export interface RtcStore { hostSession: RtcHostSession | null; viewerSession: RtcViewerSession | null; - liveKitConnection: RtcLiveKitConnection | null; setHostSession: (session: RtcHostSession) => void; updateHostSessionExpiresAt: (roomId: string, expiresAt: string) => void; @@ -17,25 +12,21 @@ export interface RtcStore { setViewerSession: (session: RtcViewerSession) => void; clearViewerSession: () => void; - setLiveKitConnection: (connection: RtcLiveKitConnection) => void; - clearLiveKitConnection: () => void; clearRtcSession: () => void; } /** - * RTC 토큰은 SecureStore 등에 영속화하지 않고 현재 앱 프로세스의 - * 메모리에만 보관합니다. + * RTC Host/Viewer session identity는 SecureStore 등에 영속화하지 않고 + * 현재 앱 프로세스의 메모리에만 보관합니다. */ export const useRtcStore = create()((set) => ({ hostSession: null, viewerSession: null, - liveKitConnection: null, setHostSession: (hostSession) => { set({ hostSession, viewerSession: null, - liveKitConnection: null, }); }, updateHostSessionExpiresAt: (roomId, expiresAt) => { @@ -51,43 +42,26 @@ export const useRtcStore = create()((set) => ({ }); }, clearHostSession: () => { - set((state) => ({ + set({ hostSession: null, - liveKitConnection: - state.liveKitConnection?.role === "HOST" - ? null - : state.liveKitConnection, - })); + }); }, setViewerSession: (viewerSession) => { set({ hostSession: null, viewerSession, - liveKitConnection: null, }); }, clearViewerSession: () => { - set((state) => ({ + set({ viewerSession: null, - liveKitConnection: - state.liveKitConnection?.role === "VIEWER" - ? null - : state.liveKitConnection, - })); - }, - - setLiveKitConnection: (liveKitConnection) => { - set({ liveKitConnection }); - }, - clearLiveKitConnection: () => { - set({ liveKitConnection: null }); + }); }, clearRtcSession: () => { set({ hostSession: null, viewerSession: null, - liveKitConnection: null, }); }, })); diff --git a/src/entities/rtc-session/model/rtc-viewer-session.ts b/src/entities/rtc-session/model/rtc-viewer-session.ts deleted file mode 100644 index cd2f004..0000000 --- a/src/entities/rtc-session/model/rtc-viewer-session.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { RtcViewerLiveKitTokenRequest, RtcViewerSession } from "./models"; - -export function isCurrentRtcViewerSession( - session: RtcViewerSession | null, - request: RtcViewerLiveKitTokenRequest, -): boolean { - return ( - session?.roomId === request.roomId.trim() && - session.participantId === request.participantId.trim() - ); -} diff --git a/src/features/rtc/finalize-session/index.ts b/src/features/rtc/finalize-session/index.ts index 6bd903d..f73b4e9 100644 --- a/src/features/rtc/finalize-session/index.ts +++ b/src/features/rtc/finalize-session/index.ts @@ -4,3 +4,4 @@ export { } from "./lib/prepare-rtc-end-images"; export { resetMyRtcStoredPhotos } from "./lib/reset-my-rtc-stored-photos"; export { useResetMyRtcStoredPhotos } from "./model/use-reset-my-rtc-stored-photos"; +export { RtcEndPhotoSelection } from "./ui/rtc-end-photo-selection"; diff --git a/src/pages/camera/ui/sharing-end-selection-page.tsx b/src/features/rtc/finalize-session/ui/rtc-end-photo-selection.tsx similarity index 94% rename from src/pages/camera/ui/sharing-end-selection-page.tsx rename to src/features/rtc/finalize-session/ui/rtc-end-photo-selection.tsx index 1df13ec..f163f19 100644 --- a/src/pages/camera/ui/sharing-end-selection-page.tsx +++ b/src/features/rtc/finalize-session/ui/rtc-end-photo-selection.tsx @@ -1,5 +1,4 @@ import { RTC_MAX_SELECTED_PHOTOS } from "@entities/rtc-room"; -import { SessionPhoto } from "@features/camera/capture-photo"; import { Button, ButtonText, @@ -14,15 +13,20 @@ import { useMemo, useState } from "react"; import { Alert } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -export interface SharingEndSelectionPageProps { - photos: SessionPhoto[]; - onConfirm: (photos: SessionPhoto[]) => void; +export interface RtcEndPhotoSelectionProps { + photos: RtcEndSelectablePhoto[]; + onConfirm: (photos: RtcEndSelectablePhoto[]) => void; } -export function SharingEndSelectionPage({ +export interface RtcEndSelectablePhoto { + id: string; + uri: string; +} + +export function RtcEndPhotoSelection({ photos, onConfirm, -}: SharingEndSelectionPageProps) { +}: RtcEndPhotoSelectionProps) { const [selectedPhotoIds, setSelectedPhotoIds] = useState>( () => new Set(), ); diff --git a/src/features/rtc/host-controls/api/rtc-host-query.ts b/src/features/rtc/host-controls/api/rtc-host-query.ts index 2728de2..b806f4f 100644 --- a/src/features/rtc/host-controls/api/rtc-host-query.ts +++ b/src/features/rtc/host-controls/api/rtc-host-query.ts @@ -89,15 +89,6 @@ export function useCreateHostLiveKitToken() { ); return response.data; }, - onSuccess: (response, { roomId }) => { - if (useRtcStore.getState().hostSession?.roomId !== roomId.trim()) return; - useRtcStore.getState().setLiveKitConnection({ - role: "HOST", - url: response.url, - token: response.token, - expiresAt: response.expiresAt, - }); - }, }); } diff --git a/src/features/rtc/host-controls/index.ts b/src/features/rtc/host-controls/index.ts index 6869af2..c55426e 100644 --- a/src/features/rtc/host-controls/index.ts +++ b/src/features/rtc/host-controls/index.ts @@ -6,11 +6,15 @@ export { type RtcCameraMenuMode, type RtcFinalizationBlockingState, type RtcHostFinalizationState, + RTC_HOST_ROOM_EXPIRES_IN_MINUTES, } from "./model/rtc-host-control"; export { useRtcRoomEvents, type RtcRoomStreamState, } from "./model/use-rtc-room-events"; +export { useRtcHostTerminationController } from "./model/use-rtc-host-termination-controller"; export { RtcCameraRoomMenu } from "./ui/rtc-camera-room-menu"; export { RtcFinalizationOverlay } from "./ui/rtc-finalization-overlay"; +export { RtcHostLiveKit } from "./ui/rtc-host-livekit"; +export { RtcSharingSheet } from "./ui/rtc-sharing-sheet"; export { rtcHostQuery } from "./api"; diff --git a/src/features/rtc/host-controls/model/rtc-host-control.ts b/src/features/rtc/host-controls/model/rtc-host-control.ts index 8d84e92..63e5cb9 100644 --- a/src/features/rtc/host-controls/model/rtc-host-control.ts +++ b/src/features/rtc/host-controls/model/rtc-host-control.ts @@ -8,6 +8,8 @@ export type RtcFinalizationBlockingState = Extract< "ENDING_ROOM" | "DELIVERING_RESULT" >; +export const RTC_HOST_ROOM_EXPIRES_IN_MINUTES = 60; + export function resolveRtcCameraMenuMode({ isBusy, isLive, diff --git a/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts b/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts new file mode 100644 index 0000000..0c40caa --- /dev/null +++ b/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts @@ -0,0 +1,115 @@ +import type { RtcEndRoomResponse } from "@entities/rtc-room"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RtcHostFinalizationState } from "./rtc-host-control"; + +interface UseRtcHostTerminationControllerOptions { + preparePhotos: () => Promise; + stopPublishing: () => Promise; + endRoom: () => Promise; + deliverResult: (result: RtcEndRoomResponse) => Promise; + disconnectRoom: () => Promise; + onCompleted: (result: RtcEndRoomResponse) => void | Promise; + onStateChange?: (state: RtcHostFinalizationState) => void; +} + +/** + * Host 종료의 단일 lifecycle을 소유합니다. + * 사진 확정 → 송출 중단 → 서버 종료 → 결과 전달 → 연결 정리 순서를 + * single-flight로 실행하며, 서버가 이미 종료된 재시도에서는 결과를 재사용합니다. + */ +export function useRtcHostTerminationController({ + preparePhotos, + stopPublishing, + endRoom, + deliverResult, + disconnectRoom, + onCompleted, + onStateChange, +}: UseRtcHostTerminationControllerOptions) { + const isMountedRef = useRef(true); + const terminationPromiseRef = useRef | null>(null); + const completedResultRef = useRef(null); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const requestTermination = useCallback(() => { + if (terminationPromiseRef.current) return terminationPromiseRef.current; + + const terminationPromise = (async () => { + onStateChange?.("PREPARING_PHOTOS"); + setErrorMessage(null); + + let result = completedResultRef.current; + let finalizationError: unknown; + + try { + // 사진을 고르는 동안에는 기존 영상 송출을 유지합니다. + await preparePhotos(); + + onStateChange?.("ENDING_ROOM"); + await stopPublishing(); + + if (!result) { + result = await endRoom(); + completedResultRef.current = result; + } + + if (!isMountedRef.current) return; + + onStateChange?.("DELIVERING_RESULT"); + await deliverResult(result); + } catch (error) { + finalizationError = error; + } finally { + try { + await stopPublishing(); + } catch (error) { + finalizationError ??= error; + } + try { + await disconnectRoom(); + } catch (error) { + finalizationError ??= error; + } + } + + if (finalizationError || !result) { + if (isMountedRef.current) { + setErrorMessage( + finalizationError instanceof Error + ? finalizationError.message + : "RTC 공유 종료에 실패했습니다. 종료 처리를 다시 시도해주세요.", + ); + onStateChange?.("FAILED"); + } + return; + } + + await onCompleted(result); + if (isMountedRef.current) onStateChange?.("IDLE"); + })().finally(() => { + terminationPromiseRef.current = null; + }); + + terminationPromiseRef.current = terminationPromise; + return terminationPromise; + }, [ + deliverResult, + disconnectRoom, + endRoom, + onCompleted, + onStateChange, + preparePhotos, + stopPublishing, + ]); + + const clearError = useCallback(() => setErrorMessage(null), []); + + return { clearError, errorMessage, requestTermination }; +} diff --git a/src/pages/camera/ui/rtc-livekit-page.tsx b/src/features/rtc/host-controls/ui/rtc-host-livekit.tsx similarity index 55% rename from src/pages/camera/ui/rtc-livekit-page.tsx rename to src/features/rtc/host-controls/ui/rtc-host-livekit.tsx index baea224..d395cc8 100644 --- a/src/pages/camera/ui/rtc-livekit-page.tsx +++ b/src/features/rtc/host-controls/ui/rtc-host-livekit.tsx @@ -1,53 +1,32 @@ import { - decodeRtcRoomEndedRpcPayload, encodeRtcRoomEndedRpcPayload, RTC_ROOM_ENDED_RPC_ACK, RTC_ROOM_ENDED_RPC_METHOD, RtcEndRoomResponse, - RtcRoomResponse, } from "@entities/rtc-room"; import { createVisionCameraVideoPublisher, - RtcLiveKitConnection, - RtcVideoPublisher, - RtcVideoPublisherFactory, + type RtcLiveKitConnection, + type RtcVideoPublisher, + type RtcVideoPublisherFactory, } from "@entities/rtc-session"; -import type { RtcHostFinalizationState } from "@features/rtc/host-controls"; +import type { RtcHostFinalizationState } from "../model/rtc-host-control"; +import { useRtcHostTerminationController } from "../model/use-rtc-host-termination-controller"; import { - LiveKitRoom, RoomContext, useConnectionState, useRoomContext, - useTracks, - VideoTrack, } from "@livekit/react-native"; -import { - Box, - Button, - ButtonIcon, - ButtonText, - FramingGridOverlay, - Text, - VStack, -} from "@shared/ui"; -import { IconX } from "@tabler/icons-react-native"; -import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"; -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { Alert, BackHandler, StyleSheet, View } from "react-native"; +import { Button, ButtonText, Text, VStack } from "@shared/ui"; +import { ConnectionState, Room, RoomEvent } from "livekit-client"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, StyleSheet, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { SharingWaitingPage } from "./sharing-waiting-page"; const isRoomConnected = (room: Room) => room.state === ConnectionState.Connected; -interface RtcHostLiveKitPageProps { +interface RtcHostLiveKitProps { connection: RtcLiveKitConnection; isActive: boolean; onPrepareEndRoom: () => Promise; @@ -58,18 +37,10 @@ interface RtcHostLiveKitPageProps { publisherFactory?: RtcVideoPublisherFactory; } -interface RtcViewerLiveKitPageProps { - connection: RtcLiveKitConnection; - roomId: string; - rtcRoom: RtcRoomResponse | null; - reactionPicker: ReactNode; - onCancel: () => void | Promise; - onRoomEnded: (result: RtcEndRoomResponse) => void | Promise; -} - interface HostRoomContentProps { canPublish: boolean; connectionError: string | null; + disconnectRoom: () => Promise; ensureConnected: () => Promise; isActive: boolean; onPrepareEndRoom: () => Promise; @@ -83,6 +54,7 @@ interface HostRoomContentProps { function HostRoomContent({ canPublish, connectionError, + disconnectRoom, ensureConnected, isActive, onPrepareEndRoom, @@ -98,16 +70,9 @@ function HostRoomContent({ const isActiveRef = useRef(isActive); const canPublishRef = useRef(canPublish); const hasPublisherStartBeenRequestedRef = useRef(false); - const isStoppingRef = useRef(false); - const hasStopBeenRequestedRef = useRef(false); - const completedResultRef = useRef(null); const handledEndRequestIdRef = useRef(endRequestId); const [publisherError, setPublisherError] = useState(null); - const [finalizationErrorMessage, setFinalizationErrorMessage] = useState< - string | null - >(null); const [isPublishing, setIsPublishing] = useState(false); - const [, setIsStopping] = useState(false); isActiveRef.current = isActive; canPublishRef.current = canPublish; @@ -131,7 +96,6 @@ function HostRoomContent({ if ( !canPublishRef.current || connectionState !== ConnectionState.Connected || - hasStopBeenRequestedRef.current || hasPublisherStartBeenRequestedRef.current ) { return; @@ -140,7 +104,6 @@ function HostRoomContent({ hasPublisherStartBeenRequestedRef.current = true; setIsPublishing(true); setPublisherError(null); - setFinalizationErrorMessage(null); try { await publisher.start(); @@ -219,93 +182,23 @@ function HostRoomContent({ [ensureConnected, room], ); - const handleStop = useCallback(async () => { - if (isStoppingRef.current) return; - - isStoppingRef.current = true; - hasStopBeenRequestedRef.current = true; - setIsStopping(true); - onFinalizationStateChange?.("PREPARING_PHOTOS"); - setPublisherError(null); - setFinalizationErrorMessage(null); - - let result = completedResultRef.current; - let finalizationError: unknown; - - try { - // 호스트가 사진을 고르는 동안에는 기존 영상 송출을 유지합니다. - // 선택을 확정한 뒤에만 publisher와 native frame sink를 정리합니다. - await onPrepareEndRoom(); - - // publisher 내부에서 frame sink 차단 → unpublish → raw track - // stop/release 순서로 정리합니다. - onFinalizationStateChange?.("ENDING_ROOM"); - await publisher.stop(); - - if (!result) { - result = await onEndRoom(); - completedResultRef.current = result; - } - - // 화면 cleanup이 이미 시작됐다면 결과 전송을 위해 Room을 다시 - // 연결하지 않습니다. 아래 finally에서 마지막 disconnect만 보장합니다. - if (!isMountedRef.current) return; - - onFinalizationStateChange?.("DELIVERING_RESULT"); - await deliverResult(result); - } catch (error) { - finalizationError = error; - } finally { - // API/RPC가 실패해도 카메라 track과 Room 연결이 남지 않습니다. - try { - await publisher.stop(); - } catch (error) { - finalizationError ??= error; - } - try { - await room.disconnect(); - } catch (error) { - finalizationError ??= error; - } - } - - if (finalizationError || !result) { - if (isMountedRef.current) { - setFinalizationErrorMessage( - finalizationError instanceof Error - ? finalizationError.message - : "RTC 공유 종료에 실패했습니다. 종료 처리를 다시 시도해주세요.", - ); - setIsStopping(false); - onFinalizationStateChange?.("FAILED"); - } - isStoppingRef.current = false; - return; - } - - try { - await onStopped(result); - } finally { - isStoppingRef.current = false; - if (isMountedRef.current) { - setIsStopping(false); - onFinalizationStateChange?.("IDLE"); - } - } - }, [ + const { + clearError: clearFinalizationError, + errorMessage: finalizationErrorMessage, + requestTermination, + } = useRtcHostTerminationController({ + preparePhotos: onPrepareEndRoom, + stopPublishing: () => publisher.stop(), + endRoom: onEndRoom, deliverResult, - onPrepareEndRoom, - onEndRoom, - onStopped, - onFinalizationStateChange, - publisher, - room, - ]); + disconnectRoom, + onCompleted: onStopped, + onStateChange: onFinalizationStateChange, + }); useEffect(() => { if (!finalizationErrorMessage) return; - const clearError = () => setFinalizationErrorMessage(null); Alert.alert( "RTC 방 종료 실패", finalizationErrorMessage, @@ -313,22 +206,22 @@ function HostRoomContent({ { text: "닫기", style: "cancel", - onPress: clearError, + onPress: clearFinalizationError, }, { text: "종료 처리 다시 시도", onPress: () => { - clearError(); - void handleStop(); + clearFinalizationError(); + void requestTermination(); }, }, ], { cancelable: true, - onDismiss: clearError, + onDismiss: clearFinalizationError, }, ); - }, [finalizationErrorMessage, handleStop]); + }, [clearFinalizationError, finalizationErrorMessage, requestTermination]); useEffect(() => { if (endRequestId <= handledEndRequestIdRef.current || endRequestId <= 0) { @@ -336,20 +229,8 @@ function HostRoomContent({ } handledEndRequestIdRef.current = endRequestId; - void handleStop(); - }, [endRequestId, handleStop]); - - useEffect(() => { - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - () => { - void handleStop(); - return true; - }, - ); - - return () => subscription.remove(); - }, [handleStop]); + void requestTermination(); + }, [endRequestId, requestTermination]); const errorMessage = publisherError ?? connectionError; @@ -377,7 +258,7 @@ function HostRoomContent({ ); } -export function RtcHostLiveKitPage({ +export function RtcHostLiveKit({ connection, isActive, onPrepareEndRoom, @@ -386,7 +267,7 @@ export function RtcHostLiveKitPage({ endRequestId = 0, onFinalizationStateChange, publisherFactory = createVisionCameraVideoPublisher, -}: RtcHostLiveKitPageProps) { +}: RtcHostLiveKitProps) { const room = useMemo( () => new Room({ @@ -573,6 +454,7 @@ export function RtcHostLiveKitPage({ ); } - -interface ViewerRoomContentProps { - connectionError: string | null; - roomId: string; - rtcRoom: RtcRoomResponse | null; - reactionPicker: ReactNode; - onCancel: () => void | Promise; - onRoomEnded: (result: RtcEndRoomResponse) => void | Promise; -} - -function ViewerRoomContent({ - connectionError, - roomId, - rtcRoom, - reactionPicker, - onCancel, - onRoomEnded, -}: ViewerRoomContentProps) { - const room = useRoomContext(); - const connectionState = useConnectionState(); - const cameraTracks = useTracks([Track.Source.Camera], { - onlySubscribed: true, - }); - const remoteCameraTrack = cameraTracks.find( - ({ participant }) => !participant.isLocal, - ); - const [isLeaving, setIsLeaving] = useState(false); - const hostNickname = rtcRoom?.host.nickname.trim() || "호스트"; - const isLeavingRef = useRef(false); - const deliveredResultRef = useRef(null); - - const handleCancel = useCallback(async () => { - if (isLeavingRef.current) return; - - isLeavingRef.current = true; - setIsLeaving(true); - try { - await room.disconnect(); - } finally { - await onCancel(); - } - }, [onCancel, room]); - - useEffect(() => { - room.registerRpcMethod( - RTC_ROOM_ENDED_RPC_METHOD, - async ({ callerIdentity, payload }) => { - const caller = room.remoteParticipants.get(callerIdentity); - const result = decodeRtcRoomEndedRpcPayload(payload); - - if ( - caller?.permissions?.canPublish !== true || - !result || - result.roomId !== roomId - ) { - throw new Error("유효하지 않은 RTC 종료 결과입니다."); - } - - if (!isLeavingRef.current) { - isLeavingRef.current = true; - deliveredResultRef.current = result; - setIsLeaving(true); - - // RPC 응답이 host에 도착한 뒤 LiveKitRoom을 unmount하도록 - // 다음 macrotask에서 결과 화면 전환을 시작합니다. - setTimeout(() => { - const deliveredResult = deliveredResultRef.current; - if (!deliveredResult) return; - - void (async () => { - try { - await room.disconnect(); - } finally { - await onRoomEnded(deliveredResult); - } - })(); - }, 0); - } - - return RTC_ROOM_ENDED_RPC_ACK; - }, - ); - - return () => { - room.unregisterRpcMethod(RTC_ROOM_ENDED_RPC_METHOD); - }; - }, [onRoomEnded, room, roomId]); - - useEffect(() => { - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - () => { - void handleCancel(); - return true; - }, - ); - - return () => subscription.remove(); - }, [handleCancel]); - - if (!remoteCameraTrack) { - return ( - - void handleCancel()} - /> - {connectionError ? ( - - {connectionError} - - ) : null} - - ); - } - - return ( - - - - - - - {hostNickname} 님의 실시간 공유 - - - - 사진에 찍히는 내 모습 - - - - - - - - {connectionError ? ( - - {connectionError} - - ) : null} - - - - {reactionPicker} - - - ); -} - -export function RtcViewerLiveKitPage({ - connection, - roomId, - rtcRoom, - reactionPicker, - onCancel, - onRoomEnded, -}: RtcViewerLiveKitPageProps) { - const [connectionError, setConnectionError] = useState(null); - - return ( - setConnectionError(null)} - onDisconnected={() => - setConnectionError("LiveKit 연결이 종료되었습니다.") - } - onError={(error) => setConnectionError(error.message)} - > - - - ); -} diff --git a/src/pages/camera/ui/sharing-camera-page.tsx b/src/features/rtc/host-controls/ui/rtc-sharing-sheet.tsx similarity index 96% rename from src/pages/camera/ui/sharing-camera-page.tsx rename to src/features/rtc/host-controls/ui/rtc-sharing-sheet.tsx index a5b5e47..88a7522 100644 --- a/src/pages/camera/ui/sharing-camera-page.tsx +++ b/src/features/rtc/host-controls/ui/rtc-sharing-sheet.tsx @@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native"; import QRCode from "react-native-qrcode-svg"; import { SafeAreaView } from "react-native-safe-area-context"; -export interface SharingCameraSheetProps { +export interface RtcSharingSheetProps { joinCode: string; qrValue: string; participantCount: number; @@ -21,7 +21,7 @@ export interface SharingCameraSheetProps { onStart: () => void; } -export function SharingCameraSheet({ +export function RtcSharingSheet({ joinCode, qrValue, participantCount, @@ -29,7 +29,7 @@ export function SharingCameraSheet({ canStart = true, onCancel, onStart, -}: SharingCameraSheetProps) { +}: RtcSharingSheetProps) { const { width } = useWindowDimensions(); const qrSize = Math.min(236, Math.max(176, width - 112)); const safeParticipantCount = Math.max(0, participantCount); diff --git a/src/features/rtc/join-room/api/rtc-viewer-mutation.ts b/src/features/rtc/join-room/api/rtc-viewer-mutation.ts index 9f2d652..3edce55 100644 --- a/src/features/rtc/join-room/api/rtc-viewer-mutation.ts +++ b/src/features/rtc/join-room/api/rtc-viewer-mutation.ts @@ -8,7 +8,6 @@ import { } from "@entities/rtc-room"; import { assertRtcAppAccessToken, - isCurrentRtcViewerSession, type RtcViewerLiveKitTokenRequest, type RtcViewerLiveKitTokenResponse, useRtcStore, @@ -46,21 +45,6 @@ export function useCreateViewerLiveKitToken() { ); return response.data; }, - onSuccess: (response, request) => { - if ( - !isCurrentRtcViewerSession( - useRtcStore.getState().viewerSession, - request, - ) - ) { - return; - } - useRtcStore.getState().setLiveKitConnection({ - role: "VIEWER", - url: response.url, - token: response.token, - }); - }, }); } diff --git a/src/features/rtc/join-room/index.ts b/src/features/rtc/join-room/index.ts index 560b237..c01dc00 100644 --- a/src/features/rtc/join-room/index.ts +++ b/src/features/rtc/join-room/index.ts @@ -1,6 +1,8 @@ export { RtcJoinForm, RtcJoinFormHeader } from "./ui/rtc-join-form"; export type { RtcJoinFormProps } from "./ui/rtc-join-form"; export { RtcJoinSheet } from "./ui/rtc-join-sheet"; +export { RtcViewerWaiting } from "./ui/rtc-viewer-waiting"; +export { RtcViewerLiveKit } from "./ui/rtc-viewer-livekit"; export { resolveRtcViewerRoomSignal, type RtcViewerRoomSignal, @@ -10,4 +12,8 @@ export { type RtcViewerEntryPhase, type RtcViewerEntryStreamState, } from "./model/use-rtc-viewer-entry"; +export { + useRtcViewerExitController, + type RtcViewerExitResult, +} from "./model/use-rtc-viewer-exit-controller"; export { rtcViewerQuery } from "./api"; diff --git a/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts b/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts index a1c7a94..a20462e 100644 --- a/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts +++ b/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts @@ -5,7 +5,10 @@ import { RtcRoomResponseSchema, type RtcRoomEvent, } from "@entities/rtc-room"; -import type { RtcViewerSession } from "@entities/rtc-session"; +import type { + RtcLiveKitConnection, + RtcViewerSession, +} from "@entities/rtc-session"; import { useCallback, useEffect, useRef, useState } from "react"; import { resolveRtcViewerRoomSignal } from "./rtc-viewer-entry"; import { rtcViewerQuery } from "../api"; @@ -30,6 +33,7 @@ interface UseRtcViewerEntryResult { phase: RtcViewerEntryPhase; streamState: RtcViewerEntryStreamState; room: RtcRoomResponse | null; + connection: RtcLiveKitConnection | null; tokenErrorMessage: string | null; retryToken: () => void; } @@ -65,6 +69,9 @@ export function useRtcViewerEntry({ useState("IDLE"); const [room, setRoom] = useState(null); + const [connection, setConnection] = useState( + null, + ); const [tokenErrorMessage, setTokenErrorMessage] = useState( null, @@ -73,17 +80,20 @@ export function useRtcViewerEntry({ ? `${session.roomId.trim()}:${session.participantId.trim()}` : ""; const sessionKeyRef = useRef(sessionKey); + const entryEpochRef = useRef(0); const latestRoomSignalRef = useRef<"LIVE" | null>(null); const tokenAttemptedRef = useRef(false); const tokenPendingRef = useRef(false); const roomEndedRef = useRef(false); useEffect(() => { + entryEpochRef.current += 1; sessionKeyRef.current = sessionKey; latestRoomSignalRef.current = null; tokenAttemptedRef.current = false; tokenPendingRef.current = false; roomEndedRef.current = false; + setConnection(null); setTokenErrorMessage(null); setPhase(enabled && session ? "WAITING_FOR_LIVE" : "IDLE"); setStreamState(enabled && session ? "CONNECTING" : "IDLE"); @@ -106,6 +116,7 @@ export function useRtcViewerEntry({ } const requestSessionKey = sessionKey; + const requestEntryEpoch = entryEpochRef.current; tokenAttemptedRef.current = true; tokenPendingRef.current = true; setTokenErrorMessage(null); @@ -115,17 +126,23 @@ export function useRtcViewerEntry({ roomId: session.roomId, participantId: session.participantId, }) - .then(() => { + .then((response) => { if ( sessionKeyRef.current === requestSessionKey && + entryEpochRef.current === requestEntryEpoch && !roomEndedRef.current ) { + setConnection({ + url: response.url, + token: response.token, + }); setPhase("READY"); } }) .catch((error: unknown) => { if ( sessionKeyRef.current === requestSessionKey && + entryEpochRef.current === requestEntryEpoch && !roomEndedRef.current ) { setTokenErrorMessage(getErrorMessage(error)); @@ -133,7 +150,10 @@ export function useRtcViewerEntry({ } }) .finally(() => { - if (sessionKeyRef.current === requestSessionKey) { + if ( + sessionKeyRef.current === requestSessionKey && + entryEpochRef.current === requestEntryEpoch + ) { tokenPendingRef.current = false; } }); @@ -189,6 +209,7 @@ export function useRtcViewerEntry({ roomEndedRef.current = true; latestRoomSignalRef.current = null; if (isMounted) { + setConnection(null); setPhase("ROOM_ENDED"); setStreamState("ENDED"); } @@ -255,6 +276,7 @@ export function useRtcViewerEntry({ phase, streamState, room, + connection, tokenErrorMessage, retryToken, }; diff --git a/src/features/rtc/join-room/model/use-rtc-viewer-exit-controller.ts b/src/features/rtc/join-room/model/use-rtc-viewer-exit-controller.ts new file mode 100644 index 0000000..8c414c1 --- /dev/null +++ b/src/features/rtc/join-room/model/use-rtc-viewer-exit-controller.ts @@ -0,0 +1,85 @@ +import type { RtcViewerSession } from "@entities/rtc-session"; +import { getApiErrorMessage } from "@shared/api"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { rtcViewerQuery } from "../api"; + +type DisconnectLiveKit = () => Promise; + +export type RtcViewerExitResult = + { ok: true } | { ok: false; errorMessage: string }; + +interface UseRtcViewerExitControllerOptions { + session: RtcViewerSession | null; + onExited: () => void; +} + +/** + * Viewer의 명시적인 나가기 순서를 한 경로로 직렬화합니다. + * 서버 leave가 성공하기 전에는 LiveKit과 runtime session을 유지합니다. + */ +export function useRtcViewerExitController({ + session, + onExited, +}: UseRtcViewerExitControllerOptions) { + const { mutateAsync: leaveRoom } = rtcViewerQuery.useLeaveRtcRoom(); + const [isExiting, setIsExiting] = useState(false); + const isMountedRef = useRef(true); + const exitPromiseRef = useRef | null>(null); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const requestExit = useCallback( + (disconnectLiveKit?: DisconnectLiveKit) => { + if (exitPromiseRef.current) return exitPromiseRef.current; + + const participantId = session?.participantId.trim(); + if (!participantId) { + return Promise.resolve({ + ok: false, + errorMessage: "RTC 참여 정보가 없습니다.", + }); + } + + if (isMountedRef.current) setIsExiting(true); + + const exitPromise = (async (): Promise => { + try { + await leaveRoom({ participantId }); + } catch (error) { + return { + ok: false, + errorMessage: getApiErrorMessage( + error, + "방에서 나가지 못했습니다. 잠시 후 다시 시도해주세요.", + ), + }; + } + + // 서버가 Viewer를 퇴장시킨 뒤에는 로컬 disconnect 실패가 + // session 종료를 되돌릴 수 없으므로 cleanup을 끝까지 진행합니다. + try { + await disconnectLiveKit?.(); + } catch { + // LiveKitRoom unmount cleanup이 마지막 disconnect를 보장합니다. + } + + onExited(); + return { ok: true }; + })().finally(() => { + exitPromiseRef.current = null; + if (isMountedRef.current) setIsExiting(false); + }); + + exitPromiseRef.current = exitPromise; + return exitPromise; + }, + [leaveRoom, onExited, session?.participantId], + ); + + return { isExiting, requestExit }; +} diff --git a/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx b/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx new file mode 100644 index 0000000..fb074b7 --- /dev/null +++ b/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx @@ -0,0 +1,250 @@ +import { + decodeRtcRoomEndedRpcPayload, + RTC_ROOM_ENDED_RPC_ACK, + RTC_ROOM_ENDED_RPC_METHOD, + type RtcEndRoomResponse, + type RtcRoomResponse, +} from "@entities/rtc-room"; +import type { RtcLiveKitConnection } from "@entities/rtc-session"; +import { + LiveKitRoom, + useConnectionState, + useRoomContext, + useTracks, + VideoTrack, +} from "@livekit/react-native"; +import { + Box, + Button, + ButtonIcon, + FramingGridOverlay, + Text, + VStack, +} from "@shared/ui"; +import { IconX } from "@tabler/icons-react-native"; +import { ConnectionState, Track } from "livekit-client"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, StyleSheet, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { RtcViewerExitResult } from "../model/use-rtc-viewer-exit-controller"; +import { RtcViewerWaiting } from "./rtc-viewer-waiting"; + +type DisconnectLiveKit = () => Promise; + +interface RtcViewerLiveKitProps { + connection: RtcLiveKitConnection; + roomId: string; + rtcRoom: RtcRoomResponse | null; + reactionPicker: ReactNode; + isExiting: boolean; + exitRequestId: number; + onRequestExit: ( + disconnectLiveKit: DisconnectLiveKit, + ) => Promise; + onRoomEnded: (result: RtcEndRoomResponse) => void | Promise; +} + +interface ViewerRoomContentProps extends Omit< + RtcViewerLiveKitProps, + "connection" +> { + connectionError: string | null; +} + +function ViewerRoomContent({ + connectionError, + roomId, + rtcRoom, + reactionPicker, + isExiting, + exitRequestId, + onRequestExit, + onRoomEnded, +}: ViewerRoomContentProps) { + const room = useRoomContext(); + const connectionState = useConnectionState(); + const cameraTracks = useTracks([Track.Source.Camera], { + onlySubscribed: true, + }); + const remoteCameraTrack = cameraTracks.find( + ({ participant }) => !participant.isLocal, + ); + const hostNickname = rtcRoom?.host.nickname.trim() || "호스트"; + const isHandlingRoomEndRef = useRef(false); + const handledExitRequestIdRef = useRef(exitRequestId); + const deliveredResultRef = useRef(null); + + const handleCancel = useCallback(async () => { + const result = await onRequestExit(() => room.disconnect()); + if (!result.ok) { + Alert.alert("RTC 방 나가기 실패", result.errorMessage); + } + }, [onRequestExit, room]); + + useEffect(() => { + room.registerRpcMethod( + RTC_ROOM_ENDED_RPC_METHOD, + async ({ callerIdentity, payload }) => { + const caller = room.remoteParticipants.get(callerIdentity); + const result = decodeRtcRoomEndedRpcPayload(payload); + + if ( + caller?.permissions?.canPublish !== true || + !result || + result.roomId !== roomId + ) { + throw new Error("유효하지 않은 RTC 종료 결과입니다."); + } + + if (!isHandlingRoomEndRef.current) { + isHandlingRoomEndRef.current = true; + deliveredResultRef.current = result; + + // ACK가 Host에 도착한 다음 macrotask에서 Room을 정리하고 + // 결과 화면으로 전환합니다. + setTimeout(() => { + const deliveredResult = deliveredResultRef.current; + if (!deliveredResult) return; + + void (async () => { + try { + await room.disconnect(); + } finally { + await onRoomEnded(deliveredResult); + } + })(); + }, 0); + } + + return RTC_ROOM_ENDED_RPC_ACK; + }, + ); + + return () => { + room.unregisterRpcMethod(RTC_ROOM_ENDED_RPC_METHOD); + }; + }, [onRoomEnded, room, roomId]); + + useEffect(() => { + if ( + exitRequestId <= handledExitRequestIdRef.current || + exitRequestId <= 0 + ) { + return; + } + + handledExitRequestIdRef.current = exitRequestId; + void handleCancel(); + }, [exitRequestId, handleCancel]); + + if (!remoteCameraTrack) { + return ( + + void handleCancel()} + /> + {connectionError ? ( + + {connectionError} + + ) : null} + + ); + } + + return ( + + + + + + + {hostNickname} 님의 실시간 공유 + + + 사진에 찍히는 내 모습 + + + + + + + + {connectionError ? ( + + {connectionError} + + ) : null} + + + + {reactionPicker} + + + ); +} + +export function RtcViewerLiveKit({ + connection, + roomId, + rtcRoom, + reactionPicker, + isExiting, + exitRequestId, + onRequestExit, + onRoomEnded, +}: RtcViewerLiveKitProps) { + const [connectionError, setConnectionError] = useState(null); + + return ( + setConnectionError(null)} + onDisconnected={() => + setConnectionError("LiveKit 연결이 종료되었습니다.") + } + onError={(error) => setConnectionError(error.message)} + > + + + ); +} diff --git a/src/pages/camera/ui/sharing-waiting-page.tsx b/src/features/rtc/join-room/ui/rtc-viewer-waiting.tsx similarity index 96% rename from src/pages/camera/ui/sharing-waiting-page.tsx rename to src/features/rtc/join-room/ui/rtc-viewer-waiting.tsx index 0ceec8c..143d5d7 100644 --- a/src/pages/camera/ui/sharing-waiting-page.tsx +++ b/src/features/rtc/join-room/ui/rtc-viewer-waiting.tsx @@ -2,7 +2,7 @@ import { Button, ButtonText, Center, Text, VStack } from "@shared/ui"; import { ActivityIndicator, Image } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -export interface SharingWaitingPageProps { +export interface RtcViewerWaitingProps { hostNickname?: string; onCancel: () => void; isCanceling?: boolean; @@ -11,14 +11,14 @@ export interface SharingWaitingPageProps { onRetry?: () => void; } -export function SharingWaitingPage({ +export function RtcViewerWaiting({ hostNickname, onCancel, isCanceling = false, isConnecting = false, connectionError, onRetry, -}: SharingWaitingPageProps) { +}: RtcViewerWaitingProps) { const displayHostNickname = hostNickname?.trim() || "호스트"; return ( diff --git a/src/pages/camera/ui/camera-page.tsx b/src/pages/camera/ui/camera-page.tsx index 54242c1..8417c31 100644 --- a/src/pages/camera/ui/camera-page.tsx +++ b/src/pages/camera/ui/camera-page.tsx @@ -3,7 +3,7 @@ import { RTC_MAX_CAPTURED_PHOTOS, RtcEndRoomResponse, } from "@entities/rtc-room"; -import { RtcLiveKitConnection, useRtcStore } from "@entities/rtc-session"; +import { type RtcLiveKitConnection, useRtcStore } from "@entities/rtc-session"; import { Camera, CameraRuntimeGeometry, @@ -22,6 +22,7 @@ import { } from "@features/camera/guide-feed"; import { prepareRtcEndImages, + RtcEndPhotoSelection, useResetMyRtcStoredPhotos, } from "@features/rtc/finalize-session"; import { @@ -29,6 +30,9 @@ import { isRtcFinalizationPending, RtcCameraRoomMenu, RtcFinalizationOverlay, + RtcHostLiveKit, + RTC_HOST_ROOM_EXPIRES_IN_MINUTES, + RtcSharingSheet, rtcHostQuery, type RtcHostFinalizationState, useRtcRoomEvents, @@ -49,9 +53,6 @@ import { Alert, BackHandler } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { CapturedPhotosLayer } from "./captured-photos-layer"; import { CameraHeader } from "./camera-header"; -import { RtcHostLiveKitPage } from "./rtc-livekit-page"; -import { SharingCameraSheet } from "./sharing-camera-page"; -import { SharingEndSelectionPage } from "./sharing-end-selection-page"; import { SharingResultPage } from "./sharing-result-page"; type CameraMode = "VISION_CAMERA" | "RESULT"; @@ -99,9 +100,6 @@ export function CameraPage() { RTC_NAVIGATION.values.joinSheetOpen; const hostSession = useRtcStore((state) => state.hostSession); const clearHostSession = useRtcStore((state) => state.clearHostSession); - const clearLiveKitConnection = useRtcStore( - (state) => state.clearLiveKitConnection, - ); const createRoomMutation = rtcHostQuery.useCreateRtcRoom(); const createHostTokenMutation = rtcHostQuery.useCreateHostLiveKitToken(); const endRoomMutation = rtcHostQuery.useEndRtcRoom(); @@ -134,6 +132,7 @@ export function CameraPage() { ); const selectedEndPhotosRef = useRef(null); const preparedEndImagesRef = useRef(null); + const shouldExitAfterResultRef = useRef(false); const appliedInitialGuideFeedIdRef = useRef(null); const cameraGuide = useCameraGuideController({ cameraActive: @@ -155,19 +154,6 @@ export function CameraPage() { ); const openConfirm = useConfirm(); - usePreventRemove(isFinalizationBlocking, () => undefined); - - useEffect(() => { - if (!isFinalizationBlocking) return; - - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - () => true, - ); - - return () => subscription.remove(); - }, [isFinalizationBlocking]); - useEffect(() => { if ( !initialGuideFeedId || @@ -277,7 +263,7 @@ export function CameraPage() { try { setIsJoinSheetOpen(false); await createRoomMutation.mutateAsync({ - expiresInMinutes: 60, + expiresInMinutes: RTC_HOST_ROOM_EXPIRES_IN_MINUTES, }); setCapturedPhotos([]); selectedEndPhotosRef.current = null; @@ -288,22 +274,26 @@ export function CameraPage() { } }; - const handleRequestEndRoom = useCallback(async () => { - if (!broadcastConnection || isRtcFinalizationPending(finalizationState)) { - return; - } + const handleRequestEndRoom = useCallback( + async (exitAfterResult = false) => { + if (!broadcastConnection || isRtcFinalizationPending(finalizationState)) { + return; + } - const confirmed = await openConfirm({ - title: "실시간 공유를 종료할까요?", - message: "종료 전에 방에 저장할 촬영 사진을 선택할 수 있습니다.", - confirmText: "종료하기", - cancelText: "계속 공유", - destructive: true, - }); - if (!confirmed) return; + const confirmed = await openConfirm({ + title: "실시간 공유를 종료할까요?", + message: "종료 전에 방에 저장할 촬영 사진을 선택할 수 있습니다.", + confirmText: "종료하기", + cancelText: "계속 공유", + destructive: true, + }); + if (!confirmed) return; - setEndRequestId((current) => current + 1); - }, [broadcastConnection, finalizationState, openConfirm]); + shouldExitAfterResultRef.current = exitAfterResult; + setEndRequestId((current) => current + 1); + }, + [broadcastConnection, finalizationState, openConfirm], + ); const handleOpenJoinSheet = () => { setJoinInitialCode(undefined); @@ -330,7 +320,6 @@ export function CameraPage() { roomId: hostSession.roomId, }); clearHostSession(); - clearLiveKitConnection(); selectedEndPhotosRef.current = null; preparedEndImagesRef.current = null; setIsShareSheetOpen(false); @@ -360,7 +349,6 @@ export function CameraPage() { !isCameraPageFocusedRef.current || !isVisionCameraRunningRef.current ) { - clearLiveKitConnection(); Alert.alert( "카메라 상태 변경", "카메라가 중지되어 LiveKit 송출을 시작하지 않았습니다.", @@ -368,10 +356,8 @@ export function CameraPage() { return; } const connection: RtcLiveKitConnection = { - role: "HOST", url: response.url, token: response.token, - expiresAt: response.expiresAt, }; // VisionCamera는 그대로 실행됩니다. HOST RTC 계층은 같은 @@ -458,7 +444,6 @@ export function CameraPage() { ); clearHostSession(); - clearLiveKitConnection(); setBroadcastConnection(null); setFinalizationState("IDLE"); setEndRequestId(0); @@ -485,6 +470,8 @@ export function CameraPage() { }; const handleCloseResult = () => { + const shouldExit = shouldExitAfterResultRef.current; + shouldExitAfterResultRef.current = false; setResultImages(null); setCapturedPhotos([]); selectedEndPhotosRef.current = null; @@ -492,9 +479,45 @@ export function CameraPage() { isVisionCameraRunningRef.current = false; setIsVisionCameraRunning(false); setCameraMode("VISION_CAMERA"); + + if (shouldExit) { + router.back(); + return; + } + setIsVisionCameraActive(true); }; + const handleRequestCameraExit = useCallback(() => { + if (!broadcastConnection) { + router.back(); + return; + } + + void handleRequestEndRoom(true); + }, [broadcastConnection, handleRequestEndRoom]); + + usePreventRemove( + Boolean(broadcastConnection) || isFinalizationBlocking, + () => { + if (!isFinalizationBlocking) handleRequestCameraExit(); + }, + ); + + useEffect(() => { + if (!broadcastConnection && !isFinalizationBlocking) return; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + if (!isFinalizationBlocking) handleRequestCameraExit(); + return true; + }, + ); + + return () => subscription.remove(); + }, [broadcastConnection, handleRequestCameraExit, isFinalizationBlocking]); + const handlePhotoLimitReached = useCallback((maxPhotos: number) => { Alert.alert( "사진 촬영 한도", @@ -550,11 +573,11 @@ export function CameraPage() { router.back()} + onClose={handleRequestCameraExit} renderHeader={({ onOpenSettings, presentation }) => ( router.back()} + onBackPress={handleRequestCameraExit} onSettingsPress={onOpenSettings} rtcControl={ void handleOpenShare()} onJoinPress={handleOpenJoinSheet} - onEndRoomPress={() => void handleRequestEndRoom()} + onEndRoomPress={() => void handleRequestEndRoom(false)} /> } /> @@ -667,7 +690,7 @@ export function CameraPage() { /> {broadcastConnection ? ( - {isShareSheetOpen && hostSession ? ( - - diff --git a/src/pages/camera/ui/rtc-viewer-page.tsx b/src/pages/camera/ui/rtc-viewer-page.tsx index ea281f5..54ef057 100644 --- a/src/pages/camera/ui/rtc-viewer-page.tsx +++ b/src/pages/camera/ui/rtc-viewer-page.tsx @@ -5,18 +5,21 @@ import { import { useRtcStore } from "@entities/rtc-session"; import { RTC_STORED_PHOTO_MAX_TAKE } from "@entities/rtc-stored-photo"; import { useResetMyRtcStoredPhotos } from "@features/rtc/finalize-session"; -import { rtcViewerQuery, useRtcViewerEntry } from "@features/rtc/join-room"; +import { + RtcViewerLiveKit, + RtcViewerWaiting, + useRtcViewerEntry, + useRtcViewerExitController, +} from "@features/rtc/join-room"; import { RtcViewerReactionPicker } from "@features/rtc/reactions"; -import { getApiErrorMessage } from "@shared/api"; import { RTC_NAVIGATION } from "@shared/config"; import { Button, ButtonText, Center, Text, VStack } from "@shared/ui"; import { Href, router } from "expo-router"; +import { usePreventRemove } from "expo-router/react-navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert } from "react-native"; +import { Alert, BackHandler } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { RtcViewerLiveKitPage } from "./rtc-livekit-page"; import { SharingResultPage } from "./sharing-result-page"; -import { SharingWaitingPage } from "./sharing-waiting-page"; import { rtcStoredPhotoQuery } from "../api"; interface ViewerResultImage { @@ -131,30 +134,24 @@ function RtcViewerResultPage({ } export function RtcViewerPage() { - const { mutateAsync: leaveRoom, isPending: isLeavingRoom } = - rtcViewerQuery.useLeaveRtcRoom(); - const resetMyRtcStoredPhotos = useResetMyRtcStoredPhotos(); const viewerSession = useRtcStore((state) => state.viewerSession); - const liveKitConnection = useRtcStore((state) => state.liveKitConnection); const clearViewerSession = useRtcStore((state) => state.clearViewerSession); const [viewerResult, setViewerResult] = useState( null, ); + const [exitRequestId, setExitRequestId] = useState(0); + const [isExitCompleted, setIsExitCompleted] = useState(false); const hasPresentedEndedAlertRef = useRef(false); const hasPresentedResultRef = useRef(false); const hasEnteredLiveRef = useRef(false); - const leaveLockRef = useRef(false); - const endedFallbackTimerRef = useRef | null>( null, ); - const hasViewerConnection = liveKitConnection?.role === "VIEWER"; - // LiveKit 연결 이후에도 SSE 구독을 유지한다. const viewerEntry = useRtcViewerEntry({ enabled: Boolean(viewerSession), @@ -162,10 +159,10 @@ export function RtcViewerPage() { }); useEffect(() => { - if (hasViewerConnection) { + if (viewerEntry.connection) { hasEnteredLiveRef.current = true; } - }, [hasViewerConnection]); + }, [viewerEntry.connection]); const clearEndedFallbackTimer = useCallback(() => { if (!endedFallbackTimerRef.current) { @@ -178,10 +175,15 @@ export function RtcViewerPage() { const finishViewerExit = useCallback(() => { clearEndedFallbackTimer(); + setIsExitCompleted(true); clearViewerSession(); - router.replace("/feed" as Href); }, [clearEndedFallbackTimer, clearViewerSession]); + const { isExiting, requestExit } = useRtcViewerExitController({ + session: viewerSession, + onExited: finishViewerExit, + }); + /* * RPC와 SSE가 모두 이 함수를 통해 결과 화면으로 진입한다. * @@ -228,37 +230,46 @@ export function RtcViewerPage() { ); const handleCancelBeforeLiveKit = useCallback(async () => { - const participantId = viewerSession?.participantId; + const result = await requestExit(); + if (!result.ok) { + Alert.alert("RTC 방 나가기 실패", result.errorMessage); + } + }, [requestExit]); - if (!participantId || isLeavingRoom || leaveLockRef.current) { + const handleRequestViewerExit = useCallback(() => { + if (isExiting) return; + + if (viewerEntry.connection) { + setExitRequestId((current) => current + 1); return; } - leaveLockRef.current = true; + void handleCancelBeforeLiveKit(); + }, [handleCancelBeforeLiveKit, isExiting, viewerEntry.connection]); - try { - await leaveRoom({ - participantId, - }); + const shouldPreventViewerExit = + Boolean(viewerSession) && viewerResult === null && !isExitCompleted; + usePreventRemove(shouldPreventViewerExit, handleRequestViewerExit); - finishViewerExit(); - } catch (error) { - Alert.alert( - "RTC 방 나가기 실패", - getApiErrorMessage( - error, - "방에서 나가지 못했습니다. 잠시 후 다시 시도해주세요.", - ), - ); - } finally { - leaveLockRef.current = false; + useEffect(() => { + if (!shouldPreventViewerExit) return; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + handleRequestViewerExit(); + return true; + }, + ); + + return () => subscription.remove(); + }, [handleRequestViewerExit, shouldPreventViewerExit]); + + useEffect(() => { + if (isExitCompleted) { + router.replace("/feed" as Href); } - }, [ - finishViewerExit, - isLeavingRoom, - leaveRoom, - viewerSession?.participantId, - ]); + }, [isExitCompleted]); /* * 유효한 Viewer Session 없이 직접 Viewer 페이지에 @@ -271,12 +282,13 @@ export function RtcViewerPage() { if ( !viewerSession && viewerResult === null && + !isExitCompleted && !hasPresentedResultRef.current && !hasPresentedEndedAlertRef.current ) { router.replace(RTC_NAVIGATION.paths.join as Href); } - }, [viewerResult, viewerSession]); + }, [isExitCompleted, viewerResult, viewerSession]); /* * SSE ended 처리 @@ -378,13 +390,9 @@ export function RtcViewerPage() { ); } - if ( - !viewerSession || - !liveKitConnection || - liveKitConnection.role !== "VIEWER" - ) { + if (!viewerSession || !viewerEntry.connection) { return ( - { void handleCancelBeforeLiveKit(); }} @@ -406,8 +414,8 @@ export function RtcViewerPage() { } return ( - } - onCancel={finishViewerExit} + isExiting={isExiting} + exitRequestId={exitRequestId} + onRequestExit={requestExit} onRoomEnded={handleRoomEnded} /> ); From 74991ab85667287aa525e6c5aeee6c79f26d1c17 Mon Sep 17 00:00:00 2001 From: lioba00700 Date: Wed, 2 Sep 2026 19:54:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20RTC=20=EC=A2=85=EB=A3=8C=20lifecycl?= =?UTF-8?q?e=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/rtc-host-controls.test.cjs | 42 +++++++++-- .../__tests__/rtc-join-room.test.cjs | 72 ++++++++----------- .../__tests__/framing-grid-overlay.test.cjs | 5 +- 3 files changed, 72 insertions(+), 47 deletions(-) diff --git a/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs b/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs index 618aeb0..904a68f 100644 --- a/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs +++ b/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs @@ -2,6 +2,19 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); +const ts = require("typescript"); + +require.extensions[".ts"] = (module, filename) => { + const source = fs.readFileSync(filename, "utf8"); + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: filename, + }); + module._compile(output.outputText, filename); +}; const readSource = (relativePath) => fs.readFileSync(path.resolve(__dirname, relativePath), "utf8"); @@ -131,10 +144,13 @@ test("카메라 종료 오버레이는 lifecycle을 유지한 채 입력과 화 const pageSource = readSource("../../../../pages/camera/ui/camera-page.tsx"); const overlaySource = readSource("../ui/rtc-finalization-overlay.tsx"); - assert.match(pageSource, /usePreventRemove\(isFinalizationBlocking/); assert.match( pageSource, - /BackHandler\.addEventListener\(\s*"hardwareBackPress",\s*\(\) => true/, + /usePreventRemove\(\s*Boolean\(broadcastConnection\) \|\| isFinalizationBlocking/, + ); + assert.match( + pageSource, + /BackHandler\.addEventListener\(\s*"hardwareBackPress"/, ); assert.match( pageSource, @@ -153,18 +169,36 @@ test("사진이 포함된 RTC 종료 요청은 호스트 인증과 upload fetch assert.match(source, /headers,/); assert.match( source, - /privateApiClient\.patch\(url, undefined, \{\s*headers,/, + /privateApiClient\.patch\(url, undefined, \{\s*headers\s*\}\)/, ); }); test("RTC 종료 실패는 인라인 문구 대신 재시도 Alert로 표시한다", () => { - const source = readSource("../../../../pages/camera/ui/rtc-livekit-page.tsx"); + const source = readSource("../ui/rtc-host-livekit.tsx"); assert.match(source, /Alert\.alert\(\s*"RTC 방 종료 실패"/); assert.match(source, /text: "종료 처리 다시 시도"/); assert.doesNotMatch(source, /종료 처리 다시 시도<\/ButtonText>/); }); +test("Host 종료 controller는 사진 준비부터 연결 정리까지 순서대로 실행한다", () => { + const source = readSource("../model/use-rtc-host-termination-controller.ts"); + const prepareIndex = source.indexOf("await preparePhotos()"); + const stopIndex = source.indexOf("await stopPublishing()", prepareIndex); + const endIndex = source.indexOf("result = await endRoom()", stopIndex); + const deliverIndex = source.indexOf("await deliverResult(result)", endIndex); + const disconnectIndex = source.indexOf( + "await disconnectRoom()", + deliverIndex, + ); + + assert.ok(prepareIndex >= 0); + assert.ok(stopIndex > prepareIndex); + assert.ok(endIndex > stopIndex); + assert.ok(deliverIndex > endIndex); + assert.ok(disconnectIndex > deliverIndex); +}); + test("RTC SSE 재연결 지연은 지수 증가 후 15초로 제한한다", () => { assert.equal(getRtcRoomReconnectDelay(0), 1_000); assert.equal(getRtcRoomReconnectDelay(2), 4_000); diff --git a/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs b/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs index 11978e0..54335b6 100644 --- a/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs +++ b/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs @@ -1,50 +1,8 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { - isCurrentRtcViewerSession, -} = require("../../../../entities/rtc-session/model/rtc-viewer-session.ts"); const { resolveRtcViewerRoomSignal } = require("../model/rtc-viewer-entry.ts"); -const currentSession = { - roomId: "room-1", - participantId: "participant-1", -}; - -test("참여자 LiveKit 요청은 roomId와 participantId가 모두 같아야 현재 세션이다", () => { - assert.equal( - isCurrentRtcViewerSession(currentSession, { - roomId: "room-1", - participantId: "participant-1", - }), - true, - ); - assert.equal( - isCurrentRtcViewerSession(currentSession, { - roomId: "room-2", - participantId: "participant-1", - }), - false, - ); - assert.equal( - isCurrentRtcViewerSession(currentSession, { - roomId: "room-1", - participantId: "participant-2", - }), - false, - ); -}); - -test("참여자 LiveKit 요청 비교 전 ID의 바깥 공백을 제거한다", () => { - assert.equal( - isCurrentRtcViewerSession(currentSession, { - roomId: " room-1 ", - participantId: " participant-1 ", - }), - true, - ); -}); - test("RTC snapshot 상태가 LIVE일 때만 참여자 연결 신호를 반환한다", () => { assert.equal( resolveRtcViewerRoomSignal({ @@ -105,4 +63,34 @@ test("참여자 진입 hook은 공용 SSE와 수동 토큰 재시도를 소유 assert.match(source, /signal === "LIVE"/); assert.match(source, /phase !== "TOKEN_ERROR"/); assert.match(source, /tokenAttemptedRef\.current = false/); + assert.match(source, /setConnection\(\{/); +}); + +test("참여자 나가기는 서버 leave 성공 후 LiveKit과 session을 정리한다", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const source = fs.readFileSync( + path.resolve(__dirname, "../model/use-rtc-viewer-exit-controller.ts"), + "utf8", + ); + const leaveIndex = source.indexOf("await leaveRoom"); + const disconnectIndex = source.indexOf("await disconnectLiveKit?.()"); + const exitIndex = source.indexOf("onExited()", disconnectIndex); + + assert.ok(leaveIndex >= 0); + assert.ok(disconnectIndex > leaveIndex); + assert.ok(exitIndex > disconnectIndex); +}); + +test("Viewer 화면 이탈도 연결 상태에 맞는 동일한 나가기 요청을 사용한다", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const source = fs.readFileSync( + path.resolve(__dirname, "../../../../pages/camera/ui/rtc-viewer-page.tsx"), + "utf8", + ); + + assert.match(source, /usePreventRemove\(shouldPreventViewerExit/); + assert.match(source, /setExitRequestId\(\(current\) => current \+ 1\)/); + assert.match(source, /void handleCancelBeforeLiveKit\(\)/); }); diff --git a/src/shared/ui/framing-grid-overlay/__tests__/framing-grid-overlay.test.cjs b/src/shared/ui/framing-grid-overlay/__tests__/framing-grid-overlay.test.cjs index b785f07..827af5e 100644 --- a/src/shared/ui/framing-grid-overlay/__tests__/framing-grid-overlay.test.cjs +++ b/src/shared/ui/framing-grid-overlay/__tests__/framing-grid-overlay.test.cjs @@ -53,7 +53,10 @@ test("촬영자 그리드는 흰색 가이드보다 아래 레이어에 배치 test("참여자 영상은 전체 구도를 유지하고 그리드와 이모지 footer를 사용한다", () => { const liveKitSource = fs.readFileSync( - path.join(projectRoot, "src/pages/camera/ui/rtc-livekit-page.tsx"), + path.join( + projectRoot, + "src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx", + ), "utf8", ); const pickerSource = fs.readFileSync( From 55e6ce7f709ed479194c2749b8faadee43ffc041 Mon Sep 17 00:00:00 2001 From: lioba00700 Date: Wed, 2 Sep 2026 19:55:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20RTC=20=EC=97=AD=ED=95=A0=EB=B3=84?= =?UTF-8?q?=20session=20lifecycle=20=EA=B2=B0=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-camera-controls-rtc-stream-and-gallery.md | 2 +- ...-0016-rtc-viewer-livekit-token-identity.md | 2 +- ...tc-camera-session-and-livekit-lifecycle.md | 13 +- ...-0024-rtc-finalization-blocking-overlay.md | 4 +- .../adr-0026-rtc-viewer-waiting-entry.md | 5 + .../adr-0040-rtc-entity-boundaries.md | 7 +- ...-rtc-role-session-termination-lifecycle.md | 133 ++++++++++++++++++ 7 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 docs/architecture/adr-0044-rtc-role-session-termination-lifecycle.md diff --git a/docs/architecture/adr-0015-camera-controls-rtc-stream-and-gallery.md b/docs/architecture/adr-0015-camera-controls-rtc-stream-and-gallery.md index 72c27af..32112ac 100644 --- a/docs/architecture/adr-0015-camera-controls-rtc-stream-and-gallery.md +++ b/docs/architecture/adr-0015-camera-controls-rtc-stream-and-gallery.md @@ -94,7 +94,7 @@ RTC dropdown confirm ↓ end request command ↓ -RtcHostLiveKitPage +RtcHostLiveKit ↓ 사진 선택 → publisher 정리 → 서버 종료 ↓ diff --git a/docs/architecture/adr-0016-rtc-viewer-livekit-token-identity.md b/docs/architecture/adr-0016-rtc-viewer-livekit-token-identity.md index 57ca28a..70c0593 100644 --- a/docs/architecture/adr-0016-rtc-viewer-livekit-token-identity.md +++ b/docs/architecture/adr-0016-rtc-viewer-livekit-token-identity.md @@ -49,7 +49,7 @@ lifecycle과 stale-response 처리 의도가 명확하다. 서버 endpoint의 식별자 계약을 정확히 따르면서 비동기 응답을 현재 Viewer session에만 적용하기 위해 두 식별자를 함께 유지하는 방식을 선택했다. Host 토큰과 Viewer 토큰은 서로 다른 query와 endpoint를 -사용하며 앱 전체에는 `role: "VIEWER"`인 연결 정보만 노출한다. +사용하며 Viewer entry lifecycle에는 실제 연결에 필요한 URL과 token만 노출한다. ## Trade-off diff --git a/docs/architecture/adr-0022-rtc-camera-session-and-livekit-lifecycle.md b/docs/architecture/adr-0022-rtc-camera-session-and-livekit-lifecycle.md index 17491c1..edfc9e9 100644 --- a/docs/architecture/adr-0022-rtc-camera-session-and-livekit-lifecycle.md +++ b/docs/architecture/adr-0022-rtc-camera-session-and-livekit-lifecycle.md @@ -13,9 +13,9 @@ RTC frame sink 활성화와 역순 cleanup을 소유한다. CameraPage와 LiveKi native track 구현을 직접 알지 않는다. RTC session identity는 메모리 Zustand store가 소유한다. `hostSession`과 -`viewerSession`은 상호배타적이며 LiveKit connection도 현재 role과 함께 -보관한다. 방 access token과 LiveKit token은 앱 재시작 이후 복구하지 않고 -현재 프로세스에만 유지한다. 이 런타임 책임은 서버의 방 상태와 구분하기 위해 +`viewerSession`은 상호배타적이다. LiveKit connection은 현재 role feature의 +화면 lifecycle에 로컬로 유지하며 앱 재시작 이후 복구하지 않는다. 이 런타임 +책임은 서버의 방 상태와 구분하기 위해 `entities/rtc-session` Slice가 소유한다. 방 응답, 참가자와 이벤트는 `entities/rtc-room`이 소유한다. @@ -88,7 +88,7 @@ WebRTC track을, publisher는 LiveKit publication lifecycle을 담당한다. 경 → unpublish → raw track stop/release 순서다. track registry 소유권은 실제 release 호출 전에 소비해 cleanup 재진입에서도 두 번 해제하지 않는다. -`RtcHostLiveKitPage`는 connect와 cleanup promise를 single-flight로 유지하고 +`RtcHostLiveKit`은 connect와 cleanup promise를 single-flight로 유지하고 lifecycle epoch로 오래된 connect 완료를 무효화한다. Camera route가 focus를 잃으면 CameraSession과 host publisher/Room cleanup을 시작하며, 다시 활성화될 때만 새 연결 세대를 허용한다. @@ -126,8 +126,9 @@ Room disconnect를 재시도한다. 정확히 한 번 해제한다. - 호스트는 VisionCamera native track만 LiveKit에 publish하며 참여자는 camera capture 없이 remote track만 구독한다. -- store setter가 host와 viewer session을 상호배타적으로 전환하고 role이 - 일치하는 LiveKit connection만 함께 정리한다. +- store setter가 host와 viewer session을 상호배타적으로 전환한다. +- 역할별 LiveKit connection과 종료 lifecycle은 ADR-0044에 따라 feature + 내부에서 관리한다. - 화면 blur, unmount와 방 종료가 같은 idempotent publisher cleanup 경로를 재사용한다. - 참여자 종료 결과는 권한이 있는 host RPC와 같은 `roomId`인지 검증한 뒤에만 diff --git a/docs/architecture/adr-0024-rtc-finalization-blocking-overlay.md b/docs/architecture/adr-0024-rtc-finalization-blocking-overlay.md index 4cac7d4..2f69a23 100644 --- a/docs/architecture/adr-0024-rtc-finalization-blocking-overlay.md +++ b/docs/architecture/adr-0024-rtc-finalization-blocking-overlay.md @@ -60,7 +60,7 @@ CameraPage finalizationState └─ IDLE → 촬영 결과 화면 또는 기존 카메라 ``` -종료 workflow와 재시도용 완료 결과 cache는 `RtcHostLiveKitPage`가, +종료 workflow와 재시도용 완료 결과 cache는 Host termination controller가, presentation state snapshot은 CameraPage가 소유한다. overlay는 LiveKit이나 서버 API를 알지 않고 `RtcHostFinalizationState`만 입력받는다. VisionCamera와 LiveKit lifecycle은 기존 owner가 계속 관리한다. @@ -87,7 +87,7 @@ LiveKit lifecycle은 기존 owner가 계속 관리한다. - `isRtcFinalizationBlocking` type guard로 차단 상태를 두 단계로 제한했다. - CameraPage가 `usePreventRemove`, hardware BackHandler, 최상단 pointer overlay를 같은 조건으로 적용한다. -- overlay를 추가해도 Camera와 `RtcHostLiveKitPage`는 기존 component tree에 +- overlay를 추가해도 Camera와 `RtcHostLiveKit`은 기존 component tree에 계속 마운트된다. - 상태 분류, navigation 차단, overlay pointer 계약 테스트와 iOS/Android Expo SDK 56 export를 통과했다. diff --git a/docs/architecture/adr-0026-rtc-viewer-waiting-entry.md b/docs/architecture/adr-0026-rtc-viewer-waiting-entry.md index cd42baf..df8104a 100644 --- a/docs/architecture/adr-0026-rtc-viewer-waiting-entry.md +++ b/docs/architecture/adr-0026-rtc-viewer-waiting-entry.md @@ -31,6 +31,10 @@ LiveKit 토큰 발급 실패는 자동 반복하지 않는다. 같은 SSE 연결 이벤트가 오면 viewer session을 먼저 무효화해 늦은 토큰 응답을 차단하고, Alert 확인 후 피드 화면으로 이동한다. +발급된 LiveKit connection은 전역 RTC store가 아니라 이 entry hook의 현재 +session epoch에 로컬로 유지한다. 나가기 lifecycle은 연결 전후 모두 서버 leave를 +먼저 수행하는 ADR-0044의 Viewer exit controller가 담당한다. + ## Context 기존 참여 폼은 join API와 LiveKit 토큰 API를 하나의 submit 흐름으로 묶었다. @@ -71,6 +75,7 @@ LIVE 판정, token single-flight와 수동 retry를 담당한다. 상태 경계 - 반복 LIVE 이벤트에 의한 자동 token 재요청 방지 - 공용 SSE adapter와 역할별 lifecycle 분리 - ended와 진행 중 token 응답 사이 race 차단 +- 이전 session epoch에서 늦게 완료된 token 응답의 연결 활성화 차단 포기하거나 제한된 것: diff --git a/docs/architecture/adr-0040-rtc-entity-boundaries.md b/docs/architecture/adr-0040-rtc-entity-boundaries.md index 99cd8e9..efe2fcf 100644 --- a/docs/architecture/adr-0040-rtc-entity-boundaries.md +++ b/docs/architecture/adr-0040-rtc-entity-boundaries.md @@ -15,7 +15,7 @@ entities/rtc-room entities/rtc-session - 메모리 기반 Host/Viewer session identity - - LiveKit connection과 token 계약 + - 역할 feature가 사용하는 LiveKit connection과 token 계약 - Host 인증 header 조합 - VisionCamera → LiveKit video publisher adapter @@ -125,8 +125,9 @@ LiveKit과 VisionCamera를 연결하는 publisher는 외부 라이브러리 세 ## Result - 방 관련 API, schema, SSE와 Query Key는 `entities/rtc-room`에서만 export한다. -- Host/Viewer session, LiveKit connection과 publisher는 - `entities/rtc-session`에서만 export한다. +- Host/Viewer session과 LiveKit connection 계약, publisher adapter는 + `entities/rtc-session`에서 export한다. 실제 connection 상태는 역할 feature가 + 로컬로 소유한다. - `entities/rtc-stored-photo`의 독립 Query와 만료 lifecycle은 유지한다. - Reaction emoji 계약과 Query Key는 `features/rtc/reactions` 내부로 이동했다. - 기존 endpoint, 요청 payload, RTC store 전이와 Camera/LiveKit lifecycle은 diff --git a/docs/architecture/adr-0044-rtc-role-session-termination-lifecycle.md b/docs/architecture/adr-0044-rtc-role-session-termination-lifecycle.md new file mode 100644 index 0000000..d492bc2 --- /dev/null +++ b/docs/architecture/adr-0044-rtc-role-session-termination-lifecycle.md @@ -0,0 +1,133 @@ +# ADR-0044: RTC 역할별 연결과 Session 종료 lifecycle + +## Decision + +Host와 Viewer의 LiveKit 연결 정보 및 명시적 종료 절차를 각 역할 feature가 +소유한다. `entities/rtc-session` store에는 현재 프로세스의 Host/Viewer session +identity만 두고, 화면 연결에만 필요한 LiveKit URL/token은 전역 상태로 올리지 +않는다. + +```text +Host 종료 입력 +(종료 메뉴 / 화면 뒤로가기 / Android back) + ↓ +host termination controller (single-flight) + ↓ 사진 선택·업로드 준비 + ↓ publisher stop + ↓ 서버 room end + ↓ Viewer 결과 RPC + ↓ LiveKit disconnect + ↓ Host session clear + ↓ 결과 화면 + ↓ 완료: Camera 유지 또는 이전 화면 이동 + +Viewer 나가기 입력 +(대기 취소 / LiveKit 화면 닫기 / Android back) + ↓ +viewer exit controller (single-flight) + ↓ 서버 participant leave + ↓ LiveKit disconnect (연결된 경우) + ↓ Viewer session clear + ↓ Feed 이동 +``` + +Host 종료 controller는 사진 준비, publisher 정리, 서버 종료, 결과 전달과 Room +정리의 실행 순서를 조정한다. Camera Page는 사진 선택 UI와 종료 API callback을 +주입하고, LiveKit UI는 publisher/Room adapter를 주입한다. 종료 버튼과 화면 +이탈은 같은 요청을 사용한다. 화면 이탈에서 시작된 종료도 사진 선택과 결과 +전달을 생략하지 않으며, 결과 화면에서 `완료`를 선택한 뒤 이전 화면으로 +이동한다. + +Viewer exit controller는 LiveKit 연결 전후에 관계없이 서버 leave를 먼저 +수행한다. 서버 leave가 실패하면 LiveKit 연결과 Viewer session을 유지해 같은 +화면에서 재시도할 수 있다. 서버 leave 성공 후 로컬 disconnect가 실패한 경우는 +서버 session을 되돌릴 수 없으므로 session 정리와 navigation을 계속하고, +component unmount cleanup에 마지막 disconnect를 맡긴다. + +역할별 lifecycle UI도 사용하는 feature로 이동한다. + +- `host-controls`: Host LiveKit publisher/Room, 공유 준비 Sheet +- `join-room`: Viewer SSE/token entry, LiveKit 구독, 대기 UI, exit controller +- `finalize-session`: 종료 사진 선택 UI와 업로드 파일 준비 + +Camera/Viewer/Result 화면 전체의 Widget 재설계는 이 결정에 포함하지 않는다. + +## Context + +기존 store는 Host/Viewer session과 `liveKitConnection`을 함께 보관했다. 하지만 +Host는 Camera Page가 token 응답으로 별도의 `broadcastConnection`을 다시 +만들었고, Viewer만 전역 connection을 실제 렌더링에 사용했다. 한 store의 +필드가 역할별로 다르게 사용되어 연결 상태의 실제 소유자를 알기 어려웠고, +늦은 token 응답이 runtime 전역 상태를 변경할 수 있었다. + +종료 흐름도 진입 위치에 따라 달랐다. Host의 종료 메뉴는 사진 저장, 서버 방 +종료와 결과 RPC를 수행했지만 화면 뒤로가기는 component unmount cleanup만 +실행했다. Viewer는 LiveKit 연결 전 취소에서는 서버 leave를 호출했지만 연결 후 +나가기에서는 Room disconnect와 로컬 session clear만 수행했다. 그 결과 UI는 +사라졌지만 서버에는 활성 Room 또는 participant가 남을 수 있었다. + +## Alternatives + +### 전역 RTC store에 모든 connection과 종료 상태 유지 + +페이지 간 연결 전달은 단순하지만 session identity, 일회성 token, Room 객체와 +종료 진행 상태가 하나의 전역 수명으로 섞인다. Host/Viewer의 다른 연결 방식과 +화면 unmount 시점을 store action이 알아야 한다. + +### 각 UI event handler에서 필요한 cleanup 직접 수행 + +추가 abstraction은 적지만 종료 버튼, back handler와 연결 전후 화면마다 호출 +순서가 다시 갈라진다. 새 종료 진입점이 추가될 때 서버 요청이나 cleanup 단계가 +누락되기 쉽다. + +### 역할별 local connection과 lifecycle controller 사용 + +연결 정보는 실제 연결 화면의 수명에 묶고, 여러 UI 진입점은 역할별 controller의 +한 종료 명령으로 합친다. callback 경계가 추가되지만 서버 상태와 media cleanup의 +순서를 한 곳에서 보장할 수 있다. + +## Reason + +세 번째 안을 선택했다. LiveKit token은 session identity가 아니라 특정 화면 +연결을 위한 단기 자격이므로 소비하는 역할 feature가 소유하는 편이 수명과 +일치한다. 반면 `roomId`, Host access token과 `participantId`는 여러 역할 +use case가 공유하는 runtime session identity이므로 Entity store에 남긴다. + +종료는 단순 UI callback이 아니라 서버와 media 상태를 함께 바꾸는 use case다. +역할별 controller가 single-flight와 순서를 소유하면 어떤 UI에서 시작하더라도 +동일한 계약을 지킬 수 있고, API 실패 시 session을 지운 뒤 복구할 수 없는 +상태를 피할 수 있다. + +## Trade-off + +얻는 것: + +- Host/Viewer LiveKit token의 실제 소비자와 상태 소유자가 일치한다. +- 종료 버튼과 화면 이탈이 같은 사진 저장·서버 종료·cleanup 경로를 사용한다. +- Viewer 연결 전후의 나가기가 같은 서버 leave 정책을 사용한다. +- 중복 종료 입력은 controller의 진행 중 Promise를 재사용한다. +- 서버 종료 실패 시 runtime session을 유지해 재시도할 수 있다. +- lifecycle UI가 역할 feature public API를 통해 Pages에 제공된다. + +포기하거나 제한된 것: + +- 앱 프로세스가 종료되면 LiveKit 연결 정보와 RTC session은 복원되지 않는다. +- Host 서버 종료 후 Viewer RPC가 실패하면 재시도 시 새 Room 연결이 필요하다. +- LiveKit disconnect 자체가 실패해도 서버 종료가 확정된 뒤에는 session 종료를 + 되돌리지 않는다. +- Camera/Viewer/Result의 큰 화면 조합과 표시 전용 UI 이동은 후속 구조 개선 + 범위로 남는다. + +## Result + +- `useRtcStore`는 Host/Viewer session identity만 관리한다. +- 역할별 local connection은 공통 `url`, `token`만 표현하며 상태 소유 위치가 + Host/Viewer 역할을 결정한다. +- Host token 응답은 Camera 흐름의 local `broadcastConnection`으로만 전달된다. +- Viewer token 응답은 `useRtcViewerEntry`의 local connection이 되며 session + epoch가 바뀐 뒤 완료된 응답은 무시한다. +- Host 종료 controller가 사진 선택, publisher stop, 서버 end, 결과 RPC와 Room + disconnect를 직렬화한다. +- Host 뒤로가기는 종료 완료와 결과 확인 전에는 route를 제거하지 않는다. +- Viewer exit controller는 서버 leave 성공 뒤에만 LiveKit과 session을 정리한다. +- 서버 leave/end 실패 시 현재 화면과 session identity를 유지한다. From 085a1fa5126da92170ddaeed059bc65bb2fa0da5 Mon Sep 17 00:00:00 2001 From: lioba00700 Date: Wed, 2 Sep 2026 22:04:12 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20RTC=20=EC=A2=85=EB=A3=8C=20lifecycle?= =?UTF-8?q?=20=EA=B2=BD=EC=9F=81=20=EC=A1=B0=EA=B1=B4=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/rtc-host-controls.test.cjs | 40 ++++++++++++++ .../model/rtc-host-termination.ts | 28 ++++++++++ .../use-rtc-host-termination-controller.ts | 24 +++++++- .../__tests__/rtc-join-room.test.cjs | 55 ++++++++++++++++++- src/features/rtc/join-room/index.ts | 1 + .../rtc/join-room/model/rtc-viewer-entry.ts | 28 ++++++++++ .../join-room/model/use-rtc-viewer-entry.ts | 26 ++++++++- .../rtc/join-room/ui/rtc-viewer-livekit.tsx | 7 ++- src/pages/camera/ui/rtc-viewer-page.tsx | 13 ++++- 9 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/features/rtc/host-controls/model/rtc-host-termination.ts diff --git a/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs b/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs index 904a68f..22ce2bc 100644 --- a/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs +++ b/src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs @@ -25,6 +25,9 @@ const { isRtcFinalizationPending, resolveRtcCameraMenuMode, } = require("../model/rtc-host-control.ts"); +const { + completeRtcHostTermination, +} = require("../model/rtc-host-termination.ts"); const { mergeRtcRoomEvent, } = require("../../../../entities/rtc-room/api/rtc-room-event-state.ts"); @@ -199,6 +202,43 @@ test("Host 종료 controller는 사진 준비부터 연결 정리까지 순서 assert.ok(disconnectIndex > deliverIndex); }); +test("Host 종료 완료 callback 실패를 lifecycle 실패로 반환한다", async () => { + const expectedError = new Error("결과 화면 전환 실패"); + const result = await completeRtcHostTermination({ + result: { + roomId: "room-1", + status: "ENDED", + endedAt: "2026-09-02T12:00:00.000Z", + savedImages: [], + }, + isMounted: () => true, + onCompleted: async () => { + throw expectedError; + }, + }); + + assert.deepEqual(result, { status: "FAILED", error: expectedError }); +}); + +test("Host 종료 cleanup 중 unmount되면 완료 callback을 실행하지 않는다", async () => { + let completed = false; + const result = await completeRtcHostTermination({ + result: { + roomId: "room-1", + status: "ENDED", + endedAt: "2026-09-02T12:00:00.000Z", + savedImages: [], + }, + isMounted: () => false, + onCompleted: () => { + completed = true; + }, + }); + + assert.deepEqual(result, { status: "SKIPPED" }); + assert.equal(completed, false); +}); + test("RTC SSE 재연결 지연은 지수 증가 후 15초로 제한한다", () => { assert.equal(getRtcRoomReconnectDelay(0), 1_000); assert.equal(getRtcRoomReconnectDelay(2), 4_000); diff --git a/src/features/rtc/host-controls/model/rtc-host-termination.ts b/src/features/rtc/host-controls/model/rtc-host-termination.ts new file mode 100644 index 0000000..54662a2 --- /dev/null +++ b/src/features/rtc/host-controls/model/rtc-host-termination.ts @@ -0,0 +1,28 @@ +import type { RtcEndRoomResponse } from "@entities/rtc-room"; + +export type RtcHostCompletionResult = + | { status: "COMPLETED" } + | { status: "SKIPPED" } + | { status: "FAILED"; error: unknown }; + +interface CompleteRtcHostTerminationOptions { + result: RtcEndRoomResponse; + isMounted: () => boolean; + onCompleted: (result: RtcEndRoomResponse) => void | Promise; +} + +export async function completeRtcHostTermination({ + result, + isMounted, + onCompleted, +}: CompleteRtcHostTerminationOptions): Promise { + if (!isMounted()) return { status: "SKIPPED" }; + + try { + await onCompleted(result); + } catch (error) { + return { status: "FAILED", error }; + } + + return isMounted() ? { status: "COMPLETED" } : { status: "SKIPPED" }; +} diff --git a/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts b/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts index 0c40caa..318529a 100644 --- a/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts +++ b/src/features/rtc/host-controls/model/use-rtc-host-termination-controller.ts @@ -1,6 +1,7 @@ import type { RtcEndRoomResponse } from "@entities/rtc-room"; import { useCallback, useEffect, useRef, useState } from "react"; import type { RtcHostFinalizationState } from "./rtc-host-control"; +import { completeRtcHostTermination } from "./rtc-host-termination"; interface UseRtcHostTerminationControllerOptions { preparePhotos: () => Promise; @@ -91,8 +92,27 @@ export function useRtcHostTerminationController({ return; } - await onCompleted(result); - if (isMountedRef.current) onStateChange?.("IDLE"); + const completion = await completeRtcHostTermination({ + result, + isMounted: () => isMountedRef.current, + onCompleted, + }); + + if (completion.status === "FAILED") { + if (isMountedRef.current) { + setErrorMessage( + completion.error instanceof Error + ? completion.error.message + : "RTC 공유 종료에 실패했습니다. 종료 처리를 다시 시도해주세요.", + ); + onStateChange?.("FAILED"); + } + return; + } + + if (completion.status === "COMPLETED" && isMountedRef.current) { + onStateChange?.("IDLE"); + } })().finally(() => { terminationPromiseRef.current = null; }); diff --git a/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs b/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs index 54335b6..913676f 100644 --- a/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs +++ b/src/features/rtc/join-room/__tests__/rtc-join-room.test.cjs @@ -1,7 +1,11 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { resolveRtcViewerRoomSignal } = require("../model/rtc-viewer-entry.ts"); +const { + isRtcViewerEntryCallbackCurrent, + resolveRtcViewerRoomSignal, + shouldMountRtcViewerLiveKit, +} = require("../model/rtc-viewer-entry.ts"); test("RTC snapshot 상태가 LIVE일 때만 참여자 연결 신호를 반환한다", () => { assert.equal( @@ -38,6 +42,55 @@ test("ended 이벤트는 LIVE 이전에도 종료 신호로 처리한다", () => ); }); +test("정리된 이전 Viewer entry의 늦은 LIVE와 ENDED callback을 무시한다", () => { + for (const signal of ["LIVE", "ENDED"]) { + assert.equal( + isRtcViewerEntryCallbackCurrent({ + currentEpoch: 2, + callbackEpoch: 1, + isMounted: true, + }), + false, + `${signal} callback이 이전 entry에서 전달됨`, + ); + } + assert.equal( + isRtcViewerEntryCallbackCurrent({ + currentEpoch: 1, + callbackEpoch: 1, + isMounted: false, + }), + false, + ); + assert.equal( + isRtcViewerEntryCallbackCurrent({ + currentEpoch: 1, + callbackEpoch: 1, + isMounted: true, + }), + true, + ); +}); + +test("LiveKit 진입 전 취소 중에는 늦게 도착한 connection을 mount하지 않는다", () => { + assert.equal( + shouldMountRtcViewerLiveKit({ + hasSession: true, + hasConnection: true, + isCancelingBeforeLiveKit: true, + }), + false, + ); + assert.equal( + shouldMountRtcViewerLiveKit({ + hasSession: true, + hasConnection: true, + isCancelingBeforeLiveKit: false, + }), + true, + ); +}); + test("참여 폼은 join 성공 후 토큰을 발급하지 않고 대기 화면으로 이동한다", () => { const fs = require("node:fs"); const path = require("node:path"); diff --git a/src/features/rtc/join-room/index.ts b/src/features/rtc/join-room/index.ts index c01dc00..48d8ec6 100644 --- a/src/features/rtc/join-room/index.ts +++ b/src/features/rtc/join-room/index.ts @@ -5,6 +5,7 @@ export { RtcViewerWaiting } from "./ui/rtc-viewer-waiting"; export { RtcViewerLiveKit } from "./ui/rtc-viewer-livekit"; export { resolveRtcViewerRoomSignal, + shouldMountRtcViewerLiveKit, type RtcViewerRoomSignal, } from "./model/rtc-viewer-entry"; export { diff --git a/src/features/rtc/join-room/model/rtc-viewer-entry.ts b/src/features/rtc/join-room/model/rtc-viewer-entry.ts index 347c042..1b2fa72 100644 --- a/src/features/rtc/join-room/model/rtc-viewer-entry.ts +++ b/src/features/rtc/join-room/model/rtc-viewer-entry.ts @@ -2,6 +2,34 @@ import type { RtcRoomEvent } from "@entities/rtc-room"; export type RtcViewerRoomSignal = "LIVE" | "ENDED" | null; +interface RtcViewerEntryCallbackIdentity { + currentEpoch: number; + callbackEpoch: number; + isMounted: boolean; +} + +interface RtcViewerLiveKitMountState { + hasSession: boolean; + hasConnection: boolean; + isCancelingBeforeLiveKit: boolean; +} + +export function isRtcViewerEntryCallbackCurrent({ + currentEpoch, + callbackEpoch, + isMounted, +}: RtcViewerEntryCallbackIdentity): boolean { + return isMounted && currentEpoch === callbackEpoch; +} + +export function shouldMountRtcViewerLiveKit({ + hasSession, + hasConnection, + isCancelingBeforeLiveKit, +}: RtcViewerLiveKitMountState): boolean { + return hasSession && hasConnection && !isCancelingBeforeLiveKit; +} + export function resolveRtcViewerRoomSignal( event: RtcRoomEvent, ): RtcViewerRoomSignal { diff --git a/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts b/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts index a20462e..d02d98e 100644 --- a/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts +++ b/src/features/rtc/join-room/model/use-rtc-viewer-entry.ts @@ -10,7 +10,10 @@ import type { RtcViewerSession, } from "@entities/rtc-session"; import { useCallback, useEffect, useRef, useState } from "react"; -import { resolveRtcViewerRoomSignal } from "./rtc-viewer-entry"; +import { + isRtcViewerEntryCallbackCurrent, + resolveRtcViewerRoomSignal, +} from "./rtc-viewer-entry"; import { rtcViewerQuery } from "../api"; export type RtcViewerEntryPhase = @@ -178,6 +181,7 @@ export function useRtcViewerEntry({ if (!enabled || !roomId) return; const lifecycleController = new AbortController(); + const callbackEntryEpoch = entryEpochRef.current; let isMounted = true; let isTerminal = false; @@ -185,6 +189,16 @@ export function useRtcViewerEntry({ event: RtcRoomEvent, streamController: AbortController, ) => { + if ( + !isRtcViewerEntryCallbackCurrent({ + currentEpoch: entryEpochRef.current, + callbackEpoch: callbackEntryEpoch, + isMounted, + }) + ) { + return; + } + if (event.type !== "heartbeat" && event.payload.roomId !== roomId) { return; } @@ -242,7 +256,15 @@ export function useRtcViewerEntry({ roomId, signal: streamController.signal, onOpen: () => { - if (isMounted) setStreamState("OPEN"); + if ( + isRtcViewerEntryCallbackCurrent({ + currentEpoch: entryEpochRef.current, + callbackEpoch: callbackEntryEpoch, + isMounted, + }) + ) { + setStreamState("OPEN"); + } }, onEvent: (event) => handleEvent(event, streamController), }); diff --git a/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx b/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx index fb074b7..d501116 100644 --- a/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx +++ b/src/features/rtc/join-room/ui/rtc-viewer-livekit.tsx @@ -113,7 +113,12 @@ function ViewerRoomContent({ } finally { await onRoomEnded(deliveredResult); } - })(); + })().catch((error: unknown) => { + console.error("[RTC Viewer] room-end cleanup failed", { + error, + roomId, + }); + }); }, 0); } diff --git a/src/pages/camera/ui/rtc-viewer-page.tsx b/src/pages/camera/ui/rtc-viewer-page.tsx index 54ef057..9071763 100644 --- a/src/pages/camera/ui/rtc-viewer-page.tsx +++ b/src/pages/camera/ui/rtc-viewer-page.tsx @@ -8,6 +8,7 @@ import { useResetMyRtcStoredPhotos } from "@features/rtc/finalize-session"; import { RtcViewerLiveKit, RtcViewerWaiting, + shouldMountRtcViewerLiveKit, useRtcViewerEntry, useRtcViewerExitController, } from "@features/rtc/join-room"; @@ -144,6 +145,8 @@ export function RtcViewerPage() { ); const [exitRequestId, setExitRequestId] = useState(0); const [isExitCompleted, setIsExitCompleted] = useState(false); + const [isCancelingBeforeLiveKit, setIsCancelingBeforeLiveKit] = + useState(false); const hasPresentedEndedAlertRef = useRef(false); const hasPresentedResultRef = useRef(false); @@ -230,8 +233,10 @@ export function RtcViewerPage() { ); const handleCancelBeforeLiveKit = useCallback(async () => { + setIsCancelingBeforeLiveKit(true); const result = await requestExit(); if (!result.ok) { + setIsCancelingBeforeLiveKit(false); Alert.alert("RTC 방 나가기 실패", result.errorMessage); } }, [requestExit]); @@ -390,7 +395,13 @@ export function RtcViewerPage() { ); } - if (!viewerSession || !viewerEntry.connection) { + const shouldMountLiveKit = shouldMountRtcViewerLiveKit({ + hasSession: Boolean(viewerSession), + hasConnection: Boolean(viewerEntry.connection), + isCancelingBeforeLiveKit, + }); + + if (!shouldMountLiveKit || !viewerSession || !viewerEntry.connection) { return (