Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d0a1ce5
[FEAT] 탭 복귀를 감지하는 useDocumentVisibility 훅 추가
coli-geonwoo Sep 25, 2026
40be421
[FIX] 사회자 탭 복귀 시 즉시 상태 공유 및 재연결
coli-geonwoo Sep 25, 2026
270bfb7
[FIX] 청중 탭 복귀 시 현재 상태 재동기화
coli-geonwoo Sep 25, 2026
83d9e26
[FIX] 재연결 소진 시 마지막 화면을 유지한 채 새로고침 안내
coli-geonwoo Sep 25, 2026
ae98e43
[FIX] 사회자에게도 라이브 연결 끊김과 새로고침을 안내
coli-geonwoo Sep 25, 2026
d5075b8
[DOCS] 재연결 소진과 백그라운드 탭 복귀 규약 정리
coli-geonwoo Sep 25, 2026
c1fda6f
[FIX] 상태 공유 시 남은 시간을 실제 시각 기준으로 보정
coli-geonwoo Sep 25, 2026
f80453b
[FIX] 테이블 재조회 실패로 보던 화면을 버리지 않도록 수정
coli-geonwoo Sep 25, 2026
88e6ac9
[FIX] 테이블 재조회에 실패해도 청중 소켓을 유지
coli-geonwoo Sep 25, 2026
9808cad
[FIX] 재연결하는 동안 청중 화면을 비우지 않음
coli-geonwoo Sep 25, 2026
adf805a
[FIX] 사회자 소켓은 재연결 횟수를 제한하지 않음
coli-geonwoo Sep 25, 2026
acd5006
[DESIGN] 라이브 공유 버튼에 공유 상태 점 상시 표시
coli-geonwoo Sep 25, 2026
5e0dad8
[FIX] 사회자 재연결에 2분 제한 시간 적용
coli-geonwoo Sep 25, 2026
f0cda45
[FIX] 화면이 낮아질 때 헤더 제목이 헤더 밖으로 넘치는 문제
coli-geonwoo Sep 25, 2026
5388607
[DOCS] 재연결 규약을 청중과 사회자로 나누어 기술
coli-geonwoo Sep 25, 2026
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
25 changes: 25 additions & 0 deletions docs/live-share-timer-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,28 @@
- 이전/다음 차례·토론 종료 버튼은 한 번 누르면 500ms(`ROUND_CONTROL_COOLDOWN_MS`) 동안 다시 누를 수 없습니다. 세 버튼이 대기 시간을 공유하므로, 마지막 직전 차례에서 다음 차례를 더블클릭해도 같은 자리에 나타난 토론 종료가 눌리지 않습니다. (`RoundControlRow`)
- 핫키를 길게 눌러 생기는 반복 입력(`event.repeat`)은 무시합니다. (`useTimerHotkey`)
- **서버 검증은 두지 않습니다.** 서버는 룸의 현재 순서를 알지 못하는 중계자이고, 의도적으로 빠르게 넘긴 것과 실수를 구분할 수 없습니다. `NEXT`/`BEFORE`는 이동 전 순서(`sequence`)를 담은 절대값이라 같은 이벤트가 중복 도착해도 청중 결과는 같고, 오래된 이벤트는 `version` 검증이 막습니다.

## 5. 재연결과 백그라운드 탭 (Case 7, 9)

### 재연결 시도 소진 (Case 7)

