Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/architecture/adr-0008-rtc-reaction-socket-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ join timeout 또는 거절 시 joined 상태를 false로 유지하고 1초부터
15초까지 지수 backoff로 join handshake를 재시도한다. 이 기간에는 VIEWER
전송을 차단한다.

Socket handshake의 `auth`는 고정 token 객체가 아니라 연결 시점마다 현재
access token을 반환하는 비동기 resolver를 사용한다. JWT가 만료되었거나 30초
안에 만료될 예정이면 공용 single-flight refresh를 먼저 수행하고, Socket.IO의
최초 연결과 자동 reconnect 모두 갱신된 token으로 handshake한다. refresh가
실패하면 공용 인증 lifecycle이 session을 종료한다.

서버의 역할별 join 실패 계약에는 인증 만료를 식별할 안정적인 error code가
없다. 따라서 일반 join timeout이나 disconnect를 인증 오류로 추정해 refresh하지
않고 기존 backoff 재시도 정책을 유지한다. 이는 네트워크 장애를 잘못된 logout으로
확대하지 않기 위한 경계다.

## Trade-off

영상과 반응에 각각 연결이 필요해 연결 상태가 하나 더 생긴다. 대신 반응
Expand All @@ -67,6 +78,8 @@ event ID로 취급하지 않는다.
- join acknowledgement 성공 이후에만 CONNECTED와 VIEWER 전송을 허용한다.
- disconnect cleanup과 Socket.IO reconnect에서 joined 상태를 초기화하고
역할별 join을 다시 수행한다.
- 최초 연결과 reconnect는 매번 현재 access token을 resolve하며, 만료 임박
token은 공용 single-flight refresh 후 사용한다.
- DEV 로그는 socket connect, join, send, receive, bubble queue 단계를 토큰
노출 없이 추적한다.
- 실제 기기에서 다중 VIEWER 전송, HOST burst queue, 재연결, 방 전환 후
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ lifecycle epoch로 오래된 connect 완료를 무효화한다. Camera route가
때만 새 연결 세대를 허용한다.

종료 흐름은 사용자가 사진을 확정한 후 publisher를 정리하고 서버 방 종료 및
참여자 결과 RPC를 수행한다. 중간 단계가 실패해도 `finally`에서 publisher와
Room disconnect를 재시도한다.
참여자 결과 RPC를 수행한다. `finally`에서 publisher와 Room disconnect를
재시도하되, 서버 방 종료가 성공한 뒤의 RPC와 로컬 cleanup은 ADR-0044에 따라
best-effort로 취급한다.

## Trade-off

Expand Down
16 changes: 16 additions & 0 deletions docs/architecture/adr-0026-rtc-viewer-waiting-entry.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ SSE 구독 adapter는 호스트 전용 query와 분리해
둔다. 앱 access token을 사용하는 공용 전송 경계이며, host와 viewer feature가
각자의 lifecycle에 맞게 재사용한다.

SSE의 `expo/fetch` 요청은 upload와 같은 공용 authenticated fetch 정책을
사용한다. 첫 401에서는 single-flight token refresh 후 한 번만 다시 구독하고,
재요청도 401이면 이전 session을 유지하지 않고 logout한다. stream이 정상적으로
열리면 해당 lifecycle의 지수 backoff 횟수를 0으로 초기화하여 과거 장애의 긴
재연결 지연이 다음 장애에 누적되지 않게 한다.

LiveKit 토큰 발급 실패는 자동 반복하지 않는다. 같은 SSE 연결에서 LIVE가
반복되어도 최초 실패 상태를 유지하고 사용자가 대기 화면의 `다시 시도`를
누른 경우에만 동일한 `participantId`로 다시 요청한다. LIVE 전에 `ended`
Expand All @@ -35,6 +41,13 @@ Alert 확인 후 피드 화면으로 이동한다.
session epoch에 로컬로 유지한다. 나가기 lifecycle은 연결 전후 모두 서버 leave를
먼저 수행하는 ADR-0044의 Viewer exit controller가 담당한다.

