-
Notifications
You must be signed in to change notification settings - Fork 2
[FEAT] 청중용 라이브 토론 공유 페이지 구현 #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
15e6ad7
62aa4e3
2751267
cef9872
0b8e3a8
50f96b6
c4f76f7
9a02bba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -166,6 +166,7 @@ describe('SocketManager', () => { | |
| socketManager.connect({ baseRetryDelayMs: 1000 }); | ||
|
|
||
| const client = getLatestClient(); | ||
| client.config.onConnect?.({} as IFrame); // 연결 성공 상태로 만들기 | ||
|
|
||
| // 초기 딜레이: calculateBackoffDelay(0) = 500 + 10 | ||
| expect(client.reconnectDelay).toBe(500 + 10); | ||
|
|
@@ -182,6 +183,7 @@ describe('SocketManager', () => { | |
| it('최대 재시도 횟수 초과 시 재시도 딜레이를 0으로 설정하고 연결을 해제해야 한다', () => { | ||
| socketManager.connect({ maxRetries: 3 }); | ||
| const client = getLatestClient(); | ||
| client.config.onConnect?.({} as IFrame); // 연결 성공 상태로 만들기 | ||
|
|
||
| // 3번까지는 아직 maxRetries 미초과 → deactivate 호출 안 됨 | ||
| for (let i = 0; i < 3; i++) { | ||
|
|
@@ -199,6 +201,7 @@ describe('SocketManager', () => { | |
| vi.spyOn(Math, 'random').mockReturnValue(0.5); | ||
| socketManager.connect({ baseRetryDelayMs: 1000, maxRetries: 5 }); | ||
| const client = getLatestClient(); | ||
| client.config.onConnect?.({} as IFrame); // 처음 연결 성공 상태로 만들기 | ||
|
|
||
| // 끊김 2번 → retryCount = 2 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
|
|
@@ -307,4 +310,122 @@ describe('SocketManager', () => { | |
| expect(listener).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Error Events & Publishing', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드레빗도 달아두었지만 해당 설명은 규칙과 다른 것 같아서 설명을 한글로 적어주시면 좋을 것 같아요! |
||
| it('오류 리스너가 등록되면 SocketError를 받고, 해제 후에는 호출되지 않아야 한다', () => { | ||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
|
|
||
| // trigger error by failing URL | ||
| socketManager.connect({ url: '', baseUrl: '' }); | ||
| expect(listener).toHaveBeenCalledOnce(); | ||
| const error = listener.mock.calls[0][0]; | ||
| expect(error.code).toBe('SOCKET_URL_UNAVAILABLE'); | ||
|
|
||
| listener.mockClear(); | ||
| socketManager.offErrorEvent(listener); | ||
| socketManager.connect({ url: '', baseUrl: '' }); | ||
| expect(listener).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('연결 URL을 결정할 수 없는 경우 SOCKET_URL_UNAVAILABLE 코드와 기술 원인을 가진 오류를 한 번 발행해야 한다', () => { | ||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
|
|
||
| socketManager.connect({ url: '', baseUrl: '' }); | ||
|
|
||
| expect(listener).toHaveBeenCalledOnce(); | ||
| const error = listener.mock.calls[0][0]; | ||
| expect(error.code).toBe('SOCKET_URL_UNAVAILABLE'); | ||
| }); | ||
|
|
||
| it('STOMP onStompError 발생 시 SOCKET_STOMP_ERROR 코드와 frame의 message/body 상세를 가진 오류가 발행되어야 한다', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 어떤 에러가 발생했는지를 알 수 있었으면 좋겠어용! |
||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
| socketManager.connect(); | ||
|
|
||
| const client = getLatestClient(); | ||
| client.config.onStompError?.({ | ||
| headers: { message: 'stomp msg' }, | ||
| body: 'body content', | ||
| } as unknown as IFrame); | ||
|
|
||
| expect(listener).toHaveBeenCalledOnce(); | ||
| const error = listener.mock.calls[0][0]; | ||
| expect(error.code).toBe('SOCKET_STOMP_ERROR'); | ||
| expect(error.message).toBe('stomp msg'); | ||
| expect(error.detail).toEqual({ | ||
| headers: { message: 'stomp msg' }, | ||
| body: 'body content', | ||
| }); | ||
| }); | ||
|
|
||
| it('한 번도 연결 성공하지 못한 세션에서 WebSocket close가 발생하면 SOCKET_SERVER_REJECTED 오류가 발행되어야 한다', () => { | ||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
| socketManager.connect(); | ||
|
|
||
| const client = getLatestClient(); | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
|
|
||
| expect(listener).toHaveBeenCalledOnce(); | ||
| const error = listener.mock.calls[0][0]; | ||
| expect(error.code).toBe('SOCKET_SERVER_REJECTED'); | ||
| }); | ||
|
|
||
| it('연결 성공 후 close는 기존 재연결 횟수가 남아 있는 동안 오류를 발행하지 않고, 최대 재시도 소진 시 SOCKET_RETRY_EXHAUSTED 오류를 발행해야 한다', () => { | ||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
| socketManager.connect({ maxRetries: 2 }); | ||
|
|
||
| const client = getLatestClient(); | ||
| client.config.onConnect?.({} as IFrame); // 연결 성공 | ||
|
|
||
| // 1차 끊김: 오류 발행 안됨 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
| expect(listener).not.toHaveBeenCalled(); | ||
|
|
||
| // 2차 끊김: 오류 발행 안됨 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
| expect(listener).not.toHaveBeenCalled(); | ||
|
|
||
| // 3차 끊김: 재시도 초과 -> 오류 발행 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
| expect(listener).toHaveBeenCalledOnce(); | ||
| const error = listener.mock.calls[0][0]; | ||
| expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED'); | ||
| }); | ||
|
|
||
| it('STOMP heartbeat 누락으로 onWebSocketClose가 발생한 경우에도 같은 재시도 정책을 따르고, 재시도 소진 후 SOCKET_RETRY_EXHAUSTED로 종료되어야 한다', () => { | ||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
| socketManager.connect({ maxRetries: 1 }); | ||
|
|
||
| const client = getLatestClient(); | ||
| client.config.onConnect?.({} as IFrame); // 연결 성공 | ||
|
|
||
| // 하트비트 누락에 의한 1차 끊김: 오류 발행 안됨 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
| expect(listener).not.toHaveBeenCalled(); | ||
|
|
||
| // 2차 끊김: 재시도 초과 -> 오류 발행 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
| expect(listener).toHaveBeenCalledOnce(); | ||
| const error = listener.mock.calls[0][0]; | ||
| expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED'); | ||
| }); | ||
|
|
||
| it('명시적 disconnect 후에는 지연 이벤트가 오류를 발행하지 않아야 한다', () => { | ||
| const listener = vi.fn(); | ||
| socketManager.onErrorEvent(listener); | ||
| socketManager.connect(); | ||
|
|
||
| const client = getLatestClient(); | ||
| socketManager.disconnect(); | ||
|
|
||
| // 과거 세션 이벤트 | ||
| client.config.onWebSocketClose?.({} as CloseEvent); | ||
| expect(listener).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import { Client, IMessage, StompHeaders } from '@stomp/stompjs'; | ||
| import SockJS from 'sockjs-client'; | ||
| import { SocketMessage } from './type'; | ||
| import { SocketError } from './error'; | ||
|
|
||
| /** | ||
| * 소켓 설정을 정하는 인터페이스 | ||
|
|
@@ -72,6 +73,9 @@ class SocketManager { | |
| // - 발행자(publisher)는 재연결 여부를 알리는 이 클래스 (SocketManager) | ||
| private connectListeners: Set<() => void> = new Set(); | ||
| private closeListeners: Set<() => void> = new Set(); | ||
| private errorListeners: Set<(error: SocketError) => void> = new Set(); | ||
|
|
||
| private hasConnected: boolean = false; | ||
|
|
||
| /** | ||
| * 관찰자를 등록하는 함수 | ||
|
|
@@ -105,6 +109,27 @@ class SocketManager { | |
| this.closeListeners.delete(listener); | ||
| } | ||
|
|
||
| public onErrorEvent(listener: (error: SocketError) => void) { | ||
| this.errorListeners.add(listener); | ||
| } | ||
|
|
||
| public offErrorEvent(listener: (error: SocketError) => void) { | ||
| this.errorListeners.delete(listener); | ||
| } | ||
|
|
||
| private dispatchError(error: SocketError) { | ||
| this.errorListeners.forEach((listener) => { | ||
| try { | ||
| listener(error); | ||
| } catch (e) { | ||
| console.error( | ||
| '오류 리스너 실행 중 오류 발생. 다음 리스너로 진행합니다.', | ||
| e, | ||
| ); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 연결 여부를 확인하는 함수 | ||
| * @returns 연결 여부 | ||
|
|
@@ -129,13 +154,20 @@ class SocketManager { | |
|
|
||
| // 사용자가 지정한 옵션이 있다면 덮어쓰기 | ||
| this.retryCount = 0; | ||
| this.hasConnected = false; | ||
| this.currentOptions = { ...DEFAULT_OPTIONS, ...options }; | ||
|
|
||
| const wsUrl = this.resolveWebSocketUrl(this.currentOptions); | ||
| if (!wsUrl) { | ||
| console.error( | ||
| '웹소켓 연결 주소를 결정할 수 없습니다. url 또는 baseUrl 옵션, 혹은 VITE_API_BASE_URL 환경 변수를 확인해주세요.', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이제 SocketError를 발생시키는데 console.error는 여전히 유지하는 것이 좋을까요? 숀의 생각이 궁금합니다! |
||
| ); | ||
| this.dispatchError( | ||
| new SocketError( | ||
| 'SOCKET_URL_UNAVAILABLE', | ||
| '웹소켓 연결 주소를 결정할 수 없습니다.', | ||
| ), | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -158,6 +190,7 @@ class SocketManager { | |
| onConnect: () => { | ||
| console.log('✅ 웹 소켓(STOMP) 연결 성공'); | ||
| this.retryCount = 0; | ||
| this.hasConnected = true; | ||
|
|
||
| // 모든 관찰자에게 연결이 수립되었다고 알림 | ||
| this.connectListeners.forEach((listener) => { | ||
|
|
@@ -172,6 +205,13 @@ class SocketManager { | |
| onStompError: (frame) => { | ||
| console.error('❌ 브로커 에러 발생:', frame.headers['message']); | ||
| console.error('상세 내용:', frame.body); | ||
| this.dispatchError( | ||
| new SocketError( | ||
| 'SOCKET_STOMP_ERROR', | ||
| frame.headers['message'] || 'STOMP 오류 발생', | ||
| { headers: frame.headers, body: frame.body }, | ||
| ), | ||
| ); | ||
| }, | ||
|
|
||
| onWebSocketClose: () => { | ||
|
|
@@ -192,6 +232,24 @@ class SocketManager { | |
| ); | ||
| } | ||
| }); | ||
|
|
||
| if (!this.hasConnected) { | ||
| this.dispatchError( | ||
| new SocketError( | ||
| 'SOCKET_SERVER_REJECTED', | ||
| '서버에 의해 웹소켓 연결이 거부되었거나 즉시 종료되었습니다.', | ||
| ), | ||
| ); | ||
|
|
||
| if (this.client) { | ||
| this.client.reconnectDelay = 0; | ||
| const clientToDeactivate = this.client; | ||
| this.client = null; | ||
| clientToDeactivate.deactivate(); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| this.handleReconnection(); | ||
| }, | ||
| }); | ||
|
|
@@ -215,6 +273,7 @@ class SocketManager { | |
| this.currentOptions = DEFAULT_OPTIONS; | ||
| this.connectListeners.clear(); | ||
| this.closeListeners.clear(); | ||
| this.errorListeners.clear(); | ||
|
|
||
| console.log('🛑 웹 소켓 연결을 수동으로 해제했습니다.'); | ||
| } | ||
|
|
@@ -271,6 +330,12 @@ class SocketManager { | |
| console.error( | ||
| '🚨 최대 재연결 시도 횟수를 초과했습니다. 연결을 포기합니다.', | ||
| ); | ||
| this.dispatchError( | ||
| new SocketError( | ||
| 'SOCKET_RETRY_EXHAUSTED', | ||
| '최대 재연결 시도 횟수를 초과했습니다.', | ||
| ), | ||
| ); | ||
| this.client.reconnectDelay = 0; | ||
|
|
||
| // 클라이언트 분리 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { SocketError, isSocketError } from './error'; | ||
|
|
||
| describe('SocketError', () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 테스트 설명을 한국어로 통일해주세요.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| it('SocketError 인스턴스는 지정된 코드와 상세 내용을 가져야 한다', () => { | ||
| const error = new SocketError('SOCKET_URL_UNAVAILABLE', 'Message', { | ||
| foo: 'bar', | ||
| }); | ||
| expect(error.code).toBe('SOCKET_URL_UNAVAILABLE'); | ||
| expect(error.message).toBe('Message'); | ||
| expect(error.detail).toEqual({ foo: 'bar' }); | ||
| expect(error.name).toBe('SocketError'); | ||
| }); | ||
|
|
||
| it('isSocketError는 SocketError 인스턴스에 대해 true를 반환한다', () => { | ||
| const error = new SocketError('SOCKET_STOMP_ERROR', 'Message'); | ||
| expect(isSocketError(error)).toBe(true); | ||
| }); | ||
|
|
||
| it('isSocketError는 일반 Error 인스턴스에 대해 false를 반환한다', () => { | ||
| const error = new Error('Message'); | ||
| expect(isSocketError(error)).toBe(false); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export type SocketErrorCode = | ||
| | 'SOCKET_URL_UNAVAILABLE' | ||
| | 'SOCKET_SERVER_REJECTED' | ||
| | 'SOCKET_STOMP_ERROR' | ||
| | 'SOCKET_RETRY_EXHAUSTED'; | ||
|
|
||
| export class SocketError extends Error { | ||
| public readonly code: SocketErrorCode; | ||
| public readonly detail?: unknown; | ||
|
|
||
| constructor(code: SocketErrorCode, message: string, detail?: unknown) { | ||
| super(message); | ||
| this.name = 'SocketError'; | ||
| this.code = code; | ||
| this.detail = detail; | ||
| } | ||
| } | ||
|
|
||
| export function isSocketError(error: unknown): error is SocketError { | ||
| return error instanceof SocketError; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
신규 테스트 스위트 설명을 한국어로 작성해 주세요.
Error Events & Publishing는 테스트 설명 한국어 규칙에 맞지 않습니다. 한국어로 변경해 주세요.🤖 Prompt for AI Agents
Source: Coding guidelines