- 소켓은 끊기면 자동으로 재연결합니다. (풀 지터 + 지수 백오프, 대기 상한 30초, `SocketManager`)
- 청중: 최대 3회까지 재시도합니다. 청중은 수가 많아 동시 재접속 부담이 큽니다.
- 사회자: 횟수 제한 없이 첫 끊김부터 2분 동안 재시도합니다. (`useChairmanSocket`) 서버는 활성 사회자 등록을 메모리로만 들고 있어, 사회자 연결이 끊긴 채 남으면 청중이 새로고침해도 복구되지 않기 때문입니다. 2분 안에 서버가 살아나면 재구독으로 활성 사회자가 다시 등록되고, 곧바로 현재 상태를 공유해 청중도 함께 복구됩니다.
- 재시도 조건을 넘기면 `SOCKET_RETRY_EXHAUSTED` 오류와 함께 클라이언트를 완전히 닫습니다. 이 상태는 자동으로 복구되지 않습니다.
- **청중**: 이미 타이머를 보고 있었다면 화면을 지우지 않고, 마지막 상태를 남긴 채 새로고침 안내 오버레이를 덮습니다. 카운트다운은 사회자 부재와 같은 방식으로 마지막 값에서 멈춥니다. 한 번도 타이머를 표시하지 못했다면 기존 오류 화면을 씁니다. (`useAudienceShareState`, `ConnectionLostNotice`)
- **사회자**: 라이브 공유 모달에 연결이 끊겼다는 안내와 새로고침 안내를 표시합니다. (`LiveShareModal`)
- **수동 재연결 버튼은 두지 않습니다.** 서버 여유가 넉넉하지 않아 재연결 시점을 사용자에게 맡기지 않고, 새로고침으로 상태를 처음부터 다시 맞추게 합니다.

### 백그라운드 탭 (Case 9)

브라우저는 백그라운드 탭의 타이머와 네트워크를 억제합니다. 그래서 탭이 돌아오는 시점(`visibilitychange`)에 양쪽 모두 상태를 다시 맞춥니다. (`useDocumentVisibility`)

| 주체 | 복귀 시 동작 |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 사회자 | 연결이 살아 있으면 heartbeat를 기다리지 않고 즉시 `SYNC`를 발행합니다. 연결이 끊겨 있었다면 공유를 다시 시작합니다. (최소 간격 1초) |
| 청중 | 연결이 살아 있으면 `/room/{roomId}`를 다시 구독합니다. 서버는 청중이 구독할 때 사회자에게 상태 공유를 요청하므로, 재구독이 곧 재동기화 요청입니다. (최소 간격 3초) |
| 청중 | 연결이 끊겨 있었다면 다시 연결합니다. |

- 사회자 탭이 백그라운드에 있는 동안에는 heartbeat가 늦어져 청중이 "사회자 연결 끊김"으로 볼 수 있습니다. 복귀 즉시 `SYNC`를 보내 이 오해를 빠르게 풉니다.
- 청중이 여럿 동시에 복귀하면 상태 공유 요청이 그만큼 늘어납니다. 서버는 룸별로 요청 최소 간격(500ms)을 두어 사회자의 발행이 증폭되지 않게 합니다. 버려진 요청은 직전 요청에 대한 `SYNC` 브로드캐스트가 대신합니다.
8 changes: 7 additions & 1 deletion public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,5 +310,11 @@
"업데이트 이미지": "Update image",
"일주일 간 보지 않기": "Don't show again for a week",
"자세히 보기": "View details",
"패치 노트 링크를 읽는 중 오류가 발생했습니다.": "An error occurred while loading the patch note link."
"패치 노트 링크를 읽는 중 오류가 발생했습니다.": "An error occurred while loading the patch note link.",
"서버 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the server was lost. Please refresh the page.",
"새로고침": "Refresh",
"라이브 연결 끊김": "Live sharing disconnected",
"라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the live server was lost. Please refresh the page.",
"필요한 데이터를 불러오지 못했어요.": "We couldn't load the data we need.",
"다시 시도해보세요.": "Please try again."
}
8 changes: 7 additions & 1 deletion public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,5 +310,11 @@
"업데이트 이미지": "업데이트 이미지",
"일주일 간 보지 않기": "일주일 간 보지 않기",
"자세히 보기": "자세히 보기",
"패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다."
"패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다.",
"서버 연결이 끊겼어요. 새로고침 해주세요.": "서버 연결이 끊겼어요. 새로고침 해주세요.",
"새로고침": "새로고침",
"라이브 연결 끊김": "라이브 연결 끊김",
"라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.",
"필요한 데이터를 불러오지 못했어요.": "필요한 데이터를 불러오지 못했어요.",
"다시 시도해보세요.": "다시 시도해보세요."
}
79 changes: 79 additions & 0 deletions src/apis/sockets/SocketManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,85 @@ describe('소켓 관리자 테스트', () => {
expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED');
});