LIVE 진입 후 종료 SSE가 RPC 결과보다 먼저 도착하면 기존 대기 UI를
`PREPARING_RESULT` 모드로 사용한다. 이 1초 fallback 구간에는 취소 동작과 route
이탈을 허용하지 않는다. RPC가 도착하면 즉시 그 결과를 사용하고, 도착하지 않으면
같은 화면에서 room photo query로 결과를 복구한다. fallback timer는 결과 표시,
session 종료와 unmount에서 항상 취소하므로 다른 화면으로 이동한 뒤 늦게 결과
화면이 나타나지 않는다.

## Context

기존 참여 폼은 join API와 LiveKit 토큰 API를 하나의 submit 흐름으로 묶었다.
Expand Down Expand Up @@ -90,5 +103,8 @@ LIVE 판정, token single-flight와 수동 retry를 담당한다. 상태 경계
ROOM_ENDED 전이를 소유한다.
- token 오류 시 대기 화면에 다시 시도 버튼과 취소 버튼이 함께 표시된다.
- LIVE 이전 ended는 취소 불가능한 Alert로 안내하고 확인 후 피드로 이동한다.
- LIVE 이후 ended와 RPC 사이에는 결과 준비 대기 화면을 유지하며, fallback이
끝날 때까지 화면 이탈을 차단한다.
- SSE가 정상적으로 open되면 재연결 backoff를 초기화한다.
- 실제 서버 SSE 순서와 실제 기기 LiveKit 연결 결과는 통합 테스트 후 이 항목에
추가 기록한다.
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ host termination controller (single-flight)
↓ 사진 선택·업로드 준비
↓ publisher stop
↓ 서버 room end
↓ Viewer 결과 RPC
↓ LiveKit disconnect
↓ Viewer 결과 RPC (best-effort)
↓ LiveKit disconnect (best-effort)
↓ Host session clear
↓ 결과 화면
↓ 완료: Camera 유지 또는 이전 화면 이동
Expand All @@ -39,6 +39,14 @@ publisher/Room adapter를 주입한다. 종료 버튼과 화면
전달을 생략하지 않으며, 결과 화면에서 `완료`를 선택한 뒤 이전 화면으로
이동한다.

서버 `endRoom` 성공 응답을 Host 종료의 authoritative result로 사용한다. 그 뒤
Viewer RPC는 결과 화면을 빠르게 전달하기 위한 최적화이며 종료 transaction의
성공 조건이 아니다. RPC 또는 publisher/Room의 사후 cleanup이 실패해도 이미
확정된 서버 종료와 사진 저장을 실패 상태로 되돌리지 않고 Host session 정리와
결과 화면 전환을 계속한다. 반대로 사진 준비, 최초 publisher stop, 서버
`endRoom` 중 하나가 실패하면 server result가 없으므로 session을 유지하고
재시도 가능한 실패로 처리한다.

Viewer exit controller는 LiveKit 연결 전후에 관계없이 서버 leave를 먼저
수행한다. 서버 leave가 실패하면 LiveKit 연결과 Viewer session을 유지해 같은
화면에서 재시도할 수 있다. 서버 leave 성공 후 로컬 disconnect가 실패한 경우는
Expand Down Expand Up @@ -108,12 +116,14 @@ use case가 공유하는 runtime session identity이므로 Entity store에 남
- Viewer 연결 전후의 나가기가 같은 서버 leave 정책을 사용한다.
- 중복 종료 입력은 controller의 진행 중 Promise를 재사용한다.
- 서버 종료 실패 시 runtime session을 유지해 재시도할 수 있다.
- 서버 종료 성공 이후 RPC와 로컬 cleanup 실패는 서버 결과를 무효화하지 않는다.
- lifecycle UI가 역할 feature public API를 통해 Workspace에 제공된다.