it('재시도 횟수 제한이 없으면 끊겨도 오류를 발행하지 않고 계속 재시도해야 한다', () => {
const listener = vi.fn();
socketManager.onErrorEvent(listener);
socketManager.connect({ maxRetries: null });

const client = getLatestClient();
client.config.onConnect?.({} as IFrame); // 연결 성공

for (let attempt = 0; attempt < 10; attempt++) {
client.config.onWebSocketClose?.({} as CloseEvent);
}

expect(listener).not.toHaveBeenCalled();
expect(client.deactivate).not.toHaveBeenCalled();
});

it('제한 시간이 지나면 횟수가 남아 있어도 재시도를 멈춰야 한다', () => {
const listener = vi.fn();
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValue(1_000_000);
socketManager.onErrorEvent(listener);
socketManager.connect({ maxRetries: null, retryDeadlineMs: 120000 });

const client = getLatestClient();
client.config.onConnect?.({} as IFrame); // 연결 성공

// 첫 끊김: 제한 시간 기준이 시작된다
client.config.onWebSocketClose?.({} as CloseEvent);
expect(listener).not.toHaveBeenCalled();

// 제한 시간 직전: 아직 재시도한다
nowSpy.mockReturnValue(1_000_000 + 119_999);
client.config.onWebSocketClose?.({} as CloseEvent);
expect(listener).not.toHaveBeenCalled();

// 제한 시간 경과: 포기한다
nowSpy.mockReturnValue(1_000_000 + 120_000);
client.config.onWebSocketClose?.({} as CloseEvent);
expect(listener).toHaveBeenCalledOnce();
const error = listener.mock.calls[0][0];
expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED');
expect(error.message).toBe('재연결 제한 시간을 초과했습니다.');
});

it('재연결에 성공하면 제한 시간 기준을 다시 시작해야 한다', () => {
const listener = vi.fn();
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValue(1_000_000);
socketManager.onErrorEvent(listener);
socketManager.connect({ maxRetries: null, retryDeadlineMs: 120000 });

const client = getLatestClient();
client.config.onConnect?.({} as IFrame);
client.config.onWebSocketClose?.({} as CloseEvent);

// 재연결 성공 후 한참 뒤에 다시 끊겨도 곧바로 포기하지 않는다
nowSpy.mockReturnValue(1_000_000 + 200_000);
client.config.onConnect?.({} as IFrame);
client.config.onWebSocketClose?.({} as CloseEvent);

expect(listener).not.toHaveBeenCalled();
});

it('재시도 대기 시간은 상한을 넘지 않아야 한다', () => {
socketManager.connect({
maxRetries: null,
baseRetryDelayMs: 1000,
maxRetryDelayMs: 5000,
});

const client = getLatestClient();
client.config.onConnect?.({} as IFrame);

for (let attempt = 0; attempt < 10; attempt++) {
client.config.onWebSocketClose?.({} as CloseEvent);
expect(client.reconnectDelay).toBeLessThanOrEqual(5000 + 10);
}
});