포기하거나 제한된 것:

- 앱 프로세스가 종료되면 LiveKit 연결 정보와 RTC session은 복원되지 않는다.
- Host 서버 종료 후 Viewer RPC가 실패하면 재시도 시 새 Room 연결이 필요하다.
- Viewer RPC 실패 시 Viewer는 종료 SSE 뒤의 photo query fallback으로 결과를
복구하므로 Host 종료를 다시 요청하지 않는다.
- LiveKit disconnect 자체가 실패해도 서버 종료가 확정된 뒤에는 session 종료를
되돌리지 않는다.
- 화면 조합 책임은 ADR-0045의 Camera/RTC Workspace가 담당한다.
Expand All @@ -128,6 +138,8 @@ use case가 공유하는 runtime session identity이므로 Entity store에 남
epoch가 바뀐 뒤 완료된 응답은 무시한다.
- Host 종료 controller가 사진 선택, publisher stop, 서버 end, 결과 RPC와 Room
disconnect를 직렬화한다.
- Host의 authoritative 종료 경계는 서버 end 성공이며, 이후 RPC와 로컬
cleanup은 진단 가능한 best-effort 단계다.
- Host 뒤로가기는 종료 완료와 결과 확인 전에는 route를 제거하지 않는다.
- Viewer exit controller는 서버 leave 성공 뒤에만 LiveKit과 session을 정리한다.
- 서버 leave/end 실패 시 현재 화면과 session identity를 유지한다.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"test:feed-browse": "node --test src/features/feed/browse-feed-detail/__tests__/feed-detail-navigation.test.cjs",
"test:photo-grid": "node --test src/shared/ui/photo-grid/__tests__/photo-grid-layout.test.cjs",
"test:guest-account-link": "node --test src/shared/model/__tests__/guest-account-linking.test.cjs",
"test:auth-session": "node --test src/shared/model/__tests__/auth-session-storage.test.cjs",
"test:auth-session": "node --test src/shared/model/__tests__/auth-session-storage.test.cjs src/shared/api/__tests__/authenticated-fetch.test.cjs",
"test:profile-flow": "node --test src/pages/profile/overview/__tests__/profile-flow.test.cjs",
"test:rtc-stored-photos": "node --test src/features/rtc-photo/browse-stored-photos/__tests__/rtc-stored-photo.test.cjs",
"test:rtc-host-controls": "node --test src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs",
Expand Down
27 changes: 13 additions & 14 deletions src/entities/rtc-room/api/rtc-room-query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createSseParser } from "@shared/api";
import { createSseParser, executeAuthenticatedFetch } from "@shared/api";
import { env } from "@shared/config";
import { useAuthStore } from "@shared/model";
import { fetch } from "expo/fetch";
import { verifyRtcId } from "../lib";
import { type RtcRoomEvent } from "../model";
Expand All @@ -14,7 +13,8 @@ interface SubscribeRtcRoomEventsOptions {
}

/**
* RTC 역할과 관계없이 앱 access token으로 방 상태를 구독합니다.
* RTC 방 상태·참여자 현황 실시간 구독
* @description RTC 역할과 관계없이 앱 access token으로 방 상태를 구독합니다.
*/
export async function subscribeRtcRoomEvents({
roomId,
Expand All @@ -27,18 +27,17 @@ export async function subscribeRtcRoomEvents({
}

const id = verifyRtcId(roomId, "RTC 방 ID");
const accessToken = useAuthStore.getState().accessToken?.trim() ?? "";
if (!accessToken) {
throw new Error("로그인이 필요합니다.");
}

const response = await fetch(`${env.API_URL}/rtc/rooms/${id}/events`, {
method: "GET",
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${accessToken}`,
},
const response = await executeAuthenticatedFetch({
signal,
request: (accessToken) =>
fetch(`${env.API_URL}/rtc/rooms/${id}/events`, {
method: "GET",
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${accessToken}`,
},
signal,
}),
});

if (!response.ok) {
Expand Down
114 changes: 99 additions & 15 deletions src/features/rtc/host-controls/__tests__/rtc-host-controls.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const {
} = require("../model/rtc-host-control.ts");
const {
completeRtcHostTermination,
executeRtcHostTermination,
} = require("../model/rtc-host-termination.ts");
const {
mergeRtcRoomEvent,
Expand Down Expand Up @@ -191,24 +192,107 @@ test("RTC 종료 실패는 인라인 문구 대신 재시도 Alert로 표시한
assert.doesNotMatch(source, /<ButtonText>종료 처리 다시 시도<\/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,
);
test("Host 종료는 사진 준비부터 완료 callback까지 순서대로 실행한다", async () => {
const calls = [];
const result = {
roomId: "room-1",
status: "ENDED",
endedAt: "2026-09-05T12:00:00.000Z",
savedImages: [],
};

const completion = await executeRtcHostTermination({
existingResult: null,
preparePhotos: async () => calls.push("prepare"),
stopPublishing: async () => calls.push("stop"),
endRoom: async () => {
calls.push("end");
return result;
},
deliverResult: async () => calls.push("deliver"),
disconnectRoom: async () => calls.push("disconnect"),
isMounted: () => true,
onCompleted: () => calls.push("complete"),
onRoomEnded: () => calls.push("cache-result"),
onStateChange: (state) => calls.push(state),
});

assert.ok(prepareIndex >= 0);
assert.ok(stopIndex > prepareIndex);
assert.ok(endIndex > stopIndex);
assert.ok(deliverIndex > endIndex);
assert.ok(disconnectIndex > deliverIndex);
assert.deepEqual(completion, { status: "COMPLETED" });
assert.deepEqual(calls, [
"PREPARING_PHOTOS",
"prepare",
"ENDING_ROOM",
"stop",
"end",
"cache-result",
"DELIVERING_RESULT",
"deliver",
"stop",
"disconnect",
"complete",
]);
});

for (const failureStage of [
"RESULT_DELIVERY",
"PUBLISHER_CLEANUP",
"ROOM_DISCONNECT",
]) {
test(`${failureStage} 실패는 Host 종료 완료를 막지 않는다`, async () => {
const calls = [];
const diagnostics = [];
let stopCallCount = 0;
const expectedError = new Error(`${failureStage} failed`);
const result = {
roomId: "room-1",
status: "ENDED",
endedAt: "2026-09-05T12:00:00.000Z",
savedImages: [],
};

const completion = await executeRtcHostTermination({
existingResult: null,
preparePhotos: async () => calls.push("prepare"),
stopPublishing: async () => {
stopCallCount += 1;
calls.push(`stop-${stopCallCount}`);
if (failureStage === "PUBLISHER_CLEANUP" && stopCallCount === 2) {
throw expectedError;
}
},
endRoom: async () => {
calls.push("end");
return result;
},
deliverResult: async () => {
calls.push("deliver");
if (failureStage === "RESULT_DELIVERY") throw expectedError;
},
disconnectRoom: async () => {
calls.push("disconnect");
if (failureStage === "ROOM_DISCONNECT") throw expectedError;
},
isMounted: () => true,
onCompleted: () => calls.push("complete"),
onRoomEnded: () => calls.push("cache-result"),
onNonFatalError: (stage, error) => diagnostics.push([stage, error]),
});

assert.deepEqual(completion, { status: "COMPLETED" });
assert.deepEqual(diagnostics, [[failureStage, expectedError]]);
assert.deepEqual(calls, [
"prepare",
"stop-1",
"end",
"cache-result",
"deliver",
"stop-2",
"disconnect",
"complete",
]);
});
}

test("Host 종료 완료 callback 실패를 lifecycle 실패로 반환한다", async () => {
const expectedError = new Error("결과 화면 전환 실패");
const result = await completeRtcHostTermination({
Expand Down
Loading
Loading