it('명시적으로 연결을 끊은 뒤에는 늦게 도착한 이벤트가 오류를 발행하지 않아야 한다', () => {
const listener = vi.fn();
socketManager.onErrorEvent(listener);
Expand Down
63 changes: 51 additions & 12 deletions src/apis/sockets/SocketManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { SocketError } from './error';

/**
* 소켓 설정을 정하는 인터페이스
* - `maxRetries` 최대 재시도 횟수 (기본값 3회)
* - `maxRetries` 최대 재시도 횟수 (기본값 3회, `null`이면 제한 없음)
* - `baseRetryDelayMs` 기본 재시도 대기 시간 (기본값 1000 ms)
* - `maxRetryDelayMs` 재시도 대기 시간 상한 (기본값 30000 ms)
* - `retryDeadlineMs` 재시도를 포기하기까지의 제한 시간 (기본값 없음)
* - `heartbeatInMs` 수신 하트비트 주기 (기본값 10000 ms)
* - `heartbeatOutMs` 발신 하트비트 주기 (기본값 10000 ms)
*/
Expand All @@ -17,12 +19,21 @@ export interface SocketOptions {
/** SockJS 연결에 사용할 API base URL. 지정하면 `${baseUrl}/ws`로 연결합니다. */
baseUrl?: string;

/** 최대 재시도 횟수 (기본값: 3) */
maxRetries?: number;
/** 최대 재시도 횟수 (기본값: 3). `null`이면 횟수 제한 없이 계속 재연결합니다. */
maxRetries?: number | null;

/** 지수 백오프 계산의 기준이 되는 초기 지연 시간 (기본값: 1000ms) */
baseRetryDelayMs?: number;

/** 재시도 대기 시간의 상한 (기본값: 30000ms) */
maxRetryDelayMs?: number;

/**
* 재시도를 포기하기까지의 제한 시간 (기본값: `null`, 제한 없음)
* 첫 끊김 이후 이 시간이 지나면 횟수가 남아 있어도 재연결을 멈춘다.
*/
retryDeadlineMs?: number | null;

/** 수신 하트비트 주기 (기본값: 10000ms) */
heartbeatInMs?: number;

Expand All @@ -34,15 +45,19 @@ export interface SocketOptions {
type ResolvedSocketOptions = {
url?: string;
baseUrl?: string;
maxRetries: number;
maxRetries: number | null;
baseRetryDelayMs: number;
maxRetryDelayMs: number;
retryDeadlineMs: number | null;
heartbeatInMs: number;
heartbeatOutMs: number;
};

const DEFAULT_OPTIONS: ResolvedSocketOptions = {
maxRetries: 3,
baseRetryDelayMs: 1000,
maxRetryDelayMs: 30000,
retryDeadlineMs: null,
heartbeatInMs: 10000,
heartbeatOutMs: 10000,
};
Expand All @@ -55,6 +70,9 @@ class SocketManager {
private currentOptions: ResolvedSocketOptions = DEFAULT_OPTIONS; // 소켓 설정을 저장하는 변수
private retryCount: number = 0;

// 현재 끊김 구간에서 처음 재시도를 시작한 시각 (제한 시간 판단 기준)
private retryStartedAt: number | null = null;

// 싱글톤 패턴 적용
// - 생성자를 차단하여 외부에서의 객체 생성을 막음
private constructor() {}
Expand Down Expand Up @@ -152,7 +170,7 @@ class SocketManager {
* HTTPS에서 WS로 업그레이드 시 청중과 사회자 모두 무인증으로 연결합니다.
*
* @param options - 소켓 클라이언트 동작을 제어하는 선택적 설정 객체
* @param options.maxRetries - 최대 재연결 시도 횟수 (기본값: 3)
* @param options.maxRetries - 최대 재연결 시도 횟수 (기본값: 3, `null`이면 제한 없음)
* @param options.baseRetryDelayMs - 지수 백오프 계산의 기준이 되는 초기 지연 시간 (기본값: 1000)
* @param options.heartbeatInMs - 서버 > 클라이언트 수신 하트비트 주기(ms) (기본값: 10000)
* @param options.heartbeatOutMs - 클라이언트 > 서버 발신 하트비트 주기(ms) (기본값: 10000)
Expand All @@ -163,6 +181,7 @@ class SocketManager {

// 사용자가 지정한 옵션이 있다면 덮어쓰기
this.retryCount = 0;
this.retryStartedAt = null;
this.hasConnected = false;
this.currentOptions = { ...DEFAULT_OPTIONS, ...options };

Expand Down Expand Up @@ -196,6 +215,7 @@ class SocketManager {
onConnect: () => {
console.log('✅ 웹 소켓(STOMP) 연결 성공');
this.retryCount = 0;
this.retryStartedAt = null;
this.hasConnected = true;

// 모든 관찰자에게 연결이 수립되었다고 알림
Expand Down Expand Up @@ -273,6 +293,7 @@ class SocketManager {

// 각종 설정 초기화
this.retryCount = 0;
this.retryStartedAt = null;
this.client = null;
this.currentOptions = DEFAULT_OPTIONS;
// 리스너는 등록한 훅이 언마운트될 때 직접 제거하므로 유지한다
Expand Down Expand Up @@ -333,12 +354,24 @@ class SocketManager {
return;
}

// 재시도 횟수를 초과했을 경우 연결을 즉시 종료
if (this.retryCount >= this.currentOptions.maxRetries) {
const now = Date.now();
if (this.retryStartedAt === null) {
this.retryStartedAt = now;
}

// 횟수를 초과했거나 제한 시간이 지났으면 연결을 즉시 종료
const { maxRetries, retryDeadlineMs } = this.currentOptions;
const hasExceededCount =
maxRetries !== null && this.retryCount >= maxRetries;
const hasExceededDeadline =
retryDeadlineMs !== null && now - this.retryStartedAt >= retryDeadlineMs;
if (hasExceededCount || hasExceededDeadline) {
this.reportSocketError(
new SocketError(
'SOCKET_RETRY_EXHAUSTED',
'최대 재연결 시도 횟수를 초과했습니다.',
hasExceededDeadline
? '재연결 제한 시간을 초과했습니다.'
: '최대 재연결 시도 횟수를 초과했습니다.',
),
);
this.client.reconnectDelay = 0;
Expand All @@ -356,8 +389,12 @@ class SocketManager {
this.client.reconnectDelay = nextDelay;
this.retryCount++;

const attemptLabel =
maxRetries === null
? `${this.retryCount}회째`
: `${this.retryCount}/${maxRetries}`;
console.log(
`🔄 재연결 시도 대기 중... (${this.retryCount}/${this.currentOptions.maxRetries}) - ${nextDelay}ms 후 시도`,
`🔄 재연결 시도 대기 중... (${attemptLabel}) - ${nextDelay}ms 후 시도`,
);
}

Expand All @@ -370,9 +407,11 @@ class SocketManager {
* @returns 재연결 요청까지 필요한 딜레이 시간
*/
private calculateBackoffDelay(currentRetryCount: number): number {
// 최대 지터 상한선 계산
const maxExponentialDelay =
this.currentOptions.baseRetryDelayMs * Math.pow(2, currentRetryCount);
// 최대 지터 상한선 계산 (상한을 넘지 않도록 자른다)
const maxExponentialDelay = Math.min(
this.currentOptions.baseRetryDelayMs * Math.pow(2, currentRetryCount),
this.currentOptions.maxRetryDelayMs,
);

// 난수가 0이 될 경우 STOMP가 재연결 비활성화로 파악하는 것을 막기 위해
// 계산한 값에 10 ms를 추가
Expand Down
4 changes: 3 additions & 1 deletion src/components/HeaderTableInfo/HeaderTableInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export default function HeaderTableInfo(props: HeaderTitleProps) {
)}
{!isLoading && (
<div className="flex min-w-0 flex-col space-y-[4px]">
<h1 className="truncate text-lg md:text-2xl">{name}</h1>
<h1 className="truncate text-lg leading-tight md:text-2xl short:text-lg">
{name}
</h1>
</div>
)}
</>
Expand Down
2 changes: 1 addition & 1 deletion src/components/HeaderTitle/HeaderTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function HeaderTitle(props: HeaderTitleProps) {
</div>
)}
{!isLoading && (
<h1 className="w-full max-w-[50vw] overflow-hidden text-ellipsis whitespace-nowrap text-xl md:text-3xl">
<h1 className="w-full max-w-[50vw] overflow-hidden text-ellipsis whitespace-nowrap text-xl leading-tight md:text-3xl short:text-xl">
{title}
</h1>
)}
Expand Down
Loading
Loading